commit 5374c202d9cf78425e5a0223a599773e12d019c2 Author: Yera All Date: Thu Apr 2 17:05:45 2026 +0500 Initial import with GitLab CI/CD and registry deploy flow diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..cd80f80 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,25 @@ +.git +.github +.pytest_cache +__pycache__/ +*.py[cod] + +.artifacts +.local_stack +.tmp +test-results + +.data +.data_local +.data_smoke +.data_gate* +.data_uat_* + +docs +tests + +.env +.env.local +.env.production + +backups diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..92e7d1e --- /dev/null +++ b/.env.example @@ -0,0 +1,132 @@ +# Gateway +GATEWAY_PORT=8080 +CC_DATA_DIR=.data + +# Shared DB URL for services (sqlite by default; switch to postgres as needed) +DATABASE_URL=sqlite:///./.data/mvp_cc.db +SCHEMA_MANAGEMENT_MODE=legacy +# DATABASE_URL=postgresql://mvp:mvp@localhost:5432/mvpcc +# SCHEMA_MANAGEMENT_MODE=migrations + +AUTH_SERVICE_URL=http://localhost:8001 +AUDIT_SERVICE_URL=http://localhost:8002 +CUSTOMER_SERVICE_URL=http://localhost:8003 +INTERACTION_SERVICE_URL=http://localhost:8004 +ROUTING_SERVICE_URL=http://localhost:8005 +VOICE_ADAPTER_SERVICE_URL=http://localhost:8006 +TELEGRAM_ADAPTER_SERVICE_URL=http://localhost:8007 +KB_SERVICE_URL=http://localhost:8008 +REPORTING_SERVICE_URL=http://localhost:8009 +SUPERVISOR_SERVICE_URL=http://localhost:8010 +TELEGRAM_BOT_ENABLED=0 +TELEGRAM_BOT_TOKEN= +TELEGRAM_WEBHOOK_SECRET= +TELEGRAM_DEFAULT_QUEUE_ID=q_telegram +TELEGRAM_BOT_API_BASE=https://api.telegram.org +WHATSAPP_DEFAULT_QUEUE_ID=q_whatsapp +WHATSAPP_WEBHOOK_SECRET= +WHATSAPP_META_ACCESS_TOKEN= +WHATSAPP_META_PHONE_NUMBER_ID= +WHATSAPP_META_VERIFY_TOKEN= +WHATSAPP_META_APP_SECRET= +WHATSAPP_META_GRAPH_API_VERSION=v22.0 +WHATSAPP_OUTBOUND_ENABLED=0 +AI_PROVIDER=stub +AI_API_BASE= +AI_API_KEY= +AI_MODEL=stub-telegram-assistant +AI_TIMEOUT_SECONDS=20 +AI_TELEGRAM_ENABLED=0 +AI_TELEGRAM_ALWAYS_REPLY=0 +AI_TELEGRAM_MAX_CONTEXT_MESSAGES=20 +AI_TELEGRAM_MAX_KB_RESULTS=3 +AI_TELEGRAM_CONFIDENCE_HANDOFF_THRESHOLD=0.65 +AI_WHATSAPP_ENABLED=0 +AI_WHATSAPP_ALWAYS_REPLY=0 +AI_WHATSAPP_MAX_CONTEXT_MESSAGES=20 +AI_WHATSAPP_MAX_KB_RESULTS=3 +AI_WHATSAPP_CONFIDENCE_HANDOFF_THRESHOLD=0.65 +AI_VOICE_RUNTIME_SERVICE_URL=http://localhost:8018 +AI_VOICE_ENABLED=0 +AI_VOICE_QUEUE_CONFIG_JSON={} +AI_VOICE_ASR_PROVIDER=openai +AI_VOICE_TTS_PROVIDER=openai +AI_VOICE_AUDIOSOCKET_ENABLED=0 +AI_VOICE_AUDIOSOCKET_HOST=0.0.0.0 +AI_VOICE_AUDIOSOCKET_PORT=9019 +AI_VOICE_ASR_MODEL=gpt-4o-mini-transcribe +AI_VOICE_TTS_MODEL=gpt-4o-mini-tts +AI_VOICE_TTS_VOICE=alloy +AI_VOICE_TTS_YANDEX_API_BASE=https://tts.api.ml.yandexcloud.kz +AI_VOICE_TTS_YANDEX_API_KEY= +AI_VOICE_TTS_YANDEX_IAM_TOKEN= +AI_VOICE_TTS_YANDEX_FOLDER_ID= +AI_VOICE_TTS_YANDEX_VOICE=jane +AI_VOICE_TTS_YANDEX_KK_VOICE=amira +AI_VOICE_TTS_YANDEX_SPEED=1.0 +AI_VOICE_TTS_YANDEX_ROLE= +AI_VOICE_TTS_YANDEX_EMOTION= +AI_VOICE_TTS_YANDEX_SAMPLE_RATE_HZ=8000 +AI_VOICE_TTS_CACHE_ENABLED=1 +AI_VOICE_TTS_CACHE_DIR=.data/ai_voice_tts_cache +AI_VOICE_VAD_MIN_SPEECH_MS=300 +AI_VOICE_VAD_TRAILING_SILENCE_MS=700 +AI_VOICE_TURN_MAX_MS=10000 +AI_VOICE_MEDIA_IDLE_TIMEOUT_SECONDS=15 +AI_VOICE_MAX_CONTEXT_SEGMENTS=8 +AI_VOICE_HANDOFF_TIMEOUT_SECONDS=8 +AI_VOICE_RUNTIME_TRUSTED_SERVICE_SUBJECTS=svc:ai-voice-runtime + +# Event bus (Track 8) +EVENT_BUS_ENABLED=0 +EVENT_BUS_URL=amqp://guest:guest@localhost:5672/ +EVENT_BUS_EXCHANGE=mvpcc.domain.events +EVENT_BUS_CONSUMER_ENABLED=1 +EVENT_BUS_AUDIT_QUEUE=mvpcc.audit.events +EVENT_BUS_REPORTING_QUEUE=mvpcc.reporting.events + +# Asterisk bridge (Track 9) +ASTERISK_BRIDGE_ENABLED=0 +ASTERISK_AMI_HOST= +ASTERISK_AMI_PORT=5038 +ASTERISK_AMI_USERNAME= +ASTERISK_AMI_SECRET= +ASTERISK_AMI_EVENT_PREFIX=MVPCC +ASTERISK_QUEUE_MAP_JSON={} +ASTERISK_BRIDGE_POLL_INTERVAL_SECONDS=1 +ASTERISK_IVR_FASTAGI_ENABLED=0 +ASTERISK_IVR_FASTAGI_HOST=127.0.0.1 +ASTERISK_IVR_FASTAGI_PORT=4573 +ASTERISK_IVR_DTMF_TIMEOUT_SECONDS=5 +ASTERISK_IVR_MAX_NO_INPUT_RETRIES=2 +ASTERISK_IVR_MAX_INVALID_RETRIES=2 +ASTERISK_IVR_CALL_LINK_WAIT_SECONDS=5 +# bridge internal auth mode: +# - legacy_headers (default QA baseline) +# - bearer +# - bearer_first (try bearer, fallback to legacy on 401/403) +ASTERISK_BRIDGE_AUTH_MODE=legacy_headers +ASTERISK_BRIDGE_AUTH_FALLBACK_LEGACY=1 +ASTERISK_BRIDGE_AUTH_SUBJECT=svc:asterisk-bridge +ASTERISK_BRIDGE_AUTH_USER=asterisk-bridge +ASTERISK_BRIDGE_AUTH_ROLE=admin +ASTERISK_BRIDGE_AUTH_TOKEN_TTL_SECONDS=300 +ASTERISK_CALLCONTROL_ENABLED=0 +ASTERISK_CALLCONTROL_ACTION_TIMEOUT_SECONDS=10 +ASTERISK_WEBRTC_ENABLED=0 +ASTERISK_WEBRTC_WS_URL= +ASTERISK_WEBRTC_ICE_SERVERS_JSON=[] +ASTERISK_OPERATOR_EXTENSION_MAP_JSON={} +ASTERISK_BROWSER_SIP_MAP_JSON={} +ASTERISK_TRANSFER_TARGET_MAP_JSON={} +ASTERISK_SFTP_HOST= +ASTERISK_SFTP_PORT=22 +ASTERISK_SFTP_USERNAME= +ASTERISK_SFTP_PASSWORD= +ASTERISK_SFTP_BASE_PATH=/var/spool/asterisk/monitor + +# Track 9.1 service-token hardening +IVR_RUNTIME_TRUSTED_SERVICE_SUBJECTS=svc:asterisk-bridge +VOICE_ADAPTER_TRUSTED_SERVICE_SUBJECTS=svc:asterisk-bridge,svc:ivr-service +RECORDING_IMPORT_TRUSTED_SERVICE_SUBJECTS=svc:asterisk-bridge +RECORDING_IMPORT_ALLOW_ADMIN=1 diff --git a/.env.postgres.local.template b/.env.postgres.local.template new file mode 100644 index 0000000..1562492 --- /dev/null +++ b/.env.postgres.local.template @@ -0,0 +1,17 @@ +# Local PostgreSQL profile for scripts/local_stack.py --env-file + +CC_DATA_DIR=.data_local_pg +DATABASE_URL=postgresql://mvp:mvp@127.0.0.1:5432/mvpcc +SCHEMA_MANAGEMENT_MODE=migrations +APP_TOKEN_SECRET=dev-secret-change-me +ALLOW_LEGACY_HEADER_AUTH=1 + +EVENT_BUS_ENABLED=0 +EVENT_BUS_URL=amqp://guest:guest@127.0.0.1:5672/ +ASTERISK_BRIDGE_ENABLED=0 +TELEGRAM_BOT_ENABLED=0 +WHATSAPP_OUTBOUND_ENABLED=0 +AI_PROVIDER=stub +AI_TELEGRAM_ENABLED=0 +AI_WHATSAPP_ENABLED=0 +AI_VOICE_ENABLED=0 diff --git a/.env.postgres.parallel.server.template b/.env.postgres.parallel.server.template new file mode 100644 index 0000000..f2e3659 --- /dev/null +++ b/.env.postgres.parallel.server.template @@ -0,0 +1,25 @@ +# Server-only overrides for a parallel PostgreSQL-backed call-center stack. +# Keep .env.production as the base, then copy this file to .env.postgres.parallel.server +# and replace plus the password placeholder. + +CC_DATA_DIR=.data_pg_parallel +DATABASE_URL=postgresql://mvpcc_app:change-me-with-the-password-from-.env.postgres.server@call-center-postgres:5432/mvpcc +SCHEMA_MANAGEMENT_MODE=migrations +LOCAL_STACK_GATEWAY_PORT=18080 +LOCAL_STACK_PUBLIC_BASE_URL=http://:18080 + +# Keep the parallel stack passive to avoid duplicate side effects while SQLite remains live. +EVENT_BUS_ENABLED=0 +TELEGRAM_BOT_ENABLED=0 +AI_TELEGRAM_ENABLED=0 +AI_TELEGRAM_ALWAYS_REPLY=0 +WHATSAPP_OUTBOUND_ENABLED=0 +AI_WHATSAPP_ENABLED=0 +AI_WHATSAPP_ALWAYS_REPLY=0 +AI_VOICE_ENABLED=0 +AI_VOICE_AUDIOSOCKET_ENABLED=0 +AI_VOICE_TTS_CACHE_DIR=/app/.data_pg_parallel/ai_voice_tts_cache +ASTERISK_BRIDGE_ENABLED=0 +ASTERISK_IVR_FASTAGI_ENABLED=0 +ASTERISK_CALLCONTROL_ENABLED=0 +ASTERISK_WEBRTC_ENABLED=0 diff --git a/.env.postgres.server.template b/.env.postgres.server.template new file mode 100644 index 0000000..dece12a --- /dev/null +++ b/.env.postgres.server.template @@ -0,0 +1,6 @@ +# Server-only template for deployment/docker-compose.postgres.server.yml +# Copy to .env.postgres.server on the target host and replace the password. + +POSTGRES_DB=mvpcc +POSTGRES_USER=mvpcc_app +POSTGRES_PASSWORD=change-me-with-a-strong-random-secret diff --git a/.env.production.template b/.env.production.template new file mode 100644 index 0000000..b65f60d --- /dev/null +++ b/.env.production.template @@ -0,0 +1,128 @@ +# Track 9 QA lab template (do not commit real secrets into .env.production) + +# Core runtime +CC_DATA_DIR=.data +DATABASE_URL=postgresql://mvp:mvp@:5432/mvpcc +SCHEMA_MANAGEMENT_MODE=migrations + +# Internal service URLs (cluster/internal or host-routed URLs) +INTERACTION_SERVICE_URL=http://interaction-service:8000 +VOICE_ADAPTER_SERVICE_URL=http://voice-adapter-service:8000 +RECORDING_SERVICE_URL=http://recording-service:8000 +TELEGRAM_ADAPTER_SERVICE_URL=http://telegram-adapter-service:8000 +AI_ORCHESTRATOR_SERVICE_URL=http://ai-orchestrator-service:8000 +AI_VOICE_RUNTIME_SERVICE_URL=http://ai-voice-runtime-service:8000 + +# Telegram real-bot chat (Track 15) +TELEGRAM_BOT_ENABLED=1 +TELEGRAM_BOT_TOKEN= +TELEGRAM_WEBHOOK_SECRET= +TELEGRAM_DEFAULT_QUEUE_ID= +TELEGRAM_BOT_API_BASE=https://api.telegram.org +WHATSAPP_DEFAULT_QUEUE_ID= +WHATSAPP_WEBHOOK_SECRET= +WHATSAPP_META_ACCESS_TOKEN= +WHATSAPP_META_PHONE_NUMBER_ID= +WHATSAPP_META_VERIFY_TOKEN= +WHATSAPP_META_APP_SECRET= +WHATSAPP_META_GRAPH_API_VERSION=v22.0 +WHATSAPP_OUTBOUND_ENABLED=0 +AI_PROVIDER=openai_compatible +AI_API_BASE=https:///v1 +AI_API_KEY= +AI_MODEL= +AI_TIMEOUT_SECONDS=20 +AI_TELEGRAM_ENABLED=0 +AI_TELEGRAM_ALWAYS_REPLY=0 +AI_TELEGRAM_MAX_CONTEXT_MESSAGES=20 +AI_TELEGRAM_MAX_KB_RESULTS=3 +AI_TELEGRAM_CONFIDENCE_HANDOFF_THRESHOLD=0.65 +AI_WHATSAPP_ENABLED=0 +AI_WHATSAPP_ALWAYS_REPLY=0 +AI_WHATSAPP_MAX_CONTEXT_MESSAGES=20 +AI_WHATSAPP_MAX_KB_RESULTS=3 +AI_WHATSAPP_CONFIDENCE_HANDOFF_THRESHOLD=0.65 +AI_VOICE_ENABLED=0 +AI_VOICE_QUEUE_CONFIG_JSON={"voice_lab_ai":{"mode":"ai_first","agent_profile":"voice_support","handoff_queue_code":"voice_lab","language":"ru"}} +AI_VOICE_ASR_PROVIDER=openai +AI_VOICE_TTS_PROVIDER=openai +AI_VOICE_AUDIOSOCKET_ENABLED=1 +AI_VOICE_AUDIOSOCKET_HOST=0.0.0.0 +AI_VOICE_AUDIOSOCKET_PORT=9019 +AI_VOICE_ASR_MODEL=gpt-4o-mini-transcribe +AI_VOICE_TTS_MODEL=gpt-4o-mini-tts +AI_VOICE_TTS_VOICE=alloy +AI_VOICE_TTS_YANDEX_API_BASE=https://tts.api.ml.yandexcloud.kz +AI_VOICE_TTS_YANDEX_API_KEY= +AI_VOICE_TTS_YANDEX_IAM_TOKEN= +AI_VOICE_TTS_YANDEX_FOLDER_ID= +AI_VOICE_TTS_YANDEX_VOICE=jane +AI_VOICE_TTS_YANDEX_KK_VOICE=amira +AI_VOICE_TTS_YANDEX_SPEED=1.0 +AI_VOICE_TTS_YANDEX_ROLE= +# backward-compatible alias for old v1-style config +AI_VOICE_TTS_YANDEX_EMOTION= +AI_VOICE_TTS_YANDEX_SAMPLE_RATE_HZ=8000 +AI_VOICE_TTS_CACHE_ENABLED=1 +AI_VOICE_TTS_CACHE_DIR=/app/.data_local/ai_voice_tts_cache +AI_VOICE_VAD_MIN_SPEECH_MS=300 +AI_VOICE_VAD_TRAILING_SILENCE_MS=700 +AI_VOICE_TURN_MAX_MS=10000 +AI_VOICE_MEDIA_IDLE_TIMEOUT_SECONDS=15 +AI_VOICE_MAX_CONTEXT_SEGMENTS=8 +AI_VOICE_HANDOFF_TIMEOUT_SECONDS=8 +AI_VOICE_RUNTIME_TRUSTED_SERVICE_SUBJECTS=svc:ai-voice-runtime + +# Event bus compatibility (Track 8 baseline) +EVENT_BUS_ENABLED=1 +EVENT_BUS_URL=amqp://guest:guest@:5672/ +EVENT_BUS_EXCHANGE=mvpcc.domain.events +EVENT_BUS_CONSUMER_ENABLED=1 + +# Asterisk bridge (Track 9) +ASTERISK_BRIDGE_ENABLED=1 +ASTERISK_AMI_HOST= +ASTERISK_AMI_PORT=5038 +ASTERISK_AMI_USERNAME=mvpcc +ASTERISK_AMI_SECRET= +ASTERISK_AMI_EVENT_PREFIX=MVPCC +ASTERISK_QUEUE_MAP_JSON={"voice_lab":"","voice_lab_ai":"","voice_lab_ivr":""} +ASTERISK_BRIDGE_POLL_INTERVAL_SECONDS=1 +# Bridge internal auth for QA: +# keep legacy_headers for Track 9 acceptance baseline. +# use bearer_first or bearer in Track 9.1 hardening. +ASTERISK_BRIDGE_AUTH_MODE=legacy_headers +ASTERISK_BRIDGE_AUTH_FALLBACK_LEGACY=1 +ASTERISK_BRIDGE_AUTH_SUBJECT=svc:asterisk-bridge +ASTERISK_BRIDGE_AUTH_USER=asterisk-bridge +ASTERISK_BRIDGE_AUTH_ROLE=admin +ASTERISK_BRIDGE_AUTH_TOKEN_TTL_SECONDS=300 +ASTERISK_IVR_FASTAGI_ENABLED=1 +ASTERISK_IVR_FASTAGI_HOST=0.0.0.0 +ASTERISK_IVR_FASTAGI_PORT=4573 +ASTERISK_IVR_DTMF_TIMEOUT_SECONDS=5 +ASTERISK_IVR_MAX_NO_INPUT_RETRIES=2 +ASTERISK_IVR_MAX_INVALID_RETRIES=2 +ASTERISK_IVR_CALL_LINK_WAIT_SECONDS=5 +ASTERISK_CALLCONTROL_ENABLED=0 +ASTERISK_CALLCONTROL_ACTION_TIMEOUT_SECONDS=10 +ASTERISK_WEBRTC_ENABLED=0 +ASTERISK_WEBRTC_WS_URL=wss://:8089/ws +ASTERISK_WEBRTC_ICE_SERVERS_JSON=[] +ASTERISK_OPERATOR_EXTENSION_MAP_JSON={"operator_a":"2001","operator_b":"2002"} +ASTERISK_BROWSER_SIP_MAP_JSON={"operator":{"authorization_username":"2001","password":"","display_name":"Operator Browser","operator_extension":"2001"}} +ASTERISK_TRANSFER_TARGET_MAP_JSON={"voice_lab":"2001","voice_lab_ai":"2001","voice_lab_ivr":"7200"} + +# Track 9.1 service-token hardening +IVR_RUNTIME_TRUSTED_SERVICE_SUBJECTS=svc:asterisk-bridge +VOICE_ADAPTER_TRUSTED_SERVICE_SUBJECTS=svc:asterisk-bridge,svc:ivr-service +RECORDING_IMPORT_TRUSTED_SERVICE_SUBJECTS=svc:asterisk-bridge +# set to 0 in strict QA/prod mode +RECORDING_IMPORT_ALLOW_ADMIN=1 + +# SFTP pickup from Asterisk VM +ASTERISK_SFTP_HOST= +ASTERISK_SFTP_PORT=22 +ASTERISK_SFTP_USERNAME= +ASTERISK_SFTP_PASSWORD= +ASTERISK_SFTP_BASE_PATH=/var/spool/asterisk/monitor diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..204b814 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,91 @@ +name: ci + +on: + push: + branches: ["main", "master"] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + + - name: Run tests + run: pytest -q + + postgres-readiness: + runs-on: ubuntu-latest + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_DB: mvpcc + POSTGRES_USER: mvp + POSTGRES_PASSWORD: mvp + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U mvp -d mvpcc" + --health-interval 10s + --health-timeout 5s + --health-retries 10 + rabbitmq: + image: rabbitmq:3-management + ports: + - 5672:5672 + - 15672:15672 + env: + DATABASE_URL: postgresql://mvp:mvp@localhost:5432/mvpcc + SCHEMA_MANAGEMENT_MODE: migrations + CC_DATA_DIR: .testdata_pg + EVENT_BUS_ENABLED: "0" + EVENT_BUS_URL: amqp://guest:guest@localhost:5672/ + APP_TOKEN_SECRET: ci-secret + ALLOW_LEGACY_HEADER_AUTH: "1" + TELEGRAM_BOT_ENABLED: "0" + WHATSAPP_OUTBOUND_ENABLED: "0" + AI_PROVIDER: stub + AI_TELEGRAM_ENABLED: "0" + AI_WHATSAPP_ENABLED: "0" + AI_VOICE_ENABLED: "0" + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + + - name: Apply migrations + run: python scripts/migrate_core_db.py + + - name: Run PostgreSQL readiness smoke + run: pytest -q tests/test_postgres_readiness.py + + build-image: + runs-on: ubuntu-latest + needs: [test, postgres-readiness] + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Build image + run: docker build -t mvp-cc-platform:ci . diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b438a13 --- /dev/null +++ b/.gitignore @@ -0,0 +1,22 @@ +.data/ +.data_local/ +.data_pg/ +.data_pg_parallel/ +.data_smoke/ +.data_gate*/ +.data_uat_*/ +.artifacts/ +.local_stack/ +.codex_tmp/ +.server_backups/ +.tmp/ +test-results/ +.testdata/ +backups/ +__pycache__/ +**/__pycache__/ +.pytest_cache/ +.env.production +.env.postgres.server +.env.postgres.parallel.server +deployment/.env.images diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..e1469ea --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,154 @@ +stages: + - test + - build + - deploy + +variables: + PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip" + APP_IMAGE_REPOSITORY: "$CI_REGISTRY_IMAGE/app" + ASTERISK_IMAGE_REPOSITORY: "$CI_REGISTRY_IMAGE/asterisk" + APP_IMAGE_TAG: "$CI_COMMIT_SHA" + ASTERISK_IMAGE_TAG: "$CI_COMMIT_SHA" + APP_IMAGE: "$CI_REGISTRY_IMAGE/app:$CI_COMMIT_SHA" + ASTERISK_IMAGE: "$CI_REGISTRY_IMAGE/asterisk:$CI_COMMIT_SHA" + DOCKER_TLS_CERTDIR: "/certs" + DOCKER_HOST: "tcp://docker:2376" + DOCKER_TLS_VERIFY: "1" + DOCKER_CERT_PATH: "$DOCKER_TLS_CERTDIR/client" + +default: + cache: + paths: + - .cache/pip + +.python_job: + image: python:3.11-slim + before_script: + - python -m pip install --upgrade pip + - pip install -r requirements.txt + +test: + extends: .python_job + stage: test + script: + - pytest -q + +postgres-readiness: + extends: .python_job + stage: test + services: + - name: postgres:16-alpine + alias: postgres + - name: rabbitmq:3-management + alias: rabbitmq + variables: + POSTGRES_DB: mvpcc + POSTGRES_USER: mvp + POSTGRES_PASSWORD: mvp + DATABASE_URL: postgresql://mvp:mvp@postgres:5432/mvpcc + SCHEMA_MANAGEMENT_MODE: migrations + CC_DATA_DIR: .testdata_pg + EVENT_BUS_ENABLED: "0" + EVENT_BUS_URL: amqp://guest:guest@rabbitmq:5672/ + APP_TOKEN_SECRET: ci-secret + ALLOW_LEGACY_HEADER_AUTH: "1" + TELEGRAM_BOT_ENABLED: "0" + WHATSAPP_OUTBOUND_ENABLED: "0" + AI_PROVIDER: stub + AI_TELEGRAM_ENABLED: "0" + AI_WHATSAPP_ENABLED: "0" + AI_VOICE_ENABLED: "0" + script: + - python scripts/migrate_core_db.py + - pytest -q tests/test_postgres_readiness.py + +.docker_build: + image: docker:27 + services: + - name: docker:27-dind + alias: docker + before_script: + - echo "$CI_REGISTRY_PASSWORD" | docker login "$CI_REGISTRY" -u "$CI_REGISTRY_USER" --password-stdin + rules: + - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH' + - if: '$CI_COMMIT_TAG' + +build-app-image: + extends: .docker_build + stage: build + needs: + - test + - postgres-readiness + script: + - docker build -t "$APP_IMAGE" . + - docker push "$APP_IMAGE" + - | + if [ "$CI_COMMIT_BRANCH" = "$CI_DEFAULT_BRANCH" ]; then + docker tag "$APP_IMAGE" "$APP_IMAGE_REPOSITORY:latest" + docker push "$APP_IMAGE_REPOSITORY:latest" + fi + - | + if [ -n "$CI_COMMIT_TAG" ]; then + docker tag "$APP_IMAGE" "$APP_IMAGE_REPOSITORY:$CI_COMMIT_TAG" + docker push "$APP_IMAGE_REPOSITORY:$CI_COMMIT_TAG" + fi + +build-asterisk-image: + extends: .docker_build + stage: build + needs: + - test + - postgres-readiness + script: + - docker build -f deployment/asterisk/Dockerfile -t "$ASTERISK_IMAGE" . + - docker push "$ASTERISK_IMAGE" + - | + if [ "$CI_COMMIT_BRANCH" = "$CI_DEFAULT_BRANCH" ]; then + docker tag "$ASTERISK_IMAGE" "$ASTERISK_IMAGE_REPOSITORY:latest" + docker push "$ASTERISK_IMAGE_REPOSITORY:latest" + fi + - | + if [ -n "$CI_COMMIT_TAG" ]; then + docker tag "$ASTERISK_IMAGE" "$ASTERISK_IMAGE_REPOSITORY:$CI_COMMIT_TAG" + docker push "$ASTERISK_IMAGE_REPOSITORY:$CI_COMMIT_TAG" + fi + +deploy-production: + stage: deploy + image: alpine:3.21 + needs: + - build-app-image + - build-asterisk-image + environment: + name: production + before_script: + - apk add --no-cache openssh-client rsync + - mkdir -p ~/.ssh + - chmod 700 ~/.ssh + - printf '%s\n' "$DEPLOY_SSH_PRIVATE_KEY" > ~/.ssh/id_ed25519 + - chmod 600 ~/.ssh/id_ed25519 + - ssh-keyscan -H "$DEPLOY_HOST" >> ~/.ssh/known_hosts + script: + - test -n "$DEPLOY_HOST" + - test -n "$DEPLOY_USER" + - test -n "$DEPLOY_PATH" + - ssh "$DEPLOY_USER@$DEPLOY_HOST" "mkdir -p '$DEPLOY_PATH/deployment'" + - rsync -av deployment/ "$DEPLOY_USER@$DEPLOY_HOST:$DEPLOY_PATH/deployment/" + - | + ssh "$DEPLOY_USER@$DEPLOY_HOST" \ + "mkdir -p '$DEPLOY_PATH/.data_local/generated_ivr_yandex/ivr' '$DEPLOY_PATH/.data_local/recordings' '$DEPLOY_PATH/.asterisk_assets'" + - | + ssh "$DEPLOY_USER@$DEPLOY_HOST" \ + "printf 'APP_IMAGE=%s\nASTERISK_IMAGE=%s\n' '$APP_IMAGE' '$ASTERISK_IMAGE' > '$DEPLOY_PATH/deployment/.env.images'" + - | + ssh "$DEPLOY_USER@$DEPLOY_HOST" \ + "echo '$CI_REGISTRY_PASSWORD' | docker login '$CI_REGISTRY' -u '$CI_REGISTRY_USER' --password-stdin && \ + cd '$DEPLOY_PATH/deployment' && \ + docker compose --env-file .env.images -f docker-compose.server.registry.yml pull && \ + docker compose --env-file .env.images -f docker-compose.server.registry.yml up -d && \ + docker compose --env-file .env.images -f docker-compose.asterisk.server.registry.yml pull && \ + docker compose --env-file .env.images -f docker-compose.asterisk.server.registry.yml up -d" + rules: + - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH' + when: manual + allow_failure: false diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9fb0f4e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,10 @@ +FROM python:3.11-slim + +WORKDIR /app +COPY requirements.txt /app/requirements.txt +RUN pip install --no-cache-dir -r /app/requirements.txt +COPY . /app + +ENV APP_MODULE=services.auth_service.app:app +EXPOSE 8000 9019 +CMD ["sh", "-c", "uvicorn ${APP_MODULE} --host 0.0.0.0 --port 8000"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..9571539 --- /dev/null +++ b/README.md @@ -0,0 +1,373 @@ +# MVP Contact Center (KZ) - Sequential 4-Stage Implementation + +This repository bootstraps the agreed MVP implementation for the contact center platform. + +## What is implemented + +- Stage 1 foundation artifacts + - API gateway skeleton + - local RBAC auth service + - users, health, audit contracts + - Kubernetes + Helm + CI skeleton + - observability baseline docs and manifests +- Stage 2 operator core APIs + - customers + - interactions and escalation + - queue/routing rules + - voice events adapter (Asterisk-facing) + - Telegram webhook adapter + - Webchat intake adapter +- Stage 3 supervisor/reporting/KB-lite APIs + - knowledge base categories/articles/search + - KPI aggregation (SL, ASA, AHT, Abandon, FCR) + - supervisor realtime endpoints +- Stage 4 pilot hardening package + - gate checklists + - UAT package and templates + - runbooks for backup/restore and pilot operations +- Wave 2 Track 1-2 baseline + - signed bearer-token auth + optional Keycloak OIDC login + - first additional digital channels: Webchat and Email +- Wave 2 Track 4 baseline + - recording-service for managed local voice recordings + - supervisor playback/download/archive flow +- Wave 2 Track 5 baseline + - ivr-service for DTMF voice flows and IVR runtime sessions + - IVR-aware route preview and admin-side IVR management +- Wave 2 Track 6 baseline + - expanded KPI catalog with channel-aware metrics and coverage endpoint + - reporting export now includes channel and agent dimensions +- Wave 2 Track 7 baseline + - production-like Helm scale profile for K8s on-prem + - Postgres-first DB pooling and formal load/hardening validation scripts +- Wave 2 Track 8 baseline + - RabbitMQ-backed event bus with transactional outbox + - first consumers wired for audit and reporting + +## Architecture + +- Microservices (FastAPI), each service isolated in `services/*` +- API contracts in `contracts/openapi` +- Event schemas in `contracts/events` +- Deployment: + - Local compose: `deployment/docker-compose.yml` + - Kubernetes manifests: `deployment/kubernetes` + - Helm chart: `deployment/helm` + - GitLab registry server deploy: `deployment/docker-compose.server.registry.yml` +- Service state persistence: + - SQL storage via `DATABASE_URL` for all services (SQLite by default, PostgreSQL supported) + - production-like K8s scale validation is PostgreSQL-only + +## Quick start (local) + +1. Install dependencies + +```bash +python -m pip install -r requirements.txt +``` + +2. Run tests + +```bash +pytest -q +``` + +3. Apply DB migrations + +```bash +python scripts/migrate_core_db.py +``` + +3. Start individual services (example) + +```bash +uvicorn services.auth_service.app:app --reload --port 8001 +uvicorn services.interaction_service.app:app --reload --port 8004 +``` + +4. Start local gateway + +```bash +uvicorn gateway.app:app --reload --port 8080 +``` + +Operator UI is available at: + +```text +http://localhost:8080/operator +``` + +Additional dedicated Wave 2 shells: + +```text +http://localhost:8080/supervisor +http://localhost:8080/admin +``` + +5. Optional helper scripts (PowerShell) + +```powershell +powershell -ExecutionPolicy Bypass -File scripts\run_all_local.ps1 +powershell -ExecutionPolicy Bypass -File scripts\stop_all_local.ps1 +powershell -ExecutionPolicy Bypass -File scripts\prepare_demo.ps1 +powershell -ExecutionPolicy Bypass -File scripts\backup_data.ps1 +powershell -ExecutionPolicy Bypass -File scripts\restore_data.ps1 -BackupZip e:\Zhan\backups\mvp_cc_data_YYYYMMDD_HHMMSS.zip +powershell -ExecutionPolicy Bypass -File scripts\clean_workspace.ps1 +python scripts\local_stack.py status +python scripts\demo_seed.py +python scripts\oidc_smoke.py --base-url http://localhost:8080 +python scripts\load_test.py --base-url http://localhost:8080 --profile baseline_100_100 +python scripts\track7_check.py --namespace mvp-cc --report-dir .artifacts\track7\ +python scripts\postgres_dev_preflight.py --env-file .env.postgres.local.template +python scripts\postgres_dev_preflight.py --env-file .env.postgres.local.template --base-url http://127.0.0.1:8080 +python scripts\live_smoke_gate12.py +python scripts\live_smoke_gate12.py --database-url postgresql://mvp:mvp@localhost:5432/mvpcc +python scripts\track9_preflight.py --base-url http://127.0.0.1:8080 --check-sftp +powershell -ExecutionPolicy Bypass -File scripts\start_track9_qa.ps1 +python scripts\asterisk_lab_smoke.py --base-url http://127.0.0.1:8080 --database-url +python scripts\track9_check.py --base-url http://127.0.0.1:8080 --database-url --require-recording +python scripts\track9_collect_evidence.py --base-url http://127.0.0.1:8080 --database-url --run-checks --require-recording +powershell -ExecutionPolicy Bypass -File scripts\track9_2_cutover.ps1 -KubeContext -Namespace -Release -GatewayBaseUrl http:// -DatabaseUrl postgresql://<...> -ImageTag -AmiHost -AmiUser -AmiSecret -SftpHost -SftpUser -SftpPassword -QueueId -AppTokenSecret +python scripts\gate3_check.py --auto-start +python scripts\gate4_check.py +python scripts\uat_preflight.py --auto-start +python scripts\uat_dry_run.py --auto-start --update-defect-register +python scripts\uat_manual_prepare.py --environment-url http://:8080 +python scripts\finalize_mvp_pilot.py --session-dir docs/uat/evidence/manual_ --dry-run +python scripts\finalize_mvp_pilot.py --session-dir docs/uat/evidence/manual_ +``` + +## Demo-ready local run + +Use one command to start the stack, run smoke checks, and preload demo data: + +```powershell +powershell -ExecutionPolicy Bypass -File scripts\prepare_demo.ps1 +``` + +This starts every service in the background without opening extra PowerShell windows, writes logs to `.local_stack\logs`, and seeds: + +- a demo customer +- a closed escalated voice interaction +- an active follow-up interaction +- voice + Telegram integration events +- one `recording.ready` event imported into managed storage +- one IVR flow with a completed demo session and route preview +- KB demo article +- supervisor snapshot +- KPI sample rows + +Stop the stack with: + +```powershell +powershell -ExecutionPolicy Bypass -File scripts\stop_all_local.ps1 +``` + +Local runtime state under `.data*`, `.local_stack`, and `.artifacts` is disposable. +To reset the local workspace back to a clean baseline, run: + +```powershell +powershell -ExecutionPolicy Bypass -File scripts\clean_workspace.ps1 +``` + +Relevant PostgreSQL cutover runbooks: + +- [local-run.md](/e:/Zhan/docs/runbooks/local-run.md) +- [postgres-dev-cutover.md](/e:/Zhan/docs/runbooks/postgres-dev-cutover.md) +- [postgres-server-docker.md](/e:/Zhan/docs/runbooks/postgres-server-docker.md) +- [postgres-server-parallel-stack.md](/e:/Zhan/docs/runbooks/postgres-server-parallel-stack.md) +- [deployment-onprem.md](/e:/Zhan/docs/runbooks/deployment-onprem.md) + +## Wave 2 enterprise auth baseline (Track 1) + +Track 1 introduces signed application bearer tokens and optional Keycloak-backed OIDC login. + +Default local/demo mode: + +- `OIDC_ENABLED=0` +- `ALLOW_LEGACY_HEADER_AUTH=1` + +Enterprise-shaped mode: + +- `OIDC_ENABLED=1` +- `ALLOW_LEGACY_HEADER_AUTH=0` +- set `APP_TOKEN_SECRET` +- set `OIDC_ISSUER_URL`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`, `OIDC_REDIRECT_URI` + +Quick checks: + +```powershell +python scripts\migrate_core_db.py +python scripts\oidc_smoke.py --base-url http://localhost:8080 +python scripts\oidc_smoke.py --base-url http://localhost:8080 --require-enabled +``` + +Detailed rollout guidance: + +- `docs/runbooks/keycloak-oidc.md` +- `docs/runbooks/deployment-onprem.md` + +## Wave 2 Track 3 dedicated shells + +Track 3 starts moving critical workflows out of the single operator shell: + +- `/supervisor` for realtime, KPI, agent status, and queue snapshot controls +- `/admin` for users, queues, and routing previews + +## Wave 2 Track 4 recordings + +Track 4 introduces the first voice recording lifecycle: + +- `recording-service` stores metadata for voice recordings +- audio files are copied into managed local storage +- `/supervisor` can import, preview, download, and archive recordings + +Operational runbook: + +- `docs/runbooks/recordings-local.md` + +## Wave 2 Track 5 IVR + +Track 5 introduces the first IVR runtime for voice: + +- `ivr-service` stores IVR flow configs and runtime sessions +- DTMF steps can complete a session into an `ivr.completed` event +- `/admin` can create flows, start sessions, send digits, and preview routing with `ivr_session_id` + +Operational runbook: + +- `docs/runbooks/ivr-local.md` + +## Wave 2 Track 6 KPI Expansion + +Track 6 expands reporting beyond the MVP core subset: + +- `reporting-service` now stores `channel` and `agent_id` +- `/reports/kpi` adds expanded metrics and channel breakdowns +- `/reports/coverage` exposes the implemented KPI catalog + +Reference: + +- `docs/kpi/definitions.md` + +## Wave 2 Track 7 Scale-Up and Hardening + +Track 7 adds the first production-shaped scale profile: + +- Helm is now the canonical path for K8s scale validation +- `DATABASE_URL` is required for the `scale500` profile +- runtime DB pooling is configurable with: + - `DB_POOL_SIZE` + - `DB_MAX_OVERFLOW` + - `DB_POOL_TIMEOUT_SECONDS` + - `DB_POOL_RECYCLE_SECONDS` +- `scripts/load_test.py` now runs mixed load profiles and writes evidence to `.artifacts/track7/*` +- `scripts/track7_check.py` validates the load report plus K8s pod/HPA health + +Operational runbook: + +- `docs/runbooks/track7-scale-validation.md` + +## Wave 2 Track 8 Event Bus + +Track 8 adds the first asynchronous integration backbone: + +- `event_outbox` / `event_inbox` tables for reliable publication and idempotent consumption +- `event-bus-service` for outbox dispatch and operational retry +- RabbitMQ as the first canonical message bus +- first consumers: + - `audit-service` + - `reporting-service` + +Operational runbook: + +- `docs/runbooks/event-bus-local.md` + +## Wave 2 Track 9 Asterisk Lab Integration + +Track 9 adds the first real telephony bridge toward Asterisk: + +- `asterisk-bridge-service` listens to AMI `UserEvent` messages with the `MVPCC` prefix +- Asterisk `QueueCode` values are mapped to platform `queue_id` values through config +- `MVPCCCallStarted` creates a real `voice` interaction and forwards `call.started` +- `MVPCCCallEnded` forwards `call.ended` and keeps the interaction linked +- `MVPCCRecordingReady` can fetch a recording from a Linux VM over `SFTP` and upload it into `recording-service` +- bridge internal auth mode is configurable: + - `ASTERISK_BRIDGE_AUTH_MODE=legacy_headers` (Track 9 QA baseline) + - `ASTERISK_BRIDGE_AUTH_MODE=bearer_first|bearer` (Track 9.1 hardening) +- strict service-token hardening adds subject allowlists: + - `VOICE_ADAPTER_TRUSTED_SERVICE_SUBJECTS` + - `RECORDING_IMPORT_TRUSTED_SERVICE_SUBJECTS` + - `RECORDING_IMPORT_ALLOW_ADMIN=0` in strict mode + +Repository assets for the Linux VM lab: + +- `deployment/asterisk/pjsip.conf` +- `deployment/asterisk/extensions.conf` +- `deployment/asterisk/manager.conf` + +Operational runbook: + +- `docs/runbooks/asterisk-lab-linux-vm.md` +- `docs/runbooks/.env.production.checklist.md` +- `deployment/helm/values.track9-strict.yaml` (strict bridge auth overlay) +- `docs/runbooks/track9-2-production-cutover.md` (controlled production cutover) + +Track 9 QA config artifacts: + +- `.env.production.template` +- `docs/acceptance/track9/track9-acceptance.md` + +## Wave 2 Track 11 Live Operator Voice Control + +Track 11 extends the Asterisk bridge with operator call control: + +- new bridge routes: + - `/asterisk/live-calls` + - `/asterisk/live-calls/{call_id}/claim` + - `/asterisk/live-calls/{call_id}/hangup` + - `/asterisk/live-calls/{call_id}/blind-transfer` + - `/asterisk/live-calls/{call_id}/actions` +- operator shell gets a `Живые звонки` block with claim/hangup/blind-transfer controls +- own-call RBAC guard for operator role +- additive voice events: + - `call.connected` + - `call.transferred` + +Runbook and acceptance templates: + +- `docs/runbooks/track11-live-operator-call-control.md` +- `docs/acceptance/track11/README.md` +- `docs/acceptance/track11/track11-acceptance.template.md` + +## PostgreSQL run (docker-compose) + +```bash +cd deployment +docker compose up -d postgres +cd .. +python scripts/migrate_core_db.py +``` + +## Shared Docker host PostgreSQL for call-center + +When the server already hosts other Docker projects and the running `call-center` stack is still SQLite-backed, use the isolated server-side PostgreSQL asset instead of reusing an existing database: + +- compose: `deployment/docker-compose.postgres.server.yml` +- template: `.env.postgres.server.template` +- runbook: `docs/runbooks/postgres-server-docker.md` + +To bring up a second PostgreSQL-backed application stack in parallel on the same host: + +- compose: `deployment/docker-compose.parallel.server.yml` +- template: `.env.postgres.parallel.server.template` +- runbook: `docs/runbooks/postgres-server-parallel-stack.md` + +## Stage-by-stage execution + +- Stage 1: see `docs/roadmap/01-foundation.md` +- Stage 2: see `docs/roadmap/02-operator-core.md` +- Stage 3: see `docs/roadmap/03-supervisor-reporting-kb.md` +- Stage 4: see `docs/roadmap/04-pilot-hardening.md` + +Every stage has a gate checklist in `docs/gates`. diff --git a/contracts/events/agent.state.changed.json b/contracts/events/agent.state.changed.json new file mode 100644 index 0000000..7ebf18e --- /dev/null +++ b/contracts/events/agent.state.changed.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "AgentStateChanged", + "type": "object", + "required": ["event", "agent_id", "state", "created_at"], + "properties": { + "event": { "const": "agent.state.changed" }, + "agent_id": { "type": "string" }, + "state": { "type": "string", "enum": ["READY", "BUSY", "BREAK", "OFFLINE"] }, + "queue_id": { "type": ["string", "null"] }, + "created_at": { "type": "string", "format": "date-time" } + } +} diff --git a/contracts/events/call.connected.json b/contracts/events/call.connected.json new file mode 100644 index 0000000..f0b9633 --- /dev/null +++ b/contracts/events/call.connected.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "CallConnected", + "type": "object", + "required": ["event", "call_id", "created_at"], + "properties": { + "event": { "const": "call.connected" }, + "call_id": { "type": "string" }, + "interaction_id": { "type": ["string", "null"] }, + "operator_extension": { "type": ["string", "null"] }, + "claimed_by_user": { "type": ["string", "null"] }, + "created_at": { "type": "string", "format": "date-time" } + } +} diff --git a/contracts/events/call.recording.ready.json b/contracts/events/call.recording.ready.json new file mode 100644 index 0000000..126b631 --- /dev/null +++ b/contracts/events/call.recording.ready.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "CallRecordingReady", + "type": "object", + "required": ["event", "call_id", "recording_path", "duration_seconds", "created_at"], + "properties": { + "event": { "const": "call.recording.ready" }, + "call_id": { "type": "string" }, + "interaction_id": { "type": ["string", "null"] }, + "recording_path": { "type": "string" }, + "duration_seconds": { "type": "integer", "minimum": 0 }, + "created_at": { "type": "string", "format": "date-time" } + } +} diff --git a/contracts/events/call.transferred.json b/contracts/events/call.transferred.json new file mode 100644 index 0000000..3dfdf3e --- /dev/null +++ b/contracts/events/call.transferred.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "CallTransferred", + "type": "object", + "required": ["event", "call_id", "created_at"], + "properties": { + "event": { "const": "call.transferred" }, + "call_id": { "type": "string" }, + "interaction_id": { "type": ["string", "null"] }, + "target_type": { "type": ["string", "null"], "enum": ["extension", "queue_code", null] }, + "target_value": { "type": ["string", "null"] }, + "target_extension": { "type": ["string", "null"] }, + "actor_user": { "type": ["string", "null"] }, + "created_at": { "type": "string", "format": "date-time" } + } +} diff --git a/contracts/events/interaction.assigned.json b/contracts/events/interaction.assigned.json new file mode 100644 index 0000000..61553b3 --- /dev/null +++ b/contracts/events/interaction.assigned.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "InteractionAssigned", + "type": "object", + "required": ["event", "interaction_id", "assignee", "created_at"], + "properties": { + "event": { "const": "interaction.assigned" }, + "interaction_id": { "type": "string" }, + "assignee": { "type": "string" }, + "created_at": { "type": "string", "format": "date-time" } + } +} diff --git a/contracts/events/interaction.closed.json b/contracts/events/interaction.closed.json new file mode 100644 index 0000000..e173f55 --- /dev/null +++ b/contracts/events/interaction.closed.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "InteractionClosed", + "type": "object", + "required": ["event", "interaction_id", "resolved_first_contact", "created_at"], + "properties": { + "event": { "const": "interaction.closed" }, + "interaction_id": { "type": "string" }, + "resolved_first_contact": { "type": "boolean" }, + "created_at": { "type": "string", "format": "date-time" } + } +} diff --git a/contracts/events/interaction.created.json b/contracts/events/interaction.created.json new file mode 100644 index 0000000..8b2ed7b --- /dev/null +++ b/contracts/events/interaction.created.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "InteractionCreated", + "type": "object", + "required": ["event", "interaction_id", "channel", "created_at"], + "properties": { + "event": { "const": "interaction.created" }, + "interaction_id": { "type": "string" }, + "channel": { "type": "string", "enum": ["voice", "telegram", "email", "webchat"] }, + "queue_id": { "type": ["string", "null"] }, + "created_at": { "type": "string", "format": "date-time" } + } +} diff --git a/contracts/events/interaction.escalated.json b/contracts/events/interaction.escalated.json new file mode 100644 index 0000000..6fb14f6 --- /dev/null +++ b/contracts/events/interaction.escalated.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "InteractionEscalated", + "type": "object", + "required": ["event", "interaction_id", "target_queue_id", "created_at"], + "properties": { + "event": { "const": "interaction.escalated" }, + "interaction_id": { "type": "string" }, + "target_queue_id": { "type": "string" }, + "created_at": { "type": "string", "format": "date-time" } + } +} diff --git a/contracts/events/ivr.completed.json b/contracts/events/ivr.completed.json new file mode 100644 index 0000000..14945c5 --- /dev/null +++ b/contracts/events/ivr.completed.json @@ -0,0 +1,54 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://mvp-cc.local/contracts/events/ivr.completed.json", + "title": "ivr.completed", + "type": "object", + "required": ["event_type", "call_id", "payload"], + "properties": { + "event_type": { + "const": "ivr.completed" + }, + "call_id": { + "type": "string", + "minLength": 1 + }, + "interaction_id": { + "type": ["string", "null"] + }, + "payload": { + "type": "object", + "required": ["session_id", "flow_id", "outcome_code", "resolved_queue_id", "digits", "terminal_node_id"], + "properties": { + "session_id": { + "type": "string", + "minLength": 1 + }, + "flow_id": { + "type": "string", + "minLength": 1 + }, + "outcome_code": { + "type": "string", + "minLength": 1 + }, + "resolved_queue_id": { + "type": "string", + "minLength": 1 + }, + "digits": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[0-9]$" + } + }, + "terminal_node_id": { + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": true + } + }, + "additionalProperties": true +} diff --git a/contracts/openapi/stage1-auth-users-health-audit.yaml b/contracts/openapi/stage1-auth-users-health-audit.yaml new file mode 100644 index 0000000..f4bddbe --- /dev/null +++ b/contracts/openapi/stage1-auth-users-health-audit.yaml @@ -0,0 +1,60 @@ +openapi: 3.0.3 +info: + title: Stage 1 Contracts (Auth, Users, Health, Audit) + version: 1.0.0 +paths: + /health: + get: + summary: Health check + responses: + '200': + description: OK + /auth/login: + post: + summary: Login + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [username, password] + properties: + username: { type: string } + password: { type: string } + responses: + '200': + description: Token response + /users: + get: + summary: List users + responses: + '200': + description: Users list + post: + summary: Create user + responses: + '200': + description: User created + /users/{user_id}: + get: + summary: Get user by id + parameters: + - in: path + name: user_id + required: true + schema: { type: string } + responses: + '200': + description: User data + /audit/events: + get: + summary: List audit events + responses: + '200': + description: Audit list + post: + summary: Create audit event + responses: + '200': + description: Event created diff --git a/contracts/openapi/stage2-customers-interactions-queues-integrations.yaml b/contracts/openapi/stage2-customers-interactions-queues-integrations.yaml new file mode 100644 index 0000000..6bbc071 --- /dev/null +++ b/contracts/openapi/stage2-customers-interactions-queues-integrations.yaml @@ -0,0 +1,68 @@ +openapi: 3.0.3 +info: + title: Stage 2 Contracts (Operator Core) + version: 1.0.0 +paths: + /customers: + get: { summary: Search customers, responses: { '200': { description: OK } } } + post: { summary: Create customer, responses: { '200': { description: OK } } } + /customers/{customer_id}: + get: + summary: Get customer + parameters: + - in: path + name: customer_id + required: true + schema: { type: string } + responses: + '200': { description: OK } + /interactions: + get: { summary: List interactions, responses: { '200': { description: OK } } } + post: { summary: Create interaction, responses: { '200': { description: OK } } } + /interactions/{interaction_id}/assign: + patch: + summary: Assign interaction + parameters: + - in: path + name: interaction_id + required: true + schema: { type: string } + responses: + '200': { description: OK } + /interactions/{interaction_id}/status: + patch: + summary: Update interaction status + parameters: + - in: path + name: interaction_id + required: true + schema: { type: string } + responses: + '200': { description: OK } + /interactions/{interaction_id}/escalate: + post: + summary: Escalate interaction + parameters: + - in: path + name: interaction_id + required: true + schema: { type: string } + responses: + '200': { description: OK } + /queues: + get: { summary: List queues, responses: { '200': { description: OK } } } + post: { summary: Create queue, responses: { '200': { description: OK } } } + /queues/{queue_id}/rules: + patch: + summary: Update queue rules + parameters: + - in: path + name: queue_id + required: true + schema: { type: string } + responses: + '200': { description: OK } + /integrations/voice/events: + post: { summary: Ingest Asterisk event, responses: { '200': { description: OK } } } + /integrations/telegram/webhook: + post: { summary: Ingest Telegram update, responses: { '200': { description: OK } } } diff --git a/contracts/openapi/stage3-kb-reporting-supervisor.yaml b/contracts/openapi/stage3-kb-reporting-supervisor.yaml new file mode 100644 index 0000000..93d1571 --- /dev/null +++ b/contracts/openapi/stage3-kb-reporting-supervisor.yaml @@ -0,0 +1,54 @@ +openapi: 3.0.3 +info: + title: Stage 3 Contracts (Supervisor, Reporting, KB-lite) + version: 1.0.0 +paths: + /knowledge/categories: + get: { summary: List categories, responses: { '200': { description: OK } } } + post: { summary: Create category, responses: { '200': { description: OK } } } + /knowledge/articles: + post: { summary: Create localized article, responses: { '200': { description: OK } } } + /knowledge/articles/{article_id}: + get: + summary: Get article + parameters: + - in: path + name: article_id + required: true + schema: { type: string } + responses: + '200': { description: OK } + patch: + summary: Update article + parameters: + - in: path + name: article_id + required: true + schema: { type: string } + responses: + '200': { description: OK } + /knowledge/search: + get: + summary: Search localized articles + parameters: + - in: query + name: q + schema: { type: string } + - in: query + name: language + schema: { type: string, enum: [ru, kz] } + - in: query + name: article_group_id + schema: { type: string } + responses: + '200': { description: OK } + /reports/events: + post: { summary: Ingest KPI event, responses: { '200': { description: OK } } } + /reports/kpi: + get: { summary: KPI snapshot, responses: { '200': { description: OK } } } + /reports/export: + get: { summary: Export CSV, responses: { '200': { description: OK } } } + /supervisor/agent-states: + post: { summary: Update agent state, responses: { '200': { description: OK } } } + /supervisor/realtime: + get: { summary: Realtime supervisor view, responses: { '200': { description: OK } } } diff --git a/contracts/versioning.md b/contracts/versioning.md new file mode 100644 index 0000000..abaebd3 --- /dev/null +++ b/contracts/versioning.md @@ -0,0 +1,8 @@ +# API Versioning + +Current stable baseline: `v1`. + +## Rules +- Stage 1-3 may add endpoints only in scope of agreed stage. +- Stage 4 freezes public API contracts and documentation. +- Breaking changes require new major version (`v2`) after pilot acceptance. diff --git a/deployment/.env.images.example b/deployment/.env.images.example new file mode 100644 index 0000000..ac953e9 --- /dev/null +++ b/deployment/.env.images.example @@ -0,0 +1,2 @@ +APP_IMAGE=registry.example.com/group/project/app:latest +ASTERISK_IMAGE=registry.example.com/group/project/asterisk:latest diff --git a/deployment/asterisk/Dockerfile b/deployment/asterisk/Dockerfile new file mode 100644 index 0000000..7b8fee5 --- /dev/null +++ b/deployment/asterisk/Dockerfile @@ -0,0 +1,21 @@ +FROM ubuntu:24.04 + +ENV DEBIAN_FRONTEND=noninteractive + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + asterisk \ + ca-certificates \ + curl \ + openssl \ + python3 \ + procps \ + uuid-runtime \ + && rm -rf /var/lib/apt/lists/* + +COPY deployment/asterisk/entrypoint.sh /usr/local/bin/mvpcc-asterisk-entrypoint.sh + +RUN chmod +x /usr/local/bin/mvpcc-asterisk-entrypoint.sh \ + && mkdir -p /config-template /assets /etc/asterisk/keys /var/spool/asterisk/monitor/mvpcc + +ENTRYPOINT ["/usr/local/bin/mvpcc-asterisk-entrypoint.sh"] diff --git a/deployment/asterisk/README.md b/deployment/asterisk/README.md new file mode 100644 index 0000000..df84723 --- /dev/null +++ b/deployment/asterisk/README.md @@ -0,0 +1,74 @@ +# Asterisk Lab Config + +This folder contains the lab-grade configuration set for Track 9, Track 11, and Track 14: + +- `pjsip.conf` +- `extensions.conf` +- `manager.conf` +- `http.conf` +- `rtp.conf` + +These files are templates for a dedicated Linux VM running Asterisk 20 LTS. + +Before using them: + +1. Replace default passwords and secrets. +2. Confirm the softphone source network. +3. Create the recording directory: + - `/var/spool/asterisk/monitor/mvpcc` +4. Point `ASTERISK_QUEUE_MAP_JSON` in the platform to the matching queue code: + - `voice_lab` + - `voice_lab_ai` for the dedicated AI-first staging entrypoint + - `voice_lab_ivr` for the dedicated IVR staging entrypoint +5. Configure operator extension mapping in bridge env for Track 11 call control: + - `ASTERISK_OPERATOR_EXTENSION_MAP_JSON={"operator_a":"2001","operator_b":"2002"}` +6. Optional transfer map for queue-code based blind transfer: + - `ASTERISK_TRANSFER_TARGET_MAP_JSON={"voice_lab":"2001","voice_lab_ai":"2001","voice_lab_ivr":"7200"}` +7. For IVR-over-Asterisk staging: + - enable `ASTERISK_IVR_FASTAGI_ENABLED=1` + - set `ASTERISK_IVR_FASTAGI_HOST=` + - set `ASTERISK_IVR_FASTAGI_PORT=4573` + - set `IVR_RUNTIME_TRUSTED_SERVICE_SUBJECTS=svc:asterisk-bridge` + - provision the referenced `prompt_audio_key` files under the Asterisk sounds directory + - if you want a higher-quality prompt from OpenAI TTS, generate it once and keep it on the server: + - `python scripts/materialize_tts_prompt.py --text "Здравствуйте! Добро пожаловать в контакт-центр. Для русского языка нажмите 1. Қазақ тілі үшін 2 басыңыз." --output /usr/share/asterisk/sounds/en/mvpcc_ivr_language.wav --language ru` + - `python scripts/materialize_tts_prompt.py --text "Для отдела продаж нажмите 1. Для службы поддержки нажмите 2." --output /usr/share/asterisk/sounds/en/mvpcc_ivr_menu_ru.wav --language ru` + - `python scripts/materialize_tts_prompt.py --text "Сату бөлімі үшін 1 басыңыз. Қолдау қызметі үшін 2 басыңыз." --output /usr/share/asterisk/sounds/en/mvpcc_ivr_menu_kz.wav --language kk` + - repeated runs reuse the server cache from `AI_VOICE_TTS_CACHE_DIR` instead of calling OpenAI again for the same text +8. For Track 14 browser softphone QA: + - enable `ASTERISK_WEBRTC_ENABLED=1` + - set `ASTERISK_WEBRTC_WS_URL=wss://:8089/ws` + - configure `ASTERISK_BROWSER_SIP_MAP_JSON` + - replace TLS placeholders in `http.conf` with a trusted cert/key pair +9. For Voice AI V1 lab wiring: + - enable `AI_VOICE_ENABLED=1` + - set `AI_VOICE_QUEUE_CONFIG_JSON={"voice_lab_ai":{"mode":"ai_first","agent_profile":"voice_support","handoff_queue_code":"voice_lab","language":"ru"}}` + - set `AI_VOICE_RUNTIME_SERVICE_URL=http://ai-voice-runtime-service:8000` + - keep `AI_VOICE_RUNTIME_TRUSTED_SERVICE_SUBJECTS=svc:ai-voice-runtime` + - load `app_audiosocket.so` on the Asterisk VM before enabling the AI media path + - set `MVPCC_AI_AUDIOSOCKET_SERVICE=:` in `extensions.conf` only after the AudioSocket listener is reachable + +Entrypoints in `extensions.conf`: + +- `7000`: human-only baseline. This path preserves the original Track 9/11 behavior. +- `7100`: AI-first staging path. It emits the normal `MVPCCCallStarted` event with `QueueCode=voice_lab_ai`, then: + - falls back to human dialing immediately if `MVPCC_AI_AUDIOSOCKET_SERVICE` is blank + - otherwise starts `AudioSocket(,:)` for the media bridge +- `7200`: IVR staging path. It emits the normal `MVPCCCallStarted` event with `QueueCode=voice_lab_ivr`, then: + - hands control to FastAGI at `MVPCC_IVR_FASTAGI_HOSTPORT` + - routes completed IVR sessions through `mvpcc-transfer` + - falls back to the human baseline if FastAGI does not return a transfer target + +Important Voice AI note: + +- `AI_VOICE_RUNTIME_SERVICE_URL` is the HTTP control-plane endpoint for platform services. +- `MVPCC_AI_AUDIOSOCKET_SERVICE` is a separate raw AudioSocket address for Asterisk media. +- Do not point `MVPCC_AI_AUDIOSOCKET_SERVICE` at the runtime HTTP port. + +Default lab SIP accounts from `pjsip.conf`: +- `1001` (caller) +- `2001` (operator A) +- `2002` (operator B) + +Track 14 note: +- `2001` and `2002` are allowed to keep multiple contacts so browser registration can coexist with `MicroSIP/Zoiper` in QA. diff --git a/deployment/asterisk/entrypoint.sh b/deployment/asterisk/entrypoint.sh new file mode 100644 index 0000000..ff8ba3d --- /dev/null +++ b/deployment/asterisk/entrypoint.sh @@ -0,0 +1,104 @@ +#!/usr/bin/env bash +set -euo pipefail + +template_dir="/config-template" +monitor_dir="/var/spool/asterisk/monitor/mvpcc" +primary_sounds_dir="/usr/share/asterisk/sounds" +legacy_sounds_dir="/var/lib/asterisk/sounds" +keys_dir="/etc/asterisk/keys" + +password_1001="${ASTERISK_ENDPOINT_1001_PASSWORD:-${ASTERISK_AMI_SECRET:-ChangeMe-1001}}" +password_2001="${ASTERISK_ENDPOINT_2001_PASSWORD:-${ASTERISK_AMI_SECRET:-ChangeMe-2001}}" +password_2002="${ASTERISK_ENDPOINT_2002_PASSWORD:-${ASTERISK_AMI_SECRET:-ChangeMe-2002}}" +ami_secret="${ASTERISK_AMI_SECRET:-ChangeMe-AmiSecret}" +external_address="${ASTERISK_EXTERNAL_ADDRESS:-92.38.48.166}" + +mkdir -p /etc/asterisk "$monitor_dir" "$primary_sounds_dir" "$legacy_sounds_dir" "$keys_dir" + +for file in extensions.conf pjsip.conf manager.conf http.conf rtp.conf; do + cp "${template_dir}/${file}" "/etc/asterisk/${file}" +done + +export ASTERISK_PASSWORD_1001="$password_1001" +export ASTERISK_PASSWORD_2001="$password_2001" +export ASTERISK_PASSWORD_2002="$password_2002" +export ASTERISK_MANAGER_SECRET="$ami_secret" +export ASTERISK_EXTERNAL_ADDRESS_VALUE="$external_address" + +python3 - <<'PY' +from pathlib import Path +import os + +replacements = { + "ChangeMe-1001": os.environ["ASTERISK_PASSWORD_1001"], + "ChangeMe-2001": os.environ["ASTERISK_PASSWORD_2001"], + "ChangeMe-2002": os.environ["ASTERISK_PASSWORD_2002"], + "ChangeMe-AmiSecret": os.environ["ASTERISK_MANAGER_SECRET"], + "92.38.48.166": os.environ["ASTERISK_EXTERNAL_ADDRESS_VALUE"], +} + +for path in ( + Path("/etc/asterisk/pjsip.conf"), + Path("/etc/asterisk/manager.conf"), +): + text = path.read_text(encoding="utf-8") + for old, new in replacements.items(): + text = text.replace(old, new) + path.write_text(text, encoding="utf-8") + +modules_path = Path("/etc/asterisk/modules.conf") +if modules_path.exists(): + text = modules_path.read_text(encoding="utf-8") + if "noload => chan_sip.so" not in text: + stanza = ( + "; MVPCC uses PJSIP/WebRTC for browser softphones.\n" + "noload => chan_sip.so\n\n" + ) + if "[global]" in text: + text = text.replace("[global]", f"{stanza}[global]", 1) + elif "[modules]" in text: + text = text.rstrip() + "\n\n" + stanza + else: + text = "[modules]\nautoload=yes\n\n" + stanza + text + modules_path.write_text(text, encoding="utf-8") +PY + +if [ ! -f "${keys_dir}/asterisk.pem" ] || [ ! -f "${keys_dir}/asterisk.key" ]; then + openssl req \ + -x509 \ + -nodes \ + -newkey rsa:2048 \ + -keyout "${keys_dir}/asterisk.key" \ + -out "${keys_dir}/asterisk.pem" \ + -days 3650 \ + -subj "/CN=${external_address}" +fi + +if [ -f /assets/mvpcc_ivr_prompt.wav ]; then + for sounds_dir in "$primary_sounds_dir" "$legacy_sounds_dir"; do + cp /assets/mvpcc_ivr_prompt.wav "${sounds_dir}/mvpcc_ivr_prompt.wav" + mkdir -p "${sounds_dir}/en" "${sounds_dir}/ru" "${sounds_dir}/ru_RU" + cp /assets/mvpcc_ivr_prompt.wav "${sounds_dir}/en/mvpcc_ivr_prompt.wav" + cp /assets/mvpcc_ivr_prompt.wav "${sounds_dir}/ru/mvpcc_ivr_prompt.wav" + cp /assets/mvpcc_ivr_prompt.wav "${sounds_dir}/ru_RU/mvpcc_ivr_prompt.wav" + done +fi + +if [ -d /assets/ivr ]; then + for sounds_dir in "$primary_sounds_dir" "$legacy_sounds_dir"; do + mkdir -p "${sounds_dir}/ivr" + cp -a /assets/ivr/. "${sounds_dir}/ivr/" + done +fi + +chown -R asterisk:asterisk /etc/asterisk "$keys_dir" /var/spool/asterisk/monitor + +for sounds_dir in "$primary_sounds_dir" "$legacy_sounds_dir"; do + if [ -w "$sounds_dir" ]; then + chown -R asterisk:asterisk "$sounds_dir" + fi +done + +chmod 0755 /var/spool/asterisk /var/spool/asterisk/monitor "$monitor_dir" + +exec /usr/sbin/asterisk -f -U asterisk -G asterisk -vvv diff --git a/deployment/asterisk/extensions.conf b/deployment/asterisk/extensions.conf new file mode 100644 index 0000000..e575a50 --- /dev/null +++ b/deployment/asterisk/extensions.conf @@ -0,0 +1,119 @@ +[globals] +MVPCC_QUEUE_CODE=voice_lab +MVPCC_AI_QUEUE_CODE=voice_lab_ai +MVPCC_IVR_QUEUE_CODE=voice_lab_ivr +MVPCC_IVR_FALLBACK_QUEUE_CODE=voice_lab +MVPCC_MONITOR_DIR=/var/spool/asterisk/monitor/mvpcc +MVPCC_HUMAN_DIAL_TARGETS=PJSIP/2001&PJSIP/2002 +MVPCC_AI_AUDIOSOCKET_SERVICE=127.0.0.1:9019 +MVPCC_IVR_FASTAGI_HOSTPORT=127.0.0.1:4573 + +[from-softphones] +exten => 7000,1,NoOp(MVPCC human baseline entrypoint) + same => n,Answer() + same => n,Set(MVPCC_QUEUE=${MVPCC_QUEUE_CODE}) + same => n,Gosub(mvpcc-call-init,s,1(${EXTEN})) + same => n,Dial(${MVPCC_HUMAN_DIAL_TARGETS},40,U(mvpcc-connected^${MVPCC_CALL_ID}^${MVPCC_LINKED_ID})) + same => n,Hangup() + +exten => 7100,1,NoOp(MVPCC AI-first voice lab entrypoint) + same => n,Set(MVPCC_QUEUE=${IF($["${MVPCC_AI_QUEUE_OVERRIDE}" != ""]?${MVPCC_AI_QUEUE_OVERRIDE}:${MVPCC_AI_QUEUE_CODE})}) + same => n,GotoIf($["${MVPCC_AI_REUSE_CALL}" = "1"]?ai-reuse,1) + same => n,Answer() + same => n,Gosub(mvpcc-call-init,s,1(${EXTEN})) + same => n,Goto(ai-audiosocket,1) + +exten => ai-reuse,1,NoOp(MVPCC AI handoff reusing active IVR call) + same => n,Goto(ai-audiosocket,1) + +exten => ai-audiosocket,1,NoOp(MVPCC AI audio bridge start) + same => n,GotoIf($["${MVPCC_AI_AUDIOSOCKET_SERVICE}" = ""]?ai-human-fallback,1) + same => n,Set(MVPCC_MEDIA_UUID=${SHELL(uuidgen | tr -d '\r\n')}) + same => n,UserEvent(MVPCCAIAudioBridgeRequested,CallID:${MVPCC_CALL_ID},LinkedID:${MVPCC_LINKED_ID},QueueCode:${MVPCC_QUEUE},Extension:${MVPCC_ENTRY_EXTENSION},Context:${CONTEXT},MediaUUID:${MVPCC_MEDIA_UUID},UuidLen:${LEN(${MVPCC_MEDIA_UUID})},AudioSocketService:${MVPCC_AI_AUDIOSOCKET_SERVICE},Channel:${CHANNEL}) + same => n,UserEvent(MVPCCAIAudioSocketAttempt,CallID:${MVPCC_CALL_ID},LinkedID:${MVPCC_LINKED_ID},QueueCode:${MVPCC_QUEUE},Extension:${MVPCC_ENTRY_EXTENSION},Context:${CONTEXT},MediaUUID:${MVPCC_MEDIA_UUID},UuidLen:${LEN(${MVPCC_MEDIA_UUID})},AudioSocketService:${MVPCC_AI_AUDIOSOCKET_SERVICE},Channel:${CHANNEL}) + same => n,TryExec(AudioSocket(${MVPCC_MEDIA_UUID},${MVPCC_AI_AUDIOSOCKET_SERVICE})) + same => n,UserEvent(MVPCCAIAudioSocketResult,CallID:${MVPCC_CALL_ID},LinkedID:${MVPCC_LINKED_ID},QueueCode:${MVPCC_QUEUE},Extension:${MVPCC_ENTRY_EXTENSION},Context:${CONTEXT},MediaUUID:${MVPCC_MEDIA_UUID},UuidLen:${LEN(${MVPCC_MEDIA_UUID})},AudioSocketService:${MVPCC_AI_AUDIOSOCKET_SERVICE},TryExecStatus:${TRYSTATUS},Channel:${CHANNEL}) + same => n,GotoIf($["${TRYSTATUS}" = "SUCCESS"]?ai-bridge-ended,1) + same => n,UserEvent(MVPCCAIFallbackToHuman,CallID:${MVPCC_CALL_ID},LinkedID:${MVPCC_LINKED_ID},QueueCode:${MVPCC_QUEUE},Extension:${MVPCC_ENTRY_EXTENSION},Context:${CONTEXT},Reason:audiosocket_failed,TryExecStatus:${TRYSTATUS},Channel:${CHANNEL}) + same => n,Dial(${MVPCC_HUMAN_DIAL_TARGETS},40,U(mvpcc-connected^${MVPCC_CALL_ID}^${MVPCC_LINKED_ID})) + same => n,Hangup() + +exten => ai-bridge-ended,1,UserEvent(MVPCCAIAudioBridgeEnded,CallID:${MVPCC_CALL_ID},LinkedID:${MVPCC_LINKED_ID},QueueCode:${MVPCC_QUEUE},Extension:${MVPCC_ENTRY_EXTENSION},Context:${CONTEXT},MediaUUID:${MVPCC_MEDIA_UUID},AudioSocketService:${MVPCC_AI_AUDIOSOCKET_SERVICE},Channel:${CHANNEL}) + same => n,Hangup() + +exten => ai-human-fallback,1,UserEvent(MVPCCAIFallbackToHuman,CallID:${MVPCC_CALL_ID},LinkedID:${MVPCC_LINKED_ID},QueueCode:${MVPCC_QUEUE},Extension:${MVPCC_ENTRY_EXTENSION},Context:${CONTEXT},Reason:audiosocket_service_missing,Channel:${CHANNEL}) + same => n,Dial(${MVPCC_HUMAN_DIAL_TARGETS},40,U(mvpcc-connected^${MVPCC_CALL_ID}^${MVPCC_LINKED_ID})) + same => n,Hangup() + +exten => 7200,1,NoOp(MVPCC IVR staging entrypoint) + same => n,Answer() + same => n,Set(MVPCC_QUEUE=${MVPCC_IVR_QUEUE_CODE}) + same => n,Set(MVPCC_IVR_FALLBACK_QUEUE_CODE=${GLOBAL(MVPCC_IVR_FALLBACK_QUEUE_CODE)}) + same => n,Gosub(mvpcc-call-init,s,1(${EXTEN})) + same => n,Set(MVPCC_IVR_RESULT=started) + same => n,Set(MVPCC_IVR_TARGET_QUEUE_CODE=) + same => n,Set(MVPCC_IVR_TARGET_EXTENSION=) + same => n,Set(MVPCC_IVR_SESSION_ID=) + same => n,GotoIf($["${MVPCC_IVR_FASTAGI_HOSTPORT}" = ""]?ivr-human-fallback,1) + same => n,AGI(agi://${MVPCC_IVR_FASTAGI_HOSTPORT}/ivr,${MVPCC_CALL_ID},${MVPCC_LINKED_ID},${MVPCC_QUEUE},${MVPCC_IVR_FALLBACK_QUEUE_CODE},${EXTEN}) + same => n,GotoIf($["${MVPCC_IVR_TARGET_EXTENSION}" = ""]?ivr-human-fallback,1) + same => n,Goto(mvpcc-transfer,${MVPCC_IVR_TARGET_EXTENSION},1) + +exten => ivr-human-fallback,1,UserEvent(MVPCCIVRFallbackToHuman,CallID:${MVPCC_CALL_ID},LinkedID:${MVPCC_LINKED_ID},QueueCode:${MVPCC_QUEUE},FallbackQueueCode:${MVPCC_IVR_FALLBACK_QUEUE_CODE},Extension:${MVPCC_ENTRY_EXTENSION},Result:${MVPCC_IVR_RESULT},TargetQueueCode:${MVPCC_IVR_TARGET_QUEUE_CODE},TargetExtension:${MVPCC_IVR_TARGET_EXTENSION},Channel:${CHANNEL}) + same => n,Dial(${MVPCC_HUMAN_DIAL_TARGETS},40,U(mvpcc-connected^${MVPCC_CALL_ID}^${MVPCC_LINKED_ID})) + same => n,Hangup() + +exten => h,1,StopMixMonitor() + same => n,UserEvent(MVPCCCallEnded,CallID:${MVPCC_CALL_ID},LinkedID:${MVPCC_LINKED_ID},HangupCause:${HANGUPCAUSE},DurationSeconds:${CDR(duration)}) + same => n,UserEvent(MVPCCRecordingReady,CallID:${MVPCC_CALL_ID},LinkedID:${MVPCC_LINKED_ID},RemotePath:${MONITOR_FILENAME},FileName:${MVPCC_RECORD_FILE},MimeType:audio/wav,DurationSeconds:${CDR(duration)}) + +[mvpcc-call-init] +exten => s,1,Set(MVPCC_ENTRY_EXTENSION=${ARG1}) + same => n,Set(MVPCC_CALL_ID=${UNIQUEID}) + same => n,Set(MVPCC_LINKED_ID=${CHANNEL(linkedid)}) + same => n,Set(MVPCC_RECORD_FILE=${STRFTIME(${EPOCH},,%Y%m%d-%H%M%S)}-${UNIQUEID}.wav) + same => n,Set(MONITOR_FILENAME=${MVPCC_MONITOR_DIR}/${MVPCC_RECORD_FILE}) + same => n,UserEvent(MVPCCCallStarted,CallID:${MVPCC_CALL_ID},LinkedID:${MVPCC_LINKED_ID},CallerNumber:${CALLERID(num)},CallerName:${CALLERID(name)},QueueCode:${MVPCC_QUEUE},Extension:${MVPCC_ENTRY_EXTENSION},Context:${CONTEXT},Direction:inbound,Channel:${CHANNEL}) + same => n,MixMonitor(${MONITOR_FILENAME},a) + same => n,Return() + +[mvpcc-connected] +exten => s,1,Set(MVPCC_OPERATOR=${IF($["${ARG3}" != ""]?${ARG3}:${CHANNEL(peername)})}) + same => n,Set(MVPCC_OPERATOR=${IF($["${MVPCC_OPERATOR}" = ""]?${EXTEN}:${MVPCC_OPERATOR})}) + same => n,UserEvent(MVPCCOperatorConnected,CallID:${ARG1},LinkedID:${ARG2},OperatorExtension:${MVPCC_OPERATOR},Context:${CONTEXT},Channel:${CHANNEL},ClaimedByUser:${MVPCC_OPERATOR}) + same => n,Return() + +[mvpcc-claim] +exten => _X.,1,NoOp(Claimed call routed to operator ${EXTEN}) + same => n,Set(MVPCC_CALL_ID=${CHANNEL(linkedid)}) + same => n,Set(MVPCC_LINKED_ID=${CHANNEL(linkedid)}) + same => n,Dial(PJSIP/${EXTEN},40,U(mvpcc-connected^${MVPCC_CALL_ID}^${MVPCC_LINKED_ID}^${EXTEN})) + same => n,Hangup() + +[mvpcc-transfer] +exten => 7000,1,NoOp(Queue transfer target ${EXTEN}) + same => n,Dial(${MVPCC_HUMAN_DIAL_TARGETS},40) + same => n,Hangup() + +exten => 7100,1,NoOp(Queue transfer target ${EXTEN}) + same => n,Goto(from-softphones,7100,1) + +exten => 7200,1,NoOp(Queue transfer target ${EXTEN}) + same => n,Goto(from-softphones,7200,1) + +exten => _X.,1,NoOp(Blind transfer target ${EXTEN}) + same => n,Dial(PJSIP/${EXTEN},40,U(mvpcc-connected^${MVPCC_CALL_ID}^${MVPCC_LINKED_ID}^${EXTEN})) + same => n,GotoIf($["${DIALSTATUS}" = "ANSWER"]?transfer-finished,1) + same => n,GotoIf($["${MVPCC_IVR_FALLBACK_QUEUE_CODE}" = ""]?transfer-finished,1) + same => n,Set(MVPCC_IVR_RESULT=fallback_transfer_unavailable) + same => n,Goto(from-softphones,ivr-human-fallback,1) + +exten => transfer-finished,1,Hangup() + +exten => h,1,StopMixMonitor() + same => n,UserEvent(MVPCCCallEnded,CallID:${MVPCC_CALL_ID},LinkedID:${MVPCC_LINKED_ID},HangupCause:${HANGUPCAUSE},DurationSeconds:${CDR(duration)}) + same => n,UserEvent(MVPCCRecordingReady,CallID:${MVPCC_CALL_ID},LinkedID:${MVPCC_LINKED_ID},RemotePath:${MONITOR_FILENAME},FileName:${MVPCC_RECORD_FILE},MimeType:audio/wav,DurationSeconds:${CDR(duration)}) + +[mvpcc-operators] +exten => _X.,1,Dial(PJSIP/${EXTEN},30) + same => n,Hangup() diff --git a/deployment/asterisk/http.conf b/deployment/asterisk/http.conf new file mode 100644 index 0000000..0a7fa8e --- /dev/null +++ b/deployment/asterisk/http.conf @@ -0,0 +1,12 @@ +[general] +enabled=yes +enablestatic=yes +bindaddr=0.0.0.0 +bindport=8088 + +; QA-only TLS placeholders for direct browser SIP over WebSocket. +; Replace with your actual trusted certificate and key paths before enabling WSS. +tlsenable=yes +tlsbindaddr=0.0.0.0:8089 +tlscertfile=/etc/asterisk/keys/asterisk.pem +tlsprivatekey=/etc/asterisk/keys/asterisk.key diff --git a/deployment/asterisk/manager.conf b/deployment/asterisk/manager.conf new file mode 100644 index 0000000..9ba4414 --- /dev/null +++ b/deployment/asterisk/manager.conf @@ -0,0 +1,11 @@ +[general] +enabled = yes +port = 5038 +bindaddr = 0.0.0.0 + +[mvpcc] +secret = ChangeMe-AmiSecret +deny = 0.0.0.0/0.0.0.0 +permit = 0.0.0.0/0.0.0.0 +read = system,call,log,verbose,command,agent,user,dtmf,reporting,cdr,dialplan +write = command,call,originate,agent,user diff --git a/deployment/asterisk/pjsip.conf b/deployment/asterisk/pjsip.conf new file mode 100644 index 0000000..6890720 --- /dev/null +++ b/deployment/asterisk/pjsip.conf @@ -0,0 +1,84 @@ +[transport-udp] +type=transport +protocol=udp +bind=0.0.0.0 +external_signaling_address=92.38.48.166 +external_media_address=92.38.48.166 + +[transport-wss] +type=transport +protocol=wss +bind=0.0.0.0 +external_signaling_address=92.38.48.166 +external_media_address=92.38.48.166 + +[softphone-endpoint](!) +type=endpoint +disallow=all +allow=opus,ulaw,alaw +dtmf_mode=auto_info +direct_media=no +rtp_symmetric=yes +force_rport=yes +rewrite_contact=yes +webrtc=yes +ice_support=yes +rtcp_mux=yes +use_avpf=yes +media_encryption=dtls +dtls_auto_generate_cert=yes +dtls_setup=actpass +media_use_received_transport=yes +media_address=92.38.48.166 + +[caller-endpoint](!,softphone-endpoint) +context=from-softphones + +[operator-endpoint](!,softphone-endpoint) +context=mvpcc-operators + +[1001](caller-endpoint) +auth=1001-auth +aors=1001 + +[1001-auth] +type=auth +auth_type=userpass +username=1001 +password=ChangeMe-1001 + +[1001] +type=aor +max_contacts=2 + +[2001](operator-endpoint) +auth=2001-auth +aors=2001 + +[2001-auth] +type=auth +auth_type=userpass +username=2001 +password=ChangeMe-2001 + +[2001] +type=aor +max_contacts=1 +remove_existing=yes +support_path=yes + +[2002](operator-endpoint) +auth=2002-auth +aors=2002 + +[2002-auth] +type=auth +auth_type=userpass +username=2002 +password=ChangeMe-2002 + +[2002] +type=aor +max_contacts=1 +remove_existing=yes +support_path=yes diff --git a/deployment/asterisk/rtp.conf b/deployment/asterisk/rtp.conf new file mode 100644 index 0000000..7e3f230 --- /dev/null +++ b/deployment/asterisk/rtp.conf @@ -0,0 +1,5 @@ +[general] +rtpstart=10000 +rtpend=20000 +icesupport=true +stunaddr=stun.l.google.com:19302 diff --git a/deployment/docker-compose.asterisk.server.registry.yml b/deployment/docker-compose.asterisk.server.registry.yml new file mode 100644 index 0000000..56d2957 --- /dev/null +++ b/deployment/docker-compose.asterisk.server.registry.yml @@ -0,0 +1,24 @@ +services: + asterisk: + image: ${ASTERISK_IMAGE:?Set ASTERISK_IMAGE in deployment/.env.images or shell env} + container_name: call-center-asterisk + restart: unless-stopped + env_file: + - ../.env.production + environment: + ASTERISK_EXTERNAL_ADDRESS: 92.38.48.166 + network_mode: host + volumes: + - ./asterisk:/config-template:ro + - ../.asterisk_assets:/assets + - ../.data_local/generated_ivr_yandex/ivr:/usr/share/asterisk/sounds/ivr + - ../.data_local/generated_ivr_yandex/ivr:/var/lib/asterisk/sounds/ivr + - /var/spool/asterisk/monitor:/var/spool/asterisk/monitor + healthcheck: + test: + - CMD-SHELL + - asterisk -rx "core show version" >/dev/null 2>&1 + interval: 15s + timeout: 5s + retries: 10 + start_period: 20s diff --git a/deployment/docker-compose.asterisk.server.yml b/deployment/docker-compose.asterisk.server.yml new file mode 100644 index 0000000..33747d0 --- /dev/null +++ b/deployment/docker-compose.asterisk.server.yml @@ -0,0 +1,27 @@ +services: + asterisk: + build: + context: .. + dockerfile: deployment/asterisk/Dockerfile + image: call-center-asterisk:local + container_name: call-center-asterisk + restart: unless-stopped + env_file: + - ../.env.production + environment: + ASTERISK_EXTERNAL_ADDRESS: 92.38.48.166 + network_mode: host + volumes: + - ./asterisk:/config-template:ro + - ../.codex_tmp:/assets + - ../.data_local/generated_ivr_yandex/ivr:/usr/share/asterisk/sounds/ivr + - ../.data_local/generated_ivr_yandex/ivr:/var/lib/asterisk/sounds/ivr + - /var/spool/asterisk/monitor:/var/spool/asterisk/monitor + healthcheck: + test: + - CMD-SHELL + - asterisk -rx "core show version" >/dev/null 2>&1 + interval: 15s + timeout: 5s + retries: 10 + start_period: 20s diff --git a/deployment/docker-compose.parallel.server.yml b/deployment/docker-compose.parallel.server.yml new file mode 100644 index 0000000..042f834 --- /dev/null +++ b/deployment/docker-compose.parallel.server.yml @@ -0,0 +1,207 @@ +name: call-center-pg + +x-app-env: &app_env + CC_DATA_DIR: /app/.data_pg_parallel + SCHEMA_MANAGEMENT_MODE: migrations + AUTH_SERVICE_URL: http://auth-service:8000 + AUDIT_SERVICE_URL: http://audit-service:8000 + CUSTOMER_SERVICE_URL: http://customer-service:8000 + INTERACTION_SERVICE_URL: http://interaction-service:8000 + ROUTING_SERVICE_URL: http://routing-service:8000 + VOICE_ADAPTER_SERVICE_URL: http://voice-adapter-service:8000 + RECORDING_SERVICE_URL: http://recording-service:8000 + IVR_SERVICE_URL: http://ivr-service:8000 + EVENT_BUS_SERVICE_URL: http://event-bus-service:8000 + ASTERISK_BRIDGE_SERVICE_URL: http://asterisk-bridge-service:8000 + TELEGRAM_ADAPTER_SERVICE_URL: http://telegram-adapter-service:8000 + WHATSAPP_ADAPTER_SERVICE_URL: http://whatsapp-adapter-service:8000 + AI_ORCHESTRATOR_SERVICE_URL: http://ai-orchestrator-service:8000 + AI_VOICE_RUNTIME_SERVICE_URL: http://ai-voice-runtime-service:8000 + WEBCHAT_ADAPTER_SERVICE_URL: http://webchat-adapter-service:8000 + EMAIL_ADAPTER_SERVICE_URL: http://email-adapter-service:8000 + KB_SERVICE_URL: http://kb-service:8000 + REPORTING_SERVICE_URL: http://reporting-service:8000 + SUPERVISOR_SERVICE_URL: http://supervisor-service:8000 + +x-service-defaults: &service_defaults + build: + context: .. + image: call-center-app:pg-parallel + restart: unless-stopped + env_file: + - ../.env.production + - ../.env.postgres.parallel.server + environment: + <<: *app_env + volumes: + - ../.data_pg_parallel:/app/.data_pg_parallel + networks: + - default + - postgres + extra_hosts: + - host.docker.internal:host-gateway + healthcheck: + test: + - CMD + - python + - -c + - | + from urllib.request import urlopen + import sys + sys.exit(0 if urlopen("http://127.0.0.1:8000/health", timeout=2).getcode() == 200 else 1) + interval: 15s + timeout: 5s + retries: 10 + start_period: 10s + +services: + auth-service: + <<: *service_defaults + environment: + <<: *app_env + APP_MODULE: services.auth_service.app:app + + audit-service: + <<: *service_defaults + environment: + <<: *app_env + APP_MODULE: services.audit_service.app:app + + customer-service: + <<: *service_defaults + environment: + <<: *app_env + APP_MODULE: services.customer_service.app:app + + interaction-service: + <<: *service_defaults + environment: + <<: *app_env + APP_MODULE: services.interaction_service.app:app + + routing-service: + <<: *service_defaults + environment: + <<: *app_env + APP_MODULE: services.routing_service.app:app + + voice-adapter-service: + <<: *service_defaults + environment: + <<: *app_env + APP_MODULE: services.voice_adapter_service.app:app + + recording-service: + <<: *service_defaults + environment: + <<: *app_env + APP_MODULE: services.recording_service.app:app + CC_RECORDINGS_DIR: /app/.data_pg_parallel/recordings + RECORDING_MAX_BYTES: 26214400 + + ivr-service: + <<: *service_defaults + environment: + <<: *app_env + APP_MODULE: services.ivr_service.app:app + + telegram-adapter-service: + <<: *service_defaults + environment: + <<: *app_env + APP_MODULE: services.telegram_adapter_service.app:app + + whatsapp-adapter-service: + <<: *service_defaults + environment: + <<: *app_env + APP_MODULE: services.whatsapp_adapter_service.app:app + + ai-orchestrator-service: + <<: *service_defaults + environment: + <<: *app_env + APP_MODULE: services.ai_orchestrator_service.app:app + + ai-voice-runtime-service: + <<: *service_defaults + environment: + <<: *app_env + APP_MODULE: services.ai_voice_runtime_service.app:app + + webchat-adapter-service: + <<: *service_defaults + environment: + <<: *app_env + APP_MODULE: services.webchat_adapter_service.app:app + + email-adapter-service: + <<: *service_defaults + environment: + <<: *app_env + APP_MODULE: services.email_adapter_service.app:app + + kb-service: + <<: *service_defaults + environment: + <<: *app_env + APP_MODULE: services.kb_service.app:app + + reporting-service: + <<: *service_defaults + environment: + <<: *app_env + APP_MODULE: services.reporting_service.app:app + + supervisor-service: + <<: *service_defaults + environment: + <<: *app_env + APP_MODULE: services.supervisor_service.app:app + + event-bus-service: + <<: *service_defaults + environment: + <<: *app_env + APP_MODULE: services.event_bus_service.app:app + + asterisk-bridge-service: + <<: *service_defaults + environment: + <<: *app_env + APP_MODULE: services.asterisk_bridge_service.app:app + ASTERISK_IVR_FASTAGI_HOST: 0.0.0.0 + ASTERISK_IVR_FASTAGI_PORT: 4573 + + api-gateway: + <<: *service_defaults + depends_on: + - auth-service + - audit-service + - customer-service + - interaction-service + - routing-service + - voice-adapter-service + - recording-service + - ivr-service + - telegram-adapter-service + - whatsapp-adapter-service + - ai-orchestrator-service + - ai-voice-runtime-service + - webchat-adapter-service + - email-adapter-service + - kb-service + - reporting-service + - supervisor-service + - event-bus-service + - asterisk-bridge-service + environment: + <<: *app_env + APP_MODULE: gateway.app:app + ports: + - "18080:8000" + +networks: + postgres: + external: true + name: call-center-postgres_default diff --git a/deployment/docker-compose.postgres.server.yml b/deployment/docker-compose.postgres.server.yml new file mode 100644 index 0000000..253b8cb --- /dev/null +++ b/deployment/docker-compose.postgres.server.yml @@ -0,0 +1,21 @@ +name: call-center-postgres + +services: + postgres: + container_name: call-center-postgres + image: postgres:16-alpine + restart: unless-stopped + env_file: + - ../.env.postgres.server + ports: + - "127.0.0.1:5434:5432" + volumes: + - ../.data_pg:/var/lib/postgresql/data + healthcheck: + test: + - CMD-SHELL + - pg_isready -U "$$POSTGRES_USER" -d "$$POSTGRES_DB" + interval: 10s + timeout: 5s + retries: 12 + start_period: 15s diff --git a/deployment/docker-compose.server.registry.yml b/deployment/docker-compose.server.registry.yml new file mode 100644 index 0000000..887aee3 --- /dev/null +++ b/deployment/docker-compose.server.registry.yml @@ -0,0 +1,201 @@ +name: call-center + +x-app-env: &app_env + CC_DATA_DIR: /app/.data_local + DATABASE_URL: sqlite:////app/.data_local/mvp_cc.db + AUTH_SERVICE_URL: http://auth-service:8000 + AUDIT_SERVICE_URL: http://audit-service:8000 + CUSTOMER_SERVICE_URL: http://customer-service:8000 + INTERACTION_SERVICE_URL: http://interaction-service:8000 + ROUTING_SERVICE_URL: http://routing-service:8000 + VOICE_ADAPTER_SERVICE_URL: http://voice-adapter-service:8000 + RECORDING_SERVICE_URL: http://recording-service:8000 + IVR_SERVICE_URL: http://ivr-service:8000 + EVENT_BUS_SERVICE_URL: http://event-bus-service:8000 + ASTERISK_BRIDGE_SERVICE_URL: http://asterisk-bridge-service:8000 + TELEGRAM_ADAPTER_SERVICE_URL: http://telegram-adapter-service:8000 + WHATSAPP_ADAPTER_SERVICE_URL: http://whatsapp-adapter-service:8000 + AI_ORCHESTRATOR_SERVICE_URL: http://ai-orchestrator-service:8000 + AI_VOICE_RUNTIME_SERVICE_URL: http://ai-voice-runtime-service:8000 + WEBCHAT_ADAPTER_SERVICE_URL: http://webchat-adapter-service:8000 + EMAIL_ADAPTER_SERVICE_URL: http://email-adapter-service:8000 + KB_SERVICE_URL: http://kb-service:8000 + REPORTING_SERVICE_URL: http://reporting-service:8000 + SUPERVISOR_SERVICE_URL: http://supervisor-service:8000 + +x-service-defaults: &service_defaults + image: ${APP_IMAGE:?Set APP_IMAGE in deployment/.env.images or shell env} + restart: unless-stopped + env_file: + - ../.env.production + environment: + <<: *app_env + volumes: + - ../.data_local:/app/.data_local + extra_hosts: + - host.docker.internal:host-gateway + healthcheck: + test: + - CMD + - python + - -c + - | + from urllib.request import urlopen + import sys + sys.exit(0 if urlopen("http://127.0.0.1:8000/health", timeout=2).getcode() == 200 else 1) + interval: 15s + timeout: 5s + retries: 10 + start_period: 10s + +services: + auth-service: + <<: *service_defaults + environment: + <<: *app_env + APP_MODULE: services.auth_service.app:app + + audit-service: + <<: *service_defaults + environment: + <<: *app_env + APP_MODULE: services.audit_service.app:app + + customer-service: + <<: *service_defaults + environment: + <<: *app_env + APP_MODULE: services.customer_service.app:app + + interaction-service: + <<: *service_defaults + environment: + <<: *app_env + APP_MODULE: services.interaction_service.app:app + + routing-service: + <<: *service_defaults + environment: + <<: *app_env + APP_MODULE: services.routing_service.app:app + + voice-adapter-service: + <<: *service_defaults + environment: + <<: *app_env + APP_MODULE: services.voice_adapter_service.app:app + + recording-service: + <<: *service_defaults + environment: + <<: *app_env + APP_MODULE: services.recording_service.app:app + CC_RECORDINGS_DIR: /app/.data_local/recordings + RECORDING_MAX_BYTES: 26214400 + + ivr-service: + <<: *service_defaults + environment: + <<: *app_env + APP_MODULE: services.ivr_service.app:app + + telegram-adapter-service: + <<: *service_defaults + environment: + <<: *app_env + APP_MODULE: services.telegram_adapter_service.app:app + + whatsapp-adapter-service: + <<: *service_defaults + environment: + <<: *app_env + APP_MODULE: services.whatsapp_adapter_service.app:app + + ai-orchestrator-service: + <<: *service_defaults + environment: + <<: *app_env + APP_MODULE: services.ai_orchestrator_service.app:app + + ai-voice-runtime-service: + <<: *service_defaults + environment: + <<: *app_env + APP_MODULE: services.ai_voice_runtime_service.app:app + ports: + - "9019:9019" + + webchat-adapter-service: + <<: *service_defaults + environment: + <<: *app_env + APP_MODULE: services.webchat_adapter_service.app:app + + email-adapter-service: + <<: *service_defaults + environment: + <<: *app_env + APP_MODULE: services.email_adapter_service.app:app + + kb-service: + <<: *service_defaults + environment: + <<: *app_env + APP_MODULE: services.kb_service.app:app + + reporting-service: + <<: *service_defaults + environment: + <<: *app_env + APP_MODULE: services.reporting_service.app:app + + supervisor-service: + <<: *service_defaults + environment: + <<: *app_env + APP_MODULE: services.supervisor_service.app:app + + event-bus-service: + <<: *service_defaults + environment: + <<: *app_env + APP_MODULE: services.event_bus_service.app:app + + asterisk-bridge-service: + <<: *service_defaults + environment: + <<: *app_env + APP_MODULE: services.asterisk_bridge_service.app:app + ASTERISK_IVR_FASTAGI_HOST: 0.0.0.0 + ASTERISK_IVR_FASTAGI_PORT: 4573 + ports: + - "4573:4573" + + api-gateway: + <<: *service_defaults + container_name: call-center-api-gateway + depends_on: + - auth-service + - audit-service + - customer-service + - interaction-service + - routing-service + - voice-adapter-service + - recording-service + - ivr-service + - telegram-adapter-service + - whatsapp-adapter-service + - ai-orchestrator-service + - ai-voice-runtime-service + - webchat-adapter-service + - email-adapter-service + - kb-service + - reporting-service + - supervisor-service + - event-bus-service + - asterisk-bridge-service + environment: + <<: *app_env + APP_MODULE: gateway.app:app + ports: + - "8080:8000" diff --git a/deployment/docker-compose.server.yml b/deployment/docker-compose.server.yml new file mode 100644 index 0000000..555322a --- /dev/null +++ b/deployment/docker-compose.server.yml @@ -0,0 +1,203 @@ +name: call-center + +x-app-env: &app_env + CC_DATA_DIR: /app/.data_local + DATABASE_URL: sqlite:////app/.data_local/mvp_cc.db + AUTH_SERVICE_URL: http://auth-service:8000 + AUDIT_SERVICE_URL: http://audit-service:8000 + CUSTOMER_SERVICE_URL: http://customer-service:8000 + INTERACTION_SERVICE_URL: http://interaction-service:8000 + ROUTING_SERVICE_URL: http://routing-service:8000 + VOICE_ADAPTER_SERVICE_URL: http://voice-adapter-service:8000 + RECORDING_SERVICE_URL: http://recording-service:8000 + IVR_SERVICE_URL: http://ivr-service:8000 + EVENT_BUS_SERVICE_URL: http://event-bus-service:8000 + ASTERISK_BRIDGE_SERVICE_URL: http://asterisk-bridge-service:8000 + TELEGRAM_ADAPTER_SERVICE_URL: http://telegram-adapter-service:8000 + WHATSAPP_ADAPTER_SERVICE_URL: http://whatsapp-adapter-service:8000 + AI_ORCHESTRATOR_SERVICE_URL: http://ai-orchestrator-service:8000 + AI_VOICE_RUNTIME_SERVICE_URL: http://ai-voice-runtime-service:8000 + WEBCHAT_ADAPTER_SERVICE_URL: http://webchat-adapter-service:8000 + EMAIL_ADAPTER_SERVICE_URL: http://email-adapter-service:8000 + KB_SERVICE_URL: http://kb-service:8000 + REPORTING_SERVICE_URL: http://reporting-service:8000 + SUPERVISOR_SERVICE_URL: http://supervisor-service:8000 + +x-service-defaults: &service_defaults + build: + context: .. + image: call-center-app:local + restart: unless-stopped + env_file: + - ../.env.production + environment: + <<: *app_env + volumes: + - ../.data_local:/app/.data_local + extra_hosts: + - host.docker.internal:host-gateway + healthcheck: + test: + - CMD + - python + - -c + - | + from urllib.request import urlopen + import sys + sys.exit(0 if urlopen("http://127.0.0.1:8000/health", timeout=2).getcode() == 200 else 1) + interval: 15s + timeout: 5s + retries: 10 + start_period: 10s + +services: + auth-service: + <<: *service_defaults + environment: + <<: *app_env + APP_MODULE: services.auth_service.app:app + + audit-service: + <<: *service_defaults + environment: + <<: *app_env + APP_MODULE: services.audit_service.app:app + + customer-service: + <<: *service_defaults + environment: + <<: *app_env + APP_MODULE: services.customer_service.app:app + + interaction-service: + <<: *service_defaults + environment: + <<: *app_env + APP_MODULE: services.interaction_service.app:app + + routing-service: + <<: *service_defaults + environment: + <<: *app_env + APP_MODULE: services.routing_service.app:app + + voice-adapter-service: + <<: *service_defaults + environment: + <<: *app_env + APP_MODULE: services.voice_adapter_service.app:app + + recording-service: + <<: *service_defaults + environment: + <<: *app_env + APP_MODULE: services.recording_service.app:app + CC_RECORDINGS_DIR: /app/.data_local/recordings + RECORDING_MAX_BYTES: 26214400 + + ivr-service: + <<: *service_defaults + environment: + <<: *app_env + APP_MODULE: services.ivr_service.app:app + + telegram-adapter-service: + <<: *service_defaults + environment: + <<: *app_env + APP_MODULE: services.telegram_adapter_service.app:app + + whatsapp-adapter-service: + <<: *service_defaults + environment: + <<: *app_env + APP_MODULE: services.whatsapp_adapter_service.app:app + + ai-orchestrator-service: + <<: *service_defaults + environment: + <<: *app_env + APP_MODULE: services.ai_orchestrator_service.app:app + + ai-voice-runtime-service: + <<: *service_defaults + environment: + <<: *app_env + APP_MODULE: services.ai_voice_runtime_service.app:app + ports: + - "9019:9019" + + webchat-adapter-service: + <<: *service_defaults + environment: + <<: *app_env + APP_MODULE: services.webchat_adapter_service.app:app + + email-adapter-service: + <<: *service_defaults + environment: + <<: *app_env + APP_MODULE: services.email_adapter_service.app:app + + kb-service: + <<: *service_defaults + environment: + <<: *app_env + APP_MODULE: services.kb_service.app:app + + reporting-service: + <<: *service_defaults + environment: + <<: *app_env + APP_MODULE: services.reporting_service.app:app + + supervisor-service: + <<: *service_defaults + environment: + <<: *app_env + APP_MODULE: services.supervisor_service.app:app + + event-bus-service: + <<: *service_defaults + environment: + <<: *app_env + APP_MODULE: services.event_bus_service.app:app + + asterisk-bridge-service: + <<: *service_defaults + environment: + <<: *app_env + APP_MODULE: services.asterisk_bridge_service.app:app + ASTERISK_IVR_FASTAGI_HOST: 0.0.0.0 + ASTERISK_IVR_FASTAGI_PORT: 4573 + ports: + - "4573:4573" + + api-gateway: + <<: *service_defaults + container_name: call-center-api-gateway + depends_on: + - auth-service + - audit-service + - customer-service + - interaction-service + - routing-service + - voice-adapter-service + - recording-service + - ivr-service + - telegram-adapter-service + - whatsapp-adapter-service + - ai-orchestrator-service + - ai-voice-runtime-service + - webchat-adapter-service + - email-adapter-service + - kb-service + - reporting-service + - supervisor-service + - event-bus-service + - asterisk-bridge-service + environment: + <<: *app_env + APP_MODULE: gateway.app:app + ports: + - "8080:8000" diff --git a/deployment/docker-compose.yml b/deployment/docker-compose.yml new file mode 100644 index 0000000..a8efc53 --- /dev/null +++ b/deployment/docker-compose.yml @@ -0,0 +1,325 @@ + +x-app-env: &app_env + CC_DATA_DIR: /app/.data + DATABASE_URL: postgresql://mvp:mvp@postgres:5432/mvpcc + SCHEMA_MANAGEMENT_MODE: migrations + EVENT_BUS_ENABLED: "1" + EVENT_BUS_URL: amqp://guest:guest@rabbitmq:5672/ + EVENT_BUS_EXCHANGE: mvpcc.domain.events + EVENT_BUS_DISPATCH_BATCH_SIZE: "50" + EVENT_BUS_MAX_ATTEMPTS: "5" + EVENT_BUS_POLL_INTERVAL_SECONDS: "2" + EVENT_BUS_CONSUMER_ENABLED: "1" + EVENT_BUS_AUDIT_QUEUE: mvpcc.audit.events + EVENT_BUS_REPORTING_QUEUE: mvpcc.reporting.events + ASTERISK_BRIDGE_ENABLED: "0" + ASTERISK_AMI_EVENT_PREFIX: MVPCC + ASTERISK_QUEUE_MAP_JSON: "{}" + AI_VOICE_RUNTIME_SERVICE_URL: http://ai-voice-runtime-service:8000 + AI_VOICE_ENABLED: "0" + AI_VOICE_QUEUE_CONFIG_JSON: "{}" + AI_VOICE_ASR_PROVIDER: openai + AI_VOICE_TTS_PROVIDER: openai + AI_VOICE_AUDIOSOCKET_ENABLED: "1" + AI_VOICE_AUDIOSOCKET_HOST: 0.0.0.0 + AI_VOICE_AUDIOSOCKET_PORT: "9019" + AI_VOICE_ASR_MODEL: gpt-4o-mini-transcribe + AI_VOICE_TTS_MODEL: gpt-4o-mini-tts + AI_VOICE_TTS_VOICE: alloy + AI_VOICE_TTS_CACHE_ENABLED: "1" + AI_VOICE_TTS_CACHE_DIR: /app/.data/ai_voice_tts_cache + AI_VOICE_VAD_MIN_SPEECH_MS: "300" + AI_VOICE_VAD_TRAILING_SILENCE_MS: "700" + AI_VOICE_TURN_MAX_MS: "10000" + AI_VOICE_MEDIA_IDLE_TIMEOUT_SECONDS: "15" + AI_VOICE_MAX_CONTEXT_SEGMENTS: "8" + AI_VOICE_HANDOFF_TIMEOUT_SECONDS: "8" + AI_VOICE_RUNTIME_TRUSTED_SERVICE_SUBJECTS: svc:ai-voice-runtime + +services: + postgres: + image: postgres:16-alpine + environment: + POSTGRES_DB: mvpcc + POSTGRES_USER: mvp + POSTGRES_PASSWORD: mvp + volumes: + - pgdata:/var/lib/postgresql/data + ports: + - "5432:5432" + + rabbitmq: + image: rabbitmq:3-management + ports: + - "5672:5672" + - "15672:15672" + + auth-service: + build: .. + depends_on: [postgres, rabbitmq] + environment: + <<: *app_env + APP_MODULE: services.auth_service.app:app + volumes: + - ../.data:/app/.data + ports: + - "8001:8000" + + audit-service: + build: .. + depends_on: [postgres, rabbitmq] + environment: + <<: *app_env + APP_MODULE: services.audit_service.app:app + volumes: + - ../.data:/app/.data + ports: + - "8002:8000" + + customer-service: + build: .. + depends_on: [postgres, rabbitmq] + environment: + <<: *app_env + APP_MODULE: services.customer_service.app:app + volumes: + - ../.data:/app/.data + ports: + - "8003:8000" + + interaction-service: + build: .. + depends_on: [postgres, rabbitmq] + environment: + <<: *app_env + APP_MODULE: services.interaction_service.app:app + volumes: + - ../.data:/app/.data + ports: + - "8004:8000" + + routing-service: + build: .. + depends_on: [postgres, rabbitmq] + environment: + <<: *app_env + APP_MODULE: services.routing_service.app:app + volumes: + - ../.data:/app/.data + ports: + - "8005:8000" + + voice-adapter-service: + build: .. + depends_on: [postgres, rabbitmq] + environment: + <<: *app_env + APP_MODULE: services.voice_adapter_service.app:app + volumes: + - ../.data:/app/.data + ports: + - "8006:8000" + + recording-service: + build: .. + depends_on: [postgres, rabbitmq] + environment: + <<: *app_env + APP_MODULE: services.recording_service.app:app + CC_RECORDINGS_DIR: /app/.data/recordings + RECORDING_MAX_BYTES: 26214400 + volumes: + - ../.data:/app/.data + ports: + - "8013:8000" + + ivr-service: + build: .. + depends_on: [postgres, rabbitmq] + environment: + <<: *app_env + APP_MODULE: services.ivr_service.app:app + volumes: + - ../.data:/app/.data + ports: + - "8014:8000" + + telegram-adapter-service: + build: .. + depends_on: [postgres, rabbitmq] + environment: + <<: *app_env + APP_MODULE: services.telegram_adapter_service.app:app + volumes: + - ../.data:/app/.data + ports: + - "8007:8000" + + whatsapp-adapter-service: + build: .. + depends_on: [postgres, rabbitmq] + environment: + <<: *app_env + APP_MODULE: services.whatsapp_adapter_service.app:app + volumes: + - ../.data:/app/.data + ports: + - "8019:8000" + + ai-orchestrator-service: + build: .. + depends_on: [postgres, rabbitmq] + environment: + <<: *app_env + APP_MODULE: services.ai_orchestrator_service.app:app + TELEGRAM_ADAPTER_SERVICE_URL: http://telegram-adapter-service:8000 + WHATSAPP_ADAPTER_SERVICE_URL: http://whatsapp-adapter-service:8000 + INTERACTION_SERVICE_URL: http://interaction-service:8000 + volumes: + - ../.data:/app/.data + ports: + - "8017:8000" + + ai-voice-runtime-service: + build: .. + depends_on: [postgres, rabbitmq] + environment: + <<: *app_env + APP_MODULE: services.ai_voice_runtime_service.app:app + AI_ORCHESTRATOR_SERVICE_URL: http://ai-orchestrator-service:8000 + ASTERISK_BRIDGE_SERVICE_URL: http://asterisk-bridge-service:8000 + volumes: + - ../.data:/app/.data + ports: + - "8018:8000" + - "9019:9019" + + webchat-adapter-service: + build: .. + depends_on: [postgres, rabbitmq] + environment: + <<: *app_env + APP_MODULE: services.webchat_adapter_service.app:app + volumes: + - ../.data:/app/.data + ports: + - "8011:8000" + + email-adapter-service: + build: .. + depends_on: [postgres, rabbitmq] + environment: + <<: *app_env + APP_MODULE: services.email_adapter_service.app:app + volumes: + - ../.data:/app/.data + ports: + - "8012:8000" + + kb-service: + build: .. + depends_on: [postgres, rabbitmq] + environment: + <<: *app_env + APP_MODULE: services.kb_service.app:app + volumes: + - ../.data:/app/.data + ports: + - "8008:8000" + + reporting-service: + build: .. + depends_on: [postgres, rabbitmq] + environment: + <<: *app_env + APP_MODULE: services.reporting_service.app:app + volumes: + - ../.data:/app/.data + ports: + - "8009:8000" + + supervisor-service: + build: .. + depends_on: [postgres, rabbitmq] + environment: + <<: *app_env + APP_MODULE: services.supervisor_service.app:app + volumes: + - ../.data:/app/.data + ports: + - "8010:8000" + + event-bus-service: + build: .. + depends_on: [postgres, rabbitmq] + environment: + <<: *app_env + APP_MODULE: services.event_bus_service.app:app + volumes: + - ../.data:/app/.data + ports: + - "8015:8000" + + asterisk-bridge-service: + build: .. + depends_on: [postgres, rabbitmq] + environment: + <<: *app_env + APP_MODULE: services.asterisk_bridge_service.app:app + INTERACTION_SERVICE_URL: http://interaction-service:8000 + VOICE_ADAPTER_SERVICE_URL: http://voice-adapter-service:8000 + RECORDING_SERVICE_URL: http://recording-service:8000 + IVR_SERVICE_URL: http://ivr-service:8000 + volumes: + - ../.data:/app/.data + ports: + - "8016:8000" + - "4573:4573" + + api-gateway: + build: .. + depends_on: + - auth-service + - audit-service + - customer-service + - interaction-service + - routing-service + - voice-adapter-service + - recording-service + - ivr-service + - telegram-adapter-service + - whatsapp-adapter-service + - ai-orchestrator-service + - ai-voice-runtime-service + - webchat-adapter-service + - email-adapter-service + - kb-service + - reporting-service + - supervisor-service + - event-bus-service + - asterisk-bridge-service + environment: + APP_MODULE: gateway.app:app + AUTH_SERVICE_URL: http://auth-service:8000 + AUDIT_SERVICE_URL: http://audit-service:8000 + CUSTOMER_SERVICE_URL: http://customer-service:8000 + INTERACTION_SERVICE_URL: http://interaction-service:8000 + ROUTING_SERVICE_URL: http://routing-service:8000 + VOICE_ADAPTER_SERVICE_URL: http://voice-adapter-service:8000 + RECORDING_SERVICE_URL: http://recording-service:8000 + IVR_SERVICE_URL: http://ivr-service:8000 + EVENT_BUS_SERVICE_URL: http://event-bus-service:8000 + ASTERISK_BRIDGE_SERVICE_URL: http://asterisk-bridge-service:8000 + TELEGRAM_ADAPTER_SERVICE_URL: http://telegram-adapter-service:8000 + WHATSAPP_ADAPTER_SERVICE_URL: http://whatsapp-adapter-service:8000 + AI_ORCHESTRATOR_SERVICE_URL: http://ai-orchestrator-service:8000 + WEBCHAT_ADAPTER_SERVICE_URL: http://webchat-adapter-service:8000 + EMAIL_ADAPTER_SERVICE_URL: http://email-adapter-service:8000 + KB_SERVICE_URL: http://kb-service:8000 + REPORTING_SERVICE_URL: http://reporting-service:8000 + SUPERVISOR_SERVICE_URL: http://supervisor-service:8000 + ports: + - "8080:8000" + +volumes: + pgdata: diff --git a/deployment/helm/Chart.yaml b/deployment/helm/Chart.yaml new file mode 100644 index 0000000..27afc70 --- /dev/null +++ b/deployment/helm/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: mvp-cc-platform +description: Helm chart for MVP Contact Center microservices +type: application +version: 0.1.0 +appVersion: "1.0.0" diff --git a/deployment/helm/templates/migration-job.yaml b/deployment/helm/templates/migration-job.yaml new file mode 100644 index 0000000..5777352 --- /dev/null +++ b/deployment/helm/templates/migration-job.yaml @@ -0,0 +1,48 @@ +{{- $databaseUrl := $.Values.global.databaseUrl | default "" }} +{{- $schemaManagementMode := $.Values.global.schemaManagementMode | default "legacy" }} +{{- $runMigrations := and $.Values.migrations.enabled (eq $schemaManagementMode "migrations") (or $.Values.profiles.scale500.enabled $databaseUrl) }} +{{- if $runMigrations }} +apiVersion: batch/v1 +kind: Job +metadata: + name: {{ .Release.Name }}-db-migrate + namespace: {{ $.Values.namespace }} + annotations: + "helm.sh/hook": pre-install,pre-upgrade + "helm.sh/hook-weight": "-10" + "helm.sh/hook-delete-policy": "{{ $.Values.migrations.hookDeletePolicy }}" +spec: + backoffLimit: {{ $.Values.migrations.backoffLimit }} + activeDeadlineSeconds: {{ $.Values.migrations.activeDeadlineSeconds }} + ttlSecondsAfterFinished: {{ $.Values.migrations.ttlSecondsAfterFinished }} + template: + metadata: + labels: + app: {{ .Release.Name }}-db-migrate + spec: + restartPolicy: Never + {{- if $.Values.global.runtimeClass }} + runtimeClassName: {{ $.Values.global.runtimeClass }} + {{- end }} + containers: + - name: migrate + image: "{{ $.Values.image.repository }}:{{ $.Values.image.tag }}" + imagePullPolicy: {{ $.Values.global.imagePullPolicy }} + command: + - python + - scripts/migrate_core_db.py + env: + - name: CC_DATA_DIR + value: /var/lib/mvp-cc/data + - name: DATABASE_URL + value: "{{ if $.Values.profiles.scale500.enabled }}{{ required "global.databaseUrl is required when profiles.scale500.enabled=true" $.Values.global.databaseUrl }}{{ else }}{{ $databaseUrl }}{{ end }}" + - name: SCHEMA_MANAGEMENT_MODE + value: "{{ $schemaManagementMode }}" + resources: + requests: + cpu: {{ $.Values.migrations.resources.requests.cpu }} + memory: {{ $.Values.migrations.resources.requests.memory }} + limits: + cpu: {{ $.Values.migrations.resources.limits.cpu }} + memory: {{ $.Values.migrations.resources.limits.memory }} +{{- end }} diff --git a/deployment/helm/templates/namespace.yaml b/deployment/helm/templates/namespace.yaml new file mode 100644 index 0000000..aa4b5e1 --- /dev/null +++ b/deployment/helm/templates/namespace.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: {{ .Values.namespace }} diff --git a/deployment/helm/templates/rabbitmq.yaml b/deployment/helm/templates/rabbitmq.yaml new file mode 100644 index 0000000..5bcbe05 --- /dev/null +++ b/deployment/helm/templates/rabbitmq.yaml @@ -0,0 +1,46 @@ +{{- if .Values.rabbitmq.enabled }} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: rabbitmq + namespace: {{ .Values.namespace }} +spec: + replicas: {{ .Values.rabbitmq.replicas }} + selector: + matchLabels: + app: rabbitmq + template: + metadata: + labels: + app: rabbitmq + spec: + containers: + - name: rabbitmq + image: {{ .Values.rabbitmq.image }} + env: + - name: RABBITMQ_DEFAULT_USER + value: "{{ .Values.rabbitmq.user }}" + - name: RABBITMQ_DEFAULT_PASS + value: "{{ .Values.rabbitmq.password }}" + ports: + - name: amqp + containerPort: {{ .Values.rabbitmq.servicePort }} + - name: management + containerPort: {{ .Values.rabbitmq.managementPort }} +--- +apiVersion: v1 +kind: Service +metadata: + name: rabbitmq + namespace: {{ .Values.namespace }} +spec: + selector: + app: rabbitmq + ports: + - name: amqp + port: {{ .Values.rabbitmq.servicePort }} + targetPort: {{ .Values.rabbitmq.servicePort }} + - name: management + port: {{ .Values.rabbitmq.managementPort }} + targetPort: {{ .Values.rabbitmq.managementPort }} +{{- end }} diff --git a/deployment/helm/templates/services.yaml b/deployment/helm/templates/services.yaml new file mode 100644 index 0000000..37ab637 --- /dev/null +++ b/deployment/helm/templates/services.yaml @@ -0,0 +1,404 @@ +{{- range $name, $svc := .Values.services }} +{{- $databaseUrl := $.Values.global.databaseUrl | default "" }} +{{- $schemaManagementMode := $.Values.global.schemaManagementMode | default "legacy" }} +{{- $isGateway := default false $svc.gateway }} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ $name }} + namespace: {{ $.Values.namespace }} +spec: + replicas: {{ $svc.replicas }} + selector: + matchLabels: + app: {{ $name }} + template: + metadata: + labels: + app: {{ $name }} + spec: + terminationGracePeriodSeconds: 30 + {{- if $.Values.global.runtimeClass }} + runtimeClassName: {{ $.Values.global.runtimeClass }} + {{- end }} + {{- if gt (int $svc.replicas) 1 }} + affinity: + podAntiAffinity: + preferredDuringSchedulingIgnoredDuringExecution: + - weight: 100 + podAffinityTerm: + topologyKey: kubernetes.io/hostname + labelSelector: + matchLabels: + app: {{ $name }} + {{- end }} + containers: + - name: app + image: "{{ $.Values.image.repository }}:{{ $.Values.image.tag }}" + imagePullPolicy: {{ $.Values.global.imagePullPolicy }} + env: + - name: APP_MODULE + value: "{{ $svc.module }}" + - name: CC_DATA_DIR + value: /var/lib/mvp-cc/data + - name: APP_TOKEN_SECRET + value: "{{ $.Values.auth.appTokenSecret }}" + - name: APP_TOKEN_TTL_SECONDS + value: "{{ $.Values.auth.appTokenTtlSeconds }}" + - name: ALLOW_LEGACY_HEADER_AUTH + value: "{{ $.Values.auth.allowLegacyHeaderAuth }}" + {{- if or $.Values.profiles.scale500.enabled $databaseUrl }} + - name: DATABASE_URL + value: "{{ if $.Values.profiles.scale500.enabled }}{{ required "global.databaseUrl is required when profiles.scale500.enabled=true" $.Values.global.databaseUrl }}{{ else }}{{ $databaseUrl }}{{ end }}" + {{- end }} + - name: SCHEMA_MANAGEMENT_MODE + value: "{{ $schemaManagementMode }}" + - name: DB_POOL_SIZE + value: "{{ $.Values.db.poolSize }}" + - name: DB_MAX_OVERFLOW + value: "{{ $.Values.db.maxOverflow }}" + - name: DB_POOL_TIMEOUT_SECONDS + value: "{{ $.Values.db.poolTimeoutSeconds }}" + - name: DB_POOL_RECYCLE_SECONDS + value: "{{ $.Values.db.poolRecycleSeconds }}" + - name: EVENT_BUS_ENABLED + value: "{{ $.Values.eventBus.enabled }}" + - name: EVENT_BUS_URL + value: "{{ $.Values.eventBus.url }}" + - name: EVENT_BUS_EXCHANGE + value: "{{ $.Values.eventBus.exchange }}" + - name: EVENT_BUS_DISPATCH_BATCH_SIZE + value: "{{ $.Values.eventBus.dispatchBatchSize }}" + - name: EVENT_BUS_MAX_ATTEMPTS + value: "{{ $.Values.eventBus.maxAttempts }}" + - name: EVENT_BUS_POLL_INTERVAL_SECONDS + value: "{{ $.Values.eventBus.pollIntervalSeconds }}" + - name: EVENT_BUS_CONSUMER_ENABLED + value: "{{ $.Values.eventBus.consumerEnabled }}" + - name: EVENT_BUS_AUDIT_QUEUE + value: "{{ $.Values.eventBus.auditQueue }}" + - name: EVENT_BUS_REPORTING_QUEUE + value: "{{ $.Values.eventBus.reportingQueue }}" + {{- if eq $name "asterisk-bridge-service" }} + - name: ASTERISK_BRIDGE_ENABLED + value: "{{ $.Values.asteriskBridge.enabled }}" + - name: ASTERISK_AMI_HOST + value: "{{ $.Values.asteriskBridge.amiHost }}" + - name: ASTERISK_AMI_PORT + value: "{{ $.Values.asteriskBridge.amiPort }}" + - name: ASTERISK_AMI_USERNAME + value: "{{ $.Values.asteriskBridge.amiUsername }}" + - name: ASTERISK_AMI_SECRET + value: "{{ $.Values.asteriskBridge.amiSecret }}" + - name: ASTERISK_AMI_EVENT_PREFIX + value: "{{ $.Values.asteriskBridge.amiEventPrefix }}" + - name: ASTERISK_QUEUE_MAP_JSON + value: '{{ $.Values.asteriskBridge.queueMapJson }}' + - name: ASTERISK_SFTP_HOST + value: "{{ $.Values.asteriskBridge.sftpHost }}" + - name: ASTERISK_SFTP_PORT + value: "{{ $.Values.asteriskBridge.sftpPort }}" + - name: ASTERISK_SFTP_USERNAME + value: "{{ $.Values.asteriskBridge.sftpUsername }}" + - name: ASTERISK_SFTP_PASSWORD + value: "{{ $.Values.asteriskBridge.sftpPassword }}" + - name: ASTERISK_SFTP_BASE_PATH + value: "{{ $.Values.asteriskBridge.sftpBasePath }}" + - name: ASTERISK_BRIDGE_POLL_INTERVAL_SECONDS + value: "{{ $.Values.asteriskBridge.pollIntervalSeconds }}" + - name: ASTERISK_BRIDGE_AUTH_MODE + value: "{{ $.Values.asteriskBridge.authMode }}" + - name: ASTERISK_BRIDGE_AUTH_FALLBACK_LEGACY + value: "{{ $.Values.asteriskBridge.authFallbackLegacy }}" + - name: ASTERISK_BRIDGE_AUTH_SUBJECT + value: "{{ $.Values.asteriskBridge.authSubject }}" + - name: ASTERISK_BRIDGE_AUTH_USER + value: "{{ $.Values.asteriskBridge.authUser }}" + - name: ASTERISK_BRIDGE_AUTH_ROLE + value: "{{ $.Values.asteriskBridge.authRole }}" + - name: ASTERISK_BRIDGE_AUTH_TOKEN_TTL_SECONDS + value: "{{ $.Values.asteriskBridge.authTokenTtlSeconds }}" + - name: ASTERISK_IVR_FASTAGI_ENABLED + value: "{{ $.Values.asteriskBridge.ivrFastagiEnabled }}" + - name: ASTERISK_IVR_FASTAGI_HOST + value: "{{ $.Values.asteriskBridge.ivrFastagiHost }}" + - name: ASTERISK_IVR_FASTAGI_PORT + value: "{{ $.Values.asteriskBridge.ivrFastagiPort }}" + - name: ASTERISK_IVR_DTMF_TIMEOUT_SECONDS + value: "{{ $.Values.asteriskBridge.ivrDtmfTimeoutSeconds }}" + - name: ASTERISK_IVR_MAX_NO_INPUT_RETRIES + value: "{{ $.Values.asteriskBridge.ivrMaxNoInputRetries }}" + - name: ASTERISK_IVR_MAX_INVALID_RETRIES + value: "{{ $.Values.asteriskBridge.ivrMaxInvalidRetries }}" + - name: ASTERISK_IVR_CALL_LINK_WAIT_SECONDS + value: "{{ $.Values.asteriskBridge.ivrCallLinkWaitSeconds }}" + - name: ASTERISK_CALLCONTROL_ENABLED + value: "{{ $.Values.asteriskBridge.callcontrolEnabled }}" + - name: ASTERISK_CALLCONTROL_ACTION_TIMEOUT_SECONDS + value: "{{ $.Values.asteriskBridge.callcontrolActionTimeoutSeconds }}" + - name: ASTERISK_OPERATOR_EXTENSION_MAP_JSON + value: '{{ $.Values.asteriskBridge.operatorExtensionMapJson }}' + - name: ASTERISK_TRANSFER_TARGET_MAP_JSON + value: '{{ $.Values.asteriskBridge.transferTargetMapJson }}' + - name: ASTERISK_CALLCONTROL_CLAIM_CONTEXT + value: "{{ $.Values.asteriskBridge.claimContext }}" + - name: ASTERISK_CALLCONTROL_TRANSFER_CONTEXT + value: "{{ $.Values.asteriskBridge.transferContext }}" + - name: AI_VOICE_RUNTIME_SERVICE_URL + value: http://ai-voice-runtime-service:8000 + - name: AI_VOICE_ENABLED + value: "{{ $.Values.voiceAI.enabled }}" + - name: AI_VOICE_QUEUE_CONFIG_JSON + value: '{{ $.Values.voiceAI.queueConfigJson }}' + - name: AI_VOICE_RUNTIME_TRUSTED_SERVICE_SUBJECTS + value: "{{ $.Values.voiceAI.runtimeTrustedServiceSubjects }}" + - name: INTERACTION_SERVICE_URL + value: http://interaction-service:8000 + - name: VOICE_ADAPTER_SERVICE_URL + value: http://voice-adapter-service:8000 + - name: RECORDING_SERVICE_URL + value: http://recording-service:8000 + - name: IVR_SERVICE_URL + value: http://ivr-service:8000 + {{- end }} + {{- if eq $name "ivr-service" }} + - name: IVR_RUNTIME_TRUSTED_SERVICE_SUBJECTS + value: "{{ $.Values.ivrRuntime.trustedServiceSubjects }}" + {{- end }} + {{- if eq $name "ai-orchestrator-service" }} + - name: INTERACTION_SERVICE_URL + value: http://interaction-service:8000 + - name: TELEGRAM_ADAPTER_SERVICE_URL + value: http://telegram-adapter-service:8000 + - name: AI_VOICE_MAX_CONTEXT_SEGMENTS + value: "{{ $.Values.voiceAI.maxContextSegments }}" + {{- end }} + {{- if eq $name "ai-voice-runtime-service" }} + - name: AI_ORCHESTRATOR_SERVICE_URL + value: http://ai-orchestrator-service:8000 + - name: ASTERISK_BRIDGE_SERVICE_URL + value: http://asterisk-bridge-service:8000 + - name: AI_VOICE_ASR_PROVIDER + value: "{{ $.Values.voiceAI.asrProvider }}" + - name: AI_VOICE_TTS_PROVIDER + value: "{{ $.Values.voiceAI.ttsProvider }}" + - name: AI_VOICE_AUDIOSOCKET_ENABLED + value: "{{ $.Values.voiceAI.audioSocketEnabled }}" + - name: AI_VOICE_AUDIOSOCKET_HOST + value: "{{ $.Values.voiceAI.audioSocketHost }}" + - name: AI_VOICE_AUDIOSOCKET_PORT + value: "{{ $.Values.voiceAI.audioSocketPort }}" + - name: AI_VOICE_ASR_MODEL + value: "{{ $.Values.voiceAI.asrModel }}" + - name: AI_VOICE_TTS_MODEL + value: "{{ $.Values.voiceAI.ttsModel }}" + - name: AI_VOICE_TTS_VOICE + value: "{{ $.Values.voiceAI.ttsVoice }}" + - name: AI_VOICE_TTS_CACHE_ENABLED + value: "{{ $.Values.voiceAI.ttsCacheEnabled }}" + - name: AI_VOICE_TTS_CACHE_DIR + value: "{{ $.Values.voiceAI.ttsCacheDir }}" + - name: AI_VOICE_VAD_MIN_SPEECH_MS + value: "{{ $.Values.voiceAI.vadMinSpeechMs }}" + - name: AI_VOICE_VAD_TRAILING_SILENCE_MS + value: "{{ $.Values.voiceAI.vadTrailingSilenceMs }}" + - name: AI_VOICE_TURN_MAX_MS + value: "{{ $.Values.voiceAI.turnMaxMs }}" + - name: AI_VOICE_MEDIA_IDLE_TIMEOUT_SECONDS + value: "{{ $.Values.voiceAI.mediaIdleTimeoutSeconds }}" + - name: AI_VOICE_HANDOFF_TIMEOUT_SECONDS + value: "{{ $.Values.voiceAI.handoffTimeoutSeconds }}" + {{- end }} + {{- if eq $name "recording-service" }} + - name: CC_RECORDINGS_DIR + value: /var/lib/mvp-cc/data/recordings + - name: RECORDING_MAX_BYTES + value: "26214400" + - name: RECORDING_IMPORT_TRUSTED_SERVICE_SUBJECTS + value: "{{ $.Values.recording.importTrustedServiceSubjects }}" + - name: RECORDING_IMPORT_ALLOW_ADMIN + value: "{{ $.Values.recording.importAllowAdmin }}" + {{- end }} + {{- if eq $name "voice-adapter-service" }} + - name: VOICE_ADAPTER_TRUSTED_SERVICE_SUBJECTS + value: "{{ $.Values.voiceAdapter.trustedServiceSubjects }}" + {{- end }} + {{- if eq $name "auth-service" }} + - name: OIDC_ENABLED + value: "{{ $.Values.auth.oidc.enabled }}" + - name: OIDC_PROVIDER + value: "{{ $.Values.auth.oidc.provider }}" + - name: OIDC_ISSUER_URL + value: "{{ $.Values.auth.oidc.issuerUrl }}" + - name: OIDC_CLIENT_ID + value: "{{ $.Values.auth.oidc.clientId }}" + - name: OIDC_CLIENT_SECRET + value: "{{ $.Values.auth.oidc.clientSecret }}" + - name: OIDC_REDIRECT_URI + value: "{{ $.Values.auth.oidc.redirectUri }}" + - name: OIDC_SCOPES + value: "{{ $.Values.auth.oidc.scopes }}" + - name: OIDC_ROLE_CLAIM + value: "{{ $.Values.auth.oidc.roleClaim }}" + - name: OIDC_ROLE_MAP_JSON + value: '{{ $.Values.auth.oidc.roleMapJson }}' + {{- end }} + {{- if $isGateway }} + - name: AUTH_SERVICE_URL + value: http://auth-service:8000 + - name: AUDIT_SERVICE_URL + value: http://audit-service:8000 + - name: CUSTOMER_SERVICE_URL + value: http://customer-service:8000 + - name: INTERACTION_SERVICE_URL + value: http://interaction-service:8000 + - name: ROUTING_SERVICE_URL + value: http://routing-service:8000 + - name: VOICE_ADAPTER_SERVICE_URL + value: http://voice-adapter-service:8000 + - name: RECORDING_SERVICE_URL + value: http://recording-service:8000 + - name: IVR_SERVICE_URL + value: http://ivr-service:8000 + - name: EVENT_BUS_SERVICE_URL + value: http://event-bus-service:8000 + - name: ASTERISK_BRIDGE_SERVICE_URL + value: http://asterisk-bridge-service:8000 + - name: TELEGRAM_ADAPTER_SERVICE_URL + value: http://telegram-adapter-service:8000 + - name: AI_ORCHESTRATOR_SERVICE_URL + value: http://ai-orchestrator-service:8000 + - name: AI_VOICE_RUNTIME_SERVICE_URL + value: http://ai-voice-runtime-service:8000 + - name: WEBCHAT_ADAPTER_SERVICE_URL + value: http://webchat-adapter-service:8000 + - name: EMAIL_ADAPTER_SERVICE_URL + value: http://email-adapter-service:8000 + - name: KB_SERVICE_URL + value: http://kb-service:8000 + - name: REPORTING_SERVICE_URL + value: http://reporting-service:8000 + - name: SUPERVISOR_SERVICE_URL + value: http://supervisor-service:8000 + {{- end }} + ports: + - containerPort: 8000 + {{- if eq $name "ai-voice-runtime-service" }} + - containerPort: 9019 + {{- end }} + {{- if eq $name "asterisk-bridge-service" }} + - containerPort: 4573 + {{- end }} + resources: + requests: + cpu: {{ $svc.resources.requests.cpu }} + memory: {{ $svc.resources.requests.memory }} + limits: + cpu: {{ $svc.resources.limits.cpu }} + memory: {{ $svc.resources.limits.memory }} + startupProbe: + httpGet: + path: /health + port: 8000 + failureThreshold: 30 + periodSeconds: 5 + readinessProbe: + httpGet: + path: /health + port: 8000 + initialDelaySeconds: 5 + periodSeconds: 10 + timeoutSeconds: 2 + livenessProbe: + httpGet: + path: /health + port: 8000 + initialDelaySeconds: 20 + periodSeconds: 15 + timeoutSeconds: 2 + {{- if $svc.persistence.enabled }} + volumeMounts: + - name: app-data + mountPath: /var/lib/mvp-cc/data + {{- end }} + {{- if $svc.persistence.enabled }} + volumes: + - name: app-data + persistentVolumeClaim: + claimName: {{ $name }}-data + {{- end }} +--- +apiVersion: v1 +kind: Service +metadata: + name: {{ $name }} + namespace: {{ $.Values.namespace }} +spec: + selector: + app: {{ $name }} + ports: + - name: http + port: 8000 + targetPort: 8000 + {{- if eq $name "ai-voice-runtime-service" }} + - name: audiosocket + port: 9019 + targetPort: 9019 + {{- end }} + {{- if eq $name "asterisk-bridge-service" }} + - name: fastagi + port: 4573 + targetPort: 4573 + {{- end }} + type: {{ if $isGateway }}LoadBalancer{{ else }}ClusterIP{{ end }} +--- +{{- if $svc.persistence.enabled }} +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: {{ $name }}-data + namespace: {{ $.Values.namespace }} +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: {{ $svc.persistence.size }} +--- +{{- end }} +{{- if and $svc.podDisruptionBudget.enabled (gt (int $svc.replicas) 1) }} +apiVersion: policy/v1 +kind: PodDisruptionBudget +metadata: + name: {{ $name }}-pdb + namespace: {{ $.Values.namespace }} +spec: + minAvailable: {{ $svc.podDisruptionBudget.minAvailable }} + selector: + matchLabels: + app: {{ $name }} +--- +{{- end }} +{{- if $svc.autoscaling.enabled }} +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: {{ $name }}-hpa + namespace: {{ $.Values.namespace }} +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: {{ $name }} + minReplicas: {{ $svc.autoscaling.minReplicas }} + maxReplicas: {{ $svc.autoscaling.maxReplicas }} + metrics: + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: {{ $svc.autoscaling.targetCPUUtilizationPercentage }} +--- +{{- end }} +{{- end }} diff --git a/deployment/helm/values.scale500.yaml b/deployment/helm/values.scale500.yaml new file mode 100644 index 0000000..02ee54d --- /dev/null +++ b/deployment/helm/values.scale500.yaml @@ -0,0 +1,12 @@ +profiles: + scale500: + enabled: true + +auth: + allowLegacyHeaderAuth: "0" + +db: + poolSize: "15" + maxOverflow: "30" + poolTimeoutSeconds: "30" + poolRecycleSeconds: "1800" diff --git a/deployment/helm/values.track9-live.yaml b/deployment/helm/values.track9-live.yaml new file mode 100644 index 0000000..6392af0 --- /dev/null +++ b/deployment/helm/values.track9-live.yaml @@ -0,0 +1,77 @@ +auth: + allowLegacyHeaderAuth: "0" + +eventBus: + enabled: "0" + url: amqp://guest:guest@rabbitmq:5672/ + exchange: mvpcc.domain.events + dispatchBatchSize: "50" + maxAttempts: "5" + pollIntervalSeconds: "2" + consumerEnabled: "1" + auditQueue: mvpcc.audit.events + reportingQueue: mvpcc.reporting.events + +rabbitmq: + enabled: false + image: rabbitmq:3-management + replicas: 1 + user: guest + password: guest + servicePort: 5672 + managementPort: 15672 + +asteriskBridge: + enabled: "1" + amiEventPrefix: MVPCC + queueMapJson: '{"voice_lab":""}' + authMode: bearer + authFallbackLegacy: "0" + authSubject: svc:asterisk-bridge + authUser: asterisk-bridge + authRole: admin + authTokenTtlSeconds: "300" + callcontrolEnabled: "0" + callcontrolActionTimeoutSeconds: "10" + operatorExtensionMapJson: "{}" + transferTargetMapJson: "{}" + claimContext: mvpcc-claim + transferContext: mvpcc-transfer + pollIntervalSeconds: "1" + +voiceAdapter: + trustedServiceSubjects: svc:asterisk-bridge,svc:ivr-service + +recording: + importTrustedServiceSubjects: svc:asterisk-bridge + importAllowAdmin: "0" + +services: + auth-service: + replicas: 1 + voice-adapter-service: + replicas: 1 + recording-service: + replicas: 1 + asterisk-bridge-service: + module: services.asterisk_bridge_service.app:app + # Must stay singleton: background AMI consumption and retry/reconcile loops are not multi-replica safe. + replicas: 1 + autoscaling: + enabled: false + minReplicas: 1 + maxReplicas: 1 + targetCPUUtilizationPercentage: 70 + podDisruptionBudget: + enabled: false + minAvailable: 1 + persistence: + enabled: false + size: 1Gi + resources: + requests: + cpu: 200m + memory: 256Mi + limits: + cpu: 750m + memory: 512Mi diff --git a/deployment/helm/values.track9-strict.yaml b/deployment/helm/values.track9-strict.yaml new file mode 100644 index 0000000..a55edcd --- /dev/null +++ b/deployment/helm/values.track9-strict.yaml @@ -0,0 +1,18 @@ +auth: + allowLegacyHeaderAuth: "0" + +asteriskBridge: + enabled: "1" + authMode: bearer + authFallbackLegacy: "0" + authSubject: svc:asterisk-bridge + authUser: asterisk-bridge + authRole: admin + authTokenTtlSeconds: "300" + +voiceAdapter: + trustedServiceSubjects: svc:asterisk-bridge,svc:ivr-service + +recording: + importTrustedServiceSubjects: svc:asterisk-bridge + importAllowAdmin: "0" diff --git a/deployment/helm/values.yaml b/deployment/helm/values.yaml new file mode 100644 index 0000000..a4e575a --- /dev/null +++ b/deployment/helm/values.yaml @@ -0,0 +1,538 @@ +namespace: mvp-cc + +image: + repository: mvp-cc-platform + tag: latest + +global: + databaseUrl: postgresql://mvp:mvp@postgresql.mvp-cc.svc.cluster.local:5432/mvpcc + schemaManagementMode: migrations + runtimeClass: "" + imagePullPolicy: IfNotPresent + +migrations: + enabled: true + backoffLimit: 1 + activeDeadlineSeconds: 300 + ttlSecondsAfterFinished: 600 + hookDeletePolicy: before-hook-creation,hook-succeeded + resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: 500m + memory: 256Mi + +profiles: + scale500: + enabled: false + +db: + poolSize: "15" + maxOverflow: "30" + poolTimeoutSeconds: "30" + poolRecycleSeconds: "1800" + +voiceAdapter: + trustedServiceSubjects: svc:asterisk-bridge,svc:ivr-service + +ivrRuntime: + trustedServiceSubjects: svc:asterisk-bridge + +recording: + importTrustedServiceSubjects: svc:asterisk-bridge + importAllowAdmin: "1" + +voiceAI: + enabled: "0" + queueConfigJson: "{}" + asrProvider: openai + ttsProvider: openai + audioSocketEnabled: "1" + audioSocketHost: 0.0.0.0 + audioSocketPort: "9019" + asrModel: gpt-4o-mini-transcribe + ttsModel: gpt-4o-mini-tts + ttsVoice: alloy + ttsCacheEnabled: "1" + ttsCacheDir: /var/lib/mvp-cc/data/ai_voice_tts_cache + vadMinSpeechMs: "300" + vadTrailingSilenceMs: "700" + turnMaxMs: "10000" + mediaIdleTimeoutSeconds: "15" + maxContextSegments: "8" + handoffTimeoutSeconds: "8" + runtimeTrustedServiceSubjects: svc:ai-voice-runtime + +eventBus: + enabled: "0" + url: amqp://guest:guest@rabbitmq:5672/ + exchange: mvpcc.domain.events + dispatchBatchSize: "50" + maxAttempts: "5" + pollIntervalSeconds: "2" + consumerEnabled: "1" + auditQueue: mvpcc.audit.events + reportingQueue: mvpcc.reporting.events + +asteriskBridge: + enabled: "0" + amiHost: "" + amiPort: "5038" + amiUsername: "" + amiSecret: "" + amiEventPrefix: MVPCC + queueMapJson: "{}" + authMode: legacy_headers + authFallbackLegacy: "1" + authSubject: svc:asterisk-bridge + authUser: asterisk-bridge + authRole: admin + authTokenTtlSeconds: "300" + ivrFastagiEnabled: "0" + ivrFastagiHost: 0.0.0.0 + ivrFastagiPort: "4573" + ivrDtmfTimeoutSeconds: "5" + ivrMaxNoInputRetries: "2" + ivrMaxInvalidRetries: "2" + ivrCallLinkWaitSeconds: "5" + callcontrolEnabled: "0" + callcontrolActionTimeoutSeconds: "10" + operatorExtensionMapJson: "{}" + transferTargetMapJson: "{}" + claimContext: mvpcc-claim + transferContext: mvpcc-transfer + sftpHost: "" + sftpPort: "22" + sftpUsername: "" + sftpPassword: "" + sftpBasePath: /var/spool/asterisk/monitor + pollIntervalSeconds: "1" + +rabbitmq: + enabled: false + image: rabbitmq:3-management + replicas: 1 + user: guest + password: guest + servicePort: 5672 + managementPort: 15672 + +auth: + appTokenSecret: change-me-app-token-secret + appTokenTtlSeconds: "3600" + allowLegacyHeaderAuth: "1" + oidc: + enabled: "0" + provider: keycloak + issuerUrl: https://keycloak.example.local/realms/mvp-cc + clientId: mvp-cc + clientSecret: change-me-keycloak-client-secret + redirectUri: https://cc.example.local/proxy/auth/auth/oidc/callback + scopes: openid profile email + roleClaim: groups + roleMapJson: '{"kc_admin":"admin","kc_supervisor":"supervisor","kc_operator":"operator","kc_analyst":"analyst"}' + +services: + auth-service: + module: services.auth_service.app:app + replicas: 2 + resources: + requests: + cpu: 200m + memory: 256Mi + limits: + cpu: 750m + memory: 512Mi + autoscaling: + enabled: false + minReplicas: 2 + maxReplicas: 2 + targetCPUUtilizationPercentage: 70 + podDisruptionBudget: + enabled: true + minAvailable: 1 + persistence: + enabled: false + size: 1Gi + audit-service: + module: services.audit_service.app:app + replicas: 2 + resources: + requests: + cpu: 200m + memory: 256Mi + limits: + cpu: 750m + memory: 512Mi + autoscaling: + enabled: false + minReplicas: 2 + maxReplicas: 2 + targetCPUUtilizationPercentage: 70 + podDisruptionBudget: + enabled: true + minAvailable: 1 + persistence: + enabled: false + size: 1Gi + customer-service: + module: services.customer_service.app:app + replicas: 2 + resources: + requests: + cpu: 200m + memory: 256Mi + limits: + cpu: 750m + memory: 512Mi + autoscaling: + enabled: false + minReplicas: 2 + maxReplicas: 2 + targetCPUUtilizationPercentage: 70 + podDisruptionBudget: + enabled: true + minAvailable: 1 + persistence: + enabled: false + size: 1Gi + interaction-service: + module: services.interaction_service.app:app + replicas: 4 + resources: + requests: + cpu: 500m + memory: 512Mi + limits: + cpu: 1500m + memory: 1Gi + autoscaling: + enabled: true + minReplicas: 4 + maxReplicas: 8 + targetCPUUtilizationPercentage: 70 + podDisruptionBudget: + enabled: true + minAvailable: 1 + persistence: + enabled: false + size: 1Gi + routing-service: + module: services.routing_service.app:app + replicas: 3 + resources: + requests: + cpu: 500m + memory: 512Mi + limits: + cpu: 1500m + memory: 1Gi + autoscaling: + enabled: true + minReplicas: 3 + maxReplicas: 6 + targetCPUUtilizationPercentage: 70 + podDisruptionBudget: + enabled: true + minAvailable: 1 + persistence: + enabled: false + size: 1Gi + voice-adapter-service: + module: services.voice_adapter_service.app:app + replicas: 2 + resources: + requests: + cpu: 250m + memory: 256Mi + limits: + cpu: 1000m + memory: 512Mi + autoscaling: + enabled: true + minReplicas: 2 + maxReplicas: 4 + targetCPUUtilizationPercentage: 70 + podDisruptionBudget: + enabled: true + minAvailable: 1 + persistence: + enabled: false + size: 1Gi + recording-service: + module: services.recording_service.app:app + replicas: 1 + resources: + requests: + cpu: 250m + memory: 512Mi + limits: + cpu: 1000m + memory: 1Gi + autoscaling: + enabled: false + minReplicas: 1 + maxReplicas: 1 + targetCPUUtilizationPercentage: 70 + podDisruptionBudget: + enabled: false + minAvailable: 1 + persistence: + enabled: true + size: 10Gi + ivr-service: + module: services.ivr_service.app:app + replicas: 2 + resources: + requests: + cpu: 200m + memory: 256Mi + limits: + cpu: 750m + memory: 512Mi + autoscaling: + enabled: false + minReplicas: 2 + maxReplicas: 2 + targetCPUUtilizationPercentage: 70 + podDisruptionBudget: + enabled: true + minAvailable: 1 + persistence: + enabled: false + size: 1Gi + event-bus-service: + module: services.event_bus_service.app:app + replicas: 1 + resources: + requests: + cpu: 200m + memory: 256Mi + limits: + cpu: 750m + memory: 512Mi + autoscaling: + enabled: false + minReplicas: 1 + maxReplicas: 1 + targetCPUUtilizationPercentage: 70 + podDisruptionBudget: + enabled: false + minAvailable: 1 + persistence: + enabled: false + size: 1Gi + asterisk-bridge-service: + module: services.asterisk_bridge_service.app:app + # Must stay singleton: background AMI consumption and retry/reconcile loops are not multi-replica safe. + replicas: 1 + resources: + requests: + cpu: 200m + memory: 256Mi + limits: + cpu: 750m + memory: 512Mi + autoscaling: + enabled: false + minReplicas: 1 + maxReplicas: 1 + targetCPUUtilizationPercentage: 70 + podDisruptionBudget: + enabled: false + minAvailable: 1 + persistence: + enabled: false + size: 1Gi + telegram-adapter-service: + module: services.telegram_adapter_service.app:app + replicas: 2 + resources: + requests: + cpu: 250m + memory: 256Mi + limits: + cpu: 1000m + memory: 512Mi + autoscaling: + enabled: true + minReplicas: 2 + maxReplicas: 4 + targetCPUUtilizationPercentage: 70 + podDisruptionBudget: + enabled: true + minAvailable: 1 + persistence: + enabled: false + size: 1Gi + ai-orchestrator-service: + module: services.ai_orchestrator_service.app:app + replicas: 2 + resources: + requests: + cpu: 250m + memory: 256Mi + limits: + cpu: 1000m + memory: 512Mi + autoscaling: + enabled: true + minReplicas: 2 + maxReplicas: 4 + targetCPUUtilizationPercentage: 70 + podDisruptionBudget: + enabled: true + minAvailable: 1 + persistence: + enabled: false + size: 1Gi + ai-voice-runtime-service: + module: services.ai_voice_runtime_service.app:app + replicas: 1 + resources: + requests: + cpu: 250m + memory: 256Mi + limits: + cpu: 1000m + memory: 512Mi + autoscaling: + enabled: false + minReplicas: 1 + maxReplicas: 1 + targetCPUUtilizationPercentage: 70 + podDisruptionBudget: + enabled: false + minAvailable: 1 + persistence: + enabled: false + size: 1Gi + webchat-adapter-service: + module: services.webchat_adapter_service.app:app + replicas: 2 + resources: + requests: + cpu: 250m + memory: 256Mi + limits: + cpu: 1000m + memory: 512Mi + autoscaling: + enabled: true + minReplicas: 2 + maxReplicas: 4 + targetCPUUtilizationPercentage: 70 + podDisruptionBudget: + enabled: true + minAvailable: 1 + persistence: + enabled: false + size: 1Gi + email-adapter-service: + module: services.email_adapter_service.app:app + replicas: 2 + resources: + requests: + cpu: 250m + memory: 256Mi + limits: + cpu: 1000m + memory: 512Mi + autoscaling: + enabled: true + minReplicas: 2 + maxReplicas: 4 + targetCPUUtilizationPercentage: 70 + podDisruptionBudget: + enabled: true + minAvailable: 1 + persistence: + enabled: false + size: 1Gi + kb-service: + module: services.kb_service.app:app + replicas: 2 + resources: + requests: + cpu: 200m + memory: 256Mi + limits: + cpu: 750m + memory: 512Mi + autoscaling: + enabled: false + minReplicas: 2 + maxReplicas: 2 + targetCPUUtilizationPercentage: 70 + podDisruptionBudget: + enabled: true + minAvailable: 1 + persistence: + enabled: false + size: 1Gi + reporting-service: + module: services.reporting_service.app:app + replicas: 2 + resources: + requests: + cpu: 200m + memory: 256Mi + limits: + cpu: 750m + memory: 512Mi + autoscaling: + enabled: false + minReplicas: 2 + maxReplicas: 2 + targetCPUUtilizationPercentage: 70 + podDisruptionBudget: + enabled: true + minAvailable: 1 + persistence: + enabled: false + size: 1Gi + supervisor-service: + module: services.supervisor_service.app:app + replicas: 2 + resources: + requests: + cpu: 200m + memory: 256Mi + limits: + cpu: 750m + memory: 512Mi + autoscaling: + enabled: false + minReplicas: 2 + maxReplicas: 2 + targetCPUUtilizationPercentage: 70 + podDisruptionBudget: + enabled: true + minAvailable: 1 + persistence: + enabled: false + size: 1Gi + api-gateway: + module: gateway.app:app + gateway: true + replicas: 4 + resources: + requests: + cpu: 500m + memory: 512Mi + limits: + cpu: 1500m + memory: 1Gi + autoscaling: + enabled: true + minReplicas: 4 + maxReplicas: 8 + targetCPUUtilizationPercentage: 70 + podDisruptionBudget: + enabled: true + minAvailable: 1 + persistence: + enabled: false + size: 1Gi diff --git a/deployment/kubernetes/mvp-cc-platform.yaml b/deployment/kubernetes/mvp-cc-platform.yaml new file mode 100644 index 0000000..7cdcddf --- /dev/null +++ b/deployment/kubernetes/mvp-cc-platform.yaml @@ -0,0 +1,1105 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: mvp-cc +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: auth-service + namespace: mvp-cc +spec: + replicas: 1 + selector: + matchLabels: + app: auth-service + template: + metadata: + labels: + app: auth-service + spec: + containers: + - name: app + image: mvp-cc-platform:latest + imagePullPolicy: IfNotPresent + env: + - name: APP_MODULE + value: 'services.auth_service.app:app' + - name: CC_DATA_DIR + value: '/var/lib/mvp-cc/data' + - name: APP_TOKEN_SECRET + value: 'change-me-app-token-secret' + - name: APP_TOKEN_TTL_SECONDS + value: '3600' + - name: ALLOW_LEGACY_HEADER_AUTH + value: '1' + - name: VOICE_ADAPTER_TRUSTED_SERVICE_SUBJECTS + value: 'svc:asterisk-bridge,svc:ivr-service' + - name: OIDC_ENABLED + value: '0' + - name: OIDC_PROVIDER + value: 'keycloak' + - name: OIDC_ISSUER_URL + value: 'https://keycloak.example.local/realms/mvp-cc' + - name: OIDC_CLIENT_ID + value: 'mvp-cc' + - name: OIDC_CLIENT_SECRET + value: 'change-me-keycloak-client-secret' + - name: OIDC_REDIRECT_URI + value: 'https://cc.example.local/proxy/auth/auth/oidc/callback' + - name: OIDC_SCOPES + value: 'openid profile email' + - name: OIDC_ROLE_CLAIM + value: 'groups' + - name: OIDC_ROLE_MAP_JSON + value: '{"kc_admin":"admin","kc_supervisor":"supervisor","kc_operator":"operator","kc_analyst":"analyst"}' + ports: + - containerPort: 8000 + readinessProbe: + httpGet: + path: /health + port: 8000 + initialDelaySeconds: 5 + periodSeconds: 10 +--- +apiVersion: v1 +kind: Service +metadata: + name: auth-service + namespace: mvp-cc +spec: + selector: + app: auth-service + ports: + - name: http + port: 8000 + targetPort: 8000 +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: audit-service + namespace: mvp-cc +spec: + replicas: 1 + selector: + matchLabels: + app: audit-service + template: + metadata: + labels: + app: audit-service + spec: + containers: + - name: app + image: mvp-cc-platform:latest + imagePullPolicy: IfNotPresent + env: + - name: APP_MODULE + value: 'services.audit_service.app:app' + - name: CC_DATA_DIR + value: '/var/lib/mvp-cc/data' + - name: APP_TOKEN_SECRET + value: 'change-me-app-token-secret' + - name: APP_TOKEN_TTL_SECONDS + value: '3600' + - name: ALLOW_LEGACY_HEADER_AUTH + value: '1' + - name: RECORDING_IMPORT_TRUSTED_SERVICE_SUBJECTS + value: 'svc:asterisk-bridge' + - name: RECORDING_IMPORT_ALLOW_ADMIN + value: '1' + ports: + - containerPort: 8000 + readinessProbe: + httpGet: + path: /health + port: 8000 + initialDelaySeconds: 5 + periodSeconds: 10 +--- +apiVersion: v1 +kind: Service +metadata: + name: audit-service + namespace: mvp-cc +spec: + selector: + app: audit-service + ports: + - name: http + port: 8000 + targetPort: 8000 +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: customer-service + namespace: mvp-cc +spec: + replicas: 1 + selector: + matchLabels: + app: customer-service + template: + metadata: + labels: + app: customer-service + spec: + containers: + - name: app + image: mvp-cc-platform:latest + imagePullPolicy: IfNotPresent + env: + - name: APP_MODULE + value: 'services.customer_service.app:app' + - name: CC_DATA_DIR + value: '/var/lib/mvp-cc/data' + - name: APP_TOKEN_SECRET + value: 'change-me-app-token-secret' + - name: APP_TOKEN_TTL_SECONDS + value: '3600' + - name: ALLOW_LEGACY_HEADER_AUTH + value: '1' + - name: IVR_RUNTIME_TRUSTED_SERVICE_SUBJECTS + value: 'svc:asterisk-bridge' + ports: + - containerPort: 8000 + readinessProbe: + httpGet: + path: /health + port: 8000 + initialDelaySeconds: 5 + periodSeconds: 10 +--- +apiVersion: v1 +kind: Service +metadata: + name: customer-service + namespace: mvp-cc +spec: + selector: + app: customer-service + ports: + - name: http + port: 8000 + targetPort: 8000 +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: interaction-service + namespace: mvp-cc +spec: + replicas: 1 + selector: + matchLabels: + app: interaction-service + template: + metadata: + labels: + app: interaction-service + spec: + containers: + - name: app + image: mvp-cc-platform:latest + imagePullPolicy: IfNotPresent + env: + - name: APP_MODULE + value: 'services.interaction_service.app:app' + - name: CC_DATA_DIR + value: '/var/lib/mvp-cc/data' + - name: APP_TOKEN_SECRET + value: 'change-me-app-token-secret' + - name: APP_TOKEN_TTL_SECONDS + value: '3600' + - name: ALLOW_LEGACY_HEADER_AUTH + value: '1' + ports: + - containerPort: 8000 + readinessProbe: + httpGet: + path: /health + port: 8000 + initialDelaySeconds: 5 + periodSeconds: 10 +--- +apiVersion: v1 +kind: Service +metadata: + name: interaction-service + namespace: mvp-cc +spec: + selector: + app: interaction-service + ports: + - name: http + port: 8000 + targetPort: 8000 +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: routing-service + namespace: mvp-cc +spec: + replicas: 1 + selector: + matchLabels: + app: routing-service + template: + metadata: + labels: + app: routing-service + spec: + containers: + - name: app + image: mvp-cc-platform:latest + imagePullPolicy: IfNotPresent + env: + - name: APP_MODULE + value: 'services.routing_service.app:app' + - name: CC_DATA_DIR + value: '/var/lib/mvp-cc/data' + - name: APP_TOKEN_SECRET + value: 'change-me-app-token-secret' + - name: APP_TOKEN_TTL_SECONDS + value: '3600' + - name: ALLOW_LEGACY_HEADER_AUTH + value: '1' + ports: + - containerPort: 8000 + readinessProbe: + httpGet: + path: /health + port: 8000 + initialDelaySeconds: 5 + periodSeconds: 10 +--- +apiVersion: v1 +kind: Service +metadata: + name: routing-service + namespace: mvp-cc +spec: + selector: + app: routing-service + ports: + - name: http + port: 8000 + targetPort: 8000 +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: voice-adapter-service + namespace: mvp-cc +spec: + replicas: 1 + selector: + matchLabels: + app: voice-adapter-service + template: + metadata: + labels: + app: voice-adapter-service + spec: + containers: + - name: app + image: mvp-cc-platform:latest + imagePullPolicy: IfNotPresent + env: + - name: APP_MODULE + value: 'services.voice_adapter_service.app:app' + - name: CC_DATA_DIR + value: '/var/lib/mvp-cc/data' + - name: APP_TOKEN_SECRET + value: 'change-me-app-token-secret' + - name: APP_TOKEN_TTL_SECONDS + value: '3600' + - name: ALLOW_LEGACY_HEADER_AUTH + value: '1' + ports: + - containerPort: 8000 + readinessProbe: + httpGet: + path: /health + port: 8000 + initialDelaySeconds: 5 + periodSeconds: 10 +--- +apiVersion: v1 +kind: Service +metadata: + name: voice-adapter-service + namespace: mvp-cc +spec: + selector: + app: voice-adapter-service + ports: + - name: http + port: 8000 + targetPort: 8000 +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: telegram-adapter-service + namespace: mvp-cc +spec: + replicas: 1 + selector: + matchLabels: + app: telegram-adapter-service + template: + metadata: + labels: + app: telegram-adapter-service + spec: + containers: + - name: app + image: mvp-cc-platform:latest + imagePullPolicy: IfNotPresent + env: + - name: APP_MODULE + value: 'services.telegram_adapter_service.app:app' + - name: CC_DATA_DIR + value: '/var/lib/mvp-cc/data' + - name: APP_TOKEN_SECRET + value: 'change-me-app-token-secret' + - name: APP_TOKEN_TTL_SECONDS + value: '3600' + - name: ALLOW_LEGACY_HEADER_AUTH + value: '1' + ports: + - containerPort: 8000 + readinessProbe: + httpGet: + path: /health + port: 8000 + initialDelaySeconds: 5 + periodSeconds: 10 +--- +apiVersion: v1 +kind: Service +metadata: + name: telegram-adapter-service + namespace: mvp-cc +spec: + selector: + app: telegram-adapter-service + ports: + - name: http + port: 8000 + targetPort: 8000 +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: webchat-adapter-service + namespace: mvp-cc +spec: + replicas: 1 + selector: + matchLabels: + app: webchat-adapter-service + template: + metadata: + labels: + app: webchat-adapter-service + spec: + containers: + - name: app + image: mvp-cc-platform:latest + imagePullPolicy: IfNotPresent + env: + - name: APP_MODULE + value: 'services.webchat_adapter_service.app:app' + - name: CC_DATA_DIR + value: '/var/lib/mvp-cc/data' + - name: APP_TOKEN_SECRET + value: 'change-me-app-token-secret' + - name: APP_TOKEN_TTL_SECONDS + value: '3600' + - name: ALLOW_LEGACY_HEADER_AUTH + value: '1' + - name: EVENT_BUS_ENABLED + value: '1' + - name: EVENT_BUS_URL + value: 'amqp://guest:guest@rabbitmq:5672/' + - name: EVENT_BUS_EXCHANGE + value: 'mvpcc.domain.events' + - name: EVENT_BUS_CONSUMER_ENABLED + value: '1' + - name: EVENT_BUS_AUDIT_QUEUE + value: 'mvpcc.audit.events' + - name: EVENT_BUS_REPORTING_QUEUE + value: 'mvpcc.reporting.events' + ports: + - containerPort: 8000 + readinessProbe: + httpGet: + path: /health + port: 8000 + initialDelaySeconds: 5 + periodSeconds: 10 +--- +apiVersion: v1 +kind: Service +metadata: + name: webchat-adapter-service + namespace: mvp-cc +spec: + selector: + app: webchat-adapter-service + ports: + - name: http + port: 8000 + targetPort: 8000 +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: email-adapter-service + namespace: mvp-cc +spec: + replicas: 1 + selector: + matchLabels: + app: email-adapter-service + template: + metadata: + labels: + app: email-adapter-service + spec: + containers: + - name: app + image: mvp-cc-platform:latest + imagePullPolicy: IfNotPresent + env: + - name: APP_MODULE + value: 'services.email_adapter_service.app:app' + - name: CC_DATA_DIR + value: '/var/lib/mvp-cc/data' + - name: APP_TOKEN_SECRET + value: 'change-me-app-token-secret' + - name: APP_TOKEN_TTL_SECONDS + value: '3600' + - name: ALLOW_LEGACY_HEADER_AUTH + value: '1' + - name: EVENT_BUS_ENABLED + value: '1' + - name: EVENT_BUS_URL + value: 'amqp://guest:guest@rabbitmq:5672/' + - name: EVENT_BUS_EXCHANGE + value: 'mvpcc.domain.events' + - name: EVENT_BUS_CONSUMER_ENABLED + value: '1' + - name: EVENT_BUS_AUDIT_QUEUE + value: 'mvpcc.audit.events' + - name: EVENT_BUS_REPORTING_QUEUE + value: 'mvpcc.reporting.events' + ports: + - containerPort: 8000 + readinessProbe: + httpGet: + path: /health + port: 8000 + initialDelaySeconds: 5 + periodSeconds: 10 +--- +apiVersion: v1 +kind: Service +metadata: + name: email-adapter-service + namespace: mvp-cc +spec: + selector: + app: email-adapter-service + ports: + - name: http + port: 8000 + targetPort: 8000 +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: kb-service + namespace: mvp-cc +spec: + replicas: 1 + selector: + matchLabels: + app: kb-service + template: + metadata: + labels: + app: kb-service + spec: + containers: + - name: app + image: mvp-cc-platform:latest + imagePullPolicy: IfNotPresent + env: + - name: APP_MODULE + value: 'services.kb_service.app:app' + - name: CC_DATA_DIR + value: '/var/lib/mvp-cc/data' + - name: APP_TOKEN_SECRET + value: 'change-me-app-token-secret' + - name: APP_TOKEN_TTL_SECONDS + value: '3600' + - name: ALLOW_LEGACY_HEADER_AUTH + value: '1' + ports: + - containerPort: 8000 + readinessProbe: + httpGet: + path: /health + port: 8000 + initialDelaySeconds: 5 + periodSeconds: 10 +--- +apiVersion: v1 +kind: Service +metadata: + name: kb-service + namespace: mvp-cc +spec: + selector: + app: kb-service + ports: + - name: http + port: 8000 + targetPort: 8000 +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: reporting-service + namespace: mvp-cc +spec: + replicas: 1 + selector: + matchLabels: + app: reporting-service + template: + metadata: + labels: + app: reporting-service + spec: + containers: + - name: app + image: mvp-cc-platform:latest + imagePullPolicy: IfNotPresent + env: + - name: APP_MODULE + value: 'services.reporting_service.app:app' + - name: CC_DATA_DIR + value: '/var/lib/mvp-cc/data' + - name: APP_TOKEN_SECRET + value: 'change-me-app-token-secret' + - name: APP_TOKEN_TTL_SECONDS + value: '3600' + - name: ALLOW_LEGACY_HEADER_AUTH + value: '1' + ports: + - containerPort: 8000 + readinessProbe: + httpGet: + path: /health + port: 8000 + initialDelaySeconds: 5 + periodSeconds: 10 +--- +apiVersion: v1 +kind: Service +metadata: + name: reporting-service + namespace: mvp-cc +spec: + selector: + app: reporting-service + ports: + - name: http + port: 8000 + targetPort: 8000 +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: supervisor-service + namespace: mvp-cc +spec: + replicas: 1 + selector: + matchLabels: + app: supervisor-service + template: + metadata: + labels: + app: supervisor-service + spec: + containers: + - name: app + image: mvp-cc-platform:latest + imagePullPolicy: IfNotPresent + env: + - name: APP_MODULE + value: 'services.supervisor_service.app:app' + - name: CC_DATA_DIR + value: '/var/lib/mvp-cc/data' + ports: + - containerPort: 8000 + readinessProbe: + httpGet: + path: /health + port: 8000 + initialDelaySeconds: 5 + periodSeconds: 10 +--- +apiVersion: v1 +kind: Service +metadata: + name: supervisor-service + namespace: mvp-cc +spec: + selector: + app: supervisor-service + ports: + - name: http + port: 8000 + targetPort: 8000 +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: recording-service + namespace: mvp-cc +spec: + replicas: 1 + selector: + matchLabels: + app: recording-service + template: + metadata: + labels: + app: recording-service + spec: + containers: + - name: app + image: mvp-cc-platform:latest + imagePullPolicy: IfNotPresent + env: + - name: APP_MODULE + value: 'services.recording_service.app:app' + - name: CC_DATA_DIR + value: '/var/lib/mvp-cc/data' + - name: CC_RECORDINGS_DIR + value: '/var/lib/mvp-cc/data/recordings' + - name: RECORDING_MAX_BYTES + value: '26214400' + - name: APP_TOKEN_SECRET + value: 'change-me-app-token-secret' + - name: APP_TOKEN_TTL_SECONDS + value: '3600' + - name: ALLOW_LEGACY_HEADER_AUTH + value: '1' + ports: + - containerPort: 8000 + readinessProbe: + httpGet: + path: /health + port: 8000 + initialDelaySeconds: 5 + periodSeconds: 10 +--- +apiVersion: v1 +kind: Service +metadata: + name: recording-service + namespace: mvp-cc +spec: + selector: + app: recording-service + ports: + - name: http + port: 8000 + targetPort: 8000 +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: ivr-service + namespace: mvp-cc +spec: + replicas: 1 + selector: + matchLabels: + app: ivr-service + template: + metadata: + labels: + app: ivr-service + spec: + containers: + - name: app + image: mvp-cc-platform:latest + imagePullPolicy: IfNotPresent + env: + - name: APP_MODULE + value: 'services.ivr_service.app:app' + - name: CC_DATA_DIR + value: '/var/lib/mvp-cc/data' + - name: APP_TOKEN_SECRET + value: 'change-me-app-token-secret' + - name: APP_TOKEN_TTL_SECONDS + value: '3600' + - name: ALLOW_LEGACY_HEADER_AUTH + value: '1' + ports: + - containerPort: 8000 + readinessProbe: + httpGet: + path: /health + port: 8000 + initialDelaySeconds: 5 + periodSeconds: 10 +--- +apiVersion: v1 +kind: Service +metadata: + name: ivr-service + namespace: mvp-cc +spec: + selector: + app: ivr-service + ports: + - name: http + port: 8000 + targetPort: 8000 +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: api-gateway + namespace: mvp-cc +spec: + replicas: 1 + selector: + matchLabels: + app: api-gateway + template: + metadata: + labels: + app: api-gateway + spec: + containers: + - name: app + image: mvp-cc-platform:latest + imagePullPolicy: IfNotPresent + env: + - name: APP_MODULE + value: 'gateway.app:app' + - name: CC_DATA_DIR + value: '/var/lib/mvp-cc/data' + - name: APP_TOKEN_SECRET + value: 'change-me-app-token-secret' + - name: APP_TOKEN_TTL_SECONDS + value: '3600' + - name: ALLOW_LEGACY_HEADER_AUTH + value: '1' + - name: AUTH_SERVICE_URL + value: 'http://auth-service:8000' + - name: AUDIT_SERVICE_URL + value: 'http://audit-service:8000' + - name: CUSTOMER_SERVICE_URL + value: 'http://customer-service:8000' + - name: INTERACTION_SERVICE_URL + value: 'http://interaction-service:8000' + - name: ROUTING_SERVICE_URL + value: 'http://routing-service:8000' + - name: VOICE_ADAPTER_SERVICE_URL + value: 'http://voice-adapter-service:8000' + - name: RECORDING_SERVICE_URL + value: 'http://recording-service:8000' + - name: IVR_SERVICE_URL + value: 'http://ivr-service:8000' + - name: TELEGRAM_ADAPTER_SERVICE_URL + value: 'http://telegram-adapter-service:8000' + - name: WEBCHAT_ADAPTER_SERVICE_URL + value: 'http://webchat-adapter-service:8000' + - name: EMAIL_ADAPTER_SERVICE_URL + value: 'http://email-adapter-service:8000' + - name: KB_SERVICE_URL + value: 'http://kb-service:8000' + - name: REPORTING_SERVICE_URL + value: 'http://reporting-service:8000' + - name: SUPERVISOR_SERVICE_URL + value: 'http://supervisor-service:8000' + - name: EVENT_BUS_SERVICE_URL + value: 'http://event-bus-service:8000' + - name: ASTERISK_BRIDGE_SERVICE_URL + value: 'http://asterisk-bridge-service:8000' + ports: + - containerPort: 8000 + readinessProbe: + httpGet: + path: /health + port: 8000 + initialDelaySeconds: 5 + periodSeconds: 10 +--- +apiVersion: v1 +kind: Service +metadata: + name: api-gateway + namespace: mvp-cc +spec: + selector: + app: api-gateway + ports: + - name: http + port: 8000 + targetPort: 8000 + type: LoadBalancer +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: rabbitmq + namespace: mvp-cc +spec: + replicas: 1 + selector: + matchLabels: + app: rabbitmq + template: + metadata: + labels: + app: rabbitmq + spec: + containers: + - name: rabbitmq + image: rabbitmq:3-management + imagePullPolicy: IfNotPresent + ports: + - containerPort: 5672 + - containerPort: 15672 +--- +apiVersion: v1 +kind: Service +metadata: + name: rabbitmq + namespace: mvp-cc +spec: + selector: + app: rabbitmq + ports: + - name: amqp + port: 5672 + targetPort: 5672 + - name: management + port: 15672 + targetPort: 15672 +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: event-bus-service + namespace: mvp-cc +spec: + replicas: 1 + selector: + matchLabels: + app: event-bus-service + template: + metadata: + labels: + app: event-bus-service + spec: + containers: + - name: app + image: mvp-cc-platform:latest + imagePullPolicy: IfNotPresent + env: + - name: APP_MODULE + value: 'services.event_bus_service.app:app' + - name: CC_DATA_DIR + value: '/var/lib/mvp-cc/data' + - name: APP_TOKEN_SECRET + value: 'change-me-app-token-secret' + - name: APP_TOKEN_TTL_SECONDS + value: '3600' + - name: ALLOW_LEGACY_HEADER_AUTH + value: '1' + - name: EVENT_BUS_ENABLED + value: '1' + - name: EVENT_BUS_URL + value: 'amqp://guest:guest@rabbitmq:5672/' + - name: EVENT_BUS_EXCHANGE + value: 'mvpcc.domain.events' + - name: EVENT_BUS_DISPATCH_BATCH_SIZE + value: '50' + - name: EVENT_BUS_MAX_ATTEMPTS + value: '5' + - name: EVENT_BUS_POLL_INTERVAL_SECONDS + value: '2' + - name: EVENT_BUS_CONSUMER_ENABLED + value: '1' + - name: EVENT_BUS_AUDIT_QUEUE + value: 'mvpcc.audit.events' + - name: EVENT_BUS_REPORTING_QUEUE + value: 'mvpcc.reporting.events' + ports: + - containerPort: 8000 + readinessProbe: + httpGet: + path: /health + port: 8000 + initialDelaySeconds: 5 + periodSeconds: 10 +--- +apiVersion: v1 +kind: Service +metadata: + name: event-bus-service + namespace: mvp-cc +spec: + selector: + app: event-bus-service + ports: + - name: http + port: 8000 + targetPort: 8000 +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: asterisk-bridge-service + namespace: mvp-cc +spec: + # Must stay singleton: background AMI consumption and retry/reconcile loops are not multi-replica safe. + replicas: 1 + selector: + matchLabels: + app: asterisk-bridge-service + template: + metadata: + labels: + app: asterisk-bridge-service + spec: + containers: + - name: app + image: mvp-cc-platform:latest + imagePullPolicy: IfNotPresent + env: + - name: APP_MODULE + value: 'services.asterisk_bridge_service.app:app' + - name: CC_DATA_DIR + value: '/var/lib/mvp-cc/data' + - name: APP_TOKEN_SECRET + value: 'change-me-app-token-secret' + - name: APP_TOKEN_TTL_SECONDS + value: '3600' + - name: ALLOW_LEGACY_HEADER_AUTH + value: '0' + - name: ASTERISK_BRIDGE_ENABLED + value: '0' + - name: ASTERISK_AMI_HOST + value: '' + - name: ASTERISK_AMI_PORT + value: '5038' + - name: ASTERISK_AMI_USERNAME + value: '' + - name: ASTERISK_AMI_SECRET + value: '' + - name: ASTERISK_AMI_EVENT_PREFIX + value: 'MVPCC' + - name: ASTERISK_QUEUE_MAP_JSON + value: '{}' + - name: ASTERISK_SFTP_HOST + value: '' + - name: ASTERISK_SFTP_PORT + value: '22' + - name: ASTERISK_SFTP_USERNAME + value: '' + - name: ASTERISK_SFTP_PASSWORD + value: '' + - name: ASTERISK_SFTP_BASE_PATH + value: '/var/spool/asterisk/monitor' + - name: ASTERISK_BRIDGE_POLL_INTERVAL_SECONDS + value: '1' + - name: ASTERISK_BRIDGE_AUTH_MODE + value: 'bearer' + - name: ASTERISK_BRIDGE_AUTH_FALLBACK_LEGACY + value: '0' + - name: ASTERISK_BRIDGE_AUTH_SUBJECT + value: 'svc:asterisk-bridge' + - name: ASTERISK_BRIDGE_AUTH_USER + value: 'asterisk-bridge' + - name: ASTERISK_BRIDGE_AUTH_ROLE + value: 'admin' + - name: ASTERISK_BRIDGE_AUTH_TOKEN_TTL_SECONDS + value: '300' + - name: ASTERISK_IVR_FASTAGI_ENABLED + value: '0' + - name: ASTERISK_IVR_FASTAGI_HOST + value: '0.0.0.0' + - name: ASTERISK_IVR_FASTAGI_PORT + value: '4573' + - name: ASTERISK_IVR_DTMF_TIMEOUT_SECONDS + value: '5' + - name: ASTERISK_IVR_MAX_NO_INPUT_RETRIES + value: '2' + - name: ASTERISK_IVR_MAX_INVALID_RETRIES + value: '2' + - name: ASTERISK_IVR_CALL_LINK_WAIT_SECONDS + value: '5' + - name: ASTERISK_CALLCONTROL_ENABLED + value: '0' + - name: ASTERISK_CALLCONTROL_ACTION_TIMEOUT_SECONDS + value: '10' + - name: ASTERISK_OPERATOR_EXTENSION_MAP_JSON + value: '{}' + - name: ASTERISK_TRANSFER_TARGET_MAP_JSON + value: '{}' + - name: ASTERISK_CALLCONTROL_CLAIM_CONTEXT + value: 'mvpcc-claim' + - name: ASTERISK_CALLCONTROL_TRANSFER_CONTEXT + value: 'mvpcc-transfer' + - name: INTERACTION_SERVICE_URL + value: 'http://interaction-service:8000' + - name: VOICE_ADAPTER_SERVICE_URL + value: 'http://voice-adapter-service:8000' + - name: RECORDING_SERVICE_URL + value: 'http://recording-service:8000' + - name: IVR_SERVICE_URL + value: 'http://ivr-service:8000' + ports: + - containerPort: 8000 + - containerPort: 4573 + readinessProbe: + httpGet: + path: /health + port: 8000 + initialDelaySeconds: 5 + periodSeconds: 10 +--- +apiVersion: v1 +kind: Service +metadata: + name: asterisk-bridge-service + namespace: mvp-cc +spec: + selector: + app: asterisk-bridge-service + ports: + - name: http + port: 8000 + targetPort: 8000 + - name: fastagi + port: 4573 + targetPort: 4573 +--- + diff --git a/deployment/kubernetes/observability.yaml b/deployment/kubernetes/observability.yaml new file mode 100644 index 0000000..472680c --- /dev/null +++ b/deployment/kubernetes/observability.yaml @@ -0,0 +1,67 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: prometheus-config + namespace: mvp-cc +data: + prometheus.yml: | + global: + scrape_interval: 15s + scrape_configs: + - job_name: 'mvp-services' + metrics_path: /health + static_configs: + - targets: + - auth-service:8000 + - audit-service:8000 + - customer-service:8000 + - interaction-service:8000 + - routing-service:8000 + - voice-adapter-service:8000 + - telegram-adapter-service:8000 + - kb-service:8000 + - reporting-service:8000 + - supervisor-service:8000 + - api-gateway:8000 +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: prometheus + namespace: mvp-cc +spec: + replicas: 1 + selector: + matchLabels: + app: prometheus + template: + metadata: + labels: + app: prometheus + spec: + containers: + - name: prometheus + image: prom/prometheus:v2.55.1 + args: + - --config.file=/etc/prometheus/prometheus.yml + volumeMounts: + - name: config + mountPath: /etc/prometheus + volumes: + - name: config + configMap: + name: prometheus-config +--- +apiVersion: v1 +kind: Service +metadata: + name: prometheus + namespace: mvp-cc +spec: + selector: + app: prometheus + ports: + - name: http + port: 9090 + targetPort: 9090 + type: ClusterIP diff --git a/docs/acceptance/.gitignore b/docs/acceptance/.gitignore new file mode 100644 index 0000000..12bd819 --- /dev/null +++ b/docs/acceptance/.gitignore @@ -0,0 +1,4 @@ +track9/[0-9]*/ +track9_1/[0-9]*/ +track10/[0-9]*/ +track11/[0-9]*/ diff --git a/docs/acceptance/track10/README.md b/docs/acceptance/track10/README.md new file mode 100644 index 0000000..b6e8840 --- /dev/null +++ b/docs/acceptance/track10/README.md @@ -0,0 +1,13 @@ +# Track 10 Acceptance Evidence + +Store Track 10 reliability evidence per run: + +- `docs/acceptance/track10//...` + +Recommended contents: +- `voice-latency-latest.json` +- `command-output.txt` +- `track10-acceptance.md` + +Reference runbook: +- `docs/runbooks/track10-live-voice-reliability.md` diff --git a/docs/acceptance/track10/track10-acceptance.md b/docs/acceptance/track10/track10-acceptance.md new file mode 100644 index 0000000..396a89f --- /dev/null +++ b/docs/acceptance/track10/track10-acceptance.md @@ -0,0 +1,41 @@ +# Track 10 Acceptance (Latest) + +## Environment + +- Date: `2026-03-05` +- Gateway URL: `http://127.0.0.1:8080` +- Database URL (masked): `sqlite:///.data_local/mvp_cc.db` +- Evidence folder: `docs/acceptance/track10/20260305_163330` + +## Validation command + +```powershell +python scripts\track10_voice_latency_report.py ` + --database-url sqlite:///.data_local/mvp_cc.db ` + --since-minutes 90 ` + --breach-mode direct ` + --json-out docs/acceptance/track10/20260305_163330/voice-latency-latest.json +``` + +## Result summary + +- total calls: `10` +- active_no_end: `0` +- ended_no_recording_upload: `0` +- ended_direct_calls: `4` +- ended_reconciled_calls: `6` +- p95 started->ended: `1686.35s` +- p95 ended->recording upload: `455.25s` +- p95 direct started->ended: `33.0s` +- p95 direct ended->recording upload: `2.85s` + +## Decision + +- [x] GO (with exception) +- [ ] NO-GO + +## Notes + +- Direct path remains stable (`active_no_end=0`, `ended_no_recording_upload=0`). +- Threshold exception recorded: direct `started->ended` is `33s` vs target `30s`. +- Detailed run: `docs/acceptance/track10/20260305_163330/track10-acceptance.md` diff --git a/docs/acceptance/track11/README.md b/docs/acceptance/track11/README.md new file mode 100644 index 0000000..0862f76 --- /dev/null +++ b/docs/acceptance/track11/README.md @@ -0,0 +1,15 @@ +# Track 11 Acceptance Evidence + +Stable summary: + +- [track11-acceptance.md](/e:/Zhan/docs/acceptance/track11/track11-acceptance.md) + +Store Track 11 QA evidence per run: + +- `docs/acceptance/track11//track11-acceptance.md` +- `docs/acceptance/track11//operator-live-calls.json` +- `docs/acceptance/track11//bridge-actions.json` +- `docs/acceptance/track11//playback-proof.json` + +Reference runbook: +- `docs/runbooks/track11-live-operator-call-control.md` diff --git a/docs/acceptance/track11/track11-acceptance.md b/docs/acceptance/track11/track11-acceptance.md new file mode 100644 index 0000000..7ad1401 --- /dev/null +++ b/docs/acceptance/track11/track11-acceptance.md @@ -0,0 +1,35 @@ +# Track 11 Acceptance (Latest) + +## Environment + +- Date: `2026-03-07` +- Base URL: `http://localhost:8080` +- Database URL (masked): `sqlite:///.data_local/mvp_cc.db` +- Evidence package: [20260307_104748](/e:/Zhan/docs/acceptance/track11/20260307_104748) + +## Executed scenario + +1. Operator received a live inbound call in `/operator`. +2. Operator claimed the call. +3. Operator completed a real hangup path and recording playback was validated. +4. Operator completed a blind transfer flow from the live-call block. + +## Validation summary + +- live inbound visibility: `yes` +- claim persisted: `yes` +- hangup persisted: `yes` +- transfer persisted: `yes` +- `call.connected` present: `yes` +- `call.transferred` present: `yes` +- recording playback: `PASS` + +## Result + +- GO / NO-GO: `GO` +- Track 11 status: `Accepted` + +## Notes + +- Detailed package: `docs/acceptance/track11/20260307_104748/track11-acceptance.md` +- Track 11 keeps browser UI control and external softphone media as the accepted baseline. diff --git a/docs/acceptance/track11/track11-acceptance.template.md b/docs/acceptance/track11/track11-acceptance.template.md new file mode 100644 index 0000000..a44d5bc --- /dev/null +++ b/docs/acceptance/track11/track11-acceptance.template.md @@ -0,0 +1,41 @@ +# Track 11 Acceptance - + +## Environment + +- Base URL: `` +- Database: `` +- Asterisk VM: `` +- Operator account: `` +- Operator extension: `` + +## Executed scenario + +1. Caller `1001` called `7000`. +2. Operator opened `/operator` -> `Живые звонки`. +3. Operator clicked `Принять в работу`. +4. Voice conversation in `MicroSIP/Zoiper` was confirmed. +5. Operator executed one or both actions: + - [ ] `Завершить` + - [ ] `Передать` (blind transfer) +6. Recording was checked in `/supervisor`. + +## API and DB evidence + +- Live call list saved: `operator-live-calls.json` +- Action log saved: `bridge-actions.json` +- Playback proof saved: `playback-proof.json` + +## Mandatory checks + +- [ ] `call.started` exists (`payload.source=asterisk`) +- [ ] `call.connected` exists +- [ ] `call.ended` exists +- [ ] `recording.ready` exists +- [ ] optional `call.transferred` exists when transfer path was tested +- [ ] no unresolved failed call-control actions in the acceptance window + +## Decision + +- GO / NO-GO: `` +- Notes: + - `` diff --git a/docs/acceptance/track12/20260307_161629/bridge-actions.json b/docs/acceptance/track12/20260307_161629/bridge-actions.json new file mode 100644 index 0000000..3289c98 --- /dev/null +++ b/docs/acceptance/track12/20260307_161629/bridge-actions.json @@ -0,0 +1,27 @@ +{ + "latest_call": "1772881979.108", + "latest_call_actions": [], + "reference_recent_ok_actions": [ + { + "call_id": "1772881673.105", + "action_type": "hangup", + "actor_user": "operator", + "result_status": "ok", + "created_at": "2026-03-07T11:08:42+00:00" + }, + { + "call_id": "1772881673.105", + "action_type": "claim", + "actor_user": "operator", + "result_status": "ok", + "created_at": "2026-03-07T11:08:16+00:00" + }, + { + "call_id": "1772876380.93", + "action_type": "hangup", + "actor_user": "operator", + "result_status": "ok", + "created_at": "2026-03-07T09:40:02+00:00" + } + ] +} \ No newline at end of file diff --git a/docs/acceptance/track12/20260307_161629/operator-live-calls-empty.json b/docs/acceptance/track12/20260307_161629/operator-live-calls-empty.json new file mode 100644 index 0000000..0637a08 --- /dev/null +++ b/docs/acceptance/track12/20260307_161629/operator-live-calls-empty.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/docs/acceptance/track12/20260307_161629/operator-live-calls.json b/docs/acceptance/track12/20260307_161629/operator-live-calls.json new file mode 100644 index 0000000..16517d8 --- /dev/null +++ b/docs/acceptance/track12/20260307_161629/operator-live-calls.json @@ -0,0 +1,25 @@ +[ + { + "call_id": "1772881979.108", + "caller_name": "1001", + "caller_number": "1001", + "channel_name": "PJSIP/2001-00000047", + "claimed_at": null, + "claimed_by_user": null, + "connected_at": "2026-03-07T11:13:05+00:00", + "ended_at": null, + "hangup_cause": "unknown", + "has_recording": false, + "interaction_id": "int_0349796d1e", + "last_transition_at": "2026-03-07T11:13:26+00:00", + "operator_extension": "2001", + "queue_code": "voice_lab", + "queue_id": "que_23fb0ad07c", + "started_at": "2026-03-07T11:13:01+00:00", + "status": "active", + "telephony_status": "connected", + "terminal_action": null, + "terminal_target": null, + "updated_at": "2026-03-07T11:13:26+00:00" + } +] \ No newline at end of file diff --git a/docs/acceptance/track12/20260307_161629/operator-recent-calls.json b/docs/acceptance/track12/20260307_161629/operator-recent-calls.json new file mode 100644 index 0000000..0637a08 --- /dev/null +++ b/docs/acceptance/track12/20260307_161629/operator-recent-calls.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/docs/acceptance/track12/20260307_161629/playback-proof.json b/docs/acceptance/track12/20260307_161629/playback-proof.json new file mode 100644 index 0000000..9d48bf9 --- /dev/null +++ b/docs/acceptance/track12/20260307_161629/playback-proof.json @@ -0,0 +1,10 @@ +{ + "call_id": "1772881979.108", + "interaction_id": "int_0349796d1e", + "recording_id": "rec_733ef323b1", + "url": "http://127.0.0.1:8080/proxy/recording/recordings/rec_733ef323b1/content?disposition=inline", + "status_code": 200, + "content_type": "audio/wav", + "content_length": 457324, + "file_name": "20260307-161300-1772881979.108.wav" +} \ No newline at end of file diff --git a/docs/acceptance/track12/20260307_161629/track12-acceptance.md b/docs/acceptance/track12/20260307_161629/track12-acceptance.md new file mode 100644 index 0000000..b52681b --- /dev/null +++ b/docs/acceptance/track12/20260307_161629/track12-acceptance.md @@ -0,0 +1,75 @@ +# Track 12 Acceptance - 20260307_161629 + +## Environment + +- Base URL: `http://localhost:8080` +- Database: `sqlite:///./.data_local/mvp_cc.db` +- Asterisk host: `92.38.48.166` +- Operator user: `operator` +- Operator extension: `2001` + +## Acceptance Basis + +Track 12 is a UX/state hardening layer on top of accepted Track 11. +This package closes the Track 12 delta and keeps Track 11 control-path proof as the inherited baseline. + +Reference control-path package: +- [track11-acceptance.md](/e:/Zhan/docs/acceptance/track11/20260307_104748/track11-acceptance.md) + +Track 12 delta call: +- `call_id = 1772881979.108` +- `interaction_id = int_0349796d1e` +- `recording_id = rec_733ef323b1` + +## Executed Scenarios + +- [x] Happy path inherited from accepted Track 11 package +- [x] Transfer path inherited from accepted Track 11 package +- [x] Passive path: answer in SIP, no browser action, call remains in `live-calls` until real hangup +- [x] No-fake-cards path: `Живые звонки` uses bridge data only and does not show `interaction:int_*` + +## Delta Evidence + +- Active live call snapshot: [operator-live-calls.json](/e:/Zhan/docs/acceptance/track12/20260307_161629/operator-live-calls.json) +- Recent call snapshot: [operator-recent-calls.json](/e:/Zhan/docs/acceptance/track12/20260307_161629/operator-recent-calls.json) +- Empty live snapshot after call end: [operator-live-calls-empty.json](/e:/Zhan/docs/acceptance/track12/20260307_161629/operator-live-calls-empty.json) +- Action log summary: [bridge-actions.json](/e:/Zhan/docs/acceptance/track12/20260307_161629/bridge-actions.json) +- Playback proof: [playback-proof.json](/e:/Zhan/docs/acceptance/track12/20260307_161629/playback-proof.json) +- Live monitor trace: [track12-monitor.log](/e:/Zhan/docs/acceptance/track12/20260307_161629/track12-monitor.log) + +## What The Monitor Proved + +For call `1772881979.108`: + +1. At `2026-03-07T11:13:01+00:00` the call entered `/asterisk/live-calls` as: + - `status = active` + - `telephony_status = ringing` +2. At `2026-03-07T11:13:05+00:00` it stayed in `/asterisk/live-calls` and moved to: + - `telephony_status = connected` + - `operator_extension = 2001` +3. At `2026-03-07T11:13:26+00:00` a synthetic reconciled `call.ended` was emitted, but the call still remained in `/asterisk/live-calls`. +4. Only at the real final hangup (`2026-03-07T11:13:30+00:00`) the call left `/asterisk/live-calls` and moved to `/asterisk/recent-calls`. +5. `recording.ready` was emitted and the recording was imported successfully. + +This closes the earlier defect where the card could disappear from the operator flow on the reconciled service event instead of the real final hangup. + +## Mandatory Checks + +- [x] `GET /proxy/asterisk-bridge/asterisk/live-calls` returns only active calls +- [x] `GET /proxy/asterisk-bridge/asterisk/recent-calls` returns only recent ended calls +- [x] passive SIP answer does not force the card out of `Активные звонки` before real hangup +- [x] final hangup moves the card to `Только что завершённые` +- [x] no `interaction:int_*` fallback cards are used for `Живые звонки` +- [x] recording is available for the monitored call +- [x] Track 11 `claim / hangup / blind-transfer` control path remains covered by the accepted Track 11 package + +## Notes + +- The initial runtime regression after a local restart was caused by `scripts/local_stack.py` not loading `.env.production`; this is fixed and the launcher now auto-loads `.env.production`, `.env.local`, and `.env`. +- The current passive-path monitored call ended without browser actions, so `bridge-actions.json` contains no action rows for `1772881979.108`. This is expected for the passive-path check. +- Control-path actions (`claim`, `hangup`, `blind-transfer`) remain validated by the accepted Track 11 package and were not redefined by Track 12. + +## Decision + +- GO / NO-GO: `GO` +- Track 12 status: `Accepted` diff --git a/docs/acceptance/track12/20260307_161629/track12-monitor.log b/docs/acceptance/track12/20260307_161629/track12-monitor.log new file mode 100644 index 0000000..c364db0 --- /dev/null +++ b/docs/acceptance/track12/20260307_161629/track12-monitor.log @@ -0,0 +1,21 @@ +[2026-03-07T16:10:31+05:00] monitor {"status": "started", "window_seconds": 180} +[2026-03-07T16:10:31+05:00] auth {"role": "operator", "status": "ok", "user": "operator"} +[2026-03-07T16:10:31+05:00] live-calls [] +[2026-03-07T16:10:31+05:00] recent-calls [{"call_id": "1772881673.105", "caller_name": "1001", "caller_number": "1001", "channel_name": "PJSIP/2001-00000045", "claimed_at": "2026-03-07T11:08:26+00:00", "claimed_by_user": "operator", "connected_at": "2026-03-07T11:07:57+00:00", "ended_at": "2026-03-07T11:08:53+00:00", "hangup_cause": "16", "has_recording": true, "interaction_id": "int_052586cdaa", "last_transition_at": "2026-03-07T11:08:53+00:00", "operator_extension": "2001", "queue_code": "voice_lab", "queue_id": "que_23fb0ad07c", "started_at": "2026-03-07T11:07:55+00:00", "status": "ended", "telephony_status": "ended", "terminal_action": "hangup", "terminal_target": null, "updated_at": "2026-03-07T11:08:53+00:00"}] +[2026-03-07T16:10:31+05:00] voice-events [{"call_id": "1772881673.105", "created_at": "2026-03-07T11:08:53+00:00", "event_id": "vev_9245c8bb30", "event_type": "recording.ready", "interaction_id": "int_052586cdaa", "payload": {"duration_seconds": 58, "file_name": "20260307-160753-1772881673.105.wav", "linked_id": "1772881673.105", "mime_type": "audio/wav", "remote_path": "/var/spool/asterisk/monitor/mvpcc/20260307-160753-1772881673.105.wav", "source": "asterisk"}}, {"call_id": "1772881673.105", "created_at": "2026-03-07T11:08:53+00:00", "event_id": "vev_3e2c4eedc7", "event_type": "call.ended", "interaction_id": "int_052586cdaa", "payload": {"duration_seconds": 58, "hangup_cause": "16", "linked_id": "1772881673.105", "source": "asterisk"}}, {"call_id": "1772881673.105", "created_at": "2026-03-07T11:08:20+00:00", "event_id": "vev_dbf1ba4399", "event_type": "call.ended", "interaction_id": "int_052586cdaa", "payload": {"duration_seconds": null, "hangup_cause": "unknown", "linked_id": "1772881673.105", "reconciled": true, "source": "asterisk"}}, {"call_id": "1772881673.105", "created_at": "2026-03-07T11:07:57+00:00", "event_id": "vev_8be5ab34b9", "event_type": "call.connected", "interaction_id": "int_052586cdaa", "payload": {"channel_name": "PJSIP/2001-00000045", "claimed_by_user": null, "linked_id": "1772881673.105", "operator_extension": "2001", "source": "asterisk"}}, {"call_id": "1772881673.105", "created_at": "2026-03-07T11:07:55+00:00", "event_id": "vev_2b41d093c7", "event_type": "call.started", "interaction_id": "int_052586cdaa", "payload": {"caller_name": "1001", "caller_number": "1001", "context": "from-softphones", "direction": "inbound", "extension": "7000", "linked_id": "1772881673.105", "source": "asterisk"}}, {"call_id": "1772876380.93", "created_at": "2026-03-07T09:40:12+00:00", "event_id": "vev_4c44dd5888", "event_type": "recording.ready", "interaction_id": "int_69443612e1", "payload": {"duration_seconds": 31, "file_name": "20260307-143940-1772876380.93.wav", "linked_id": "1772876380.93", "mime_type": "audio/wav", "remote_path": "/var/spool/asterisk/monitor/mvpcc/20260307-143940-1772876380.93.wav", "source": "asterisk"}}] +[2026-03-07T16:10:31+05:00] call-links [{"call_id": "1772881673.105", "claimed_by_user": "operator", "connected_at": "2026-03-07T11:07:57+00:00", "ended_at": "2026-03-07T11:08:53+00:00", "interaction_id": "int_052586cdaa", "operator_extension": "2001", "started_at": "2026-03-07T11:07:55+00:00", "status": "ended", "telephony_status": "ended"}, {"call_id": "1772876380.93", "claimed_by_user": null, "connected_at": "2026-03-07T09:39:49+00:00", "ended_at": "2026-03-07T09:40:12+00:00", "interaction_id": "int_69443612e1", "operator_extension": "2001", "started_at": "2026-03-07T09:39:42+00:00", "status": "ended", "telephony_status": "ended"}, {"call_id": "1772875171.90", "claimed_by_user": null, "connected_at": "2026-03-07T09:19:36+00:00", "ended_at": "2026-03-07T09:19:39+00:00", "interaction_id": "int_726c36d95d", "operator_extension": "2001", "started_at": "2026-03-07T09:19:33+00:00", "status": "ended", "telephony_status": "ended"}, {"call_id": "1772874280.87", "claimed_by_user": null, "connected_at": "2026-03-07T09:04:47+00:00", "ended_at": "2026-03-07T09:04:54+00:00", "interaction_id": "int_6ef8abe6ec", "operator_extension": "2001", "started_at": "2026-03-07T09:04:42+00:00", "status": "ended", "telephony_status": "ended"}] +[2026-03-07T16:10:53+05:00] recent-calls [] +[2026-03-07T16:13:01+05:00] live-calls [{"call_id": "1772881979.108", "caller_name": "1001", "caller_number": "1001", "channel_name": "PJSIP/1001-00000046", "claimed_at": null, "claimed_by_user": null, "connected_at": null, "ended_at": null, "hangup_cause": null, "has_recording": false, "interaction_id": "int_0349796d1e", "last_transition_at": "2026-03-07T11:13:01+00:00", "operator_extension": null, "queue_code": "voice_lab", "queue_id": "que_23fb0ad07c", "started_at": "2026-03-07T11:13:01+00:00", "status": "active", "telephony_status": "ringing", "terminal_action": null, "terminal_target": null, "updated_at": "2026-03-07T11:13:01+00:00"}] +[2026-03-07T16:13:01+05:00] voice-events [{"call_id": "1772881979.108", "created_at": "2026-03-07T11:13:01+00:00", "event_id": "vev_8ece03ec07", "event_type": "call.started", "interaction_id": "int_0349796d1e", "payload": {"caller_name": "1001", "caller_number": "1001", "context": "from-softphones", "direction": "inbound", "extension": "7000", "linked_id": "1772881979.108", "source": "asterisk"}}, {"call_id": "1772881673.105", "created_at": "2026-03-07T11:08:53+00:00", "event_id": "vev_9245c8bb30", "event_type": "recording.ready", "interaction_id": "int_052586cdaa", "payload": {"duration_seconds": 58, "file_name": "20260307-160753-1772881673.105.wav", "linked_id": "1772881673.105", "mime_type": "audio/wav", "remote_path": "/var/spool/asterisk/monitor/mvpcc/20260307-160753-1772881673.105.wav", "source": "asterisk"}}, {"call_id": "1772881673.105", "created_at": "2026-03-07T11:08:53+00:00", "event_id": "vev_3e2c4eedc7", "event_type": "call.ended", "interaction_id": "int_052586cdaa", "payload": {"duration_seconds": 58, "hangup_cause": "16", "linked_id": "1772881673.105", "source": "asterisk"}}, {"call_id": "1772881673.105", "created_at": "2026-03-07T11:08:20+00:00", "event_id": "vev_dbf1ba4399", "event_type": "call.ended", "interaction_id": "int_052586cdaa", "payload": {"duration_seconds": null, "hangup_cause": "unknown", "linked_id": "1772881673.105", "reconciled": true, "source": "asterisk"}}, {"call_id": "1772881673.105", "created_at": "2026-03-07T11:07:57+00:00", "event_id": "vev_8be5ab34b9", "event_type": "call.connected", "interaction_id": "int_052586cdaa", "payload": {"channel_name": "PJSIP/2001-00000045", "claimed_by_user": null, "linked_id": "1772881673.105", "operator_extension": "2001", "source": "asterisk"}}, {"call_id": "1772881673.105", "created_at": "2026-03-07T11:07:55+00:00", "event_id": "vev_2b41d093c7", "event_type": "call.started", "interaction_id": "int_052586cdaa", "payload": {"caller_name": "1001", "caller_number": "1001", "context": "from-softphones", "direction": "inbound", "extension": "7000", "linked_id": "1772881673.105", "source": "asterisk"}}] +[2026-03-07T16:13:01+05:00] call-links [{"call_id": "1772881979.108", "claimed_by_user": null, "connected_at": null, "ended_at": null, "interaction_id": "int_0349796d1e", "operator_extension": null, "started_at": "2026-03-07T11:13:01+00:00", "status": "active", "telephony_status": "ringing"}, {"call_id": "1772881673.105", "claimed_by_user": "operator", "connected_at": "2026-03-07T11:07:57+00:00", "ended_at": "2026-03-07T11:08:53+00:00", "interaction_id": "int_052586cdaa", "operator_extension": "2001", "started_at": "2026-03-07T11:07:55+00:00", "status": "ended", "telephony_status": "ended"}, {"call_id": "1772876380.93", "claimed_by_user": null, "connected_at": "2026-03-07T09:39:49+00:00", "ended_at": "2026-03-07T09:40:12+00:00", "interaction_id": "int_69443612e1", "operator_extension": "2001", "started_at": "2026-03-07T09:39:42+00:00", "status": "ended", "telephony_status": "ended"}, {"call_id": "1772875171.90", "claimed_by_user": null, "connected_at": "2026-03-07T09:19:36+00:00", "ended_at": "2026-03-07T09:19:39+00:00", "interaction_id": "int_726c36d95d", "operator_extension": "2001", "started_at": "2026-03-07T09:19:33+00:00", "status": "ended", "telephony_status": "ended"}] +[2026-03-07T16:13:06+05:00] live-calls [{"call_id": "1772881979.108", "caller_name": "1001", "caller_number": "1001", "channel_name": "PJSIP/2001-00000047", "claimed_at": null, "claimed_by_user": null, "connected_at": "2026-03-07T11:13:05+00:00", "ended_at": null, "hangup_cause": null, "has_recording": false, "interaction_id": "int_0349796d1e", "last_transition_at": "2026-03-07T11:13:05+00:00", "operator_extension": "2001", "queue_code": "voice_lab", "queue_id": "que_23fb0ad07c", "started_at": "2026-03-07T11:13:01+00:00", "status": "active", "telephony_status": "connected", "terminal_action": null, "terminal_target": null, "updated_at": "2026-03-07T11:13:05+00:00"}] +[2026-03-07T16:13:06+05:00] voice-events [{"call_id": "1772881979.108", "created_at": "2026-03-07T11:13:05+00:00", "event_id": "vev_e3bec0b0e6", "event_type": "call.connected", "interaction_id": "int_0349796d1e", "payload": {"channel_name": "PJSIP/2001-00000047", "claimed_by_user": null, "linked_id": "1772881979.108", "operator_extension": "2001", "source": "asterisk"}}, {"call_id": "1772881979.108", "created_at": "2026-03-07T11:13:01+00:00", "event_id": "vev_8ece03ec07", "event_type": "call.started", "interaction_id": "int_0349796d1e", "payload": {"caller_name": "1001", "caller_number": "1001", "context": "from-softphones", "direction": "inbound", "extension": "7000", "linked_id": "1772881979.108", "source": "asterisk"}}, {"call_id": "1772881673.105", "created_at": "2026-03-07T11:08:53+00:00", "event_id": "vev_9245c8bb30", "event_type": "recording.ready", "interaction_id": "int_052586cdaa", "payload": {"duration_seconds": 58, "file_name": "20260307-160753-1772881673.105.wav", "linked_id": "1772881673.105", "mime_type": "audio/wav", "remote_path": "/var/spool/asterisk/monitor/mvpcc/20260307-160753-1772881673.105.wav", "source": "asterisk"}}, {"call_id": "1772881673.105", "created_at": "2026-03-07T11:08:53+00:00", "event_id": "vev_3e2c4eedc7", "event_type": "call.ended", "interaction_id": "int_052586cdaa", "payload": {"duration_seconds": 58, "hangup_cause": "16", "linked_id": "1772881673.105", "source": "asterisk"}}, {"call_id": "1772881673.105", "created_at": "2026-03-07T11:08:20+00:00", "event_id": "vev_dbf1ba4399", "event_type": "call.ended", "interaction_id": "int_052586cdaa", "payload": {"duration_seconds": null, "hangup_cause": "unknown", "linked_id": "1772881673.105", "reconciled": true, "source": "asterisk"}}, {"call_id": "1772881673.105", "created_at": "2026-03-07T11:07:57+00:00", "event_id": "vev_8be5ab34b9", "event_type": "call.connected", "interaction_id": "int_052586cdaa", "payload": {"channel_name": "PJSIP/2001-00000045", "claimed_by_user": null, "linked_id": "1772881673.105", "operator_extension": "2001", "source": "asterisk"}}] +[2026-03-07T16:13:06+05:00] call-links [{"call_id": "1772881979.108", "claimed_by_user": null, "connected_at": "2026-03-07T11:13:05+00:00", "ended_at": null, "interaction_id": "int_0349796d1e", "operator_extension": "2001", "started_at": "2026-03-07T11:13:01+00:00", "status": "active", "telephony_status": "connected"}, {"call_id": "1772881673.105", "claimed_by_user": "operator", "connected_at": "2026-03-07T11:07:57+00:00", "ended_at": "2026-03-07T11:08:53+00:00", "interaction_id": "int_052586cdaa", "operator_extension": "2001", "started_at": "2026-03-07T11:07:55+00:00", "status": "ended", "telephony_status": "ended"}, {"call_id": "1772876380.93", "claimed_by_user": null, "connected_at": "2026-03-07T09:39:49+00:00", "ended_at": "2026-03-07T09:40:12+00:00", "interaction_id": "int_69443612e1", "operator_extension": "2001", "started_at": "2026-03-07T09:39:42+00:00", "status": "ended", "telephony_status": "ended"}, {"call_id": "1772875171.90", "claimed_by_user": null, "connected_at": "2026-03-07T09:19:36+00:00", "ended_at": "2026-03-07T09:19:39+00:00", "interaction_id": "int_726c36d95d", "operator_extension": "2001", "started_at": "2026-03-07T09:19:33+00:00", "status": "ended", "telephony_status": "ended"}] +[2026-03-07T16:13:27+05:00] live-calls [{"call_id": "1772881979.108", "caller_name": "1001", "caller_number": "1001", "channel_name": "PJSIP/2001-00000047", "claimed_at": null, "claimed_by_user": null, "connected_at": "2026-03-07T11:13:05+00:00", "ended_at": null, "hangup_cause": "unknown", "has_recording": false, "interaction_id": "int_0349796d1e", "last_transition_at": "2026-03-07T11:13:26+00:00", "operator_extension": "2001", "queue_code": "voice_lab", "queue_id": "que_23fb0ad07c", "started_at": "2026-03-07T11:13:01+00:00", "status": "active", "telephony_status": "connected", "terminal_action": null, "terminal_target": null, "updated_at": "2026-03-07T11:13:26+00:00"}] +[2026-03-07T16:13:27+05:00] voice-events [{"call_id": "1772881979.108", "created_at": "2026-03-07T11:13:26+00:00", "event_id": "vev_9a03fc9ff8", "event_type": "call.ended", "interaction_id": "int_0349796d1e", "payload": {"duration_seconds": null, "hangup_cause": "unknown", "linked_id": "1772881979.108", "reconciled": true, "source": "asterisk"}}, {"call_id": "1772881979.108", "created_at": "2026-03-07T11:13:05+00:00", "event_id": "vev_e3bec0b0e6", "event_type": "call.connected", "interaction_id": "int_0349796d1e", "payload": {"channel_name": "PJSIP/2001-00000047", "claimed_by_user": null, "linked_id": "1772881979.108", "operator_extension": "2001", "source": "asterisk"}}, {"call_id": "1772881979.108", "created_at": "2026-03-07T11:13:01+00:00", "event_id": "vev_8ece03ec07", "event_type": "call.started", "interaction_id": "int_0349796d1e", "payload": {"caller_name": "1001", "caller_number": "1001", "context": "from-softphones", "direction": "inbound", "extension": "7000", "linked_id": "1772881979.108", "source": "asterisk"}}, {"call_id": "1772881673.105", "created_at": "2026-03-07T11:08:53+00:00", "event_id": "vev_9245c8bb30", "event_type": "recording.ready", "interaction_id": "int_052586cdaa", "payload": {"duration_seconds": 58, "file_name": "20260307-160753-1772881673.105.wav", "linked_id": "1772881673.105", "mime_type": "audio/wav", "remote_path": "/var/spool/asterisk/monitor/mvpcc/20260307-160753-1772881673.105.wav", "source": "asterisk"}}, {"call_id": "1772881673.105", "created_at": "2026-03-07T11:08:53+00:00", "event_id": "vev_3e2c4eedc7", "event_type": "call.ended", "interaction_id": "int_052586cdaa", "payload": {"duration_seconds": 58, "hangup_cause": "16", "linked_id": "1772881673.105", "source": "asterisk"}}, {"call_id": "1772881673.105", "created_at": "2026-03-07T11:08:20+00:00", "event_id": "vev_dbf1ba4399", "event_type": "call.ended", "interaction_id": "int_052586cdaa", "payload": {"duration_seconds": null, "hangup_cause": "unknown", "linked_id": "1772881673.105", "reconciled": true, "source": "asterisk"}}] +[2026-03-07T16:13:30+05:00] live-calls [] +[2026-03-07T16:13:30+05:00] recent-calls [{"call_id": "1772881979.108", "caller_name": "1001", "caller_number": "1001", "channel_name": "PJSIP/2001-00000047", "claimed_at": null, "claimed_by_user": null, "connected_at": "2026-03-07T11:13:05+00:00", "ended_at": "2026-03-07T11:13:30+00:00", "hangup_cause": "16", "has_recording": false, "interaction_id": "int_0349796d1e", "last_transition_at": "2026-03-07T11:13:30+00:00", "operator_extension": "2001", "queue_code": "voice_lab", "queue_id": "que_23fb0ad07c", "started_at": "2026-03-07T11:13:01+00:00", "status": "ended", "telephony_status": "ended", "terminal_action": "ended", "terminal_target": null, "updated_at": "2026-03-07T11:13:30+00:00"}] +[2026-03-07T16:13:30+05:00] voice-events [{"call_id": "1772881979.108", "created_at": "2026-03-07T11:13:30+00:00", "event_id": "vev_42cd34ba3b", "event_type": "recording.ready", "interaction_id": "int_0349796d1e", "payload": {"duration_seconds": 28, "file_name": "20260307-161300-1772881979.108.wav", "linked_id": "1772881979.108", "mime_type": "audio/wav", "remote_path": "/var/spool/asterisk/monitor/mvpcc/20260307-161300-1772881979.108.wav", "source": "asterisk"}}, {"call_id": "1772881979.108", "created_at": "2026-03-07T11:13:30+00:00", "event_id": "vev_dcf3378912", "event_type": "call.ended", "interaction_id": "int_0349796d1e", "payload": {"duration_seconds": 28, "hangup_cause": "16", "linked_id": "1772881979.108", "source": "asterisk"}}, {"call_id": "1772881979.108", "created_at": "2026-03-07T11:13:26+00:00", "event_id": "vev_9a03fc9ff8", "event_type": "call.ended", "interaction_id": "int_0349796d1e", "payload": {"duration_seconds": null, "hangup_cause": "unknown", "linked_id": "1772881979.108", "reconciled": true, "source": "asterisk"}}, {"call_id": "1772881979.108", "created_at": "2026-03-07T11:13:05+00:00", "event_id": "vev_e3bec0b0e6", "event_type": "call.connected", "interaction_id": "int_0349796d1e", "payload": {"channel_name": "PJSIP/2001-00000047", "claimed_by_user": null, "linked_id": "1772881979.108", "operator_extension": "2001", "source": "asterisk"}}, {"call_id": "1772881979.108", "created_at": "2026-03-07T11:13:01+00:00", "event_id": "vev_8ece03ec07", "event_type": "call.started", "interaction_id": "int_0349796d1e", "payload": {"caller_name": "1001", "caller_number": "1001", "context": "from-softphones", "direction": "inbound", "extension": "7000", "linked_id": "1772881979.108", "source": "asterisk"}}, {"call_id": "1772881673.105", "created_at": "2026-03-07T11:08:53+00:00", "event_id": "vev_9245c8bb30", "event_type": "recording.ready", "interaction_id": "int_052586cdaa", "payload": {"duration_seconds": 58, "file_name": "20260307-160753-1772881673.105.wav", "linked_id": "1772881673.105", "mime_type": "audio/wav", "remote_path": "/var/spool/asterisk/monitor/mvpcc/20260307-160753-1772881673.105.wav", "source": "asterisk"}}] +[2026-03-07T16:13:30+05:00] call-links [{"call_id": "1772881979.108", "claimed_by_user": null, "connected_at": "2026-03-07T11:13:05+00:00", "ended_at": "2026-03-07T11:13:30+00:00", "interaction_id": "int_0349796d1e", "operator_extension": "2001", "started_at": "2026-03-07T11:13:01+00:00", "status": "ended", "telephony_status": "ended"}, {"call_id": "1772881673.105", "claimed_by_user": "operator", "connected_at": "2026-03-07T11:07:57+00:00", "ended_at": "2026-03-07T11:08:53+00:00", "interaction_id": "int_052586cdaa", "operator_extension": "2001", "started_at": "2026-03-07T11:07:55+00:00", "status": "ended", "telephony_status": "ended"}, {"call_id": "1772876380.93", "claimed_by_user": null, "connected_at": "2026-03-07T09:39:49+00:00", "ended_at": "2026-03-07T09:40:12+00:00", "interaction_id": "int_69443612e1", "operator_extension": "2001", "started_at": "2026-03-07T09:39:42+00:00", "status": "ended", "telephony_status": "ended"}, {"call_id": "1772875171.90", "claimed_by_user": null, "connected_at": "2026-03-07T09:19:36+00:00", "ended_at": "2026-03-07T09:19:39+00:00", "interaction_id": "int_726c36d95d", "operator_extension": "2001", "started_at": "2026-03-07T09:19:33+00:00", "status": "ended", "telephony_status": "ended"}] +[2026-03-07T16:13:31+05:00] recent-calls [{"call_id": "1772881979.108", "caller_name": "1001", "caller_number": "1001", "channel_name": "PJSIP/2001-00000047", "claimed_at": null, "claimed_by_user": null, "connected_at": "2026-03-07T11:13:05+00:00", "ended_at": "2026-03-07T11:13:30+00:00", "hangup_cause": "16", "has_recording": true, "interaction_id": "int_0349796d1e", "last_transition_at": "2026-03-07T11:13:30+00:00", "operator_extension": "2001", "queue_code": "voice_lab", "queue_id": "que_23fb0ad07c", "started_at": "2026-03-07T11:13:01+00:00", "status": "ended", "telephony_status": "ended", "terminal_action": "ended", "terminal_target": null, "updated_at": "2026-03-07T11:13:30+00:00"}] +[2026-03-07T16:13:32+05:00] monitor {"status": "finished"} \ No newline at end of file diff --git a/docs/acceptance/track12/track12-acceptance.template.md b/docs/acceptance/track12/track12-acceptance.template.md new file mode 100644 index 0000000..a5eb685 --- /dev/null +++ b/docs/acceptance/track12/track12-acceptance.template.md @@ -0,0 +1,38 @@ +# Track 12 Acceptance - + +## Environment + +- Base URL: `` +- Database: `` +- Asterisk host: `` +- Operator user: `` +- Operator extension: `` + +## Executed scenarios + +- [ ] Happy path: answer in SIP -> `Принять в работу` -> `Завершить` +- [ ] Transfer path: answer in SIP -> `Принять в работу` -> `Передать` +- [ ] Passive path: answer in SIP, no browser action, call stays active until real hangup +- [ ] No-fake-cards path: `Живые звонки` does not show `interaction:int_*` + +## Evidence + +- Active live call snapshot: `operator-live-calls.json` +- Recent call snapshot: `operator-recent-calls.json` +- Action log: `bridge-actions.json` +- Playback proof: `playback-proof.json` + +## Mandatory checks + +- [ ] `GET /proxy/asterisk-bridge/asterisk/live-calls` returns only active calls +- [ ] `GET /proxy/asterisk-bridge/asterisk/recent-calls` returns only recent ended/transferred calls +- [ ] `claim` keeps the card in `Активные звонки` +- [ ] `hangup` moves the card to `Только что завершённые` +- [ ] `blind transfer` moves the card to `Только что завершённые` +- [ ] recording is available in `/supervisor` + +## Decision + +- GO / NO-GO: `` +- Notes: + - `` diff --git a/docs/acceptance/track14/track14-acceptance.template.md b/docs/acceptance/track14/track14-acceptance.template.md new file mode 100644 index 0000000..d64cd08 --- /dev/null +++ b/docs/acceptance/track14/track14-acceptance.template.md @@ -0,0 +1,38 @@ +# Track 14 Acceptance + +## Environment + +- Browser: `` +- Gateway base URL: `` +- Asterisk host: `` +- WSS URL: `` +- Operator app user: `` +- Browser SIP auth user: `` + +## Scenario + +1. Open `/operator` in Chrome or Edge. +2. Connect browser softphone. +3. Place one inbound call `1001 -> 7000`. +4. Answer in the browser. +5. Confirm auto-claim or perform manual `Принять в работу`. +6. Hang up through the browser path. +7. Confirm the call moves to recent and the recording is playable in `/supervisor`. + +## Evidence + +- Browser softphone registration state screenshot +- Operator live-call screenshot while ringing or connected +- Browser answer or hangup action proof +- Bridge actions snapshot +- Recent-calls snapshot +- Recording playback proof + +## Result + +- `GO` or `NO-GO` +- Notes: + - browser registration + - answer behavior + - auto-claim result + - recording result diff --git a/docs/acceptance/track15/track15-acceptance.template.md b/docs/acceptance/track15/track15-acceptance.template.md new file mode 100644 index 0000000..9b3a2a9 --- /dev/null +++ b/docs/acceptance/track15/track15-acceptance.template.md @@ -0,0 +1,41 @@ +# Track 15 Acceptance + +## Environment + +- Base URL: +- Telegram bot username: +- Queue ID: +- Operator user: +- Date/time: + +## Scenario + +1. Send the first Telegram message to the bot. +2. Open `/operator` -> `Telegram`. +3. Confirm a new thread appears and links to a Telegram interaction. +4. Claim the thread. +5. Send a reply from the operator page. +6. Send another inbound message from the same Telegram chat. +7. Confirm the same thread and same interaction are reused. +8. Close the thread. +9. Send one more inbound message and confirm reactivation to `new`. + +## Expected results + +- thread created on first inbound +- same thread reused on repeated inbound from the same `chat_id` +- operator reply persisted and delivered +- closed interaction reactivated on new inbound +- unsupported content, if tested, is shown as a system placeholder + +## Evidence + +- thread payload: +- messages payload: +- linked interaction payload: +- reply proof: +- reactivation proof: + +## Result + +- PASS / FAIL diff --git a/docs/acceptance/track9/README.md b/docs/acceptance/track9/README.md new file mode 100644 index 0000000..7681e48 --- /dev/null +++ b/docs/acceptance/track9/README.md @@ -0,0 +1,27 @@ +# Track 9 Acceptance Evidence + +Use this folder for formal QA-lab acceptance artifacts for Asterisk integration. + +Recommended structure: + +- `docs/acceptance/track9//...` for each run +- include: + - bridge status snapshots + - smoke/check outputs + - sampled DB evidence + - final acceptance note + +Generate a baseline package with: + +```powershell +python scripts\track9_collect_evidence.py --base-url http://localhost:8080 --database-url --run-checks --require-recording +``` + +When `--run-checks` is used, the package now includes: +- `preflight_output.txt` +- `smoke_output.txt` +- `track9_check_output.txt` + +For production cutover tracking (Track 9.2), start from: + +- [track9-cutover-sheet.template.md](/e:/Zhan/docs/acceptance/track9/track9-cutover-sheet.template.md) diff --git a/docs/acceptance/track9/track9-acceptance.md b/docs/acceptance/track9/track9-acceptance.md new file mode 100644 index 0000000..859d6ad --- /dev/null +++ b/docs/acceptance/track9/track9-acceptance.md @@ -0,0 +1,50 @@ +# Track 9 Acceptance (Latest QA Run) + +## Environment + +- Date: `2026-03-05` +- Host: local QA lab (`127.0.0.1`) +- Gateway URL: `http://127.0.0.1:8080` +- Asterisk VM host: `92.38.48.166` +- Database URL (masked): `sqlite:///.data_local/mvp_cc.db` +- Evidence package: [20260305_160002](/e:/Zhan/docs/acceptance/track9/20260305_160002) + +## Executed scenario + +1. Softphone `1001` called `7000`. +2. Bridge reported AMI connected. +3. `call.started` received from Asterisk source. +4. `call.ended` received. +5. `recording.ready` processed and recording uploaded. +6. Recording playback validated in supervisor shell. + +## Validation commands + +```powershell +python scripts\track9_preflight.py --base-url --check-sftp +python scripts\asterisk_lab_smoke.py --base-url --database-url --require-recording +python scripts\track9_check.py --base-url --database-url --require-recording +python scripts\track9_collect_evidence.py --base-url --database-url --run-checks --require-recording +``` + +## Result summary + +- AMI connected: `yes` +- call.started present: `yes` +- call.ended present: `yes` +- call links present: `yes` +- recording imported: `yes` +- failed bridge events: `0` +- smoke: `PASS` +- track9_check: `PASS` +- preflight: `PASS` + +## Risks / open items + +- Asterisk dialplan still emits some duplicate/late lifecycle signals in lab runs; bridge reconciliation masks this and keeps platform data consistent. +- For production cutover, keep strict auth and rollback window from Track 9.2 sheet. + +## Decision + +- [x] GO +- [ ] NO-GO diff --git a/docs/acceptance/track9/track9-cutover-sheet.template.md b/docs/acceptance/track9/track9-cutover-sheet.template.md new file mode 100644 index 0000000..06c37a9 --- /dev/null +++ b/docs/acceptance/track9/track9-cutover-sheet.template.md @@ -0,0 +1,87 @@ +# Track 9.2 Cutover Sheet (Template) + +## Window + +- Date: +- Start time: +- End time: +- Change ticket: + +## Owners + +- K8s owner: +- Voice/Asterisk owner: +- Application owner: +- On-call: + +## Environment contract + +- `KUBE_CONTEXT`: +- `NAMESPACE`: +- `RELEASE`: +- `GATEWAY_BASE_URL`: +- `DATABASE_URL`: +- `IMAGE_TAG`: +- `ASTERISK_AMI_HOST`: +- `ASTERISK_SFTP_HOST`: +- `QUEUE_ID (voice_lab)`: + +## Entry checks (T-1 day) + +- [ ] QA baseline accepted: + - `docs/acceptance/track9/track9-acceptance.md` +- [ ] Production queue map validated (`voice_lab -> queue_id` exists) +- [ ] AMI port reachable (`5038/tcp`) +- [ ] SFTP port reachable (`22/tcp`) +- [ ] PostgreSQL confirmed for target env + +## Dry-run results (T-4h to T-1h) + +- [ ] `helm lint` PASS +- [ ] `helm template` PASS +- [ ] strict env keys found in rendered manifest +- Artifact folder: + +## Execution (T0) + +- Helm command: +- Start time: +- End time: +- Result: + +Rollout statuses: + +- [ ] `api-gateway` ready +- [ ] `asterisk-bridge-service` ready +- [ ] `voice-adapter-service` ready +- [ ] `recording-service` ready + +## Post-cutover checks + +- [ ] `track9_preflight --check-sftp --require-strict-service-auth` PASS +- [ ] Real call completed (`1001 -> 7000`) +- [ ] `asterisk_lab_smoke --require-recording` PASS +- [ ] `track9_check --require-recording` PASS +- [ ] Supervisor playback confirmed (`HTTP 200`, `audio/*`) + +## Evidence + +- Evidence folder: +- `track9-acceptance.md`: +- `playback-proof.md`: +- `playback-proof.json` (if used): + +## Decision + +- [ ] GO +- [ ] NO-GO + +Reason: + +## Rollback section (fill if NO-GO) + +- Trigger: +- Rollback command: +- Rollback completion time: +- Post-rollback preflight result: +- Incident / RCA ticket: diff --git a/docs/acceptance/track9_1/README.md b/docs/acceptance/track9_1/README.md new file mode 100644 index 0000000..2241116 --- /dev/null +++ b/docs/acceptance/track9_1/README.md @@ -0,0 +1,18 @@ +# Track 9.1 Acceptance (Latest Strict Auth Baseline) + +## Summary + +Track 9.1 captures the strict service-token hardening pass for the Asterisk bridge flow. + +- strict auth profile: `ALLOW_LEGACY_HEADER_AUTH=0` +- bridge auth mode: `ASTERISK_BRIDGE_AUTH_MODE=bearer` +- trusted service subjects enforced for voice ingest and recording import + +## Latest retained evidence package + +- [20260305_042801](/e:/Zhan/docs/acceptance/track9_1/20260305_042801) + +## Notes + +- Earlier timestamp bundles were reduced during repository cleanup. +- Use the retained package for low-level QA artifacts and Track 9.1 reference data. diff --git a/docs/architecture/api-matrix.md b/docs/architecture/api-matrix.md new file mode 100644 index 0000000..496b950 --- /dev/null +++ b/docs/architecture/api-matrix.md @@ -0,0 +1,91 @@ +# API Matrix by Stage + +## Stage 1 +- auth-service: `/auth/login`, `/auth/me`, `/users` +- audit-service: `/audit/events` +- all services: `/health` + +## Stage 2 +- customer-service: `/customers` +- interaction-service: `/interactions` +- routing-service: `/queues` +- voice-adapter-service: `/integrations/voice/events` +- telegram-adapter-service: `/integrations/telegram/webhook` + +## Stage 3 +- kb-service: `/knowledge/*` +- reporting-service: `/reports/kpi`, `/reports/export` +- supervisor-service: `/supervisor/realtime` + +## Stage 4 +- recording-service: `/recordings`, `/recordings/*` + +## Stage 5 +- ivr-service: `/ivr/flows`, `/ivr/flows/*`, `/ivr/sessions`, `/ivr/sessions/*` +- routing-service: `/queues/{queue_id}/route` with optional `ivr_session_id` + +## Stage 6 +- reporting-service: `/reports/kpi` with optional `channel` +- reporting-service: `/reports/coverage` +- reporting-service: `/reports/export` with optional `queue_id` and `channel` + +## Stage 7 +- no new business REST routes +- ops CLI: + - `scripts/load_test.py` with profile-driven mixed workload + - `scripts/track7_check.py` for scale/hardening evidence validation + +## Stage 8 +- no new business REST routes +- event-bus-service (ops only): `/bus/outbox`, `/bus/outbox/*` +- ops CLI: + - `scripts/event_bus_smoke.py` + - `scripts/track8_check.py` + +## Stage 9 +- no new business REST routes +- recording-service: `/recordings/import-upload` (internal bridge upload) +- asterisk-bridge-service (ops only): `/asterisk/status`, `/asterisk/events`, `/asterisk/events/*` +- bridge-to-platform auth modes: + - `legacy_headers` (QA baseline) + - `bearer_first` / `bearer` (Track 9.1 hardening) +- ops CLI: + - `scripts/track9_preflight.py` + - `scripts/asterisk_lab_smoke.py` + - `scripts/track9_check.py` + - `scripts/track9_collect_evidence.py` + - `scripts/track9_2_cutover.ps1` (controlled Helm cutover automation) + +## Stage 11 +- no new business REST routes +- asterisk-bridge-service call-control routes: + - `/asterisk/live-calls` + - `/asterisk/live-calls/{call_id}/claim` + - `/asterisk/live-calls/{call_id}/hangup` + - `/asterisk/live-calls/{call_id}/blind-transfer` + - `/asterisk/live-calls/{call_id}/actions` +- operator shell uses these routes for live call handling with external softphone media + +## Stage 12 +- no new business REST routes +- additive operator UX route: + - `/asterisk/recent-calls` +- operator shell uses this route for the short-lived recent-calls bucket + +## Stage 14 +- no new business REST routes +- additive browser softphone config route: + - `/asterisk/browser-softphone/config` +- operator shell may use direct browser media over Asterisk WSS in QA + +## Stage 15 +- telegram-adapter-service real-bot webhook: + - `/integrations/telegram/bot/webhook` +- telegram-adapter-service operator thread workspace: + - `/integrations/telegram/threads` + - `/integrations/telegram/threads/{thread_id}` + - `/integrations/telegram/threads/{thread_id}/messages` + - `/integrations/telegram/threads/{thread_id}/claim` + - `/integrations/telegram/threads/{thread_id}/close` + - `/integrations/telegram/threads/{thread_id}/escalate` +- operator shell gets a separate `Telegram` page as the canonical surface for this channel diff --git a/docs/architecture/asterisk-user-events.md b/docs/architecture/asterisk-user-events.md new file mode 100644 index 0000000..3bb1885 --- /dev/null +++ b/docs/architecture/asterisk-user-events.md @@ -0,0 +1,59 @@ +# Asterisk UserEvent Contract + +Track 9 uses dialplan-generated `UserEvent` frames over AMI as the stable bridge contract +between Asterisk and the platform. + +The bridge listens only for `UserEvent` names starting with `MVPCC`. + +## `MVPCCCallStarted` + +Required fields: + +- `CallID` +- `LinkedID` +- `CallerNumber` +- `CallerName` +- `QueueCode` +- `Extension` +- `Context` +- `Direction` + +Example: + +```text +Event: UserEvent +UserEvent: MVPCCCallStarted +CallID: 1740912000.12 +LinkedID: 1740912000.12 +CallerNumber: 1001 +CallerName: Lab Caller +QueueCode: voice_lab +Extension: 7000 +Context: from-softphones +Direction: inbound +``` + +## `MVPCCCallEnded` + +Required fields: + +- `CallID` +- `LinkedID` +- `HangupCause` +- `DurationSeconds` + +## `MVPCCRecordingReady` + +Required fields: + +- `CallID` +- `LinkedID` +- `RemotePath` +- `FileName` +- `MimeType` +- `DurationSeconds` + +Notes: + +- `RemotePath` is the absolute path on the Asterisk Linux VM. +- The platform downloads the file over SFTP, then uploads it into `recording-service`. diff --git a/docs/architecture/call.recording.ready.json b/docs/architecture/call.recording.ready.json new file mode 100644 index 0000000..2ca774e --- /dev/null +++ b/docs/architecture/call.recording.ready.json @@ -0,0 +1,44 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "call.recording.ready.json", + "title": "call.recording.ready", + "type": "object", + "required": ["event_type", "call_id", "payload"], + "properties": { + "event_type": { + "const": "recording.ready" + }, + "call_id": { + "type": "string", + "minLength": 1 + }, + "interaction_id": { + "type": ["string", "null"] + }, + "payload": { + "type": "object", + "required": ["source_path"], + "properties": { + "source_path": { + "type": "string", + "minLength": 1 + }, + "file_name": { + "type": ["string", "null"] + }, + "mime_type": { + "type": ["string", "null"] + }, + "duration_seconds": { + "type": ["integer", "null"], + "minimum": 0 + }, + "recorded_at": { + "type": ["string", "null"] + } + }, + "additionalProperties": true + } + }, + "additionalProperties": true +} diff --git a/docs/architecture/event-envelope.json b/docs/architecture/event-envelope.json new file mode 100644 index 0000000..011a70f --- /dev/null +++ b/docs/architecture/event-envelope.json @@ -0,0 +1,30 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://mvp-cc.local/docs/architecture/event-envelope.json", + "title": "EventEnvelope", + "type": "object", + "required": [ + "event_id", + "event_type", + "event_version", + "occurred_at", + "producer", + "entity_type", + "entity_id", + "routing_key", + "payload" + ], + "properties": { + "event_id": {"type": "string", "minLength": 1}, + "event_type": {"type": "string", "minLength": 1}, + "event_version": {"type": "integer", "minimum": 1}, + "occurred_at": {"type": "string", "format": "date-time"}, + "producer": {"type": "string", "minLength": 1}, + "entity_type": {"type": "string", "minLength": 1}, + "entity_id": {"type": "string", "minLength": 1}, + "correlation_id": {"type": ["string", "null"]}, + "routing_key": {"type": "string", "minLength": 1}, + "payload": {"type": "object"} + }, + "additionalProperties": true +} diff --git a/docs/architecture/event-schemas.md b/docs/architecture/event-schemas.md new file mode 100644 index 0000000..501e6b0 --- /dev/null +++ b/docs/architecture/event-schemas.md @@ -0,0 +1,37 @@ +# Event Schema Index + +- `interaction.created.json` +- `interaction.assigned.json` +- `interaction.escalated.json` +- `interaction.closed.json` +- `agent.state.changed.json` +- `call.recording.ready.json` +- `call.connected.json` +- `call.transferred.json` +- `ivr.completed.json` + +Each file is a JSON Schema (draft 2020-12) and can be used as a validation contract between services. + +Track 8 introduces a canonical transport envelope for bus-delivered events: + +- `docs/architecture/event-envelope.json` + +Payload-specific schemas under `contracts/events` remain the authoritative contracts for the +domain payload inside that envelope. + +Track 4 now provides the concrete schema file for the recording import placeholder: + +- `docs/architecture/call.recording.ready.json` + +Track 5 adds the concrete schema for IVR completion: + +- `docs/architecture/ivr.completed.json` + +Track 9 adds the dialplan-to-platform `UserEvent` contract reference: + +- `docs/architecture/asterisk-user-events.md` + +Track 11 adds additive voice lifecycle event types produced by call-control flow: + +- `call.connected` (operator connected to customer) +- `call.transferred` (blind transfer requested by operator/admin) diff --git a/docs/architecture/ivr.completed.json b/docs/architecture/ivr.completed.json new file mode 100644 index 0000000..14945c5 --- /dev/null +++ b/docs/architecture/ivr.completed.json @@ -0,0 +1,54 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://mvp-cc.local/contracts/events/ivr.completed.json", + "title": "ivr.completed", + "type": "object", + "required": ["event_type", "call_id", "payload"], + "properties": { + "event_type": { + "const": "ivr.completed" + }, + "call_id": { + "type": "string", + "minLength": 1 + }, + "interaction_id": { + "type": ["string", "null"] + }, + "payload": { + "type": "object", + "required": ["session_id", "flow_id", "outcome_code", "resolved_queue_id", "digits", "terminal_node_id"], + "properties": { + "session_id": { + "type": "string", + "minLength": 1 + }, + "flow_id": { + "type": "string", + "minLength": 1 + }, + "outcome_code": { + "type": "string", + "minLength": 1 + }, + "resolved_queue_id": { + "type": "string", + "minLength": 1 + }, + "digits": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[0-9]$" + } + }, + "terminal_node_id": { + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": true + } + }, + "additionalProperties": true +} diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md new file mode 100644 index 0000000..69ab2c6 --- /dev/null +++ b/docs/architecture/overview.md @@ -0,0 +1,25 @@ +# Architecture Overview + +## Services +- api-gateway +- auth-service +- audit-service +- customer-service +- interaction-service +- routing-service +- voice-adapter-service +- telegram-adapter-service +- kb-service +- reporting-service +- supervisor-service + +## Data and event strategy +- All services use SQL storage via `DATABASE_URL` +- Default local mode: SQLite (`.data/mvp_cc.db`) +- Target mode: PostgreSQL (supported in `deployment/docker-compose.yml`) +- Contract-first API and JSON event schema artifacts +- Event contracts are fixed and ready for message bus integration (RabbitMQ/Kafka) in next phase + +## Deployment +- Local: Docker Compose +- On-prem: Kubernetes manifests and Helm chart diff --git a/docs/architecture/voice-ai-v1.md b/docs/architecture/voice-ai-v1.md new file mode 100644 index 0000000..a6c9e2b --- /dev/null +++ b/docs/architecture/voice-ai-v1.md @@ -0,0 +1,744 @@ +# Voice AI V1 Technical Design + +Status: `Proposed` + +## 1. Goal + +Add an AI-first voice operator on top of the accepted Asterisk voice baseline without replacing the current telephony stack. + +Voice AI V1 must: + +- answer selected inbound calls before a human joins; +- reuse the existing `interaction`, `customer`, `KB`, `queue`, and operator UI model; +- keep Asterisk as the system of record for call lifecycle, transfer, recording, SIP/WebRTC, and queue ownership; +- store business state in platform tables, not only inside model context; +- support deterministic AI-to-human handoff into the existing human queue. + +## 2. Non-goals + +Voice AI V1 does not: + +- replace Asterisk, AMI, dialplan, queueing, SIP/WebRTC, or recording; +- introduce a separate AI telephony platform or second source of truth for calls; +- push full CRM history, full call transcript, or raw recordings into every model turn; +- introduce a multi-agent swarm; +- add attended transfer, conference, or full human-to-AI return in the same live call. + +`return-to-ai` after human takeover is intentionally deferred to V2. It requires a second telephony redirect and fresh media re-attachment, which is riskier than AI-first plus human handoff. + +## 3. Existing Baseline We Build On + +The current repo already has the pieces needed for Voice AI V1: + +- `asterisk_bridge_service` owns AMI ingestion, live call tracking, claim/hangup/transfer, and recording import. +- `voice_adapter_service` stores telephony lifecycle events. +- `interaction_service` owns the canonical interaction lifecycle. +- `customer_service` plus `customer_external_identities` already provide cross-channel identity linking. +- `ai_orchestrator_service` already implements the Telegram AI pattern: + - deterministic orchestration; + - explicit AI disclosure; + - `ai_sessions` and `ai_turns`; + - AI-to-human handoff; + - source-of-truth separation between channel service and AI service. +- `/operator` already has a hybrid Telegram AI UX with AI badges and AI summary cards. + +Voice AI V1 should copy the Telegram AI service boundary pattern, not the Telegram channel model itself. + +## 4. Target Architecture + +### 4.1 Service responsibilities + +| Service | Role in Voice AI V1 | +| --- | --- | +| `asterisk_bridge_service` | Keeps telephony truth, selects AI path for eligible calls, starts/stops AI runtime sessions, performs AI-to-human redirect, exposes operator-facing voice AI summary. | +| `ai_voice_runtime_service` | New realtime service. Owns media session state, ASR/TTS streaming, turn detection, barge-in, runtime latency budget, and handoff requests. | +| `ai_orchestrator_service` | Reused and extended. Owns deterministic voice decisioning, filtered context assembly, KB/tool usage, safe business policies, and AI summary generation. | +| `interaction_service` | Remains the owner of interaction status and timeline; gains an internal timeline append endpoint for additive AI events. | +| `voice_adapter_service` | Stays the owner of telephony event persistence only. It does not become the AI runtime. | +| `ui/operator` | Reuses the current live call and popup flow; adds voice AI badges and handoff summary, but no separate `AI telephony` workspace. | + +### 4.2 High-level component view + +```mermaid +flowchart LR + A["Asterisk
queue, redirect, recording, SIP/WebRTC"] --> B["asterisk_bridge_service
call truth + control plane"] + B --> C["ai_voice_runtime_service
media bridge + ASR/TTS + barge-in"] + C --> D["ai_orchestrator_service
policy + tools + summaries"] + D --> E["customer_service / shared DB
customer profile + memory"] + D --> F["interaction_service
interaction status + timeline"] + D --> G["kb_service
KB lookup"] + B --> H["operator UI
live call popup + voice AI summary"] +``` + +### 4.3 Source-of-truth rule + +- Telephony truth stays in `AsteriskCallLinkRow`, AMI events, queue redirect, and recording flow. +- AI truth stays in `voice_ai_sessions`, `ai_sessions`, `ai_turns`, and transcript rows. +- Interaction truth stays in `Interaction` plus `InteractionTimeline`. +- Customer truth stays in `Customer` plus `CustomerExternalIdentity`. + +No single prompt becomes the long-term state container. + +## 5. Call Flow + +### 5.1 AI-first inbound voice flow + +```mermaid +sequenceDiagram + participant A as Asterisk + participant B as asterisk_bridge_service + participant R as ai_voice_runtime_service + participant O as ai_orchestrator_service + participant I as interaction_service + + A->>B: MVPCCCallStarted + B->>I: create / reuse interaction + B->>B: create or update AsteriskCallLinkRow + B->>R: POST /internal/voice-ai/sessions + R->>O: POST /ai/voice/sessions/{id}/start + R->>A: play AI disclosure + greeting + A->>R: caller audio stream + R->>O: POST /ai/voice/sessions/{id}/turns + O-->>R: reply plan or handoff decision + R->>A: TTS playback +``` + +Detailed steps: + +1. `asterisk_bridge_service` receives the existing `MVPCCCallStarted` event. +2. It keeps the current behavior of creating or resolving `interaction_id` and `AsteriskCallLinkRow`. +3. It evaluates whether this queue or IVR outcome should go to AI-first mode. +4. If not AI-eligible, the current human flow stays unchanged. +5. If AI-eligible: + - create `voice_ai_session`; + - create or link generic `ai_session` with `channel="voice"`; + - mark the call row as AI-owned; + - start a runtime control session in `ai_voice_runtime_service`; + - attach the customer leg to the AI media bridge. +6. `ai_voice_runtime_service` plays a short disclosure and greeting. +7. Finalized caller utterances are sent to `ai_orchestrator_service`. +8. The orchestrator loads filtered business context, runs deterministic policy and KB/tool lookup, and returns a structured decision. +9. The runtime either: + - plays TTS back to the caller; or + - requests handoff to a human queue. + +### 5.2 AI-to-human handoff + +```mermaid +sequenceDiagram + participant R as ai_voice_runtime_service + participant O as ai_orchestrator_service + participant B as asterisk_bridge_service + participant A as Asterisk + participant U as Operator UI + + R->>O: POST voice turn + O-->>R: needs_handoff=true + reason + summary + R->>B: POST /internal/voice-ai/calls/{call_id}/handoff + B->>B: mark ai_state=handoff_required + B->>A: redirect caller leg to existing human queue + A->>U: normal operator incoming call path + U->>B: GET /asterisk/live-calls/{call_id}/ai-summary + B-->>U: AI handoff summary +``` + +Handoff rules: + +- handoff happens only through the existing Asterisk queue path; +- the AI runtime never calls an operator directly and never becomes a second queueing owner; +- `interaction_id` stays the same through AI and human phases; +- the operator receives a concise AI summary before or during claim. + +### 5.3 Call end + +- `MVPCCCallEnded` and `MVPCCRecordingReady` continue to come from Asterisk through the existing bridge. +- The bridge informs the runtime session that telephony ended. +- The runtime closes `voice_ai_session`. +- The orchestrator writes the final AI summary and marks `ai_session` closed. +- The interaction timeline gets final AI events if they were not written yet. + +## 6. Queue Selection and AI Eligibility + +Voice AI V1 should start with config-driven AI eligibility, not a new admin UI. + +Recommended V1 config: + +`AI_VOICE_QUEUE_CONFIG_JSON` + +Example shape: + +```json +{ + "voice_lab": { + "mode": "ai_first", + "agent_profile": "voice_support", + "handoff_queue_code": "voice_lab", + "language": "ru" + } +} +``` + +Why config-first: + +- it avoids touching the accepted routing UI and queue schema in the first step; +- it keeps rollout reversible per queue; +- it matches the current repo style where telephony behavior is still largely env-driven. + +The longer-term path can move this policy into routing/admin later. + +## 7. Data Model + +## 7.1 Reused tables + +These tables stay authoritative and should be reused: + +- `interactions` +- `interaction_timelines` +- `customer_external_identities` +- `asterisk_call_links` +- `voice_events` +- `call_recordings` +- `ai_sessions` +- `ai_turns` + +Voice customer linking should reuse `customer_external_identities` with: + +- `channel = "voice"` +- `external_subject = normalized caller number` + +No new identity table is needed. + +## 7.2 New table: `voice_ai_sessions` + +This table stores runtime state that does not fit cleanly into generic `ai_sessions`. + +Suggested columns: + +| Column | Type | Purpose | +| --- | --- | --- | +| `id` | integer pk | Internal row id | +| `session_id` | string unique | Public runtime session id | +| `call_id` | string unique index | Current Asterisk customer-leg call id | +| `linked_id` | string index | Asterisk linked id for recovery if call id changes | +| `interaction_id` | string index | Shared interaction | +| `customer_id` | string index nullable | Linked customer | +| `queue_id` | string index | Original platform queue | +| `ai_session_id` | string index | Generic AI session link | +| `agent_profile` | string index | Voice policy profile | +| `status` | string index | `queued`, `greeting`, `listening`, `thinking`, `speaking`, `handoff_requested`, `human_owned`, `completed`, `error` | +| `language` | string index nullable | Current active language | +| `asr_provider` | string nullable | Selected ASR backend | +| `tts_provider` | string nullable | Selected TTS backend | +| `handoff_reason` | text nullable | Last handoff reason | +| `handoff_target_queue_id` | string nullable | Human target queue | +| `disclosure_played_at` | string nullable | When AI disclosure was first spoken | +| `last_user_utterance_at` | string nullable | Latest finalized caller speech | +| `last_ai_reply_at` | string nullable | Latest completed AI reply | +| `started_at` | string index | Session start | +| `updated_at` | string index | Last state update | +| `ended_at` | string index nullable | Session end | + +## 7.3 New table: `voice_transcript_segments` + +This table stores finalized voice transcript units separately from generic AI turns. + +Suggested columns: + +| Column | Type | Purpose | +| --- | --- | --- | +| `id` | integer pk | Internal row id | +| `segment_id` | string unique | Public segment id | +| `session_id` | string index | `voice_ai_sessions.session_id` | +| `call_id` | string index | Voice call correlation | +| `interaction_id` | string index | Interaction correlation | +| `speaker` | string index | `caller`, `assistant`, `system`, `operator` | +| `source_type` | string index | `asr`, `tts`, `handoff_summary`, `system` | +| `sequence_no` | integer index | Ordered segment number | +| `text` | text | Final transcript text | +| `confidence` | number nullable | ASR confidence when applicable | +| `is_final` | boolean | Finalized transcript only in V1, but keep the flag for future partials | +| `barge_in_interrupted` | boolean | Whether the assistant segment was interrupted | +| `payload_json` | text | Provider metadata, timestamps, tool refs | +| `created_at` | string index | Write time | + +V1 should store finalized transcript segments only. Partial ASR events can stay in memory inside the runtime. + +## 7.4 Additive columns on `asterisk_call_links` + +This mirrors the Telegram pattern where the channel source-of-truth row also exposes AI status. + +Add: + +- `voice_session_id VARCHAR(64) NULL` +- `ai_session_id VARCHAR(64) NULL` +- `ai_state VARCHAR(32) NULL` +- `ai_handoff_reason TEXT NULL` +- `ai_last_model_at VARCHAR(64) NULL` + +Suggested `ai_state` values: + +- `queued` +- `greeting` +- `listening` +- `thinking` +- `active` +- `handoff_required` +- `human_owned` +- `closed` +- `error` + +This lets `/asterisk/live-calls` and `/asterisk/recent-calls` drive UI chips directly without extra joins on every poll. + +## 7.5 Additive column on `ai_sessions` + +Add: + +- `call_id VARCHAR(128) NULL` + +Reason: + +- Telegram already uses `thread_id`; +- voice needs a direct telephony key for fast lookup and summary generation; +- this keeps `ai_sessions` truly cross-channel instead of Telegram-shaped. + +## 7.6 Reuse of `ai_turns` + +Reuse `ai_turns` for: + +- finalized user turn seen by the orchestrator; +- model reply plan; +- tool invocation results; +- summary turn written during handoff or closure. + +Do not reuse `ai_jobs` for voice V1. Telegram jobs are thread-triggered and synchronous voice turn processing does not need that queue model. + +## 8. API Design + +## 8.1 `asterisk_bridge_service` -> `ai_voice_runtime_service` + +New internal control-plane endpoints: + +### `POST /internal/voice-ai/sessions` + +Starts a runtime session for an already accepted telephony call. + +Request: + +```json +{ + "call_id": "1740912000.12", + "linked_id": "1740912000.12", + "interaction_id": "int_...", + "queue_id": "que_...", + "caller_number": "+7701...", + "caller_name": "Lab Caller", + "agent_profile": "voice_support", + "language_hint": "ru", + "handoff_queue_id": "que_...", + "metadata": { + "queue_code": "voice_lab", + "direction": "inbound" + } +} +``` + +Response: + +```json +{ + "voice_session_id": "avs_...", + "ai_session_id": "ais_...", + "status": "queued" +} +``` + +### `POST /internal/voice-ai/sessions/{session_id}/telephony-events` + +Bridge notifies runtime about: + +- `call.connected` +- `call.ended` +- `recording.ready` +- `operator.connected` + +This keeps telephony truth in the bridge while runtime stays current. + +## 8.2 `ai_voice_runtime_service` -> `ai_orchestrator_service` + +### `POST /ai/voice/sessions/{session_id}/start` + +Creates or reopens the generic AI session and returns greeting policy: + +```json +{ + "voice_session_id": "avs_...", + "call_id": "1740912000.12", + "interaction_id": "int_...", + "customer_id": "cus_...", + "language_hint": "ru", + "agent_profile": "voice_support" +} +``` + +Response: + +```json +{ + "session_id": "ais_...", + "language": "ru", + "greeting_text": "Здравствуйте. Я AI-оператор компании...", + "disclosure_required": true +} +``` + +### `POST /ai/voice/sessions/{session_id}/turns` + +Main deterministic decision endpoint. + +Request: + +```json +{ + "voice_session_id": "avs_...", + "call_id": "1740912000.12", + "interaction_id": "int_...", + "transcript_text": "Хочу узнать статус заявки", + "language": "ru", + "sequence_no": 3, + "barge_in": false, + "metadata": { + "turn_duration_ms": 4200 + } +} +``` + +Response: + +```json +{ + "language": "ru", + "intent": "status_check", + "reply_text": "Я AI-оператор компании. Проверяю данные по обращению...", + "confidence": 0.83, + "needs_handoff": false, + "handoff_reason": null, + "case_action": "keep_open", + "kb_refs": ["art_..."], + "summary_text": "Клиент уточняет статус заявки.", + "model": "gpt-4o-mini", + "latency_ms": 780 +} +``` + +`case_action` should stay aligned with the existing Telegram pattern: + +- `none` +- `keep_open` +- `close` +- `escalate` + +### `POST /ai/voice/sessions/{session_id}/close` + +Finalizes AI state and writes the terminal summary. + +## 8.3 `ai_voice_runtime_service` -> `asterisk_bridge_service` + +### `POST /internal/voice-ai/calls/{call_id}/handoff` + +Requests redirect of the current customer leg into the existing human queue. + +Request: + +```json +{ + "voice_session_id": "avs_...", + "ai_session_id": "ais_...", + "interaction_id": "int_...", + "target_queue_id": "que_...", + "reason": "Нужен человек для чувствительного запроса.", + "summary": { + "customer_request_text": "Клиент просит изменить договор", + "ai_outcome_text": "AI собрал контекст и не выполнял чувствительное действие", + "recommended_next_step": "Проверить договор и продолжить вручную" + } +} +``` + +Behavior: + +- bridge validates the call is still active; +- bridge updates `ai_state` to `handoff_required`; +- bridge appends timeline `ai.handoff_requested`; +- bridge redirects the customer leg into the configured human queue; +- when the normal operator-connected flow happens, the same call row becomes `human_owned`. + +This endpoint is internal-only and trusted for service actors such as `svc:ai-voice-runtime`. + +## 8.4 `interaction_service` + +Add one internal endpoint: + +### `POST /interactions/{interaction_id}/timeline` + +Request: + +```json +{ + "action": "ai.reply_generated", + "metadata": { + "call_id": "1740912000.12", + "voice_session_id": "avs_...", + "ai_session_id": "ais_..." + } +} +``` + +Why add this now: + +- Voice AI should not write cross-service timeline rows by reaching into another service's DB contract ad hoc; +- the same endpoint can later be reused by Telegram AI without changing its current behavior immediately; +- it makes the AI event contract explicit. + +Required Voice AI timeline actions: + +- `ai.session_started` +- `ai.reply_generated` +- `ai.handoff_requested` +- `ai.handoff_completed` +- `ai.error` + +Existing interaction endpoints remain reused as-is: + +- `PATCH /interactions/{id}/status` +- `POST /interactions/{id}/escalate` +- `PATCH /interactions/{id}/assign` + +## 8.5 Operator-facing read APIs + +Extend `asterisk_bridge_service` output: + +### `GET /asterisk/live-calls` + +Add to each row: + +- `voice_session_id` +- `ai_session_id` +- `ai_state` +- `ai_handoff_reason` +- `ai_last_model_at` + +### `GET /asterisk/recent-calls` + +Expose the same additive AI fields. + +### `GET /asterisk/live-calls/{call_id}/ai-summary` + +Return a summary shape intentionally aligned with Telegram: + +```json +{ + "call_id": "1740912000.12", + "session_id": "ais_...", + "voice_session_id": "avs_...", + "status_label": "AI передал звонок оператору", + "status_tone": "handoff", + "customer_request_text": "Клиент хочет узнать статус обращения и изменить способ оплаты", + "ai_outcome_text": "AI собрал контекст и объяснил рамки, но не выполнил чувствительное действие", + "handoff_reason": "Запрос требует человека и проверки вручную", + "recommended_next_step": "Проверить карточку обращения и продолжить звонок вручную", + "generated_at": "2026-03-09T12:34:56Z" +} +``` + +This should be produced from `voice_ai_sessions`, `ai_turns`, and the latest transcript segments. + +## 9. Context Filtering Rules + +Voice AI must not send the whole call history into the model each turn. + +For every voice turn, `ai_orchestrator_service` should assemble a filtered context from: + +- customer profile: + - `customer_id` + - display name + - preferred phone + - tags +- customer memory: + - recent resolved issues + - notable preferences +- interaction state: + - `interaction_id` + - status + - queue + - last relevant timeline events +- voice session state: + - language + - disclosure already played or not + - previous handoff flag + - current turn number +- recent transcript window: + - last `6-10` finalized segments, not the entire transcript +- KB: + - top `3` matched articles or fewer +- business policy: + - allowed actions + - mandatory disclosure + - sensitive-topic escalation rules + +This matches the Telegram AI design principle already present in the repo. + +## 10. Runtime Behavior + +## 10.1 ASR/TTS abstraction + +`ai_voice_runtime_service` should expose provider interfaces and start with one configured provider per environment. + +Recommended internal modules: + +- `providers/asr.py` +- `providers/tts.py` +- `session_manager.py` +- `media_bridge.py` +- `barge_in.py` + +V1 supports one active ASR provider and one active TTS provider, behind interfaces. Multi-provider fallback is not required in the first version. + +## 10.2 Barge-in + +Barge-in is a V1 requirement because voice UX breaks if the caller cannot interrupt TTS. + +Required behavior: + +- while TTS is playing, incoming speech activity stops or fades out current playback; +- interrupted assistant output is marked with `barge_in_interrupted=true` in transcript; +- only finalized caller speech creates a new orchestrator turn; +- if interruption happens repeatedly or ASR confidence is poor, handoff rules may trigger. + +## 10.3 Latency budget + +Target budget for one AI turn: + +- end-of-utterance to finalized ASR text: `<= 600 ms` +- orchestrator decision: `<= 900 ms` +- first TTS audio chunk: `<= 500 ms` +- total pause before AI speech starts: `<= 2.0 s` + +If the runtime cannot stay within the budget repeatedly, it should prefer human handoff over a degraded long-silence experience. + +## 11. Operator UI Integration + +Voice AI V1 should integrate into the current operator shell, not create a second voice console. + +### 11.1 Existing surfaces to extend + +- browser call popup overlay; +- `Voice debug` live/recent calls list; +- unified customer history on `/operator`. + +### 11.2 Required UI changes + +1. Extend live call rows with an AI chip using the same language as Telegram: + - `AI active` + - `Ждёт человека` + - `AI error` +2. When a transferred AI-owned call reaches the operator popup: + - load `GET /asterisk/live-calls/{call_id}/ai-summary`; + - show a compact `Сводка AI` card above call actions; + - keep existing `Принять в работу`, `Передать`, `Завершить` controls unchanged. +3. Add AI metadata to customer history: + - AI session started + - AI handoff requested + - AI handoff completed +4. Do not add a separate full transcript workspace in V1. + +### 11.3 UI behavior intentionally deferred + +Deferred to V2: + +- operator button `Вернуть AI` for live voice calls; +- inline live transcript for operators during the call; +- supervisor transcript explorer for full recordings plus transcripts. + +## 12. Deployment and Config + +## 12.1 New service in `docker-compose.server.yml` + +Add: + +- `ai-voice-runtime-service` + +New shared env: + +- `AI_VOICE_RUNTIME_SERVICE_URL` +- `AI_VOICE_ENABLED` +- `AI_VOICE_QUEUE_CONFIG_JSON` +- `AI_VOICE_ASR_PROVIDER` +- `AI_VOICE_TTS_PROVIDER` +- `AI_VOICE_TTS_CACHE_ENABLED` +- `AI_VOICE_TTS_CACHE_DIR` +- `AI_VOICE_MAX_CONTEXT_SEGMENTS` +- `AI_VOICE_HANDOFF_TIMEOUT_SECONDS` + +Service auth: + +- add trusted subject `svc:ai-voice-runtime` where internal bridge endpoints require it. + +## 12.2 Asterisk-side change + +The accepted human voice path stays intact. + +Voice AI adds one new AI media bridge path in dialplan only for AI-selected queues. Preferred V1 implementation is: + +- Asterisk dialplan redirects the customer leg into an external media bridge context dedicated to AI. + +Exact low-level primitive should be validated in the lab: + +- preferred: `AudioSocket` or equivalent bidirectional audio bridge; +- fallback: narrowly scoped external-media/ARI only for AI queues. + +The choice must keep Asterisk as the telephony owner. + +## 13. Rollout Order + +Recommended implementation order: + +1. schema changes only: + - `voice_ai_sessions` + - `voice_transcript_segments` + - additive AI columns on `asterisk_call_links` + - additive `call_id` on `ai_sessions` +2. control plane only: + - bridge starts and closes empty runtime sessions behind `AI_VOICE_ENABLED=0` +3. lab media path: + - one AI-enabled lab queue + - disclosure + greeting + ASR/TTS echo flow +4. orchestrator integration: + - KB lookup + - filtered context + - handoff decisioning +5. operator summary: + - popup card + - voice debug AI chips +6. staged pilot rollout per queue. + +## 14. Key Risks and Open Questions + +1. `call_id` stability during AI-to-human redirect must be validated in the Asterisk lab. + If redirect creates a new call id, bridge recovery must switch to `linked_id` first and only then reuse `interaction_id`. +2. The exact Asterisk media primitive must be confirmed before implementation. + The design assumes a bidirectional bridge is available without replacing the accepted telephony baseline. +3. Provider latency must be measured in the target environment before enabling AI-first for production queues. +4. Sensitive actions should stay read-only in V1. + Voice AI should use KB, customer lookup, and safe interaction updates, but not execute risky external business actions directly. + +## 15. Summary + +Voice AI V1 should be implemented as an additive AI layer over the accepted voice baseline: + +- `asterisk_bridge_service` remains telephony truth and handoff executor; +- `ai_voice_runtime_service` is the new realtime media layer; +- `ai_orchestrator_service` is reused for deterministic business decisioning; +- `interaction_service` remains the owner of the canonical AI timeline; +- operator UI gets AI badges and AI handoff summary, not a new telephony product. + +This keeps the current live voice contour intact while adding the same AI-first and human-handoff architecture that already works in Telegram. diff --git a/docs/gates/gate-01-foundation.md b/docs/gates/gate-01-foundation.md new file mode 100644 index 0000000..8908cc8 --- /dev/null +++ b/docs/gates/gate-01-foundation.md @@ -0,0 +1,8 @@ +# Gate 1 Checklist (Foundation) + +- [ ] CI pipeline executes tests +- [ ] Gateway and stage 1 services expose `/health` +- [ ] `/auth/login` and RBAC restrictions validated +- [ ] Stage 1 OpenAPI published +- [ ] Event schemas approved +- [ ] Kubernetes + Helm manifests validated by lint/dry run diff --git a/docs/gates/gate-02-operator-core.md b/docs/gates/gate-02-operator-core.md new file mode 100644 index 0000000..7aa0462 --- /dev/null +++ b/docs/gates/gate-02-operator-core.md @@ -0,0 +1,8 @@ +# Gate 2 Checklist (Operator Core) + +- [ ] Create customer and interaction (voice/telegram) +- [ ] Route and assign interaction +- [ ] Escalate interaction and preserve timeline +- [ ] Voice event and Telegram webhook ingestion works +- [ ] Stage 2 OpenAPI frozen +- [ ] Functional tests pass diff --git a/docs/gates/gate-03-supervisor-reporting-kb.md b/docs/gates/gate-03-supervisor-reporting-kb.md new file mode 100644 index 0000000..f213d16 --- /dev/null +++ b/docs/gates/gate-03-supervisor-reporting-kb.md @@ -0,0 +1,12 @@ +# Gate 3 Checklist (Supervisor + Reporting + KB-lite) + +- [x] KPI events ingested and core KPIs available +- [x] Supervisor realtime reflects agent and queue state +- [x] KB categories/articles/search operate by role +- [x] CSV export works +- [x] Stage 3 OpenAPI frozen +- [x] Integration tests pass + +Verification run (2026-02-26): +- `pytest -q` +- `python scripts/gate3_check.py --auto-start` diff --git a/docs/gates/gate-04-pilot-hardening.md b/docs/gates/gate-04-pilot-hardening.md new file mode 100644 index 0000000..c5f0fe7 --- /dev/null +++ b/docs/gates/gate-04-pilot-hardening.md @@ -0,0 +1,35 @@ +# Gate 4 Checklist (Pilot Hardening) + +- [x] Load test target reached (100 voice + 100 digital) +- [x] Backup and restore verified +- [x] Security checklist completed +- [ ] UAT signed with real operators/supervisors +- [ ] P1/P2 defects closed +- [x] v1 release notes and runbooks delivered + +Verification run (2026-02-26): +- `python scripts/gate4_check.py` +- `pytest -q` + +Pilot scope freeze: +- `docs/gates/mvp-pilot-baseline.md` +- `docs/roadmap/05-wave2-backlog.md` + +Required manual evidence bundle before closing the last two checklist items: +- preflight report from `docs/uat/evidence/` +- completed session protocol from `docs/uat/session-template.md` +- filled defect log from `docs/uat/defect-log-template.csv` +- signed sheet from `docs/uat/signoff-template.md` +- updated `docs/gates/p1-p2-defects.md` + +Manual close-out sequence: +1. Run preflight and automated dry-run to prepare the evidence pack. +2. Execute real UAT with operators and supervisor on the frozen MVP scope. +3. Fix only `P1` and `P2` issues, then rerun regression checks. +4. Capture sign-off and only then mark the final two checklist items as done. + +Notes: +- UAT sign-off must be completed on pilot site with real operators and supervisor. +- P1/P2 closure is finalized after pilot UAT defect intake. +- UAT package location: `docs/uat/`. +- Automated dry-run command: `python scripts/uat_dry_run.py --auto-start --update-defect-register`. diff --git a/docs/gates/mvp-pilot-baseline.md b/docs/gates/mvp-pilot-baseline.md new file mode 100644 index 0000000..08e3486 --- /dev/null +++ b/docs/gates/mvp-pilot-baseline.md @@ -0,0 +1,71 @@ +# MVP Pilot Baseline + +This document freezes the scope for MVP pilot acceptance and separates pilot defects +from post-MVP feature requests. + +## Accepted Pilot Scope + +The following capabilities are part of MVP pilot acceptance: + +- auth login and RBAC enforcement +- customers create/search/history shell +- interactions create/assign/status/escalate/timeline +- queues and routing rules +- voice event intake +- Telegram webhook intake +- KB-lite categories/articles/search +- KPI core (`SL`, `ASA`, `AHT`, `Abandon`, `FCR`) and CSV export +- supervisor realtime API +- current operator UI shell (`/operator`) + +## Explicitly Out of Scope + +The following items are not treated as MVP defects during pilot acceptance: + +- LDAP/SSO +- full omnichannel beyond `voice` and `Telegram` +- standalone supervisor UI +- standalone admin UI +- full WFM module +- advanced IVR builder +- full recording suite and audio archive UX +- full KPI catalog from the extended technical specification +- scale target beyond MVP baseline (`100 voice + 100 digital`) + +All out-of-scope requests must be redirected to `Wave 2` backlog: +`docs/roadmap/05-wave2-backlog.md`. + +## Approved Pilot Environments + +- Primary mode: dedicated pilot gateway/environment maintained outside local auto-start. +- Fallback mode: local rehearsal via: + - `python scripts/uat_preflight.py --auto-start` + - `python scripts/uat_dry_run.py --auto-start --update-defect-register` + +## Scope Freeze Rules + +- No breaking API changes are allowed during pilot close-out. +- Only backward-compatible fixes are allowed for UAT remediation. +- Only `P1` and `P2` defects are fixed inside MVP close-out. +- `P3` and `P4` findings are logged and moved to post-MVP backlog unless they block sign-off. +- New feature requests must not be recorded as MVP defects. + +## Required Evidence Bundle + +Pilot close-out is based on the following artifacts: + +- preflight report from `docs/uat/evidence/` +- completed session protocol +- defect log with severity and verification steps +- signed sign-off sheet +- updated `docs/gates/p1-p2-defects.md` + +## Exit Criteria + +The MVP pilot baseline is considered closed only when all conditions are true: + +- all mandatory scenarios from `docs/uat/scenario-checklist.md` are executed +- no open `P1` defects +- no open `P2` defects +- business and IT owners sign the UAT package +- Gate 4 is updated to reflect final pilot closure diff --git a/docs/gates/p1-p2-defects.md b/docs/gates/p1-p2-defects.md new file mode 100644 index 0000000..c29a4b8 --- /dev/null +++ b/docs/gates/p1-p2-defects.md @@ -0,0 +1,16 @@ +# P1/P2 Defect Register (Pilot) + +Last update: 2026-02-27 (UAT-DRY-20260227_085446) + +## Current Status +- Open P1: 0 +- Open P2: 0 +- Source: `scripts/uat_dry_run.py` and automated checks +- Latest dry-run evidence: `docs/uat/evidence/dry_run_UAT-DRY-20260227_085446/` + +## Triage Policy +- Only `P1` and `P2` issues belong to MVP remediation. +- `P3` and `P4` issues move to `docs/roadmap/05-wave2-backlog.md` unless they block sign-off. + +## Pilot Note +- Final P1/P2 closure is confirmed only after the real-operator UAT cycle and sign-off. \ No newline at end of file diff --git a/docs/gates/uat-protocol-template.md b/docs/gates/uat-protocol-template.md new file mode 100644 index 0000000..41b2ce8 --- /dev/null +++ b/docs/gates/uat-protocol-template.md @@ -0,0 +1,12 @@ +# UAT Protocol Template (Stage 4) + +Primary working template moved to: + +- `docs/uat/session-template.md` + +Additional artifacts: + +- Scenario checklist: `docs/uat/scenario-checklist.md` +- Defect log: `docs/uat/defect-log-template.csv` +- Sign-off: `docs/uat/signoff-template.md` +- Preflight evidence: `docs/uat/evidence/` diff --git a/docs/kpi/definitions.md b/docs/kpi/definitions.md new file mode 100644 index 0000000..c76d3b7 --- /dev/null +++ b/docs/kpi/definitions.md @@ -0,0 +1,37 @@ +# KPI Definitions (MVP Core) + +## SL (Service Level) +`answered_within_threshold / total_interactions * 100` + +## ASA (Average Speed of Answer) +`sum(wait_seconds for answered) / answered` + +## AHT (Average Handle Time) +`sum(handle_seconds for answered) / answered` + +## Abandon +`abandoned / total_interactions * 100` + +## FCR +`resolved_first_contact / answered * 100` + +## AnswerRate +`answered / total_interactions * 100` + +## WaitP95 +`p95(wait_seconds for answered)` + +## HandleP95 +`p95(handle_seconds for answered)` + +## Occupancy +`sum(handle_seconds) / (sum(handle_seconds) + sum(wait_seconds)) * 100` + +## DigitalShare +`non_voice_interactions / total_interactions * 100` + +## Supported Dimensions +- `queue_id` +- `channel` +- `agent_id` +- time window (`from_ts`, `to_ts`) diff --git a/docs/releases/v1.0.0-mvp.md b/docs/releases/v1.0.0-mvp.md new file mode 100644 index 0000000..84a1542 --- /dev/null +++ b/docs/releases/v1.0.0-mvp.md @@ -0,0 +1,22 @@ +# Release Notes - v1.0.0-mvp + +Date: 2026-02-26 + +## Included +- Stage 1 foundation services, gateway, contracts, and deployment skeleton +- Stage 2 operator core flows (`voice + telegram`) with interaction lifecycle +- Stage 3 supervisor, KPI reporting, and KB-lite APIs +- SQL persistence for all services (SQLite/PostgreSQL support) +- Local and pilot runbooks, gate checklists, and automation scripts + +## Hardening Pack +- Load test script for `100 voice + 100 digital` +- Backup/restore scripts and runbook +- Gate 3 automation: `scripts/gate3_check.py` +- Gate 4 automation: `scripts/gate4_check.py` +- Security checklist: `docs/security/checklist.md` + +## Known Limits +- UAT sign-off by real operators/supervisors is a pilot activity +- P1/P2 closure depends on pilot defect intake +- LDAP planned for next wave (post-MVP) diff --git a/docs/roadmap/01-foundation.md b/docs/roadmap/01-foundation.md new file mode 100644 index 0000000..0f13b0a --- /dev/null +++ b/docs/roadmap/01-foundation.md @@ -0,0 +1,25 @@ +# Stage 1 - Foundation + Environments (Weeks 1-3) + +## Goal +Prepare a runnable technical baseline: on-prem deployment patterns, CI/CD, API contracts, RBAC, service monitoring. + +## Scope +- Gateway skeleton +- auth-service, users API, audit-service +- health endpoints for all services +- OpenAPI stage 1 contracts +- event schema baseline +- docker-compose + Kubernetes + Helm baseline +- CI workflow + +## Deliverables +- `deployment/*` +- `.github/workflows/ci.yml` +- `contracts/openapi/stage1-auth-users-health-audit.yaml` +- `contracts/events/*.json` + +## Gate 1 DoD +- Auto deploy path documented +- Login + roles work +- Health endpoints available +- API/event contracts frozen diff --git a/docs/roadmap/02-operator-core.md b/docs/roadmap/02-operator-core.md new file mode 100644 index 0000000..523fb95 --- /dev/null +++ b/docs/roadmap/02-operator-core.md @@ -0,0 +1,21 @@ +# Stage 2 - Operator Core (Weeks 4-7) + +## Goal +Deliver real operator flow for `voice + Telegram` with interaction lifecycle. + +## Scope +- customer-service (cards/search/history) +- interaction-service (create/assign/status/escalate/timeline) +- routing-service (queues/rules/route) +- voice-adapter-service (Asterisk events) +- telegram-adapter-service (webhook/messages) + +## Deliverables +- operator core APIs in services +- stage 2 OpenAPI contracts +- smoke scripts and tests for e2e API-level scenarios + +## Gate 2 DoD +- Operator can process both channels from unified interaction model +- Status and history are consistent +- Escalation and assignment work without manual workaround diff --git a/docs/roadmap/03-supervisor-reporting-kb.md b/docs/roadmap/03-supervisor-reporting-kb.md new file mode 100644 index 0000000..3886913 --- /dev/null +++ b/docs/roadmap/03-supervisor-reporting-kb.md @@ -0,0 +1,20 @@ +# Stage 3 - Supervisor + Reporting + KB-lite (Weeks 8-10) + +## Goal +Add manageability and analytics: realtime supervisor view, KPI core reporting, knowledge base support. + +## Scope +- supervisor-service realtime state +- reporting-service KPI aggregation: SL, ASA, AHT, Abandon, FCR +- kb-service categories/articles/search +- stage 3 OpenAPI contracts + +## Deliverables +- dashboard-ready supervisor API +- reporting export endpoint +- KB-lite APIs for operational usage + +## Gate 3 DoD +- Supervisor sees realtime operational state +- KPI reports reproducible +- KB usable in operator flow diff --git a/docs/roadmap/04-pilot-hardening.md b/docs/roadmap/04-pilot-hardening.md new file mode 100644 index 0000000..13401a2 --- /dev/null +++ b/docs/roadmap/04-pilot-hardening.md @@ -0,0 +1,22 @@ +# Stage 4 - Pilot Hardening + Acceptance (Weeks 11-12) + +## Goal +Stabilize and pass pilot acceptance. + +## Scope +- load tests up to 100/100 +- backup/restore drills +- security hardening checks +- UAT protocol and defect closure (P1/P2) +- final documentation pack + +## Deliverables +- test protocols +- UAT evidence +- operational runbooks +- accepted MVP v1 baseline + +## Gate 4 DoD +- Pilot passed +- SLA/KPI acceptance criteria confirmed +- System ready for post-MVP expansion diff --git a/docs/roadmap/05-wave2-backlog.md b/docs/roadmap/05-wave2-backlog.md new file mode 100644 index 0000000..cee63e0 --- /dev/null +++ b/docs/roadmap/05-wave2-backlog.md @@ -0,0 +1,126 @@ +# Wave 2 - Post-MVP Backlog + +Wave 2 starts only after MVP pilot sign-off is complete and Gate 4 is formally closed. +This document captures the first prioritized expansion backlog so pilot defects and +new features do not mix. + +## Priority Order + +### 1. LDAP / SSO + +Goal: +- replace local-only auth as the primary enterprise login path + +Expected outputs: +- LDAP or SSO integration design +- role mapping rules +- operational runbook for identity integration + +Acceptance: +- enterprise login works without breaking current RBAC behavior +- fallback admin access is documented + +### 2. Additional Channels + +Goal: +- expand beyond the current MVP channel baseline + +Expected outputs: +- `webchat` handling flow +- `email` intake flow +- external messenger backlog after `webchat` and `email` + +Acceptance: +- new channels reuse the unified interaction model +- routing, history, and KPI ingestion stay consistent + +### 3. Dedicated Supervisor/Admin UI + +Goal: +- move beyond the single operator shell and provide dedicated operational consoles + +Expected outputs: +- supervisor UI for realtime monitoring, control, and reporting +- admin UI for users, queues, rules, and configuration + +Acceptance: +- critical supervisor and admin workflows no longer depend on the operator shell + +### 4. Recording and Audio Operations + +Goal: +- turn the current event-level recording placeholders into a usable product block + +Expected outputs: +- recording storage model +- playback/download flow +- access control and retention rules + +Acceptance: +- supervisors can find, review, and export recording artifacts safely + +### 5. Advanced IVR and Voice Flow + +Goal: +- introduce configurable IVR flows beyond current baseline voice event intake + +Expected outputs: +- IVR flow model +- editable configuration or builder +- routing integration for IVR outcomes + +Acceptance: +- IVR outcomes are traceable in interaction lifecycle and routing decisions + +### 6. Full KPI Catalog + +Goal: +- expand reporting from MVP core KPIs to the wider target metric model + +Expected outputs: +- KPI mapping backlog based on the extended specification +- data model updates +- report coverage matrix + +Acceptance: +- KPI definitions are reproducible and traceable to the specification + +### 7. Scale-Up and Hardening + +Goal: +- move from MVP pilot performance to target production scale + +Expected outputs: +- capacity plan +- higher load targets +- scale validation on the target deployment profile + +Acceptance: +- agreed performance target is met with documented evidence + +### 8. Event Bus and Integrations + +Goal: +- evolve the platform from contract-ready integration to operational event exchange + +Expected outputs: +- message bus integration design +- service event publishing/consumption plan +- external integration sequencing + +Acceptance: +- core domain events are delivered through the selected bus without contract drift + +## Delivery Rules + +- Wave 2 must be planned as a separate delivery track from MVP pilot closure. +- MVP defects remain in the MVP remediation lane until sign-off is complete. +- New Wave 2 scope is approved only after MVP accepted baseline is frozen. + +## Status Update (2026-03-05) + +- Track 9 (Asterisk AMI lab bridge) is accepted in QA evidence packs. +- Track 9.2 cutover prep is ready; production execute requires target context/secrets window. +- Track 10 is closed: + - see `docs/acceptance/track10/track10-acceptance.md`. +- Next execution step: Track 11 (live voice control scope). diff --git a/docs/roadmap/06-track10-live-voice-reliability.md b/docs/roadmap/06-track10-live-voice-reliability.md new file mode 100644 index 0000000..73e1ccd --- /dev/null +++ b/docs/roadmap/06-track10-live-voice-reliability.md @@ -0,0 +1,65 @@ +# Wave 2 / Track 10 - Live Voice Reliability and Realtime SLA + +Track 10 starts after Track 9 QA acceptance is collected and frozen. + +## Status Update (2026-03-05) + +- Track 10 is closed using acceptance package: + - `docs/acceptance/track10/track10-acceptance.md` +- Closure mode: `GO with exception` + - direct p95 `started->ended` measured `33s` vs target `30s` + - reliability gates for stuck lifecycle/import are green (`active_no_end=0`, `ended_no_recording_upload=0`) +- Follow-up latency tightening is moved to the next track. + +## Goal + +Move from "Asterisk integration works" to "voice lifecycle is predictably near-realtime". + +Required outcome: +- `call.started` appears in supervisor flow quickly and consistently. +- `call.ended` is not delayed by reconciliation paths. +- recording upload latency is measured and controlled. + +## Scope + +In scope: +- latency/SLA instrumentation for voice lifecycle (`started -> ended -> recording`), +- stricter reconcile behavior for stale AMI events, +- automated latency report as an acceptance artifact, +- Track 10 acceptance checklist and evidence structure. + +Out of scope: +- ARI call control, +- operator softphone controls, +- new business voice features. + +## Baseline SLO (Track 10 target) + +- p95 `call.started -> call.ended` <= `30s` for lab calls. +- p95 `call.ended -> recording uploaded` <= `45s`. +- unresolved `failed bridge events` = `0` for acceptance window. + +## Deliverables + +1. Latency report script: + - `scripts/track10_voice_latency_report.py` +2. Runbook: + - `docs/runbooks/track10-live-voice-reliability.md` +3. Acceptance evidence folder: + - `docs/acceptance/track10//` +4. Final decision doc: + - `docs/acceptance/track10/track10-acceptance.md` + +## Phase 1 (implemented slice) + +Completed: +- added Track 10 latency report script with SLO thresholds and JSON artifacts, +- added `direct` vs `reconciled` split in latency metrics, +- updated bridge path so `recording.ready` can close call lifecycle immediately when `call.ended` is missing. + +## Acceptance + +Track 10 is accepted when: +- latency report is generated from real calls, +- all mandatory checks pass for the acceptance window, +- `GO` is signed in `track10-acceptance.md`. diff --git a/docs/roadmap/07-track11-live-operator-call-control.md b/docs/roadmap/07-track11-live-operator-call-control.md new file mode 100644 index 0000000..a0d30b2 --- /dev/null +++ b/docs/roadmap/07-track11-live-operator-call-control.md @@ -0,0 +1,60 @@ +# Wave 2 / Track 11 - Live Operator Voice Control + +Status: `Accepted` + +Track 11 introduced the first real operator call-control loop on top of the accepted Asterisk bridge. + +## Goal + +Move from event-only voice integration to operator-controlled live calls in the existing operator shell. + +Accepted control path: +- operator sees a live inbound call in `/operator` +- operator answers media in external `MicroSIP/Zoiper` +- operator fixes ownership in the browser with `Принять в работу` +- operator can `Завершить` or `Передать` + +Voice media stays outside the browser. No WebRTC in this track. + +## Scope + +In scope: +- bridge live-call routes under `/asterisk/live-calls` +- own-call RBAC for `operator` +- operator live-call block +- action log and replay-safe behavior +- Asterisk event `MVPCCOperatorConnected` +- QA acceptance with real call and recording playback + +Out of scope: +- ARI +- attended transfer or conference +- embedded browser softphone +- business API redesign + +## Deliverables + +1. Migrations: + - `migrations/sql/0011_track11_voice_control_sqlite.sql` + - `migrations/sql/0011_track11_voice_control_postgres.sql` +2. Backend: + - `services/asterisk_bridge_service/app.py` +3. Operator UI: + - `ui/operator/index.html` + - `ui/operator/app.js` +4. Asterisk templates: + - `deployment/asterisk/pjsip.conf` + - `deployment/asterisk/extensions.conf` +5. Evidence and docs: + - `docs/runbooks/track11-live-operator-call-control.md` + - `docs/acceptance/track11/track11-acceptance.md` + +## Accepted baseline + +Track 11 is frozen with these guarantees: +- live inbound call visibility works in `/operator` +- `claim`, `hangup`, and `blind transfer` persist in bridge action logs +- `call.connected` and `call.transferred` are written as voice events +- recording import and supervisor playback stay green + +For the current operator UX, use the Track 12 runbook. Track 11 remains the control-path baseline. diff --git a/docs/roadmap/08-track12-operator-voice-ux-hardening.md b/docs/roadmap/08-track12-operator-voice-ux-hardening.md new file mode 100644 index 0000000..a6cd0ea --- /dev/null +++ b/docs/roadmap/08-track12-operator-voice-ux-hardening.md @@ -0,0 +1,57 @@ +# Wave 2 / Track 12 - Operator Voice UX Hardening + +Status: `Accepted` + +Track 12 hardens the accepted Track 11 voice-control baseline without changing the telephony architecture. + +## Goal + +Keep the current Asterisk plus `MicroSIP/Zoiper` model, but make the operator voice flow predictable and readable. + +Accepted outcome: +- `/operator` shows only real bridge-backed calls +- active and recently ended calls are separated +- browser actions behave consistently +- recent calls stay visible briefly instead of disappearing immediately + +## Scope + +In scope: +- `GET /asterisk/recent-calls` +- additive live-call metadata +- operator UI split into `Активные звонки` and `Только что завершённые` +- clearer labels and SIP-versus-browser hint text +- removal of interaction-based live-call guessing +- QA acceptance for active/recent call behavior + +Out of scope: +- WebRTC or browser softphone +- claim-first telephony redesign +- ARI +- attended transfer or conference +- production cutover + +## Deliverables + +1. Backend: + - `services/asterisk_bridge_service/app.py` + - `services/shared/models.py` + - `gateway/app.py` +2. Operator UI: + - `ui/operator/index.html` + - `ui/operator/app.js` + - `ui/operator/styles.css` +3. Evidence and docs: + - `docs/runbooks/track12-operator-voice-ux.md` + - `docs/acceptance/track12/track12-acceptance.template.md` + +## Accepted baseline + +Track 12 is frozen with these guarantees: +- `Живые звонки` never falls back to `interaction:int_*` placeholder cards +- `reconciled` service traces do not remove an active call early +- `hangup` and `blind transfer` move the card into the recent bucket +- recent cards stay visible for the short configured window +- Track 11 control-path behavior and recording playback remain green + +This is the current operator voice UX baseline. diff --git a/docs/roadmap/09-track14-browser-softphone-webrtc.md b/docs/roadmap/09-track14-browser-softphone-webrtc.md new file mode 100644 index 0000000..55f15a6 --- /dev/null +++ b/docs/roadmap/09-track14-browser-softphone-webrtc.md @@ -0,0 +1,45 @@ +# Wave 2 / Track 14 - Browser Softphone (WebRTC, QA First) + +Status: `Implemented, QA validation pending` + +Track 14 adds the first browser-native operator media path on top of the accepted Track 11 and Track 12 voice baseline. + +## Goal + +Keep the accepted bridge-backed live-call model and move the operator media path into the browser for the first QA slice. + +Implemented direction: +- direct Asterisk WSS +- inbound MVP only +- browser softphone coexists with `MicroSIP/Zoiper` +- existing bridge `claim/hangup/recent-calls` remains authoritative + +## Deliverables + +1. Bridge: + - additive route: `/asterisk/browser-softphone/config` + - additive status fields: `webrtc_enabled`, `webrtc_ws_url` +2. Operator shell: + - `Browser Softphone` block + - register/disconnect state + - incoming call banner + - `Answer`, `Hang up`, `Mute/Unmute` + - microphone and speaker selectors +3. Asterisk lab assets: + - `deployment/asterisk/http.conf` + - `deployment/asterisk/rtp.conf` + - updated `deployment/asterisk/pjsip.conf` + +## Boundaries + +Track 14 does not introduce: +- production cutover +- ARI +- browser-only mandatory mode +- DB schema changes +- WebRTC backend service outside Asterisk + +The accepted voice system of record remains: +- bridge live/recent calls +- bridge action log +- recording import and supervisor playback diff --git a/docs/roadmap/10-track15-telegram-chat.md b/docs/roadmap/10-track15-telegram-chat.md new file mode 100644 index 0000000..16f9ce9 --- /dev/null +++ b/docs/roadmap/10-track15-telegram-chat.md @@ -0,0 +1,30 @@ +# Track 15 - Telegram Chat + +Status: `implemented` + +## Scope + +- real Telegram Bot API webhook +- one persistent thread per `chat_id` +- 2-way text chat +- separate `Telegram` page in `/operator` + +## What changed + +- `telegram-adapter-service` now owns Telegram threads and thread messages +- new thread/message routes exist under `/integrations/telegram/threads/*` +- inbound messages reuse the same thread and linked interaction +- a closed Telegram interaction is reactivated on new inbound activity +- operator replies go through Telegram `sendMessage` + +## Out of scope + +- Telegram voice/video calls +- media/file workflow +- admin UI for bot setup + +## Acceptance focus + +- first inbound message creates thread + interaction +- repeated inbound on the same `chat_id` reuses the same thread +- operator can claim and reply from the dedicated `Telegram` page diff --git a/docs/roadmap/README.md b/docs/roadmap/README.md new file mode 100644 index 0000000..a182b01 --- /dev/null +++ b/docs/roadmap/README.md @@ -0,0 +1,11 @@ +# Sequential Execution Rules + +1. Stages are executed strictly in sequence. +2. A stage starts only when previous gate is formally closed. +3. Scope changes are deferred to the next stage backlog. +4. Weekly checkpoint + demo is mandatory. +5. Every stage has: + - scope + - deliverables + - tests + - gate checklist diff --git a/docs/runbooks/.env.production.checklist.md b/docs/runbooks/.env.production.checklist.md new file mode 100644 index 0000000..d6c512f --- /dev/null +++ b/docs/runbooks/.env.production.checklist.md @@ -0,0 +1,132 @@ +# `.env.production` Checklist (Track 9/11 QA Lab) + +Use this checklist before enabling `ASTERISK_BRIDGE_ENABLED=1`. + +## 1) File handling and secret hygiene + +- [ ] Create local `.env.production` from [`.env.production.template`](/e:/Zhan/.env.production.template). +- [ ] Keep `.env.production` out of git (it is ignored in [`.gitignore`](/e:/Zhan/.gitignore)). +- [ ] Replace all placeholder values: + - `` + - `` + - `` + - `` + - `` + - `` +- [ ] Verify that AMI/SFTP credentials are not copied into docs, commits, or screenshots. + +## 2) Required environment variables + +### Core + +- [ ] `DATABASE_URL` points to PostgreSQL (not SQLite). +- [ ] `INTERACTION_SERVICE_URL` is reachable from `asterisk-bridge-service`. +- [ ] `VOICE_ADAPTER_SERVICE_URL` is reachable. +- [ ] `RECORDING_SERVICE_URL` is reachable. + +### Track 9 bridge + +- [ ] `ASTERISK_BRIDGE_ENABLED=1` +- [ ] `ASTERISK_AMI_HOST` / `ASTERISK_AMI_PORT` are correct. +- [ ] `ASTERISK_AMI_USERNAME` / `ASTERISK_AMI_SECRET` are correct. +- [ ] `ASTERISK_AMI_EVENT_PREFIX=MVPCC` unless your dialplan intentionally changed it. +- [ ] `ASTERISK_QUEUE_MAP_JSON` contains valid JSON object. +- [ ] Every `queue_code` in dialplan has a mapped platform `queue_id`. +- [ ] `ASTERISK_BRIDGE_AUTH_MODE` chosen intentionally: + - `legacy_headers` for Track 9 QA baseline + - `bearer_first` for transition hardening + - `bearer` for strict service-token mode +- [ ] If bearer mode is used, `APP_TOKEN_SECRET` is the same across gateway/services/bridge runtime. +- [ ] `ASTERISK_BRIDGE_AUTH_USER` / `ASTERISK_BRIDGE_AUTH_ROLE` match expected service identity. +- [ ] `VOICE_ADAPTER_TRUSTED_SERVICE_SUBJECTS` includes `svc:asterisk-bridge`. +- [ ] `RECORDING_IMPORT_TRUSTED_SERVICE_SUBJECTS` includes `svc:asterisk-bridge`. +- [ ] For strict Track 9.1 mode, set `RECORDING_IMPORT_ALLOW_ADMIN=0`. + +### Track 11 call-control + +- [ ] `ASTERISK_CALLCONTROL_ENABLED=1` (в QA, если проверяете операторское управление звонком). +- [ ] `ASTERISK_CALLCONTROL_ACTION_TIMEOUT_SECONDS` задан (рекомендуемо `10`). +- [ ] `ASTERISK_OPERATOR_EXTENSION_MAP_JSON` содержит mapping `operator -> extension`. + - пример: `{"operator_a":"2001","operator_b":"2002"}` +- [ ] `ASTERISK_TRANSFER_TARGET_MAP_JSON` содержит mapping `queue_code -> extension` для blind transfer. + - пример: `{"voice_lab":"2001"}` +- [ ] `ASTERISK_CALLCONTROL_CLAIM_CONTEXT` и `ASTERISK_CALLCONTROL_TRANSFER_CONTEXT` совпадают с dialplan contexts. + +### SFTP pickup + +- [ ] `ASTERISK_SFTP_HOST` / `ASTERISK_SFTP_PORT` are correct. +- [ ] `ASTERISK_SFTP_USERNAME` / `ASTERISK_SFTP_PASSWORD` are correct. +- [ ] `ASTERISK_SFTP_BASE_PATH` points to the recording directory (default `/var/spool/asterisk/monitor`). + +### Voice AI V1 staging + +- [ ] `AI_VOICE_ENABLED=1` only for the pilot queues you intend to test. +- [ ] `AI_VOICE_QUEUE_CONFIG_JSON` contains the AI-first queue code from the dialplan. + - example: `{"voice_lab_ai":{"mode":"ai_first","agent_profile":"voice_support","handoff_queue_code":"voice_lab","language":"ru"}}` +- [ ] `AI_VOICE_RUNTIME_SERVICE_URL` points to the HTTP runtime service, not the AudioSocket media endpoint. +- [ ] `AI_VOICE_RUNTIME_TRUSTED_SERVICE_SUBJECTS` includes `svc:ai-voice-runtime`. +- [ ] `AI_VOICE_ASR_PROVIDER` / `AI_VOICE_TTS_PROVIDER` are set intentionally. +- [ ] `ASTERISK_TRANSFER_TARGET_MAP_JSON` includes both the human and AI queue codes when they can hand off to the same operator group. + - example: `{"voice_lab":"2001","voice_lab_ai":"2001"}` +- [ ] On the Asterisk VM, leave `MVPCC_AI_AUDIOSOCKET_SERVICE=` blank until the media listener is ready. +- [ ] After the media listener is available, verify `app_audiosocket.so` is loaded and `MVPCC_AI_AUDIOSOCKET_SERVICE=:` uses the raw AudioSocket address. + +### Event bus baseline (Track 8 compatibility) + +- [ ] `EVENT_BUS_ENABLED=1` in QA. +- [ ] `EVENT_BUS_URL` resolves to reachable RabbitMQ. + +## 3) Preflight checks before first call + +- [ ] Run migrations: + - `python scripts\migrate_core_db.py` +- [ ] Run full regression: + - `pytest -q` +- [ ] Start stack with production env loaded: + - `powershell -ExecutionPolicy Bypass -File scripts\start_track9_qa.ps1` +- [ ] Bridge health: + - `GET /proxy/asterisk-bridge/health` returns `ok` +- [ ] Bridge status: + - `GET /proxy/asterisk-bridge/asterisk/status` shows `ami_connected=true` + +## 4) Acceptance checks after test call `1001 -> 7000` + +- [ ] `voice` interaction created from Asterisk event flow. +- [ ] `voice_events` contain: + - `call.started` with `payload.source = "asterisk"` + - `call.ended` for the same `call_id` +- [ ] `MVPCCRecordingReady` led to uploaded recording in `recording-service`. +- [ ] Playback works in `supervisor`. +- [ ] Для Track 11: + - оператор видит звонок в `/operator` -> блок `Живые звонки` + - `claim` работает и пишет action log + - `hangup` работает + - `blind transfer` работает +- [ ] Run: + - `python scripts\asterisk_lab_smoke.py --base-url --database-url --require-recording` + - `python scripts\track9_check.py --base-url --database-url --require-recording` + - `python scripts\track9_collect_evidence.py --base-url --database-url --run-checks --require-recording` + +## 4.1) Voice AI control-plane smoke after test call `1001 -> 7100` + +- [ ] `MVPCCCallStarted` used `QueueCode=voice_lab_ai`. +- [ ] `voice_ai_sessions` row created for the call. +- [ ] `ai_sessions` row created with `channel=voice`. +- [ ] Interaction timeline contains `ai.session_started`. +- [ ] Operator UI shows an AI badge on the live or recent voice call card. +- [ ] `GET /proxy/asterisk-bridge/asterisk/live-calls/{call_id}/ai-summary` returns `200` once the AI summary exists. +- [ ] Human fallback path still works if `MVPCC_AI_AUDIOSOCKET_SERVICE=` is blank. + +## 5) Failure policy + +- [ ] If failed bridge events exist, inspect: + - `GET /proxy/asterisk-bridge/asterisk/events?status=failed` +- [ ] Retry after fixing root cause: + - `POST /proxy/asterisk-bridge/asterisk/events/{bridge_event_id}/retry` +- [ ] Do not mark Track 9 accepted until failed bridge events are resolved or explicitly waived. + +## 6) Track 9.2 cutover handoff + +- [ ] Prepare [track9-cutover-sheet.template.md](/e:/Zhan/docs/acceptance/track9/track9-cutover-sheet.template.md). +- [ ] Follow [track9-2-production-cutover.md](/e:/Zhan/docs/runbooks/track9-2-production-cutover.md) for controlled Helm rollout. +- [ ] Use `scripts\track9_2_cutover.ps1` first in dry-run mode, then with `-Execute` during the approved window. diff --git a/docs/runbooks/asterisk-lab-linux-vm.md b/docs/runbooks/asterisk-lab-linux-vm.md new file mode 100644 index 0000000..1511d42 --- /dev/null +++ b/docs/runbooks/asterisk-lab-linux-vm.md @@ -0,0 +1,315 @@ +# Asterisk Lab on Linux VM + +Use this runbook to stand up the Track 9 human baseline, the IVR staging entrypoint, and the Voice AI V1 staging entrypoint on the same Asterisk VM. + +## Target + +- Host: dedicated Linux VM +- OS: Ubuntu 22.04 LTS or Ubuntu 24.04 LTS +- Asterisk: 20 LTS +- Integration mode: AMI + custom `UserEvent` +- Call source: softphone lab only +- Human baseline entrypoint: `7000` +- IVR staging entrypoint: `7200` +- Voice AI staging entrypoint: `7100` + +## Prepare the VM + +1. Install Asterisk 20 LTS. +2. Ensure these ports are reachable from the platform host: + - `5060/udp` for SIP + - `5038/tcp` for AMI + - `22/tcp` for SFTP +3. Create the recording directory: + - `/var/spool/asterisk/monitor/mvpcc` + +## Apply repo configs + +Copy these files from the repository: + +- `deployment/asterisk/pjsip.conf` +- `deployment/asterisk/extensions.conf` +- `deployment/asterisk/manager.conf` + +Before reload: + +1. Replace default passwords. +2. Confirm extension `1001`. +3. Keep extension `7000` as the human-only inbound test entrypoint. +4. Keep extension `7100` reserved for Voice AI staging. +5. Keep extension `7200` reserved for IVR staging. + +Voice AI dialplan notes: + +- `deployment/asterisk/extensions.conf` now defines two queue codes: + - `voice_lab` for `7000` + - `voice_lab_ai` for `7100` +- `7100` falls back to the human dial targets unless `MVPCC_AI_AUDIOSOCKET_SERVICE` is populated. +- When you are ready to test the media bridge, load `app_audiosocket.so` and set `MVPCC_AI_AUDIOSOCKET_SERVICE=:`. +- Asterisk `AudioSocket()` requires a raw TCP media listener and a UUID per call. The template generates the UUID with `uuidgen`. +- Official app reference: + - [Asterisk AudioSocket application](https://docs.asterisk.org/Latest_API/API_Documentation/Dialplan_Applications/AudioSocket/) + +IVR dialplan notes: + +- `deployment/asterisk/extensions.conf` defines `voice_lab_ivr` for `7200`. +- `7200` calls FastAGI at `MVPCC_IVR_FASTAGI_HOSTPORT` and expects `prompt_audio_key` values to match pre-provisioned Asterisk sound files. +- If FastAGI does not return `MVPCC_IVR_TARGET_EXTENSION`, the call falls back to the human dial targets. + +## Configure the platform bridge + +Set these environment variables for `asterisk-bridge-service`: + +- `ASTERISK_BRIDGE_ENABLED=1` +- `ASTERISK_AMI_HOST=` +- `ASTERISK_AMI_PORT=5038` +- `ASTERISK_AMI_USERNAME=mvpcc` +- `ASTERISK_AMI_SECRET=` +- `ASTERISK_AMI_EVENT_PREFIX=MVPCC` +- `ASTERISK_QUEUE_MAP_JSON={"voice_lab":"","voice_lab_ai":"","voice_lab_ivr":""}` +- `ASTERISK_BRIDGE_AUTH_MODE=legacy_headers` (or `bearer_first` / `bearer`) +- `ASTERISK_BRIDGE_AUTH_FALLBACK_LEGACY=1` (used only with `bearer_first`) +- `ASTERISK_BRIDGE_AUTH_SUBJECT=svc:asterisk-bridge` +- `ASTERISK_BRIDGE_AUTH_USER=asterisk-bridge` +- `ASTERISK_BRIDGE_AUTH_ROLE=admin` +- `ASTERISK_BRIDGE_AUTH_TOKEN_TTL_SECONDS=300` +- `ASTERISK_IVR_FASTAGI_ENABLED=1` +- `ASTERISK_IVR_FASTAGI_HOST=0.0.0.0` (or the explicit bridge bind IP) +- `ASTERISK_IVR_FASTAGI_PORT=4573` +- `ASTERISK_IVR_DTMF_TIMEOUT_SECONDS=5` +- `ASTERISK_IVR_MAX_NO_INPUT_RETRIES=2` +- `ASTERISK_IVR_MAX_INVALID_RETRIES=2` +- `IVR_RUNTIME_TRUSTED_SERVICE_SUBJECTS=svc:asterisk-bridge` +- `VOICE_ADAPTER_TRUSTED_SERVICE_SUBJECTS=svc:asterisk-bridge,svc:ivr-service` +- `RECORDING_IMPORT_TRUSTED_SERVICE_SUBJECTS=svc:asterisk-bridge` +- `RECORDING_IMPORT_ALLOW_ADMIN=1` (set `0` in strict mode) +- `ASTERISK_SFTP_HOST=` +- `ASTERISK_SFTP_PORT=22` +- `ASTERISK_SFTP_USERNAME=` +- `ASTERISK_SFTP_PASSWORD=` +- `ASTERISK_SFTP_BASE_PATH=/var/spool/asterisk/monitor` +- `ASTERISK_TRANSFER_TARGET_MAP_JSON={"voice_lab":"2001","voice_lab_ai":"2001","voice_lab_ivr":"7200"}` +- `AI_VOICE_ENABLED=1` +- `AI_VOICE_QUEUE_CONFIG_JSON={"voice_lab_ai":{"mode":"ai_first","agent_profile":"voice_support","handoff_queue_code":"voice_lab","language":"ru"}}` +- `AI_VOICE_RUNTIME_SERVICE_URL=http://ai-voice-runtime-service:8000` +- `AI_VOICE_RUNTIME_TRUSTED_SERVICE_SUBJECTS=svc:ai-voice-runtime` +- `AI_VOICE_ASR_PROVIDER=openai` +- `AI_VOICE_TTS_PROVIDER=openai` +- `AI_VOICE_MAX_CONTEXT_SEGMENTS=8` +- `AI_VOICE_HANDOFF_TIMEOUT_SECONDS=8` + +For Track 9 QA acceptance baseline, keep: + +- bridge auth mode in compatibility headers (`X-User` / `X-Role`) +- this is expected for Track 9 baseline and moved to hardening in Track 9.1 + +For Track 9.1 hardening, switch to: + +- `ASTERISK_BRIDGE_AUTH_MODE=bearer_first` first +- then `ASTERISK_BRIDGE_AUTH_MODE=bearer` after successful smoke and no auth regressions +- set `RECORDING_IMPORT_ALLOW_ADMIN=0` +- in Helm-based environments, use `deployment/helm/values.track9-strict.yaml` as an overlay + +Voice AI V1 staging guidance: + +- Start with `MVPCC_AI_AUDIOSOCKET_SERVICE=` blank in `extensions.conf`. +- This lets `7100` validate the AI control-plane wiring while still falling back to the existing human voice path. +- Only point `MVPCC_AI_AUDIOSOCKET_SERVICE` to a live AudioSocket listener after the media listener is reachable from the VM. +- `AI_VOICE_RUNTIME_SERVICE_URL` is not the AudioSocket address; it is only the HTTP control-plane URL. + +## Register the softphone + +In your softphone: + +- username: `1001` +- password: the value set in `pjsip.conf` +- server: the Linux VM IP/hostname + +## QA preflight (required) + +1. Copy [`.env.production.template`](/e:/Zhan/.env.production.template) to local `.env.production`. +2. Complete [`.env.production.checklist.md`](/e:/Zhan/docs/runbooks/.env.production.checklist.md). +3. Start the stack with production env loaded: + +```powershell +powershell -ExecutionPolicy Bypass -File scripts\start_track9_qa.ps1 +``` + +4. Run: + +```powershell +python scripts\migrate_core_db.py +pytest -q +python scripts\track9_preflight.py --base-url http://127.0.0.1:8080 --check-sftp +python scripts\track9_preflight.py --base-url http://127.0.0.1:8080 --check-sftp --require-strict-service-auth +``` + +## Run the first test call + +1. Start the platform. +2. Start `asterisk-bridge-service`. +3. Call `7000` from extension `1001`. +4. Hang up after the playback finishes. + +Expected result: + +- `asterisk-bridge-service` receives: + - `MVPCCCallStarted` + - `MVPCCCallEnded` + - `MVPCCRecordingReady` +- platform creates a new `voice` interaction +- `voice_events` contain: + - `call.started` + - `call.ended` +- one recording is uploaded into `recording-service` + +## Run the IVR-over-Asterisk smoke + +1. Create or activate an IVR flow for the queue mapped from `voice_lab_ivr`. +2. Make sure every live node uses a valid `prompt_audio_key`, and the audio files are present in Asterisk sounds. +3. Call `7200` from extension `1001`. +4. Test both branches: + - enter `1` or `2` to complete IVR and transfer into the mapped queue target + - stay silent until retry exhaustion to verify fallback into the human baseline + +Expected result: + +- `asterisk-bridge-service` receives `MVPCCCallStarted` with `QueueCode=voice_lab_ivr`. +- `ivr_sessions` contains a live session for the `call_id`. +- interaction timeline contains `ivr.session.started` and `ivr.step.no_input` or `ivr.step.completed`. +- terminal completion writes `voice_events.event_type="ivr.completed"`. +- telephony returns to `mvpcc-transfer` for the resolved queue target, or to the human baseline on retry exhaustion. + +## Run the Voice AI control-plane smoke + +Use this smoke before turning on the real AudioSocket media path. + +1. Keep `MVPCC_AI_AUDIOSOCKET_SERVICE=` blank in `extensions.conf`. +2. Start the platform and `ai-voice-runtime-service`. +3. Call `7100` from extension `1001`. +4. Let the call ring through to the fallback human dial targets. + +Expected result: + +- `asterisk-bridge-service` receives `MVPCCCallStarted` with `QueueCode=voice_lab_ai`. +- platform creates: + - a `voice` interaction + - a `voice_ai_sessions` row + - an `ai_sessions` row with `channel=voice` +- operator UI shows the AI badge on the live/recent call card +- normal call recording flow stays green + +This smoke proves queue selection, bridge-to-runtime session creation, timeline events, and safe human fallback. It does not prove realtime ASR/TTS yet. + +## Verify from the platform + +1. Check: + - `GET /proxy/asterisk-bridge/asterisk/status` +2. Then run: + +```powershell +python scripts\track9_preflight.py --base-url http://127.0.0.1:8080 --check-sftp +``` + +3. For smoke validation: + +```powershell +python scripts\asterisk_lab_smoke.py --base-url http://127.0.0.1:8080 --database-url --require-recording +``` + +4. For formal validation: + +```powershell +python scripts\track9_check.py --base-url http://127.0.0.1:8080 --database-url --require-recording +``` + +5. For Voice AI control-plane validation, also verify: + - `GET /proxy/asterisk-bridge/asterisk/live-calls` + - `GET /proxy/asterisk-bridge/asterisk/live-calls/{call_id}/ai-summary` + - `ai.session_started` exists in the interaction timeline for the `7100` call + +## Build formal acceptance evidence pack + +After smoke/check pass: + +```powershell +python scripts\track9_collect_evidence.py --base-url http://127.0.0.1:8080 --database-url --run-checks --require-recording +``` + +Then fill: + +- `docs/acceptance/track9//track9-acceptance.md` + +At minimum include: + +- bridge status snapshot +- smoke output +- track9_check output +- sample rows from: + - `asterisk_event_log` + - `voice_events` + - recording linkage + +## Retry semantics and duplicate safety + +Bridge event statuses: + +- `received`: event accepted by bridge, no forward result yet. +- `forwarded`: event processed successfully and forwarded to platform services. +- `failed`: forwarding failed; inspect `last_error`, fix root cause, then retry. + +Retry endpoint: + +- `POST /proxy/asterisk-bridge/asterisk/events/{bridge_event_id}/retry` + +Expected behavior: + +1. Retry is safe for the same `bridge_event_id`. +2. Existing `call_id -> interaction_id` links prevent duplicate interaction creation. +3. Recording retry will update missing upload state if SFTP/upload issue is fixed. +4. Keep retries manual in QA until root cause is verified. + +## Timeout / retry policy baseline (QA) + +| Area | Current baseline | Operator action | +|---|---|---| +| AMI reconnect | `ASTERISK_BRIDGE_POLL_INTERVAL_SECONDS` (default `1s`) reconnect loop | Verify `ami_connected=true` in `/asterisk/status` | +| SFTP fetch | Single attempt per event processing | Fix credentials/path/network, then call retry endpoint | +| Recording upload | Single upload attempt per event processing | Fix `recording-service` reachability/auth, then retry | +| Failed event aging | No auto-waiver | Track 9 is not accepted with unresolved failed events | + +## Troubleshooting + +- `AMI is not connected` + - verify `manager.conf`, firewall, and credentials +- `Unknown QueueCode` + - ensure dialplan `QueueCode` matches `ASTERISK_QUEUE_MAP_JSON` + - for Voice AI staging, verify both `voice_lab` and `voice_lab_ai` + - verify mapped `queue_id` exists in platform: + - `GET /proxy/routing/queues` + - re-run failed event with retry endpoint +- `7100` immediately falls back to a human and no AI badge appears + - verify `AI_VOICE_ENABLED=1` + - verify `AI_VOICE_QUEUE_CONFIG_JSON` contains `voice_lab_ai` + - verify the `MVPCCCallStarted` event shows `QueueCode=voice_lab_ai` +- `7100` errors as soon as AudioSocket is enabled + - verify `app_audiosocket.so` is loaded on Asterisk + - verify `MVPCC_AI_AUDIOSOCKET_SERVICE` points to a raw TCP media listener, not `http://...:8000` + - verify the media host and port are reachable from the Asterisk VM +- `No uploaded recordings linked to Asterisk events` + - verify SFTP credentials and `/var/spool/asterisk/monitor/mvpcc` + - verify `ASTERISK_SFTP_BASE_PATH` + - verify `POST /proxy/recording/recordings/import-upload` path is reachable from bridge host + - retry failed `MVPCCRecordingReady` events after fix +- `Failed bridge events present` + - inspect: + - `GET /proxy/asterisk-bridge/asterisk/events?status=failed` + - then retry with: + - `POST /proxy/asterisk-bridge/asterisk/events/{bridge_event_id}/retry` + +## Track 9.2 production cutover + +After QA acceptance is signed, switch to controlled production rollout: + +- [track9-2-production-cutover.md](/e:/Zhan/docs/runbooks/track9-2-production-cutover.md) +- `scripts/track9_2_cutover.ps1` diff --git a/docs/runbooks/backup-restore.md b/docs/runbooks/backup-restore.md new file mode 100644 index 0000000..ca4dcbe --- /dev/null +++ b/docs/runbooks/backup-restore.md @@ -0,0 +1,20 @@ +# Runbook - Backup and Restore (Stage 4) + +## Backup +- Stop write-heavy operations (or enter maintenance window). +- Run backup script: + - `powershell -ExecutionPolicy Bypass -File scripts/backup_data.ps1` +- Keep backup config (`values.yaml`, contracts, runbooks). + +## Restore drill +1. Start clean environment +2. Re-apply deployment manifests +3. Restore snapshots: + - `powershell -ExecutionPolicy Bypass -File scripts/restore_data.ps1 -BackupZip ` +4. Validate smoke scenarios: + - login + - create interaction + - KPI endpoint + +## Acceptance +- RTO and data integrity documented in pilot protocol diff --git a/docs/runbooks/demo-showcase.md b/docs/runbooks/demo-showcase.md new file mode 100644 index 0000000..fbcb4f9 --- /dev/null +++ b/docs/runbooks/demo-showcase.md @@ -0,0 +1,79 @@ +# Demo Showcase Runbook + +Use this flow before showing the MVP to management. + +## One-command prep + +```powershell +powershell -ExecutionPolicy Bypass -File scripts\prepare_demo.ps1 +``` + +What it does: + +- starts the full local stack in the background +- clears local demo data before start +- runs the smoke check against `http://localhost:8080` +- seeds demo data for customers, interactions, integrations, KB, supervisor, KPI, and one sample recording + +## What to open + +- Operator UI: `http://localhost:8080/operator` +- Supervisor UI: `http://localhost:8080/supervisor` +- Admin UI: `http://localhost:8080/admin` +- Logs: `.local_stack\logs` +- Demo summary: `.local_stack\demo-seed-summary.json` +- Login: `admin / admin123` + +## Demo sequence + +1. Log in as `admin / admin123`. +2. Open the interactions list and show: + - one voice interaction already assigned, escalated, and closed + - one active follow-up interaction +3. Show that assign / escalate uses default values (`operator_a`, `line2`) without browser prompts. +4. Trigger the voice channel, Telegram, Webchat, and Email checks. +5. Search KB for `demo-showcase`. +6. Refresh the supervisor block and read the summary cards. +7. Refresh KPI and read the summary cards. + - the JSON payload also includes extended metrics and a `by_channel` breakdown +8. Open `/supervisor`, load `Recordings`, and show playback/download of the seeded sample. +9. Open `/admin`, load the seeded IVR flow, show the completed IVR session, and preview route override with `ivr_session_id`. + +## Optional Telegram chat follow-up + +If the Telegram bot env is configured: + +1. Open `/operator`. +2. Go to the `Telegram` page in the sidebar. +3. Send one test message to the configured bot from a real Telegram client. +4. Show that: + - a persistent thread appears for that `chat_id` + - the linked interaction uses `channel="telegram"` + - the operator can claim the thread and reply from the same page + +## Optional live voice follow-up + +If the Asterisk lab bridge is enabled on a dedicated Linux VM: + +1. Open `/operator`. +2. Choose the QA media path: + - browser softphone via `Browser Softphone` + - or external fallback via `MicroSIP/Zoiper` +3. Place one lab call from softphone `1001` to `7000`. +4. If using browser media, connect the browser softphone first and answer in Chrome/Edge. +5. Use the current voice baseline from `docs/runbooks/voice-baseline.md`. + +## Optional separate shells + +After the main management flow, you can open: + +- `/supervisor` to show dedicated realtime and queue controls +- `/admin` to show separate user and queue management + +## Stop after the demo + +```powershell +powershell -ExecutionPolicy Bypass -File scripts\stop_all_local.ps1 +``` + +For a short speaking script during the meeting, use `docs/runbooks/management-demo-brief.md`. diff --git a/docs/runbooks/deployment-onprem.md b/docs/runbooks/deployment-onprem.md new file mode 100644 index 0000000..eae95e8 --- /dev/null +++ b/docs/runbooks/deployment-onprem.md @@ -0,0 +1,175 @@ +# Runbook - On-Prem Deployment + +## Helm +- `helm upgrade --install mvp-cc ./deployment/helm -n mvp-cc --create-namespace` +- For PostgreSQL-backed Helm releases, the chart now runs `scripts/migrate_core_db.py` automatically via a pre-install / pre-upgrade migration Job. + +## Raw manifests +- `kubectl apply -f deployment/kubernetes/mvp-cc-platform.yaml` +- Raw manifests remain a minimal reference/bootstrap path. +- Helm is the canonical production-scale deployment path for Wave 2 / Track 7. + +## Core DB migration +- Helm path: + - keep `migrations.enabled=true` + - keep `SCHEMA_MANAGEMENT_MODE=migrations` + - the Helm hook Job applies migrations before rollout +- Raw manifests / manual path: + - run `python scripts/migrate_core_db.py` + - then start services with `SCHEMA_MANAGEMENT_MODE=migrations` + +## Wave 2 / Track 7 scale baseline + +Production-like K8s scale validation requires: + +- `DATABASE_URL` set to a shared PostgreSQL endpoint +- `DB_POOL_SIZE` +- `DB_MAX_OVERFLOW` +- `DB_POOL_TIMEOUT_SECONDS` +- `DB_POOL_RECYCLE_SECONDS` +- a working `metrics-server` in the target cluster + +Recommended Wave 2 / Track 7 rollout: + +1. Render Helm config for the scale profile. +2. Confirm `DATABASE_URL` points to PostgreSQL (not SQLite). +3. Confirm `SCHEMA_MANAGEMENT_MODE=migrations`. +4. Confirm `migrations.enabled=true`. +5. Deploy via Helm. +6. Verify the migration Job completed successfully. +7. Run `python scripts/load_test.py --base-url http:// --profile step_250_250 --auth-mode bearer`. +8. Run `python scripts/track7_check.py --namespace mvp-cc --report-dir `. +9. Repeat with `--profile target_500_500`. + +For the complete procedure, use: + +- [track7-scale-validation.md](/e:/Zhan/docs/runbooks/track7-scale-validation.md) + +## Wave 2 / Track 1 auth configuration + +Minimum enterprise auth variables: + +- `APP_TOKEN_SECRET` +- `APP_TOKEN_TTL_SECONDS` (default `3600`) +- `ALLOW_LEGACY_HEADER_AUTH=0` +- `OIDC_ENABLED=1` +- `OIDC_PROVIDER=keycloak` +- `OIDC_ISSUER_URL` +- `OIDC_CLIENT_ID` +- `OIDC_CLIENT_SECRET` +- `OIDC_REDIRECT_URI` +- `OIDC_SCOPES` (default `openid profile email`) +- `OIDC_ROLE_CLAIM` (default `groups`) +- `OIDC_ROLE_MAP_JSON` + +Helm and raw Kubernetes manifests now include placeholder values for these settings. +Replace all `change-me-*` values before deploying outside local/demo. + +Recommended rollout sequence: + +1. For Helm, keep migration hook enabled so schema upgrades run before deploy. +2. Deploy with `OIDC_ENABLED=1` and `ALLOW_LEGACY_HEADER_AUTH=1` in QA. +3. Run `python scripts/oidc_smoke.py --base-url http:// --require-enabled`. +4. Validate a real Keycloak login round trip. +5. Switch enterprise pilot to `ALLOW_LEGACY_HEADER_AUTH=0`. + +## Post-deploy checks +- Pods ready state +- `/health` probes green +- Gateway registry endpoint available +- `GET /proxy/auth/auth/oidc/config` returns `enabled=true` +- `GET /proxy/auth/auth/oidc/health` returns provider metadata status + +See also: + +- [keycloak-oidc.md](/e:/Zhan/docs/runbooks/keycloak-oidc.md) +- [postgres-dev-cutover.md](/e:/Zhan/docs/runbooks/postgres-dev-cutover.md) +- [postgres-server-docker.md](/e:/Zhan/docs/runbooks/postgres-server-docker.md) +- [postgres-server-parallel-stack.md](/e:/Zhan/docs/runbooks/postgres-server-parallel-stack.md) + +## Wave 2 / Track 9.1 strict bridge auth cutover + +Use Helm overlays to force strict bridge auth mode in QA/enterprise environments: + +```powershell +helm upgrade --install mvp-cc deployment\helm ` + -f deployment\helm\values.scale500.yaml ` + -f deployment\helm\values.track9-strict.yaml ` + --set-string auth.appTokenSecret= ` + --set-string asteriskBridge.enabled=1 ` + --set-string asteriskBridge.amiHost= ` + --set-string asteriskBridge.amiUsername= ` + --set-string asteriskBridge.amiSecret= ` + --set-string asteriskBridge.queueMapJson='{\"voice_lab\":\"\"}' ` + --set-string asteriskBridge.sftpHost= ` + --set-string asteriskBridge.sftpUsername= ` + --set-string asteriskBridge.sftpPassword= ` + -n mvp-cc --create-namespace +``` + +Expected strict baseline after deploy: + +- `ALLOW_LEGACY_HEADER_AUTH=0` +- `ASTERISK_BRIDGE_AUTH_MODE=bearer` +- `ASTERISK_BRIDGE_AUTH_FALLBACK_LEGACY=0` +- `VOICE_ADAPTER_TRUSTED_SERVICE_SUBJECTS` contains `svc:asterisk-bridge` +- `RECORDING_IMPORT_TRUSTED_SERVICE_SUBJECTS` contains `svc:asterisk-bridge` +- `RECORDING_IMPORT_ALLOW_ADMIN=0` + +Post-cutover validation: + +```powershell +python scripts\track9_preflight.py --base-url http:// --check-sftp +python scripts\asterisk_lab_smoke.py --base-url http:// --database-url --require-recording +python scripts\track9_check.py --base-url http:// --database-url --require-recording +``` + +## Wave 2 / Track 9.2 controlled production cutover + +Use the dedicated cutover runbook and script: + +- [track9-2-production-cutover.md](/e:/Zhan/docs/runbooks/track9-2-production-cutover.md) +- `scripts/track9_2_cutover.ps1` + +Dry-run (render/lint/snapshot only): + +```powershell +powershell -ExecutionPolicy Bypass -File scripts\track9_2_cutover.ps1 ` + -KubeContext ` + -Namespace ` + -Release ` + -GatewayBaseUrl http:// ` + -DatabaseUrl postgresql://<...> ` + -ImageTag ` + -AmiHost ` + -AmiUser ` + -AmiSecret ` + -SftpHost ` + -SftpUser ` + -SftpPassword ` + -QueueId ` + -AppTokenSecret +``` + +Execute cutover: + +```powershell +powershell -ExecutionPolicy Bypass -File scripts\track9_2_cutover.ps1 ` + -KubeContext ` + -Namespace ` + -Release ` + -GatewayBaseUrl http:// ` + -DatabaseUrl postgresql://<...> ` + -ImageTag ` + -AmiHost ` + -AmiUser ` + -AmiSecret ` + -SftpHost ` + -SftpUser ` + -SftpPassword ` + -QueueId ` + -AppTokenSecret ` + -Execute +``` + +If Helm reports HPA scale field conflicts, rerun the same command with `-ForceUpgrade`. diff --git a/docs/runbooks/event-bus-local.md b/docs/runbooks/event-bus-local.md new file mode 100644 index 0000000..758a9be --- /dev/null +++ b/docs/runbooks/event-bus-local.md @@ -0,0 +1,83 @@ +# Runbook - Event Bus (Track 8, Local) + +## Goal + +Run the first RabbitMQ-backed event bus flow locally and verify: + +- outbox writes happen in business services +- `event-bus-service` publishes events +- `audit-service` consumes them +- `reporting-service` consumes them + +## Local compose path + +Use Docker Compose because it includes RabbitMQ: + +```powershell +cd deployment +docker compose up -d +cd .. +python scripts\migrate_core_db.py +``` + +RabbitMQ management UI: + +- `http://localhost:15672` +- default local credentials: `guest / guest` + +## Required env + +The compose profile already sets: + +- `EVENT_BUS_ENABLED=1` +- `EVENT_BUS_URL=amqp://guest:guest@rabbitmq:5672/` +- `EVENT_BUS_CONSUMER_ENABLED=1` + +For non-compose local runs, export them manually before starting services. + +## Smoke check + +```powershell +python scripts\event_bus_smoke.py --base-url http://localhost:8080 +``` + +Expected: + +- gateway and `event-bus-service` are healthy +- an interaction is created +- its outbox event becomes `published` +- `audit-service` processes the event +- `reporting-service` processes the event + +## Inspect outbox + +```powershell +curl http://localhost:8080/proxy/event-bus/bus/outbox -H "X-User: admin" -H "X-Role: admin" +``` + +Useful statuses: + +- `pending` +- `published` +- `failed` + +## Retry a failed event + +```powershell +curl -X POST http://localhost:8080/proxy/event-bus/bus/outbox//retry -H "X-User: admin" -H "X-Role: admin" +``` + +## Acceptance check + +```powershell +python scripts\track8_check.py --base-url http://localhost:8080 +``` + +## Compatibility mode + +If you want to disable the bus and keep the platform on pure HTTP-only behavior: + +- set `EVENT_BUS_ENABLED=0` +- restart services + +The business REST APIs keep working in that mode. diff --git a/docs/runbooks/gitlab-server-deploy.md b/docs/runbooks/gitlab-server-deploy.md new file mode 100644 index 0000000..2590df3 --- /dev/null +++ b/docs/runbooks/gitlab-server-deploy.md @@ -0,0 +1,82 @@ +# GitLab -> Server Deploy + +This project is prepared for a GitLab-first delivery flow: + +1. Push source code to GitLab. +2. GitLab CI runs tests. +3. GitLab CI builds and pushes container images to GitLab Container Registry. +4. The server only receives the deployment bundle and pulls images from the registry. + +## Files used + +- `.gitlab-ci.yml` +- `deployment/docker-compose.server.registry.yml` +- `deployment/docker-compose.asterisk.server.registry.yml` +- `deployment/.env.images.example` + +## GitLab CI variables + +Required for image publishing: + +- `CI_REGISTRY` +- `CI_REGISTRY_USER` +- `CI_REGISTRY_PASSWORD` +- `CI_REGISTRY_IMAGE` + +Required for the optional deploy job: + +- `DEPLOY_HOST` +- `DEPLOY_USER` +- `DEPLOY_PATH` +- `DEPLOY_SSH_PRIVATE_KEY` + +`DEPLOY_PATH` should point to a minimal runtime directory on the server, for example: + +```text +/opt/call-center +``` + +The server runtime directory should contain: + +- `.env.production` +- `.data_local/` +- `.asterisk_assets/` (can stay empty) +- `deployment/` + +## Server deploy bundle + +The server no longer needs the full project checkout. It only needs: + +- `deployment/` +- `.env.production` +- `.data_local/` +- `.asterisk_assets/` if you use custom prompt files + +The app image is resolved through `APP_IMAGE`. +The Asterisk image is resolved through `ASTERISK_IMAGE`. + +Store them in `deployment/.env.images`, for example: + +```text +APP_IMAGE=registry.gitlab.example.com/group/project/app: +ASTERISK_IMAGE=registry.gitlab.example.com/group/project/asterisk: +``` + +## Manual server deploy + +From the server: + +```bash +cd /opt/call-center/deployment +docker login +docker compose --env-file .env.images -f docker-compose.server.registry.yml pull +docker compose --env-file .env.images -f docker-compose.server.registry.yml up -d +docker compose --env-file .env.images -f docker-compose.asterisk.server.registry.yml pull +docker compose --env-file .env.images -f docker-compose.asterisk.server.registry.yml up -d +``` + +## Notes + +- `deployment/docker-compose.server.yml` is still the source-build variant. +- `deployment/docker-compose.server.registry.yml` is the registry/pull-only variant for the server. +- The deploy job in `.gitlab-ci.yml` is manual on the default branch by design. diff --git a/docs/runbooks/ivr-local.md b/docs/runbooks/ivr-local.md new file mode 100644 index 0000000..0400c3b --- /dev/null +++ b/docs/runbooks/ivr-local.md @@ -0,0 +1,102 @@ +# IVR Local Runbook + +Use this runbook to validate both the preview IVR runtime and the live Asterisk-backed IVR staging path. + +## What Track 5 adds + +- `ivr-service` for IVR flow CRUD and DTMF runtime sessions +- one active IVR flow per voice queue +- `routing-service` support for `ivr_session_id` +- admin-side IVR preview flow in `/admin` + +## Preview baseline vs live IVR + +- Preview baseline: use the `/admin` IVR block to create flows, start sessions, send DTMF, and preview route overrides without telephony. +- Live Asterisk IVR: call `7200` on the lab PBX after `asterisk-bridge-service` is configured with `ASTERISK_IVR_FASTAGI_*` and the flow nodes have valid `prompt_audio_key` values. + +## Create a flow + +1. Open `http://localhost:8080/admin` +2. Go to the `IVR` block +3. Set: + - `queue_id` + - `entry_node_id` (`root`) + - `flow_json` +4. Click `Create flow` + +The flow JSON is validated before it is saved. + +## Start a session + +1. In the same `IVR` block, set: + - `call_id` + - `queue_id` + - optional `interaction_id` +2. Click `Start session` + +The response returns: + +- `session_id` +- `current_node_id` +- current node prompt metadata + +## Send DTMF + +1. Enter a single digit (`0`-`9`) +2. Click `Send DTMF` + +If the transition reaches a terminal node: + +- the session becomes `completed` +- `outcome_code` is set +- `resolved_queue_id` is set +- `resolved_queue_code` is set +- a `voice_event` with `event_type="ivr.completed"` is written + +## Preview route override + +1. Keep the completed `session_id` +2. Click `Preview IVR route` + +This calls `routing-service` with `ivr_session_id` and shows: + +- `original_queue_id` +- `resolved_queue_id` +- `ivr_outcome_code` + +## Demo seed + +`scripts/prepare_demo.ps1` now seeds: + +- one demo IVR flow +- one completed demo IVR session +- one route preview stored in `.local_stack/demo-seed-summary.json` + +Backstage fields: + +- `ivr_flow_id` +- `ivr_session_id` +- `ivr_outcome_code` +- `ivr_resolved_queue_id` + +## Live Asterisk smoke + +1. Enable: + - `ASTERISK_BRIDGE_ENABLED=1` + - `ASTERISK_IVR_FASTAGI_ENABLED=1` + - `IVR_RUNTIME_TRUSTED_SERVICE_SUBJECTS=svc:asterisk-bridge` +2. Make sure `ASTERISK_QUEUE_MAP_JSON` contains `voice_lab_ivr`. +3. Provision the `prompt_audio_key` sound files in Asterisk. +4. Call `7200` and verify: + - DTMF completes the IVR and transfers through `mvpcc-transfer` + - silence triggers `ivr.step.no_input` + - retry exhaustion abandons the IVR session and falls back to the human baseline + +## Troubleshooting + +- `404 No active IVR flow for queue` + - create or activate a flow for the target queue first +- `400 Digit must be a single character 0-9` + - send exactly one DTMF digit +- `400 IVR session is not completed` on route preview + - finish the session with a terminal node before previewing routing diff --git a/docs/runbooks/keycloak-oidc.md b/docs/runbooks/keycloak-oidc.md new file mode 100644 index 0000000..40f1094 --- /dev/null +++ b/docs/runbooks/keycloak-oidc.md @@ -0,0 +1,128 @@ +# Runbook - Keycloak OIDC (Wave 2 Track 1) + +## Scope + +This runbook covers the enterprise identity baseline introduced in Wave 2 Track 1: + +- Keycloak-backed OIDC login +- signed application bearer tokens +- controlled fallback to local login + +It is for QA and enterprise-like environments, not the default demo flow. + +## Modes + +### Local / demo + +- `OIDC_ENABLED=0` +- `ALLOW_LEGACY_HEADER_AUTH=1` + +Use this when: + +- running local demos +- using seeded local users +- preserving existing Stage 1-4 scripts + +### QA transition + +- `OIDC_ENABLED=1` +- `ALLOW_LEGACY_HEADER_AUTH=1` + +Use this when: + +- validating OIDC without breaking old header-based scripts +- checking dual-mode compatibility before cutover + +### Enterprise pilot + +- `OIDC_ENABLED=1` +- `ALLOW_LEGACY_HEADER_AUTH=0` + +Use this when: + +- OIDC is the primary login path +- bearer-token validation is enforced end-to-end + +## Required configuration + +- `APP_TOKEN_SECRET` +- `APP_TOKEN_TTL_SECONDS` (default `3600`) +- `OIDC_ENABLED` +- `OIDC_PROVIDER=keycloak` +- `OIDC_ISSUER_URL` +- `OIDC_CLIENT_ID` +- `OIDC_CLIENT_SECRET` +- `OIDC_REDIRECT_URI` +- `OIDC_SCOPES` (default `openid profile email`) +- `OIDC_ROLE_CLAIM` (default `groups`) +- `OIDC_ROLE_MAP_JSON` +- `ALLOW_LEGACY_HEADER_AUTH` + +Default role map: + +```json +{ + "kc_admin": "admin", + "kc_supervisor": "supervisor", + "kc_operator": "operator", + "kc_analyst": "analyst" +} +``` + +## Keycloak client settings + +Recommended client type: + +- confidential client +- standard authorization code flow enabled +- PKCE enabled + +Redirect URI: + +- `https:///proxy/auth/auth/oidc/callback` + +Web origins: + +- `https://` + +## Validation flow + +1. Apply migrations: + - `python scripts/migrate_core_db.py` +2. Start the stack with Track 1 env vars. +3. Check OIDC endpoints: + - `python scripts/oidc_smoke.py --base-url http:// --require-enabled` +4. Open: + - `http:///operator` +5. Click `Корпоративный вход`. +6. Complete Keycloak login. +7. Confirm the operator UI receives a session and continues using bearer auth. + +## Failure handling + +### OIDC health fails + +- verify `OIDC_ISSUER_URL` +- verify Keycloak realm is reachable from the auth-service +- verify TLS and reverse proxy settings + +### Login returns `403` + +- verify user groups in Keycloak +- verify `OIDC_ROLE_MAP_JSON` +- verify `OIDC_ROLE_CLAIM` matches the actual claim in the ID token + +### Existing scripts stop working + +- in QA only, temporarily set `ALLOW_LEGACY_HEADER_AUTH=1` +- in enterprise pilot, do not revert silently; update the script/client to bearer auth or use documented break-glass local login only + +## Break-glass local access + +Local `/auth/login` remains available in Track 1 for: + +- local development +- demo mode +- break-glass admin access + +It is not the primary path in enterprise mode. diff --git a/docs/runbooks/load-test-plan.md b/docs/runbooks/load-test-plan.md new file mode 100644 index 0000000..40219ca --- /dev/null +++ b/docs/runbooks/load-test-plan.md @@ -0,0 +1,20 @@ +# Load Test Plan (Stage 4) + +## Target +- 100 concurrent voice sessions +- 100 concurrent digital sessions +- Script entrypoint: + - `python scripts/load_test.py --base-url http://localhost:8080 --voice 100 --digital 100` + - or the profile-based equivalent: + - `python scripts/load_test.py --base-url http://localhost:8080 --profile baseline_100_100` + +## Metrics +- API error rate +- P95 response latency +- queue processing delay +- service availability during load + +## Success criteria +- No critical errors +- Stable response times under threshold +- No data loss in interaction lifecycle diff --git a/docs/runbooks/local-run.md b/docs/runbooks/local-run.md new file mode 100644 index 0000000..c6c1c5f --- /dev/null +++ b/docs/runbooks/local-run.md @@ -0,0 +1,65 @@ +# Runbook - Local Startup + +1. Install dependencies: + - `python -m pip install -r requirements.txt` +2. Run tests: + - `pytest -q` +3. SQLite local stack: + - `python scripts/local_stack.py start` +4. PostgreSQL local stack: + - start PostgreSQL and RabbitMQ locally + - `python scripts/postgres_dev_preflight.py --env-file .env.postgres.local.template` + - `python scripts/migrate_core_db.py` + - `python scripts/local_stack.py start --env-file .env.postgres.local.template` + - `python scripts/postgres_dev_preflight.py --env-file .env.postgres.local.template --base-url http://127.0.0.1:8080` +5. Gateway URL: + - `http://localhost:8080` +6. Check health: + - `GET /proxy/auth/health` +7. Run Gate 1 quick check: + - `python scripts/gate1_check.py` +8. Run live Gate 1/2 smoke with auto-started services: + - `python scripts/live_smoke_gate12.py` + - PostgreSQL mode: `python scripts/live_smoke_gate12.py --database-url postgresql://mvp:mvp@localhost:5432/mvpcc` +9. Run Gate 3 check: + - Existing running stack: `python scripts/gate3_check.py` + - Auto-start local stack: `python scripts/gate3_check.py --auto-start` +10. Run Gate 4 automated checks: + - `python scripts/gate4_check.py` + - Keep artifacts: `python scripts/gate4_check.py --keep-artifacts` +11. Run UAT preflight: + - Existing running stack: `python scripts/uat_preflight.py --base-url http://localhost:8080` + - Auto-start local stack: `python scripts/uat_preflight.py --auto-start` +12. Run UAT dry-run package: + - Existing running stack: `python scripts/uat_dry_run.py --base-url http://localhost:8080 --update-defect-register` + - Auto-start local stack: `python scripts/uat_dry_run.py --auto-start --update-defect-register` +13. Optional OIDC smoke-check: + - `python scripts/oidc_smoke.py --base-url http://localhost:8080` + - Require enterprise path enabled: `python scripts/oidc_smoke.py --base-url http://localhost:8080 --require-enabled` +14. Open operator UI: + - `http://localhost:8080/operator` +15. Optional dedicated Wave 2 shells: + - `http://localhost:8080/supervisor` + - `http://localhost:8080/admin` +16. Reset local runtime artifacts when needed: + - `powershell -ExecutionPolicy Bypass -File scripts\clean_workspace.ps1` + +## Runtime artifacts + +- `.data*`, `.local_stack`, and `.artifacts` are regeneratable local runtime directories. +- PostgreSQL local profile keeps runtime artifacts in `.data_local_pg` by default. +- Use `scripts/clean_workspace.ps1` to clear local DBs, logs, cached recordings, and test/runtime leftovers without touching source files or backups. + +## Local auth modes + +- Demo/dev default: + - `OIDC_ENABLED=0` + - `ALLOW_LEGACY_HEADER_AUTH=1` +- Enterprise-like local validation: + - set `OIDC_ENABLED=1` + - set `ALLOW_LEGACY_HEADER_AUTH=0` + - configure `APP_TOKEN_SECRET`, `OIDC_ISSUER_URL`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`, `OIDC_REDIRECT_URI` + +See also: + +- [keycloak-oidc.md](/e:/Zhan/docs/runbooks/keycloak-oidc.md) diff --git a/docs/runbooks/management-demo-brief.md b/docs/runbooks/management-demo-brief.md new file mode 100644 index 0000000..84a5481 --- /dev/null +++ b/docs/runbooks/management-demo-brief.md @@ -0,0 +1,39 @@ +# Management Demo Brief + +Use this note during the meeting so the demo stays short and clear. + +## One-minute version + +1. Open `http://localhost:8080/operator`. +2. Log in as `admin / admin123`. +3. Show one closed interaction and one active interaction. +4. Show the supervisor summary. +5. Show the KPI summary. + +## Three-minute version + +1. Show the demo checklist at the top of the screen. +2. Explain that the stand is started by one command and loaded with demo data. +3. Open customers and interactions. +4. Show assignment, transfer to second line, and closing. +5. Trigger the voice, Telegram, Webchat, and Email channel checks. +6. Search KB for `demo-showcase`. +7. Show supervisor and KPI summary cards. + +## What to say by block + +- Session: role-based access and a working login flow. +- Customers: the platform stores and finds client cards. +- Interactions: the request can be created, assigned, escalated, and closed. +- Integrations: the system accepts voice, Telegram, Webchat, and Email events. +- Knowledge Base: the operator can quickly find a prepared answer. +- Supervisor: the lead sees current queue and agent state. +- KPI: the system already calculates core operational metrics. + +## Five short answers + +- What is ready: MVP with operator flow, integrations, KB, supervisor, KPI, and the first Wave 2 webchat/email channels. +- What is not yet final: this is a pilot-ready MVP, not a full production rollout. +- What is the stack: Python/FastAPI backend, browser UI, microservice layout. +- What about telephony: the voice integration contour is ready and demonstrated by event intake. +- What is next: a separate Wave 2 with SSO, extra channels, expanded admin UX, and scaling. diff --git a/docs/runbooks/pilot-uat.md b/docs/runbooks/pilot-uat.md new file mode 100644 index 0000000..67d0463 --- /dev/null +++ b/docs/runbooks/pilot-uat.md @@ -0,0 +1,88 @@ +# Runbook - Pilot UAT + +## Participants + +- Operators +- Supervisor +- Analyst +- Admin +- Business owner +- IT owner + +## Scope Rules + +- Freeze the pilot scope using `docs/gates/mvp-pilot-baseline.md`. +- Do not record out-of-scope feature requests as MVP defects. +- Route deferred requests to `docs/roadmap/05-wave2-backlog.md`. + +## Preparation + +1. Run environment preflight: + - Existing environment: `python scripts/uat_preflight.py --base-url http://:8080` + - Local auto-start: `python scripts/uat_preflight.py --auto-start` +2. Run UAT dry-run package generator: + - Existing environment: `python scripts/uat_dry_run.py --base-url http://:8080 --update-defect-register` + - Local auto-start: `python scripts/uat_dry_run.py --auto-start --update-defect-register` +3. Create session protocol from template or generated artifact: + - `docs/uat/session-template.md` + - Recommended bundle generator: + - `python scripts/uat_manual_prepare.py --environment-url http://:8080` +4. Prepare defect log file: + - `docs/uat/defect-log-template.csv` +5. Confirm the pilot gateway URL and the build under test in the session protocol. + +## Mandatory scenarios + +- Execute all scenarios from: + - `docs/uat/scenario-checklist.md` +- Required scope: + - auth login and RBAC deny + - customer create/search + - voice lifecycle + - assignment and escalation + - routing and timeline verification + - voice event intake + - Telegram lifecycle + - supervisor realtime + - KPI report and CSV + - KB usage in handling flow + - backup/restore verification + - load test baseline (`100 voice + 100 digital`) + +## Defect triage + +- `P1`: acceptance impossible, fix in MVP immediately +- `P2`: key journey broken, fix in MVP immediately +- `P3`: acceptable with limitation, defer unless it blocks sign-off +- `P4`: cosmetic or backlog-only + +Only `P1` and `P2` defects belong to the MVP remediation cycle. + +## Evidence package + +- Preflight report from `docs/uat/evidence/` +- Completed scenario checklist +- Completed session protocol +- Filled defect log +- Signed sheet from `docs/uat/signoff-template.md` +- Updated `docs/gates/p1-p2-defects.md` + +## Formal closure + +After manual signatures are collected and no open `P1/P2` items remain: + +- Optional validation only: + - `python scripts/finalize_mvp_pilot.py --session-dir docs/uat/evidence/manual_ --dry-run` +- Run: + - `python scripts/finalize_mvp_pilot.py --session-dir docs/uat/evidence/manual_` +- This updates: + - `docs/gates/gate-04-pilot-hardening.md` + - `docs/gates/p1-p2-defects.md` + - `docs/releases/v1.0.0-mvp-accepted.md` + +## Exit criteria + +- No open P1 defects +- No open P2 defects +- Critical user journeys approved +- Sign-off captured from business and IT owners diff --git a/docs/runbooks/postgres-dev-cutover.md b/docs/runbooks/postgres-dev-cutover.md new file mode 100644 index 0000000..cf2da55 --- /dev/null +++ b/docs/runbooks/postgres-dev-cutover.md @@ -0,0 +1,275 @@ +# PostgreSQL Dev Cutover + +Этот runbook описывает безопасный перевод `dev`-окружения с SQLite на PostgreSQL без переноса старых данных. + +## Что считается "без простоя" + +В этом проекте без переноса SQLite-данных безостановочный cutover означает не hot-swap одной и той же базы, а: + +1. поднять новый PostgreSQL-backed stack параллельно; +2. прогнать smoke на новом stack; +3. переключить dev-трафик на новый gateway; +4. оставить старый SQLite stack живым до подтверждения GO; +5. при проблеме вернуть трафик обратно без восстановления данных. + +Если сейчас у dev только один instance и нет возможности держать parallel stack, нужен короткий maintenance window. + +## Scope + +Включает: + +- подготовку PostgreSQL и RabbitMQ; +- применение SQL migrations; +- запуск сервисов в `SCHEMA_MANAGEMENT_MODE=migrations`; +- smoke-проверки до и после переключения; +- rollback на старый SQLite-backed dev stack. + +Не включает: + +- перенос данных из SQLite; +- cleanup legacy SQLite-path; +- production cutover. + +## Required Inputs + +- `DEV_GATEWAY_OLD` +- `DEV_GATEWAY_NEW` +- `POSTGRES_DATABASE_URL` +- `APP_TOKEN_SECRET` +- `PUBLIC_SWITCH_METHOD` + - DNS + - reverse proxy + - load balancer route +- `RABBITMQ_URL` + +Рекомендуемые значения для локально-управляемого dev: + +- `POSTGRES_DATABASE_URL=postgresql://mvp:mvp@:5432/mvpcc` +- `SCHEMA_MANAGEMENT_MODE=migrations` +- env template: [\.env.postgres.local.template](/e:/Zhan/.env.postgres.local.template) + +## Phase 0 - Preconditions + +Подтвердить перед началом: + +1. Текущий SQLite-backed dev stack стабилен. +2. Перенос данных не нужен. +3. PostgreSQL доступен по сети с host, где запускаются сервисы. +4. RabbitMQ доступен по сети, если в dev нужен `EVENT_BUS_ENABLED=1`. +5. Новая dev-конфигурация использует: + - `DATABASE_URL=postgresql://...` + - `SCHEMA_MANAGEMENT_MODE=migrations` +6. Старый SQLite stack не останавливается до завершения smoke на новом stack. + +## Phase 1 - Preflight + +На будущем PostgreSQL-backed dev host: + +1. Проверить Python зависимости: + +```powershell +python -m pip install -r requirements.txt +``` + +2. Подготовить env-файл: + - скопировать [\.env.postgres.local.template](/e:/Zhan/.env.postgres.local.template) + - заполнить `DATABASE_URL`, `APP_TOKEN_SECRET`, интеграционные URL и при необходимости AI/Telegram/WhatsApp переменные + +3. Запустить DB-only preflight: + +```powershell +python scripts\postgres_dev_preflight.py --env-file .env.postgres.local.template +``` + +4. Убедиться, что mode не legacy: + +```text +SCHEMA_MANAGEMENT_MODE=migrations +``` + +5. Применить миграции в PostgreSQL: + +```powershell +python scripts\migrate_core_db.py +``` + +Ожидается: + +- миграции применились без ошибок; +- в PostgreSQL появилась таблица `schema_migrations`; +- сервисы ещё не стартовали. + +## Phase 2 - Bring Up Parallel Stack + +Поднять новый stack параллельно старому. + +Если запуск локальный: + +```powershell +python scripts\local_stack.py start ` + --runtime-dir .local_stack_pg ` + --env-file .env.postgres.local.template ` + --force-restart +``` + +Если это dev server / VM: + +1. развернуть те же сервисы в отдельный deployment set; +2. прокинуть им: + - `DATABASE_URL=postgresql://...` + - `SCHEMA_MANAGEMENT_MODE=migrations` +3. не направлять внешний dev-трафик на новый gateway до завершения smoke. + +Важно: + +- новый stack должен использовать отдельный runtime/log directory; +- старый SQLite-backed stack остаётся доступным; +- нельзя смешивать новый dev-трафик со старым gateway до smoke. + +## Phase 3 - Smoke Before Switch + +Выполнить проверки на `DEV_GATEWAY_NEW`. + +Сначала прогнать HTTP-aware preflight: + +```powershell +python scripts\postgres_dev_preflight.py ` + --env-file .env.postgres.local.template ` + --base-url http://127.0.0.1:8080 +``` + +1. Health: + +```powershell +python scripts\live_smoke_gate12.py --base-url http:// --database-url +``` + +2. Focused PostgreSQL smoke: + +```powershell +pytest -q tests/test_postgres_readiness.py +``` + +3. Дополнительно проверить вручную: + +- `GET /proxy/auth/health` +- `GET /proxy/interaction/health` +- `GET /proxy/voice/health` +- `GET /proxy/ai-voice-runtime/health` +- открыть `http:///operator` + +4. Если в dev включён event bus: + +- проверить подключение к RabbitMQ; +- убедиться, что `event-bus-service` поднимается в `healthy`; +- убедиться, что новые записи появляются в `event_outbox`. + +GO в следующую фазу только если: + +- health green; +- smoke green; +- новый stack не пытается auto-create schema на старте; +- нет ошибок вида `run python scripts/migrate_core_db.py` после фактического применения миграций. + +## Phase 4 - Traffic Switch + +Переключение выполняется только после успешного Phase 3. + +Рекомендуемый порядок: + +1. уменьшить TTL у dev DNS / подготовить proxy route заранее; +2. переключить `DEV_GATEWAY` на `DEV_GATEWAY_NEW`; +3. не останавливать старый SQLite stack; +4. сразу после switch выполнить быстрый post-switch smoke. + +Примеры post-switch smoke: + +```powershell +python scripts\live_smoke_gate12.py --base-url http:// --database-url +``` + +Проверить руками: + +- логин; +- создание interaction; +- digital thread/message flow; +- voice event ingest; +- создание voice AI session. + +## Phase 5 - Stabilization Window + +В течение первых 15-30 минут после switch: + +1. наблюдать логи gateway и сервисов; +2. следить за ошибками подключения к PostgreSQL; +3. следить за ошибками `schema mismatch`; +4. следить за ошибками блокировок/unique conflict в voice transcript path; +5. сравнивать user-visible поведение со старым dev stack. + +Если всё стабильно: + +1. объявить GO; +2. зафиксировать, что `dev` теперь PostgreSQL-backed; +3. оставить SQLite stack выключенным, но не удалённым до конца рабочего дня. + +## Rollback + +Rollback делается только через возврат трафика на старый SQLite-backed stack. + +Триггеры rollback: + +- новый gateway не проходит smoke; +- сервисы падают на startup; +- критичный API path broken; +- UI не работает; +- ошибки подключения к PostgreSQL не устраняются быстро; +- event bus / voice path деградирует. + +Шаги rollback: + +1. вернуть `DEV_GATEWAY_PUBLIC` на `DEV_GATEWAY_OLD`; +2. убедиться, что старый SQLite stack всё ещё жив; +3. выполнить быстрый smoke на старом stack; +4. запретить новый трафик на PostgreSQL-backed stack; +5. собрать логи с нового stack. + +Rollback успешен, если: + +- dev UI снова открывается на старом gateway; +- CRUD и smoke снова зелёные; +- команда работает на старом dev без дополнительных действий. + +## Cutover Sheet + +Перед cutover заполнить: + +- время старта; +- кто выполняет switch; +- `DEV_GATEWAY_OLD`; +- `DEV_GATEWAY_NEW`; +- `POSTGRES_DATABASE_URL`; +- результат preflight; +- результат smoke до switch; +- время switch; +- результат smoke после switch; +- GO / ROLLBACK; +- короткий список замечаний. + +## Minimal Command Sequence + +Если нужен самый короткий practical path для dev: + +```powershell +python scripts\migrate_core_db.py +python scripts\postgres_dev_preflight.py --env-file .env.postgres.local.template +python scripts\local_stack.py start --runtime-dir .local_stack_pg --env-file .env.postgres.local.template --force-restart +python scripts\postgres_dev_preflight.py --env-file .env.postgres.local.template --base-url http://127.0.0.1:8080 +pytest -q tests/test_postgres_readiness.py +python scripts\live_smoke_gate12.py --database-url postgresql://mvp:mvp@localhost:5432/mvpcc +``` + +После этого: + +1. переключить dev gateway/public route на новый stack; +2. прогнать smoke ещё раз уже через публичный dev URL; +3. оставить старый SQLite stack как rollback target. diff --git a/docs/runbooks/postgres-server-docker.md b/docs/runbooks/postgres-server-docker.md new file mode 100644 index 0000000..8a22f69 --- /dev/null +++ b/docs/runbooks/postgres-server-docker.md @@ -0,0 +1,88 @@ +# Runbook - Shared Docker Host PostgreSQL for call-center + +Use this runbook when the server already hosts other Docker projects and `call-center` still runs on SQLite. This flow creates an isolated PostgreSQL instance for `call-center` only and does not restart the current application stack. + +## Assets + +- Compose file: `deployment/docker-compose.postgres.server.yml` +- Secret env file: `/home/mvpcc/call-center/.env.postgres.server` +- Data directory: `/home/mvpcc/call-center/.data_pg` +- Host-local PostgreSQL port: `127.0.0.1:5434` + +Future application connection string: + +- containers on the same Docker host: `postgresql://mvpcc_app:@call-center-postgres:5432/mvpcc` +- host-local checks: `postgresql://mvpcc_app:@127.0.0.1:5434/mvpcc` + +## Bring-up + +From the repository root on the target server: + +```bash +cd /home/mvpcc/call-center +python3 - <<'PY' +from pathlib import Path +import secrets + +target = Path(".env.postgres.server") +target.write_text( + "POSTGRES_DB=mvpcc\n" + "POSTGRES_USER=mvpcc_app\n" + f"POSTGRES_PASSWORD={secrets.token_urlsafe(32)}\n", + encoding="utf-8", +) +PY +chmod 600 .env.postgres.server +mkdir -p .data_pg +chmod 700 .data_pg +docker compose -f deployment/docker-compose.postgres.server.yml up -d +``` + +## Verification + +Container status and logs: + +```bash +cd /home/mvpcc/call-center +docker compose -f deployment/docker-compose.postgres.server.yml ps +docker compose -f deployment/docker-compose.postgres.server.yml logs postgres --tail 50 +docker exec call-center-postgres pg_isready -U mvpcc_app -d mvpcc +``` + +Host-local connection check without installing `psql` on the server: + +```bash +cd /home/mvpcc/call-center +set -a +. ./.env.postgres.server +set +a +docker run --rm --network host \ + -e PGPASSWORD="$POSTGRES_PASSWORD" \ + postgres:16-alpine \ + psql -h 127.0.0.1 -p 5434 -U "$POSTGRES_USER" -d "$POSTGRES_DB" \ + -Atqc "select current_database(), current_user;" +``` + +Port exposure check: + +```bash +ss -ltn | grep 5434 +``` + +Expected result: + +- the container is `healthy` +- `pg_isready` returns `accepting connections` +- the SQL check returns `mvpcc|mvpcc_app` +- `ss` shows `127.0.0.1:5434`, not `0.0.0.0:5434` + +## Scope guardrails + +Do not do these actions in this step: + +- do not edit `deployment/docker-compose.server.yml` +- do not switch `DATABASE_URL` for the running `call-center` services +- do not run application migrations against the new PostgreSQL instance yet +- do not touch existing PostgreSQL containers used by other projects + +When a second Dockerized `call-center` stack is added later on the same host, connect it to the external network `call-center-postgres_default` and use `call-center-postgres:5432` from the application containers. diff --git a/docs/runbooks/postgres-server-parallel-stack.md b/docs/runbooks/postgres-server-parallel-stack.md new file mode 100644 index 0000000..a7cb616 --- /dev/null +++ b/docs/runbooks/postgres-server-parallel-stack.md @@ -0,0 +1,105 @@ +# Runbook - Parallel PostgreSQL-backed Stack on a Shared Docker Host + +Use this runbook after the isolated PostgreSQL server from `postgres-server-docker.md` is already running. The goal is to bring up a second `call-center` stack on PostgreSQL without stopping the current SQLite-backed stack on port `8080`. + +## Assets + +- Compose file: `deployment/docker-compose.parallel.server.yml` +- Base env: `/home/mvpcc/call-center-pg/.env.production` +- Override env: `/home/mvpcc/call-center-pg/.env.postgres.parallel.server` +- Runtime data dir: `/home/mvpcc/call-center-pg/.data_pg_parallel` +- Gateway port: `18080` + +This parallel stack intentionally disables active external integrations so it stays passive while the SQLite-backed stack remains live: + +- Telegram bot disabled +- WhatsApp outbound disabled +- Asterisk bridge disabled +- FastAGI disabled +- AI voice disabled + +The parallel stack reaches PostgreSQL over the external Docker network created by `deployment/docker-compose.postgres.server.yml`, using the hostname `call-center-postgres` on port `5432`. + +## Server bring-up + +Prepare a separate build workspace from the current repository checkout: + +```bash +rm -rf /home/mvpcc/call-center-pg +mkdir -p /home/mvpcc/call-center-pg +``` + +Copy the current `.env.production` from the live SQLite stack, then add a PostgreSQL override file: + +```bash +cd /home/mvpcc/call-center-pg +cp /home/mvpcc/call-center/.env.production .env.production +cp .env.postgres.parallel.server.template .env.postgres.parallel.server +``` + +Update `.env.postgres.parallel.server`: + +- replace `` with the real server host or IP +- replace the PostgreSQL password placeholder with the password from `/home/mvpcc/call-center/.env.postgres.server` +- keep `DATABASE_URL` pointed at `call-center-postgres:5432`, not `127.0.0.1:5434` + +Build the image and prepare the runtime directory: + +```bash +cd /home/mvpcc/call-center-pg +mkdir -p .data_pg_parallel +docker compose -f deployment/docker-compose.parallel.server.yml build +``` + +The parallel compose builds and uses the dedicated image tag `call-center-app:pg-parallel`. + +Run migrations before starting the stack: + +```bash +cd /home/mvpcc/call-center-pg +docker compose -f deployment/docker-compose.parallel.server.yml run --rm auth-service python scripts/migrate_core_db.py +``` + +Bring up the PostgreSQL-backed stack: + +```bash +cd /home/mvpcc/call-center-pg +docker compose -f deployment/docker-compose.parallel.server.yml up -d +``` + +## Verification + +Health and status: + +```bash +cd /home/mvpcc/call-center-pg +docker compose -f deployment/docker-compose.parallel.server.yml ps +curl -fsS http://127.0.0.1:18080/health +curl -fsS http://127.0.0.1:18080/proxy/auth/health +curl -fsS http://127.0.0.1:8080/health +``` + +Focused PostgreSQL preflight from inside the parallel stack: + +```bash +cd /home/mvpcc/call-center-pg +docker compose -f deployment/docker-compose.parallel.server.yml run --rm auth-service \ + sh -lc 'python scripts/postgres_dev_preflight.py --database-url "$DATABASE_URL" --base-url http://api-gateway:8000' +``` + +Expected result: + +- the new stack is reachable at `http://:18080` +- the old SQLite-backed stack stays reachable at `http://:8080` +- no host ports except `18080` are added for the parallel stack +- the PostgreSQL-backed containers stay healthy +- application containers talk to PostgreSQL over Docker network `call-center-postgres_default` + +## Scope guardrails + +Do not do these actions in this step: + +- do not stop or restart the existing `call-center` compose project +- do not change `/home/mvpcc/call-center/deployment/docker-compose.server.yml` +- do not reuse the existing `call-center-app:local` image tag for the new stack +- do not re-enable Telegram, WhatsApp, or Asterisk on the parallel stack until cutover is planned diff --git a/docs/runbooks/recordings-local.md b/docs/runbooks/recordings-local.md new file mode 100644 index 0000000..079ad6c --- /dev/null +++ b/docs/runbooks/recordings-local.md @@ -0,0 +1,65 @@ +# Local Recordings Runbook + +Use this flow to validate Track 4 locally. + +## Storage location + +- Default root: `.data_local\recordings` +- Override with `CC_RECORDINGS_DIR` + +Files are copied into a managed layout: + +- `YYYY\MM\DD\rec__` + +## Import flow + +1. Submit a `recording.ready` voice event with: + - `payload.source_path` + - optional `file_name`, `mime_type`, `duration_seconds`, `recorded_at` +2. Import it through: + +```text +POST /proxy/recording/recordings/import-from-voice-event/{event_id} +``` + +You can also register directly by server path: + +```text +POST /proxy/recording/recordings/register +``` + +## Review in supervisor shell + +1. Open `http://localhost:8080/supervisor` +2. Go to `Recordings` +3. Load recordings or import by `voice_event_id` +4. Select a row to open metadata and preview +5. Use: + - `Open` for metadata + playback + - `Download` for file export + - `Archive` to mark the recording as archived + +## Missing file handling + +If the DB row exists but the stored file is gone: + +- content request returns `410 Gone` +- the row status becomes `missing` +- the metadata remains available for diagnosis + +## Limits + +- Default max size: `25 MB` +- Override with `RECORDING_MAX_BYTES` + +## Demo seed + +`scripts\prepare_demo.ps1` now creates: + +- one sample `.wav` file +- one `recording.ready` voice event +- one imported managed recording + +The generated `recording_id` is stored in: + +- `.local_stack\demo-seed-summary.json` diff --git a/docs/runbooks/track10-live-voice-reliability.md b/docs/runbooks/track10-live-voice-reliability.md new file mode 100644 index 0000000..f501c4e --- /dev/null +++ b/docs/runbooks/track10-live-voice-reliability.md @@ -0,0 +1,77 @@ +# Runbook - Track 10 Live Voice Reliability + +Use this runbook to validate lifecycle latency after Track 9 integration. + +## Preconditions + +- Platform stack is running. +- Asterisk bridge is connected (`/proxy/asterisk-bridge/asterisk/status`). +- Real lab calls are already made (`1001 -> 7000`). + +## 1) Generate latency report + +```powershell +python scripts\track10_voice_latency_report.py ` + --database-url sqlite:///.data_local/mvp_cc.db ` + --since-hours 8 ` + --breach-mode direct ` + --json-out .artifacts/track10/voice-latency-latest.json +``` + +For post-fix short-window validation use minutes: + +```powershell +python scripts\track10_voice_latency_report.py ` + --database-url sqlite:///.data_local/mvp_cc.db ` + --since-minutes 30 ` + --breach-mode direct ` + --json-out .artifacts/track10/voice-latency-30m.json +``` + +Optional strict gate mode: + +```powershell +python scripts\track10_voice_latency_report.py ` + --database-url sqlite:///.data_local/mvp_cc.db ` + --since-hours 8 ` + --max-started-to-ended-seconds 30 ` + --max-ended-to-recording-seconds 45 ` + --breach-mode direct ` + --fail-on-breach +``` + +## 2) Review report fields + +Mandatory fields: +- `summary.total_calls` +- `summary.active_no_end` +- `summary.ended_no_recording_upload` +- `summary.start_to_end.p95` +- `summary.end_to_recording_upload.p95` +- `summary.ended_direct_calls` +- `summary.ended_reconciled_calls` +- `summary.direct_start_to_end.p95` +- `summary.direct_end_to_recording_upload.p95` + +## 3) Save acceptance evidence + +Create a timestamp folder: + +`docs/acceptance/track10//` + +Place: +- `voice-latency-latest.json` +- command output log +- note with latest call IDs included in report + +## 4) Decision guidance + +GO candidate: +- `active_no_end = 0` +- `ended_no_recording_upload = 0` +- direct p95 metrics within Track 10 thresholds + +NO-GO candidate: +- repeated high latency outside thresholds, +- stale active calls without `call.ended`, +- missing recordings after completed calls. diff --git a/docs/runbooks/track11-live-operator-call-control.md b/docs/runbooks/track11-live-operator-call-control.md new file mode 100644 index 0000000..bb83142 --- /dev/null +++ b/docs/runbooks/track11-live-operator-call-control.md @@ -0,0 +1,76 @@ +# Runbook - Track 11 Live Operator Call Control + +Use this runbook to verify the accepted call-control path introduced in Track 11. + +For the current operator UX and active/recent call behavior, use `docs/runbooks/track12-operator-voice-ux.md`. + +## Preconditions + +- Track 9 bridge works: `/proxy/asterisk-bridge/asterisk/status` returns `ami_connected=true` +- operator SIP extensions exist in Asterisk: `2001`, `2002` +- bridge env includes: + - `ASTERISK_CALLCONTROL_ENABLED=1` + - `ASTERISK_OPERATOR_EXTENSION_MAP_JSON` + - `ASTERISK_TRANSFER_TARGET_MAP_JSON` + +## 1) Start the QA stack + +```powershell +powershell -ExecutionPolicy Bypass -File scripts\start_track9_qa.ps1 +``` + +Verify: + +```powershell +curl http://127.0.0.1:8080/proxy/asterisk-bridge/asterisk/status +``` + +## 2) Real call scenario + +1. Register caller softphone `1001`. +2. Register operator softphone `2001`. +3. Make call `1001 -> 7000`. +4. Open `http://127.0.0.1:8080/operator` as `operator`. +5. In `Живые звонки`: + - select the active call + - click `Принять в работу` +6. Speak in `MicroSIP/Zoiper`. +7. Finish with one of: + - `Завершить` + - `Передать` + +## 3) Verify persisted events + +Expected event types for the same call: +- `call.started` +- `call.connected` +- `call.ended` +- `recording.ready` +- optional `call.transferred` + +## 4) Verify recording playback + +1. Open `http://127.0.0.1:8080/supervisor`. +2. In `Записи`, load the latest recording for the call. +3. Check inline playback: `audio/*`, HTTP `200`. + +## 5) Inspect action log + +```powershell +curl -H "X-User: operator_a" -H "X-Role: operator" ^ + http://127.0.0.1:8080/proxy/asterisk-bridge/asterisk/live-calls//actions +``` + +Each action should have: +- `action_type` in `claim|hangup|blind-transfer` +- `result_status=ok` +- `ami_action_id` for a successful AMI call + +## 6) Failure handling + +- `403 Operator can control only own claimed call` + - use the correct operator account or claim the call first +- `409 Unable to resolve active channel for call` + - check AMI permissions and channel resolution +- `Unknown transfer queue_code` + - fix `ASTERISK_TRANSFER_TARGET_MAP_JSON` diff --git a/docs/runbooks/track12-operator-voice-ux.md b/docs/runbooks/track12-operator-voice-ux.md new file mode 100644 index 0000000..bf7ffcf --- /dev/null +++ b/docs/runbooks/track12-operator-voice-ux.md @@ -0,0 +1,53 @@ +# Runbook - Track 12 Operator Voice UX + +Use this runbook to validate the frozen operator voice baseline after Track 12. + +## Preconditions + +- Platform is running locally or in QA +- `ASTERISK_CALLCONTROL_ENABLED=1` +- operator softphone is registered: `2001` or `2002` +- caller softphone `1001` can call `7000` + +## Operator model + +- The phone answer happens in `MicroSIP/Zoiper` +- Browser action `Принять в работу` does not answer media +- Browser actions manage ownership and call-control in the platform +- `Активные звонки` shows only live bridge-backed calls +- `Только что завершённые` shows short-lived recent terminal calls + +## Happy path + +1. Open `/operator` and log in as `operator / op12345`. +2. Place a call `1001 -> 7000`. +3. Answer in `MicroSIP/Zoiper`. +4. Confirm the card appears in `Активные звонки`. +5. Click `Принять в работу`. +6. Confirm the card stays in `Активные звонки`. +7. Click `Завершить`. +8. Confirm the card moves to `Только что завершённые`. +9. Open `/supervisor` and verify the imported recording. + +## Transfer path + +1. Repeat the call flow. +2. Click `Принять в работу`. +3. Set: + - `target type = extension` + - `target value = 2002` +4. Click `Передать`. +5. Confirm the card moves to `Только что завершённые`. +6. Confirm the recent card shows transfer as the terminal action. + +## Passive path + +1. Place a call and answer it in SIP. +2. Do not click any browser action for at least 30-40 seconds. +3. Confirm the call stays in `Активные звонки` until the real hangup. + +## Failure cues + +- If `Живые звонки` shows `interaction:int_*`, the browser is using stale assets +- If a call disappears before the real hangup, inspect bridge reconciliation behavior +- If `Передать` succeeds in SIP but not in UI, inspect `/asterisk/live-calls/{call_id}/actions` diff --git a/docs/runbooks/track14-browser-softphone.md b/docs/runbooks/track14-browser-softphone.md new file mode 100644 index 0000000..40f3e6d --- /dev/null +++ b/docs/runbooks/track14-browser-softphone.md @@ -0,0 +1,69 @@ +# Track 14 Browser Softphone Runbook + +## Preconditions + +- `ASTERISK_WEBRTC_ENABLED=1` +- `ASTERISK_WEBRTC_WS_URL` points to the Asterisk WSS endpoint +- `ASTERISK_BROWSER_SIP_MAP_JSON` contains a mapping for the operator app user +- Asterisk `http.conf`, `rtp.conf`, and `pjsip.conf` from `deployment/asterisk/` are applied on the lab VM +- The operator workstation trusts the QA certificate used by Asterisk WSS +- Chrome or Edge is used for `/operator` + +## Browser config contract + +Bridge route: +- `/proxy/asterisk-bridge/asterisk/browser-softphone/config` + +Expected config fields: +- `enabled` +- `ws_url` +- `sip_uri` +- `authorization_username` +- `password` +- `display_name` +- `ice_servers` +- `operator_extension` + +If the logged-in app user has no browser SIP mapping, the route returns `403`. + +## QA startup flow + +1. Open `/operator` in Chrome or Edge. +2. Log in with a user that has: + - operator role access + - a browser SIP mapping in `ASTERISK_BROWSER_SIP_MAP_JSON` +3. Click `Подключить browser softphone`. +4. Allow microphone access. +5. Confirm the `Browser Softphone` block reaches `Зарегистрирован`. + +## Inbound QA call flow + +1. Place a lab call from `1001` to `7000`. +2. Wait for the incoming browser banner. +3. Click `Answer`. +4. The browser tries to auto-claim the live call. +5. If auto-claim does not happen because matching is ambiguous, click `Принять в работу` manually. +6. Click `Hang up` in the browser block or `Завершить` in the live-call block. +7. Confirm the call moves to `Только что завершённые`. +8. Confirm the recording is available in `/supervisor`. + +## Coexistence notes + +- `MicroSIP/Zoiper` remains supported in QA. +- Browser softphone does not replace the accepted Track 11/12 control-path. +- Browser `Hang up` still uses the accepted bridge hangup route when a live call match exists. + +## Common failures + +### Browser softphone config missing +- Check `ASTERISK_BROWSER_SIP_MAP_JSON` +- Check that the app user name matches the mapping key exactly + +### WSS registration fails +- Check Asterisk `http.conf` +- Check that the workstation trusts the QA certificate +- Check `ASTERISK_WEBRTC_WS_URL` + +### Browser answer works but auto-claim does not +- The current rule requires exactly one active ringing call for the operator extension +- If more than one candidate exists, use manual `Принять в работу` diff --git a/docs/runbooks/track15-telegram-chat.md b/docs/runbooks/track15-telegram-chat.md new file mode 100644 index 0000000..f6d5025 --- /dev/null +++ b/docs/runbooks/track15-telegram-chat.md @@ -0,0 +1,44 @@ +# Track 15 - Telegram Chat Runbook + +## Required env + +- `TELEGRAM_BOT_ENABLED=1` +- `TELEGRAM_BOT_TOKEN` +- `TELEGRAM_WEBHOOK_SECRET` +- `TELEGRAM_DEFAULT_QUEUE_ID` + +Optional: + +- `TELEGRAM_BOT_API_BASE` when running against a stub or proxy + +## Register the bot webhook + +Use Telegram Bot API `setWebhook` with: + +- URL -> your public `telegram-adapter-service` webhook path: + - `/integrations/telegram/bot/webhook` +- secret token -> `TELEGRAM_WEBHOOK_SECRET` + +## Operator workflow + +1. Open `/operator` +2. Go to the `Telegram` page +3. Wait for a real inbound bot message +4. Select the thread from the left list +5. Click `Принять в работу` +6. Reply from the composer +7. Close or escalate the thread if needed + +## Runtime behavior + +- one Telegram thread is stored per `chat_id` +- new inbound messages reuse the same `thread_id` and `interaction_id` +- if the linked interaction was `closed`, a new inbound message reactivates it to `new` +- unsupported inbound Telegram content is stored as a visible system placeholder + +## QA check + +- send one first message from Telegram -> thread appears +- send a second message from the same `chat_id` -> same thread is reused +- operator claim works +- operator reply appears in the thread and is sent via Bot API diff --git a/docs/runbooks/track7-scale-validation.md b/docs/runbooks/track7-scale-validation.md new file mode 100644 index 0000000..97fc537 --- /dev/null +++ b/docs/runbooks/track7-scale-validation.md @@ -0,0 +1,134 @@ +# Runbook - Track 7 Scale Validation + +## Goal + +Validate the production-like `scale500` profile for Wave 2 / Track 7 on `K8s on-prem`. + +This runbook assumes: + +- Helm is the canonical deployment path +- `DATABASE_URL` points to a shared PostgreSQL endpoint +- `metrics-server` is installed and healthy +- Helm migration hook is enabled + +## Prerequisites + +- `kubectl` access to the target cluster +- `helm` installed locally +- namespace chosen (examples below use `mvp-cc`) +- Helm chart configured with: + - `global.schemaManagementMode=migrations` + - `migrations.enabled=true` + +## Configure Helm values + +Before deployment, confirm: + +- `profiles.scale500.enabled=true` +- `global.databaseUrl` is set to PostgreSQL +- `global.schemaManagementMode=migrations` +- `migrations.enabled=true` +- `db.poolSize`, `db.maxOverflow`, `db.poolTimeoutSeconds`, `db.poolRecycleSeconds` match the target baseline + +Render and inspect: + +```powershell +helm lint deployment\helm +helm template mvp-cc deployment\helm -n mvp-cc +``` + +## Deploy + +```powershell +helm upgrade --install mvp-cc deployment\helm -n mvp-cc --create-namespace +``` + +Wait until: + +- migration Job `mvp-cc-db-migrate` completed successfully +- all expected pods are `Ready` +- HPA objects are created for the hot-path services +- `recording-service` stays singleton + +## Step validation (`250 + 250`) + +Run the staged profile first: + +```powershell +python scripts\load_test.py --base-url http:// --profile step_250_250 --auth-mode bearer +python scripts\track7_check.py --namespace mvp-cc --report-dir .artifacts\track7\ --require-success-rate 99 --require-p95-seconds 1.5 --require-p99-seconds 3.0 +``` + +Expected: + +- success rate `>= 99%` +- no crash loops or OOM kills +- no unexpected pod restarts + +You can also run the staged helper, which writes a formal acceptance pack automatically: + +```powershell +python scripts\track7_stage.py --base-url http:// --namespace mvp-cc --auth-mode bearer +``` + +This creates: + +- `.artifacts/track7/staged_/step_250_250` +- `.artifacts/track7/staged_/target_500_500` +- `.artifacts/track7/staged_/acceptance_summary.json` +- `.artifacts/track7/staged_/acceptance_summary.md` + +## Target validation (`500 + 500`) + +Only after the step profile passes: + +```powershell +python scripts\load_test.py --base-url http:// --profile target_500_500 --auth-mode bearer +python scripts\track7_check.py --namespace mvp-cc --report-dir .artifacts\track7\ --require-success-rate 99 --require-p95-seconds 2.0 --require-p99-seconds 3.5 +``` + +Expected: + +- `500 voice + 500 digital` mixed profile +- success rate `>= 99%` +- p95 `<= 2.0s` +- p99 `<= 3.5s` +- `5xx` / transport failure rate `<= 0.5%` + +## Evidence pack + +Keep the following together: + +- rendered Helm values/profile +- `summary.json` +- `latency_samples.csv` +- `error_samples.json` +- `mix_profile.json` +- output from `track7_check.py` +- rollout notes (cluster, namespace, date, profile, result) + +## Rollback + +If the target profile fails: + +1. Roll back the Helm release: + +```powershell +helm rollback mvp-cc +``` + +2. Keep the failed report directory for analysis. +3. Reduce to the last passing profile and re-run validation only after tuning. + +## Canonical scale500 values + +Use the built-in preset as the base overlay for the production-like scale profile: + +```powershell +helm upgrade --install mvp-cc deployment\helm -f deployment\helm\values.scale500.yaml -n mvp-cc --create-namespace +``` + +Then override at least: + +- `global.databaseUrl` +- image tag / repository as needed for the target cluster diff --git a/docs/runbooks/track9-2-production-cutover.md b/docs/runbooks/track9-2-production-cutover.md new file mode 100644 index 0000000..ef5c5df --- /dev/null +++ b/docs/runbooks/track9-2-production-cutover.md @@ -0,0 +1,190 @@ +# Wave 2 / Track 9.2 - Production Cutover (K8s Helm, Controlled Window) + +This runbook executes Track 9.2 as an operations cutover, not a feature track. + +## Goal + +Move the accepted Asterisk bridge flow from QA to production Helm deployment with: + +- strict bridge auth (`bearer` only), +- one real softphone call validation, +- recording import + supervisor playback proof, +- formal GO/NO-GO evidence and rollback path. + +## Scope + +Includes: + +- production Helm upgrade in strict mode, +- post-cutover checks: + - `track9_preflight --check-sftp --require-strict-service-auth` + - `asterisk_lab_smoke --require-recording` + - `track9_check --require-recording` +- evidence package collection. + +Excludes: + +- ARI/call-control, +- new voice features, +- business API contract changes. + +## Required inputs + +Fill [track9-cutover-sheet.template.md](/e:/Zhan/docs/acceptance/track9/track9-cutover-sheet.template.md) first. + +Mandatory values: + +- `KUBE_CONTEXT` +- `NAMESPACE` +- `RELEASE` +- `GATEWAY_BASE_URL` +- `DATABASE_URL` (PostgreSQL in production) +- `IMAGE_TAG` +- `ASTERISK_AMI_HOST`, `ASTERISK_AMI_USERNAME`, `ASTERISK_AMI_SECRET` +- `ASTERISK_SFTP_HOST`, `ASTERISK_SFTP_USERNAME`, `ASTERISK_SFTP_PASSWORD` +- `QUEUE_ID` for `voice_lab` +- `APP_TOKEN_SECRET` + +## Phase 0 - Entry checks (T-1 day) + +1. Baseline reference is fixed: + - [docs/acceptance/track9/track9-acceptance.md](/e:/Zhan/docs/acceptance/track9/track9-acceptance.md) +2. Confirm `voice_lab -> queue_id` exists in target routing DB. +3. Confirm cluster can reach Asterisk VM: + - AMI `5038/tcp` + - SFTP `22/tcp` +4. Confirm maintenance window and owners. + +## Phase 1 - Pre-cutover dry-run (T-4h to T-1h) + +Run dry-run command (no deploy): + +```powershell +powershell -ExecutionPolicy Bypass -File scripts\track9_2_cutover.ps1 ` + -KubeContext ` + -Namespace ` + -Release ` + -GatewayBaseUrl ` + -DatabaseUrl ` + -ImageTag ` + -AmiHost ` + -AmiUser ` + -AmiSecret ` + -SftpHost ` + -SftpUser ` + -SftpPassword ` + -QueueId ` + -AppTokenSecret +``` + +Expected: + +- `helm lint` PASS +- `helm template` PASS +- rendered manifest contains: + - `ALLOW_LEGACY_HEADER_AUTH=0` + - `ASTERISK_BRIDGE_AUTH_MODE=bearer` + - `ASTERISK_BRIDGE_AUTH_FALLBACK_LEGACY=0` + - `VOICE_ADAPTER_TRUSTED_SERVICE_SUBJECTS` with `svc:asterisk-bridge` + - `RECORDING_IMPORT_TRUSTED_SERVICE_SUBJECTS` with `svc:asterisk-bridge` + - `RECORDING_IMPORT_ALLOW_ADMIN=0` + +Artifacts are saved under: + +- `.artifacts/track9_2//` + +## Phase 2 - Controlled cutover execute (T0) + +Run same command with `-Execute`: + +```powershell +powershell -ExecutionPolicy Bypass -File scripts\track9_2_cutover.ps1 ` + -KubeContext ` + -Namespace ` + -Release ` + -GatewayBaseUrl ` + -DatabaseUrl ` + -ImageTag ` + -AmiHost ` + -AmiUser ` + -AmiSecret ` + -SftpHost ` + -SftpUser ` + -SftpPassword ` + -QueueId ` + -AppTokenSecret ` + -Execute +``` + +If Helm reports conflicts on HPA-managed `Deployment.spec.replicas`, rerun with: + +```powershell +... -Execute -ForceUpgrade +``` + +Script actions: + +1. snapshot: + - `kubectl get pods -o wide` + - `helm history` (before) +2. `helm upgrade --install` with strict overlay +3. wait for the migration Job to complete +4. rollout wait: + - `api-gateway` + - `asterisk-bridge-service` + - `voice-adapter-service` + - `recording-service` +5. strict preflight + smoke + track9_check +6. evidence package via `track9_collect_evidence` +7. writes rollback hint from Helm history + +## Phase 3 - Live acceptance (mandatory) + +During window, place one real call: + +- softphone `1001 -> 7000` + +Then confirm: + +- `call.started` from Asterisk source exists +- `call.ended` for same call exists +- `recording.ready` uploaded to `recording-service` +- supervisor playback is `HTTP 200` and `audio/*` + +## Phase 4 - Evidence and GO/NO-GO + +Use generated evidence folder from script and finalize: + +- `track9-acceptance.md` +- `playback-proof.md` +- optional `playback-proof.json` + +GO only if all true: + +1. preflight PASS +2. smoke PASS +3. track9_check PASS +4. `failed bridge events = 0` +5. supervisor playback confirmed + +## Rollback policy + +Rollback triggers: + +- preflight FAIL +- track9_check FAIL +- no recording import after live call +- failed bridge events keep growing + +Rollback command pattern: + +```powershell +helm -n rollback +kubectl -n rollout status deploy/api-gateway +kubectl -n rollout status deploy/asterisk-bridge-service +``` + +After rollback: + +- rerun `track9_preflight` in previous baseline, +- log incident note + root cause item in cutover sheet. diff --git a/docs/runbooks/voice-baseline.md b/docs/runbooks/voice-baseline.md new file mode 100644 index 0000000..9802b02 --- /dev/null +++ b/docs/runbooks/voice-baseline.md @@ -0,0 +1,49 @@ +# Voice Baseline + +Status: `Track 11/12 accepted, Track 14 browser QA path added` + +This document describes what the voice path already supports today. + +## What works + +- Asterisk 20 on a Linux VM forwards inbound lab calls into the platform through `asterisk-bridge-service` +- A call from `1001` to `7000` creates a real voice interaction and writes: + - `call.started` + - `call.connected` + - `call.ended` + - `recording.ready` +- `/operator` shows real bridge-backed live calls in `Активные звонки` +- The operator can use accepted browser controls: + - `Принять в работу` + - `Завершить` + - `Передать` +- `/operator` keeps short-lived terminal cards in `Только что завершённые` +- Recording import is automatic and `/supervisor` can play the audio back +- A QA browser softphone path is now available when: + - bridge WebRTC config is enabled + - the operator user has a browser SIP mapping + - Asterisk WSS config is applied + +## What this baseline does not do + +- no ARI +- no attended transfer or conference +- no production cutover workflow +- no telephony redesign beyond the accepted Track 11 and Track 12 behavior +- no production browser softphone rollout yet + +## Source documents + +- Track 11 roadmap: `docs/roadmap/07-track11-live-operator-call-control.md` +- Track 12 roadmap: `docs/roadmap/08-track12-operator-voice-ux-hardening.md` +- Track 14 roadmap: `docs/roadmap/09-track14-browser-softphone-webrtc.md` +- Track 11 runbook: `docs/runbooks/track11-live-operator-call-control.md` +- Track 12 runbook: `docs/runbooks/track12-operator-voice-ux.md` +- Track 14 runbook: `docs/runbooks/track14-browser-softphone.md` + +## Acceptance references + +- Track 11 accepted package: + - `docs/acceptance/track11/track11-acceptance.md` +- Track 12 accepted package: + - `docs/acceptance/track12/20260307_161629/track12-acceptance.md` diff --git a/docs/security/checklist.md b/docs/security/checklist.md new file mode 100644 index 0000000..09649c0 --- /dev/null +++ b/docs/security/checklist.md @@ -0,0 +1,24 @@ +# Security Checklist (Stage 4) + +## Scope +- MVP on-prem deployment baseline +- API gateway + service-level RBAC +- Data persistence and backup handling + +## Checklist +- [x] RBAC enforced on privileged endpoints (`auth/users`, `routing/queues`, `knowledge/categories`) +- [x] Role-denied requests return `403` without side effects +- [x] Request payload validation enabled via Pydantic models +- [x] SQL access uses parameterized ORM operations (SQLAlchemy) +- [x] Health and registry endpoints do not expose secrets +- [x] Backup archives created and restored via controlled scripts +- [x] Security smoke checks automated in `scripts/gate4_check.py` + +## Verification Commands +- `python scripts/gate4_check.py` +- Optional artifact retention: `python scripts/gate4_check.py --keep-artifacts` + +## Out of Scope for MVP +- LDAP/SSO integration +- External secrets manager integration +- WAF and SIEM integration diff --git a/docs/uat/README.md b/docs/uat/README.md new file mode 100644 index 0000000..0be32e1 --- /dev/null +++ b/docs/uat/README.md @@ -0,0 +1,59 @@ +# UAT Pilot Package + +This folder contains the operational UAT package for Stage 4 pilot acceptance. + +## Scope Control + +- The frozen MVP pilot scope is defined in `docs/gates/mvp-pilot-baseline.md`. +- Out-of-scope requests must be redirected to `docs/roadmap/05-wave2-backlog.md`. +- During pilot close-out, only `P1` and `P2` defects are remediated in MVP. + +## Workflow + +1. Run preflight before participants join: + - Existing environment: `python scripts/uat_preflight.py --base-url http://:8080` + - Local auto-start: `python scripts/uat_preflight.py --auto-start` +2. Run automated dry-run and generate UAT package: + - Existing environment: `python scripts/uat_dry_run.py --base-url http://:8080 --update-defect-register` + - Local auto-start: `python scripts/uat_dry_run.py --auto-start --update-defect-register` +3. Create/adjust a session protocol from template (or from generated dry-run package): + - `docs/uat/session-template.md` + - Or prepare a full manual bundle: + - `python scripts/uat_manual_prepare.py --environment-url http://:8080` +4. Execute all scenarios: + - `docs/uat/scenario-checklist.md` +5. Register all defects: + - `docs/uat/defect-log-template.csv` +6. Capture sign-off: + - `docs/uat/signoff-template.md` +7. Attach preflight report from: + - `docs/uat/evidence/` +8. After signatures and zero open `P1/P2`, close Gate 4: + - Optional validation only: + - `python scripts/finalize_mvp_pilot.py --session-dir docs/uat/evidence/manual_ --dry-run` + - `python scripts/finalize_mvp_pilot.py --session-dir docs/uat/evidence/manual_` + +## Triage Rules + +- `P1`: pilot acceptance is impossible +- `P2`: key MVP user journey is broken +- `P3`: acceptable with limitation, defer unless it blocks sign-off +- `P4`: cosmetic or low-priority backlog item + +`P3` and `P4` findings should be logged and moved to post-MVP backlog instead of +stretching the MVP remediation cycle. + +## Mandatory Evidence Bundle + +- preflight report +- completed session protocol +- completed scenario checklist +- filled defect log +- signed sign-off sheet +- updated `docs/gates/p1-p2-defects.md` + +## Exit Criteria + +- All mandatory scenarios executed +- No open P1/P2 defects +- Signed protocol by business and IT owners diff --git a/docs/uat/defect-log-template.csv b/docs/uat/defect-log-template.csv new file mode 100644 index 0000000..eec4ec0 --- /dev/null +++ b/docs/uat/defect-log-template.csv @@ -0,0 +1,2 @@ +defect_id,severity,status,scenario,service,risk_level,summary,repro_steps,actual_result,expected_result,verification_step,owner,opened_at,closed_at,backlog_bucket,notes +UAT-001,P3,Open,S4,routing-service,Medium,"Example defect summary","1) ... 2) ...","Actual behavior","Expected behavior","Re-run scenario S4 after fix",owner_name,2026-02-26T00:00:00Z,,Wave2, diff --git a/docs/uat/evidence/.gitignore b/docs/uat/evidence/.gitignore new file mode 100644 index 0000000..4076b43 --- /dev/null +++ b/docs/uat/evidence/.gitignore @@ -0,0 +1,3 @@ +dry_run_*/ +manual_*/ +preflight_*.md diff --git a/docs/uat/evidence/README.md b/docs/uat/evidence/README.md new file mode 100644 index 0000000..795a9cb --- /dev/null +++ b/docs/uat/evidence/README.md @@ -0,0 +1,8 @@ +# UAT Evidence + +Store generated pilot artifacts in this folder, for example: + +- Preflight reports from `python scripts/uat_preflight.py` +- Session protocol copies +- Signed acceptance documents +- Exported logs/screenshots diff --git a/docs/uat/scenario-checklist.md b/docs/uat/scenario-checklist.md new file mode 100644 index 0000000..97b56d3 --- /dev/null +++ b/docs/uat/scenario-checklist.md @@ -0,0 +1,223 @@ +# UAT Scenario Checklist + +## S0 Login and RBAC deny + +- Preconditions: + - Test users are available (`admin`, `supervisor`, `operator`, `analyst`) +- Steps: + - Sign in through the gateway + - Verify role-dependent access to main flows + - Attempt a denied privileged action as `operator` (for example create a user) +- Expected: + - Login succeeds + - Allowed actions work for the selected role + - Denied privileged action returns `403` +- Result: + - [ ] Pass + - [ ] Fail +- Evidence: + - user: + - screenshot/log: + +## S1 Customer create and search + +- Preconditions: + - Operator UI or API access is available +- Steps: + - Create a customer + - Search the customer by name or phone + - Open the customer card +- Expected: + - Customer is created once + - Search returns the same customer + - Customer card data is consistent +- Result: + - [ ] Pass + - [ ] Fail +- Evidence: + - customer_id: + - screenshot/log: + +## S2 Voice interaction lifecycle + +- Preconditions: + - Operator account is available + - Voice queue is available +- Steps: + - Create a voice interaction + - Assign interaction to operator + - Change status to `closed` + - Open the interaction timeline +- Expected: + - Status transitions are correct + - Timeline contains creation, assignment, and close events +- Result: + - [ ] Pass + - [ ] Fail +- Evidence: + - interaction_id: + - screenshot/log: + +## S3 Assignment and escalation to second line + +- Preconditions: + - Queue routing rule for escalation exists +- Steps: + - Create or select an active interaction + - Assign it to an operator + - Escalate it to the target queue +- Expected: + - Assignment is accepted + - Queue is changed to the target queue + - Status changes to `escalated` +- Result: + - [ ] Pass + - [ ] Fail +- Evidence: + - interaction_id: + - target_queue: + +## S4 Routing and timeline verification + +- Preconditions: + - Queue rules are configured + - At least one completed interaction exists +- Steps: + - Execute a route request for the active queue + - Verify assigned operator and SLA result + - Re-open interaction timeline +- Expected: + - Routing returns assignee and SLA + - Timeline remains complete and chronological +- Result: + - [ ] Pass + - [ ] Fail +- Evidence: + - queue_id: + - route output: + +## S5 Voice event intake + +- Preconditions: + - Voice adapter endpoint is reachable +- Steps: + - Submit a voice event (`call.started`, `ivr.completed`, or `call.ended`) + - Verify event is stored and retrievable +- Expected: + - Voice event is accepted + - Event ID is returned + - Event can be listed afterwards +- Result: + - [ ] Pass + - [ ] Fail +- Evidence: + - event_id: + - call_id: + +## S6 Telegram interaction lifecycle + +- Preconditions: + - Telegram adapter endpoint is reachable +- Steps: + - Send webhook payload to Telegram adapter + - Verify message registration + - Verify interaction visibility in operator workflow +- Expected: + - Message is accepted + - Related interaction or adapter state is traceable +- Result: + - [ ] Pass + - [ ] Fail +- Evidence: + - message_id: + - screenshot/log: + +## S7 KB usage in active handling + +- Preconditions: + - KB category/article created by analyst/supervisor +- Steps: + - Search KB by keyword + - Open article + - Use article during interaction processing +- Expected: + - Relevant article found + - Operator confirms article usability +- Result: + - [ ] Pass + - [ ] Fail +- Evidence: + - article_id: + - keyword: + +## S8 Supervisor realtime + +- Preconditions: + - At least two agent states are submitted +- Steps: + - Set agent states (`READY`, `BUSY`) + - Update queue metrics + - Open realtime view/API +- Expected: + - Agent totals and states are visible + - Queue snapshot is visible +- Result: + - [ ] Pass + - [ ] Fail +- Evidence: + - timestamp: + - screenshot/log: + +## S9 KPI report and export validation + +- Preconditions: + - KPI events are ingested for the test queue +- Steps: + - Open `/reports/kpi` + - Verify KPI payload + - Open `/reports/export` and capture CSV output +- Expected: + - KPI payload contains `SL`, `ASA`, `AHT`, `Abandon`, `FCR` + - Export contains the expected header and queue rows +- Result: + - [ ] Pass + - [ ] Fail +- Evidence: + - queue_id: + - export snippet: + +## O1 Backup and restore verification + +- Preconditions: + - Pilot environment contains test data +- Steps: + - Run backup script + - Restore into a clean target + - Verify the restored data set +- Expected: + - Archive is created + - Restore succeeds + - Core interaction data remains intact +- Result: + - [ ] Pass + - [ ] Fail +- Evidence: + - archive: + - restore check: + +## O2 Load test baseline (`100 voice + 100 digital`) + +- Preconditions: + - Pilot environment is stable +- Steps: + - Run `python scripts/load_test.py --base-url http://:8080 --voice 100 --digital 100` + - Record success and latency output +- Expected: + - Load completes without blocking errors + - MVP baseline target is met +- Result: + - [ ] Pass + - [ ] Fail +- Evidence: + - command: + - summary: diff --git a/docs/uat/session-template.md b/docs/uat/session-template.md new file mode 100644 index 0000000..3cd1ca7 --- /dev/null +++ b/docs/uat/session-template.md @@ -0,0 +1,72 @@ +# UAT Session Protocol + +## Session Metadata + +- Session ID: +- Date: +- Start time: +- End time: +- Environment URL: +- Build/version: +- Deployment date: +- Cluster/namespace: + +## Scope Confirmation + +- [ ] MVP pilot scope reviewed against `docs/gates/mvp-pilot-baseline.md` +- [ ] Out-of-scope requests redirected to `docs/roadmap/05-wave2-backlog.md` +- [ ] API stabilization rules acknowledged (backward-compatible fixes only) + +## Participants + +- Operators: +- Supervisor: +- Analyst: +- Admin: +- Business owner: +- IT owner: + +## Preconditions + +- [ ] Preflight report attached (`docs/uat/evidence/preflight_*.md`) +- [ ] Test accounts are active +- [ ] Test queue is configured +- [ ] Recording and audit logging are enabled +- [ ] Defect log prepared from `docs/uat/defect-log-template.csv` + +## Execution Summary + +- Mandatory scenarios executed: +- Passed: +- Failed: +- Blocked: + +## Defect Summary + +- P1: +- P2: +- P3: +- P4: +- Defect log attachment: + +## Decision + +- [ ] Accepted with conditions +- [ ] Accepted for pilot completion +- [ ] Not accepted + +Accepted with conditions is allowed only when all `P1` and `P2` items are closed and +remaining `P3`/`P4` findings are explicitly deferred to `Wave 2`. + +## Comments + +- Business comments: +- IT comments: +- Deferred backlog references: + +## Signatures + +- Business owner: +- IT owner: +- Supervisor representative: +- Date: diff --git a/docs/uat/signoff-template.md b/docs/uat/signoff-template.md new file mode 100644 index 0000000..9732ee9 --- /dev/null +++ b/docs/uat/signoff-template.md @@ -0,0 +1,48 @@ +# UAT Sign-off Sheet + +## Scope Confirmation + +- MVP pilot scope confirmed against `docs/gates/mvp-pilot-baseline.md` +- Out-of-scope items acknowledged and deferred to `docs/roadmap/05-wave2-backlog.md` +- No new feature requests are included as MVP defects + +## Acceptance Statement + +The undersigned confirm that UAT scenarios were executed for MVP pilot scope and +results are recorded in the session protocol. + +## Conditions + +- No open P1 defects +- No open P2 defects +- Mandatory scenarios completed + +## Known Exclusions + +- LDAP / SSO +- additional channels beyond `voice` and `Telegram` +- standalone supervisor/admin UI +- advanced IVR builder +- full recording suite +- extended KPI catalog + +## Decision + +- [ ] Accepted for pilot completion +- [ ] Accepted with conditions (only `P3`/`P4` deferred) +- [ ] Not accepted + +## Signatures + +- Business owner: + - Name: + - Signature: + - Date: +- IT owner: + - Name: + - Signature: + - Date: +- Supervisor representative: + - Name: + - Signature: + - Date: diff --git a/gateway/__init__.py b/gateway/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/gateway/app.py b/gateway/app.py new file mode 100644 index 0000000..95eb831 --- /dev/null +++ b/gateway/app.py @@ -0,0 +1,373 @@ +from __future__ import annotations + +import os +from pathlib import Path +from typing import Any + +import httpx +from fastapi import FastAPI, HTTPException, Request +from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, RedirectResponse, Response +from fastapi.staticfiles import StaticFiles + +from services.shared.models import HealthResponse + +app = FastAPI(title="api-gateway", version="1.0.0") + +_LEGAL_HTML = { + "privacy": """ + + + + + KonturAI Privacy Policy + + + +
+

KonturAI Privacy Policy

+

KonturAI processes customer communication data to route conversations, assist operators, and provide AI-supported support workflows across channels including WhatsApp.

+

Data We Process

+
    +
  • Profile data shared by the messaging platform, including display name and phone number.
  • +
  • Message content and delivery metadata needed to operate support conversations.
  • +
  • Operational data used for routing, analytics, and service quality monitoring.
  • +
+

How We Use Data

+
    +
  • Deliver inbound and outbound customer support messages.
  • +
  • Route conversations to AI or human operators.
  • +
  • Generate summaries, audit trails, and service performance reports.
  • +
+

Contact

+

For privacy-related requests, contact ernurreen@gmail.com.

+
+ + +""", + "terms": """ + + + + + KonturAI Terms of Service + + + +
+

KonturAI Terms of Service

+

KonturAI provides customer communication tooling for internal support operations, including operator workspaces, AI-assisted workflows, and messaging integrations.

+

Use of Service

+
    +
  • The service is intended for authorized business use only.
  • +
  • Users are responsible for ensuring their communications comply with applicable law and platform policies.
  • +
  • KonturAI may log operational events to maintain service reliability and auditability.
  • +
+

Availability

+

The service is provided on a best-effort basis and may be updated, improved, or temporarily interrupted for maintenance.

+

Contact

+

Questions about these terms can be sent to ernurreen@gmail.com.

+
+ + +""", + "data-deletion": """ + + + + + KonturAI Data Deletion + + + +
+

KonturAI Data Deletion Instructions

+

To request deletion of personal data processed through KonturAI, send an email to ernurreen@gmail.com with the subject line Data Deletion Request.

+

Please include

+
    +
  • Your phone number or account identifier used in the conversation.
  • +
  • The approximate date of the interaction.
  • +
  • Any details needed to identify the record you want removed.
  • +
+

Requests are reviewed and processed within a reasonable timeframe subject to legal, security, and audit retention obligations.

+
+ + +""", +} + +_ROOT = Path(__file__).resolve().parents[1] +_LOGIN_UI_DIR = _ROOT / "ui" / "login" +_OPERATOR_UI_DIR = _ROOT / "ui" / "operator" +_SUPERVISOR_UI_DIR = _ROOT / "ui" / "supervisor" +_ANALYST_UI_DIR = _ROOT / "ui" / "analyst" +_ADMIN_UI_DIR = _ROOT / "ui" / "admin" +_IVR_PREVIEW_AUDIO_DIR = Path( + os.getenv("IVR_PREVIEW_AUDIO_DIR", str(_ROOT / ".data_local" / "generated_ivr_yandex")) +).expanduser() +if _LOGIN_UI_DIR.exists(): + app.mount("/login/assets", StaticFiles(directory=str(_LOGIN_UI_DIR)), name="login_assets") +if _OPERATOR_UI_DIR.exists(): + app.mount("/operator/assets", StaticFiles(directory=str(_OPERATOR_UI_DIR)), name="operator_assets") +if _SUPERVISOR_UI_DIR.exists(): + app.mount("/supervisor/assets", StaticFiles(directory=str(_SUPERVISOR_UI_DIR)), name="supervisor_assets") +if _ANALYST_UI_DIR.exists(): + app.mount("/analyst/assets", StaticFiles(directory=str(_ANALYST_UI_DIR)), name="analyst_assets") +if _ADMIN_UI_DIR.exists(): + app.mount("/admin/assets", StaticFiles(directory=str(_ADMIN_UI_DIR)), name="admin_assets") +if _IVR_PREVIEW_AUDIO_DIR.exists(): + app.mount("/_preview_audio", StaticFiles(directory=str(_IVR_PREVIEW_AUDIO_DIR)), name="ivr_preview_audio") + +SERVICE_URLS = { + "auth": os.getenv("AUTH_SERVICE_URL", "http://localhost:8001"), + "audit": os.getenv("AUDIT_SERVICE_URL", "http://localhost:8002"), + "customer": os.getenv("CUSTOMER_SERVICE_URL", "http://localhost:8003"), + "interaction": os.getenv("INTERACTION_SERVICE_URL", "http://localhost:8004"), + "routing": os.getenv("ROUTING_SERVICE_URL", "http://localhost:8005"), + "voice": os.getenv("VOICE_ADAPTER_SERVICE_URL", "http://localhost:8006"), + "recording": os.getenv("RECORDING_SERVICE_URL", "http://localhost:8013"), + "ivr": os.getenv("IVR_SERVICE_URL", "http://localhost:8014"), + "event-bus": os.getenv("EVENT_BUS_SERVICE_URL", "http://localhost:8015"), + "asterisk-bridge": os.getenv("ASTERISK_BRIDGE_SERVICE_URL", "http://localhost:8016"), + "telegram": os.getenv("TELEGRAM_ADAPTER_SERVICE_URL", "http://localhost:8007"), + "whatsapp": os.getenv("WHATSAPP_ADAPTER_SERVICE_URL", "http://localhost:8019"), + "ai": os.getenv("AI_ORCHESTRATOR_SERVICE_URL", "http://localhost:8017"), + "ai-voice-runtime": os.getenv("AI_VOICE_RUNTIME_SERVICE_URL", "http://localhost:8018"), + "webchat": os.getenv("WEBCHAT_ADAPTER_SERVICE_URL", "http://localhost:8011"), + "email": os.getenv("EMAIL_ADAPTER_SERVICE_URL", "http://localhost:8012"), + "kb": os.getenv("KB_SERVICE_URL", "http://localhost:8008"), + "reporting": os.getenv("REPORTING_SERVICE_URL", "http://localhost:8009"), + "supervisor": os.getenv("SUPERVISOR_SERVICE_URL", "http://localhost:8010"), +} + + +@app.get("/health", response_model=HealthResponse) +def health() -> HealthResponse: + return HealthResponse(status="ok", service="api-gateway") + + +@app.get("/") +def login_ui() -> FileResponse: + if not _LOGIN_UI_DIR.exists(): + raise HTTPException(status_code=404, detail="Login UI not found") + return FileResponse(_LOGIN_UI_DIR / "index.html") + + +@app.get("/login") +def login_alias() -> FileResponse: + return login_ui() + + +@app.get("/registry") +def registry() -> dict: + return {"services": SERVICE_URLS} + + +@app.get("/contracts") +def contracts() -> dict: + return { + "stage_1": ["/auth/*", "/users/*", "/health", "/audit/events"], + "stage_2": [ + "/customers/*", + "/interactions/*", + "/queues/*", + "/integrations/voice/events", + "/integrations/telegram/webhook", + "/integrations/whatsapp/webhook", + "/integrations/webchat/messages", + "/integrations/email/messages", + ], + "stage_3": [ + "/knowledge/*", + "/reports/kpi", + "/reports/export", + "/supervisor/realtime", + ], + "stage_4": [ + "/recordings/*", + ], + "stage_5": [ + "/ivr/flows", + "/ivr/sessions", + ], + "stage_6": [ + "/reports/agents/overview", + "/reports/agents/timeseries", + "/reports/coverage", + "/reports/drilldown", + "/reports/export", + "/reports/facts/interactions", + "/reports/kpi", + "/reports/timeseries", + "/reports/views", + "/reports/views/*", + ], + "stage_8": [ + "/bus/outbox", + "/bus/outbox/*", + ], + "stage_9": [ + "/asterisk/status", + "/asterisk/events", + "/asterisk/events/*", + ], + "stage_11": [ + "/asterisk/live-calls", + "/asterisk/live-calls/*", + ], + "stage_12": [ + "/asterisk/recent-calls", + ], + "stage_14": [ + "/asterisk/browser-softphone/config", + ], + "stage_15": [ + "/integrations/telegram/bot/webhook", + "/integrations/telegram/threads", + "/integrations/telegram/threads/*", + "/integrations/whatsapp/provider/webhook", + "/integrations/whatsapp/threads", + "/integrations/whatsapp/threads/*", + ], + "stage_16": [ + "/ai/telegram/threads/*", + "/ai/whatsapp/threads/*", + "/ai/analytics/overview", + "/ai/analytics/timeseries", + "/ai/analytics/drilldown", + "/ai/analytics/sessions/*", + ], + "stage_17": [ + "/ai/voice/sessions/*", + "/asterisk/live-calls/*/ai-summary", + ], + } + + +@app.get("/operator") +def operator_ui() -> FileResponse: + if not _OPERATOR_UI_DIR.exists(): + raise HTTPException(status_code=404, detail="Operator UI not found") + return FileResponse(_OPERATOR_UI_DIR / "index.html") + + +@app.get("/supervisor") +def supervisor_ui() -> FileResponse: + if not _SUPERVISOR_UI_DIR.exists(): + raise HTTPException(status_code=404, detail="Supervisor UI not found") + return FileResponse(_SUPERVISOR_UI_DIR / "index.html") + + +@app.get("/analyst") +def analyst_ui() -> FileResponse: + if not _ANALYST_UI_DIR.exists(): + raise HTTPException(status_code=404, detail="Analyst UI not found") + return FileResponse(_ANALYST_UI_DIR / "index.html") + + +@app.get("/admin") +def admin_ui() -> FileResponse: + if not _ADMIN_UI_DIR.exists(): + raise HTTPException(status_code=404, detail="Admin UI not found") + return FileResponse(_ADMIN_UI_DIR / "index.html") + + +@app.get("/privacy") +def privacy_page() -> HTMLResponse: + return HTMLResponse(_LEGAL_HTML["privacy"]) + + +@app.get("/terms") +def terms_page() -> HTMLResponse: + return HTMLResponse(_LEGAL_HTML["terms"]) + + +@app.get("/data-deletion") +def data_deletion_page() -> HTMLResponse: + return HTMLResponse(_LEGAL_HTML["data-deletion"]) + + +async def _forward(method: str, service: str, path: str, request: Request) -> Response: + base = SERVICE_URLS.get(service) + if not base: + raise HTTPException(status_code=404, detail="Unknown service") + + url = f"{base.rstrip('/')}/{path.lstrip('/')}" + headers = { + "X-User": request.headers.get("X-User", ""), + "X-Role": request.headers.get("X-Role", ""), + } + if request.headers.get("Authorization"): + headers["Authorization"] = request.headers["Authorization"] + if request.headers.get("X-Telegram-Bot-Api-Secret-Token"): + headers["X-Telegram-Bot-Api-Secret-Token"] = request.headers[ + "X-Telegram-Bot-Api-Secret-Token" + ] + if request.headers.get("X-WhatsApp-Webhook-Secret"): + headers["X-WhatsApp-Webhook-Secret"] = request.headers["X-WhatsApp-Webhook-Secret"] + if request.headers.get("X-Hub-Signature-256"): + headers["X-Hub-Signature-256"] = request.headers["X-Hub-Signature-256"] + if request.headers.get("Content-Type"): + headers["Content-Type"] = request.headers["Content-Type"] + + payload: Any = None + if method in {"POST", "PATCH", "PUT"}: + payload = await request.body() + if payload == b"": + payload = None + + async with httpx.AsyncClient(timeout=20) as client: + resp = await client.request( + method, + url, + params=dict(request.query_params), + content=payload, + headers=headers, + ) + + if 300 <= resp.status_code < 400 and resp.headers.get("location"): + return RedirectResponse(url=resp.headers["location"], status_code=resp.status_code) + + content_type = resp.headers.get("content-type", "") + if "application/json" in content_type: + return JSONResponse(status_code=resp.status_code, content=resp.json()) + if "text/html" in content_type: + return HTMLResponse(status_code=resp.status_code, content=resp.text) + if content_type.startswith("text/"): + return Response( + status_code=resp.status_code, + content=resp.text, + media_type=content_type.split(";", 1)[0], + ) + if content_type.startswith("audio/") or "application/octet-stream" in content_type: + passthrough_headers = {} + if resp.headers.get("content-disposition"): + passthrough_headers["Content-Disposition"] = resp.headers["content-disposition"] + return Response( + status_code=resp.status_code, + content=resp.content, + media_type=content_type.split(";", 1)[0], + headers=passthrough_headers, + ) + return JSONResponse(status_code=resp.status_code, content={"raw": resp.text}) + + +@app.api_route("/proxy/{service}/{path:path}", methods=["GET", "POST", "PATCH", "PUT", "DELETE"]) +async def proxy(service: str, path: str, request: Request) -> Response: + return await _forward(request.method.upper(), service, path, request) diff --git a/migrations/README.md b/migrations/README.md new file mode 100644 index 0000000..b7de08e --- /dev/null +++ b/migrations/README.md @@ -0,0 +1,24 @@ +# Core DB Migrations + +This folder contains SQL migrations for core MVP services: +- `auth-service` +- `customer-service` +- `interaction-service` +- `routing-service` + +## Files +- `sql/0001_core_sqlite.sql` +- `sql/0001_core_postgres.sql` +- `sql/0002_stage3_sqlite.sql` +- `sql/0002_stage3_postgres.sql` + +## Apply + +```bash +python scripts/migrate_core_db.py +``` + +The runner: +- detects dialect from `DATABASE_URL` +- applies all `*_{dialect}.sql` migrations in order +- tracks applied files in `schema_migrations` diff --git a/migrations/sql/0001_core_postgres.sql b/migrations/sql/0001_core_postgres.sql new file mode 100644 index 0000000..195c433 --- /dev/null +++ b/migrations/sql/0001_core_postgres.sql @@ -0,0 +1,72 @@ +-- Core MVP schema (PostgreSQL) + +CREATE TABLE IF NOT EXISTS auth_users ( + id BIGSERIAL PRIMARY KEY, + user_id VARCHAR(64) NOT NULL UNIQUE, + username VARCHAR(128) NOT NULL UNIQUE, + password VARCHAR(256) NOT NULL, + full_name VARCHAR(256) NOT NULL, + role VARCHAR(32) NOT NULL, + created_at VARCHAR(64) NOT NULL +); +CREATE INDEX IF NOT EXISTS ix_auth_users_user_id ON auth_users (user_id); +CREATE INDEX IF NOT EXISTS ix_auth_users_username ON auth_users (username); +CREATE INDEX IF NOT EXISTS ix_auth_users_role ON auth_users (role); + +CREATE TABLE IF NOT EXISTS customers ( + id BIGSERIAL PRIMARY KEY, + customer_id VARCHAR(64) NOT NULL UNIQUE, + display_name VARCHAR(256) NOT NULL, + phones_json TEXT NOT NULL DEFAULT '[]', + preferred_phone VARCHAR(64), + tags_json TEXT NOT NULL DEFAULT '[]', + created_at VARCHAR(64) NOT NULL +); +CREATE INDEX IF NOT EXISTS ix_customers_customer_id ON customers (customer_id); +CREATE INDEX IF NOT EXISTS ix_customers_display_name ON customers (display_name); + +CREATE TABLE IF NOT EXISTS interactions ( + id BIGSERIAL PRIMARY KEY, + interaction_id VARCHAR(64) NOT NULL UNIQUE, + channel VARCHAR(32) NOT NULL, + subject VARCHAR(512) NOT NULL, + customer_id VARCHAR(64), + queue_id VARCHAR(64), + priority INTEGER NOT NULL DEFAULT 3, + status VARCHAR(32) NOT NULL, + assigned_to VARCHAR(128), + created_at VARCHAR(64) NOT NULL, + updated_at VARCHAR(64) NOT NULL +); +CREATE INDEX IF NOT EXISTS ix_interactions_interaction_id ON interactions (interaction_id); +CREATE INDEX IF NOT EXISTS ix_interactions_status ON interactions (status); +CREATE INDEX IF NOT EXISTS ix_interactions_queue_id ON interactions (queue_id); +CREATE INDEX IF NOT EXISTS ix_interactions_assigned_to ON interactions (assigned_to); + +CREATE TABLE IF NOT EXISTS interaction_timelines ( + id BIGSERIAL PRIMARY KEY, + interaction_id VARCHAR(64) NOT NULL, + timestamp VARCHAR(64) NOT NULL, + action VARCHAR(128) NOT NULL, + metadata_json TEXT NOT NULL DEFAULT '{}' +); +CREATE INDEX IF NOT EXISTS ix_interaction_timelines_interaction_id ON interaction_timelines (interaction_id); +CREATE INDEX IF NOT EXISTS ix_interaction_timelines_timestamp ON interaction_timelines (timestamp); + +CREATE TABLE IF NOT EXISTS queues ( + id BIGSERIAL PRIMARY KEY, + queue_id VARCHAR(64) NOT NULL UNIQUE, + name VARCHAR(256) NOT NULL, + description TEXT NOT NULL DEFAULT '', + rules_json TEXT NOT NULL DEFAULT '[]', + created_at VARCHAR(64) NOT NULL +); +CREATE INDEX IF NOT EXISTS ix_queues_queue_id ON queues (queue_id); +CREATE INDEX IF NOT EXISTS ix_queues_name ON queues (name); + +CREATE TABLE IF NOT EXISTS routing_counters ( + id BIGSERIAL PRIMARY KEY, + channel VARCHAR(32) NOT NULL UNIQUE, + counter INTEGER NOT NULL DEFAULT 0 +); +CREATE INDEX IF NOT EXISTS ix_routing_counters_channel ON routing_counters (channel); diff --git a/migrations/sql/0001_core_sqlite.sql b/migrations/sql/0001_core_sqlite.sql new file mode 100644 index 0000000..c78a2b2 --- /dev/null +++ b/migrations/sql/0001_core_sqlite.sql @@ -0,0 +1,72 @@ +-- Core MVP schema (SQLite) + +CREATE TABLE IF NOT EXISTS auth_users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL UNIQUE, + username TEXT NOT NULL UNIQUE, + password TEXT NOT NULL, + full_name TEXT NOT NULL, + role TEXT NOT NULL, + created_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS ix_auth_users_user_id ON auth_users (user_id); +CREATE INDEX IF NOT EXISTS ix_auth_users_username ON auth_users (username); +CREATE INDEX IF NOT EXISTS ix_auth_users_role ON auth_users (role); + +CREATE TABLE IF NOT EXISTS customers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + customer_id TEXT NOT NULL UNIQUE, + display_name TEXT NOT NULL, + phones_json TEXT NOT NULL DEFAULT '[]', + preferred_phone TEXT, + tags_json TEXT NOT NULL DEFAULT '[]', + created_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS ix_customers_customer_id ON customers (customer_id); +CREATE INDEX IF NOT EXISTS ix_customers_display_name ON customers (display_name); + +CREATE TABLE IF NOT EXISTS interactions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + interaction_id TEXT NOT NULL UNIQUE, + channel TEXT NOT NULL, + subject TEXT NOT NULL, + customer_id TEXT, + queue_id TEXT, + priority INTEGER NOT NULL DEFAULT 3, + status TEXT NOT NULL, + assigned_to TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS ix_interactions_interaction_id ON interactions (interaction_id); +CREATE INDEX IF NOT EXISTS ix_interactions_status ON interactions (status); +CREATE INDEX IF NOT EXISTS ix_interactions_queue_id ON interactions (queue_id); +CREATE INDEX IF NOT EXISTS ix_interactions_assigned_to ON interactions (assigned_to); + +CREATE TABLE IF NOT EXISTS interaction_timelines ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + interaction_id TEXT NOT NULL, + timestamp TEXT NOT NULL, + action TEXT NOT NULL, + metadata_json TEXT NOT NULL DEFAULT '{}' +); +CREATE INDEX IF NOT EXISTS ix_interaction_timelines_interaction_id ON interaction_timelines (interaction_id); +CREATE INDEX IF NOT EXISTS ix_interaction_timelines_timestamp ON interaction_timelines (timestamp); + +CREATE TABLE IF NOT EXISTS queues ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + queue_id TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + rules_json TEXT NOT NULL DEFAULT '[]', + created_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS ix_queues_queue_id ON queues (queue_id); +CREATE INDEX IF NOT EXISTS ix_queues_name ON queues (name); + +CREATE TABLE IF NOT EXISTS routing_counters ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + channel TEXT NOT NULL UNIQUE, + counter INTEGER NOT NULL DEFAULT 0 +); +CREATE INDEX IF NOT EXISTS ix_routing_counters_channel ON routing_counters (channel); diff --git a/migrations/sql/0002_stage3_postgres.sql b/migrations/sql/0002_stage3_postgres.sql new file mode 100644 index 0000000..e72a4e0 --- /dev/null +++ b/migrations/sql/0002_stage3_postgres.sql @@ -0,0 +1,107 @@ +-- Stage 3+ service schema (PostgreSQL) + +CREATE TABLE IF NOT EXISTS audit_events ( + id BIGSERIAL PRIMARY KEY, + event_id VARCHAR(64) NOT NULL UNIQUE, + actor VARCHAR(128) NOT NULL, + action VARCHAR(128) NOT NULL, + entity VARCHAR(128) NOT NULL, + metadata_json TEXT NOT NULL DEFAULT '{}', + created_at VARCHAR(64) NOT NULL +); +CREATE INDEX IF NOT EXISTS ix_audit_events_event_id ON audit_events (event_id); +CREATE INDEX IF NOT EXISTS ix_audit_events_actor ON audit_events (actor); +CREATE INDEX IF NOT EXISTS ix_audit_events_action ON audit_events (action); +CREATE INDEX IF NOT EXISTS ix_audit_events_entity ON audit_events (entity); +CREATE INDEX IF NOT EXISTS ix_audit_events_created_at ON audit_events (created_at); + +CREATE TABLE IF NOT EXISTS voice_events ( + id BIGSERIAL PRIMARY KEY, + event_id VARCHAR(64) NOT NULL UNIQUE, + event_type VARCHAR(64) NOT NULL, + call_id VARCHAR(128) NOT NULL, + interaction_id VARCHAR(64), + payload_json TEXT NOT NULL DEFAULT '{}', + created_at VARCHAR(64) NOT NULL +); +CREATE INDEX IF NOT EXISTS ix_voice_events_event_id ON voice_events (event_id); +CREATE INDEX IF NOT EXISTS ix_voice_events_event_type ON voice_events (event_type); +CREATE INDEX IF NOT EXISTS ix_voice_events_call_id ON voice_events (call_id); +CREATE INDEX IF NOT EXISTS ix_voice_events_interaction_id ON voice_events (interaction_id); +CREATE INDEX IF NOT EXISTS ix_voice_events_created_at ON voice_events (created_at); + +CREATE TABLE IF NOT EXISTS telegram_messages ( + id BIGSERIAL PRIMARY KEY, + message_id VARCHAR(64) NOT NULL UNIQUE, + chat_id VARCHAR(128) NOT NULL, + text TEXT NOT NULL, + customer_external_id VARCHAR(128), + payload_json TEXT NOT NULL DEFAULT '{}', + created_at VARCHAR(64) NOT NULL +); +CREATE INDEX IF NOT EXISTS ix_telegram_messages_message_id ON telegram_messages (message_id); +CREATE INDEX IF NOT EXISTS ix_telegram_messages_chat_id ON telegram_messages (chat_id); +CREATE INDEX IF NOT EXISTS ix_telegram_messages_customer_external_id ON telegram_messages (customer_external_id); +CREATE INDEX IF NOT EXISTS ix_telegram_messages_created_at ON telegram_messages (created_at); + +CREATE TABLE IF NOT EXISTS kb_categories ( + id BIGSERIAL PRIMARY KEY, + category_id VARCHAR(64) NOT NULL UNIQUE, + name VARCHAR(256) NOT NULL, + description TEXT NOT NULL DEFAULT '', + created_at VARCHAR(64) NOT NULL +); +CREATE INDEX IF NOT EXISTS ix_kb_categories_category_id ON kb_categories (category_id); +CREATE INDEX IF NOT EXISTS ix_kb_categories_name ON kb_categories (name); +CREATE INDEX IF NOT EXISTS ix_kb_categories_created_at ON kb_categories (created_at); + +CREATE TABLE IF NOT EXISTS kb_articles ( + id BIGSERIAL PRIMARY KEY, + article_id VARCHAR(64) NOT NULL UNIQUE, + category_id VARCHAR(64) NOT NULL, + title VARCHAR(512) NOT NULL, + body TEXT NOT NULL, + tags_json TEXT NOT NULL DEFAULT '[]', + created_at VARCHAR(64) NOT NULL, + updated_at VARCHAR(64) NOT NULL +); +CREATE INDEX IF NOT EXISTS ix_kb_articles_article_id ON kb_articles (article_id); +CREATE INDEX IF NOT EXISTS ix_kb_articles_category_id ON kb_articles (category_id); +CREATE INDEX IF NOT EXISTS ix_kb_articles_title ON kb_articles (title); +CREATE INDEX IF NOT EXISTS ix_kb_articles_created_at ON kb_articles (created_at); +CREATE INDEX IF NOT EXISTS ix_kb_articles_updated_at ON kb_articles (updated_at); + +CREATE TABLE IF NOT EXISTS reporting_events ( + id BIGSERIAL PRIMARY KEY, + queue_id VARCHAR(64) NOT NULL, + answered BOOLEAN NOT NULL DEFAULT FALSE, + wait_seconds INTEGER NOT NULL DEFAULT 0, + handle_seconds INTEGER NOT NULL DEFAULT 0, + abandoned BOOLEAN NOT NULL DEFAULT FALSE, + resolved_first_contact BOOLEAN NOT NULL DEFAULT FALSE, + created_at VARCHAR(64) NOT NULL +); +CREATE INDEX IF NOT EXISTS ix_reporting_events_queue_id ON reporting_events (queue_id); +CREATE INDEX IF NOT EXISTS ix_reporting_events_created_at ON reporting_events (created_at); + +CREATE TABLE IF NOT EXISTS supervisor_agent_states ( + id BIGSERIAL PRIMARY KEY, + agent_id VARCHAR(128) NOT NULL UNIQUE, + state VARCHAR(32) NOT NULL, + queue_id VARCHAR(64), + updated_at VARCHAR(64) NOT NULL +); +CREATE INDEX IF NOT EXISTS ix_supervisor_agent_states_agent_id ON supervisor_agent_states (agent_id); +CREATE INDEX IF NOT EXISTS ix_supervisor_agent_states_state ON supervisor_agent_states (state); +CREATE INDEX IF NOT EXISTS ix_supervisor_agent_states_queue_id ON supervisor_agent_states (queue_id); +CREATE INDEX IF NOT EXISTS ix_supervisor_agent_states_updated_at ON supervisor_agent_states (updated_at); + +CREATE TABLE IF NOT EXISTS supervisor_queue_snapshots ( + id BIGSERIAL PRIMARY KEY, + queue_id VARCHAR(64) NOT NULL UNIQUE, + in_queue INTEGER NOT NULL DEFAULT 0, + avg_wait_seconds INTEGER NOT NULL DEFAULT 0, + updated_at VARCHAR(64) NOT NULL +); +CREATE INDEX IF NOT EXISTS ix_supervisor_queue_snapshots_queue_id ON supervisor_queue_snapshots (queue_id); +CREATE INDEX IF NOT EXISTS ix_supervisor_queue_snapshots_updated_at ON supervisor_queue_snapshots (updated_at); diff --git a/migrations/sql/0002_stage3_sqlite.sql b/migrations/sql/0002_stage3_sqlite.sql new file mode 100644 index 0000000..8401a5b --- /dev/null +++ b/migrations/sql/0002_stage3_sqlite.sql @@ -0,0 +1,107 @@ +-- Stage 3+ service schema (SQLite) + +CREATE TABLE IF NOT EXISTS audit_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + event_id TEXT NOT NULL UNIQUE, + actor TEXT NOT NULL, + action TEXT NOT NULL, + entity TEXT NOT NULL, + metadata_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS ix_audit_events_event_id ON audit_events (event_id); +CREATE INDEX IF NOT EXISTS ix_audit_events_actor ON audit_events (actor); +CREATE INDEX IF NOT EXISTS ix_audit_events_action ON audit_events (action); +CREATE INDEX IF NOT EXISTS ix_audit_events_entity ON audit_events (entity); +CREATE INDEX IF NOT EXISTS ix_audit_events_created_at ON audit_events (created_at); + +CREATE TABLE IF NOT EXISTS voice_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + event_id TEXT NOT NULL UNIQUE, + event_type TEXT NOT NULL, + call_id TEXT NOT NULL, + interaction_id TEXT, + payload_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS ix_voice_events_event_id ON voice_events (event_id); +CREATE INDEX IF NOT EXISTS ix_voice_events_event_type ON voice_events (event_type); +CREATE INDEX IF NOT EXISTS ix_voice_events_call_id ON voice_events (call_id); +CREATE INDEX IF NOT EXISTS ix_voice_events_interaction_id ON voice_events (interaction_id); +CREATE INDEX IF NOT EXISTS ix_voice_events_created_at ON voice_events (created_at); + +CREATE TABLE IF NOT EXISTS telegram_messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + message_id TEXT NOT NULL UNIQUE, + chat_id TEXT NOT NULL, + text TEXT NOT NULL, + customer_external_id TEXT, + payload_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS ix_telegram_messages_message_id ON telegram_messages (message_id); +CREATE INDEX IF NOT EXISTS ix_telegram_messages_chat_id ON telegram_messages (chat_id); +CREATE INDEX IF NOT EXISTS ix_telegram_messages_customer_external_id ON telegram_messages (customer_external_id); +CREATE INDEX IF NOT EXISTS ix_telegram_messages_created_at ON telegram_messages (created_at); + +CREATE TABLE IF NOT EXISTS kb_categories ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + category_id TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS ix_kb_categories_category_id ON kb_categories (category_id); +CREATE INDEX IF NOT EXISTS ix_kb_categories_name ON kb_categories (name); +CREATE INDEX IF NOT EXISTS ix_kb_categories_created_at ON kb_categories (created_at); + +CREATE TABLE IF NOT EXISTS kb_articles ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + article_id TEXT NOT NULL UNIQUE, + category_id TEXT NOT NULL, + title TEXT NOT NULL, + body TEXT NOT NULL, + tags_json TEXT NOT NULL DEFAULT '[]', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS ix_kb_articles_article_id ON kb_articles (article_id); +CREATE INDEX IF NOT EXISTS ix_kb_articles_category_id ON kb_articles (category_id); +CREATE INDEX IF NOT EXISTS ix_kb_articles_title ON kb_articles (title); +CREATE INDEX IF NOT EXISTS ix_kb_articles_created_at ON kb_articles (created_at); +CREATE INDEX IF NOT EXISTS ix_kb_articles_updated_at ON kb_articles (updated_at); + +CREATE TABLE IF NOT EXISTS reporting_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + queue_id TEXT NOT NULL, + answered INTEGER NOT NULL DEFAULT 0, + wait_seconds INTEGER NOT NULL DEFAULT 0, + handle_seconds INTEGER NOT NULL DEFAULT 0, + abandoned INTEGER NOT NULL DEFAULT 0, + resolved_first_contact INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS ix_reporting_events_queue_id ON reporting_events (queue_id); +CREATE INDEX IF NOT EXISTS ix_reporting_events_created_at ON reporting_events (created_at); + +CREATE TABLE IF NOT EXISTS supervisor_agent_states ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + agent_id TEXT NOT NULL UNIQUE, + state TEXT NOT NULL, + queue_id TEXT, + updated_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS ix_supervisor_agent_states_agent_id ON supervisor_agent_states (agent_id); +CREATE INDEX IF NOT EXISTS ix_supervisor_agent_states_state ON supervisor_agent_states (state); +CREATE INDEX IF NOT EXISTS ix_supervisor_agent_states_queue_id ON supervisor_agent_states (queue_id); +CREATE INDEX IF NOT EXISTS ix_supervisor_agent_states_updated_at ON supervisor_agent_states (updated_at); + +CREATE TABLE IF NOT EXISTS supervisor_queue_snapshots ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + queue_id TEXT NOT NULL UNIQUE, + in_queue INTEGER NOT NULL DEFAULT 0, + avg_wait_seconds INTEGER NOT NULL DEFAULT 0, + updated_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS ix_supervisor_queue_snapshots_queue_id ON supervisor_queue_snapshots (queue_id); +CREATE INDEX IF NOT EXISTS ix_supervisor_queue_snapshots_updated_at ON supervisor_queue_snapshots (updated_at); diff --git a/migrations/sql/0003_wave2_auth_postgres.sql b/migrations/sql/0003_wave2_auth_postgres.sql new file mode 100644 index 0000000..45b1346 --- /dev/null +++ b/migrations/sql/0003_wave2_auth_postgres.sql @@ -0,0 +1,40 @@ +-- Wave 2 Track 1 auth/OIDC schema (PostgreSQL) + +CREATE TABLE IF NOT EXISTS auth_external_identities ( + id SERIAL PRIMARY KEY, + provider VARCHAR(64) NOT NULL, + external_subject VARCHAR(256) NOT NULL, + username VARCHAR(128) NOT NULL, + email VARCHAR(256), + full_name VARCHAR(256), + role VARCHAR(32) NOT NULL, + linked_user_id VARCHAR(64), + created_at VARCHAR(64) NOT NULL, + updated_at VARCHAR(64) NOT NULL, + last_login_at VARCHAR(64) NOT NULL +); +CREATE INDEX IF NOT EXISTS ix_auth_external_identities_provider ON auth_external_identities (provider); +CREATE INDEX IF NOT EXISTS ix_auth_external_identities_external_subject ON auth_external_identities (external_subject); +CREATE INDEX IF NOT EXISTS ix_auth_external_identities_username ON auth_external_identities (username); +CREATE INDEX IF NOT EXISTS ix_auth_external_identities_email ON auth_external_identities (email); +CREATE INDEX IF NOT EXISTS ix_auth_external_identities_role ON auth_external_identities (role); +CREATE INDEX IF NOT EXISTS ix_auth_external_identities_linked_user_id ON auth_external_identities (linked_user_id); +CREATE INDEX IF NOT EXISTS ix_auth_external_identities_created_at ON auth_external_identities (created_at); +CREATE INDEX IF NOT EXISTS ix_auth_external_identities_updated_at ON auth_external_identities (updated_at); +CREATE INDEX IF NOT EXISTS ix_auth_external_identities_last_login_at ON auth_external_identities (last_login_at); + +CREATE TABLE IF NOT EXISTS auth_oidc_states ( + id SERIAL PRIMARY KEY, + state VARCHAR(128) NOT NULL UNIQUE, + nonce VARCHAR(128) NOT NULL, + code_verifier VARCHAR(256) NOT NULL, + redirect_uri VARCHAR(512) NOT NULL, + return_mode VARCHAR(32) NOT NULL DEFAULT 'popup', + created_at VARCHAR(64) NOT NULL, + expires_at VARCHAR(64) NOT NULL, + consumed_at VARCHAR(64) +); +CREATE INDEX IF NOT EXISTS ix_auth_oidc_states_state ON auth_oidc_states (state); +CREATE INDEX IF NOT EXISTS ix_auth_oidc_states_created_at ON auth_oidc_states (created_at); +CREATE INDEX IF NOT EXISTS ix_auth_oidc_states_expires_at ON auth_oidc_states (expires_at); +CREATE INDEX IF NOT EXISTS ix_auth_oidc_states_consumed_at ON auth_oidc_states (consumed_at); diff --git a/migrations/sql/0003_wave2_auth_sqlite.sql b/migrations/sql/0003_wave2_auth_sqlite.sql new file mode 100644 index 0000000..bb7ef33 --- /dev/null +++ b/migrations/sql/0003_wave2_auth_sqlite.sql @@ -0,0 +1,40 @@ +-- Wave 2 Track 1 auth/OIDC schema (SQLite) + +CREATE TABLE IF NOT EXISTS auth_external_identities ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + provider TEXT NOT NULL, + external_subject TEXT NOT NULL, + username TEXT NOT NULL, + email TEXT, + full_name TEXT, + role TEXT NOT NULL, + linked_user_id TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + last_login_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS ix_auth_external_identities_provider ON auth_external_identities (provider); +CREATE INDEX IF NOT EXISTS ix_auth_external_identities_external_subject ON auth_external_identities (external_subject); +CREATE INDEX IF NOT EXISTS ix_auth_external_identities_username ON auth_external_identities (username); +CREATE INDEX IF NOT EXISTS ix_auth_external_identities_email ON auth_external_identities (email); +CREATE INDEX IF NOT EXISTS ix_auth_external_identities_role ON auth_external_identities (role); +CREATE INDEX IF NOT EXISTS ix_auth_external_identities_linked_user_id ON auth_external_identities (linked_user_id); +CREATE INDEX IF NOT EXISTS ix_auth_external_identities_created_at ON auth_external_identities (created_at); +CREATE INDEX IF NOT EXISTS ix_auth_external_identities_updated_at ON auth_external_identities (updated_at); +CREATE INDEX IF NOT EXISTS ix_auth_external_identities_last_login_at ON auth_external_identities (last_login_at); + +CREATE TABLE IF NOT EXISTS auth_oidc_states ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + state TEXT NOT NULL UNIQUE, + nonce TEXT NOT NULL, + code_verifier TEXT NOT NULL, + redirect_uri TEXT NOT NULL, + return_mode TEXT NOT NULL DEFAULT 'popup', + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + consumed_at TEXT +); +CREATE INDEX IF NOT EXISTS ix_auth_oidc_states_state ON auth_oidc_states (state); +CREATE INDEX IF NOT EXISTS ix_auth_oidc_states_created_at ON auth_oidc_states (created_at); +CREATE INDEX IF NOT EXISTS ix_auth_oidc_states_expires_at ON auth_oidc_states (expires_at); +CREATE INDEX IF NOT EXISTS ix_auth_oidc_states_consumed_at ON auth_oidc_states (consumed_at); diff --git a/migrations/sql/0004_webchat_postgres.sql b/migrations/sql/0004_webchat_postgres.sql new file mode 100644 index 0000000..331197f --- /dev/null +++ b/migrations/sql/0004_webchat_postgres.sql @@ -0,0 +1,21 @@ +CREATE TABLE IF NOT EXISTS webchat_messages ( + id SERIAL PRIMARY KEY, + message_id TEXT NOT NULL UNIQUE, + session_id TEXT NOT NULL, + text TEXT NOT NULL, + visitor_name TEXT NULL, + customer_external_id TEXT NULL, + interaction_id TEXT NULL, + queue_id TEXT NULL, + priority INTEGER NOT NULL DEFAULT 3, + payload_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS ix_webchat_messages_message_id ON webchat_messages (message_id); +CREATE INDEX IF NOT EXISTS ix_webchat_messages_session_id ON webchat_messages (session_id); +CREATE INDEX IF NOT EXISTS ix_webchat_messages_customer_external_id ON webchat_messages (customer_external_id); +CREATE INDEX IF NOT EXISTS ix_webchat_messages_interaction_id ON webchat_messages (interaction_id); +CREATE INDEX IF NOT EXISTS ix_webchat_messages_queue_id ON webchat_messages (queue_id); +CREATE INDEX IF NOT EXISTS ix_webchat_messages_priority ON webchat_messages (priority); +CREATE INDEX IF NOT EXISTS ix_webchat_messages_created_at ON webchat_messages (created_at); diff --git a/migrations/sql/0004_webchat_sqlite.sql b/migrations/sql/0004_webchat_sqlite.sql new file mode 100644 index 0000000..4455e8e --- /dev/null +++ b/migrations/sql/0004_webchat_sqlite.sql @@ -0,0 +1,21 @@ +CREATE TABLE IF NOT EXISTS webchat_messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + message_id TEXT NOT NULL UNIQUE, + session_id TEXT NOT NULL, + text TEXT NOT NULL, + visitor_name TEXT NULL, + customer_external_id TEXT NULL, + interaction_id TEXT NULL, + queue_id TEXT NULL, + priority INTEGER NOT NULL DEFAULT 3, + payload_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS ix_webchat_messages_message_id ON webchat_messages (message_id); +CREATE INDEX IF NOT EXISTS ix_webchat_messages_session_id ON webchat_messages (session_id); +CREATE INDEX IF NOT EXISTS ix_webchat_messages_customer_external_id ON webchat_messages (customer_external_id); +CREATE INDEX IF NOT EXISTS ix_webchat_messages_interaction_id ON webchat_messages (interaction_id); +CREATE INDEX IF NOT EXISTS ix_webchat_messages_queue_id ON webchat_messages (queue_id); +CREATE INDEX IF NOT EXISTS ix_webchat_messages_priority ON webchat_messages (priority); +CREATE INDEX IF NOT EXISTS ix_webchat_messages_created_at ON webchat_messages (created_at); diff --git a/migrations/sql/0005_email_postgres.sql b/migrations/sql/0005_email_postgres.sql new file mode 100644 index 0000000..a89eef1 --- /dev/null +++ b/migrations/sql/0005_email_postgres.sql @@ -0,0 +1,22 @@ +CREATE TABLE IF NOT EXISTS email_messages ( + id SERIAL PRIMARY KEY, + message_id TEXT NOT NULL UNIQUE, + from_email TEXT NOT NULL, + subject TEXT NOT NULL, + body TEXT NOT NULL, + customer_external_id TEXT NULL, + interaction_id TEXT NULL, + queue_id TEXT NULL, + priority INTEGER NOT NULL DEFAULT 3, + payload_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS ix_email_messages_message_id ON email_messages (message_id); +CREATE INDEX IF NOT EXISTS ix_email_messages_from_email ON email_messages (from_email); +CREATE INDEX IF NOT EXISTS ix_email_messages_subject ON email_messages (subject); +CREATE INDEX IF NOT EXISTS ix_email_messages_customer_external_id ON email_messages (customer_external_id); +CREATE INDEX IF NOT EXISTS ix_email_messages_interaction_id ON email_messages (interaction_id); +CREATE INDEX IF NOT EXISTS ix_email_messages_queue_id ON email_messages (queue_id); +CREATE INDEX IF NOT EXISTS ix_email_messages_priority ON email_messages (priority); +CREATE INDEX IF NOT EXISTS ix_email_messages_created_at ON email_messages (created_at); diff --git a/migrations/sql/0005_email_sqlite.sql b/migrations/sql/0005_email_sqlite.sql new file mode 100644 index 0000000..4bd966f --- /dev/null +++ b/migrations/sql/0005_email_sqlite.sql @@ -0,0 +1,22 @@ +CREATE TABLE IF NOT EXISTS email_messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + message_id TEXT NOT NULL UNIQUE, + from_email TEXT NOT NULL, + subject TEXT NOT NULL, + body TEXT NOT NULL, + customer_external_id TEXT NULL, + interaction_id TEXT NULL, + queue_id TEXT NULL, + priority INTEGER NOT NULL DEFAULT 3, + payload_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS ix_email_messages_message_id ON email_messages (message_id); +CREATE INDEX IF NOT EXISTS ix_email_messages_from_email ON email_messages (from_email); +CREATE INDEX IF NOT EXISTS ix_email_messages_subject ON email_messages (subject); +CREATE INDEX IF NOT EXISTS ix_email_messages_customer_external_id ON email_messages (customer_external_id); +CREATE INDEX IF NOT EXISTS ix_email_messages_interaction_id ON email_messages (interaction_id); +CREATE INDEX IF NOT EXISTS ix_email_messages_queue_id ON email_messages (queue_id); +CREATE INDEX IF NOT EXISTS ix_email_messages_priority ON email_messages (priority); +CREATE INDEX IF NOT EXISTS ix_email_messages_created_at ON email_messages (created_at); diff --git a/migrations/sql/0006_recordings_postgres.sql b/migrations/sql/0006_recordings_postgres.sql new file mode 100644 index 0000000..127a1cc --- /dev/null +++ b/migrations/sql/0006_recordings_postgres.sql @@ -0,0 +1,33 @@ +CREATE TABLE IF NOT EXISTS call_recordings ( + id SERIAL PRIMARY KEY, + recording_id TEXT NOT NULL UNIQUE, + channel TEXT NOT NULL, + call_id TEXT NOT NULL, + interaction_id TEXT NULL, + source_event_id TEXT NULL, + file_name TEXT NOT NULL, + storage_backend TEXT NOT NULL, + storage_path TEXT NOT NULL, + mime_type TEXT NOT NULL, + size_bytes INTEGER NOT NULL, + duration_seconds INTEGER NULL, + checksum_sha256 TEXT NOT NULL, + status TEXT NOT NULL, + recorded_at TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + archived_at TEXT NULL +); + +CREATE INDEX IF NOT EXISTS ix_call_recordings_recording_id ON call_recordings (recording_id); +CREATE INDEX IF NOT EXISTS ix_call_recordings_channel ON call_recordings (channel); +CREATE INDEX IF NOT EXISTS ix_call_recordings_call_id ON call_recordings (call_id); +CREATE INDEX IF NOT EXISTS ix_call_recordings_interaction_id ON call_recordings (interaction_id); +CREATE INDEX IF NOT EXISTS ix_call_recordings_source_event_id ON call_recordings (source_event_id); +CREATE INDEX IF NOT EXISTS ix_call_recordings_storage_backend ON call_recordings (storage_backend); +CREATE INDEX IF NOT EXISTS ix_call_recordings_checksum_sha256 ON call_recordings (checksum_sha256); +CREATE INDEX IF NOT EXISTS ix_call_recordings_status ON call_recordings (status); +CREATE INDEX IF NOT EXISTS ix_call_recordings_recorded_at ON call_recordings (recorded_at); +CREATE INDEX IF NOT EXISTS ix_call_recordings_created_at ON call_recordings (created_at); +CREATE INDEX IF NOT EXISTS ix_call_recordings_updated_at ON call_recordings (updated_at); +CREATE INDEX IF NOT EXISTS ix_call_recordings_archived_at ON call_recordings (archived_at); diff --git a/migrations/sql/0006_recordings_sqlite.sql b/migrations/sql/0006_recordings_sqlite.sql new file mode 100644 index 0000000..a74ee24 --- /dev/null +++ b/migrations/sql/0006_recordings_sqlite.sql @@ -0,0 +1,33 @@ +CREATE TABLE IF NOT EXISTS call_recordings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + recording_id TEXT NOT NULL UNIQUE, + channel TEXT NOT NULL, + call_id TEXT NOT NULL, + interaction_id TEXT NULL, + source_event_id TEXT NULL, + file_name TEXT NOT NULL, + storage_backend TEXT NOT NULL, + storage_path TEXT NOT NULL, + mime_type TEXT NOT NULL, + size_bytes INTEGER NOT NULL, + duration_seconds INTEGER NULL, + checksum_sha256 TEXT NOT NULL, + status TEXT NOT NULL, + recorded_at TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + archived_at TEXT NULL +); + +CREATE INDEX IF NOT EXISTS ix_call_recordings_recording_id ON call_recordings (recording_id); +CREATE INDEX IF NOT EXISTS ix_call_recordings_channel ON call_recordings (channel); +CREATE INDEX IF NOT EXISTS ix_call_recordings_call_id ON call_recordings (call_id); +CREATE INDEX IF NOT EXISTS ix_call_recordings_interaction_id ON call_recordings (interaction_id); +CREATE INDEX IF NOT EXISTS ix_call_recordings_source_event_id ON call_recordings (source_event_id); +CREATE INDEX IF NOT EXISTS ix_call_recordings_storage_backend ON call_recordings (storage_backend); +CREATE INDEX IF NOT EXISTS ix_call_recordings_checksum_sha256 ON call_recordings (checksum_sha256); +CREATE INDEX IF NOT EXISTS ix_call_recordings_status ON call_recordings (status); +CREATE INDEX IF NOT EXISTS ix_call_recordings_recorded_at ON call_recordings (recorded_at); +CREATE INDEX IF NOT EXISTS ix_call_recordings_created_at ON call_recordings (created_at); +CREATE INDEX IF NOT EXISTS ix_call_recordings_updated_at ON call_recordings (updated_at); +CREATE INDEX IF NOT EXISTS ix_call_recordings_archived_at ON call_recordings (archived_at); diff --git a/migrations/sql/0007_ivr_postgres.sql b/migrations/sql/0007_ivr_postgres.sql new file mode 100644 index 0000000..22e1a5f --- /dev/null +++ b/migrations/sql/0007_ivr_postgres.sql @@ -0,0 +1,51 @@ +CREATE TABLE IF NOT EXISTS ivr_flows ( + id SERIAL PRIMARY KEY, + flow_id TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + channel TEXT NOT NULL, + queue_id TEXT NOT NULL, + version INTEGER NOT NULL DEFAULT 1, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + entry_node_id TEXT NOT NULL, + flow_json TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS ix_ivr_flows_flow_id ON ivr_flows (flow_id); +CREATE INDEX IF NOT EXISTS ix_ivr_flows_name ON ivr_flows (name); +CREATE INDEX IF NOT EXISTS ix_ivr_flows_channel ON ivr_flows (channel); +CREATE INDEX IF NOT EXISTS ix_ivr_flows_queue_id ON ivr_flows (queue_id); +CREATE INDEX IF NOT EXISTS ix_ivr_flows_is_active ON ivr_flows (is_active); +CREATE INDEX IF NOT EXISTS ix_ivr_flows_created_at ON ivr_flows (created_at); +CREATE INDEX IF NOT EXISTS ix_ivr_flows_updated_at ON ivr_flows (updated_at); + +CREATE TABLE IF NOT EXISTS ivr_sessions ( + id SERIAL PRIMARY KEY, + session_id TEXT NOT NULL UNIQUE, + call_id TEXT NOT NULL, + interaction_id TEXT NULL, + flow_id TEXT NOT NULL, + queue_id TEXT NOT NULL, + current_node_id TEXT NOT NULL, + entered_digits_json TEXT NOT NULL DEFAULT '[]', + status TEXT NOT NULL, + outcome_code TEXT NULL, + resolved_queue_id TEXT NULL, + completed_at TEXT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS ix_ivr_sessions_session_id ON ivr_sessions (session_id); +CREATE INDEX IF NOT EXISTS ix_ivr_sessions_call_id ON ivr_sessions (call_id); +CREATE INDEX IF NOT EXISTS ix_ivr_sessions_interaction_id ON ivr_sessions (interaction_id); +CREATE INDEX IF NOT EXISTS ix_ivr_sessions_flow_id ON ivr_sessions (flow_id); +CREATE INDEX IF NOT EXISTS ix_ivr_sessions_queue_id ON ivr_sessions (queue_id); +CREATE INDEX IF NOT EXISTS ix_ivr_sessions_status ON ivr_sessions (status); +CREATE INDEX IF NOT EXISTS ix_ivr_sessions_outcome_code ON ivr_sessions (outcome_code); +CREATE INDEX IF NOT EXISTS ix_ivr_sessions_resolved_queue_id ON ivr_sessions (resolved_queue_id); +CREATE INDEX IF NOT EXISTS ix_ivr_sessions_completed_at ON ivr_sessions (completed_at); +CREATE INDEX IF NOT EXISTS ix_ivr_sessions_created_at ON ivr_sessions (created_at); +CREATE INDEX IF NOT EXISTS ix_ivr_sessions_updated_at ON ivr_sessions (updated_at); diff --git a/migrations/sql/0007_ivr_sqlite.sql b/migrations/sql/0007_ivr_sqlite.sql new file mode 100644 index 0000000..233b3d0 --- /dev/null +++ b/migrations/sql/0007_ivr_sqlite.sql @@ -0,0 +1,51 @@ +CREATE TABLE IF NOT EXISTS ivr_flows ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + flow_id TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + channel TEXT NOT NULL, + queue_id TEXT NOT NULL, + version INTEGER NOT NULL DEFAULT 1, + is_active INTEGER NOT NULL DEFAULT 1, + entry_node_id TEXT NOT NULL, + flow_json TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS ix_ivr_flows_flow_id ON ivr_flows (flow_id); +CREATE INDEX IF NOT EXISTS ix_ivr_flows_name ON ivr_flows (name); +CREATE INDEX IF NOT EXISTS ix_ivr_flows_channel ON ivr_flows (channel); +CREATE INDEX IF NOT EXISTS ix_ivr_flows_queue_id ON ivr_flows (queue_id); +CREATE INDEX IF NOT EXISTS ix_ivr_flows_is_active ON ivr_flows (is_active); +CREATE INDEX IF NOT EXISTS ix_ivr_flows_created_at ON ivr_flows (created_at); +CREATE INDEX IF NOT EXISTS ix_ivr_flows_updated_at ON ivr_flows (updated_at); + +CREATE TABLE IF NOT EXISTS ivr_sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL UNIQUE, + call_id TEXT NOT NULL, + interaction_id TEXT NULL, + flow_id TEXT NOT NULL, + queue_id TEXT NOT NULL, + current_node_id TEXT NOT NULL, + entered_digits_json TEXT NOT NULL DEFAULT '[]', + status TEXT NOT NULL, + outcome_code TEXT NULL, + resolved_queue_id TEXT NULL, + completed_at TEXT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS ix_ivr_sessions_session_id ON ivr_sessions (session_id); +CREATE INDEX IF NOT EXISTS ix_ivr_sessions_call_id ON ivr_sessions (call_id); +CREATE INDEX IF NOT EXISTS ix_ivr_sessions_interaction_id ON ivr_sessions (interaction_id); +CREATE INDEX IF NOT EXISTS ix_ivr_sessions_flow_id ON ivr_sessions (flow_id); +CREATE INDEX IF NOT EXISTS ix_ivr_sessions_queue_id ON ivr_sessions (queue_id); +CREATE INDEX IF NOT EXISTS ix_ivr_sessions_status ON ivr_sessions (status); +CREATE INDEX IF NOT EXISTS ix_ivr_sessions_outcome_code ON ivr_sessions (outcome_code); +CREATE INDEX IF NOT EXISTS ix_ivr_sessions_resolved_queue_id ON ivr_sessions (resolved_queue_id); +CREATE INDEX IF NOT EXISTS ix_ivr_sessions_completed_at ON ivr_sessions (completed_at); +CREATE INDEX IF NOT EXISTS ix_ivr_sessions_created_at ON ivr_sessions (created_at); +CREATE INDEX IF NOT EXISTS ix_ivr_sessions_updated_at ON ivr_sessions (updated_at); diff --git a/migrations/sql/0008_reporting_expansion_postgres.sql b/migrations/sql/0008_reporting_expansion_postgres.sql new file mode 100644 index 0000000..aa2a7ea --- /dev/null +++ b/migrations/sql/0008_reporting_expansion_postgres.sql @@ -0,0 +1,5 @@ +ALTER TABLE reporting_events ADD COLUMN IF NOT EXISTS channel VARCHAR(32) NOT NULL DEFAULT 'voice'; +ALTER TABLE reporting_events ADD COLUMN IF NOT EXISTS agent_id VARCHAR(128) NULL; + +CREATE INDEX IF NOT EXISTS ix_reporting_events_channel ON reporting_events (channel); +CREATE INDEX IF NOT EXISTS ix_reporting_events_agent_id ON reporting_events (agent_id); diff --git a/migrations/sql/0008_reporting_expansion_sqlite.sql b/migrations/sql/0008_reporting_expansion_sqlite.sql new file mode 100644 index 0000000..6f632cf --- /dev/null +++ b/migrations/sql/0008_reporting_expansion_sqlite.sql @@ -0,0 +1,5 @@ +ALTER TABLE reporting_events ADD COLUMN channel TEXT NOT NULL DEFAULT 'voice'; +ALTER TABLE reporting_events ADD COLUMN agent_id TEXT NULL; + +CREATE INDEX IF NOT EXISTS ix_reporting_events_channel ON reporting_events (channel); +CREATE INDEX IF NOT EXISTS ix_reporting_events_agent_id ON reporting_events (agent_id); diff --git a/migrations/sql/0009_event_bus_postgres.sql b/migrations/sql/0009_event_bus_postgres.sql new file mode 100644 index 0000000..3ebb44c --- /dev/null +++ b/migrations/sql/0009_event_bus_postgres.sql @@ -0,0 +1,55 @@ +CREATE TABLE IF NOT EXISTS event_outbox ( + id SERIAL PRIMARY KEY, + event_id VARCHAR(64) NOT NULL UNIQUE, + event_type VARCHAR(128) NOT NULL, + event_version INTEGER NOT NULL DEFAULT 1, + producer_service VARCHAR(128) NOT NULL, + entity_type VARCHAR(128) NOT NULL, + entity_id VARCHAR(128) NOT NULL, + correlation_id VARCHAR(128) NULL, + routing_key VARCHAR(128) NOT NULL, + payload_json TEXT NOT NULL DEFAULT '{}', + status VARCHAR(32) NOT NULL, + attempt_count INTEGER NOT NULL DEFAULT 0, + last_error TEXT NULL, + available_at VARCHAR(64) NOT NULL, + published_at VARCHAR(64) NULL, + created_at VARCHAR(64) NOT NULL, + updated_at VARCHAR(64) NOT NULL +); +CREATE INDEX IF NOT EXISTS ix_event_outbox_event_id ON event_outbox (event_id); +CREATE INDEX IF NOT EXISTS ix_event_outbox_event_type ON event_outbox (event_type); +CREATE INDEX IF NOT EXISTS ix_event_outbox_status ON event_outbox (status); +CREATE INDEX IF NOT EXISTS ix_event_outbox_available_at ON event_outbox (available_at); +CREATE INDEX IF NOT EXISTS ix_event_outbox_entity_type ON event_outbox (entity_type); +CREATE INDEX IF NOT EXISTS ix_event_outbox_entity_id ON event_outbox (entity_id); + +CREATE TABLE IF NOT EXISTS event_inbox ( + id SERIAL PRIMARY KEY, + consumer_name VARCHAR(128) NOT NULL, + event_id VARCHAR(64) NOT NULL, + event_type VARCHAR(128) NOT NULL, + processed_at VARCHAR(64) NOT NULL, + status VARCHAR(32) NOT NULL, + notes TEXT NULL +); +CREATE INDEX IF NOT EXISTS ix_event_inbox_consumer_name ON event_inbox (consumer_name); +CREATE INDEX IF NOT EXISTS ix_event_inbox_event_id ON event_inbox (event_id); +CREATE INDEX IF NOT EXISTS ix_event_inbox_event_type ON event_inbox (event_type); +CREATE UNIQUE INDEX IF NOT EXISTS uq_event_inbox_consumer_event ON event_inbox (consumer_name, event_id); + +CREATE TABLE IF NOT EXISTS reporting_event_log ( + id SERIAL PRIMARY KEY, + event_id VARCHAR(64) NOT NULL, + event_type VARCHAR(128) NOT NULL, + queue_id VARCHAR(64) NULL, + channel VARCHAR(32) NULL, + interaction_id VARCHAR(64) NULL, + payload_json TEXT NOT NULL DEFAULT '{}', + created_at VARCHAR(64) NOT NULL +); +CREATE INDEX IF NOT EXISTS ix_reporting_event_log_event_id ON reporting_event_log (event_id); +CREATE INDEX IF NOT EXISTS ix_reporting_event_log_event_type ON reporting_event_log (event_type); +CREATE INDEX IF NOT EXISTS ix_reporting_event_log_queue_id ON reporting_event_log (queue_id); +CREATE INDEX IF NOT EXISTS ix_reporting_event_log_channel ON reporting_event_log (channel); +CREATE INDEX IF NOT EXISTS ix_reporting_event_log_interaction_id ON reporting_event_log (interaction_id); diff --git a/migrations/sql/0009_event_bus_sqlite.sql b/migrations/sql/0009_event_bus_sqlite.sql new file mode 100644 index 0000000..4001908 --- /dev/null +++ b/migrations/sql/0009_event_bus_sqlite.sql @@ -0,0 +1,55 @@ +CREATE TABLE IF NOT EXISTS event_outbox ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + event_id TEXT NOT NULL UNIQUE, + event_type TEXT NOT NULL, + event_version INTEGER NOT NULL DEFAULT 1, + producer_service TEXT NOT NULL, + entity_type TEXT NOT NULL, + entity_id TEXT NOT NULL, + correlation_id TEXT NULL, + routing_key TEXT NOT NULL, + payload_json TEXT NOT NULL DEFAULT '{}', + status TEXT NOT NULL, + attempt_count INTEGER NOT NULL DEFAULT 0, + last_error TEXT NULL, + available_at TEXT NOT NULL, + published_at TEXT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS ix_event_outbox_event_id ON event_outbox (event_id); +CREATE INDEX IF NOT EXISTS ix_event_outbox_event_type ON event_outbox (event_type); +CREATE INDEX IF NOT EXISTS ix_event_outbox_status ON event_outbox (status); +CREATE INDEX IF NOT EXISTS ix_event_outbox_available_at ON event_outbox (available_at); +CREATE INDEX IF NOT EXISTS ix_event_outbox_entity_type ON event_outbox (entity_type); +CREATE INDEX IF NOT EXISTS ix_event_outbox_entity_id ON event_outbox (entity_id); + +CREATE TABLE IF NOT EXISTS event_inbox ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + consumer_name TEXT NOT NULL, + event_id TEXT NOT NULL, + event_type TEXT NOT NULL, + processed_at TEXT NOT NULL, + status TEXT NOT NULL, + notes TEXT NULL +); +CREATE INDEX IF NOT EXISTS ix_event_inbox_consumer_name ON event_inbox (consumer_name); +CREATE INDEX IF NOT EXISTS ix_event_inbox_event_id ON event_inbox (event_id); +CREATE INDEX IF NOT EXISTS ix_event_inbox_event_type ON event_inbox (event_type); +CREATE UNIQUE INDEX IF NOT EXISTS uq_event_inbox_consumer_event ON event_inbox (consumer_name, event_id); + +CREATE TABLE IF NOT EXISTS reporting_event_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + event_id TEXT NOT NULL, + event_type TEXT NOT NULL, + queue_id TEXT NULL, + channel TEXT NULL, + interaction_id TEXT NULL, + payload_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS ix_reporting_event_log_event_id ON reporting_event_log (event_id); +CREATE INDEX IF NOT EXISTS ix_reporting_event_log_event_type ON reporting_event_log (event_type); +CREATE INDEX IF NOT EXISTS ix_reporting_event_log_queue_id ON reporting_event_log (queue_id); +CREATE INDEX IF NOT EXISTS ix_reporting_event_log_channel ON reporting_event_log (channel); +CREATE INDEX IF NOT EXISTS ix_reporting_event_log_interaction_id ON reporting_event_log (interaction_id); diff --git a/migrations/sql/0010_asterisk_bridge_postgres.sql b/migrations/sql/0010_asterisk_bridge_postgres.sql new file mode 100644 index 0000000..24189ea --- /dev/null +++ b/migrations/sql/0010_asterisk_bridge_postgres.sql @@ -0,0 +1,48 @@ +CREATE TABLE IF NOT EXISTS asterisk_event_log ( + id SERIAL PRIMARY KEY, + bridge_event_id VARCHAR(64) NOT NULL UNIQUE, + ami_event_name VARCHAR(128) NOT NULL, + call_id VARCHAR(128) NOT NULL, + linked_id VARCHAR(128), + interaction_id VARCHAR(64), + recording_id VARCHAR(64), + forward_status VARCHAR(32) NOT NULL, + payload_json TEXT NOT NULL DEFAULT '{}', + last_error TEXT, + created_at VARCHAR(64) NOT NULL, + updated_at VARCHAR(64) NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_asterisk_event_log_bridge_event_id ON asterisk_event_log(bridge_event_id); +CREATE INDEX IF NOT EXISTS idx_asterisk_event_log_ami_event_name ON asterisk_event_log(ami_event_name); +CREATE INDEX IF NOT EXISTS idx_asterisk_event_log_call_id ON asterisk_event_log(call_id); +CREATE INDEX IF NOT EXISTS idx_asterisk_event_log_linked_id ON asterisk_event_log(linked_id); +CREATE INDEX IF NOT EXISTS idx_asterisk_event_log_interaction_id ON asterisk_event_log(interaction_id); +CREATE INDEX IF NOT EXISTS idx_asterisk_event_log_recording_id ON asterisk_event_log(recording_id); +CREATE INDEX IF NOT EXISTS idx_asterisk_event_log_forward_status ON asterisk_event_log(forward_status); +CREATE INDEX IF NOT EXISTS idx_asterisk_event_log_created_at ON asterisk_event_log(created_at); +CREATE INDEX IF NOT EXISTS idx_asterisk_event_log_updated_at ON asterisk_event_log(updated_at); + +CREATE TABLE IF NOT EXISTS asterisk_call_links ( + id SERIAL PRIMARY KEY, + call_id VARCHAR(128) NOT NULL UNIQUE, + linked_id VARCHAR(128), + queue_code VARCHAR(64) NOT NULL, + queue_id VARCHAR(64) NOT NULL, + interaction_id VARCHAR(64) NOT NULL, + caller_number VARCHAR(64), + caller_name VARCHAR(128), + status VARCHAR(32) NOT NULL, + started_at VARCHAR(64) NOT NULL, + ended_at VARCHAR(64), + updated_at VARCHAR(64) NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_asterisk_call_links_call_id ON asterisk_call_links(call_id); +CREATE INDEX IF NOT EXISTS idx_asterisk_call_links_linked_id ON asterisk_call_links(linked_id); +CREATE INDEX IF NOT EXISTS idx_asterisk_call_links_queue_code ON asterisk_call_links(queue_code); +CREATE INDEX IF NOT EXISTS idx_asterisk_call_links_queue_id ON asterisk_call_links(queue_id); +CREATE INDEX IF NOT EXISTS idx_asterisk_call_links_interaction_id ON asterisk_call_links(interaction_id); +CREATE INDEX IF NOT EXISTS idx_asterisk_call_links_caller_number ON asterisk_call_links(caller_number); +CREATE INDEX IF NOT EXISTS idx_asterisk_call_links_status ON asterisk_call_links(status); +CREATE INDEX IF NOT EXISTS idx_asterisk_call_links_started_at ON asterisk_call_links(started_at); +CREATE INDEX IF NOT EXISTS idx_asterisk_call_links_ended_at ON asterisk_call_links(ended_at); +CREATE INDEX IF NOT EXISTS idx_asterisk_call_links_updated_at ON asterisk_call_links(updated_at); diff --git a/migrations/sql/0010_asterisk_bridge_sqlite.sql b/migrations/sql/0010_asterisk_bridge_sqlite.sql new file mode 100644 index 0000000..1b9998e --- /dev/null +++ b/migrations/sql/0010_asterisk_bridge_sqlite.sql @@ -0,0 +1,48 @@ +CREATE TABLE IF NOT EXISTS asterisk_event_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + bridge_event_id TEXT NOT NULL UNIQUE, + ami_event_name TEXT NOT NULL, + call_id TEXT NOT NULL, + linked_id TEXT, + interaction_id TEXT, + recording_id TEXT, + forward_status TEXT NOT NULL, + payload_json TEXT NOT NULL DEFAULT '{}', + last_error TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_asterisk_event_log_bridge_event_id ON asterisk_event_log(bridge_event_id); +CREATE INDEX IF NOT EXISTS idx_asterisk_event_log_ami_event_name ON asterisk_event_log(ami_event_name); +CREATE INDEX IF NOT EXISTS idx_asterisk_event_log_call_id ON asterisk_event_log(call_id); +CREATE INDEX IF NOT EXISTS idx_asterisk_event_log_linked_id ON asterisk_event_log(linked_id); +CREATE INDEX IF NOT EXISTS idx_asterisk_event_log_interaction_id ON asterisk_event_log(interaction_id); +CREATE INDEX IF NOT EXISTS idx_asterisk_event_log_recording_id ON asterisk_event_log(recording_id); +CREATE INDEX IF NOT EXISTS idx_asterisk_event_log_forward_status ON asterisk_event_log(forward_status); +CREATE INDEX IF NOT EXISTS idx_asterisk_event_log_created_at ON asterisk_event_log(created_at); +CREATE INDEX IF NOT EXISTS idx_asterisk_event_log_updated_at ON asterisk_event_log(updated_at); + +CREATE TABLE IF NOT EXISTS asterisk_call_links ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + call_id TEXT NOT NULL UNIQUE, + linked_id TEXT, + queue_code TEXT NOT NULL, + queue_id TEXT NOT NULL, + interaction_id TEXT NOT NULL, + caller_number TEXT, + caller_name TEXT, + status TEXT NOT NULL, + started_at TEXT NOT NULL, + ended_at TEXT, + updated_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_asterisk_call_links_call_id ON asterisk_call_links(call_id); +CREATE INDEX IF NOT EXISTS idx_asterisk_call_links_linked_id ON asterisk_call_links(linked_id); +CREATE INDEX IF NOT EXISTS idx_asterisk_call_links_queue_code ON asterisk_call_links(queue_code); +CREATE INDEX IF NOT EXISTS idx_asterisk_call_links_queue_id ON asterisk_call_links(queue_id); +CREATE INDEX IF NOT EXISTS idx_asterisk_call_links_interaction_id ON asterisk_call_links(interaction_id); +CREATE INDEX IF NOT EXISTS idx_asterisk_call_links_caller_number ON asterisk_call_links(caller_number); +CREATE INDEX IF NOT EXISTS idx_asterisk_call_links_status ON asterisk_call_links(status); +CREATE INDEX IF NOT EXISTS idx_asterisk_call_links_started_at ON asterisk_call_links(started_at); +CREATE INDEX IF NOT EXISTS idx_asterisk_call_links_ended_at ON asterisk_call_links(ended_at); +CREATE INDEX IF NOT EXISTS idx_asterisk_call_links_updated_at ON asterisk_call_links(updated_at); diff --git a/migrations/sql/0011_track11_voice_control_postgres.sql b/migrations/sql/0011_track11_voice_control_postgres.sql new file mode 100644 index 0000000..07997d8 --- /dev/null +++ b/migrations/sql/0011_track11_voice_control_postgres.sql @@ -0,0 +1,44 @@ +ALTER TABLE asterisk_call_links ADD COLUMN IF NOT EXISTS telephony_status VARCHAR(32) NOT NULL DEFAULT 'ringing'; +ALTER TABLE asterisk_call_links ADD COLUMN IF NOT EXISTS claimed_by_user VARCHAR(128); +ALTER TABLE asterisk_call_links ADD COLUMN IF NOT EXISTS claimed_at VARCHAR(64); +ALTER TABLE asterisk_call_links ADD COLUMN IF NOT EXISTS operator_extension VARCHAR(64); +ALTER TABLE asterisk_call_links ADD COLUMN IF NOT EXISTS channel_name VARCHAR(256); +ALTER TABLE asterisk_call_links ADD COLUMN IF NOT EXISTS connected_at VARCHAR(64); + +CREATE INDEX IF NOT EXISTS idx_asterisk_call_links_telephony_status ON asterisk_call_links(telephony_status); +CREATE INDEX IF NOT EXISTS idx_asterisk_call_links_claimed_by_user ON asterisk_call_links(claimed_by_user); +CREATE INDEX IF NOT EXISTS idx_asterisk_call_links_claimed_at ON asterisk_call_links(claimed_at); +CREATE INDEX IF NOT EXISTS idx_asterisk_call_links_operator_extension ON asterisk_call_links(operator_extension); +CREATE INDEX IF NOT EXISTS idx_asterisk_call_links_channel_name ON asterisk_call_links(channel_name); +CREATE INDEX IF NOT EXISTS idx_asterisk_call_links_connected_at ON asterisk_call_links(connected_at); + +UPDATE asterisk_call_links +SET telephony_status = CASE + WHEN status = 'ended' THEN 'ended' + ELSE 'ringing' +END +WHERE telephony_status IS NULL OR telephony_status = ''; + +CREATE TABLE IF NOT EXISTS asterisk_call_action_log ( + id SERIAL PRIMARY KEY, + action_id VARCHAR(64) NOT NULL UNIQUE, + call_id VARCHAR(128) NOT NULL, + interaction_id VARCHAR(64), + action_type VARCHAR(64) NOT NULL, + actor_user VARCHAR(128) NOT NULL, + actor_role VARCHAR(32) NOT NULL, + request_json TEXT NOT NULL DEFAULT '{}', + result_status VARCHAR(32) NOT NULL, + ami_action_id VARCHAR(64), + error TEXT, + created_at VARCHAR(64) NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_asterisk_call_action_log_action_id ON asterisk_call_action_log(action_id); +CREATE INDEX IF NOT EXISTS idx_asterisk_call_action_log_call_id ON asterisk_call_action_log(call_id); +CREATE INDEX IF NOT EXISTS idx_asterisk_call_action_log_interaction_id ON asterisk_call_action_log(interaction_id); +CREATE INDEX IF NOT EXISTS idx_asterisk_call_action_log_action_type ON asterisk_call_action_log(action_type); +CREATE INDEX IF NOT EXISTS idx_asterisk_call_action_log_actor_user ON asterisk_call_action_log(actor_user); +CREATE INDEX IF NOT EXISTS idx_asterisk_call_action_log_actor_role ON asterisk_call_action_log(actor_role); +CREATE INDEX IF NOT EXISTS idx_asterisk_call_action_log_result_status ON asterisk_call_action_log(result_status); +CREATE INDEX IF NOT EXISTS idx_asterisk_call_action_log_ami_action_id ON asterisk_call_action_log(ami_action_id); +CREATE INDEX IF NOT EXISTS idx_asterisk_call_action_log_created_at ON asterisk_call_action_log(created_at); diff --git a/migrations/sql/0011_track11_voice_control_sqlite.sql b/migrations/sql/0011_track11_voice_control_sqlite.sql new file mode 100644 index 0000000..8d28801 --- /dev/null +++ b/migrations/sql/0011_track11_voice_control_sqlite.sql @@ -0,0 +1,44 @@ +ALTER TABLE asterisk_call_links ADD COLUMN telephony_status TEXT NOT NULL DEFAULT 'ringing'; +ALTER TABLE asterisk_call_links ADD COLUMN claimed_by_user TEXT; +ALTER TABLE asterisk_call_links ADD COLUMN claimed_at TEXT; +ALTER TABLE asterisk_call_links ADD COLUMN operator_extension TEXT; +ALTER TABLE asterisk_call_links ADD COLUMN channel_name TEXT; +ALTER TABLE asterisk_call_links ADD COLUMN connected_at TEXT; + +CREATE INDEX IF NOT EXISTS idx_asterisk_call_links_telephony_status ON asterisk_call_links(telephony_status); +CREATE INDEX IF NOT EXISTS idx_asterisk_call_links_claimed_by_user ON asterisk_call_links(claimed_by_user); +CREATE INDEX IF NOT EXISTS idx_asterisk_call_links_claimed_at ON asterisk_call_links(claimed_at); +CREATE INDEX IF NOT EXISTS idx_asterisk_call_links_operator_extension ON asterisk_call_links(operator_extension); +CREATE INDEX IF NOT EXISTS idx_asterisk_call_links_channel_name ON asterisk_call_links(channel_name); +CREATE INDEX IF NOT EXISTS idx_asterisk_call_links_connected_at ON asterisk_call_links(connected_at); + +UPDATE asterisk_call_links +SET telephony_status = CASE + WHEN status = 'ended' THEN 'ended' + ELSE 'ringing' +END +WHERE telephony_status IS NULL OR telephony_status = ''; + +CREATE TABLE IF NOT EXISTS asterisk_call_action_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + action_id TEXT NOT NULL UNIQUE, + call_id TEXT NOT NULL, + interaction_id TEXT, + action_type TEXT NOT NULL, + actor_user TEXT NOT NULL, + actor_role TEXT NOT NULL, + request_json TEXT NOT NULL DEFAULT '{}', + result_status TEXT NOT NULL, + ami_action_id TEXT, + error TEXT, + created_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_asterisk_call_action_log_action_id ON asterisk_call_action_log(action_id); +CREATE INDEX IF NOT EXISTS idx_asterisk_call_action_log_call_id ON asterisk_call_action_log(call_id); +CREATE INDEX IF NOT EXISTS idx_asterisk_call_action_log_interaction_id ON asterisk_call_action_log(interaction_id); +CREATE INDEX IF NOT EXISTS idx_asterisk_call_action_log_action_type ON asterisk_call_action_log(action_type); +CREATE INDEX IF NOT EXISTS idx_asterisk_call_action_log_actor_user ON asterisk_call_action_log(actor_user); +CREATE INDEX IF NOT EXISTS idx_asterisk_call_action_log_actor_role ON asterisk_call_action_log(actor_role); +CREATE INDEX IF NOT EXISTS idx_asterisk_call_action_log_result_status ON asterisk_call_action_log(result_status); +CREATE INDEX IF NOT EXISTS idx_asterisk_call_action_log_ami_action_id ON asterisk_call_action_log(ami_action_id); +CREATE INDEX IF NOT EXISTS idx_asterisk_call_action_log_created_at ON asterisk_call_action_log(created_at); diff --git a/migrations/sql/0012_telegram_chat_postgres.sql b/migrations/sql/0012_telegram_chat_postgres.sql new file mode 100644 index 0000000..c69eec8 --- /dev/null +++ b/migrations/sql/0012_telegram_chat_postgres.sql @@ -0,0 +1,44 @@ +CREATE TABLE IF NOT EXISTS telegram_threads ( + id SERIAL PRIMARY KEY, + thread_id TEXT NOT NULL UNIQUE, + chat_id TEXT NOT NULL UNIQUE, + interaction_id TEXT NOT NULL, + telegram_user_id TEXT, + username TEXT, + display_name TEXT, + queue_id TEXT, + status TEXT NOT NULL, + claimed_by_user TEXT, + claimed_at TEXT, + last_message_at TEXT NOT NULL, + last_message_preview TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_telegram_threads_thread_id ON telegram_threads(thread_id); +CREATE INDEX IF NOT EXISTS idx_telegram_threads_chat_id ON telegram_threads(chat_id); +CREATE INDEX IF NOT EXISTS idx_telegram_threads_interaction_id ON telegram_threads(interaction_id); +CREATE INDEX IF NOT EXISTS idx_telegram_threads_telegram_user_id ON telegram_threads(telegram_user_id); +CREATE INDEX IF NOT EXISTS idx_telegram_threads_username ON telegram_threads(username); +CREATE INDEX IF NOT EXISTS idx_telegram_threads_display_name ON telegram_threads(display_name); +CREATE INDEX IF NOT EXISTS idx_telegram_threads_queue_id ON telegram_threads(queue_id); +CREATE INDEX IF NOT EXISTS idx_telegram_threads_status ON telegram_threads(status); +CREATE INDEX IF NOT EXISTS idx_telegram_threads_claimed_by_user ON telegram_threads(claimed_by_user); +CREATE INDEX IF NOT EXISTS idx_telegram_threads_claimed_at ON telegram_threads(claimed_at); +CREATE INDEX IF NOT EXISTS idx_telegram_threads_last_message_at ON telegram_threads(last_message_at); +CREATE INDEX IF NOT EXISTS idx_telegram_threads_created_at ON telegram_threads(created_at); +CREATE INDEX IF NOT EXISTS idx_telegram_threads_updated_at ON telegram_threads(updated_at); + +ALTER TABLE telegram_messages ADD COLUMN IF NOT EXISTS thread_id TEXT; +ALTER TABLE telegram_messages ADD COLUMN IF NOT EXISTS interaction_id TEXT; +ALTER TABLE telegram_messages ADD COLUMN IF NOT EXISTS direction TEXT NOT NULL DEFAULT 'inbound'; +ALTER TABLE telegram_messages ADD COLUMN IF NOT EXISTS telegram_message_id_external TEXT; +ALTER TABLE telegram_messages ADD COLUMN IF NOT EXISTS operator_user TEXT; +ALTER TABLE telegram_messages ADD COLUMN IF NOT EXISTS delivery_status TEXT; + +CREATE INDEX IF NOT EXISTS idx_telegram_messages_thread_id ON telegram_messages(thread_id); +CREATE INDEX IF NOT EXISTS idx_telegram_messages_interaction_id ON telegram_messages(interaction_id); +CREATE INDEX IF NOT EXISTS idx_telegram_messages_direction ON telegram_messages(direction); +CREATE INDEX IF NOT EXISTS idx_telegram_messages_external_id ON telegram_messages(telegram_message_id_external); +CREATE INDEX IF NOT EXISTS idx_telegram_messages_operator_user ON telegram_messages(operator_user); +CREATE INDEX IF NOT EXISTS idx_telegram_messages_delivery_status ON telegram_messages(delivery_status); diff --git a/migrations/sql/0012_telegram_chat_sqlite.sql b/migrations/sql/0012_telegram_chat_sqlite.sql new file mode 100644 index 0000000..a775a28 --- /dev/null +++ b/migrations/sql/0012_telegram_chat_sqlite.sql @@ -0,0 +1,44 @@ +CREATE TABLE IF NOT EXISTS telegram_threads ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + thread_id TEXT NOT NULL UNIQUE, + chat_id TEXT NOT NULL UNIQUE, + interaction_id TEXT NOT NULL, + telegram_user_id TEXT, + username TEXT, + display_name TEXT, + queue_id TEXT, + status TEXT NOT NULL, + claimed_by_user TEXT, + claimed_at TEXT, + last_message_at TEXT NOT NULL, + last_message_preview TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_telegram_threads_thread_id ON telegram_threads(thread_id); +CREATE INDEX IF NOT EXISTS idx_telegram_threads_chat_id ON telegram_threads(chat_id); +CREATE INDEX IF NOT EXISTS idx_telegram_threads_interaction_id ON telegram_threads(interaction_id); +CREATE INDEX IF NOT EXISTS idx_telegram_threads_telegram_user_id ON telegram_threads(telegram_user_id); +CREATE INDEX IF NOT EXISTS idx_telegram_threads_username ON telegram_threads(username); +CREATE INDEX IF NOT EXISTS idx_telegram_threads_display_name ON telegram_threads(display_name); +CREATE INDEX IF NOT EXISTS idx_telegram_threads_queue_id ON telegram_threads(queue_id); +CREATE INDEX IF NOT EXISTS idx_telegram_threads_status ON telegram_threads(status); +CREATE INDEX IF NOT EXISTS idx_telegram_threads_claimed_by_user ON telegram_threads(claimed_by_user); +CREATE INDEX IF NOT EXISTS idx_telegram_threads_claimed_at ON telegram_threads(claimed_at); +CREATE INDEX IF NOT EXISTS idx_telegram_threads_last_message_at ON telegram_threads(last_message_at); +CREATE INDEX IF NOT EXISTS idx_telegram_threads_created_at ON telegram_threads(created_at); +CREATE INDEX IF NOT EXISTS idx_telegram_threads_updated_at ON telegram_threads(updated_at); + +ALTER TABLE telegram_messages ADD COLUMN thread_id TEXT; +ALTER TABLE telegram_messages ADD COLUMN interaction_id TEXT; +ALTER TABLE telegram_messages ADD COLUMN direction TEXT NOT NULL DEFAULT 'inbound'; +ALTER TABLE telegram_messages ADD COLUMN telegram_message_id_external TEXT; +ALTER TABLE telegram_messages ADD COLUMN operator_user TEXT; +ALTER TABLE telegram_messages ADD COLUMN delivery_status TEXT; + +CREATE INDEX IF NOT EXISTS idx_telegram_messages_thread_id ON telegram_messages(thread_id); +CREATE INDEX IF NOT EXISTS idx_telegram_messages_interaction_id ON telegram_messages(interaction_id); +CREATE INDEX IF NOT EXISTS idx_telegram_messages_direction ON telegram_messages(direction); +CREATE INDEX IF NOT EXISTS idx_telegram_messages_external_id ON telegram_messages(telegram_message_id_external); +CREATE INDEX IF NOT EXISTS idx_telegram_messages_operator_user ON telegram_messages(operator_user); +CREATE INDEX IF NOT EXISTS idx_telegram_messages_delivery_status ON telegram_messages(delivery_status); diff --git a/migrations/sql/0013_asterisk_bridge_idempotency_postgres.sql b/migrations/sql/0013_asterisk_bridge_idempotency_postgres.sql new file mode 100644 index 0000000..434b484 --- /dev/null +++ b/migrations/sql/0013_asterisk_bridge_idempotency_postgres.sql @@ -0,0 +1,6 @@ +ALTER TABLE voice_events ADD COLUMN IF NOT EXISTS source_event_id VARCHAR(64); + +CREATE UNIQUE INDEX IF NOT EXISTS ix_voice_events_source_event_id ON voice_events(source_event_id); + +DROP INDEX IF EXISTS ix_call_recordings_source_event_id; +CREATE UNIQUE INDEX IF NOT EXISTS ix_call_recordings_source_event_id ON call_recordings(source_event_id); diff --git a/migrations/sql/0013_asterisk_bridge_idempotency_sqlite.sql b/migrations/sql/0013_asterisk_bridge_idempotency_sqlite.sql new file mode 100644 index 0000000..c5fa4d2 --- /dev/null +++ b/migrations/sql/0013_asterisk_bridge_idempotency_sqlite.sql @@ -0,0 +1,6 @@ +ALTER TABLE voice_events ADD COLUMN source_event_id TEXT; + +CREATE UNIQUE INDEX IF NOT EXISTS ix_voice_events_source_event_id ON voice_events(source_event_id); + +DROP INDEX IF EXISTS ix_call_recordings_source_event_id; +CREATE UNIQUE INDEX IF NOT EXISTS ix_call_recordings_source_event_id ON call_recordings(source_event_id); diff --git a/migrations/sql/0013_telegram_ai_postgres.sql b/migrations/sql/0013_telegram_ai_postgres.sql new file mode 100644 index 0000000..d4d1161 --- /dev/null +++ b/migrations/sql/0013_telegram_ai_postgres.sql @@ -0,0 +1,123 @@ +CREATE TABLE IF NOT EXISTS customer_external_identities ( + id SERIAL PRIMARY KEY, + identity_id TEXT NOT NULL UNIQUE, + customer_id TEXT NOT NULL, + channel TEXT NOT NULL, + external_subject TEXT NOT NULL, + display_name_snapshot TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); +CREATE UNIQUE INDEX IF NOT EXISTS ix_customer_external_identities_channel_subject_unique + ON customer_external_identities(channel, external_subject); +CREATE INDEX IF NOT EXISTS idx_customer_external_identities_identity_id ON customer_external_identities(identity_id); +CREATE INDEX IF NOT EXISTS idx_customer_external_identities_customer_id ON customer_external_identities(customer_id); +CREATE INDEX IF NOT EXISTS idx_customer_external_identities_channel ON customer_external_identities(channel); +CREATE INDEX IF NOT EXISTS idx_customer_external_identities_external_subject ON customer_external_identities(external_subject); +CREATE INDEX IF NOT EXISTS idx_customer_external_identities_created_at ON customer_external_identities(created_at); +CREATE INDEX IF NOT EXISTS idx_customer_external_identities_updated_at ON customer_external_identities(updated_at); + +CREATE TABLE IF NOT EXISTS ai_sessions ( + id SERIAL PRIMARY KEY, + session_id TEXT NOT NULL UNIQUE, + channel TEXT NOT NULL, + thread_id TEXT, + interaction_id TEXT, + customer_id TEXT, + agent_profile TEXT NOT NULL DEFAULT 'telegram_support', + language TEXT, + status TEXT NOT NULL DEFAULT 'active', + summary_text TEXT NOT NULL DEFAULT '', + last_user_message_id TEXT, + last_ai_message_id TEXT, + handoff_reason TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + closed_at TEXT +); +CREATE INDEX IF NOT EXISTS idx_ai_sessions_session_id ON ai_sessions(session_id); +CREATE INDEX IF NOT EXISTS idx_ai_sessions_channel ON ai_sessions(channel); +CREATE INDEX IF NOT EXISTS idx_ai_sessions_thread_id ON ai_sessions(thread_id); +CREATE INDEX IF NOT EXISTS idx_ai_sessions_interaction_id ON ai_sessions(interaction_id); +CREATE INDEX IF NOT EXISTS idx_ai_sessions_customer_id ON ai_sessions(customer_id); +CREATE INDEX IF NOT EXISTS idx_ai_sessions_agent_profile ON ai_sessions(agent_profile); +CREATE INDEX IF NOT EXISTS idx_ai_sessions_language ON ai_sessions(language); +CREATE INDEX IF NOT EXISTS idx_ai_sessions_status ON ai_sessions(status); +CREATE INDEX IF NOT EXISTS idx_ai_sessions_last_user_message_id ON ai_sessions(last_user_message_id); +CREATE INDEX IF NOT EXISTS idx_ai_sessions_last_ai_message_id ON ai_sessions(last_ai_message_id); +CREATE INDEX IF NOT EXISTS idx_ai_sessions_created_at ON ai_sessions(created_at); +CREATE INDEX IF NOT EXISTS idx_ai_sessions_updated_at ON ai_sessions(updated_at); +CREATE INDEX IF NOT EXISTS idx_ai_sessions_closed_at ON ai_sessions(closed_at); + +CREATE TABLE IF NOT EXISTS ai_jobs ( + id SERIAL PRIMARY KEY, + job_id TEXT NOT NULL UNIQUE, + session_id TEXT NOT NULL, + thread_id TEXT NOT NULL, + trigger_message_id TEXT, + status TEXT NOT NULL DEFAULT 'pending', + attempts INTEGER NOT NULL DEFAULT 0, + next_attempt_at TEXT, + locked_until TEXT, + last_error TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_ai_jobs_job_id ON ai_jobs(job_id); +CREATE INDEX IF NOT EXISTS idx_ai_jobs_session_id ON ai_jobs(session_id); +CREATE INDEX IF NOT EXISTS idx_ai_jobs_thread_id ON ai_jobs(thread_id); +CREATE INDEX IF NOT EXISTS idx_ai_jobs_trigger_message_id ON ai_jobs(trigger_message_id); +CREATE INDEX IF NOT EXISTS idx_ai_jobs_status ON ai_jobs(status); +CREATE INDEX IF NOT EXISTS idx_ai_jobs_next_attempt_at ON ai_jobs(next_attempt_at); +CREATE INDEX IF NOT EXISTS idx_ai_jobs_locked_until ON ai_jobs(locked_until); +CREATE INDEX IF NOT EXISTS idx_ai_jobs_created_at ON ai_jobs(created_at); +CREATE INDEX IF NOT EXISTS idx_ai_jobs_updated_at ON ai_jobs(updated_at); + +CREATE TABLE IF NOT EXISTS ai_turns ( + id SERIAL PRIMARY KEY, + turn_id TEXT NOT NULL UNIQUE, + session_id TEXT NOT NULL, + thread_id TEXT, + interaction_id TEXT, + role TEXT NOT NULL, + source_type TEXT NOT NULL, + text TEXT NOT NULL, + payload_json TEXT NOT NULL DEFAULT '{}', + model TEXT, + finish_reason TEXT, + latency_ms INTEGER, + created_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_ai_turns_turn_id ON ai_turns(turn_id); +CREATE INDEX IF NOT EXISTS idx_ai_turns_session_id ON ai_turns(session_id); +CREATE INDEX IF NOT EXISTS idx_ai_turns_thread_id ON ai_turns(thread_id); +CREATE INDEX IF NOT EXISTS idx_ai_turns_interaction_id ON ai_turns(interaction_id); +CREATE INDEX IF NOT EXISTS idx_ai_turns_role ON ai_turns(role); +CREATE INDEX IF NOT EXISTS idx_ai_turns_source_type ON ai_turns(source_type); +CREATE INDEX IF NOT EXISTS idx_ai_turns_model ON ai_turns(model); +CREATE INDEX IF NOT EXISTS idx_ai_turns_created_at ON ai_turns(created_at); + +ALTER TABLE telegram_threads ADD COLUMN IF NOT EXISTS ai_session_id TEXT; +ALTER TABLE telegram_threads ADD COLUMN IF NOT EXISTS ai_state TEXT; +ALTER TABLE telegram_threads ADD COLUMN IF NOT EXISTS ai_handoff_reason TEXT; +ALTER TABLE telegram_threads ADD COLUMN IF NOT EXISTS ai_last_model_at TEXT; +CREATE INDEX IF NOT EXISTS idx_telegram_threads_ai_session_id ON telegram_threads(ai_session_id); +CREATE INDEX IF NOT EXISTS idx_telegram_threads_ai_state ON telegram_threads(ai_state); +CREATE INDEX IF NOT EXISTS idx_telegram_threads_ai_last_model_at ON telegram_threads(ai_last_model_at); + +ALTER TABLE telegram_messages ADD COLUMN IF NOT EXISTS author_type TEXT NOT NULL DEFAULT 'customer'; +ALTER TABLE telegram_messages ADD COLUMN IF NOT EXISTS author_id TEXT; +UPDATE telegram_messages +SET author_type = CASE + WHEN direction = 'outbound' THEN 'human' + WHEN direction = 'system' THEN 'system' + ELSE 'customer' +END +WHERE author_type IS NULL OR TRIM(author_type) = ''; +UPDATE telegram_messages +SET author_id = operator_user +WHERE direction = 'outbound' + AND operator_user IS NOT NULL + AND (author_id IS NULL OR TRIM(author_id) = ''); +CREATE INDEX IF NOT EXISTS idx_telegram_messages_author_type ON telegram_messages(author_type); +CREATE INDEX IF NOT EXISTS idx_telegram_messages_author_id ON telegram_messages(author_id); diff --git a/migrations/sql/0013_telegram_ai_sqlite.sql b/migrations/sql/0013_telegram_ai_sqlite.sql new file mode 100644 index 0000000..2df33c0 --- /dev/null +++ b/migrations/sql/0013_telegram_ai_sqlite.sql @@ -0,0 +1,123 @@ +CREATE TABLE IF NOT EXISTS customer_external_identities ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + identity_id TEXT NOT NULL UNIQUE, + customer_id TEXT NOT NULL, + channel TEXT NOT NULL, + external_subject TEXT NOT NULL, + display_name_snapshot TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); +CREATE UNIQUE INDEX IF NOT EXISTS ix_customer_external_identities_channel_subject_unique + ON customer_external_identities(channel, external_subject); +CREATE INDEX IF NOT EXISTS idx_customer_external_identities_identity_id ON customer_external_identities(identity_id); +CREATE INDEX IF NOT EXISTS idx_customer_external_identities_customer_id ON customer_external_identities(customer_id); +CREATE INDEX IF NOT EXISTS idx_customer_external_identities_channel ON customer_external_identities(channel); +CREATE INDEX IF NOT EXISTS idx_customer_external_identities_external_subject ON customer_external_identities(external_subject); +CREATE INDEX IF NOT EXISTS idx_customer_external_identities_created_at ON customer_external_identities(created_at); +CREATE INDEX IF NOT EXISTS idx_customer_external_identities_updated_at ON customer_external_identities(updated_at); + +CREATE TABLE IF NOT EXISTS ai_sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL UNIQUE, + channel TEXT NOT NULL, + thread_id TEXT, + interaction_id TEXT, + customer_id TEXT, + agent_profile TEXT NOT NULL DEFAULT 'telegram_support', + language TEXT, + status TEXT NOT NULL DEFAULT 'active', + summary_text TEXT NOT NULL DEFAULT '', + last_user_message_id TEXT, + last_ai_message_id TEXT, + handoff_reason TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + closed_at TEXT +); +CREATE INDEX IF NOT EXISTS idx_ai_sessions_session_id ON ai_sessions(session_id); +CREATE INDEX IF NOT EXISTS idx_ai_sessions_channel ON ai_sessions(channel); +CREATE INDEX IF NOT EXISTS idx_ai_sessions_thread_id ON ai_sessions(thread_id); +CREATE INDEX IF NOT EXISTS idx_ai_sessions_interaction_id ON ai_sessions(interaction_id); +CREATE INDEX IF NOT EXISTS idx_ai_sessions_customer_id ON ai_sessions(customer_id); +CREATE INDEX IF NOT EXISTS idx_ai_sessions_agent_profile ON ai_sessions(agent_profile); +CREATE INDEX IF NOT EXISTS idx_ai_sessions_language ON ai_sessions(language); +CREATE INDEX IF NOT EXISTS idx_ai_sessions_status ON ai_sessions(status); +CREATE INDEX IF NOT EXISTS idx_ai_sessions_last_user_message_id ON ai_sessions(last_user_message_id); +CREATE INDEX IF NOT EXISTS idx_ai_sessions_last_ai_message_id ON ai_sessions(last_ai_message_id); +CREATE INDEX IF NOT EXISTS idx_ai_sessions_created_at ON ai_sessions(created_at); +CREATE INDEX IF NOT EXISTS idx_ai_sessions_updated_at ON ai_sessions(updated_at); +CREATE INDEX IF NOT EXISTS idx_ai_sessions_closed_at ON ai_sessions(closed_at); + +CREATE TABLE IF NOT EXISTS ai_jobs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + job_id TEXT NOT NULL UNIQUE, + session_id TEXT NOT NULL, + thread_id TEXT NOT NULL, + trigger_message_id TEXT, + status TEXT NOT NULL DEFAULT 'pending', + attempts INTEGER NOT NULL DEFAULT 0, + next_attempt_at TEXT, + locked_until TEXT, + last_error TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_ai_jobs_job_id ON ai_jobs(job_id); +CREATE INDEX IF NOT EXISTS idx_ai_jobs_session_id ON ai_jobs(session_id); +CREATE INDEX IF NOT EXISTS idx_ai_jobs_thread_id ON ai_jobs(thread_id); +CREATE INDEX IF NOT EXISTS idx_ai_jobs_trigger_message_id ON ai_jobs(trigger_message_id); +CREATE INDEX IF NOT EXISTS idx_ai_jobs_status ON ai_jobs(status); +CREATE INDEX IF NOT EXISTS idx_ai_jobs_next_attempt_at ON ai_jobs(next_attempt_at); +CREATE INDEX IF NOT EXISTS idx_ai_jobs_locked_until ON ai_jobs(locked_until); +CREATE INDEX IF NOT EXISTS idx_ai_jobs_created_at ON ai_jobs(created_at); +CREATE INDEX IF NOT EXISTS idx_ai_jobs_updated_at ON ai_jobs(updated_at); + +CREATE TABLE IF NOT EXISTS ai_turns ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + turn_id TEXT NOT NULL UNIQUE, + session_id TEXT NOT NULL, + thread_id TEXT, + interaction_id TEXT, + role TEXT NOT NULL, + source_type TEXT NOT NULL, + text TEXT NOT NULL, + payload_json TEXT NOT NULL DEFAULT '{}', + model TEXT, + finish_reason TEXT, + latency_ms INTEGER, + created_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_ai_turns_turn_id ON ai_turns(turn_id); +CREATE INDEX IF NOT EXISTS idx_ai_turns_session_id ON ai_turns(session_id); +CREATE INDEX IF NOT EXISTS idx_ai_turns_thread_id ON ai_turns(thread_id); +CREATE INDEX IF NOT EXISTS idx_ai_turns_interaction_id ON ai_turns(interaction_id); +CREATE INDEX IF NOT EXISTS idx_ai_turns_role ON ai_turns(role); +CREATE INDEX IF NOT EXISTS idx_ai_turns_source_type ON ai_turns(source_type); +CREATE INDEX IF NOT EXISTS idx_ai_turns_model ON ai_turns(model); +CREATE INDEX IF NOT EXISTS idx_ai_turns_created_at ON ai_turns(created_at); + +ALTER TABLE telegram_threads ADD COLUMN ai_session_id TEXT; +ALTER TABLE telegram_threads ADD COLUMN ai_state TEXT; +ALTER TABLE telegram_threads ADD COLUMN ai_handoff_reason TEXT; +ALTER TABLE telegram_threads ADD COLUMN ai_last_model_at TEXT; +CREATE INDEX IF NOT EXISTS idx_telegram_threads_ai_session_id ON telegram_threads(ai_session_id); +CREATE INDEX IF NOT EXISTS idx_telegram_threads_ai_state ON telegram_threads(ai_state); +CREATE INDEX IF NOT EXISTS idx_telegram_threads_ai_last_model_at ON telegram_threads(ai_last_model_at); + +ALTER TABLE telegram_messages ADD COLUMN author_type TEXT NOT NULL DEFAULT 'customer'; +ALTER TABLE telegram_messages ADD COLUMN author_id TEXT; +UPDATE telegram_messages +SET author_type = CASE + WHEN direction = 'outbound' THEN 'human' + WHEN direction = 'system' THEN 'system' + ELSE 'customer' +END +WHERE author_type IS NULL OR TRIM(author_type) = ''; +UPDATE telegram_messages +SET author_id = operator_user +WHERE direction = 'outbound' + AND operator_user IS NOT NULL + AND (author_id IS NULL OR TRIM(author_id) = ''); +CREATE INDEX IF NOT EXISTS idx_telegram_messages_author_type ON telegram_messages(author_type); +CREATE INDEX IF NOT EXISTS idx_telegram_messages_author_id ON telegram_messages(author_id); diff --git a/migrations/sql/0014_voice_ai_postgres.sql b/migrations/sql/0014_voice_ai_postgres.sql new file mode 100644 index 0000000..a4c85ee --- /dev/null +++ b/migrations/sql/0014_voice_ai_postgres.sql @@ -0,0 +1,82 @@ +ALTER TABLE ai_sessions ADD COLUMN IF NOT EXISTS call_id TEXT; +CREATE INDEX IF NOT EXISTS idx_ai_sessions_call_id ON ai_sessions(call_id); + +ALTER TABLE asterisk_call_links ADD COLUMN IF NOT EXISTS voice_session_id TEXT; +ALTER TABLE asterisk_call_links ADD COLUMN IF NOT EXISTS ai_session_id TEXT; +ALTER TABLE asterisk_call_links ADD COLUMN IF NOT EXISTS ai_state TEXT; +ALTER TABLE asterisk_call_links ADD COLUMN IF NOT EXISTS ai_handoff_reason TEXT; +ALTER TABLE asterisk_call_links ADD COLUMN IF NOT EXISTS ai_last_model_at TEXT; +CREATE INDEX IF NOT EXISTS idx_asterisk_call_links_voice_session_id ON asterisk_call_links(voice_session_id); +CREATE INDEX IF NOT EXISTS idx_asterisk_call_links_ai_session_id ON asterisk_call_links(ai_session_id); +CREATE INDEX IF NOT EXISTS idx_asterisk_call_links_ai_state ON asterisk_call_links(ai_state); +CREATE INDEX IF NOT EXISTS idx_asterisk_call_links_ai_last_model_at ON asterisk_call_links(ai_last_model_at); + +CREATE TABLE IF NOT EXISTS voice_ai_sessions ( + id SERIAL PRIMARY KEY, + session_id TEXT NOT NULL UNIQUE, + call_id TEXT NOT NULL UNIQUE, + linked_id TEXT, + interaction_id TEXT, + customer_id TEXT, + queue_id TEXT, + ai_session_id TEXT, + agent_profile TEXT NOT NULL DEFAULT 'voice_support', + language TEXT, + asr_provider TEXT, + tts_provider TEXT, + status TEXT NOT NULL DEFAULT 'queued', + handoff_reason TEXT, + handoff_target_queue_id TEXT, + disclosure_played_at TEXT, + last_user_utterance_at TEXT, + last_ai_reply_at TEXT, + started_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + ended_at TEXT +); +CREATE INDEX IF NOT EXISTS idx_voice_ai_sessions_session_id ON voice_ai_sessions(session_id); +CREATE INDEX IF NOT EXISTS idx_voice_ai_sessions_call_id ON voice_ai_sessions(call_id); +CREATE INDEX IF NOT EXISTS idx_voice_ai_sessions_linked_id ON voice_ai_sessions(linked_id); +CREATE INDEX IF NOT EXISTS idx_voice_ai_sessions_interaction_id ON voice_ai_sessions(interaction_id); +CREATE INDEX IF NOT EXISTS idx_voice_ai_sessions_customer_id ON voice_ai_sessions(customer_id); +CREATE INDEX IF NOT EXISTS idx_voice_ai_sessions_queue_id ON voice_ai_sessions(queue_id); +CREATE INDEX IF NOT EXISTS idx_voice_ai_sessions_ai_session_id ON voice_ai_sessions(ai_session_id); +CREATE INDEX IF NOT EXISTS idx_voice_ai_sessions_agent_profile ON voice_ai_sessions(agent_profile); +CREATE INDEX IF NOT EXISTS idx_voice_ai_sessions_language ON voice_ai_sessions(language); +CREATE INDEX IF NOT EXISTS idx_voice_ai_sessions_asr_provider ON voice_ai_sessions(asr_provider); +CREATE INDEX IF NOT EXISTS idx_voice_ai_sessions_tts_provider ON voice_ai_sessions(tts_provider); +CREATE INDEX IF NOT EXISTS idx_voice_ai_sessions_status ON voice_ai_sessions(status); +CREATE INDEX IF NOT EXISTS idx_voice_ai_sessions_handoff_target_queue_id ON voice_ai_sessions(handoff_target_queue_id); +CREATE INDEX IF NOT EXISTS idx_voice_ai_sessions_disclosure_played_at ON voice_ai_sessions(disclosure_played_at); +CREATE INDEX IF NOT EXISTS idx_voice_ai_sessions_last_user_utterance_at ON voice_ai_sessions(last_user_utterance_at); +CREATE INDEX IF NOT EXISTS idx_voice_ai_sessions_last_ai_reply_at ON voice_ai_sessions(last_ai_reply_at); +CREATE INDEX IF NOT EXISTS idx_voice_ai_sessions_started_at ON voice_ai_sessions(started_at); +CREATE INDEX IF NOT EXISTS idx_voice_ai_sessions_updated_at ON voice_ai_sessions(updated_at); +CREATE INDEX IF NOT EXISTS idx_voice_ai_sessions_ended_at ON voice_ai_sessions(ended_at); + +CREATE TABLE IF NOT EXISTS voice_transcript_segments ( + id SERIAL PRIMARY KEY, + segment_id TEXT NOT NULL UNIQUE, + session_id TEXT NOT NULL, + call_id TEXT NOT NULL, + interaction_id TEXT, + speaker TEXT NOT NULL, + source_type TEXT NOT NULL, + sequence_no INTEGER NOT NULL, + text TEXT NOT NULL, + confidence DOUBLE PRECISION, + is_final BOOLEAN NOT NULL DEFAULT TRUE, + barge_in_interrupted BOOLEAN NOT NULL DEFAULT FALSE, + payload_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_voice_transcript_segments_segment_id ON voice_transcript_segments(segment_id); +CREATE INDEX IF NOT EXISTS idx_voice_transcript_segments_session_id ON voice_transcript_segments(session_id); +CREATE INDEX IF NOT EXISTS idx_voice_transcript_segments_call_id ON voice_transcript_segments(call_id); +CREATE INDEX IF NOT EXISTS idx_voice_transcript_segments_interaction_id ON voice_transcript_segments(interaction_id); +CREATE INDEX IF NOT EXISTS idx_voice_transcript_segments_speaker ON voice_transcript_segments(speaker); +CREATE INDEX IF NOT EXISTS idx_voice_transcript_segments_source_type ON voice_transcript_segments(source_type); +CREATE INDEX IF NOT EXISTS idx_voice_transcript_segments_sequence_no ON voice_transcript_segments(sequence_no); +CREATE INDEX IF NOT EXISTS idx_voice_transcript_segments_is_final ON voice_transcript_segments(is_final); +CREATE INDEX IF NOT EXISTS idx_voice_transcript_segments_barge_in_interrupted ON voice_transcript_segments(barge_in_interrupted); +CREATE INDEX IF NOT EXISTS idx_voice_transcript_segments_created_at ON voice_transcript_segments(created_at); diff --git a/migrations/sql/0014_voice_ai_sqlite.sql b/migrations/sql/0014_voice_ai_sqlite.sql new file mode 100644 index 0000000..4238b49 --- /dev/null +++ b/migrations/sql/0014_voice_ai_sqlite.sql @@ -0,0 +1,82 @@ +ALTER TABLE ai_sessions ADD COLUMN call_id TEXT; +CREATE INDEX IF NOT EXISTS idx_ai_sessions_call_id ON ai_sessions(call_id); + +ALTER TABLE asterisk_call_links ADD COLUMN voice_session_id TEXT; +ALTER TABLE asterisk_call_links ADD COLUMN ai_session_id TEXT; +ALTER TABLE asterisk_call_links ADD COLUMN ai_state TEXT; +ALTER TABLE asterisk_call_links ADD COLUMN ai_handoff_reason TEXT; +ALTER TABLE asterisk_call_links ADD COLUMN ai_last_model_at TEXT; +CREATE INDEX IF NOT EXISTS idx_asterisk_call_links_voice_session_id ON asterisk_call_links(voice_session_id); +CREATE INDEX IF NOT EXISTS idx_asterisk_call_links_ai_session_id ON asterisk_call_links(ai_session_id); +CREATE INDEX IF NOT EXISTS idx_asterisk_call_links_ai_state ON asterisk_call_links(ai_state); +CREATE INDEX IF NOT EXISTS idx_asterisk_call_links_ai_last_model_at ON asterisk_call_links(ai_last_model_at); + +CREATE TABLE IF NOT EXISTS voice_ai_sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL UNIQUE, + call_id TEXT NOT NULL UNIQUE, + linked_id TEXT, + interaction_id TEXT, + customer_id TEXT, + queue_id TEXT, + ai_session_id TEXT, + agent_profile TEXT NOT NULL DEFAULT 'voice_support', + language TEXT, + asr_provider TEXT, + tts_provider TEXT, + status TEXT NOT NULL DEFAULT 'queued', + handoff_reason TEXT, + handoff_target_queue_id TEXT, + disclosure_played_at TEXT, + last_user_utterance_at TEXT, + last_ai_reply_at TEXT, + started_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + ended_at TEXT +); +CREATE INDEX IF NOT EXISTS idx_voice_ai_sessions_session_id ON voice_ai_sessions(session_id); +CREATE INDEX IF NOT EXISTS idx_voice_ai_sessions_call_id ON voice_ai_sessions(call_id); +CREATE INDEX IF NOT EXISTS idx_voice_ai_sessions_linked_id ON voice_ai_sessions(linked_id); +CREATE INDEX IF NOT EXISTS idx_voice_ai_sessions_interaction_id ON voice_ai_sessions(interaction_id); +CREATE INDEX IF NOT EXISTS idx_voice_ai_sessions_customer_id ON voice_ai_sessions(customer_id); +CREATE INDEX IF NOT EXISTS idx_voice_ai_sessions_queue_id ON voice_ai_sessions(queue_id); +CREATE INDEX IF NOT EXISTS idx_voice_ai_sessions_ai_session_id ON voice_ai_sessions(ai_session_id); +CREATE INDEX IF NOT EXISTS idx_voice_ai_sessions_agent_profile ON voice_ai_sessions(agent_profile); +CREATE INDEX IF NOT EXISTS idx_voice_ai_sessions_language ON voice_ai_sessions(language); +CREATE INDEX IF NOT EXISTS idx_voice_ai_sessions_asr_provider ON voice_ai_sessions(asr_provider); +CREATE INDEX IF NOT EXISTS idx_voice_ai_sessions_tts_provider ON voice_ai_sessions(tts_provider); +CREATE INDEX IF NOT EXISTS idx_voice_ai_sessions_status ON voice_ai_sessions(status); +CREATE INDEX IF NOT EXISTS idx_voice_ai_sessions_handoff_target_queue_id ON voice_ai_sessions(handoff_target_queue_id); +CREATE INDEX IF NOT EXISTS idx_voice_ai_sessions_disclosure_played_at ON voice_ai_sessions(disclosure_played_at); +CREATE INDEX IF NOT EXISTS idx_voice_ai_sessions_last_user_utterance_at ON voice_ai_sessions(last_user_utterance_at); +CREATE INDEX IF NOT EXISTS idx_voice_ai_sessions_last_ai_reply_at ON voice_ai_sessions(last_ai_reply_at); +CREATE INDEX IF NOT EXISTS idx_voice_ai_sessions_started_at ON voice_ai_sessions(started_at); +CREATE INDEX IF NOT EXISTS idx_voice_ai_sessions_updated_at ON voice_ai_sessions(updated_at); +CREATE INDEX IF NOT EXISTS idx_voice_ai_sessions_ended_at ON voice_ai_sessions(ended_at); + +CREATE TABLE IF NOT EXISTS voice_transcript_segments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + segment_id TEXT NOT NULL UNIQUE, + session_id TEXT NOT NULL, + call_id TEXT NOT NULL, + interaction_id TEXT, + speaker TEXT NOT NULL, + source_type TEXT NOT NULL, + sequence_no INTEGER NOT NULL, + text TEXT NOT NULL, + confidence REAL, + is_final INTEGER NOT NULL DEFAULT 1, + barge_in_interrupted INTEGER NOT NULL DEFAULT 0, + payload_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_voice_transcript_segments_segment_id ON voice_transcript_segments(segment_id); +CREATE INDEX IF NOT EXISTS idx_voice_transcript_segments_session_id ON voice_transcript_segments(session_id); +CREATE INDEX IF NOT EXISTS idx_voice_transcript_segments_call_id ON voice_transcript_segments(call_id); +CREATE INDEX IF NOT EXISTS idx_voice_transcript_segments_interaction_id ON voice_transcript_segments(interaction_id); +CREATE INDEX IF NOT EXISTS idx_voice_transcript_segments_speaker ON voice_transcript_segments(speaker); +CREATE INDEX IF NOT EXISTS idx_voice_transcript_segments_source_type ON voice_transcript_segments(source_type); +CREATE INDEX IF NOT EXISTS idx_voice_transcript_segments_sequence_no ON voice_transcript_segments(sequence_no); +CREATE INDEX IF NOT EXISTS idx_voice_transcript_segments_is_final ON voice_transcript_segments(is_final); +CREATE INDEX IF NOT EXISTS idx_voice_transcript_segments_barge_in_interrupted ON voice_transcript_segments(barge_in_interrupted); +CREATE INDEX IF NOT EXISTS idx_voice_transcript_segments_created_at ON voice_transcript_segments(created_at); diff --git a/migrations/sql/0015_voice_ai_media_postgres.sql b/migrations/sql/0015_voice_ai_media_postgres.sql new file mode 100644 index 0000000..110fce6 --- /dev/null +++ b/migrations/sql/0015_voice_ai_media_postgres.sql @@ -0,0 +1,11 @@ +ALTER TABLE voice_ai_sessions ADD COLUMN IF NOT EXISTS media_uuid TEXT; +ALTER TABLE voice_ai_sessions ADD COLUMN IF NOT EXISTS media_status TEXT; +ALTER TABLE voice_ai_sessions ADD COLUMN IF NOT EXISTS media_connected_at TEXT; +ALTER TABLE voice_ai_sessions ADD COLUMN IF NOT EXISTS media_ended_at TEXT; +ALTER TABLE voice_ai_sessions ADD COLUMN IF NOT EXISTS last_media_frame_at TEXT; + +CREATE INDEX IF NOT EXISTS idx_voice_ai_sessions_media_uuid ON voice_ai_sessions(media_uuid); +CREATE INDEX IF NOT EXISTS idx_voice_ai_sessions_media_status ON voice_ai_sessions(media_status); +CREATE INDEX IF NOT EXISTS idx_voice_ai_sessions_media_connected_at ON voice_ai_sessions(media_connected_at); +CREATE INDEX IF NOT EXISTS idx_voice_ai_sessions_media_ended_at ON voice_ai_sessions(media_ended_at); +CREATE INDEX IF NOT EXISTS idx_voice_ai_sessions_last_media_frame_at ON voice_ai_sessions(last_media_frame_at); diff --git a/migrations/sql/0015_voice_ai_media_sqlite.sql b/migrations/sql/0015_voice_ai_media_sqlite.sql new file mode 100644 index 0000000..665a911 --- /dev/null +++ b/migrations/sql/0015_voice_ai_media_sqlite.sql @@ -0,0 +1,11 @@ +ALTER TABLE voice_ai_sessions ADD COLUMN media_uuid TEXT; +ALTER TABLE voice_ai_sessions ADD COLUMN media_status TEXT; +ALTER TABLE voice_ai_sessions ADD COLUMN media_connected_at TEXT; +ALTER TABLE voice_ai_sessions ADD COLUMN media_ended_at TEXT; +ALTER TABLE voice_ai_sessions ADD COLUMN last_media_frame_at TEXT; + +CREATE INDEX IF NOT EXISTS idx_voice_ai_sessions_media_uuid ON voice_ai_sessions(media_uuid); +CREATE INDEX IF NOT EXISTS idx_voice_ai_sessions_media_status ON voice_ai_sessions(media_status); +CREATE INDEX IF NOT EXISTS idx_voice_ai_sessions_media_connected_at ON voice_ai_sessions(media_connected_at); +CREATE INDEX IF NOT EXISTS idx_voice_ai_sessions_media_ended_at ON voice_ai_sessions(media_ended_at); +CREATE INDEX IF NOT EXISTS idx_voice_ai_sessions_last_media_frame_at ON voice_ai_sessions(last_media_frame_at); diff --git a/migrations/sql/0016_ivr_queue_code_postgres.sql b/migrations/sql/0016_ivr_queue_code_postgres.sql new file mode 100644 index 0000000..d80a970 --- /dev/null +++ b/migrations/sql/0016_ivr_queue_code_postgres.sql @@ -0,0 +1,4 @@ +ALTER TABLE ivr_sessions +ADD COLUMN IF NOT EXISTS resolved_queue_code TEXT NULL; + +CREATE INDEX IF NOT EXISTS ix_ivr_sessions_resolved_queue_code ON ivr_sessions (resolved_queue_code); diff --git a/migrations/sql/0016_ivr_queue_code_sqlite.sql b/migrations/sql/0016_ivr_queue_code_sqlite.sql new file mode 100644 index 0000000..902d73f --- /dev/null +++ b/migrations/sql/0016_ivr_queue_code_sqlite.sql @@ -0,0 +1,3 @@ +ALTER TABLE ivr_sessions ADD COLUMN resolved_queue_code TEXT NULL; + +CREATE INDEX IF NOT EXISTS ix_ivr_sessions_resolved_queue_code ON ivr_sessions (resolved_queue_code); diff --git a/migrations/sql/0017_kb_localization_postgres.sql b/migrations/sql/0017_kb_localization_postgres.sql new file mode 100644 index 0000000..cd9939c --- /dev/null +++ b/migrations/sql/0017_kb_localization_postgres.sql @@ -0,0 +1,17 @@ +ALTER TABLE kb_articles +ADD COLUMN IF NOT EXISTS article_group_id TEXT NULL; + +ALTER TABLE kb_articles +ADD COLUMN IF NOT EXISTS language TEXT NULL DEFAULT 'ru'; + +UPDATE kb_articles +SET language = 'ru' +WHERE language IS NULL OR BTRIM(language) = ''; + +UPDATE kb_articles +SET article_group_id = article_id +WHERE article_group_id IS NULL OR BTRIM(article_group_id) = ''; + +CREATE INDEX IF NOT EXISTS idx_kb_articles_article_group_id ON kb_articles (article_group_id); + +CREATE INDEX IF NOT EXISTS idx_kb_articles_language ON kb_articles (language); diff --git a/migrations/sql/0017_kb_localization_sqlite.sql b/migrations/sql/0017_kb_localization_sqlite.sql new file mode 100644 index 0000000..2c834fe --- /dev/null +++ b/migrations/sql/0017_kb_localization_sqlite.sql @@ -0,0 +1,15 @@ +ALTER TABLE kb_articles ADD COLUMN article_group_id TEXT NULL; + +ALTER TABLE kb_articles ADD COLUMN language TEXT NULL DEFAULT 'ru'; + +UPDATE kb_articles +SET language = 'ru' +WHERE language IS NULL OR TRIM(language) = ''; + +UPDATE kb_articles +SET article_group_id = article_id +WHERE article_group_id IS NULL OR TRIM(article_group_id) = ''; + +CREATE INDEX IF NOT EXISTS idx_kb_articles_article_group_id ON kb_articles (article_group_id); + +CREATE INDEX IF NOT EXISTS idx_kb_articles_language ON kb_articles (language); diff --git a/migrations/sql/0018_postgres_readiness_postgres.sql b/migrations/sql/0018_postgres_readiness_postgres.sql new file mode 100644 index 0000000..5059096 --- /dev/null +++ b/migrations/sql/0018_postgres_readiness_postgres.sql @@ -0,0 +1,150 @@ +ALTER TABLE telegram_messages ADD COLUMN IF NOT EXISTS delivery_attempts INTEGER NOT NULL DEFAULT 0; +ALTER TABLE telegram_messages ADD COLUMN IF NOT EXISTS next_delivery_attempt_at TEXT; +ALTER TABLE telegram_messages ADD COLUMN IF NOT EXISTS delivery_locked_until TEXT; +ALTER TABLE telegram_messages ADD COLUMN IF NOT EXISTS last_delivery_error TEXT; + +CREATE INDEX IF NOT EXISTS idx_telegram_messages_next_delivery_attempt_at + ON telegram_messages(next_delivery_attempt_at); +CREATE INDEX IF NOT EXISTS idx_telegram_messages_delivery_locked_until + ON telegram_messages(delivery_locked_until); + +CREATE TABLE IF NOT EXISTS whatsapp_threads ( + id SERIAL PRIMARY KEY, + thread_id TEXT NOT NULL UNIQUE, + chat_id TEXT NOT NULL UNIQUE, + interaction_id TEXT NOT NULL, + whatsapp_user_id TEXT, + phone_number TEXT, + display_name TEXT, + queue_id TEXT, + is_group BOOLEAN NOT NULL DEFAULT FALSE, + status TEXT NOT NULL, + claimed_by_user TEXT, + claimed_at TEXT, + ai_session_id TEXT, + ai_state TEXT, + ai_handoff_reason TEXT, + ai_last_model_at TEXT, + last_message_at TEXT NOT NULL, + last_message_preview TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +ALTER TABLE whatsapp_threads ADD COLUMN IF NOT EXISTS phone_number TEXT; +ALTER TABLE whatsapp_threads ADD COLUMN IF NOT EXISTS is_group BOOLEAN NOT NULL DEFAULT FALSE; +ALTER TABLE whatsapp_threads ADD COLUMN IF NOT EXISTS ai_session_id TEXT; +ALTER TABLE whatsapp_threads ADD COLUMN IF NOT EXISTS ai_state TEXT; +ALTER TABLE whatsapp_threads ADD COLUMN IF NOT EXISTS ai_handoff_reason TEXT; +ALTER TABLE whatsapp_threads ADD COLUMN IF NOT EXISTS ai_last_model_at TEXT; + +CREATE INDEX IF NOT EXISTS idx_whatsapp_threads_thread_id ON whatsapp_threads(thread_id); +CREATE INDEX IF NOT EXISTS idx_whatsapp_threads_chat_id ON whatsapp_threads(chat_id); +CREATE INDEX IF NOT EXISTS idx_whatsapp_threads_interaction_id ON whatsapp_threads(interaction_id); +CREATE INDEX IF NOT EXISTS idx_whatsapp_threads_whatsapp_user_id ON whatsapp_threads(whatsapp_user_id); +CREATE INDEX IF NOT EXISTS idx_whatsapp_threads_phone_number ON whatsapp_threads(phone_number); +CREATE INDEX IF NOT EXISTS idx_whatsapp_threads_display_name ON whatsapp_threads(display_name); +CREATE INDEX IF NOT EXISTS idx_whatsapp_threads_queue_id ON whatsapp_threads(queue_id); +CREATE INDEX IF NOT EXISTS idx_whatsapp_threads_is_group ON whatsapp_threads(is_group); +CREATE INDEX IF NOT EXISTS idx_whatsapp_threads_status ON whatsapp_threads(status); +CREATE INDEX IF NOT EXISTS idx_whatsapp_threads_claimed_by_user ON whatsapp_threads(claimed_by_user); +CREATE INDEX IF NOT EXISTS idx_whatsapp_threads_claimed_at ON whatsapp_threads(claimed_at); +CREATE INDEX IF NOT EXISTS idx_whatsapp_threads_ai_session_id ON whatsapp_threads(ai_session_id); +CREATE INDEX IF NOT EXISTS idx_whatsapp_threads_ai_state ON whatsapp_threads(ai_state); +CREATE INDEX IF NOT EXISTS idx_whatsapp_threads_ai_last_model_at ON whatsapp_threads(ai_last_model_at); +CREATE INDEX IF NOT EXISTS idx_whatsapp_threads_last_message_at ON whatsapp_threads(last_message_at); +CREATE INDEX IF NOT EXISTS idx_whatsapp_threads_created_at ON whatsapp_threads(created_at); +CREATE INDEX IF NOT EXISTS idx_whatsapp_threads_updated_at ON whatsapp_threads(updated_at); + +CREATE TABLE IF NOT EXISTS whatsapp_messages ( + id SERIAL PRIMARY KEY, + message_id TEXT NOT NULL UNIQUE, + thread_id TEXT, + interaction_id TEXT, + chat_id TEXT NOT NULL, + text TEXT NOT NULL, + customer_id TEXT, + direction TEXT NOT NULL DEFAULT 'inbound', + whatsapp_message_id_external TEXT, + operator_user TEXT, + author_type TEXT NOT NULL DEFAULT 'customer', + author_id TEXT, + delivery_status TEXT, + delivery_attempts INTEGER NOT NULL DEFAULT 0, + next_delivery_attempt_at TEXT, + delivery_locked_until TEXT, + last_delivery_error TEXT, + payload_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL +); + +ALTER TABLE whatsapp_messages ADD COLUMN IF NOT EXISTS thread_id TEXT; +ALTER TABLE whatsapp_messages ADD COLUMN IF NOT EXISTS interaction_id TEXT; +ALTER TABLE whatsapp_messages ADD COLUMN IF NOT EXISTS customer_id TEXT; +ALTER TABLE whatsapp_messages ADD COLUMN IF NOT EXISTS direction TEXT NOT NULL DEFAULT 'inbound'; +ALTER TABLE whatsapp_messages ADD COLUMN IF NOT EXISTS whatsapp_message_id_external TEXT; +ALTER TABLE whatsapp_messages ADD COLUMN IF NOT EXISTS operator_user TEXT; +ALTER TABLE whatsapp_messages ADD COLUMN IF NOT EXISTS delivery_status TEXT; +ALTER TABLE whatsapp_messages ADD COLUMN IF NOT EXISTS delivery_attempts INTEGER NOT NULL DEFAULT 0; +ALTER TABLE whatsapp_messages ADD COLUMN IF NOT EXISTS next_delivery_attempt_at TEXT; +ALTER TABLE whatsapp_messages ADD COLUMN IF NOT EXISTS delivery_locked_until TEXT; +ALTER TABLE whatsapp_messages ADD COLUMN IF NOT EXISTS last_delivery_error TEXT; +ALTER TABLE whatsapp_messages ADD COLUMN IF NOT EXISTS author_type TEXT NOT NULL DEFAULT 'customer'; +ALTER TABLE whatsapp_messages ADD COLUMN IF NOT EXISTS author_id TEXT; + +UPDATE whatsapp_messages +SET author_type = CASE + WHEN direction = 'outbound' THEN 'human' + WHEN direction = 'system' THEN 'system' + ELSE 'customer' +END +WHERE author_type IS NULL OR BTRIM(author_type) = ''; + +UPDATE whatsapp_messages +SET author_id = operator_user +WHERE operator_user IS NOT NULL + AND ( + direction = 'outbound' + OR direction IS NULL + OR BTRIM(direction) = '' + ) + AND (author_id IS NULL OR BTRIM(author_id) = ''); + +CREATE UNIQUE INDEX IF NOT EXISTS ix_whatsapp_messages_chat_external_unique + ON whatsapp_messages(chat_id, whatsapp_message_id_external); +CREATE INDEX IF NOT EXISTS idx_whatsapp_messages_message_id ON whatsapp_messages(message_id); +CREATE INDEX IF NOT EXISTS idx_whatsapp_messages_thread_id ON whatsapp_messages(thread_id); +CREATE INDEX IF NOT EXISTS idx_whatsapp_messages_interaction_id ON whatsapp_messages(interaction_id); +CREATE INDEX IF NOT EXISTS idx_whatsapp_messages_chat_id ON whatsapp_messages(chat_id); +CREATE INDEX IF NOT EXISTS idx_whatsapp_messages_customer_id ON whatsapp_messages(customer_id); +CREATE INDEX IF NOT EXISTS idx_whatsapp_messages_direction ON whatsapp_messages(direction); +CREATE INDEX IF NOT EXISTS idx_whatsapp_messages_whatsapp_message_id_external + ON whatsapp_messages(whatsapp_message_id_external); +CREATE INDEX IF NOT EXISTS idx_whatsapp_messages_operator_user ON whatsapp_messages(operator_user); +CREATE INDEX IF NOT EXISTS idx_whatsapp_messages_author_type ON whatsapp_messages(author_type); +CREATE INDEX IF NOT EXISTS idx_whatsapp_messages_author_id ON whatsapp_messages(author_id); +CREATE INDEX IF NOT EXISTS idx_whatsapp_messages_delivery_status ON whatsapp_messages(delivery_status); +CREATE INDEX IF NOT EXISTS idx_whatsapp_messages_next_delivery_attempt_at + ON whatsapp_messages(next_delivery_attempt_at); +CREATE INDEX IF NOT EXISTS idx_whatsapp_messages_delivery_locked_until + ON whatsapp_messages(delivery_locked_until); +CREATE INDEX IF NOT EXISTS idx_whatsapp_messages_created_at ON whatsapp_messages(created_at); + +CREATE INDEX IF NOT EXISTS idx_event_outbox_status_available_at_id + ON event_outbox(status, available_at, id); +CREATE INDEX IF NOT EXISTS idx_ai_jobs_thread_status_id + ON ai_jobs(thread_id, status, id DESC); +CREATE INDEX IF NOT EXISTS idx_ai_jobs_thread_trigger_id + ON ai_jobs(thread_id, trigger_message_id, id DESC); +CREATE INDEX IF NOT EXISTS idx_telegram_messages_thread_direction_author_created_id + ON telegram_messages(thread_id, direction, author_type, created_at DESC, id DESC); +CREATE INDEX IF NOT EXISTS idx_whatsapp_messages_thread_direction_author_created_id + ON whatsapp_messages(thread_id, direction, author_type, created_at DESC, id DESC); +CREATE INDEX IF NOT EXISTS idx_voice_transcript_segments_session_speaker_final_id + ON voice_transcript_segments(session_id, speaker, is_final, id DESC); +CREATE UNIQUE INDEX IF NOT EXISTS uq_voice_transcript_segments_session_sequence + ON voice_transcript_segments(session_id, sequence_no); +CREATE INDEX IF NOT EXISTS idx_asterisk_event_log_name_call_status_id + ON asterisk_event_log(ami_event_name, call_id, forward_status, id DESC); +CREATE INDEX IF NOT EXISTS idx_reporting_events_queue_channel_created_at + ON reporting_events(queue_id, channel, created_at); diff --git a/migrations/sql/0018_postgres_readiness_sqlite.sql b/migrations/sql/0018_postgres_readiness_sqlite.sql new file mode 100644 index 0000000..08ebb25 --- /dev/null +++ b/migrations/sql/0018_postgres_readiness_sqlite.sql @@ -0,0 +1,129 @@ +ALTER TABLE telegram_messages ADD COLUMN delivery_attempts INTEGER NOT NULL DEFAULT 0; +ALTER TABLE telegram_messages ADD COLUMN next_delivery_attempt_at TEXT; +ALTER TABLE telegram_messages ADD COLUMN delivery_locked_until TEXT; +ALTER TABLE telegram_messages ADD COLUMN last_delivery_error TEXT; + +CREATE INDEX IF NOT EXISTS idx_telegram_messages_next_delivery_attempt_at + ON telegram_messages(next_delivery_attempt_at); +CREATE INDEX IF NOT EXISTS idx_telegram_messages_delivery_locked_until + ON telegram_messages(delivery_locked_until); + +CREATE TABLE IF NOT EXISTS whatsapp_threads ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + thread_id TEXT NOT NULL UNIQUE, + chat_id TEXT NOT NULL UNIQUE, + interaction_id TEXT NOT NULL, + whatsapp_user_id TEXT, + phone_number TEXT, + display_name TEXT, + queue_id TEXT, + is_group INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL, + claimed_by_user TEXT, + claimed_at TEXT, + ai_session_id TEXT, + ai_state TEXT, + ai_handoff_reason TEXT, + ai_last_model_at TEXT, + last_message_at TEXT NOT NULL, + last_message_preview TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_whatsapp_threads_thread_id ON whatsapp_threads(thread_id); +CREATE INDEX IF NOT EXISTS idx_whatsapp_threads_chat_id ON whatsapp_threads(chat_id); +CREATE INDEX IF NOT EXISTS idx_whatsapp_threads_interaction_id ON whatsapp_threads(interaction_id); +CREATE INDEX IF NOT EXISTS idx_whatsapp_threads_whatsapp_user_id ON whatsapp_threads(whatsapp_user_id); +CREATE INDEX IF NOT EXISTS idx_whatsapp_threads_phone_number ON whatsapp_threads(phone_number); +CREATE INDEX IF NOT EXISTS idx_whatsapp_threads_display_name ON whatsapp_threads(display_name); +CREATE INDEX IF NOT EXISTS idx_whatsapp_threads_queue_id ON whatsapp_threads(queue_id); +CREATE INDEX IF NOT EXISTS idx_whatsapp_threads_is_group ON whatsapp_threads(is_group); +CREATE INDEX IF NOT EXISTS idx_whatsapp_threads_status ON whatsapp_threads(status); +CREATE INDEX IF NOT EXISTS idx_whatsapp_threads_claimed_by_user ON whatsapp_threads(claimed_by_user); +CREATE INDEX IF NOT EXISTS idx_whatsapp_threads_claimed_at ON whatsapp_threads(claimed_at); +CREATE INDEX IF NOT EXISTS idx_whatsapp_threads_ai_session_id ON whatsapp_threads(ai_session_id); +CREATE INDEX IF NOT EXISTS idx_whatsapp_threads_ai_state ON whatsapp_threads(ai_state); +CREATE INDEX IF NOT EXISTS idx_whatsapp_threads_ai_last_model_at ON whatsapp_threads(ai_last_model_at); +CREATE INDEX IF NOT EXISTS idx_whatsapp_threads_last_message_at ON whatsapp_threads(last_message_at); +CREATE INDEX IF NOT EXISTS idx_whatsapp_threads_created_at ON whatsapp_threads(created_at); +CREATE INDEX IF NOT EXISTS idx_whatsapp_threads_updated_at ON whatsapp_threads(updated_at); + +CREATE TABLE IF NOT EXISTS whatsapp_messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + message_id TEXT NOT NULL UNIQUE, + thread_id TEXT, + interaction_id TEXT, + chat_id TEXT NOT NULL, + text TEXT NOT NULL, + customer_id TEXT, + direction TEXT NOT NULL DEFAULT 'inbound', + whatsapp_message_id_external TEXT, + operator_user TEXT, + author_type TEXT NOT NULL DEFAULT 'customer', + author_id TEXT, + delivery_status TEXT, + delivery_attempts INTEGER NOT NULL DEFAULT 0, + next_delivery_attempt_at TEXT, + delivery_locked_until TEXT, + last_delivery_error TEXT, + payload_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL +); + +UPDATE whatsapp_messages +SET author_type = CASE + WHEN direction = 'outbound' THEN 'human' + WHEN direction = 'system' THEN 'system' + ELSE 'customer' +END +WHERE author_type IS NULL OR TRIM(author_type) = ''; + +UPDATE whatsapp_messages +SET author_id = operator_user +WHERE operator_user IS NOT NULL + AND ( + direction = 'outbound' + OR direction IS NULL + OR TRIM(direction) = '' + ) + AND (author_id IS NULL OR TRIM(author_id) = ''); + +CREATE UNIQUE INDEX IF NOT EXISTS ix_whatsapp_messages_chat_external_unique + ON whatsapp_messages(chat_id, whatsapp_message_id_external); +CREATE INDEX IF NOT EXISTS idx_whatsapp_messages_message_id ON whatsapp_messages(message_id); +CREATE INDEX IF NOT EXISTS idx_whatsapp_messages_thread_id ON whatsapp_messages(thread_id); +CREATE INDEX IF NOT EXISTS idx_whatsapp_messages_interaction_id ON whatsapp_messages(interaction_id); +CREATE INDEX IF NOT EXISTS idx_whatsapp_messages_chat_id ON whatsapp_messages(chat_id); +CREATE INDEX IF NOT EXISTS idx_whatsapp_messages_customer_id ON whatsapp_messages(customer_id); +CREATE INDEX IF NOT EXISTS idx_whatsapp_messages_direction ON whatsapp_messages(direction); +CREATE INDEX IF NOT EXISTS idx_whatsapp_messages_whatsapp_message_id_external + ON whatsapp_messages(whatsapp_message_id_external); +CREATE INDEX IF NOT EXISTS idx_whatsapp_messages_operator_user ON whatsapp_messages(operator_user); +CREATE INDEX IF NOT EXISTS idx_whatsapp_messages_author_type ON whatsapp_messages(author_type); +CREATE INDEX IF NOT EXISTS idx_whatsapp_messages_author_id ON whatsapp_messages(author_id); +CREATE INDEX IF NOT EXISTS idx_whatsapp_messages_delivery_status ON whatsapp_messages(delivery_status); +CREATE INDEX IF NOT EXISTS idx_whatsapp_messages_next_delivery_attempt_at + ON whatsapp_messages(next_delivery_attempt_at); +CREATE INDEX IF NOT EXISTS idx_whatsapp_messages_delivery_locked_until + ON whatsapp_messages(delivery_locked_until); +CREATE INDEX IF NOT EXISTS idx_whatsapp_messages_created_at ON whatsapp_messages(created_at); + +CREATE INDEX IF NOT EXISTS idx_event_outbox_status_available_at_id + ON event_outbox(status, available_at, id); +CREATE INDEX IF NOT EXISTS idx_ai_jobs_thread_status_id + ON ai_jobs(thread_id, status, id); +CREATE INDEX IF NOT EXISTS idx_ai_jobs_thread_trigger_id + ON ai_jobs(thread_id, trigger_message_id, id); +CREATE INDEX IF NOT EXISTS idx_telegram_messages_thread_direction_author_created_id + ON telegram_messages(thread_id, direction, author_type, created_at, id); +CREATE INDEX IF NOT EXISTS idx_whatsapp_messages_thread_direction_author_created_id + ON whatsapp_messages(thread_id, direction, author_type, created_at, id); +CREATE INDEX IF NOT EXISTS idx_voice_transcript_segments_session_speaker_final_id + ON voice_transcript_segments(session_id, speaker, is_final, id); +CREATE UNIQUE INDEX IF NOT EXISTS uq_voice_transcript_segments_session_sequence + ON voice_transcript_segments(session_id, sequence_no); +CREATE INDEX IF NOT EXISTS idx_asterisk_event_log_name_call_status_id + ON asterisk_event_log(ami_event_name, call_id, forward_status, id); +CREATE INDEX IF NOT EXISTS idx_reporting_events_queue_channel_created_at + ON reporting_events(queue_id, channel, created_at); diff --git a/migrations/sql/0019_reporting_kpi_facts_postgres.sql b/migrations/sql/0019_reporting_kpi_facts_postgres.sql new file mode 100644 index 0000000..da20cbc --- /dev/null +++ b/migrations/sql/0019_reporting_kpi_facts_postgres.sql @@ -0,0 +1,24 @@ +CREATE TABLE IF NOT EXISTS reporting_interaction_facts ( + id SERIAL PRIMARY KEY, + interaction_id VARCHAR(64) NOT NULL, + channel VARCHAR(32) NULL, + queue_id VARCHAR(64) NULL, + agent_id VARCHAR(128) NULL, + status VARCHAR(32) NULL, + created_at VARCHAR(64) NULL, + updated_at VARCHAR(64) NOT NULL, + closed_at VARCHAR(64) NULL, + answered BOOLEAN NULL, + abandoned BOOLEAN NULL, + wait_seconds INTEGER NULL, + handle_seconds INTEGER NULL, + resolved_first_contact BOOLEAN NULL, + source VARCHAR(64) NOT NULL DEFAULT 'unknown', + CONSTRAINT uq_reporting_interaction_facts_interaction_id UNIQUE (interaction_id) +); + +CREATE INDEX IF NOT EXISTS idx_reporting_interaction_facts_created_queue_channel +ON reporting_interaction_facts (created_at, queue_id, channel); + +CREATE INDEX IF NOT EXISTS idx_reporting_interaction_facts_status_agent +ON reporting_interaction_facts (status, agent_id); diff --git a/migrations/sql/0019_reporting_kpi_facts_sqlite.sql b/migrations/sql/0019_reporting_kpi_facts_sqlite.sql new file mode 100644 index 0000000..2c9c227 --- /dev/null +++ b/migrations/sql/0019_reporting_kpi_facts_sqlite.sql @@ -0,0 +1,24 @@ +CREATE TABLE IF NOT EXISTS reporting_interaction_facts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + interaction_id TEXT NOT NULL, + channel TEXT NULL, + queue_id TEXT NULL, + agent_id TEXT NULL, + status TEXT NULL, + created_at TEXT NULL, + updated_at TEXT NOT NULL, + closed_at TEXT NULL, + answered INTEGER NULL, + abandoned INTEGER NULL, + wait_seconds INTEGER NULL, + handle_seconds INTEGER NULL, + resolved_first_contact INTEGER NULL, + source TEXT NOT NULL DEFAULT 'unknown', + CONSTRAINT uq_reporting_interaction_facts_interaction_id UNIQUE (interaction_id) +); + +CREATE INDEX IF NOT EXISTS idx_reporting_interaction_facts_created_queue_channel +ON reporting_interaction_facts (created_at, queue_id, channel); + +CREATE INDEX IF NOT EXISTS idx_reporting_interaction_facts_status_agent +ON reporting_interaction_facts (status, agent_id); diff --git a/migrations/sql/0020_reporting_saved_views_postgres.sql b/migrations/sql/0020_reporting_saved_views_postgres.sql new file mode 100644 index 0000000..79a3302 --- /dev/null +++ b/migrations/sql/0020_reporting_saved_views_postgres.sql @@ -0,0 +1,14 @@ +CREATE TABLE IF NOT EXISTS reporting_saved_views ( + id SERIAL PRIMARY KEY, + owner_user VARCHAR(128) NOT NULL, + owner_role VARCHAR(32) NULL, + view_id VARCHAR(64) NOT NULL, + name VARCHAR(160) NOT NULL, + snapshot_json TEXT NOT NULL DEFAULT '{}', + created_at VARCHAR(64) NOT NULL, + updated_at VARCHAR(64) NOT NULL, + CONSTRAINT uq_reporting_saved_views_owner_view UNIQUE (owner_user, view_id) +); + +CREATE INDEX IF NOT EXISTS idx_reporting_saved_views_owner_updated +ON reporting_saved_views (owner_user, updated_at); diff --git a/migrations/sql/0020_reporting_saved_views_sqlite.sql b/migrations/sql/0020_reporting_saved_views_sqlite.sql new file mode 100644 index 0000000..a0c52ce --- /dev/null +++ b/migrations/sql/0020_reporting_saved_views_sqlite.sql @@ -0,0 +1,14 @@ +CREATE TABLE IF NOT EXISTS reporting_saved_views ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + owner_user TEXT NOT NULL, + owner_role TEXT NULL, + view_id TEXT NOT NULL, + name TEXT NOT NULL, + snapshot_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + CONSTRAINT uq_reporting_saved_views_owner_view UNIQUE (owner_user, view_id) +); + +CREATE INDEX IF NOT EXISTS idx_reporting_saved_views_owner_updated +ON reporting_saved_views (owner_user, updated_at); diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..9a7ee09 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,6 @@ +[tool.pytest.ini_options] +addopts = "-q" +testpaths = ["tests"] + +[tool.black] +line-length = 100 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..62abde2 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,10 @@ +fastapi==0.116.1 +uvicorn==0.35.0 +pydantic==2.11.7 +httpx==0.28.1 +pytest==8.4.1 +SQLAlchemy==2.0.38 +psycopg[binary]==3.2.6 +pika==1.3.2 +paramiko==3.5.0 +python-multipart==0.0.20 diff --git a/scripts/__init__.py b/scripts/__init__.py new file mode 100644 index 0000000..5498dbf --- /dev/null +++ b/scripts/__init__.py @@ -0,0 +1 @@ +# Script helpers package marker for tests. diff --git a/scripts/asterisk_ivr_reverse_tunnel.py b/scripts/asterisk_ivr_reverse_tunnel.py new file mode 100644 index 0000000..38f6a3b --- /dev/null +++ b/scripts/asterisk_ivr_reverse_tunnel.py @@ -0,0 +1,99 @@ +from __future__ import annotations + +import argparse +import select +import socket +import sys +import threading +import time + +import paramiko + + +def _bridge_channels(chan: paramiko.Channel, upstream: socket.socket) -> None: + try: + while True: + readers, _, _ = select.select([chan, upstream], [], [], 1.0) + if chan in readers: + data = chan.recv(4096) + if not data: + break + upstream.sendall(data) + if upstream in readers: + data = upstream.recv(4096) + if not data: + break + chan.sendall(data) + finally: + try: + chan.close() + except Exception: + pass + try: + upstream.close() + except Exception: + pass + + +def _accept_channels(transport: paramiko.Transport, local_host: str, local_port: int) -> None: + while transport.is_active(): + chan = transport.accept(1000) + if chan is None: + continue + try: + upstream = socket.create_connection((local_host, local_port), timeout=10) + except OSError: + chan.close() + continue + threading.Thread(target=_bridge_channels, args=(chan, upstream), daemon=True).start() + + +def main() -> int: + parser = argparse.ArgumentParser(description="Maintain reverse SSH tunnel for Asterisk IVR FastAGI.") + parser.add_argument("--ssh-host", required=True) + parser.add_argument("--ssh-port", type=int, default=22) + parser.add_argument("--ssh-user", required=True) + parser.add_argument("--ssh-password", required=True) + parser.add_argument("--remote-bind-host", default="127.0.0.1") + parser.add_argument("--remote-port", type=int, default=4573) + parser.add_argument("--local-host", default="127.0.0.1") + parser.add_argument("--local-port", type=int, default=4573) + args = parser.parse_args() + + while True: + client = paramiko.SSHClient() + client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + try: + client.connect( + args.ssh_host, + port=args.ssh_port, + username=args.ssh_user, + password=args.ssh_password, + timeout=15, + banner_timeout=15, + auth_timeout=15, + ) + transport = client.get_transport() + if transport is None: + raise RuntimeError("SSH transport is unavailable") + transport.request_port_forward(args.remote_bind_host, args.remote_port) + print( + f"Reverse tunnel active: {args.remote_bind_host}:{args.remote_port} -> " + f"{args.local_host}:{args.local_port}", + flush=True, + ) + _accept_channels(transport, args.local_host, args.local_port) + except KeyboardInterrupt: + return 0 + except Exception as exc: + print(f"Reverse tunnel error: {exc}", file=sys.stderr, flush=True) + time.sleep(3) + finally: + try: + client.close() + except Exception: + pass + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/asterisk_lab_smoke.py b/scripts/asterisk_lab_smoke.py new file mode 100644 index 0000000..6fb4c63 --- /dev/null +++ b/scripts/asterisk_lab_smoke.py @@ -0,0 +1,139 @@ +from __future__ import annotations + +import argparse +import json +import os +from pathlib import Path +import sys +import time + +import httpx +from sqlalchemy import create_engine, text + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from services.shared.security import issue_app_token + + +def _load_env_file(path: str) -> None: + env_path = Path(path).expanduser().resolve() + if not env_path.exists() or not env_path.is_file(): + return + for line in env_path.read_text(encoding="utf-8").splitlines(): + stripped = line.strip() + if not stripped or stripped.startswith("#") or "=" not in stripped: + continue + key, value = stripped.split("=", 1) + key = key.strip() + if key and key not in os.environ: + os.environ[key] = value.strip() + + +def _bool_env(name: str, default: bool) -> bool: + raw = os.getenv(name) + if raw is None: + return default + return raw.strip().lower() in {"1", "true", "yes", "on"} + + +def _ops_headers() -> dict[str, str]: + if _bool_env("ALLOW_LEGACY_HEADER_AUTH", True): + return {"X-User": "admin", "X-Role": "admin"} + token = issue_app_token( + subject="ops:track9-smoke", + username="track9-smoke", + role="admin", + auth_source="service", + provider="track9-script", + ttl_seconds=300, + ) + return {"Authorization": f"Bearer {token}"} + + +def _get_json(url: str) -> dict: + with httpx.Client(timeout=10, trust_env=False) as client: + response = client.get(url, headers=_ops_headers()) + response.raise_for_status() + return response.json() + + +def _wait_for_ami_connected(base_url: str, retries: int = 8, delay_seconds: float = 1.0) -> dict: + last_status: dict = {} + for _ in range(retries): + status = _get_json(f"{base_url.rstrip('/')}/proxy/asterisk-bridge/asterisk/status") + last_status = status + if status.get("ami_connected"): + return status + time.sleep(delay_seconds) + return last_status + + +def _scalar(conn, sql: str) -> int: + value = conn.execute(text(sql)).scalar() + return int(value or 0) + + +def main() -> int: + parser = argparse.ArgumentParser(description="Track 9 Asterisk bridge smoke check") + parser.add_argument("--base-url", default="http://127.0.0.1:8080") + parser.add_argument("--env-file", default=".env.production") + parser.add_argument("--database-url", required=True) + parser.add_argument("--require-recording", action="store_true") + args = parser.parse_args() + + _load_env_file(args.env_file) + + health = _get_json(f"{args.base_url.rstrip('/')}/proxy/asterisk-bridge/health") + status = _wait_for_ami_connected(args.base_url) + + failures: list[str] = [] + if health.get("status") != "ok": + failures.append("asterisk-bridge health is not ok") + if not status.get("ami_connected"): + failures.append("AMI is not connected") + if not status.get("queue_codes_loaded"): + failures.append("Queue mapping is empty") + + engine = create_engine(args.database_url, future=True) + with engine.begin() as conn: + started = _scalar( + conn, + "SELECT COUNT(*) FROM asterisk_event_log WHERE ami_event_name = 'MVPCCCallStarted' AND forward_status = 'forwarded'", + ) + ended = _scalar( + conn, + "SELECT COUNT(*) FROM asterisk_event_log WHERE ami_event_name = 'MVPCCCallEnded' AND forward_status = 'forwarded'", + ) + links = _scalar(conn, "SELECT COUNT(*) FROM asterisk_call_links") + recordings = _scalar( + conn, + "SELECT COUNT(*) FROM asterisk_event_log WHERE ami_event_name = 'MVPCCRecordingReady' AND recording_id IS NOT NULL", + ) + + if started < 1: + failures.append("No forwarded MVPCCCallStarted events") + if ended < 1: + failures.append("No forwarded MVPCCCallEnded events") + if links < 1: + failures.append("No asterisk_call_links rows") + if args.require_recording and recordings < 1: + failures.append("No uploaded recording linked to an Asterisk event") + + if failures: + print("[FAIL] Asterisk lab smoke failed") + for item in failures: + print(f"- {item}") + return 1 + + print("[PASS] Asterisk lab smoke passed") + print(f"- started_events: {started}") + print(f"- ended_events: {ended}") + print(f"- call_links: {links}") + print(f"- recording_events_with_upload: {recordings}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/backup_data.ps1 b/scripts/backup_data.ps1 new file mode 100644 index 0000000..1a14f1c --- /dev/null +++ b/scripts/backup_data.ps1 @@ -0,0 +1,18 @@ +param( + [string]$SourceDir = "e:\Zhan\.data", + [string]$OutputDir = "e:\Zhan\backups" +) + +if (!(Test-Path $SourceDir)) { + Write-Error "Source data directory not found: $SourceDir" + exit 1 +} + +New-Item -ItemType Directory -Force -Path $OutputDir | Out-Null + +$timestamp = Get-Date -Format "yyyyMMdd_HHmmss" +$archivePath = Join-Path $OutputDir ("mvp_cc_data_" + $timestamp + ".zip") + +Compress-Archive -Path (Join-Path $SourceDir "*") -DestinationPath $archivePath -Force + +Write-Host "Backup created:" $archivePath diff --git a/scripts/clean_workspace.ps1 b/scripts/clean_workspace.ps1 new file mode 100644 index 0000000..36aed3a --- /dev/null +++ b/scripts/clean_workspace.ps1 @@ -0,0 +1,61 @@ +$ErrorActionPreference = "Stop" +$root = Split-Path -Parent $PSScriptRoot +Set-Location $root + +function Remove-DirectoryIfExists { + param( + [Parameter(Mandatory = $true)] + [string]$Path + ) + + if (Test-Path -LiteralPath $Path) { + Write-Host "Removing directory:" $Path + Remove-Item -LiteralPath $Path -Recurse -Force + } +} + +function Clear-DirectoryContents { + param( + [Parameter(Mandatory = $true)] + [string]$Path + ) + + if (!(Test-Path -LiteralPath $Path)) { + return + } + + Write-Host "Clearing directory contents:" $Path + Get-ChildItem -LiteralPath $Path -Force -ErrorAction SilentlyContinue | ForEach-Object { + Remove-Item -LiteralPath $_.FullName -Recurse -Force + } +} + +$cacheDirs = Get-ChildItem -Path $root -Directory -Recurse -Force -ErrorAction SilentlyContinue | + Where-Object { $_.Name -eq "__pycache__" } + +foreach ($dir in $cacheDirs) { + Write-Host "Removing cache directory:" $dir.FullName + Remove-Item -LiteralPath $dir.FullName -Recurse -Force +} + +Remove-DirectoryIfExists -Path (Join-Path $root ".pytest_cache") +Remove-DirectoryIfExists -Path (Join-Path $root ".codex_tmp") +Remove-DirectoryIfExists -Path (Join-Path $root ".local_stack") +Remove-DirectoryIfExists -Path (Join-Path $root ".artifacts") + +$dataDirs = Get-ChildItem -Path $root -Directory -Force -ErrorAction SilentlyContinue | + Where-Object { + $_.Name -eq ".data" -or + $_.Name -eq ".data_local" -or + $_.Name -eq ".data_smoke" -or + $_.Name -like ".data_gate*" -or + $_.Name -like ".data_uat_*" + } + +foreach ($dir in $dataDirs) { + Clear-DirectoryContents -Path $dir.FullName +} + +Write-Host "" +Write-Host "Workspace cleanup completed." +Write-Host "Preserved source directories, docs, templates, and backups." diff --git a/scripts/demo_seed.py b/scripts/demo_seed.py new file mode 100644 index 0000000..1622e44 --- /dev/null +++ b/scripts/demo_seed.py @@ -0,0 +1,605 @@ +from __future__ import annotations + +import argparse +from datetime import datetime, timezone +import json +import math +import os +from pathlib import Path +import struct +from typing import Any +import wave + +import httpx + + +ROOT = Path(__file__).resolve().parents[1] +DEFAULT_OUTPUT = ROOT / ".local_stack" / "demo-seed-summary.json" + + +def utc_now() -> datetime: + return datetime.now(timezone.utc) + + +def now_stamp() -> str: + return utc_now().strftime("%Y%m%d_%H%M%S") + + +def build_seed_plan(tag: str) -> dict[str, Any]: + return { + "queue_name": "Demo Queue", + "customer_name": "Demo Customer", + "customer_phone": "+77010000001", + "voice_subject": "Demo voice escalation", + "chat_subject": "Demo Telegram follow-up", + "webchat_text": "Demo webchat request from the website", + "email_subject": "Demo email request about account access", + "email_body": "Please restore access to the portal. This message is preloaded for the management demo.", + "kb_category": "Demo Knowledge", + "kb_title": "Demo Knowledge Article", + "kb_keyword": "demo-showcase", + "telegram_text": "Demo inbound Telegram message", + "voice_event_type": "call.started", + "recording_file_name": "demo-call.wav", + "ivr_root_prompt_kz": "\u0421\u0430\u043b\u0430\u043c\u0430\u0442\u0441\u044b\u0437 \u0431\u0430! \u049a\u0430\u0437\u0430\u049b \u0442\u0456\u043b\u0456\u043d \u0442\u0430\u04a3\u0434\u0430\u0443 \u04af\u0448\u0456\u043d \u0431\u0456\u0440 \u0446\u0438\u0444\u0440\u044b\u043d \u0442\u0435\u0440\u0456\u04a3\u0456\u0437.", + "ivr_root_prompt_ru": "\u0417\u0434\u0440\u0430\u0432\u0441\u0442\u0432\u0443\u0439\u0442\u0435! \u0414\u043e\u0431\u0440\u043e \u043f\u043e\u0436\u0430\u043b\u043e\u0432\u0430\u0442\u044c \u0432 \u043a\u043e\u043d\u0442\u0430\u043a\u0442-\u0446\u0435\u043d\u0442\u0440. \u0414\u043b\u044f \u0440\u0443\u0441\u0441\u043a\u043e\u0433\u043e \u044f\u0437\u044b\u043a\u0430 \u043d\u0430\u0436\u043c\u0438\u0442\u0435 \u0446\u0438\u0444\u0440\u0443 \u0434\u0432\u0430.", + "ivr_root_prompt": "Саламатсыз ба! Қазақ тілін таңдау үшін бір цифрын теріңіз. Здравствуйте! Добро пожаловать в контакт-центр. Для русского языка нажмите цифру два.", + "ivr_ru_menu_prompt": "Для отдела продаж нажмите 1. Для службы поддержки нажмите 2.", + "ivr_kz_menu_prompt": "Сату бөлімі үшін 1 басыңыз. Қолдау қызметі үшін 2 басыңыз.", + } + + +def _demo_ivr_flow_document(plan: dict[str, Any], *, sales_queue_id: str, support_queue_id: str) -> dict[str, Any]: + return { + "nodes": [ + { + "node_id": "root", + "prompt_text": plan["ivr_root_prompt"], + "prompt_sequence": [ + { + "prompt_audio_key": "ivr/demo-language-kz", + "prompt_text": plan["ivr_root_prompt_kz"], + "language": "kz", + }, + { + "prompt_audio_key": "ivr/demo-language-ru", + "prompt_text": plan["ivr_root_prompt_ru"], + "language": "ru", + }, + ], + "is_terminal": False, + "invalid_target_node_id": "root", + "no_input_target_node_id": "root", + "options": [ + {"digit": "1", "target_node_id": "menu_kz"}, + {"digit": "2", "target_node_id": "menu_ru"}, + ], + }, + { + "node_id": "menu_ru", + "prompt_text": plan["ivr_ru_menu_prompt"], + "prompt_audio_key": "ivr/demo-menu-ru", + "is_terminal": False, + "invalid_target_node_id": "menu_ru", + "no_input_target_node_id": "menu_ru", + "options": [ + {"digit": "1", "target_node_id": "sales_ru"}, + {"digit": "2", "target_node_id": "support_ru"}, + ], + }, + { + "node_id": "menu_kz", + "prompt_text": plan["ivr_kz_menu_prompt"], + "prompt_audio_key": "ivr/demo-menu-kz", + "is_terminal": False, + "invalid_target_node_id": "menu_kz", + "no_input_target_node_id": "menu_kz", + "options": [ + {"digit": "1", "target_node_id": "sales_kz"}, + {"digit": "2", "target_node_id": "support_kz"}, + ], + }, + { + "node_id": "sales_ru", + "prompt_text": "Переводим в отдел продаж.", + "prompt_audio_key": "ivr/demo-sales-ru", + "is_terminal": True, + "outcome_code": "sales_route_ru", + "resolved_queue_id": sales_queue_id, + "resolved_queue_code": "ivr_sales_ai_ru", + "options": [], + }, + { + "node_id": "support_ru", + "prompt_text": "Переводим в службу поддержки.", + "prompt_audio_key": "ivr/demo-support-ru", + "is_terminal": True, + "outcome_code": "support_route_ru", + "resolved_queue_id": support_queue_id, + "resolved_queue_code": "ivr_support_ai_ru", + "options": [], + }, + { + "node_id": "sales_kz", + "prompt_text": "Сату бөліміне қосып жатырмыз.", + "prompt_audio_key": "ivr/demo-sales-kz", + "is_terminal": True, + "outcome_code": "sales_route_kz", + "resolved_queue_id": sales_queue_id, + "resolved_queue_code": "ivr_sales_ai_kz", + "options": [], + }, + { + "node_id": "support_kz", + "prompt_text": "Қолдау қызметіне қосып жатырмыз.", + "prompt_audio_key": "ivr/demo-support-kz", + "is_terminal": True, + "outcome_code": "support_route_kz", + "resolved_queue_id": support_queue_id, + "resolved_queue_code": "ivr_support_ai_kz", + "options": [], + }, + ] + } + + +def _raise_for_status(response: httpx.Response, action: str) -> None: + try: + response.raise_for_status() + except httpx.HTTPStatusError as exc: + detail = response.text.strip() + raise RuntimeError(f"{action} failed: {exc.response.status_code} {detail}") from exc + + +def _build_demo_wav(target_path: Path, duration_seconds: int = 1) -> Path: + target_path.parent.mkdir(parents=True, exist_ok=True) + sample_rate = 8000 + amplitude = 12000 + frequency = 440.0 + total_frames = sample_rate * duration_seconds + + with wave.open(str(target_path), "wb") as handle: + handle.setnchannels(1) + handle.setsampwidth(2) + handle.setframerate(sample_rate) + frames = bytearray() + for index in range(total_frames): + sample = int(amplitude * math.sin(2 * math.pi * frequency * (index / sample_rate))) + frames.extend(struct.pack(" Path: + raw = os.getenv("CC_DATA_DIR") + if raw: + return Path(raw).resolve() + local_dir = (ROOT / ".data_local").resolve() + if local_dir.exists(): + return local_dir + return (ROOT / ".data").resolve() + + +def seed_demo(base_url: str) -> dict[str, Any]: + admin = {"X-User": "admin", "X-Role": "admin"} + supervisor = {"X-User": "supervisor", "X-Role": "supervisor"} + operator = {"X-User": "operator", "X-Role": "operator"} + analyst = {"X-User": "analyst", "X-Role": "analyst"} + + tag = now_stamp() + plan = build_seed_plan(tag) + sample_audio_path = _build_demo_wav((_seed_data_dir() / "demo_assets" / plan["recording_file_name"]).resolve()) + + with httpx.Client(base_url=base_url, timeout=10) as client: + health = client.get("/health") + _raise_for_status(health, "gateway health check") + + queue = client.post( + "/proxy/routing/queues", + headers=admin, + json={ + "name": plan["queue_name"], + "description": "Prepared automatically for a management demo", + "rules": [ + {"channel": "voice", "priority": 3, "strategy": "round_robin", "sla_seconds": 30}, + {"channel": "telegram", "priority": 3, "strategy": "round_robin", "sla_seconds": 45}, + {"channel": "webchat", "priority": 3, "strategy": "round_robin", "sla_seconds": 40}, + {"channel": "email", "priority": 3, "strategy": "round_robin", "sla_seconds": 120}, + ], + }, + ) + _raise_for_status(queue, "queue create") + queue_id = queue.json()["queue_id"] + + sales_queue = client.post( + "/proxy/routing/queues", + headers=admin, + json={ + "name": "IVR Sales Queue", + "description": "Prepared automatically for IVR routing demo", + "rules": [ + {"channel": "voice", "priority": 3, "strategy": "round_robin", "sla_seconds": 25}, + ], + }, + ) + _raise_for_status(sales_queue, "sales queue create") + sales_queue_id = sales_queue.json()["queue_id"] + + support_queue = client.post( + "/proxy/routing/queues", + headers=admin, + json={ + "name": "IVR Support Queue", + "description": "Prepared automatically for IVR routing demo", + "rules": [ + {"channel": "voice", "priority": 3, "strategy": "round_robin", "sla_seconds": 35}, + ], + }, + ) + _raise_for_status(support_queue, "support queue create") + support_queue_id = support_queue.json()["queue_id"] + + customer = client.post( + "/proxy/customer/customers", + json={ + "display_name": plan["customer_name"], + "phones": [plan["customer_phone"]], + "preferred_phone": plan["customer_phone"], + "tags": ["demo", "showcase"], + }, + ) + _raise_for_status(customer, "customer create") + customer_id = customer.json()["customer_id"] + + voice_interaction = client.post( + "/proxy/interaction/interactions", + headers=operator, + json={ + "channel": "voice", + "subject": plan["voice_subject"], + "customer_id": customer_id, + "queue_id": queue_id, + "priority": 3, + }, + ) + _raise_for_status(voice_interaction, "voice interaction create") + voice_interaction_id = voice_interaction.json()["interaction_id"] + + assign = client.patch( + f"/proxy/interaction/interactions/{voice_interaction_id}/assign", + headers=supervisor, + json={"assignee": "operator_a"}, + ) + _raise_for_status(assign, "voice interaction assign") + + escalate = client.post( + f"/proxy/interaction/interactions/{voice_interaction_id}/escalate", + headers=operator, + json={"target_queue_id": "line2"}, + ) + _raise_for_status(escalate, "voice interaction escalate") + + close = client.patch( + f"/proxy/interaction/interactions/{voice_interaction_id}/status", + headers=operator, + json={"status": "closed"}, + ) + _raise_for_status(close, "voice interaction close") + + active_interaction = client.post( + "/proxy/interaction/interactions", + headers=operator, + json={ + "channel": "telegram", + "subject": plan["chat_subject"], + "customer_id": customer_id, + "queue_id": "line2", + "priority": 3, + }, + ) + _raise_for_status(active_interaction, "active interaction create") + active_interaction_id = active_interaction.json()["interaction_id"] + + voice_event = client.post( + "/proxy/voice/integrations/voice/events", + headers=operator, + json={ + "event_type": plan["voice_event_type"], + "call_id": "demo_call", + "interaction_id": voice_interaction_id, + "payload": {"source": "demo-seed"}, + }, + ) + _raise_for_status(voice_event, "voice event send") + voice_event_id = voice_event.json()["event_id"] + + recording_ready_event = client.post( + "/proxy/voice/integrations/voice/events", + headers=operator, + json={ + "event_type": "recording.ready", + "call_id": "demo_call", + "interaction_id": voice_interaction_id, + "payload": { + "source": "demo-seed", + "source_path": str(sample_audio_path), + "file_name": plan["recording_file_name"], + "mime_type": "audio/wav", + "duration_seconds": 1, + "recorded_at": utc_now().replace(microsecond=0).isoformat(), + }, + }, + ) + _raise_for_status(recording_ready_event, "recording ready event send") + recording_event_id = recording_ready_event.json()["event_id"] + + recording = client.post( + f"/proxy/recording/recordings/import-from-voice-event/{recording_event_id}", + headers=supervisor, + ) + _raise_for_status(recording, "recording import") + recording_id = recording.json()["recording_id"] + + ivr_flow = client.post( + "/proxy/ivr/ivr/flows", + headers=admin, + json={ + "name": "Demo IVR Flow", + "description": "Prepared automatically for a management demo", + "queue_id": queue_id, + "entry_node_id": "root", + "flow_json": _demo_ivr_flow_document( + plan, + sales_queue_id=sales_queue_id, + support_queue_id=support_queue_id, + ), + "is_active": True, + }, + ) + _raise_for_status(ivr_flow, "ivr flow create") + ivr_flow_id = ivr_flow.json()["flow_id"] + + ivr_session_start = client.post( + "/proxy/ivr/ivr/sessions/start", + headers=admin, + json={ + "call_id": "demo_call_ivr", + "queue_id": queue_id, + "interaction_id": voice_interaction_id, + }, + ) + _raise_for_status(ivr_session_start, "ivr session start") + ivr_session_id = ivr_session_start.json()["session"]["session_id"] + + ivr_session_step = client.post( + f"/proxy/ivr/ivr/sessions/{ivr_session_id}/dtmf", + headers=admin, + json={"digit": "1"}, + ) + _raise_for_status(ivr_session_step, "ivr language step") + + ivr_session_step = client.post( + f"/proxy/ivr/ivr/sessions/{ivr_session_id}/dtmf", + headers=admin, + json={"digit": "2"}, + ) + _raise_for_status(ivr_session_step, "ivr dtmf step") + ivr_session_payload = ivr_session_step.json() + + ivr_route_preview = client.post( + f"/proxy/routing/queues/{queue_id}/route?channel=voice&priority=3&ivr_session_id={ivr_session_id}", + headers=admin, + ) + _raise_for_status(ivr_route_preview, "ivr route preview") + + telegram = client.post( + "/proxy/telegram/integrations/telegram/webhook", + json={ + "chat_id": "demo_chat", + "text": plan["telegram_text"], + "customer_external_id": customer_id, + "payload": {"source": "demo-seed"}, + }, + ) + _raise_for_status(telegram, "telegram webhook send") + telegram_id = telegram.json()["message_id"] + + webchat = client.post( + "/proxy/webchat/integrations/webchat/messages", + json={ + "session_id": "demo_webchat_session", + "text": plan["webchat_text"], + "visitor_name": "Demo Visitor", + "customer_external_id": customer_id, + "queue_id": "line2", + "priority": 3, + "payload": {"source": "demo-seed", "subject": "Demo webchat intake"}, + }, + ) + _raise_for_status(webchat, "webchat message send") + webchat_id = webchat.json()["message_id"] + webchat_interaction_id = webchat.json()["interaction_id"] + + email = client.post( + "/proxy/email/integrations/email/messages", + json={ + "from_email": "demo.user@example.com", + "subject": plan["email_subject"], + "body": plan["email_body"], + "customer_external_id": customer_id, + "queue_id": "line2", + "priority": 3, + "payload": {"source": "demo-seed", "mailbox": "support@example.com"}, + }, + ) + _raise_for_status(email, "email message send") + email_id = email.json()["message_id"] + email_interaction_id = email.json()["interaction_id"] + + category = client.post( + "/proxy/kb/knowledge/categories", + headers=analyst, + json={"name": plan["kb_category"], "description": "Demo search content"}, + ) + _raise_for_status(category, "kb category create") + category_id = category.json()["category_id"] + + article = client.post( + "/proxy/kb/knowledge/articles", + headers=analyst, + json={ + "category_id": category_id, + "title": plan["kb_title"], + "body": "Use the demo-showcase article when management asks about answer prompts.", + "tags": ["demo", plan["kb_keyword"]], + }, + ) + _raise_for_status(article, "kb article create") + article_id = article.json()["article_id"] + + search = client.get(f"/proxy/kb/knowledge/search?q={plan['kb_keyword']}") + _raise_for_status(search, "kb search") + + agent1 = client.post( + "/proxy/supervisor/supervisor/agent-states", + headers=supervisor, + json={"agent_id": "demo_agent_a", "state": "READY", "queue_id": "line2"}, + ) + _raise_for_status(agent1, "supervisor agent update 1") + agent2 = client.post( + "/proxy/supervisor/supervisor/agent-states", + headers=supervisor, + json={"agent_id": "demo_agent_b", "state": "BUSY", "queue_id": "line2"}, + ) + _raise_for_status(agent2, "supervisor agent update 2") + queue_metrics = client.post( + "/proxy/supervisor/supervisor/queue-metrics?queue_id=line2&in_queue=1&avg_wait_seconds=14", + headers=supervisor, + ) + _raise_for_status(queue_metrics, "supervisor queue metrics") + realtime = client.get("/proxy/supervisor/supervisor/realtime", headers=supervisor) + _raise_for_status(realtime, "supervisor realtime") + + kpi_rows = [ + { + "queue_id": "line2", + "channel": "voice", + "agent_id": "demo_agent_b", + "answered": True, + "wait_seconds": 15, + "handle_seconds": 90, + "abandoned": False, + "resolved_first_contact": True, + }, + { + "queue_id": "line2", + "channel": "webchat", + "agent_id": "demo_agent_a", + "answered": True, + "wait_seconds": 25, + "handle_seconds": 110, + "abandoned": False, + "resolved_first_contact": False, + }, + { + "queue_id": "line2", + "channel": "email", + "agent_id": None, + "answered": False, + "wait_seconds": 12, + "handle_seconds": 0, + "abandoned": True, + "resolved_first_contact": False, + }, + ] + for idx, row in enumerate(kpi_rows, start=1): + created = client.post("/proxy/reporting/reports/events", json=row) + _raise_for_status(created, f"kpi event ingest {idx}") + + kpi = client.get("/proxy/reporting/reports/kpi?queue_id=line2&sl_threshold_seconds=30") + _raise_for_status(kpi, "kpi query") + + event_bus_enabled = os.getenv("EVENT_BUS_ENABLED", "0").strip().lower() in {"1", "true", "yes", "on"} + event_bus_smoke_passed = False + seeded_event_id = None + if event_bus_enabled: + try: + from scripts.event_bus_smoke import run_smoke_check + + smoke = run_smoke_check(base_url=base_url, auth_mode="legacy_headers", timeout_seconds=10) + event_bus_smoke_passed = bool(smoke.get("passed")) + seeded_event_id = smoke.get("event_id") + except Exception: + event_bus_smoke_passed = False + + return { + "generated_at": utc_now().isoformat(), + "base_url": base_url, + "demo_login": {"username": "admin", "password": "admin123"}, + "queue_id": queue_id, + "customer_id": customer_id, + "voice_interaction_id": voice_interaction_id, + "active_interaction_id": active_interaction_id, + "voice_event_id": voice_event_id, + "recording_event_id": recording_event_id, + "recording_id": recording_id, + "recording_call_id": "demo_call", + "recording_file_name": plan["recording_file_name"], + "event_bus_enabled": event_bus_enabled, + "event_bus_smoke_passed": event_bus_smoke_passed, + "seeded_event_id": seeded_event_id, + "ivr_flow_id": ivr_flow_id, + "ivr_session_id": ivr_session_id, + "ivr_outcome_code": ivr_session_payload["session"]["outcome_code"], + "ivr_resolved_queue_id": ivr_session_payload["session"]["resolved_queue_id"], + "ivr_route_preview": ivr_route_preview.json(), + "telegram_message_id": telegram_id, + "webchat_message_id": webchat_id, + "webchat_interaction_id": webchat_interaction_id, + "email_message_id": email_id, + "email_interaction_id": email_interaction_id, + "kb_category_id": category_id, + "kb_article_id": article_id, + "kb_keyword": plan["kb_keyword"], + "sample_recording_path": str(sample_audio_path), + "kpi_snapshot": kpi.json(), + "supervisor_snapshot": realtime.json(), + } + + +def main() -> int: + parser = argparse.ArgumentParser(description="Seed demo-ready data into the local MVP stack") + parser.add_argument("--base-url", default="http://localhost:8080", help="Gateway base URL") + parser.add_argument( + "--output", + default=str(DEFAULT_OUTPUT), + help="Where to store the seed summary JSON", + ) + args = parser.parse_args() + + output_path = Path(args.output) + if not output_path.is_absolute(): + output_path = (ROOT / output_path).resolve() + output_path.parent.mkdir(parents=True, exist_ok=True) + + summary = seed_demo(args.base_url) + output_path.write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8") + + print("Demo data is ready.") + print(f"UI: {args.base_url}/operator") + print(f"Summary: {output_path}") + print(f"Customer: {summary['customer_id']}") + print(f"Closed interaction: {summary['voice_interaction_id']}") + print(f"Active interaction: {summary['active_interaction_id']}") + print(f"Recording: {summary['recording_id']}") + print(f"IVR flow: {summary['ivr_flow_id']}") + print(f"IVR session: {summary['ivr_session_id']}") + print(f"Webchat interaction: {summary['webchat_interaction_id']}") + print(f"Email interaction: {summary['email_interaction_id']}") + print(f"KB keyword: {summary['kb_keyword']}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/event_bus_smoke.py b/scripts/event_bus_smoke.py new file mode 100644 index 0000000..b14c69b --- /dev/null +++ b/scripts/event_bus_smoke.py @@ -0,0 +1,130 @@ +from __future__ import annotations + +import argparse +import sys +import time +from pathlib import Path + +import httpx +from sqlalchemy import create_engine, select +from sqlalchemy.orm import Session + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from services.shared.core import utc_now_iso +from services.shared.db import DATABASE_URL, _normalize_database_url +from services.shared.sql_models import EventInboxRow, EventOutboxRow + + +def _engine_for(database_url: str | None): + url = _normalize_database_url(database_url or DATABASE_URL) + return create_engine(url, future=True) + + +def _auth_headers(client: httpx.Client, auth_mode: str) -> dict[str, str]: + if auth_mode == "legacy_headers": + return {"X-User": "admin", "X-Role": "admin"} + response = client.post( + "/proxy/auth/auth/login", + json={"username": "admin", "password": "admin123"}, + timeout=10, + ) + response.raise_for_status() + token = response.json()["access_token"] + return {"Authorization": f"Bearer {token}"} + + +def run_smoke_check( + *, + base_url: str, + database_url: str | None = None, + auth_mode: str = "legacy_headers", + timeout_seconds: int = 15, +) -> dict: + engine = _engine_for(database_url) + with httpx.Client(base_url=base_url, timeout=10) as client: + headers = _auth_headers(client, auth_mode) + event_bus_health = client.get("/proxy/event-bus/health", headers=headers) + if event_bus_health.status_code != 200: + raise RuntimeError("event-bus-service health check failed") + + created = client.post( + "/proxy/interaction/interactions", + headers=headers, + json={ + "channel": "voice", + "subject": f"Track8 smoke {utc_now_iso()}", + "customer_id": None, + "queue_id": "track8_smoke_queue", + "priority": 3, + }, + ) + created.raise_for_status() + interaction_id = created.json()["interaction_id"] + + deadline = time.time() + timeout_seconds + found: dict[str, str] = {} + with Session(engine) as session: + while time.time() < deadline: + outbox_row = session.execute( + select(EventOutboxRow).where( + EventOutboxRow.entity_id == interaction_id, + EventOutboxRow.event_type == "interaction.created", + ) + ).scalar_one_or_none() + if outbox_row: + found["event_id"] = outbox_row.event_id + found["outbox_status"] = outbox_row.status + audit_seen = session.execute( + select(EventInboxRow).where( + EventInboxRow.consumer_name == "audit-service", + EventInboxRow.event_id == outbox_row.event_id, + ) + ).scalar_one_or_none() + reporting_seen = session.execute( + select(EventInboxRow).where( + EventInboxRow.consumer_name == "reporting-service", + EventInboxRow.event_id == outbox_row.event_id, + ) + ).scalar_one_or_none() + if outbox_row.status == "published" and audit_seen and reporting_seen: + return { + "passed": True, + "interaction_id": interaction_id, + "event_id": outbox_row.event_id, + "outbox_status": outbox_row.status, + } + session.expire_all() + time.sleep(0.5) + + return { + "passed": False, + "interaction_id": interaction_id, + "event_id": found.get("event_id"), + "outbox_status": found.get("outbox_status"), + } + + +def main() -> None: + parser = argparse.ArgumentParser(description="Run a lightweight Track 8 event-bus smoke check.") + parser.add_argument("--base-url", default="http://localhost:8080") + parser.add_argument("--database-url", default=None) + parser.add_argument("--auth-mode", choices=["legacy_headers", "bearer"], default="legacy_headers") + parser.add_argument("--timeout-seconds", type=int, default=15) + args = parser.parse_args() + + result = run_smoke_check( + base_url=args.base_url, + database_url=args.database_url, + auth_mode=args.auth_mode, + timeout_seconds=args.timeout_seconds, + ) + print(result) + if not result["passed"]: + raise SystemExit(1) + + +if __name__ == "__main__": + main() diff --git a/scripts/finalize_mvp_pilot.py b/scripts/finalize_mvp_pilot.py new file mode 100644 index 0000000..0766cfb --- /dev/null +++ b/scripts/finalize_mvp_pilot.py @@ -0,0 +1,283 @@ +from __future__ import annotations + +import argparse +import csv +from datetime import datetime, timezone +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +GATE4_PATH = ROOT / "docs" / "gates" / "gate-04-pilot-hardening.md" +P1P2_PATH = ROOT / "docs" / "gates" / "p1-p2-defects.md" +ACCEPTED_RELEASE_PATH = ROOT / "docs" / "releases" / "v1.0.0-mvp-accepted.md" + + +def utc_now() -> datetime: + return datetime.now(timezone.utc) + + +def iso_now() -> str: + return utc_now().isoformat() + + +def _normalized_status(value: str) -> str: + return value.strip().lower() + + +def _is_open_status(value: str) -> bool: + normalized = _normalized_status(value) + return normalized not in {"closed", "resolved", "verified"} + + +def parse_open_defect_counts(defect_log_path: Path) -> dict[str, int]: + counts = {"P1": 0, "P2": 0, "P3": 0, "P4": 0} + with defect_log_path.open("r", newline="", encoding="utf-8") as handle: + reader = csv.DictReader(handle) + for row in reader: + severity = (row.get("severity") or "").strip().upper() + status = row.get("status") or "" + if severity in counts and _is_open_status(status): + counts[severity] += 1 + return counts + + +def validate_session_protocol(session_protocol_path: Path) -> list[str]: + content = session_protocol_path.read_text(encoding="utf-8") + errors: list[str] = [] + blank_markers = [ + "- Session ID:", + "- Environment URL:", + "- Build/version:", + "- Deployment date:", + "- Cluster/namespace:", + "- Business owner:", + "- IT owner:", + ] + for marker in blank_markers: + if f"{marker}\n" in content or f"{marker}\r\n" in content: + errors.append(f"Session protocol still contains blank field: {marker}") + + if "## Decision" not in content or "[x]" not in content: + errors.append("Session protocol does not contain a selected decision checkbox") + + return errors + + +def validate_signoff_sheet(signoff_sheet_path: Path) -> list[str]: + content = signoff_sheet_path.read_text(encoding="utf-8") + errors: list[str] = [] + placeholder_lines = [ + " - Name:", + " - Signature:", + " - Date:", + ] + for placeholder in placeholder_lines: + if content.count(placeholder) > 0: + errors.append(f"Sign-off sheet still contains placeholder lines matching: {placeholder.strip()}") + break + + decision_lines = [line for line in content.splitlines() if line.startswith("- [")] + if sum(1 for line in decision_lines if "[x]" in line.lower()) != 1: + errors.append("Sign-off sheet must have exactly one selected decision checkbox") + + return errors + + +def apply_gate4_closure( + content: str, + *, + session_id: str, + session_dir: Path, + acceptance: str, + open_counts: dict[str, int], +) -> str: + updated = content + updated = updated.replace( + "- [ ] UAT signed with real operators/supervisors", + "- [x] UAT signed with real operators/supervisors", + ) + updated = updated.replace( + "- [ ] P1/P2 defects closed", + "- [x] P1/P2 defects closed", + ) + + marker = "Manual closure update:" + if marker in updated: + updated = updated.split(marker, 1)[0].rstrip() + + lines = [ + "", + "Manual closure update:", + f"- Date (UTC): {iso_now()}", + f"- Session ID: {session_id}", + f"- Session directory: `{session_dir.as_posix()}`", + f"- Acceptance: {acceptance}", + ( + "- Remaining open defects: " + f"P1={open_counts['P1']}, P2={open_counts['P2']}, " + f"P3={open_counts['P3']}, P4={open_counts['P4']}" + ), + ] + return updated.rstrip() + "\n" + "\n".join(lines) + "\n" + + +def build_p1p2_register( + *, + session_id: str, + session_dir: Path, + open_counts: dict[str, int], + acceptance: str, +) -> str: + lines = [ + "# P1/P2 Defect Register (Pilot)", + "", + f"Last update: {utc_now().date().isoformat()} ({session_id})", + "", + "## Current Status", + f"- Open P1: {open_counts['P1']}", + f"- Open P2: {open_counts['P2']}", + "- Source: manual UAT close-out", + f"- Manual session evidence: `{session_dir.as_posix()}`", + "", + "## Triage Policy", + "- Only `P1` and `P2` issues belong to MVP remediation.", + "- `P3` and `P4` issues move to `docs/roadmap/05-wave2-backlog.md` unless they block sign-off.", + "", + "## Manual Closure", + f"- Acceptance: {acceptance}", + "- P1/P2 closure confirmed by signed manual UAT package.", + ] + return "\n".join(lines) + "\n" + + +def build_acceptance_release_note( + *, + session_id: str, + session_dir: Path, + acceptance: str, + open_counts: dict[str, int], +) -> str: + lines = [ + "# Release Notes - v1.0.0-mvp Accepted", + "", + f"Date: {utc_now().date().isoformat()}", + "", + "## Acceptance Result", + f"- Session ID: {session_id}", + f"- Acceptance: {acceptance}", + f"- Manual session evidence: `{session_dir.as_posix()}`", + "", + "## Closed Gate 4 Conditions", + "- UAT signed with real operators/supervisors", + "- P1/P2 defects closed", + "", + "## Remaining Deferred Items", + f"- Open P3: {open_counts['P3']}", + f"- Open P4: {open_counts['P4']}", + "- Deferred scope remains tracked in `docs/roadmap/05-wave2-backlog.md`.", + "", + "## Baseline", + "- Accepted baseline remains the current MVP v1 scope without API expansion.", + "- Stage 1-3 public routes remain unchanged.", + ] + return "\n".join(lines) + "\n" + + +def finalize_manual_session( + *, + session_dir: Path, + acceptance: str, + dry_run: bool = False, +) -> dict[str, int]: + required_files = { + "session_protocol": session_dir / "session-protocol.md", + "scenario_checklist": session_dir / "scenario-checklist.md", + "defect_log": session_dir / "defect-log.csv", + "signoff_sheet": session_dir / "signoff-sheet.md", + "manifest": session_dir / "manifest.json", + } + missing = [name for name, path in required_files.items() if not path.exists()] + attachments_dir = session_dir / "attachments" + if not attachments_dir.exists(): + missing.append("attachments") + if missing: + raise FileNotFoundError(f"Manual session bundle is incomplete: {missing}") + + validation_errors: list[str] = [] + validation_errors.extend(validate_session_protocol(required_files["session_protocol"])) + validation_errors.extend(validate_signoff_sheet(required_files["signoff_sheet"])) + open_counts = parse_open_defect_counts(required_files["defect_log"]) + if open_counts["P1"] != 0 or open_counts["P2"] != 0: + validation_errors.append( + f"Cannot close MVP pilot with open P1/P2 defects: P1={open_counts['P1']}, P2={open_counts['P2']}" + ) + + if validation_errors: + details = "\n".join(f"- {error}" for error in validation_errors) + raise ValueError(f"Manual UAT package validation failed:\n{details}") + + if dry_run: + return open_counts + + session_id = session_dir.name.replace("manual_", "", 1) + gate4_content = GATE4_PATH.read_text(encoding="utf-8") + updated_gate4 = apply_gate4_closure( + gate4_content, + session_id=session_id, + session_dir=session_dir, + acceptance=acceptance, + open_counts=open_counts, + ) + GATE4_PATH.write_text(updated_gate4, encoding="utf-8") + P1P2_PATH.write_text( + build_p1p2_register( + session_id=session_id, + session_dir=session_dir, + open_counts=open_counts, + acceptance=acceptance, + ), + encoding="utf-8", + ) + ACCEPTED_RELEASE_PATH.write_text( + build_acceptance_release_note( + session_id=session_id, + session_dir=session_dir, + acceptance=acceptance, + open_counts=open_counts, + ), + encoding="utf-8", + ) + return open_counts + + +def main() -> None: + parser = argparse.ArgumentParser(description="Validate a manual UAT bundle and close MVP Gate 4") + parser.add_argument("--session-dir", required=True, help="Path to the prepared manual session directory") + parser.add_argument( + "--acceptance", + choices=["accepted_for_pilot_completion", "accepted_with_conditions"], + default="accepted_for_pilot_completion", + help="Acceptance mode recorded in gate closure artifacts", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Validate the manual session bundle without updating gate/release documents", + ) + args = parser.parse_args() + + session_dir = Path(args.session_dir) + if not session_dir.is_absolute(): + session_dir = (ROOT / session_dir).resolve() + + counts = finalize_manual_session( + session_dir=session_dir, + acceptance=args.acceptance, + dry_run=args.dry_run, + ) + prefix = "MVP pilot finalization dry-run passed:" if args.dry_run else "MVP pilot finalized:" + print(f"{prefix} P1={counts['P1']} P2={counts['P2']} P3={counts['P3']} P4={counts['P4']}") + + +if __name__ == "__main__": + main() diff --git a/scripts/gate1_check.py b/scripts/gate1_check.py new file mode 100644 index 0000000..fe34bb8 --- /dev/null +++ b/scripts/gate1_check.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +import asyncio +import sys + +import httpx + + +REQUIRED_SERVICES = [ + "auth", + "audit", + "customer", + "interaction", + "routing", + "voice", + "telegram", + "kb", + "reporting", + "supervisor", +] + + +async def check_gateway(base_url: str) -> bool: + async with httpx.AsyncClient(base_url=base_url, timeout=5) as client: + try: + health = await client.get("/health") + if health.status_code != 200: + print(f"[FAIL] gateway /health status={health.status_code}") + return False + + registry = await client.get("/registry") + if registry.status_code != 200: + print(f"[FAIL] gateway /registry status={registry.status_code}") + return False + data = registry.json() + services = data.get("services", {}) + missing = [s for s in REQUIRED_SERVICES if s not in services] + if missing: + print(f"[FAIL] registry missing services: {missing}") + return False + + login = await client.post( + "/proxy/auth/auth/login", + json={"username": "admin", "password": "admin123"}, + ) + if login.status_code != 200: + print(f"[FAIL] auth login status={login.status_code}") + return False + + print("[PASS] Stage 1 gateway/auth health checks passed") + return True + except Exception as exc: + print(f"[FAIL] exception: {exc}") + return False + + +async def main() -> None: + base_url = "http://localhost:8080" + ok = await check_gateway(base_url) + sys.exit(0 if ok else 1) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/scripts/gate3_check.py b/scripts/gate3_check.py new file mode 100644 index 0000000..250cc30 --- /dev/null +++ b/scripts/gate3_check.py @@ -0,0 +1,303 @@ +from __future__ import annotations + +import argparse +import asyncio +import os +from pathlib import Path +import shutil +import subprocess +import sys +import time +from typing import Any + +import httpx + + +ROOT = Path(__file__).resolve().parents[1] +DATA_ROOT = ROOT / ".data_gate3" + +SERVICE_SPECS = [ + {"name": "auth", "module": "services.auth_service.app:app", "port": 28001}, + {"name": "audit", "module": "services.audit_service.app:app", "port": 28002}, + {"name": "customer", "module": "services.customer_service.app:app", "port": 28003}, + {"name": "interaction", "module": "services.interaction_service.app:app", "port": 28004}, + {"name": "routing", "module": "services.routing_service.app:app", "port": 28005}, + {"name": "voice", "module": "services.voice_adapter_service.app:app", "port": 28006}, + {"name": "telegram", "module": "services.telegram_adapter_service.app:app", "port": 28007}, + {"name": "kb", "module": "services.kb_service.app:app", "port": 28008}, + {"name": "reporting", "module": "services.reporting_service.app:app", "port": 28009}, + {"name": "supervisor", "module": "services.supervisor_service.app:app", "port": 28010}, + {"name": "gateway", "module": "gateway.app:app", "port": 28080}, +] + +REQUIRED_STAGE3_SERVICES = ["kb", "reporting", "supervisor"] + + +async def wait_for_health(base_url: str, retries: int = 80, delay: float = 0.25) -> None: + async with httpx.AsyncClient(timeout=2) as client: + for _ in range(retries): + try: + response = await client.get(f"{base_url}/health") + if response.status_code == 200: + return + except Exception: + pass + await asyncio.sleep(delay) + raise RuntimeError(f"Service not healthy: {base_url}/health") + + +def start_one_service(spec: dict[str, Any], database_url: str | None, data_dir: Path) -> subprocess.Popen: + env = os.environ.copy() + env["CC_DATA_DIR"] = str(data_dir) + if database_url: + env["DATABASE_URL"] = database_url + + if spec["name"] == "gateway": + env["AUTH_SERVICE_URL"] = "http://127.0.0.1:28001" + env["AUDIT_SERVICE_URL"] = "http://127.0.0.1:28002" + env["CUSTOMER_SERVICE_URL"] = "http://127.0.0.1:28003" + env["INTERACTION_SERVICE_URL"] = "http://127.0.0.1:28004" + env["ROUTING_SERVICE_URL"] = "http://127.0.0.1:28005" + env["VOICE_ADAPTER_SERVICE_URL"] = "http://127.0.0.1:28006" + env["TELEGRAM_ADAPTER_SERVICE_URL"] = "http://127.0.0.1:28007" + env["KB_SERVICE_URL"] = "http://127.0.0.1:28008" + env["REPORTING_SERVICE_URL"] = "http://127.0.0.1:28009" + env["SUPERVISOR_SERVICE_URL"] = "http://127.0.0.1:28010" + + cmd = [ + sys.executable, + "-m", + "uvicorn", + spec["module"], + "--host", + "127.0.0.1", + "--port", + str(spec["port"]), + ] + return subprocess.Popen( + cmd, + cwd=str(ROOT), + env=env, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + + +async def start_services_sequential(database_url: str | None, data_dir: Path) -> list[subprocess.Popen]: + data_dir.mkdir(parents=True, exist_ok=True) + processes: list[subprocess.Popen] = [] + for spec in SERVICE_SPECS: + proc = start_one_service(spec, database_url, data_dir) + processes.append(proc) + await wait_for_health(f"http://127.0.0.1:{spec['port']}", retries=60, delay=0.2) + return processes + + +def stop_services(processes: list[subprocess.Popen]) -> None: + for proc in processes: + if proc.poll() is not None: + continue + try: + proc.terminate() + except Exception: + pass + + for proc in processes: + try: + proc.wait(timeout=2) + except Exception: + try: + proc.kill() + except Exception: + pass + + +async def run_gate3_checks(base_url: str, reporting_url: str | None = None) -> bool: + contract_path = ROOT / "contracts" / "openapi" / "stage3-kb-reporting-supervisor.yaml" + if not contract_path.exists(): + print(f"[FAIL] Missing Stage 3 OpenAPI: {contract_path}") + return False + print(f"[PASS] Stage 3 OpenAPI present: {contract_path.name}") + + operator = {"X-User": "operator", "X-Role": "operator"} + analyst = {"X-User": "analyst", "X-Role": "analyst"} + + async with httpx.AsyncClient(base_url=base_url, timeout=10) as client: + health = await client.get("/health") + if health.status_code != 200: + print(f"[FAIL] gateway /health status={health.status_code}") + return False + + registry = await client.get("/registry") + if registry.status_code != 200: + print(f"[FAIL] gateway /registry status={registry.status_code}") + return False + services = registry.json().get("services", {}) + missing = [name for name in REQUIRED_STAGE3_SERVICES if name not in services] + if missing: + print(f"[FAIL] registry missing Stage 3 services: {missing}") + return False + print("[PASS] Gateway and registry checks") + + denied = await client.post( + "/proxy/kb/knowledge/categories", + headers=operator, + json={"name": "Forbidden", "description": "Role check"}, + ) + if denied.status_code != 403: + print(f"[FAIL] KB role check expected 403, got {denied.status_code}") + return False + + category = await client.post( + "/proxy/kb/knowledge/categories", + headers=analyst, + json={"name": "Stage 3 KB", "description": "Gate 3"}, + ) + if category.status_code != 200: + print(f"[FAIL] KB category create status={category.status_code}") + return False + category_id = category.json()["category_id"] + + article = await client.post( + "/proxy/kb/knowledge/articles", + headers=analyst, + json={ + "category_id": category_id, + "title": "Install helper", + "body": "Step-by-step instruction for operator", + "tags": ["stage3", "kb"], + }, + ) + if article.status_code != 200: + print(f"[FAIL] KB article create status={article.status_code}") + return False + + search = await client.get("/proxy/kb/knowledge/search?q=helper") + if search.status_code != 200 or len(search.json()) < 1: + print( + f"[FAIL] KB search failed status={search.status_code} count={len(search.json()) if search.status_code == 200 else 0}" + ) + return False + print("[PASS] KB checks") + + rows = [ + { + "queue_id": "q_gate3", + "answered": True, + "wait_seconds": 15, + "handle_seconds": 95, + "abandoned": False, + "resolved_first_contact": True, + }, + { + "queue_id": "q_gate3", + "answered": False, + "wait_seconds": 20, + "handle_seconds": 0, + "abandoned": True, + "resolved_first_contact": False, + }, + ] + for row in rows: + ingested = await client.post("/proxy/reporting/reports/events", json=row) + if ingested.status_code != 200: + print(f"[FAIL] reporting ingest status={ingested.status_code}") + return False + + kpi = await client.get("/proxy/reporting/reports/kpi?queue_id=q_gate3&sl_threshold_seconds=30") + if kpi.status_code != 200: + print(f"[FAIL] reporting KPI status={kpi.status_code}") + return False + kpi_body = kpi.json() + if "kpi" not in kpi_body or not {"SL", "ASA", "AHT", "Abandon", "FCR"}.issubset(set(kpi_body["kpi"])): + print("[FAIL] KPI payload missing required keys") + return False + print("[PASS] Reporting KPI checks") + + report_service_url = reporting_url or services.get("reporting") + if not report_service_url: + print("[FAIL] reporting service URL not found") + return False + + async with httpx.AsyncClient(base_url=report_service_url, timeout=10) as reporting_client: + exported = await reporting_client.get("/reports/export") + if exported.status_code != 200: + print(f"[FAIL] reporting export status={exported.status_code}") + return False + if "queue_id,answered,wait_seconds,handle_seconds,abandoned,resolved_first_contact,created_at" not in exported.text: + print("[FAIL] reporting export missing CSV header") + return False + if "q_gate3" not in exported.text: + print("[FAIL] reporting export missing inserted data") + return False + print("[PASS] Reporting CSV export checks") + + up1 = await client.post( + "/proxy/supervisor/supervisor/agent-states", + json={"agent_id": "a_gate3_1", "state": "READY", "queue_id": "q_gate3"}, + ) + up2 = await client.post( + "/proxy/supervisor/supervisor/agent-states", + json={"agent_id": "a_gate3_2", "state": "BUSY", "queue_id": "q_gate3"}, + ) + queue = await client.post( + "/proxy/supervisor/supervisor/queue-metrics?queue_id=q_gate3&in_queue=3&avg_wait_seconds=21" + ) + if up1.status_code != 200 or up2.status_code != 200 or queue.status_code != 200: + print( + f"[FAIL] supervisor updates status: agent1={up1.status_code}, agent2={up2.status_code}, queue={queue.status_code}" + ) + return False + + realtime = await client.get("/proxy/supervisor/supervisor/realtime") + if realtime.status_code != 200: + print(f"[FAIL] supervisor realtime status={realtime.status_code}") + return False + body: dict[str, Any] = realtime.json() + if body.get("agents", {}).get("total", 0) < 2: + print("[FAIL] supervisor realtime has less than 2 agents") + return False + queues = body.get("queues", []) + if not any(item.get("queue_id") == "q_gate3" for item in queues): + print("[FAIL] supervisor realtime missing q_gate3 queue snapshot") + return False + print("[PASS] Supervisor realtime checks") + + print("[PASS] Gate 3 checks completed") + return True + + +async def main() -> None: + parser = argparse.ArgumentParser(description="Gate 3 checker") + parser.add_argument("--base-url", default="http://localhost:8080", help="Gateway base URL") + parser.add_argument("--auto-start", action="store_true", help="Auto-start local services for the check") + parser.add_argument( + "--database-url", + default=os.getenv("DATABASE_URL", ""), + help="Optional DB URL for auto-start mode", + ) + args = parser.parse_args() + + if not args.auto_start: + ok = await run_gate3_checks(args.base_url) + sys.exit(0 if ok else 1) + + db_url = args.database_url.strip() or None + run_data_dir = DATA_ROOT / f"run_{int(time.time() * 1000)}" + processes: list[subprocess.Popen] = [] + try: + processes = await start_services_sequential(db_url, run_data_dir) + ok = await run_gate3_checks("http://127.0.0.1:28080", reporting_url="http://127.0.0.1:28009") + if db_url: + print(f"[INFO] DB mode: {db_url}") + sys.exit(0 if ok else 1) + finally: + stop_services(processes) + try: + shutil.rmtree(run_data_dir) + except Exception: + pass + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/scripts/gate4_check.py b/scripts/gate4_check.py new file mode 100644 index 0000000..688e30d --- /dev/null +++ b/scripts/gate4_check.py @@ -0,0 +1,350 @@ +from __future__ import annotations + +import argparse +import asyncio +import os +from pathlib import Path +import shutil +import statistics +import subprocess +import sys +import time +from typing import Any + +import httpx + + +ROOT = Path(__file__).resolve().parents[1] +DATA_ROOT = ROOT / ".data_gate4" + +SERVICE_SPECS = [ + {"name": "auth", "module": "services.auth_service.app:app", "port": 38001}, + {"name": "audit", "module": "services.audit_service.app:app", "port": 38002}, + {"name": "customer", "module": "services.customer_service.app:app", "port": 38003}, + {"name": "interaction", "module": "services.interaction_service.app:app", "port": 38004}, + {"name": "routing", "module": "services.routing_service.app:app", "port": 38005}, + {"name": "voice", "module": "services.voice_adapter_service.app:app", "port": 38006}, + {"name": "telegram", "module": "services.telegram_adapter_service.app:app", "port": 38007}, + {"name": "kb", "module": "services.kb_service.app:app", "port": 38008}, + {"name": "reporting", "module": "services.reporting_service.app:app", "port": 38009}, + {"name": "supervisor", "module": "services.supervisor_service.app:app", "port": 38010}, + {"name": "gateway", "module": "gateway.app:app", "port": 38080}, +] + + +async def wait_for_health(base_url: str, retries: int = 60, delay: float = 0.2) -> None: + async with httpx.AsyncClient(timeout=2) as client: + for _ in range(retries): + try: + response = await client.get(f"{base_url}/health") + if response.status_code == 200: + return + except Exception: + pass + await asyncio.sleep(delay) + raise RuntimeError(f"Service not healthy: {base_url}/health") + + +def start_one_service(spec: dict[str, Any], data_dir: Path) -> subprocess.Popen: + env = os.environ.copy() + env["CC_DATA_DIR"] = str(data_dir) + + if spec["name"] == "gateway": + env["AUTH_SERVICE_URL"] = "http://127.0.0.1:38001" + env["AUDIT_SERVICE_URL"] = "http://127.0.0.1:38002" + env["CUSTOMER_SERVICE_URL"] = "http://127.0.0.1:38003" + env["INTERACTION_SERVICE_URL"] = "http://127.0.0.1:38004" + env["ROUTING_SERVICE_URL"] = "http://127.0.0.1:38005" + env["VOICE_ADAPTER_SERVICE_URL"] = "http://127.0.0.1:38006" + env["TELEGRAM_ADAPTER_SERVICE_URL"] = "http://127.0.0.1:38007" + env["KB_SERVICE_URL"] = "http://127.0.0.1:38008" + env["REPORTING_SERVICE_URL"] = "http://127.0.0.1:38009" + env["SUPERVISOR_SERVICE_URL"] = "http://127.0.0.1:38010" + + cmd = [ + sys.executable, + "-m", + "uvicorn", + spec["module"], + "--host", + "127.0.0.1", + "--port", + str(spec["port"]), + ] + return subprocess.Popen( + cmd, + cwd=str(ROOT), + env=env, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + + +async def start_services_sequential(data_dir: Path) -> list[subprocess.Popen]: + data_dir.mkdir(parents=True, exist_ok=True) + processes: list[subprocess.Popen] = [] + for spec in SERVICE_SPECS: + proc = start_one_service(spec, data_dir) + processes.append(proc) + await wait_for_health(f"http://127.0.0.1:{spec['port']}") + return processes + + +def stop_services(processes: list[subprocess.Popen]) -> None: + for proc in processes: + if proc.poll() is not None: + continue + try: + proc.terminate() + except Exception: + pass + + for proc in processes: + try: + proc.wait(timeout=2) + except Exception: + try: + proc.kill() + except Exception: + pass + + +async def _create_interaction(client: httpx.AsyncClient, channel: str, idx: int) -> tuple[bool, float]: + headers = {"X-User": "operator", "X-Role": "operator"} + payload = { + "channel": channel, + "subject": f"Gate4 load {channel} #{idx}", + "customer_id": None, + "queue_id": "q_gate4", + "priority": 3, + } + started = time.perf_counter() + try: + resp = await client.post("/proxy/interaction/interactions", headers=headers, json=payload, timeout=10) + elapsed = time.perf_counter() - started + return resp.status_code == 200, elapsed + except Exception: + elapsed = time.perf_counter() - started + return False, elapsed + + +async def run_load_wave(base_url: str, voice: int, digital: int) -> dict[str, float | int]: + async with httpx.AsyncClient(base_url=base_url) as client: + tasks = [] + for i in range(voice): + tasks.append(_create_interaction(client, "voice", i)) + for i in range(digital): + tasks.append(_create_interaction(client, "telegram", i)) + results = await asyncio.gather(*tasks) + + ok_times = [t for ok, t in results if ok] + failed = len(results) - len(ok_times) + if ok_times: + p95 = sorted(ok_times)[max(0, int(len(ok_times) * 0.95) - 1)] + avg = statistics.mean(ok_times) + else: + p95 = 0.0 + avg = 0.0 + + return { + "total": len(results), + "success": len(ok_times), + "failed": failed, + "avg_seconds": round(avg, 4), + "p95_seconds": round(p95, 4), + } + + +async def get_interaction_count(base_url: str, limit: int = 1000) -> int: + async with httpx.AsyncClient(base_url=base_url, timeout=10) as client: + response = await client.get(f"/proxy/interaction/interactions?limit={limit}") + response.raise_for_status() + items = response.json() + return len(items) + + +async def run_security_smoke(base_url: str) -> bool: + operator = {"X-User": "operator", "X-Role": "operator"} + + async with httpx.AsyncClient(base_url=base_url, timeout=10) as client: + create_user = await client.post( + "/proxy/auth/users", + headers=operator, + json={ + "username": "sec_denied_user", + "password": "secret123", + "full_name": "Security Denied", + "role": "operator", + }, + ) + if create_user.status_code != 403: + print(f"[FAIL] Security RBAC auth/users expected 403, got {create_user.status_code}") + return False + + create_queue = await client.post( + "/proxy/routing/queues", + json={ + "name": "Denied queue", + "description": "Role check", + "rules": [{"channel": "voice", "priority": 3, "strategy": "round_robin", "sla_seconds": 30}], + }, + ) + if create_queue.status_code != 403: + print(f"[FAIL] Security RBAC routing/queues expected 403, got {create_queue.status_code}") + return False + + kb_create = await client.post( + "/proxy/kb/knowledge/categories", + headers=operator, + json={"name": "Denied KB", "description": "Role check"}, + ) + if kb_create.status_code != 403: + print(f"[FAIL] Security RBAC kb category expected 403, got {kb_create.status_code}") + return False + + print("[PASS] Security smoke checks") + return True + + +def run_backup_restore(source_dir: Path) -> tuple[Path, Path]: + backup_script = ROOT / "scripts" / "backup_data.ps1" + restore_script = ROOT / "scripts" / "restore_data.ps1" + backup_output = DATA_ROOT / f"backups_{int(time.time() * 1000)}" + backup_output.mkdir(parents=True, exist_ok=True) + + backup_cmd = [ + "powershell", + "-ExecutionPolicy", + "Bypass", + "-File", + str(backup_script), + "-SourceDir", + str(source_dir), + "-OutputDir", + str(backup_output), + ] + result = subprocess.run(backup_cmd, cwd=str(ROOT), capture_output=True, text=True, check=False) + if result.returncode != 0: + raise RuntimeError(f"Backup failed: {result.stdout}\n{result.stderr}") + + archives = sorted(backup_output.glob("*.zip"), key=lambda p: p.stat().st_mtime, reverse=True) + if not archives: + raise RuntimeError("Backup archive not created") + archive = archives[0] + + if source_dir.exists(): + shutil.rmtree(source_dir) + + restore_cmd = [ + "powershell", + "-ExecutionPolicy", + "Bypass", + "-File", + str(restore_script), + "-BackupZip", + str(archive), + "-TargetDir", + str(source_dir), + ] + restore = subprocess.run(restore_cmd, cwd=str(ROOT), capture_output=True, text=True, check=False) + if restore.returncode != 0: + raise RuntimeError(f"Restore failed: {restore.stdout}\n{restore.stderr}") + + db_file = source_dir / "mvp_cc.db" + if not db_file.exists(): + raise RuntimeError(f"Restored DB file not found: {db_file}") + + return backup_output, archive + + +def check_docs_bundle() -> bool: + required = [ + ROOT / "docs" / "security" / "checklist.md", + ROOT / "docs" / "releases" / "v1.0.0-mvp.md", + ROOT / "docs" / "gates" / "mvp-pilot-baseline.md", + ROOT / "docs" / "gates" / "p1-p2-defects.md", + ROOT / "docs" / "roadmap" / "05-wave2-backlog.md", + ROOT / "docs" / "runbooks" / "backup-restore.md", + ROOT / "docs" / "runbooks" / "load-test-plan.md", + ROOT / "docs" / "runbooks" / "pilot-uat.md", + ROOT / "docs" / "uat" / "README.md", + ROOT / "docs" / "uat" / "scenario-checklist.md", + ROOT / "docs" / "uat" / "session-template.md", + ROOT / "docs" / "uat" / "signoff-template.md", + ROOT / "docs" / "uat" / "defect-log-template.csv", + ] + missing = [str(path) for path in required if not path.exists()] + if missing: + print(f"[FAIL] Missing required Stage 4 docs: {missing}") + return False + print("[PASS] Stage 4 docs bundle present") + return True + + +async def main() -> None: + parser = argparse.ArgumentParser(description="Gate 4 checker: load, backup/restore, security smoke") + parser.add_argument("--voice", type=int, default=100, help="Concurrent voice requests") + parser.add_argument("--digital", type=int, default=100, help="Concurrent digital requests") + parser.add_argument("--keep-artifacts", action="store_true", help="Keep .data_gate4 run and backup artifacts") + args = parser.parse_args() + + run_data_dir = DATA_ROOT / f"run_{int(time.time() * 1000)}" + backup_output: Path | None = None + processes: list[subprocess.Popen] = [] + try: + processes = await start_services_sequential(run_data_dir) + base_url = "http://127.0.0.1:38080" + + load_result = await run_load_wave(base_url, args.voice, args.digital) + print("[INFO] Load result:", load_result) + if load_result["failed"] != 0 or load_result["success"] != load_result["total"]: + print("[FAIL] Load target not reached with zero errors") + sys.exit(1) + print("[PASS] Load target reached") + + count_before = await get_interaction_count(base_url, limit=max(500, args.voice + args.digital + 100)) + if count_before < (args.voice + args.digital): + print( + f"[FAIL] Interaction count before backup too low: {count_before} < {args.voice + args.digital}" + ) + sys.exit(1) + print(f"[PASS] Interaction count before backup: {count_before}") + + security_ok = await run_security_smoke(base_url) + if not security_ok: + sys.exit(1) + + stop_services(processes) + processes = [] + + backup_output, archive = run_backup_restore(run_data_dir) + print(f"[PASS] Backup/restore scripts completed, archive: {archive}") + + processes = await start_services_sequential(run_data_dir) + count_after = await get_interaction_count(base_url, limit=max(500, args.voice + args.digital + 100)) + if count_after != count_before: + print(f"[FAIL] Restored interaction count mismatch: before={count_before}, after={count_after}") + sys.exit(1) + print(f"[PASS] Restore data integrity validated: {count_after} interactions") + + if not check_docs_bundle(): + sys.exit(1) + + print("[PASS] Gate 4 automated checks completed") + finally: + stop_services(processes) + if not args.keep_artifacts: + try: + if run_data_dir.exists(): + shutil.rmtree(run_data_dir) + except Exception: + pass + try: + if backup_output and backup_output.exists(): + shutil.rmtree(backup_output) + except Exception: + pass + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/scripts/kb_voice_basic_kz_localizations.json b/scripts/kb_voice_basic_kz_localizations.json new file mode 100644 index 0000000..656c413 --- /dev/null +++ b/scripts/kb_voice_basic_kz_localizations.json @@ -0,0 +1,99 @@ +{ + "category_name": "Базовые ответы Voice AI", + "source_language": "ru", + "target_language": "kz", + "localizations": [ + { + "source_title": "График работы", + "title": "Жұмыс кестесі", + "body": "Нақты жұмыс кестесін айту үшін қаланы, филиалды немесе мекенжайды көрсетіңіз. Егер филиалды білмесеңіз, кемінде қаланы айтыңыз, мен ақпаратты нақтылауға көмектесемін немесе қоңырауды операторға бағыттаймын.", + "tags": [ + "жұмыс кестесі", + "жұмыс режимі", + "жұмыс уақыты", + "қашан жұмыс істейсіз", + "қандай жұмыс кестесі", + "жұмыс кестесін білгім келеді", + "жұмыс кестесін білу", + "филиалдың жұмыс уақыты", + "кесте" + ] + }, + { + "source_title": "Статус заявки", + "title": "Өтінім мәртебесі", + "body": "Өтінім мәртебесін тексеру үшін өтінім нөмірін немесе рәсімделген телефон нөмірін атаңыз. Нөмір болмаса, қолмен тексеру үшін қоңырауды операторға бағыттаймын.", + "tags": [ + "өтінім мәртебесі", + "мәртебе", + "өтінім", + "өтінім нөмірі", + "тапсырыс мәртебесі", + "өтінім мәртебесін тексеру", + "өтінім мәртебесін білу", + "менің тапсырысым" + ] + }, + { + "source_title": "Адрес и филиал", + "title": "Мекенжай және филиал", + "body": "Мекенжайды немесе филиалды айту үшін қаланы және мүмкін болса ауданды, көшені немесе бағдарды атаңыз. Егер қолайлы филиалды анықтай алмасам, қоңырауды операторға бағыттаймын.", + "tags": [ + "мекенжай", + "филиал", + "офис", + "бөлімше", + "қайда орналасқансыз", + "сіздерді қалай табамын", + "офистің мекенжайы", + "филиалдың мекенжайы", + "қала" + ] + }, + { + "source_title": "Тарифы и стоимость", + "title": "Тарифтер мен құны", + "body": "Тарифті, бағаны немесе құнын айту үшін сізді қызықтыратын қызметті немесе өнімді атаңыз. Дәл есептеу немесе жеке ұсыныс үшін қоңырау операторға бағытталуы мүмкін.", + "tags": [ + "тариф", + "тарифтер", + "құны", + "баға", + "қанша тұрады", + "қандай баға", + "құнын білу", + "қызмет ақысы", + "қызмет құны" + ] + }, + { + "source_title": "Проблема с услугой", + "title": "Қызметке қатысты мәселе", + "body": "Егер сервис жұмыс істемесе немесе қате шықса, мәселені қысқаша сипаттап, телефон нөмірін, жеке шотты немесе өтінім нөмірін көрсетіңіз. Диагностика үшін қоңырау операторға бағытталуы мүмкін.", + "tags": [ + "жұмыс істемейді", + "қате", + "мәселе", + "ақау", + "болмай тұр", + "интернет жұмыс істемейді", + "қате бар", + "қызмет жұмыс істемейді" + ] + }, + { + "source_title": "Соединение с оператором", + "title": "Оператормен қосу", + "body": "Егер тірі маман керек болса, былай айтыңыз: оператормен қосыңыз, операторға ауыстырыңыз немесе адам керек. Осыдан кейін қоңырау операторға бағытталады.", + "tags": [ + "оператор", + "тірі оператор", + "оператормен қосыңыз", + "операторға ауыстырыңыз", + "оператор керек", + "адам керек", + "оператормен байланыстырыңыз" + ] + } + ] +} diff --git a/scripts/kb_voice_basic_seed.json b/scripts/kb_voice_basic_seed.json new file mode 100644 index 0000000..62ee9b6 --- /dev/null +++ b/scripts/kb_voice_basic_seed.json @@ -0,0 +1,94 @@ +{ + "category": { + "name": "Базовые ответы Voice AI", + "description": "Стартовые customer-facing статьи для голосового AI и ручного наполнения KB." + }, + "articles": [ + { + "title": "График работы", + "body": "Чтобы назвать точный график работы, укажите город, филиал или адрес. Если вы не знаете филиал, скажите хотя бы город, и я помогу уточнить информацию или передам звонок оператору.", + "tags": [ + "график работы", + "режим работы", + "часы работы", + "время работы", + "когда вы работаете", + "какой график работы", + "хочу узнать график работы", + "узнать график работы", + "время работы филиала" + ] + }, + { + "title": "Статус заявки", + "body": "Чтобы проверить статус заявки, назовите номер заявки или телефон, по которому она оформлялась. Если номера нет, я передам звонок оператору для ручной проверки.", + "tags": [ + "статус заявки", + "статус", + "заявка", + "номер заявки", + "статус заказа", + "проверить статус заявки", + "узнать статус заявки", + "мой заказ" + ] + }, + { + "title": "Адрес и филиал", + "body": "Чтобы подсказать адрес или филиал, назовите город и по возможности район, улицу или ориентир. Если подходящий филиал не удастся определить, я передам звонок оператору.", + "tags": [ + "адрес", + "филиал", + "офис", + "отделение", + "где вы находитесь", + "как вас найти", + "адрес офиса", + "адрес филиала", + "город" + ] + }, + { + "title": "Тарифы и стоимость", + "body": "Чтобы подсказать тариф, цену или стоимость, назовите услугу или продукт, который вас интересует. Для точного расчета или персонального предложения звонок может быть передан оператору.", + "tags": [ + "тариф", + "тарифы", + "стоимость", + "цена", + "сколько стоит", + "какая цена", + "узнать стоимость", + "оплата услуг", + "стоимость услуги" + ] + }, + { + "title": "Проблема с услугой", + "body": "Если сервис не работает или появилась ошибка, кратко опишите проблему и укажите номер телефона, лицевого счета или заявки. Для диагностики звонок может быть передан оператору.", + "tags": [ + "не работает", + "ошибка", + "проблема", + "сбой", + "не получается", + "интернет не работает", + "есть ошибка", + "не работает услуга" + ] + }, + { + "title": "Соединение с оператором", + "body": "Если нужен живой специалист, скажите: соедините с оператором, переведите на оператора или нужен человек. После этого звонок будет передан оператору.", + "tags": [ + "оператор", + "живой оператор", + "соедините с оператором", + "переведите на оператора", + "нужен оператор", + "нужен человек", + "свяжите с оператором" + ] + } + ] +} diff --git a/scripts/live_smoke_gate12.py b/scripts/live_smoke_gate12.py new file mode 100644 index 0000000..2033de0 --- /dev/null +++ b/scripts/live_smoke_gate12.py @@ -0,0 +1,251 @@ +from __future__ import annotations + +import argparse +import asyncio +import os +from pathlib import Path +import shutil +import subprocess +import sys +import time +from typing import Any + +import httpx + + +ROOT = Path(__file__).resolve().parents[1] +DATA_ROOT = ROOT / ".data_smoke" + +SERVICE_SPECS = [ + {"name": "auth", "module": "services.auth_service.app:app", "port": 18001}, + {"name": "audit", "module": "services.audit_service.app:app", "port": 18002}, + {"name": "customer", "module": "services.customer_service.app:app", "port": 18003}, + {"name": "interaction", "module": "services.interaction_service.app:app", "port": 18004}, + {"name": "routing", "module": "services.routing_service.app:app", "port": 18005}, + {"name": "voice", "module": "services.voice_adapter_service.app:app", "port": 18006}, + {"name": "telegram", "module": "services.telegram_adapter_service.app:app", "port": 18007}, + {"name": "kb", "module": "services.kb_service.app:app", "port": 18008}, + {"name": "reporting", "module": "services.reporting_service.app:app", "port": 18009}, + {"name": "supervisor", "module": "services.supervisor_service.app:app", "port": 18010}, + {"name": "gateway", "module": "gateway.app:app", "port": 18080}, +] + + +async def wait_for_health(base_url: str, retries: int = 80, delay: float = 0.25) -> None: + async with httpx.AsyncClient(timeout=2) as client: + for _ in range(retries): + try: + response = await client.get(f"{base_url}/health") + if response.status_code == 200: + return + except Exception: + pass + await asyncio.sleep(delay) + raise RuntimeError(f"Service not healthy: {base_url}/health") + + +def start_services(database_url: str | None, data_dir: Path) -> list[subprocess.Popen]: + data_dir.mkdir(parents=True, exist_ok=True) + processes: list[subprocess.Popen] = [] + for spec in SERVICE_SPECS: + env = os.environ.copy() + env["CC_DATA_DIR"] = str(data_dir) + if database_url: + env["DATABASE_URL"] = database_url + + if spec["name"] == "gateway": + env["AUTH_SERVICE_URL"] = "http://127.0.0.1:18001" + env["AUDIT_SERVICE_URL"] = "http://127.0.0.1:18002" + env["CUSTOMER_SERVICE_URL"] = "http://127.0.0.1:18003" + env["INTERACTION_SERVICE_URL"] = "http://127.0.0.1:18004" + env["ROUTING_SERVICE_URL"] = "http://127.0.0.1:18005" + env["VOICE_ADAPTER_SERVICE_URL"] = "http://127.0.0.1:18006" + env["TELEGRAM_ADAPTER_SERVICE_URL"] = "http://127.0.0.1:18007" + env["KB_SERVICE_URL"] = "http://127.0.0.1:18008" + env["REPORTING_SERVICE_URL"] = "http://127.0.0.1:18009" + env["SUPERVISOR_SERVICE_URL"] = "http://127.0.0.1:18010" + + cmd = [ + sys.executable, + "-m", + "uvicorn", + spec["module"], + "--host", + "127.0.0.1", + "--port", + str(spec["port"]), + ] + + proc = subprocess.Popen( + cmd, + cwd=str(ROOT), + env=env, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + processes.append(proc) + + return processes + + +def stop_services(processes: list[subprocess.Popen]) -> None: + for proc in processes: + if proc.poll() is not None: + continue + try: + proc.terminate() + except Exception: + pass + + for proc in processes: + try: + proc.wait(timeout=2) + except Exception: + try: + proc.kill() + except Exception: + pass + + +async def run_gate_checks() -> dict[str, Any]: + for spec in SERVICE_SPECS: + await wait_for_health(f"http://127.0.0.1:{spec['port']}") + + base = "http://127.0.0.1:18080" + admin = {"X-User": "admin", "X-Role": "admin"} + operator = {"X-User": "operator", "X-Role": "operator"} + supervisor = {"X-User": "supervisor", "X-Role": "supervisor"} + + async with httpx.AsyncClient(base_url=base, timeout=10) as client: + health = await client.get("/health") + health.raise_for_status() + + login = await client.post("/proxy/auth/auth/login", json={"username": "admin", "password": "admin123"}) + login.raise_for_status() + + queue = await client.post( + "/proxy/routing/queues", + headers=admin, + json={ + "name": "Main Queue", + "description": "Smoke queue", + "rules": [ + {"channel": "voice", "priority": 3, "strategy": "round_robin", "sla_seconds": 30}, + {"channel": "telegram", "priority": 3, "strategy": "round_robin", "sla_seconds": 45}, + ], + }, + ) + queue.raise_for_status() + queue_id = queue.json()["queue_id"] + + customer = await client.post( + "/proxy/customer/customers", + json={ + "display_name": "Gate User", + "phones": ["+77010000011"], + "preferred_phone": "+77010000011", + "tags": ["smoke"], + }, + ) + customer.raise_for_status() + customer_id = customer.json()["customer_id"] + + interaction = await client.post( + "/proxy/interaction/interactions", + headers=operator, + json={ + "channel": "voice", + "subject": "Gate voice check", + "customer_id": customer_id, + "queue_id": queue_id, + "priority": 3, + }, + ) + interaction.raise_for_status() + interaction_id = interaction.json()["interaction_id"] + + assign = await client.patch( + f"/proxy/interaction/interactions/{interaction_id}/assign", + headers=supervisor, + json={"assignee": "operator_a"}, + ) + assign.raise_for_status() + + escalate = await client.post( + f"/proxy/interaction/interactions/{interaction_id}/escalate", + headers=operator, + json={"target_queue_id": "line2"}, + ) + escalate.raise_for_status() + + close = await client.patch( + f"/proxy/interaction/interactions/{interaction_id}/status", + headers=operator, + json={"status": "closed"}, + ) + close.raise_for_status() + + voice_event = await client.post( + "/proxy/voice/integrations/voice/events", + headers=operator, + json={ + "event_type": "call.started", + "call_id": "smoke_call_1", + "interaction_id": interaction_id, + "payload": {"source": "live_smoke"}, + }, + ) + voice_event.raise_for_status() + + tg_event = await client.post( + "/proxy/telegram/integrations/telegram/webhook", + json={ + "chat_id": "smoke_chat", + "text": "Smoke message", + "customer_external_id": None, + "payload": {"source": "live_smoke"}, + }, + ) + tg_event.raise_for_status() + + interactions = await client.get("/proxy/interaction/interactions") + interactions.raise_for_status() + rows = interactions.json() + + return { + "gateway": health.json(), + "queue_id": queue_id, + "customer_id": customer_id, + "interaction_id": interaction_id, + "interactions_total": len(rows), + } + + +async def main() -> None: + parser = argparse.ArgumentParser(description="Live Gate 1/2 smoke") + parser.add_argument( + "--database-url", + default=os.getenv("DATABASE_URL", ""), + help="Optional DB URL override for started services", + ) + args = parser.parse_args() + + db_url = args.database_url.strip() or None + run_data_dir = DATA_ROOT / f"run_{int(time.time() * 1000)}" + processes = start_services(db_url, run_data_dir) + try: + result = await run_gate_checks() + print("Gate 1/2 live smoke passed") + print(result) + if db_url: + print(f"DB mode: {db_url}") + finally: + stop_services(processes) + try: + shutil.rmtree(run_data_dir) + except Exception: + pass + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/scripts/load_test.py b/scripts/load_test.py new file mode 100644 index 0000000..9135f8d --- /dev/null +++ b/scripts/load_test.py @@ -0,0 +1,452 @@ +from __future__ import annotations + +import argparse +import asyncio +import csv +import json +import math +import statistics +import time +from collections import Counter +from pathlib import Path +from typing import Any + +import httpx + + +PROFILE_DEFAULTS = { + "baseline_100_100": {"voice": 100, "digital": 100, "ramp_seconds": 15, "hold_seconds": 60}, + "step_250_250": {"voice": 250, "digital": 250, "ramp_seconds": 30, "hold_seconds": 120}, + "target_500_500": {"voice": 500, "digital": 500, "ramp_seconds": 60, "hold_seconds": 180}, +} +DIGITAL_DISTRIBUTION = (("telegram", 0.4), ("webchat", 0.3), ("email", 0.3)) +READ_TRAFFIC_INTERVAL_SECONDS = 1.0 +MAX_ERROR_SAMPLES = 200 +WORKER_PAUSE_SECONDS = 1.0 + + +def resolve_profile( + name: str, + ramp_seconds: int | None = None, + hold_seconds: int | None = None, + voice: int | None = None, + digital: int | None = None, +) -> dict[str, int | str]: + if name not in PROFILE_DEFAULTS: + raise ValueError(f"Unsupported profile: {name}") + profile = dict(PROFILE_DEFAULTS[name]) + profile["profile"] = name + if ramp_seconds is not None: + profile["ramp_seconds"] = ramp_seconds + if hold_seconds is not None: + profile["hold_seconds"] = hold_seconds + if voice is not None: + profile["voice"] = voice + if digital is not None: + profile["digital"] = digital + return profile + + +def split_digital_mix(total: int) -> dict[str, int]: + remaining = total + result: dict[str, int] = {} + for idx, (name, ratio) in enumerate(DIGITAL_DISTRIBUTION): + if idx == len(DIGITAL_DISTRIBUTION) - 1: + count = remaining + else: + count = int(round(total * ratio)) + remaining -= count + result[name] = count + return result + + +def build_mix_profile(profile: dict[str, int | str], include_read_traffic: bool) -> dict[str, Any]: + voice = int(profile["voice"]) + digital = int(profile["digital"]) + digital_mix = split_digital_mix(digital) + payload = { + "profile": profile["profile"], + "voice_workers": voice, + "digital_workers": digital, + "ramp_seconds": int(profile["ramp_seconds"]), + "hold_seconds": int(profile["hold_seconds"]), + "channels": {"voice": voice, **digital_mix}, + "include_read_traffic": include_read_traffic, + "read_endpoints": [ + "/proxy/supervisor/supervisor/realtime", + "/proxy/reporting/reports/kpi", + ] + if include_read_traffic + else [], + } + return payload + + +def _build_voice_payload(idx: int) -> tuple[str, str, dict[str, Any]]: + return ( + "POST", + "/proxy/interaction/interactions", + { + "channel": "voice", + "subject": f"Track7 voice load #{idx}", + "customer_id": None, + "queue_id": "q_load_voice", + "priority": 3, + }, + ) + + +def _build_telegram_payload(idx: int) -> tuple[str, str, dict[str, Any]]: + return ( + "POST", + "/proxy/telegram/integrations/telegram/webhook", + { + "chat_id": f"load_chat_{idx}", + "text": f"Track7 telegram load #{idx}", + "customer_external_id": None, + "payload": {"source": "track7-load"}, + }, + ) + + +def _build_webchat_payload(idx: int) -> tuple[str, str, dict[str, Any]]: + return ( + "POST", + "/proxy/webchat/integrations/webchat/messages", + { + "session_id": f"load_session_{idx}", + "text": f"Track7 webchat load #{idx}", + "visitor_name": "Load Test Visitor", + "customer_external_id": None, + "queue_id": "q_load_webchat", + "priority": 3, + "payload": {"source": "track7-load"}, + }, + ) + + +def _build_email_payload(idx: int) -> tuple[str, str, dict[str, Any]]: + return ( + "POST", + "/proxy/email/integrations/email/messages", + { + "from_email": f"load{idx}@example.test", + "subject": f"Track7 email load #{idx}", + "body": "Synthetic email for scale validation.", + "customer_external_id": None, + "queue_id": "q_load_email", + "priority": 3, + "payload": {"source": "track7-load"}, + }, + ) + + +def build_request(target: str, idx: int) -> tuple[str, str, dict[str, Any]]: + if target == "voice": + return _build_voice_payload(idx) + if target == "telegram": + return _build_telegram_payload(idx) + if target == "webchat": + return _build_webchat_payload(idx) + if target == "email": + return _build_email_payload(idx) + raise ValueError(f"Unsupported target: {target}") + + +def build_worker_targets(profile: dict[str, int | str]) -> list[str]: + targets = ["voice"] * int(profile["voice"]) + digital_mix = split_digital_mix(int(profile["digital"])) + for name, count in digital_mix.items(): + targets.extend([name] * count) + return targets + + +def percentile(samples: list[float], ratio: float) -> float: + if not samples: + return 0.0 + sorted_samples = sorted(samples) + index = max(0, math.ceil(len(sorted_samples) * ratio) - 1) + return sorted_samples[index] + + +def ensure_report_dir(report_dir: str | None = None) -> Path: + if report_dir: + target = Path(report_dir) + else: + timestamp = time.strftime("%Y%m%d_%H%M%S") + target = Path(".artifacts") / "track7" / timestamp + target.mkdir(parents=True, exist_ok=True) + return target + + +async def build_auth_headers(client: httpx.AsyncClient, auth_mode: str) -> dict[str, str]: + if auth_mode == "legacy_headers": + return {"X-User": "admin", "X-Role": "admin"} + if auth_mode != "bearer": + raise ValueError(f"Unsupported auth mode: {auth_mode}") + + response = await client.post( + "/proxy/auth/auth/login", + json={"username": "admin", "password": "admin123"}, + timeout=10, + ) + response.raise_for_status() + token = response.json()["access_token"] + return {"Authorization": f"Bearer {token}"} + + +def _record_result( + results: list[dict[str, Any]], + errors: list[dict[str, Any]], + target: str, + started_at: float, + elapsed: float, + status_code: int, + ok: bool, + error_message: str | None = None, +) -> None: + results.append( + { + "ts": round(started_at, 6), + "target": target, + "status_code": status_code, + "latency_seconds": round(elapsed, 6), + "ok": ok, + } + ) + if (not ok or status_code >= 500 or error_message) and len(errors) < MAX_ERROR_SAMPLES: + errors.append( + { + "ts": round(started_at, 6), + "target": target, + "status_code": status_code, + "latency_seconds": round(elapsed, 6), + "error": error_message, + } + ) + + +async def _run_worker( + client: httpx.AsyncClient, + headers: dict[str, str], + target: str, + idx: int, + stop_event: asyncio.Event, + results: list[dict[str, Any]], + errors: list[dict[str, Any]], +) -> None: + method, path, payload = build_request(target, idx) + while not stop_event.is_set(): + started = time.perf_counter() + try: + response = await client.request(method, path, headers=headers, json=payload, timeout=20) + elapsed = time.perf_counter() - started + ok = response.status_code < 500 and response.status_code < 400 + _record_result(results, errors, target, started, elapsed, response.status_code, ok) + except Exception as exc: + elapsed = time.perf_counter() - started + _record_result(results, errors, target, started, elapsed, 0, False, str(exc)) + await asyncio.sleep(WORKER_PAUSE_SECONDS) + + +async def _run_read_probe( + client: httpx.AsyncClient, + headers: dict[str, str], + path: str, + stop_event: asyncio.Event, + results: list[dict[str, Any]], + errors: list[dict[str, Any]], +) -> None: + target = f"read:{path.rsplit('/', 1)[-1]}" + while not stop_event.is_set(): + started = time.perf_counter() + try: + response = await client.get(path, headers=headers, timeout=20) + elapsed = time.perf_counter() - started + ok = response.status_code < 500 and response.status_code < 400 + _record_result(results, errors, target, started, elapsed, response.status_code, ok) + except Exception as exc: + elapsed = time.perf_counter() - started + _record_result(results, errors, target, started, elapsed, 0, False, str(exc)) + await asyncio.sleep(READ_TRAFFIC_INTERVAL_SECONDS) + + +def build_summary( + results: list[dict[str, Any]], + mix_profile: dict[str, Any], + thresholds: dict[str, float | None], + started_at: str, + completed_at: str, +) -> dict[str, Any]: + latencies = [item["latency_seconds"] for item in results] + total = len(results) + success = sum(1 for item in results if item["ok"]) + failures = total - success + five_xx = sum(1 for item in results if int(item["status_code"]) >= 500 or int(item["status_code"]) == 0) + by_target = Counter(item["target"] for item in results) + summary = { + "profile": mix_profile["profile"], + "started_at": started_at, + "completed_at": completed_at, + "traffic": mix_profile, + "results": { + "total_requests": total, + "success": success, + "failed": failures, + "five_xx_or_transport": five_xx, + "success_rate": round((success / total) * 100, 2) if total else 0.0, + "five_xx_rate": round((five_xx / total) * 100, 2) if total else 0.0, + "avg_seconds": round(statistics.mean(latencies), 4) if latencies else 0.0, + "p95_seconds": round(percentile(latencies, 0.95), 4) if latencies else 0.0, + "p99_seconds": round(percentile(latencies, 0.99), 4) if latencies else 0.0, + "by_target": dict(by_target), + }, + "thresholds": thresholds, + } + summary["passed"] = evaluate_thresholds(summary) + return summary + + +def evaluate_thresholds(summary: dict[str, Any]) -> bool: + results = summary["results"] + thresholds = summary["thresholds"] + if thresholds.get("require_success_rate") is not None and results["success_rate"] < thresholds["require_success_rate"]: + return False + if thresholds.get("require_p95_seconds") is not None and results["p95_seconds"] > thresholds["require_p95_seconds"]: + return False + if thresholds.get("require_p99_seconds") is not None and results["p99_seconds"] > thresholds["require_p99_seconds"]: + return False + return True + + +def write_report(report_dir: Path, summary: dict[str, Any], results: list[dict[str, Any]], errors: list[dict[str, Any]]) -> None: + (report_dir / "summary.json").write_text(json.dumps(summary, indent=2), encoding="utf-8") + (report_dir / "mix_profile.json").write_text(json.dumps(summary["traffic"], indent=2), encoding="utf-8") + (report_dir / "error_samples.json").write_text(json.dumps(errors, indent=2), encoding="utf-8") + with (report_dir / "latency_samples.csv").open("w", encoding="utf-8", newline="") as handle: + writer = csv.DictWriter(handle, fieldnames=["ts", "target", "status_code", "latency_seconds", "ok"]) + writer.writeheader() + writer.writerows(results) + + +async def run_profile( + *, + base_url: str, + profile_name: str, + ramp_seconds: int | None, + hold_seconds: int | None, + voice: int | None, + digital: int | None, + include_read_traffic: bool, + auth_mode: str, + report_dir: str | None, + require_success_rate: float | None, + require_p95_seconds: float | None, + require_p99_seconds: float | None, +) -> tuple[dict[str, Any], Path]: + profile = resolve_profile( + profile_name, + ramp_seconds=ramp_seconds, + hold_seconds=hold_seconds, + voice=voice, + digital=digital, + ) + mix_profile = build_mix_profile(profile, include_read_traffic) + out_dir = ensure_report_dir(report_dir) + started_at_iso = time.strftime("%Y-%m-%dT%H:%M:%S") + results: list[dict[str, Any]] = [] + errors: list[dict[str, Any]] = [] + stop_event = asyncio.Event() + worker_targets = build_worker_targets(profile) + + async with httpx.AsyncClient(base_url=base_url) as client: + headers = await build_auth_headers(client, auth_mode) + workers: list[asyncio.Task[None]] = [] + ramp_window = max(1, int(profile["ramp_seconds"])) + spacing = ramp_window / max(1, len(worker_targets)) + for idx, target in enumerate(worker_targets): + workers.append(asyncio.create_task(_run_worker(client, headers, target, idx, stop_event, results, errors))) + if spacing > 0: + await asyncio.sleep(spacing) + + read_tasks: list[asyncio.Task[None]] = [] + if include_read_traffic: + read_tasks = [ + asyncio.create_task( + _run_read_probe(client, headers, "/proxy/supervisor/supervisor/realtime", stop_event, results, errors) + ), + asyncio.create_task( + _run_read_probe(client, headers, "/proxy/reporting/reports/kpi", stop_event, results, errors) + ), + ] + + await asyncio.sleep(int(profile["hold_seconds"])) + stop_event.set() + await asyncio.gather(*workers, *read_tasks, return_exceptions=True) + + completed_at_iso = time.strftime("%Y-%m-%dT%H:%M:%S") + thresholds = { + "require_success_rate": require_success_rate, + "require_p95_seconds": require_p95_seconds, + "require_p99_seconds": require_p99_seconds, + } + summary = build_summary(results, mix_profile, thresholds, started_at_iso, completed_at_iso) + write_report(out_dir, summary, results, errors) + return summary, out_dir + + +async def main() -> None: + parser = argparse.ArgumentParser(description="Track 7 mixed workload load harness through the gateway.") + parser.add_argument("--base-url", default="http://localhost:8080", help="Gateway URL") + parser.add_argument( + "--profile", + default="baseline_100_100", + choices=sorted(PROFILE_DEFAULTS), + help="Named load profile", + ) + parser.add_argument("--ramp-seconds", type=int, default=None, help="Override ramp duration") + parser.add_argument("--hold-seconds", type=int, default=None, help="Override hold duration") + parser.add_argument("--voice", type=int, default=None, help="Backward-compatible voice worker override") + parser.add_argument("--digital", type=int, default=None, help="Backward-compatible digital worker override") + parser.add_argument("--report-dir", default=None, help="Directory for JSON/CSV reports") + parser.add_argument("--require-success-rate", type=float, default=None, help="Optional success-rate threshold") + parser.add_argument("--require-p95-seconds", type=float, default=None, help="Optional p95 threshold") + parser.add_argument("--require-p99-seconds", type=float, default=None, help="Optional p99 threshold") + parser.add_argument( + "--include-read-traffic", + type=int, + choices=[0, 1], + default=1, + help="Include background supervisor/reporting reads during the hold window", + ) + parser.add_argument( + "--auth-mode", + choices=["legacy_headers", "bearer"], + default="legacy_headers", + help="Authentication mode for load traffic", + ) + args = parser.parse_args() + + summary, out_dir = await run_profile( + base_url=args.base_url, + profile_name=args.profile, + ramp_seconds=args.ramp_seconds, + hold_seconds=args.hold_seconds, + voice=args.voice, + digital=args.digital, + include_read_traffic=bool(args.include_read_traffic), + auth_mode=args.auth_mode, + report_dir=args.report_dir, + require_success_rate=args.require_success_rate, + require_p95_seconds=args.require_p95_seconds, + require_p99_seconds=args.require_p99_seconds, + ) + print("Track 7 load test result:") + print(json.dumps(summary, indent=2)) + print(f"Report dir: {out_dir}") + if not summary["passed"]: + raise SystemExit(1) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/scripts/local_stack.py b/scripts/local_stack.py new file mode 100644 index 0000000..680fa3e --- /dev/null +++ b/scripts/local_stack.py @@ -0,0 +1,567 @@ +from __future__ import annotations + +import argparse +from datetime import datetime, timezone +import json +import os +from pathlib import Path +import signal +import subprocess +import sys +import time +from typing import Any +from urllib.error import URLError +from urllib.request import urlopen + + +ROOT = Path(__file__).resolve().parents[1] +DEFAULT_RUNTIME_DIR = ROOT / ".local_stack" +DEFAULT_DATA_DIR = ROOT / ".data_local" +DEFAULT_ENV_FILES = [ + ROOT / ".env.production", + ROOT / ".env.local", + ROOT / ".env", +] + + +def _env_int(name: str, default: int, env: dict[str, str] | None = None) -> int: + source = env or os.environ + raw = str(source.get(name, "")).strip() + if not raw: + return default + try: + value = int(raw) + except ValueError as exc: + raise RuntimeError(f"{name} must be an integer") from exc + if value <= 0: + raise RuntimeError(f"{name} must be positive") + return value + + +def utc_now_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +def load_env_file(path: Path) -> dict[str, str]: + if not path.exists(): + return {} + result: dict[str, str] = {} + for raw_line in path.read_text(encoding="utf-8").splitlines(): + line = raw_line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + key = key.strip() + value = value.strip() + if not key: + continue + if value and value[0] == value[-1] and value[0] in {'"', "'"}: + value = value[1:-1] + result[key] = value + return result + + +def load_stack_env(extra_env_files: list[Path] | None = None) -> dict[str, str]: + env = os.environ.copy() + for path in DEFAULT_ENV_FILES: + for key, value in load_env_file(path).items(): + env.setdefault(key, value) + for path in extra_env_files or []: + for key, value in load_env_file(path).items(): + env[key] = value + return env + + +def build_service_specs(env: dict[str, str] | None = None) -> list[dict[str, Any]]: + gateway_port = _env_int("LOCAL_STACK_GATEWAY_PORT", 8080, env) + return [ + {"name": "auth", "port": 8001, "module": "services.auth_service.app:app"}, + {"name": "audit", "port": 8002, "module": "services.audit_service.app:app"}, + {"name": "customer", "port": 8003, "module": "services.customer_service.app:app"}, + {"name": "interaction", "port": 8004, "module": "services.interaction_service.app:app"}, + {"name": "routing", "port": 8005, "module": "services.routing_service.app:app"}, + {"name": "voice", "port": 8006, "module": "services.voice_adapter_service.app:app"}, + {"name": "recording", "port": 8013, "module": "services.recording_service.app:app"}, + {"name": "ivr", "port": 8014, "module": "services.ivr_service.app:app"}, + {"name": "event-bus", "port": 8015, "module": "services.event_bus_service.app:app"}, + {"name": "asterisk-bridge", "port": 8016, "module": "services.asterisk_bridge_service.app:app"}, + {"name": "telegram", "port": 8007, "module": "services.telegram_adapter_service.app:app"}, + {"name": "whatsapp", "port": 8019, "module": "services.whatsapp_adapter_service.app:app"}, + {"name": "ai", "port": 8017, "module": "services.ai_orchestrator_service.app:app"}, + {"name": "ai-voice-runtime", "port": 8018, "module": "services.ai_voice_runtime_service.app:app"}, + {"name": "webchat", "port": 8011, "module": "services.webchat_adapter_service.app:app"}, + {"name": "email", "port": 8012, "module": "services.email_adapter_service.app:app"}, + {"name": "kb", "port": 8008, "module": "services.kb_service.app:app"}, + {"name": "reporting", "port": 8009, "module": "services.reporting_service.app:app"}, + {"name": "supervisor", "port": 8010, "module": "services.supervisor_service.app:app"}, + {"name": "gateway", "port": gateway_port, "module": "gateway.app:app"}, + ] + +def build_gateway_env(env: dict[str, str] | None = None) -> dict[str, str]: + ports = {spec["name"]: int(spec["port"]) for spec in build_service_specs(env)} + base = "http://127.0.0.1" + return { + "AUTH_SERVICE_URL": f"{base}:{ports['auth']}", + "AUDIT_SERVICE_URL": f"{base}:{ports['audit']}", + "CUSTOMER_SERVICE_URL": f"{base}:{ports['customer']}", + "INTERACTION_SERVICE_URL": f"{base}:{ports['interaction']}", + "ROUTING_SERVICE_URL": f"{base}:{ports['routing']}", + "VOICE_ADAPTER_SERVICE_URL": f"{base}:{ports['voice']}", + "RECORDING_SERVICE_URL": f"{base}:{ports['recording']}", + "IVR_SERVICE_URL": f"{base}:{ports['ivr']}", + "EVENT_BUS_SERVICE_URL": f"{base}:{ports['event-bus']}", + "ASTERISK_BRIDGE_SERVICE_URL": f"{base}:{ports['asterisk-bridge']}", + "TELEGRAM_ADAPTER_SERVICE_URL": f"{base}:{ports['telegram']}", + "WHATSAPP_ADAPTER_SERVICE_URL": f"{base}:{ports['whatsapp']}", + "AI_ORCHESTRATOR_SERVICE_URL": f"{base}:{ports['ai']}", + "AI_VOICE_RUNTIME_SERVICE_URL": f"{base}:{ports['ai-voice-runtime']}", + "WEBCHAT_ADAPTER_SERVICE_URL": f"{base}:{ports['webchat']}", + "EMAIL_ADAPTER_SERVICE_URL": f"{base}:{ports['email']}", + "KB_SERVICE_URL": f"{base}:{ports['kb']}", + "REPORTING_SERVICE_URL": f"{base}:{ports['reporting']}", + "SUPERVISOR_SERVICE_URL": f"{base}:{ports['supervisor']}", + } + + +def service_bind_host(spec_name: str, env: dict[str, str]) -> str: + if spec_name == "gateway": + return env.get("LOCAL_STACK_GATEWAY_HOST", "127.0.0.1").strip() or "127.0.0.1" + return env.get("LOCAL_STACK_SERVICE_HOST", "127.0.0.1").strip() or "127.0.0.1" + + +def service_health_host(spec_name: str, env: dict[str, str]) -> str: + if spec_name == "gateway": + configured = env.get("LOCAL_STACK_GATEWAY_HEALTH_HOST", "").strip() + if configured: + return configured + bind_host = service_bind_host(spec_name, env) + if bind_host in {"0.0.0.0", "::"}: + return "127.0.0.1" + return bind_host + + +def public_base_url(env: dict[str, str] | None = None) -> str: + current_env = env or {} + default_url = "http://127.0.0.1:8080" + return current_env.get("LOCAL_STACK_PUBLIC_BASE_URL", default_url).strip() or default_url + + +def build_manifest_payload( + runtime_dir: Path, + data_dir: Path, + services: list[dict[str, Any]], + env: dict[str, str] | None = None, +) -> dict[str, Any]: + return { + "generated_at": utc_now_iso(), + "root": str(ROOT), + "runtime_dir": str(runtime_dir), + "data_dir": str(data_dir), + "base_url": public_base_url(env), + "services": services, + } + + +def manifest_path(runtime_dir: Path) -> Path: + return runtime_dir / "manifest.json" + + +def load_manifest(runtime_dir: Path) -> dict[str, Any] | None: + path = manifest_path(runtime_dir) + if not path.exists(): + return None + return json.loads(path.read_text(encoding="utf-8")) + + +def save_manifest(runtime_dir: Path, payload: dict[str, Any]) -> None: + runtime_dir.mkdir(parents=True, exist_ok=True) + manifest_path(runtime_dir).write_text(json.dumps(payload, indent=2), encoding="utf-8") + + +def remove_manifest(runtime_dir: Path) -> None: + path = manifest_path(runtime_dir) + if path.exists(): + path.unlink() + + +def is_pid_running(pid: int) -> bool: + if pid <= 0: + return False + if os.name == "nt": + result = subprocess.run( + ["tasklist", "/FI", f"PID eq {pid}"], + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + text=True, + check=False, + ) + if result.returncode != 0: + return False + return str(pid) in (result.stdout or "") + try: + os.kill(pid, 0) + except (OSError, SystemError): + return False + return True + + +def resolve_listener_pid(port: int) -> int | None: + if os.name == "nt": + result = subprocess.run( + ["netstat", "-ano", "-p", "tcp"], + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + text=True, + check=False, + ) + if result.returncode != 0: + return None + markers = {f"127.0.0.1:{port}", f"0.0.0.0:{port}", f"[::]:{port}"} + for raw_line in (result.stdout or "").splitlines(): + line = raw_line.strip() + if "LISTENING" not in line: + continue + parts = line.split() + if len(parts) < 5: + continue + local_address = parts[1] + state = parts[3] + pid_raw = parts[4] + if local_address not in markers or state != "LISTENING": + continue + try: + pid = int(pid_raw) + except ValueError: + continue + if pid > 0: + return pid + return None + return None + + +def wait_for_health(host: str, port: int, retries: int = 80, delay: float = 0.25) -> None: + url = f"http://{host}:{port}/health" + for _ in range(retries): + try: + with urlopen(url, timeout=1.5) as response: # noqa: S310 - local health probe + if response.status == 200: + return + except (OSError, URLError): + pass + time.sleep(delay) + raise RuntimeError(f"Service did not become healthy: {url}") + + +def create_log_paths(runtime_dir: Path, name: str) -> tuple[Path, Path]: + log_dir = runtime_dir / "logs" + log_dir.mkdir(parents=True, exist_ok=True) + return log_dir / f"{name}.stdout.log", log_dir / f"{name}.stderr.log" + + +def spawn_service(spec: dict[str, Any], runtime_dir: Path, data_dir: Path, base_env: dict[str, str]) -> dict[str, Any]: + env = base_env.copy() + env["CC_DATA_DIR"] = str(data_dir) + env["EVENT_BUS_ENABLED"] = env.get("EVENT_BUS_ENABLED", "0") + env["EVENT_BUS_URL"] = env.get("EVENT_BUS_URL", "amqp://guest:guest@127.0.0.1:5672/") + env["EVENT_BUS_EXCHANGE"] = env.get("EVENT_BUS_EXCHANGE", "mvpcc.domain.events") + env["EVENT_BUS_DISPATCH_BATCH_SIZE"] = env.get("EVENT_BUS_DISPATCH_BATCH_SIZE", "50") + env["EVENT_BUS_MAX_ATTEMPTS"] = env.get("EVENT_BUS_MAX_ATTEMPTS", "5") + env["EVENT_BUS_POLL_INTERVAL_SECONDS"] = env.get("EVENT_BUS_POLL_INTERVAL_SECONDS", "2") + env["EVENT_BUS_CONSUMER_ENABLED"] = env.get("EVENT_BUS_CONSUMER_ENABLED", "1") + env["EVENT_BUS_AUDIT_QUEUE"] = env.get("EVENT_BUS_AUDIT_QUEUE", "mvpcc.audit.events") + env["EVENT_BUS_REPORTING_QUEUE"] = env.get("EVENT_BUS_REPORTING_QUEUE", "mvpcc.reporting.events") + env["ASTERISK_BRIDGE_ENABLED"] = env.get("ASTERISK_BRIDGE_ENABLED", "0") + env["ASTERISK_AMI_EVENT_PREFIX"] = env.get("ASTERISK_AMI_EVENT_PREFIX", "MVPCC") + env["ASTERISK_QUEUE_MAP_JSON"] = env.get("ASTERISK_QUEUE_MAP_JSON", "{}") + env["ASTERISK_CALLCONTROL_ENABLED"] = env.get("ASTERISK_CALLCONTROL_ENABLED", "0") + env["ASTERISK_CALLCONTROL_ACTION_TIMEOUT_SECONDS"] = env.get( + "ASTERISK_CALLCONTROL_ACTION_TIMEOUT_SECONDS", + "10", + ) + env["ASTERISK_WEBRTC_ENABLED"] = env.get("ASTERISK_WEBRTC_ENABLED", "0") + env["ASTERISK_WEBRTC_WS_URL"] = env.get("ASTERISK_WEBRTC_WS_URL", "") + env["ASTERISK_WEBRTC_ICE_SERVERS_JSON"] = env.get("ASTERISK_WEBRTC_ICE_SERVERS_JSON", "[]") + env["ASTERISK_OPERATOR_EXTENSION_MAP_JSON"] = env.get("ASTERISK_OPERATOR_EXTENSION_MAP_JSON", "{}") + env["ASTERISK_BROWSER_SIP_MAP_JSON"] = env.get("ASTERISK_BROWSER_SIP_MAP_JSON", "{}") + env["ASTERISK_TRANSFER_TARGET_MAP_JSON"] = env.get("ASTERISK_TRANSFER_TARGET_MAP_JSON", "{}") + env["AI_PROVIDER"] = env.get("AI_PROVIDER", "stub") + env["AI_API_BASE"] = env.get("AI_API_BASE", "") + env["AI_API_KEY"] = env.get("AI_API_KEY", "") + env["AI_MODEL"] = env.get("AI_MODEL", "stub-telegram-assistant") + env["AI_TIMEOUT_SECONDS"] = env.get("AI_TIMEOUT_SECONDS", "20") + env["AI_TELEGRAM_ENABLED"] = env.get("AI_TELEGRAM_ENABLED", "0") + env["AI_TELEGRAM_ALWAYS_REPLY"] = env.get("AI_TELEGRAM_ALWAYS_REPLY", "0") + env["AI_TELEGRAM_MAX_CONTEXT_MESSAGES"] = env.get("AI_TELEGRAM_MAX_CONTEXT_MESSAGES", "20") + env["AI_TELEGRAM_MAX_KB_RESULTS"] = env.get("AI_TELEGRAM_MAX_KB_RESULTS", "3") + env["AI_TELEGRAM_CONFIDENCE_HANDOFF_THRESHOLD"] = env.get( + "AI_TELEGRAM_CONFIDENCE_HANDOFF_THRESHOLD", + "0.65", + ) + env["AI_WHATSAPP_ENABLED"] = env.get("AI_WHATSAPP_ENABLED", "0") + env["AI_WHATSAPP_ALWAYS_REPLY"] = env.get("AI_WHATSAPP_ALWAYS_REPLY", "0") + env["AI_WHATSAPP_MAX_CONTEXT_MESSAGES"] = env.get("AI_WHATSAPP_MAX_CONTEXT_MESSAGES", "20") + env["AI_WHATSAPP_MAX_KB_RESULTS"] = env.get("AI_WHATSAPP_MAX_KB_RESULTS", "3") + env["AI_WHATSAPP_CONFIDENCE_HANDOFF_THRESHOLD"] = env.get( + "AI_WHATSAPP_CONFIDENCE_HANDOFF_THRESHOLD", + "0.65", + ) + env["AI_VOICE_RUNTIME_SERVICE_URL"] = env.get("AI_VOICE_RUNTIME_SERVICE_URL", "http://127.0.0.1:8018") + env["AI_VOICE_ENABLED"] = env.get("AI_VOICE_ENABLED", "0") + env["AI_VOICE_QUEUE_CONFIG_JSON"] = env.get("AI_VOICE_QUEUE_CONFIG_JSON", "{}") + env["AI_VOICE_ASR_PROVIDER"] = env.get("AI_VOICE_ASR_PROVIDER", "openai") + env["AI_VOICE_TTS_PROVIDER"] = env.get("AI_VOICE_TTS_PROVIDER", "openai") + env["AI_VOICE_MAX_CONTEXT_SEGMENTS"] = env.get("AI_VOICE_MAX_CONTEXT_SEGMENTS", "8") + env["AI_VOICE_HANDOFF_TIMEOUT_SECONDS"] = env.get("AI_VOICE_HANDOFF_TIMEOUT_SECONDS", "8") + env["AI_VOICE_RUNTIME_TRUSTED_SERVICE_SUBJECTS"] = env.get( + "AI_VOICE_RUNTIME_TRUSTED_SERVICE_SUBJECTS", + "svc:ai-voice-runtime", + ) + env["ASTERISK_CALLCONTROL_CLAIM_CONTEXT"] = env.get("ASTERISK_CALLCONTROL_CLAIM_CONTEXT", "mvpcc-claim") + env["ASTERISK_CALLCONTROL_TRANSFER_CONTEXT"] = env.get( + "ASTERISK_CALLCONTROL_TRANSFER_CONTEXT", + "mvpcc-transfer", + ) + if spec["name"] == "recording": + env["CC_RECORDINGS_DIR"] = str((data_dir / "recordings").resolve()) + env["RECORDING_MAX_BYTES"] = env.get("RECORDING_MAX_BYTES", "26214400") + if spec["name"] == "gateway": + env.update(build_gateway_env(env)) + + stdout_path, stderr_path = create_log_paths(runtime_dir, str(spec["name"])) + creationflags = 0 + if os.name == "nt": + creationflags = getattr(subprocess, "CREATE_NO_WINDOW", 0) + + cmd = [ + sys.executable, + "-m", + "uvicorn", + str(spec["module"]), + "--host", + service_bind_host(str(spec["name"]), env), + "--port", + str(spec["port"]), + ] + + with stdout_path.open("ab") as stdout_file, stderr_path.open("ab") as stderr_file: + proc = subprocess.Popen( + cmd, + cwd=str(ROOT), + env=env, + stdin=subprocess.DEVNULL, + stdout=stdout_file, + stderr=stderr_file, + creationflags=creationflags, + ) + + time.sleep(0.15) + if proc.poll() is not None: + raise RuntimeError(f"{spec['name']} failed to start; check {stderr_path}") + + return { + "name": spec["name"], + "module": spec["module"], + "port": spec["port"], + "pid": proc.pid, + "stdout_log": str(stdout_path), + "stderr_log": str(stderr_path), + } + + +def terminate_pid(pid: int) -> None: + if not is_pid_running(pid): + return + try: + os.kill(pid, signal.SIGTERM) + except OSError: + return + + for _ in range(20): + if not is_pid_running(pid): + return + time.sleep(0.2) + + if os.name == "nt": + subprocess.run( + ["taskkill", "/PID", str(pid), "/T", "/F"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=False, + ) + return + + try: + os.kill(pid, signal.SIGKILL) + except OSError: + pass + + +def running_services(manifest: dict[str, Any] | None) -> list[dict[str, Any]]: + if not manifest or not isinstance(manifest, dict): + return [] + active: list[dict[str, Any]] = [] + for svc in manifest.get("services", []): + if not isinstance(svc, dict): + continue + try: + pid = int(svc.get("pid", 0)) + except Exception: # noqa: BLE001 + continue + if is_pid_running(pid): + active.append(svc) + return active + + +def start_stack(runtime_dir: Path, data_dir: Path, force_restart: bool, env: dict[str, str]) -> int: + service_specs = build_service_specs(env) + current = load_manifest(runtime_dir) + active = running_services(current) + if active and not force_restart: + print("Local stack is already running.") + print(f"Base URL: {current.get('base_url', public_base_url(env))}") + print(f"Logs: {runtime_dir / 'logs'}") + print("Use `python scripts\\local_stack.py stop` first or rerun with --force-restart.") + return 0 + + if active and force_restart: + stop_stack(runtime_dir) + + if current and not active: + remove_manifest(runtime_dir) + + runtime_dir.mkdir(parents=True, exist_ok=True) + data_dir.mkdir(parents=True, exist_ok=True) + + launched: list[dict[str, Any]] = [] + try: + for spec in service_specs: + meta = spawn_service(spec, runtime_dir, data_dir, env) + wait_for_health(service_health_host(str(spec["name"]), env), int(meta["port"])) + if not is_pid_running(int(meta["pid"])): + listener_pid = resolve_listener_pid(int(meta["port"])) + if listener_pid and is_pid_running(listener_pid): + meta["pid"] = listener_pid + else: + raise RuntimeError(f"{meta['name']} exited before becoming ready; check {meta['stderr_log']}") + launched.append(meta) + print(f"[ok] {meta['name']} on {meta['port']} (pid {meta['pid']})") + except Exception as exc: + print(f"[fail] {exc}") + print(f"Check logs: {runtime_dir / 'logs'}") + print("If ports are already busy, stop old local processes and rerun.") + for meta in reversed(launched): + terminate_pid(int(meta["pid"])) + return 1 + + save_manifest(runtime_dir, build_manifest_payload(runtime_dir, data_dir, launched, env)) + print("") + print("Local stack is ready.") + print(f"UI: {public_base_url(env)}/") + print(f"Logs: {runtime_dir / 'logs'}") + print("Stop: python scripts\\local_stack.py stop") + return 0 + + +def stop_stack(runtime_dir: Path) -> int: + current = load_manifest(runtime_dir) + if not current: + print("Local stack is not running (no manifest found).") + return 0 + + services = current.get("services", []) if isinstance(current, dict) else [] + for meta in reversed(services): + if not isinstance(meta, dict): + continue + name = str(meta.get("name", "service")) + try: + pid = int(meta.get("pid", 0)) + except Exception: # noqa: BLE001 + print(f"[skip] {name} (pid invalid)") + continue + if is_pid_running(pid): + terminate_pid(pid) + print(f"[stopped] {name} (pid {pid})") + else: + print(f"[skip] {name} (pid {pid}) already stopped") + + remove_manifest(runtime_dir) + print("Local stack is stopped.") + return 0 + + +def status_stack(runtime_dir: Path, env: dict[str, str]) -> int: + current = load_manifest(runtime_dir) + if not current: + print("Local stack is not running.") + return 0 + + print(f"Base URL: {current.get('base_url', public_base_url(env))}") + print(f"Data dir: {current.get('data_dir', str(DEFAULT_DATA_DIR))}") + print(f"Logs: {runtime_dir / 'logs'}") + print("") + services = current.get("services", []) if isinstance(current, dict) else [] + for meta in services: + if not isinstance(meta, dict): + continue + try: + pid = int(meta.get("pid", 0)) + except Exception: # noqa: BLE001 + pid = 0 + label = "running" if is_pid_running(pid) else "stopped" + print(f"- {meta.get('name')}: {label} on {meta.get('port')} (pid {pid})") + return 0 + + +def main() -> int: + parser = argparse.ArgumentParser(description="Manage the local MVP stack without opening extra consoles") + parser.add_argument("command", choices=["start", "stop", "status"], help="Action to perform") + parser.add_argument( + "--runtime-dir", + default=str(DEFAULT_RUNTIME_DIR), + help="Directory for manifest and logs", + ) + parser.add_argument( + "--data-dir", + default=None, + help="Directory for local runtime data and recordings", + ) + parser.add_argument( + "--env-file", + action="append", + default=[], + help="Additional env file to apply after the default stack env files", + ) + parser.add_argument( + "--force-restart", + action="store_true", + help="Stop the existing managed stack before starting a new one", + ) + args = parser.parse_args() + + extra_env_files: list[Path] = [] + for raw_path in args.env_file: + path = Path(raw_path) + if not path.is_absolute(): + path = (ROOT / path).resolve() + extra_env_files.append(path) + + env = load_stack_env(extra_env_files) + + runtime_dir = Path(args.runtime_dir) + if not runtime_dir.is_absolute(): + runtime_dir = (ROOT / runtime_dir).resolve() + + raw_data_dir = args.data_dir or env.get("CC_DATA_DIR") or str(DEFAULT_DATA_DIR) + data_dir = Path(raw_data_dir) + if not data_dir.is_absolute(): + data_dir = (ROOT / data_dir).resolve() + + if args.command == "start": + return start_stack(runtime_dir, data_dir, args.force_restart, env) + if args.command == "stop": + return stop_stack(runtime_dir) + return status_stack(runtime_dir, env) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/materialize_ivr_prompts.py b/scripts/materialize_ivr_prompts.py new file mode 100644 index 0000000..1add495 --- /dev/null +++ b/scripts/materialize_ivr_prompts.py @@ -0,0 +1,252 @@ +from __future__ import annotations + +import argparse +import json +import os +from pathlib import Path +import shutil +import sqlite3 +import sys +import tempfile +from typing import Any + + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8") + +from services.ai_voice_runtime_service.audiosocket import pcm16le_to_wav_bytes, resample_pcm16le +from services.ai_voice_runtime_service.providers.tts import build_tts_provider + + +def _default_provider_name() -> str: + return os.getenv("AI_VOICE_TTS_PROVIDER", "openai").strip() or "openai" + + +def _parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Bulk-materialize IVR prompt WAV files from a flow JSON document or IVR SQLite DB.", + ) + source = parser.add_mutually_exclusive_group(required=True) + source.add_argument("--flow-json-file", help="Path to a flow JSON document.") + source.add_argument("--sqlite-db", help="Path to SQLite DB containing ivr_flows.") + parser.add_argument("--flow-id", default=None, help="Optional specific flow_id when reading from SQLite.") + parser.add_argument("--output-dir", required=True, help="Directory where .wav files will be written.") + parser.add_argument("--preview-dir", default=None, help="Optional directory to mirror generated WAV files into.") + parser.add_argument( + "--provider", + default=_default_provider_name(), + help="TTS provider name. Defaults to AI_VOICE_TTS_PROVIDER or openai.", + ) + parser.add_argument("--sample-rate", type=int, default=8000, help="Target WAV sample rate. Defaults to 8000.") + parser.add_argument("--default-language", default="ru", help="Fallback language for ambiguous prompts. Defaults to ru.") + parser.add_argument( + "--language-override", + action="append", + default=[], + help="Explicit language override in prompt_audio_key=language form. May be repeated.", + ) + parser.add_argument("--dry-run", action="store_true", help="Print planned prompt files without generating audio.") + return parser.parse_args() + + +def _parse_language_overrides(raw_overrides: list[str]) -> dict[str, str]: + overrides: dict[str, str] = {} + for raw in raw_overrides: + item = str(raw or "").strip() + if not item: + continue + key, sep, value = item.partition("=") + if not sep or not key.strip() or not value.strip(): + raise SystemExit(f"Invalid --language-override value: {raw!r}") + overrides[key.strip()] = value.strip() + return overrides + + +def _load_flow_document(args: argparse.Namespace) -> dict[str, Any]: + if args.flow_json_file: + return json.loads(Path(args.flow_json_file).read_text(encoding="utf-8")) + + conn = sqlite3.connect(str(args.sqlite_db)) + try: + if str(args.flow_id or "").strip(): + row = conn.execute( + "select flow_json from ivr_flows where flow_id = ? order by id desc limit 1", + (args.flow_id.strip(),), + ).fetchone() + else: + row = conn.execute( + "select flow_json from ivr_flows where is_active = 1 order by id desc limit 1" + ).fetchone() + finally: + conn.close() + if row is None or not str(row[0] or "").strip(): + raise SystemExit("Unable to load IVR flow JSON") + return json.loads(row[0]) + + +def _node_identity(node: dict[str, Any]) -> str: + return str(node.get("node_id") or node.get("id") or "").strip() + + +def _infer_language(node: dict[str, Any], *, prompt_key: str, default_language: str) -> str: + candidates = [ + prompt_key, + _node_identity(node), + str(node.get("resolved_queue_code") or "").strip(), + str(node.get("outcome_code") or "").strip(), + ] + for candidate in candidates: + lowered = candidate.lower() + if any(token in lowered for token in ("_kz", "-kz", "_kk", "-kk", "kz_", "kk_")): + return "kz" + if any(token in lowered for token in ("_ru", "-ru", "ru_")): + return "ru" + text = str(node.get("prompt_text") or "").strip().lower() + has_kazakh_letters = any(char in text for char in "әіңғүұқөһ") + has_russian_wording = any(token in text for token in ("здравствуйте", "добро пожаловать", "русского", "службы поддержки", "отдела продаж")) + if has_kazakh_letters and not has_russian_wording: + return "kz" + return str(default_language or "ru").strip() or "ru" + + +def _collect_prompts( + flow_document: dict[str, Any], + *, + default_language: str, + language_overrides: dict[str, str], +) -> list[dict[str, str]]: + prompts: list[dict[str, str]] = [] + seen: dict[str, str] = {} + + def _append_prompt(*, node: dict[str, Any], prompt_key: str, prompt_text: str, language: str | None = None) -> None: + normalized_key = str(prompt_key or "").strip() + normalized_text = str(prompt_text or "").strip() + if not normalized_key or not normalized_text: + return + previous_text = seen.get(normalized_key) + if previous_text is not None and previous_text != normalized_text: + raise SystemExit(f"Prompt key {normalized_key!r} is reused with different texts") + seen[normalized_key] = normalized_text + prompts.append( + { + "prompt_audio_key": normalized_key, + "prompt_text": normalized_text, + "language": language_overrides.get( + normalized_key, + str(language or "").strip() + or _infer_language(node, prompt_key=normalized_key, default_language=default_language), + ), + "node_id": _node_identity(node), + } + ) + + for raw_node in flow_document.get("nodes", []): + node = raw_node if isinstance(raw_node, dict) else None + if node is None: + continue + prompt_sequence = node.get("prompt_sequence") + if isinstance(prompt_sequence, list): + for prompt in prompt_sequence: + if not isinstance(prompt, dict): + continue + _append_prompt( + node=node, + prompt_key=str(prompt.get("prompt_audio_key") or ""), + prompt_text=str(prompt.get("prompt_text") or ""), + language=str(prompt.get("language") or "").strip() or None, + ) + _append_prompt( + node=node, + prompt_key=str(node.get("prompt_audio_key") or ""), + prompt_text=str(node.get("prompt_text") or ""), + ) + return prompts + + +def _write_atomic(target: Path, payload: bytes) -> None: + target.parent.mkdir(parents=True, exist_ok=True) + temp_path: str | None = None + try: + with tempfile.NamedTemporaryFile(dir=target.parent, delete=False, suffix=".tmp") as handle: + handle.write(payload) + temp_path = handle.name + Path(temp_path).replace(target) + target.chmod(0o644) + finally: + if temp_path: + try: + Path(temp_path).unlink(missing_ok=True) + except OSError: + pass + + +def _materialize_prompt( + *, + provider_name: str, + prompt_text: str, + language: str, + sample_rate: int, + output_path: Path, +) -> None: + provider = build_tts_provider(provider_name) + synthesis = provider.synthesize(prompt_text, language=language) + pcm_bytes = resample_pcm16le( + synthesis.audio_bytes, + input_rate_hz=synthesis.sample_rate_hz, + output_rate_hz=max(sample_rate, 1), + ) + wav_bytes = pcm16le_to_wav_bytes( + pcm_bytes, + sample_rate_hz=max(sample_rate, 1), + ) + _write_atomic(output_path, wav_bytes) + + +def main() -> int: + args = _parse_args() + flow_document = _load_flow_document(args) + language_overrides = _parse_language_overrides(args.language_override) + prompts = _collect_prompts( + flow_document, + default_language=args.default_language, + language_overrides=language_overrides, + ) + + output_dir = Path(args.output_dir).expanduser() + preview_dir = Path(args.preview_dir).expanduser() if args.preview_dir else None + output_dir.mkdir(parents=True, exist_ok=True) + if preview_dir is not None: + preview_dir.mkdir(parents=True, exist_ok=True) + + generated: list[dict[str, str]] = [] + for prompt in prompts: + output_path = output_dir / f"{prompt['prompt_audio_key']}.wav" + if not args.dry_run: + _materialize_prompt( + provider_name=args.provider, + prompt_text=prompt["prompt_text"], + language=prompt["language"], + sample_rate=args.sample_rate, + output_path=output_path, + ) + if preview_dir is not None: + preview_path = preview_dir / output_path.name + shutil.copyfile(output_path, preview_path) + preview_path.chmod(0o644) + generated.append( + { + **prompt, + "output_path": str(output_path), + } + ) + + print(json.dumps({"provider": args.provider, "count": len(generated), "prompts": generated}, ensure_ascii=False, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/materialize_tts_prompt.py b/scripts/materialize_tts_prompt.py new file mode 100644 index 0000000..b929dd5 --- /dev/null +++ b/scripts/materialize_tts_prompt.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +import argparse +import os +from pathlib import Path +import sys +import tempfile + + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from services.ai_voice_runtime_service.audiosocket import pcm16le_to_wav_bytes, resample_pcm16le +from services.ai_voice_runtime_service.providers.tts import build_tts_provider + + +def _parse_args() -> argparse.Namespace: + default_provider = os.getenv("AI_VOICE_TTS_PROVIDER", "openai").strip() or "openai" + parser = argparse.ArgumentParser( + description="Generate TTS once, cache it on the server, and materialize a WAV prompt file.", + ) + parser.add_argument("--text", required=True, help="Text to synthesize.") + parser.add_argument("--output", required=True, help="Target WAV path.") + parser.add_argument("--language", default=None, help="Optional language hint.") + parser.add_argument( + "--provider", + default=default_provider, + help="TTS provider name. Defaults to AI_VOICE_TTS_PROVIDER or openai.", + ) + parser.add_argument("--sample-rate", type=int, default=8000, help="Target WAV sample rate. Defaults to 8000.") + return parser.parse_args() + + +def _write_atomic(target: Path, payload: bytes) -> None: + target.parent.mkdir(parents=True, exist_ok=True) + temp_path: str | None = None + try: + with tempfile.NamedTemporaryFile(dir=target.parent, delete=False, suffix=".tmp") as handle: + handle.write(payload) + temp_path = handle.name + Path(temp_path).replace(target) + target.chmod(0o644) + finally: + if temp_path: + try: + Path(temp_path).unlink(missing_ok=True) + except OSError: + pass + + +def main() -> int: + args = _parse_args() + provider = build_tts_provider(args.provider) + synthesis = provider.synthesize(args.text, language=args.language) + pcm_bytes = resample_pcm16le( + synthesis.audio_bytes, + input_rate_hz=synthesis.sample_rate_hz, + output_rate_hz=max(args.sample_rate, 1), + ) + wav_bytes = pcm16le_to_wav_bytes( + pcm_bytes, + sample_rate_hz=max(args.sample_rate, 1), + ) + output_path = Path(args.output).expanduser() + _write_atomic(output_path, wav_bytes) + print(output_path) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/migrate_core_db.py b/scripts/migrate_core_db.py new file mode 100644 index 0000000..5f1e24a --- /dev/null +++ b/scripts/migrate_core_db.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +from pathlib import Path +import sys + +from sqlalchemy.exc import OperationalError, ProgrammingError +from sqlalchemy import text + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from services.shared.db import engine +from services.shared.schema_migrations import ( + MIGRATIONS_DIR, + applied_migration_versions, + database_backend_name, + ensure_schema_migrations_table, + list_migration_files, +) + + +def _split_statements(sql: str) -> list[str]: + chunks = [] + for part in sql.split(";"): + stmt = part.strip() + if stmt: + chunks.append(stmt) + return chunks + + +def _apply_file(path: Path) -> None: + # Some checked-in SQL files may contain a UTF-8 BOM; accept them for both + # local SQLite runs and Postgres migration jobs. + statements = _split_statements(path.read_text(encoding="utf-8-sig")) + with engine.begin() as conn: + for stmt in statements: + try: + conn.execute(text(stmt)) + except (OperationalError, ProgrammingError) as exc: + message = str(exc).lower() + duplicate_markers = [ + "duplicate column name", + "already exists", + "duplicate key value violates unique constraint", + ] + if any(marker in message for marker in duplicate_markers): + continue + raise + conn.execute( + text("INSERT INTO schema_migrations(version, applied_at) VALUES (:v, CURRENT_TIMESTAMP)"), + {"v": path.name}, + ) + + +def main() -> None: + files = list_migration_files() + if not files: + raise RuntimeError(f"No migration files found for dialect in {MIGRATIONS_DIR}") + + ensure_schema_migrations_table() + applied = applied_migration_versions() + + applied_now = [] + for file_path in files: + if file_path.name in applied: + continue + _apply_file(file_path) + applied_now.append(file_path.name) + + print(f"Dialect: {database_backend_name()}") + if applied_now: + print("Applied migrations:") + for m in applied_now: + print(f"- {m}") + else: + print("No new migrations to apply") + + +if __name__ == "__main__": + main() diff --git a/scripts/oidc_smoke.py b/scripts/oidc_smoke.py new file mode 100644 index 0000000..483c18f --- /dev/null +++ b/scripts/oidc_smoke.py @@ -0,0 +1,100 @@ +from __future__ import annotations + +import argparse +import json +import sys +from typing import Any + +import httpx + + +def _normalize_base_url(base_url: str) -> str: + return base_url.rstrip("/") + + +def check_oidc( + base_url: str, + *, + require_enabled: bool = False, + force_health: bool = False, + timeout: float = 5.0, + client: httpx.Client | None = None, +) -> dict[str, Any]: + base_url = _normalize_base_url(base_url) + own_client = client is None + client = client or httpx.Client(timeout=timeout, follow_redirects=False) + + try: + config_resp = client.get(f"{base_url}/proxy/auth/auth/oidc/config") + config_resp.raise_for_status() + config = config_resp.json() + enabled = bool(config.get("enabled")) + + if require_enabled and not enabled: + raise RuntimeError("OIDC is disabled but --require-enabled was specified") + + summary: dict[str, Any] = { + "base_url": base_url, + "config": config, + "health": None, + } + + if enabled or force_health: + health_resp = client.get(f"{base_url}/proxy/auth/auth/oidc/health") + health_resp.raise_for_status() + summary["health"] = health_resp.json() + + return summary + finally: + if own_client: + client.close() + + +def _build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Smoke-check OIDC config and provider health via the gateway") + parser.add_argument("--base-url", default="http://localhost:8080", help="Gateway base URL") + parser.add_argument( + "--require-enabled", + action="store_true", + help="Fail if OIDC is disabled", + ) + parser.add_argument( + "--force-health", + action="store_true", + help="Call the OIDC health endpoint even if OIDC is disabled", + ) + parser.add_argument("--timeout", type=float, default=5.0, help="HTTP timeout in seconds") + return parser + + +def main(argv: list[str] | None = None) -> int: + parser = _build_parser() + args = parser.parse_args(argv) + + try: + summary = check_oidc( + args.base_url, + require_enabled=args.require_enabled, + force_health=args.force_health, + timeout=args.timeout, + ) + except (httpx.HTTPError, RuntimeError) as exc: + print(f"OIDC smoke check failed: {exc}", file=sys.stderr) + return 1 + + config = summary["config"] + health = summary["health"] + + print("OIDC config:") + print(json.dumps(config, ensure_ascii=False, indent=2)) + if health is not None: + print("OIDC health:") + print(json.dumps(health, ensure_ascii=False, indent=2)) + else: + print("OIDC health skipped because OIDC is disabled") + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/postgres_dev_preflight.py b/scripts/postgres_dev_preflight.py new file mode 100644 index 0000000..9097e6e --- /dev/null +++ b/scripts/postgres_dev_preflight.py @@ -0,0 +1,316 @@ +from __future__ import annotations + +import argparse +from dataclasses import asdict, dataclass +import json +import os +from pathlib import Path +import socket +import sys +from urllib.parse import urlparse + +import httpx +from sqlalchemy import create_engine, text + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from services.shared.schema_migrations import applied_migration_versions, list_migration_files, validate_schema_migrations_applied +from services.shared.security import issue_app_token + + +REQUIRED_PROXY_HEALTH_SERVICES = [ + "auth", + "customer", + "interaction", + "routing", + "voice", + "telegram", + "whatsapp", + "recording", + "ivr", + "ai", + "ai-voice-runtime", + "kb", + "reporting", + "supervisor", +] + + +@dataclass +class CheckResult: + name: str + ok: bool + details: str + + +def _load_env_file(path: Path) -> None: + if not path.exists() or not path.is_file(): + return + for line in path.read_text(encoding="utf-8").splitlines(): + stripped = line.strip() + if not stripped or stripped.startswith("#") or "=" not in stripped: + continue + key, value = stripped.split("=", 1) + key = key.strip() + if key and key not in os.environ: + os.environ[key] = value.strip() + + +def _bool_env(name: str, default: bool) -> bool: + raw = os.getenv(name) + if raw is None: + return default + return raw.strip().lower() in {"1", "true", "yes", "on"} + + +def _normalize_database_url(url: str) -> str: + if url.startswith("postgres://"): + return "postgresql+psycopg://" + url[len("postgres://") :] + if url.startswith("postgresql://") and "+psycopg" not in url: + return "postgresql+psycopg://" + url[len("postgresql://") :] + return url + + +def _ops_headers() -> dict[str, str]: + if _bool_env("ALLOW_LEGACY_HEADER_AUTH", True): + return {"X-User": "admin", "X-Role": "admin"} + token = issue_app_token( + subject="ops:postgres-dev-preflight", + username="postgres-dev-preflight", + role="admin", + auth_source="service", + provider="postgres-preflight", + ttl_seconds=300, + ) + return {"Authorization": f"Bearer {token}"} + + +def _check_tcp_endpoint(name: str, url: str, default_port: int) -> CheckResult: + parsed = urlparse(url) + host = parsed.hostname + port = parsed.port or default_port + if not host: + return CheckResult(name, False, "host is missing") + try: + with socket.create_connection((host, port), timeout=3.0): + pass + except OSError as exc: + return CheckResult(name, False, f"{host}:{port} unreachable: {exc}") + return CheckResult(name, True, f"{host}:{port} reachable") + + +def _check_database_connection(database_url: str) -> CheckResult: + engine = create_engine( + _normalize_database_url(database_url), + future=True, + pool_pre_ping=True, + connect_args={"connect_timeout": 5}, + ) + try: + with engine.connect() as conn: + conn.execute(text("SELECT 1")) + except Exception as exc: # noqa: BLE001 + return CheckResult("database_connection", False, str(exc)) + finally: + engine.dispose() + return CheckResult("database_connection", True, "SELECT 1 succeeded") + + +def _check_schema_migrations(database_url: str) -> CheckResult: + engine = create_engine( + _normalize_database_url(database_url), + future=True, + pool_pre_ping=True, + connect_args={"connect_timeout": 5}, + ) + try: + validate_schema_migrations_applied(db_engine=engine) + expected = list_migration_files(db_engine=engine) + applied = applied_migration_versions(db_engine=engine) + except Exception as exc: # noqa: BLE001 + return CheckResult("schema_migrations", False, str(exc)) + finally: + engine.dispose() + return CheckResult("schema_migrations", True, f"applied={len(applied)}/{len(expected)}") + + +def _http_preflight_checks(base_url: str) -> list[CheckResult]: + results: list[CheckResult] = [] + normalized = base_url.rstrip("/") + ops_headers = _ops_headers() + read_headers = dict(ops_headers) + with httpx.Client(base_url=normalized, timeout=10, trust_env=False) as client: + try: + response = client.get("/health") + ok = response.status_code == 200 and response.json().get("status") == "ok" + results.append(CheckResult("gateway_health", ok, f"status={response.status_code}")) + except Exception as exc: # noqa: BLE001 + return [CheckResult("gateway_health", False, str(exc))] + + try: + response = client.get("/registry") + payload = response.json() if response.status_code == 200 else {} + services = payload.get("services", {}) if isinstance(payload, dict) else {} + missing = [name for name in REQUIRED_PROXY_HEALTH_SERVICES if name not in services] + ok = response.status_code == 200 and not missing + details = f"status={response.status_code}" + if missing: + details += f", missing={missing}" + results.append(CheckResult("gateway_registry", ok, details)) + except Exception as exc: # noqa: BLE001 + results.append(CheckResult("gateway_registry", False, str(exc))) + return results + + services_to_check = list(REQUIRED_PROXY_HEALTH_SERVICES) + if _bool_env("EVENT_BUS_ENABLED", False): + services_to_check.append("event-bus") + if _bool_env("ASTERISK_BRIDGE_ENABLED", False): + services_to_check.append("asterisk-bridge") + + for service in services_to_check: + try: + response = client.get(f"/proxy/{service}/health", headers=ops_headers) + payload = response.json() if response.status_code == 200 else {} + status_value = str(payload.get("status") or "").strip().lower() if isinstance(payload, dict) else "" + ok = response.status_code == 200 and status_value == "ok" + results.append(CheckResult(f"{service}_health", ok, f"status={response.status_code}")) + except Exception as exc: # noqa: BLE001 + results.append(CheckResult(f"{service}_health", False, str(exc))) + + try: + response = client.post( + "/proxy/auth/auth/login", + json={"username": "admin", "password": "admin123"}, + ) + payload = response.json() if response.status_code == 200 else {} + access_token = str(payload.get("access_token") or "").strip() if isinstance(payload, dict) else "" + if access_token: + read_headers = {"Authorization": f"Bearer {access_token}"} + ok = response.status_code == 200 and bool(access_token) + results.append(CheckResult("auth_login", ok, f"status={response.status_code}")) + except Exception as exc: # noqa: BLE001 + results.append(CheckResult("auth_login", False, str(exc))) + + try: + response = client.get("/proxy/routing/queues", headers=read_headers) + ok = response.status_code == 200 and isinstance(response.json(), list) + results.append(CheckResult("routing_read", ok, f"status={response.status_code}")) + except Exception as exc: # noqa: BLE001 + results.append(CheckResult("routing_read", False, str(exc))) + + try: + response = client.get("/proxy/voice/integrations/voice/events?limit=1", headers=read_headers) + ok = response.status_code == 200 and isinstance(response.json(), list) + results.append(CheckResult("voice_read", ok, f"status={response.status_code}")) + except Exception as exc: # noqa: BLE001 + results.append(CheckResult("voice_read", False, str(exc))) + + try: + response = client.get("/proxy/whatsapp/integrations/whatsapp/threads", headers=read_headers) + ok = response.status_code == 200 and isinstance(response.json(), list) + results.append(CheckResult("whatsapp_read", ok, f"status={response.status_code}")) + except Exception as exc: # noqa: BLE001 + results.append(CheckResult("whatsapp_read", False, str(exc))) + return results + + +def run_preflight( + *, + database_url: str, + base_url: str | None, + require_rabbitmq: bool, +) -> list[CheckResult]: + results: list[CheckResult] = [] + raw_database_url = str(database_url or "").strip() + if not raw_database_url: + return [CheckResult("database_url", False, "DATABASE_URL is required")] + + if not raw_database_url.startswith(("postgres://", "postgresql://", "postgresql+psycopg://")): + return [CheckResult("database_url", False, "DATABASE_URL must point to PostgreSQL")] + + results.append(CheckResult("database_url", True, "PostgreSQL URL detected")) + + mode = str(os.getenv("SCHEMA_MANAGEMENT_MODE", "")).strip().lower() + if mode != "migrations": + results.append(CheckResult("schema_management_mode", False, "SCHEMA_MANAGEMENT_MODE must be set to migrations")) + return results + results.append(CheckResult("schema_management_mode", True, "migrations")) + + results.append(_check_tcp_endpoint("postgres_tcp", raw_database_url, 5432)) + if not results[-1].ok: + return results + + results.append(_check_database_connection(raw_database_url)) + if not results[-1].ok: + return results + + results.append(_check_schema_migrations(raw_database_url)) + if not results[-1].ok: + return results + + if require_rabbitmq or _bool_env("EVENT_BUS_ENABLED", False): + event_bus_url = str(os.getenv("EVENT_BUS_URL", "")).strip() + if not event_bus_url: + results.append(CheckResult("rabbitmq_tcp", False, "EVENT_BUS_URL is required when RabbitMQ check is enabled")) + else: + results.append(_check_tcp_endpoint("rabbitmq_tcp", event_bus_url, 5672)) + + if base_url: + results.extend(_http_preflight_checks(base_url)) + + return results + + +def main() -> int: + parser = argparse.ArgumentParser(description="PostgreSQL dev preflight checks") + parser.add_argument("--env-file", action="append", default=[".env.production"]) + parser.add_argument("--database-url", default=os.getenv("DATABASE_URL", "")) + parser.add_argument("--base-url", default="") + parser.add_argument("--require-rabbitmq", action="store_true") + parser.add_argument("--json", action="store_true") + args = parser.parse_args() + + for raw_path in args.env_file: + path = Path(raw_path) + if not path.is_absolute(): + path = (ROOT / path).resolve() + _load_env_file(path) + + database_url = str(args.database_url or os.getenv("DATABASE_URL", "")).strip() + base_url = str(args.base_url or "").strip() or None + results = run_preflight( + database_url=database_url, + base_url=base_url, + require_rabbitmq=bool(args.require_rabbitmq), + ) + failures = [item for item in results if not item.ok] + + if args.json: + print( + json.dumps( + { + "ok": not failures, + "checks": [asdict(item) for item in results], + }, + ensure_ascii=False, + indent=2, + ) + ) + return 0 if not failures else 1 + + for item in results: + label = "[ok]" if item.ok else "[fail]" + print(f"{label} {item.name}: {item.details}") + + if failures: + print("[FAIL] PostgreSQL dev preflight failed") + return 1 + + print("[PASS] PostgreSQL dev preflight passed") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/prepare_demo.ps1 b/scripts/prepare_demo.ps1 new file mode 100644 index 0000000..59a9c28 --- /dev/null +++ b/scripts/prepare_demo.ps1 @@ -0,0 +1,41 @@ +$ErrorActionPreference = "Stop" +$root = Split-Path -Parent $PSScriptRoot +Set-Location $root + +python scripts\local_stack.py stop @args +if ($LASTEXITCODE -ne 0) { + exit $LASTEXITCODE +} + +$dataDir = Join-Path $root ".data_local" +if (Test-Path $dataDir) { + Remove-Item -Path $dataDir -Recurse -Force +} + +$summaryPath = Join-Path $root ".local_stack\demo-seed-summary.json" +if (Test-Path $summaryPath) { + Remove-Item -Path $summaryPath -Force +} + +python scripts\local_stack.py start @args +if ($LASTEXITCODE -ne 0) { + exit $LASTEXITCODE +} + +& "$PSScriptRoot\smoke_test.ps1" +if ($LASTEXITCODE -ne 0) { + exit $LASTEXITCODE +} + +python scripts\demo_seed.py +if ($LASTEXITCODE -ne 0) { + exit $LASTEXITCODE +} + +Write-Host "" +Write-Host "Demo environment is ready." +Write-Host "UI: http://localhost:8080/operator" +Write-Host "Login: admin / admin123" +Write-Host "KB keyword: demo-showcase" +Write-Host "Summary: .local_stack\demo-seed-summary.json" +Write-Host "Stop: powershell -ExecutionPolicy Bypass -File scripts\stop_all_local.ps1" diff --git a/scripts/restore_data.ps1 b/scripts/restore_data.ps1 new file mode 100644 index 0000000..e1b30cf --- /dev/null +++ b/scripts/restore_data.ps1 @@ -0,0 +1,30 @@ +param( + [string]$BackupZip = "", + [string]$TargetDir = "e:\Zhan\.data" +) + +if ($BackupZip -eq "") { + $latest = Get-ChildItem -Path "e:\Zhan\backups" -Filter "*.zip" -ErrorAction SilentlyContinue | Sort-Object LastWriteTime -Descending | Select-Object -First 1 + if ($null -eq $latest) { + Write-Error "No backup zip found in e:\Zhan\backups and BackupZip not provided." + exit 1 + } + $BackupZip = $latest.FullName +} + +if (!(Test-Path $BackupZip)) { + Write-Error "Backup zip not found: $BackupZip" + exit 1 +} + +if (Test-Path $TargetDir) { + $bakDir = "$TargetDir.bak.$((Get-Date).ToString('yyyyMMdd_HHmmss'))" + Move-Item -Path $TargetDir -Destination $bakDir + Write-Host "Existing data moved to:" $bakDir +} + +New-Item -ItemType Directory -Force -Path $TargetDir | Out-Null +Expand-Archive -Path $BackupZip -DestinationPath $TargetDir -Force + +Write-Host "Data restored from:" $BackupZip +Write-Host "Target directory:" $TargetDir diff --git a/scripts/run_all_local.ps1 b/scripts/run_all_local.ps1 new file mode 100644 index 0000000..19c486a --- /dev/null +++ b/scripts/run_all_local.ps1 @@ -0,0 +1,5 @@ +$ErrorActionPreference = "Stop" +$root = Split-Path -Parent $PSScriptRoot +Set-Location $root +python scripts\local_stack.py start @args +exit $LASTEXITCODE diff --git a/scripts/seed_kb_localizations.py b/scripts/seed_kb_localizations.py new file mode 100644 index 0000000..fad95b9 --- /dev/null +++ b/scripts/seed_kb_localizations.py @@ -0,0 +1,192 @@ +from __future__ import annotations + +from argparse import ArgumentParser +from pathlib import Path +import json +import sys + +from sqlalchemy import select + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from services.shared.core import new_id, utc_now_iso +from services.shared.db import get_session +from services.shared.kb_localization import normalize_kb_language, resolve_article_group_id +from services.shared.sql_models import KBArticleRow, KBCategoryRow + + +def _parser() -> ArgumentParser: + parser = ArgumentParser(description="Seed KB article localizations without creating duplicates.") + parser.add_argument( + "--seed-file", + default=str(ROOT / "scripts" / "kb_voice_basic_kz_localizations.json"), + help="Path to the localization seed JSON file.", + ) + parser.add_argument( + "--apply", + action="store_true", + help="Persist changes. Without this flag the script runs in dry-run mode.", + ) + parser.add_argument( + "--update-existing", + action="store_true", + help="Update existing localized articles instead of leaving them untouched.", + ) + return parser + + +def _load_seed(path: Path) -> dict: + return json.loads(path.read_text(encoding="utf-8")) + + +def _find_category(session, category_name: str) -> KBCategoryRow: + row = session.execute( + select(KBCategoryRow).where(KBCategoryRow.name == category_name) + ).scalar_one_or_none() + if row is None: + raise RuntimeError(f"KB category not found: {category_name}") + return row + + +def _find_source_article( + session, + *, + category_id: str, + source_title: str, + source_language: str, +) -> KBArticleRow: + rows = session.execute( + select(KBArticleRow) + .where(KBArticleRow.category_id == category_id) + .where(KBArticleRow.title == source_title) + .where(KBArticleRow.language == source_language) + .order_by(KBArticleRow.id.asc()) + ).scalars().all() + if not rows: + raise RuntimeError(f"Source article not found: {source_title}") + if len(rows) > 1: + raise RuntimeError(f"Multiple source articles found for title: {source_title}") + return rows[0] + + +def _find_localized_article( + session, + *, + article_group_id: str, + target_language: str, +) -> KBArticleRow | None: + return session.execute( + select(KBArticleRow) + .where(KBArticleRow.article_group_id == article_group_id) + .where(KBArticleRow.language == target_language) + ).scalar_one_or_none() + + +def main() -> None: + args = _parser().parse_args() + seed_file = Path(args.seed_file).resolve() + seed = _load_seed(seed_file) + source_language = normalize_kb_language(seed.get("source_language")) + target_language = normalize_kb_language(seed.get("target_language")) + category_name = str(seed.get("category_name") or "").strip() + if not category_name: + raise RuntimeError("Seed file must define category_name") + + session = get_session() + try: + category = _find_category(session, category_name) + summary = { + "category_id": category.category_id, + "category_name": category.name, + "seed_file": str(seed_file), + "source_language": source_language, + "target_language": target_language, + "apply": bool(args.apply), + "update_existing": bool(args.update_existing), + "created": [], + "updated": [], + "skipped": [], + } + + for item in seed.get("localizations", []): + source_title = str(item.get("source_title") or "").strip() + title = str(item.get("title") or "").strip() + body = str(item.get("body") or "").strip() + tags = [str(tag).strip() for tag in item.get("tags", []) if str(tag).strip()] + if not source_title or not title or not body: + raise RuntimeError(f"Incomplete localization entry: {item!r}") + + source_article = _find_source_article( + session, + category_id=category.category_id, + source_title=source_title, + source_language=source_language, + ) + article_group_id = resolve_article_group_id( + source_article.article_id, + source_article.article_group_id, + ) + existing = _find_localized_article( + session, + article_group_id=article_group_id, + target_language=target_language, + ) + if existing is None: + row = KBArticleRow( + article_id=new_id("kba"), + category_id=category.category_id, + article_group_id=article_group_id, + language=target_language, + title=title, + body=body, + tags_json=json.dumps(tags, ensure_ascii=False), + created_at=utc_now_iso(), + updated_at=utc_now_iso(), + ) + session.add(row) + summary["created"].append( + { + "source_title": source_title, + "article_group_id": article_group_id, + "target_title": title, + } + ) + continue + + if args.update_existing: + existing.title = title + existing.body = body + existing.tags_json = json.dumps(tags, ensure_ascii=False) + existing.updated_at = utc_now_iso() + summary["updated"].append( + { + "source_title": source_title, + "article_id": existing.article_id, + "article_group_id": article_group_id, + "target_title": title, + } + ) + else: + summary["skipped"].append( + { + "source_title": source_title, + "article_id": existing.article_id, + "article_group_id": article_group_id, + "target_title": existing.title, + } + ) + + if args.apply: + session.commit() + else: + session.rollback() + + print(json.dumps(summary, ensure_ascii=True, indent=2)) + finally: + session.close() + + +if __name__ == "__main__": + main() diff --git a/scripts/smoke_test.ps1 b/scripts/smoke_test.ps1 new file mode 100644 index 0000000..89933f8 --- /dev/null +++ b/scripts/smoke_test.ps1 @@ -0,0 +1,11 @@ +Write-Host 'Checking gateway health...' +Invoke-RestMethod -Uri 'http://localhost:8080/health' | ConvertTo-Json -Depth 5 + +Write-Host 'Checking auth health through proxy...' +Invoke-RestMethod -Uri 'http://localhost:8080/proxy/auth/health' | ConvertTo-Json -Depth 5 + +Write-Host 'Logging in to auth...' +$loginBody = @{ username='admin'; password='admin123' } | ConvertTo-Json +Invoke-RestMethod -Method Post -Uri 'http://localhost:8080/proxy/auth/auth/login' -Body $loginBody -ContentType 'application/json' | ConvertTo-Json -Depth 5 + +Write-Host 'Smoke test completed.' diff --git a/scripts/start_track9_qa.ps1 b/scripts/start_track9_qa.ps1 new file mode 100644 index 0000000..a0896f6 --- /dev/null +++ b/scripts/start_track9_qa.ps1 @@ -0,0 +1,62 @@ +$ErrorActionPreference = "Stop" + +$root = Split-Path -Parent $PSScriptRoot +Set-Location $root + +$envFile = Join-Path $root ".env.production" +if (-not (Test-Path $envFile)) { + Write-Error ".env.production not found. Create it from .env.production.template first." +} + +Get-Content $envFile | ForEach-Object { + $line = $_.Trim() + if (-not $line -or $line.StartsWith("#")) { + return + } + $parts = $line -split "=", 2 + if ($parts.Count -ne 2) { + return + } + $name = $parts[0].Trim() + $value = $parts[1].Trim() + if ($name) { + Set-Item -Path "Env:$name" -Value $value + } +} + +python scripts\local_stack.py start --force-restart +if ($LASTEXITCODE -ne 0) { + exit $LASTEXITCODE +} + +$amiHost = $env:ASTERISK_AMI_HOST +$amiPortValue = $env:ASTERISK_AMI_PORT +if (-not $amiPortValue) { + $amiPortValue = "5038" +} +$amiPort = [int]$amiPortValue + +$sftpHost = $env:ASTERISK_SFTP_HOST +$sftpPortValue = $env:ASTERISK_SFTP_PORT +if (-not $sftpPortValue) { + $sftpPortValue = "22" +} +$sftpPort = [int]$sftpPortValue + +if ($amiHost) { + $ami = Test-NetConnection $amiHost -Port $amiPort -WarningAction SilentlyContinue + Write-Host "AMI TCP check: $($ami.TcpTestSucceeded) (${amiHost}:$amiPort)" +} +if ($sftpHost) { + $sftp = Test-NetConnection $sftpHost -Port $sftpPort -WarningAction SilentlyContinue + Write-Host "SFTP TCP check: $($sftp.TcpTestSucceeded) (${sftpHost}:$sftpPort)" +} + +python scripts\track9_preflight.py --base-url http://127.0.0.1:8080 +if ($LASTEXITCODE -ne 0) { + exit $LASTEXITCODE +} + +Write-Host "" +Write-Host "Track 9 QA stack is up." +Write-Host "Next step: place real softphone call 1001 -> 7000." diff --git a/scripts/stop_all_local.ps1 b/scripts/stop_all_local.ps1 new file mode 100644 index 0000000..7d6ce06 --- /dev/null +++ b/scripts/stop_all_local.ps1 @@ -0,0 +1,5 @@ +$ErrorActionPreference = "Stop" +$root = Split-Path -Parent $PSScriptRoot +Set-Location $root +python scripts\local_stack.py stop @args +exit $LASTEXITCODE diff --git a/scripts/track10_voice_latency_report.py b/scripts/track10_voice_latency_report.py new file mode 100644 index 0000000..2ee2764 --- /dev/null +++ b/scripts/track10_voice_latency_report.py @@ -0,0 +1,295 @@ +from __future__ import annotations + +import argparse +import json +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone +from pathlib import Path +from statistics import median + +from sqlalchemy import create_engine, text + + +def _parse_iso(value: str | None) -> datetime | None: + raw = str(value or "").strip() + if not raw: + return None + try: + parsed = datetime.fromisoformat(raw) + except ValueError: + return None + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed.astimezone(timezone.utc) + + +def _percentile(values: list[float], q: float) -> float | None: + if not values: + return None + if len(values) == 1: + return values[0] + sorted_values = sorted(values) + idx = (len(sorted_values) - 1) * q + lo = int(idx) + hi = min(lo + 1, len(sorted_values) - 1) + if lo == hi: + return sorted_values[lo] + frac = idx - lo + return sorted_values[lo] + (sorted_values[hi] - sorted_values[lo]) * frac + + +@dataclass +class CallTimeline: + call_id: str + started_at: datetime | None = None + ended_at: datetime | None = None + ended_reconciled: bool | None = None + recording_ready_at: datetime | None = None + recording_ready_reconciled: bool | None = None + recording_uploaded_at: datetime | None = None + interaction_id: str | None = None + + def start_to_end_seconds(self) -> float | None: + if self.started_at and self.ended_at: + return max((self.ended_at - self.started_at).total_seconds(), 0.0) + return None + + def end_to_recording_ready_seconds(self) -> float | None: + if self.ended_at and self.recording_ready_at: + return max((self.recording_ready_at - self.ended_at).total_seconds(), 0.0) + return None + + def end_to_recording_upload_seconds(self) -> float | None: + if self.ended_at and self.recording_uploaded_at: + return max((self.recording_uploaded_at - self.ended_at).total_seconds(), 0.0) + return None + + def status(self) -> str: + if self.started_at and not self.ended_at: + return "active_no_end" + if self.ended_at and not self.recording_uploaded_at: + return "ended_no_recording_upload" + if self.ended_at and self.recording_uploaded_at: + return "complete" + return "unknown" + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Track 10: report Asterisk voice lifecycle latency." + ) + parser.add_argument("--database-url", required=True) + parser.add_argument("--since-hours", type=int, default=6) + parser.add_argument("--since-minutes", type=int, default=0) + parser.add_argument("--limit-events", type=int, default=5000) + parser.add_argument("--max-started-to-ended-seconds", type=float, default=30.0) + parser.add_argument("--max-ended-to-recording-seconds", type=float, default=45.0) + parser.add_argument("--print-limit", type=int, default=15) + parser.add_argument("--json-out", default="") + parser.add_argument("--breach-mode", choices=["all", "direct"], default="direct") + parser.add_argument("--fail-on-breach", action="store_true") + args = parser.parse_args() + + if args.since_minutes and args.since_minutes > 0: + cutoff = datetime.now(timezone.utc) - timedelta(minutes=args.since_minutes) + else: + cutoff = datetime.now(timezone.utc) - timedelta(hours=max(args.since_hours, 1)) + cutoff_iso = cutoff.isoformat() + + engine = create_engine(args.database_url, future=True) + calls: dict[str, CallTimeline] = {} + started_from_asterisk: set[str] = set() + + with engine.begin() as conn: + rows = conn.execute( + text( + """ + SELECT call_id, interaction_id, event_type, payload_json, created_at + FROM voice_events + WHERE event_type IN ('call.started', 'call.ended', 'recording.ready') + AND created_at >= :cutoff + ORDER BY id DESC + LIMIT :limit + """ + ), + {"cutoff": cutoff_iso, "limit": max(args.limit_events, 100)}, + ).mappings().all() + + for row in rows: + call_id = str(row["call_id"] or "").strip() + if not call_id: + continue + payload_raw = str(row["payload_json"] or "{}") + try: + payload = json.loads(payload_raw) + except json.JSONDecodeError: + payload = {} + event_type = str(row["event_type"] or "") + created_at = _parse_iso(str(row["created_at"] or "")) + if created_at is None: + continue + + timeline = calls.get(call_id) or CallTimeline(call_id=call_id) + if event_type == "call.started": + if payload.get("source") != "asterisk": + continue + started_from_asterisk.add(call_id) + timeline.started_at = max(filter(None, [timeline.started_at, created_at])) + elif event_type == "call.ended": + if timeline.ended_at is None or created_at > timeline.ended_at: + timeline.ended_at = created_at + timeline.ended_reconciled = bool(payload.get("reconciled")) + elif event_type == "recording.ready": + if timeline.recording_ready_at is None or created_at > timeline.recording_ready_at: + timeline.recording_ready_at = created_at + timeline.recording_ready_reconciled = bool(payload.get("reconciled")) + timeline.interaction_id = timeline.interaction_id or str(row["interaction_id"] or "").strip() or None + calls[call_id] = timeline + + rec_rows = conn.execute( + text( + """ + SELECT call_id, interaction_id, created_at + FROM call_recordings + WHERE created_at >= :cutoff + ORDER BY id DESC + LIMIT :limit + """ + ), + {"cutoff": cutoff_iso, "limit": max(args.limit_events, 100)}, + ).mappings().all() + for row in rec_rows: + call_id = str(row["call_id"] or "").strip() + if call_id not in calls: + continue + created_at = _parse_iso(str(row["created_at"] or "")) + if created_at is None: + continue + timeline = calls[call_id] + if timeline.recording_uploaded_at is None or created_at > timeline.recording_uploaded_at: + timeline.recording_uploaded_at = created_at + timeline.interaction_id = timeline.interaction_id or str(row["interaction_id"] or "").strip() or None + + items = [calls[call_id] for call_id in started_from_asterisk if call_id in calls] + items.sort(key=lambda x: x.started_at or datetime.min.replace(tzinfo=timezone.utc), reverse=True) + + start_end = [v for v in (item.start_to_end_seconds() for item in items) if v is not None] + end_rec = [v for v in (item.end_to_recording_upload_seconds() for item in items) if v is not None] + direct_items = [item for item in items if item.ended_at is not None and item.ended_reconciled is False] + direct_start_end = [v for v in (item.start_to_end_seconds() for item in direct_items) if v is not None] + direct_end_rec = [ + v for v in (item.end_to_recording_upload_seconds() for item in direct_items) if v is not None + ] + ended_no_recording = [item for item in items if item.status() == "ended_no_recording_upload"] + active_no_end = [item for item in items if item.status() == "active_no_end"] + + p95_start_end = _percentile(start_end, 0.95) + p95_end_rec = _percentile(end_rec, 0.95) + p95_direct_start_end = _percentile(direct_start_end, 0.95) + p95_direct_end_rec = _percentile(direct_end_rec, 0.95) + + breach = False + if args.breach_mode == "direct": + left = p95_direct_start_end if p95_direct_start_end is not None else p95_start_end + right = p95_direct_end_rec if p95_direct_end_rec is not None else p95_end_rec + else: + left = p95_start_end + right = p95_end_rec + + if left is not None and left > args.max_started_to_ended_seconds: + breach = True + if right is not None and right > args.max_ended_to_recording_seconds: + breach = True + if ended_no_recording: + breach = True + + report = { + "generated_at": datetime.now(timezone.utc).isoformat(), + "since_hours": args.since_hours, + "since_minutes": args.since_minutes, + "thresholds": { + "max_started_to_ended_seconds": args.max_started_to_ended_seconds, + "max_ended_to_recording_seconds": args.max_ended_to_recording_seconds, + }, + "summary": { + "total_calls": len(items), + "complete_calls": sum(1 for item in items if item.status() == "complete"), + "active_no_end": len(active_no_end), + "ended_no_recording_upload": len(ended_no_recording), + "ended_reconciled_calls": sum(1 for item in items if item.ended_reconciled is True), + "ended_direct_calls": sum(1 for item in items if item.ended_reconciled is False), + "start_to_end": { + "count": len(start_end), + "median": median(start_end) if start_end else None, + "p95": p95_start_end, + }, + "end_to_recording_upload": { + "count": len(end_rec), + "median": median(end_rec) if end_rec else None, + "p95": p95_end_rec, + }, + "direct_start_to_end": { + "count": len(direct_start_end), + "median": median(direct_start_end) if direct_start_end else None, + "p95": p95_direct_start_end, + }, + "direct_end_to_recording_upload": { + "count": len(direct_end_rec), + "median": median(direct_end_rec) if direct_end_rec else None, + "p95": p95_direct_end_rec, + }, + "breach_mode": args.breach_mode, + }, + "worst_calls": [ + { + "call_id": item.call_id, + "interaction_id": item.interaction_id, + "status": item.status(), + "started_at": item.started_at.isoformat() if item.started_at else None, + "ended_at": item.ended_at.isoformat() if item.ended_at else None, + "ended_reconciled": item.ended_reconciled, + "recording_ready_at": item.recording_ready_at.isoformat() if item.recording_ready_at else None, + "recording_ready_reconciled": item.recording_ready_reconciled, + "recording_uploaded_at": item.recording_uploaded_at.isoformat() if item.recording_uploaded_at else None, + "start_to_end_seconds": item.start_to_end_seconds(), + "end_to_recording_upload_seconds": item.end_to_recording_upload_seconds(), + } + for item in items[: max(args.print_limit, 1)] + ], + } + + print( + "[INFO] Track10 voice latency report: " + f"calls={report['summary']['total_calls']} " + f"complete={report['summary']['complete_calls']} " + f"active_no_end={report['summary']['active_no_end']} " + f"ended_no_recording_upload={report['summary']['ended_no_recording_upload']}" + ) + print( + "[INFO] p95 started->ended=" + f"{report['summary']['start_to_end']['p95']}s; " + "p95 ended->recording_upload=" + f"{report['summary']['end_to_recording_upload']['p95']}s" + ) + print( + "[INFO] direct p95 started->ended=" + f"{report['summary']['direct_start_to_end']['p95']}s; " + "direct p95 ended->recording_upload=" + f"{report['summary']['direct_end_to_recording_upload']['p95']}s" + ) + print(json.dumps(report, ensure_ascii=False, indent=2)) + + if args.json_out.strip(): + out_path = Path(args.json_out).expanduser().resolve() + out_path.parent.mkdir(parents=True, exist_ok=True) + out_path.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8") + print(f"[INFO] report written: {out_path}") + + if args.fail_on_breach and breach: + print("[FAIL] SLO breach detected for Track 10 criteria") + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/track12_live_monitor.py b/scripts/track12_live_monitor.py new file mode 100644 index 0000000..55ca49b --- /dev/null +++ b/scripts/track12_live_monitor.py @@ -0,0 +1,163 @@ +from __future__ import annotations + +import json +import sqlite3 +import time +from datetime import datetime, timezone +from pathlib import Path +from urllib.error import URLError +from urllib.request import Request, urlopen + + +ROOT = Path(__file__).resolve().parents[1] +DB_PATH = ROOT / ".data_local" / "mvp_cc.db" +OUT_DIR = ROOT / ".artifacts" / "track12-monitor" +OUT_DIR.mkdir(parents=True, exist_ok=True) +OUT_PATH = OUT_DIR / "latest.log" + +BASE_URL = "http://127.0.0.1:8080" +AUTH_BASE_URL = "http://127.0.0.1:8001" +USERNAME = "operator" +PASSWORD = "op12345" +ROLE = "operator" +WINDOW_SECONDS = 180 +POLL_SECONDS = 1.0 + + +def iso_now() -> str: + return datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds") + + +def write_line(handle, label: str, payload) -> None: + handle.write(f"[{iso_now()}] {label} {json.dumps(payload, ensure_ascii=False, sort_keys=True)}\n") + handle.flush() + + +def login_headers() -> dict[str, str]: + body = json.dumps( + { + "username": USERNAME, + "password": PASSWORD, + "role": ROLE, + } + ).encode("utf-8") + req = Request( + f"{AUTH_BASE_URL}/auth/login", + headers={"Content-Type": "application/json"}, + data=body, + method="POST", + ) + with urlopen(req, timeout=10) as response: + payload = json.loads(response.read().decode("utf-8", errors="replace")) + token = str(payload.get("access_token") or "").strip() + if not token: + raise RuntimeError("auth/login did not return access_token") + return {"Authorization": f"Bearer {token}"} + + +def fetch_json(path: str, headers: dict[str, str]): + req = Request( + f"{BASE_URL}{path}", + headers=headers, + method="GET", + ) + with urlopen(req, timeout=10) as response: + body = response.read().decode("utf-8", errors="replace") + return json.loads(body) + + +def latest_events(conn: sqlite3.Connection, limit: int = 6) -> list[dict]: + rows = conn.execute( + """ + select event_id, event_type, call_id, interaction_id, payload_json, created_at + from voice_events + order by id desc + limit ? + """, + (limit,), + ).fetchall() + result = [] + for row in rows: + payload = row["payload_json"] + try: + payload = json.loads(payload) + except Exception: + pass + result.append( + { + "event_id": row["event_id"], + "event_type": row["event_type"], + "call_id": row["call_id"], + "interaction_id": row["interaction_id"], + "payload": payload, + "created_at": row["created_at"], + } + ) + return result + + +def latest_links(conn: sqlite3.Connection, limit: int = 4) -> list[dict]: + rows = conn.execute( + """ + select call_id, interaction_id, status, telephony_status, claimed_by_user, + operator_extension, started_at, connected_at, ended_at + from asterisk_call_links + order by id desc + limit ? + """, + (limit,), + ).fetchall() + return [dict(row) for row in rows] + + +def main() -> int: + with OUT_PATH.open("w", encoding="utf-8") as handle: + write_line(handle, "monitor", {"status": "started", "window_seconds": WINDOW_SECONDS}) + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + headers = login_headers() + write_line(handle, "auth", {"status": "ok", "user": USERNAME, "role": ROLE}) + end_at = time.time() + WINDOW_SECONDS + seen = { + "live": None, + "recent": None, + "events": None, + "links": None, + } + try: + while time.time() < end_at: + try: + live = fetch_json("/proxy/asterisk-bridge/asterisk/live-calls", headers) + if live != seen["live"]: + seen["live"] = live + write_line(handle, "live-calls", live) + except URLError as exc: + write_line(handle, "live-calls-error", {"error": str(exc)}) + + try: + recent = fetch_json("/proxy/asterisk-bridge/asterisk/recent-calls", headers) + if recent != seen["recent"]: + seen["recent"] = recent + write_line(handle, "recent-calls", recent) + except URLError as exc: + write_line(handle, "recent-calls-error", {"error": str(exc)}) + + events = latest_events(conn) + if events != seen["events"]: + seen["events"] = events + write_line(handle, "voice-events", events) + + links = latest_links(conn) + if links != seen["links"]: + seen["links"] = links + write_line(handle, "call-links", links) + + time.sleep(POLL_SECONDS) + finally: + conn.close() + write_line(handle, "monitor", {"status": "finished"}) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/track7_check.py b/scripts/track7_check.py new file mode 100644 index 0000000..678921e --- /dev/null +++ b/scripts/track7_check.py @@ -0,0 +1,154 @@ +from __future__ import annotations + +import argparse +import json +import subprocess +from pathlib import Path +from typing import Any + + +def load_summary(report_dir: Path) -> dict[str, Any]: + summary_path = report_dir / "summary.json" + if not summary_path.exists(): + raise FileNotFoundError(f"Missing summary.json: {summary_path}") + return json.loads(summary_path.read_text(encoding="utf-8")) + + +def evaluate_summary( + summary: dict[str, Any], + *, + require_success_rate: float, + require_p95_seconds: float, + require_p99_seconds: float, +) -> list[str]: + issues: list[str] = [] + results = summary.get("results", {}) + success_rate = float(results.get("success_rate", 0.0)) + p95 = float(results.get("p95_seconds", 0.0)) + p99 = float(results.get("p99_seconds", 0.0)) + five_xx_rate = float(results.get("five_xx_rate", 0.0)) + + if success_rate < require_success_rate: + issues.append(f"Success rate {success_rate:.2f}% is below threshold {require_success_rate:.2f}%") + if p95 > require_p95_seconds: + issues.append(f"P95 {p95:.2f}s exceeds threshold {require_p95_seconds:.2f}s") + if p99 > require_p99_seconds: + issues.append(f"P99 {p99:.2f}s exceeds threshold {require_p99_seconds:.2f}s") + if five_xx_rate > 0.5: + issues.append(f"5xx/transport error rate {five_xx_rate:.2f}% exceeds 0.50%") + return issues + + +def _run_kubectl(args: list[str]) -> subprocess.CompletedProcess[str]: + return subprocess.run(args, capture_output=True, text=True, check=False) + + +def check_pod_health(namespace: str, kubectl_context: str | None = None) -> list[str]: + cmd = ["kubectl"] + if kubectl_context: + cmd.extend(["--context", kubectl_context]) + cmd.extend(["get", "pods", "-n", namespace, "-o", "json"]) + result = _run_kubectl(cmd) + if result.returncode != 0: + return [f"kubectl get pods failed: {result.stderr.strip() or result.stdout.strip()}"] + + payload = json.loads(result.stdout) + issues: list[str] = [] + for item in payload.get("items", []): + name = item.get("metadata", {}).get("name", "") + status = item.get("status", {}) + phase = status.get("phase") + if phase not in {"Running", "Succeeded"}: + issues.append(f"Pod {name} is in unexpected phase {phase}") + for container in status.get("containerStatuses", []): + if container.get("restartCount", 0) > 0: + issues.append(f"Pod {name} restartCount={container.get('restartCount')}") + state = container.get("state", {}) + waiting = state.get("waiting") or {} + terminated = state.get("terminated") or {} + if waiting.get("reason") == "CrashLoopBackOff": + issues.append(f"Pod {name} is in CrashLoopBackOff") + if terminated.get("reason") == "OOMKilled": + issues.append(f"Pod {name} had OOMKilled termination") + return issues + + +def check_kubectl_top(namespace: str, kubectl_context: str | None = None) -> list[str]: + cmd = ["kubectl"] + if kubectl_context: + cmd.extend(["--context", kubectl_context]) + cmd.extend(["top", "pods", "-n", namespace, "--no-headers"]) + result = _run_kubectl(cmd) + if result.returncode != 0: + return [f"kubectl top pods failed: {result.stderr.strip() or result.stdout.strip()}"] + if not result.stdout.strip(): + return ["kubectl top pods returned no rows"] + return [] + + +def check_hpa(namespace: str, kubectl_context: str | None = None) -> list[str]: + cmd = ["kubectl"] + if kubectl_context: + cmd.extend(["--context", kubectl_context]) + cmd.extend(["get", "hpa", "-n", namespace, "-o", "json"]) + result = _run_kubectl(cmd) + if result.returncode != 0: + return [f"kubectl get hpa failed: {result.stderr.strip() or result.stdout.strip()}"] + + payload = json.loads(result.stdout) + items = payload.get("items", []) + if not items: + return ["No HPA objects found in namespace"] + + issues: list[str] = [] + for item in items: + name = item.get("metadata", {}).get("name", "") + status = item.get("status", {}) + current_metrics = status.get("currentMetrics") or [] + if not current_metrics and status.get("currentCPUUtilizationPercentage") is None: + issues.append(f"HPA {name} has no current utilization metrics") + return issues + + +def main() -> None: + parser = argparse.ArgumentParser(description="Validate Track 7 scale/hardening evidence.") + parser.add_argument("--namespace", required=True, help="Kubernetes namespace") + parser.add_argument("--report-dir", required=True, help="Directory produced by scripts/load_test.py") + parser.add_argument("--require-success-rate", type=float, default=99.0) + parser.add_argument("--require-p95-seconds", type=float, default=2.0) + parser.add_argument("--require-p99-seconds", type=float, default=3.5) + parser.add_argument("--kubectl-context", default=None) + args = parser.parse_args() + + report_dir = Path(args.report_dir) + issues: list[str] = [] + try: + summary = load_summary(report_dir) + except Exception as exc: + raise SystemExit(f"[FAIL] {exc}") from exc + + issues.extend( + evaluate_summary( + summary, + require_success_rate=args.require_success_rate, + require_p95_seconds=args.require_p95_seconds, + require_p99_seconds=args.require_p99_seconds, + ) + ) + issues.extend(check_pod_health(args.namespace, kubectl_context=args.kubectl_context)) + issues.extend(check_kubectl_top(args.namespace, kubectl_context=args.kubectl_context)) + issues.extend(check_hpa(args.namespace, kubectl_context=args.kubectl_context)) + + if issues: + print("[FAIL] Track 7 validation failed:") + for issue in issues: + print(f"- {issue}") + raise SystemExit(1) + + print("[PASS] Track 7 validation checks passed") + print(f"- namespace: {args.namespace}") + print(f"- report_dir: {report_dir}") + + +if __name__ == "__main__": + main() diff --git a/scripts/track7_stage.py b/scripts/track7_stage.py new file mode 100644 index 0000000..273ccf6 --- /dev/null +++ b/scripts/track7_stage.py @@ -0,0 +1,220 @@ +from __future__ import annotations + +import argparse +import asyncio +import json +import sys +import time +from pathlib import Path +from typing import Any + +if __package__ in {None, ""}: + sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + from scripts import load_test, track7_check +else: + from scripts import load_test, track7_check + + +PROFILE_THRESHOLDS = { + "step_250_250": { + "require_success_rate": 99.0, + "require_p95_seconds": 1.5, + "require_p99_seconds": 3.0, + }, + "target_500_500": { + "require_success_rate": 99.0, + "require_p95_seconds": 2.0, + "require_p99_seconds": 3.5, + }, +} + + +def build_stage_root(report_root: str | None = None) -> Path: + if report_root: + target = Path(report_root) + else: + timestamp = time.strftime("%Y%m%d_%H%M%S") + target = Path(".artifacts") / "track7" / f"staged_{timestamp}" + target.mkdir(parents=True, exist_ok=True) + return target + + +def resolve_profiles(profiles_raw: str) -> list[str]: + requested = [item.strip() for item in profiles_raw.split(",") if item.strip()] + if not requested: + raise ValueError("At least one profile is required") + unsupported = [name for name in requested if name not in PROFILE_THRESHOLDS] + if unsupported: + raise ValueError(f"Unsupported staged profiles: {', '.join(unsupported)}") + return requested + + +def evaluate_stage( + *, + namespace: str, + report_dir: Path, + kubectl_context: str | None, + thresholds: dict[str, float], +) -> list[str]: + summary = track7_check.load_summary(report_dir) + issues: list[str] = [] + issues.extend( + track7_check.evaluate_summary( + summary, + require_success_rate=thresholds["require_success_rate"], + require_p95_seconds=thresholds["require_p95_seconds"], + require_p99_seconds=thresholds["require_p99_seconds"], + ) + ) + issues.extend(track7_check.check_pod_health(namespace, kubectl_context=kubectl_context)) + issues.extend(track7_check.check_kubectl_top(namespace, kubectl_context=kubectl_context)) + issues.extend(track7_check.check_hpa(namespace, kubectl_context=kubectl_context)) + return issues + + +def write_acceptance_pack(stage_root: Path, payload: dict[str, Any]) -> None: + (stage_root / "acceptance_summary.json").write_text(json.dumps(payload, indent=2), encoding="utf-8") + + lines = [ + "# Track 7 Staged Validation", + "", + f"- Namespace: `{payload['namespace']}`", + f"- Base URL: `{payload['base_url']}`", + f"- Auth mode: `{payload['auth_mode']}`", + f"- Include read traffic: `{int(payload['include_read_traffic'])}`", + f"- Started at: `{payload['started_at']}`", + f"- Completed at: `{payload['completed_at']}`", + f"- Passed: `{payload['passed']}`", + "", + "## Stages", + "", + ] + for stage in payload["stages"]: + lines.extend( + [ + f"### {stage['profile']}", + "", + f"- Report dir: `{stage['report_dir']}`", + f"- Passed: `{stage['passed']}`", + f"- Success rate: `{stage['results']['success_rate']:.2f}%`", + f"- P95: `{stage['results']['p95_seconds']:.4f}s`", + f"- P99: `{stage['results']['p99_seconds']:.4f}s`", + ] + ) + if stage["issues"]: + lines.append("- Issues:") + for issue in stage["issues"]: + lines.append(f" - {issue}") + lines.append("") + (stage_root / "acceptance_summary.md").write_text("\n".join(lines), encoding="utf-8") + + +async def run_staged_validation( + *, + base_url: str, + namespace: str, + profiles: list[str], + auth_mode: str, + include_read_traffic: bool, + kubectl_context: str | None, + report_root: str | None, +) -> tuple[dict[str, Any], Path]: + stage_root = build_stage_root(report_root) + started_at = time.strftime("%Y-%m-%dT%H:%M:%S") + stages: list[dict[str, Any]] = [] + + for profile_name in profiles: + thresholds = PROFILE_THRESHOLDS[profile_name] + report_dir = stage_root / profile_name + summary, out_dir = await load_test.run_profile( + base_url=base_url, + profile_name=profile_name, + ramp_seconds=None, + hold_seconds=None, + voice=None, + digital=None, + include_read_traffic=include_read_traffic, + auth_mode=auth_mode, + report_dir=str(report_dir), + require_success_rate=thresholds["require_success_rate"], + require_p95_seconds=thresholds["require_p95_seconds"], + require_p99_seconds=thresholds["require_p99_seconds"], + ) + issues = evaluate_stage( + namespace=namespace, + report_dir=out_dir, + kubectl_context=kubectl_context, + thresholds=thresholds, + ) + stage_payload = { + "profile": profile_name, + "report_dir": str(out_dir), + "thresholds": thresholds, + "results": summary["results"], + "passed": not issues, + "issues": issues, + } + stages.append(stage_payload) + if issues: + break + + completed_at = time.strftime("%Y-%m-%dT%H:%M:%S") + payload = { + "namespace": namespace, + "base_url": base_url, + "auth_mode": auth_mode, + "include_read_traffic": include_read_traffic, + "started_at": started_at, + "completed_at": completed_at, + "stages": stages, + "passed": all(stage["passed"] for stage in stages) and len(stages) == len(profiles), + } + write_acceptance_pack(stage_root, payload) + return payload, stage_root + + +async def main() -> None: + parser = argparse.ArgumentParser(description="Run staged Track 7 validation and write an acceptance pack.") + parser.add_argument("--base-url", required=True, help="Gateway base URL") + parser.add_argument("--namespace", required=True, help="Kubernetes namespace to validate") + parser.add_argument( + "--profiles", + default="step_250_250,target_500_500", + help="Comma-separated staged profiles. Supported: step_250_250,target_500_500", + ) + parser.add_argument( + "--auth-mode", + choices=["legacy_headers", "bearer"], + default="bearer", + help="Authentication mode for load traffic", + ) + parser.add_argument( + "--include-read-traffic", + type=int, + choices=[0, 1], + default=1, + help="Include background supervisor/reporting reads during staged runs", + ) + parser.add_argument("--kubectl-context", default=None, help="Optional kubectl context override") + parser.add_argument("--report-root", default=None, help="Root directory for stage artifacts") + args = parser.parse_args() + + profiles = resolve_profiles(args.profiles) + payload, stage_root = await run_staged_validation( + base_url=args.base_url, + namespace=args.namespace, + profiles=profiles, + auth_mode=args.auth_mode, + include_read_traffic=bool(args.include_read_traffic), + kubectl_context=args.kubectl_context, + report_root=args.report_root, + ) + print("Track 7 staged validation:") + print(json.dumps(payload, indent=2)) + print(f"Acceptance pack: {stage_root}") + if not payload["passed"]: + raise SystemExit(1) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/scripts/track8_check.py b/scripts/track8_check.py new file mode 100644 index 0000000..a2d17d1 --- /dev/null +++ b/scripts/track8_check.py @@ -0,0 +1,104 @@ +from __future__ import annotations + +import argparse +import os +import sys +from datetime import datetime, timedelta, timezone +from pathlib import Path + +from sqlalchemy import create_engine, inspect, select +from sqlalchemy.orm import Session + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from scripts.event_bus_smoke import run_smoke_check +from services.shared.db import DATABASE_URL, _normalize_database_url +from services.shared.sql_models import EventOutboxRow + + +def _engine_for(database_url: str | None): + return create_engine(_normalize_database_url(database_url or DATABASE_URL), future=True) + + +def _parse_iso(value: str | None) -> datetime | None: + if not value: + return None + try: + return datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + return None + + +def evaluate_track8( + *, + database_url: str | None = None, + require_bus_enabled: bool = False, + base_url: str | None = None, + failed_age_seconds: int = 300, +) -> list[str]: + issues: list[str] = [] + engine = _engine_for(database_url) + inspector = inspect(engine) + tables = set(inspector.get_table_names()) + required_tables = {"event_outbox", "event_inbox", "reporting_event_log"} + missing = sorted(required_tables - tables) + if missing: + issues.append(f"Missing required tables: {', '.join(missing)}") + + if require_bus_enabled and os.getenv("EVENT_BUS_ENABLED", "0").strip() not in {"1", "true", "yes", "on"}: + issues.append("EVENT_BUS_ENABLED is not enabled in the current environment") + + with Session(engine) as session: + failed_rows = session.execute( + select(EventOutboxRow).where(EventOutboxRow.status == "failed") + ).scalars().all() + stale_cutoff = datetime.now(timezone.utc) - timedelta(seconds=failed_age_seconds) + stale_failed = [ + row for row in failed_rows if (_parse_iso(row.updated_at) or _parse_iso(row.created_at) or stale_cutoff) < stale_cutoff + ] + if stale_failed: + issues.append(f"Found {len(stale_failed)} failed outbox events older than {failed_age_seconds}s") + + pending_rows = session.execute( + select(EventOutboxRow).where(EventOutboxRow.status == "pending") + ).scalars().all() + if len(pending_rows) > 500: + issues.append(f"Outbox backlog too high: {len(pending_rows)} pending events") + + if not (ROOT / "contracts" / "events" / "ivr.completed.json").exists(): + issues.append("Missing contracts/events/ivr.completed.json") + + if base_url: + smoke = run_smoke_check(base_url=base_url, database_url=database_url) + if not smoke["passed"]: + issues.append("Event bus smoke check failed") + + return issues + + +def main() -> None: + parser = argparse.ArgumentParser(description="Validate Track 8 event-bus readiness.") + parser.add_argument("--database-url", default=None) + parser.add_argument("--require-bus-enabled", action="store_true") + parser.add_argument("--base-url", default=None) + parser.add_argument("--failed-age-seconds", type=int, default=300) + args = parser.parse_args() + + issues = evaluate_track8( + database_url=args.database_url, + require_bus_enabled=args.require_bus_enabled, + base_url=args.base_url, + failed_age_seconds=args.failed_age_seconds, + ) + if issues: + print("[FAIL] Track 8 validation failed:") + for issue in issues: + print(f"- {issue}") + raise SystemExit(1) + print("[PASS] Track 8 validation checks passed") + + +if __name__ == "__main__": + main() diff --git a/scripts/track9_2_cutover.ps1 b/scripts/track9_2_cutover.ps1 new file mode 100644 index 0000000..92a0d8e --- /dev/null +++ b/scripts/track9_2_cutover.ps1 @@ -0,0 +1,249 @@ +param( + [Parameter(Mandatory = $true)] + [string]$KubeContext, + [Parameter(Mandatory = $true)] + [string]$Namespace, + [Parameter(Mandatory = $true)] + [string]$Release, + [Parameter(Mandatory = $true)] + [string]$GatewayBaseUrl, + [Parameter(Mandatory = $true)] + [string]$DatabaseUrl, + [Parameter(Mandatory = $true)] + [string]$ImageTag, + [Parameter(Mandatory = $true)] + [string]$AmiHost, + [Parameter(Mandatory = $true)] + [string]$AmiUser, + [Parameter(Mandatory = $true)] + [string]$AmiSecret, + [Parameter(Mandatory = $true)] + [string]$SftpHost, + [Parameter(Mandatory = $true)] + [string]$SftpUser, + [Parameter(Mandatory = $true)] + [string]$SftpPassword, + [Parameter(Mandatory = $true)] + [string]$QueueId, + [Parameter(Mandatory = $true)] + [string]$AppTokenSecret, + [string]$ScaleValuesPath = "deployment/helm/values.scale500.yaml", + [string]$StrictValuesPath = "deployment/helm/values.track9-strict.yaml", + [string]$EvidenceOutDir = "", + [switch]$Execute, + [switch]$ForceUpgrade, + [switch]$SkipChecks +) + +$ErrorActionPreference = "Stop" + +$root = Split-Path -Parent $PSScriptRoot +Set-Location $root + +function Run-Checked { + param( + [Parameter(Mandatory = $true)] + [string]$Label, + [Parameter(Mandatory = $true)] + [string[]]$Args + ) + Write-Host "" + Write-Host "==> $Label" + Write-Host " $($Args -join ' ')" + $command = $Args[0] + $arguments = @() + if ($Args.Length -gt 1) { + $arguments = $Args[1..($Args.Length - 1)] + } + & $command @arguments + if ($LASTEXITCODE -ne 0) { + throw "$Label failed with exit code $LASTEXITCODE" + } +} + +function Ensure-File { + param([string]$PathValue) + if (-not (Test-Path $PathValue)) { + throw "Required file not found: $PathValue" + } +} + +Ensure-File -PathValue $ScaleValuesPath +Ensure-File -PathValue $StrictValuesPath + +$timestamp = Get-Date -Format "yyyyMMdd_HHmmss" +$artifactsDir = Join-Path $root ".artifacts/track9_2/$timestamp" +New-Item -ItemType Directory -Path $artifactsDir -Force | Out-Null + +$helmTemplateOut = Join-Path $artifactsDir "helm_template.yaml" +$cutoverValuesFile = Join-Path $artifactsDir "cutover.override.yaml" +$podsSnapshotOut = Join-Path $artifactsDir "pods_before.txt" +$historyBeforeOut = Join-Path $artifactsDir "helm_history_before.txt" +$historyAfterOut = Join-Path $artifactsDir "helm_history_after.txt" +$cutoverSummaryOut = Join-Path $artifactsDir "cutover-summary.txt" + +function Escape-YamlSingleQuoted { + param([string]$Value) + return ($Value -replace "'", "''") +} + +$queueMapJson = '{"voice_lab":"' + $QueueId + '"}' +$overrideYaml = @" +namespace: '$(Escape-YamlSingleQuoted $Namespace)' +image: + tag: '$(Escape-YamlSingleQuoted $ImageTag)' +auth: + appTokenSecret: '$(Escape-YamlSingleQuoted $AppTokenSecret)' +asteriskBridge: + enabled: "1" + amiHost: '$(Escape-YamlSingleQuoted $AmiHost)' + amiUsername: '$(Escape-YamlSingleQuoted $AmiUser)' + amiSecret: '$(Escape-YamlSingleQuoted $AmiSecret)' + queueMapJson: '$(Escape-YamlSingleQuoted $queueMapJson)' + sftpHost: '$(Escape-YamlSingleQuoted $SftpHost)' + sftpUsername: '$(Escape-YamlSingleQuoted $SftpUser)' + sftpPassword: '$(Escape-YamlSingleQuoted $SftpPassword)' +"@ +$overrideYaml | Out-File -FilePath $cutoverValuesFile -Encoding utf8 + +Run-Checked -Label "Switch kube context" -Args @("kubectl", "config", "use-context", $KubeContext) + +Run-Checked -Label "Helm lint" -Args @("helm", "lint", "deployment/helm") + +Write-Host "" +Write-Host "==> Helm history (before)" +Write-Host " helm -n $Namespace history $Release" +& helm -n $Namespace history $Release | Out-File -FilePath $historyBeforeOut -Encoding utf8 +if ($LASTEXITCODE -ne 0) { + "release not found (this can be valid for first install)" | Out-File -FilePath $historyBeforeOut -Encoding utf8 + Write-Warning "Release '$Release' not found in namespace '$Namespace'. Continuing with first-install flow." +} + +Write-Host "" +Write-Host "==> Pods snapshot (before)" +Write-Host " kubectl -n $Namespace get pods -o wide" +& kubectl -n $Namespace get pods -o wide | Out-File -FilePath $podsSnapshotOut -Encoding utf8 +if ($LASTEXITCODE -ne 0) { + "namespace missing or no pods yet (pre-install state)" | Out-File -FilePath $podsSnapshotOut -Encoding utf8 + Write-Warning "Could not fetch pods for namespace '$Namespace'. Continuing." +} + +Write-Host "" +Write-Host "==> Helm template render" +Write-Host " helm template $Release deployment/helm -n $Namespace -f $ScaleValuesPath -f $StrictValuesPath -f $cutoverValuesFile" +& helm template $Release deployment/helm -n $Namespace ` + -f $ScaleValuesPath ` + -f $StrictValuesPath ` + -f $cutoverValuesFile | Out-File -FilePath $helmTemplateOut -Encoding utf8 +if ($LASTEXITCODE -ne 0) { + throw "Helm template render failed with exit code $LASTEXITCODE" +} + +$requiredStrictKeys = @( + "ALLOW_LEGACY_HEADER_AUTH" + "ASTERISK_BRIDGE_AUTH_MODE" + "ASTERISK_BRIDGE_AUTH_FALLBACK_LEGACY" + "VOICE_ADAPTER_TRUSTED_SERVICE_SUBJECTS" + "RECORDING_IMPORT_TRUSTED_SERVICE_SUBJECTS" + "RECORDING_IMPORT_ALLOW_ADMIN" +) +foreach ($key in $requiredStrictKeys) { + if (-not (Select-String -Path $helmTemplateOut -Pattern $key -SimpleMatch)) { + throw "Rendered manifest does not contain expected strict key: $key" + } +} + +if (-not $Execute) { + @( + "Track 9.2 cutover dry-run completed." + "Artifacts: $artifactsDir" + "Rendered manifest: $helmTemplateOut" + "To execute deploy, rerun with -Execute" + ) | Out-File -FilePath $cutoverSummaryOut -Encoding utf8 + Write-Host "" + Write-Host "Dry-run completed." + Write-Host "Artifacts: $artifactsDir" + Write-Host "Run again with -Execute to apply Helm upgrade." + exit 0 +} + +$helmUpgradeArgs = @( + "helm", "upgrade", "--install", $Release, "deployment/helm", "-n", $Namespace, + "-f", $ScaleValuesPath, + "-f", $StrictValuesPath, + "-f", $cutoverValuesFile +) +if ($ForceUpgrade) { + $helmUpgradeArgs += "--force" +} +Run-Checked -Label "Helm upgrade/install" -Args $helmUpgradeArgs + +Run-Checked -Label "Rollout status: api-gateway" -Args @("kubectl", "-n", $Namespace, "rollout", "status", "deploy/api-gateway") +Run-Checked -Label "Rollout status: asterisk-bridge-service" -Args @("kubectl", "-n", $Namespace, "rollout", "status", "deploy/asterisk-bridge-service") +Run-Checked -Label "Rollout status: voice-adapter-service" -Args @("kubectl", "-n", $Namespace, "rollout", "status", "deploy/voice-adapter-service") +Run-Checked -Label "Rollout status: recording-service" -Args @("kubectl", "-n", $Namespace, "rollout", "status", "deploy/recording-service") + +Run-Checked -Label "Helm history (after)" -Args @("helm", "-n", $Namespace, "history", $Release) +& helm -n $Namespace history $Release | Out-File -FilePath $historyAfterOut -Encoding utf8 + +if (-not $SkipChecks) { + Run-Checked -Label "Track9 preflight strict" -Args @( + "python", "scripts/track9_preflight.py", + "--base-url", $GatewayBaseUrl, + "--check-sftp", + "--require-strict-service-auth" + ) + Run-Checked -Label "Asterisk lab smoke" -Args @( + "python", "scripts/asterisk_lab_smoke.py", + "--base-url", $GatewayBaseUrl, + "--database-url", $DatabaseUrl, + "--require-recording" + ) + Run-Checked -Label "Track9 check" -Args @( + "python", "scripts/track9_check.py", + "--base-url", $GatewayBaseUrl, + "--database-url", $DatabaseUrl, + "--require-recording" + ) +} + +$evidenceArgs = @( + "python", "scripts/track9_collect_evidence.py", + "--base-url", $GatewayBaseUrl, + "--database-url", $DatabaseUrl, + "--run-checks", + "--require-recording" +) +if ($EvidenceOutDir.Trim()) { + $evidenceArgs += @("--out-dir", $EvidenceOutDir) +} +Run-Checked -Label "Collect Track9 evidence" -Args $evidenceArgs + +$historyLines = Get-Content $historyAfterOut +$deployedLine = $historyLines | Select-String -Pattern "deployed" | Select-Object -Last 1 +$rollbackHint = "" +if ($deployedLine) { + $parts = ($deployedLine.ToString() -split "\s+", [System.StringSplitOptions]::RemoveEmptyEntries) + if ($parts.Length -ge 1) { + $currentRevision = $parts[0] + $previousRevision = [int]$currentRevision - 1 + if ($previousRevision -ge 1) { + $rollbackHint = "helm -n $Namespace rollback $Release $previousRevision" + } + } +} + +@( + "Track 9.2 cutover execution completed." + "Artifacts: $artifactsDir" + "Gateway: $GatewayBaseUrl" + "Database: $DatabaseUrl" + if ($rollbackHint) { "Rollback command: $rollbackHint" } else { "Rollback command: check helm history manually." } +) | Out-File -FilePath $cutoverSummaryOut -Encoding utf8 + +Write-Host "" +Write-Host "Track 9.2 cutover completed." +Write-Host "Artifacts: $artifactsDir" +if ($rollbackHint) { + Write-Host "Rollback hint: $rollbackHint" +} diff --git a/scripts/track9_check.py b/scripts/track9_check.py new file mode 100644 index 0000000..bbe67ec --- /dev/null +++ b/scripts/track9_check.py @@ -0,0 +1,155 @@ +from __future__ import annotations + +import argparse +import json +import os +from pathlib import Path +import sys +import time + +import httpx +from sqlalchemy import create_engine, text + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from services.shared.security import issue_app_token + + +def _load_env_file(path: str) -> None: + env_path = Path(path).expanduser().resolve() + if not env_path.exists() or not env_path.is_file(): + return + for line in env_path.read_text(encoding="utf-8").splitlines(): + stripped = line.strip() + if not stripped or stripped.startswith("#") or "=" not in stripped: + continue + key, value = stripped.split("=", 1) + key = key.strip() + if key and key not in os.environ: + os.environ[key] = value.strip() + + +def _bool_env(name: str, default: bool) -> bool: + raw = os.getenv(name) + if raw is None: + return default + return raw.strip().lower() in {"1", "true", "yes", "on"} + + +def _ops_headers() -> dict[str, str]: + if _bool_env("ALLOW_LEGACY_HEADER_AUTH", True): + return {"X-User": "admin", "X-Role": "admin"} + token = issue_app_token( + subject="ops:track9-check", + username="track9-check", + role="admin", + auth_source="service", + provider="track9-script", + ttl_seconds=300, + ) + return {"Authorization": f"Bearer {token}"} + + +def _get_json(url: str) -> dict: + with httpx.Client(timeout=10, trust_env=False) as client: + response = client.get(url, headers=_ops_headers()) + response.raise_for_status() + return response.json() + + +def _wait_for_ami_connected(base_url: str, retries: int = 8, delay_seconds: float = 1.0) -> dict: + last_status: dict = {} + for _ in range(retries): + status = _get_json(f"{base_url.rstrip('/')}/proxy/asterisk-bridge/asterisk/status") + last_status = status + if status.get("ami_connected"): + return status + time.sleep(delay_seconds) + return last_status + + +def _table_exists(conn, name: str) -> bool: + try: + conn.execute(text(f"SELECT 1 FROM {name} LIMIT 1")) + return True + except Exception: # noqa: BLE001 + return False + + +def _scalar(conn, sql: str) -> int: + value = conn.execute(text(sql)).scalar() + return int(value or 0) + + +def main() -> int: + parser = argparse.ArgumentParser(description="Track 9 Asterisk integration acceptance validator") + parser.add_argument("--base-url", default="http://127.0.0.1:8080") + parser.add_argument("--env-file", default=".env.production") + parser.add_argument("--database-url", required=True) + parser.add_argument("--require-recording", action="store_true") + args = parser.parse_args() + + _load_env_file(args.env_file) + + failures: list[str] = [] + + health = _get_json(f"{args.base_url.rstrip('/')}/proxy/asterisk-bridge/health") + status = _wait_for_ami_connected(args.base_url) + if health.get("status") != "ok": + failures.append("Bridge health is not ok") + if not status.get("ami_connected"): + failures.append("AMI is not connected") + + engine = create_engine(args.database_url, future=True) + with engine.begin() as conn: + for table in ("asterisk_event_log", "asterisk_call_links", "voice_events"): + if not _table_exists(conn, table): + failures.append(f"Missing table: {table}") + + started = _scalar( + conn, + "SELECT COUNT(*) FROM asterisk_event_log WHERE ami_event_name = 'MVPCCCallStarted' AND forward_status = 'forwarded'", + ) + call_started = _scalar( + conn, + "SELECT COUNT(*) FROM voice_events WHERE event_type = 'call.started' AND payload_json LIKE '%\"source\": \"asterisk\"%'", + ) + links = _scalar(conn, "SELECT COUNT(*) FROM asterisk_call_links") + stale_failures = _scalar( + conn, + "SELECT COUNT(*) FROM asterisk_event_log WHERE forward_status = 'failed'", + ) + recordings = _scalar( + conn, + "SELECT COUNT(*) FROM asterisk_event_log WHERE ami_event_name = 'MVPCCRecordingReady' AND recording_id IS NOT NULL", + ) + + if started < 1: + failures.append("No forwarded MVPCCCallStarted events") + if call_started < 1: + failures.append("No platform call.started events from Asterisk source") + if links < 1: + failures.append("No linked Asterisk calls") + if stale_failures > 0: + failures.append(f"Failed bridge events present: {stale_failures}") + if args.require_recording and recordings < 1: + failures.append("No uploaded recordings linked to Asterisk events") + + if failures: + print("[FAIL] Track 9 validation failed") + for item in failures: + print(f"- {item}") + return 1 + + print("[PASS] Track 9 validation checks passed") + print(f"- started_events: {started}") + print(f"- platform_call_started: {call_started}") + print(f"- call_links: {links}") + print(f"- recording_events_with_upload: {recordings}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/track9_collect_evidence.py b/scripts/track9_collect_evidence.py new file mode 100644 index 0000000..f495f5e --- /dev/null +++ b/scripts/track9_collect_evidence.py @@ -0,0 +1,329 @@ +from __future__ import annotations + +import argparse +import json +import os +import subprocess +import sys +from datetime import datetime +from pathlib import Path + +import httpx +from sqlalchemy import create_engine, text + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from services.shared.security import issue_app_token + + +def _load_env_file(path: str) -> None: + env_path = Path(path).expanduser().resolve() + if not env_path.exists() or not env_path.is_file(): + return + for line in env_path.read_text(encoding="utf-8").splitlines(): + stripped = line.strip() + if not stripped or stripped.startswith("#") or "=" not in stripped: + continue + key, value = stripped.split("=", 1) + key = key.strip() + if key and key not in os.environ: + os.environ[key] = value.strip() + + +def _bool_env(name: str, default: bool) -> bool: + raw = os.getenv(name) + if raw is None: + return default + return raw.strip().lower() in {"1", "true", "yes", "on"} + + +def _ops_headers() -> dict[str, str]: + if _bool_env("ALLOW_LEGACY_HEADER_AUTH", True): + return {"X-User": "admin", "X-Role": "admin"} + token = issue_app_token( + subject="ops:track9-evidence", + username="track9-evidence", + role="admin", + auth_source="service", + provider="track9-script", + ttl_seconds=300, + ) + return {"Authorization": f"Bearer {token}"} + + +def _get_json(url: str) -> dict | list: + with httpx.Client(timeout=15, trust_env=False) as client: + response = client.get(url, headers=_ops_headers()) + response.raise_for_status() + return response.json() + + +def _write_json(path: Path, payload: dict | list) -> None: + path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") + + +def _query_rows(conn, sql: str, limit: int = 20) -> list[dict]: + rows = conn.execute(text(sql), {"limit": limit}).mappings().all() + return [dict(item) for item in rows] + + +def _run_command(command: list[str], output_path: Path) -> int: + result = subprocess.run( # noqa: S603 + command, + capture_output=True, + text=True, + check=False, + ) + output = (result.stdout or "").strip() + errors = (result.stderr or "").strip() + rendered = "\n".join( + [ + f"$ {' '.join(command)}", + f"exit_code={result.returncode}", + "", + "STDOUT:", + output if output else "", + "", + "STDERR:", + errors if errors else "", + "", + ] + ) + output_path.write_text(rendered, encoding="utf-8") + return int(result.returncode) + + +def _render_summary( + *, + base_url: str, + db_url: str, + out_dir: Path, + bridge_status: dict, + failed_events: list[dict], + started_rows: list[dict], + ended_rows: list[dict], + recording_rows: list[dict], + preflight_exit_code: int | None, + smoke_exit_code: int | None, + track9_check_exit_code: int | None, +) -> str: + now = datetime.now().isoformat(timespec="seconds") + lines = [ + "# Track 9 Acceptance Summary", + "", + f"- Generated at: `{now}`", + f"- Gateway: `{base_url}`", + f"- Database: `{db_url}`", + f"- Evidence directory: `{out_dir.as_posix()}`", + "", + "## Bridge status", + "", + f"- `status`: `{bridge_status.get('status')}`", + f"- `ami_connected`: `{bridge_status.get('ami_connected')}`", + f"- `queue_codes_loaded`: `{len(bridge_status.get('queue_codes_loaded') or [])}`", + f"- `sftp_enabled`: `{bridge_status.get('sftp_enabled')}`", + "", + "## Event evidence counts", + "", + f"- `failed bridge events`: `{len(failed_events)}`", + f"- `call.started (asterisk source)`: `{len(started_rows)}`", + f"- `call.ended`: `{len(ended_rows)}`", + f"- `recording-ready with recording_id`: `{len(recording_rows)}`", + "", + "## Files", + "", + "- `bridge_health.json`", + "- `bridge_status.json`", + "- `bridge_failed_events.json`", + "- `db_voice_call_started.json`", + "- `db_voice_call_ended.json`", + "- `db_recording_ready_links.json`", + "- `playback-proof.md`", + ] + if preflight_exit_code is not None: + lines.extend( + [ + "- `preflight_output.txt`", + f" status: `{'PASS' if preflight_exit_code == 0 else 'FAIL'}`", + ] + ) + if smoke_exit_code is not None: + lines.extend( + [ + "- `smoke_output.txt`", + f" status: `{'PASS' if smoke_exit_code == 0 else 'FAIL'}`", + ] + ) + if track9_check_exit_code is not None: + lines.extend( + [ + "- `track9_check_output.txt`", + f" status: `{'PASS' if track9_check_exit_code == 0 else 'FAIL'}`", + ] + ) + lines.extend( + [ + "", + "## Manual attachments", + "", + "- Add supervisor playback proof (screenshot or note) in `playback-proof.md`.", + "", + "## Acceptance decision", + "", + "- [ ] GO", + "- [ ] NO-GO", + "", + "## Notes", + "", + "- Fill environment details and any deviations from runbook.", + ] + ) + return "\n".join(lines) + "\n" + + +def main() -> int: + parser = argparse.ArgumentParser(description="Collect Track 9 acceptance evidence package") + parser.add_argument("--base-url", default="http://127.0.0.1:8080") + parser.add_argument("--env-file", default=".env.production") + parser.add_argument("--database-url", required=True) + parser.add_argument("--out-dir", default="") + parser.add_argument("--limit", type=int, default=20) + parser.add_argument("--run-checks", action="store_true") + parser.add_argument("--require-recording", action="store_true") + parser.add_argument("--python-exe", default=sys.executable) + args = parser.parse_args() + + _load_env_file(args.env_file) + + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + out_dir = ( + Path(args.out_dir) + if args.out_dir.strip() + else Path("docs/acceptance/track9") / timestamp + ) + out_dir.mkdir(parents=True, exist_ok=True) + + base_url = args.base_url.rstrip("/") + health = _get_json(f"{base_url}/proxy/asterisk-bridge/health") + status = _get_json(f"{base_url}/proxy/asterisk-bridge/asterisk/status") + failed_events = _get_json(f"{base_url}/proxy/asterisk-bridge/asterisk/events?status=failed") + + _write_json(out_dir / "bridge_health.json", health) # type: ignore[arg-type] + _write_json(out_dir / "bridge_status.json", status) # type: ignore[arg-type] + _write_json(out_dir / "bridge_failed_events.json", failed_events) # type: ignore[arg-type] + + engine = create_engine(args.database_url, future=True) + with engine.begin() as conn: + started_rows = _query_rows( + conn, + """ + SELECT event_id, call_id, interaction_id, payload_json, created_at + FROM voice_events + WHERE event_type = 'call.started' AND payload_json LIKE '%"source": "asterisk"%' + ORDER BY id DESC + LIMIT :limit + """, + limit=args.limit, + ) + ended_rows = _query_rows( + conn, + """ + SELECT event_id, call_id, interaction_id, payload_json, created_at + FROM voice_events + WHERE event_type = 'call.ended' + ORDER BY id DESC + LIMIT :limit + """, + limit=args.limit, + ) + recording_rows = _query_rows( + conn, + """ + SELECT bridge_event_id, call_id, interaction_id, recording_id, forward_status, updated_at + FROM asterisk_event_log + WHERE ami_event_name = 'MVPCCRecordingReady' AND recording_id IS NOT NULL + ORDER BY id DESC + LIMIT :limit + """, + limit=args.limit, + ) + + _write_json(out_dir / "db_voice_call_started.json", started_rows) + _write_json(out_dir / "db_voice_call_ended.json", ended_rows) + _write_json(out_dir / "db_recording_ready_links.json", recording_rows) + (out_dir / "playback-proof.md").write_text( + "\n".join( + [ + "# Playback Proof", + "", + "- Date/time:", + "- Supervisor user:", + "- Recording ID:", + "- Playback URL:", + "- Result (played/downloaded):", + "- Evidence link/screenshot path:", + "", + ] + ), + encoding="utf-8", + ) + + preflight_exit_code: int | None = None + smoke_exit_code: int | None = None + track9_check_exit_code: int | None = None + if args.run_checks: + preflight_cmd = [ + args.python_exe, + "scripts/track9_preflight.py", + "--base-url", + base_url, + "--check-sftp", + ] + smoke_cmd = [ + args.python_exe, + "scripts/asterisk_lab_smoke.py", + "--base-url", + base_url, + "--database-url", + args.database_url, + ] + check_cmd = [ + args.python_exe, + "scripts/track9_check.py", + "--base-url", + base_url, + "--database-url", + args.database_url, + ] + if args.require_recording: + smoke_cmd.append("--require-recording") + check_cmd.append("--require-recording") + + preflight_exit_code = _run_command(preflight_cmd, out_dir / "preflight_output.txt") + smoke_exit_code = _run_command(smoke_cmd, out_dir / "smoke_output.txt") + track9_check_exit_code = _run_command(check_cmd, out_dir / "track9_check_output.txt") + + summary = _render_summary( + base_url=base_url, + db_url=args.database_url, + out_dir=out_dir, + bridge_status=status if isinstance(status, dict) else {}, + failed_events=failed_events if isinstance(failed_events, list) else [], + started_rows=started_rows, + ended_rows=ended_rows, + recording_rows=recording_rows, + preflight_exit_code=preflight_exit_code, + smoke_exit_code=smoke_exit_code, + track9_check_exit_code=track9_check_exit_code, + ) + (out_dir / "track9-acceptance.md").write_text(summary, encoding="utf-8") + + print("[PASS] Track 9 evidence package created") + print(f"- output_dir: {out_dir.as_posix()}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/track9_preflight.py b/scripts/track9_preflight.py new file mode 100644 index 0000000..dac5900 --- /dev/null +++ b/scripts/track9_preflight.py @@ -0,0 +1,216 @@ +from __future__ import annotations + +import argparse +import json +import os +from pathlib import Path +import sys + +import httpx + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from services.shared.security import issue_app_token + + +def _get_json(url: str) -> dict: + with httpx.Client(timeout=10, trust_env=False) as client: + response = client.get(url, headers=_ops_headers()) + response.raise_for_status() + return response.json() + + +def _parse_queue_map(raw: str) -> dict[str, str]: + payload = json.loads(raw or "{}") + if not isinstance(payload, dict): + raise ValueError("ASTERISK_QUEUE_MAP_JSON must be a JSON object") + result: dict[str, str] = {} + for key, value in payload.items(): + key_s = str(key).strip() + val_s = str(value).strip() + if key_s and val_s: + result[key_s] = val_s + return result + + +def _parse_subjects(raw: str) -> set[str]: + return {item.strip() for item in (raw or "").split(",") if item.strip()} + + +def _load_env_file(path: str) -> None: + env_path = Path(path).expanduser().resolve() + if not env_path.exists() or not env_path.is_file(): + return + for line in env_path.read_text(encoding="utf-8").splitlines(): + stripped = line.strip() + if not stripped or stripped.startswith("#") or "=" not in stripped: + continue + key, value = stripped.split("=", 1) + key = key.strip() + if key and key not in os.environ: + os.environ[key] = value.strip() + + +def _bool_env(name: str, default: bool) -> bool: + raw = os.getenv(name) + if raw is None: + return default + return raw.strip().lower() in {"1", "true", "yes", "on"} + + +def _ops_headers() -> dict[str, str]: + if _bool_env("ALLOW_LEGACY_HEADER_AUTH", True): + return {"X-User": "admin", "X-Role": "admin"} + token = issue_app_token( + subject="ops:track9-preflight", + username="track9-preflight", + role="admin", + auth_source="service", + provider="track9-script", + ttl_seconds=300, + ) + return {"Authorization": f"Bearer {token}"} + + +def _bridge_auth_mode() -> str: + raw = os.getenv("ASTERISK_BRIDGE_AUTH_MODE", "legacy_headers").strip().lower() + if raw in {"legacy_headers", "bearer", "bearer_first"}: + return raw + raise ValueError( + "ASTERISK_BRIDGE_AUTH_MODE must be one of: legacy_headers, bearer_first, bearer" + ) + + +def _check_sftp() -> tuple[bool, str]: + host = os.getenv("ASTERISK_SFTP_HOST", "").strip() + username = os.getenv("ASTERISK_SFTP_USERNAME", "").strip() + password = os.getenv("ASTERISK_SFTP_PASSWORD", "").strip() + base_path = os.getenv("ASTERISK_SFTP_BASE_PATH", "/var/spool/asterisk/monitor").strip() + try: + port = int(os.getenv("ASTERISK_SFTP_PORT", "22").strip()) + except ValueError: + return False, "ASTERISK_SFTP_PORT is not a valid integer" + + if not (host and username and password): + return False, "SFTP env is incomplete (host/username/password)" + + try: + import paramiko # type: ignore + except ImportError: + return False, "paramiko is not installed" + + transport = paramiko.Transport((host, port)) + try: + transport.connect(username=username, password=password) + sftp = paramiko.SFTPClient.from_transport(transport) + try: + sftp.listdir(base_path) + finally: + sftp.close() + except Exception as exc: # noqa: BLE001 + return False, f"SFTP check failed: {exc}" + finally: + try: + transport.close() + except Exception: # noqa: BLE001 + pass + return True, "ok" + + +def main() -> int: + parser = argparse.ArgumentParser(description="Track 9 preflight checks") + parser.add_argument("--base-url", default="http://127.0.0.1:8080") + parser.add_argument("--env-file", default=".env.production") + parser.add_argument("--check-sftp", action="store_true") + parser.add_argument("--require-strict-service-auth", action="store_true") + args = parser.parse_args() + + _load_env_file(args.env_file) + + failures: list[str] = [] + base_url = args.base_url.rstrip("/") + + health = _get_json(f"{base_url}/proxy/asterisk-bridge/health") + status = _get_json(f"{base_url}/proxy/asterisk-bridge/asterisk/status") + queues = _get_json(f"{base_url}/proxy/routing/queues") + + if health.get("status") != "ok": + failures.append("asterisk-bridge health is not ok") + if status.get("status") not in {"ok", "disabled"}: + failures.append("asterisk-bridge status is invalid") + + try: + queue_map = _parse_queue_map(os.getenv("ASTERISK_QUEUE_MAP_JSON", "{}")) + except ValueError as exc: + failures.append(str(exc)) + queue_map = {} + + try: + auth_mode = _bridge_auth_mode() + except ValueError as exc: + failures.append(str(exc)) + auth_mode = "legacy_headers" + + if auth_mode in {"bearer", "bearer_first"}: + app_secret = os.getenv("APP_TOKEN_SECRET", "").strip() + if not app_secret: + failures.append("APP_TOKEN_SECRET is required for bearer bridge auth modes") + elif app_secret == "dev-secret-change-me": + failures.append("APP_TOKEN_SECRET must not use default value in bearer bridge auth modes") + + bridge_subject = os.getenv("ASTERISK_BRIDGE_AUTH_SUBJECT", "svc:asterisk-bridge").strip() + voice_subjects = _parse_subjects(os.getenv("VOICE_ADAPTER_TRUSTED_SERVICE_SUBJECTS", "")) + recording_subjects = _parse_subjects(os.getenv("RECORDING_IMPORT_TRUSTED_SERVICE_SUBJECTS", "")) + if bridge_subject and voice_subjects and bridge_subject not in voice_subjects: + failures.append("VOICE_ADAPTER_TRUSTED_SERVICE_SUBJECTS does not include ASTERISK_BRIDGE_AUTH_SUBJECT") + if bridge_subject and recording_subjects and bridge_subject not in recording_subjects: + failures.append("RECORDING_IMPORT_TRUSTED_SERVICE_SUBJECTS does not include ASTERISK_BRIDGE_AUTH_SUBJECT") + + if args.require_strict_service_auth: + if auth_mode != "bearer": + failures.append("Strict mode requires ASTERISK_BRIDGE_AUTH_MODE=bearer") + if _bool_env("ALLOW_LEGACY_HEADER_AUTH", True): + failures.append("Strict mode requires ALLOW_LEGACY_HEADER_AUTH=0") + if _bool_env("ASTERISK_BRIDGE_AUTH_FALLBACK_LEGACY", False): + failures.append("Strict mode requires ASTERISK_BRIDGE_AUTH_FALLBACK_LEGACY=0") + if _bool_env("RECORDING_IMPORT_ALLOW_ADMIN", True): + failures.append("Strict mode requires RECORDING_IMPORT_ALLOW_ADMIN=0") + if not voice_subjects: + failures.append("Strict mode requires VOICE_ADAPTER_TRUSTED_SERVICE_SUBJECTS") + if not recording_subjects: + failures.append("Strict mode requires RECORDING_IMPORT_TRUSTED_SERVICE_SUBJECTS") + + queue_ids = {str(item.get("queue_id")) for item in queues if isinstance(item, dict)} + missing_queue_ids = sorted({value for value in queue_map.values() if value not in queue_ids}) + if not queue_map: + failures.append("ASTERISK_QUEUE_MAP_JSON is empty") + if missing_queue_ids: + failures.append( + f"Queue map contains unknown queue_id values: {', '.join(missing_queue_ids)}" + ) + + if args.check_sftp: + sftp_ok, sftp_message = _check_sftp() + if not sftp_ok: + failures.append(sftp_message) + + if failures: + print("[FAIL] Track 9 preflight failed") + for item in failures: + print(f"- {item}") + return 1 + + print("[PASS] Track 9 preflight passed") + print(f"- bridge_status: {status.get('status')}") + print(f"- bridge_auth_mode: {auth_mode}") + print(f"- queue_codes_loaded: {len(queue_map)}") + print(f"- mapped_queue_ids: {len(set(queue_map.values()))}") + if args.check_sftp: + print("- sftp: ok") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/uat_dry_run.py b/scripts/uat_dry_run.py new file mode 100644 index 0000000..b0ad30d --- /dev/null +++ b/scripts/uat_dry_run.py @@ -0,0 +1,967 @@ +from __future__ import annotations + +import argparse +import asyncio +import csv +from dataclasses import dataclass +from datetime import datetime, timezone +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys +import time +from typing import Any + +import httpx + + +ROOT = Path(__file__).resolve().parents[1] +DATA_ROOT = ROOT / ".data_uat_dry_run" +DEFAULT_OUTPUT_ROOT = ROOT / "docs" / "uat" / "evidence" + +SCENARIO_SERVICE_MAP = { + "SYS": "platform", + "S0": "auth-service/gateway", + "S1": "customer-service", + "S2": "interaction-service", + "S3": "interaction-service/routing-service", + "S4": "routing-service/interaction-service", + "S5": "voice-adapter-service", + "S6": "telegram-adapter-service", + "S7": "kb-service", + "S8": "supervisor-service", + "S9": "reporting-service", +} + +SEVERITY_RISK_MAP = { + "P1": "High", + "P2": "Medium", + "P3": "Medium", + "P4": "Low", +} + +SERVICE_SPECS = [ + {"name": "auth", "module": "services.auth_service.app:app", "port": 58001}, + {"name": "audit", "module": "services.audit_service.app:app", "port": 58002}, + {"name": "customer", "module": "services.customer_service.app:app", "port": 58003}, + {"name": "interaction", "module": "services.interaction_service.app:app", "port": 58004}, + {"name": "routing", "module": "services.routing_service.app:app", "port": 58005}, + {"name": "voice", "module": "services.voice_adapter_service.app:app", "port": 58006}, + {"name": "telegram", "module": "services.telegram_adapter_service.app:app", "port": 58007}, + {"name": "kb", "module": "services.kb_service.app:app", "port": 58008}, + {"name": "reporting", "module": "services.reporting_service.app:app", "port": 58009}, + {"name": "supervisor", "module": "services.supervisor_service.app:app", "port": 58010}, + {"name": "gateway", "module": "gateway.app:app", "port": 58080}, +] + + +@dataclass +class ScenarioResult: + scenario_id: str + title: str + passed: bool + severity_on_fail: str + details: str + evidence: dict[str, Any] + + +def utc_now() -> datetime: + return datetime.now(timezone.utc) + + +def now_compact() -> str: + return utc_now().strftime("%Y%m%d_%H%M%S") + + +def iso_now() -> str: + return utc_now().isoformat() + + +async def wait_for_health(base_url: str, retries: int = 80, delay: float = 0.2) -> None: + async with httpx.AsyncClient(timeout=2) as client: + for _ in range(retries): + try: + response = await client.get(f"{base_url}/health") + if response.status_code == 200: + return + except Exception: + pass + await asyncio.sleep(delay) + raise RuntimeError(f"Service not healthy: {base_url}/health") + + +def start_one_service(spec: dict[str, Any], data_dir: Path) -> subprocess.Popen: + env = os.environ.copy() + env["CC_DATA_DIR"] = str(data_dir) + + if spec["name"] == "gateway": + env["AUTH_SERVICE_URL"] = "http://127.0.0.1:58001" + env["AUDIT_SERVICE_URL"] = "http://127.0.0.1:58002" + env["CUSTOMER_SERVICE_URL"] = "http://127.0.0.1:58003" + env["INTERACTION_SERVICE_URL"] = "http://127.0.0.1:58004" + env["ROUTING_SERVICE_URL"] = "http://127.0.0.1:58005" + env["VOICE_ADAPTER_SERVICE_URL"] = "http://127.0.0.1:58006" + env["TELEGRAM_ADAPTER_SERVICE_URL"] = "http://127.0.0.1:58007" + env["KB_SERVICE_URL"] = "http://127.0.0.1:58008" + env["REPORTING_SERVICE_URL"] = "http://127.0.0.1:58009" + env["SUPERVISOR_SERVICE_URL"] = "http://127.0.0.1:58010" + + cmd = [ + sys.executable, + "-m", + "uvicorn", + spec["module"], + "--host", + "127.0.0.1", + "--port", + str(spec["port"]), + ] + return subprocess.Popen( + cmd, + cwd=str(ROOT), + env=env, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + + +async def start_services_sequential(data_dir: Path) -> list[subprocess.Popen]: + data_dir.mkdir(parents=True, exist_ok=True) + processes: list[subprocess.Popen] = [] + for spec in SERVICE_SPECS: + proc = start_one_service(spec, data_dir) + processes.append(proc) + await wait_for_health(f"http://127.0.0.1:{spec['port']}") + return processes + + +def stop_services(processes: list[subprocess.Popen]) -> None: + for proc in processes: + if proc.poll() is not None: + continue + try: + proc.terminate() + except Exception: + pass + + for proc in processes: + try: + proc.wait(timeout=2) + except Exception: + try: + proc.kill() + except Exception: + pass + + +def scenario_result( + scenario_id: str, + title: str, + passed: bool, + severity_on_fail: str, + details: str, + evidence: dict[str, Any] | None = None, +) -> ScenarioResult: + return ScenarioResult( + scenario_id=scenario_id, + title=title, + passed=passed, + severity_on_fail=severity_on_fail, + details=details, + evidence=evidence or {}, + ) + + +async def run_scenarios(base_url: str) -> tuple[list[ScenarioResult], dict[str, Any]]: + admin = {"X-User": "admin", "X-Role": "admin"} + supervisor = {"X-User": "supervisor", "X-Role": "supervisor"} + operator = {"X-User": "operator", "X-Role": "operator"} + analyst = {"X-User": "analyst", "X-Role": "analyst"} + results: list[ScenarioResult] = [] + context: dict[str, Any] = { + "started_at": iso_now(), + "baseline_doc": "docs/gates/mvp-pilot-baseline.md", + "wave2_backlog": "docs/roadmap/05-wave2-backlog.md", + } + + async with httpx.AsyncClient(base_url=base_url, timeout=12) as client: + health = await client.get("/health") + registry = await client.get("/registry") + if health.status_code != 200 or registry.status_code != 200: + details = f"health={health.status_code}, registry={registry.status_code}" + return [scenario_result("SYS", "System precheck", False, "P1", details)], context + services = registry.json().get("services", {}) + context["reporting_url"] = services.get("reporting", "") + + login = await client.post("/proxy/auth/auth/login", json={"username": "admin", "password": "admin123"}) + login_ok = False + if login.status_code == 200: + login_ok = bool(login.json().get("access_token")) + denied = await client.post( + "/proxy/auth/users", + headers=operator, + json={ + "username": f"uat_denied_{now_compact()}", + "password": "secret123", + "full_name": "UAT Denied", + "role": "operator", + }, + ) + results.append( + scenario_result( + "S0", + "Login and RBAC deny", + login_ok and denied.status_code == 403, + "P1", + f"login={login.status_code}, denied={denied.status_code}", + {"user": "admin", "denied_status": denied.status_code}, + ) + ) + + queue = await client.post( + "/proxy/routing/queues", + headers=admin, + json={ + "name": f"UAT Dry Queue {now_compact()}", + "description": "UAT dry-run queue", + "rules": [ + {"channel": "voice", "priority": 3, "strategy": "round_robin", "sla_seconds": 30}, + {"channel": "telegram", "priority": 3, "strategy": "round_robin", "sla_seconds": 45}, + ], + }, + ) + if queue.status_code != 200: + results.append( + scenario_result( + "SYS", + "System precheck", + False, + "P1", + f"queue create failed status={queue.status_code}", + ) + ) + context["completed_at"] = iso_now() + return results, context + queue_id = queue.json()["queue_id"] + context["queue_id"] = queue_id + + customer = await client.post( + "/proxy/customer/customers", + json={ + "display_name": "UAT Dry Customer", + "phones": ["+77017770000"], + "preferred_phone": "+77017770000", + "tags": ["uat", "dry-run"], + }, + ) + customer_id = "" + search_ok = False + if customer.status_code == 200: + customer_id = customer.json()["customer_id"] + search = await client.get("/proxy/customer/customers?query=dry-run") + if search.status_code == 200: + search_ok = any(item.get("customer_id") == customer_id for item in search.json()) + context["customer_id"] = customer_id + results.append( + scenario_result( + "S1", + "Customer create and search", + customer.status_code == 200 and search_ok, + "P1", + f"create={customer.status_code}, search={search_ok}", + {"customer_id": customer_id, "query": "dry-run"}, + ) + ) + + voice_interaction_id = "" + interaction = await client.post( + "/proxy/interaction/interactions", + headers=operator, + json={ + "channel": "voice", + "subject": "UAT dry-run S2 voice", + "customer_id": customer_id or None, + "queue_id": queue_id, + "priority": 3, + }, + ) + if interaction.status_code != 200: + results.append( + scenario_result( + "S2", + "Voice interaction lifecycle", + False, + "P1", + f"create status={interaction.status_code}", + ) + ) + else: + voice_interaction_id = interaction.json()["interaction_id"] + context["voice_interaction_id"] = voice_interaction_id + assign = await client.patch( + f"/proxy/interaction/interactions/{voice_interaction_id}/assign", + headers=supervisor, + json={"assignee": "operator_a"}, + ) + close = await client.patch( + f"/proxy/interaction/interactions/{voice_interaction_id}/status", + headers=operator, + json={"status": "closed"}, + ) + timeline = await client.get(f"/proxy/interaction/interactions/{voice_interaction_id}/timeline") + actions: set[str] = set() + if timeline.status_code == 200: + actions = {event.get("action", "") for event in timeline.json().get("events", [])} + expected = {"interaction.created", "interaction.assigned", "interaction.status_changed"} + results.append( + scenario_result( + "S2", + "Voice interaction lifecycle", + assign.status_code == 200 and close.status_code == 200 and expected.issubset(actions), + "P1", + f"assign={assign.status_code}, close={close.status_code}, actions={sorted(actions)}", + {"interaction_id": voice_interaction_id}, + ) + ) + + esc_create = await client.post( + "/proxy/interaction/interactions", + headers=operator, + json={ + "channel": "voice", + "subject": "UAT dry-run S3 escalation", + "customer_id": customer_id or None, + "queue_id": queue_id, + "priority": 3, + }, + ) + if esc_create.status_code != 200: + results.append( + scenario_result( + "S3", + "Assignment and escalation to second line", + False, + "P1", + f"create status={esc_create.status_code}", + ) + ) + else: + esc_interaction_id = esc_create.json()["interaction_id"] + assign = await client.patch( + f"/proxy/interaction/interactions/{esc_interaction_id}/assign", + headers=supervisor, + json={"assignee": "operator_b"}, + ) + escalated = await client.post( + f"/proxy/interaction/interactions/{esc_interaction_id}/escalate", + headers=operator, + json={"target_queue_id": "line2"}, + ) + timeline = await client.get(f"/proxy/interaction/interactions/{esc_interaction_id}/timeline") + actions = [] + if timeline.status_code == 200: + actions = [event.get("action", "") for event in timeline.json().get("events", [])] + results.append( + scenario_result( + "S3", + "Assignment and escalation to second line", + assign.status_code == 200 + and escalated.status_code == 200 + and escalated.json().get("status") == "escalated" + and escalated.json().get("queue_id") == "line2" + and "interaction.escalated" in actions, + "P1", + f"assign={assign.status_code}, escalate={escalated.status_code}, queue={escalated.json().get('queue_id') if escalated.status_code == 200 else ''}", + {"interaction_id": esc_interaction_id, "target_queue": "line2"}, + ) + ) + + route = await client.post(f"/proxy/routing/queues/{queue_id}/route?channel=voice&priority=3") + timeline = ( + await client.get(f"/proxy/interaction/interactions/{voice_interaction_id}/timeline") + if voice_interaction_id + else None + ) + timeline_ok = False + timeline_count = 0 + if timeline and timeline.status_code == 200: + timeline_count = len(timeline.json().get("events", [])) + timeline_ok = timeline_count >= 3 + route_ok = False + if route.status_code == 200: + route_ok = bool(route.json().get("assignee")) + results.append( + scenario_result( + "S4", + "Routing and timeline verification", + route_ok and timeline_ok, + "P2", + f"route={route.status_code}, timeline_ok={timeline_ok}, timeline_events={timeline_count}", + { + "queue_id": queue_id, + "routed_assignee": route.json().get("assignee") if route.status_code == 200 else "", + "interaction_id": voice_interaction_id, + }, + ) + ) + + voice_event = await client.post( + "/proxy/voice/integrations/voice/events", + headers=operator, + json={ + "event_type": "call.started", + "call_id": f"uat_call_{now_compact()}", + "interaction_id": voice_interaction_id or None, + "payload": {"source": "uat_dry_run"}, + }, + ) + voice_event_id = voice_event.json().get("event_id") if voice_event.status_code == 200 else "" + voice_list_ok = False + if voice_event.status_code == 200: + voice_events = await client.get( + "/proxy/voice/integrations/voice/events?limit=10", + headers=supervisor, + ) + if voice_events.status_code == 200: + voice_list_ok = any(item.get("event_id") == voice_event_id for item in voice_events.json()) + results.append( + scenario_result( + "S5", + "Voice event intake", + voice_event.status_code == 200 and voice_list_ok, + "P2", + f"create={voice_event.status_code}, listed={voice_list_ok}", + {"event_id": voice_event_id}, + ) + ) + + telegram = await client.post( + "/proxy/telegram/integrations/telegram/webhook", + json={ + "chat_id": f"uat_dry_chat_{now_compact()}", + "text": "UAT dry-run telegram", + "payload": {"source": "uat_dry_run"}, + }, + ) + telegram_id = telegram.json().get("message_id") if telegram.status_code == 200 else "" + telegram_list_ok = False + if telegram.status_code == 200: + messages = await client.get("/proxy/telegram/integrations/telegram/messages?limit=10") + if messages.status_code == 200: + telegram_list_ok = any(item.get("message_id") == telegram_id for item in messages.json()) + results.append( + scenario_result( + "S6", + "Telegram interaction lifecycle", + telegram.status_code == 200 and telegram_list_ok, + "P1", + f"create={telegram.status_code}, listed={telegram_list_ok}", + {"message_id": telegram_id}, + ) + ) + + category = await client.post( + "/proxy/kb/knowledge/categories", + headers=analyst, + json={"name": f"UAT Dry {now_compact()}", "description": "dry-run"}, + ) + if category.status_code != 200: + results.append( + scenario_result( + "S7", + "KB usage in active handling", + False, + "P2", + f"category create status={category.status_code}", + ) + ) + else: + category_id = category.json()["category_id"] + article = await client.post( + "/proxy/kb/knowledge/articles", + headers=analyst, + json={ + "category_id": category_id, + "title": "UAT dry KB article", + "body": "KB article body for dry run", + "tags": ["uat", "kb", "dry"], + }, + ) + search = await client.get("/proxy/kb/knowledge/search?q=dry") + article_id = article.json().get("article_id") if article.status_code == 200 else "" + found = False + if search.status_code == 200: + found = any(item.get("article_id") == article_id for item in search.json()) + results.append( + scenario_result( + "S7", + "KB usage in active handling", + article.status_code == 200 and search.status_code == 200 and found, + "P2", + f"article={article.status_code}, search={search.status_code}, found={found}", + {"article_id": article_id, "keyword": "dry"}, + ) + ) + + up1 = await client.post( + "/proxy/supervisor/supervisor/agent-states", + json={"agent_id": "uat_dry_a1", "state": "READY", "queue_id": queue_id}, + ) + up2 = await client.post( + "/proxy/supervisor/supervisor/agent-states", + json={"agent_id": "uat_dry_a2", "state": "BUSY", "queue_id": queue_id}, + ) + metrics = await client.post( + f"/proxy/supervisor/supervisor/queue-metrics?queue_id={queue_id}&in_queue=2&avg_wait_seconds=19" + ) + realtime = await client.get("/proxy/supervisor/supervisor/realtime") + queue_seen = False + agent_total = 0 + if realtime.status_code == 200: + body = realtime.json() + agent_total = int(body.get("agents", {}).get("total", 0)) + queue_seen = any(row.get("queue_id") == queue_id for row in body.get("queues", [])) + results.append( + scenario_result( + "S8", + "Supervisor realtime", + up1.status_code == 200 + and up2.status_code == 200 + and metrics.status_code == 200 + and realtime.status_code == 200 + and agent_total >= 2 + and queue_seen, + "P2", + f"state_updates=({up1.status_code},{up2.status_code}), metrics={metrics.status_code}, realtime={realtime.status_code}", + {"queue_id": queue_id, "agent_total": agent_total}, + ) + ) + + kpi_rows = [ + { + "queue_id": queue_id, + "answered": True, + "wait_seconds": 15, + "handle_seconds": 90, + "abandoned": False, + "resolved_first_contact": True, + }, + { + "queue_id": queue_id, + "answered": True, + "wait_seconds": 33, + "handle_seconds": 110, + "abandoned": False, + "resolved_first_contact": False, + }, + { + "queue_id": queue_id, + "answered": False, + "wait_seconds": 10, + "handle_seconds": 0, + "abandoned": True, + "resolved_first_contact": False, + }, + ] + ingest_statuses: list[int] = [] + for row in kpi_rows: + ingested = await client.post("/proxy/reporting/reports/events", json=row) + ingest_statuses.append(ingested.status_code) + kpi = await client.get(f"/proxy/reporting/reports/kpi?queue_id={queue_id}&sl_threshold_seconds=30") + has_kpi = False + if kpi.status_code == 200: + payload = kpi.json().get("kpi", {}) + has_kpi = {"SL", "ASA", "AHT", "Abandon", "FCR"}.issubset(set(payload.keys())) + exported = await client.get("/proxy/reporting/reports/export") + export_raw = exported.json().get("raw", "") if exported.status_code == 200 else "" + csv_ok = ( + exported.status_code == 200 + and "queue_id,answered,wait_seconds,handle_seconds,abandoned,resolved_first_contact,created_at" in export_raw + and queue_id in export_raw + ) + results.append( + scenario_result( + "S9", + "KPI report and export validation", + all(code == 200 for code in ingest_statuses) and kpi.status_code == 200 and has_kpi and csv_ok, + "P2", + f"ingest={ingest_statuses}, kpi={kpi.status_code}, csv={csv_ok}", + {"queue_id": queue_id}, + ) + ) + + context["completed_at"] = iso_now() + return results, context + + +def build_defects(results: list[ScenarioResult]) -> list[dict[str, str]]: + defects: list[dict[str, str]] = [] + stamp = iso_now() + counter = 1 + for row in results: + if row.passed: + continue + defect_id = f"UAT-DRY-{counter:03d}" + counter += 1 + severity = row.severity_on_fail + defects.append( + { + "defect_id": defect_id, + "severity": severity, + "status": "Open", + "scenario": row.scenario_id, + "service": SCENARIO_SERVICE_MAP.get(row.scenario_id, "platform"), + "risk_level": SEVERITY_RISK_MAP.get(severity, "Medium"), + "summary": row.title, + "repro_steps": "See scenario checklist and automated dry-run logs", + "actual_result": row.details, + "expected_result": "Scenario should pass", + "verification_step": f"Re-run scenario {row.scenario_id} after fix", + "owner": "TBD", + "opened_at": stamp, + "closed_at": "", + "backlog_bucket": "MVP" if severity in {"P1", "P2"} else "Wave2", + "notes": "Generated by scripts/uat_dry_run.py", + } + ) + return defects + + +def write_scenario_json(output_dir: Path, results: list[ScenarioResult], context: dict[str, Any]) -> Path: + payload = { + "generated_at": iso_now(), + "context": context, + "scenarios": [ + { + "scenario_id": row.scenario_id, + "title": row.title, + "passed": row.passed, + "severity_on_fail": row.severity_on_fail, + "details": row.details, + "evidence": row.evidence, + } + for row in results + ], + } + path = output_dir / "scenario-results.json" + path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") + return path + + +def write_defect_csv(output_dir: Path, defects: list[dict[str, str]]) -> Path: + path = output_dir / "defect-log.csv" + fieldnames = [ + "defect_id", + "severity", + "status", + "scenario", + "service", + "risk_level", + "summary", + "repro_steps", + "actual_result", + "expected_result", + "verification_step", + "owner", + "opened_at", + "closed_at", + "backlog_bucket", + "notes", + ] + with path.open("w", newline="", encoding="utf-8") as fp: + writer = csv.DictWriter(fp, fieldnames=fieldnames) + writer.writeheader() + for row in defects: + writer.writerow(row) + return path + + +def write_session_protocol( + output_dir: Path, + session_id: str, + base_url: str, + results: list[ScenarioResult], + defects: list[dict[str, str]], + build_version: str, + deployment_date: str, + cluster_namespace: str, +) -> Path: + passed = sum(1 for row in results if row.passed) + failed = sum(1 for row in results if not row.passed) + p1 = sum(1 for row in defects if row["severity"] == "P1") + p2 = sum(1 for row in defects if row["severity"] == "P2") + p3 = sum(1 for row in defects if row["severity"] == "P3") + p4 = sum(1 for row in defects if row["severity"] == "P4") + + lines: list[str] = [] + lines.append("# UAT Session Protocol (Automated Dry Run)") + lines.append("") + lines.append("## Session Metadata") + lines.append("") + lines.append(f"- Session ID: {session_id}") + lines.append(f"- Date: {utc_now().date().isoformat()}") + lines.append(f"- Start time (UTC): {iso_now()}") + lines.append(f"- End time (UTC): {iso_now()}") + lines.append(f"- Environment URL: {base_url}") + lines.append(f"- Build/version: {build_version}") + lines.append(f"- Deployment date: {deployment_date}") + lines.append(f"- Cluster/namespace: {cluster_namespace}") + lines.append("") + lines.append("## Scope Confirmation") + lines.append("") + lines.append("- [x] MVP pilot scope reviewed against `docs/gates/mvp-pilot-baseline.md`") + lines.append("- [x] Out-of-scope requests remain deferred to `docs/roadmap/05-wave2-backlog.md`") + lines.append("- [x] API stabilization rules remain in effect (backward-compatible fixes only)") + lines.append("") + lines.append("## Participants") + lines.append("") + lines.append("- Operators: automated dry-run actor") + lines.append("- Supervisor: automated dry-run actor") + lines.append("- Analyst: automated dry-run actor") + lines.append("- Admin: automated dry-run actor") + lines.append("- Business owner: pending manual UAT") + lines.append("- IT owner: pending manual UAT") + lines.append("") + lines.append("## Preconditions") + lines.append("") + lines.append("- [x] Environment is reachable") + lines.append("- [x] Test accounts are active") + lines.append("- [x] Test queue is configured") + lines.append("- [x] Audit and reporting endpoints are reachable") + lines.append("") + lines.append("## Execution Summary") + lines.append("") + lines.append(f"- Mandatory scenarios executed: {len(results)}") + lines.append(f"- Passed: {passed}") + lines.append(f"- Failed: {failed}") + lines.append("- Blocked: 0") + lines.append("") + lines.append("## Scenario Results") + lines.append("") + for row in results: + mark = "PASS" if row.passed else "FAIL" + lines.append(f"- [{mark}] {row.scenario_id} {row.title}: {row.details}") + lines.append("") + lines.append("## Defect Summary") + lines.append("") + lines.append(f"- P1: {p1}") + lines.append(f"- P2: {p2}") + lines.append(f"- P3: {p3}") + lines.append(f"- P4: {p4}") + lines.append("- Defect log attachment: defect-log.csv") + lines.append("") + lines.append("## Decision") + lines.append("") + if failed == 0: + lines.append("- [x] Accepted with conditions") + lines.append("- [ ] Accepted for pilot completion") + lines.append("- [ ] Not accepted") + else: + lines.append("- [ ] Accepted with conditions") + lines.append("- [ ] Accepted for pilot completion") + lines.append("- [x] Not accepted") + lines.append("") + lines.append("## Comments") + lines.append("") + lines.append("- Automated dry-run completed.") + lines.append("- Manual UAT with real operators/supervisor is still required for final sign-off.") + lines.append("- Any future scope additions must be recorded in `docs/roadmap/05-wave2-backlog.md`.") + lines.append("") + lines.append("## Signatures") + lines.append("") + lines.append("- Business owner: pending") + lines.append("- IT owner: pending") + lines.append("- Supervisor representative: pending") + lines.append(f"- Date: {utc_now().date().isoformat()}") + lines.append("") + path = output_dir / "session-protocol.md" + path.write_text("\n".join(lines), encoding="utf-8") + return path + + +def write_signoff_draft(output_dir: Path, session_id: str, failed: int) -> Path: + lines: list[str] = [] + lines.append("# UAT Sign-off Sheet (Draft)") + lines.append("") + lines.append(f"- Session ID: {session_id}") + lines.append(f"- Date: {utc_now().date().isoformat()}") + lines.append(f"- Scenario failures: {failed}") + lines.append("") + lines.append("## Scope Confirmation") + lines.append("") + lines.append("- MVP pilot scope confirmed against `docs/gates/mvp-pilot-baseline.md`") + lines.append("- Out-of-scope requests remain deferred to `docs/roadmap/05-wave2-backlog.md`") + lines.append("") + lines.append("## Acceptance Statement") + lines.append("") + lines.append("This draft is generated by automated dry-run and cannot replace real UAT signatures.") + lines.append("") + lines.append("## Required Manual Signatures") + lines.append("") + lines.append("- Business owner: pending") + lines.append("- IT owner: pending") + lines.append("- Supervisor representative: pending") + lines.append("") + path = output_dir / "signoff-draft.md" + path.write_text("\n".join(lines), encoding="utf-8") + return path + + +def write_summary( + output_dir: Path, + session_id: str, + base_url: str, + scenario_file: Path, + defect_file: Path, + protocol_file: Path, + signoff_file: Path, + results: list[ScenarioResult], + defects: list[dict[str, str]], +) -> Path: + passed = sum(1 for row in results if row.passed) + failed = sum(1 for row in results if not row.passed) + p1 = sum(1 for row in defects if row["severity"] == "P1") + p2 = sum(1 for row in defects if row["severity"] == "P2") + + lines: list[str] = [] + lines.append("# UAT Dry-Run Summary") + lines.append("") + lines.append(f"- Session ID: {session_id}") + lines.append(f"- Generated at (UTC): {iso_now()}") + lines.append(f"- Environment URL: {base_url}") + lines.append(f"- Scenarios: {len(results)} total, {passed} passed, {failed} failed") + lines.append(f"- Defects: P1={p1}, P2={p2}") + lines.append("") + lines.append("## Files") + lines.append("") + lines.append(f"- Scenario results: `{scenario_file.name}`") + lines.append(f"- Session protocol: `{protocol_file.name}`") + lines.append(f"- Defect log: `{defect_file.name}`") + lines.append(f"- Sign-off draft: `{signoff_file.name}`") + lines.append("") + lines.append("## Next Step") + lines.append("") + lines.append("Run manual UAT with real participants, fix only P1/P2 items, and collect signatures.") + lines.append("Route feature requests and deferred scope to `docs/roadmap/05-wave2-backlog.md`.") + lines.append("") + path = output_dir / "summary.md" + path.write_text("\n".join(lines), encoding="utf-8") + return path + + +def update_p1p2_register(defects: list[dict[str, str]], session_id: str) -> None: + p1 = sum(1 for row in defects if row["severity"] == "P1" and row["status"] == "Open") + p2 = sum(1 for row in defects if row["severity"] == "P2" and row["status"] == "Open") + path = ROOT / "docs" / "gates" / "p1-p2-defects.md" + lines: list[str] = [] + lines.append("# P1/P2 Defect Register (Pilot)") + lines.append("") + lines.append(f"Last update: {utc_now().date().isoformat()} ({session_id})") + lines.append("") + lines.append("## Current Status") + lines.append(f"- Open P1: {p1}") + lines.append(f"- Open P2: {p2}") + lines.append("- Source: `scripts/uat_dry_run.py` and automated checks") + lines.append(f"- Latest dry-run evidence: `docs/uat/evidence/dry_run_{session_id}/`") + lines.append("") + lines.append("## Triage Policy") + lines.append("- Only `P1` and `P2` issues belong to MVP remediation.") + lines.append("- `P3` and `P4` issues move to `docs/roadmap/05-wave2-backlog.md` unless they block sign-off.") + lines.append("") + lines.append("## Pilot Note") + lines.append("- Final P1/P2 closure is confirmed only after the real-operator UAT cycle and sign-off.") + path.write_text("\n".join(lines), encoding="utf-8") + + +async def main() -> None: + parser = argparse.ArgumentParser(description="Run automated UAT dry-run and generate session artifacts") + parser.add_argument("--base-url", default="http://localhost:8080", help="Gateway base URL") + parser.add_argument("--auto-start", action="store_true", help="Auto-start local services for dry-run") + parser.add_argument("--keep-data", action="store_true", help="Keep temporary auto-start data directory") + parser.add_argument( + "--output-dir", + default="", + help="Output root directory (default: docs/uat/evidence)", + ) + parser.add_argument("--session-id", default="", help="Optional session ID override") + parser.add_argument("--build-version", default="v1.0.0-mvp", help="Build/version for protocol") + parser.add_argument( + "--deployment-date", + default=utc_now().date().isoformat(), + help="Deployment date for protocol", + ) + parser.add_argument("--cluster-namespace", default="local/standalone", help="Cluster/namespace for protocol") + parser.add_argument( + "--update-defect-register", + action="store_true", + help="Update docs/gates/p1-p2-defects.md using dry-run result", + ) + args = parser.parse_args() + + output_root = Path(args.output_dir) if args.output_dir else DEFAULT_OUTPUT_ROOT + output_root = output_root if output_root.is_absolute() else (ROOT / output_root).resolve() + session_id = args.session_id.strip() or f"UAT-DRY-{now_compact()}" + session_dir = output_root / f"dry_run_{session_id}" + session_dir.mkdir(parents=True, exist_ok=True) + + run_data_dir = DATA_ROOT / f"run_{int(time.time() * 1000)}" + processes: list[subprocess.Popen] = [] + base_url = args.base_url + + try: + if args.auto_start: + processes = await start_services_sequential(run_data_dir) + base_url = "http://127.0.0.1:58080" + + results, context = await run_scenarios(base_url) + defects = build_defects(results) + + scenario_file = write_scenario_json(session_dir, results, context) + defect_file = write_defect_csv(session_dir, defects) + protocol_file = write_session_protocol( + session_dir, + session_id, + base_url, + results, + defects, + args.build_version, + args.deployment_date, + args.cluster_namespace, + ) + signoff_file = write_signoff_draft(session_dir, session_id, sum(1 for row in results if not row.passed)) + summary_file = write_summary( + session_dir, + session_id, + base_url, + scenario_file, + defect_file, + protocol_file, + signoff_file, + results, + defects, + ) + + if args.update_defect_register: + update_p1p2_register(defects, session_id) + + passed = sum(1 for row in results if row.passed) + failed = sum(1 for row in results if not row.passed) + print(f"UAT dry-run session: {session_id}") + print(f"Output directory: {session_dir}") + print(f"Scenarios: total={len(results)} passed={passed} failed={failed}") + print(f"Summary: {summary_file}") + sys.exit(0 if failed == 0 else 1) + finally: + stop_services(processes) + if args.auto_start and not args.keep_data: + try: + if run_data_dir.exists(): + shutil.rmtree(run_data_dir) + except Exception: + pass + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/scripts/uat_manual_prepare.py b/scripts/uat_manual_prepare.py new file mode 100644 index 0000000..5512d5b --- /dev/null +++ b/scripts/uat_manual_prepare.py @@ -0,0 +1,329 @@ +from __future__ import annotations + +import argparse +import json +from datetime import datetime, timezone +from pathlib import Path +import shutil +from typing import Any + + +ROOT = Path(__file__).resolve().parents[1] +DEFAULT_EVIDENCE_ROOT = ROOT / "docs" / "uat" / "evidence" +DEFAULT_TEMPLATE_ROOT = ROOT / "docs" / "uat" + + +def utc_now() -> datetime: + return datetime.now(timezone.utc) + + +def now_compact() -> str: + return utc_now().strftime("%Y%m%d_%H%M%S") + + +def iso_now() -> str: + return utc_now().isoformat() + + +def find_latest_preflight(evidence_root: Path) -> Path: + candidates = sorted( + evidence_root.glob("preflight_*.md"), + key=lambda path: path.stat().st_mtime, + reverse=True, + ) + if not candidates: + raise FileNotFoundError("No preflight report found in docs/uat/evidence") + return candidates[0] + + +def find_latest_dry_run_dir(evidence_root: Path) -> Path: + candidates = sorted( + [path for path in evidence_root.glob("dry_run_*") if path.is_dir()], + key=lambda path: path.stat().st_mtime, + reverse=True, + ) + if not candidates: + raise FileNotFoundError("No dry-run directory found in docs/uat/evidence") + return candidates[0] + + +def _replace_line(content: str, prefix: str, value: str) -> str: + replacement = f"{prefix} {value}".rstrip() + return content.replace(prefix, replacement, 1) + + +def render_session_protocol( + template_text: str, + *, + session_id: str, + environment_url: str, + build_version: str, + deployment_date: str, + cluster_namespace: str, + participants: dict[str, str], + preflight_name: str, + defect_log_name: str, +) -> str: + content = template_text + today = utc_now().date().isoformat() + content = _replace_line(content, "- Session ID:", session_id) + content = _replace_line(content, "- Date:", today) + content = _replace_line(content, "- Start time:", "") + content = _replace_line(content, "- End time:", "") + content = _replace_line(content, "- Environment URL:", environment_url) + content = _replace_line(content, "- Build/version:", build_version) + content = _replace_line(content, "- Deployment date:", deployment_date) + content = _replace_line(content, "- Cluster/namespace:", cluster_namespace) + + field_map = { + "- Operators:": participants["operators"], + "- Supervisor:": participants["supervisor"], + "- Analyst:": participants["analyst"], + "- Admin:": participants["admin"], + "- Business owner:": participants["business_owner"], + "- IT owner:": participants["it_owner"], + } + for prefix, value in field_map.items(): + content = _replace_line(content, prefix, value) + + content = content.replace( + "- [ ] Preflight report attached (`docs/uat/evidence/preflight_*.md`)", + f"- [x] Preflight report attached (`attachments/{preflight_name}`)", + 1, + ) + content = content.replace( + "- [ ] Defect log prepared from `docs/uat/defect-log-template.csv`", + f"- [x] Defect log prepared from `{defect_log_name}`", + 1, + ) + return content + + +def render_signoff_sheet( + template_text: str, + *, + session_id: str, + environment_url: str, + build_version: str, +) -> str: + lines = template_text.splitlines() + if not lines: + return template_text + + header = [ + lines[0], + "", + f"- Session ID: {session_id}", + f"- Environment URL: {environment_url}", + f"- Build/version: {build_version}", + f"- Prepared at (UTC): {iso_now()}", + "", + ] + return "\n".join(header + lines[1:]) + + +def build_manual_readme( + *, + session_id: str, + environment_url: str, + preflight_name: str, + dry_run_name: str, +) -> str: + lines = [ + "# Manual UAT Session Bundle", + "", + f"- Session ID: {session_id}", + f"- Environment URL: {environment_url}", + f"- Prepared at (UTC): {iso_now()}", + "", + "## Included Files", + "", + "- `session-protocol.md`", + "- `scenario-checklist.md`", + "- `defect-log.csv`", + "- `signoff-sheet.md`", + "- `manifest.json`", + "", + "## Attached Evidence", + "", + f"- `attachments/{preflight_name}`", + f"- `attachments/{dry_run_name}/`", + "", + "## Next Step", + "", + "Run the live manual UAT on the pilot gateway, record any defects, and use the", + "`scripts/finalize_mvp_pilot.py` command only after signatures are captured and", + "all open P1/P2 defects are closed.", + ] + return "\n".join(lines) + + +def prepare_manual_bundle( + *, + session_id: str, + environment_url: str, + build_version: str, + deployment_date: str, + cluster_namespace: str, + participants: dict[str, str], + output_root: Path, + template_root: Path, + preflight_report: Path, + dry_run_dir: Path, + overwrite: bool = False, +) -> Path: + session_dir = output_root / f"manual_{session_id}" + if session_dir.exists(): + if not overwrite: + raise FileExistsError(f"Session directory already exists: {session_dir}") + shutil.rmtree(session_dir) + + attachments_dir = session_dir / "attachments" + attachments_dir.mkdir(parents=True, exist_ok=True) + + copied_preflight = attachments_dir / preflight_report.name + shutil.copy2(preflight_report, copied_preflight) + + copied_dry_run = attachments_dir / dry_run_dir.name + shutil.copytree(dry_run_dir, copied_dry_run) + + session_template = (template_root / "session-template.md").read_text(encoding="utf-8") + signoff_template = (template_root / "signoff-template.md").read_text(encoding="utf-8") + checklist_template = (template_root / "scenario-checklist.md").read_text(encoding="utf-8") + defect_template = (template_root / "defect-log-template.csv").read_text(encoding="utf-8") + + rendered_session = render_session_protocol( + session_template, + session_id=session_id, + environment_url=environment_url, + build_version=build_version, + deployment_date=deployment_date, + cluster_namespace=cluster_namespace, + participants=participants, + preflight_name=preflight_report.name, + defect_log_name="defect-log.csv", + ) + rendered_signoff = render_signoff_sheet( + signoff_template, + session_id=session_id, + environment_url=environment_url, + build_version=build_version, + ) + + (session_dir / "session-protocol.md").write_text(rendered_session, encoding="utf-8") + (session_dir / "signoff-sheet.md").write_text(rendered_signoff, encoding="utf-8") + (session_dir / "scenario-checklist.md").write_text(checklist_template, encoding="utf-8") + (session_dir / "defect-log.csv").write_text(defect_template, encoding="utf-8") + + manifest: dict[str, Any] = { + "prepared_at": iso_now(), + "session_id": session_id, + "environment_url": environment_url, + "build_version": build_version, + "deployment_date": deployment_date, + "cluster_namespace": cluster_namespace, + "participants": participants, + "artifacts": { + "preflight_report": f"attachments/{preflight_report.name}", + "dry_run_bundle": f"attachments/{dry_run_dir.name}", + "session_protocol": "session-protocol.md", + "scenario_checklist": "scenario-checklist.md", + "defect_log": "defect-log.csv", + "signoff_sheet": "signoff-sheet.md", + }, + } + (session_dir / "manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8") + (session_dir / "README.md").write_text( + build_manual_readme( + session_id=session_id, + environment_url=environment_url, + preflight_name=preflight_report.name, + dry_run_name=dry_run_dir.name, + ), + encoding="utf-8", + ) + + return session_dir + + +def main() -> None: + parser = argparse.ArgumentParser(description="Prepare a manual UAT bundle from the latest preflight and dry-run") + parser.add_argument("--session-id", default=f"UAT-MANUAL-{now_compact()}", help="Manual UAT session id") + parser.add_argument( + "--environment-url", + default="http://:8080", + help="Pilot gateway URL recorded in the session pack", + ) + parser.add_argument("--build-version", default="v1.0.0-mvp", help="Build/version under test") + parser.add_argument( + "--deployment-date", + default=utc_now().date().isoformat(), + help="Deployment date recorded in the session pack", + ) + parser.add_argument( + "--cluster-namespace", + default="pilot/production-like", + help="Cluster or namespace label recorded in the session pack", + ) + parser.add_argument("--operators", default="TBD", help="Operator participants") + parser.add_argument("--supervisor", default="TBD", help="Supervisor participant") + parser.add_argument("--analyst", default="TBD", help="Analyst participant") + parser.add_argument("--admin", default="TBD", help="Admin participant") + parser.add_argument("--business-owner", default="TBD", help="Business owner") + parser.add_argument("--it-owner", default="TBD", help="IT owner") + parser.add_argument( + "--preflight-report", + default="", + help="Optional explicit preflight report path (default: latest preflight report)", + ) + parser.add_argument( + "--dry-run-dir", + default="", + help="Optional explicit dry-run directory path (default: latest dry-run evidence folder)", + ) + parser.add_argument( + "--output-root", + default="", + help="Output root directory (default: docs/uat/evidence)", + ) + parser.add_argument("--overwrite", action="store_true", help="Overwrite the session folder if it already exists") + args = parser.parse_args() + + output_root = Path(args.output_root) if args.output_root else DEFAULT_EVIDENCE_ROOT + output_root = output_root if output_root.is_absolute() else (ROOT / output_root).resolve() + + preflight_report = Path(args.preflight_report) if args.preflight_report else find_latest_preflight(DEFAULT_EVIDENCE_ROOT) + if not preflight_report.is_absolute(): + preflight_report = (ROOT / preflight_report).resolve() + + dry_run_dir = Path(args.dry_run_dir) if args.dry_run_dir else find_latest_dry_run_dir(DEFAULT_EVIDENCE_ROOT) + if not dry_run_dir.is_absolute(): + dry_run_dir = (ROOT / dry_run_dir).resolve() + + participants = { + "operators": args.operators, + "supervisor": args.supervisor, + "analyst": args.analyst, + "admin": args.admin, + "business_owner": args.business_owner, + "it_owner": args.it_owner, + } + + session_dir = prepare_manual_bundle( + session_id=args.session_id, + environment_url=args.environment_url, + build_version=args.build_version, + deployment_date=args.deployment_date, + cluster_namespace=args.cluster_namespace, + participants=participants, + output_root=output_root, + template_root=DEFAULT_TEMPLATE_ROOT, + preflight_report=preflight_report, + dry_run_dir=dry_run_dir, + overwrite=args.overwrite, + ) + print(f"Manual UAT bundle prepared: {session_dir}") + + +if __name__ == "__main__": + main() diff --git a/scripts/uat_preflight.py b/scripts/uat_preflight.py new file mode 100644 index 0000000..a2c6338 --- /dev/null +++ b/scripts/uat_preflight.py @@ -0,0 +1,457 @@ +from __future__ import annotations + +import argparse +import asyncio +from dataclasses import dataclass +from datetime import datetime, timezone +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys +import time +from typing import Any + +import httpx + + +ROOT = Path(__file__).resolve().parents[1] +DATA_ROOT = ROOT / ".data_uat_preflight" +DEFAULT_EVIDENCE_DIR = ROOT / "docs" / "uat" / "evidence" + +SERVICE_SPECS = [ + {"name": "auth", "module": "services.auth_service.app:app", "port": 48001}, + {"name": "audit", "module": "services.audit_service.app:app", "port": 48002}, + {"name": "customer", "module": "services.customer_service.app:app", "port": 48003}, + {"name": "interaction", "module": "services.interaction_service.app:app", "port": 48004}, + {"name": "routing", "module": "services.routing_service.app:app", "port": 48005}, + {"name": "voice", "module": "services.voice_adapter_service.app:app", "port": 48006}, + {"name": "telegram", "module": "services.telegram_adapter_service.app:app", "port": 48007}, + {"name": "kb", "module": "services.kb_service.app:app", "port": 48008}, + {"name": "reporting", "module": "services.reporting_service.app:app", "port": 48009}, + {"name": "supervisor", "module": "services.supervisor_service.app:app", "port": 48010}, + {"name": "gateway", "module": "gateway.app:app", "port": 48080}, +] + +REQUIRED_SERVICES = [ + "auth", + "audit", + "customer", + "interaction", + "routing", + "voice", + "telegram", + "kb", + "reporting", + "supervisor", +] + + +@dataclass +class CheckResult: + name: str + ok: bool + details: str + + +def utc_now() -> datetime: + return datetime.now(timezone.utc) + + +def now_compact() -> str: + return utc_now().strftime("%Y%m%d_%H%M%S") + + +def iso_now() -> str: + return utc_now().isoformat() + + +async def wait_for_health(base_url: str, retries: int = 80, delay: float = 0.2) -> None: + async with httpx.AsyncClient(timeout=2) as client: + for _ in range(retries): + try: + response = await client.get(f"{base_url}/health") + if response.status_code == 200: + return + except Exception: + pass + await asyncio.sleep(delay) + raise RuntimeError(f"Service not healthy: {base_url}/health") + + +def start_one_service(spec: dict[str, Any], data_dir: Path) -> subprocess.Popen: + env = os.environ.copy() + env["CC_DATA_DIR"] = str(data_dir) + + if spec["name"] == "gateway": + env["AUTH_SERVICE_URL"] = "http://127.0.0.1:48001" + env["AUDIT_SERVICE_URL"] = "http://127.0.0.1:48002" + env["CUSTOMER_SERVICE_URL"] = "http://127.0.0.1:48003" + env["INTERACTION_SERVICE_URL"] = "http://127.0.0.1:48004" + env["ROUTING_SERVICE_URL"] = "http://127.0.0.1:48005" + env["VOICE_ADAPTER_SERVICE_URL"] = "http://127.0.0.1:48006" + env["TELEGRAM_ADAPTER_SERVICE_URL"] = "http://127.0.0.1:48007" + env["KB_SERVICE_URL"] = "http://127.0.0.1:48008" + env["REPORTING_SERVICE_URL"] = "http://127.0.0.1:48009" + env["SUPERVISOR_SERVICE_URL"] = "http://127.0.0.1:48010" + + cmd = [ + sys.executable, + "-m", + "uvicorn", + spec["module"], + "--host", + "127.0.0.1", + "--port", + str(spec["port"]), + ] + return subprocess.Popen( + cmd, + cwd=str(ROOT), + env=env, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + + +async def start_services_sequential(data_dir: Path) -> list[subprocess.Popen]: + data_dir.mkdir(parents=True, exist_ok=True) + processes: list[subprocess.Popen] = [] + for spec in SERVICE_SPECS: + proc = start_one_service(spec, data_dir) + processes.append(proc) + await wait_for_health(f"http://127.0.0.1:{spec['port']}") + return processes + + +def stop_services(processes: list[subprocess.Popen]) -> None: + for proc in processes: + if proc.poll() is not None: + continue + try: + proc.terminate() + except Exception: + pass + + for proc in processes: + try: + proc.wait(timeout=2) + except Exception: + try: + proc.kill() + except Exception: + pass + + +async def run_checks(base_url: str) -> tuple[list[CheckResult], dict[str, Any]]: + checks: list[CheckResult] = [] + artifacts: dict[str, Any] = {} + admin = {"X-User": "admin", "X-Role": "admin"} + supervisor = {"X-User": "supervisor", "X-Role": "supervisor"} + operator = {"X-User": "operator", "X-Role": "operator"} + analyst = {"X-User": "analyst", "X-Role": "analyst"} + + async with httpx.AsyncClient(base_url=base_url, timeout=10) as client: + try: + health = await client.get("/health") + ok = health.status_code == 200 + checks.append(CheckResult("Gateway health", ok, f"status={health.status_code}")) + if not ok: + return checks, artifacts + except Exception as exc: + checks.append(CheckResult("Gateway health", False, f"exception={exc}")) + return checks, artifacts + + try: + registry = await client.get("/registry") + data = registry.json() if registry.status_code == 200 else {} + services = data.get("services", {}) + missing = [name for name in REQUIRED_SERVICES if name not in services] + ok = registry.status_code == 200 and not missing + details = f"status={registry.status_code}" + if missing: + details += f", missing={missing}" + checks.append(CheckResult("Service registry", ok, details)) + if not ok: + return checks, artifacts + except Exception as exc: + checks.append(CheckResult("Service registry", False, f"exception={exc}")) + return checks, artifacts + + try: + login = await client.post("/proxy/auth/auth/login", json={"username": "admin", "password": "admin123"}) + ok = login.status_code == 200 and "access_token" in login.json() + checks.append(CheckResult("Auth login", ok, f"status={login.status_code}")) + except Exception as exc: + checks.append(CheckResult("Auth login", False, f"exception={exc}")) + return checks, artifacts + + try: + queue = await client.post( + "/proxy/routing/queues", + headers=admin, + json={ + "name": f"UAT Queue {now_compact()}", + "description": "UAT preflight queue", + "rules": [ + {"channel": "voice", "priority": 3, "strategy": "round_robin", "sla_seconds": 30}, + {"channel": "telegram", "priority": 3, "strategy": "round_robin", "sla_seconds": 45}, + ], + }, + ) + queue_ok = queue.status_code == 200 + queue_id = queue.json().get("queue_id") if queue_ok else "" + artifacts["queue_id"] = queue_id + checks.append(CheckResult("Queue create", queue_ok, f"status={queue.status_code}, queue_id={queue_id}")) + except Exception as exc: + checks.append(CheckResult("Queue create", False, f"exception={exc}")) + return checks, artifacts + + try: + customer = await client.post( + "/proxy/customer/customers", + json={ + "display_name": "UAT Preflight Customer", + "phones": ["+77019990000"], + "preferred_phone": "+77019990000", + "tags": ["uat", "preflight"], + }, + ) + customer_ok = customer.status_code == 200 + customer_id = customer.json().get("customer_id") if customer_ok else "" + artifacts["customer_id"] = customer_id + checks.append( + CheckResult("Customer create", customer_ok, f"status={customer.status_code}, customer_id={customer_id}") + ) + except Exception as exc: + checks.append(CheckResult("Customer create", False, f"exception={exc}")) + return checks, artifacts + + interaction_id = "" + try: + interaction = await client.post( + "/proxy/interaction/interactions", + headers=operator, + json={ + "channel": "voice", + "subject": "UAT preflight voice interaction", + "customer_id": artifacts["customer_id"], + "queue_id": artifacts["queue_id"], + "priority": 3, + }, + ) + ok = interaction.status_code == 200 + interaction_id = interaction.json().get("interaction_id") if ok else "" + artifacts["voice_interaction_id"] = interaction_id + checks.append(CheckResult("Voice interaction create", ok, f"status={interaction.status_code}")) + if not ok: + return checks, artifacts + + assign = await client.patch( + f"/proxy/interaction/interactions/{interaction_id}/assign", + headers=supervisor, + json={"assignee": "operator_a"}, + ) + assign_ok = assign.status_code == 200 and assign.json().get("status") == "in_progress" + checks.append(CheckResult("Voice interaction assign", assign_ok, f"status={assign.status_code}")) + + close = await client.patch( + f"/proxy/interaction/interactions/{interaction_id}/status", + headers=operator, + json={"status": "closed"}, + ) + close_ok = close.status_code == 200 and close.json().get("status") == "closed" + checks.append(CheckResult("Voice interaction close", close_ok, f"status={close.status_code}")) + except Exception as exc: + checks.append(CheckResult("Voice interaction lifecycle", False, f"exception={exc}")) + return checks, artifacts + + try: + tg = await client.post( + "/proxy/telegram/integrations/telegram/webhook", + json={ + "chat_id": f"uat_chat_{now_compact()}", + "text": "UAT preflight telegram", + "customer_external_id": None, + "payload": {"source": "uat_preflight"}, + }, + ) + ok = tg.status_code == 200 and tg.json().get("message_id") + artifacts["telegram_message_id"] = tg.json().get("message_id") if tg.status_code == 200 else "" + checks.append(CheckResult("Telegram webhook", bool(ok), f"status={tg.status_code}")) + except Exception as exc: + checks.append(CheckResult("Telegram webhook", False, f"exception={exc}")) + return checks, artifacts + + try: + cat = await client.post( + "/proxy/kb/knowledge/categories", + headers=analyst, + json={"name": f"UAT {now_compact()}", "description": "UAT preflight"}, + ) + cat_ok = cat.status_code == 200 + category_id = cat.json().get("category_id") if cat_ok else "" + if not cat_ok: + checks.append(CheckResult("KB category", False, f"status={cat.status_code}")) + return checks, artifacts + + art = await client.post( + "/proxy/kb/knowledge/articles", + headers=analyst, + json={ + "category_id": category_id, + "title": "UAT preflight article", + "body": "Use this article during UAT flow.", + "tags": ["uat", "kb"], + }, + ) + art_ok = art.status_code == 200 + checks.append(CheckResult("KB article", art_ok, f"status={art.status_code}")) + if not art_ok: + return checks, artifacts + + search = await client.get("/proxy/kb/knowledge/search?q=preflight") + search_ok = search.status_code == 200 and len(search.json()) > 0 + checks.append(CheckResult("KB search", search_ok, f"status={search.status_code}, count={len(search.json())}")) + except Exception as exc: + checks.append(CheckResult("KB checks", False, f"exception={exc}")) + return checks, artifacts + + try: + ingest = await client.post( + "/proxy/reporting/reports/events", + json={ + "queue_id": artifacts["queue_id"], + "answered": True, + "wait_seconds": 18, + "handle_seconds": 105, + "abandoned": False, + "resolved_first_contact": True, + }, + ) + ingest_ok = ingest.status_code == 200 + checks.append(CheckResult("KPI event ingest", ingest_ok, f"status={ingest.status_code}")) + if not ingest_ok: + return checks, artifacts + + kpi = await client.get(f"/proxy/reporting/reports/kpi?queue_id={artifacts['queue_id']}") + kpi_ok = kpi.status_code == 200 and "kpi" in kpi.json() + checks.append(CheckResult("KPI report", kpi_ok, f"status={kpi.status_code}")) + except Exception as exc: + checks.append(CheckResult("KPI checks", False, f"exception={exc}")) + return checks, artifacts + + try: + up1 = await client.post( + "/proxy/supervisor/supervisor/agent-states", + json={"agent_id": "uat_agent_1", "state": "READY", "queue_id": artifacts["queue_id"]}, + ) + up2 = await client.post( + "/proxy/supervisor/supervisor/agent-states", + json={"agent_id": "uat_agent_2", "state": "BUSY", "queue_id": artifacts["queue_id"]}, + ) + rt = await client.get("/proxy/supervisor/supervisor/realtime") + realtime_ok = up1.status_code == 200 and up2.status_code == 200 and rt.status_code == 200 + checks.append(CheckResult("Supervisor realtime", realtime_ok, f"status={rt.status_code}")) + except Exception as exc: + checks.append(CheckResult("Supervisor realtime", False, f"exception={exc}")) + return checks, artifacts + + try: + audit = await client.get("/proxy/audit/audit/events?limit=5") + ok = audit.status_code == 200 + checks.append(CheckResult("Audit availability", ok, f"status={audit.status_code}")) + except Exception as exc: + checks.append(CheckResult("Audit availability", False, f"exception={exc}")) + + artifacts["completed_at"] = iso_now() + return checks, artifacts + + +def write_report( + output_path: Path, + base_url: str, + checks: list[CheckResult], + artifacts: dict[str, Any], + auto_start: bool, +) -> None: + ok_count = sum(1 for c in checks if c.ok) + total = len(checks) + overall = "PASS" if ok_count == total else "FAIL" + + lines: list[str] = [] + lines.append("# UAT Preflight Report") + lines.append("") + lines.append(f"- Timestamp (UTC): {iso_now()}") + lines.append(f"- Base URL: {base_url}") + lines.append(f"- Auto-start mode: {str(auto_start).lower()}") + lines.append(f"- Overall: {overall} ({ok_count}/{total})") + lines.append("") + lines.append("## Scope Baseline") + lines.append("- Pilot scope freeze: `docs/gates/mvp-pilot-baseline.md`") + lines.append("- Deferred scope backlog: `docs/roadmap/05-wave2-backlog.md`") + lines.append("") + lines.append("## Checks") + for item in checks: + marker = "PASS" if item.ok else "FAIL" + lines.append(f"- [{marker}] {item.name}: {item.details}") + lines.append("") + lines.append("## Artifacts") + if artifacts: + for key, value in artifacts.items(): + lines.append(f"- {key}: {value}") + else: + lines.append("- none") + lines.append("") + lines.append("## Next Action") + lines.append("- If all checks passed, run `scripts/uat_dry_run.py` and prepare the manual UAT session.") + lines.append("") + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text("\n".join(lines), encoding="utf-8") + + +async def main() -> None: + parser = argparse.ArgumentParser(description="UAT preflight checker for pilot session") + parser.add_argument("--base-url", default="http://localhost:8080", help="Gateway base URL") + parser.add_argument("--auto-start", action="store_true", help="Auto-start local services for preflight") + parser.add_argument( + "--output", + default="", + help="Report output path (default: docs/uat/evidence/preflight_.md)", + ) + parser.add_argument("--keep-data", action="store_true", help="Keep temporary auto-start data directory") + args = parser.parse_args() + + output = Path(args.output) if args.output else DEFAULT_EVIDENCE_DIR / f"preflight_{now_compact()}.md" + output = output if output.is_absolute() else (ROOT / output).resolve() + + processes: list[subprocess.Popen] = [] + run_data_dir = DATA_ROOT / f"run_{int(time.time() * 1000)}" + base_url = args.base_url + checks: list[CheckResult] = [] + artifacts: dict[str, Any] = {} + try: + if args.auto_start: + processes = await start_services_sequential(run_data_dir) + base_url = "http://127.0.0.1:48080" + + checks, artifacts = await run_checks(base_url) + write_report(output, base_url, checks, artifacts, args.auto_start) + + ok = all(item.ok for item in checks) and len(checks) > 0 + print(f"UAT preflight report: {output}") + print(f"Result: {'PASS' if ok else 'FAIL'}") + sys.exit(0 if ok else 1) + finally: + stop_services(processes) + if args.auto_start and not args.keep_data: + try: + if run_data_dir.exists(): + shutil.rmtree(run_data_dir) + except Exception: + pass + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/services/__init__.py b/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/ai_orchestrator_service/__init__.py b/services/ai_orchestrator_service/__init__.py new file mode 100644 index 0000000..c07c459 --- /dev/null +++ b/services/ai_orchestrator_service/__init__.py @@ -0,0 +1 @@ +from .app import app diff --git a/services/ai_orchestrator_service/app.py b/services/ai_orchestrator_service/app.py new file mode 100644 index 0000000..e8c8c80 --- /dev/null +++ b/services/ai_orchestrator_service/app.py @@ -0,0 +1,2901 @@ +from __future__ import annotations + +from collections import defaultdict +from datetime import datetime, timedelta, timezone +import json +import logging +import math +import os +import re +import time +from typing import Any + +import httpx +from fastapi import Depends, FastAPI, HTTPException, Query +from sqlalchemy import select + +from services.shared.core import Role, new_id, utc_now_iso +from services.shared.db import get_session +from services.shared.kb_localization import normalize_kb_language +from services.shared.kb_search import search_kb_rows +from services.shared.models import ( + AIWhatsAppEnqueueIn, + AIAnalyticsDrilldownFiltersOut, + AIAnalyticsDrilldownItemOut, + AIAnalyticsDrilldownOut, + AIWhatsAppPauseIn, + AIAnalyticsBreakdownsOut, + AIAnalyticsChannelBreakdownOut, + AIAnalyticsCoverageOut, + AIAnalyticsFiltersOut, + AIAnalyticsHandoffReasonBreakdownOut, + AIAnalyticsMetricsOut, + AIAnalyticsOutcomeBreakdownOut, + AIAnalyticsOverviewOut, + AIAnalyticsSessionDetailOut, + AIAnalyticsSessionEventOut, + AIAnalyticsSessionLinkedInteractionOut, + AIAnalyticsTimeseriesOut, + AIAnalyticsTimeseriesPointOut, + AIAnalyticsTotalsOut, + AIAnalyticsWindowOut, + AITelegramEnqueueIn, + AITelegramPauseIn, + HealthResponse, + VoiceAIStartIn, + VoiceAIStartOut, + VoiceAITurnIn, +) +from services.shared.security import issue_app_token, require_roles +from services.shared.sql_init import init_sql_schema +from services.shared.sql_models import ( + AIJobRow, + AISessionRow, + AITurnRow, + Customer, + CustomerExternalIdentity, + Interaction, + InteractionTimeline, + KBArticleRow, + WhatsAppMessageRow, + WhatsAppThreadRow, + TelegramMessageRow, + TelegramThreadRow, +) +from services.ai_orchestrator_service import voice as voice_flows + +app = FastAPI(title="ai-orchestrator-service", version="1.0.0") + +init_sql_schema() + +logger = logging.getLogger(__name__) + +_AI_ANALYTICS_CHANNELS = {"telegram", "whatsapp"} +_AI_ANALYTICS_TERMINAL_STATUSES = {"closed", "handoff_required", "human_owned", "error"} +_AI_ANALYTICS_METRICS = { + "containment_rate", + "handoff_rate", + "ai_latency_avg_ms", + "closed_without_operator_rate", + "human_touched_rate", +} +_AI_ANALYTICS_INTERVALS = {"hour", "day"} +_AI_ANALYTICS_SLICES = {"all", "contained", "handoff", "human_touched", "closed_without_operator", "active", "error"} +_AI_ANALYTICS_DRILLDOWN_SORT_FIELDS = {"created_at", "updated_at", "ai_latency_avg_ms", "status"} +_AI_ANALYTICS_REASON_LABELS = { + "requested_human": "Запрос клиента на оператора", + "knowledge_or_tool_gap": "Недостаточно знаний или tools", + "policy_or_sensitive": "Policy или чувствительная тема", + "delivery_or_runtime_error": "Ошибка доставки или runtime", + "manual_claim": "Ручной takeover", + "other": "Другая причина", +} +_AI_ANALYTICS_OUTCOME_LABELS = { + "contained": "Containment", + "handoff": "Handoff", + "human_touched": "Human touched", + "closed_without_operator": "Closed without operator", + "active": "Active", + "error": "Error", +} + + +def _parse_analytics_timestamp(raw: str, field_name: str) -> datetime: + normalized = raw.strip() + if normalized.endswith("Z"): + normalized = f"{normalized[:-1]}+00:00" + try: + parsed = datetime.fromisoformat(normalized) + except ValueError as exc: + raise HTTPException(status_code=400, detail=f"Invalid {field_name}") from exc + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed.astimezone(timezone.utc) + + +def _normalize_ai_analytics_channel(channel: str | None) -> tuple[str, list[str]]: + normalized = (channel or "all").strip().lower() or "all" + if normalized == "all": + return normalized, ["telegram", "whatsapp"] + if normalized in _AI_ANALYTICS_CHANNELS: + return normalized, [normalized] + return normalized, [] + + +def _normalize_ai_analytics_metric(metric: str) -> str: + normalized = (metric or "containment_rate").strip() + if normalized not in _AI_ANALYTICS_METRICS: + raise HTTPException(status_code=400, detail="Invalid metric") + return normalized + + +def _normalize_ai_analytics_interval(interval: str) -> str: + normalized = (interval or "day").strip().lower() + if normalized not in _AI_ANALYTICS_INTERVALS: + raise HTTPException(status_code=400, detail="Invalid interval") + return normalized + + +def _normalize_ai_analytics_slice(slice_name: str | None) -> str: + normalized = (slice_name or "all").strip().lower() or "all" + if normalized not in _AI_ANALYTICS_SLICES: + raise HTTPException(status_code=400, detail="Invalid slice") + return normalized + + +def _normalize_ai_analytics_sort(sort_by: str | None, sort_dir: str | None) -> tuple[str, str]: + normalized_sort_by = (sort_by or "created_at").strip().lower() or "created_at" + if normalized_sort_by not in _AI_ANALYTICS_DRILLDOWN_SORT_FIELDS: + raise HTTPException(status_code=400, detail="Invalid sort_by") + normalized_sort_dir = (sort_dir or "desc").strip().lower() or "desc" + if normalized_sort_dir not in {"asc", "desc"}: + raise HTTPException(status_code=400, detail="Invalid sort_dir") + return normalized_sort_by, normalized_sort_dir + + +def _normalize_ai_analytics_reason_key(reason_key: str | None) -> str | None: + normalized = (reason_key or "").strip().lower() or None + if normalized is None: + return None + if normalized not in _AI_ANALYTICS_REASON_LABELS: + raise HTTPException(status_code=400, detail="Invalid reason_key") + return normalized + + +def _analytics_percent(numerator: int, denominator: int) -> float: + if denominator <= 0: + return 0.0 + return round((numerator / denominator) * 100, 2) + + +def _analytics_percentile(values: list[int], percentile: float) -> float | None: + if not values: + return None + ordered = sorted(int(value) for value in values) + index = max(0, math.ceil(percentile * len(ordered)) - 1) + return float(ordered[index]) + + +def _analytics_average(values: list[int]) -> float | None: + if not values: + return None + return round(sum(values) / len(values), 2) + + +def _analytics_window(range_from: datetime, range_to: datetime) -> AIAnalyticsWindowOut: + return AIAnalyticsWindowOut(from_ts=range_from.isoformat(), to_ts=range_to.isoformat()) + + +def _analytics_filters(range_from: datetime, range_to: datetime, queue_id: str | None, channel: str | None) -> AIAnalyticsFiltersOut: + return AIAnalyticsFiltersOut( + from_ts=range_from.isoformat(), + to_ts=range_to.isoformat(), + queue_id=queue_id, + channel=channel, + ) + + +def _normalize_ai_handoff_reason(reason: str | None, claimed_by_user: str | None = None) -> tuple[str | None, str | None, str | None]: + raw = (reason or "").strip() or None + if not raw and claimed_by_user: + return "manual_claim", _AI_ANALYTICS_REASON_LABELS["manual_claim"], None + if not raw: + return None, None, None + lowered = raw.lower() + if any(token in lowered for token in ("operator", "жив", "человек", "customer requested", "requested by customer", "клиент запрос")): + key = "requested_human" + elif any(token in lowered for token in ("knowledge", "kb", "tools", "tool", "баз", "знани", "инструмент", "данных")): + key = "knowledge_or_tool_gap" + elif any(token in lowered for token in ("policy", "sensitive", "чувств", "политик", "комплаенс", "restricted")): + key = "policy_or_sensitive" + elif any(token in lowered for token in ("error", "timeout", "delivery", "runtime", "exception", "ошиб", "сбой", "failure")): + key = "delivery_or_runtime_error" + else: + key = "other" + return key, _AI_ANALYTICS_REASON_LABELS[key], raw + + +def _build_ai_analytics_snapshot( + row: AISessionRow, + *, + interaction: Interaction | None, + thread: TelegramThreadRow | WhatsAppThreadRow | None, + turns: list[AITurnRow], +) -> dict[str, Any]: + resolved_queue_id = None + if interaction and interaction.queue_id: + resolved_queue_id = interaction.queue_id + elif thread and getattr(thread, "queue_id", None): + resolved_queue_id = getattr(thread, "queue_id") + + assigned_to = interaction.assigned_to if interaction else None + claimed_by_user = getattr(thread, "claimed_by_user", None) if thread else None + handoff_reason_source = (row.handoff_reason or getattr(thread, "ai_handoff_reason", None) or "").strip() or None + reason_key, reason_label, raw_handoff_reason = _normalize_ai_handoff_reason(handoff_reason_source, claimed_by_user) + + assistant_turns = sum(1 for turn in turns if turn.role == "assistant") + user_turns = sum(1 for turn in turns if turn.role == "user") + tool_turns = sum(1 for turn in turns if turn.source_type == "tool") + latency_values = [ + int(turn.latency_ms) + for turn in turns + if turn.role == "assistant" and turn.source_type == "model" and turn.latency_ms is not None + ] + + interaction_closed = bool(interaction and interaction.status == "closed") + human_touched = bool(row.status == "human_owned" or assigned_to or claimed_by_user) + handoff = bool(row.status in {"handoff_required", "human_owned"} or raw_handoff_reason) + contained = bool(row.status == "closed" and not raw_handoff_reason and not human_touched and not assigned_to) + closed_without_operator = bool(interaction_closed and interaction and not interaction.assigned_to) + + return { + "session_id": row.session_id, + "thread_id": row.thread_id, + "interaction_id": row.interaction_id, + "channel": row.channel, + "queue_id": resolved_queue_id, + "status": row.status, + "created_at": row.created_at, + "updated_at": row.updated_at, + "closed_at": row.closed_at, + "contained": contained, + "handoff": handoff, + "human_touched": human_touched, + "closed_without_operator": closed_without_operator, + "interaction_closed": interaction_closed, + "assigned_to": assigned_to, + "claimed_by_user": claimed_by_user, + "assistant_turns": assistant_turns, + "user_turns": user_turns, + "tool_turns": tool_turns, + "latencies": latency_values, + "ai_latency_avg_ms": _analytics_average(latency_values), + "ai_latency_p95_ms": _analytics_percentile(latency_values, 0.95), + "reason_key": reason_key, + "reason_label": reason_label, + "raw_handoff_reason": raw_handoff_reason, + "subject": interaction.subject if interaction else None, + } + + +def _ai_analytics_coverage_from_snapshots(snapshots: list[dict[str, Any]]) -> AIAnalyticsCoverageOut: + return AIAnalyticsCoverageOut( + sessions_with_interaction_id=sum(1 for item in snapshots if item.get("interaction_id")), + sessions_with_queue_id=sum(1 for item in snapshots if item.get("queue_id")), + sessions_with_latency_turns=sum(1 for item in snapshots if item.get("latencies")), + sessions_with_terminal_state=sum(1 for item in snapshots if item.get("status") in _AI_ANALYTICS_TERMINAL_STATUSES), + sessions_with_handoff_reason=sum(1 for item in snapshots if item.get("raw_handoff_reason")), + ) + + +def _ai_analytics_snapshot_matches_slice(snapshot: dict[str, Any], slice_name: str) -> bool: + if slice_name == "all": + return True + if slice_name == "contained": + return bool(snapshot.get("contained")) + if slice_name == "handoff": + return bool(snapshot.get("handoff")) + if slice_name == "human_touched": + return bool(snapshot.get("human_touched")) + if slice_name == "closed_without_operator": + return bool(snapshot.get("closed_without_operator")) + if slice_name == "active": + return str(snapshot.get("status") or "").lower() == "active" + if slice_name == "error": + return str(snapshot.get("status") or "").lower() == "error" + return True + + +def _ai_analytics_snapshot_matches_query(snapshot: dict[str, Any], query_text: str | None) -> bool: + normalized = (query_text or "").strip().lower() + if not normalized: + return True + haystack = " ".join( + [ + str(snapshot.get("session_id") or ""), + str(snapshot.get("interaction_id") or ""), + str(snapshot.get("thread_id") or ""), + str(snapshot.get("raw_handoff_reason") or ""), + ] + ).lower() + return normalized in haystack + + +def _ai_analytics_snapshot_sort_value(snapshot: dict[str, Any], sort_by: str) -> Any: + if sort_by == "updated_at": + return snapshot.get("updated_at") or "" + if sort_by == "ai_latency_avg_ms": + latency = snapshot.get("ai_latency_avg_ms") + if latency is None: + return -1 + return float(latency) + if sort_by == "status": + return str(snapshot.get("status") or "") + return snapshot.get("created_at") or "" + + +def _ai_analytics_snapshot_to_item(snapshot: dict[str, Any]) -> AIAnalyticsDrilldownItemOut: + return AIAnalyticsDrilldownItemOut( + session_id=str(snapshot.get("session_id") or ""), + thread_id=snapshot.get("thread_id"), + interaction_id=snapshot.get("interaction_id"), + channel=str(snapshot.get("channel") or "unknown"), + queue_id=snapshot.get("queue_id"), + status=str(snapshot.get("status") or "unknown"), + created_at=str(snapshot.get("created_at") or ""), + updated_at=str(snapshot.get("updated_at") or ""), + closed_at=snapshot.get("closed_at"), + contained=bool(snapshot.get("contained")), + handoff=bool(snapshot.get("handoff")), + human_touched=bool(snapshot.get("human_touched")), + closed_without_operator=bool(snapshot.get("closed_without_operator")), + reason_key=snapshot.get("reason_key"), + reason_label=snapshot.get("reason_label"), + raw_handoff_reason=snapshot.get("raw_handoff_reason"), + assigned_to=snapshot.get("assigned_to"), + claimed_by_user=snapshot.get("claimed_by_user"), + assistant_turns=int(snapshot.get("assistant_turns") or 0), + user_turns=int(snapshot.get("user_turns") or 0), + tool_turns=int(snapshot.get("tool_turns") or 0), + ai_latency_avg_ms=snapshot.get("ai_latency_avg_ms"), + ai_latency_p95_ms=snapshot.get("ai_latency_p95_ms"), + ) + + +def _empty_ai_analytics_overview( + *, + range_from: datetime, + range_to: datetime, + queue_id: str | None, + channel: str | None, +) -> AIAnalyticsOverviewOut: + return AIAnalyticsOverviewOut( + window=_analytics_window(range_from, range_to), + filters=_analytics_filters(range_from, range_to, queue_id, channel), + totals=AIAnalyticsTotalsOut(), + metrics=AIAnalyticsMetricsOut(), + breakdowns=AIAnalyticsBreakdownsOut(), + coverage=AIAnalyticsCoverageOut(), + ) + + +def _empty_ai_analytics_timeseries( + *, + range_from: datetime, + range_to: datetime, + metric: str, + interval: str, + queue_id: str | None, + channel: str | None, +) -> AIAnalyticsTimeseriesOut: + return AIAnalyticsTimeseriesOut( + metric=metric, # type: ignore[arg-type] + interval=interval, # type: ignore[arg-type] + filters=_analytics_filters(range_from, range_to, queue_id, channel), + points=[], + ) + + +def _load_ai_analytics_snapshots( + session, + *, + range_from: datetime, + range_to: datetime, + queue_id: str | None, + channels: list[str], +) -> list[dict[str, Any]]: + if not channels: + return [] + + rows = session.execute( + select(AISessionRow).where( + AISessionRow.created_at >= range_from.isoformat(), + AISessionRow.created_at < range_to.isoformat(), + AISessionRow.channel.in_(channels), + ) + ).scalars().all() + if not rows: + return [] + + interaction_ids = {row.interaction_id for row in rows if row.interaction_id} + telegram_thread_ids = {row.thread_id for row in rows if row.channel == "telegram" and row.thread_id} + whatsapp_thread_ids = {row.thread_id for row in rows if row.channel == "whatsapp" and row.thread_id} + session_ids = [row.session_id for row in rows] + + interactions = {} + if interaction_ids: + interactions = { + row.interaction_id: row + for row in session.execute( + select(Interaction).where(Interaction.interaction_id.in_(interaction_ids)) + ).scalars().all() + } + + telegram_threads = {} + if telegram_thread_ids: + telegram_threads = { + row.thread_id: row + for row in session.execute( + select(TelegramThreadRow).where(TelegramThreadRow.thread_id.in_(telegram_thread_ids)) + ).scalars().all() + } + + whatsapp_threads = {} + if whatsapp_thread_ids: + whatsapp_threads = { + row.thread_id: row + for row in session.execute( + select(WhatsAppThreadRow).where(WhatsAppThreadRow.thread_id.in_(whatsapp_thread_ids)) + ).scalars().all() + } + + turns_by_session: dict[str, list[AITurnRow]] = defaultdict(list) + for turn in session.execute( + select(AITurnRow).where( + AITurnRow.session_id.in_(session_ids), + ) + ).scalars().all(): + turns_by_session[turn.session_id].append(turn) + + snapshots: list[dict[str, Any]] = [] + for row in rows: + interaction = interactions.get(row.interaction_id) if row.interaction_id else None + if row.channel == "telegram": + thread = telegram_threads.get(row.thread_id) if row.thread_id else None + else: + thread = whatsapp_threads.get(row.thread_id) if row.thread_id else None + + resolved_queue_id = None + if interaction and interaction.queue_id: + resolved_queue_id = interaction.queue_id + elif thread and getattr(thread, "queue_id", None): + resolved_queue_id = getattr(thread, "queue_id") + + if queue_id: + if not resolved_queue_id or resolved_queue_id != queue_id: + continue + + snapshots.append( + _build_ai_analytics_snapshot( + row, + interaction=interaction, + thread=thread, + turns=list(turns_by_session.get(row.session_id, [])), + ) + ) + return snapshots + + +def _aggregate_ai_analytics_overview( + snapshots: list[dict[str, Any]], + *, + range_from: datetime, + range_to: datetime, + queue_id: str | None, + channel: str | None, +) -> AIAnalyticsOverviewOut: + if not snapshots: + return _empty_ai_analytics_overview( + range_from=range_from, + range_to=range_to, + queue_id=queue_id, + channel=channel, + ) + + total_sessions = len(snapshots) + contained_sessions = sum(1 for item in snapshots if item["contained"]) + handoff_sessions = sum(1 for item in snapshots if item["handoff"]) + closed_sessions = sum(1 for item in snapshots if item["status"] == "closed" or item["interaction_closed"]) + closed_without_operator = sum(1 for item in snapshots if item["closed_without_operator"]) + closed_candidates = sum(1 for item in snapshots if item["interaction_closed"]) + human_touched_sessions = sum(1 for item in snapshots if item["human_touched"]) + latency_values = [latency for item in snapshots for latency in item["latencies"]] + + totals = AIAnalyticsTotalsOut( + sessions_started=total_sessions, + sessions_contained=contained_sessions, + sessions_handoff=handoff_sessions, + sessions_closed=closed_sessions, + sessions_closed_without_operator=closed_without_operator, + assistant_turns=len(latency_values), + ) + metrics = AIAnalyticsMetricsOut( + containment_rate=_analytics_percent(contained_sessions, total_sessions), + handoff_rate=_analytics_percent(handoff_sessions, total_sessions), + ai_latency_avg_ms=_analytics_average(latency_values), + ai_latency_p95_ms=_analytics_percentile(latency_values, 0.95), + closed_without_operator_rate=_analytics_percent(closed_without_operator, closed_candidates), + human_touched_rate=_analytics_percent(human_touched_sessions, total_sessions), + ) + + channel_breakdowns = [] + grouped: dict[str, list[dict[str, Any]]] = defaultdict(list) + for item in snapshots: + grouped[item["channel"]].append(item) + for resolved_channel, items in sorted(grouped.items(), key=lambda entry: (-len(entry[1]), entry[0])): + channel_latencies = [latency for item in items for latency in item["latencies"]] + channel_closed_candidates = sum(1 for item in items if item["interaction_closed"]) + channel_contained = sum(1 for item in items if item["contained"]) + channel_handoff = sum(1 for item in items if item["handoff"]) + channel_closed_without_operator = sum(1 for item in items if item["closed_without_operator"]) + human_touched_sessions = sum(1 for item in items if item["human_touched"]) + channel_breakdowns.append( + AIAnalyticsChannelBreakdownOut( + channel=resolved_channel, + sessions_started=len(items), + sessions_contained=channel_contained, + sessions_handoff=channel_handoff, + sessions_closed_without_operator=channel_closed_without_operator, + assistant_turns=len(channel_latencies), + containment_rate=_analytics_percent(channel_contained, len(items)), + handoff_rate=_analytics_percent(channel_handoff, len(items)), + closed_without_operator_rate=_analytics_percent( + channel_closed_without_operator, + channel_closed_candidates, + ), + ai_latency_avg_ms=_analytics_average(channel_latencies), + ai_only_sessions=len(items) - human_touched_sessions, + human_touched_sessions=human_touched_sessions, + ) + ) + + outcome_breakdowns = [ + AIAnalyticsOutcomeBreakdownOut( + outcome="contained", + label=_AI_ANALYTICS_OUTCOME_LABELS["contained"], + sessions=contained_sessions, + share=_analytics_percent(contained_sessions, total_sessions), + ), + AIAnalyticsOutcomeBreakdownOut( + outcome="handoff", + label=_AI_ANALYTICS_OUTCOME_LABELS["handoff"], + sessions=handoff_sessions, + share=_analytics_percent(handoff_sessions, total_sessions), + ), + AIAnalyticsOutcomeBreakdownOut( + outcome="human_touched", + label=_AI_ANALYTICS_OUTCOME_LABELS["human_touched"], + sessions=human_touched_sessions, + share=_analytics_percent(human_touched_sessions, total_sessions), + ), + AIAnalyticsOutcomeBreakdownOut( + outcome="closed_without_operator", + label=_AI_ANALYTICS_OUTCOME_LABELS["closed_without_operator"], + sessions=closed_without_operator, + share=_analytics_percent(closed_without_operator, total_sessions), + ), + AIAnalyticsOutcomeBreakdownOut( + outcome="active", + label=_AI_ANALYTICS_OUTCOME_LABELS["active"], + sessions=sum(1 for item in snapshots if item["status"] == "active"), + share=_analytics_percent(sum(1 for item in snapshots if item["status"] == "active"), total_sessions), + ), + AIAnalyticsOutcomeBreakdownOut( + outcome="error", + label=_AI_ANALYTICS_OUTCOME_LABELS["error"], + sessions=sum(1 for item in snapshots if item["status"] == "error"), + share=_analytics_percent(sum(1 for item in snapshots if item["status"] == "error"), total_sessions), + ), + ] + + handoff_reason_rows: list[AIAnalyticsHandoffReasonBreakdownOut] = [] + handoff_reason_groups: dict[str, int] = defaultdict(int) + for item in snapshots: + if item["reason_key"]: + handoff_reason_groups[str(item["reason_key"])] += 1 + for reason_key, sessions_count in sorted(handoff_reason_groups.items(), key=lambda entry: (-entry[1], entry[0])): + handoff_reason_rows.append( + AIAnalyticsHandoffReasonBreakdownOut( + reason_key=reason_key, + label=_AI_ANALYTICS_REASON_LABELS.get(reason_key, reason_key), + sessions=sessions_count, + share=_analytics_percent(sessions_count, handoff_sessions or sessions_count), + ) + ) + + coverage = _ai_analytics_coverage_from_snapshots(snapshots) + + return AIAnalyticsOverviewOut( + window=_analytics_window(range_from, range_to), + filters=_analytics_filters(range_from, range_to, queue_id, channel), + totals=totals, + metrics=metrics, + breakdowns=AIAnalyticsBreakdownsOut( + by_channel=channel_breakdowns, + by_outcome=outcome_breakdowns, + by_handoff_reason=handoff_reason_rows, + ), + coverage=coverage, + ) + + +def _load_ai_analytics_overview( + session, + *, + range_from: datetime, + range_to: datetime, + queue_id: str | None, + channel: str | None, +) -> AIAnalyticsOverviewOut: + normalized_channel, channels = _normalize_ai_analytics_channel(channel) + snapshots = _load_ai_analytics_snapshots( + session, + range_from=range_from, + range_to=range_to, + queue_id=queue_id, + channels=channels, + ) + return _aggregate_ai_analytics_overview( + snapshots, + range_from=range_from, + range_to=range_to, + queue_id=queue_id, + channel=normalized_channel, + ) + + +def _timeseries_metric_value(metric: str, overview: AIAnalyticsOverviewOut) -> float | None: + if metric == "containment_rate": + return overview.metrics.containment_rate + if metric == "handoff_rate": + return overview.metrics.handoff_rate + if metric == "ai_latency_avg_ms": + return overview.metrics.ai_latency_avg_ms + if metric == "human_touched_rate": + return overview.metrics.human_touched_rate + return overview.metrics.closed_without_operator_rate + + +def _load_ai_analytics_drilldown( + session, + *, + range_from: datetime, + range_to: datetime, + queue_id: str | None, + channel: str | None, + slice_name: str, + reason_key: str | None, + status: str | None, + query_text: str | None, + sort_by: str, + sort_dir: str, + limit: int, + offset: int, +) -> AIAnalyticsDrilldownOut: + normalized_channel, channels = _normalize_ai_analytics_channel(channel) + if not channels: + return AIAnalyticsDrilldownOut( + items=[], + total=0, + limit=limit, + offset=offset, + filters=AIAnalyticsDrilldownFiltersOut( + from_ts=range_from.isoformat(), + to_ts=range_to.isoformat(), + queue_id=queue_id, + channel=normalized_channel, + slice=slice_name, # type: ignore[arg-type] + reason_key=reason_key, + status=status, + q=query_text, + sort_by=sort_by, # type: ignore[arg-type] + sort_dir=sort_dir, # type: ignore[arg-type] + ), + coverage=AIAnalyticsCoverageOut(), + ) + + snapshots = _load_ai_analytics_snapshots( + session, + range_from=range_from, + range_to=range_to, + queue_id=queue_id, + channels=channels, + ) + filtered = [ + item + for item in snapshots + if _ai_analytics_snapshot_matches_slice(item, slice_name) + and (not reason_key or item.get("reason_key") == reason_key) + and (not status or item.get("status") == status) + and _ai_analytics_snapshot_matches_query(item, query_text) + ] + + reverse = sort_dir == "desc" + filtered.sort( + key=lambda item: ( + _ai_analytics_snapshot_sort_value(item, sort_by), + item.get("created_at") or "", + item.get("session_id") or "", + ), + reverse=reverse, + ) + + paged = filtered[offset : offset + limit] + return AIAnalyticsDrilldownOut( + items=[_ai_analytics_snapshot_to_item(item) for item in paged], + total=len(filtered), + limit=limit, + offset=offset, + filters=AIAnalyticsDrilldownFiltersOut( + from_ts=range_from.isoformat(), + to_ts=range_to.isoformat(), + queue_id=queue_id, + channel=normalized_channel, + slice=slice_name, # type: ignore[arg-type] + reason_key=reason_key, + status=status, + q=query_text, + sort_by=sort_by, # type: ignore[arg-type] + sort_dir=sort_dir, # type: ignore[arg-type] + ), + coverage=_ai_analytics_coverage_from_snapshots(filtered), + ) + + +def _load_ai_analytics_session_detail(session, session_id: str) -> AIAnalyticsSessionDetailOut: + row = session.execute( + select(AISessionRow).where(AISessionRow.session_id == session_id) + ).scalar_one_or_none() + if row is None or row.channel not in _AI_ANALYTICS_CHANNELS: + raise HTTPException(status_code=404, detail="AI session not found") + + interaction = None + if row.interaction_id: + interaction = session.execute( + select(Interaction).where(Interaction.interaction_id == row.interaction_id) + ).scalar_one_or_none() + + thread = None + if row.thread_id: + if row.channel == "telegram": + thread = session.execute( + select(TelegramThreadRow).where(TelegramThreadRow.thread_id == row.thread_id) + ).scalar_one_or_none() + elif row.channel == "whatsapp": + thread = session.execute( + select(WhatsAppThreadRow).where(WhatsAppThreadRow.thread_id == row.thread_id) + ).scalar_one_or_none() + + turns = session.execute( + select(AITurnRow).where(AITurnRow.session_id == session_id).order_by(AITurnRow.created_at.asc(), AITurnRow.id.asc()) + ).scalars().all() + + snapshot = _build_ai_analytics_snapshot(row, interaction=interaction, thread=thread, turns=turns) + timeline: list[AIAnalyticsSessionEventOut] = [ + AIAnalyticsSessionEventOut( + ts=row.created_at, + event_type="session_created", + label="AI session created", + status=row.status, + metadata={ + "channel": row.channel, + "agent_profile": row.agent_profile, + "language": row.language, + }, + ) + ] + for turn in turns: + timeline.append( + AIAnalyticsSessionEventOut( + ts=turn.created_at, + event_type="turn", + label=f"{turn.role or 'turn'} via {turn.source_type or 'unknown'}", + role=turn.role, + source_type=turn.source_type, + latency_ms=turn.latency_ms, + finish_reason=turn.finish_reason, + metadata={ + "model": turn.model, + "thread_id": turn.thread_id, + "interaction_id": turn.interaction_id, + }, + ) + ) + if row.handoff_reason or getattr(thread, "ai_handoff_reason", None): + timeline.append( + AIAnalyticsSessionEventOut( + ts=row.updated_at, + event_type="handoff", + label="Handoff recorded", + status=row.status, + metadata={ + "reason_key": snapshot.get("reason_key"), + "claimed_by_user": snapshot.get("claimed_by_user"), + }, + ) + ) + if row.closed_at: + timeline.append( + AIAnalyticsSessionEventOut( + ts=row.closed_at, + event_type="session_closed", + label="AI session closed", + status=row.status, + metadata={ + "contained": snapshot.get("contained"), + "closed_without_operator": snapshot.get("closed_without_operator"), + }, + ) + ) + elif row.updated_at and row.updated_at != row.created_at: + timeline.append( + AIAnalyticsSessionEventOut( + ts=row.updated_at, + event_type="session_updated", + label="AI session updated", + status=row.status, + metadata={ + "human_touched": snapshot.get("human_touched"), + "reason_key": snapshot.get("reason_key"), + }, + ) + ) + + timeline.sort(key=lambda item: (item.ts, item.event_type)) + + interaction_snapshot = None + if interaction: + interaction_snapshot = AIAnalyticsSessionLinkedInteractionOut( + interaction_id=interaction.interaction_id, + channel=interaction.channel, + queue_id=interaction.queue_id, + status=interaction.status, + assigned_to=interaction.assigned_to, + subject=interaction.subject, + created_at=interaction.created_at, + updated_at=interaction.updated_at, + ) + + return AIAnalyticsSessionDetailOut( + session=_ai_analytics_snapshot_to_item(snapshot), + interaction=interaction_snapshot, + timeline=timeline, + ) + + +def _bool_env(name: str, default: bool) -> bool: + raw = os.getenv(name) + if raw is None: + return default + return raw.strip().lower() in {"1", "true", "yes", "on"} + + +def _int_env(name: str, default: int) -> int: + raw = os.getenv(name) + if raw is None: + return default + try: + return int(raw.strip()) + except ValueError: + return default + + +def _float_env(name: str, default: float) -> float: + raw = os.getenv(name) + if raw is None: + return default + try: + return float(raw.strip()) + except ValueError: + return default + + +def _ai_provider() -> str: + return os.getenv("AI_PROVIDER", "stub").strip() or "stub" + + +def _ai_api_base() -> str: + return os.getenv("AI_API_BASE", "").rstrip("/") + + +def _ai_api_key() -> str: + return os.getenv("AI_API_KEY", "").strip() + + +def _ai_model() -> str: + return os.getenv("AI_MODEL", "stub-telegram-assistant").strip() or "stub-telegram-assistant" + + +def _ai_timeout_seconds() -> float: + return max(3.0, _float_env("AI_TIMEOUT_SECONDS", 20.0)) + + +def _ai_telegram_enabled() -> bool: + return _bool_env("AI_TELEGRAM_ENABLED", False) + + +def _ai_telegram_always_reply() -> bool: + return _bool_env("AI_TELEGRAM_ALWAYS_REPLY", False) + + +def _ai_whatsapp_enabled() -> bool: + return _bool_env("AI_WHATSAPP_ENABLED", False) + + +def _ai_whatsapp_always_reply() -> bool: + return _bool_env("AI_WHATSAPP_ALWAYS_REPLY", False) + + +def _ai_max_context_messages() -> int: + return max(4, _int_env("AI_TELEGRAM_MAX_CONTEXT_MESSAGES", 20)) + + +def _ai_whatsapp_max_context_messages() -> int: + return max(4, _int_env("AI_WHATSAPP_MAX_CONTEXT_MESSAGES", 20)) + + +def _ai_max_kb_results() -> int: + return max(1, _int_env("AI_TELEGRAM_MAX_KB_RESULTS", 3)) + + +def _ai_whatsapp_max_kb_results() -> int: + return max(1, _int_env("AI_WHATSAPP_MAX_KB_RESULTS", 3)) + + +def _ai_handoff_threshold() -> float: + return max(0.0, min(1.0, _float_env("AI_TELEGRAM_CONFIDENCE_HANDOFF_THRESHOLD", 0.65))) + + +def _ai_whatsapp_handoff_threshold() -> float: + return max(0.0, min(1.0, _float_env("AI_WHATSAPP_CONFIDENCE_HANDOFF_THRESHOLD", 0.65))) + + +def _interaction_service_url() -> str: + return os.getenv("INTERACTION_SERVICE_URL", "http://localhost:8004").rstrip("/") + + +def _telegram_service_url() -> str: + return os.getenv("TELEGRAM_ADAPTER_SERVICE_URL", "http://localhost:8007").rstrip("/") + + +def _whatsapp_service_url() -> str: + return os.getenv("WHATSAPP_ADAPTER_SERVICE_URL", "http://localhost:8019").rstrip("/") + + +def _service_headers() -> dict[str, str]: + token = issue_app_token( + subject="svc:ai-orchestrator", + username="ai-orchestrator", + role="admin", + auth_source="service", + provider="ai-orchestrator", + ttl_seconds=300, + ) + return {"Authorization": f"Bearer {token}"} + + +def _interaction_request(method: str, path: str, *, payload: dict | None = None) -> dict: + with httpx.Client(timeout=10.0) as client: + response = client.request( + method, + f"{_interaction_service_url()}{path}", + json=payload, + headers=_service_headers(), + ) + response.raise_for_status() + return response.json() + + +def _telegram_request(method: str, path: str, *, payload: dict | None = None) -> dict: + with httpx.Client(timeout=10.0) as client: + response = client.request( + method, + f"{_telegram_service_url()}{path}", + json=payload, + headers=_service_headers(), + ) + response.raise_for_status() + return response.json() + + +def _whatsapp_request(method: str, path: str, *, payload: dict | None = None) -> dict: + with httpx.Client(timeout=10.0) as client: + response = client.request( + method, + f"{_whatsapp_service_url()}{path}", + json=payload, + headers=_service_headers(), + ) + response.raise_for_status() + return response.json() + + +def _response_error_detail(response: httpx.Response) -> str: + try: + payload = response.json() + except Exception: # noqa: BLE001 + payload = None + if isinstance(payload, dict): + detail = payload.get("detail") + if isinstance(detail, str) and detail.strip(): + return detail.strip() + text = response.text.strip() + if text: + return text + return f"HTTP {response.status_code}" + + +def _conflict_result_from_telegram_error( + session, + *, + job: AIJobRow, + ai_session: AISessionRow, + thread_id: str, + exc: Exception, + reply_message_id: str | None = None, +) -> dict[str, Any] | None: + if not isinstance(exc, httpx.HTTPStatusError) or exc.response.status_code != 409: + return None + + now = utc_now_iso() + thread = _thread_or_404(session, thread_id) + detail = _response_error_detail(exc.response).lower() + + if thread.status == "closed" or thread.ai_state == "closed" or "closed" in detail: + ai_session.status = "closed" + ai_session.closed_at = ai_session.closed_at or now + ai_session.updated_at = now + _mark_job_done(session, job) + session.commit() + result: dict[str, Any] = {"ok": True, "status": "closed", "job_id": job.job_id} + if reply_message_id: + result["reply_message_id"] = reply_message_id + return result + + if thread.claimed_by_user or thread.ai_state == "human_owned" or "human operator" in detail: + ai_session.status = "human_owned" + ai_session.updated_at = now + ai_session.handoff_reason = thread.ai_handoff_reason + _mark_job_done(session, job) + session.commit() + result = {"ok": True, "status": "human_owned", "job_id": job.job_id} + if reply_message_id: + result["reply_message_id"] = reply_message_id + return result + + return None + + +def _conflict_result_from_whatsapp_error( + session, + *, + job: AIJobRow, + ai_session: AISessionRow, + thread_id: str, + exc: Exception, + reply_message_id: str | None = None, +) -> dict[str, Any] | None: + if not isinstance(exc, httpx.HTTPStatusError) or exc.response.status_code != 409: + return None + + now = utc_now_iso() + thread = _whatsapp_thread_or_404(session, thread_id) + detail = _response_error_detail(exc.response).lower() + + if thread.status == "closed" or thread.ai_state == "closed" or "closed" in detail: + ai_session.status = "closed" + ai_session.closed_at = ai_session.closed_at or now + ai_session.updated_at = now + _mark_job_done(session, job) + session.commit() + result: dict[str, Any] = {"ok": True, "status": "closed", "job_id": job.job_id} + if reply_message_id: + result["reply_message_id"] = reply_message_id + return result + + if thread.claimed_by_user or thread.ai_state == "human_owned" or "human operator" in detail: + ai_session.status = "human_owned" + ai_session.updated_at = now + ai_session.handoff_reason = thread.ai_handoff_reason + _mark_job_done(session, job) + session.commit() + result = {"ok": True, "status": "human_owned", "job_id": job.job_id} + if reply_message_id: + result["reply_message_id"] = reply_message_id + return result + + return None + + +def _push_timeline(session, interaction_id: str, action: str, metadata: dict | None = None) -> None: + session.add( + InteractionTimeline( + interaction_id=interaction_id, + timestamp=utc_now_iso(), + action=action, + metadata_json=json.dumps(metadata or {}, ensure_ascii=False), + ) + ) + + +def _normalize_external_subject(value: str | None) -> str | None: + raw = str(value or "").strip() + if not raw: + return None + if raw.startswith("telegram:") or raw.startswith("whatsapp:"): + return raw.split(":", 1)[1].strip() or None + return raw + + +def _customer_id_is_real(customer_id: str | None) -> bool: + return str(customer_id or "").startswith("cus_") + + +def _telegram_identity_subjects( + telegram_user_id: str | None, + chat_id: str, + explicit: str | None = None, +) -> list[str]: + values = [ + _normalize_external_subject(telegram_user_id), + _normalize_external_subject(explicit), + _normalize_external_subject(chat_id), + ] + seen: set[str] = set() + result: list[str] = [] + for value in values: + if not value or value in seen: + continue + seen.add(value) + result.append(value) + return result + + +def _whatsapp_identity_subjects( + whatsapp_user_id: str | None, + chat_id: str, + *, + phone_number: str | None = None, + explicit: str | None = None, +) -> list[str]: + values = [ + _normalize_external_subject(whatsapp_user_id), + _normalize_external_subject(phone_number), + _normalize_external_subject(explicit), + _normalize_external_subject(chat_id), + ] + seen: set[str] = set() + result: list[str] = [] + for value in values: + if not value or value in seen: + continue + seen.add(value) + result.append(value) + return result + + +def _ensure_customer_external_identity( + session, + *, + customer_id: str, + channel: str, + external_subject: str, + display_name_snapshot: str | None, + now: str, +) -> None: + row = session.execute( + select(CustomerExternalIdentity).where( + CustomerExternalIdentity.channel == channel, + CustomerExternalIdentity.external_subject == external_subject, + ) + ).scalar_one_or_none() + if row: + row.customer_id = customer_id + if display_name_snapshot: + row.display_name_snapshot = display_name_snapshot + row.updated_at = now + return + session.add( + CustomerExternalIdentity( + identity_id=new_id("cei"), + customer_id=customer_id, + channel=channel, + external_subject=external_subject, + display_name_snapshot=display_name_snapshot, + created_at=now, + updated_at=now, + ) + ) + + +def _resolve_or_create_customer_id(session, thread: TelegramThreadRow, interaction: Interaction) -> str: + if _customer_id_is_real(interaction.customer_id): + return str(interaction.customer_id) + + now = utc_now_iso() + legacy_subject = _normalize_external_subject(interaction.customer_id) + subjects = _telegram_identity_subjects(thread.telegram_user_id, thread.chat_id, legacy_subject) + + for subject in subjects: + identity = session.execute( + select(CustomerExternalIdentity).where( + CustomerExternalIdentity.channel == "telegram", + CustomerExternalIdentity.external_subject == subject, + ) + ).scalar_one_or_none() + if identity: + if thread.display_name and identity.display_name_snapshot != thread.display_name: + identity.display_name_snapshot = thread.display_name + identity.updated_at = now + interaction.customer_id = identity.customer_id + interaction.updated_at = now + return identity.customer_id + + customer = Customer( + customer_id=new_id("cus"), + display_name=thread.display_name or thread.username or f"Telegram {thread.chat_id}", + phones_json="[]", + preferred_phone=None, + tags_json=json.dumps(["telegram"], ensure_ascii=False), + created_at=now, + ) + session.add(customer) + interaction.customer_id = customer.customer_id + interaction.updated_at = now + + for subject in subjects or [_normalize_external_subject(thread.chat_id) or thread.chat_id]: + _ensure_customer_external_identity( + session, + customer_id=customer.customer_id, + channel="telegram", + external_subject=subject, + display_name_snapshot=thread.display_name, + now=now, + ) + return customer.customer_id + + +def _resolve_or_create_whatsapp_customer_id(session, thread: WhatsAppThreadRow, interaction: Interaction) -> str: + if _customer_id_is_real(interaction.customer_id): + return str(interaction.customer_id) + + now = utc_now_iso() + legacy_subject = _normalize_external_subject(interaction.customer_id) + subjects = _whatsapp_identity_subjects( + thread.whatsapp_user_id, + thread.chat_id, + phone_number=thread.phone_number, + explicit=legacy_subject, + ) + + for subject in subjects: + identity = session.execute( + select(CustomerExternalIdentity).where( + CustomerExternalIdentity.channel == "whatsapp", + CustomerExternalIdentity.external_subject == subject, + ) + ).scalar_one_or_none() + if identity: + if thread.display_name and identity.display_name_snapshot != thread.display_name: + identity.display_name_snapshot = thread.display_name + identity.updated_at = now + interaction.customer_id = identity.customer_id + interaction.updated_at = now + return identity.customer_id + + phones = [thread.phone_number] if thread.phone_number else [] + customer = Customer( + customer_id=new_id("cus"), + display_name=thread.display_name or thread.phone_number or f"WhatsApp {thread.chat_id}", + phones_json=json.dumps(phones, ensure_ascii=False), + preferred_phone=thread.phone_number, + tags_json=json.dumps(["whatsapp"], ensure_ascii=False), + created_at=now, + ) + session.add(customer) + interaction.customer_id = customer.customer_id + interaction.updated_at = now + + for subject in subjects or [_normalize_external_subject(thread.chat_id) or thread.chat_id]: + _ensure_customer_external_identity( + session, + customer_id=customer.customer_id, + channel="whatsapp", + external_subject=subject, + display_name_snapshot=thread.display_name, + now=now, + ) + return customer.customer_id + + +def _infer_language(text: str) -> str: + source = str(text or "").lower() + if re.search(r"[әіңғүұқөһ]", source): + return "kz" + kz_keywords = ("сәлем", "көмек", "рақмет", "өтінемін", "керек", "қалай", "қайырлы") + ru_keywords = ("здравствуйте", "помощь", "оператор", "заявка", "тариф") + kz_hits = sum(token in source for token in kz_keywords) + ru_hits = sum(token in source for token in ru_keywords) + if kz_hits > ru_hits and kz_hits > 0: + return "kz" + if ru_hits > kz_hits and ru_hits > 0: + return "ru" + if re.search(r"[әіңғүұқөһ]", source): + return "kz" + if any(token in source for token in ("сәлем", "көмек", "рақмет", "өтінемін", "баға", "тариф")): + return "kz" + return "ru" + + +def _thread_or_404(session, thread_id: str) -> TelegramThreadRow: + row = session.execute( + select(TelegramThreadRow).where(TelegramThreadRow.thread_id == thread_id) + ).scalar_one_or_none() + if not row: + raise HTTPException(status_code=404, detail="Telegram thread not found") + return row + + +def _whatsapp_thread_or_404(session, thread_id: str) -> WhatsAppThreadRow: + row = session.execute( + select(WhatsAppThreadRow).where(WhatsAppThreadRow.thread_id == thread_id) + ).scalar_one_or_none() + if not row: + raise HTTPException(status_code=404, detail="WhatsApp thread not found") + return row + + +def _article_snippet(article: KBArticleRow, limit: int = 240) -> str: + raw = f"{article.title}. {article.body}".strip() + if len(raw) <= limit: + return raw + return f"{raw[: limit - 3]}..." + + +def _kb_search(session, text: str, *, language: str | None = None) -> list[KBArticleRow]: + if not str(text or "").strip(): + return [] + stmt = select(KBArticleRow).order_by(KBArticleRow.id.desc()) + if language is not None: + stmt = stmt.where(KBArticleRow.language == normalize_kb_language(language)) + rows = session.execute(stmt).scalars().all() + return search_kb_rows(rows, text, limit=_ai_max_kb_results()) + + +def _last_messages(session, thread_id: str, limit: int) -> list[TelegramMessageRow]: + rows = session.execute( + select(TelegramMessageRow) + .where(TelegramMessageRow.thread_id == thread_id) + .order_by(TelegramMessageRow.id.desc()) + ).scalars().all() + return list(reversed(rows[:limit])) + + +def _last_whatsapp_messages(session, thread_id: str, limit: int) -> list[WhatsAppMessageRow]: + rows = session.execute( + select(WhatsAppMessageRow) + .where(WhatsAppMessageRow.thread_id == thread_id) + .order_by(WhatsAppMessageRow.id.desc()) + ).scalars().all() + return list(reversed(rows[:limit])) + + +def _select_trigger_message( + session, + *, + thread_id: str, + trigger_message_id: str | None, +) -> TelegramMessageRow | None: + if trigger_message_id: + direct = session.execute( + select(TelegramMessageRow).where(TelegramMessageRow.message_id == trigger_message_id) + ).scalar_one_or_none() + if direct and direct.thread_id == thread_id and direct.author_type == "customer": + return direct + rows = session.execute( + select(TelegramMessageRow) + .where( + TelegramMessageRow.thread_id == thread_id, + TelegramMessageRow.author_type == "customer", + ) + .order_by(TelegramMessageRow.id.desc()) + ).scalars().all() + return rows[0] if rows else None + + +def _select_whatsapp_trigger_message( + session, + *, + thread_id: str, + trigger_message_id: str | None, +) -> WhatsAppMessageRow | None: + if trigger_message_id: + direct = session.execute( + select(WhatsAppMessageRow).where(WhatsAppMessageRow.message_id == trigger_message_id) + ).scalar_one_or_none() + if direct and direct.thread_id == thread_id and direct.author_type == "customer": + return direct + rows = session.execute( + select(WhatsAppMessageRow) + .where( + WhatsAppMessageRow.thread_id == thread_id, + WhatsAppMessageRow.author_type == "customer", + ) + .order_by(WhatsAppMessageRow.id.desc()) + ).scalars().all() + return rows[0] if rows else None + + +def _existing_job_for_thread(session, thread_id: str) -> AIJobRow | None: + rows = session.execute( + select(AIJobRow) + .where( + AIJobRow.thread_id == thread_id, + AIJobRow.status.in_(["pending", "running"]), + ) + .order_by(AIJobRow.id.desc()) + ).scalars().all() + return rows[0] if rows else None + + +def _latest_job_for_trigger(session, thread_id: str, trigger_message_id: str | None) -> AIJobRow | None: + if not trigger_message_id: + return None + rows = session.execute( + select(AIJobRow) + .where( + AIJobRow.thread_id == thread_id, + AIJobRow.trigger_message_id == trigger_message_id, + ) + .order_by(AIJobRow.id.desc()) + ).scalars().all() + return rows[0] if rows else None + + +def _ensure_ai_session( + session, + *, + thread: TelegramThreadRow, + interaction: Interaction, + customer_id: str, + language: str, +) -> tuple[AISessionRow, bool]: + existing = None + if thread.ai_session_id: + existing = session.execute( + select(AISessionRow).where(AISessionRow.session_id == thread.ai_session_id) + ).scalar_one_or_none() + if existing and existing.status not in {"closed", "error"}: + existing.customer_id = customer_id + existing.interaction_id = interaction.interaction_id + existing.language = language or existing.language + existing.updated_at = utc_now_iso() + return existing, False + + now = utc_now_iso() + ai_session = AISessionRow( + session_id=new_id("ais"), + channel="telegram", + thread_id=thread.thread_id, + interaction_id=interaction.interaction_id, + customer_id=customer_id, + agent_profile="telegram_support", + language=language, + status="active", + summary_text="", + last_user_message_id=None, + last_ai_message_id=None, + handoff_reason=None, + created_at=now, + updated_at=now, + closed_at=None, + ) + session.add(ai_session) + thread.ai_session_id = ai_session.session_id + thread.ai_state = "queued" + thread.ai_handoff_reason = None + thread.updated_at = now + _push_timeline( + session, + interaction.interaction_id, + "ai.session_started", + {"thread_id": thread.thread_id, "session_id": ai_session.session_id, "language": language}, + ) + return ai_session, True + + +def _ensure_job( + session, + *, + thread: TelegramThreadRow, + ai_session: AISessionRow, + trigger_message_id: str | None, +) -> tuple[AIJobRow | None, bool]: + if trigger_message_id and ai_session.last_user_message_id == trigger_message_id: + existing = _latest_job_for_trigger(session, thread.thread_id, trigger_message_id) + if existing: + return existing, True + existing = _existing_job_for_thread(session, thread.thread_id) + if existing: + return existing, True + now = utc_now_iso() + job = AIJobRow( + job_id=new_id("aij"), + session_id=ai_session.session_id, + thread_id=thread.thread_id, + trigger_message_id=trigger_message_id, + status="pending", + attempts=0, + next_attempt_at=now, + locked_until=None, + last_error=None, + created_at=now, + updated_at=now, + ) + session.add(job) + return job, False + + +def _ensure_whatsapp_ai_session( + session, + *, + thread: WhatsAppThreadRow, + interaction: Interaction, + customer_id: str, + language: str, +) -> tuple[AISessionRow, bool]: + existing = None + if thread.ai_session_id: + existing = session.execute( + select(AISessionRow).where(AISessionRow.session_id == thread.ai_session_id) + ).scalar_one_or_none() + if existing and existing.status not in {"closed", "error"}: + existing.customer_id = customer_id + existing.interaction_id = interaction.interaction_id + existing.language = language or existing.language + existing.updated_at = utc_now_iso() + return existing, False + + now = utc_now_iso() + ai_session = AISessionRow( + session_id=new_id("ais"), + channel="whatsapp", + thread_id=thread.thread_id, + interaction_id=interaction.interaction_id, + customer_id=customer_id, + agent_profile="whatsapp_support", + language=language, + status="active", + summary_text="", + last_user_message_id=None, + last_ai_message_id=None, + handoff_reason=None, + created_at=now, + updated_at=now, + closed_at=None, + ) + session.add(ai_session) + thread.ai_session_id = ai_session.session_id + thread.ai_state = "queued" + thread.ai_handoff_reason = None + thread.updated_at = now + _push_timeline( + session, + interaction.interaction_id, + "ai.session_started", + {"thread_id": thread.thread_id, "session_id": ai_session.session_id, "language": language}, + ) + return ai_session, True + + +def _looks_like_human_request(text: str) -> bool: + source = str(text or "").lower() + patterns = [ + "оператор", + "человек", + "менеджер", + "живой", + "переведи", + "transfer", + "human", + "сотрудник", + "специалист", + ] + return any(token in source for token in patterns) + + +def _is_sensitive_request(text: str) -> bool: + source = str(text or "").lower() + tokens = [ + "жалоб", + "претензи", + "верните деньги", + "деньги", + "оплат", + "договор", + "суд", + "юрист", + "паспорт", + "iin", + "бин", + "конфиден", + ] + return any(token in source for token in tokens) + + +def _looks_like_resolution_confirmation(text: str) -> bool: + source = str(text or "").lower() + tokens = [ + "спасибо", + "решено", + "все понятно", + "всё понятно", + "не нужно", + "ок", + "хорошо", + "рахмет", + "түсінікті", + ] + return any(token in source for token in tokens) + + +def _extract_json_object(raw: str) -> dict[str, Any]: + text = str(raw or "").strip() + if not text: + raise ValueError("Model returned empty response") + try: + parsed = json.loads(text) + if isinstance(parsed, dict): + return parsed + except json.JSONDecodeError: + pass + match = re.search(r"\{.*\}", text, re.DOTALL) + if not match: + raise ValueError("Model response does not contain JSON object") + parsed = json.loads(match.group(0)) + if not isinstance(parsed, dict): + raise ValueError("Model response JSON is not an object") + return parsed + + +def _stub_decision( + *, + customer: Customer | None, + interaction: Interaction, + last_user_message: TelegramMessageRow, + kb_results: list[KBArticleRow], + language: str, +) -> dict[str, Any]: + text = last_user_message.text + if _looks_like_human_request(text): + return { + "language": language, + "intent": "handoff_request", + "reply_text": "", + "confidence": 0.2, + "needs_handoff": True, + "handoff_reason": "Клиент запросил живого оператора.", + "case_action": "keep_open", + "kb_refs": [], + } + if _is_sensitive_request(text): + return { + "language": language, + "intent": "sensitive_request", + "reply_text": "", + "confidence": 0.25, + "needs_handoff": True, + "handoff_reason": "Нужен человек: запрос затрагивает чувствительную тему или действие вне доступных tools.", + "case_action": "keep_open", + "kb_refs": [], + } + if _looks_like_resolution_confirmation(text): + reply = ( + "Мен компанияның AI көмекшісімін. Рақмет, өтінішті жабамын. Қажет болса, адам операторын қоса аламын." + if language == "kz" + else "Я AI-помощник компании. Спасибо, отмечаю вопрос как решённый. Если понадобится человек, сразу передам диалог оператору." + ) + return { + "language": language, + "intent": "resolution_confirmed", + "reply_text": reply, + "confidence": 0.9, + "needs_handoff": False, + "handoff_reason": None, + "case_action": "close", + "kb_refs": [], + } + if kb_results: + best = kb_results[0] + snippet = _article_snippet(best) + reply = ( + "Мен компанияның AI көмекшісімін. Білім базасына сүйеніп жауап беремін: " + f"{snippet} Егер қажет болса, адам операторына бірден өткіземін." + if language == "kz" + else "Я AI-помощник компании и отвечаю по базе знаний. " + f"{snippet} Если этого недостаточно, сразу передам диалог живому оператору." + ) + return { + "language": language, + "intent": "kb_answer", + "reply_text": reply, + "confidence": 0.84, + "needs_handoff": False, + "handoff_reason": None, + "case_action": "keep_open", + "kb_refs": [best.article_id], + } + name = customer.display_name if customer else (interaction.customer_id or "клиент") + reply = ( + f"Мен компанияның AI көмекшісімін. {name}, сұрағыңызды түсіндім, бірақ бұл үшін қосымша тексеріс керек. " + "Қажет болса, адам операторына бірден өткіземін." + if language == "kz" + else f"Я AI-помощник компании. {name}, понял ваш вопрос, но для точного ответа мне не хватает данных " + "из доступных инструментов. Передаю диалог оператору." + ) + return { + "language": language, + "intent": "handoff_missing_tool", + "reply_text": "", + "confidence": 0.3, + "needs_handoff": True, + "handoff_reason": "Нет достаточных данных в базе знаний или доступных инструментах.", + "case_action": "keep_open", + "kb_refs": [], + } + + +def _openai_prompt( + *, + customer: Customer | None, + interaction: Interaction, + thread: Any, + messages: list[Any], + kb_results: list[KBArticleRow], + language: str, + channel_label: str = "Telegram", + channel_key: str = "telegram", +) -> list[dict[str, str]]: + customer_summary = { + "customer_id": customer.customer_id if customer else interaction.customer_id, + "display_name": customer.display_name if customer else thread.display_name, + "tags": json.loads(customer.tags_json or "[]") if customer else [], + "channel": channel_key, + } + history = [ + { + "author_type": item.author_type, + "author_id": item.author_id, + "direction": item.direction, + "text": item.text, + "created_at": item.created_at, + } + for item in messages + ] + kb_context = [ + { + "article_id": article.article_id, + "title": article.title, + "snippet": _article_snippet(article), + } + for article in kb_results + ] + system_prompt = ( + f"You are the company's AI assistant for {channel_label}. " + "Always disclose you are an AI assistant in the first meaningful reply. " + "Use only provided business context, KB snippets, and interaction state. " + "Never invent order statuses, tariffs, discounts, deadlines, or actions that are not in context. " + "If confidence is low or a human is needed, set needs_handoff=true and do not bluff. " + "Return only a JSON object with keys: language, intent, reply_text, confidence, needs_handoff, " + "handoff_reason, case_action, kb_refs. case_action must be one of none, close, escalate, keep_open. " + f"Prefer {'Kazakh' if language == 'kz' else 'Russian'} for the reply." + ) + user_prompt = { + "customer": customer_summary, + "interaction": { + "interaction_id": interaction.interaction_id, + "status": interaction.status, + "queue_id": interaction.queue_id, + "subject": interaction.subject, + }, + "thread": { + "thread_id": thread.thread_id, + "chat_id": thread.chat_id, + "display_name": thread.display_name, + }, + "kb_results": kb_context, + "history": history, + } + return [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": json.dumps(user_prompt, ensure_ascii=False)}, + ] + + +def _openai_compatible_decision( + *, + customer: Customer | None, + interaction: Interaction, + thread: Any, + messages: list[Any], + kb_results: list[KBArticleRow], + language: str, + channel_label: str = "Telegram", + channel_key: str = "telegram", +) -> dict[str, Any]: + if not _ai_api_base() or not _ai_api_key(): + raise RuntimeError("AI_API_BASE / AI_API_KEY are required for openai_compatible provider") + started = time.perf_counter() + payload = { + "model": _ai_model(), + "temperature": 0.2, + "response_format": {"type": "json_object"}, + "messages": _openai_prompt( + customer=customer, + interaction=interaction, + thread=thread, + messages=messages, + kb_results=kb_results, + language=language, + channel_label=channel_label, + channel_key=channel_key, + ), + } + with httpx.Client(timeout=_ai_timeout_seconds()) as client: + response = client.post( + f"{_ai_api_base()}/chat/completions", + json=payload, + headers={"Authorization": f"Bearer {_ai_api_key()}"}, + ) + response.raise_for_status() + result = response.json() + choice = ((result.get("choices") or [{}])[0] if isinstance(result.get("choices"), list) else {}) or {} + message = choice.get("message") if isinstance(choice.get("message"), dict) else {} + raw_content = message.get("content") or "" + decision = _extract_json_object(str(raw_content)) + decision["_model"] = result.get("model") or _ai_model() + decision["_latency_ms"] = int((time.perf_counter() - started) * 1000) + decision["_finish_reason"] = choice.get("finish_reason") + return decision + + +def _sanitize_decision(raw: dict[str, Any], *, fallback_language: str) -> dict[str, Any]: + decision = { + "language": str(raw.get("language") or fallback_language or "ru"), + "intent": str(raw.get("intent") or "unknown"), + "reply_text": str(raw.get("reply_text") or "").strip(), + "confidence": float(raw.get("confidence") or 0.0), + "needs_handoff": bool(raw.get("needs_handoff")), + "handoff_reason": str(raw.get("handoff_reason") or "").strip() or None, + "case_action": str(raw.get("case_action") or "keep_open"), + "kb_refs": [str(item) for item in (raw.get("kb_refs") or []) if str(item).strip()], + "_model": raw.get("_model") or _ai_model(), + "_finish_reason": raw.get("_finish_reason"), + "_latency_ms": int(raw.get("_latency_ms") or 0), + } + if decision["case_action"] not in {"none", "close", "escalate", "keep_open"}: + decision["case_action"] = "keep_open" + if decision["confidence"] < 0: + decision["confidence"] = 0.0 + if decision["confidence"] > 1: + decision["confidence"] = 1.0 + return decision + + +def _always_reply_fallback(last_user_text: str, language: str) -> str: + normalized = str(last_user_text or "").strip().lower() + greeting_tokens = ("привет", "здравствуйте", "добрый", "салем", "сә", "сәлем", "hello", "hi") + if language == "kz": + if any(token in normalized for token in greeting_tokens): + return ( + "Сәлеметсіз бе! Мен компанияның AI-көмекшісімін. " + "Сұрағыңызды жазыңыз, мен бірден көмектесуге тырысамын." + ) + return ( + "Мен көмектесуге дайынмын. Сұрағыңызды нақтырақ жазыңыз, " + "мен сізге бірден жауап беремін." + ) + if any(token in normalized for token in greeting_tokens): + return ( + "Здравствуйте! Я AI-помощник компании. " + "Напишите ваш вопрос, и я сразу постараюсь помочь." + ) + return ( + "Я на связи и готов помочь. " + "Напишите, пожалуйста, чуть подробнее, что именно вам нужно." + ) + + +def _apply_always_reply_mode( + decision: dict[str, Any], + *, + last_user_text: str, + enabled: bool | None = None, + handoff_threshold: float | None = None, +) -> dict[str, Any]: + if enabled is None: + enabled = _ai_telegram_always_reply() + if handoff_threshold is None: + handoff_threshold = _ai_handoff_threshold() + if not enabled: + return decision + decision["needs_handoff"] = False + decision["handoff_reason"] = None + decision["case_action"] = "keep_open" + if not str(decision.get("reply_text") or "").strip(): + decision["reply_text"] = _always_reply_fallback(last_user_text, decision.get("language") or "ru") + if float(decision.get("confidence") or 0.0) < handoff_threshold: + decision["confidence"] = max(handoff_threshold, 0.7) + return decision + + +def _decide_reply( + *, + customer: Customer | None, + interaction: Interaction, + thread: Any, + messages: list[Any], + kb_results: list[KBArticleRow], + language: str, + channel_label: str = "Telegram", + channel_key: str = "telegram", +) -> dict[str, Any]: + last_user_message = next((item for item in reversed(messages) if item.author_type == "customer"), None) + if last_user_message is None: + raise RuntimeError("No customer message found for AI decision") + if _ai_provider() == "openai_compatible": + raw = _openai_compatible_decision( + customer=customer, + interaction=interaction, + thread=thread, + messages=messages, + kb_results=kb_results, + language=language, + channel_label=channel_label, + channel_key=channel_key, + ) + else: + raw = _stub_decision( + customer=customer, + interaction=interaction, + last_user_message=last_user_message, + kb_results=kb_results, + language=language, + ) + raw["_model"] = _ai_model() + raw["_latency_ms"] = 1 + raw["_finish_reason"] = "stop" + return _sanitize_decision(raw, fallback_language=language) + + +def _update_thread_after_close(session, thread: TelegramThreadRow, when: str) -> None: + thread.status = "closed" + thread.ai_state = "closed" + thread.ai_handoff_reason = None + thread.ai_last_model_at = when + thread.updated_at = when + + +def _update_whatsapp_thread_after_close(session, thread: WhatsAppThreadRow, when: str) -> None: + thread.status = "closed" + thread.ai_state = "closed" + thread.ai_handoff_reason = None + thread.ai_last_model_at = when + thread.updated_at = when + + +def _mark_job_done(session, job: AIJobRow, *, error: str | None = None) -> None: + job.status = "failed" if error else "done" + job.last_error = error + job.updated_at = utc_now_iso() + + +def _record_turn( + session, + *, + session_id: str, + thread_id: str, + interaction_id: str, + role: str, + source_type: str, + text: str, + payload: dict[str, Any], + model: str | None = None, + finish_reason: str | None = None, + latency_ms: int | None = None, +) -> None: + session.add( + AITurnRow( + turn_id=new_id("ait"), + session_id=session_id, + thread_id=thread_id, + interaction_id=interaction_id, + role=role, + source_type=source_type, + text=text, + payload_json=json.dumps(payload, ensure_ascii=False), + model=model, + finish_reason=finish_reason, + latency_ms=latency_ms, + created_at=utc_now_iso(), + ) + ) + + +def _process_job(job_id: str) -> dict[str, Any]: + session = get_session() + try: + job = session.execute(select(AIJobRow).where(AIJobRow.job_id == job_id)).scalar_one_or_none() + if not job: + raise HTTPException(status_code=404, detail="AI job not found") + thread = _thread_or_404(session, job.thread_id) + interaction = session.execute( + select(Interaction).where(Interaction.interaction_id == thread.interaction_id) + ).scalar_one() + customer_id = _resolve_or_create_customer_id(session, thread, interaction) + customer = session.execute( + select(Customer).where(Customer.customer_id == customer_id) + ).scalar_one_or_none() + trigger_message = _select_trigger_message( + session, + thread_id=thread.thread_id, + trigger_message_id=job.trigger_message_id, + ) + if not trigger_message: + _mark_job_done(session, job, error="No trigger customer message found") + session.commit() + return {"ok": False, "status": job.status, "job_id": job.job_id} + ai_session = session.execute( + select(AISessionRow).where(AISessionRow.session_id == job.session_id) + ).scalar_one() + if thread.ai_state == "human_owned" or thread.claimed_by_user: + ai_session.status = "human_owned" + ai_session.updated_at = utc_now_iso() + _mark_job_done(session, job) + session.commit() + return {"ok": True, "status": "human_owned", "job_id": job.job_id} + + job.status = "running" + job.attempts = int(job.attempts or 0) + 1 + job.updated_at = utc_now_iso() + thread.ai_state = "thinking" + thread.ai_handoff_reason = None + thread.updated_at = utc_now_iso() + ai_session.status = "active" + ai_session.language = _infer_language(trigger_message.text) + ai_session.customer_id = customer_id + ai_session.last_user_message_id = trigger_message.message_id + ai_session.updated_at = utc_now_iso() + _record_turn( + session, + session_id=ai_session.session_id, + thread_id=thread.thread_id, + interaction_id=interaction.interaction_id, + role="user", + source_type="telegram", + text=trigger_message.text, + payload={"message_id": trigger_message.message_id, "author_type": trigger_message.author_type}, + ) + session.commit() + + messages = _last_messages(session, thread.thread_id, _ai_max_context_messages()) + kb_results = _kb_search(session, trigger_message.text, language=ai_session.language) + decision = _decide_reply( + customer=customer, + interaction=interaction, + thread=thread, + messages=messages, + kb_results=kb_results, + language=ai_session.language or "ru", + ) + decision = _apply_always_reply_mode(decision, last_user_text=trigger_message.text) + _record_turn( + session, + session_id=ai_session.session_id, + thread_id=thread.thread_id, + interaction_id=interaction.interaction_id, + role="assistant", + source_type="model", + text=decision["reply_text"] or (decision["handoff_reason"] or decision["intent"]), + payload=decision, + model=decision["_model"], + finish_reason=decision["_finish_reason"], + latency_ms=decision["_latency_ms"], + ) + ai_session.updated_at = utc_now_iso() + ai_session.summary_text = decision["reply_text"] or (decision["handoff_reason"] or ai_session.summary_text) + session.commit() + + needs_handoff = False + if not _ai_telegram_always_reply(): + needs_handoff = ( + bool(decision["needs_handoff"]) + or float(decision["confidence"]) < _ai_handoff_threshold() + or _looks_like_human_request(trigger_message.text) + or _is_sensitive_request(trigger_message.text) + ) + if needs_handoff: + reason = decision["handoff_reason"] or "AI передаёт диалог оператору." + try: + _telegram_request( + "POST", + f"/integrations/telegram/threads/{thread.thread_id}/ai/handoff", + payload={ + "reason": reason, + "agent_profile": ai_session.agent_profile, + "trigger_message_id": trigger_message.message_id, + "confidence": decision["confidence"], + "payload": {"intent": decision["intent"], "kb_refs": decision["kb_refs"]}, + }, + ) + except Exception as exc: # noqa: BLE001 + resolved = _conflict_result_from_telegram_error( + session, + job=job, + ai_session=ai_session, + thread_id=thread.thread_id, + exc=exc, + ) + if resolved is not None: + return resolved + raise + ai_session.status = "handoff_required" + ai_session.handoff_reason = reason + ai_session.updated_at = utc_now_iso() + _mark_job_done(session, job) + session.commit() + return {"ok": True, "status": "handoff_required", "job_id": job.job_id} + + try: + reply_payload = _telegram_request( + "POST", + f"/integrations/telegram/threads/{thread.thread_id}/ai/reply", + payload={ + "text": decision["reply_text"], + "agent_profile": ai_session.agent_profile, + "model": decision["_model"], + "trigger_message_id": trigger_message.message_id, + "language": decision["language"], + "confidence": decision["confidence"], + "kb_refs": decision["kb_refs"], + "payload": {"intent": decision["intent"]}, + }, + ) + except Exception as exc: # noqa: BLE001 + resolved = _conflict_result_from_telegram_error( + session, + job=job, + ai_session=ai_session, + thread_id=thread.thread_id, + exc=exc, + ) + if resolved is not None: + return resolved + raise + + if decision["case_action"] == "escalate": + _interaction_request( + "POST", + f"/interactions/{interaction.interaction_id}/escalate", + payload={"target_queue_id": interaction.queue_id or thread.queue_id or "q_telegram"}, + ) + try: + _telegram_request( + "POST", + f"/integrations/telegram/threads/{thread.thread_id}/ai/handoff", + payload={ + "reason": decision["handoff_reason"] or "Нужна передача оператору по результатам AI-анализа.", + "agent_profile": ai_session.agent_profile, + "trigger_message_id": trigger_message.message_id, + "confidence": decision["confidence"], + "payload": {"intent": decision["intent"], "kb_refs": decision["kb_refs"]}, + }, + ) + except Exception as exc: # noqa: BLE001 + resolved = _conflict_result_from_telegram_error( + session, + job=job, + ai_session=ai_session, + thread_id=thread.thread_id, + exc=exc, + reply_message_id=reply_payload.get("message_id"), + ) + if resolved is not None: + return resolved + raise + elif decision["case_action"] == "close" and _looks_like_resolution_confirmation(trigger_message.text): + _interaction_request( + "PATCH", + f"/interactions/{interaction.interaction_id}/status", + payload={"status": "closed"}, + ) + thread = _thread_or_404(session, thread.thread_id) + _update_thread_after_close(session, thread, utc_now_iso()) + ai_session.status = "closed" + ai_session.closed_at = utc_now_iso() + ai_session.updated_at = utc_now_iso() + _push_timeline( + session, + interaction.interaction_id, + "ai.case_closed", + {"thread_id": thread.thread_id, "reply_message_id": reply_payload.get("message_id")}, + ) + _mark_job_done(session, job) + session.commit() + return {"ok": True, "status": "done", "job_id": job.job_id, "reply_message_id": reply_payload.get("message_id")} + except HTTPException: + raise + except Exception as exc: # noqa: BLE001 + logger.exception("AI Telegram job failed", extra={"job_id": job_id}) + try: + session.rollback() + job = session.execute(select(AIJobRow).where(AIJobRow.job_id == job_id)).scalar_one_or_none() + if job: + job.status = "failed" + job.last_error = str(exc)[:1000] + job.updated_at = utc_now_iso() + thread = _thread_or_404(session, job.thread_id) + thread.ai_state = "error" + thread.ai_handoff_reason = str(exc)[:240] + thread.updated_at = utc_now_iso() + interaction = session.execute( + select(Interaction).where(Interaction.interaction_id == thread.interaction_id) + ).scalar_one_or_none() + if interaction: + _push_timeline( + session, + interaction.interaction_id, + "ai.error", + {"thread_id": thread.thread_id, "job_id": job.job_id, "error": str(exc)[:500]}, + ) + session.commit() + finally: + pass + raise HTTPException(status_code=502, detail=f"AI Telegram processing failed: {exc}") from exc + finally: + session.close() + + +def _process_whatsapp_job(job_id: str) -> dict[str, Any]: + session = get_session() + try: + job = session.execute(select(AIJobRow).where(AIJobRow.job_id == job_id)).scalar_one_or_none() + if not job: + raise HTTPException(status_code=404, detail="AI job not found") + thread = _whatsapp_thread_or_404(session, job.thread_id) + interaction = session.execute( + select(Interaction).where(Interaction.interaction_id == thread.interaction_id) + ).scalar_one() + customer_id = _resolve_or_create_whatsapp_customer_id(session, thread, interaction) + customer = session.execute( + select(Customer).where(Customer.customer_id == customer_id) + ).scalar_one_or_none() + trigger_message = _select_whatsapp_trigger_message( + session, + thread_id=thread.thread_id, + trigger_message_id=job.trigger_message_id, + ) + if not trigger_message: + _mark_job_done(session, job, error="No trigger customer message found") + session.commit() + return {"ok": False, "status": job.status, "job_id": job.job_id} + ai_session = session.execute( + select(AISessionRow).where(AISessionRow.session_id == job.session_id) + ).scalar_one() + if thread.ai_state == "human_owned" or thread.claimed_by_user: + ai_session.status = "human_owned" + ai_session.updated_at = utc_now_iso() + _mark_job_done(session, job) + session.commit() + return {"ok": True, "status": "human_owned", "job_id": job.job_id} + + job.status = "running" + job.attempts = int(job.attempts or 0) + 1 + job.updated_at = utc_now_iso() + thread.ai_state = "thinking" + thread.ai_handoff_reason = None + thread.updated_at = utc_now_iso() + ai_session.status = "active" + ai_session.language = _infer_language(trigger_message.text) + ai_session.customer_id = customer_id + ai_session.last_user_message_id = trigger_message.message_id + ai_session.updated_at = utc_now_iso() + _record_turn( + session, + session_id=ai_session.session_id, + thread_id=thread.thread_id, + interaction_id=interaction.interaction_id, + role="user", + source_type="whatsapp", + text=trigger_message.text, + payload={"message_id": trigger_message.message_id, "author_type": trigger_message.author_type}, + ) + session.commit() + + messages = _last_whatsapp_messages(session, thread.thread_id, _ai_whatsapp_max_context_messages()) + kb_results = _kb_search( + session, + trigger_message.text, + language=ai_session.language, + )[: _ai_whatsapp_max_kb_results()] + decision = _decide_reply( + customer=customer, + interaction=interaction, + thread=thread, + messages=messages, + kb_results=kb_results, + language=ai_session.language or "ru", + channel_label="WhatsApp", + channel_key="whatsapp", + ) + decision = _apply_always_reply_mode( + decision, + last_user_text=trigger_message.text, + enabled=_ai_whatsapp_always_reply(), + handoff_threshold=_ai_whatsapp_handoff_threshold(), + ) + _record_turn( + session, + session_id=ai_session.session_id, + thread_id=thread.thread_id, + interaction_id=interaction.interaction_id, + role="assistant", + source_type="model", + text=decision["reply_text"] or (decision["handoff_reason"] or decision["intent"]), + payload=decision, + model=decision["_model"], + finish_reason=decision["_finish_reason"], + latency_ms=decision["_latency_ms"], + ) + ai_session.updated_at = utc_now_iso() + ai_session.summary_text = decision["reply_text"] or (decision["handoff_reason"] or ai_session.summary_text) + session.commit() + + needs_handoff = False + if not _ai_whatsapp_always_reply(): + needs_handoff = ( + bool(decision["needs_handoff"]) + or float(decision["confidence"]) < _ai_whatsapp_handoff_threshold() + or _looks_like_human_request(trigger_message.text) + or _is_sensitive_request(trigger_message.text) + ) + if needs_handoff: + reason = decision["handoff_reason"] or "AI передаёт диалог оператору." + try: + _whatsapp_request( + "POST", + f"/integrations/whatsapp/threads/{thread.thread_id}/ai/handoff", + payload={ + "reason": reason, + "agent_profile": ai_session.agent_profile, + "trigger_message_id": trigger_message.message_id, + "confidence": decision["confidence"], + "payload": {"intent": decision["intent"], "kb_refs": decision["kb_refs"]}, + }, + ) + except Exception as exc: # noqa: BLE001 + resolved = _conflict_result_from_whatsapp_error( + session, + job=job, + ai_session=ai_session, + thread_id=thread.thread_id, + exc=exc, + ) + if resolved is not None: + return resolved + raise + ai_session.status = "handoff_required" + ai_session.handoff_reason = reason + ai_session.updated_at = utc_now_iso() + _mark_job_done(session, job) + session.commit() + return {"ok": True, "status": "handoff_required", "job_id": job.job_id} + + try: + reply_payload = _whatsapp_request( + "POST", + f"/integrations/whatsapp/threads/{thread.thread_id}/ai/reply", + payload={ + "text": decision["reply_text"], + "agent_profile": ai_session.agent_profile, + "model": decision["_model"], + "trigger_message_id": trigger_message.message_id, + "language": decision["language"], + "confidence": decision["confidence"], + "kb_refs": decision["kb_refs"], + "payload": {"intent": decision["intent"]}, + }, + ) + except Exception as exc: # noqa: BLE001 + resolved = _conflict_result_from_whatsapp_error( + session, + job=job, + ai_session=ai_session, + thread_id=thread.thread_id, + exc=exc, + ) + if resolved is not None: + return resolved + raise + + if decision["case_action"] == "escalate": + _interaction_request( + "POST", + f"/interactions/{interaction.interaction_id}/escalate", + payload={"target_queue_id": interaction.queue_id or thread.queue_id or "q_whatsapp"}, + ) + try: + _whatsapp_request( + "POST", + f"/integrations/whatsapp/threads/{thread.thread_id}/ai/handoff", + payload={ + "reason": decision["handoff_reason"] or "Нужна передача оператору по результатам AI-анализа.", + "agent_profile": ai_session.agent_profile, + "trigger_message_id": trigger_message.message_id, + "confidence": decision["confidence"], + "payload": {"intent": decision["intent"], "kb_refs": decision["kb_refs"]}, + }, + ) + except Exception as exc: # noqa: BLE001 + resolved = _conflict_result_from_whatsapp_error( + session, + job=job, + ai_session=ai_session, + thread_id=thread.thread_id, + exc=exc, + reply_message_id=reply_payload.get("message_id"), + ) + if resolved is not None: + return resolved + raise + elif decision["case_action"] == "close" and _looks_like_resolution_confirmation(trigger_message.text): + _interaction_request( + "PATCH", + f"/interactions/{interaction.interaction_id}/status", + payload={"status": "closed"}, + ) + thread = _whatsapp_thread_or_404(session, thread.thread_id) + _update_whatsapp_thread_after_close(session, thread, utc_now_iso()) + ai_session.status = "closed" + ai_session.closed_at = utc_now_iso() + ai_session.updated_at = utc_now_iso() + _push_timeline( + session, + interaction.interaction_id, + "ai.case_closed", + {"thread_id": thread.thread_id, "reply_message_id": reply_payload.get("message_id")}, + ) + _mark_job_done(session, job) + session.commit() + return {"ok": True, "status": "done", "job_id": job.job_id, "reply_message_id": reply_payload.get("message_id")} + except HTTPException: + raise + except Exception as exc: # noqa: BLE001 + logger.exception("AI WhatsApp job failed", extra={"job_id": job_id}) + try: + session.rollback() + job = session.execute(select(AIJobRow).where(AIJobRow.job_id == job_id)).scalar_one_or_none() + if job: + job.status = "failed" + job.last_error = str(exc)[:1000] + job.updated_at = utc_now_iso() + thread = _whatsapp_thread_or_404(session, job.thread_id) + thread.ai_state = "error" + thread.ai_handoff_reason = str(exc)[:240] + thread.updated_at = utc_now_iso() + interaction = session.execute( + select(Interaction).where(Interaction.interaction_id == thread.interaction_id) + ).scalar_one_or_none() + if interaction: + _push_timeline( + session, + interaction.interaction_id, + "ai.error", + {"thread_id": thread.thread_id, "job_id": job.job_id, "error": str(exc)[:500]}, + ) + session.commit() + finally: + pass + raise HTTPException(status_code=502, detail=f"AI WhatsApp processing failed: {exc}") from exc + finally: + session.close() + + +@app.get("/health", response_model=HealthResponse) +def health() -> HealthResponse: + return HealthResponse(status="ok", service="ai-orchestrator-service", version="v1") + + +@app.get("/ai/analytics/overview", response_model=AIAnalyticsOverviewOut) +def ai_analytics_overview( + from_ts: str = Query(...), + to_ts: str = Query(...), + queue_id: str | None = None, + channel: str | None = Query(default="all"), + _: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.ANALYST)), +) -> AIAnalyticsOverviewOut: + range_from = _parse_analytics_timestamp(from_ts, "from_ts") + range_to = _parse_analytics_timestamp(to_ts, "to_ts") + if range_to <= range_from: + raise HTTPException(status_code=400, detail="to_ts must be greater than from_ts") + + session = get_session() + try: + return _load_ai_analytics_overview( + session, + range_from=range_from, + range_to=range_to, + queue_id=queue_id, + channel=channel, + ) + finally: + session.close() + + +@app.get("/ai/analytics/timeseries", response_model=AIAnalyticsTimeseriesOut) +def ai_analytics_timeseries( + from_ts: str = Query(...), + to_ts: str = Query(...), + metric: str = Query(default="containment_rate"), + interval: str = Query(default="day"), + queue_id: str | None = None, + channel: str | None = Query(default="all"), + _: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.ANALYST)), +) -> AIAnalyticsTimeseriesOut: + range_from = _parse_analytics_timestamp(from_ts, "from_ts") + range_to = _parse_analytics_timestamp(to_ts, "to_ts") + if range_to <= range_from: + raise HTTPException(status_code=400, detail="to_ts must be greater than from_ts") + + normalized_metric = _normalize_ai_analytics_metric(metric) + normalized_interval = _normalize_ai_analytics_interval(interval) + normalized_channel, channels = _normalize_ai_analytics_channel(channel) + if not channels: + return _empty_ai_analytics_timeseries( + range_from=range_from, + range_to=range_to, + metric=normalized_metric, + interval=normalized_interval, + queue_id=queue_id, + channel=normalized_channel, + ) + + step = timedelta(hours=1) if normalized_interval == "hour" else timedelta(days=1) + points: list[AIAnalyticsTimeseriesPointOut] = [] + session = get_session() + try: + cursor = range_from + while cursor < range_to: + bucket_from = cursor + bucket_to = min(bucket_from + step, range_to) + overview = _load_ai_analytics_overview( + session, + range_from=bucket_from, + range_to=bucket_to, + queue_id=queue_id, + channel=normalized_channel, + ) + points.append( + AIAnalyticsTimeseriesPointOut( + ts=bucket_from.isoformat(), + value=_timeseries_metric_value(normalized_metric, overview), + sessions=overview.totals.sessions_started, + assistant_turns=overview.totals.assistant_turns, + ) + ) + cursor = bucket_to + finally: + session.close() + + return AIAnalyticsTimeseriesOut( + metric=normalized_metric, # type: ignore[arg-type] + interval=normalized_interval, # type: ignore[arg-type] + filters=_analytics_filters(range_from, range_to, queue_id, normalized_channel), + points=points, + ) + + +@app.get("/ai/analytics/drilldown", response_model=AIAnalyticsDrilldownOut) +def ai_analytics_drilldown( + from_ts: str = Query(...), + to_ts: str = Query(...), + slice: str = Query(default="all"), + queue_id: str | None = None, + channel: str | None = Query(default="all"), + reason_key: str | None = None, + status: str | None = None, + q: str | None = None, + sort_by: str = Query(default="created_at"), + sort_dir: str = Query(default="desc"), + limit: int = Query(default=12, ge=1, le=200), + offset: int = Query(default=0, ge=0), + _: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.ANALYST)), +) -> AIAnalyticsDrilldownOut: + range_from = _parse_analytics_timestamp(from_ts, "from_ts") + range_to = _parse_analytics_timestamp(to_ts, "to_ts") + if range_to <= range_from: + raise HTTPException(status_code=400, detail="to_ts must be greater than from_ts") + + normalized_slice = _normalize_ai_analytics_slice(slice) + normalized_reason_key = _normalize_ai_analytics_reason_key(reason_key) + normalized_sort_by, normalized_sort_dir = _normalize_ai_analytics_sort(sort_by, sort_dir) + + session = get_session() + try: + return _load_ai_analytics_drilldown( + session, + range_from=range_from, + range_to=range_to, + queue_id=queue_id, + channel=channel, + slice_name=normalized_slice, + reason_key=normalized_reason_key, + status=(status or "").strip() or None, + query_text=q, + sort_by=normalized_sort_by, + sort_dir=normalized_sort_dir, + limit=limit, + offset=offset, + ) + finally: + session.close() + + +@app.get("/ai/analytics/sessions/{session_id}", response_model=AIAnalyticsSessionDetailOut) +def ai_analytics_session_detail( + session_id: str, + _: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.ANALYST)), +) -> AIAnalyticsSessionDetailOut: + session = get_session() + try: + return _load_ai_analytics_session_detail(session, session_id) + finally: + session.close() + + +@app.post("/ai/voice/sessions/{session_id}/start") +def start_voice_ai_session( + session_id: str, + payload: VoiceAIStartIn, + _: dict = Depends(require_roles(Role.ADMIN)), +) -> VoiceAIStartOut: + return voice_flows.start_voice_session(session_id, payload) + + +@app.post("/ai/voice/sessions/{session_id}/turns") +def turn_voice_ai_session( + session_id: str, + payload: VoiceAITurnIn, + _: dict = Depends(require_roles(Role.ADMIN)), +) -> dict[str, Any]: + return voice_flows.turn_voice_session(session_id, payload).model_dump() + + +@app.post("/ai/voice/sessions/{session_id}/close") +def close_voice_ai_session( + session_id: str, + _: dict = Depends(require_roles(Role.ADMIN)), +) -> dict[str, Any]: + return voice_flows.close_voice_session(session_id) + + +@app.post("/ai/telegram/threads/{thread_id}/enqueue") +def enqueue_telegram_thread( + thread_id: str, + payload: AITelegramEnqueueIn, + _: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR)), +) -> dict[str, Any]: + if not _ai_telegram_enabled(): + return {"ok": True, "status": "disabled", "thread_id": thread_id} + + session = get_session() + try: + thread = _thread_or_404(session, thread_id) + interaction = session.execute( + select(Interaction).where(Interaction.interaction_id == thread.interaction_id) + ).scalar_one() + customer_id = _resolve_or_create_customer_id(session, thread, interaction) + trigger_message = _select_trigger_message( + session, + thread_id=thread.thread_id, + trigger_message_id=payload.trigger_message_id, + ) + if not trigger_message: + return {"ok": False, "status": "skipped", "reason": "no customer message", "thread_id": thread_id} + language = _infer_language(trigger_message.text) + ai_session, _ = _ensure_ai_session( + session, + thread=thread, + interaction=interaction, + customer_id=customer_id, + language=language, + ) + job, deduplicated = _ensure_job( + session, + thread=thread, + ai_session=ai_session, + trigger_message_id=trigger_message.message_id, + ) + session.commit() + if deduplicated and job: + return { + "ok": True, + "status": job.status, + "thread_id": thread.thread_id, + "session_id": ai_session.session_id, + "job_id": job.job_id, + "deduplicated": True, + } + if not job: + return { + "ok": True, + "status": "skipped", + "thread_id": thread.thread_id, + "session_id": ai_session.session_id, + "deduplicated": True, + } + job_id = job.job_id + session_id = ai_session.session_id + finally: + session.close() + + result = _process_job(job_id) + result["thread_id"] = thread_id + result["session_id"] = session_id + result["deduplicated"] = False + return result + + +@app.post("/ai/telegram/threads/{thread_id}/pause") +def pause_telegram_thread_ai( + thread_id: str, + payload: AITelegramPauseIn, + _: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR)), +) -> dict[str, Any]: + session = get_session() + try: + thread = _thread_or_404(session, thread_id) + interaction = session.execute( + select(Interaction).where(Interaction.interaction_id == thread.interaction_id) + ).scalar_one_or_none() + now = utc_now_iso() + thread.ai_state = "human_owned" + thread.ai_handoff_reason = payload.reason + thread.updated_at = now + if thread.ai_session_id: + ai_session = session.execute( + select(AISessionRow).where(AISessionRow.session_id == thread.ai_session_id) + ).scalar_one_or_none() + if ai_session: + ai_session.status = "human_owned" + ai_session.handoff_reason = payload.reason + ai_session.updated_at = now + if interaction: + _push_timeline( + session, + interaction.interaction_id, + "ai.handoff_requested", + {"thread_id": thread.thread_id, "reason": payload.reason, "actor_user": payload.actor_user}, + ) + session.commit() + return {"ok": True, "status": "human_owned", "thread_id": thread.thread_id} + finally: + session.close() + + +@app.post("/ai/whatsapp/threads/{thread_id}/enqueue") +def enqueue_whatsapp_thread( + thread_id: str, + payload: AIWhatsAppEnqueueIn, + _: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR)), +) -> dict[str, Any]: + if not _ai_whatsapp_enabled(): + return {"ok": True, "status": "disabled", "thread_id": thread_id} + + session = get_session() + try: + thread = _whatsapp_thread_or_404(session, thread_id) + interaction = session.execute( + select(Interaction).where(Interaction.interaction_id == thread.interaction_id) + ).scalar_one() + customer_id = _resolve_or_create_whatsapp_customer_id(session, thread, interaction) + trigger_message = _select_whatsapp_trigger_message( + session, + thread_id=thread.thread_id, + trigger_message_id=payload.trigger_message_id, + ) + if not trigger_message: + return {"ok": False, "status": "skipped", "reason": "no customer message", "thread_id": thread_id} + language = _infer_language(trigger_message.text) + ai_session, _ = _ensure_whatsapp_ai_session( + session, + thread=thread, + interaction=interaction, + customer_id=customer_id, + language=language, + ) + job, deduplicated = _ensure_job( + session, + thread=thread, + ai_session=ai_session, + trigger_message_id=trigger_message.message_id, + ) + session.commit() + if deduplicated and job: + return { + "ok": True, + "status": job.status, + "thread_id": thread.thread_id, + "session_id": ai_session.session_id, + "job_id": job.job_id, + "deduplicated": True, + } + if not job: + return { + "ok": True, + "status": "skipped", + "thread_id": thread.thread_id, + "session_id": ai_session.session_id, + "deduplicated": True, + } + job_id = job.job_id + session_id = ai_session.session_id + finally: + session.close() + + result = _process_whatsapp_job(job_id) + result["thread_id"] = thread_id + result["session_id"] = session_id + result["deduplicated"] = False + return result + + +@app.post("/ai/whatsapp/threads/{thread_id}/pause") +def pause_whatsapp_thread_ai( + thread_id: str, + payload: AIWhatsAppPauseIn, + _: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR)), +) -> dict[str, Any]: + session = get_session() + try: + thread = _whatsapp_thread_or_404(session, thread_id) + interaction = session.execute( + select(Interaction).where(Interaction.interaction_id == thread.interaction_id) + ).scalar_one_or_none() + now = utc_now_iso() + thread.ai_state = "human_owned" + thread.ai_handoff_reason = payload.reason + thread.updated_at = now + if thread.ai_session_id: + ai_session = session.execute( + select(AISessionRow).where(AISessionRow.session_id == thread.ai_session_id) + ).scalar_one_or_none() + if ai_session: + ai_session.status = "human_owned" + ai_session.handoff_reason = payload.reason + ai_session.updated_at = now + if interaction: + _push_timeline( + session, + interaction.interaction_id, + "ai.handoff_requested", + {"thread_id": thread.thread_id, "reason": payload.reason, "actor_user": payload.actor_user}, + ) + session.commit() + return {"ok": True, "status": "human_owned", "thread_id": thread.thread_id} + finally: + session.close() + + + + diff --git a/services/ai_orchestrator_service/voice.py b/services/ai_orchestrator_service/voice.py new file mode 100644 index 0000000..6aaba8d --- /dev/null +++ b/services/ai_orchestrator_service/voice.py @@ -0,0 +1,896 @@ +from __future__ import annotations + +import importlib +import json +import re +from typing import Any + +from fastapi import HTTPException +from sqlalchemy import select + +from services.shared.core import new_id, utc_now_iso +from services.shared.db import get_session +from services.shared.models import VoiceAIStartIn, VoiceAIStartOut, VoiceAITurnDecisionOut, VoiceAITurnIn +from services.shared.sql_models import ( + AISessionRow, + AITurnRow, + AsteriskCallLinkRow, + Customer, + CustomerExternalIdentity, + Interaction, + InteractionTimeline, + VoiceAISessionRow, + VoiceTranscriptSegmentRow, +) + + +def _app(): + return importlib.import_module("services.ai_orchestrator_service.app") + + +def _voice_max_context_segments() -> int: + app = _app() + return max(6, app._int_env("AI_VOICE_MAX_CONTEXT_SEGMENTS", 8)) + + +def _voice_disclosure_prefix(language: str) -> str: + if language == "kz": + return "Men kompaniyanyn AI operatoriymyn. " + return "Я AI-оператор компании. " + + +def _voice_greeting(language: str) -> str: + if language == "kz": + return ( + "Men kompaniyanyn AI operatoriymyn. Salemetsiz be. " + "Suragynyzdy aitanyz, men birden komektesuge tyrisamyn." + ) + return ( + "Я AI-оператор компании. Здравствуйте. " + "Коротко расскажите, с чем помочь, и я сразу начну разбираться." + ) + + +def _voice_handoff_reply(language: str) -> str: + if language == "kz": + return ( + "Men AI operator retinde bastapky konteksti jiyap aldyм. " + "Kazir sizdi tiry operatorga kosamyn." + ) + return ( + "Я как AI-оператор собрал первичный контекст. " + "Сейчас переведу вас на живого оператора." + ) + + +def _normalize_phone(value: str | None) -> str | None: + raw = str(value or "").strip() + if not raw: + return None + digits = "".join(ch for ch in raw if ch.isdigit()) + if not digits: + return raw + if raw.startswith("+"): + return f"+{digits}" + return digits + + +def _voice_identity_subjects(caller_number: str | None) -> list[str]: + normalized = _normalize_phone(caller_number) + values = [normalized, str(caller_number or "").strip() or None] + seen: set[str] = set() + result: list[str] = [] + for value in values: + if not value or value in seen: + continue + seen.add(value) + result.append(value) + return result + + +def _resolve_caller_from_call(session, call_id: str) -> tuple[str | None, str | None]: + row = session.execute( + select(AsteriskCallLinkRow).where(AsteriskCallLinkRow.call_id == call_id) + ).scalar_one_or_none() + if row is None: + return None, None + return row.caller_number, row.caller_name + + +def _ensure_voice_identity( + session, + *, + customer_id: str, + caller_number: str, + caller_name: str | None, + now: str, +) -> None: + for subject in _voice_identity_subjects(caller_number): + identity = session.execute( + select(CustomerExternalIdentity).where( + CustomerExternalIdentity.channel == "voice", + CustomerExternalIdentity.external_subject == subject, + ) + ).scalar_one_or_none() + if identity: + identity.customer_id = customer_id + identity.display_name_snapshot = caller_name or identity.display_name_snapshot + identity.updated_at = now + continue + session.add( + CustomerExternalIdentity( + identity_id=new_id("cei"), + customer_id=customer_id, + channel="voice", + external_subject=subject, + display_name_snapshot=caller_name, + created_at=now, + updated_at=now, + ) + ) + + +def _resolve_or_create_voice_customer_id( + session, + *, + interaction: Interaction, + call_id: str, +) -> str | None: + app = _app() + if app._customer_id_is_real(interaction.customer_id): + return str(interaction.customer_id) + + caller_number, caller_name = _resolve_caller_from_call(session, call_id) + subjects = _voice_identity_subjects(caller_number) + now = utc_now_iso() + for subject in subjects: + identity = session.execute( + select(CustomerExternalIdentity).where( + CustomerExternalIdentity.channel == "voice", + CustomerExternalIdentity.external_subject == subject, + ) + ).scalar_one_or_none() + if identity: + interaction.customer_id = identity.customer_id + interaction.updated_at = now + return identity.customer_id + + if not caller_number: + return str(interaction.customer_id or "").strip() or None + + customer = Customer( + customer_id=new_id("cus"), + display_name=caller_name or caller_number, + phones_json=json.dumps([caller_number], ensure_ascii=False), + preferred_phone=caller_number, + tags_json=json.dumps(["voice"], ensure_ascii=False), + created_at=now, + ) + session.add(customer) + interaction.customer_id = customer.customer_id + interaction.updated_at = now + _ensure_voice_identity( + session, + customer_id=customer.customer_id, + caller_number=caller_number, + caller_name=caller_name, + now=now, + ) + return customer.customer_id + + +def _voice_recent_segments( + session, + *, + voice_session_id: str, + limit: int, +) -> list[VoiceTranscriptSegmentRow]: + rows = session.execute( + select(VoiceTranscriptSegmentRow) + .where(VoiceTranscriptSegmentRow.session_id == voice_session_id) + .where(VoiceTranscriptSegmentRow.is_final.is_(True)) + .order_by(VoiceTranscriptSegmentRow.id.desc()) + .limit(max(limit, 1)) + ).scalars().all() + return list(reversed(rows)) + + +def _voice_text_key(text: str | None) -> str: + compact = re.sub(r"[^\w\s]+", " ", str(text or "").lower(), flags=re.UNICODE) + return re.sub(r"\s+", " ", compact).strip() + + +def _voice_recent_caller_texts( + transcript_window: list[VoiceTranscriptSegmentRow], + *, + limit: int = 4, +) -> list[str]: + caller_texts = [str(segment.text or "").strip() for segment in transcript_window if segment.speaker == "caller"] + return caller_texts[-max(limit, 1) :] + + +def _voice_is_low_signal_caller_text(text: str | None) -> bool: + normalized = _voice_text_key(text) + if not normalized: + return True + low_signal_phrases = { + "алло", + "ага", + "да", + "добрый день", + "здравствуйте", + "ладно", + "неа", + "нет", + "ок", + "понял", + "поняла", + "привет", + "слышу", + "слышно", + "угу", + "хорошо", + "ясно", + } + return normalized in low_signal_phrases + + +def _voice_is_confused_caller_text(text: str | None) -> bool: + normalized = _voice_text_key(text) + confusion_markers = ( + "не понял", + "не поняла", + "не понимаю", + "неясно", + "о каком", + "каком запросе", + "повторите", + "что именно", + "что вы имеете", + ) + return any(marker in normalized for marker in confusion_markers) + + +def _voice_recent_clarification_count(transcript_window: list[VoiceTranscriptSegmentRow]) -> int: + markers = ( + "в двух словах", + "график работы какого", + "какая услуга", + "какой филиал", + "назовите пожалуйста", + "опишите пожалуйста", + "подскажите", + "скажите коротко", + "уточните", + "цель звонка", + "что вам нужно", + "что именно не работает", + "чтобы помочь", + ) + count = 0 + for segment in transcript_window: + if segment.speaker != "assistant": + continue + normalized = _voice_text_key(segment.text) + if any(marker in normalized for marker in markers): + count += 1 + return count + + +def _voice_repeated_assistant_reply_count(transcript_window: list[VoiceTranscriptSegmentRow]) -> int: + assistant_texts = [ + _voice_text_key(segment.text) + for segment in transcript_window + if segment.speaker == "assistant" and str(segment.text or "").strip() + ] + if not assistant_texts: + return 0 + last_text = assistant_texts[-1] + return sum(1 for text in assistant_texts[-3:] if text == last_text) + + +def _voice_topic_prompt(language: str, caller_texts: list[str]) -> str | None: + context = _voice_text_key(" ".join(caller_texts)) + if not context: + return None + if any(marker in context for marker in ("график", "время работы", "режим работы", "часы работы", "работаете")): + if language == "kz": + return "Qai filialdyn, mekendyng nemese qalanyng jumys uaqyty qyzyqtyratynyn aitnyz." + return "Подскажите, график работы какого филиала, адреса или города вас интересует?" + if any(marker in context for marker in ("статус", "заявк", "заказ", "обращени", "запрос")): + if language == "kz": + return "Otinish no'mirin nemese resimdegen telefon nomerin aitnyz." + return "Назовите, пожалуйста, номер заявки или телефон, по которому она оформлялась." + if any(marker in context for marker in ("тариф", "стоимост", "цен", "оплат", "оплата", "услуг")): + if language == "kz": + return "Qai qyzmet, tarif nemese bagasy qyzyqtyratynyn naqtylańyz." + return "Уточните, какая услуга, тариф или стоимость вас интересует." + if any(marker in context for marker in ("адрес", "филиал", "город", "офис", "отделени", "где находит")): + if language == "kz": + return "Qai filial, mekenjai nemese qala qyzyqtyratynyn aitnyz." + return "Подскажите, какой филиал, адрес или город вас интересует." + if any(marker in context for marker in ("не работает", "ошибка", "проблем", "сбой", "интернет", "связь")): + if language == "kz": + return "Bir soilemmen naqty aitynyz: ne istep turmagan?" + return "Опишите, пожалуйста, проблему одним предложением: что именно не работает?" + return None + + +def _voice_generic_prompt(language: str) -> str: + if language == "kz": + return "Jyldam komektesu ushin eki ush sozben ne kerek ekenin aitnyz: jumys uaqyty, otinish statusy, tarif nemese operator." + return "Чтобы помочь быстрее, скажите в двух словах, что вам нужно: график работы, статус заявки, тариф или оператор." + + +def _voice_confusion_prompt(language: str, caller_texts: list[str]) -> str: + topic_prompt = _voice_topic_prompt(language, caller_texts) + if topic_prompt: + if language == "kz": + return f"Qongyrau taqyrybyn naqtylap jatyrmyn. {topic_prompt}" + return f"Сейчас уточняю цель звонка. {topic_prompt}" + if language == "kz": + return "Qazir qongyraudyn maqratyn anyqtap jatyrmyn. Qysqasha aitnyz: jumys uaqyty, otinish statusy, tarif nemese operator." + return "Сейчас уточняю цель звонка. Скажите коротко, что именно нужно: график работы, статус заявки, тариф или оператор." + + +def _voice_loop_handoff(language: str) -> tuple[str, str, str]: + if language == "kz": + return ( + "Men suraqty birneshe ret naqtylap korgendim. Sizdi kuttirmey, tiri operatorga qosamyn.", + "Voice AI birneshe naqtylau areketinen keyin suraqty anyqtay almady.", + "AI birneshe naqtylau areketinen keyin qongyraudyn maqratyn operatorga berdi.", + ) + return ( + "Похоже, я не смог точно уточнить вопрос в голосовом режиме. Чтобы не задерживать вас, перевожу на оператора.", + "Voice AI не смог уточнить запрос после нескольких попыток без прогресса.", + "AI не смог уточнить цель звонка после нескольких попыток и перевел клиента на оператора.", + ) + + +def _voice_recent_timeline( + session, + *, + interaction_id: str, + limit: int = 6, +) -> list[dict[str, Any]]: + rows = session.execute( + select(InteractionTimeline) + .where(InteractionTimeline.interaction_id == interaction_id) + .order_by(InteractionTimeline.id.desc()) + .limit(max(limit, 1)) + ).scalars().all() + result: list[dict[str, Any]] = [] + for row in reversed(rows): + try: + metadata = json.loads(row.metadata_json or "{}") + except Exception: + metadata = {} + result.append({"timestamp": row.timestamp, "action": row.action, "metadata": metadata}) + return result + + +def _append_timeline(interaction_id: str, action: str, metadata: dict[str, Any]) -> None: + app = _app() + try: + app._interaction_request( + "POST", + f"/interactions/{interaction_id}/timeline", + payload={"action": action, "metadata": metadata}, + ) + except Exception: + session = get_session() + try: + app._push_timeline(session, interaction_id, action, metadata) + session.commit() + finally: + session.close() + + +def _record_voice_ai_turn( + session, + *, + ai_session_id: str, + interaction_id: str, + role: str, + source_type: str, + text: str, + payload: dict[str, Any], + model: str | None = None, + latency_ms: int | None = None, +) -> None: + session.add( + AITurnRow( + turn_id=new_id("ait"), + session_id=ai_session_id, + thread_id=None, + interaction_id=interaction_id, + role=role, + source_type=source_type, + text=text, + payload_json=json.dumps(payload, ensure_ascii=False), + model=model, + finish_reason="stop", + latency_ms=latency_ms, + created_at=utc_now_iso(), + ) + ) + + +def _ensure_voice_ai_session( + session, + *, + voice_session: VoiceAISessionRow, + interaction: Interaction, + customer_id: str | None, + language: str, + agent_profile: str, +) -> tuple[AISessionRow, bool]: + existing = None + if str(voice_session.ai_session_id or "").strip(): + existing = session.execute( + select(AISessionRow).where(AISessionRow.session_id == voice_session.ai_session_id) + ).scalar_one_or_none() + if existing and existing.status not in {"closed", "error"}: + existing.call_id = voice_session.call_id + existing.interaction_id = interaction.interaction_id + existing.customer_id = customer_id + existing.language = language + existing.agent_profile = agent_profile + existing.updated_at = utc_now_iso() + return existing, False + + now = utc_now_iso() + ai_session = AISessionRow( + session_id=new_id("ais"), + channel="voice", + call_id=voice_session.call_id, + thread_id=None, + interaction_id=interaction.interaction_id, + customer_id=customer_id, + agent_profile=agent_profile, + language=language, + status="active", + summary_text="", + last_user_message_id=None, + last_ai_message_id=None, + handoff_reason=None, + created_at=now, + updated_at=now, + closed_at=None, + ) + session.add(ai_session) + voice_session.ai_session_id = ai_session.session_id + return ai_session, True + + +def _voice_decision_legacy( + *, + language: str, + customer: Customer | None, + interaction: Interaction, + transcript_text: str, + transcript_window: list[VoiceTranscriptSegmentRow], + kb_results: list[Any], + disclosure_required: bool, +) -> dict[str, Any]: + app = _app() + normalized = str(transcript_text or "").strip() + lower_text = normalized.lower() + needs_handoff = app._looks_like_human_request(lower_text) or app._is_sensitive_request(lower_text) + model = app._ai_model() + if needs_handoff: + reply_text = _voice_handoff_reply(language) + if disclosure_required and not reply_text.startswith(_voice_disclosure_prefix(language)): + reply_text = f"{_voice_disclosure_prefix(language)}{reply_text}" + return { + "language": language, + "intent": "handoff_request", + "reply_text": reply_text, + "confidence": 0.25, + "needs_handoff": True, + "handoff_reason": "Запрос требует участия живого оператора.", + "case_action": "keep_open", + "kb_refs": [], + "summary_text": "AI собрал первичный контекст и запросил живого оператора.", + "model": model, + "latency_ms": 1, + } + + customer_name = customer.display_name if customer else "клиент" + if kb_results: + article = kb_results[0] + snippet = app._article_snippet(article, limit=220) + reply_text = f"По базе знаний вижу следующее: {snippet}" + summary_text = f"AI дал первичный ответ по базе знаний для {customer_name}." + kb_refs = [article.article_id] + confidence = 0.82 + intent = "kb_answer" + else: + transcript_context = " ".join(segment.text for segment in transcript_window[-3:] if segment.speaker == "caller") + reply_text = ( + "Я услышал запрос и уже собрал основной контекст. " + "Пожалуйста, уточните самый важный результат, который вы хотите получить." + ) + if transcript_context and transcript_context != normalized: + reply_text += " Если правильно понял, речь идет об этом вопросе из разговора." + summary_text = f"AI уточняет цель звонка и собирает контекст для {customer_name}." + kb_refs = [] + confidence = 0.68 + intent = "clarification" + + if disclosure_required and not reply_text.startswith(_voice_disclosure_prefix(language)): + reply_text = f"{_voice_disclosure_prefix(language)}{reply_text}" + return { + "language": language, + "intent": intent, + "reply_text": reply_text, + "confidence": confidence, + "needs_handoff": False, + "handoff_reason": None, + "case_action": "keep_open", + "kb_refs": kb_refs, + "summary_text": summary_text, + "model": model, + "latency_ms": 1, + } + + +def _voice_decision( + *, + language: str, + customer: Customer | None, + interaction: Interaction, + transcript_text: str, + transcript_window: list[VoiceTranscriptSegmentRow], + kb_results: list[Any], + disclosure_required: bool, +) -> dict[str, Any]: + app = _app() + normalized = str(transcript_text or "").strip() + lower_text = normalized.lower() + caller_texts = _voice_recent_caller_texts(transcript_window) + needs_handoff = app._looks_like_human_request(lower_text) or app._is_sensitive_request(lower_text) + model = app._ai_model() + if needs_handoff: + reply_text = _voice_handoff_reply(language) + if disclosure_required and not reply_text.startswith(_voice_disclosure_prefix(language)): + reply_text = f"{_voice_disclosure_prefix(language)}{reply_text}" + return { + "language": language, + "intent": "handoff_request", + "reply_text": reply_text, + "confidence": 0.25, + "needs_handoff": True, + "handoff_reason": "Запрос требует участия живого оператора.", + "case_action": "keep_open", + "kb_refs": [], + "summary_text": "AI собрал первичный контекст и запросил живого оператора.", + "model": model, + "latency_ms": 1, + } + + customer_name = customer.display_name if customer else "клиент" + if kb_results: + article = kb_results[0] + snippet = app._article_snippet(article, limit=220) + reply_text = f"По базе знаний вижу следующее: {snippet}" + summary_text = f"AI дал первичный ответ по базе знаний для {customer_name}." + kb_refs = [article.article_id] + confidence = 0.82 + intent = "kb_answer" + else: + clarification_count = _voice_recent_clarification_count(transcript_window) + repeated_reply_count = _voice_repeated_assistant_reply_count(transcript_window) + recent_caller_window = caller_texts[-3:] + low_signal_count = sum(1 for text in recent_caller_window if _voice_is_low_signal_caller_text(text)) + caller_confused = _voice_is_confused_caller_text(normalized) + + if clarification_count >= 4 or ( + clarification_count >= 3 and (caller_confused or low_signal_count >= 2 or repeated_reply_count >= 2) + ): + reply_text, handoff_reason, summary_text = _voice_loop_handoff(language) + if disclosure_required and not reply_text.startswith(_voice_disclosure_prefix(language)): + reply_text = f"{_voice_disclosure_prefix(language)}{reply_text}" + return { + "language": language, + "intent": "handoff_request", + "reply_text": reply_text, + "confidence": 0.34, + "needs_handoff": True, + "handoff_reason": handoff_reason, + "case_action": "keep_open", + "kb_refs": [], + "summary_text": summary_text, + "model": model, + "latency_ms": 1, + } + + if caller_confused: + reply_text = _voice_confusion_prompt(language, caller_texts) + else: + topic_prompt = _voice_topic_prompt(language, caller_texts) + if clarification_count >= 2 and not topic_prompt: + reply_text = _voice_confusion_prompt(language, caller_texts) + else: + reply_text = topic_prompt or _voice_generic_prompt(language) + summary_text = f"AI уточняет цель звонка и собирает контекст для {customer_name}." + kb_refs = [] + confidence = 0.68 + intent = "clarification" + + if disclosure_required and not reply_text.startswith(_voice_disclosure_prefix(language)): + reply_text = f"{_voice_disclosure_prefix(language)}{reply_text}" + return { + "language": language, + "intent": intent, + "reply_text": reply_text, + "confidence": confidence, + "needs_handoff": False, + "handoff_reason": None, + "case_action": "keep_open", + "kb_refs": kb_refs, + "summary_text": summary_text, + "model": model, + "latency_ms": 1, + } + + +def start_voice_session(session_id: str, payload: VoiceAIStartIn) -> VoiceAIStartOut: + session = get_session() + try: + voice_session = session.execute( + select(VoiceAISessionRow).where(VoiceAISessionRow.session_id == session_id) + ).scalar_one_or_none() + if voice_session is None: + raise HTTPException(status_code=404, detail="Voice AI session not found") + interaction = session.execute( + select(Interaction).where(Interaction.interaction_id == payload.interaction_id) + ).scalar_one_or_none() + if interaction is None: + raise HTTPException(status_code=404, detail="Interaction not found") + + language = str(payload.language_hint or voice_session.language or "ru").strip() or "ru" + customer_id = payload.customer_id or _resolve_or_create_voice_customer_id( + session, + interaction=interaction, + call_id=payload.call_id, + ) + customer = None + if customer_id: + customer = session.execute( + select(Customer).where(Customer.customer_id == customer_id) + ).scalar_one_or_none() + ai_session, created = _ensure_voice_ai_session( + session, + voice_session=voice_session, + interaction=interaction, + customer_id=customer_id, + language=language, + agent_profile=payload.agent_profile or voice_session.agent_profile, + ) + now = utc_now_iso() + voice_session.language = language + voice_session.status = "greeting" + voice_session.updated_at = now + if customer and not interaction.customer_id: + interaction.customer_id = customer.customer_id + interaction.updated_at = now + session.commit() + if created: + _append_timeline( + interaction.interaction_id, + "ai.session_started", + { + "call_id": payload.call_id, + "voice_session_id": voice_session.session_id, + "ai_session_id": ai_session.session_id, + "language": language, + }, + ) + return VoiceAIStartOut( + session_id=ai_session.session_id, + language=language, + greeting_text=_voice_greeting(language), + disclosure_required=voice_session.disclosure_played_at is None, + ) + finally: + session.close() + + +def turn_voice_session(session_id: str, payload: VoiceAITurnIn) -> VoiceAITurnDecisionOut: + app = _app() + session = get_session() + try: + voice_session = session.execute( + select(VoiceAISessionRow).where(VoiceAISessionRow.session_id == session_id) + ).scalar_one_or_none() + if voice_session is None: + raise HTTPException(status_code=404, detail="Voice AI session not found") + interaction = session.execute( + select(Interaction).where(Interaction.interaction_id == payload.interaction_id) + ).scalar_one_or_none() + if interaction is None: + raise HTTPException(status_code=404, detail="Interaction not found") + + customer_id = _resolve_or_create_voice_customer_id( + session, + interaction=interaction, + call_id=payload.call_id, + ) + customer = None + if customer_id: + customer = session.execute( + select(Customer).where(Customer.customer_id == customer_id) + ).scalar_one_or_none() + + ai_session, _ = _ensure_voice_ai_session( + session, + voice_session=voice_session, + interaction=interaction, + customer_id=customer_id, + language=str(payload.language or voice_session.language or "ru").strip() or "ru", + agent_profile=voice_session.agent_profile, + ) + now = utc_now_iso() + voice_session.last_user_utterance_at = now + voice_session.status = "thinking" + voice_session.updated_at = now + ai_session.updated_at = now + ai_session.call_id = payload.call_id + ai_session.interaction_id = interaction.interaction_id + ai_session.customer_id = customer_id + ai_session.language = str(payload.language or voice_session.language or "ru").strip() or "ru" + + _record_voice_ai_turn( + session, + ai_session_id=ai_session.session_id, + interaction_id=interaction.interaction_id, + role="user", + source_type="voice_asr", + text=payload.transcript_text, + payload={ + "voice_session_id": payload.voice_session_id, + "call_id": payload.call_id, + "sequence_no": payload.sequence_no, + "barge_in": payload.barge_in, + "metadata": payload.metadata, + }, + ) + if payload.barge_in: + last_assistant_segment = session.execute( + select(VoiceTranscriptSegmentRow) + .where(VoiceTranscriptSegmentRow.session_id == voice_session.session_id) + .where(VoiceTranscriptSegmentRow.speaker == "assistant") + .order_by(VoiceTranscriptSegmentRow.id.desc()) + .limit(1) + ).scalar_one_or_none() + if last_assistant_segment: + last_assistant_segment.barge_in_interrupted = True + transcript_window = _voice_recent_segments( + session, + voice_session_id=voice_session.session_id, + limit=_voice_max_context_segments(), + ) + timeline_window = _voice_recent_timeline( + session, + interaction_id=interaction.interaction_id, + ) + kb_results = app._kb_search( + session, + payload.transcript_text, + language=ai_session.language, + ) + disclosure_required = voice_session.disclosure_played_at is None + decision = _voice_decision( + language=ai_session.language or "ru", + customer=customer, + interaction=interaction, + transcript_text=payload.transcript_text, + transcript_window=transcript_window, + kb_results=kb_results, + disclosure_required=disclosure_required, + ) + _record_voice_ai_turn( + session, + ai_session_id=ai_session.session_id, + interaction_id=interaction.interaction_id, + role="assistant", + source_type="voice_policy", + text=decision["reply_text"] or decision["summary_text"] or decision["handoff_reason"], + payload={ + "voice_session_id": payload.voice_session_id, + "call_id": payload.call_id, + "kb_refs": decision["kb_refs"], + "timeline_window": timeline_window, + "context_segments": [ + { + "speaker": segment.speaker, + "text": segment.text, + "sequence_no": segment.sequence_no, + } + for segment in transcript_window + ], + "decision": decision, + }, + model=decision["model"], + latency_ms=decision["latency_ms"], + ) + voice_session.language = decision["language"] + voice_session.updated_at = now + voice_session.status = "handoff_requested" if decision["needs_handoff"] else "active" + voice_session.handoff_reason = decision["handoff_reason"] + ai_session.status = "handoff_required" if decision["needs_handoff"] else "active" + ai_session.handoff_reason = decision["handoff_reason"] + ai_session.summary_text = decision["summary_text"] or decision["reply_text"] or ai_session.summary_text + ai_session.updated_at = now + session.commit() + _append_timeline( + interaction.interaction_id, + "ai.handoff_requested" if decision["needs_handoff"] else "ai.reply_generated", + { + "call_id": payload.call_id, + "voice_session_id": payload.voice_session_id, + "ai_session_id": ai_session.session_id, + "sequence_no": payload.sequence_no, + "confidence": decision["confidence"], + "kb_refs": decision["kb_refs"], + "handoff_reason": decision["handoff_reason"], + }, + ) + return VoiceAITurnDecisionOut( + language=decision["language"], + intent=decision["intent"], + reply_text=decision["reply_text"], + confidence=decision["confidence"], + needs_handoff=decision["needs_handoff"], + handoff_reason=decision["handoff_reason"], + case_action=decision["case_action"], + kb_refs=decision["kb_refs"], + summary_text=decision["summary_text"], + model=decision["model"], + latency_ms=decision["latency_ms"], + status="handoff_requested" if decision["needs_handoff"] else "active", + ) + except HTTPException: + raise + except Exception as exc: + try: + session.rollback() + finally: + pass + raise HTTPException(status_code=502, detail=f"Voice AI turn failed: {exc}") from exc + finally: + session.close() + + +def close_voice_session(session_id: str) -> dict[str, Any]: + session = get_session() + try: + voice_session = session.execute( + select(VoiceAISessionRow).where(VoiceAISessionRow.session_id == session_id) + ).scalar_one_or_none() + if voice_session is None: + raise HTTPException(status_code=404, detail="Voice AI session not found") + now = utc_now_iso() + voice_session.status = "completed" if voice_session.status != "error" else "error" + voice_session.ended_at = voice_session.ended_at or now + voice_session.updated_at = now + if str(voice_session.ai_session_id or "").strip(): + ai_session = session.execute( + select(AISessionRow).where(AISessionRow.session_id == voice_session.ai_session_id) + ).scalar_one_or_none() + if ai_session: + ai_session.status = "closed" if ai_session.status != "error" else "error" + ai_session.closed_at = now + ai_session.updated_at = now + session.commit() + return { + "ok": True, + "voice_session_id": voice_session.session_id, + "ai_session_id": voice_session.ai_session_id, + "status": voice_session.status, + } + finally: + session.close() + + diff --git a/services/ai_voice_runtime_service/__init__.py b/services/ai_voice_runtime_service/__init__.py new file mode 100644 index 0000000..06c6b2a --- /dev/null +++ b/services/ai_voice_runtime_service/__init__.py @@ -0,0 +1 @@ +"""Voice AI runtime service.""" diff --git a/services/ai_voice_runtime_service/app.py b/services/ai_voice_runtime_service/app.py new file mode 100644 index 0000000..a781dea --- /dev/null +++ b/services/ai_voice_runtime_service/app.py @@ -0,0 +1,1017 @@ +from __future__ import annotations + +import asyncio +import json +import logging +import os +import threading +import time +from contextlib import asynccontextmanager +from typing import Any + +import httpx +from fastapi import Depends, FastAPI, HTTPException +from sqlalchemy import select +from sqlalchemy.exc import OperationalError + +from services.ai_voice_runtime_service.audiosocket import normalize_media_uuid +from services.ai_voice_runtime_service.media_runtime import AudioSocketMediaRuntime, MediaRegistration +from services.ai_voice_runtime_service.providers.asr import build_asr_provider +from services.ai_voice_runtime_service.providers.tts import build_tts_provider +from services.shared.core import Role, new_id, utc_now_iso +from services.shared.db import get_session +from services.shared.models import ( + HealthResponse, + VoiceAIHandoffRequestIn, + VoiceAIMediaBridgeEventIn, + VoiceAISessionCreateIn, + VoiceAISessionOut, + VoiceAIStartIn, + VoiceAITelephonyEventIn, + VoiceAITurnDecisionOut, + VoiceAITurnIn, +) +from services.shared.security import issue_app_token, require_roles +from services.shared.sql_init import init_sql_schema +from services.shared.sql_models import VoiceAISessionRow, VoiceTranscriptSegmentRow +from services.shared.voice_transcripts import add_transcript_segment, next_transcript_sequence + +init_sql_schema() + + +LOGGER = logging.getLogger("uvicorn.error") + + +def _bool_env(name: str, default: bool) -> bool: + raw = os.getenv(name) + if raw is None: + return default + return raw.strip().lower() in {"1", "true", "yes", "on"} + + +def _int_env(name: str, default: int) -> int: + raw = os.getenv(name) + if raw is None: + return default + try: + return int(raw.strip()) + except ValueError: + return default + + +def _float_env(name: str, default: float) -> float: + raw = os.getenv(name) + if raw is None: + return default + try: + return float(raw.strip()) + except ValueError: + return default + + +def _ai_orchestrator_service_url() -> str: + return os.getenv("AI_ORCHESTRATOR_SERVICE_URL", "http://localhost:8017").rstrip("/") + + +def _asterisk_bridge_service_url() -> str: + return os.getenv("ASTERISK_BRIDGE_SERVICE_URL", "http://localhost:8016").rstrip("/") + + +def _interaction_service_url() -> str: + return os.getenv("INTERACTION_SERVICE_URL", "http://localhost:8004").rstrip("/") + + +def _asr_provider_name() -> str: + return os.getenv("AI_VOICE_ASR_PROVIDER", "openai").strip() or "openai" + + +def _tts_provider_name() -> str: + return os.getenv("AI_VOICE_TTS_PROVIDER", "openai").strip() or "openai" + + +def _handoff_timeout_seconds() -> float: + return max(3.0, _float_env("AI_VOICE_HANDOFF_TIMEOUT_SECONDS", 8.0)) + + +def _audiosocket_enabled() -> bool: + return _bool_env("AI_VOICE_AUDIOSOCKET_ENABLED", False) + + +def _audiosocket_host() -> str: + return os.getenv("AI_VOICE_AUDIOSOCKET_HOST", "0.0.0.0").strip() or "0.0.0.0" + + +def _audiosocket_port() -> int: + return max(_int_env("AI_VOICE_AUDIOSOCKET_PORT", 9019), 1) + + +def _vad_frame_ms() -> int: + return 20 + + +def _vad_min_speech_ms() -> int: + return max(_int_env("AI_VOICE_VAD_MIN_SPEECH_MS", 300), _vad_frame_ms()) + + +def _vad_trailing_silence_ms() -> int: + return max(_int_env("AI_VOICE_VAD_TRAILING_SILENCE_MS", 700), _vad_frame_ms()) + + +def _turn_max_ms() -> int: + return max(_int_env("AI_VOICE_TURN_MAX_MS", 10000), _vad_frame_ms()) + + +def _media_idle_timeout_seconds() -> float: + return max(_float_env("AI_VOICE_MEDIA_IDLE_TIMEOUT_SECONDS", 15.0), 5.0) + + +def _media_registration_wait_timeout_seconds() -> float: + # `call.started` still performs cross-service writes before the AudioSocket + # registration event can be processed, so keep the media socket open long + # enough for the registration to arrive. + return max(_float_env("AI_VOICE_MEDIA_REGISTRATION_WAIT_TIMEOUT_SECONDS", 12.0), 0.0) + + +def _service_headers() -> dict[str, str]: + token = issue_app_token( + subject="svc:ai-voice-runtime", + username="ai-voice-runtime", + role="admin", + auth_source="service", + provider="ai-voice-runtime", + ttl_seconds=300, + ) + return {"Authorization": f"Bearer {token}"} + + +def _is_sqlite_locked(exc: Exception) -> bool: + return "database is locked" in str(exc).lower() + + +def _retry_db_write(fn, *, attempts: int = 6, base_delay_seconds: float = 0.05): + last_exc: Exception | None = None + for attempt in range(attempts): + try: + return fn() + except OperationalError as exc: + last_exc = exc + if not _is_sqlite_locked(exc) or attempt >= attempts - 1: + raise + time.sleep(base_delay_seconds * (attempt + 1)) + if last_exc is not None: + raise last_exc + raise RuntimeError("DB write retry exhausted without exception") + + +def _request(method: str, url: str, *, payload: dict[str, Any] | None = None, timeout: float = 10.0) -> dict[str, Any]: + with httpx.Client(timeout=timeout) as client: + response = client.request( + method, + url, + json=payload, + headers=_service_headers(), + ) + response.raise_for_status() + return response.json() + + +def _orchestrator_request(method: str, path: str, *, payload: dict[str, Any] | None = None, timeout: float = 10.0) -> dict[str, Any]: + return _request(method, f"{_ai_orchestrator_service_url()}{path}", payload=payload, timeout=timeout) + + +def _bridge_request(method: str, path: str, *, payload: dict[str, Any] | None = None, timeout: float = 10.0) -> dict[str, Any]: + return _request(method, f"{_asterisk_bridge_service_url()}{path}", payload=payload, timeout=timeout) + + +def _interaction_request(method: str, path: str, *, payload: dict[str, Any] | None = None, timeout: float = 5.0) -> dict[str, Any]: + return _request(method, f"{_interaction_service_url()}{path}", payload=payload, timeout=timeout) + + +def _resolved_voice_language(language_hint: str | None, fallback: str | None = None) -> str: + return str(language_hint or fallback or "ru").strip() or "ru" + + +def _default_voice_greeting(language: str | None) -> str: + if str(language or "").strip() == "kz": + return ( + "Мен компанияның AI оператормын. Сәлеметсіз бе. " + "Қандай сұрағыңыз бар екенін айтыңыз, мен бірден көмектесуге тырысамын." + ) + return ( + "Я AI-оператор компании. Здравствуйте. " + "Коротко расскажите, с чем помочь, и я сразу начну разбираться." + ) + + +_ASR_PROVIDER = build_asr_provider(_asr_provider_name()) +_TTS_PROVIDER = build_tts_provider(_tts_provider_name()) + + +def _load_voice_session(session, session_id: str) -> VoiceAISessionRow: + row = session.execute( + select(VoiceAISessionRow).where(VoiceAISessionRow.session_id == session_id) + ).scalar_one_or_none() + if row is None: + raise HTTPException(status_code=404, detail="Voice AI session not found") + return row + + +def _next_sequence(session, session_id: str) -> int: + return next_transcript_sequence(session, session_id) + + +def _record_segment( + session, + *, + voice_session: VoiceAISessionRow, + speaker: str, + source_type: str, + text: str, + sequence_no: int, + confidence: float | None = None, + payload: dict[str, Any] | None = None, + is_final: bool = True, +) -> None: + add_transcript_segment( + session, + session_id=voice_session.session_id, + call_id=voice_session.call_id, + interaction_id=voice_session.interaction_id, + speaker=speaker, + source_type=source_type, + text=text, + confidence=confidence, + payload=payload, + is_final=is_final, + sequence_no=sequence_no, + ) + + +def _load_greeting_segment(session, session_id: str) -> VoiceTranscriptSegmentRow | None: + rows = session.execute( + select(VoiceTranscriptSegmentRow) + .where(VoiceTranscriptSegmentRow.session_id == session_id) + .where(VoiceTranscriptSegmentRow.speaker == "assistant") + .order_by(VoiceTranscriptSegmentRow.id.asc()) + ).scalars().all() + for row in rows: + try: + payload = json.loads(row.payload_json or "{}") + except Exception: + payload = {} + if str(payload.get("kind") or "").strip() == "greeting": + return row + return None + + +def _ensure_greeting_segment(session, voice_session: VoiceAISessionRow, greeting_text: str) -> None: + if not str(greeting_text or "").strip(): + return + if _load_greeting_segment(session, voice_session.session_id) is not None: + return + _record_segment( + session, + voice_session=voice_session, + speaker="assistant", + source_type="tts", + text=greeting_text, + sequence_no=_next_sequence(session, voice_session.session_id), + payload={"kind": "greeting", "delivery_status": "planned"}, + is_final=False, + ) + + +def _mark_last_assistant_segment_interrupted(session, session_id: str) -> None: + row = session.execute( + select(VoiceTranscriptSegmentRow) + .where(VoiceTranscriptSegmentRow.session_id == session_id) + .where(VoiceTranscriptSegmentRow.speaker == "assistant") + .where(VoiceTranscriptSegmentRow.is_final.is_(False)) + .order_by(VoiceTranscriptSegmentRow.id.desc()) + .limit(1) + ).scalar_one_or_none() + if row is None: + row = session.execute( + select(VoiceTranscriptSegmentRow) + .where(VoiceTranscriptSegmentRow.session_id == session_id) + .where(VoiceTranscriptSegmentRow.speaker == "assistant") + .order_by(VoiceTranscriptSegmentRow.id.desc()) + .limit(1) + ).scalar_one_or_none() + if row is not None: + row.barge_in_interrupted = True + try: + payload = json.loads(row.payload_json or "{}") + except Exception: + payload = {} + payload["delivery_status"] = "interrupted" + row.payload_json = json.dumps(payload, ensure_ascii=False) + + +def _append_interaction_timeline(interaction_id: str | None, action: str, metadata: dict[str, Any] | None = None) -> None: + if not str(interaction_id or "").strip(): + return + _interaction_request( + "POST", + f"/interactions/{interaction_id}/timeline", + payload={"action": action, "metadata": metadata or {}}, + timeout=3.0, + ) + + +def _push_bridge_call_state( + voice_session: VoiceAISessionRow, + *, + ai_state: str, + handoff_reason: str | None = None, + metadata: dict[str, Any] | None = None, +) -> None: + _bridge_request( + "POST", + f"/internal/voice-ai/calls/{voice_session.call_id}/state", + payload={ + "voice_session_id": voice_session.session_id, + "ai_session_id": voice_session.ai_session_id, + "ai_state": ai_state, + "handoff_reason": handoff_reason, + "metadata": metadata or {}, + }, + timeout=3.0, + ) + + +def _media_registration_from_row(row: VoiceAISessionRow) -> MediaRegistration: + return MediaRegistration( + voice_session_id=row.session_id, + call_id=row.call_id, + interaction_id=str(row.interaction_id or "").strip(), + ai_session_id=str(row.ai_session_id or "").strip() or None, + language=str(row.language or "").strip() or None, + media_uuid=str(row.media_uuid or "").strip() or None, + ) + + +def _load_media_registration_by_uuid(media_uuid: str) -> MediaRegistration | None: + session = get_session() + try: + normalized = normalize_media_uuid(media_uuid) + row = session.execute( + select(VoiceAISessionRow) + .where(VoiceAISessionRow.media_uuid == normalized) + .order_by(VoiceAISessionRow.id.desc()) + .limit(1) + ).scalar_one_or_none() + if row is None: + return None + return _media_registration_from_row(row) + finally: + session.close() + + +def _persist_media_bridge_event(session_id: str, payload: VoiceAIMediaBridgeEventIn) -> VoiceAISessionRow: + def _write() -> VoiceAISessionRow: + session = get_session() + try: + voice_session = _load_voice_session(session, session_id) + if str(voice_session.call_id or "").strip() != str(payload.call_id or "").strip(): + raise HTTPException(status_code=409, detail="call_id mismatch") + media_uuid = normalize_media_uuid(payload.media_uuid) + now = utc_now_iso() + if payload.event_type == "requested": + voice_session.media_uuid = media_uuid + voice_session.media_status = "requested" + voice_session.media_connected_at = None + voice_session.media_ended_at = None + else: + voice_session.media_uuid = voice_session.media_uuid or media_uuid + voice_session.media_status = "ended" + voice_session.media_ended_at = now + voice_session.updated_at = now + session.commit() + session.refresh(voice_session) + return voice_session + finally: + session.close() + + return _retry_db_write(_write) + + +def _mark_media_connected(session_id: str, media_uuid: str) -> None: + def _write() -> None: + session = get_session() + try: + voice_session = _load_voice_session(session, session_id) + now = utc_now_iso() + voice_session.media_uuid = normalize_media_uuid(media_uuid) + voice_session.media_status = "connected" + voice_session.media_connected_at = now + voice_session.updated_at = now + session.commit() + finally: + session.close() + + _retry_db_write(_write) + + +def _mark_media_ended(session_id: str, reason: str) -> None: + def _write() -> None: + session = get_session() + try: + voice_session = _load_voice_session(session, session_id) + now = utc_now_iso() + voice_session.media_status = "ended" + voice_session.media_ended_at = now + voice_session.updated_at = now + if voice_session.status in {"queued", "greeting", "listening", "thinking", "speaking", "active"}: + voice_session.status = "completed" + voice_session.ended_at = voice_session.ended_at or now + session.commit() + finally: + session.close() + + _retry_db_write(_write) + + +def _touch_media_frame(session_id: str) -> None: + def _write() -> None: + session = get_session() + try: + voice_session = _load_voice_session(session, session_id) + now = utc_now_iso() + voice_session.last_media_frame_at = now + voice_session.updated_at = now + session.commit() + finally: + session.close() + + _retry_db_write(_write) + + +def _load_pending_greeting_text(session_id: str) -> str | None: + session = get_session() + try: + voice_session = _load_voice_session(session, session_id) + if voice_session.disclosure_played_at: + return None + rows = session.execute( + select(VoiceTranscriptSegmentRow) + .where(VoiceTranscriptSegmentRow.session_id == session_id) + .where(VoiceTranscriptSegmentRow.speaker == "assistant") + .order_by(VoiceTranscriptSegmentRow.id.desc()) + ).scalars().all() + for row in rows: + try: + payload = json.loads(row.payload_json or "{}") + except Exception: + payload = {} + if str(payload.get("kind") or "").strip() == "greeting": + return str(row.text or "").strip() or None + return None + finally: + session.close() + + +def _mark_reply_delivered(session_id: str, text: str, is_greeting: bool) -> None: + def _write() -> None: + session = get_session() + try: + voice_session = _load_voice_session(session, session_id) + now = utc_now_iso() + pending_rows = session.execute( + select(VoiceTranscriptSegmentRow) + .where(VoiceTranscriptSegmentRow.session_id == session_id) + .where(VoiceTranscriptSegmentRow.speaker == "assistant") + .where(VoiceTranscriptSegmentRow.is_final.is_(False)) + .order_by(VoiceTranscriptSegmentRow.id.desc()) + ).scalars().all() + matched_row = None + normalized_text = str(text or "").strip() + for row in pending_rows: + try: + payload = json.loads(row.payload_json or "{}") + except Exception: + payload = {} + row_kind = str(payload.get("kind") or "").strip() + if is_greeting: + if row_kind == "greeting": + matched_row = row + payload["delivery_status"] = "delivered" + row.payload_json = json.dumps(payload, ensure_ascii=False) + break + continue + if str(row.text or "").strip() != normalized_text: + continue + matched_row = row + payload["delivery_status"] = "delivered" + row.payload_json = json.dumps(payload, ensure_ascii=False) + break + if matched_row is not None: + matched_row.is_final = True + if is_greeting and not voice_session.disclosure_played_at: + voice_session.disclosure_played_at = now + voice_session.last_ai_reply_at = now + voice_session.updated_at = now + session.commit() + finally: + session.close() + + _retry_db_write(_write) + + +def _set_voice_session_state( + session_id: str, + ai_state: str, + handoff_reason: str | None = None, + metadata: dict[str, Any] | None = None, +) -> None: + def _write() -> VoiceAISessionRow: + session = get_session() + try: + voice_session = _load_voice_session(session, session_id) + now = utc_now_iso() + voice_session.status = ai_state + if handoff_reason is not None: + voice_session.handoff_reason = str(handoff_reason).strip() or None + voice_session.updated_at = now + if ai_state in {"completed", "closed"}: + voice_session.ended_at = voice_session.ended_at or now + session.commit() + session.refresh(voice_session) + return voice_session + finally: + session.close() + + voice_session = _retry_db_write(_write) + _push_bridge_call_state( + voice_session, + ai_state=ai_state, + handoff_reason=handoff_reason, + metadata=metadata, + ) + if ai_state == "error": + _append_interaction_timeline( + voice_session.interaction_id, + "ai.error", + { + "call_id": voice_session.call_id, + "voice_session_id": voice_session.session_id, + "ai_session_id": voice_session.ai_session_id, + "error": str(handoff_reason or "").strip()[:500], + **(metadata or {}), + }, + ) + + +def _request_runtime_handoff( + session_id: str, + customer_request_text: str, + decision: VoiceAITurnDecisionOut, +) -> None: + session = get_session() + try: + voice_session = _load_voice_session(session, session_id) + if not str(voice_session.interaction_id or "").strip(): + raise RuntimeError("Voice AI session is missing interaction_id") + handoff_payload = VoiceAIHandoffRequestIn( + voice_session_id=voice_session.session_id, + ai_session_id=voice_session.ai_session_id, + interaction_id=voice_session.interaction_id, + target_queue_id=voice_session.handoff_target_queue_id, + reason=decision.handoff_reason or "AI requested human handoff.", + summary={ + "customer_request_text": customer_request_text, + "ai_outcome_text": decision.summary_text or decision.reply_text or decision.handoff_reason or "", + "recommended_next_step": "Продолжить звонок вручную и проверить контекст обращения.", + }, + ) + LOGGER.warning( + "voice_runtime.handoff_request call_id=%s session_id=%s ai_session_id=%s interaction_id=%s target_queue_id=%s reason=%s", + voice_session.call_id, + voice_session.session_id, + voice_session.ai_session_id, + voice_session.interaction_id, + voice_session.handoff_target_queue_id, + (decision.handoff_reason or "AI requested human handoff.")[:300], + ) + try: + _bridge_request( + "POST", + f"/internal/voice-ai/calls/{voice_session.call_id}/handoff", + payload=handoff_payload.model_dump(), + timeout=_handoff_timeout_seconds(), + ) + except Exception as exc: + LOGGER.warning( + "voice_runtime.handoff_failed call_id=%s session_id=%s ai_session_id=%s error=%s", + voice_session.call_id, + voice_session.session_id, + voice_session.ai_session_id, + str(exc)[:500], + ) + raise + finally: + session.close() + + +def _handle_media_error(session_id: str, message: str, metadata: dict[str, Any] | None = None) -> None: + session = get_session() + try: + voice_session = _load_voice_session(session, session_id) + previous_status = str(voice_session.status or "").strip() + now = utc_now_iso() + voice_session.status = "error" + voice_session.handoff_reason = str(message or "media_error")[:1000] + voice_session.media_status = "error" + voice_session.media_ended_at = now + voice_session.updated_at = now + session.commit() + _push_bridge_call_state( + voice_session, + ai_state="error", + handoff_reason=voice_session.handoff_reason, + metadata=metadata, + ) + _append_interaction_timeline( + voice_session.interaction_id, + "ai.error", + { + "call_id": voice_session.call_id, + "voice_session_id": voice_session.session_id, + "ai_session_id": voice_session.ai_session_id, + "error": voice_session.handoff_reason, + **(metadata or {}), + }, + ) + if previous_status not in {"human_owned", "completed", "closed", "handoff_requested", "handoff_required"} and str(voice_session.interaction_id or "").strip(): + handoff_payload = VoiceAIHandoffRequestIn( + voice_session_id=voice_session.session_id, + ai_session_id=voice_session.ai_session_id, + interaction_id=voice_session.interaction_id, + target_queue_id=voice_session.handoff_target_queue_id, + reason=voice_session.handoff_reason or "Voice AI media error.", + summary={ + "customer_request_text": "Не удалось завершить AI-аудио-сценарий.", + "ai_outcome_text": voice_session.handoff_reason or "Voice AI media error.", + "recommended_next_step": "Продолжить звонок вручную и проверить состояние Voice AI runtime.", + }, + ) + try: + _bridge_request( + "POST", + f"/internal/voice-ai/calls/{voice_session.call_id}/handoff", + payload=handoff_payload.model_dump(), + timeout=_handoff_timeout_seconds(), + ) + except Exception: + pass + finally: + session.close() + + +def _process_voice_ai_turn_sync( + session_id: str, + payload: VoiceAITurnIn, + *, + auto_handoff: bool, +) -> VoiceAITurnDecisionOut: + session = get_session() + voice_session = None + try: + voice_session = _load_voice_session(session, session_id) + if voice_session.status in {"human_owned", "completed", "error"}: + raise HTTPException(status_code=409, detail="Voice AI session is no longer active") + now = utc_now_iso() + voice_session.status = "thinking" + voice_session.last_user_utterance_at = now + voice_session.updated_at = now + if payload.barge_in: + _mark_last_assistant_segment_interrupted(session, voice_session.session_id) + _record_segment( + session, + voice_session=voice_session, + speaker="caller", + source_type="asr", + text=payload.transcript_text, + sequence_no=max(payload.sequence_no, _next_sequence(session, voice_session.session_id)), + payload={"metadata": payload.metadata, "barge_in": payload.barge_in}, + ) + session.commit() + + decision_payload = _orchestrator_request( + "POST", + f"/ai/voice/sessions/{session_id}/turns", + payload=payload.model_dump(), + timeout=12.0, + ) + decision = VoiceAITurnDecisionOut.model_validate(decision_payload) + voice_session.language = decision.language or voice_session.language + voice_session.handoff_reason = decision.handoff_reason + voice_session.status = "handoff_requested" if decision.needs_handoff else "active" + voice_session.updated_at = utc_now_iso() + if decision.reply_text: + _record_segment( + session, + voice_session=voice_session, + speaker="assistant", + source_type="tts", + text=decision.reply_text, + sequence_no=_next_sequence(session, voice_session.session_id), + payload={ + "intent": decision.intent, + "kb_refs": decision.kb_refs, + "model": decision.model, + "delivery_status": "planned", + }, + is_final=False, + ) + session.commit() + if auto_handoff and decision.needs_handoff: + _request_runtime_handoff(session_id, payload.transcript_text, decision) + return decision + except HTTPException: + raise + except Exception as exc: + if voice_session is not None: + voice_session.status = "error" + voice_session.handoff_reason = str(exc)[:1000] + voice_session.updated_at = utc_now_iso() + session.commit() + raise HTTPException(status_code=502, detail=f"Voice AI runtime turn failed: {exc}") from exc + finally: + session.close() + + +def _media_process_turn( + session_id: str, + transcript_text: str, + language: str | None, + barge_in: bool, + metadata: dict[str, Any] | None, +) -> VoiceAITurnDecisionOut: + session = get_session() + try: + voice_session = _load_voice_session(session, session_id) + interaction_id = str(voice_session.interaction_id or "").strip() + call_id = str(voice_session.call_id or "").strip() + finally: + session.close() + if not interaction_id or not call_id: + raise RuntimeError("Voice AI session is missing call linkage") + + payload = VoiceAITurnIn( + voice_session_id=session_id, + call_id=call_id, + interaction_id=interaction_id, + transcript_text=transcript_text, + language=language, + sequence_no=1, + barge_in=barge_in, + metadata=metadata or {}, + ) + return _process_voice_ai_turn_sync(session_id, payload, auto_handoff=False) + + +def _complete_voice_ai_session_start(session_id: str, start_payload: VoiceAIStartIn) -> None: + try: + started = _orchestrator_request( + "POST", + f"/ai/voice/sessions/{session_id}/start", + payload=start_payload.model_dump(), + timeout=10.0, + ) + except Exception as exc: + session = get_session() + try: + voice_session = _load_voice_session(session, session_id) + voice_session.handoff_reason = str(exc)[:1000] + voice_session.updated_at = utc_now_iso() + session.commit() + finally: + session.close() + return + + session = get_session() + try: + voice_session = _load_voice_session(session, session_id) + now = utc_now_iso() + greeting_text = str(started.get("greeting_text") or "").strip() + voice_session.ai_session_id = str(started.get("session_id") or voice_session.ai_session_id or "").strip() or None + voice_session.language = _resolved_voice_language(started.get("language"), voice_session.language) + if greeting_text or _load_greeting_segment(session, session_id) is not None: + voice_session.status = "greeting" + elif not str(voice_session.status or "").strip(): + voice_session.status = "active" + voice_session.handoff_reason = None + voice_session.updated_at = now + if greeting_text: + _ensure_greeting_segment(session, voice_session, greeting_text) + session.commit() + finally: + session.close() + + +_MEDIA_RUNTIME = AudioSocketMediaRuntime( + enabled=_audiosocket_enabled(), + host=_audiosocket_host(), + port=_audiosocket_port(), + frame_ms=_vad_frame_ms(), + idle_timeout_seconds=_media_idle_timeout_seconds(), + registration_wait_timeout_seconds=_media_registration_wait_timeout_seconds(), + min_speech_ms=_vad_min_speech_ms(), + trailing_silence_ms=_vad_trailing_silence_ms(), + max_turn_ms=_turn_max_ms(), + asr_provider=_ASR_PROVIDER, + tts_provider=_TTS_PROVIDER, + load_registration_by_media_uuid=_load_media_registration_by_uuid, + mark_media_connected=_mark_media_connected, + mark_media_ended=_mark_media_ended, + touch_media_frame=_touch_media_frame, + set_state=_set_voice_session_state, + get_pending_greeting=_load_pending_greeting_text, + mark_reply_delivered=_mark_reply_delivered, + process_turn=_media_process_turn, + request_handoff=_request_runtime_handoff, + handle_media_error=_handle_media_error, +) + + +@asynccontextmanager +async def _lifespan(_: FastAPI): + await _MEDIA_RUNTIME.start() + try: + yield + finally: + await _MEDIA_RUNTIME.stop() + + +app = FastAPI(title="ai-voice-runtime-service", version="1.0.0", lifespan=_lifespan) + + +@app.get("/health", response_model=HealthResponse) +def health() -> HealthResponse: + return HealthResponse(status="ok", service="ai-voice-runtime-service", version="v1") + + +@app.post("/internal/voice-ai/sessions", response_model=VoiceAISessionOut) +def create_voice_ai_session( + payload: VoiceAISessionCreateIn, + _: dict = Depends(require_roles(Role.ADMIN)), +) -> VoiceAISessionOut: + session = get_session() + voice_session = None + should_start_async = False + try: + voice_session = session.execute( + select(VoiceAISessionRow).where(VoiceAISessionRow.call_id == payload.call_id) + ).scalar_one_or_none() + now = utc_now_iso() + language = _resolved_voice_language( + payload.language_hint, + voice_session.language if voice_session is not None else None, + ) + if voice_session is None: + voice_session = VoiceAISessionRow( + session_id=new_id("avs"), + call_id=payload.call_id, + linked_id=payload.linked_id, + interaction_id=payload.interaction_id, + customer_id=None, + queue_id=payload.queue_id, + ai_session_id=None, + agent_profile=payload.agent_profile, + language=language, + asr_provider=_ASR_PROVIDER.name, + tts_provider=_TTS_PROVIDER.name, + status="greeting", + handoff_reason=None, + handoff_target_queue_id=payload.handoff_queue_id or payload.queue_id, + media_uuid=None, + media_status=None, + media_connected_at=None, + media_ended_at=None, + last_media_frame_at=None, + disclosure_played_at=None, + last_user_utterance_at=None, + last_ai_reply_at=None, + started_at=now, + updated_at=now, + ended_at=None, + ) + session.add(voice_session) + should_start_async = True + else: + voice_session.linked_id = payload.linked_id + voice_session.interaction_id = payload.interaction_id + voice_session.queue_id = payload.queue_id + voice_session.agent_profile = payload.agent_profile + voice_session.language = language + voice_session.status = "greeting" + voice_session.handoff_reason = None + voice_session.handoff_target_queue_id = payload.handoff_queue_id or voice_session.handoff_target_queue_id or payload.queue_id + voice_session.updated_at = now + should_start_async = not str(voice_session.ai_session_id or "").strip() + _ensure_greeting_segment(session, voice_session, _default_voice_greeting(language)) + session.commit() + + start_payload = VoiceAIStartIn( + voice_session_id=voice_session.session_id, + call_id=payload.call_id, + interaction_id=payload.interaction_id, + customer_id=None, + language_hint=language, + agent_profile=payload.agent_profile, + ) + if should_start_async: + threading.Thread( + target=_complete_voice_ai_session_start, + args=(voice_session.session_id, start_payload), + name=f"voice-start-{voice_session.session_id}", + daemon=True, + ).start() + return VoiceAISessionOut( + voice_session_id=voice_session.session_id, + ai_session_id=voice_session.ai_session_id, + status=voice_session.status, # type: ignore[arg-type] + ) + except HTTPException: + raise + except Exception as exc: + if voice_session is not None: + voice_session.status = "error" + voice_session.handoff_reason = str(exc)[:1000] + voice_session.updated_at = utc_now_iso() + session.commit() + raise HTTPException(status_code=502, detail=f"Voice AI session start failed: {exc}") from exc + finally: + session.close() + + +@app.post("/internal/voice-ai/sessions/{session_id}/media-bridge", response_model=VoiceAISessionOut) +def register_voice_ai_media_bridge( + session_id: str, + payload: VoiceAIMediaBridgeEventIn, + _: dict = Depends(require_roles(Role.ADMIN)), +) -> VoiceAISessionOut: + voice_session = _persist_media_bridge_event(session_id, payload) + if payload.event_type == "ended": + _MEDIA_RUNTIME.close_session_sync(session_id, reason=payload.reason or "media_bridge_ended") + return VoiceAISessionOut( + voice_session_id=voice_session.session_id, + ai_session_id=voice_session.ai_session_id, + status=voice_session.status, # type: ignore[arg-type] + ) + + +@app.post("/internal/voice-ai/sessions/{session_id}/telephony-events", response_model=VoiceAISessionOut) +def push_voice_ai_telephony_event( + session_id: str, + payload: VoiceAITelephonyEventIn, + _: dict = Depends(require_roles(Role.ADMIN)), +) -> VoiceAISessionOut: + session = get_session() + try: + voice_session = _load_voice_session(session, session_id) + now = utc_now_iso() + if payload.event_type == "call.connected": + if voice_session.status in {"queued", "greeting", "active", "thinking", "speaking"}: + voice_session.status = "listening" + elif payload.event_type == "operator.connected": + voice_session.status = "human_owned" + elif payload.event_type == "call.ended": + voice_session.status = "completed" if voice_session.status != "error" else "error" + voice_session.ended_at = voice_session.ended_at or now + voice_session.media_status = voice_session.media_status or "ended" + voice_session.media_ended_at = voice_session.media_ended_at or now + voice_session.updated_at = now + session.commit() + ai_session_id = voice_session.ai_session_id + status = str(voice_session.status or "queued") + finally: + session.close() + + if payload.event_type == "call.ended": + _MEDIA_RUNTIME.close_session_sync(session_id, reason="call_ended") + try: + _orchestrator_request( + "POST", + f"/ai/voice/sessions/{session_id}/close", + payload={}, + timeout=5.0, + ) + except Exception: + pass + + return VoiceAISessionOut( + voice_session_id=session_id, + ai_session_id=ai_session_id, + status=status, # type: ignore[arg-type] + ) + + +@app.post("/internal/voice-ai/sessions/{session_id}/turns", response_model=VoiceAITurnDecisionOut) +def process_voice_ai_turn( + session_id: str, + payload: VoiceAITurnIn, + _: dict = Depends(require_roles(Role.ADMIN)), +) -> VoiceAITurnDecisionOut: + return _process_voice_ai_turn_sync(session_id, payload, auto_handoff=True) diff --git a/services/ai_voice_runtime_service/audiosocket.py b/services/ai_voice_runtime_service/audiosocket.py new file mode 100644 index 0000000..6c125b9 --- /dev/null +++ b/services/ai_voice_runtime_service/audiosocket.py @@ -0,0 +1,160 @@ +from __future__ import annotations + +import asyncio +import audioop +import io +import struct +import uuid +import wave +from dataclasses import dataclass + + +AUDIO_SOCKET_PACKET_HANGUP = 0x00 +AUDIO_SOCKET_PACKET_UUID = 0x01 +AUDIO_SOCKET_PACKET_DTMF = 0x03 +AUDIO_SOCKET_PACKET_PCM16 = 0x10 + + +def normalize_media_uuid(value: str | bytes) -> str: + if isinstance(value, bytes): + try: + return str(uuid.UUID(bytes=value)).lower() + except (TypeError, ValueError, AttributeError): + return str(uuid.UUID(value.decode("utf-8").strip())).lower() + return str(uuid.UUID(str(value).strip())).lower() + + +def encode_packet(packet_type: int, payload: bytes = b"") -> bytes: + return struct.pack("!BH", packet_type & 0xFF, len(payload)) + payload + + +def encode_audio_packet(pcm_bytes: bytes) -> bytes: + return encode_packet(AUDIO_SOCKET_PACKET_PCM16, pcm_bytes) + + +async def read_packet(reader: asyncio.StreamReader, *, timeout: float) -> tuple[int, bytes]: + header = await asyncio.wait_for(reader.readexactly(3), timeout=timeout) + packet_type, payload_length = struct.unpack("!BH", header) + payload = b"" + if payload_length: + payload = await asyncio.wait_for(reader.readexactly(payload_length), timeout=timeout) + return packet_type, payload + + +def pcm16le_to_wav_bytes( + pcm_bytes: bytes, + *, + sample_rate_hz: int, + channels: int = 1, + sample_width_bytes: int = 2, +) -> bytes: + handle = io.BytesIO() + with wave.open(handle, "wb") as wav_file: + wav_file.setnchannels(channels) + wav_file.setsampwidth(sample_width_bytes) + wav_file.setframerate(sample_rate_hz) + wav_file.writeframes(pcm_bytes) + return handle.getvalue() + + +def resample_pcm16le( + pcm_bytes: bytes, + *, + input_rate_hz: int, + output_rate_hz: int, + channels: int = 1, + sample_width_bytes: int = 2, +) -> bytes: + if not pcm_bytes or input_rate_hz == output_rate_hz: + return pcm_bytes + mono_bytes = pcm_bytes + if channels == 2: + mono_bytes = audioop.tomono(pcm_bytes, sample_width_bytes, 0.5, 0.5) + converted, _ = audioop.ratecv( + mono_bytes, + sample_width_bytes, + 1, + input_rate_hz, + output_rate_hz, + None, + ) + return converted + + +def chunk_audio(pcm_bytes: bytes, *, frame_bytes: int) -> list[bytes]: + if frame_bytes <= 0: + raise ValueError("frame_bytes must be positive") + frames = [pcm_bytes[index : index + frame_bytes] for index in range(0, len(pcm_bytes), frame_bytes)] + if frames and len(frames[-1]) < frame_bytes: + frames[-1] = frames[-1] + (b"\x00" * (frame_bytes - len(frames[-1]))) + return frames + + +@dataclass(slots=True) +class EnergyVADResult: + speech_started: bool = False + utterance_pcm: bytes | None = None + + +class EnergyVAD: + def __init__( + self, + *, + frame_ms: int, + min_speech_ms: int, + trailing_silence_ms: int, + max_turn_ms: int, + rms_threshold: int = 250, + ) -> None: + self._frame_ms = max(frame_ms, 10) + self._min_speech_ms = max(min_speech_ms, self._frame_ms) + self._trailing_silence_ms = max(trailing_silence_ms, self._frame_ms) + self._max_turn_ms = max(max_turn_ms, self._frame_ms) + self._rms_threshold = max(rms_threshold, 1) + self.reset() + + def reset(self) -> None: + self._pending_pcm = bytearray() + self._utterance_pcm = bytearray() + self._speech_ms = 0 + self._silence_ms = 0 + self._active = False + + def feed(self, pcm_frame: bytes) -> EnergyVADResult: + if not pcm_frame: + return EnergyVADResult() + + is_speech = audioop.rms(pcm_frame, 2) >= self._rms_threshold + result = EnergyVADResult() + + if not self._active: + if is_speech: + self._pending_pcm.extend(pcm_frame) + self._speech_ms += self._frame_ms + if self._speech_ms >= self._min_speech_ms: + self._active = True + self._utterance_pcm.extend(self._pending_pcm) + self._pending_pcm.clear() + self._silence_ms = 0 + result.speech_started = True + else: + self._pending_pcm.clear() + self._speech_ms = 0 + return result + + self._utterance_pcm.extend(pcm_frame) + if is_speech: + self._silence_ms = 0 + else: + self._silence_ms += self._frame_ms + + if self._silence_ms >= self._trailing_silence_ms or len(self._utterance_pcm) >= self._max_pcm_bytes: + result.utterance_pcm = bytes(self._utterance_pcm) + self.reset() + return result + + @property + def _max_pcm_bytes(self) -> int: + samples_per_ms = 8 + bytes_per_sample = 2 + return self._max_turn_ms * samples_per_ms * bytes_per_sample diff --git a/services/ai_voice_runtime_service/media_runtime.py b/services/ai_voice_runtime_service/media_runtime.py new file mode 100644 index 0000000..d56b96d --- /dev/null +++ b/services/ai_voice_runtime_service/media_runtime.py @@ -0,0 +1,548 @@ +from __future__ import annotations + +import asyncio +import contextlib +import logging +import time +from dataclasses import dataclass, field +from typing import Any, Callable + +from services.ai_voice_runtime_service.audiosocket import ( + AUDIO_SOCKET_PACKET_DTMF, + AUDIO_SOCKET_PACKET_HANGUP, + AUDIO_SOCKET_PACKET_PCM16, + AUDIO_SOCKET_PACKET_UUID, + EnergyVAD, + chunk_audio, + encode_audio_packet, + normalize_media_uuid, + pcm16le_to_wav_bytes, + read_packet, + resample_pcm16le, +) +from services.ai_voice_runtime_service.providers.asr import ASRProvider +from services.ai_voice_runtime_service.providers.tts import TTSProvider +from services.shared.models import VoiceAITurnDecisionOut + + +logger = logging.getLogger("uvicorn.error") + + +@dataclass(slots=True) +class MediaRegistration: + voice_session_id: str + call_id: str + interaction_id: str + ai_session_id: str | None = None + language: str | None = None + media_uuid: str | None = None + + +@dataclass(slots=True) +class MediaActor: + registration: MediaRegistration + reader: asyncio.StreamReader + writer: asyncio.StreamWriter + vad: EnergyVAD + frame_ms: int + frame_bytes: int + turn_queue: asyncio.Queue[tuple[bytes, bool]] = field(default_factory=asyncio.Queue) + worker_task: asyncio.Task | None = None + keepalive_task: asyncio.Task | None = None + handoff_task: asyncio.Task | None = None + playback_interrupt: asyncio.Event = field(default_factory=asyncio.Event) + write_lock: asyncio.Lock = field(default_factory=asyncio.Lock) + closed: bool = False + state: str = "greeting" + barge_in_pending: bool = False + last_media_touch_monotonic: float = 0.0 + last_outbound_audio_monotonic: float = 0.0 + first_pcm_logged: bool = False + keepalive_loop_logged: bool = False + + +class AudioSocketMediaRuntime: + def __init__( + self, + *, + enabled: bool, + host: str, + port: int, + frame_ms: int, + idle_timeout_seconds: float, + registration_wait_timeout_seconds: float, + min_speech_ms: int, + trailing_silence_ms: int, + max_turn_ms: int, + asr_provider: ASRProvider, + tts_provider: TTSProvider, + load_registration_by_media_uuid: Callable[[str], MediaRegistration | None], + mark_media_connected: Callable[[str, str], None], + mark_media_ended: Callable[[str, str], None], + touch_media_frame: Callable[[str], None], + set_state: Callable[[str, str, str | None, dict[str, Any] | None], None], + get_pending_greeting: Callable[[str], str | None], + mark_reply_delivered: Callable[[str, str, bool], None], + process_turn: Callable[[str, str, str | None, bool, dict[str, Any] | None], VoiceAITurnDecisionOut], + request_handoff: Callable[[str, str, VoiceAITurnDecisionOut], None], + handle_media_error: Callable[[str, str, dict[str, Any] | None], None], + ) -> None: + self._enabled = enabled + self._host = host + self._port = port + self._frame_ms = max(frame_ms, 20) + self._frame_bytes = int((8000 * self._frame_ms / 1000.0) * 2) + self._idle_timeout_seconds = max(idle_timeout_seconds, 5.0) + self._registration_wait_timeout_seconds = max(registration_wait_timeout_seconds, 0.0) + self._outbound_keepalive_interval_seconds = 0.75 + self._min_speech_ms = max(min_speech_ms, self._frame_ms) + self._trailing_silence_ms = max(trailing_silence_ms, self._frame_ms) + self._max_turn_ms = max(max_turn_ms, self._frame_ms) + self._asr_provider = asr_provider + self._tts_provider = tts_provider + self._load_registration_by_media_uuid = load_registration_by_media_uuid + self._mark_media_connected = mark_media_connected + self._mark_media_ended = mark_media_ended + self._touch_media_frame = touch_media_frame + self._set_state = set_state + self._get_pending_greeting = get_pending_greeting + self._mark_reply_delivered = mark_reply_delivered + self._process_turn = process_turn + self._request_handoff = request_handoff + self._handle_media_error = handle_media_error + self._server: asyncio.base_events.Server | None = None + self._loop: asyncio.AbstractEventLoop | None = None + self._actors: dict[str, MediaActor] = {} + + @property + def address(self) -> str: + return f"{self._host}:{self._port}" + + async def start(self) -> None: + if not self._enabled or self._server is not None: + return + self._loop = asyncio.get_running_loop() + self._server = await asyncio.start_server(self._handle_connection, self._host, self._port) + + async def _await_registration( + self, + media_uuid: str, + *, + writer: asyncio.StreamWriter | None = None, + frame_bytes: int | None = None, + ) -> MediaRegistration | None: + deadline = time.monotonic() + self._registration_wait_timeout_seconds + last_keepalive_at = 0.0 + keepalive_payload = b"\x00" * max(frame_bytes or 0, 0) + keepalive_logged = False + while True: + registration = await asyncio.to_thread(self._load_registration_by_media_uuid, media_uuid) + if registration is not None: + return registration + if time.monotonic() >= deadline: + logger.warning("audiosocket.registration_timeout media_uuid=%s", media_uuid) + return None + if writer is not None and keepalive_payload: + now = time.monotonic() + if now - last_keepalive_at >= self._outbound_keepalive_interval_seconds: + writer.write(encode_audio_packet(keepalive_payload)) + await writer.drain() + if not keepalive_logged: + keepalive_logged = True + logger.warning( + "audiosocket.prereg_keepalive media_uuid=%s frame_bytes=%s", + media_uuid, + len(keepalive_payload), + ) + last_keepalive_at = now + await asyncio.sleep(0.05) + + async def stop(self) -> None: + actor_ids = list(self._actors.keys()) + for session_id in actor_ids: + await self._close_session(session_id, reason="runtime_shutdown") + if self._server is not None: + self._server.close() + await self._server.wait_closed() + self._server = None + + def close_session_sync(self, session_id: str, *, reason: str) -> None: + if not self._loop: + return + future = asyncio.run_coroutine_threadsafe( + self._close_session(session_id, reason=reason), + self._loop, + ) + with contextlib.suppress(Exception): + future.result(timeout=5.0) + + async def _handle_connection( + self, + reader: asyncio.StreamReader, + writer: asyncio.StreamWriter, + ) -> None: + actor: MediaActor | None = None + close_reason = "connection_closed" + close_as_error = False + peer = writer.get_extra_info("peername") + try: + logger.warning("audiosocket.accept peer=%s", peer) + packet_type, payload = await read_packet(reader, timeout=self._idle_timeout_seconds) + logger.warning( + "audiosocket.initial_packet peer=%s packet_type=%s payload_len=%s", + peer, + packet_type, + len(payload), + ) + if packet_type != AUDIO_SOCKET_PACKET_UUID: + raise RuntimeError("AudioSocket UUID handshake is required") + media_uuid = normalize_media_uuid(payload) + logger.warning("audiosocket.handshake peer=%s media_uuid=%s", peer, media_uuid) + registration = await self._await_registration( + media_uuid, + writer=writer, + frame_bytes=self._frame_bytes, + ) + if registration is None: + raise RuntimeError(f"Unknown AudioSocket media_uuid: {media_uuid}") + logger.warning( + "audiosocket.registered peer=%s session_id=%s media_uuid=%s", + peer, + registration.voice_session_id, + media_uuid, + ) + + actor = MediaActor( + registration=registration, + reader=reader, + writer=writer, + vad=EnergyVAD( + frame_ms=self._frame_ms, + min_speech_ms=self._min_speech_ms, + trailing_silence_ms=self._trailing_silence_ms, + max_turn_ms=self._max_turn_ms, + ), + frame_ms=self._frame_ms, + frame_bytes=self._frame_bytes, + ) + previous_actor = self._actors.get(registration.voice_session_id) + if previous_actor is not None: + await self._cleanup_actor(previous_actor, reason="media_replaced", error=False) + self._actors[registration.voice_session_id] = actor + actor.last_outbound_audio_monotonic = time.monotonic() + await asyncio.to_thread(self._mark_media_connected, registration.voice_session_id, media_uuid) + actor.keepalive_task = asyncio.create_task(self._keepalive_loop(actor)) + actor.worker_task = asyncio.create_task(self._worker(actor)) + + while not actor.closed: + packet_type, payload = await read_packet(reader, timeout=self._idle_timeout_seconds) + if packet_type == AUDIO_SOCKET_PACKET_PCM16: + await self._handle_pcm(actor, payload) + continue + if packet_type == AUDIO_SOCKET_PACKET_DTMF: + logger.warning( + "audiosocket.dtmf session_id=%s payload_len=%s", + actor.registration.voice_session_id, + len(payload), + ) + continue + if packet_type == AUDIO_SOCKET_PACKET_HANGUP: + close_reason = "audiosocket_hangup" + break + if packet_type == AUDIO_SOCKET_PACKET_UUID: + logger.warning( + "audiosocket.extra_uuid session_id=%s payload_len=%s", + actor.registration.voice_session_id, + len(payload), + ) + continue + raise RuntimeError(f"Unsupported AudioSocket packet type: {packet_type}") + except asyncio.TimeoutError: + close_reason = "media_idle_timeout" + close_as_error = True + logger.warning("audiosocket.timeout peer=%s", peer) + except asyncio.IncompleteReadError: + close_reason = "connection_closed" + logger.warning("audiosocket.peer_closed peer=%s", peer) + except Exception as exc: + close_reason = str(exc)[:1000] or "media_runtime_error" + close_as_error = True + logger.warning("audiosocket.error peer=%s reason=%s", peer, close_reason) + finally: + if actor is not None: + await self._cleanup_actor(actor, reason=close_reason, error=close_as_error) + else: + writer.close() + with contextlib.suppress(Exception): + await writer.wait_closed() + + async def _handle_pcm(self, actor: MediaActor, pcm_frame: bytes) -> None: + if actor.closed or not pcm_frame: + return + if not actor.first_pcm_logged: + actor.first_pcm_logged = True + logger.warning( + "audiosocket.first_pcm session_id=%s state=%s frame_bytes=%s", + actor.registration.voice_session_id, + actor.state, + len(pcm_frame), + ) + now = time.monotonic() + if now - actor.last_media_touch_monotonic >= 1.0: + actor.last_media_touch_monotonic = now + await asyncio.to_thread(self._touch_media_frame, actor.registration.voice_session_id) + + if actor.state not in {"listening", "speaking"}: + return + + vad_result = actor.vad.feed(pcm_frame) + if actor.state == "speaking" and vad_result.speech_started: + actor.playback_interrupt.set() + actor.barge_in_pending = True + if vad_result.utterance_pcm: + await actor.turn_queue.put((vad_result.utterance_pcm, actor.barge_in_pending)) + actor.barge_in_pending = False + + async def _worker(self, actor: MediaActor) -> None: + greeting_text = await asyncio.to_thread( + self._get_pending_greeting, + actor.registration.voice_session_id, + ) + if greeting_text: + logger.warning( + "audiosocket.greeting session_id=%s text_len=%s", + actor.registration.voice_session_id, + len(greeting_text), + ) + await self._speak_text(actor, greeting_text, is_greeting=True) + if not actor.closed: + await self._set_actor_state(actor, "listening") + + while not actor.closed: + pcm_bytes, barge_in = await actor.turn_queue.get() + if actor.closed: + break + await self._process_utterance(actor, pcm_bytes, barge_in) + + async def _process_utterance(self, actor: MediaActor, pcm_bytes: bytes, barge_in: bool) -> None: + await self._set_actor_state(actor, "thinking") + wav_bytes = pcm16le_to_wav_bytes(pcm_bytes, sample_rate_hz=8000) + transcription = await asyncio.to_thread( + lambda: self._asr_provider.transcribe( + wav_bytes, + language_hint=actor.registration.language, + ) + ) + transcript_text = str(transcription.text or "").strip() + if not transcript_text: + await self._set_actor_state(actor, "listening") + return + + metadata = { + "turn_duration_ms": int(len(pcm_bytes) / 16), + "media_uuid": actor.registration.media_uuid, + } + decision = await asyncio.to_thread( + self._process_turn, + actor.registration.voice_session_id, + transcript_text, + transcription.language or actor.registration.language, + barge_in, + metadata, + ) + handoff_task: asyncio.Task | None = None + if decision.needs_handoff: + await self._set_actor_state(actor, "handoff_requested", decision.handoff_reason) + handoff_task = self._start_handoff_request(actor, transcript_text, decision) + if decision.reply_text: + await self._speak_text(actor, decision.reply_text, is_greeting=False) + if actor.closed: + return + if decision.needs_handoff: + if handoff_task is not None and handoff_task.done(): + with contextlib.suppress(asyncio.CancelledError, Exception): + await handoff_task + return + await self._set_actor_state(actor, "listening") + + def _start_handoff_request( + self, + actor: MediaActor, + customer_request_text: str, + decision: VoiceAITurnDecisionOut, + ) -> asyncio.Task: + existing = actor.handoff_task + if existing is not None and not existing.done(): + return existing + + async def _run() -> None: + try: + await asyncio.to_thread( + self._request_handoff, + actor.registration.voice_session_id, + customer_request_text, + decision, + ) + except Exception as exc: + logger.warning( + "audiosocket.handoff_request_failed session_id=%s error=%s", + actor.registration.voice_session_id, + str(exc)[:500], + ) + + actor.handoff_task = asyncio.create_task(_run()) + return actor.handoff_task + + async def _speak_text(self, actor: MediaActor, text: str, *, is_greeting: bool) -> None: + if actor.closed or not text: + return + await self._set_actor_state(actor, "speaking") + synth_started_at = time.monotonic() + synthesis = await asyncio.to_thread( + lambda: self._tts_provider.synthesize( + text, + language=actor.registration.language, + ) + ) + if not synthesis.audio_bytes: + raise RuntimeError("TTS provider returned empty audio") + logger.warning( + "audiosocket.tts_ready session_id=%s greeting=%s synth_ms=%s audio_bytes=%s", + actor.registration.voice_session_id, + is_greeting, + int((time.monotonic() - synth_started_at) * 1000), + len(synthesis.audio_bytes), + ) + + pcm_8k = resample_pcm16le( + synthesis.audio_bytes, + input_rate_hz=synthesis.sample_rate_hz, + output_rate_hz=8000, + ) + first_frame_sent = False + for frame in chunk_audio(pcm_8k, frame_bytes=actor.frame_bytes): + if actor.closed or actor.playback_interrupt.is_set(): + break + await self._write_audio_packet(actor, frame) + if not first_frame_sent: + first_frame_sent = True + logger.warning( + "audiosocket.first_frame session_id=%s greeting=%s frame_bytes=%s", + actor.registration.voice_session_id, + is_greeting, + len(frame), + ) + await asyncio.sleep(actor.frame_ms / 1000.0) + + interrupted = actor.playback_interrupt.is_set() + actor.playback_interrupt.clear() + if not interrupted and not actor.closed: + await asyncio.to_thread( + self._mark_reply_delivered, + actor.registration.voice_session_id, + text, + is_greeting, + ) + logger.warning( + "audiosocket.reply_delivered session_id=%s greeting=%s", + actor.registration.voice_session_id, + is_greeting, + ) + + async def _write_audio_packet(self, actor: MediaActor, pcm_frame: bytes) -> None: + if actor.closed: + return + async with actor.write_lock: + if actor.closed: + return + actor.writer.write(encode_audio_packet(pcm_frame)) + await actor.writer.drain() + actor.last_outbound_audio_monotonic = time.monotonic() + + async def _keepalive_loop(self, actor: MediaActor) -> None: + silence_frame = b"\x00" * actor.frame_bytes + while not actor.closed: + await asyncio.sleep(0.25) + if actor.closed: + break + if (time.monotonic() - actor.last_outbound_audio_monotonic) < self._outbound_keepalive_interval_seconds: + continue + if not actor.keepalive_loop_logged: + actor.keepalive_loop_logged = True + logger.warning( + "audiosocket.keepalive_loop session_id=%s state=%s frame_bytes=%s", + actor.registration.voice_session_id, + actor.state, + actor.frame_bytes, + ) + await self._write_audio_packet(actor, silence_frame) + + async def _set_actor_state( + self, + actor: MediaActor, + state: str, + handoff_reason: str | None = None, + ) -> None: + if actor.closed and state not in {"error", "closed"}: + return + if actor.state == state and (handoff_reason or None) is None: + return + logger.warning( + "audiosocket.state session_id=%s from_state=%s to_state=%s handoff_reason=%s", + actor.registration.voice_session_id, + actor.state, + state, + handoff_reason, + ) + actor.state = state + await asyncio.to_thread( + self._set_state, + actor.registration.voice_session_id, + state, + handoff_reason, + {"media_uuid": actor.registration.media_uuid}, + ) + + async def _close_session(self, session_id: str, *, reason: str) -> None: + actor = self._actors.get(session_id) + if actor is None: + return + await self._cleanup_actor(actor, reason=reason, error=False) + + async def _cleanup_actor(self, actor: MediaActor, *, reason: str, error: bool) -> None: + if actor.closed: + return + actor.closed = True + logger.warning( + "audiosocket.cleanup session_id=%s reason=%s error=%s first_pcm=%s", + actor.registration.voice_session_id, + reason, + error, + actor.first_pcm_logged, + ) + self._actors.pop(actor.registration.voice_session_id, None) + if actor.keepalive_task is not None: + actor.keepalive_task.cancel() + with contextlib.suppress(asyncio.CancelledError, Exception): + await actor.keepalive_task + if actor.handoff_task is not None: + actor.handoff_task.cancel() + with contextlib.suppress(asyncio.CancelledError, Exception): + await actor.handoff_task + if actor.worker_task is not None: + actor.worker_task.cancel() + with contextlib.suppress(asyncio.CancelledError, Exception): + await actor.worker_task + actor.writer.close() + with contextlib.suppress(Exception): + await actor.writer.wait_closed() + + if error: + await asyncio.to_thread( + self._handle_media_error, + actor.registration.voice_session_id, + reason, + {"media_uuid": actor.registration.media_uuid}, + ) + return + await asyncio.to_thread(self._mark_media_ended, actor.registration.voice_session_id, reason) diff --git a/services/ai_voice_runtime_service/providers/__init__.py b/services/ai_voice_runtime_service/providers/__init__.py new file mode 100644 index 0000000..2bd05f2 --- /dev/null +++ b/services/ai_voice_runtime_service/providers/__init__.py @@ -0,0 +1 @@ +"""Provider abstractions for Voice AI runtime.""" diff --git a/services/ai_voice_runtime_service/providers/asr.py b/services/ai_voice_runtime_service/providers/asr.py new file mode 100644 index 0000000..5094782 --- /dev/null +++ b/services/ai_voice_runtime_service/providers/asr.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +import os +from dataclasses import dataclass + +import httpx + + +def _api_base() -> str: + return (os.getenv("AI_API_BASE", "https://api.openai.com/v1").strip() or "https://api.openai.com/v1").rstrip("/") + + +def _api_key() -> str: + return os.getenv("AI_API_KEY", "").strip() + + +def _timeout_seconds() -> float: + raw = os.getenv("AI_TIMEOUT_SECONDS", "20").strip() + try: + value = float(raw) + except ValueError: + value = 20.0 + return max(value, 3.0) + + +def _openai_asr_model() -> str: + return os.getenv("AI_VOICE_ASR_MODEL", "gpt-4o-mini-transcribe").strip() or "gpt-4o-mini-transcribe" + + +@dataclass(slots=True) +class ASRTranscription: + text: str + language: str | None = None + confidence: float | None = None + + +class ASRProvider: + name = "stub" + + def transcribe(self, audio_bytes: bytes, *, language_hint: str | None = None) -> ASRTranscription: + del audio_bytes + return ASRTranscription(text="", language=language_hint, confidence=None) + + +class OpenAIASRProvider(ASRProvider): + name = "openai" + + def __init__(self) -> None: + self._api_base = _api_base() + self._api_key = _api_key() + self._timeout_seconds = _timeout_seconds() + self._model = _openai_asr_model() + + def transcribe(self, audio_bytes: bytes, *, language_hint: str | None = None) -> ASRTranscription: + if not audio_bytes: + return ASRTranscription(text="", language=language_hint, confidence=None) + if not self._api_key: + raise RuntimeError("AI_API_KEY is required for OpenAI ASR") + + data: dict[str, str] = {"model": self._model} + language = str(language_hint or "").strip() + if language: + data["language"] = language + + with httpx.Client(timeout=self._timeout_seconds) as client: + response = client.post( + f"{self._api_base}/audio/transcriptions", + headers={"Authorization": f"Bearer {self._api_key}"}, + data=data, + files={"file": ("turn.wav", audio_bytes, "audio/wav")}, + ) + response.raise_for_status() + payload = response.json() + return ASRTranscription( + text=str(payload.get("text") or "").strip(), + language=str(payload.get("language") or language_hint or "").strip() or language_hint, + confidence=None, + ) + + +def build_asr_provider(name: str) -> ASRProvider: + normalized = str(name or "stub").strip().lower() + if normalized == "openai": + return OpenAIASRProvider() + return ASRProvider() diff --git a/services/ai_voice_runtime_service/providers/tts.py b/services/ai_voice_runtime_service/providers/tts.py new file mode 100644 index 0000000..f5ade08 --- /dev/null +++ b/services/ai_voice_runtime_service/providers/tts.py @@ -0,0 +1,432 @@ +from __future__ import annotations + +import base64 +import hashlib +import json +import os +from dataclasses import dataclass +from pathlib import Path +import tempfile +from threading import Lock + +import httpx + + +def _bool_env(name: str, default: bool) -> bool: + raw = os.getenv(name) + if raw is None: + return default + return raw.strip().lower() in {"1", "true", "yes", "on"} + + +def _api_base() -> str: + return (os.getenv("AI_API_BASE", "https://api.openai.com/v1").strip() or "https://api.openai.com/v1").rstrip("/") + + +def _api_key() -> str: + return os.getenv("AI_API_KEY", "").strip() + + +def _timeout_seconds() -> float: + raw = os.getenv("AI_TIMEOUT_SECONDS", "20").strip() + try: + value = float(raw) + except ValueError: + value = 20.0 + return max(value, 3.0) + + +def _openai_tts_model() -> str: + return os.getenv("AI_VOICE_TTS_MODEL", "gpt-4o-mini-tts").strip() or "gpt-4o-mini-tts" + + +def _openai_tts_voice() -> str: + return os.getenv("AI_VOICE_TTS_VOICE", "alloy").strip() or "alloy" + + +def _yandex_api_base() -> str: + return ( + os.getenv("AI_VOICE_TTS_YANDEX_API_BASE", "https://tts.api.ml.yandexcloud.kz").strip() + or "https://tts.api.ml.yandexcloud.kz" + ).rstrip("/") + + +def _yandex_api_key() -> str: + return os.getenv("AI_VOICE_TTS_YANDEX_API_KEY", "").strip() + + +def _yandex_iam_token() -> str: + return os.getenv("AI_VOICE_TTS_YANDEX_IAM_TOKEN", "").strip() + + +def _yandex_folder_id() -> str: + return os.getenv("AI_VOICE_TTS_YANDEX_FOLDER_ID", "").strip() + + +def _yandex_default_language() -> str: + return os.getenv("AI_VOICE_TTS_YANDEX_LANGUAGE", "ru-RU").strip() or "ru-RU" + + +def _normalize_yandex_language(language: str | None) -> str: + raw = str(language or "").strip() + if not raw: + return _yandex_default_language() + lowered = raw.lower() + if lowered == "ru": + return "ru-RU" + if lowered in {"kk", "kz"}: + return "kk-KZ" + return raw + + +def _yandex_voice_for_language(language: str | None) -> str: + normalized = _normalize_yandex_language(language).lower() + if normalized.startswith("kk"): + return os.getenv("AI_VOICE_TTS_YANDEX_KK_VOICE", "amira").strip() or "amira" + return os.getenv("AI_VOICE_TTS_YANDEX_VOICE", "jane").strip() or "jane" + + +def _yandex_role() -> str: + explicit = os.getenv("AI_VOICE_TTS_YANDEX_ROLE", "").strip() + if explicit: + return explicit + # Backward compatibility with the old v1-style env. + return os.getenv("AI_VOICE_TTS_YANDEX_EMOTION", "").strip() + + +def _yandex_speed() -> str: + raw = os.getenv("AI_VOICE_TTS_YANDEX_SPEED", "1.0").strip() + try: + value = float(raw) + except ValueError: + value = 1.0 + return f"{max(0.1, min(value, 3.0)):g}" + + +def _yandex_sample_rate_hz() -> int: + raw = os.getenv("AI_VOICE_TTS_YANDEX_SAMPLE_RATE_HZ", "8000").strip() + try: + value = int(raw) + except ValueError: + value = 8000 + if value < 8000 or value > 48000: + value = 8000 + return value + + +def _tts_cache_enabled() -> bool: + return _bool_env("AI_VOICE_TTS_CACHE_ENABLED", True) + + +def _tts_cache_dir() -> Path: + explicit = os.getenv("AI_VOICE_TTS_CACHE_DIR", "").strip() + if explicit: + return Path(explicit).expanduser() + data_dir = os.getenv("CC_DATA_DIR", "").strip() + if data_dir: + return Path(data_dir).expanduser() / "ai_voice_tts_cache" + local_data_dir = Path(".data_local") + if local_data_dir.exists(): + return local_data_dir / "ai_voice_tts_cache" + return Path(".data") / "ai_voice_tts_cache" + + +@dataclass(slots=True) +class TTSSynthesis: + text: str + audio_bytes: bytes = b"" + sample_rate_hz: int = 24000 + + +class TTSProvider: + name = "stub" + + def synthesize(self, text: str, *, language: str | None = None) -> TTSSynthesis: + del language + return TTSSynthesis(text=text, audio_bytes=b"", sample_rate_hz=8000) + + +class OpenAITTSProvider(TTSProvider): + name = "openai" + + def __init__(self) -> None: + self._api_base = _api_base() + self._api_key = _api_key() + self._timeout_seconds = _timeout_seconds() + self._model = _openai_tts_model() + self._voice = _openai_tts_voice() + self._cache_enabled = _tts_cache_enabled() + self._cache_dir = _tts_cache_dir() + self._cache_lock = Lock() + + def _cache_key(self, text: str, *, language: str | None) -> str: + payload = { + "provider": self.name, + "model": self._model, + "voice": self._voice, + "language": str(language or "").strip() or None, + "text": text, + } + encoded = json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8") + return hashlib.sha256(encoded).hexdigest() + + def _cache_paths(self, cache_key: str) -> tuple[Path, Path]: + prefix = self._cache_dir / cache_key[:2] / cache_key[2:4] + return prefix / f"{cache_key}.pcm", prefix / f"{cache_key}.json" + + def _load_cached_synthesis(self, text: str, *, language: str | None) -> TTSSynthesis | None: + if not self._cache_enabled: + return None + pcm_path, meta_path = self._cache_paths(self._cache_key(text, language=language)) + if not pcm_path.exists(): + return None + + try: + audio_bytes = pcm_path.read_bytes() + sample_rate_hz = 24000 + if meta_path.exists(): + metadata = json.loads(meta_path.read_text(encoding="utf-8")) + sample_rate_hz = max(int(metadata.get("sample_rate_hz") or 24000), 1) + except (OSError, ValueError, TypeError, json.JSONDecodeError): + return None + + return TTSSynthesis(text=text, audio_bytes=audio_bytes, sample_rate_hz=sample_rate_hz) + + def _write_cached_synthesis(self, synthesis: TTSSynthesis, *, language: str | None) -> None: + if not self._cache_enabled or not synthesis.audio_bytes: + return + + pcm_path, meta_path = self._cache_paths(self._cache_key(synthesis.text, language=language)) + pcm_path.parent.mkdir(parents=True, exist_ok=True) + metadata = { + "provider": self.name, + "model": self._model, + "voice": self._voice, + "language": str(language or "").strip() or None, + "sample_rate_hz": synthesis.sample_rate_hz, + "text": synthesis.text, + } + + pcm_tmp: str | None = None + meta_tmp: str | None = None + try: + with tempfile.NamedTemporaryFile(dir=pcm_path.parent, delete=False, suffix=".pcm.tmp") as handle: + handle.write(synthesis.audio_bytes) + pcm_tmp = handle.name + with tempfile.NamedTemporaryFile(dir=meta_path.parent, delete=False, suffix=".json.tmp", mode="w", encoding="utf-8") as handle: + json.dump(metadata, handle, ensure_ascii=False, sort_keys=True) + meta_tmp = handle.name + os.replace(pcm_tmp, pcm_path) + os.replace(meta_tmp, meta_path) + finally: + for temp_path in (pcm_tmp, meta_tmp): + if not temp_path: + continue + try: + if os.path.exists(temp_path): + os.remove(temp_path) + except OSError: + pass + + def synthesize(self, text: str, *, language: str | None = None) -> TTSSynthesis: + if not text: + return TTSSynthesis(text=text, audio_bytes=b"", sample_rate_hz=24000) + cached = self._load_cached_synthesis(text, language=language) + if cached is not None: + return cached + if not self._api_key: + raise RuntimeError("AI_API_KEY is required for OpenAI TTS") + + with httpx.Client(timeout=self._timeout_seconds) as client: + response = client.post( + f"{self._api_base}/audio/speech", + headers={ + "Authorization": f"Bearer {self._api_key}", + "Accept": "application/octet-stream", + }, + json={ + "model": self._model, + "voice": self._voice, + "input": text, + "response_format": "pcm", + }, + ) + response.raise_for_status() + synthesis = TTSSynthesis(text=text, audio_bytes=response.content, sample_rate_hz=24000) + with self._cache_lock: + cached = self._load_cached_synthesis(text, language=language) + if cached is not None: + return cached + self._write_cached_synthesis(synthesis, language=language) + return synthesis + + +class YandexTTSProvider(TTSProvider): + name = "yandex" + + def __init__(self) -> None: + self._api_base = _yandex_api_base() + self._api_key = _yandex_api_key() + self._iam_token = _yandex_iam_token() + self._folder_id = _yandex_folder_id() + self._timeout_seconds = _timeout_seconds() + self._speed = _yandex_speed() + self._role = _yandex_role() + self._sample_rate_hz = _yandex_sample_rate_hz() + self._cache_enabled = _tts_cache_enabled() + self._cache_dir = _tts_cache_dir() + self._cache_lock = Lock() + + def _voice(self, language: str | None) -> str: + return _yandex_voice_for_language(language) + + def _lang(self, language: str | None) -> str: + return _normalize_yandex_language(language) + + def _cache_key(self, text: str, *, language: str | None) -> str: + payload = { + "provider": self.name, + "voice": self._voice(language), + "language": self._lang(language), + "role": self._role or None, + "speed": self._speed, + "sample_rate_hz": self._sample_rate_hz, + "text": text, + } + encoded = json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8") + return hashlib.sha256(encoded).hexdigest() + + def _cache_paths(self, cache_key: str) -> tuple[Path, Path]: + prefix = self._cache_dir / cache_key[:2] / cache_key[2:4] + return prefix / f"{cache_key}.pcm", prefix / f"{cache_key}.json" + + def _load_cached_synthesis(self, text: str, *, language: str | None) -> TTSSynthesis | None: + if not self._cache_enabled: + return None + pcm_path, meta_path = self._cache_paths(self._cache_key(text, language=language)) + if not pcm_path.exists(): + return None + + try: + audio_bytes = pcm_path.read_bytes() + sample_rate_hz = self._sample_rate_hz + if meta_path.exists(): + metadata = json.loads(meta_path.read_text(encoding="utf-8")) + sample_rate_hz = max(int(metadata.get("sample_rate_hz") or self._sample_rate_hz), 1) + except (OSError, ValueError, TypeError, json.JSONDecodeError): + return None + + return TTSSynthesis(text=text, audio_bytes=audio_bytes, sample_rate_hz=sample_rate_hz) + + def _write_cached_synthesis(self, synthesis: TTSSynthesis, *, language: str | None) -> None: + if not self._cache_enabled or not synthesis.audio_bytes: + return + + pcm_path, meta_path = self._cache_paths(self._cache_key(synthesis.text, language=language)) + pcm_path.parent.mkdir(parents=True, exist_ok=True) + metadata = { + "provider": self.name, + "voice": self._voice(language), + "language": self._lang(language), + "role": self._role or None, + "speed": self._speed, + "sample_rate_hz": synthesis.sample_rate_hz, + "text": synthesis.text, + } + + pcm_tmp: str | None = None + meta_tmp: str | None = None + try: + with tempfile.NamedTemporaryFile(dir=pcm_path.parent, delete=False, suffix=".pcm.tmp") as handle: + handle.write(synthesis.audio_bytes) + pcm_tmp = handle.name + with tempfile.NamedTemporaryFile(dir=meta_path.parent, delete=False, suffix=".json.tmp", mode="w", encoding="utf-8") as handle: + json.dump(metadata, handle, ensure_ascii=False, sort_keys=True) + meta_tmp = handle.name + os.replace(pcm_tmp, pcm_path) + os.replace(meta_tmp, meta_path) + finally: + for temp_path in (pcm_tmp, meta_tmp): + if not temp_path: + continue + try: + if os.path.exists(temp_path): + os.remove(temp_path) + except OSError: + pass + + def synthesize(self, text: str, *, language: str | None = None) -> TTSSynthesis: + if not text: + return TTSSynthesis(text=text, audio_bytes=b"", sample_rate_hz=self._sample_rate_hz) + cached = self._load_cached_synthesis(text, language=language) + if cached is not None: + return cached + if not self._api_key and not self._iam_token: + raise RuntimeError( + "AI_VOICE_TTS_YANDEX_API_KEY or AI_VOICE_TTS_YANDEX_IAM_TOKEN is required for Yandex TTS" + ) + + headers = {"Accept": "application/json"} + if self._api_key: + headers["Authorization"] = f"Api-Key {self._api_key}" + else: + headers["Authorization"] = f"Bearer {self._iam_token}" + if self._folder_id: + headers["x-folder-id"] = self._folder_id + + hints: list[dict[str, object]] = [ + {"voice": self._voice(language)}, + {"speed": float(self._speed)}, + ] + if self._role: + hints.append({"role": self._role}) + + payload = { + "text": text, + "hints": hints, + "outputAudioSpec": { + "rawAudio": { + "audioEncoding": "LINEAR16_PCM", + "sampleRateHertz": self._sample_rate_hz, + } + }, + "loudnessNormalizationType": "LUFS", + } + + with httpx.Client(timeout=self._timeout_seconds) as client: + response = client.post( + f"{self._api_base}/tts/v3/utteranceSynthesis", + headers=headers, + json=payload, + ) + response.raise_for_status() + response_payload = response.json() + result = response_payload.get("result") + if isinstance(result, dict): + response_payload = result + audio_chunk = response_payload.get("audioChunk") + if not isinstance(audio_chunk, dict): + raise RuntimeError("Yandex TTS response is missing audioChunk") + audio_data = audio_chunk.get("data") + if not isinstance(audio_data, str) or not audio_data: + raise RuntimeError("Yandex TTS response is missing audioChunk.data") + try: + audio_bytes = base64.b64decode(audio_data) + except (ValueError, TypeError) as exc: + raise RuntimeError("Yandex TTS returned invalid base64 audio data") from exc + synthesis = TTSSynthesis(text=text, audio_bytes=audio_bytes, sample_rate_hz=self._sample_rate_hz) + with self._cache_lock: + cached = self._load_cached_synthesis(text, language=language) + if cached is not None: + return cached + self._write_cached_synthesis(synthesis, language=language) + return synthesis + + +def build_tts_provider(name: str) -> TTSProvider: + normalized = str(name or "stub").strip().lower() + if normalized == "openai": + return OpenAITTSProvider() + if normalized in {"yandex", "yandex_speechkit", "speechkit"}: + return YandexTTSProvider() + return TTSProvider() diff --git a/services/asterisk_bridge_service/ami.py b/services/asterisk_bridge_service/ami.py new file mode 100644 index 0000000..bf94d72 --- /dev/null +++ b/services/asterisk_bridge_service/ami.py @@ -0,0 +1,437 @@ +from __future__ import annotations + +from dataclasses import dataclass +import json +import socket +import sys +import time +from typing import Any + +from sqlalchemy import select + +from services.shared.core import new_id, utc_now_iso +from services.shared.sql_models import AsteriskCallLinkRow, AsteriskEventLogRow + + +def _bridge_app(): + return sys.modules["services.asterisk_bridge_service.app"] + + +def first_non_empty(*values: Any) -> str | None: + for value in values: + text = str(value or "").strip() + if text: + return text + return None + + +def extract_call_id(payload: dict[str, Any]) -> str | None: + return first_non_empty( + payload.get("CallID"), + payload.get("CallId"), + payload.get("Callid"), + payload.get("LinkedID"), + payload.get("LinkedId"), + payload.get("Linkedid"), + payload.get("UniqueID"), + payload.get("UniqueId"), + payload.get("Uniqueid"), + ) + + +def extract_linked_id(payload: dict[str, Any], call_id: str | None = None) -> str | None: + return first_non_empty( + payload.get("LinkedID"), + payload.get("LinkedId"), + payload.get("Linkedid"), + payload.get("CallID"), + payload.get("CallId"), + payload.get("Callid"), + call_id, + ) + + +def resolve_channel_from_payload(payload: dict[str, Any]) -> str | None: + return first_non_empty( + payload.get("Channel"), + payload.get("channel"), + payload.get("InboundChannel"), + payload.get("Inboundchannel"), + ) + + +def read_ami_frame(handle) -> dict[str, str] | None: + fields: dict[str, str] = {} + while True: + try: + raw = handle.readline() + except TimeoutError: + return {} + except socket.timeout: + return {} + if raw == b"": + return fields or None + line = raw.decode("utf-8", errors="replace").strip("\r\n") + if not line: + return fields or {} + if ":" not in line: + continue + key, value = line.split(":", 1) + fields[key.strip()] = value.strip() + + +def send_ami_action(handle, fields: dict[str, str]) -> None: + chunks = [f"{key}: {value}\r\n".encode("utf-8") for key, value in fields.items()] + chunks.append(b"\r\n") + handle.write(b"".join(chunks)) + handle.flush() + + +def ami_login(handle) -> None: + bridge = _bridge_app() + send_ami_action( + handle, + { + "Action": "Login", + "Username": bridge._ami_username(), + "Secret": bridge._ami_secret(), + "Events": "on", + }, + ) + while True: + frame = read_ami_frame(handle) + if frame is None: + raise RuntimeError("AMI connection closed during login") + if not frame: + continue + if frame.get("Response"): + if frame.get("Response", "").lower() != "success": + raise RuntimeError(frame.get("Message", "AMI login failed")) + return + + +def list_channels_via_coreshowchannels(*, call_id: str, linked_id: str | None) -> list[str] | None: + bridge = _bridge_app() + sock = None + handle = None + action_id = new_id("ami") + try: + sock = socket.create_connection((bridge._ami_host(), bridge._ami_port()), timeout=10) + sock.settimeout(bridge._callcontrol_action_timeout_seconds()) + handle = sock.makefile("rwb") + ami_login(handle) + send_ami_action( + handle, + { + "Action": "CoreShowChannels", + "ActionID": action_id, + }, + ) + + candidates: list[str] = [] + started_at = time.time() + while True: + if (time.time() - started_at) > bridge._callcontrol_action_timeout_seconds(): + break + frame = read_ami_frame(handle) + if frame is None: + break + if not frame: + continue + if frame.get("Event") == "CoreShowChannel": + uniqueid = first_non_empty(frame.get("Uniqueid"), frame.get("UniqueID")) + frame_linked = first_non_empty(frame.get("Linkedid"), frame.get("LinkedID")) + channel = first_non_empty(frame.get("Channel")) + if not channel: + continue + if uniqueid == call_id or (linked_id and frame_linked == linked_id) or frame_linked == call_id: + candidates.append(channel) + if frame.get("Event") == "CoreShowChannelsComplete": + break + + return candidates + except Exception: + return None + finally: + try: + if handle is not None: + handle.close() + except Exception: + pass + try: + if sock is not None: + sock.close() + except Exception: + pass + + +def resolve_channel_via_coreshowchannels(*, call_id: str, linked_id: str | None) -> str | None: + candidates = list_channels_via_coreshowchannels(call_id=call_id, linked_id=linked_id) + if not candidates: + return None + for channel in candidates: + if channel.startswith("PJSIP/"): + return channel + return candidates[0] + + +def extract_extension_from_channel(channel: str | None) -> str | None: + value = str(channel or "").strip() + if not value or "/" not in value: + return None + body = value.split("/", 1)[1] + if "-" in body: + body = body.split("-", 1)[0] + return body.strip() or None + + +def operator_channel_for_extension(channels: list[str], operator_extension: str | None) -> str | None: + prefix = str(operator_extension or "").strip() + if not prefix: + return None + needle = f"PJSIP/{prefix}-" + for channel in channels: + if channel.startswith(needle): + return channel + return None + + +@dataclass(slots=True) +class CallChannelSnapshot: + live_channels: list[str] + lookup_available: bool + operator_extension: str | None + cached_channel: str | None + operator_channel: str | None + connected_channel: str | None + started_channel: str | None + + +def latest_event_channel(session, *, call_id: str, event_name: str) -> str | None: + row = session.execute( + select(AsteriskEventLogRow) + .where(AsteriskEventLogRow.call_id == call_id) + .where(AsteriskEventLogRow.ami_event_name == event_name) + .order_by(AsteriskEventLogRow.id.desc()) + .limit(1) + ).scalar_one_or_none() + if row is None: + return None + try: + payload = json.loads(row.payload_json or "{}") + except Exception: + payload = {} + return resolve_channel_from_payload(payload) + + +def build_call_channel_snapshot( + session, + link: AsteriskCallLinkRow, + *, + operator_extension: str | None = None, +) -> CallChannelSnapshot: + bridge = _bridge_app() + normalized_operator_extension = str(operator_extension or link.operator_extension or "").strip() or None + resolved_live_channels = bridge._list_channels_via_coreshowchannels(call_id=link.call_id, linked_id=link.linked_id) + live_channels = resolved_live_channels or [] + return CallChannelSnapshot( + live_channels=live_channels, + lookup_available=resolved_live_channels is not None, + operator_extension=normalized_operator_extension, + cached_channel=str(link.channel_name or "").strip() or None, + operator_channel=operator_channel_for_extension(live_channels, normalized_operator_extension), + connected_channel=latest_event_channel(session, call_id=link.call_id, event_name=f"{bridge._ami_prefix()}OperatorConnected"), + started_channel=latest_event_channel(session, call_id=link.call_id, event_name=f"{bridge._ami_prefix()}CallStarted"), + ) + + +def candidate_channels_for_call( + session, + link: AsteriskCallLinkRow, + *, + operator_extension: str | None = None, + prefer_operator: bool = False, + snapshot: CallChannelSnapshot | None = None, +) -> list[str]: + candidates: list[str] = [] + + def _add(channel: str | None) -> None: + value = str(channel or "").strip() + if value and value not in candidates: + candidates.append(value) + + snapshot = snapshot or build_call_channel_snapshot(session, link, operator_extension=operator_extension) + operator_extension = snapshot.operator_extension + + if prefer_operator: + _add(snapshot.operator_channel) + if extract_extension_from_channel(snapshot.connected_channel) == operator_extension: + _add(snapshot.connected_channel) + + _add(snapshot.cached_channel) + _add(snapshot.operator_channel) + _add(snapshot.connected_channel) + _add(snapshot.started_channel) + for channel in snapshot.live_channels: + _add(channel) + return candidates + + +def resolve_channel_name( + session, + link: AsteriskCallLinkRow, + *, + prefer_operator: bool = False, + refresh: bool = False, + snapshot: CallChannelSnapshot | None = None, +) -> str | None: + bridge = _bridge_app() + snapshot = snapshot or build_call_channel_snapshot(session, link) + cached_channel = snapshot.cached_channel + live_channels = snapshot.live_channels + lookup_available = snapshot.lookup_available + operator_channel = snapshot.operator_channel + connected_channel = snapshot.connected_channel + + if not refresh and cached_channel and (not lookup_available or cached_channel in live_channels): + if prefer_operator and operator_channel: + cached_channel = operator_channel + if cached_channel != link.channel_name: + link.channel_name = cached_channel + link.updated_at = utc_now_iso() + return cached_channel + + connected_channel = latest_event_channel(session, call_id=link.call_id, event_name=f"{bridge._ami_prefix()}OperatorConnected") + if connected_channel and (not lookup_available or connected_channel in live_channels): + cached_channel = connected_channel + elif prefer_operator and operator_channel: + cached_channel = operator_channel + elif cached_channel and lookup_available and live_channels and cached_channel in live_channels: + pass + elif operator_channel: + cached_channel = operator_channel + else: + started_channel = latest_event_channel(session, call_id=link.call_id, event_name=f"{bridge._ami_prefix()}CallStarted") + if started_channel and (not lookup_available or started_channel in live_channels): + cached_channel = started_channel + elif lookup_available and live_channels: + cached_channel = bridge._resolve_channel_via_coreshowchannels(call_id=link.call_id, linked_id=link.linked_id) + + if cached_channel: + link.channel_name = cached_channel + link.updated_at = utc_now_iso() + return cached_channel + + +def ami_action(action: str, params: dict[str, Any]) -> dict[str, Any]: + bridge = _bridge_app() + if not (bridge._ami_host() and bridge._ami_username() and bridge._ami_secret()): + raise RuntimeError("AMI host/credentials are not configured") + + action_id = new_id("ami") + sock = None + handle = None + try: + sock = socket.create_connection((bridge._ami_host(), bridge._ami_port()), timeout=10) + sock.settimeout(bridge._callcontrol_action_timeout_seconds()) + handle = sock.makefile("rwb") + ami_login(handle) + + frame_fields = { + "Action": action, + "ActionID": action_id, + } + for key, value in params.items(): + if value is None: + continue + frame_fields[str(key)] = str(value) + send_ami_action(handle, frame_fields) + + started_at = time.time() + while True: + if (time.time() - started_at) > bridge._callcontrol_action_timeout_seconds(): + raise RuntimeError(f"AMI action timeout: {action}") + frame = read_ami_frame(handle) + if frame is None: + raise RuntimeError("AMI action connection closed") + if not frame: + continue + if frame.get("Response"): + if frame.get("ActionID") and frame.get("ActionID") != action_id: + continue + if frame.get("Response", "").lower() != "success": + raise RuntimeError(frame.get("Message", f"AMI {action} failed")) + return { + "action_id": action_id, + "response": frame, + } + finally: + try: + if handle is not None: + handle.close() + except Exception: + pass + try: + if sock is not None: + sock.close() + except Exception: + pass + + +def ami_loop(stop_event=None) -> None: + bridge = _bridge_app() + active_stop_event = stop_event or bridge._background_stop_event() + while not active_stop_event.is_set(): + if not bridge._bridge_enabled(): + bridge._STATE.set_connected(False) + if active_stop_event.wait(bridge._poll_interval()): + break + continue + + if not (bridge._ami_host() and bridge._ami_username() and bridge._ami_secret()): + bridge._STATE.set_connected(False) + bridge._STATE.set_error("AMI host/credentials are not configured") + if active_stop_event.wait(max(bridge._poll_interval(), 2)): + break + continue + + sock = None + handle = None + try: + sock = socket.create_connection((bridge._ami_host(), bridge._ami_port()), timeout=10) + sock.settimeout(10) + handle = sock.makefile("rwb") + ami_login(handle) + bridge._STATE.set_connected(True) + + while not active_stop_event.is_set(): + if bridge._STATE.take_reconnect(): + raise RuntimeError("Reconnect requested") + frame = read_ami_frame(handle) + if frame is None: + raise RuntimeError("AMI connection closed") + if not frame: + continue + if frame.get("Event") != "UserEvent": + continue + user_event = str(frame.get("UserEvent") or "").strip() + if not user_event.startswith(bridge._ami_prefix()): + continue + bridge._STATE.set_last_event() + bridge._record_ami_payload(frame) + except Exception as exc: + bridge._STATE.set_connected(False) + bridge._STATE.set_error(str(exc)) + if active_stop_event.wait(max(bridge._poll_interval(), 2)): + break + finally: + try: + if handle is not None: + handle.close() + except Exception: + pass + try: + if sock is not None: + sock.close() + except Exception: + pass diff --git a/services/asterisk_bridge_service/app.py b/services/asterisk_bridge_service/app.py new file mode 100644 index 0000000..1738226 --- /dev/null +++ b/services/asterisk_bridge_service/app.py @@ -0,0 +1,514 @@ +from __future__ import annotations + +import os +from pathlib import Path +import threading +import time +from typing import Any, Callable +from datetime import datetime, timezone + +import httpx +from fastapi import Depends, FastAPI + +from services.shared.core import Role, utc_now_iso +from services.shared.db import DATABASE_URL, engine +from services.shared.models import ( + AsteriskBridgeStatusOut, + AsteriskEventOut, + BrowserSoftphoneConfigOut, + HealthResponse, + VoiceAICallStateUpdateIn, + VoiceAIHandoffRequestIn, + VoiceAISummaryOut, + VoiceCallActionOut, + VoiceCallBlindTransferIn, + VoiceCallClaimIn, + VoiceLiveCallOut, +) +from services.shared.security import get_actor, issue_app_token, require_roles +from services.shared.sql_init import init_sql_schema +from services.asterisk_bridge_service import ami as bridge_ami +from services.asterisk_bridge_service import bridge_processing +from services.asterisk_bridge_service import call_control as bridge_call_control +from services.asterisk_bridge_service import config as bridge_config +from services.asterisk_bridge_service import http_client as bridge_http_client +from services.asterisk_bridge_service import ivr_fastagi as bridge_ivr_fastagi +from services.asterisk_bridge_service import presenters as bridge_presenters +from services.asterisk_bridge_service import recording_io as bridge_recording_io +from services.asterisk_bridge_service import reconcile as bridge_reconcile +from services.asterisk_bridge_service import routes as bridge_routes +from services.asterisk_bridge_service import runtime as bridge_runtime +from services.asterisk_bridge_service import voice_ai as bridge_voice_ai + +app = FastAPI(title="asterisk-bridge-service", version="1.0.0") + +init_sql_schema() + + +_bool_env = bridge_config.bool_env +_bridge_enabled = bridge_config.bridge_enabled +_ami_host = bridge_config.ami_host +_ami_port = bridge_config.ami_port +_ami_username = bridge_config.ami_username +_ami_secret = bridge_config.ami_secret +_ami_prefix = bridge_config.ami_prefix +_poll_interval = bridge_config.poll_interval +_recent_calls_window_seconds = bridge_config.recent_calls_window_seconds +_failed_retry_enabled = bridge_config.failed_retry_enabled +_failed_retry_interval_seconds = bridge_config.failed_retry_interval_seconds +_failed_retry_batch_size = bridge_config.failed_retry_batch_size +_reconcile_enabled = bridge_config.reconcile_enabled +_reconcile_stale_seconds = bridge_config.reconcile_stale_seconds +_reconcile_settle_seconds = bridge_config.reconcile_settle_seconds +_reconcile_scan_limit = bridge_config.reconcile_scan_limit +_forward_timeout_seconds = bridge_config.forward_timeout_seconds +_forward_max_attempts = bridge_config.forward_max_attempts +_forward_retry_backoff_seconds = bridge_config.forward_retry_backoff_seconds +_queue_map = bridge_config.queue_map +_sftp_enabled = bridge_config.sftp_enabled +_sftp_host = bridge_config.sftp_host +_sftp_port = bridge_config.sftp_port +_sftp_username = bridge_config.sftp_username +_sftp_password = bridge_config.sftp_password +_interaction_service_url = bridge_config.interaction_service_url +_voice_adapter_service_url = bridge_config.voice_adapter_service_url +_recording_service_url = bridge_config.recording_service_url +_ivr_service_url = bridge_config.ivr_service_url +_ai_voice_runtime_service_url = bridge_config.ai_voice_runtime_service_url +_ai_voice_enabled = bridge_config.ai_voice_enabled +_ai_voice_queue_config = bridge_config.ai_voice_queue_config +_ai_voice_config_for_queue = bridge_config.ai_voice_config_for_queue +_ivr_fastagi_enabled = bridge_config.ivr_fastagi_enabled +_ivr_fastagi_host = bridge_config.ivr_fastagi_host +_ivr_fastagi_port = bridge_config.ivr_fastagi_port +_ivr_dtmf_timeout_seconds = bridge_config.ivr_dtmf_timeout_seconds +_ivr_max_no_input_retries = bridge_config.ivr_max_no_input_retries +_ivr_max_invalid_retries = bridge_config.ivr_max_invalid_retries +_ivr_call_link_wait_seconds = bridge_config.ivr_call_link_wait_seconds +_callcontrol_enabled = bridge_config.callcontrol_enabled +_callcontrol_action_timeout_seconds = bridge_config.callcontrol_action_timeout_seconds +_webrtc_enabled = bridge_config.webrtc_enabled +_webrtc_ws_url = bridge_config.webrtc_ws_url +_webrtc_ice_servers = bridge_config.webrtc_ice_servers +_operator_extension_map = bridge_config.operator_extension_map +_user_for_operator_extension = bridge_config.user_for_operator_extension +_browser_sip_map = bridge_config.browser_sip_map +_default_browser_sip_domain = bridge_config.default_browser_sip_domain +_browser_softphone_config_for_actor = bridge_config.browser_softphone_config_for_actor +_transfer_target_map = bridge_config.transfer_target_map +_claim_context = bridge_config.claim_context +_transfer_context = bridge_config.transfer_context +_bridge_auth_mode = bridge_config.bridge_auth_mode +_bridge_auth_fallback_legacy = bridge_config.bridge_auth_fallback_legacy +_bridge_auth_user = bridge_config.bridge_auth_user +_bridge_auth_role = bridge_config.bridge_auth_role +_bridge_auth_subject = bridge_config.bridge_auth_subject +_bridge_auth_token_ttl_seconds = bridge_config.bridge_auth_token_ttl_seconds +_ai_voice_runtime_trusted_subjects = bridge_config.ai_voice_runtime_trusted_subjects + + +def _legacy_headers() -> dict[str, str]: + return bridge_http_client.legacy_headers( + bridge_auth_user=_bridge_auth_user(), + bridge_auth_role=_bridge_auth_role(), + ) + + +def _bearer_headers() -> dict[str, str]: + return bridge_http_client.bearer_headers( + issue_token=lambda: issue_app_token( + subject=_bridge_auth_subject(), + username=_bridge_auth_user(), + role=_bridge_auth_role(), + auth_source="service", + provider="asterisk-bridge", + ttl_seconds=_bridge_auth_token_ttl_seconds(), + ) + ) + + +def _request_with_bridge_auth( + client: httpx.Client, + *, + method: str, + url: str, + retry_reset: Callable[[], None] | None = None, + **kwargs: Any, +) -> httpx.Response: + return bridge_http_client.request_with_bridge_auth( + client=client, + httpx_module=httpx, + method=method, + url=url, + bridge_auth_mode=_bridge_auth_mode(), + bridge_auth_fallback_legacy=_bridge_auth_fallback_legacy(), + legacy_headers_payload=_legacy_headers(), + bearer_headers_payload=_bearer_headers(), + retry_reset=retry_reset, + **kwargs, + ) + + +def _request_json( + method: str, + url: str, + payload: dict[str, Any], + *, + timeout_seconds: float | None = None, + max_attempts: int | None = None, + retry_backoff_seconds: float | None = None, +) -> dict[str, Any]: + return bridge_http_client.request_json( + httpx_module=httpx, + send_request=_request_with_bridge_auth, + method=method, + url=url, + payload=payload, + timeout_seconds=timeout_seconds, + max_attempts=max_attempts, + retry_backoff_seconds=retry_backoff_seconds, + forward_timeout_seconds=_forward_timeout_seconds, + forward_max_attempts=_forward_max_attempts, + forward_retry_backoff_seconds=_forward_retry_backoff_seconds, + sleep=time.sleep, + ) + + +def _post_json(url: str, payload: dict[str, Any], **kwargs: Any) -> dict[str, Any]: + return _request_json("POST", url, payload, **kwargs) + + +def _patch_json(url: str, payload: dict[str, Any], **kwargs: Any) -> dict[str, Any]: + return _request_json("PATCH", url, payload, **kwargs) + + +_BridgeState = bridge_runtime.BridgeState +_STATE = _BridgeState() +_BRIDGE_SINGLETON_LOCK_KEY = 417_352_001 +_BRIDGE_SINGLETON_LOCK_MESSAGE = ( + "Asterisk bridge background workers require singleton deployment " + "(replicas=1, workers=1)" +) +_BRIDGE_SINGLETON_DB_CONNECTION = None +_BRIDGE_SINGLETON_FILE_HANDLE = None + + +def _bridge_singleton_lock_path() -> Path: + return bridge_runtime.bridge_singleton_lock_path( + database_url=DATABASE_URL, + cc_data_dir=os.getenv("CC_DATA_DIR", ".data"), + ) + + +def _try_lock_file(handle) -> bool: + return bridge_runtime.try_lock_file(handle) + + +def _unlock_file(handle) -> None: + bridge_runtime.unlock_file(handle) + + +def _try_acquire_bridge_singleton_guard() -> bool: + global _BRIDGE_SINGLETON_DB_CONNECTION, _BRIDGE_SINGLETON_FILE_HANDLE + + acquired = bridge_runtime.try_acquire_bridge_singleton_guard( + database_url=DATABASE_URL, + engine=engine, + lock_key=_BRIDGE_SINGLETON_LOCK_KEY, + cc_data_dir=os.getenv("CC_DATA_DIR", ".data"), + ) + _BRIDGE_SINGLETON_DB_CONNECTION = bridge_runtime.bridge_singleton_db_connection() + _BRIDGE_SINGLETON_FILE_HANDLE = bridge_runtime.bridge_singleton_file_handle() + return acquired + + +def _ensure_bridge_singleton_guard() -> None: + if not _bridge_enabled(): + return + if not _try_acquire_bridge_singleton_guard(): + raise RuntimeError(_BRIDGE_SINGLETON_LOCK_MESSAGE) + + +def _release_bridge_singleton_guard() -> None: + global _BRIDGE_SINGLETON_DB_CONNECTION, _BRIDGE_SINGLETON_FILE_HANDLE + + bridge_runtime.release_bridge_singleton_guard(lock_key=_BRIDGE_SINGLETON_LOCK_KEY) + _BRIDGE_SINGLETON_DB_CONNECTION = None + _BRIDGE_SINGLETON_FILE_HANDLE = None + + +def _background_threads_alive() -> list[threading.Thread]: + return bridge_runtime.background_threads_alive() + + +def _background_stop_event() -> threading.Event: + return bridge_runtime.background_stop_event() + + +def _background_shutdown_timeout_seconds() -> float: + return bridge_runtime.background_shutdown_timeout_seconds( + poll_interval=_poll_interval(), + failed_retry_interval_seconds=_failed_retry_interval_seconds(), + ) + + +def _start_background_threads() -> None: + try: + extra_loops: list[tuple[str, Callable[..., Any]]] = [] + if _ivr_fastagi_enabled(): + extra_loops.append(("asterisk-ivr-fastagi-loop", _ivr_fastagi_loop)) + bridge_runtime.start_background_threads( + ami_loop=_ami_loop, + failed_retry_loop=_failed_retry_loop, + ensure_guard=_ensure_bridge_singleton_guard, + extra_loops=extra_loops, + ) + except Exception: + _release_bridge_singleton_guard() + raise + + +def _stop_background_threads() -> None: + bridge_runtime.stop_background_threads( + state=_STATE, + release_guard=_release_bridge_singleton_guard, + shutdown_timeout_seconds=_background_shutdown_timeout_seconds(), + ) + + +# Final compatibility re-exports. Keep these in app.py so tests and extracted +# modules still share the same compatibility surface during the transition. +_to_out = bridge_presenters.to_out +_action_to_out = bridge_presenters.action_to_out +_safe_json_loads = bridge_presenters.safe_json_loads +_latest_voice_event_row = bridge_presenters.latest_voice_event_row +_latest_successful_action_row = bridge_presenters.latest_successful_action_row +_latest_iso = bridge_presenters.latest_iso +_hangup_cause_for_call = bridge_presenters.hangup_cause_for_call +_terminal_action_for_call = bridge_presenters.terminal_action_for_call +_live_call_to_out = bridge_presenters.live_call_to_out + +_append_interaction_timeline = bridge_voice_ai.append_interaction_timeline +_start_voice_ai_session = bridge_voice_ai.start_voice_ai_session +_notify_voice_ai_telephony_event = bridge_voice_ai.notify_voice_ai_telephony_event +_register_voice_ai_media_bridge = bridge_voice_ai.register_voice_ai_media_bridge +_trusted_voice_runtime_actor = bridge_voice_ai.trusted_voice_runtime_actor +_assert_trusted_voice_runtime_actor = bridge_voice_ai.assert_trusted_voice_runtime_actor +_queue_code_for_queue_id = bridge_voice_ai._queue_code_for_queue_id +_request_voice_ai_handoff = bridge_voice_ai.request_handoff +_update_voice_ai_call_state = bridge_voice_ai.update_call_ai_state +_voice_ai_summary_for_call = bridge_voice_ai.voice_ai_summary_for_call + +_first_non_empty = bridge_ami.first_non_empty +_extract_call_id = bridge_ami.extract_call_id +_extract_linked_id = bridge_ami.extract_linked_id +_resolve_channel_from_payload = bridge_ami.resolve_channel_from_payload +_list_channels_via_coreshowchannels = bridge_ami.list_channels_via_coreshowchannels +_resolve_channel_via_coreshowchannels = bridge_ami.resolve_channel_via_coreshowchannels +_extract_extension_from_channel = bridge_ami.extract_extension_from_channel +_operator_channel_for_extension = bridge_ami.operator_channel_for_extension +_CallChannelSnapshot = bridge_ami.CallChannelSnapshot +_latest_event_channel = bridge_ami.latest_event_channel +_build_call_channel_snapshot = bridge_ami.build_call_channel_snapshot +_candidate_channels_for_call = bridge_ami.candidate_channels_for_call +_resolve_channel_name = bridge_ami.resolve_channel_name +_ami_action = bridge_ami.ami_action +_read_ami_frame = bridge_ami.read_ami_frame +_send_ami_action = bridge_ami.send_ami_action +_ami_login = bridge_ami.ami_login +_ami_loop = bridge_ami.ami_loop + +_fetch_recording_file = bridge_recording_io.fetch_recording_file +_recording_search_directories = bridge_recording_io.recording_search_directories +_guess_mime_type = bridge_recording_io.guess_mime_type +_find_remote_recording_for_call = bridge_recording_io.find_remote_recording_for_call + +_AgiRequest = bridge_ivr_fastagi.AgiRequest +_AgiHangup = bridge_ivr_fastagi.AgiHangup +_SimpleAgiChannel = bridge_ivr_fastagi.SimpleAgiChannel +_parse_agi_request = bridge_ivr_fastagi.parse_agi_request +_run_ivr_fastagi_call = bridge_ivr_fastagi.run_ivr_call +_ivr_fastagi_loop = bridge_ivr_fastagi.fastagi_loop + +_parse_iso = bridge_reconcile.parse_iso +_has_voice_event = bridge_reconcile.has_voice_event +_has_recording = bridge_reconcile.has_recording +_latest_recording_for_call = bridge_reconcile.latest_recording_for_call +_reconcile_stale_calls_once = bridge_reconcile.reconcile_stale_calls_once + +_create_bridge_log = bridge_processing.create_bridge_log +_mark_log_forwarded = bridge_processing.mark_log_forwarded +_mark_log_failed = bridge_processing.mark_log_failed +_stable_source_event_id = bridge_processing.stable_source_event_id +_bridge_source_event_id = bridge_processing.bridge_source_event_id +_reconcile_source_event_id = bridge_processing.reconcile_source_event_id +_load_bridge_event_row = bridge_processing.load_bridge_event_row +_claim_bridge_event_for_processing = bridge_processing.claim_bridge_event_for_processing +_process_claimed_bridge_event = bridge_processing.process_claimed_bridge_event +_create_action_log = bridge_processing.create_action_log +_set_action_result = bridge_processing.set_action_result +_find_call_link = bridge_processing.find_call_link +_find_interaction_in_db_by_call_id = bridge_processing.find_interaction_in_db_by_call_id +_recover_call_link_from_started_event = bridge_processing.recover_call_link_from_started_event +_find_forwarded_log = bridge_processing.find_forwarded_log +_create_interaction = bridge_processing.create_interaction +_find_interaction_by_call_id = bridge_processing.find_interaction_by_call_id +_resolve_started_interaction_id = bridge_processing.resolve_started_interaction_id +_callcontrol_side_effect_timeout_seconds = bridge_processing.callcontrol_side_effect_timeout_seconds +_callcontrol_side_effect_max_attempts = bridge_processing.callcontrol_side_effect_max_attempts +_emit_voice_event = bridge_processing.emit_voice_event +_assign_interaction = bridge_processing.assign_interaction +_upload_recording = bridge_processing.upload_recording +_normalize_duration = bridge_processing.normalize_duration +_process_call_started = bridge_processing.process_call_started +_process_audio_bridge_requested = bridge_processing.process_audio_bridge_requested +_process_audio_bridge_ended = bridge_processing.process_audio_bridge_ended +_process_call_ended = bridge_processing.process_call_ended +_process_operator_connected = bridge_processing.process_operator_connected +_process_recording_ready = bridge_processing.process_recording_ready +_process_bridge_row = bridge_processing.process_bridge_row +_record_ami_payload = bridge_processing.record_ami_payload +_retry_failed_events_once = bridge_processing.retry_failed_events_once +_failed_retry_loop = bridge_processing.failed_retry_loop + +_load_live_call_or_404 = bridge_call_control.load_live_call_or_404 +_call_is_ended = bridge_call_control.call_is_ended +_resolve_operator_extension = bridge_call_control.resolve_operator_extension +_resolve_transfer_extension = bridge_call_control.resolve_transfer_extension +_assert_callcontrol_enabled = bridge_call_control.assert_callcontrol_enabled +_assert_call_control_permissions = bridge_call_control.assert_call_control_permissions + + +@app.get("/asterisk/live-calls", response_model=list[VoiceLiveCallOut]) +def list_live_calls( + include_ended: bool = False, + limit: int = 100, + _: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)), +) -> list[VoiceLiveCallOut]: + return bridge_routes.list_live_calls(include_ended=include_ended, limit=limit) + + +@app.get("/asterisk/recent-calls", response_model=list[VoiceLiveCallOut]) +def list_recent_calls( + limit: int = 20, + _: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)), +) -> list[VoiceLiveCallOut]: + return bridge_routes.list_recent_calls(limit=limit) + + +@app.post("/asterisk/live-calls/{call_id}/claim", response_model=VoiceLiveCallOut) +def claim_live_call( + call_id: str, + body: VoiceCallClaimIn, + actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)), +) -> VoiceLiveCallOut: + return bridge_routes.claim_live_call(call_id=call_id, body=body, actor=actor) + + +@app.post("/asterisk/live-calls/{call_id}/hangup", response_model=VoiceLiveCallOut) +def hangup_live_call( + call_id: str, + actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)), +) -> VoiceLiveCallOut: + return bridge_routes.hangup_live_call(call_id=call_id, actor=actor) + + +@app.post("/asterisk/live-calls/{call_id}/blind-transfer", response_model=VoiceLiveCallOut) +def blind_transfer_live_call( + call_id: str, + body: VoiceCallBlindTransferIn, + actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)), +) -> VoiceLiveCallOut: + return bridge_routes.blind_transfer_live_call(call_id=call_id, body=body, actor=actor) + + +@app.get("/asterisk/live-calls/{call_id}/actions", response_model=list[VoiceCallActionOut]) +def list_live_call_actions( + call_id: str, + limit: int = 100, + actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)), +) -> list[VoiceCallActionOut]: + return bridge_routes.list_live_call_actions(call_id=call_id, limit=limit, actor=actor) + + +@app.get("/asterisk/live-calls/{call_id}/ai-summary", response_model=VoiceAISummaryOut | None) +def get_live_call_ai_summary( + call_id: str, + _: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)), +) -> VoiceAISummaryOut | None: + return bridge_routes.get_live_call_ai_summary(call_id) + + +@app.get("/health", response_model=HealthResponse) +def health() -> HealthResponse: + return bridge_routes.health() + + +@app.get("/asterisk/status", response_model=AsteriskBridgeStatusOut) +def asterisk_status(_: dict = Depends(require_roles(Role.ADMIN))) -> AsteriskBridgeStatusOut: + return bridge_routes.asterisk_status() + + +@app.get("/asterisk/browser-softphone/config", response_model=BrowserSoftphoneConfigOut) +def browser_softphone_config( + actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR, Role.ANALYST)), +) -> BrowserSoftphoneConfigOut: + return bridge_routes.browser_softphone_config(actor) + + +@app.get("/asterisk/events", response_model=list[AsteriskEventOut]) +def list_asterisk_events( + status: str | None = None, + limit: int = 100, + _: dict = Depends(require_roles(Role.ADMIN)), +) -> list[AsteriskEventOut]: + return bridge_routes.list_asterisk_events(status=status, limit=limit) + + +@app.get("/asterisk/events/{bridge_event_id}", response_model=AsteriskEventOut) +def get_asterisk_event( + bridge_event_id: str, + _: dict = Depends(require_roles(Role.ADMIN)), +) -> AsteriskEventOut: + return bridge_routes.get_asterisk_event(bridge_event_id) + + +@app.post("/asterisk/events/{bridge_event_id}/retry", response_model=AsteriskEventOut) +def retry_asterisk_event( + bridge_event_id: str, + _: dict = Depends(require_roles(Role.ADMIN)), +) -> AsteriskEventOut: + return bridge_routes.retry_asterisk_event(bridge_event_id) + + +@app.post("/internal/voice-ai/calls/{call_id}/handoff", response_model=VoiceLiveCallOut) +def voice_ai_handoff_call( + call_id: str, + body: VoiceAIHandoffRequestIn, + actor: dict = Depends(get_actor), +) -> VoiceLiveCallOut: + return bridge_routes.voice_ai_handoff_call(call_id=call_id, body=body, actor=actor) + + +@app.post("/internal/voice-ai/calls/{call_id}/state", response_model=VoiceLiveCallOut) +def voice_ai_update_call_state( + call_id: str, + body: VoiceAICallStateUpdateIn, + actor: dict = Depends(get_actor), +) -> VoiceLiveCallOut: + return bridge_routes.voice_ai_update_call_state(call_id=call_id, body=body, actor=actor) + + +@app.post("/asterisk/reconnect", response_model=AsteriskBridgeStatusOut) +def reconnect_asterisk(_: dict = Depends(require_roles(Role.ADMIN))) -> AsteriskBridgeStatusOut: + return bridge_routes.reconnect_asterisk() + + +@app.on_event("startup") +def _startup() -> None: + _start_background_threads() + + +@app.on_event("shutdown") +def _shutdown() -> None: + _stop_background_threads() diff --git a/services/asterisk_bridge_service/bridge_processing.py b/services/asterisk_bridge_service/bridge_processing.py new file mode 100644 index 0000000..1fc7229 --- /dev/null +++ b/services/asterisk_bridge_service/bridge_processing.py @@ -0,0 +1,1264 @@ +from __future__ import annotations + +from datetime import datetime, timezone +import hashlib +import json +import logging +from pathlib import Path +import threading +from typing import Any + +import httpx +from sqlalchemy import select, update + +from services.shared.core import new_id, utc_now_iso +from services.shared.db import get_session +from services.shared.reporting_facts import upsert_reporting_interaction_fact +from services.shared.sql_models import ( + AsteriskCallActionLogRow, + AsteriskCallLinkRow, + AsteriskEventLogRow, + Interaction, + VoiceAISessionRow, +) + + +logger = logging.getLogger("uvicorn.error") + + +def _bridge_app(): + import services.asterisk_bridge_service.app as bridge_app + + return bridge_app + + +def _parse_iso_timestamp(raw: str | None) -> datetime | None: + if not raw: + return None + try: + value = datetime.fromisoformat(raw.replace("Z", "+00:00")) + except ValueError: + return None + if value.tzinfo is None: + value = value.replace(tzinfo=timezone.utc) + return value.astimezone(timezone.utc) + + +def _seconds_between(start_ts: str | None, end_ts: str | None) -> int | None: + start = _parse_iso_timestamp(start_ts) + end = _parse_iso_timestamp(end_ts) + if not start or not end: + return None + return max(int((end - start).total_seconds()), 0) + + +def _upsert_voice_reporting_fact( + session, + link: AsteriskCallLinkRow, + *, + status: str, + answered: bool | None = None, + abandoned: bool | None = None, + wait_seconds: int | None = None, + handle_seconds: int | None = None, + closed_at: str | None = None, +) -> None: + interaction_id = str(link.interaction_id or "").strip() + if not interaction_id: + return + payload: dict[str, Any] = { + "interaction_id": interaction_id, + "channel": "voice", + "queue_id": link.queue_id, + "agent_id": link.claimed_by_user, + "status": status, + "created_at": link.started_at, + "source": "asterisk-bridge", + } + if answered is not None: + payload["answered"] = answered + if abandoned is not None: + payload["abandoned"] = abandoned + if wait_seconds is not None: + payload["wait_seconds"] = wait_seconds + if handle_seconds is not None: + payload["handle_seconds"] = handle_seconds + if closed_at is not None: + payload["closed_at"] = closed_at + upsert_reporting_interaction_fact(session, payload) + + +def create_bridge_log( + session, + *, + ami_event_name: str, + call_id: str, + linked_id: str | None, + payload: dict[str, Any], +) -> AsteriskEventLogRow: + now = utc_now_iso() + row = AsteriskEventLogRow( + bridge_event_id=new_id("abe"), + ami_event_name=ami_event_name, + call_id=call_id, + linked_id=linked_id, + interaction_id=None, + recording_id=None, + forward_status="received", + payload_json=json.dumps(payload, ensure_ascii=False), + last_error=None, + created_at=now, + updated_at=now, + ) + session.add(row) + session.flush() + return row + + +def mark_log_forwarded( + session, + row: AsteriskEventLogRow, + *, + interaction_id: str | None = None, + recording_id: str | None = None, +) -> None: + row.interaction_id = interaction_id or row.interaction_id + row.recording_id = recording_id or row.recording_id + row.forward_status = "forwarded" + row.last_error = None + row.updated_at = utc_now_iso() + session.flush() + + +def mark_log_failed(session, row: AsteriskEventLogRow, message: str) -> None: + row.forward_status = "failed" + row.last_error = message[:4000] + row.updated_at = utc_now_iso() + session.flush() + + +def stable_source_event_id(*parts: str) -> str: + raw = ":".join(str(part).strip() for part in parts if str(part).strip()) + if len(raw) <= 64: + return raw + digest = hashlib.sha1(raw.encode("utf-8")).hexdigest()[:16] + return f"{raw[:47]}:{digest}" + + +def bridge_source_event_id(row: AsteriskEventLogRow, effect: str) -> str: + return stable_source_event_id(row.bridge_event_id, effect) + + +def reconcile_source_event_id(call_id: str, effect: str) -> str: + return stable_source_event_id("reconcile", call_id, effect) + + +def load_bridge_event_row(session, bridge_event_id: str) -> AsteriskEventLogRow | None: + return session.execute( + select(AsteriskEventLogRow).where(AsteriskEventLogRow.bridge_event_id == bridge_event_id) + ).scalar_one_or_none() + + +def claim_bridge_event_for_processing( + bridge_event_id: str, + *, + allowed_statuses: tuple[str, ...] = ("received", "failed"), +) -> bool: + if not allowed_statuses: + return False + + session = get_session() + try: + result = session.execute( + update(AsteriskEventLogRow) + .where(AsteriskEventLogRow.bridge_event_id == bridge_event_id) + .where(AsteriskEventLogRow.forward_status.in_(allowed_statuses)) + .values( + forward_status="processing", + last_error=None, + updated_at=utc_now_iso(), + ) + ) + session.commit() + return int(result.rowcount or 0) > 0 + finally: + session.close() + + +def process_claimed_bridge_event(bridge_event_id: str) -> AsteriskEventLogRow | None: + bridge = _bridge_app() + session = get_session() + try: + row = bridge._load_bridge_event_row(session, bridge_event_id) + if row is None: + return None + if row.forward_status != "processing": + return row + try: + bridge._process_bridge_row(session, row) + session.commit() + logger.warning( + "bridge.processed event=%s call_id=%s final_status=%s", + row.ami_event_name, + row.call_id, + row.forward_status, + ) + except Exception as exc: + session.rollback() + row = bridge._load_bridge_event_row(session, bridge_event_id) + if row is None: + return None + bridge._mark_log_failed(session, row, str(exc)) + session.commit() + logger.warning( + "bridge.process_failed event=%s call_id=%s error=%s", + row.ami_event_name, + row.call_id, + str(exc)[:500], + ) + session.refresh(row) + return row + finally: + session.close() + + +def create_action_log( + session, + *, + call_id: str, + interaction_id: str | None, + action_type: str, + actor_user: str, + actor_role: str, + request_payload: dict[str, Any], +) -> AsteriskCallActionLogRow: + row = AsteriskCallActionLogRow( + action_id=new_id("aca"), + call_id=call_id, + interaction_id=interaction_id, + action_type=action_type, + actor_user=actor_user, + actor_role=actor_role, + request_json=json.dumps(request_payload, ensure_ascii=False), + result_status="ok", + ami_action_id=None, + error=None, + created_at=utc_now_iso(), + ) + session.add(row) + return row + + +def set_action_result( + session, + row: AsteriskCallActionLogRow, + *, + result_status: str, + ami_action_id: str | None = None, + error: str | None = None, +) -> None: + row.result_status = result_status + row.ami_action_id = ami_action_id + row.error = (error or "")[:4000] or None + + +def find_call_link(session, call_id: str) -> AsteriskCallLinkRow | None: + return session.execute( + select(AsteriskCallLinkRow).where(AsteriskCallLinkRow.call_id == call_id) + ).scalar_one_or_none() + + +def find_voice_ai_session_by_call_id(session, call_id: str) -> VoiceAISessionRow | None: + return session.execute( + select(VoiceAISessionRow) + .where(VoiceAISessionRow.call_id == call_id) + .order_by(VoiceAISessionRow.id.desc()) + .limit(1) + ).scalar_one_or_none() + + +def find_interaction_in_db_by_call_id(session, call_id: str) -> Interaction | None: + marker = f"[{call_id}]" + return session.execute( + select(Interaction) + .where(Interaction.subject.like(f"%{marker}%")) + .order_by(Interaction.id.desc()) + .limit(1) + ).scalars().first() + + +def recover_call_link_from_started_event( + session, + *, + call_id: str, + exclude_bridge_event_id: str, +) -> AsteriskCallLinkRow | None: + bridge = _bridge_app() + existing = bridge._find_call_link(session, call_id) + if existing: + return existing + + started_events = session.execute( + select(AsteriskEventLogRow) + .where(AsteriskEventLogRow.ami_event_name == f"{bridge._ami_prefix()}CallStarted") + .where(AsteriskEventLogRow.call_id == call_id) + .order_by(AsteriskEventLogRow.id.desc()) + ).scalars().all() + + for started in started_events: + if started.bridge_event_id == exclude_bridge_event_id: + continue + if started.forward_status == "forwarded": + return bridge._find_call_link(session, call_id) + try: + bridge._process_bridge_row(session, started) + except Exception as exc: + bridge._mark_log_failed(session, started, str(exc)) + recovered = bridge._find_call_link(session, call_id) + if recovered: + return recovered + return None + + +def find_forwarded_log( + session, + *, + event_name: str, + call_id: str, + exclude_bridge_event_id: str, +) -> AsteriskEventLogRow | None: + rows = session.execute( + select(AsteriskEventLogRow) + .where(AsteriskEventLogRow.ami_event_name == event_name) + .where(AsteriskEventLogRow.call_id == call_id) + .where(AsteriskEventLogRow.forward_status == "forwarded") + .order_by(AsteriskEventLogRow.id.desc()) + ).scalars().all() + for row in rows: + if row.bridge_event_id != exclude_bridge_event_id: + return row + return None + + +def create_interaction(*, caller_number: str | None, queue_id: str, call_id: str) -> dict[str, Any]: + bridge = _bridge_app() + subject_value = caller_number.strip() if caller_number else "unknown" + return bridge._post_json( + f"{bridge._interaction_service_url()}/interactions", + { + "channel": "voice", + "subject": f"Inbound call {subject_value} [{call_id}]", + "queue_id": queue_id, + "priority": 3, + }, + ) + + +def find_interaction_by_call_id(call_id: str) -> dict[str, Any] | None: + bridge = _bridge_app() + with httpx.Client(timeout=bridge._forward_timeout_seconds(45.0)) as client: + response = bridge._request_with_bridge_auth( + client, + method="GET", + url=f"{bridge._interaction_service_url()}/interactions", + ) + payload = response.json() + if not isinstance(payload, list): + return None + marker = f"[{call_id}]" + for item in payload: + if not isinstance(item, dict): + continue + subject = str(item.get("subject") or "") + if marker in subject: + return item + return None + + +def resolve_started_interaction_id( + session, + *, + call_id: str, + queue_id: str, + caller_number: str | None, +) -> tuple[AsteriskCallLinkRow | None, str]: + bridge = _bridge_app() + existing_link = bridge._find_call_link(session, call_id) + interaction_id = None + if existing_link: + interaction_id = str(existing_link.interaction_id or "").strip() or None + if interaction_id: + return existing_link, interaction_id + + recovered_interaction = bridge._find_interaction_in_db_by_call_id(session, call_id) + if recovered_interaction and str(recovered_interaction.interaction_id or "").strip(): + return existing_link, str(recovered_interaction.interaction_id).strip() + + create_error: Exception | None = None + try: + created = bridge._create_interaction( + caller_number=caller_number, + queue_id=queue_id, + call_id=call_id, + ) + created_interaction_id = str((created or {}).get("interaction_id") or "").strip() + if created_interaction_id: + return existing_link, created_interaction_id + create_error = RuntimeError("Interaction service did not return interaction_id") + except Exception as exc: + create_error = exc + + recovered_interaction = bridge._find_interaction_in_db_by_call_id(session, call_id) + if recovered_interaction and str(recovered_interaction.interaction_id or "").strip(): + return existing_link, str(recovered_interaction.interaction_id).strip() + + recovered_payload = bridge._find_interaction_by_call_id(call_id) + recovered_interaction_id = str((recovered_payload or {}).get("interaction_id") or "").strip() + if recovered_interaction_id: + return existing_link, recovered_interaction_id + + if create_error is not None: + raise create_error + raise RuntimeError("Unable to resolve interaction_id for started call") + + +def callcontrol_side_effect_timeout_seconds() -> float: + bridge = _bridge_app() + return min(bridge._forward_timeout_seconds(45.0), 2.5) + + +def callcontrol_side_effect_max_attempts() -> int: + return 1 + + +def emit_voice_event( + *, + event_type: str, + call_id: str, + interaction_id: str | None, + source_event_id: str | None = None, + payload: dict[str, Any], +) -> dict[str, Any]: + bridge = _bridge_app() + return bridge._post_json( + f"{bridge._voice_adapter_service_url()}/integrations/voice/events", + { + "event_type": event_type, + "call_id": call_id, + "interaction_id": interaction_id, + "source_event_id": source_event_id, + "payload": payload, + }, + ) + + +def assign_interaction(*, interaction_id: str, assignee: str) -> dict[str, Any]: + bridge = _bridge_app() + return bridge._patch_json( + f"{bridge._interaction_service_url()}/interactions/{interaction_id}/assign", + {"assignee": assignee}, + timeout_seconds=bridge._callcontrol_side_effect_timeout_seconds(), + max_attempts=bridge._callcontrol_side_effect_max_attempts(), + retry_backoff_seconds=0.0, + ) + + +def upload_recording( + *, + local_path: Path, + call_id: str, + interaction_id: str | None, + source_event_id: str | None, + file_name: str, + mime_type: str | None, + duration_seconds: int | None, +) -> dict[str, Any]: + bridge = _bridge_app() + data = { + "call_id": call_id, + "file_name": file_name, + } + if interaction_id: + data["interaction_id"] = interaction_id + if source_event_id: + data["source_event_id"] = source_event_id + if mime_type: + data["mime_type"] = mime_type + if duration_seconds is not None: + data["duration_seconds"] = str(duration_seconds) + + last_exc: Exception | None = None + with local_path.open("rb") as handle, httpx.Client(timeout=bridge._forward_timeout_seconds(60.0)) as client: + for attempt in range(1, bridge._forward_max_attempts() + 1): + try: + response = bridge._request_with_bridge_auth( + client, + method="POST", + url=f"{bridge._recording_service_url()}/recordings/import-upload", + data=data, + files={"file": (file_name, handle, mime_type or "application/octet-stream")}, + retry_reset=handle.seek(0), + ) + return response.json() + except httpx.HTTPStatusError as exc: + last_exc = exc + status = exc.response.status_code if exc.response is not None else 0 + if status < 500 or attempt >= bridge._forward_max_attempts(): + raise + except httpx.RequestError as exc: + last_exc = exc + if attempt >= bridge._forward_max_attempts(): + raise + handle.seek(0) + if last_exc is not None: + raise last_exc + raise RuntimeError("Failed to upload recording") + + +def normalize_duration(value: Any) -> int | None: + if value in (None, ""): + return None + try: + parsed = int(str(value)) + except ValueError: + return None + return max(parsed, 0) + + +def process_call_started( + session, + row: AsteriskEventLogRow, + payload: dict[str, Any], +) -> None: + bridge = _bridge_app() + forwarded = bridge._find_forwarded_log( + session, + event_name=row.ami_event_name, + call_id=row.call_id, + exclude_bridge_event_id=row.bridge_event_id, + ) + if forwarded: + bridge._mark_log_forwarded(session, row, interaction_id=forwarded.interaction_id) + return + + queue_code = str(payload.get("QueueCode") or "").strip() + queue_id = bridge._queue_map().get(queue_code) + if not queue_id: + raise RuntimeError(f"Unknown QueueCode: {queue_code or ''}") + + caller_number = str(payload.get("CallerNumber") or "").strip() or None + existing_link, interaction_id = bridge._resolve_started_interaction_id( + session, + call_id=row.call_id, + queue_id=queue_id, + caller_number=caller_number, + ) + + bridge._emit_voice_event( + event_type="call.started", + call_id=row.call_id, + interaction_id=interaction_id, + source_event_id=bridge._bridge_source_event_id(row, "call.started"), + payload={ + "source": "asterisk", + "caller_number": payload.get("CallerNumber"), + "caller_name": payload.get("CallerName"), + "extension": payload.get("Extension"), + "context": payload.get("Context"), + "linked_id": row.linked_id, + "direction": payload.get("Direction", "inbound"), + }, + ) + + now = utc_now_iso() + channel_name = bridge._resolve_channel_from_payload(payload) + link = existing_link or AsteriskCallLinkRow( + call_id=row.call_id, + linked_id=row.linked_id, + queue_code=queue_code, + queue_id=queue_id, + interaction_id=interaction_id, + caller_number=caller_number, + caller_name=str(payload.get("CallerName") or "").strip() or None, + status="active", + telephony_status="ringing", + claimed_by_user=None, + claimed_at=None, + operator_extension=None, + channel_name=channel_name, + started_at=now, + connected_at=None, + ended_at=None, + updated_at=now, + ) + if not existing_link: + session.add(link) + else: + link.linked_id = row.linked_id + link.queue_code = queue_code + link.queue_id = queue_id + link.interaction_id = interaction_id + link.caller_number = caller_number + link.caller_name = str(payload.get("CallerName") or "").strip() or None + link.status = "active" + link.telephony_status = "ringing" + if channel_name: + link.channel_name = channel_name + link.updated_at = now + + _upsert_voice_reporting_fact(session, link, status="new") + + ai_config = bridge._ai_voice_config_for_queue(queue_code) + if ai_config: + link.ai_state = "queued" + link.ai_handoff_reason = None + link.ai_last_model_at = now + logger.warning( + "bridge.call_started_ai call_id=%s interaction_id=%s queue_code=%s queue_id=%s", + row.call_id, + interaction_id, + queue_code, + queue_id, + ) + try: + started = bridge._start_voice_ai_session( + call_id=row.call_id, + linked_id=row.linked_id, + interaction_id=interaction_id, + queue_id=queue_id, + queue_code=queue_code, + caller_number=caller_number, + caller_name=str(payload.get("CallerName") or "").strip() or None, + ai_config=ai_config, + ) + link.voice_session_id = str((started or {}).get("voice_session_id") or "").strip() or None + link.ai_session_id = str((started or {}).get("ai_session_id") or "").strip() or None + link.ai_state = str((started or {}).get("status") or "queued").strip() or "queued" + link.ai_last_model_at = utc_now_iso() + logger.warning( + "bridge.call_started_ai_result call_id=%s voice_session_id=%s ai_session_id=%s ai_state=%s", + row.call_id, + link.voice_session_id, + link.ai_session_id, + link.ai_state, + ) + try: + bridge._append_interaction_timeline( + interaction_id=interaction_id, + action="ai.session_started", + metadata={ + "call_id": row.call_id, + "voice_session_id": link.voice_session_id, + "ai_session_id": link.ai_session_id, + "queue_code": queue_code, + }, + ) + except Exception: + pass + except Exception as exc: + link.ai_state = "error" + link.ai_handoff_reason = str(exc)[:1000] + link.ai_last_model_at = utc_now_iso() + logger.warning( + "bridge.call_started_ai_error call_id=%s queue_code=%s error=%s", + row.call_id, + queue_code, + str(exc)[:500], + ) + try: + bridge._append_interaction_timeline( + interaction_id=interaction_id, + action="ai.error", + metadata={ + "call_id": row.call_id, + "queue_code": queue_code, + "error": str(exc)[:500], + }, + ) + except Exception: + pass + + bridge._mark_log_forwarded(session, row, interaction_id=interaction_id) + + +def process_audio_bridge_requested( + session, + row: AsteriskEventLogRow, + payload: dict[str, Any], +) -> None: + bridge = _bridge_app() + forwarded = bridge._find_forwarded_log( + session, + event_name=row.ami_event_name, + call_id=row.call_id, + exclude_bridge_event_id=row.bridge_event_id, + ) + if forwarded: + bridge._mark_log_forwarded(session, row, interaction_id=forwarded.interaction_id) + return + + link = bridge._find_call_link(session, row.call_id) + if not link: + link = bridge._recover_call_link_from_started_event( + session, + call_id=row.call_id, + exclude_bridge_event_id=row.bridge_event_id, + ) + if not link: + raise RuntimeError(f"Unknown CallID: {row.call_id}") + voice_session_id = str(link.voice_session_id or "").strip() or None + ai_session_id = str(link.ai_session_id or "").strip() or None + ai_state = str(link.ai_state or "").strip() or None + if not str(link.voice_session_id or "").strip(): + voice_session = find_voice_ai_session_by_call_id(session, row.call_id) + if voice_session is not None: + voice_session_id = str(voice_session.session_id or "").strip() or None + ai_session_id = str(voice_session.ai_session_id or "").strip() or None + if ai_state in {None, "", "queued"}: + ai_state = str(voice_session.status or "greeting").strip() or "greeting" + if not voice_session_id: + raise RuntimeError("Voice AI session is not ready for AudioSocket bridge") + + media_uuid = bridge._first_non_empty(payload.get("MediaUUID"), payload.get("MediaUuid")) + if not media_uuid: + raise RuntimeError("MediaUUID is required for AudioSocket bridge request") + channel = bridge._resolve_channel_from_payload(payload) + service_address = bridge._first_non_empty( + payload.get("AudioSocketService"), + payload.get("ServiceAddress"), + ) + logger.warning( + "bridge.audio_bridge_requested call_id=%s voice_session_id=%s ai_session_id=%s ai_state=%s media_uuid=%s service=%s channel=%s", + row.call_id, + voice_session_id, + ai_session_id, + ai_state, + media_uuid, + service_address, + channel, + ) + bridge._register_voice_ai_media_bridge( + voice_session_id=voice_session_id, + event_type="requested", + media_uuid=media_uuid, + call_id=row.call_id, + linked_id=row.linked_id, + channel=channel, + service_address=service_address, + ) + logger.warning( + "bridge.audio_bridge_registered call_id=%s voice_session_id=%s media_uuid=%s", + row.call_id, + voice_session_id, + media_uuid, + ) + link.voice_session_id = voice_session_id + link.ai_session_id = ai_session_id + if channel: + link.channel_name = channel + link.ai_state = "greeting" + link.ai_last_model_at = utc_now_iso() + link.updated_at = utc_now_iso() + bridge._mark_log_forwarded(session, row, interaction_id=link.interaction_id) + + +def process_audio_bridge_ended( + session, + row: AsteriskEventLogRow, + payload: dict[str, Any], +) -> None: + bridge = _bridge_app() + forwarded = bridge._find_forwarded_log( + session, + event_name=row.ami_event_name, + call_id=row.call_id, + exclude_bridge_event_id=row.bridge_event_id, + ) + if forwarded: + bridge._mark_log_forwarded(session, row, interaction_id=forwarded.interaction_id) + return + + link = bridge._find_call_link(session, row.call_id) + if not link: + link = bridge._recover_call_link_from_started_event( + session, + call_id=row.call_id, + exclude_bridge_event_id=row.bridge_event_id, + ) + if not link: + raise RuntimeError(f"Unknown CallID: {row.call_id}") + media_uuid = bridge._first_non_empty(payload.get("MediaUUID"), payload.get("MediaUuid")) + reason = bridge._first_non_empty(payload.get("Reason"), payload.get("TryExecStatus")) + logger.warning( + "bridge.audio_bridge_ended call_id=%s voice_session_id=%s media_uuid=%s reason=%s", + row.call_id, + str(link.voice_session_id or "").strip() or None, + media_uuid, + reason, + ) + if str(link.voice_session_id or "").strip() and media_uuid: + bridge._register_voice_ai_media_bridge( + voice_session_id=link.voice_session_id, + event_type="ended", + media_uuid=media_uuid, + call_id=row.call_id, + linked_id=row.linked_id, + channel=bridge._resolve_channel_from_payload(payload), + service_address=bridge._first_non_empty( + payload.get("AudioSocketService"), + payload.get("ServiceAddress"), + ), + reason=reason, + ) + link.ai_last_model_at = utc_now_iso() + link.updated_at = utc_now_iso() + bridge._mark_log_forwarded(session, row, interaction_id=link.interaction_id) + + +def process_call_ended( + session, + row: AsteriskEventLogRow, + payload: dict[str, Any], +) -> None: + bridge = _bridge_app() + forwarded = bridge._find_forwarded_log( + session, + event_name=row.ami_event_name, + call_id=row.call_id, + exclude_bridge_event_id=row.bridge_event_id, + ) + if forwarded: + bridge._mark_log_forwarded(session, row, interaction_id=forwarded.interaction_id) + return + + link = bridge._find_call_link(session, row.call_id) + if not link: + link = bridge._recover_call_link_from_started_event( + session, + call_id=row.call_id, + exclude_bridge_event_id=row.bridge_event_id, + ) + if not link: + raise RuntimeError(f"Unknown CallID: {row.call_id}") + + bridge._emit_voice_event( + event_type="call.ended", + call_id=row.call_id, + interaction_id=link.interaction_id, + source_event_id=bridge._bridge_source_event_id(row, "call.ended"), + payload={ + "source": "asterisk", + "hangup_cause": payload.get("HangupCause"), + "duration_seconds": bridge._normalize_duration(payload.get("DurationSeconds")), + "linked_id": row.linked_id, + }, + ) + now = utc_now_iso() + link.status = "ended" + link.telephony_status = "ended" + link.ended_at = now + link.updated_at = now + answered = bool(link.connected_at) + _upsert_voice_reporting_fact( + session, + link, + status="closed" if answered else "abandoned", + answered=answered, + abandoned=not answered, + wait_seconds=_seconds_between(link.started_at, link.connected_at) if answered else None, + handle_seconds=_seconds_between(link.connected_at, link.ended_at) if answered else None, + closed_at=link.ended_at, + ) + if str(link.ai_state or "").strip() and str(link.ai_state or "").strip() != "human_owned": + link.ai_state = "closed" + link.ai_last_model_at = now + logger.warning( + "bridge.call_ended call_id=%s voice_session_id=%s hangup_cause=%s duration_seconds=%s ai_state=%s", + row.call_id, + link.voice_session_id, + payload.get("HangupCause"), + payload.get("DurationSeconds"), + link.ai_state, + ) + try: + bridge._notify_voice_ai_telephony_event( + voice_session_id=link.voice_session_id, + event_type="call.ended", + payload={ + "call_id": row.call_id, + "hangup_cause": payload.get("HangupCause"), + "duration_seconds": payload.get("DurationSeconds"), + }, + ) + except Exception: + pass + bridge._mark_log_forwarded(session, row, interaction_id=link.interaction_id) + + +def process_operator_connected( + session, + row: AsteriskEventLogRow, + payload: dict[str, Any], +) -> None: + bridge = _bridge_app() + forwarded = bridge._find_forwarded_log( + session, + event_name=row.ami_event_name, + call_id=row.call_id, + exclude_bridge_event_id=row.bridge_event_id, + ) + if forwarded: + bridge._mark_log_forwarded(session, row, interaction_id=forwarded.interaction_id) + return + + link = bridge._find_call_link(session, row.call_id) + if not link: + link = bridge._recover_call_link_from_started_event( + session, + call_id=row.call_id, + exclude_bridge_event_id=row.bridge_event_id, + ) + if not link: + raise RuntimeError(f"Unknown CallID: {row.call_id}") + + operator_extension = bridge._first_non_empty( + payload.get("OperatorExtension"), + payload.get("OperatorExten"), + payload.get("Exten"), + payload.get("Extension"), + ) + claimed_by = bridge._first_non_empty( + payload.get("ClaimedBy"), + payload.get("ClaimedByUser"), + payload.get("OperatorUser"), + ) + channel = bridge._resolve_channel_from_payload(payload) + if operator_extension in {"", "s"}: + operator_extension = bridge._extract_extension_from_channel(channel) + if claimed_by in {"", "s"}: + claimed_by = None + if not claimed_by and str(link.claimed_by_user or "").strip(): + claimed_by = str(link.claimed_by_user or "").strip() + if not claimed_by and operator_extension: + claimed_by = bridge._user_for_operator_extension(operator_extension) + now = utc_now_iso() + + if operator_extension: + link.operator_extension = operator_extension + if claimed_by: + link.claimed_by_user = claimed_by + link.claimed_at = link.claimed_at or now + if channel: + link.channel_name = channel + link.telephony_status = "connected" + link.connected_at = now + link.updated_at = now + _upsert_voice_reporting_fact( + session, + link, + status="in_progress", + answered=True, + abandoned=False, + wait_seconds=_seconds_between(link.started_at, link.connected_at), + ) + ai_event_type = "call.connected" + if str(link.ai_state or "").strip() in {"handoff_requested", "handoff_required", "active", "speaking", "thinking", "listening", "greeting"}: + link.ai_state = "human_owned" + link.ai_last_model_at = now + ai_event_type = "operator.connected" + try: + bridge._append_interaction_timeline( + interaction_id=link.interaction_id, + action="ai.handoff_completed", + metadata={ + "call_id": row.call_id, + "voice_session_id": link.voice_session_id, + "ai_session_id": link.ai_session_id, + "operator_extension": operator_extension, + "claimed_by_user": claimed_by, + }, + ) + except Exception: + pass + + bridge._emit_voice_event( + event_type="call.connected", + call_id=row.call_id, + interaction_id=link.interaction_id, + source_event_id=bridge._bridge_source_event_id(row, "call.connected"), + payload={ + "source": "asterisk", + "linked_id": row.linked_id, + "operator_extension": operator_extension, + "claimed_by_user": claimed_by, + "channel_name": channel, + }, + ) + try: + bridge._notify_voice_ai_telephony_event( + voice_session_id=link.voice_session_id, + event_type=ai_event_type, + payload={ + "call_id": row.call_id, + "operator_extension": operator_extension, + "claimed_by_user": claimed_by, + "channel_name": channel, + }, + ) + except Exception: + pass + logger.warning( + "bridge.operator_connected call_id=%s voice_session_id=%s operator_extension=%s claimed_by=%s ai_event_type=%s", + row.call_id, + link.voice_session_id, + operator_extension, + claimed_by, + ai_event_type, + ) + bridge._mark_log_forwarded(session, row, interaction_id=link.interaction_id) + + +def process_recording_ready( + session, + row: AsteriskEventLogRow, + payload: dict[str, Any], +) -> None: + bridge = _bridge_app() + forwarded = bridge._find_forwarded_log( + session, + event_name=row.ami_event_name, + call_id=row.call_id, + exclude_bridge_event_id=row.bridge_event_id, + ) + if forwarded: + bridge._mark_log_forwarded( + session, + row, + interaction_id=forwarded.interaction_id, + recording_id=forwarded.recording_id, + ) + return + + link = bridge._find_call_link(session, row.call_id) + if not link: + link = bridge._recover_call_link_from_started_event( + session, + call_id=row.call_id, + exclude_bridge_event_id=row.bridge_event_id, + ) + if not link: + raise RuntimeError(f"Unknown CallID: {row.call_id}") + + remote_path = str(payload.get("RemotePath") or "").strip() + file_name = str(payload.get("FileName") or "").strip() or Path(remote_path).name or "call.wav" + mime_type = str(payload.get("MimeType") or "").strip() or None + duration_seconds = bridge._normalize_duration(payload.get("DurationSeconds")) + if not remote_path: + raise RuntimeError("RemotePath is required") + + ended_exists = bridge._has_voice_event(session, call_id=row.call_id, event_type="call.ended") + if not ended_exists: + bridge._emit_voice_event( + event_type="call.ended", + call_id=row.call_id, + interaction_id=link.interaction_id, + source_event_id=bridge._bridge_source_event_id(row, "call.ended"), + payload={ + "source": "asterisk", + "hangup_cause": "unknown", + "duration_seconds": duration_seconds, + "linked_id": row.linked_id, + "reconciled": True, + "reason": "recording.ready_without_call.ended", + }, + ) + ended_exists = True + + if ended_exists and link.status != "ended": + now = utc_now_iso() + link.status = "ended" + link.telephony_status = "ended" + link.ended_at = link.ended_at or now + link.updated_at = now + session.flush() + + existing_record = bridge._latest_recording_for_call(session, call_id=row.call_id) + if existing_record is not None: + if not bridge._has_voice_event(session, call_id=row.call_id, event_type="recording.ready"): + bridge._emit_voice_event( + event_type="recording.ready", + call_id=row.call_id, + interaction_id=link.interaction_id, + source_event_id=bridge._bridge_source_event_id(row, "recording.ready"), + payload={ + "source": "asterisk", + "remote_path": remote_path, + "file_name": file_name, + "mime_type": mime_type, + "duration_seconds": duration_seconds, + "linked_id": row.linked_id, + }, + ) + bridge._mark_log_forwarded( + session, + row, + interaction_id=link.interaction_id, + recording_id=existing_record.recording_id, + ) + return + + bridge._emit_voice_event( + event_type="recording.ready", + call_id=row.call_id, + interaction_id=link.interaction_id, + source_event_id=bridge._bridge_source_event_id(row, "recording.ready"), + payload={ + "source": "asterisk", + "remote_path": remote_path, + "file_name": file_name, + "mime_type": mime_type, + "duration_seconds": duration_seconds, + "linked_id": row.linked_id, + }, + ) + try: + bridge._notify_voice_ai_telephony_event( + voice_session_id=link.voice_session_id, + event_type="recording.ready", + payload={ + "call_id": row.call_id, + "file_name": file_name, + "mime_type": mime_type, + "duration_seconds": duration_seconds, + }, + ) + except Exception: + pass + + local_path, cleanup_required = bridge._fetch_recording_file(remote_path, file_name) + try: + created = bridge._upload_recording( + local_path=local_path, + call_id=row.call_id, + interaction_id=link.interaction_id, + source_event_id=bridge._bridge_source_event_id(row, "recording.import"), + file_name=file_name, + mime_type=mime_type, + duration_seconds=duration_seconds, + ) + bridge._mark_log_forwarded( + session, + row, + interaction_id=link.interaction_id, + recording_id=created["recording_id"], + ) + finally: + if cleanup_required and local_path.exists(): + local_path.unlink(missing_ok=True) + + +def process_bridge_row(session, row: AsteriskEventLogRow) -> AsteriskEventLogRow: + bridge = _bridge_app() + payload = json.loads(row.payload_json or "{}") + logger.warning( + "bridge.process_event event=%s call_id=%s status=%s", + row.ami_event_name, + row.call_id, + row.forward_status, + ) + if row.ami_event_name == f"{bridge._ami_prefix()}CallStarted": + bridge._process_call_started(session, row, payload) + elif row.ami_event_name == f"{bridge._ami_prefix()}AIAudioBridgeRequested": + bridge._process_audio_bridge_requested(session, row, payload) + elif row.ami_event_name == f"{bridge._ami_prefix()}AIAudioBridgeEnded": + bridge._process_audio_bridge_ended(session, row, payload) + elif row.ami_event_name in { + f"{bridge._ami_prefix()}AIAudioSocketAttempt", + f"{bridge._ami_prefix()}AIAudioSocketResult", + }: + logger.warning( + "bridge.audio_socket_diag event=%s call_id=%s payload=%s", + row.ami_event_name, + row.call_id, + row.payload_json, + ) + interaction_id = None + link = bridge._find_call_link(session, row.call_id) + if link is not None: + interaction_id = link.interaction_id + bridge._mark_log_forwarded(session, row, interaction_id=interaction_id) + elif row.ami_event_name == f"{bridge._ami_prefix()}OperatorConnected": + bridge._process_operator_connected(session, row, payload) + elif row.ami_event_name == f"{bridge._ami_prefix()}CallEnded": + bridge._process_call_ended(session, row, payload) + elif row.ami_event_name == f"{bridge._ami_prefix()}RecordingReady": + bridge._process_recording_ready(session, row, payload) + else: + row.forward_status = "received" + row.updated_at = utc_now_iso() + session.flush() + return row + + +def record_ami_payload(payload: dict[str, Any]) -> None: + bridge = _bridge_app() + event_name = str(payload.get("UserEvent") or "").strip() + call_id = bridge._extract_call_id(payload) + linked_id = bridge._extract_linked_id(payload, call_id) + if not event_name or not call_id: + return + + session = get_session() + try: + row = bridge._create_bridge_log( + session, + ami_event_name=event_name, + call_id=call_id, + linked_id=linked_id, + payload=payload, + ) + session.commit() + bridge_event_id = row.bridge_event_id + finally: + session.close() + + if not bridge._claim_bridge_event_for_processing(bridge_event_id, allowed_statuses=("received",)): + return + bridge._process_claimed_bridge_event(bridge_event_id) + + +def retry_failed_events_once() -> None: + bridge = _bridge_app() + session = get_session() + try: + rows = session.execute( + select(AsteriskEventLogRow) + .where(AsteriskEventLogRow.forward_status.in_(("failed", "received"))) + .order_by(AsteriskEventLogRow.id.asc()) + ).scalars().all() + event_ids = [row.bridge_event_id for row in rows[: bridge._failed_retry_batch_size()]] + finally: + session.close() + + for bridge_event_id in event_ids: + if not bridge._claim_bridge_event_for_processing(bridge_event_id): + continue + bridge._process_claimed_bridge_event(bridge_event_id) + + +def failed_retry_loop(stop_event: threading.Event | None = None) -> None: + bridge = _bridge_app() + active_stop_event = stop_event or bridge._background_stop_event() + while not active_stop_event.is_set(): + if bridge._bridge_enabled() and bridge._failed_retry_enabled(): + try: + bridge._retry_failed_events_once() + except Exception: + pass + if bridge._bridge_enabled() and bridge._reconcile_enabled(): + try: + bridge._reconcile_stale_calls_once() + except Exception: + pass + if active_stop_event.wait(bridge._failed_retry_interval_seconds()): + break diff --git a/services/asterisk_bridge_service/call_control.py b/services/asterisk_bridge_service/call_control.py new file mode 100644 index 0000000..2d74cba --- /dev/null +++ b/services/asterisk_bridge_service/call_control.py @@ -0,0 +1,420 @@ +from __future__ import annotations + +from typing import Any + +from fastapi import HTTPException +from sqlalchemy import select + +from services.shared.core import Role, utc_now_iso +from services.shared.db import get_session +from services.shared.models import VoiceCallBlindTransferIn, VoiceCallClaimIn, VoiceLiveCallOut +from services.shared.sql_models import AsteriskCallActionLogRow, AsteriskCallLinkRow + + +def _bridge_app(): + import services.asterisk_bridge_service.app as bridge_app + + return bridge_app + + +def load_live_call_or_404(session, call_id: str) -> AsteriskCallLinkRow: + bridge = _bridge_app() + link = bridge._find_call_link(session, call_id) + if link is None: + raise HTTPException(status_code=404, detail="Live call not found") + return link + + +def call_is_ended(link: AsteriskCallLinkRow) -> bool: + if str(link.status or "").strip().lower() == "ended": + return True + return str(link.telephony_status or "").strip().lower() == "ended" + + +def resolve_operator_extension(*, actor: dict, requested_extension: str | None = None) -> str: + bridge = _bridge_app() + actor_user = str(actor.get("user") or "").strip() + actor_role = str(actor.get("role") or "").strip().lower() + mapping = bridge._operator_extension_map() + mapped = mapping.get(actor_user) + requested = str(requested_extension or "").strip() + + if actor_role == Role.OPERATOR.value: + if not mapped: + raise HTTPException(status_code=400, detail=f"No extension mapping for operator '{actor_user}'") + if requested and requested != mapped: + raise HTTPException(status_code=400, detail="Operator extension override is not allowed") + return mapped + + if requested: + return requested + if mapped: + return mapped + raise HTTPException(status_code=400, detail="operator_extension is required for this role") + + +def resolve_transfer_extension(*, target_type: str, target_value: str) -> str: + bridge = _bridge_app() + raw_value = str(target_value or "").strip() + if not raw_value: + raise HTTPException(status_code=400, detail="target_value is required") + if target_type == "extension": + return raw_value + mapped = bridge._transfer_target_map().get(raw_value) + if not mapped: + raise HTTPException(status_code=400, detail=f"Unknown transfer queue_code: {raw_value}") + return mapped + + +def assert_callcontrol_enabled() -> None: + bridge = _bridge_app() + if not bridge._callcontrol_enabled(): + raise HTTPException(status_code=409, detail="Call control is disabled") + + +def assert_call_control_permissions(*, actor: dict, link: AsteriskCallLinkRow, action_type: str) -> None: + bridge = _bridge_app() + role = str(actor.get("role") or "").strip().lower() + user = str(actor.get("user") or "").strip() + if role != Role.OPERATOR.value: + return + claimed_by = str(link.claimed_by_user or "").strip() + if action_type == "claim": + if claimed_by and claimed_by != user: + raise HTTPException(status_code=403, detail="Call already claimed by another operator") + return + mapped_extension = bridge._operator_extension_map().get(user) + if mapped_extension and str(link.operator_extension or "").strip() == mapped_extension: + return + if claimed_by != user: + raise HTTPException(status_code=403, detail="Operator can control only own claimed call") + + +def claim_live_call(call_id: str, body: VoiceCallClaimIn, actor: dict) -> VoiceLiveCallOut: + bridge = _bridge_app() + assert_callcontrol_enabled() + session = get_session() + action: AsteriskCallActionLogRow | None = None + try: + link = load_live_call_or_404(session, call_id) + assert_call_control_permissions(actor=actor, link=link, action_type="claim") + if call_is_ended(link): + raise HTTPException(status_code=409, detail="Call is already ended") + if not link.interaction_id: + raise HTTPException(status_code=409, detail="Call has no linked interaction") + + actor_user = str(actor.get("user") or "").strip() or "unknown" + actor_role = str(actor.get("role") or "").strip() or "unknown" + operator_extension = resolve_operator_extension(actor=actor, requested_extension=body.operator_extension) + action = bridge._create_action_log( + session, + call_id=call_id, + interaction_id=link.interaction_id, + action_type="claim", + actor_user=actor_user, + actor_role=actor_role, + request_payload={"operator_extension": operator_extension}, + ) + + if link.claimed_by_user == actor_user and link.telephony_status in {"claimed", "connected"}: + bridge._set_action_result( + session, + action, + result_status="ok", + error="already_claimed_by_actor", + ) + session.commit() + session.refresh(link) + return bridge._live_call_to_out(session, link) + + snapshot = bridge._build_call_channel_snapshot(session, link, operator_extension=operator_extension) + operator_channel = snapshot.operator_channel + connected_channel = snapshot.connected_channel + already_connected = ( + link.telephony_status == "connected" + or (connected_channel is not None and bridge._extract_extension_from_channel(connected_channel) == operator_extension) + ) + channel = operator_channel or connected_channel or bridge._resolve_channel_name(session, link, refresh=True, snapshot=snapshot) + if not channel: + raise HTTPException(status_code=409, detail="Unable to resolve active channel for call") + + ami_result: dict[str, Any] | None = None + claim_warning: str | None = None + if already_connected: + claim_warning = "already_connected_to_operator" + elif not operator_channel: + source_channel = None + for candidate in bridge._candidate_channels_for_call( + session, + link, + operator_extension=operator_extension, + prefer_operator=False, + snapshot=snapshot, + ): + if bridge._extract_extension_from_channel(candidate) != operator_extension: + source_channel = candidate + break + if not source_channel: + source_channel = bridge._resolve_channel_name(session, link, refresh=True, snapshot=snapshot) + if not source_channel: + raise HTTPException(status_code=409, detail="Unable to resolve source channel for claim") + try: + ami_result = bridge._ami_action( + "Redirect", + { + "Channel": source_channel, + "Context": bridge._claim_context(), + "Exten": operator_extension, + "Priority": 1, + }, + ) + except Exception as exc: + refresh_snapshot = bridge._build_call_channel_snapshot(session, link, operator_extension=operator_extension) + if refresh_snapshot.operator_channel: + channel = refresh_snapshot.operator_channel + elif refresh_snapshot.connected_channel and bridge._extract_extension_from_channel(refresh_snapshot.connected_channel) == operator_extension: + channel = refresh_snapshot.connected_channel + else: + raise + claim_warning = f"redirect_skipped: {exc}" + + now = utc_now_iso() + link.claimed_by_user = actor_user + link.claimed_at = now + link.operator_extension = operator_extension + link.channel_name = channel + link.telephony_status = "connected" if already_connected else "claimed" + link.updated_at = now + + assign_error: str | None = None + try: + bridge._assign_interaction(interaction_id=link.interaction_id, assignee=actor_user) + except Exception as exc: + assign_error = str(exc) + action_error = "; ".join(part for part in [claim_warning, assign_error] if part) or None + + bridge._set_action_result( + session, + action, + result_status="ok", + ami_action_id=str((ami_result or {}).get("action_id") or "") or None, + error=action_error, + ) + session.commit() + session.refresh(link) + return bridge._live_call_to_out(session, link) + except HTTPException as exc: + if action is not None: + bridge._set_action_result(session, action, result_status="rejected", error=exc.detail if isinstance(exc.detail, str) else str(exc.detail)) + session.commit() + raise + except Exception as exc: + if action is not None: + bridge._set_action_result(session, action, result_status="failed", error=str(exc)) + session.commit() + raise HTTPException(status_code=502, detail=f"Claim failed: {exc}") from exc + finally: + session.close() + + +def hangup_live_call(call_id: str, actor: dict) -> VoiceLiveCallOut: + bridge = _bridge_app() + assert_callcontrol_enabled() + session = get_session() + action: AsteriskCallActionLogRow | None = None + try: + link = load_live_call_or_404(session, call_id) + assert_call_control_permissions(actor=actor, link=link, action_type="hangup") + if call_is_ended(link): + raise HTTPException(status_code=409, detail="Call is already ended") + + actor_user = str(actor.get("user") or "").strip() or "unknown" + actor_role = str(actor.get("role") or "").strip() or "unknown" + action = bridge._create_action_log( + session, + call_id=call_id, + interaction_id=link.interaction_id, + action_type="hangup", + actor_user=actor_user, + actor_role=actor_role, + request_payload={}, + ) + snapshot = bridge._build_call_channel_snapshot(session, link, operator_extension=link.operator_extension) + candidates = bridge._candidate_channels_for_call( + session, + link, + operator_extension=link.operator_extension, + prefer_operator=True, + snapshot=snapshot, + ) + if not candidates: + raise HTTPException(status_code=409, detail="Unable to resolve active channel for call") + ami_result = None + channel = None + successful_channels: list[str] = [] + last_error = None + for candidate in candidates: + try: + result = bridge._ami_action("Hangup", {"Channel": candidate}) + if ami_result is None: + ami_result = result + channel = candidate + successful_channels.append(candidate) + except Exception as exc: + last_error = exc + message = str(exc) + if "No such channel" in message or "Channel does not exist" in message: + continue + raise + if not successful_channels or ami_result is None or channel is None: + remaining_channels = bridge._build_call_channel_snapshot( + session, + link, + operator_extension=link.operator_extension, + ).live_channels + if not remaining_channels: + now = utc_now_iso() + link.telephony_status = "ended" + link.status = "ended" + link.ended_at = link.ended_at or now + link.updated_at = now + bridge._set_action_result( + session, + action, + result_status="ok", + error="channel_already_closed", + ) + session.commit() + session.refresh(link) + return bridge._live_call_to_out(session, link) + raise RuntimeError(str(last_error or "No active channel available for hangup")) + + now = utc_now_iso() + link.telephony_status = "ended" + link.status = "ended" + link.ended_at = link.ended_at or now + link.channel_name = successful_channels[0] + link.updated_at = now + bridge._set_action_result( + session, + action, + result_status="ok", + ami_action_id=str(ami_result.get("action_id") or "") or None, + ) + session.commit() + session.refresh(link) + return bridge._live_call_to_out(session, link) + except HTTPException as exc: + if action is not None: + bridge._set_action_result(session, action, result_status="rejected", error=exc.detail if isinstance(exc.detail, str) else str(exc.detail)) + session.commit() + raise + except Exception as exc: + if action is not None: + bridge._set_action_result(session, action, result_status="failed", error=str(exc)) + session.commit() + raise HTTPException(status_code=502, detail=f"Hangup failed: {exc}") from exc + finally: + session.close() + + +def blind_transfer_live_call(call_id: str, body: VoiceCallBlindTransferIn, actor: dict) -> VoiceLiveCallOut: + bridge = _bridge_app() + assert_callcontrol_enabled() + session = get_session() + action: AsteriskCallActionLogRow | None = None + try: + link = load_live_call_or_404(session, call_id) + assert_call_control_permissions(actor=actor, link=link, action_type="blind-transfer") + if call_is_ended(link): + raise HTTPException(status_code=409, detail="Call is already ended") + + actor_user = str(actor.get("user") or "").strip() or "unknown" + actor_role = str(actor.get("role") or "").strip() or "unknown" + target_extension = resolve_transfer_extension(target_type=body.target_type, target_value=body.target_value) + action = bridge._create_action_log( + session, + call_id=call_id, + interaction_id=link.interaction_id, + action_type="blind-transfer", + actor_user=actor_user, + actor_role=actor_role, + request_payload={ + "target_type": body.target_type, + "target_value": body.target_value, + "resolved_extension": target_extension, + }, + ) + + snapshot = bridge._build_call_channel_snapshot(session, link, operator_extension=link.operator_extension) + channel = bridge._resolve_channel_name(session, link, prefer_operator=True, refresh=True, snapshot=snapshot) + if not channel: + raise HTTPException(status_code=409, detail="Unable to resolve active channel for call") + ami_result = bridge._ami_action( + "Redirect", + { + "Channel": channel, + "Context": bridge._transfer_context(), + "Exten": target_extension, + "Priority": 1, + }, + ) + + bridge._emit_voice_event( + event_type="call.transferred", + call_id=call_id, + interaction_id=link.interaction_id, + payload={ + "source": "asterisk", + "target_type": body.target_type, + "target_value": body.target_value, + "target_extension": target_extension, + "actor_user": actor_user, + "linked_id": link.linked_id, + }, + ) + now = utc_now_iso() + link.status = "ended" + link.telephony_status = "ended" + link.ended_at = link.ended_at or now + link.updated_at = now + bridge._set_action_result( + session, + action, + result_status="ok", + ami_action_id=str(ami_result.get("action_id") or "") or None, + ) + session.commit() + session.refresh(link) + return bridge._live_call_to_out(session, link) + except HTTPException as exc: + if action is not None: + bridge._set_action_result(session, action, result_status="rejected", error=exc.detail if isinstance(exc.detail, str) else str(exc.detail)) + session.commit() + raise + except Exception as exc: + if action is not None: + bridge._set_action_result(session, action, result_status="failed", error=str(exc)) + session.commit() + raise HTTPException(status_code=502, detail=f"Blind transfer failed: {exc}") from exc + finally: + session.close() + + +def list_live_call_actions(call_id: str, limit: int, actor: dict) -> list[dict]: + session = get_session() + bridge = _bridge_app() + try: + role = str(actor.get("role") or "").strip().lower() + if role == Role.OPERATOR.value: + link = bridge._find_call_link(session, call_id) + if link and str(link.claimed_by_user or "").strip() not in {"", str(actor.get("user") or "").strip()}: + raise HTTPException(status_code=403, detail="Operator can view actions only for own or unclaimed call") + rows = session.execute( + select(AsteriskCallActionLogRow) + .where(AsteriskCallActionLogRow.call_id == call_id) + .order_by(AsteriskCallActionLogRow.id.desc()) + ).scalars().all() + return [bridge._action_to_out(row) for row in rows[: max(limit, 1)]] + finally: + session.close() diff --git a/services/asterisk_bridge_service/config.py b/services/asterisk_bridge_service/config.py new file mode 100644 index 0000000..eeaa16f --- /dev/null +++ b/services/asterisk_bridge_service/config.py @@ -0,0 +1,468 @@ +from __future__ import annotations + +import json +import os +from typing import Any +from urllib.parse import urlparse + +from fastapi import HTTPException + +from services.shared.models import BrowserSoftphoneConfigOut + + +def bool_env(name: str, default: bool) -> bool: + raw = os.getenv(name) + if raw is None: + return default + return raw.strip().lower() in {"1", "true", "yes", "on"} + + +def bridge_enabled() -> bool: + return bool_env("ASTERISK_BRIDGE_ENABLED", False) + + +def ami_host() -> str: + return os.getenv("ASTERISK_AMI_HOST", "").strip() + + +def ami_port() -> int: + return int(os.getenv("ASTERISK_AMI_PORT", "5038")) + + +def ami_username() -> str: + return os.getenv("ASTERISK_AMI_USERNAME", "").strip() + + +def ami_secret() -> str: + return os.getenv("ASTERISK_AMI_SECRET", "").strip() + + +def ami_prefix() -> str: + return os.getenv("ASTERISK_AMI_EVENT_PREFIX", "MVPCC").strip() or "MVPCC" + + +def poll_interval() -> float: + try: + value = float(os.getenv("ASTERISK_BRIDGE_POLL_INTERVAL_SECONDS", "1").strip()) + except ValueError: + value = 1.0 + return max(value, 0.25) + + +def recent_calls_window_seconds() -> int: + raw = os.getenv("ASTERISK_RECENT_CALLS_WINDOW_SECONDS", "120").strip() + try: + value = int(raw) + except ValueError: + value = 120 + return max(value, 30) + + +def failed_retry_enabled() -> bool: + return bool_env("ASTERISK_BRIDGE_FAILED_RETRY_ENABLED", True) + + +def failed_retry_interval_seconds() -> float: + raw = os.getenv("ASTERISK_BRIDGE_FAILED_RETRY_INTERVAL_SECONDS", "5").strip() + try: + value = float(raw) + except ValueError: + value = 5.0 + return max(value, 1.0) + + +def failed_retry_batch_size() -> int: + raw = os.getenv("ASTERISK_BRIDGE_FAILED_RETRY_BATCH_SIZE", "20").strip() + try: + value = int(raw) + except ValueError: + value = 20 + return max(value, 1) + + +def reconcile_enabled() -> bool: + return bool_env("ASTERISK_BRIDGE_RECONCILE_ENABLED", True) + + +def reconcile_stale_seconds() -> int: + raw = os.getenv("ASTERISK_BRIDGE_RECONCILE_STALE_SECONDS", "12").strip() + try: + value = int(raw) + except ValueError: + value = 30 + return max(value, 5) + + +def reconcile_settle_seconds() -> int: + raw = os.getenv("ASTERISK_BRIDGE_RECONCILE_SETTLE_SECONDS", "6").strip() + try: + value = int(raw) + except ValueError: + value = 8 + return max(value, 2) + + +def reconcile_scan_limit() -> int: + raw = os.getenv("ASTERISK_BRIDGE_RECONCILE_SCAN_LIMIT", "20").strip() + try: + value = int(raw) + except ValueError: + value = 20 + return max(value, 1) + + +def forward_timeout_seconds(default: float = 45.0) -> float: + raw = os.getenv("ASTERISK_BRIDGE_FORWARD_TIMEOUT_SECONDS", str(default)).strip() + try: + value = float(raw) + except ValueError: + value = default + return max(value, 5.0) + + +def forward_max_attempts() -> int: + raw = os.getenv("ASTERISK_BRIDGE_FORWARD_MAX_ATTEMPTS", "3").strip() + try: + value = int(raw) + except ValueError: + value = 3 + return max(value, 1) + + +def forward_retry_backoff_seconds() -> float: + raw = os.getenv("ASTERISK_BRIDGE_FORWARD_RETRY_BACKOFF_SECONDS", "0.75").strip() + try: + value = float(raw) + except ValueError: + value = 0.75 + return max(value, 0.05) + + +def queue_map() -> dict[str, str]: + raw = os.getenv("ASTERISK_QUEUE_MAP_JSON", "{}").strip() + try: + payload = json.loads(raw or "{}") + except json.JSONDecodeError as exc: + raise RuntimeError("Invalid ASTERISK_QUEUE_MAP_JSON") from exc + if not isinstance(payload, dict): + raise RuntimeError("ASTERISK_QUEUE_MAP_JSON must be a JSON object") + return {str(k): str(v) for k, v in payload.items() if str(k).strip() and str(v).strip()} + + +def sftp_enabled() -> bool: + return bool( + os.getenv("ASTERISK_SFTP_HOST", "").strip() + and os.getenv("ASTERISK_SFTP_USERNAME", "").strip() + ) + + +def sftp_host() -> str: + return os.getenv("ASTERISK_SFTP_HOST", "").strip() + + +def sftp_port() -> int: + return int(os.getenv("ASTERISK_SFTP_PORT", "22")) + + +def sftp_username() -> str: + return os.getenv("ASTERISK_SFTP_USERNAME", "").strip() + + +def sftp_password() -> str: + return os.getenv("ASTERISK_SFTP_PASSWORD", "").strip() + + +def interaction_service_url() -> str: + return os.getenv("INTERACTION_SERVICE_URL", "http://localhost:8004").rstrip("/") + + +def voice_adapter_service_url() -> str: + return os.getenv("VOICE_ADAPTER_SERVICE_URL", "http://localhost:8006").rstrip("/") + + +def recording_service_url() -> str: + return os.getenv("RECORDING_SERVICE_URL", "http://localhost:8013").rstrip("/") + + +def ivr_service_url() -> str: + return os.getenv("IVR_SERVICE_URL", "http://localhost:8014").rstrip("/") + + +def ai_voice_runtime_service_url() -> str: + return os.getenv("AI_VOICE_RUNTIME_SERVICE_URL", "http://localhost:8018").rstrip("/") + + +def ai_voice_enabled() -> bool: + return bool_env("AI_VOICE_ENABLED", False) + + +def ai_voice_queue_config() -> dict[str, dict[str, Any]]: + raw = os.getenv("AI_VOICE_QUEUE_CONFIG_JSON", "{}").strip() + try: + payload = json.loads(raw or "{}") + except json.JSONDecodeError as exc: + raise RuntimeError("Invalid AI_VOICE_QUEUE_CONFIG_JSON") from exc + if not isinstance(payload, dict): + raise RuntimeError("AI_VOICE_QUEUE_CONFIG_JSON must be a JSON object") + result: dict[str, dict[str, Any]] = {} + for queue_code, item in payload.items(): + key = str(queue_code or "").strip() + if not key or not isinstance(item, dict): + continue + result[key] = item + return result + + +def ai_voice_config_for_queue(queue_code: str | None) -> dict[str, Any] | None: + key = str(queue_code or "").strip() + if not key or not ai_voice_enabled(): + return None + item = ai_voice_queue_config().get(key) + if not isinstance(item, dict): + return None + if str(item.get("mode") or "ai_first").strip().lower() != "ai_first": + return None + handoff_queue_code = str(item.get("handoff_queue_code") or key).strip() or key + handoff_queue_id = queue_map().get(handoff_queue_code) + return { + "queue_code": key, + "mode": "ai_first", + "agent_profile": str(item.get("agent_profile") or "voice_support").strip() or "voice_support", + "language": str(item.get("language") or "").strip() or None, + "handoff_queue_code": handoff_queue_code, + "handoff_queue_id": handoff_queue_id, + } + + +def ivr_fastagi_enabled() -> bool: + return bool_env("ASTERISK_IVR_FASTAGI_ENABLED", False) + + +def ivr_fastagi_host() -> str: + return os.getenv("ASTERISK_IVR_FASTAGI_HOST", "127.0.0.1").strip() or "127.0.0.1" + + +def ivr_fastagi_port() -> int: + raw = os.getenv("ASTERISK_IVR_FASTAGI_PORT", "4573").strip() + try: + value = int(raw) + except ValueError: + value = 4573 + return max(value, 1) + + +def ivr_dtmf_timeout_seconds() -> int: + raw = os.getenv("ASTERISK_IVR_DTMF_TIMEOUT_SECONDS", "5").strip() + try: + value = int(raw) + except ValueError: + value = 5 + return max(value, 1) + + +def ivr_max_no_input_retries() -> int: + raw = os.getenv("ASTERISK_IVR_MAX_NO_INPUT_RETRIES", "2").strip() + try: + value = int(raw) + except ValueError: + value = 2 + return max(value, 0) + + +def ivr_max_invalid_retries() -> int: + raw = os.getenv("ASTERISK_IVR_MAX_INVALID_RETRIES", "2").strip() + try: + value = int(raw) + except ValueError: + value = 2 + return max(value, 0) + + +def ivr_call_link_wait_seconds() -> float: + raw = os.getenv("ASTERISK_IVR_CALL_LINK_WAIT_SECONDS", "5").strip() + try: + value = float(raw) + except ValueError: + value = 5.0 + return max(value, 0.5) + + +def callcontrol_enabled() -> bool: + return bool_env("ASTERISK_CALLCONTROL_ENABLED", False) + + +def callcontrol_action_timeout_seconds() -> float: + raw = os.getenv("ASTERISK_CALLCONTROL_ACTION_TIMEOUT_SECONDS", "10").strip() + try: + value = float(raw) + except ValueError: + value = 10.0 + return max(value, 2.0) + + +def webrtc_enabled() -> bool: + return bool_env("ASTERISK_WEBRTC_ENABLED", False) + + +def webrtc_ws_url() -> str: + return os.getenv("ASTERISK_WEBRTC_WS_URL", "").strip() + + +def webrtc_ice_servers() -> list[dict[str, Any]]: + raw = os.getenv("ASTERISK_WEBRTC_ICE_SERVERS_JSON", "[]").strip() + try: + payload = json.loads(raw or "[]") + except json.JSONDecodeError as exc: + raise RuntimeError("Invalid ASTERISK_WEBRTC_ICE_SERVERS_JSON") from exc + if not isinstance(payload, list): + raise RuntimeError("ASTERISK_WEBRTC_ICE_SERVERS_JSON must be a JSON array") + result: list[dict[str, Any]] = [] + for item in payload: + if isinstance(item, dict): + result.append(item) + return result + + +def operator_extension_map() -> dict[str, str]: + raw = os.getenv("ASTERISK_OPERATOR_EXTENSION_MAP_JSON", "{}").strip() + try: + payload = json.loads(raw or "{}") + except json.JSONDecodeError as exc: + raise RuntimeError("Invalid ASTERISK_OPERATOR_EXTENSION_MAP_JSON") from exc + if not isinstance(payload, dict): + raise RuntimeError("ASTERISK_OPERATOR_EXTENSION_MAP_JSON must be a JSON object") + return {str(k).strip(): str(v).strip() for k, v in payload.items() if str(k).strip() and str(v).strip()} + + +def user_for_operator_extension(extension: str | None) -> str | None: + target = str(extension or "").strip() + if not target: + return None + matches = [user for user, mapped in operator_extension_map().items() if mapped == target] + unique_matches = list(dict.fromkeys(matches)) + if len(unique_matches) == 1: + return unique_matches[0] + return None + + +def browser_sip_map() -> dict[str, dict[str, Any]]: + raw = os.getenv("ASTERISK_BROWSER_SIP_MAP_JSON", "{}").strip() + try: + payload = json.loads(raw or "{}") + except json.JSONDecodeError as exc: + raise RuntimeError("Invalid ASTERISK_BROWSER_SIP_MAP_JSON") from exc + if not isinstance(payload, dict): + raise RuntimeError("ASTERISK_BROWSER_SIP_MAP_JSON must be a JSON object") + result: dict[str, dict[str, Any]] = {} + for user, item in payload.items(): + if not str(user).strip() or not isinstance(item, dict): + continue + result[str(user).strip()] = item + return result + + +def default_browser_sip_domain() -> str: + ws_url = webrtc_ws_url() + if ws_url: + parsed = urlparse(ws_url) + if parsed.hostname: + return parsed.hostname + return ami_host() + + +def browser_softphone_config_for_actor(actor: dict) -> BrowserSoftphoneConfigOut: + user = str(actor.get("user") or "").strip() + entry = browser_sip_map().get(user) + if not entry: + raise HTTPException(status_code=403, detail="Browser softphone is not configured for this user") + + operator_extension = ( + str(entry.get("operator_extension") or "").strip() + or operator_extension_map().get(user, "") + ) + authorization_username = ( + str(entry.get("authorization_username") or "").strip() + or str(entry.get("username") or "").strip() + or operator_extension + ) + password = str(entry.get("password") or "").strip() + display_name = str(entry.get("display_name") or actor.get("full_name") or user).strip() or user + sip_uri = str(entry.get("sip_uri") or "").strip() + if not sip_uri and authorization_username: + domain = default_browser_sip_domain() + if domain: + sip_uri = f"sip:{authorization_username}@{domain}" + + return BrowserSoftphoneConfigOut( + enabled=webrtc_enabled(), + ws_url=webrtc_ws_url() or None, + sip_uri=sip_uri or None, + authorization_username=authorization_username or None, + password=password or None, + display_name=display_name or None, + ice_servers=webrtc_ice_servers(), + operator_extension=operator_extension or None, + ) + + +def transfer_target_map() -> dict[str, str]: + raw = os.getenv("ASTERISK_TRANSFER_TARGET_MAP_JSON", "{}").strip() + try: + payload = json.loads(raw or "{}") + except json.JSONDecodeError as exc: + raise RuntimeError("Invalid ASTERISK_TRANSFER_TARGET_MAP_JSON") from exc + if not isinstance(payload, dict): + raise RuntimeError("ASTERISK_TRANSFER_TARGET_MAP_JSON must be a JSON object") + return {str(k).strip(): str(v).strip() for k, v in payload.items() if str(k).strip() and str(v).strip()} + + +def claim_context() -> str: + return os.getenv("ASTERISK_CALLCONTROL_CLAIM_CONTEXT", "mvpcc-claim").strip() or "mvpcc-claim" + + +def transfer_context() -> str: + return os.getenv("ASTERISK_CALLCONTROL_TRANSFER_CONTEXT", "mvpcc-transfer").strip() or "mvpcc-transfer" + + +def bridge_auth_mode() -> str: + mode = os.getenv("ASTERISK_BRIDGE_AUTH_MODE", "legacy_headers").strip().lower() + if mode in {"legacy_headers", "bearer", "bearer_first"}: + return mode + return "legacy_headers" + + +def bridge_auth_fallback_legacy() -> bool: + return bool_env("ASTERISK_BRIDGE_AUTH_FALLBACK_LEGACY", True) + + +def bridge_auth_user() -> str: + return os.getenv("ASTERISK_BRIDGE_AUTH_USER", "asterisk-bridge").strip() or "asterisk-bridge" + + +def bridge_auth_role() -> str: + return os.getenv("ASTERISK_BRIDGE_AUTH_ROLE", "admin").strip().lower() or "admin" + + +def bridge_auth_subject() -> str: + return os.getenv("ASTERISK_BRIDGE_AUTH_SUBJECT", "svc:asterisk-bridge").strip() or "svc:asterisk-bridge" + + +def bridge_auth_token_ttl_seconds() -> int: + raw = os.getenv("ASTERISK_BRIDGE_AUTH_TOKEN_TTL_SECONDS", "300").strip() + try: + value = int(raw) + except ValueError: + value = 300 + return max(value, 60) + + +def ai_voice_runtime_trusted_subjects() -> set[str]: + raw = os.getenv( + "AI_VOICE_RUNTIME_TRUSTED_SERVICE_SUBJECTS", + "svc:ai-voice-runtime", + ) + return { + item.strip() + for item in raw.split(",") + if item.strip() + } diff --git a/services/asterisk_bridge_service/http_client.py b/services/asterisk_bridge_service/http_client.py new file mode 100644 index 0000000..fdfad64 --- /dev/null +++ b/services/asterisk_bridge_service/http_client.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +from typing import Any, Callable + + +def legacy_headers(*, bridge_auth_user: str, bridge_auth_role: str) -> dict[str, str]: + return {"X-User": bridge_auth_user, "X-Role": bridge_auth_role} + + +def bearer_headers(*, issue_token: Callable[[], str]) -> dict[str, str]: + return {"Authorization": f"Bearer {issue_token()}"} + + +def request_with_bridge_auth( + *, + client: Any, + httpx_module: Any, + method: str, + url: str, + bridge_auth_mode: str, + bridge_auth_fallback_legacy: bool, + legacy_headers_payload: dict[str, str], + bearer_headers_payload: dict[str, str], + retry_reset: Callable[[], None] | None = None, + **kwargs: Any, +) -> Any: + mode = bridge_auth_mode + + if mode == "legacy_headers": + response = client.request(method, url, headers=legacy_headers_payload, **kwargs) + response.raise_for_status() + return response + + if mode == "bearer": + response = client.request(method, url, headers=bearer_headers_payload, **kwargs) + response.raise_for_status() + return response + + response = client.request(method, url, headers=bearer_headers_payload, **kwargs) + if ( + mode == "bearer_first" + and bridge_auth_fallback_legacy + and response.status_code in {401, 403} + ): + if retry_reset: + retry_reset() + response = client.request(method, url, headers=legacy_headers_payload, **kwargs) + response.raise_for_status() + return response + + +def request_json( + *, + httpx_module: Any, + send_request: Callable[..., Any], + method: str, + url: str, + payload: dict[str, Any], + timeout_seconds: float | None, + max_attempts: int | None, + retry_backoff_seconds: float | None, + forward_timeout_seconds: Callable[[float], float], + forward_max_attempts: Callable[[], int], + forward_retry_backoff_seconds: Callable[[], float], + sleep: Callable[[float], None], +) -> dict[str, Any]: + last_exc: Exception | None = None + resolved_timeout = timeout_seconds if timeout_seconds is not None else forward_timeout_seconds(45.0) + resolved_attempts = max(max_attempts if max_attempts is not None else forward_max_attempts(), 1) + resolved_backoff = max( + retry_backoff_seconds if retry_backoff_seconds is not None else forward_retry_backoff_seconds(), + 0.0, + ) + with httpx_module.Client(timeout=resolved_timeout) as client: + for attempt in range(1, resolved_attempts + 1): + try: + response = send_request( + client, + method=method, + url=url, + json=payload, + ) + return response.json() + except httpx_module.HTTPStatusError as exc: + last_exc = exc + status = exc.response.status_code if exc.response is not None else 0 + if status < 500 or attempt >= resolved_attempts: + raise + except httpx_module.RequestError as exc: + last_exc = exc + if attempt >= resolved_attempts: + raise + if resolved_backoff > 0: + sleep(resolved_backoff * attempt) + if last_exc is not None: + raise last_exc + raise RuntimeError(f"Failed to {method} JSON payload") diff --git a/services/asterisk_bridge_service/ivr_fastagi.py b/services/asterisk_bridge_service/ivr_fastagi.py new file mode 100644 index 0000000..36445bf --- /dev/null +++ b/services/asterisk_bridge_service/ivr_fastagi.py @@ -0,0 +1,616 @@ +from __future__ import annotations + +from dataclasses import dataclass +import logging +import socket +import threading +import time +from typing import BinaryIO + +from services.shared.core import utc_now_iso +from services.shared.db import get_session + + +LOGGER = logging.getLogger(__name__) + + +def _bridge_app(): + import services.asterisk_bridge_service.app as bridge_app + + return bridge_app + + +class AgiHangup(RuntimeError): + pass + + +@dataclass(slots=True) +class AgiRequest: + env: dict[str, str] + args: list[str] + peer: str | None = None + + +def parse_agi_request(reader: BinaryIO, *, peer: str | None = None) -> AgiRequest: + env: dict[str, str] = {} + while True: + raw_line = reader.readline() + if not raw_line: + raise EOFError("AGI request ended before headers were fully received") + line = raw_line.decode("utf-8", "replace").strip() + if not line: + break + if ":" not in line: + continue + key, value = line.split(":", 1) + env[key.strip()] = value.strip() + + args: list[str] = [] + index = 1 + while True: + key = f"agi_arg_{index}" + if key not in env: + break + args.append(str(env.get(key) or "").strip()) + index += 1 + return AgiRequest(env=env, args=args, peer=peer) + + +class SimpleAgiChannel: + def __init__(self, reader: BinaryIO, writer: BinaryIO) -> None: + self._reader = reader + self._writer = writer + + def _command(self, command: str) -> str: + self._writer.write(f"{command}\n".encode("utf-8")) + self._writer.flush() + while True: + raw_line = self._reader.readline() + if not raw_line: + raise AgiHangup("AGI channel closed") + line = raw_line.decode("utf-8", "replace").strip() + if not line: + continue + if line.startswith("200 "): + return line + if line.startswith(("510", "511", "520")): + raise RuntimeError(f"AGI command failed: {line}") + + def _result_code(self, response: str) -> int: + marker = "result=" + if marker not in response: + raise RuntimeError(f"Missing AGI result code: {response}") + value = response.split(marker, 1)[1].split(" ", 1)[0].strip() + try: + result = int(value) + except ValueError as exc: + raise RuntimeError(f"Invalid AGI result code: {response}") from exc + if result == -1: + raise AgiHangup("Asterisk reported hangup") + return result + + def verbose(self, message: str, *, level: int = 1) -> None: + safe_message = str(message or "").replace('"', "'") + self._command(f'VERBOSE "{safe_message}" {max(level, 1)}') + + def play_and_wait_for_digit(self, prompt_audio_key: str, timeout_ms: int) -> str | None: + key = str(prompt_audio_key or "").strip() + if not key: + return None + # GET OPTION plays the prompt and waits for a matching digit during or immediately after playback. + result = self._result_code( + self._command(f'GET OPTION {key} "0123456789#*" {max(timeout_ms, 1)}') + ) + if result == 0: + return None + if 0 <= result <= 255: + return chr(result) + return str(result) + + def wait_for_digit(self, timeout_ms: int) -> str | None: + result = self._result_code(self._command(f"WAIT FOR DIGIT {max(timeout_ms, 1)}")) + if result == 0: + return None + if 0 <= result <= 255: + return chr(result) + return str(result) + + def set_variable(self, name: str, value: str | None) -> None: + variable_name = str(name or "").strip() + if not variable_name: + return + raw_value = str(value or "").replace('"', '\\"') + self._result_code(self._command(f'SET VARIABLE {variable_name} "{raw_value}"')) + + +def _prompt_audio_keys(current_node: dict) -> list[str]: + prompt_sequence = current_node.get("prompt_sequence") + if isinstance(prompt_sequence, list): + keys: list[str] = [] + for prompt in prompt_sequence: + if not isinstance(prompt, dict): + continue + key = str(prompt.get("prompt_audio_key") or "").strip() + if key: + keys.append(key) + if keys: + return keys + prompt_audio_key = str(current_node.get("prompt_audio_key") or "").strip() + if prompt_audio_key: + return [prompt_audio_key] + return [] + + +def _play_current_node_prompt( + channel: SimpleAgiChannel, + current_node: dict, + *, + timeout_ms: int, +) -> str | None: + prompt_keys = _prompt_audio_keys(current_node) + if not prompt_keys: + return None + # Keep inter-prompt gaps minimal: capture digits during each playback, then + # perform a single normal wait after the full prompt sequence. + for prompt_audio_key in prompt_keys: + digit = channel.play_and_wait_for_digit(prompt_audio_key, 1) + if digit is not None: + return digit + return None + + +@dataclass(slots=True) +class _CallContext: + call_id: str + linked_id: str | None + queue_code: str + fallback_queue_code: str | None + entry_extension: str | None + + +def _parse_call_context(request: AgiRequest) -> _CallContext: + call_id = str(request.args[0] if len(request.args) >= 1 else request.env.get("agi_uniqueid") or "").strip() + linked_id = str(request.args[1] if len(request.args) >= 2 else "").strip() or None + queue_code = str(request.args[2] if len(request.args) >= 3 else "").strip() + fallback_queue_code = str(request.args[3] if len(request.args) >= 4 else "").strip() or None + entry_extension = str(request.args[4] if len(request.args) >= 5 else "").strip() or None + if not call_id: + raise RuntimeError("Missing CallID for IVR FastAGI request") + if not queue_code: + raise RuntimeError("Missing queue_code for IVR FastAGI request") + return _CallContext( + call_id=call_id, + linked_id=linked_id, + queue_code=queue_code, + fallback_queue_code=fallback_queue_code, + entry_extension=entry_extension, + ) + + +def _queue_code_for_queue_id(queue_id: str | None) -> str | None: + bridge = _bridge_app() + target = str(queue_id or "").strip() + if not target: + return None + for queue_code, mapped_queue_id in bridge._queue_map().items(): + if str(mapped_queue_id).strip() == target: + return str(queue_code).strip() or None + return None + + +def _resolve_transfer_extension(queue_code: str | None) -> str | None: + bridge = _bridge_app() + target_code = str(queue_code or "").strip() + if not target_code: + return None + extension = str(bridge._transfer_target_map().get(target_code) or "").strip() + return extension or None + + +def _set_transfer_target( + channel: SimpleAgiChannel, + *, + queue_code: str | None, + result: str, + reuse_existing_call: bool = False, +) -> bool: + target_extension = _resolve_transfer_extension(queue_code) + if not target_extension: + return False + channel.set_variable("MVPCC_IVR_RESULT", result) + channel.set_variable("MVPCC_IVR_TARGET_QUEUE_CODE", queue_code) + channel.set_variable("MVPCC_IVR_TARGET_EXTENSION", target_extension) + if reuse_existing_call and target_extension == "7100": + channel.set_variable("MVPCC_AI_QUEUE_OVERRIDE", queue_code) + channel.set_variable("MVPCC_AI_REUSE_CALL", "1") + else: + channel.set_variable("MVPCC_AI_QUEUE_OVERRIDE", "") + channel.set_variable("MVPCC_AI_REUSE_CALL", "") + return True + + +def _fallback_to_human(channel: SimpleAgiChannel, fallback_queue_code: str | None, *, reason: str) -> None: + if _set_transfer_target(channel, queue_code=fallback_queue_code, result=reason): + return + channel.set_variable("MVPCC_IVR_RESULT", reason) + + +def _wait_for_call_link_context(call_id: str, *, stop_event: threading.Event | None = None) -> dict[str, str | None] | None: + bridge = _bridge_app() + deadline = time.monotonic() + bridge._ivr_call_link_wait_seconds() + while True: + session = get_session() + try: + link = bridge._find_call_link(session, call_id) + if link is not None: + return { + "interaction_id": str(link.interaction_id or "").strip() or None, + "queue_id": str(link.queue_id or "").strip() or None, + "queue_code": str(link.queue_code or "").strip() or None, + "caller_number": str(link.caller_number or "").strip() or None, + "caller_name": str(link.caller_name or "").strip() or None, + } + finally: + session.close() + if stop_event is not None and stop_event.is_set(): + return None + if time.monotonic() >= deadline: + return None + time.sleep(0.25) + + +def _ivr_runtime_url(path: str) -> str: + bridge = _bridge_app() + return f"{bridge._ivr_service_url()}{path}" + + +def _start_session(call_context: _CallContext, *, interaction_id: str | None, queue_id: str) -> dict: + bridge = _bridge_app() + return bridge._post_json( + _ivr_runtime_url("/ivr/sessions/start"), + { + "call_id": call_context.call_id, + "queue_id": queue_id, + "interaction_id": interaction_id, + }, + timeout_seconds=bridge._forward_timeout_seconds(10.0), + max_attempts=1, + retry_backoff_seconds=0.0, + ) + + +def _send_dtmf(session_id: str, digit: str) -> dict: + bridge = _bridge_app() + return bridge._post_json( + _ivr_runtime_url(f"/ivr/sessions/{session_id}/dtmf"), + {"digit": digit}, + timeout_seconds=bridge._forward_timeout_seconds(10.0), + max_attempts=1, + retry_backoff_seconds=0.0, + ) + + +def _send_no_input(session_id: str) -> dict: + bridge = _bridge_app() + return bridge._post_json( + _ivr_runtime_url(f"/ivr/sessions/{session_id}/no-input"), + {}, + timeout_seconds=bridge._forward_timeout_seconds(10.0), + max_attempts=1, + retry_backoff_seconds=0.0, + ) + + +def _abandon_session(session_id: str | None) -> None: + bridge = _bridge_app() + normalized = str(session_id or "").strip() + if not normalized: + return + try: + bridge._post_json( + _ivr_runtime_url(f"/ivr/sessions/{normalized}/abandon"), + {}, + timeout_seconds=bridge._forward_timeout_seconds(5.0), + max_attempts=1, + retry_backoff_seconds=0.0, + ) + except Exception: + LOGGER.debug("Unable to abandon IVR session %s", normalized, exc_info=True) + + +def _resolve_completed_queue_code(session_payload: dict, fallback_queue_code: str | None) -> str | None: + resolved_queue_code = str(session_payload.get("resolved_queue_code") or "").strip() + if resolved_queue_code: + return resolved_queue_code + resolved_queue_id = str(session_payload.get("resolved_queue_id") or "").strip() + return _queue_code_for_queue_id(resolved_queue_id) or fallback_queue_code + + +def _start_ai_session_for_ivr_transfer( + *, + call_context: _CallContext, + interaction_id: str | None, + queue_code: str | None, + caller_number: str | None, + caller_name: str | None, +) -> bool: + bridge = _bridge_app() + target_queue_code = str(queue_code or "").strip() + if not target_queue_code: + return False + resolve_ai_config = getattr(bridge, "_ai_voice_config_for_queue", None) + start_voice_ai_session = getattr(bridge, "_start_voice_ai_session", None) + find_call_link = getattr(bridge, "_find_call_link", None) + append_timeline = getattr(bridge, "_append_interaction_timeline", None) + if not callable(resolve_ai_config) or not callable(start_voice_ai_session) or not callable(find_call_link): + return False + ai_config = resolve_ai_config(target_queue_code) + if not ai_config: + return False + queue_id = str(bridge._queue_map().get(target_queue_code) or "").strip() + active_interaction_id = str(interaction_id or "").strip() + if not queue_id or not active_interaction_id: + return False + if _resolve_transfer_extension(target_queue_code) != "7100": + return False + + started = start_voice_ai_session( + call_id=call_context.call_id, + linked_id=call_context.linked_id, + interaction_id=active_interaction_id, + queue_id=queue_id, + queue_code=target_queue_code, + caller_number=caller_number, + caller_name=caller_name, + ai_config=ai_config, + ) + + now = utc_now_iso() + session = get_session() + try: + link = find_call_link(session, call_context.call_id) + if link is not None: + link.queue_code = target_queue_code + link.queue_id = queue_id + link.interaction_id = active_interaction_id + link.voice_session_id = str((started or {}).get("voice_session_id") or "").strip() or link.voice_session_id + link.ai_session_id = str((started or {}).get("ai_session_id") or "").strip() or link.ai_session_id + link.ai_state = str((started or {}).get("status") or "queued").strip() or "queued" + link.ai_handoff_reason = None + link.ai_last_model_at = now + link.updated_at = now + session.commit() + else: + session.rollback() + finally: + session.close() + + try: + if callable(append_timeline): + append_timeline( + interaction_id=active_interaction_id, + action="ai.session_started", + metadata={ + "call_id": call_context.call_id, + "queue_code": target_queue_code, + "voice_session_id": str((started or {}).get("voice_session_id") or "").strip() or None, + "ai_session_id": str((started or {}).get("ai_session_id") or "").strip() or None, + "source": "ivr-transfer", + }, + ) + except Exception: + LOGGER.debug( + "Unable to append IVR AI session timeline for %s", + call_context.call_id, + exc_info=True, + ) + return True + + +def _handle_completed_session( + channel: SimpleAgiChannel, + call_context: _CallContext, + session_payload: dict, + *, + interaction_id: str | None, + caller_number: str | None, + caller_name: str | None, + fallback_queue_code: str | None, +) -> None: + queue_code = _resolve_completed_queue_code(session_payload, fallback_queue_code) + if _start_ai_session_for_ivr_transfer( + call_context=call_context, + interaction_id=interaction_id, + queue_code=queue_code, + caller_number=caller_number, + caller_name=caller_name, + ): + if _set_transfer_target( + channel, + queue_code=queue_code, + result="completed", + reuse_existing_call=True, + ): + return + _fallback_to_human(channel, fallback_queue_code, reason="fallback_missing_ai_transfer_target") + return + if _set_transfer_target(channel, queue_code=queue_code, result="completed"): + return + _fallback_to_human(channel, fallback_queue_code, reason="fallback_missing_transfer_target") + + +def run_ivr_call( + channel: SimpleAgiChannel, + request: AgiRequest, + *, + stop_event: threading.Event | None = None, +) -> None: + bridge = _bridge_app() + call_context: _CallContext | None = None + session_id: str | None = None + no_input_retries = 0 + invalid_retries = 0 + + try: + call_context = _parse_call_context(request) + link_context = _wait_for_call_link_context(call_context.call_id, stop_event=stop_event) or {} + queue_id = ( + str(link_context.get("queue_id") or "").strip() + or str(bridge._queue_map().get(call_context.queue_code) or "").strip() + ) + interaction_id = str(link_context.get("interaction_id") or "").strip() or None + caller_number = str(link_context.get("caller_number") or "").strip() or None + caller_name = str(link_context.get("caller_name") or "").strip() or None + if not queue_id: + raise RuntimeError(f"Unknown IVR queue_code: {call_context.queue_code}") + + started = _start_session(call_context, interaction_id=interaction_id, queue_id=queue_id) + session_payload = started.get("session") or {} + session_id = str(session_payload.get("session_id") or "").strip() or None + current_node = started.get("current_node") if isinstance(started.get("current_node"), dict) else {} + if session_id: + channel.set_variable("MVPCC_IVR_SESSION_ID", session_id) + channel.verbose(f"IVR session started for call {call_context.call_id}", level=1) + + while stop_event is None or not stop_event.is_set(): + digit: str | None = None + digit = _play_current_node_prompt( + channel, + current_node, + timeout_ms=bridge._ivr_dtmf_timeout_seconds() * 1000, + ) + + if digit is None: + digit = channel.wait_for_digit(bridge._ivr_dtmf_timeout_seconds() * 1000) + if digit is None: + no_input_retries += 1 + invalid_retries = 0 + response = _send_no_input(session_id or "") + session_payload = response.get("session") or {} + current_node = response.get("current_node") if isinstance(response.get("current_node"), dict) else {} + if response.get("completed"): + _handle_completed_session( + channel, + call_context, + session_payload, + interaction_id=interaction_id, + caller_number=caller_number, + caller_name=caller_name, + fallback_queue_code=call_context.fallback_queue_code, + ) + return + if no_input_retries > bridge._ivr_max_no_input_retries(): + _abandon_session(session_id) + _fallback_to_human(channel, call_context.fallback_queue_code, reason="fallback_no_input") + return + continue + + no_input_retries = 0 + current_options = { + str(option.get("digit") or "").strip() + for option in current_node.get("options", []) + if isinstance(option, dict) and str(option.get("digit") or "").strip() + } + if digit not in current_options: + invalid_retries += 1 + else: + invalid_retries = 0 + + response = _send_dtmf(session_id or "", digit) + session_payload = response.get("session") or {} + current_node = response.get("current_node") if isinstance(response.get("current_node"), dict) else {} + if response.get("completed"): + _handle_completed_session( + channel, + call_context, + session_payload, + interaction_id=interaction_id, + caller_number=caller_number, + caller_name=caller_name, + fallback_queue_code=call_context.fallback_queue_code, + ) + return + if digit not in current_options and invalid_retries > bridge._ivr_max_invalid_retries(): + _abandon_session(session_id) + _fallback_to_human(channel, call_context.fallback_queue_code, reason="fallback_invalid") + return + + _abandon_session(session_id) + _fallback_to_human(channel, call_context.fallback_queue_code, reason="fallback_shutdown") + except AgiHangup: + _abandon_session(session_id) + except Exception: + LOGGER.exception( + "IVR FastAGI call failed for %s", + call_context.call_id if call_context is not None else "", + ) + _abandon_session(session_id) + try: + _fallback_to_human( + channel, + call_context.fallback_queue_code if call_context is not None else None, + reason="fallback_error", + ) + except Exception: + LOGGER.debug( + "Unable to set IVR fallback target for %s", + call_context.call_id if call_context is not None else "", + exc_info=True, + ) + + +def _handle_client_connection(conn: socket.socket, addr: tuple[str, int], *, stop_event: threading.Event | None) -> None: + peer = f"{addr[0]}:{addr[1]}" + with conn: + reader = conn.makefile("rb") + writer = conn.makefile("wb") + try: + request = parse_agi_request(reader, peer=peer) + run_ivr_call(SimpleAgiChannel(reader, writer), request, stop_event=stop_event) + finally: + try: + reader.close() + finally: + writer.close() + + +def fastagi_loop(stop_event: threading.Event | None = None) -> None: + bridge = _bridge_app() + active_stop_event = stop_event or bridge._background_stop_event() + if not bridge._bridge_enabled() or not bridge._ivr_fastagi_enabled(): + while not active_stop_event.is_set(): + if active_stop_event.wait(0.5): + break + return + + bind_host = bridge._ivr_fastagi_host() + bind_port = bridge._ivr_fastagi_port() + server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + server.bind((bind_host, bind_port)) + server.listen() + server.settimeout(1.0) + LOGGER.info("IVR FastAGI listener started on %s:%s", bind_host, bind_port) + try: + while not active_stop_event.is_set(): + try: + conn, addr = server.accept() + except socket.timeout: + continue + except OSError: + if active_stop_event.is_set(): + break + raise + thread = threading.Thread( + target=_handle_client_connection, + args=(conn, addr), + kwargs={"stop_event": active_stop_event}, + daemon=True, + name=f"asterisk-ivr-fastagi-client-{addr[0]}:{addr[1]}", + ) + thread.start() + finally: + try: + server.close() + except OSError: + pass diff --git a/services/asterisk_bridge_service/presenters.py b/services/asterisk_bridge_service/presenters.py new file mode 100644 index 0000000..cbac28a --- /dev/null +++ b/services/asterisk_bridge_service/presenters.py @@ -0,0 +1,209 @@ +from __future__ import annotations + +import json +import sys +from datetime import datetime +from typing import Any + +from sqlalchemy import select + +from services.shared.models import AsteriskEventOut, VoiceCallActionOut, VoiceLiveCallOut +from services.shared.sql_models import ( + AsteriskCallActionLogRow, + AsteriskCallLinkRow, + AsteriskEventLogRow, + VoiceEventRow, +) + + +def _bridge_app(): + return sys.modules["services.asterisk_bridge_service.app"] + + +def to_out(row: AsteriskEventLogRow) -> AsteriskEventOut: + return AsteriskEventOut( + bridge_event_id=row.bridge_event_id, + ami_event_name=row.ami_event_name, + call_id=row.call_id, + linked_id=row.linked_id, + interaction_id=row.interaction_id, + recording_id=row.recording_id, + forward_status=row.forward_status, + payload=json.loads(row.payload_json or "{}"), + last_error=row.last_error, + created_at=row.created_at, + updated_at=row.updated_at, + ) + + +def action_to_out(row: AsteriskCallActionLogRow) -> VoiceCallActionOut: + return VoiceCallActionOut( + action_id=row.action_id, + call_id=row.call_id, + interaction_id=row.interaction_id, + action_type=row.action_type, + actor_user=row.actor_user, + actor_role=row.actor_role, + request=json.loads(row.request_json or "{}"), + result_status=row.result_status, # type: ignore[arg-type] + ami_action_id=row.ami_action_id, + error=row.error, + created_at=row.created_at, + ) + + +def safe_json_loads(raw: str | None) -> dict[str, Any]: + try: + payload = json.loads(raw or "{}") + except Exception: # noqa: BLE001 + return {} + return payload if isinstance(payload, dict) else {} + + +def latest_voice_event_row( + session, + *, + call_id: str, + event_type: str, + include_reconciled: bool = True, +) -> VoiceEventRow | None: + rows = session.execute( + select(VoiceEventRow) + .where(VoiceEventRow.call_id == call_id) + .where(VoiceEventRow.event_type == event_type) + .order_by(VoiceEventRow.id.desc()) + .limit(20) + ).scalars().all() + if include_reconciled: + return rows[0] if rows else None + for row in rows: + payload = safe_json_loads(row.payload_json) + if payload.get("reconciled"): + continue + return row + return None + + +def latest_successful_action_row( + session, + *, + call_id: str, + action_types: tuple[str, ...], +) -> AsteriskCallActionLogRow | None: + rows = session.execute( + select(AsteriskCallActionLogRow) + .where(AsteriskCallActionLogRow.call_id == call_id) + .where(AsteriskCallActionLogRow.result_status == "ok") + .order_by(AsteriskCallActionLogRow.id.desc()) + .limit(20) + ).scalars().all() + for row in rows: + if row.action_type in action_types: + return row + return None + + +def latest_iso(*values: str | None) -> str | None: + bridge = _bridge_app() + parsed: list[tuple[datetime, str]] = [] + for value in values: + iso = str(value or "").strip() + dt = bridge._parse_iso(iso) + if dt is None: + continue + parsed.append((dt, iso)) + if not parsed: + return None + parsed.sort(key=lambda item: item[0], reverse=True) + return parsed[0][1] + + +def hangup_cause_for_call(session, *, call_id: str) -> str | None: + row = latest_voice_event_row( + session, + call_id=call_id, + event_type="call.ended", + include_reconciled=False, + ) or latest_voice_event_row( + session, + call_id=call_id, + event_type="call.ended", + include_reconciled=True, + ) + if row is None: + return None + payload = safe_json_loads(row.payload_json) + value = str(payload.get("hangup_cause") or "").strip() + return value or None + + +def terminal_action_for_call( + session, + *, + call_id: str, + row: AsteriskCallLinkRow, +) -> tuple[str | None, str | None, str | None]: + bridge = _bridge_app() + action = latest_successful_action_row( + session, + call_id=call_id, + action_types=("blind-transfer", "hangup"), + ) + if action is not None: + request_payload = safe_json_loads(action.request_json) + target = None + if action.action_type == "blind-transfer": + target = str( + request_payload.get("target_value") + or request_payload.get("resolved_extension") + or "" + ).strip() or None + return action.action_type, action.created_at, target + if bridge._call_is_ended(row): + return "ended", row.ended_at or row.updated_at, None + return None, None, None + + +def live_call_to_out(session, row: AsteriskCallLinkRow) -> VoiceLiveCallOut: + bridge = _bridge_app() + has_recording = bridge._has_recording(session, call_id=row.call_id) + terminal_action, terminal_at, terminal_target = terminal_action_for_call( + session, + call_id=row.call_id, + row=row, + ) + last_transition_at = latest_iso( + terminal_at, + row.connected_at, + row.claimed_at, + row.started_at, + row.updated_at, + ) + return VoiceLiveCallOut( + call_id=row.call_id, + interaction_id=row.interaction_id, + queue_id=row.queue_id, + queue_code=row.queue_code, + caller_number=row.caller_number, + caller_name=row.caller_name, + status=row.status, + telephony_status=row.telephony_status, # type: ignore[arg-type] + claimed_by_user=row.claimed_by_user, + claimed_at=row.claimed_at, + operator_extension=row.operator_extension, + channel_name=row.channel_name, + started_at=row.started_at, + connected_at=row.connected_at, + ended_at=row.ended_at, + updated_at=row.updated_at, + last_transition_at=last_transition_at, + hangup_cause=hangup_cause_for_call(session, call_id=row.call_id), + terminal_action=terminal_action, + terminal_target=terminal_target, + voice_session_id=row.voice_session_id, + ai_session_id=row.ai_session_id, + ai_state=row.ai_state, # type: ignore[arg-type] + ai_handoff_reason=row.ai_handoff_reason, + ai_last_model_at=row.ai_last_model_at, + has_recording=has_recording, + ) diff --git a/services/asterisk_bridge_service/reconcile.py b/services/asterisk_bridge_service/reconcile.py new file mode 100644 index 0000000..9b1ff58 --- /dev/null +++ b/services/asterisk_bridge_service/reconcile.py @@ -0,0 +1,246 @@ +from __future__ import annotations + +from datetime import datetime, timezone + +from sqlalchemy import select + +from services.shared.db import get_session +from services.shared.sql_models import ( + AsteriskCallLinkRow, + CallRecordingRow, + VoiceAISessionRow, + VoiceEventRow, +) + + +def _bridge_app(): + import services.asterisk_bridge_service.app as bridge_app + + return bridge_app + + +def parse_iso(value: str | None) -> datetime | None: + text = str(value or "").strip() + if not text: + return None + try: + parsed = datetime.fromisoformat(text) + except ValueError: + return None + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed.astimezone(timezone.utc) + + +def has_voice_event(session, *, call_id: str, event_type: str) -> bool: + row = session.execute( + select(VoiceEventRow.id) + .where(VoiceEventRow.call_id == call_id) + .where(VoiceEventRow.event_type == event_type) + .limit(1) + ).scalar_one_or_none() + return row is not None + + +def has_recording(session, *, call_id: str) -> bool: + row = session.execute( + select(CallRecordingRow.id).where(CallRecordingRow.call_id == call_id).limit(1) + ).scalar_one_or_none() + return row is not None + + +def latest_recording_for_call(session, *, call_id: str) -> CallRecordingRow | None: + return session.execute( + select(CallRecordingRow) + .where(CallRecordingRow.call_id == call_id) + .order_by(CallRecordingRow.id.desc()) + .limit(1) + ).scalar_one_or_none() + + +def _voice_session_recently_active(session, *, link: AsteriskCallLinkRow, now: datetime) -> bool: + bridge = _bridge_app() + if str(link.ai_state or "").strip() not in { + "queued", + "greeting", + "active", + "thinking", + "speaking", + "listening", + "handoff_requested", + "handoff_required", + }: + return False + voice_session_id = str(link.voice_session_id or "").strip() + if not voice_session_id: + return False + voice_session = session.execute( + select(VoiceAISessionRow).where(VoiceAISessionRow.session_id == voice_session_id) + ).scalar_one_or_none() + if voice_session is None: + return False + recent_points = [ + bridge._parse_iso(voice_session.last_media_frame_at), + bridge._parse_iso(voice_session.last_user_utterance_at), + bridge._parse_iso(voice_session.last_ai_reply_at), + bridge._parse_iso(voice_session.updated_at), + ] + recent_points = [point for point in recent_points if point is not None] + if not recent_points: + return False + latest_point = max(recent_points) + grace_seconds = max(bridge._reconcile_stale_seconds(), 12) + 8 + return max((now - latest_point).total_seconds(), 0) <= grace_seconds + + +def reconcile_stale_calls_once() -> None: + bridge = _bridge_app() + now = datetime.now(timezone.utc) + session = get_session() + try: + unresolved_recording = ( + select(CallRecordingRow.id) + .where(CallRecordingRow.call_id == AsteriskCallLinkRow.call_id) + .correlate(AsteriskCallLinkRow) + .exists() + ) + active_ids = session.execute( + select(AsteriskCallLinkRow.id) + .where(AsteriskCallLinkRow.status == "active") + .order_by(AsteriskCallLinkRow.id.asc()) + .limit(bridge._reconcile_scan_limit()) + ).scalars().all() + ended_ids = session.execute( + select(AsteriskCallLinkRow.id) + .where(AsteriskCallLinkRow.status == "ended") + .where(~unresolved_recording) + .order_by(AsteriskCallLinkRow.id.asc()) + .limit(bridge._reconcile_scan_limit()) + ).scalars().all() + finally: + session.close() + + for link_id in active_ids: + session = get_session() + try: + link = session.execute( + select(AsteriskCallLinkRow).where(AsteriskCallLinkRow.id == link_id) + ).scalar_one_or_none() + if link is None: + continue + + started_dt = bridge._parse_iso(link.started_at) or bridge._parse_iso(link.updated_at) or now + age_seconds = max((now - started_dt).total_seconds(), 0) + if age_seconds < bridge._reconcile_stale_seconds(): + continue + + live_channels = bridge._list_channels_via_coreshowchannels( + call_id=link.call_id, + linked_id=link.linked_id, + ) + if live_channels is None: + continue + if live_channels: + preferred_channel = ( + bridge._operator_channel_for_extension(live_channels, link.operator_extension) + or live_channels[0] + ) + if preferred_channel != link.channel_name: + link.channel_name = preferred_channel + link.updated_at = bridge.utc_now_iso() + session.commit() + continue + + if _voice_session_recently_active(session, link=link, now=now): + link.updated_at = bridge.utc_now_iso() + session.commit() + continue + + ended_exists = bridge._has_voice_event(session, call_id=link.call_id, event_type="call.ended") + if not ended_exists: + bridge._emit_voice_event( + event_type="call.ended", + call_id=link.call_id, + interaction_id=link.interaction_id, + source_event_id=bridge._reconcile_source_event_id(link.call_id, "call.ended"), + payload={ + "source": "asterisk", + "hangup_cause": "unknown", + "duration_seconds": None, + "linked_id": link.linked_id, + "reconciled": True, + }, + ) + now_iso = bridge.utc_now_iso() + link.status = "ended" + link.telephony_status = "ended" + link.ended_at = link.ended_at or now_iso + link.updated_at = now_iso + session.commit() + except Exception: + session.rollback() + continue + finally: + session.close() + + for link_id in ended_ids: + session = get_session() + try: + link = session.execute( + select(AsteriskCallLinkRow).where(AsteriskCallLinkRow.id == link_id) + ).scalar_one_or_none() + if link is None: + continue + + ready_exists = bridge._has_voice_event(session, call_id=link.call_id, event_type="recording.ready") + recording_exists = bridge._has_recording(session, call_id=link.call_id) + if recording_exists: + continue + + candidate = bridge._find_remote_recording_for_call(link.call_id) + if candidate is None: + continue + remote_path, file_name, mime_type, mtime_dt = candidate + idle_seconds = max((now - mtime_dt).total_seconds(), 0) + if idle_seconds < bridge._reconcile_settle_seconds(): + continue + + if not ready_exists: + bridge._emit_voice_event( + event_type="recording.ready", + call_id=link.call_id, + interaction_id=link.interaction_id, + source_event_id=bridge._reconcile_source_event_id(link.call_id, "recording.ready"), + payload={ + "source": "asterisk", + "remote_path": remote_path, + "file_name": file_name, + "mime_type": mime_type, + "duration_seconds": None, + "linked_id": link.linked_id, + "reconciled": True, + }, + ) + + local_path, cleanup_required = bridge._fetch_recording_file(remote_path, file_name) + try: + bridge._upload_recording( + local_path=local_path, + call_id=link.call_id, + interaction_id=link.interaction_id, + source_event_id=bridge._reconcile_source_event_id(link.call_id, "recording.import"), + file_name=file_name, + mime_type=mime_type, + duration_seconds=None, + ) + finally: + if cleanup_required and local_path.exists(): + local_path.unlink(missing_ok=True) + + link.updated_at = bridge.utc_now_iso() + session.commit() + except Exception: + session.rollback() + continue + finally: + session.close() diff --git a/services/asterisk_bridge_service/recording_io.py b/services/asterisk_bridge_service/recording_io.py new file mode 100644 index 0000000..384b329 --- /dev/null +++ b/services/asterisk_bridge_service/recording_io.py @@ -0,0 +1,114 @@ +from __future__ import annotations + +import os +import sys +import tempfile +from datetime import datetime, timezone +from pathlib import Path + + +def _bridge_app(): + return sys.modules["services.asterisk_bridge_service.app"] + + +def fetch_recording_file(remote_path: str, file_name: str | None) -> tuple[Path, bool]: + bridge = _bridge_app() + source = Path(remote_path) + if source.is_absolute() and source.exists(): + return source.resolve(), False + + if not bridge._sftp_enabled(): + raise RuntimeError("SFTP is not configured and remote_path is not a local file") + + try: + import paramiko # type: ignore + except ImportError as exc: # pragma: no cover - depends on runtime package set + raise RuntimeError("paramiko is required for SFTP recording transfer") from exc + + safe_name = file_name or Path(remote_path).name or "asterisk-call.wav" + suffix = Path(safe_name).suffix or ".wav" + handle = tempfile.NamedTemporaryFile(prefix="mvpcc-ast-", suffix=suffix, delete=False) + handle.close() + target = Path(handle.name) + + transport = paramiko.Transport((bridge._sftp_host(), bridge._sftp_port())) + try: + transport.connect(username=bridge._sftp_username(), password=bridge._sftp_password()) + sftp = paramiko.SFTPClient.from_transport(transport) + try: + sftp.get(remote_path, str(target)) + finally: + sftp.close() + finally: + transport.close() + return target.resolve(), True + + +def recording_search_directories() -> list[str]: + base = os.getenv("ASTERISK_SFTP_BASE_PATH", "/var/spool/asterisk/monitor").rstrip("/") + return [f"{base}/mvpcc", base] + + +def guess_mime_type(file_name: str) -> str: + lower = file_name.lower() + if lower.endswith(".mp3"): + return "audio/mpeg" + if lower.endswith(".ogg"): + return "audio/ogg" + return "audio/wav" + + +def find_remote_recording_for_call(call_id: str) -> tuple[str, str, str, datetime] | None: + bridge = _bridge_app() + suffixes = (".wav", ".mp3", ".ogg") + candidates: list[tuple[float, str, str]] = [] + + if bridge._sftp_enabled(): + try: + import paramiko # type: ignore + except ImportError: + return None + + transport = paramiko.Transport((bridge._sftp_host(), bridge._sftp_port())) + try: + transport.connect(username=bridge._sftp_username(), password=bridge._sftp_password()) + sftp = paramiko.SFTPClient.from_transport(transport) + try: + for directory in recording_search_directories(): + try: + names = sftp.listdir(directory) + except Exception: # noqa: BLE001 + continue + for name in names: + lower = name.lower() + if call_id not in name or not lower.endswith(suffixes): + continue + full_path = f"{directory}/{name}" + try: + st = sftp.stat(full_path) + except Exception: # noqa: BLE001 + continue + candidates.append((float(st.st_mtime), full_path, name)) + finally: + sftp.close() + finally: + transport.close() + else: + for directory in recording_search_directories(): + root = Path(directory) + if not root.exists(): + continue + for file_path in root.glob(f"*{call_id}*"): + if not file_path.is_file(): + continue + lower = file_path.name.lower() + if not lower.endswith(suffixes): + continue + candidates.append((file_path.stat().st_mtime, str(file_path), file_path.name)) + + if not candidates: + return None + candidates.sort(key=lambda item: item[0], reverse=True) + mtime, full_path, file_name = candidates[0] + mtime_dt = datetime.fromtimestamp(mtime, tz=timezone.utc) + return full_path, file_name, guess_mime_type(file_name), mtime_dt diff --git a/services/asterisk_bridge_service/routes.py b/services/asterisk_bridge_service/routes.py new file mode 100644 index 0000000..607fea5 --- /dev/null +++ b/services/asterisk_bridge_service/routes.py @@ -0,0 +1,184 @@ +from __future__ import annotations + +from fastapi import HTTPException +from sqlalchemy import select + +from services.shared.core import utc_now_iso +from services.shared.db import get_session +from services.shared.models import ( + HealthResponse, + VoiceAICallStateUpdateIn, + VoiceAIHandoffRequestIn, + VoiceAISummaryOut, + VoiceCallBlindTransferIn, + VoiceCallClaimIn, + VoiceLiveCallOut, +) +from services.shared.sql_models import AsteriskCallLinkRow, AsteriskEventLogRow + +from services.asterisk_bridge_service import call_control + + +def _bridge_app(): + import services.asterisk_bridge_service.app as bridge_app + + return bridge_app + + +def list_live_calls(include_ended: bool = False, limit: int = 100) -> list[VoiceLiveCallOut]: + bridge = _bridge_app() + session = get_session() + try: + stmt = select(AsteriskCallLinkRow).order_by(AsteriskCallLinkRow.id.desc()) + if not include_ended: + stmt = stmt.where(AsteriskCallLinkRow.status != "ended") + rows = session.execute(stmt).scalars().all() + result: list[VoiceLiveCallOut] = [] + for row in rows[: max(limit, 1)]: + if not row.interaction_id: + continue + if not include_ended and bridge._call_is_ended(row): + continue + result.append(bridge._live_call_to_out(session, row)) + return result + finally: + session.close() + + +def list_recent_calls(limit: int = 20) -> list[VoiceLiveCallOut]: + bridge = _bridge_app() + session = get_session() + try: + rows = session.execute( + select(AsteriskCallLinkRow) + .where(AsteriskCallLinkRow.status == "ended") + .order_by(AsteriskCallLinkRow.id.desc()) + .limit(max(limit * 4, limit, 20)) + ).scalars().all() + now = bridge.datetime.now(bridge.timezone.utc) + window_seconds = bridge._recent_calls_window_seconds() + result: list[tuple[bridge.datetime, VoiceLiveCallOut]] = [] + for row in rows: + if not row.interaction_id: + continue + payload = bridge._live_call_to_out(session, row) + transition_dt = bridge._parse_iso(payload.last_transition_at or row.ended_at or row.updated_at) + if transition_dt is None: + continue + if (now - transition_dt).total_seconds() > window_seconds: + continue + result.append((transition_dt, payload)) + result.sort(key=lambda item: item[0], reverse=True) + return [item for _, item in result[: max(limit, 1)]] + finally: + session.close() + + +def claim_live_call(call_id: str, body: VoiceCallClaimIn, actor: dict): + return call_control.claim_live_call(call_id, body, actor) + + +def hangup_live_call(call_id: str, actor: dict): + return call_control.hangup_live_call(call_id, actor) + + +def blind_transfer_live_call(call_id: str, body: VoiceCallBlindTransferIn, actor: dict): + return call_control.blind_transfer_live_call(call_id, body, actor) + + +def list_live_call_actions(call_id: str, limit: int, actor: dict): + return call_control.list_live_call_actions(call_id, limit, actor) + + +def get_live_call_ai_summary(call_id: str) -> VoiceAISummaryOut | None: + bridge = _bridge_app() + return bridge._voice_ai_summary_for_call(call_id) + + +def voice_ai_handoff_call(call_id: str, body: VoiceAIHandoffRequestIn, actor: dict) -> VoiceLiveCallOut: + bridge = _bridge_app() + return bridge._request_voice_ai_handoff(call_id, body, actor) + + +def voice_ai_update_call_state(call_id: str, body: VoiceAICallStateUpdateIn, actor: dict) -> VoiceLiveCallOut: + bridge = _bridge_app() + return bridge._update_voice_ai_call_state(call_id, body, actor) + + +def health() -> HealthResponse: + return HealthResponse(status="ok", service="asterisk-bridge-service") + + +def asterisk_status(): + bridge = _bridge_app() + snap = bridge._STATE.snapshot() + status = "ok" if bridge._bridge_enabled() else "disabled" + return bridge.AsteriskBridgeStatusOut( + status=status, + ami_connected=bool(snap["ami_connected"]), + ami_host=bridge._ami_host() or None, + last_event_at=snap["last_event_at"], + queue_codes_loaded=sorted(bridge._queue_map().keys()), + sftp_enabled=bridge._sftp_enabled(), + bridge_auth_mode=bridge._bridge_auth_mode(), + callcontrol_enabled=bridge._callcontrol_enabled(), + webrtc_enabled=bridge._webrtc_enabled(), + webrtc_ws_url=bridge._webrtc_ws_url() or None, + ) + + +def browser_softphone_config(actor: dict): + bridge = _bridge_app() + return bridge._browser_softphone_config_for_actor(actor) + + +def list_asterisk_events(status: str | None = None, limit: int = 100): + bridge = _bridge_app() + session = get_session() + try: + stmt = select(AsteriskEventLogRow).order_by(AsteriskEventLogRow.id.desc()) + if status: + stmt = stmt.where(AsteriskEventLogRow.forward_status == status) + rows = session.execute(stmt).scalars().all() + return [bridge._to_out(row) for row in rows[:limit]] + finally: + session.close() + + +def get_asterisk_event(bridge_event_id: str): + bridge = _bridge_app() + session = get_session() + try: + row = session.execute( + select(AsteriskEventLogRow) + .where(AsteriskEventLogRow.bridge_event_id == bridge_event_id) + ).scalar_one_or_none() + if row is None: + raise HTTPException(status_code=404, detail="Asterisk bridge event not found") + return bridge._to_out(row) + finally: + session.close() + + +def retry_asterisk_event(bridge_event_id: str): + bridge = _bridge_app() + if not bridge._claim_bridge_event_for_processing(bridge_event_id): + session = get_session() + try: + row = bridge._load_bridge_event_row(session, bridge_event_id) + if row is None: + raise HTTPException(status_code=404, detail="Asterisk bridge event not found") + return bridge._to_out(row) + finally: + session.close() + + row = bridge._process_claimed_bridge_event(bridge_event_id) + if row is None: + raise HTTPException(status_code=404, detail="Asterisk bridge event not found") + return bridge._to_out(row) + + +def reconnect_asterisk(): + bridge = _bridge_app() + bridge._STATE.request_reconnect() + return asterisk_status() diff --git a/services/asterisk_bridge_service/runtime.py b/services/asterisk_bridge_service/runtime.py new file mode 100644 index 0000000..7db37e8 --- /dev/null +++ b/services/asterisk_bridge_service/runtime.py @@ -0,0 +1,267 @@ +from __future__ import annotations + +import os +from pathlib import Path +import threading +from typing import Any + +from services.shared.core import utc_now_iso + + +class BridgeState: + def __init__(self) -> None: + self._lock = threading.Lock() + self.ami_connected = False + self.last_event_at: str | None = None + self.last_error: str | None = None + self.reconnect_requested = False + + def snapshot(self) -> dict[str, Any]: + with self._lock: + return { + "ami_connected": self.ami_connected, + "last_event_at": self.last_event_at, + "last_error": self.last_error, + "reconnect_requested": self.reconnect_requested, + } + + def set_connected(self, value: bool) -> None: + with self._lock: + self.ami_connected = value + if value: + self.last_error = None + + def set_last_event(self) -> None: + with self._lock: + self.last_event_at = utc_now_iso() + + def set_error(self, message: str) -> None: + with self._lock: + self.last_error = message + + def request_reconnect(self) -> None: + with self._lock: + self.reconnect_requested = True + + def take_reconnect(self) -> bool: + with self._lock: + current = self.reconnect_requested + self.reconnect_requested = False + return current + + +_BACKGROUND_LOCK = threading.Lock() +_BACKGROUND_STOP_EVENT: threading.Event | None = None +_BACKGROUND_THREADS: list[threading.Thread] = [] +_BRIDGE_SINGLETON_DB_CONNECTION = None +_BRIDGE_SINGLETON_FILE_HANDLE = None + + +def bridge_singleton_lock_path(*, database_url: str, cc_data_dir: str) -> Path: + if database_url.startswith("sqlite:///"): + db_path = Path(database_url[len("sqlite:///") :]).resolve() + db_path.parent.mkdir(parents=True, exist_ok=True) + return db_path.parent / "asterisk_bridge_service.singleton.lock" + data_dir = Path(cc_data_dir).resolve() + data_dir.mkdir(parents=True, exist_ok=True) + return data_dir / "asterisk_bridge_service.singleton.lock" + + +def try_lock_file(handle) -> bool: + try: + handle.seek(0) + handle.write(b"0") + handle.flush() + handle.seek(0) + except OSError: + return False + if os.name == "nt": + import msvcrt + + try: + msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1) + return True + except OSError: + return False + + import fcntl + + try: + fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + return True + except OSError: + return False + + +def unlock_file(handle) -> None: + handle.seek(0) + if os.name == "nt": + import msvcrt + + msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1) + return + + import fcntl + + fcntl.flock(handle.fileno(), fcntl.LOCK_UN) + + +def try_acquire_bridge_singleton_guard( + *, + database_url: str, + engine: Any, + lock_key: int, + cc_data_dir: str, +) -> bool: + global _BRIDGE_SINGLETON_DB_CONNECTION, _BRIDGE_SINGLETON_FILE_HANDLE + + if _BRIDGE_SINGLETON_DB_CONNECTION is not None or _BRIDGE_SINGLETON_FILE_HANDLE is not None: + return True + + if database_url.startswith("postgresql"): + connection = engine.raw_connection() + try: + cursor = connection.cursor() + try: + cursor.execute("SELECT pg_try_advisory_lock(%s)", (lock_key,)) + acquired_row = cursor.fetchone() + finally: + cursor.close() + connection.commit() + except Exception: + connection.close() + raise + if acquired_row and bool(acquired_row[0]): + _BRIDGE_SINGLETON_DB_CONNECTION = connection + return True + connection.close() + return False + + lock_path = bridge_singleton_lock_path(database_url=database_url, cc_data_dir=cc_data_dir) + lock_path.touch(exist_ok=True) + handle = lock_path.open("r+b") + if try_lock_file(handle): + _BRIDGE_SINGLETON_FILE_HANDLE = handle + return True + try: + handle.close() + except OSError: + pass + return False + + +def release_bridge_singleton_guard(*, lock_key: int) -> None: + global _BRIDGE_SINGLETON_DB_CONNECTION, _BRIDGE_SINGLETON_FILE_HANDLE + + if _BRIDGE_SINGLETON_DB_CONNECTION is not None: + connection = _BRIDGE_SINGLETON_DB_CONNECTION + _BRIDGE_SINGLETON_DB_CONNECTION = None + try: + cursor = connection.cursor() + try: + cursor.execute("SELECT pg_advisory_unlock(%s)", (lock_key,)) + finally: + cursor.close() + connection.commit() + finally: + connection.close() + + if _BRIDGE_SINGLETON_FILE_HANDLE is not None: + handle = _BRIDGE_SINGLETON_FILE_HANDLE + _BRIDGE_SINGLETON_FILE_HANDLE = None + try: + unlock_file(handle) + finally: + handle.close() + + +def bridge_singleton_db_connection(): + return _BRIDGE_SINGLETON_DB_CONNECTION + + +def bridge_singleton_file_handle(): + return _BRIDGE_SINGLETON_FILE_HANDLE + + +def background_threads_alive() -> list[threading.Thread]: + return [thread for thread in _BACKGROUND_THREADS if thread.is_alive()] + + +def background_stop_event() -> threading.Event: + global _BACKGROUND_STOP_EVENT + if _BACKGROUND_STOP_EVENT is None or _BACKGROUND_STOP_EVENT.is_set(): + _BACKGROUND_STOP_EVENT = threading.Event() + return _BACKGROUND_STOP_EVENT + + +def background_shutdown_timeout_seconds(*, poll_interval: float, failed_retry_interval_seconds: float) -> float: + return max(10.0, poll_interval, failed_retry_interval_seconds) + 1.0 + + +def start_background_threads( + *, + ami_loop, + failed_retry_loop, + ensure_guard, + extra_loops: list[tuple[str, Any]] | None = None, +) -> None: + global _BACKGROUND_THREADS + + with _BACKGROUND_LOCK: + alive = background_threads_alive() + if alive: + _BACKGROUND_THREADS = alive + ensure_guard() + return + + stop_event = background_stop_event() + ensure_guard() + threads = [ + threading.Thread(target=ami_loop, args=(stop_event,), daemon=True, name="asterisk-ami-loop"), + threading.Thread( + target=failed_retry_loop, + args=(stop_event,), + daemon=True, + name="asterisk-failed-retry-loop", + ), + ] + for name, target in extra_loops or []: + threads.append(threading.Thread(target=target, args=(stop_event,), daemon=True, name=name)) + _BACKGROUND_THREADS = threads + try: + for thread in threads: + thread.start() + except Exception: + stop_event.set() + _BACKGROUND_THREADS = [] + raise + + +def stop_background_threads( + *, + state: BridgeState, + release_guard, + shutdown_timeout_seconds: float, +) -> None: + global _BACKGROUND_STOP_EVENT, _BACKGROUND_THREADS + + with _BACKGROUND_LOCK: + stop_event = _BACKGROUND_STOP_EVENT + threads = list(_BACKGROUND_THREADS) + if stop_event is not None: + stop_event.set() + state.request_reconnect() + + for thread in threads: + if thread.is_alive(): + thread.join(timeout=shutdown_timeout_seconds) + + with _BACKGROUND_LOCK: + alive = background_threads_alive() + if alive: + _BACKGROUND_THREADS = alive + return + _BACKGROUND_THREADS = [] + _BACKGROUND_STOP_EVENT = None + state.set_connected(False) + release_guard() diff --git a/services/asterisk_bridge_service/voice_ai.py b/services/asterisk_bridge_service/voice_ai.py new file mode 100644 index 0000000..4198acc --- /dev/null +++ b/services/asterisk_bridge_service/voice_ai.py @@ -0,0 +1,618 @@ +from __future__ import annotations + +import json +import logging +from typing import Any + +from fastapi import HTTPException +from sqlalchemy import select + +from services.shared.core import new_id, utc_now_iso +from services.shared.db import get_session +from services.shared.models import ( + VoiceAICallStateUpdateIn, + VoiceAIHandoffRequestIn, + VoiceAIMediaBridgeEventIn, + VoiceAISummaryOut, + VoiceAISummaryTranscriptSegmentOut, + VoiceLiveCallOut, +) +from services.shared.sql_models import AISessionRow, AsteriskCallLinkRow, VoiceAISessionRow, VoiceTranscriptSegmentRow +from services.shared.voice_transcripts import add_transcript_segment, next_transcript_sequence + + +LOGGER = logging.getLogger("uvicorn.error") + + +def _bridge_app(): + import services.asterisk_bridge_service.app as bridge_app + + return bridge_app + + +def _truncate(value: str | None, limit: int = 1000) -> str | None: + raw = str(value or "").strip() + if not raw: + return None + return raw[:limit] + + +def append_interaction_timeline( + *, + interaction_id: str | None, + action: str, + metadata: dict[str, Any] | None = None, +) -> dict[str, Any] | None: + bridge = _bridge_app() + if not str(interaction_id or "").strip(): + return None + return bridge._post_json( + f"{bridge._interaction_service_url()}/interactions/{interaction_id}/timeline", + {"action": action, "metadata": metadata or {}}, + timeout_seconds=bridge._callcontrol_side_effect_timeout_seconds(), + max_attempts=bridge._callcontrol_side_effect_max_attempts(), + retry_backoff_seconds=0.0, + ) + + +def start_voice_ai_session( + *, + call_id: str, + linked_id: str | None, + interaction_id: str, + queue_id: str, + queue_code: str, + caller_number: str | None, + caller_name: str | None, + ai_config: dict[str, Any], +) -> dict[str, Any]: + bridge = _bridge_app() + return bridge._post_json( + f"{bridge._ai_voice_runtime_service_url()}/internal/voice-ai/sessions", + { + "call_id": call_id, + "linked_id": linked_id, + "interaction_id": interaction_id, + "queue_id": queue_id, + "caller_number": caller_number, + "caller_name": caller_name, + "agent_profile": ai_config.get("agent_profile") or "voice_support", + "language_hint": ai_config.get("language"), + "handoff_queue_id": ai_config.get("handoff_queue_id") or queue_id, + "metadata": { + "queue_code": queue_code, + "direction": "inbound", + }, + }, + timeout_seconds=max(3.0, min(bridge._forward_timeout_seconds(), 12.0)), + max_attempts=1, + retry_backoff_seconds=0.0, + ) + + +def notify_voice_ai_telephony_event( + *, + voice_session_id: str | None, + event_type: str, + payload: dict[str, Any] | None = None, +) -> dict[str, Any] | None: + bridge = _bridge_app() + session_id = str(voice_session_id or "").strip() + if not session_id: + return None + return bridge._post_json( + f"{bridge._ai_voice_runtime_service_url()}/internal/voice-ai/sessions/{session_id}/telephony-events", + {"event_type": event_type, "payload": payload or {}}, + timeout_seconds=3.0, + max_attempts=1, + retry_backoff_seconds=0.0, + ) + + +def register_voice_ai_media_bridge( + *, + voice_session_id: str | None, + event_type: str, + media_uuid: str | None, + call_id: str, + linked_id: str | None, + channel: str | None, + service_address: str | None, + reason: str | None = None, +) -> dict[str, Any] | None: + bridge = _bridge_app() + session_id = str(voice_session_id or "").strip() + normalized_media_uuid = str(media_uuid or "").strip() + if not session_id or not normalized_media_uuid: + return None + payload = VoiceAIMediaBridgeEventIn( + event_type="ended" if str(event_type).strip() == "ended" else "requested", + media_uuid=normalized_media_uuid, + call_id=call_id, + linked_id=linked_id, + channel=channel, + service_address=service_address, + reason=reason, + ) + return bridge._post_json( + f"{bridge._ai_voice_runtime_service_url()}/internal/voice-ai/sessions/{session_id}/media-bridge", + payload.model_dump(), + timeout_seconds=3.0, + max_attempts=1, + retry_backoff_seconds=0.0, + ) + + +def trusted_voice_runtime_actor(actor: dict) -> bool: + bridge = _bridge_app() + return ( + str(actor.get("auth_source") or "") == "service" + and str(actor.get("sub") or "") in bridge._ai_voice_runtime_trusted_subjects() + ) + + +def assert_trusted_voice_runtime_actor(actor: dict) -> None: + if not trusted_voice_runtime_actor(actor): + raise HTTPException(status_code=403, detail="Trusted Voice AI runtime subject required") + + +def _queue_code_for_queue_id(queue_id: str | None) -> str | None: + bridge = _bridge_app() + target = str(queue_id or "").strip() + if not target: + return None + for queue_code, mapped_queue_id in bridge._queue_map().items(): + if str(mapped_queue_id).strip() == target: + return str(queue_code).strip() or None + return None + + +def _resolve_handoff_extension(target_queue_id: str | None, fallback_queue_code: str | None) -> tuple[str, str]: + bridge = _bridge_app() + queue_code = _queue_code_for_queue_id(target_queue_id) or str(fallback_queue_code or "").strip() + if not queue_code: + raise HTTPException(status_code=400, detail="Unable to resolve handoff queue_code") + extension = bridge._transfer_target_map().get(queue_code) + if not extension: + raise HTTPException( + status_code=400, + detail=f"Unknown transfer queue_code: {queue_code}", + ) + return queue_code, extension + + +def _resolve_handoff_channel(session, link: AsteriskCallLinkRow) -> str: + bridge = _bridge_app() + snapshot = bridge._build_call_channel_snapshot( + session, + link, + operator_extension=link.operator_extension, + ) + for channel in bridge._candidate_channels_for_call( + session, + link, + operator_extension=link.operator_extension, + prefer_operator=False, + snapshot=snapshot, + ): + if ( + link.operator_extension + and bridge._extract_extension_from_channel(channel) == str(link.operator_extension).strip() + ): + continue + return channel + channel = bridge._resolve_channel_name( + session, + link, + prefer_operator=False, + refresh=True, + snapshot=snapshot, + ) + if not channel: + raise HTTPException(status_code=409, detail="Unable to resolve active channel for AI handoff") + return channel + + +def _recover_channel_for_ended_call(session, link: AsteriskCallLinkRow) -> str | None: + bridge = _bridge_app() + snapshot = bridge._build_call_channel_snapshot( + session, + link, + operator_extension=link.operator_extension, + ) + for channel in bridge._candidate_channels_for_call( + session, + link, + operator_extension=link.operator_extension, + prefer_operator=False, + snapshot=snapshot, + ): + if channel: + now = utc_now_iso() + link.status = "active" + link.telephony_status = "connected" + link.ended_at = None + link.updated_at = now + LOGGER.warning( + "bridge.ai_handoff_revived call_id=%s interaction_id=%s channel=%s previous_ai_state=%s", + link.call_id, + link.interaction_id, + channel, + link.ai_state, + ) + return channel + return None + + +def _summary_text_from_payload(summary: dict[str, Any] | None) -> str | None: + if not isinstance(summary, dict): + return None + for key in ("ai_outcome_text", "recommended_next_step", "customer_request_text"): + value = _truncate(summary.get(key), 1200) + if value: + return value + return None + + +def request_handoff(call_id: str, body: VoiceAIHandoffRequestIn, actor: dict) -> VoiceLiveCallOut: + bridge = _bridge_app() + assert_trusted_voice_runtime_actor(actor) + session = get_session() + action = None + try: + link = bridge._load_live_call_or_404(session, call_id) + if bridge._call_is_ended(link): + revived_channel = _recover_channel_for_ended_call(session, link) + if not revived_channel: + LOGGER.warning( + "bridge.ai_handoff_rejected call_id=%s reason=call_ended interaction_id=%s ai_state=%s status=%s telephony_status=%s", + call_id, + link.interaction_id, + link.ai_state, + link.status, + link.telephony_status, + ) + raise HTTPException(status_code=409, detail="Call is already ended") + if str(link.interaction_id or "").strip() != str(body.interaction_id or "").strip(): + LOGGER.warning( + "bridge.ai_handoff_rejected call_id=%s reason=interaction_mismatch link_interaction_id=%s body_interaction_id=%s ai_state=%s channel_name=%s", + call_id, + str(link.interaction_id or "").strip() or None, + str(body.interaction_id or "").strip() or None, + link.ai_state, + link.channel_name, + ) + raise HTTPException(status_code=409, detail="interaction_id mismatch") + if ( + str(link.ai_state or "") in {"handoff_required", "human_owned"} + and str(link.voice_session_id or "") == body.voice_session_id + ): + return bridge._live_call_to_out(session, link) + + voice_session = session.execute( + select(VoiceAISessionRow).where(VoiceAISessionRow.session_id == body.voice_session_id) + ).scalar_one_or_none() + if voice_session is None: + raise HTTPException(status_code=404, detail="Voice AI session not found") + + queue_code, target_extension = _resolve_handoff_extension( + body.target_queue_id or voice_session.handoff_target_queue_id or link.queue_id, + fallback_queue_code=_queue_code_for_queue_id(link.queue_id), + ) + actor_user = str(actor.get("user") or actor.get("sub") or "ai-voice-runtime").strip() + actor_role = str(actor.get("role") or "admin").strip() or "admin" + action = bridge._create_action_log( + session, + call_id=call_id, + interaction_id=link.interaction_id, + action_type="ai-handoff", + actor_user=actor_user, + actor_role=actor_role, + request_payload={ + "voice_session_id": body.voice_session_id, + "ai_session_id": body.ai_session_id, + "target_queue_id": body.target_queue_id or voice_session.handoff_target_queue_id or link.queue_id, + "target_queue_code": queue_code, + "resolved_extension": target_extension, + "reason": body.reason, + }, + ) + channel = _resolve_handoff_channel(session, link) + LOGGER.warning( + "bridge.ai_handoff_redirect call_id=%s interaction_id=%s source_channel=%s target_extension=%s target_queue_id=%s", + call_id, + link.interaction_id, + channel, + target_extension, + body.target_queue_id or voice_session.handoff_target_queue_id or link.queue_id, + ) + ami_result = bridge._ami_action( + "Redirect", + { + "Channel": channel, + "Context": bridge._transfer_context(), + "Exten": target_extension, + "Priority": 1, + }, + ) + + now = utc_now_iso() + link.voice_session_id = body.voice_session_id + link.ai_session_id = body.ai_session_id or link.ai_session_id or voice_session.ai_session_id + link.ai_state = "handoff_required" + link.ai_handoff_reason = _truncate(body.reason, 4000) + link.ai_last_model_at = now + link.claimed_by_user = None + link.claimed_at = None + link.operator_extension = None + link.updated_at = now + + voice_session.ai_session_id = body.ai_session_id or voice_session.ai_session_id + voice_session.status = "handoff_requested" + voice_session.handoff_reason = _truncate(body.reason, 4000) + voice_session.handoff_target_queue_id = body.target_queue_id or voice_session.handoff_target_queue_id or link.queue_id + voice_session.updated_at = now + + if body.summary: + add_transcript_segment( + session, + session_id=voice_session.session_id, + call_id=call_id, + interaction_id=link.interaction_id, + speaker="system", + source_type="handoff_summary", + text=_summary_text_from_payload(body.summary) or body.reason or "", + payload=body.summary, + created_at=now, + ) + + if str(link.ai_session_id or "").strip(): + ai_session = session.execute( + select(AISessionRow).where(AISessionRow.session_id == link.ai_session_id) + ).scalar_one_or_none() + if ai_session: + ai_session.status = "handoff_required" + ai_session.handoff_reason = _truncate(body.reason, 4000) + ai_session.summary_text = ( + _summary_text_from_payload(body.summary) + or _truncate(body.reason, 2000) + or ai_session.summary_text + ) + ai_session.updated_at = now + + bridge._set_action_result( + session, + action, + result_status="ok", + ami_action_id=str((ami_result or {}).get("action_id") or "") or None, + ) + session.commit() + + try: + append_interaction_timeline( + interaction_id=link.interaction_id, + action="ai.handoff_requested", + metadata={ + "call_id": call_id, + "voice_session_id": body.voice_session_id, + "ai_session_id": body.ai_session_id, + "reason": body.reason, + "target_queue_id": body.target_queue_id or voice_session.handoff_target_queue_id or link.queue_id, + }, + ) + except Exception: + pass + return bridge._live_call_to_out(session, link) + except HTTPException as exc: + LOGGER.warning( + "bridge.ai_handoff_http_error call_id=%s detail=%s body_interaction_id=%s body_target_queue_id=%s", + call_id, + exc.detail if isinstance(exc.detail, str) else str(exc.detail), + str(body.interaction_id or "").strip() or None, + str(body.target_queue_id or "").strip() or None, + ) + if action is not None: + bridge._set_action_result( + session, + action, + result_status="rejected", + error=exc.detail if isinstance(exc.detail, str) else str(exc.detail), + ) + session.commit() + raise + except Exception as exc: + if action is not None: + bridge._set_action_result(session, action, result_status="failed", error=str(exc)) + session.commit() + raise HTTPException(status_code=502, detail=f"AI handoff failed: {exc}") from exc + finally: + session.close() + + +def update_call_ai_state(call_id: str, body: VoiceAICallStateUpdateIn, actor: dict) -> VoiceLiveCallOut: + bridge = _bridge_app() + assert_trusted_voice_runtime_actor(actor) + session = get_session() + try: + link = bridge._load_live_call_or_404(session, call_id) + now = utc_now_iso() + if str(body.voice_session_id or "").strip(): + link.voice_session_id = body.voice_session_id + if str(body.ai_session_id or "").strip(): + link.ai_session_id = body.ai_session_id + link.ai_state = body.ai_state + if body.handoff_reason is not None: + link.ai_handoff_reason = _truncate(body.handoff_reason, 4000) + link.ai_last_model_at = now + link.updated_at = now + session.commit() + if body.ai_state == "error": + try: + append_interaction_timeline( + interaction_id=link.interaction_id, + action="ai.error", + metadata={ + "call_id": call_id, + "voice_session_id": body.voice_session_id or link.voice_session_id, + "ai_session_id": body.ai_session_id or link.ai_session_id, + "error": _truncate(body.handoff_reason, 1000), + **(body.metadata or {}), + }, + ) + except Exception: + pass + return bridge._live_call_to_out(session, link) + finally: + session.close() + + +def _next_transcript_sequence(session, session_id: str) -> int: + return next_transcript_sequence(session, session_id) + + +def _latest_segment_text( + session, + *, + voice_session_id: str, + speaker: str, + final_only: bool = False, +) -> str | None: + query = ( + select(VoiceTranscriptSegmentRow) + .where(VoiceTranscriptSegmentRow.session_id == voice_session_id) + .where(VoiceTranscriptSegmentRow.speaker == speaker) + ) + if final_only: + query = query.where(VoiceTranscriptSegmentRow.is_final.is_(True)) + row = session.execute( + query.order_by(VoiceTranscriptSegmentRow.id.desc()).limit(1) + ).scalar_one_or_none() + return _truncate(row.text if row else None, 4000) + + +def _summary_transcript_segments( + session, + *, + voice_session_id: str, +) -> list[VoiceAISummaryTranscriptSegmentOut]: + rows = session.execute( + select(VoiceTranscriptSegmentRow) + .where(VoiceTranscriptSegmentRow.session_id == voice_session_id) + .where(VoiceTranscriptSegmentRow.is_final.is_(True)) + .order_by(VoiceTranscriptSegmentRow.sequence_no.asc(), VoiceTranscriptSegmentRow.id.asc()) + ).scalars().all() + result: list[VoiceAISummaryTranscriptSegmentOut] = [] + for row in rows: + text = _truncate(row.text, 2000) + if not text: + continue + speaker = str(row.speaker or "").strip().lower() + if speaker not in {"caller", "assistant"}: + continue + result.append( + VoiceAISummaryTranscriptSegmentOut( + speaker=speaker, + text=text, + sequence_no=int(row.sequence_no or 0), + source_type=str(row.source_type or "").strip() or "unknown", + created_at=_truncate(row.created_at, 64) or utc_now_iso(), + interrupted=bool(row.barge_in_interrupted), + ) + ) + return result + + +def voice_ai_summary_for_call(call_id: str) -> VoiceAISummaryOut | None: + session = get_session() + try: + link = session.execute( + select(AsteriskCallLinkRow).where(AsteriskCallLinkRow.call_id == call_id) + ).scalar_one_or_none() + if link is None: + raise HTTPException(status_code=404, detail="Live call not found") + + voice_session = None + if str(link.voice_session_id or "").strip(): + voice_session = session.execute( + select(VoiceAISessionRow).where(VoiceAISessionRow.session_id == link.voice_session_id) + ).scalar_one_or_none() + if voice_session is None: + voice_session = session.execute( + select(VoiceAISessionRow).where(VoiceAISessionRow.call_id == call_id) + ).scalar_one_or_none() + if voice_session is None: + return None + + ai_session = None + ai_session_id = ( + str(link.ai_session_id or "").strip() + or str(voice_session.ai_session_id or "").strip() + ) + if ai_session_id: + ai_session = session.execute( + select(AISessionRow).where(AISessionRow.session_id == ai_session_id) + ).scalar_one_or_none() + transcript_segments = _summary_transcript_segments( + session, + voice_session_id=voice_session.session_id, + ) + + customer_request_text = _latest_segment_text( + session, + voice_session_id=voice_session.session_id, + speaker="caller", + ) or "Последняя реплика клиента недоступна." + assistant_reply_text = _latest_segment_text( + session, + voice_session_id=voice_session.session_id, + speaker="assistant", + final_only=True, + ) + handoff_reason = ( + _truncate(voice_session.handoff_reason, 4000) + or _truncate(ai_session.handoff_reason if ai_session else None, 4000) + or _truncate(link.ai_handoff_reason, 4000) + or "" + ) + ai_outcome_text = ( + assistant_reply_text + or _truncate(ai_session.summary_text if ai_session else None, 4000) + or handoff_reason + ) + if not ai_outcome_text and not handoff_reason and not transcript_segments: + return None + + handoff_states = {"handoff_requested", "handoff_required", "human_owned"} + is_handoff = ( + str(link.ai_state or "") in handoff_states + or str(voice_session.status or "") in handoff_states + or bool(handoff_reason) + ) + return VoiceAISummaryOut( + call_id=call_id, + session_id=ai_session.session_id if ai_session else None, + voice_session_id=voice_session.session_id, + status_label=( + "AI передал звонок оператору" + if is_handoff + else "AI ответил клиенту" + ), + status_tone="handoff" if is_handoff else "answered", + customer_request_text=customer_request_text, + ai_outcome_text=ai_outcome_text or "AI обработал обращение без дополнительной сводки.", + handoff_reason=handoff_reason, + recommended_next_step=( + "Проверьте контекст звонка и продолжайте разговор вручную." + if is_handoff + else "Продолжайте разговор, учитывая уже собранный AI контекст." + ), + generated_at=( + _truncate(link.ai_last_model_at, 64) + or _truncate(voice_session.updated_at, 64) + or _truncate(ai_session.updated_at if ai_session else None, 64) + or utc_now_iso() + ), + transcript_segments=transcript_segments, + ) + finally: + session.close() diff --git a/services/audit_service/__init__.py b/services/audit_service/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/audit_service/app.py b/services/audit_service/app.py new file mode 100644 index 0000000..0430771 --- /dev/null +++ b/services/audit_service/app.py @@ -0,0 +1,121 @@ +from __future__ import annotations + +import json +import threading + +from fastapi import FastAPI +from sqlalchemy import select + +from services.shared.core import new_id, utc_now_iso +from services.shared.db import get_session +from services.shared.event_bus import ( + consume_one_message, + consumer_enabled, + event_bus_audit_queue, + event_bus_enabled, + inbox_seen, + poll_forever, + record_inbox, +) +from services.shared.models import AuditEvent, AuditEventIn, HealthResponse +from services.shared.sql_init import init_sql_schema +from services.shared.sql_models import AuditEventRow + +app = FastAPI(title="audit-service", version="1.0.0") + +init_sql_schema() + + +def _to_out(row: AuditEventRow) -> AuditEvent: + return AuditEvent( + event_id=row.event_id, + actor=row.actor, + action=row.action, + entity=row.entity, + metadata=json.loads(row.metadata_json or "{}"), + created_at=row.created_at, + ) + + +@app.get("/health", response_model=HealthResponse) +def health() -> HealthResponse: + return HealthResponse(status="ok", service="audit-service") + + +@app.post("/audit/events", response_model=AuditEvent) +def create_event(payload: AuditEventIn) -> AuditEvent: + session = get_session() + try: + row = AuditEventRow( + event_id=new_id("aud"), + actor=payload.actor, + action=payload.action, + entity=payload.entity, + metadata_json=json.dumps(payload.metadata, ensure_ascii=False), + created_at=utc_now_iso(), + ) + session.add(row) + session.commit() + session.refresh(row) + return _to_out(row) + finally: + session.close() + + +@app.get("/audit/events", response_model=list[AuditEvent]) +def list_events(actor: str | None = None, action: str | None = None, limit: int = 100) -> list[AuditEvent]: + session = get_session() + try: + stmt = select(AuditEventRow).order_by(AuditEventRow.id.asc()) + if actor: + stmt = stmt.where(AuditEventRow.actor == actor) + if action: + stmt = stmt.where(AuditEventRow.action == action) + rows = session.execute(stmt).scalars().all() + rows = rows[-limit:] + return [_to_out(r) for r in rows] + finally: + session.close() + + +def _handle_event(envelope: dict) -> None: + session = get_session() + try: + event_id = str(envelope.get("event_id") or "").strip() + event_type = str(envelope.get("event_type") or "").strip() + if not event_id or not event_type: + raise ValueError("Invalid event envelope") + if inbox_seen(session, "audit-service", event_id): + return + + payload = envelope.get("payload") if isinstance(envelope.get("payload"), dict) else {} + row = AuditEventRow( + event_id=new_id("aud"), + actor=str(payload.get("actor") or envelope.get("producer") or "system"), + action=event_type, + entity=str(envelope.get("entity_type") or "event"), + metadata_json=json.dumps(envelope, ensure_ascii=False), + created_at=utc_now_iso(), + ) + session.add(row) + record_inbox( + session, + consumer_name="audit-service", + event_id=event_id, + event_type=event_type, + status="processed", + ) + session.commit() + finally: + session.close() + + +def _consume_once() -> None: + consume_one_message(event_bus_audit_queue(), _handle_event) + + +@app.on_event("startup") +def _startup() -> None: + if not event_bus_enabled() or not consumer_enabled(): + return + threading.Thread(target=lambda: poll_forever(_consume_once), daemon=True).start() diff --git a/services/auth_service/__init__.py b/services/auth_service/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/auth_service/app.py b/services/auth_service/app.py new file mode 100644 index 0000000..ca1b9cc --- /dev/null +++ b/services/auth_service/app.py @@ -0,0 +1,716 @@ +from __future__ import annotations + +import base64 +from datetime import datetime, timedelta, timezone +import hashlib +from html import escape +import json +import os +from secrets import token_urlsafe +from typing import Any +from urllib.parse import urlencode + +import httpx +from fastapi import Depends, FastAPI, HTTPException, Request +from fastapi.responses import HTMLResponse, RedirectResponse +from sqlalchemy import select + +from services.shared.core import Role, new_id, utc_now_iso +from services.shared.db import get_session +from services.shared.models import HealthResponse, LoginRequest, LoginResponse, UserCreate, UserOut, UserUpdate +from services.shared.security import get_actor, issue_app_token +from services.shared.sql_init import init_sql_schema +from services.shared.sql_models import AuthExternalIdentity, AuthOIDCState, AuthUser + +app = FastAPI(title="auth-service", version="1.0.0") + +init_sql_schema() + + +def _seed_users() -> None: + session = get_session() + try: + exists = session.execute(select(AuthUser.id).limit(1)).first() + if exists: + return + + seeds = [ + ("admin", "admin123", "System Admin", Role.ADMIN), + ("supervisor", "sup12345", "Shift Supervisor", Role.SUPERVISOR), + ("operator", "op12345", "Call Operator", Role.OPERATOR), + ("analyst", "an12345", "Business Analyst", Role.ANALYST), + ] + for username, password, full_name, role in seeds: + session.add( + AuthUser( + user_id=new_id("usr"), + username=username, + password=password, + full_name=full_name, + role=role.value, + created_at=utc_now_iso(), + ) + ) + session.commit() + finally: + session.close() + + +def _bool_env(name: str, default: bool) -> bool: + raw = os.getenv(name) + if raw is None: + return default + return raw.strip().lower() in {"1", "true", "yes", "on"} + + +def _oidc_enabled() -> bool: + return _bool_env("OIDC_ENABLED", False) + + +def _oidc_test_mode() -> bool: + return _bool_env("OIDC_TEST_MODE", False) + + +def _oidc_provider() -> str: + return os.getenv("OIDC_PROVIDER", "keycloak").strip() or "keycloak" + + +def _oidc_issuer_url() -> str: + return os.getenv("OIDC_ISSUER_URL", "").strip() + + +def _oidc_redirect_uri() -> str: + return os.getenv("OIDC_REDIRECT_URI", "http://localhost:8080/proxy/auth/auth/oidc/callback").strip() + + +def _oidc_client_id() -> str: + return os.getenv("OIDC_CLIENT_ID", "mvp-contact-center").strip() + + +def _oidc_client_secret() -> str: + return os.getenv("OIDC_CLIENT_SECRET", "").strip() + + +def _oidc_scopes() -> str: + return os.getenv("OIDC_SCOPES", "openid profile email").strip() + + +def _oidc_role_claim() -> str: + return os.getenv("OIDC_ROLE_CLAIM", "groups").strip() or "groups" + + +def _oidc_role_map() -> dict[str, str]: + default_map = { + "kc_admin": "admin", + "kc_supervisor": "supervisor", + "kc_operator": "operator", + "kc_analyst": "analyst", + } + raw = os.getenv("OIDC_ROLE_MAP_JSON", "").strip() + if not raw: + return default_map + try: + payload = json.loads(raw) + except json.JSONDecodeError: + return default_map + if not isinstance(payload, dict): + return default_map + parsed: dict[str, str] = {} + for key, value in payload.items(): + key_s = str(key).strip() + value_s = str(value).strip().lower() + if key_s and value_s in {role.value for role in Role}: + parsed[key_s] = value_s + return parsed or default_map + + +def _oidc_authorize_url_override() -> str: + return os.getenv("OIDC_AUTHORIZE_URL", "").strip() + + +def _oidc_token_url_override() -> str: + return os.getenv("OIDC_TOKEN_URL", "").strip() + + +def _oidc_jwks_url_override() -> str: + return os.getenv("OIDC_JWKS_URL", "").strip() + + +def _oidc_state_ttl_seconds() -> int: + return 600 + + +def _provider_metadata() -> dict[str, Any]: + authorize_override = _oidc_authorize_url_override() + token_override = _oidc_token_url_override() + jwks_override = _oidc_jwks_url_override() + if authorize_override and token_override: + return { + "issuer": _oidc_issuer_url() or "override", + "authorization_endpoint": authorize_override, + "token_endpoint": token_override, + "jwks_uri": jwks_override, + } + + issuer = _oidc_issuer_url() + if not issuer: + raise HTTPException(status_code=503, detail="OIDC issuer is not configured") + + well_known = issuer.rstrip("/") + "/.well-known/openid-configuration" + try: + response = httpx.get(well_known, timeout=5) + response.raise_for_status() + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=503, detail="OIDC provider metadata unavailable") from exc + + payload = response.json() + if not isinstance(payload, dict): + raise HTTPException(status_code=503, detail="OIDC provider metadata invalid") + return payload + + +def _provider_jwks() -> dict[str, Any]: + jwks_url = _oidc_jwks_url_override() + if not jwks_url: + jwks_url = str(_provider_metadata().get("jwks_uri") or "").strip() + if not jwks_url: + raise HTTPException(status_code=503, detail="OIDC JWKS URI unavailable") + + try: + response = httpx.get(jwks_url, timeout=5) + response.raise_for_status() + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=503, detail="OIDC JWKS unavailable") from exc + + payload = response.json() + if not isinstance(payload, dict): + raise HTTPException(status_code=503, detail="OIDC JWKS payload invalid") + return payload + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +def _iso(dt: datetime) -> str: + return dt.replace(microsecond=0).isoformat() + + +def _build_pkce_verifier() -> str: + return token_urlsafe(48) + + +def _build_pkce_challenge(code_verifier: str) -> str: + digest = hashlib.sha256(code_verifier.encode("utf-8")).digest() + return base64.urlsafe_b64encode(digest).decode("utf-8").rstrip("=") + + +def _html_bridge(payload: dict[str, Any], status_code: int = 200) -> HTMLResponse: + body = json.dumps(payload, ensure_ascii=False) + html = f""" + + OIDC Login Bridge + + +

{escape(payload.get("message", "Authentication completed."))}

+ +""" + return HTMLResponse(content=html, status_code=status_code) + + +def _read_oidc_state(session_state: str) -> AuthOIDCState: + session = get_session() + try: + row = session.execute(select(AuthOIDCState).where(AuthOIDCState.state == session_state)).scalar_one_or_none() + if not row: + raise HTTPException(status_code=400, detail="Unknown OIDC state") + if row.consumed_at: + raise HTTPException(status_code=400, detail="OIDC state already consumed") + expires = datetime.fromisoformat(row.expires_at.replace("Z", "+00:00")) + if expires < _now(): + raise HTTPException(status_code=400, detail="OIDC state expired") + return row + finally: + session.close() + + +def _consume_oidc_state(state_value: str) -> None: + session = get_session() + try: + row = session.execute(select(AuthOIDCState).where(AuthOIDCState.state == state_value)).scalar_one_or_none() + if not row: + return + row.consumed_at = utc_now_iso() + session.commit() + finally: + session.close() + + +def _save_oidc_state(state_value: str, nonce: str, code_verifier: str, redirect_uri: str, return_mode: str) -> None: + session = get_session() + try: + now = _now() + row = AuthOIDCState( + state=state_value, + nonce=nonce, + code_verifier=code_verifier, + redirect_uri=redirect_uri, + return_mode=return_mode, + created_at=_iso(now), + expires_at=_iso(now + timedelta(seconds=_oidc_state_ttl_seconds())), + consumed_at=None, + ) + session.add(row) + session.commit() + finally: + session.close() + + +def _extract_claims_from_compact_jwt(token: str) -> dict[str, Any]: + try: + parts = token.split(".") + if len(parts) != 3: + raise ValueError("compact jwt expected") + payload_part = parts[1] + padding = "=" * (-len(payload_part) % 4) + raw = base64.urlsafe_b64decode((payload_part + padding).encode("utf-8")) + payload = json.loads(raw.decode("utf-8")) + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=502, detail="OIDC id_token payload invalid") from exc + if not isinstance(payload, dict): + raise HTTPException(status_code=502, detail="OIDC id_token payload invalid") + return payload + + +def _map_role_from_claims(claims: dict[str, Any]) -> Role: + role_claim_name = _oidc_role_claim() + role_map = _oidc_role_map() + source = claims.get(role_claim_name) + + candidates: list[str] = [] + if isinstance(source, list): + candidates = [str(item) for item in source] + elif isinstance(source, str): + candidates = [source] + elif source is not None: + candidates = [str(source)] + + for candidate in candidates: + mapped = role_map.get(candidate) + if mapped: + return Role(mapped) + + raise HTTPException(status_code=403, detail="OIDC role is not mapped") + + +def _upsert_external_identity(claims: dict[str, Any], role: Role) -> None: + provider = _oidc_provider() + subject = str(claims.get("sub") or "").strip() + if not subject: + raise HTTPException(status_code=403, detail="OIDC subject missing") + username = str( + claims.get("preferred_username") + or claims.get("username") + or claims.get("email") + or subject + ).strip() + now = utc_now_iso() + full_name = str(claims.get("name") or "").strip() or None + email = str(claims.get("email") or "").strip() or None + + session = get_session() + try: + row = session.execute( + select(AuthExternalIdentity).where( + AuthExternalIdentity.provider == provider, + AuthExternalIdentity.external_subject == subject, + ) + ).scalar_one_or_none() + if not row: + row = AuthExternalIdentity( + provider=provider, + external_subject=subject, + username=username, + email=email, + full_name=full_name, + role=role.value, + linked_user_id=None, + created_at=now, + updated_at=now, + last_login_at=now, + ) + session.add(row) + else: + row.username = username + row.email = email + row.full_name = full_name + row.role = role.value + row.updated_at = now + row.last_login_at = now + session.commit() + finally: + session.close() + + +def _oidc_payload_from_mock(request: Request, state_row: AuthOIDCState) -> dict[str, Any]: + if not _oidc_test_mode(): + raise HTTPException(status_code=502, detail="OIDC provider exchange requires a live provider") + + params = request.query_params + subject = params.get("mock_sub") or "oidc-user" + username = params.get("mock_username") or "oidc.user" + email = params.get("mock_email") or "oidc.user@example.com" + full_name = params.get("mock_full_name") or "OIDC User" + groups = [item.strip() for item in (params.get("mock_groups") or "kc_operator").split(",") if item.strip()] + claims = { + "sub": subject, + "preferred_username": username, + "email": email, + "name": full_name, + _oidc_role_claim(): groups, + "iss": _oidc_issuer_url() or "test-issuer", + "nonce": state_row.nonce, + } + return claims + + +def _oidc_payload_from_token_exchange(code: str, state_row: AuthOIDCState) -> dict[str, Any]: + metadata = _provider_metadata() + token_endpoint = str(metadata.get("token_endpoint") or "").strip() + if not token_endpoint: + raise HTTPException(status_code=503, detail="OIDC token endpoint unavailable") + + form = { + "grant_type": "authorization_code", + "code": code, + "redirect_uri": state_row.redirect_uri, + "client_id": _oidc_client_id(), + "code_verifier": state_row.code_verifier, + } + if _oidc_client_secret(): + form["client_secret"] = _oidc_client_secret() + + try: + response = httpx.post(token_endpoint, data=form, timeout=8) + response.raise_for_status() + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=502, detail="OIDC token exchange failed") from exc + + payload = response.json() + if not isinstance(payload, dict): + raise HTTPException(status_code=502, detail="OIDC token payload invalid") + + claims: dict[str, Any] | None = None + if isinstance(payload.get("id_token_claims"), dict): + claims = payload["id_token_claims"] + elif isinstance(payload.get("userinfo"), dict): + claims = payload["userinfo"] + elif payload.get("id_token"): + claims = _extract_claims_from_compact_jwt(str(payload["id_token"])) + + if not claims: + raise HTTPException(status_code=502, detail="OIDC claims unavailable") + + issuer = str(metadata.get("issuer") or "").strip() + if issuer and claims.get("iss") and str(claims.get("iss")) != issuer: + raise HTTPException(status_code=403, detail="OIDC issuer mismatch") + if claims.get("nonce") and str(claims.get("nonce")) != state_row.nonce: + raise HTTPException(status_code=403, detail="OIDC nonce mismatch") + return claims + + +def _issue_login_response(user: AuthUser) -> LoginResponse: + token = issue_app_token( + subject=user.user_id, + username=user.username, + role=user.role, + auth_source="local", + full_name=user.full_name, + ) + return LoginResponse( + access_token=token, + role=Role(user.role), + auth_source="local", + full_name=user.full_name, + ) + + +def _user_out(user: AuthUser) -> UserOut: + return UserOut( + user_id=user.user_id, + username=user.username, + full_name=user.full_name, + role=Role(user.role), + ) + + +_seed_users() + + +@app.get("/health", response_model=HealthResponse) +def health() -> HealthResponse: + return HealthResponse(status="ok", service="auth-service") + + +@app.get("/auth/roles") +def roles() -> dict: + return {"roles": [r.value for r in Role]} + + +@app.get("/auth/oidc/config") +def oidc_config() -> dict: + return { + "enabled": _oidc_enabled(), + "provider_label": "Keycloak", + "login_path": "/auth/oidc/start?return_mode=popup", + "auth_source": "oidc", + } + + +@app.get("/auth/oidc/health") +def oidc_health() -> dict: + if not _oidc_enabled(): + return { + "status": "disabled", + "provider": _oidc_provider(), + "issuer": _oidc_issuer_url() or None, + "jwks_loaded": False, + } + + metadata = _provider_metadata() + jwks_loaded = False + try: + _provider_jwks() + jwks_loaded = True + except HTTPException: + jwks_loaded = False + + return { + "status": "ok" if jwks_loaded else "degraded", + "provider": _oidc_provider(), + "issuer": metadata.get("issuer") or _oidc_issuer_url() or None, + "jwks_loaded": jwks_loaded, + } + + +@app.get("/auth/oidc/start") +def oidc_start(return_mode: str = "popup") -> RedirectResponse: + if not _oidc_enabled(): + raise HTTPException(status_code=503, detail="OIDC is disabled") + + metadata = _provider_metadata() + authorize_url = str(metadata.get("authorization_endpoint") or "").strip() + if not authorize_url: + raise HTTPException(status_code=503, detail="OIDC authorization endpoint unavailable") + + state_value = token_urlsafe(24) + nonce = token_urlsafe(16) + code_verifier = _build_pkce_verifier() + redirect_uri = _oidc_redirect_uri() + _save_oidc_state(state_value, nonce, code_verifier, redirect_uri, return_mode) + + params = { + "client_id": _oidc_client_id(), + "redirect_uri": redirect_uri, + "response_type": "code", + "scope": _oidc_scopes(), + "state": state_value, + "nonce": nonce, + "code_challenge": _build_pkce_challenge(code_verifier), + "code_challenge_method": "S256", + } + return RedirectResponse(url=f"{authorize_url}?{urlencode(params)}", status_code=302) + + +@app.get("/auth/oidc/callback") +def oidc_callback(request: Request, state: str = "", code: str = "", error: str = "") -> HTMLResponse: + if not _oidc_enabled(): + return _html_bridge({"type": "oidc-error", "message": "Corporate login is disabled"}, status_code=503) + if not state: + return _html_bridge({"type": "oidc-error", "message": "OIDC state is required"}, status_code=400) + + try: + state_row = _read_oidc_state(state) + except HTTPException as exc: + return _html_bridge({"type": "oidc-error", "message": exc.detail}, status_code=exc.status_code) + + if error: + _consume_oidc_state(state) + return _html_bridge({"type": "oidc-error", "message": f"OIDC provider error: {error}"}, status_code=400) + + try: + if request.query_params.get("mock_sub"): + claims = _oidc_payload_from_mock(request, state_row) + else: + if not code: + raise HTTPException(status_code=400, detail="OIDC code is required") + claims = _oidc_payload_from_token_exchange(code, state_row) + + role = _map_role_from_claims(claims) + _upsert_external_identity(claims, role) + + username = str( + claims.get("preferred_username") + or claims.get("username") + or claims.get("email") + or claims.get("sub") + or "oidc-user" + ).strip() + full_name = str(claims.get("name") or "").strip() or None + email = str(claims.get("email") or "").strip() or None + + token = issue_app_token( + subject=str(claims.get("sub") or ""), + username=username, + role=role.value, + auth_source="oidc", + provider=_oidc_provider(), + full_name=full_name, + email=email, + ) + _consume_oidc_state(state) + return _html_bridge( + { + "type": "oidc-login", + "message": "Corporate login completed", + "access_token": token, + "role": role.value, + "username": username, + "full_name": full_name, + "auth_source": "oidc", + "provider": _oidc_provider(), + "email": email, + } + ) + except HTTPException as exc: + _consume_oidc_state(state) + return _html_bridge({"type": "oidc-error", "message": str(exc.detail)}, status_code=exc.status_code) + + +@app.post("/auth/login", response_model=LoginResponse) +def login(payload: LoginRequest) -> LoginResponse: + session = get_session() + try: + user = session.execute( + select(AuthUser).where(AuthUser.username == payload.username) + ).scalar_one_or_none() + if not user or user.password != payload.password: + raise HTTPException(status_code=401, detail="Invalid credentials") + return _issue_login_response(user) + finally: + session.close() + + +@app.get("/auth/me") +def me(actor: dict = Depends(get_actor)) -> dict: + return actor + + +@app.get("/users", response_model=list[UserOut]) +def list_users(_: dict = Depends(get_actor)) -> list[UserOut]: + actor = _ + if actor["role"] not in {Role.ADMIN.value, Role.SUPERVISOR.value}: + raise HTTPException(status_code=403, detail="Insufficient role") + + session = get_session() + try: + users = session.execute(select(AuthUser).order_by(AuthUser.id.asc())).scalars().all() + return [_user_out(u) for u in users] + finally: + session.close() + + +@app.post("/users", response_model=UserOut) +def create_user(payload: UserCreate, actor: dict = Depends(get_actor)) -> UserOut: + if actor["role"] != Role.ADMIN.value: + raise HTTPException(status_code=403, detail="Insufficient role") + + session = get_session() + try: + existing = session.execute( + select(AuthUser).where(AuthUser.username == payload.username) + ).scalar_one_or_none() + if existing: + raise HTTPException(status_code=409, detail="Username already exists") + + user = AuthUser( + user_id=new_id("usr"), + username=payload.username, + password=payload.password, + full_name=payload.full_name, + role=payload.role.value, + created_at=utc_now_iso(), + ) + session.add(user) + session.commit() + return _user_out(user) + finally: + session.close() + + +@app.get("/users/{user_id}", response_model=UserOut) +def get_user(user_id: str, actor: dict = Depends(get_actor)) -> UserOut: + if actor["role"] not in {Role.ADMIN.value, Role.SUPERVISOR.value}: + raise HTTPException(status_code=403, detail="Insufficient role") + + session = get_session() + try: + user = session.execute(select(AuthUser).where(AuthUser.user_id == user_id)).scalar_one_or_none() + if not user: + raise HTTPException(status_code=404, detail="User not found") + return _user_out(user) + finally: + session.close() + + +@app.patch("/users/{user_id}", response_model=UserOut) +def update_user(user_id: str, payload: UserUpdate, actor: dict = Depends(get_actor)) -> UserOut: + if actor["role"] != Role.ADMIN.value: + raise HTTPException(status_code=403, detail="Insufficient role") + + session = get_session() + try: + user = session.execute(select(AuthUser).where(AuthUser.user_id == user_id)).scalar_one_or_none() + if not user: + raise HTTPException(status_code=404, detail="User not found") + + if payload.password is not None: + user.password = payload.password + if payload.full_name is not None: + user.full_name = payload.full_name + if payload.role is not None: + user.role = payload.role.value + + session.commit() + session.refresh(user) + return _user_out(user) + finally: + session.close() + + +@app.delete("/users/{user_id}") +def delete_user(user_id: str, actor: dict = Depends(get_actor)) -> dict: + if actor["role"] != Role.ADMIN.value: + raise HTTPException(status_code=403, detail="Insufficient role") + + session = get_session() + try: + user = session.execute(select(AuthUser).where(AuthUser.user_id == user_id)).scalar_one_or_none() + if not user: + raise HTTPException(status_code=404, detail="User not found") + if actor.get("user") == user.username: + raise HTTPException(status_code=409, detail="Cannot delete current user") + + session.delete(user) + session.commit() + return {"user_id": user_id, "deleted": True} + finally: + session.close() diff --git a/services/customer_service/__init__.py b/services/customer_service/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/customer_service/app.py b/services/customer_service/app.py new file mode 100644 index 0000000..d3fd912 --- /dev/null +++ b/services/customer_service/app.py @@ -0,0 +1,101 @@ +from __future__ import annotations + +import json + +from fastapi import FastAPI, HTTPException +from sqlalchemy import select + +from services.shared.core import new_id, utc_now_iso +from services.shared.db import get_session +from services.shared.models import CustomerCreate, CustomerOut, HealthResponse +from services.shared.sql_init import init_sql_schema +from services.shared.sql_models import Customer + +app = FastAPI(title="customer-service", version="1.0.0") + +init_sql_schema() + + +def _to_out(row: Customer) -> CustomerOut: + phones = json.loads(row.phones_json or "[]") + tags = json.loads(row.tags_json or "[]") + return CustomerOut( + customer_id=row.customer_id, + display_name=row.display_name, + phones=phones, + preferred_phone=row.preferred_phone, + tags=tags, + created_at=row.created_at, + ) + + +@app.get("/health", response_model=HealthResponse) +def health() -> HealthResponse: + return HealthResponse(status="ok", service="customer-service") + + +@app.post("/customers", response_model=CustomerOut) +def create_customer(payload: CustomerCreate) -> CustomerOut: + if payload.preferred_phone and payload.preferred_phone not in payload.phones: + raise HTTPException(status_code=400, detail="preferred_phone must be in phones") + + session = get_session() + try: + row = Customer( + customer_id=new_id("cus"), + display_name=payload.display_name, + phones_json=json.dumps(payload.phones, ensure_ascii=False), + preferred_phone=payload.preferred_phone, + tags_json=json.dumps(payload.tags, ensure_ascii=False), + created_at=utc_now_iso(), + ) + session.add(row) + session.commit() + session.refresh(row) + return _to_out(row) + finally: + session.close() + + +@app.get("/customers/{customer_id}", response_model=CustomerOut) +def get_customer(customer_id: str) -> CustomerOut: + session = get_session() + try: + row = session.execute(select(Customer).where(Customer.customer_id == customer_id)).scalar_one_or_none() + if not row: + raise HTTPException(status_code=404, detail="Customer not found") + return _to_out(row) + finally: + session.close() + + +@app.get("/customers", response_model=list[CustomerOut]) +def search_customers(query: str | None = None, limit: int = 100) -> list[CustomerOut]: + session = get_session() + try: + rows = session.execute(select(Customer).order_by(Customer.id.desc())).scalars().all() + out = [_to_out(r) for r in rows] + if query: + q = query.strip().lower() + out = [ + c + for c in out + if q in c.display_name.lower() + or any(q in p.lower() for p in c.phones) + or any(q in t.lower() for t in c.tags) + ] + return out[:limit] + finally: + session.close() + + +@app.get("/customers/{customer_id}/history") +def customer_history(customer_id: str) -> dict: + session = get_session() + try: + row = session.execute(select(Customer).where(Customer.customer_id == customer_id)).scalar_one_or_none() + if not row: + raise HTTPException(status_code=404, detail="Customer not found") + return {"customer_id": customer_id, "interactions": []} + finally: + session.close() diff --git a/services/email_adapter_service/__init__.py b/services/email_adapter_service/__init__.py new file mode 100644 index 0000000..f69a47b --- /dev/null +++ b/services/email_adapter_service/__init__.py @@ -0,0 +1 @@ +# Wave 2 Track 2 email adapter package. diff --git a/services/email_adapter_service/app.py b/services/email_adapter_service/app.py new file mode 100644 index 0000000..08e5f38 --- /dev/null +++ b/services/email_adapter_service/app.py @@ -0,0 +1,111 @@ +from __future__ import annotations + +import json + +from fastapi import FastAPI +from sqlalchemy import select + +from services.shared.core import new_id, utc_now_iso +from services.shared.db import get_session +from services.shared.models import EmailMessageIn, EmailMessageOut, HealthResponse +from services.shared.sql_init import init_sql_schema +from services.shared.sql_models import EmailMessageRow, Interaction, InteractionTimeline + +app = FastAPI(title="email-adapter-service", version="2.0.0") + +init_sql_schema() + + +def _to_out(row: EmailMessageRow) -> EmailMessageOut: + return EmailMessageOut( + message_id=row.message_id, + from_email=row.from_email, + subject=row.subject, + body=row.body, + customer_external_id=row.customer_external_id, + queue_id=row.queue_id, + priority=row.priority, + payload=json.loads(row.payload_json or "{}"), + interaction_id=row.interaction_id, + created_at=row.created_at, + ) + + +def _create_interaction(session, payload: EmailMessageIn, created_at: str) -> str: + interaction_id = new_id("int") + session.add( + Interaction( + interaction_id=interaction_id, + channel="email", + subject=payload.subject[:120], + customer_id=payload.customer_external_id, + queue_id=payload.queue_id or "q_email", + priority=payload.priority, + status="new", + assigned_to=None, + created_at=created_at, + updated_at=created_at, + ) + ) + session.add( + InteractionTimeline( + interaction_id=interaction_id, + timestamp=created_at, + action="interaction.created", + metadata_json=json.dumps({"channel": "email"}, ensure_ascii=False), + ) + ) + session.add( + InteractionTimeline( + interaction_id=interaction_id, + timestamp=created_at, + action="email.message_received", + metadata_json=json.dumps({"from_email": payload.from_email}, ensure_ascii=False), + ) + ) + return interaction_id + + +@app.get("/health", response_model=HealthResponse) +def health() -> HealthResponse: + return HealthResponse(status="ok", service="email-adapter-service", version="v2") + + +@app.post("/integrations/email/messages", response_model=EmailMessageOut) +def create_message(payload: EmailMessageIn) -> EmailMessageOut: + session = get_session() + try: + created_at = utc_now_iso() + interaction_id = _create_interaction(session, payload, created_at) + row = EmailMessageRow( + message_id=new_id("eml"), + from_email=payload.from_email, + subject=payload.subject, + body=payload.body, + customer_external_id=payload.customer_external_id, + interaction_id=interaction_id, + queue_id=payload.queue_id or "q_email", + priority=payload.priority, + payload_json=json.dumps(payload.payload, ensure_ascii=False), + created_at=created_at, + ) + session.add(row) + session.commit() + session.refresh(row) + return _to_out(row) + finally: + session.close() + + +@app.get("/integrations/email/messages", response_model=list[EmailMessageOut]) +def list_messages(from_email: str | None = None, limit: int = 100) -> list[EmailMessageOut]: + session = get_session() + try: + stmt = select(EmailMessageRow).order_by(EmailMessageRow.id.asc()) + if from_email: + stmt = stmt.where(EmailMessageRow.from_email == from_email) + rows = session.execute(stmt).scalars().all() + rows = rows[-limit:] + return [_to_out(row) for row in rows] + finally: + session.close() diff --git a/services/event_bus_service/app.py b/services/event_bus_service/app.py new file mode 100644 index 0000000..997149c --- /dev/null +++ b/services/event_bus_service/app.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +import threading + +from fastapi import Depends, FastAPI, HTTPException +from sqlalchemy import select + +from services.shared.core import Role +from services.shared.db import get_session +from services.shared.event_bus import ( + dispatch_outbox_batch, + event_bus_enabled, + outbox_item_from_row, + poll_forever, + retry_outbox_event, +) +from services.shared.models import EventOutboxItem, HealthResponse +from services.shared.security import require_roles +from services.shared.sql_init import init_sql_schema +from services.shared.sql_models import EventOutboxRow + +app = FastAPI(title="event-bus-service", version="1.0.0") + +init_sql_schema() + + +@app.get("/health", response_model=HealthResponse) +def health() -> HealthResponse: + suffix = "enabled" if event_bus_enabled() else "disabled" + return HealthResponse(status="ok", service=f"event-bus-service ({suffix})") + + +@app.get("/bus/outbox", response_model=list[EventOutboxItem]) +def list_outbox( + status: str | None = None, + limit: int = 100, + _: dict = Depends(require_roles(Role.ADMIN)), +) -> list[EventOutboxItem]: + session = get_session() + try: + stmt = select(EventOutboxRow).order_by(EventOutboxRow.id.desc()) + if status: + stmt = stmt.where(EventOutboxRow.status == status) + rows = session.execute(stmt).scalars().all() + return [outbox_item_from_row(row) for row in rows[:limit]] + finally: + session.close() + + +@app.get("/bus/outbox/{event_id}", response_model=EventOutboxItem) +def get_outbox_event( + event_id: str, + _: dict = Depends(require_roles(Role.ADMIN)), +) -> EventOutboxItem: + session = get_session() + try: + row = session.execute(select(EventOutboxRow).where(EventOutboxRow.event_id == event_id)).scalar_one_or_none() + if not row: + raise HTTPException(status_code=404, detail="Outbox event not found") + return outbox_item_from_row(row) + finally: + session.close() + + +@app.post("/bus/outbox/{event_id}/retry", response_model=EventOutboxItem) +def retry_outbox( + event_id: str, + _: dict = Depends(require_roles(Role.ADMIN)), +) -> EventOutboxItem: + session = get_session() + try: + row = session.execute(select(EventOutboxRow).where(EventOutboxRow.event_id == event_id)).scalar_one_or_none() + if not row: + raise HTTPException(status_code=404, detail="Outbox event not found") + retry_outbox_event(session, row) + session.commit() + session.refresh(row) + return outbox_item_from_row(row) + finally: + session.close() + + +def _dispatch_once() -> None: + session = get_session() + try: + dispatch_outbox_batch(session) + finally: + session.close() + + +@app.on_event("startup") +def _startup() -> None: + if not event_bus_enabled(): + return + threading.Thread(target=lambda: poll_forever(_dispatch_once), daemon=True).start() diff --git a/services/interaction_service/__init__.py b/services/interaction_service/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/interaction_service/app.py b/services/interaction_service/app.py new file mode 100644 index 0000000..57eb780 --- /dev/null +++ b/services/interaction_service/app.py @@ -0,0 +1,489 @@ +from __future__ import annotations + +from datetime import datetime, timezone +import json + +from fastapi import Depends, FastAPI, HTTPException, Query +from sqlalchemy import func, or_, select + +from services.shared.core import Role, new_id, utc_now_iso +from services.shared.db import get_session +from services.shared.event_bus import append_outbox_event, event_bus_enabled +from services.shared.models import ( + AssignRequest, + EscalateRequest, + HealthResponse, + InteractionCreate, + InteractionDrilldownFiltersOut, + InteractionDrilldownOut, + InteractionTimelineAppendIn, + InteractionOut, + ReportingInteractionFactIn, + StatusRequest, +) +from services.shared.reporting_facts import upsert_reporting_interaction_fact +from services.shared.security import require_roles +from services.shared.sql_init import init_sql_schema +from services.shared.sql_models import Interaction, InteractionTimeline + +app = FastAPI(title="interaction-service", version="1.0.0") + +init_sql_schema() + +_DRILLDOWN_SORT_COLUMNS = { + "created_at": Interaction.created_at, + "updated_at": Interaction.updated_at, + "subject": Interaction.subject, +} +_DRILLDOWN_SORT_DIRECTIONS = {"asc", "desc"} + + +def _to_out(row: Interaction) -> InteractionOut: + return InteractionOut( + interaction_id=row.interaction_id, + channel=row.channel, + subject=row.subject, + customer_id=row.customer_id, + queue_id=row.queue_id, + priority=row.priority, + status=row.status, + assigned_to=row.assigned_to, + created_at=row.created_at, + updated_at=row.updated_at, + ) + + +def _push_event(session, interaction_id: str, action: str, metadata: dict | None = None) -> None: + session.add( + InteractionTimeline( + interaction_id=interaction_id, + timestamp=utc_now_iso(), + action=action, + metadata_json=json.dumps(metadata or {}, ensure_ascii=False), + ) + ) + + +def _reporting_fact_payload( + row: Interaction, + *, + source: str, + closed_at: str | None = None, + resolved_first_contact: bool | None = None, +) -> dict: + return ReportingInteractionFactIn( + interaction_id=row.interaction_id, + channel=row.channel, # type: ignore[arg-type] + queue_id=row.queue_id, + agent_id=row.assigned_to, + status=row.status, # type: ignore[arg-type] + created_at=row.created_at, + closed_at=closed_at, + resolved_first_contact=resolved_first_contact, + source=source, + ).model_dump(exclude_none=True) + + +def _upsert_reporting_fact( + session, + row: Interaction, + *, + source: str, + closed_at: str | None = None, + resolved_first_contact: bool | None = None, +) -> None: + upsert_reporting_interaction_fact( + session, + _reporting_fact_payload( + row, + source=source, + closed_at=closed_at, + resolved_first_contact=resolved_first_contact, + ), + ) + + +def _parse_filter_timestamp(raw: str, field_name: str) -> datetime: + normalized = raw.strip() + if normalized.endswith("Z"): + normalized = f"{normalized[:-1]}+00:00" + try: + value = datetime.fromisoformat(normalized) + except ValueError as exc: + raise HTTPException(status_code=400, detail=f"Invalid {field_name}") from exc + if value.tzinfo is None: + value = value.replace(tzinfo=timezone.utc) + return value.astimezone(timezone.utc) + + +def _normalize_drilldown_sort(sort_by: str | None, sort_dir: str | None) -> tuple[str, str]: + normalized_sort_by = (sort_by or "created_at").strip() or "created_at" + normalized_sort_dir = (sort_dir or "desc").strip().lower() or "desc" + if normalized_sort_by not in _DRILLDOWN_SORT_COLUMNS: + raise HTTPException(status_code=400, detail="Invalid sort_by") + if normalized_sort_dir not in _DRILLDOWN_SORT_DIRECTIONS: + raise HTTPException(status_code=400, detail="Invalid sort_dir") + return normalized_sort_by, normalized_sort_dir + + +def _drilldown_order_by(sort_by: str, sort_dir: str): + column = _DRILLDOWN_SORT_COLUMNS[sort_by] + direction = column.asc() if sort_dir == "asc" else column.desc() + id_order = Interaction.id.asc() if sort_dir == "asc" else Interaction.id.desc() + if sort_by == "subject": + created_order = Interaction.created_at.asc() if sort_dir == "asc" else Interaction.created_at.desc() + return [direction, created_order, id_order] + return [direction, id_order] + + +@app.get("/health", response_model=HealthResponse) +def health() -> HealthResponse: + return HealthResponse(status="ok", service="interaction-service") + + +@app.post("/interactions", response_model=InteractionOut) +def create_interaction( + payload: InteractionCreate, + _: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)), +) -> InteractionOut: + session = get_session() + try: + now = utc_now_iso() + row = Interaction( + interaction_id=new_id("int"), + channel=payload.channel, + subject=payload.subject, + customer_id=payload.customer_id, + queue_id=payload.queue_id, + priority=payload.priority, + status="new", + assigned_to=None, + created_at=now, + updated_at=now, + ) + session.add(row) + _push_event(session, row.interaction_id, "interaction.created", {"channel": payload.channel}) + _upsert_reporting_fact(session, row, source="interaction-service") + if event_bus_enabled(): + append_outbox_event( + session, + event_type="interaction.created", + producer_service="interaction-service", + entity_type="interaction", + entity_id=row.interaction_id, + payload={ + "event": "interaction.created", + "interaction_id": row.interaction_id, + "channel": payload.channel, + "customer_id": payload.customer_id, + "queue_id": payload.queue_id, + "priority": payload.priority, + "status": "new", + "created_at": now, + }, + ) + session.commit() + session.refresh(row) + return _to_out(row) + finally: + session.close() + + +@app.get("/interactions/drilldown", response_model=InteractionDrilldownOut) +def drilldown_interactions( + from_ts: str = Query(...), + to_ts: str = Query(...), + queue_id: str | None = None, + channel: str | None = None, + agent_id: str | None = None, + status: str | None = None, + q: str | None = None, + sort_by: str = Query(default="created_at"), + sort_dir: str = Query(default="desc"), + limit: int = Query(default=25, ge=1, le=100), + offset: int = Query(default=0, ge=0), + _: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.ANALYST)), +) -> InteractionDrilldownOut: + range_from = _parse_filter_timestamp(from_ts, "from_ts") + range_to = _parse_filter_timestamp(to_ts, "to_ts") + if range_to <= range_from: + raise HTTPException(status_code=400, detail="to_ts must be greater than from_ts") + normalized_q = (q or "").strip() + normalized_sort_by, normalized_sort_dir = _normalize_drilldown_sort(sort_by, sort_dir) + + filters = [ + Interaction.created_at >= range_from.isoformat(), + Interaction.created_at < range_to.isoformat(), + ] + if queue_id: + filters.append(Interaction.queue_id == queue_id) + if channel: + filters.append(Interaction.channel == channel) + if agent_id: + filters.append(Interaction.assigned_to == agent_id) + if status: + filters.append(Interaction.status == status) + if normalized_q: + search_pattern = f"%{normalized_q.lower()}%" + filters.append( + or_( + func.lower(func.coalesce(Interaction.interaction_id, "")).like(search_pattern), + func.lower(func.coalesce(Interaction.subject, "")).like(search_pattern), + func.lower(func.coalesce(Interaction.assigned_to, "")).like(search_pattern), + ) + ) + + session = get_session() + try: + total = session.execute(select(func.count()).select_from(Interaction).where(*filters)).scalar_one() + rows = session.execute( + select(Interaction) + .where(*filters) + .order_by(*_drilldown_order_by(normalized_sort_by, normalized_sort_dir)) + .offset(offset) + .limit(limit) + ).scalars().all() + return InteractionDrilldownOut( + items=[_to_out(row) for row in rows], + total=int(total or 0), + limit=limit, + offset=offset, + filters=InteractionDrilldownFiltersOut( + from_ts=range_from.isoformat(), + to_ts=range_to.isoformat(), + queue_id=queue_id, + channel=channel, + agent_id=agent_id, + status=status, + q=normalized_q or None, + sort_by=normalized_sort_by, + sort_dir=normalized_sort_dir, + ), + ) + finally: + session.close() + + +@app.get("/interactions/{interaction_id}", response_model=InteractionOut) +def get_interaction(interaction_id: str) -> InteractionOut: + session = get_session() + try: + row = session.execute( + select(Interaction).where(Interaction.interaction_id == interaction_id) + ).scalar_one_or_none() + if not row: + raise HTTPException(status_code=404, detail="Interaction not found") + return _to_out(row) + finally: + session.close() + + +@app.get("/interactions", response_model=list[InteractionOut]) +def list_interactions( + status: str | None = None, + queue_id: str | None = None, + assigned_to: str | None = None, + limit: int = 100, +) -> list[InteractionOut]: + session = get_session() + try: + stmt = select(Interaction).order_by(Interaction.id.desc()) + if status: + stmt = stmt.where(Interaction.status == status) + if queue_id: + stmt = stmt.where(Interaction.queue_id == queue_id) + if assigned_to: + stmt = stmt.where(Interaction.assigned_to == assigned_to) + + rows = session.execute(stmt).scalars().all() + return [_to_out(r) for r in rows[:limit]] + finally: + session.close() + + +@app.patch("/interactions/{interaction_id}/assign", response_model=InteractionOut) +def assign_interaction( + interaction_id: str, + payload: AssignRequest, + _: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR)), +) -> InteractionOut: + session = get_session() + try: + row = session.execute( + select(Interaction).where(Interaction.interaction_id == interaction_id) + ).scalar_one_or_none() + if not row: + raise HTTPException(status_code=404, detail="Interaction not found") + row.assigned_to = payload.assignee + row.status = "in_progress" + row.updated_at = utc_now_iso() + _push_event(session, row.interaction_id, "interaction.assigned", {"assignee": payload.assignee}) + _upsert_reporting_fact(session, row, source="interaction-service") + if event_bus_enabled(): + append_outbox_event( + session, + event_type="interaction.assigned", + producer_service="interaction-service", + entity_type="interaction", + entity_id=row.interaction_id, + payload={ + "event": "interaction.assigned", + "interaction_id": row.interaction_id, + "assignee": payload.assignee, + "queue_id": row.queue_id, + "channel": row.channel, + "status": row.status, + "updated_at": row.updated_at, + }, + ) + session.commit() + return _to_out(row) + finally: + session.close() + + +@app.patch("/interactions/{interaction_id}/status", response_model=InteractionOut) +def update_status( + interaction_id: str, + payload: StatusRequest, + _: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)), +) -> InteractionOut: + session = get_session() + try: + row = session.execute( + select(Interaction).where(Interaction.interaction_id == interaction_id) + ).scalar_one_or_none() + if not row: + raise HTTPException(status_code=404, detail="Interaction not found") + row.status = payload.status + row.updated_at = utc_now_iso() + metadata = {"status": payload.status} + if payload.resolved_first_contact is not None: + metadata["resolved_first_contact"] = payload.resolved_first_contact + _push_event(session, row.interaction_id, "interaction.status_changed", metadata) + _upsert_reporting_fact( + session, + row, + source="interaction-service", + closed_at=row.updated_at if payload.status == "closed" else None, + resolved_first_contact=payload.resolved_first_contact, + ) + if payload.status == "closed" and event_bus_enabled(): + append_outbox_event( + session, + event_type="interaction.closed", + producer_service="interaction-service", + entity_type="interaction", + entity_id=row.interaction_id, + payload={ + "event": "interaction.closed", + "interaction_id": row.interaction_id, + "queue_id": row.queue_id, + "channel": row.channel, + "assignee": row.assigned_to, + "status": row.status, + "resolved_first_contact": payload.resolved_first_contact, + "updated_at": row.updated_at, + }, + ) + session.commit() + return _to_out(row) + finally: + session.close() + + +@app.post("/interactions/{interaction_id}/escalate", response_model=InteractionOut) +def escalate( + interaction_id: str, + payload: EscalateRequest, + _: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)), +) -> InteractionOut: + session = get_session() + try: + row = session.execute( + select(Interaction).where(Interaction.interaction_id == interaction_id) + ).scalar_one_or_none() + if not row: + raise HTTPException(status_code=404, detail="Interaction not found") + row.queue_id = payload.target_queue_id + row.status = "escalated" + row.updated_at = utc_now_iso() + _push_event( + session, + row.interaction_id, + "interaction.escalated", + {"target_queue_id": payload.target_queue_id}, + ) + _upsert_reporting_fact(session, row, source="interaction-service") + if event_bus_enabled(): + append_outbox_event( + session, + event_type="interaction.escalated", + producer_service="interaction-service", + entity_type="interaction", + entity_id=row.interaction_id, + payload={ + "event": "interaction.escalated", + "interaction_id": row.interaction_id, + "target_queue_id": payload.target_queue_id, + "queue_id": row.queue_id, + "channel": row.channel, + "status": row.status, + "updated_at": row.updated_at, + }, + ) + session.commit() + return _to_out(row) + finally: + session.close() + + +@app.get("/interactions/{interaction_id}/timeline") +def timeline(interaction_id: str) -> dict: + session = get_session() + try: + row = session.execute( + select(Interaction).where(Interaction.interaction_id == interaction_id) + ).scalar_one_or_none() + if not row: + raise HTTPException(status_code=404, detail="Interaction not found") + + events = session.execute( + select(InteractionTimeline) + .where(InteractionTimeline.interaction_id == interaction_id) + .order_by(InteractionTimeline.id.asc()) + ).scalars().all() + return { + "interaction_id": interaction_id, + "events": [ + { + "timestamp": e.timestamp, + "action": e.action, + "metadata": json.loads(e.metadata_json or "{}"), + } + for e in events + ], + } + finally: + session.close() + + +@app.post("/interactions/{interaction_id}/timeline") +def append_timeline_event( + interaction_id: str, + payload: InteractionTimelineAppendIn, + _: dict = Depends(require_roles(Role.ADMIN)), +) -> dict: + session = get_session() + try: + row = session.execute( + select(Interaction).where(Interaction.interaction_id == interaction_id) + ).scalar_one_or_none() + if not row: + raise HTTPException(status_code=404, detail="Interaction not found") + row.updated_at = utc_now_iso() + _push_event(session, interaction_id, payload.action, payload.metadata) + session.commit() + return {"ok": True, "interaction_id": interaction_id, "action": payload.action} + finally: + session.close() diff --git a/services/ivr_service/__init__.py b/services/ivr_service/__init__.py new file mode 100644 index 0000000..0713ad2 --- /dev/null +++ b/services/ivr_service/__init__.py @@ -0,0 +1 @@ +"""IVR service package.""" diff --git a/services/ivr_service/app.py b/services/ivr_service/app.py new file mode 100644 index 0000000..0015aef --- /dev/null +++ b/services/ivr_service/app.py @@ -0,0 +1,666 @@ +from __future__ import annotations + +import json +import os + +from fastapi import Depends, FastAPI, HTTPException, Query +from sqlalchemy import select + +from services.shared.core import Role, new_id, utc_now_iso +from services.shared.db import get_session +from services.shared.event_bus import append_outbox_event, event_bus_enabled +from services.shared.models import ( + HealthResponse, + IvrDtmfIn, + IvrFlowCreate, + IvrFlowOut, + IvrFlowUpdate, + IvrSessionOut, + IvrSessionStartIn, +) +from services.shared.security import get_actor, require_roles +from services.shared.sql_init import init_sql_schema +from services.shared.sql_models import InteractionTimeline, IvrFlowRow, IvrSessionRow, VoiceEventRow + +app = FastAPI(title="ivr-service", version="1.0.0") + +init_sql_schema() + + +def _load_flow_doc(raw: str) -> dict: + return json.loads(raw or "{}") + + +def _to_flow_out(row: IvrFlowRow) -> IvrFlowOut: + return IvrFlowOut( + flow_id=row.flow_id, + name=row.name, + description=row.description, + channel="voice", + queue_id=row.queue_id, + version=row.version, + is_active=row.is_active, + entry_node_id=row.entry_node_id, + flow_json=_load_flow_doc(row.flow_json), + created_at=row.created_at, + updated_at=row.updated_at, + ) + + +def _to_session_out(row: IvrSessionRow) -> IvrSessionOut: + return IvrSessionOut( + session_id=row.session_id, + call_id=row.call_id, + interaction_id=row.interaction_id, + flow_id=row.flow_id, + queue_id=row.queue_id, + current_node_id=row.current_node_id, + entered_digits=json.loads(row.entered_digits_json or "[]"), + status=row.status, + outcome_code=row.outcome_code, + resolved_queue_id=row.resolved_queue_id, + resolved_queue_code=row.resolved_queue_code, + completed_at=row.completed_at, + created_at=row.created_at, + updated_at=row.updated_at, + ) + + +def _trusted_runtime_subjects() -> set[str]: + raw = os.getenv("IVR_RUNTIME_TRUSTED_SERVICE_SUBJECTS", "svc:asterisk-bridge") + return {item.strip() for item in raw.split(",") if item.strip()} + + +def _is_trusted_runtime_actor(actor: dict) -> bool: + return ( + actor.get("auth_source") == "service" + and str(actor.get("sub") or "").strip() in _trusted_runtime_subjects() + ) + + +def _require_runtime_actor(actor: dict = Depends(get_actor)) -> dict: + if actor.get("auth_source") == "service": + if _is_trusted_runtime_actor(actor): + return actor + raise HTTPException(status_code=403, detail="Untrusted service subject") + if actor.get("role") in {Role.ADMIN.value, Role.SUPERVISOR.value}: + return actor + raise HTTPException(status_code=403, detail="Insufficient role") + + +def _validate_digit(value: str) -> str: + digit = value.strip() + if len(digit) != 1 or digit not in "0123456789": + raise HTTPException(status_code=400, detail="Digit must be a single character 0-9") + return digit + + +def _validate_flow_document(entry_node_id: str, flow_doc: dict) -> dict[str, dict]: + nodes = flow_doc.get("nodes") + if not isinstance(nodes, list) or not nodes: + raise HTTPException(status_code=400, detail="flow_json.nodes must be a non-empty list") + + node_map: dict[str, dict] = {} + for node in nodes: + if not isinstance(node, dict): + raise HTTPException(status_code=400, detail="Each flow node must be an object") + node_id = str(node.get("node_id") or "").strip() + if not node_id: + raise HTTPException(status_code=400, detail="Each flow node requires node_id") + if node_id in node_map: + raise HTTPException(status_code=400, detail=f"Duplicate node_id: {node_id}") + node_map[node_id] = node + + if entry_node_id not in node_map: + raise HTTPException(status_code=400, detail="entry_node_id must reference an existing node") + + for node_id, node in node_map.items(): + is_terminal = bool(node.get("is_terminal")) + options = node.get("options", []) + prompt_audio_key = node.get("prompt_audio_key") + prompt_sequence = node.get("prompt_sequence") + if not isinstance(options, list): + raise HTTPException(status_code=400, detail=f"Node {node_id} options must be a list") + if prompt_audio_key is not None and not str(prompt_audio_key).strip(): + raise HTTPException(status_code=400, detail=f"Node {node_id} prompt_audio_key must be non-empty when set") + if prompt_sequence is not None: + if not isinstance(prompt_sequence, list) or not prompt_sequence: + raise HTTPException(status_code=400, detail=f"Node {node_id} prompt_sequence must be a non-empty list") + for index, prompt in enumerate(prompt_sequence, start=1): + if not isinstance(prompt, dict): + raise HTTPException( + status_code=400, + detail=f"Node {node_id} prompt_sequence item {index} must be an object", + ) + prompt_key = str(prompt.get("prompt_audio_key") or "").strip() + if not prompt_key: + raise HTTPException( + status_code=400, + detail=f"Node {node_id} prompt_sequence item {index} requires prompt_audio_key", + ) + prompt_text = prompt.get("prompt_text") + if prompt_text is not None and not str(prompt_text).strip(): + raise HTTPException( + status_code=400, + detail=f"Node {node_id} prompt_sequence item {index} prompt_text must be non-empty when set", + ) + prompt_language = prompt.get("language") + if prompt_language is not None and not str(prompt_language).strip(): + raise HTTPException( + status_code=400, + detail=f"Node {node_id} prompt_sequence item {index} language must be non-empty when set", + ) + + seen_digits: set[str] = set() + for option in options: + if not isinstance(option, dict): + raise HTTPException(status_code=400, detail=f"Node {node_id} option must be an object") + digit = _validate_digit(str(option.get("digit") or "")) + if digit in seen_digits: + raise HTTPException(status_code=400, detail=f"Duplicate digit {digit} in node {node_id}") + seen_digits.add(digit) + target_node_id = str(option.get("target_node_id") or "").strip() + if target_node_id not in node_map: + raise HTTPException(status_code=400, detail=f"Option target {target_node_id} is missing") + + for ref_name in ("invalid_target_node_id", "no_input_target_node_id"): + target = node.get(ref_name) + if target is not None and str(target).strip() not in node_map: + raise HTTPException(status_code=400, detail=f"{ref_name} for node {node_id} is invalid") + + if is_terminal: + if not str(node.get("outcome_code") or "").strip(): + raise HTTPException(status_code=400, detail=f"Terminal node {node_id} requires outcome_code") + if not str(node.get("resolved_queue_id") or "").strip(): + raise HTTPException(status_code=400, detail=f"Terminal node {node_id} requires resolved_queue_id") + if not str(node.get("resolved_queue_code") or "").strip(): + raise HTTPException(status_code=400, detail=f"Terminal node {node_id} requires resolved_queue_code") + elif not options: + raise HTTPException(status_code=400, detail=f"Node {node_id} requires at least one option") + + return node_map + + +def _deactivate_other_flows(session, queue_id: str, *, keep_flow_id: str | None = None) -> None: + rows = session.execute( + select(IvrFlowRow).where( + IvrFlowRow.queue_id == queue_id, + IvrFlowRow.channel == "voice", + IvrFlowRow.is_active.is_(True), + ) + ).scalars().all() + now = utc_now_iso() + for row in rows: + if keep_flow_id and row.flow_id == keep_flow_id: + continue + row.is_active = False + row.updated_at = now + + +def _get_flow(session, flow_id: str) -> IvrFlowRow: + row = session.execute(select(IvrFlowRow).where(IvrFlowRow.flow_id == flow_id)).scalar_one_or_none() + if not row: + raise HTTPException(status_code=404, detail="IVR flow not found") + return row + + +def _get_session_row(session, session_id: str) -> IvrSessionRow: + row = session.execute(select(IvrSessionRow).where(IvrSessionRow.session_id == session_id)).scalar_one_or_none() + if not row: + raise HTTPException(status_code=404, detail="IVR session not found") + return row + + +def _push_timeline(session, interaction_id: str | None, action: str, metadata: dict | None = None) -> None: + if not interaction_id: + return + session.add( + InteractionTimeline( + interaction_id=interaction_id, + timestamp=utc_now_iso(), + action=action, + metadata_json=json.dumps(metadata or {}, ensure_ascii=False), + ) + ) + + +def _emit_ivr_completed_event(session, row: IvrSessionRow, *, terminal_node_id: str) -> None: + session.add( + VoiceEventRow( + event_id=new_id("vev"), + event_type="ivr.completed", + call_id=row.call_id, + interaction_id=row.interaction_id, + payload_json=json.dumps( + { + "session_id": row.session_id, + "flow_id": row.flow_id, + "outcome_code": row.outcome_code, + "resolved_queue_id": row.resolved_queue_id, + "digits": json.loads(row.entered_digits_json or "[]"), + "terminal_node_id": terminal_node_id, + }, + ensure_ascii=False, + ), + created_at=utc_now_iso(), + ) + ) + + +def _load_flow_runtime(session, row: IvrSessionRow | IvrFlowRow) -> tuple[IvrFlowRow, dict[str, dict]]: + flow = row if isinstance(row, IvrFlowRow) else _get_flow(session, row.flow_id) + flow_doc = _load_flow_doc(flow.flow_json) + node_map = _validate_flow_document(flow.entry_node_id, flow_doc) + return flow, node_map + + +def _complete_session( + session, + row: IvrSessionRow, + *, + next_node: dict, + next_node_id: str, + entered_digits: list[str], + completed_at: str, +) -> None: + row.status = "completed" + row.outcome_code = str(next_node.get("outcome_code") or "").strip() or None + row.resolved_queue_id = str(next_node.get("resolved_queue_id") or "").strip() or None + row.resolved_queue_code = str(next_node.get("resolved_queue_code") or "").strip() or None + row.completed_at = completed_at + _emit_ivr_completed_event(session, row, terminal_node_id=next_node_id) + _push_timeline( + session, + row.interaction_id, + "ivr.completed", + { + "session_id": row.session_id, + "flow_id": row.flow_id, + "outcome_code": row.outcome_code, + "resolved_queue_id": row.resolved_queue_id, + }, + ) + if event_bus_enabled(): + append_outbox_event( + session, + event_type="ivr.completed", + producer_service="ivr-service", + entity_type="ivr_session", + entity_id=row.session_id, + correlation_id=row.interaction_id or row.call_id, + payload={ + "event": "ivr.completed", + "call_id": row.call_id, + "interaction_id": row.interaction_id, + "session_id": row.session_id, + "flow_id": row.flow_id, + "outcome_code": row.outcome_code, + "resolved_queue_id": row.resolved_queue_id, + "digits": entered_digits, + "terminal_node_id": next_node_id, + }, + ) + + +def _session_response(row: IvrSessionRow, current_node: dict | None) -> dict: + return { + "session": _to_session_out(row).model_dump(), + "current_node": current_node, + "completed": row.status == "completed", + } + + +@app.get("/health", response_model=HealthResponse) +def health() -> HealthResponse: + return HealthResponse(status="ok", service="ivr-service") + + +@app.post("/ivr/flows", response_model=IvrFlowOut) +def create_flow( + payload: IvrFlowCreate, + _: dict = Depends(require_roles(Role.ADMIN)), +) -> IvrFlowOut: + session = get_session() + try: + _validate_flow_document(payload.entry_node_id, payload.flow_json) + if payload.is_active: + _deactivate_other_flows(session, payload.queue_id) + now = utc_now_iso() + row = IvrFlowRow( + flow_id=new_id("ivr"), + name=payload.name, + description=payload.description, + channel="voice", + queue_id=payload.queue_id, + version=1, + is_active=payload.is_active, + entry_node_id=payload.entry_node_id, + flow_json=json.dumps(payload.flow_json, ensure_ascii=False), + created_at=now, + updated_at=now, + ) + session.add(row) + session.commit() + session.refresh(row) + return _to_flow_out(row) + finally: + session.close() + + +@app.get("/ivr/flows", response_model=list[IvrFlowOut]) +def list_flows( + queue_id: str | None = None, + active_only: bool = False, + _: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR)), +) -> list[IvrFlowOut]: + session = get_session() + try: + stmt = select(IvrFlowRow).order_by(IvrFlowRow.id.desc()) + if queue_id: + stmt = stmt.where(IvrFlowRow.queue_id == queue_id) + if active_only: + stmt = stmt.where(IvrFlowRow.is_active.is_(True)) + rows = session.execute(stmt).scalars().all() + return [_to_flow_out(row) for row in rows] + finally: + session.close() + + +@app.get("/ivr/flows/{flow_id}", response_model=IvrFlowOut) +def get_flow( + flow_id: str, + _: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR)), +) -> IvrFlowOut: + session = get_session() + try: + return _to_flow_out(_get_flow(session, flow_id)) + finally: + session.close() + + +@app.patch("/ivr/flows/{flow_id}", response_model=IvrFlowOut) +def update_flow( + flow_id: str, + payload: IvrFlowUpdate, + _: dict = Depends(require_roles(Role.ADMIN)), +) -> IvrFlowOut: + session = get_session() + try: + row = _get_flow(session, flow_id) + flow_doc = _load_flow_doc(row.flow_json) + changed = False + + if payload.name is not None and payload.name != row.name: + row.name = payload.name + changed = True + if payload.description is not None and payload.description != row.description: + row.description = payload.description + changed = True + + candidate_entry = payload.entry_node_id if payload.entry_node_id is not None else row.entry_node_id + if payload.flow_json is not None: + flow_doc = payload.flow_json + changed = True + _validate_flow_document(candidate_entry, flow_doc) + + if candidate_entry != row.entry_node_id: + row.entry_node_id = candidate_entry + changed = True + if payload.flow_json is not None: + row.flow_json = json.dumps(flow_doc, ensure_ascii=False) + if payload.is_active is not None and payload.is_active != row.is_active: + row.is_active = payload.is_active + changed = True + + if row.is_active: + _deactivate_other_flows(session, row.queue_id, keep_flow_id=row.flow_id) + + if changed: + row.version += 1 + row.updated_at = utc_now_iso() + + session.commit() + session.refresh(row) + return _to_flow_out(row) + finally: + session.close() + + +@app.post("/ivr/flows/{flow_id}/activate", response_model=IvrFlowOut) +def activate_flow( + flow_id: str, + _: dict = Depends(require_roles(Role.ADMIN)), +) -> IvrFlowOut: + session = get_session() + try: + row = _get_flow(session, flow_id) + _deactivate_other_flows(session, row.queue_id, keep_flow_id=row.flow_id) + row.is_active = True + row.updated_at = utc_now_iso() + session.commit() + session.refresh(row) + return _to_flow_out(row) + finally: + session.close() + + +@app.post("/ivr/sessions/start") +def start_session( + payload: IvrSessionStartIn, + _: dict = Depends(_require_runtime_actor), +) -> dict: + session = get_session() + try: + flow = session.execute( + select(IvrFlowRow).where( + IvrFlowRow.queue_id == payload.queue_id, + IvrFlowRow.channel == "voice", + IvrFlowRow.is_active.is_(True), + ) + ).scalar_one_or_none() + if not flow: + raise HTTPException(status_code=404, detail="No active IVR flow for queue") + + _, node_map = _load_flow_runtime(session, flow) + now = utc_now_iso() + row = IvrSessionRow( + session_id=new_id("ivs"), + call_id=payload.call_id, + interaction_id=payload.interaction_id, + flow_id=flow.flow_id, + queue_id=payload.queue_id, + current_node_id=flow.entry_node_id, + entered_digits_json="[]", + status="active", + outcome_code=None, + resolved_queue_id=None, + resolved_queue_code=None, + completed_at=None, + created_at=now, + updated_at=now, + ) + session.add(row) + _push_timeline( + session, + payload.interaction_id, + "ivr.session.started", + {"session_id": row.session_id, "flow_id": flow.flow_id, "queue_id": payload.queue_id}, + ) + session.commit() + session.refresh(row) + return _session_response(row, node_map[row.current_node_id]) + finally: + session.close() + + +@app.post("/ivr/sessions/{session_id}/dtmf") +def process_dtmf( + session_id: str, + payload: IvrDtmfIn, + _: dict = Depends(_require_runtime_actor), +) -> dict: + session = get_session() + try: + row = _get_session_row(session, session_id) + if row.status != "active": + raise HTTPException(status_code=409, detail="IVR session is not active") + + _, node_map = _load_flow_runtime(session, row) + current_node = node_map[row.current_node_id] + digit = _validate_digit(payload.digit) + entered_digits = json.loads(row.entered_digits_json or "[]") + + next_node_id = None + matched = False + for option in current_node.get("options", []): + if str(option.get("digit")) == digit: + next_node_id = str(option["target_node_id"]) + matched = True + entered_digits.append(digit) + break + + if not matched: + fallback_target = current_node.get("invalid_target_node_id") + next_node_id = str(fallback_target).strip() if fallback_target else row.current_node_id + + next_node = node_map[next_node_id] + now = utc_now_iso() + row.current_node_id = next_node_id + row.entered_digits_json = json.dumps(entered_digits, ensure_ascii=False) + row.updated_at = now + + _push_timeline( + session, + row.interaction_id, + "ivr.step.completed", + { + "session_id": row.session_id, + "digit": digit, + "matched": matched, + "current_node_id": next_node_id, + }, + ) + + if next_node.get("is_terminal"): + _complete_session( + session, + row, + next_node=next_node, + next_node_id=next_node_id, + entered_digits=entered_digits, + completed_at=now, + ) + + session.commit() + session.refresh(row) + return _session_response(row, next_node) + finally: + session.close() + + +@app.post("/ivr/sessions/{session_id}/no-input") +def process_no_input( + session_id: str, + _: dict = Depends(_require_runtime_actor), +) -> dict: + session = get_session() + try: + row = _get_session_row(session, session_id) + if row.status != "active": + raise HTTPException(status_code=409, detail="IVR session is not active") + + _, node_map = _load_flow_runtime(session, row) + current_node = node_map[row.current_node_id] + next_node_id = str(current_node.get("no_input_target_node_id") or "").strip() or row.current_node_id + next_node = node_map[next_node_id] + entered_digits = json.loads(row.entered_digits_json or "[]") + now = utc_now_iso() + + row.current_node_id = next_node_id + row.updated_at = now + + _push_timeline( + session, + row.interaction_id, + "ivr.step.no_input", + { + "session_id": row.session_id, + "current_node_id": next_node_id, + }, + ) + + if next_node.get("is_terminal"): + _complete_session( + session, + row, + next_node=next_node, + next_node_id=next_node_id, + entered_digits=entered_digits, + completed_at=now, + ) + + session.commit() + session.refresh(row) + return _session_response(row, next_node) + finally: + session.close() + + +@app.get("/ivr/sessions/{session_id}", response_model=IvrSessionOut) +def get_session_detail( + session_id: str, + _: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR)), +) -> IvrSessionOut: + session = get_session() + try: + return _to_session_out(_get_session_row(session, session_id)) + finally: + session.close() + + +@app.get("/ivr/sessions", response_model=list[IvrSessionOut]) +def list_sessions( + call_id: str | None = None, + interaction_id: str | None = None, + status: str | None = None, + limit: int = Query(default=100, ge=1, le=500), + _: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR)), +) -> list[IvrSessionOut]: + session = get_session() + try: + stmt = select(IvrSessionRow).order_by(IvrSessionRow.id.desc()) + if call_id: + stmt = stmt.where(IvrSessionRow.call_id == call_id) + if interaction_id: + stmt = stmt.where(IvrSessionRow.interaction_id == interaction_id) + if status: + stmt = stmt.where(IvrSessionRow.status == status) + rows = session.execute(stmt).scalars().all() + return [_to_session_out(row) for row in rows[:limit]] + finally: + session.close() + + +@app.post("/ivr/sessions/{session_id}/abandon", response_model=IvrSessionOut) +def abandon_session( + session_id: str, + _: dict = Depends(_require_runtime_actor), +) -> IvrSessionOut: + session = get_session() + try: + row = _get_session_row(session, session_id) + if row.status != "active": + raise HTTPException(status_code=409, detail="IVR session is not active") + row.status = "abandoned" + row.updated_at = utc_now_iso() + _push_timeline( + session, + row.interaction_id, + "ivr.abandoned", + {"session_id": row.session_id, "flow_id": row.flow_id}, + ) + session.commit() + session.refresh(row) + return _to_session_out(row) + finally: + session.close() diff --git a/services/kb_service/__init__.py b/services/kb_service/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/kb_service/app.py b/services/kb_service/app.py new file mode 100644 index 0000000..e76023d --- /dev/null +++ b/services/kb_service/app.py @@ -0,0 +1,232 @@ +from __future__ import annotations + +import json + +from fastapi import Depends, FastAPI, HTTPException +from sqlalchemy import func, select + +from services.shared.core import Role, new_id, utc_now_iso +from services.shared.db import get_session +from services.shared.kb_localization import normalize_kb_language, resolve_article_group_id +from services.shared.kb_search import search_kb_rows +from services.shared.models import ( + HealthResponse, + KBArticleCreate, + KBArticleOut, + KBArticleUpdate, + KBCategoryCreate, + KBCategoryOut, +) +from services.shared.security import require_roles +from services.shared.sql_init import init_sql_schema +from services.shared.sql_models import KBArticleRow, KBCategoryRow + +app = FastAPI(title="kb-service", version="1.0.0") + +init_sql_schema() + + +def _category_out(row: KBCategoryRow) -> KBCategoryOut: + return KBCategoryOut( + category_id=row.category_id, + name=row.name, + description=row.description, + created_at=row.created_at, + ) + + +def _article_out(row: KBArticleRow) -> KBArticleOut: + return KBArticleOut( + article_id=row.article_id, + category_id=row.category_id, + article_group_id=resolve_article_group_id(row.article_id, row.article_group_id), + language=normalize_kb_language(row.language), + title=row.title, + body=row.body, + tags=json.loads(row.tags_json or "[]"), + created_at=row.created_at, + updated_at=row.updated_at, + ) + + +def _article_group_expr(): + return func.coalesce(KBArticleRow.article_group_id, KBArticleRow.article_id) + + +def _find_group_language_conflict( + session, + *, + article_group_id: str, + language: str, + exclude_article_id: str | None = None, +) -> KBArticleRow | None: + stmt = select(KBArticleRow).where(_article_group_expr() == article_group_id).where(KBArticleRow.language == language) + if exclude_article_id: + stmt = stmt.where(KBArticleRow.article_id != exclude_article_id) + return session.execute(stmt).scalar_one_or_none() + + +@app.get("/health", response_model=HealthResponse) +def health() -> HealthResponse: + return HealthResponse(status="ok", service="kb-service") + + +@app.post("/knowledge/categories", response_model=KBCategoryOut) +def create_category( + payload: KBCategoryCreate, + _: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.ANALYST)), +) -> KBCategoryOut: + session = get_session() + try: + row = KBCategoryRow( + category_id=new_id("kbc"), + name=payload.name, + description=payload.description, + created_at=utc_now_iso(), + ) + session.add(row) + session.commit() + session.refresh(row) + return _category_out(row) + finally: + session.close() + + +@app.get("/knowledge/categories", response_model=list[KBCategoryOut]) +def list_categories() -> list[KBCategoryOut]: + session = get_session() + try: + rows = session.execute(select(KBCategoryRow).order_by(KBCategoryRow.id.asc())).scalars().all() + return [_category_out(r) for r in rows] + finally: + session.close() + + +@app.post("/knowledge/articles", response_model=KBArticleOut) +def create_article( + payload: KBArticleCreate, + _: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.ANALYST)), +) -> KBArticleOut: + session = get_session() + try: + category = session.execute( + select(KBCategoryRow).where(KBCategoryRow.category_id == payload.category_id) + ).scalar_one_or_none() + if not category: + raise HTTPException(status_code=400, detail="Unknown category_id") + + now = utc_now_iso() + article_id = new_id("kba") + language = normalize_kb_language(payload.language) + article_group_id = resolve_article_group_id(article_id, payload.article_group_id) + conflict = _find_group_language_conflict( + session, + article_group_id=article_group_id, + language=language, + ) + if conflict: + raise HTTPException( + status_code=409, + detail="Localized article already exists for this article_group_id and language", + ) + row = KBArticleRow( + article_id=article_id, + category_id=payload.category_id, + article_group_id=article_group_id, + language=language, + title=payload.title, + body=payload.body, + tags_json=json.dumps(payload.tags, ensure_ascii=False), + created_at=now, + updated_at=now, + ) + session.add(row) + session.commit() + session.refresh(row) + return _article_out(row) + finally: + session.close() + + +@app.get("/knowledge/articles/{article_id}", response_model=KBArticleOut) +def get_article(article_id: str) -> KBArticleOut: + session = get_session() + try: + row = session.execute( + select(KBArticleRow).where(KBArticleRow.article_id == article_id) + ).scalar_one_or_none() + if not row: + raise HTTPException(status_code=404, detail="Article not found") + return _article_out(row) + finally: + session.close() + + +@app.patch("/knowledge/articles/{article_id}", response_model=KBArticleOut) +def update_article( + article_id: str, + payload: KBArticleUpdate, + _: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.ANALYST)), +) -> KBArticleOut: + session = get_session() + try: + row = session.execute( + select(KBArticleRow).where(KBArticleRow.article_id == article_id) + ).scalar_one_or_none() + if not row: + raise HTTPException(status_code=404, detail="Article not found") + + data = payload.model_dump(exclude_none=True) + target_group_id = resolve_article_group_id( + row.article_id, + data.get("article_group_id", row.article_group_id), + ) + target_language = normalize_kb_language(data.get("language", row.language)) + conflict = _find_group_language_conflict( + session, + article_group_id=target_group_id, + language=target_language, + exclude_article_id=row.article_id, + ) + if conflict: + raise HTTPException( + status_code=409, + detail="Localized article already exists for this article_group_id and language", + ) + + if "article_group_id" in data: + row.article_group_id = target_group_id + if "language" in data: + row.language = target_language + if "title" in data: + row.title = data["title"] + if "body" in data: + row.body = data["body"] + if "tags" in data: + row.tags_json = json.dumps(data["tags"], ensure_ascii=False) + row.updated_at = utc_now_iso() + session.commit() + return _article_out(row) + finally: + session.close() + + +@app.get("/knowledge/search", response_model=list[KBArticleOut]) +def search_articles( + q: str = "", + limit: int = 50, + language: str | None = None, + article_group_id: str | None = None, +) -> list[KBArticleOut]: + session = get_session() + try: + stmt = select(KBArticleRow).order_by(KBArticleRow.id.desc()) + if language is not None: + stmt = stmt.where(KBArticleRow.language == normalize_kb_language(language)) + if article_group_id: + stmt = stmt.where(_article_group_expr() == article_group_id.strip()) + rows = session.execute(stmt).scalars().all() + matches = search_kb_rows(rows, q, limit=limit, empty_query_returns_all=True) + return [_article_out(row) for row in matches] + finally: + session.close() diff --git a/services/recording_service/app.py b/services/recording_service/app.py new file mode 100644 index 0000000..b2d4071 --- /dev/null +++ b/services/recording_service/app.py @@ -0,0 +1,429 @@ +from __future__ import annotations + +import hashlib +import json +import os +from pathlib import Path +import shutil +import tempfile + +from fastapi import Depends, FastAPI, File, Form, HTTPException, Query, UploadFile +from fastapi.responses import FileResponse +from sqlalchemy import select +from sqlalchemy.exc import IntegrityError + +from services.shared.core import Role, new_id, utc_now_iso +from services.shared.db import get_session +from services.shared.event_bus import append_outbox_event, event_bus_enabled +from services.shared.models import HealthResponse, RecordingOut, RecordingRegisterIn +from services.shared.security import get_actor, require_roles +from services.shared.sql_init import init_sql_schema +from services.shared.sql_models import CallRecordingRow, VoiceEventRow + +app = FastAPI(title="recording-service", version="1.0.0") + +init_sql_schema() + +ALLOWED_AUDIO_TYPES = { + ".wav": "audio/wav", + ".mp3": "audio/mpeg", + ".ogg": "audio/ogg", +} + + +def _bool_env(name: str, default: bool) -> bool: + raw = os.getenv(name) + if raw is None: + return default + return raw.strip().lower() in {"1", "true", "yes", "on"} + + +def _trusted_import_subjects() -> set[str]: + raw = os.getenv("RECORDING_IMPORT_TRUSTED_SERVICE_SUBJECTS", "svc:asterisk-bridge") + return {item.strip() for item in raw.split(",") if item.strip()} + + +def _require_import_upload_actor(actor: dict = Depends(get_actor)) -> dict: + if actor.get("auth_source") == "service": + if str(actor.get("sub") or "").strip() in _trusted_import_subjects(): + return actor + raise HTTPException(status_code=403, detail="Untrusted service subject") + + if _bool_env("RECORDING_IMPORT_ALLOW_ADMIN", True) and actor.get("role") == Role.ADMIN.value: + return actor + + raise HTTPException(status_code=403, detail="Insufficient role") + + +def _recordings_root() -> Path: + base_dir = os.getenv("CC_RECORDINGS_DIR") + if base_dir: + root = Path(base_dir).resolve() + else: + data_dir = Path(os.getenv("CC_DATA_DIR", ".data")).resolve() + root = data_dir / "recordings" + root.mkdir(parents=True, exist_ok=True) + return root + + +def _max_bytes() -> int: + raw = os.getenv("RECORDING_MAX_BYTES", "26214400").strip() + try: + value = int(raw) + except ValueError: + value = 26214400 + return max(value, 1024) + + +def _safe_file_name(value: str | None, source_path: Path) -> str: + base_name = (value or source_path.name or "recording.wav").strip() + cleaned = Path(base_name).name.replace(" ", "-") + return cleaned or "recording.wav" + + +def _normalize_audio_type(file_name: str, mime_type: str | None) -> tuple[str, str]: + suffix = Path(file_name).suffix.lower() + canonical = ALLOWED_AUDIO_TYPES.get(suffix) + if not canonical: + raise HTTPException(status_code=415, detail="Unsupported audio format") + return canonical, suffix + + +def _sha256_for(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(65536), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _copy_into_storage( + *, + source_path: Path, + recording_id: str, + file_name: str, +) -> tuple[Path, int, str]: + if not source_path.exists() or not source_path.is_file(): + raise HTTPException(status_code=404, detail="Source recording not found") + + size_bytes = source_path.stat().st_size + if size_bytes > _max_bytes(): + raise HTTPException(status_code=413, detail="Recording exceeds max size") + + day_prefix = utc_now_iso().split("T", 1)[0].split("-") + target_dir = _recordings_root().joinpath(*day_prefix) + target_dir.mkdir(parents=True, exist_ok=True) + target_path = (target_dir / f"{recording_id}_{file_name}").resolve() + shutil.copy2(source_path, target_path) + checksum = _sha256_for(target_path) + return target_path, size_bytes, checksum + + +def _to_out(row: CallRecordingRow) -> RecordingOut: + return RecordingOut( + recording_id=row.recording_id, + channel="voice", + call_id=row.call_id, + interaction_id=row.interaction_id, + source_event_id=row.source_event_id, + file_name=row.file_name, + mime_type=row.mime_type, + size_bytes=row.size_bytes, + duration_seconds=row.duration_seconds, + storage_backend="local_fs", + status=row.status, + recorded_at=row.recorded_at, + created_at=row.created_at, + updated_at=row.updated_at, + archived_at=row.archived_at, + ) + + +def _load_existing_by_source_event(session, source_event_id: str | None) -> CallRecordingRow | None: + if not source_event_id: + return None + return session.execute( + select(CallRecordingRow).where(CallRecordingRow.source_event_id == source_event_id) + ).scalar_one_or_none() + + +def _register_recording( + session, + *, + call_id: str, + interaction_id: str | None, + source_path: Path, + file_name: str | None, + mime_type: str | None, + duration_seconds: int | None, + recorded_at: str | None, + source_event_id: str | None, +) -> CallRecordingRow: + existing = _load_existing_by_source_event(session, source_event_id) + if existing: + return existing + + recording_id = new_id("rec") + safe_name = _safe_file_name(file_name, source_path) + normalized_mime, _ = _normalize_audio_type(safe_name, mime_type) + target_path: Path | None = None + target_path, size_bytes, checksum = _copy_into_storage( + source_path=source_path, + recording_id=recording_id, + file_name=safe_name, + ) + now = utc_now_iso() + row = CallRecordingRow( + recording_id=recording_id, + channel="voice", + call_id=call_id, + interaction_id=interaction_id, + source_event_id=source_event_id, + file_name=safe_name, + storage_backend="local_fs", + storage_path=str(target_path), + mime_type=normalized_mime, + size_bytes=size_bytes, + duration_seconds=duration_seconds, + checksum_sha256=checksum, + status="ready", + recorded_at=recorded_at or now, + created_at=now, + updated_at=now, + archived_at=None, + ) + session.add(row) + if event_bus_enabled(): + append_outbox_event( + session, + event_type="call.recording.ready", + producer_service="recording-service", + entity_type="recording", + entity_id=recording_id, + correlation_id=interaction_id or call_id, + payload={ + "event": "call.recording.ready", + "recording_id": recording_id, + "call_id": call_id, + "interaction_id": interaction_id, + "source_event_id": source_event_id, + "file_name": safe_name, + "mime_type": normalized_mime, + "duration_seconds": duration_seconds, + "status": "ready", + "recorded_at": recorded_at or now, + }, + ) + try: + session.commit() + except IntegrityError: + session.rollback() + if target_path is not None and target_path.exists(): + target_path.unlink(missing_ok=True) + existing = _load_existing_by_source_event(session, source_event_id) + if existing is not None: + return existing + raise + session.refresh(row) + return row + + +def _get_recording_row(session, recording_id: str) -> CallRecordingRow: + row = session.execute( + select(CallRecordingRow).where(CallRecordingRow.recording_id == recording_id) + ).scalar_one_or_none() + if not row: + raise HTTPException(status_code=404, detail="Recording not found") + return row + + +@app.get("/health", response_model=HealthResponse) +def health() -> HealthResponse: + return HealthResponse(status="ok", service="recording-service") + + +@app.post( + "/recordings/import-from-voice-event/{event_id}", + response_model=RecordingOut, +) +def import_from_voice_event( + event_id: str, + _: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR)), +) -> RecordingOut: + session = get_session() + try: + voice_event = session.execute( + select(VoiceEventRow).where(VoiceEventRow.event_id == event_id) + ).scalar_one_or_none() + if not voice_event: + raise HTTPException(status_code=404, detail="Voice event not found") + if voice_event.event_type != "recording.ready": + raise HTTPException(status_code=400, detail="Voice event is not recording.ready") + payload = json.loads(voice_event.payload_json or "{}") + source_path_raw = str(payload.get("source_path") or "").strip() + if not source_path_raw: + raise HTTPException(status_code=400, detail="Recording source_path is required") + + row = _register_recording( + session, + call_id=voice_event.call_id, + interaction_id=voice_event.interaction_id, + source_path=Path(source_path_raw).resolve(), + file_name=payload.get("file_name"), + mime_type=payload.get("mime_type"), + duration_seconds=payload.get("duration_seconds"), + recorded_at=payload.get("recorded_at"), + source_event_id=voice_event.event_id, + ) + return _to_out(row) + finally: + session.close() + + +@app.post("/recordings/register", response_model=RecordingOut) +def register_recording( + payload: RecordingRegisterIn, + _: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR)), +) -> RecordingOut: + session = get_session() + try: + row = _register_recording( + session, + call_id=payload.call_id, + interaction_id=payload.interaction_id, + source_path=Path(payload.source_path).resolve(), + file_name=payload.file_name, + mime_type=payload.mime_type, + duration_seconds=payload.duration_seconds, + recorded_at=payload.recorded_at, + source_event_id=payload.source_event_id, + ) + return _to_out(row) + finally: + session.close() + + +@app.post("/recordings/import-upload", response_model=RecordingOut) +def import_uploaded_recording( + file: UploadFile = File(...), + call_id: str = Form(...), + interaction_id: str | None = Form(default=None), + source_event_id: str | None = Form(default=None), + file_name: str | None = Form(default=None), + mime_type: str | None = Form(default=None), + duration_seconds: int | None = Form(default=None), + recorded_at: str | None = Form(default=None), + _: dict = Depends(_require_import_upload_actor), +) -> RecordingOut: + suffix = Path(file.filename or file_name or "recording.wav").suffix or ".wav" + temp_path: Path | None = None + try: + with tempfile.NamedTemporaryFile(prefix="mvpcc-rec-", suffix=suffix, delete=False) as handle: + shutil.copyfileobj(file.file, handle) + temp_path = Path(handle.name) + + session = get_session() + try: + row = _register_recording( + session, + call_id=call_id, + interaction_id=interaction_id, + source_path=temp_path.resolve(), + file_name=file_name or file.filename, + mime_type=mime_type or file.content_type, + duration_seconds=duration_seconds, + recorded_at=recorded_at, + source_event_id=source_event_id, + ) + return _to_out(row) + finally: + session.close() + finally: + try: + file.file.close() + except Exception: # noqa: BLE001 + pass + if temp_path and temp_path.exists(): + temp_path.unlink(missing_ok=True) + + +@app.get("/recordings", response_model=list[RecordingOut]) +def list_recordings( + call_id: str | None = None, + interaction_id: str | None = None, + status: str | None = None, + limit: int = Query(default=100, ge=1, le=500), + _: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR)), +) -> list[RecordingOut]: + session = get_session() + try: + stmt = select(CallRecordingRow).order_by(CallRecordingRow.id.desc()) + if call_id: + stmt = stmt.where(CallRecordingRow.call_id == call_id) + if interaction_id: + stmt = stmt.where(CallRecordingRow.interaction_id == interaction_id) + if status: + stmt = stmt.where(CallRecordingRow.status == status) + rows = session.execute(stmt).scalars().all() + return [_to_out(row) for row in rows[:limit]] + finally: + session.close() + + +@app.get("/recordings/{recording_id}", response_model=RecordingOut) +def get_recording( + recording_id: str, + _: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR)), +) -> RecordingOut: + session = get_session() + try: + return _to_out(_get_recording_row(session, recording_id)) + finally: + session.close() + + +@app.get("/recordings/{recording_id}/content") +def get_recording_content( + recording_id: str, + disposition: str = Query(default="inline"), + _: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR)), +) -> FileResponse: + if disposition not in {"inline", "attachment"}: + raise HTTPException(status_code=400, detail="Invalid disposition") + + session = get_session() + try: + row = _get_recording_row(session, recording_id) + path = Path(row.storage_path) + if not path.exists() or not path.is_file(): + row.status = "missing" + row.updated_at = utc_now_iso() + session.commit() + raise HTTPException(status_code=410, detail="Recording file is missing") + return FileResponse( + path=path, + media_type=row.mime_type, + filename=row.file_name, + content_disposition_type=disposition, + ) + finally: + session.close() + + +@app.post("/recordings/{recording_id}/archive", response_model=RecordingOut) +def archive_recording( + recording_id: str, + _: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR)), +) -> RecordingOut: + session = get_session() + try: + row = _get_recording_row(session, recording_id) + now = utc_now_iso() + row.status = "archived" + row.archived_at = now + row.updated_at = now + session.commit() + session.refresh(row) + return _to_out(row) + finally: + session.close() diff --git a/services/reporting_service/__init__.py b/services/reporting_service/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/reporting_service/app.py b/services/reporting_service/app.py new file mode 100644 index 0000000..541e873 --- /dev/null +++ b/services/reporting_service/app.py @@ -0,0 +1,1552 @@ +from __future__ import annotations + +from collections import Counter +import csv +from datetime import datetime, timedelta, timezone +import io +import json +import threading +from typing import Any +from uuid import uuid4 + +from fastapi import Depends, FastAPI, HTTPException, Query +from fastapi.responses import PlainTextResponse +from sqlalchemy import inspect, select, text + +from services.shared.core import Role, utc_now_iso +from services.shared.db import engine, get_session +from services.shared.event_bus import ( + consume_one_message, + consumer_enabled, + event_bus_enabled, + event_bus_reporting_queue, + inbox_seen, + poll_forever, + record_inbox, +) +from services.shared.models import ( + HealthResponse, + KpiEventIn, + ReportingAgentAnalyticsBreakdownsOut, + ReportingDrilldownFiltersOut, + ReportingDrilldownItemOut, + ReportingDrilldownOut, + ReportingAgentAnalyticsFiltersOut, + ReportingAgentAnalyticsOverviewOut, + ReportingAgentAnalyticsRowOut, + ReportingAgentAnalyticsShiftRowOut, + ReportingAgentAnalyticsTeamRowOut, + ReportingAgentAnalyticsTimeseriesOut, + ReportingAgentAnalyticsTrendFiltersOut, + ReportingAgentAnalyticsTrendPointOut, + ReportingAgentAnalyticsTotalsOut, + ReportingInteractionFactIn, + ReportingKpiCoverageOut, + ReportingAgentStateSnapshotOut, + ReportingMetricCoverageOut, + ReportingSavedViewIn, + ReportingSavedViewOut, + ReportingSavedViewSnapshot, + ReportingTimeseriesFiltersOut, + ReportingTimeseriesOut, + ReportingTimeseriesPointOut, +) +from services.shared.reporting_facts import upsert_reporting_interaction_fact +from services.shared.security import require_roles +from services.shared.sql_init import init_sql_schema +from services.shared.sql_models import ( + Interaction, + ReportingEventLogRow, + ReportingEventRow, + ReportingInteractionFactRow, + ReportingSavedViewRow, + SupervisorAgentStateRow, +) + +app = FastAPI(title="reporting-service", version="1.2.0") + +_DRILLDOWN_METRICS = {"total", "answered", "SL", "ASA", "AHT", "Abandon", "FCR", "DigitalShare"} +_TIMESERIES_METRICS = { + "volume", + "total", + "answered", + "SL", + "ASA", + "AHT", + "Abandon", + "FCR", + "AnswerRate", + "WaitP95", + "HandleP95", + "Occupancy", + "DigitalShare", +} +_AGENT_SORT_FIELDS = { + "interactions_total", + "answered_total", + "avg_handle_seconds", + "fcr_rate", + "last_activity_at", + "agent_id", +} +_AGENT_TREND_METRICS = {"agents_with_activity", "interactions_per_agent", "avg_handle_seconds", "fcr_rate"} +_SORT_DIRECTIONS = {"asc", "desc"} +_VOICE_FIRST_METRICS = {"answered", "SL", "ASA", "AHT", "Abandon", "FCR", "AnswerRate", "WaitP95", "HandleP95", "Occupancy"} +_METRIC_SUPPORTED_CHANNELS = { + "total": ["voice", "telegram", "whatsapp", "webchat", "email"], + "answered": ["voice"], + "SL": ["voice"], + "ASA": ["voice"], + "AHT": ["voice"], + "Abandon": ["voice"], + "FCR": ["voice"], + "AnswerRate": ["voice"], + "WaitP95": ["voice"], + "HandleP95": ["voice"], + "Occupancy": ["voice"], + "DigitalShare": ["voice", "telegram", "whatsapp", "webchat", "email"], +} +_AGENT_SHIFT_LABELS = { + "night": "Ночная смена", + "day": "Дневная смена", + "evening": "Вечерняя смена", +} + + +def _ensure_reporting_columns() -> None: + init_sql_schema() + inspector = inspect(engine) + if "reporting_events" not in inspector.get_table_names(): + return + + columns = {item["name"] for item in inspector.get_columns("reporting_events")} + dialect = engine.url.get_backend_name() + + statements: list[str] = [] + if "channel" not in columns: + if dialect == "postgresql": + statements.append("ALTER TABLE reporting_events ADD COLUMN channel VARCHAR(32) NOT NULL DEFAULT 'voice'") + else: + statements.append("ALTER TABLE reporting_events ADD COLUMN channel TEXT NOT NULL DEFAULT 'voice'") + statements.append("CREATE INDEX IF NOT EXISTS ix_reporting_events_channel ON reporting_events (channel)") + if "agent_id" not in columns: + if dialect == "postgresql": + statements.append("ALTER TABLE reporting_events ADD COLUMN agent_id VARCHAR(128) NULL") + else: + statements.append("ALTER TABLE reporting_events ADD COLUMN agent_id TEXT NULL") + statements.append("CREATE INDEX IF NOT EXISTS ix_reporting_events_agent_id ON reporting_events (agent_id)") + + if not statements: + return + + with engine.begin() as conn: + for stmt in statements: + conn.execute(text(stmt)) + + +_ensure_reporting_columns() + + +def _parse_filter_timestamp(raw: str | None, field_name: str) -> datetime | None: + if not raw: + return None + normalized = raw.strip() + if normalized.endswith("Z"): + normalized = f"{normalized[:-1]}+00:00" + try: + value = datetime.fromisoformat(normalized) + except ValueError as exc: + raise HTTPException(status_code=400, detail=f"Invalid {field_name}") from exc + if value.tzinfo is None: + value = value.replace(tzinfo=timezone.utc) + return value.astimezone(timezone.utc) + + +def _normalize_window(from_ts: str | None, to_ts: str | None) -> tuple[datetime | None, datetime | None]: + dt_from = _parse_filter_timestamp(from_ts, "from_ts") + dt_to = _parse_filter_timestamp(to_ts, "to_ts") + if dt_from and dt_to and dt_to <= dt_from: + raise HTTPException(status_code=400, detail="to_ts must be greater than from_ts") + return dt_from, dt_to + + +def _in_range(ts: str | None, dt_from: datetime | None, dt_to: datetime | None) -> bool: + if not ts: + return False + try: + dt = datetime.fromisoformat(ts.replace("Z", "+00:00")) + except ValueError: + return False + if dt_from and dt < dt_from: + return False + if dt_to and dt >= dt_to: + return False + return True + + +def _percentile(values: list[int], pct: float) -> float: + if not values: + return 0.0 + ordered = sorted(values) + if len(ordered) == 1: + return float(ordered[0]) + rank = (len(ordered) - 1) * pct + lower = int(rank) + upper = min(lower + 1, len(ordered) - 1) + weight = rank - lower + return float(ordered[lower] + (ordered[upper] - ordered[lower]) * weight) + + +def _safe_percent(numerator: int | float, denominator: int | float) -> float: + if not denominator: + return 0.0 + return float(numerator) / float(denominator) * 100.0 + + +def _serialize_legacy_row(row: ReportingEventRow) -> dict[str, Any]: + return { + "queue_id": row.queue_id, + "channel": row.channel, + "agent_id": row.agent_id, + "answered": row.answered, + "wait_seconds": row.wait_seconds, + "handle_seconds": row.handle_seconds, + "abandoned": row.abandoned, + "resolved_first_contact": row.resolved_first_contact, + "created_at": row.created_at, + } + + +def _serialize_fact_row(row: ReportingInteractionFactRow) -> dict[str, Any]: + return { + "interaction_id": row.interaction_id, + "channel": row.channel or "voice", + "queue_id": row.queue_id, + "agent_id": row.agent_id, + "status": row.status, + "created_at": row.created_at, + "updated_at": row.updated_at, + "closed_at": row.closed_at, + "answered": row.answered, + "abandoned": row.abandoned, + "wait_seconds": row.wait_seconds, + "handle_seconds": row.handle_seconds, + "resolved_first_contact": row.resolved_first_contact, + "source": row.source, + } + + +def _load_fact_records( + session, + *, + dt_from: datetime | None, + dt_to: datetime | None, + queue_id: str | None, + channel: str | None, +) -> list[dict[str, Any]]: + rows = session.execute( + select(ReportingInteractionFactRow).order_by(ReportingInteractionFactRow.id.asc()) + ).scalars().all() + records = [_serialize_fact_row(row) for row in rows] + if queue_id: + records = [item for item in records if item["queue_id"] == queue_id] + if channel: + records = [item for item in records if item["channel"] == channel] + if dt_from or dt_to: + records = [item for item in records if _in_range(item["created_at"], dt_from, dt_to)] + return records + + +def _load_interactions_map(session, interaction_ids: list[str]) -> dict[str, Interaction]: + ids = [item for item in interaction_ids if item] + if not ids: + return {} + rows = session.execute( + select(Interaction).where(Interaction.interaction_id.in_(ids)) + ).scalars().all() + return {row.interaction_id: row for row in rows} + + +def _supported_channels(metric: str) -> list[str]: + return list(_METRIC_SUPPORTED_CHANNELS.get(metric, ["voice"])) + + +def _metric_known(row: dict[str, Any], metric: str, sl_threshold_seconds: int) -> bool: + if metric in {"total", "DigitalShare"}: + return True + answered = row.get("answered") + abandoned = row.get("abandoned") + wait_seconds = row.get("wait_seconds") + handle_seconds = row.get("handle_seconds") + resolved_first_contact = row.get("resolved_first_contact") + if metric in {"answered", "AnswerRate"}: + return answered is not None + if metric == "Abandon": + return abandoned is not None + if metric == "SL": + return answered is not None and (answered is False or wait_seconds is not None) + if metric in {"ASA", "WaitP95"}: + return answered is not None and (answered is False or wait_seconds is not None) + if metric in {"AHT", "HandleP95"}: + return answered is not None and (answered is False or handle_seconds is not None) + if metric == "Occupancy": + return answered is not None and (answered is False or (wait_seconds is not None and handle_seconds is not None)) + if metric == "FCR": + return answered is not None and (answered is False or resolved_first_contact is not None) + return False + + +def _metric_coverage(metric: str, records: list[dict[str, Any]], sl_threshold_seconds: int) -> ReportingMetricCoverageOut: + supported_channels = _supported_channels(metric) + total_rows = len(records) + supported_rows = [row for row in records if row["channel"] in supported_channels] + known_rows = [row for row in supported_rows if _metric_known(row, metric, sl_threshold_seconds)] + note: str | None = None + status = "unavailable" + + if total_rows == 0: + note = "No exact fact rows found for the selected window." + elif not supported_rows: + note = "Exact KPI in V5 is not available for the selected channels yet." + elif len(known_rows) == total_rows and len(supported_rows) == total_rows: + status = "exact" + note = "Exact interaction-linked fact coverage is available for this metric." + elif known_rows: + status = "partial" + if len(supported_rows) < total_rows: + note = "The selected window includes channels outside the exact V5 scope." + else: + note = f"Exact facts are available for {len(known_rows)} of {len(supported_rows)} supported interactions." + else: + note = "Exact fact rows for this metric are not available yet in the selected window." + + return ReportingMetricCoverageOut( + status=status, # type: ignore[arg-type] + supported_channels=supported_channels, + exact_rows=len(known_rows), + total_rows=total_rows, + note=note, + ) + + +def _known_rows_for_metric(metric: str, records: list[dict[str, Any]], sl_threshold_seconds: int) -> list[dict[str, Any]]: + supported_channels = _supported_channels(metric) + return [ + row + for row in records + if row["channel"] in supported_channels and _metric_known(row, metric, sl_threshold_seconds) + ] + + +def _metric_slice(metric: str, records: list[dict[str, Any]], sl_threshold_seconds: int) -> list[dict[str, Any]]: + known_rows = _known_rows_for_metric(metric, records, sl_threshold_seconds) + if metric == "total": + return known_rows + if metric == "answered": + return [row for row in known_rows if row.get("answered") is True] + if metric == "SL": + return [ + row + for row in known_rows + if row.get("answered") is True and row.get("wait_seconds") is not None and int(row["wait_seconds"]) <= sl_threshold_seconds + ] + if metric == "ASA": + return [ + row + for row in known_rows + if row.get("answered") is True and row.get("wait_seconds") is not None + ] + if metric == "AHT": + return [ + row + for row in known_rows + if row.get("answered") is True and row.get("handle_seconds") is not None + ] + if metric == "Abandon": + return [row for row in known_rows if row.get("abandoned") is True] + if metric == "FCR": + return [ + row + for row in known_rows + if row.get("answered") is True and row.get("resolved_first_contact") is True + ] + if metric == "DigitalShare": + return [row for row in known_rows if row.get("channel") != "voice"] + return [] + + +def _build_channel_breakdown(records: list[dict[str, Any]]) -> dict[str, dict[str, int]]: + bucket: dict[str, dict[str, int]] = {} + for item in records: + key = item.get("channel") or "voice" + current = bucket.setdefault(key, {"total": 0, "answered": 0, "abandoned": 0}) + current["total"] += 1 + current["answered"] += 1 if item.get("answered") is True else 0 + current["abandoned"] += 1 if item.get("abandoned") is True else 0 + return bucket + + +def _metric_catalog() -> list[dict[str, Any]]: + return [ + {"code": "SL", "label": "Service Level", "formula": "answered_within_threshold / total * 100", "status": "implemented"}, + {"code": "ASA", "label": "Average Speed of Answer", "formula": "sum(wait_seconds for answered) / answered", "status": "implemented"}, + {"code": "AHT", "label": "Average Handle Time", "formula": "sum(handle_seconds for answered) / answered", "status": "implemented"}, + {"code": "Abandon", "label": "Abandon Rate", "formula": "abandoned / total * 100", "status": "implemented"}, + {"code": "FCR", "label": "First Contact Resolution", "formula": "resolved_first_contact / answered * 100", "status": "implemented"}, + {"code": "AnswerRate", "label": "Answer Rate", "formula": "answered / total * 100", "status": "implemented"}, + {"code": "WaitP95", "label": "Wait Time P95", "formula": "p95(wait_seconds for answered)", "status": "implemented"}, + {"code": "HandleP95", "label": "Handle Time P95", "formula": "p95(handle_seconds for answered)", "status": "implemented"}, + {"code": "Occupancy", "label": "Occupancy", "formula": "sum(handle_seconds) / (sum(handle_seconds) + sum(wait_seconds)) * 100", "status": "implemented"}, + {"code": "DigitalShare", "label": "Digital Share", "formula": "non_voice / total * 100", "status": "implemented"}, + ] + + +def _coverage_snapshot(records: list[dict[str, Any]], sl_threshold_seconds: int) -> ReportingKpiCoverageOut: + metrics = ["total", "answered", "SL", "ASA", "AHT", "Abandon", "FCR", "AnswerRate", "WaitP95", "HandleP95", "Occupancy", "DigitalShare"] + metric_details = { + metric: _metric_coverage(metric, records, sl_threshold_seconds) + for metric in metrics + } + total_detail = metric_details["total"] + return ReportingKpiCoverageOut( + metric_status={metric: detail.status for metric, detail in metric_details.items()}, + supported_channels={metric: detail.supported_channels for metric, detail in metric_details.items()}, + exact_rows=total_detail.exact_rows, + total_rows=total_detail.total_rows, + note=total_detail.note, + metric_details=metric_details, + ) + + +def _metric_note(metric: str) -> str | None: + if metric in _VOICE_FIRST_METRICS: + return "Exact V5 coverage is currently voice-first. Digital channels stay partial until they emit the same fact fields." + if metric == "DigitalShare": + return "DigitalShare is exact for any interaction with known channel in the selected window." + if metric == "total": + return "Total interactions use exact interaction-linked facts across all currently instrumented channels." + return None + + +def _build_kpi_from_facts(records: list[dict[str, Any]], sl_threshold_seconds: int, *, from_ts: str | None, to_ts: str | None, queue_id: str | None, channel: str | None) -> dict[str, Any]: + coverage = _coverage_snapshot(records, sl_threshold_seconds) + total_rows = _metric_slice("total", records, sl_threshold_seconds) + answered_known_rows = _known_rows_for_metric("answered", records, sl_threshold_seconds) + abandon_known_rows = _known_rows_for_metric("Abandon", records, sl_threshold_seconds) + sl_known_rows = _known_rows_for_metric("SL", records, sl_threshold_seconds) + asa_rows = _metric_slice("ASA", records, sl_threshold_seconds) + aht_rows = _metric_slice("AHT", records, sl_threshold_seconds) + fcr_known_rows = _known_rows_for_metric("FCR", records, sl_threshold_seconds) + digital_rows = _metric_slice("DigitalShare", records, sl_threshold_seconds) + wait_values = [int(row["wait_seconds"]) for row in asa_rows if row.get("wait_seconds") is not None] + handle_values = [int(row["handle_seconds"]) for row in aht_rows if row.get("handle_seconds") is not None] + answered_total = sum(1 for row in answered_known_rows if row.get("answered") is True) + abandoned_total = sum(1 for row in abandon_known_rows if row.get("abandoned") is True) + sl_hits = sum( + 1 + for row in sl_known_rows + if row.get("answered") is True and row.get("wait_seconds") is not None and int(row["wait_seconds"]) <= sl_threshold_seconds + ) + fcr_hits = sum( + 1 + for row in fcr_known_rows + if row.get("answered") is True and row.get("resolved_first_contact") is True + ) + occupancy = ( + (sum(handle_values) / (sum(handle_values) + sum(wait_values)) * 100.0) + if (sum(handle_values) + sum(wait_values)) + else 0.0 + ) + return { + "window": {"from": from_ts, "to": to_ts}, + "queue_id": queue_id, + "channel": channel, + "volume": { + "total": len(total_rows), + "answered": answered_total, + "abandoned": abandoned_total, + }, + "kpi": { + "SL": round(_safe_percent(sl_hits, len(sl_known_rows)), 2), + "ASA": round(sum(wait_values) / len(wait_values), 2) if wait_values else 0.0, + "AHT": round(sum(handle_values) / len(handle_values), 2) if handle_values else 0.0, + "Abandon": round(_safe_percent(abandoned_total, len(abandon_known_rows)), 2), + "FCR": round(_safe_percent(fcr_hits, sum(1 for row in fcr_known_rows if row.get("answered") is True)), 2), + "AnswerRate": round(_safe_percent(answered_total, len(answered_known_rows)), 2), + "WaitP95": round(_percentile(wait_values, 0.95), 2), + "HandleP95": round(_percentile(handle_values, 0.95), 2), + "Occupancy": round(occupancy, 2), + "DigitalShare": round(_safe_percent(len(digital_rows), len(total_rows)), 2), + }, + "breakdowns": { + "by_channel": _build_channel_breakdown(records), + }, + "filters": { + "queue_id": queue_id, + "channel": channel, + "sl_threshold_seconds": sl_threshold_seconds, + }, + "coverage": coverage.model_dump(), + } + + +def _compose_drilldown_item( + row: dict[str, Any], + interaction: Interaction | None, + *, + sl_threshold_seconds: int, +) -> ReportingDrilldownItemOut: + interaction_id = row["interaction_id"] + created_at = row.get("created_at") or (interaction.created_at if interaction else utc_now_iso()) + updated_at = ( + (interaction.updated_at if interaction else None) + or row.get("closed_at") + or row.get("updated_at") + or created_at + ) + wait_seconds = row.get("wait_seconds") + return ReportingDrilldownItemOut( + interaction_id=interaction_id, + channel=(row.get("channel") or (interaction.channel if interaction else "voice") or "voice"), # type: ignore[arg-type] + subject=(interaction.subject if interaction else f"Interaction {interaction_id}"), + customer_id=(interaction.customer_id if interaction else None), + queue_id=(row.get("queue_id") or (interaction.queue_id if interaction else None)), + priority=(interaction.priority if interaction else 3), + status=(row.get("status") or (interaction.status if interaction else "new") or "new"), # type: ignore[arg-type] + assigned_to=(row.get("agent_id") or (interaction.assigned_to if interaction else None)), + created_at=created_at, + updated_at=updated_at, + answered=row.get("answered"), + abandoned=row.get("abandoned"), + wait_seconds=wait_seconds, + handle_seconds=row.get("handle_seconds"), + within_sla=(wait_seconds is not None and int(wait_seconds) <= sl_threshold_seconds) if row.get("answered") is True else None, + resolved_first_contact=row.get("resolved_first_contact"), + ) + + +def _timeseries_sample_size(metric: str, records: list[dict[str, Any]], sl_threshold_seconds: int) -> int: + if metric in {"volume", "total", "DigitalShare"}: + return len(_metric_slice("total" if metric != "DigitalShare" else "DigitalShare", records, sl_threshold_seconds)) + if metric == "answered": + return len(_metric_slice("answered", records, sl_threshold_seconds)) + if metric == "SL": + return len(_known_rows_for_metric("SL", records, sl_threshold_seconds)) + if metric == "ASA": + return len(_metric_slice("ASA", records, sl_threshold_seconds)) + if metric == "AHT": + return len(_metric_slice("AHT", records, sl_threshold_seconds)) + if metric == "Abandon": + return len(_known_rows_for_metric("Abandon", records, sl_threshold_seconds)) + if metric == "FCR": + return sum( + 1 for row in _known_rows_for_metric("FCR", records, sl_threshold_seconds) if row.get("answered") is True + ) + if metric == "AnswerRate": + return len(_known_rows_for_metric("answered", records, sl_threshold_seconds)) + if metric == "WaitP95": + return len(_metric_slice("ASA", records, sl_threshold_seconds)) + if metric == "HandleP95": + return len(_metric_slice("AHT", records, sl_threshold_seconds)) + if metric == "Occupancy": + return len(_known_rows_for_metric("Occupancy", records, sl_threshold_seconds)) + return len(records) + + +def _timeseries_metric_value(metric: str, records: list[dict[str, Any]], sl_threshold_seconds: int) -> float: + envelope = _build_kpi_from_facts( + records, + sl_threshold_seconds, + from_ts=None, + to_ts=None, + queue_id=None, + channel=None, + ) + if metric in {"volume", "total"}: + return float(envelope["volume"]["total"]) + if metric == "answered": + return float(envelope["volume"]["answered"]) + return float(envelope["kpi"].get(metric, 0.0)) + + +def _safe_average(values: list[int]) -> float | None: + if not values: + return None + return round(sum(values) / len(values), 2) + + +def _normalize_agent_sort(sort_by: str | None, sort_dir: str | None) -> tuple[str, str]: + normalized_sort_by = (sort_by or "interactions_total").strip() or "interactions_total" + normalized_sort_dir = (sort_dir or "desc").strip().lower() or "desc" + if normalized_sort_by not in _AGENT_SORT_FIELDS: + raise HTTPException(status_code=400, detail="Unsupported agent sort") + if normalized_sort_dir not in _SORT_DIRECTIONS: + raise HTTPException(status_code=400, detail="Unsupported sort_dir") + return normalized_sort_by, normalized_sort_dir + + +def _load_agent_state_rows(session, queue_id: str | None = None) -> list[SupervisorAgentStateRow]: + rows = session.execute( + select(SupervisorAgentStateRow).order_by(SupervisorAgentStateRow.id.asc()) + ).scalars().all() + if queue_id: + rows = [row for row in rows if row.queue_id == queue_id] + return rows + + +def _agent_last_activity(records: list[dict[str, Any]], state_row: SupervisorAgentStateRow | None) -> str | None: + candidates = [ + row.get("updated_at") or row.get("closed_at") or row.get("created_at") + for row in records + if row.get("updated_at") or row.get("closed_at") or row.get("created_at") + ] + if state_row and state_row.updated_at: + candidates.append(state_row.updated_at) + return max(candidates) if candidates else None + + +def _build_agent_row( + agent_id: str, + records: list[dict[str, Any]], + state_row: SupervisorAgentStateRow | None, +) -> ReportingAgentAnalyticsRowOut: + answered_total = sum(1 for row in records if row.get("answered") is True) + closed_total = sum(1 for row in records if row.get("status") == "closed") + abandoned_total = sum(1 for row in records if row.get("abandoned") is True) + wait_values = [int(row["wait_seconds"]) for row in records if row.get("answered") is True and row.get("wait_seconds") is not None] + handle_values = [int(row["handle_seconds"]) for row in records if row.get("answered") is True and row.get("handle_seconds") is not None] + fcr_known_rows = [ + row + for row in records + if row.get("answered") is True and row.get("resolved_first_contact") is not None + ] + fcr_hits = sum(1 for row in fcr_known_rows if row.get("resolved_first_contact") is True) + queue_counter = Counter(row.get("queue_id") for row in records if row.get("queue_id")) + dominant_queue_id = queue_counter.most_common(1)[0][0] if queue_counter else (state_row.queue_id if state_row else None) + channels = sorted({str(row.get("channel") or "voice") for row in records if row.get("channel")}) + return ReportingAgentAnalyticsRowOut( + agent_id=agent_id, + current_state=(state_row.state if state_row else None), + current_queue_id=(state_row.queue_id if state_row else None), + current_state_updated_at=(state_row.updated_at if state_row else None), + dominant_queue_id=dominant_queue_id, + last_activity_at=_agent_last_activity(records, state_row), + interactions_total=len(records), + answered_total=answered_total, + closed_total=closed_total, + abandoned_total=abandoned_total, + avg_wait_seconds=_safe_average(wait_values), + avg_handle_seconds=_safe_average(handle_values), + answer_rate=round(_safe_percent(answered_total, len(records)), 2), + fcr_rate=round(_safe_percent(fcr_hits, len(fcr_known_rows)), 2) if fcr_known_rows else None, + channels=channels, + ) + + +def _agent_sort_key(row: ReportingAgentAnalyticsRowOut, sort_by: str) -> tuple[Any, str]: + if sort_by == "agent_id": + return (row.agent_id.lower(), row.agent_id.lower()) + if sort_by == "last_activity_at": + return (row.last_activity_at or "", row.agent_id.lower()) + if sort_by == "avg_handle_seconds": + return ((row.avg_handle_seconds if row.avg_handle_seconds is not None else -1.0), row.agent_id.lower()) + if sort_by == "fcr_rate": + return ((row.fcr_rate if row.fcr_rate is not None else -1.0), row.agent_id.lower()) + return (getattr(row, sort_by, 0) or 0, row.agent_id.lower()) + + +def _agent_team_key(row: ReportingAgentAnalyticsRowOut) -> str: + return row.dominant_queue_id or row.current_queue_id or "unassigned" + + +def _agent_team_label(team_key: str) -> str: + return "Без очереди" if team_key == "unassigned" else team_key + + +def _shift_key_for_timestamp(ts: str | None) -> str | None: + if not ts: + return None + try: + dt = datetime.fromisoformat(ts.replace("Z", "+00:00")) + except ValueError: + return None + hour = dt.hour + if hour < 8: + return "night" + if hour < 16: + return "day" + return "evening" + + +def _shift_label(shift_key: str) -> str: + return _AGENT_SHIFT_LABELS.get(shift_key, shift_key) + + +def _normalize_agent_trend_metric(metric: str | None) -> str: + normalized = (metric or "interactions_per_agent").strip() or "interactions_per_agent" + if normalized not in _AGENT_TREND_METRICS: + raise HTTPException(status_code=400, detail="Unsupported agent metric") + return normalized + + +def _agent_interval_for_window(dt_from: datetime, dt_to: datetime, requested: str | None) -> str: + if requested in {"hour", "day"}: + return requested + duration = max((dt_to - dt_from).total_seconds(), 0) + return "hour" if duration <= 36 * 60 * 60 else "day" + + +def _bucket_start(dt: datetime, interval: str) -> datetime: + if interval == "hour": + return dt.replace(minute=0, second=0, microsecond=0) + return dt.replace(hour=0, minute=0, second=0, microsecond=0) + + +def _next_bucket(dt: datetime, interval: str) -> datetime: + return dt + (timedelta(hours=1) if interval == "hour" else timedelta(days=1)) + + +def _build_agent_breakdowns( + rows: list[ReportingAgentAnalyticsRowOut], + records_by_agent: dict[str, list[dict[str, Any]]], + state_by_agent: dict[str, SupervisorAgentStateRow], +) -> ReportingAgentAnalyticsBreakdownsOut: + team_agents: dict[str, list[ReportingAgentAnalyticsRowOut]] = {} + team_records: dict[str, list[dict[str, Any]]] = {} + team_states: dict[str, Counter[str]] = {} + for row in rows: + team_key = _agent_team_key(row) + team_agents.setdefault(team_key, []).append(row) + team_records.setdefault(team_key, []).extend(records_by_agent.get(row.agent_id, [])) + team_states.setdefault(team_key, Counter()) + state_value = (state_by_agent.get(row.agent_id).state if row.agent_id in state_by_agent else "OFFLINE") or "OFFLINE" + team_states[team_key][state_value] += 1 + + team_rows: list[ReportingAgentAnalyticsTeamRowOut] = [] + for team_key, agent_rows in team_agents.items(): + records = team_records.get(team_key, []) + answered_total = sum(1 for row in records if row.get("answered") is True) + handle_values = [ + int(row["handle_seconds"]) + for row in records + if row.get("answered") is True and row.get("handle_seconds") is not None + ] + fcr_known_rows = [ + row + for row in records + if row.get("answered") is True and row.get("resolved_first_contact") is not None + ] + fcr_hits = sum(1 for row in fcr_known_rows if row.get("resolved_first_contact") is True) + states = team_states.get(team_key, Counter()) + team_rows.append( + ReportingAgentAnalyticsTeamRowOut( + team_key=team_key, + label=_agent_team_label(team_key), + agents_total=len(agent_rows), + agents_with_activity=sum(1 for row in agent_rows if row.interactions_total > 0), + interactions_total=sum(row.interactions_total for row in agent_rows), + answered_total=answered_total, + avg_handle_seconds=_safe_average(handle_values), + fcr_rate=round(_safe_percent(fcr_hits, len(fcr_known_rows)), 2) if fcr_known_rows else None, + ready_now=int(states.get("READY", 0)), + busy_now=int(states.get("BUSY", 0)), + break_now=int(states.get("BREAK", 0)), + offline_now=int(states.get("OFFLINE", 0)), + ) + ) + team_rows.sort(key=lambda item: (item.interactions_total, item.agents_total, item.label.lower()), reverse=True) + + shift_records: dict[str, list[dict[str, Any]]] = {"night": [], "day": [], "evening": []} + for agent_records in records_by_agent.values(): + for row in agent_records: + shift_key = _shift_key_for_timestamp(row.get("created_at")) + if shift_key: + shift_records[shift_key].append(row) + + shift_rows: list[ReportingAgentAnalyticsShiftRowOut] = [] + for shift_key in ("night", "day", "evening"): + records = shift_records.get(shift_key, []) + answered_total = sum(1 for row in records if row.get("answered") is True) + handle_values = [ + int(row["handle_seconds"]) + for row in records + if row.get("answered") is True and row.get("handle_seconds") is not None + ] + fcr_known_rows = [ + row + for row in records + if row.get("answered") is True and row.get("resolved_first_contact") is not None + ] + fcr_hits = sum(1 for row in fcr_known_rows if row.get("resolved_first_contact") is True) + shift_rows.append( + ReportingAgentAnalyticsShiftRowOut( + shift_key=shift_key, # type: ignore[arg-type] + label=_shift_label(shift_key), + agents_with_activity=len({str(row.get("agent_id")) for row in records if row.get("agent_id")}), + interactions_total=len(records), + answered_total=answered_total, + avg_handle_seconds=_safe_average(handle_values), + fcr_rate=round(_safe_percent(fcr_hits, len(fcr_known_rows)), 2) if fcr_known_rows else None, + ) + ) + + return ReportingAgentAnalyticsBreakdownsOut( + by_team=team_rows, + by_shift=shift_rows, + ) + + +def _build_agent_timeseries( + records: list[dict[str, Any]], + *, + from_ts: datetime, + to_ts: datetime, + queue_id: str | None, + channel: str | None, + metric: str, + interval: str, +) -> ReportingAgentAnalyticsTimeseriesOut: + points: list[ReportingAgentAnalyticsTrendPointOut] = [] + bucket = _bucket_start(from_ts, interval) + if bucket < from_ts: + bucket = _next_bucket(bucket, interval) + while bucket < to_ts: + bucket_end = _next_bucket(bucket, interval) + bucket_rows = [ + row for row in records + if _in_range(row.get("created_at"), bucket, bucket_end) + and row.get("agent_id") + ] + active_agents = len({str(row.get("agent_id")) for row in bucket_rows if row.get("agent_id")}) + handle_values = [ + int(row["handle_seconds"]) + for row in bucket_rows + if row.get("answered") is True and row.get("handle_seconds") is not None + ] + fcr_known_rows = [ + row + for row in bucket_rows + if row.get("answered") is True and row.get("resolved_first_contact") is not None + ] + fcr_hits = sum(1 for row in fcr_known_rows if row.get("resolved_first_contact") is True) + if metric == "agents_with_activity": + value = float(active_agents) + elif metric == "avg_handle_seconds": + value = float(_safe_average(handle_values) or 0.0) + elif metric == "fcr_rate": + value = round(_safe_percent(fcr_hits, len(fcr_known_rows)), 2) if fcr_known_rows else 0.0 + else: + value = round((len(bucket_rows) / active_agents), 2) if active_agents else 0.0 + points.append( + ReportingAgentAnalyticsTrendPointOut( + ts=bucket.isoformat(), + value=value, + agents_with_activity=active_agents, + interactions_total=len(bucket_rows), + ) + ) + bucket = bucket_end + + return ReportingAgentAnalyticsTimeseriesOut( + metric=metric, # type: ignore[arg-type] + interval=interval, # type: ignore[arg-type] + filters=ReportingAgentAnalyticsTrendFiltersOut( + from_ts=from_ts.isoformat(), + to_ts=to_ts.isoformat(), + queue_id=queue_id, + channel=channel, + metric=metric, # type: ignore[arg-type] + interval=interval, # type: ignore[arg-type] + ), + points=points, + ) + + +def _build_agent_analytics_overview( + records: list[dict[str, Any]], + state_rows: list[SupervisorAgentStateRow], + *, + from_ts: str, + to_ts: str, + queue_id: str | None, + channel: str | None, + sort_by: str, + sort_dir: str, + limit: int, +) -> ReportingAgentAnalyticsOverviewOut: + fact_records = [row for row in records if row.get("agent_id")] + grouped: dict[str, list[dict[str, Any]]] = {} + for row in fact_records: + grouped.setdefault(str(row.get("agent_id")), []).append(row) + + state_by_agent = {row.agent_id: row for row in state_rows if row.agent_id} + included_agent_ids = set(grouped.keys()) + if not channel: + included_agent_ids.update(state_by_agent.keys()) + + rows = [ + _build_agent_row(agent_id, grouped.get(agent_id, []), state_by_agent.get(agent_id)) + for agent_id in sorted(included_agent_ids) + ] + rows.sort( + key=lambda row: _agent_sort_key(row, sort_by), + reverse=(sort_dir == "desc"), + ) + breakdowns = _build_agent_breakdowns(rows, grouped, state_by_agent) + + visible_rows = rows[:limit] + state_counts = Counter( + (state_by_agent[agent_id].state if agent_id in state_by_agent else "OFFLINE") + for agent_id in included_agent_ids + ) + handle_values = [int(row["handle_seconds"]) for row in fact_records if row.get("answered") is True and row.get("handle_seconds") is not None] + fcr_known_rows = [ + row + for row in fact_records + if row.get("answered") is True and row.get("resolved_first_contact") is not None + ] + fcr_hits = sum(1 for row in fcr_known_rows if row.get("resolved_first_contact") is True) + latest_state_update = max((row.updated_at for row in state_rows if row.updated_at), default=None) + agents_with_activity = sum(1 for row in rows if row.interactions_total > 0) + + return ReportingAgentAnalyticsOverviewOut( + window={"from_ts": from_ts, "to_ts": to_ts}, + filters=ReportingAgentAnalyticsFiltersOut( + from_ts=from_ts, + to_ts=to_ts, + queue_id=queue_id, + channel=channel, + sort_by=sort_by, # type: ignore[arg-type] + sort_dir=sort_dir, # type: ignore[arg-type] + limit=limit, + ), + totals=ReportingAgentAnalyticsTotalsOut( + agents_total=len(rows), + agents_with_activity=agents_with_activity, + interactions_total=sum(row.interactions_total for row in rows), + answered_total=sum(row.answered_total for row in rows), + closed_total=sum(row.closed_total for row in rows), + ready_now=int(state_counts.get("READY", 0)), + busy_now=int(state_counts.get("BUSY", 0)), + break_now=int(state_counts.get("BREAK", 0)), + offline_now=int(state_counts.get("OFFLINE", 0)), + avg_interactions_per_agent=round( + (sum(row.interactions_total for row in rows) / agents_with_activity), + 2, + ) if agents_with_activity else 0.0, + avg_handle_seconds=_safe_average(handle_values), + avg_fcr_rate=round(_safe_percent(fcr_hits, len(fcr_known_rows)), 2) if fcr_known_rows else None, + ), + state_snapshot=ReportingAgentStateSnapshotOut( + by_state={key: int(value) for key, value in state_counts.items()}, + updated_at=latest_state_update, + ), + breakdowns=breakdowns, + items=visible_rows, + ) + + +def _bucket_step(interval: str) -> timedelta: + if interval == "hour": + return timedelta(hours=1) + if interval == "day": + return timedelta(days=1) + raise HTTPException(status_code=400, detail="Unsupported interval") + + +def _serialize_saved_view(row: ReportingSavedViewRow) -> ReportingSavedViewOut: + try: + snapshot = ReportingSavedViewSnapshot.model_validate(json.loads(row.snapshot_json or "{}")) + except Exception: + snapshot = ReportingSavedViewSnapshot() + return ReportingSavedViewOut( + id=row.view_id, + name=row.name, + snapshot=snapshot, + created_at=row.created_at, + updated_at=row.updated_at, + ) + + +def _csv_response(filename: str, headers: list[str], rows: list[dict[str, Any]]) -> PlainTextResponse: + buff = io.StringIO() + writer = csv.DictWriter(buff, fieldnames=headers) + writer.writeheader() + for row in rows: + writer.writerow({key: row.get(key, "") for key in headers}) + return PlainTextResponse( + content=buff.getvalue(), + media_type="text/csv", + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) + + +def _legacy_export_csv(queue_id: str | None = None, channel: str | None = None) -> PlainTextResponse: + headers = [ + "queue_id", + "channel", + "agent_id", + "answered", + "wait_seconds", + "handle_seconds", + "abandoned", + "resolved_first_contact", + "created_at", + ] + session = get_session() + try: + stmt = select(ReportingEventRow).order_by(ReportingEventRow.id.asc()) + if queue_id: + stmt = stmt.where(ReportingEventRow.queue_id == queue_id) + if channel: + stmt = stmt.where(ReportingEventRow.channel == channel) + rows = [_serialize_legacy_row(row) for row in session.execute(stmt).scalars().all()] + return _csv_response("konturcc-reporting-legacy.csv", headers, rows) + finally: + session.close() + + +@app.get("/health", response_model=HealthResponse) +def health() -> HealthResponse: + return HealthResponse(status="ok", service="reporting-service", version="v1.2") + + +@app.post("/reports/events") +def ingest_event(payload: KpiEventIn) -> dict[str, Any]: + session = get_session() + try: + item = payload.model_dump() + row = ReportingEventRow( + queue_id=item["queue_id"], + channel=item.get("channel") or "voice", + agent_id=item.get("agent_id"), + answered=bool(item["answered"]), + wait_seconds=int(item["wait_seconds"]), + handle_seconds=int(item["handle_seconds"]), + abandoned=bool(item["abandoned"]), + resolved_first_contact=bool(item["resolved_first_contact"]), + created_at=item.get("created_at") or utc_now_iso(), + ) + session.add(row) + session.commit() + count = session.execute(select(ReportingEventRow)).scalars().all() + return {"accepted": True, "count": len(count), "source": "legacy"} + finally: + session.close() + + +@app.post("/reports/facts/interactions") +def upsert_interaction_fact( + payload: ReportingInteractionFactIn, + _: dict = Depends(require_roles(Role.ADMIN)), +) -> dict[str, Any]: + session = get_session() + try: + row = upsert_reporting_interaction_fact(session, payload.model_dump(exclude_none=True)) + session.commit() + return {"accepted": True, "interaction_id": row.interaction_id, "updated_at": row.updated_at} + finally: + session.close() + + +@app.get("/reports/kpi") +def kpi( + from_ts: str | None = None, + to_ts: str | None = None, + queue_id: str | None = None, + channel: str | None = None, + sl_threshold_seconds: int = 30, +) -> dict[str, Any]: + dt_from, dt_to = _normalize_window(from_ts, to_ts) + session = get_session() + try: + records = _load_fact_records( + session, + dt_from=dt_from, + dt_to=dt_to, + queue_id=queue_id, + channel=channel, + ) + return _build_kpi_from_facts( + records, + sl_threshold_seconds, + from_ts=from_ts, + to_ts=to_ts, + queue_id=queue_id, + channel=channel, + ) + finally: + session.close() + + +@app.get("/reports/drilldown", response_model=ReportingDrilldownOut) +def drilldown( + from_ts: str = Query(...), + to_ts: str = Query(...), + metric: str = Query(...), + queue_id: str | None = None, + channel: str | None = None, + sl_threshold_seconds: int = 30, + limit: int = Query(default=25, ge=1, le=100), + offset: int = Query(default=0, ge=0), +) -> ReportingDrilldownOut: + if metric not in _DRILLDOWN_METRICS: + raise HTTPException(status_code=400, detail="Unsupported metric") + dt_from, dt_to = _normalize_window(from_ts, to_ts) + session = get_session() + try: + records = _load_fact_records( + session, + dt_from=dt_from, + dt_to=dt_to, + queue_id=queue_id, + channel=channel, + ) + coverage = _metric_coverage(metric, records, sl_threshold_seconds) + metric_rows = sorted( + _metric_slice(metric, records, sl_threshold_seconds), + key=lambda row: (row.get("created_at") or "", row["interaction_id"]), + reverse=True, + ) + total = len(metric_rows) + paged_rows = metric_rows[offset: offset + limit] + interactions = _load_interactions_map(session, [row["interaction_id"] for row in paged_rows]) + items = [ + _compose_drilldown_item( + row, + interactions.get(row["interaction_id"]), + sl_threshold_seconds=sl_threshold_seconds, + ) + for row in paged_rows + ] + if coverage.note is None: + coverage.note = _metric_note(metric) + return ReportingDrilldownOut( + items=items, + total=total, + limit=limit, + offset=offset, + metric=metric, + coverage=coverage, + filters=ReportingDrilldownFiltersOut( + from_ts=dt_from.isoformat() if dt_from else from_ts, + to_ts=dt_to.isoformat() if dt_to else to_ts, + metric=metric, + queue_id=queue_id, + channel=channel, + sl_threshold_seconds=sl_threshold_seconds, + ), + ) + finally: + session.close() + + +@app.get("/reports/timeseries", response_model=ReportingTimeseriesOut) +def timeseries( + from_ts: str = Query(...), + to_ts: str = Query(...), + metric: str = Query(...), + interval: str = Query(default="day"), + queue_id: str | None = None, + channel: str | None = None, + sl_threshold_seconds: int = 30, +) -> ReportingTimeseriesOut: + if metric not in _TIMESERIES_METRICS: + raise HTTPException(status_code=400, detail="Unsupported metric") + if interval not in {"hour", "day"}: + raise HTTPException(status_code=400, detail="Unsupported interval") + dt_from, dt_to = _normalize_window(from_ts, to_ts) + if dt_from is None or dt_to is None: + raise HTTPException(status_code=400, detail="from_ts and to_ts are required") + + session = get_session() + try: + records = _load_fact_records( + session, + dt_from=dt_from, + dt_to=dt_to, + queue_id=queue_id, + channel=channel, + ) + step = _bucket_step(interval) + cursor = dt_from + points: list[ReportingTimeseriesPointOut] = [] + while cursor < dt_to: + bucket_to = min(cursor + step, dt_to) + bucket_rows = [ + row + for row in records + if _in_range(row.get("created_at"), cursor, bucket_to) + ] + points.append( + ReportingTimeseriesPointOut( + ts=cursor.isoformat(), + value=round(_timeseries_metric_value(metric, bucket_rows, sl_threshold_seconds), 2), + sample_size=_timeseries_sample_size(metric, bucket_rows, sl_threshold_seconds), + ) + ) + cursor = bucket_to + return ReportingTimeseriesOut( + metric=metric, + interval=interval, # type: ignore[arg-type] + filters=ReportingTimeseriesFiltersOut( + from_ts=dt_from.isoformat(), + to_ts=dt_to.isoformat(), + metric=metric, + interval=interval, # type: ignore[arg-type] + queue_id=queue_id, + channel=channel, + sl_threshold_seconds=sl_threshold_seconds, + ), + points=points, + ) + finally: + session.close() + + +@app.get("/reports/agents/overview", response_model=ReportingAgentAnalyticsOverviewOut) +def agent_overview( + from_ts: str = Query(...), + to_ts: str = Query(...), + queue_id: str | None = None, + channel: str | None = None, + sort_by: str = Query(default="interactions_total"), + sort_dir: str = Query(default="desc"), + limit: int = Query(default=25, ge=1, le=100), +) -> ReportingAgentAnalyticsOverviewOut: + dt_from, dt_to = _normalize_window(from_ts, to_ts) + if dt_from is None or dt_to is None: + raise HTTPException(status_code=400, detail="from_ts and to_ts are required") + normalized_sort_by, normalized_sort_dir = _normalize_agent_sort(sort_by, sort_dir) + session = get_session() + try: + records = _load_fact_records( + session, + dt_from=dt_from, + dt_to=dt_to, + queue_id=queue_id, + channel=channel, + ) + state_rows = _load_agent_state_rows(session, queue_id=queue_id) + return _build_agent_analytics_overview( + records, + state_rows, + from_ts=dt_from.isoformat(), + to_ts=dt_to.isoformat(), + queue_id=queue_id, + channel=channel, + sort_by=normalized_sort_by, + sort_dir=normalized_sort_dir, + limit=limit, + ) + finally: + session.close() + + +@app.get("/reports/agents/timeseries", response_model=ReportingAgentAnalyticsTimeseriesOut) +def agent_timeseries( + from_ts: str = Query(...), + to_ts: str = Query(...), + queue_id: str | None = None, + channel: str | None = None, + metric: str = Query(default="interactions_per_agent"), + interval: str | None = Query(default=None), +) -> ReportingAgentAnalyticsTimeseriesOut: + dt_from, dt_to = _normalize_window(from_ts, to_ts) + if dt_from is None or dt_to is None: + raise HTTPException(status_code=400, detail="from_ts and to_ts are required") + normalized_metric = _normalize_agent_trend_metric(metric) + normalized_interval = _agent_interval_for_window(dt_from, dt_to, interval) + session = get_session() + try: + records = _load_fact_records( + session, + dt_from=dt_from, + dt_to=dt_to, + queue_id=queue_id, + channel=channel, + ) + return _build_agent_timeseries( + records, + from_ts=dt_from, + to_ts=dt_to, + queue_id=queue_id, + channel=channel, + metric=normalized_metric, + interval=normalized_interval, + ) + finally: + session.close() + + +@app.get("/reports/views", response_model=list[ReportingSavedViewOut]) +def list_saved_views( + actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.ANALYST)), +) -> list[ReportingSavedViewOut]: + owner_user = str(actor.get("user") or actor.get("username") or "analyst") + session = get_session() + try: + rows = session.execute( + select(ReportingSavedViewRow) + .where(ReportingSavedViewRow.owner_user == owner_user) + .order_by(ReportingSavedViewRow.updated_at.desc(), ReportingSavedViewRow.id.desc()) + ).scalars().all() + return [_serialize_saved_view(row) for row in rows] + finally: + session.close() + + +@app.post("/reports/views", response_model=ReportingSavedViewOut) +def save_view( + payload: ReportingSavedViewIn, + actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.ANALYST)), +) -> ReportingSavedViewOut: + owner_user = str(actor.get("user") or actor.get("username") or "analyst") + owner_role = str(actor.get("role") or "") + view_id = str(payload.id or f"view-{uuid4().hex[:12]}").strip() + if not view_id: + raise HTTPException(status_code=400, detail="View id is required") + name = payload.name.strip() + if not name: + raise HTTPException(status_code=400, detail="View name is required") + + session = get_session() + try: + row = session.execute( + select(ReportingSavedViewRow).where( + ReportingSavedViewRow.owner_user == owner_user, + ReportingSavedViewRow.view_id == view_id, + ) + ).scalar_one_or_none() + now = utc_now_iso() + if row is None: + row = ReportingSavedViewRow( + owner_user=owner_user, + owner_role=owner_role or None, + view_id=view_id, + name=name, + snapshot_json=json.dumps(payload.snapshot.model_dump(), ensure_ascii=False), + created_at=now, + updated_at=now, + ) + session.add(row) + else: + row.owner_role = owner_role or row.owner_role + row.name = name + row.snapshot_json = json.dumps(payload.snapshot.model_dump(), ensure_ascii=False) + row.updated_at = now + session.commit() + return _serialize_saved_view(row) + finally: + session.close() + + +@app.delete("/reports/views/{view_id}") +def delete_view( + view_id: str, + actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.ANALYST)), +) -> dict[str, Any]: + owner_user = str(actor.get("user") or actor.get("username") or "analyst") + session = get_session() + try: + row = session.execute( + select(ReportingSavedViewRow).where( + ReportingSavedViewRow.owner_user == owner_user, + ReportingSavedViewRow.view_id == view_id, + ) + ).scalar_one_or_none() + if row is None: + raise HTTPException(status_code=404, detail="Saved view not found") + session.delete(row) + session.commit() + return {"accepted": True, "id": view_id} + finally: + session.close() + + +@app.get("/reports/coverage") +def coverage() -> dict[str, Any]: + return { + "implemented_metrics": _metric_catalog(), + "supported_filters": ["queue_id", "channel", "from_ts", "to_ts", "sl_threshold_seconds", "metric", "interval"], + "dimensions": ["queue_id", "channel", "agent_id", "window", "interaction_id"], + } + + +@app.get("/reports/export", response_class=PlainTextResponse) +def export_csv( + from_ts: str | None = None, + to_ts: str | None = None, + queue_id: str | None = None, + channel: str | None = None, + metric: str | None = None, + sl_threshold_seconds: int = 30, +) -> PlainTextResponse: + if not from_ts and not to_ts and not metric: + return _legacy_export_csv(queue_id=queue_id, channel=channel) + + dt_from, dt_to = _normalize_window(from_ts, to_ts) + session = get_session() + try: + records = _load_fact_records( + session, + dt_from=dt_from, + dt_to=dt_to, + queue_id=queue_id, + channel=channel, + ) + date_stamp = datetime.now(timezone.utc).strftime("%Y-%m-%d") + if metric: + if metric not in _DRILLDOWN_METRICS: + raise HTTPException(status_code=400, detail="Unsupported metric") + metric_rows = sorted( + _metric_slice(metric, records, sl_threshold_seconds), + key=lambda row: (row.get("created_at") or "", row["interaction_id"]), + reverse=True, + ) + interactions = _load_interactions_map(session, [row["interaction_id"] for row in metric_rows]) + rows = [ + _compose_drilldown_item( + row, + interactions.get(row["interaction_id"]), + sl_threshold_seconds=sl_threshold_seconds, + ).model_dump() + for row in metric_rows + ] + return _csv_response( + f"konturcc-drilldown-{metric.lower()}-{date_stamp}.csv", + [ + "interaction_id", + "subject", + "channel", + "status", + "queue_id", + "assigned_to", + "created_at", + "updated_at", + "answered", + "abandoned", + "wait_seconds", + "handle_seconds", + "within_sla", + "resolved_first_contact", + ], + rows, + ) + + rows = sorted( + records, + key=lambda row: (row.get("created_at") or "", row["interaction_id"]), + reverse=True, + ) + return _csv_response( + f"konturcc-analytics-{date_stamp}.csv", + [ + "interaction_id", + "channel", + "queue_id", + "agent_id", + "status", + "created_at", + "updated_at", + "closed_at", + "answered", + "abandoned", + "wait_seconds", + "handle_seconds", + "resolved_first_contact", + "source", + ], + rows, + ) + finally: + session.close() + + +def _handle_event(envelope: dict[str, Any]) -> None: + session = get_session() + try: + event_id = str(envelope.get("event_id") or "").strip() + event_type = str(envelope.get("event_type") or "").strip() + if not event_id or not event_type: + raise ValueError("Invalid event envelope") + if inbox_seen(session, "reporting-service", event_id): + return + + payload = envelope.get("payload") if isinstance(envelope.get("payload"), dict) else {} + session.add( + ReportingEventLogRow( + event_id=event_id, + event_type=event_type, + queue_id=payload.get("queue_id") or payload.get("resolved_queue_id"), + channel=payload.get("channel"), + interaction_id=payload.get("interaction_id"), + payload_json=json.dumps(envelope, ensure_ascii=False), + created_at=utc_now_iso(), + ) + ) + + if event_type == "interaction.closed": + session.add( + ReportingEventRow( + queue_id=str(payload.get("queue_id") or "queue_unknown"), + channel=str(payload.get("channel") or "voice"), + agent_id=payload.get("assignee"), + answered=True, + wait_seconds=0, + handle_seconds=30, + abandoned=False, + resolved_first_contact=bool(payload.get("resolved_first_contact", True)), + created_at=utc_now_iso(), + ) + ) + if payload.get("interaction_id"): + upsert_reporting_interaction_fact( + session, + { + "interaction_id": payload.get("interaction_id"), + "channel": payload.get("channel") or "voice", + "queue_id": payload.get("queue_id"), + "agent_id": payload.get("assignee"), + "status": payload.get("status") or "closed", + "closed_at": payload.get("updated_at") or utc_now_iso(), + "resolved_first_contact": payload.get("resolved_first_contact"), + "source": "event-bus", + }, + ) + elif event_type == "ivr.completed": + session.add( + ReportingEventRow( + queue_id=str(payload.get("resolved_queue_id") or payload.get("queue_id") or "queue_unknown"), + channel="voice", + agent_id=None, + answered=True, + wait_seconds=0, + handle_seconds=5, + abandoned=False, + resolved_first_contact=True, + created_at=utc_now_iso(), + ) + ) + + record_inbox( + session, + consumer_name="reporting-service", + event_id=event_id, + event_type=event_type, + status="processed", + ) + session.commit() + finally: + session.close() + + +def _consume_once() -> None: + consume_one_message(event_bus_reporting_queue(), _handle_event) + + +@app.on_event("startup") +def _startup() -> None: + if not event_bus_enabled() or not consumer_enabled(): + return + threading.Thread(target=lambda: poll_forever(_consume_once), daemon=True).start() diff --git a/services/routing_service/__init__.py b/services/routing_service/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/routing_service/app.py b/services/routing_service/app.py new file mode 100644 index 0000000..e63321d --- /dev/null +++ b/services/routing_service/app.py @@ -0,0 +1,232 @@ +from __future__ import annotations + +import json + +from fastapi import Depends, FastAPI, HTTPException +from sqlalchemy import select, text + +from services.shared.core import Role, new_id, utc_now_iso +from services.shared.db import engine, get_session +from services.shared.models import HealthResponse, QueueCreate, QueueOut +from services.shared.security import require_roles +from services.shared.sql_init import init_sql_schema +from services.shared.sql_models import IvrSessionRow, Queue, RoutingCounter + +app = FastAPI(title="routing-service", version="1.1.0") + +init_sql_schema() + +_AGENTS = { + "voice": ["operator_a", "operator_b", "operator_c"], + "telegram": ["operator_t1", "operator_t2"], + "webchat": ["operator_w1"], + "email": ["operator_e1"], +} + + +def _db_backend_name() -> str: + return engine.url.get_backend_name() + + +def _next_agent_index(session, *, channel: str, agent_count: int) -> int: + if _db_backend_name() != "postgresql": + counter = session.execute( + select(RoutingCounter).where(RoutingCounter.channel == channel) + ).scalar_one_or_none() + if not counter: + counter = RoutingCounter(channel=channel, counter=0) + session.add(counter) + session.flush() + + idx = counter.counter % agent_count + counter.counter += 1 + return idx + + counter_value = session.execute( + text( + """ + INSERT INTO routing_counters(channel, counter) + VALUES (:channel, 1) + ON CONFLICT (channel) + DO UPDATE SET counter = routing_counters.counter + 1 + RETURNING counter + """ + ), + {"channel": channel}, + ).scalar_one() + return (int(counter_value) - 1) % agent_count + + +def _parse_rules_json(raw_rules_json: str | None) -> list[dict]: + if not raw_rules_json: + return [] + try: + data = json.loads(raw_rules_json) + except (TypeError, json.JSONDecodeError): + return [] + + if isinstance(data, list): + return [item for item in data if isinstance(item, dict)] + + if isinstance(data, dict): + nested_rules = data.get("rules") + if isinstance(nested_rules, list): + return [item for item in nested_rules if isinstance(item, dict)] + if data: + return [data] + return [] + + return [] + + +def _to_out(row: Queue) -> QueueOut: + return QueueOut( + queue_id=row.queue_id, + name=row.name, + description=row.description, + rules=_parse_rules_json(row.rules_json), + created_at=row.created_at, + ) + + +@app.get("/health", response_model=HealthResponse) +def health() -> HealthResponse: + return HealthResponse(status="ok", service="routing-service", version="v1.1") + + +@app.post("/queues", response_model=QueueOut) +def create_queue(payload: QueueCreate, _: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR))) -> QueueOut: + session = get_session() + try: + row = Queue( + queue_id=new_id("que"), + name=payload.name, + description=payload.description, + rules_json=json.dumps([r.model_dump() for r in payload.rules], ensure_ascii=False), + created_at=utc_now_iso(), + ) + session.add(row) + session.commit() + session.refresh(row) + return _to_out(row) + finally: + session.close() + + +@app.get("/queues", response_model=list[QueueOut]) +def list_queues( + _: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR)), +) -> list[QueueOut]: + session = get_session() + try: + rows = session.execute(select(Queue).order_by(Queue.id.desc())).scalars().all() + return [_to_out(r) for r in rows] + finally: + session.close() + + +@app.patch("/queues/{queue_id}/rules", response_model=QueueOut) +def update_rules( + queue_id: str, + payload: QueueCreate, + _: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR)), +) -> QueueOut: + session = get_session() + try: + row = session.execute(select(Queue).where(Queue.queue_id == queue_id)).scalar_one_or_none() + if not row: + raise HTTPException(status_code=404, detail="Queue not found") + row.name = payload.name + row.description = payload.description + row.rules_json = json.dumps([r.model_dump() for r in payload.rules], ensure_ascii=False) + session.commit() + return _to_out(row) + finally: + session.close() + + +@app.post("/queues/{queue_id}/route") +def route_interaction( + queue_id: str, + channel: str, + priority: int = 3, + ivr_session_id: str | None = None, + _: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR)), +) -> dict: + session = get_session() + try: + original_queue = session.execute(select(Queue).where(Queue.queue_id == queue_id)).scalar_one_or_none() + if not original_queue: + raise HTTPException(status_code=404, detail="Queue not found") + + resolved_queue_id = queue_id + ivr_flow_id = None + ivr_outcome_code = None + if ivr_session_id: + ivr_session = session.execute( + select(IvrSessionRow).where(IvrSessionRow.session_id == ivr_session_id) + ).scalar_one_or_none() + if not ivr_session: + raise HTTPException(status_code=404, detail="IVR session not found") + if ivr_session.status != "completed": + raise HTTPException(status_code=400, detail="IVR session is not completed") + if ivr_session.queue_id != queue_id: + raise HTTPException(status_code=400, detail="IVR session does not belong to the requested queue") + resolved_queue_id = ivr_session.resolved_queue_id or queue_id + ivr_flow_id = ivr_session.flow_id + ivr_outcome_code = ivr_session.outcome_code + + queue = session.execute(select(Queue).where(Queue.queue_id == resolved_queue_id)).scalar_one_or_none() + if not queue: + raise HTTPException(status_code=404, detail="Queue not found") + + agents = _AGENTS.get(channel) + if not agents: + raise HTTPException(status_code=400, detail="No agents for channel") + + idx = _next_agent_index(session, channel=channel, agent_count=len(agents)) + + sla_seconds = 30 + for rule in _parse_rules_json(queue.rules_json): + if rule.get("channel") == channel and int(rule.get("priority", 3)) == priority: + sla_seconds = int(rule.get("sla_seconds", 30)) + break + + session.commit() + response = { + "queue_id": resolved_queue_id, + "channel": channel, + "priority": priority, + "assignee": agents[idx], + "sla_seconds": sla_seconds, + } + if ivr_session_id: + response.update( + { + "original_queue_id": queue_id, + "resolved_queue_id": resolved_queue_id, + "ivr_flow_id": ivr_flow_id, + "ivr_outcome_code": ivr_outcome_code, + "ivr_session_id": ivr_session_id, + } + ) + return response + finally: + session.close() + + +@app.delete("/queues/{queue_id}") +def delete_queue( + queue_id: str, + _: dict = Depends(require_roles(Role.ADMIN)), +) -> dict: + session = get_session() + try: + row = session.execute(select(Queue).where(Queue.queue_id == queue_id)).scalar_one_or_none() + if not row: + raise HTTPException(status_code=404, detail="Queue not found") + session.delete(row) + session.commit() + return {"queue_id": queue_id, "deleted": True} + finally: + session.close() diff --git a/services/shared/__init__.py b/services/shared/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/shared/core.py b/services/shared/core.py new file mode 100644 index 0000000..5ac32fc --- /dev/null +++ b/services/shared/core.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from enum import Enum +import uuid + + +def utc_now_iso() -> str: + return datetime.now(tz=timezone.utc).replace(microsecond=0).isoformat() + + +def new_id(prefix: str) -> str: + return f"{prefix}_{uuid.uuid4().hex[:10]}" + + +class Role(str, Enum): + ADMIN = "admin" + SUPERVISOR = "supervisor" + OPERATOR = "operator" + ANALYST = "analyst" diff --git a/services/shared/db.py b/services/shared/db.py new file mode 100644 index 0000000..5f6ac6d --- /dev/null +++ b/services/shared/db.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +import os +from pathlib import Path +from typing import Any + +from sqlalchemy import create_engine, event +from sqlalchemy.orm import Session, sessionmaker +from sqlalchemy.pool import NullPool + + +def _normalize_database_url(url: str) -> str: + if url.startswith("postgres://"): + return "postgresql+psycopg://" + url[len("postgres://") :] + if url.startswith("postgresql://") and "+psycopg" not in url: + return "postgresql+psycopg://" + url[len("postgresql://") :] + return url + + +def _default_sqlite_url() -> str: + data_dir = Path(os.getenv("CC_DATA_DIR", ".data")).resolve() + data_dir.mkdir(parents=True, exist_ok=True) + return f"sqlite:///{(data_dir / 'mvp_cc.db').as_posix()}" + + +DATABASE_URL = _normalize_database_url(os.getenv("DATABASE_URL", _default_sqlite_url())) + +_connect_args: dict[str, Any] = {} +_engine_kwargs: dict[str, Any] = {"pool_pre_ping": True, "future": True, "connect_args": _connect_args} +if DATABASE_URL.startswith("sqlite"): + # SQLite is used in local/demo mode where multiple services may write concurrently. + # Use NullPool so short-lived requests across many local services don't exhaust + # a tiny per-process QueuePool during UI polling. Keep WAL + busy timeout to + # reduce "database is locked" failures. + _connect_args = {"check_same_thread": False, "timeout": float(os.getenv("SQLITE_BUSY_TIMEOUT_SECONDS", "120"))} + _engine_kwargs["connect_args"] = _connect_args + _engine_kwargs["poolclass"] = NullPool +else: + _engine_kwargs.update( + { + "pool_size": int(os.getenv("DB_POOL_SIZE", "10")), + "max_overflow": int(os.getenv("DB_MAX_OVERFLOW", "20")), + "pool_timeout": int(os.getenv("DB_POOL_TIMEOUT_SECONDS", "30")), + "pool_recycle": int(os.getenv("DB_POOL_RECYCLE_SECONDS", "1800")), + } + ) + +engine = create_engine(DATABASE_URL, **_engine_kwargs) + +if DATABASE_URL.startswith("sqlite"): + @event.listens_for(engine, "connect") + def _configure_sqlite_connection(dbapi_connection: Any, _connection_record: Any) -> None: + cursor = dbapi_connection.cursor() + cursor.execute("PRAGMA journal_mode=WAL;") + cursor.execute("PRAGMA synchronous=NORMAL;") + cursor.execute(f"PRAGMA busy_timeout={int(float(os.getenv('SQLITE_BUSY_TIMEOUT_SECONDS', '120')) * 1000)};") + cursor.close() + +SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True) + + +def get_session() -> Session: + return SessionLocal() diff --git a/services/shared/event_bus.py b/services/shared/event_bus.py new file mode 100644 index 0000000..d316770 --- /dev/null +++ b/services/shared/event_bus.py @@ -0,0 +1,399 @@ +from __future__ import annotations + +import json +import os +import time +from contextlib import contextmanager +from datetime import datetime, timezone +from typing import Any, Iterator + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from services.shared.core import new_id, utc_now_iso +from services.shared.models import EventEnvelope, EventOutboxItem +from services.shared.sql_models import EventInboxRow, EventOutboxRow + +EXCHANGE_NAME = "mvpcc.domain.events" +AUDIT_QUEUE = "mvpcc.audit.events" +REPORTING_QUEUE = "mvpcc.reporting.events" +PREFETCH_COUNT = 20 +RETRY_SCHEDULE = (5, 15, 60, 300) + + +def _bool_env(name: str, default: bool) -> bool: + raw = os.getenv(name) + if raw is None: + return default + return raw.strip().lower() in {"1", "true", "yes", "on"} + + +def event_bus_enabled() -> bool: + return _bool_env("EVENT_BUS_ENABLED", False) + + +def consumer_enabled() -> bool: + return _bool_env("EVENT_BUS_CONSUMER_ENABLED", True) + + +def event_bus_url() -> str: + return os.getenv("EVENT_BUS_URL", "amqp://guest:guest@localhost:5672/").strip() + + +def event_bus_exchange() -> str: + return os.getenv("EVENT_BUS_EXCHANGE", EXCHANGE_NAME).strip() or EXCHANGE_NAME + + +def event_bus_audit_queue() -> str: + return os.getenv("EVENT_BUS_AUDIT_QUEUE", AUDIT_QUEUE).strip() or AUDIT_QUEUE + + +def event_bus_reporting_queue() -> str: + return os.getenv("EVENT_BUS_REPORTING_QUEUE", REPORTING_QUEUE).strip() or REPORTING_QUEUE + + +def event_bus_dispatch_batch_size() -> int: + try: + return max(1, int(os.getenv("EVENT_BUS_DISPATCH_BATCH_SIZE", "50"))) + except ValueError: + return 50 + + +def event_bus_max_attempts() -> int: + try: + return max(1, int(os.getenv("EVENT_BUS_MAX_ATTEMPTS", "5"))) + except ValueError: + return 5 + + +def event_bus_poll_interval_seconds() -> float: + try: + return max(0.25, float(os.getenv("EVENT_BUS_POLL_INTERVAL_SECONDS", "2"))) + except ValueError: + return 2.0 + + +def _lazy_import_pika(): + try: + import pika # type: ignore + except ImportError as exc: + raise RuntimeError("pika is required for EVENT_BUS_ENABLED=1") from exc + return pika + + +def _iso_in(seconds: int) -> str: + dt = datetime.now(timezone.utc).timestamp() + seconds + return datetime.fromtimestamp(dt, tz=timezone.utc).replace(microsecond=0).isoformat() + + +def retry_delay_seconds(attempt_count: int) -> int: + if attempt_count <= 1: + return RETRY_SCHEDULE[0] + if attempt_count == 2: + return RETRY_SCHEDULE[1] + if attempt_count == 3: + return RETRY_SCHEDULE[2] + return RETRY_SCHEDULE[3] + + +def routing_key_for(event_type: str) -> str: + return event_type.strip() + + +def build_envelope( + *, + event_type: str, + producer: str, + entity_type: str, + entity_id: str, + payload: dict[str, Any], + correlation_id: str | None = None, + event_id: str | None = None, + event_version: int = 1, +) -> EventEnvelope: + return EventEnvelope( + event_id=event_id or new_id("evt"), + event_type=event_type, + event_version=event_version, + occurred_at=utc_now_iso(), + producer=producer, + entity_type=entity_type, + entity_id=entity_id, + correlation_id=correlation_id, + routing_key=routing_key_for(event_type), + payload=payload, + ) + + +def outbox_item_from_row(row: EventOutboxRow) -> EventOutboxItem: + envelope = json.loads(row.payload_json or "{}") + return EventOutboxItem( + event_id=row.event_id, + event_type=row.event_type, + event_version=row.event_version, + producer_service=row.producer_service, + entity_type=row.entity_type, + entity_id=row.entity_id, + correlation_id=row.correlation_id, + routing_key=row.routing_key, + payload=envelope.get("payload") if isinstance(envelope.get("payload"), dict) else {}, + status=row.status, + attempt_count=row.attempt_count, + last_error=row.last_error, + available_at=row.available_at, + published_at=row.published_at, + created_at=row.created_at, + updated_at=row.updated_at, + ) + + +def append_outbox_event( + session: Session, + *, + event_type: str, + producer_service: str, + entity_type: str, + entity_id: str, + payload: dict[str, Any], + correlation_id: str | None = None, +) -> EventOutboxRow: + envelope = build_envelope( + event_type=event_type, + producer=producer_service, + entity_type=entity_type, + entity_id=entity_id, + payload=payload, + correlation_id=correlation_id, + ) + now = utc_now_iso() + row = EventOutboxRow( + event_id=envelope.event_id, + event_type=envelope.event_type, + event_version=envelope.event_version, + producer_service=envelope.producer, + entity_type=envelope.entity_type, + entity_id=envelope.entity_id, + correlation_id=envelope.correlation_id, + routing_key=envelope.routing_key, + payload_json=json.dumps(envelope.model_dump(), ensure_ascii=False), + status="pending", + attempt_count=0, + last_error=None, + available_at=now, + published_at=None, + created_at=now, + updated_at=now, + ) + session.add(row) + return row + + +def list_publishable_outbox(session: Session, limit: int | None = None) -> list[EventOutboxRow]: + now = utc_now_iso() + stmt = ( + select(EventOutboxRow) + .where( + EventOutboxRow.available_at <= now, + EventOutboxRow.status.in_(["pending", "failed"]), + ) + .order_by(EventOutboxRow.id.asc()) + ) + rows = session.execute(stmt).scalars().all() + if limit is None: + limit = event_bus_dispatch_batch_size() + return rows[:limit] + + +def mark_outbox_published(session: Session, row: EventOutboxRow) -> None: + now = utc_now_iso() + row.status = "published" + row.published_at = now + row.updated_at = now + row.last_error = None + + +def mark_outbox_failed(session: Session, row: EventOutboxRow, error: str) -> None: + row.attempt_count += 1 + row.status = "failed" + row.last_error = error[:2000] + row.available_at = _iso_in(retry_delay_seconds(row.attempt_count)) + row.updated_at = utc_now_iso() + + +def retry_outbox_event(session: Session, row: EventOutboxRow) -> None: + now = utc_now_iso() + row.status = "pending" + row.last_error = None + row.available_at = now + row.updated_at = now + + +def inbox_seen(session: Session, consumer_name: str, event_id: str) -> bool: + row = session.execute( + select(EventInboxRow).where( + EventInboxRow.consumer_name == consumer_name, + EventInboxRow.event_id == event_id, + ) + ).scalar_one_or_none() + return row is not None + + +def record_inbox( + session: Session, + *, + consumer_name: str, + event_id: str, + event_type: str, + status: str, + notes: str | None = None, +) -> EventInboxRow: + row = EventInboxRow( + consumer_name=consumer_name, + event_id=event_id, + event_type=event_type, + processed_at=utc_now_iso(), + status=status, + notes=notes, + ) + session.add(row) + return row + + +@contextmanager +def rabbitmq_connection() -> Iterator[tuple[Any, Any]]: + pika = _lazy_import_pika() + params = pika.URLParameters(event_bus_url()) + connection = pika.BlockingConnection(params) + channel = connection.channel() + try: + yield connection, channel + finally: + try: + connection.close() + except Exception: + pass + + +def ensure_rabbitmq_topology(channel: Any) -> None: + exchange = event_bus_exchange() + audit_queue = event_bus_audit_queue() + reporting_queue = event_bus_reporting_queue() + channel.exchange_declare(exchange=exchange, exchange_type="topic", durable=True) + for queue_name, bindings in ( + (audit_queue, ["interaction.*", "agent.state.changed", "call.recording.ready", "ivr.completed"]), + ( + reporting_queue, + ["interaction.created", "interaction.closed", "agent.state.changed", "ivr.completed", "call.recording.ready"], + ), + ): + dlq_name = f"{queue_name}.dlq" + channel.queue_declare(queue=dlq_name, durable=True) + channel.queue_declare( + queue=queue_name, + durable=True, + arguments={ + "x-dead-letter-exchange": "", + "x-dead-letter-routing-key": dlq_name, + }, + ) + for binding in bindings: + channel.queue_bind(queue=queue_name, exchange=exchange, routing_key=binding) + + +def publish_envelope(channel: Any, envelope: dict[str, Any], headers: dict[str, Any] | None = None) -> None: + pika = _lazy_import_pika() + body = json.dumps(envelope, ensure_ascii=False).encode("utf-8") + properties = pika.BasicProperties( + content_type="application/json", + delivery_mode=2, + headers=headers or {}, + ) + channel.basic_publish( + exchange=event_bus_exchange(), + routing_key=envelope["routing_key"], + body=body, + properties=properties, + ) + + +def dispatch_outbox_batch(session: Session, limit: int | None = None) -> dict[str, int]: + rows = list_publishable_outbox(session, limit=limit) + if not rows: + return {"claimed": 0, "published": 0, "failed": 0} + + published = 0 + failed = 0 + try: + with rabbitmq_connection() as (_, channel): + ensure_rabbitmq_topology(channel) + for row in rows: + try: + publish_envelope(channel, json.loads(row.payload_json or "{}")) + mark_outbox_published(session, row) + published += 1 + except Exception as exc: # noqa: BLE001 + mark_outbox_failed(session, row, str(exc)) + failed += 1 + session.commit() + except Exception as exc: # noqa: BLE001 + for row in rows: + mark_outbox_failed(session, row, str(exc)) + session.commit() + failed = len(rows) + published = 0 + return {"claimed": len(rows), "published": published, "failed": failed} + + +def consume_one_message( + queue_name: str, + handler, + *, + max_attempts: int | None = None, +) -> bool: + if max_attempts is None: + max_attempts = event_bus_max_attempts() + with rabbitmq_connection() as (_, channel): + ensure_rabbitmq_topology(channel) + channel.basic_qos(prefetch_count=PREFETCH_COUNT) + method, properties, body = channel.basic_get(queue=queue_name, auto_ack=False) + if not method: + return False + + headers = dict((properties.headers or {})) + try: + envelope = json.loads(body.decode("utf-8")) + handler(envelope) + channel.basic_ack(delivery_tag=method.delivery_tag) + return True + except Exception as exc: # noqa: BLE001 + retry_count = int(headers.get("x-retry-count", 0)) + if retry_count + 1 < max_attempts: + next_headers = dict(headers) + next_headers["x-retry-count"] = retry_count + 1 + publish_envelope(channel, json.loads(body.decode("utf-8")), headers=next_headers) + channel.basic_ack(delivery_tag=method.delivery_tag) + else: + dlq_name = f"{queue_name}.dlq" + pika = _lazy_import_pika() + channel.basic_publish( + exchange="", + routing_key=dlq_name, + body=body, + properties=pika.BasicProperties( + content_type="application/json", + delivery_mode=2, + headers={**headers, "error": str(exc)[:2000]}, + ), + ) + channel.basic_ack(delivery_tag=method.delivery_tag) + return False + + +def poll_forever(loop_fn) -> None: + while True: + try: + loop_fn() + except Exception: + time.sleep(event_bus_poll_interval_seconds()) + else: + time.sleep(event_bus_poll_interval_seconds()) diff --git a/services/shared/kb_localization.py b/services/shared/kb_localization.py new file mode 100644 index 0000000..b28693b --- /dev/null +++ b/services/shared/kb_localization.py @@ -0,0 +1,14 @@ +from __future__ import annotations + +DEFAULT_KB_LANGUAGE = "ru" +SUPPORTED_KB_LANGUAGES = {"ru", "kz"} + + +def normalize_kb_language(value: str | None) -> str: + normalized = str(value or DEFAULT_KB_LANGUAGE).strip().lower() + return normalized if normalized in SUPPORTED_KB_LANGUAGES else DEFAULT_KB_LANGUAGE + + +def resolve_article_group_id(article_id: str, article_group_id: str | None) -> str: + normalized_group = str(article_group_id or "").strip() + return normalized_group or str(article_id).strip() diff --git a/services/shared/kb_search.py b/services/shared/kb_search.py new file mode 100644 index 0000000..dbbdd2d --- /dev/null +++ b/services/shared/kb_search.py @@ -0,0 +1,146 @@ +from __future__ import annotations + +import json +import re +import unicodedata +from typing import Protocol, Sequence, TypeVar + +_FIELD_WEIGHTS = { + "tags": 3.0, + "title": 2.0, + "body": 1.0, +} +_EXACT_TOKEN_SCORE = 10.0 +_SOFT_TOKEN_SCORE = 6.0 +_PHRASE_BASE_SCORE = 12.0 +_MIN_TOKEN_LENGTH = 2 +_MIN_SOFT_MATCH_LENGTH = 4 + +_NON_WORD_RE = re.compile(r"[^\w]+", re.UNICODE) +_SPACE_RE = re.compile(r"\s+") + + +class KBSearchRow(Protocol): + id: int + title: str + body: str + tags_json: str + + +T = TypeVar("T", bound=KBSearchRow) + + +def normalize_kb_text(value: str | None) -> str: + text = unicodedata.normalize("NFKC", str(value or "")).lower().replace("\u0451", "\u0435") + text = _NON_WORD_RE.sub(" ", text) + return _SPACE_RE.sub(" ", text).strip() + + +def tokenize_kb_text(value: str | None) -> list[str]: + return [token for token in normalize_kb_text(value).split(" ") if len(token) >= _MIN_TOKEN_LENGTH] + + +def search_kb_rows( + rows: Sequence[T], + query: str | None, + *, + limit: int, + empty_query_returns_all: bool = False, +) -> list[T]: + ordered_rows = sorted(rows, key=lambda row: int(getattr(row, "id", 0) or 0), reverse=True) + normalized_query = normalize_kb_text(query) + if not normalized_query: + return ordered_rows[:limit] if empty_query_returns_all else [] + + query_tokens = list(dict.fromkeys(tokenize_kb_text(normalized_query))) + if not query_tokens: + return ordered_rows[:limit] if empty_query_returns_all else [] + + ranked: list[tuple[float, int, T]] = [] + for row in ordered_rows: + score = _score_row(row, normalized_query, query_tokens) + if score <= 0: + continue + ranked.append((score, int(getattr(row, "id", 0) or 0), row)) + + ranked.sort(key=lambda item: (-item[0], -item[1])) + return [row for _, _, row in ranked[:limit]] + + +def _score_row(row: T, normalized_query: str, query_tokens: list[str]) -> float: + tags_text = " ".join(_parse_tags(getattr(row, "tags_json", "[]"))) + fields = { + "tags": { + "text": normalize_kb_text(tags_text), + "tokens": tokenize_kb_text(tags_text), + }, + "title": { + "text": normalize_kb_text(getattr(row, "title", "")), + "tokens": tokenize_kb_text(getattr(row, "title", "")), + }, + "body": { + "text": normalize_kb_text(getattr(row, "body", "")), + "tokens": tokenize_kb_text(getattr(row, "body", "")), + }, + } + + score = 0.0 + for field_name, payload in fields.items(): + field_tokens = payload["tokens"] + if not field_tokens: + continue + field_weight = _FIELD_WEIGHTS[field_name] + score += _field_phrase_bonus(query_tokens, payload["text"], field_weight) + score += _field_token_score(query_tokens, field_tokens, field_weight) + return score + + +def _field_phrase_bonus(query_tokens: list[str], field_text: str, field_weight: float) -> float: + max_window = min(4, len(query_tokens)) + for size in range(max_window, 1, -1): + for start in range(0, len(query_tokens) - size + 1): + phrase = " ".join(query_tokens[start : start + size]) + if phrase and phrase in field_text: + return field_weight * (_PHRASE_BASE_SCORE + size) + return 0.0 + + +def _field_token_score(query_tokens: list[str], field_tokens: list[str], field_weight: float) -> float: + score = 0.0 + field_token_set = set(field_tokens) + for query_token in query_tokens: + if query_token in field_token_set: + score += field_weight * _EXACT_TOKEN_SCORE + continue + if _has_soft_token_match(query_token, field_tokens): + score += field_weight * _SOFT_TOKEN_SCORE + return score + + +def _has_soft_token_match(query_token: str, field_tokens: list[str]) -> bool: + return any(_is_soft_token_match(query_token, field_token) for field_token in field_tokens) + + +def _is_soft_token_match(query_token: str, field_token: str) -> bool: + min_length = min(len(query_token), len(field_token)) + if min_length < _MIN_SOFT_MATCH_LENGTH: + return False + common_prefix = _common_prefix_len(query_token, field_token) + return common_prefix >= _MIN_SOFT_MATCH_LENGTH and common_prefix >= min_length - 1 + + +def _common_prefix_len(left: str, right: str) -> int: + index = 0 + for left_char, right_char in zip(left, right): + if left_char != right_char: + break + index += 1 + return index + + +def _parse_tags(raw_tags: str | None) -> list[str]: + try: + data = json.loads(raw_tags or "[]") + except json.JSONDecodeError: + return [] + return [str(item) for item in data if str(item).strip()] diff --git a/services/shared/models.py b/services/shared/models.py new file mode 100644 index 0000000..eb4f1d5 --- /dev/null +++ b/services/shared/models.py @@ -0,0 +1,1209 @@ +from __future__ import annotations + +from typing import Literal + +from pydantic import BaseModel, Field + +from services.shared.core import Role + + +Channel = Literal["voice", "telegram", "whatsapp", "email", "webchat"] +InteractionStatus = Literal["new", "in_progress", "escalated", "closed", "abandoned"] +AIThreadState = Literal["queued", "thinking", "active", "handoff_required", "human_owned", "closed", "error"] +VoiceAIState = Literal[ + "queued", + "greeting", + "listening", + "thinking", + "speaking", + "active", + "handoff_requested", + "handoff_required", + "human_owned", + "completed", + "closed", + "error", +] +RecordingStatus = Literal["ready", "archived", "missing"] +IvrSessionStatus = Literal["active", "completed", "abandoned"] +EventOutboxStatus = Literal["pending", "published", "failed"] +AsteriskForwardStatus = Literal["received", "processing", "forwarded", "failed"] +TelephonyStatus = Literal["ringing", "claimed", "connected", "ended", "failed"] +CallActionResultStatus = Literal["ok", "failed", "rejected"] + + +class HealthResponse(BaseModel): + status: str + service: str + version: str = "v1" + + +class LoginRequest(BaseModel): + username: str + password: str + + +class LoginResponse(BaseModel): + access_token: str + token_type: str = "bearer" + role: Role + auth_source: str | None = None + provider: str | None = None + full_name: str | None = None + + +class UserCreate(BaseModel): + username: str = Field(min_length=3) + password: str = Field(min_length=6) + full_name: str = Field(min_length=2) + role: Role + + +class UserUpdate(BaseModel): + password: str | None = Field(default=None, min_length=6) + full_name: str | None = Field(default=None, min_length=2) + role: Role | None = None + + +class UserOut(BaseModel): + user_id: str + username: str + full_name: str + role: Role + + +class AuditEventIn(BaseModel): + actor: str + action: str + entity: str + metadata: dict = Field(default_factory=dict) + + +class AuditEvent(AuditEventIn): + event_id: str + created_at: str + + +class CustomerCreate(BaseModel): + display_name: str = Field(min_length=2) + phones: list[str] = Field(default_factory=list) + preferred_phone: str | None = None + tags: list[str] = Field(default_factory=list) + + +class CustomerOut(CustomerCreate): + customer_id: str + created_at: str + + +class InteractionCreate(BaseModel): + channel: Channel + subject: str = Field(min_length=3) + customer_id: str | None = None + queue_id: str | None = None + priority: int = Field(default=3, ge=1, le=5) + + +class InteractionOut(InteractionCreate): + interaction_id: str + status: InteractionStatus + assigned_to: str | None = None + created_at: str + updated_at: str + + +class InteractionDrilldownFiltersOut(BaseModel): + from_ts: str + to_ts: str + queue_id: str | None = None + channel: str | None = None + agent_id: str | None = None + status: str | None = None + q: str | None = None + sort_by: str | None = None + sort_dir: str | None = None + + +class InteractionDrilldownOut(BaseModel): + items: list[InteractionOut] = Field(default_factory=list) + total: int + limit: int + offset: int + filters: InteractionDrilldownFiltersOut + + +class AssignRequest(BaseModel): + assignee: str + + +class StatusRequest(BaseModel): + status: InteractionStatus + resolved_first_contact: bool | None = None + + +class EscalateRequest(BaseModel): + target_queue_id: str + + +class InteractionTimelineAppendIn(BaseModel): + action: str = Field(min_length=1) + metadata: dict = Field(default_factory=dict) + + +class QueueRule(BaseModel): + channel: Channel + priority: int = Field(default=3, ge=1, le=5) + strategy: Literal["round_robin", "least_loaded", "skill_based"] = "round_robin" + sla_seconds: int = Field(default=30, ge=5, le=3600) + + +class QueueCreate(BaseModel): + name: str + description: str = "" + rules: list[QueueRule] = Field(default_factory=list) + + +class QueueOut(QueueCreate): + queue_id: str + created_at: str + + +class VoiceEventIn(BaseModel): + event_type: Literal[ + "call.started", + "ivr.completed", + "call.ended", + "recording.ready", + "call.connected", + "call.transferred", + ] + call_id: str + interaction_id: str | None = None + source_event_id: str | None = None + payload: dict = Field(default_factory=dict) + + +class VoiceEventOut(VoiceEventIn): + event_id: str + created_at: str + + +class RecordingRegisterIn(BaseModel): + call_id: str + interaction_id: str | None = None + source_path: str + file_name: str | None = None + mime_type: str | None = None + duration_seconds: int | None = Field(default=None, ge=0) + recorded_at: str | None = None + source_event_id: str | None = None + + +class RecordingOut(BaseModel): + recording_id: str + channel: Literal["voice"] + call_id: str + interaction_id: str | None = None + source_event_id: str | None = None + file_name: str + mime_type: str + size_bytes: int + duration_seconds: int | None = None + storage_backend: Literal["local_fs"] + status: RecordingStatus + recorded_at: str + created_at: str + updated_at: str + archived_at: str | None = None + + +class AsteriskBridgeStatusOut(BaseModel): + status: str + ami_connected: bool + ami_host: str | None = None + last_event_at: str | None = None + queue_codes_loaded: list[str] = Field(default_factory=list) + sftp_enabled: bool = False + bridge_auth_mode: str | None = None + callcontrol_enabled: bool | None = None + webrtc_enabled: bool = False + webrtc_ws_url: str | None = None + + +class AsteriskEventOut(BaseModel): + bridge_event_id: str + ami_event_name: str + call_id: str + linked_id: str | None = None + interaction_id: str | None = None + recording_id: str | None = None + forward_status: AsteriskForwardStatus + payload: dict = Field(default_factory=dict) + last_error: str | None = None + created_at: str + updated_at: str + + +class VoiceLiveCallOut(BaseModel): + call_id: str + interaction_id: str + queue_id: str + queue_code: str + caller_number: str | None = None + caller_name: str | None = None + status: str + telephony_status: TelephonyStatus + claimed_by_user: str | None = None + claimed_at: str | None = None + operator_extension: str | None = None + channel_name: str | None = None + started_at: str + connected_at: str | None = None + ended_at: str | None = None + updated_at: str + last_transition_at: str | None = None + hangup_cause: str | None = None + terminal_action: str | None = None + terminal_target: str | None = None + voice_session_id: str | None = None + ai_session_id: str | None = None + ai_state: VoiceAIState | None = None + ai_handoff_reason: str | None = None + ai_last_model_at: str | None = None + has_recording: bool = False + + +class VoiceCallClaimIn(BaseModel): + operator_extension: str | None = None + + +class VoiceCallBlindTransferIn(BaseModel): + target_type: Literal["extension", "queue_code"] = "extension" + target_value: str = Field(min_length=1) + + +class VoiceCallActionOut(BaseModel): + action_id: str + call_id: str + interaction_id: str | None = None + action_type: str + actor_user: str + actor_role: str + request: dict = Field(default_factory=dict) + result_status: CallActionResultStatus + ami_action_id: str | None = None + error: str | None = None + created_at: str + + +class VoiceAISummaryOut(BaseModel): + call_id: str + session_id: str | None = None + voice_session_id: str | None = None + status_label: str + status_tone: Literal["answered", "handoff"] + customer_request_text: str + ai_outcome_text: str + handoff_reason: str + recommended_next_step: str + generated_at: str + transcript_segments: list["VoiceAISummaryTranscriptSegmentOut"] = Field(default_factory=list) + + +class VoiceAISummaryTranscriptSegmentOut(BaseModel): + speaker: Literal["caller", "assistant"] + text: str + sequence_no: int + source_type: str + created_at: str + interrupted: bool = False + + +class BrowserSoftphoneConfigOut(BaseModel): + enabled: bool = False + ws_url: str | None = None + sip_uri: str | None = None + authorization_username: str | None = None + password: str | None = None + display_name: str | None = None + ice_servers: list[dict] = Field(default_factory=list) + operator_extension: str | None = None + + +class IvrFlowCreate(BaseModel): + name: str = Field(min_length=2) + description: str = "" + queue_id: str + entry_node_id: str + flow_json: dict = Field(default_factory=dict) + is_active: bool = True + + +class IvrFlowUpdate(BaseModel): + name: str | None = Field(default=None, min_length=2) + description: str | None = None + entry_node_id: str | None = None + flow_json: dict | None = None + is_active: bool | None = None + + +class IvrFlowOut(BaseModel): + flow_id: str + name: str + description: str + channel: Literal["voice"] + queue_id: str + version: int + is_active: bool + entry_node_id: str + flow_json: dict + created_at: str + updated_at: str + + +class IvrSessionStartIn(BaseModel): + call_id: str + queue_id: str + interaction_id: str | None = None + + +class IvrDtmfIn(BaseModel): + digit: str = Field(min_length=1, max_length=1) + + +class IvrSessionOut(BaseModel): + session_id: str + call_id: str + interaction_id: str | None = None + flow_id: str + queue_id: str + current_node_id: str + entered_digits: list[str] = Field(default_factory=list) + status: IvrSessionStatus + outcome_code: str | None = None + resolved_queue_id: str | None = None + resolved_queue_code: str | None = None + completed_at: str | None = None + created_at: str + updated_at: str + + +class EventEnvelope(BaseModel): + event_id: str + event_type: str + event_version: int = 1 + occurred_at: str + producer: str + entity_type: str + entity_id: str + correlation_id: str | None = None + routing_key: str + payload: dict = Field(default_factory=dict) + + +class EventOutboxItem(BaseModel): + event_id: str + event_type: str + event_version: int + producer_service: str + entity_type: str + entity_id: str + correlation_id: str | None = None + routing_key: str + payload: dict = Field(default_factory=dict) + status: EventOutboxStatus + attempt_count: int + last_error: str | None = None + available_at: str + published_at: str | None = None + created_at: str + updated_at: str + + +class TelegramWebhookIn(BaseModel): + chat_id: str + text: str + customer_external_id: str | None = None + payload: dict = Field(default_factory=dict) + + +class TelegramWebhookOut(TelegramWebhookIn): + message_id: str + thread_id: str | None = None + interaction_id: str | None = None + direction: Literal["inbound", "outbound", "system"] = "inbound" + created_at: str + + +class TelegramThreadOut(BaseModel): + thread_id: str + chat_id: str + interaction_id: str + telegram_user_id: str | None = None + username: str | None = None + display_name: str | None = None + queue_id: str | None = None + status: InteractionStatus + claimed_by_user: str | None = None + claimed_at: str | None = None + ai_session_id: str | None = None + ai_state: AIThreadState | None = None + ai_handoff_reason: str | None = None + ai_last_model_at: str | None = None + last_message_at: str + last_message_preview: str + created_at: str + updated_at: str + + +class TelegramThreadMessageOut(BaseModel): + message_id: str + thread_id: str + interaction_id: str + chat_id: str + direction: Literal["inbound", "outbound", "system"] + text: str + telegram_message_id_external: str | None = None + operator_user: str | None = None + author_type: Literal["customer", "human", "ai", "system"] = "customer" + author_id: str | None = None + delivery_status: str | None = None + payload: dict = Field(default_factory=dict) + created_at: str + + +class TelegramThreadAISummaryOut(BaseModel): + thread_id: str + session_id: str + status_label: str + status_tone: Literal["answered", "handoff"] + customer_request_text: str + ai_outcome_text: str + handoff_reason: str + recommended_next_step: str + generated_at: str + + +class TelegramThreadReplyIn(BaseModel): + text: str = Field(min_length=1) + + +class TelegramThreadEscalateIn(BaseModel): + target_queue_id: str = Field(min_length=1) + + +class AITelegramEnqueueIn(BaseModel): + trigger_message_id: str | None = None + + +class AITelegramPauseIn(BaseModel): + reason: str = Field(min_length=1) + actor_user: str | None = None + + +class VoiceAISessionCreateIn(BaseModel): + call_id: str = Field(min_length=1) + linked_id: str | None = None + interaction_id: str = Field(min_length=1) + queue_id: str = Field(min_length=1) + caller_number: str | None = None + caller_name: str | None = None + agent_profile: str = "voice_support" + language_hint: str | None = None + handoff_queue_id: str | None = None + metadata: dict = Field(default_factory=dict) + + +class VoiceAISessionOut(BaseModel): + voice_session_id: str + ai_session_id: str | None = None + status: VoiceAIState + + +class VoiceAIMediaBridgeEventIn(BaseModel): + event_type: Literal["requested", "ended"] + media_uuid: str = Field(min_length=1) + call_id: str = Field(min_length=1) + linked_id: str | None = None + channel: str | None = None + service_address: str | None = None + reason: str | None = None + + +class VoiceAITelephonyEventIn(BaseModel): + event_type: Literal["call.connected", "call.ended", "recording.ready", "operator.connected"] + payload: dict = Field(default_factory=dict) + + +class VoiceAITurnIn(BaseModel): + voice_session_id: str = Field(min_length=1) + call_id: str = Field(min_length=1) + interaction_id: str = Field(min_length=1) + transcript_text: str = Field(min_length=1) + language: str | None = None + sequence_no: int = Field(default=1, ge=1) + barge_in: bool = False + metadata: dict = Field(default_factory=dict) + + +class VoiceAITurnDecisionOut(BaseModel): + language: str + intent: str + reply_text: str + confidence: float + needs_handoff: bool + handoff_reason: str | None = None + case_action: Literal["none", "close", "escalate", "keep_open"] = "keep_open" + kb_refs: list[str] = Field(default_factory=list) + summary_text: str = "" + model: str | None = None + latency_ms: int | None = None + status: VoiceAIState = "active" + + +class VoiceAIStartIn(BaseModel): + voice_session_id: str = Field(min_length=1) + call_id: str = Field(min_length=1) + interaction_id: str = Field(min_length=1) + customer_id: str | None = None + language_hint: str | None = None + agent_profile: str = "voice_support" + + +class VoiceAIStartOut(BaseModel): + session_id: str + language: str + greeting_text: str + disclosure_required: bool = True + + +class VoiceAIHandoffRequestIn(BaseModel): + voice_session_id: str = Field(min_length=1) + ai_session_id: str | None = None + interaction_id: str = Field(min_length=1) + target_queue_id: str | None = None + reason: str = Field(min_length=1) + summary: dict = Field(default_factory=dict) + + +class VoiceAICallStateUpdateIn(BaseModel): + voice_session_id: str | None = None + ai_session_id: str | None = None + ai_state: VoiceAIState + handoff_reason: str | None = None + metadata: dict = Field(default_factory=dict) + + +class TelegramThreadAIReplyIn(BaseModel): + text: str = Field(min_length=1) + agent_profile: str = "telegram_support" + model: str | None = None + trigger_message_id: str | None = None + language: str | None = None + confidence: float | None = None + kb_refs: list[str] = Field(default_factory=list) + payload: dict = Field(default_factory=dict) + + +class TelegramThreadAIHandoffIn(BaseModel): + reason: str = Field(min_length=1) + agent_profile: str = "telegram_support" + trigger_message_id: str | None = None + confidence: float | None = None + payload: dict = Field(default_factory=dict) + + +class WhatsAppWebhookIn(BaseModel): + chat_id: str + text: str = "" + external_message_id: str | None = None + whatsapp_user_id: str | None = None + phone_number: str | None = None + display_name: str | None = None + customer_id: str | None = None + external_subject: str | None = None + queue_id: str | None = None + is_group: bool = False + payload: dict = Field(default_factory=dict) + + +class WhatsAppWebhookOut(BaseModel): + message_id: str + chat_id: str + text: str + external_message_id: str | None = None + customer_id: str | None = None + payload: dict = Field(default_factory=dict) + thread_id: str | None = None + interaction_id: str | None = None + direction: Literal["inbound", "outbound", "system"] = "inbound" + created_at: str + + +class WhatsAppThreadOut(BaseModel): + thread_id: str + chat_id: str + interaction_id: str + whatsapp_user_id: str | None = None + phone_number: str | None = None + display_name: str | None = None + queue_id: str | None = None + is_group: bool = False + status: InteractionStatus + claimed_by_user: str | None = None + claimed_at: str | None = None + ai_session_id: str | None = None + ai_state: AIThreadState | None = None + ai_handoff_reason: str | None = None + ai_last_model_at: str | None = None + unread_count: int = 0 + last_message_at: str + last_message_preview: str + created_at: str + updated_at: str + + +class WhatsAppThreadMessageOut(BaseModel): + message_id: str + thread_id: str + interaction_id: str + chat_id: str + direction: Literal["inbound", "outbound", "system"] + text: str + whatsapp_message_id_external: str | None = None + operator_user: str | None = None + author_type: Literal["customer", "human", "ai", "system"] = "customer" + author_id: str | None = None + customer_id: str | None = None + delivery_status: str | None = None + payload: dict = Field(default_factory=dict) + created_at: str + + +class WhatsAppThreadAISummaryOut(BaseModel): + thread_id: str + session_id: str + status_label: str + status_tone: Literal["answered", "handoff"] + customer_request_text: str + ai_outcome_text: str + handoff_reason: str + recommended_next_step: str + generated_at: str + + +class WhatsAppThreadReplyIn(BaseModel): + text: str = Field(min_length=1) + + +class WhatsAppThreadEscalateIn(BaseModel): + target_queue_id: str = Field(min_length=1) + + +class AIWhatsAppEnqueueIn(BaseModel): + trigger_message_id: str | None = None + + +class AIWhatsAppPauseIn(BaseModel): + reason: str = Field(min_length=1) + actor_user: str | None = None + + +class WhatsAppThreadAIReplyIn(BaseModel): + text: str = Field(min_length=1) + agent_profile: str = "whatsapp_support" + model: str | None = None + trigger_message_id: str | None = None + language: str | None = None + confidence: float | None = None + kb_refs: list[str] = Field(default_factory=list) + payload: dict = Field(default_factory=dict) + + +class WhatsAppThreadAIHandoffIn(BaseModel): + reason: str = Field(min_length=1) + agent_profile: str = "whatsapp_support" + trigger_message_id: str | None = None + confidence: float | None = None + payload: dict = Field(default_factory=dict) + + +class WebchatMessageIn(BaseModel): + session_id: str + text: str = Field(min_length=1) + visitor_name: str | None = None + customer_external_id: str | None = None + queue_id: str | None = None + priority: int = Field(default=3, ge=1, le=5) + payload: dict = Field(default_factory=dict) + + +class WebchatMessageOut(WebchatMessageIn): + message_id: str + interaction_id: str | None = None + created_at: str + + +class EmailMessageIn(BaseModel): + from_email: str + subject: str = Field(min_length=3) + body: str = Field(min_length=1) + customer_external_id: str | None = None + queue_id: str | None = None + priority: int = Field(default=3, ge=1, le=5) + payload: dict = Field(default_factory=dict) + + +class EmailMessageOut(EmailMessageIn): + message_id: str + interaction_id: str | None = None + created_at: str + + +class KBCategoryCreate(BaseModel): + name: str + description: str = "" + + +class KBCategoryOut(KBCategoryCreate): + category_id: str + created_at: str + + +class KBArticleCreate(BaseModel): + category_id: str + article_group_id: str | None = None + language: str = "ru" + title: str + body: str + tags: list[str] = Field(default_factory=list) + + +class KBArticleUpdate(BaseModel): + article_group_id: str | None = None + language: str | None = None + title: str | None = None + body: str | None = None + tags: list[str] | None = None + + +class KBArticleOut(KBArticleCreate): + article_id: str + created_at: str + updated_at: str + + +class KpiEventIn(BaseModel): + queue_id: str + channel: Channel = "voice" + agent_id: str | None = None + answered: bool + wait_seconds: int = Field(ge=0) + handle_seconds: int = Field(ge=0) + abandoned: bool = False + resolved_first_contact: bool = False + created_at: str | None = None + + +class ReportingInteractionFactIn(BaseModel): + interaction_id: str = Field(min_length=1) + channel: Channel | None = None + queue_id: str | None = None + agent_id: str | None = None + status: InteractionStatus | None = None + created_at: str | None = None + closed_at: str | None = None + answered: bool | None = None + abandoned: bool | None = None + wait_seconds: int | None = Field(default=None, ge=0) + handle_seconds: int | None = Field(default=None, ge=0) + resolved_first_contact: bool | None = None + source: str = Field(min_length=1) + + +class ReportingMetricCoverageOut(BaseModel): + status: Literal["exact", "partial", "unavailable"] = "unavailable" + supported_channels: list[str] = Field(default_factory=list) + exact_rows: int = 0 + total_rows: int = 0 + note: str | None = None + + +class ReportingKpiCoverageOut(BaseModel): + metric_status: dict[str, Literal["exact", "partial", "unavailable"]] = Field(default_factory=dict) + supported_channels: dict[str, list[str]] = Field(default_factory=dict) + exact_rows: int = 0 + total_rows: int = 0 + note: str | None = None + metric_details: dict[str, ReportingMetricCoverageOut] = Field(default_factory=dict) + + +class ReportingDrilldownFiltersOut(BaseModel): + from_ts: str + to_ts: str + metric: str + queue_id: str | None = None + channel: str | None = None + sl_threshold_seconds: int = 30 + + +class ReportingDrilldownItemOut(InteractionOut): + answered: bool | None = None + abandoned: bool | None = None + wait_seconds: int | None = None + handle_seconds: int | None = None + within_sla: bool | None = None + resolved_first_contact: bool | None = None + + +class ReportingDrilldownOut(BaseModel): + items: list[ReportingDrilldownItemOut] = Field(default_factory=list) + total: int + limit: int + offset: int + metric: str + coverage: ReportingMetricCoverageOut = Field(default_factory=ReportingMetricCoverageOut) + filters: ReportingDrilldownFiltersOut + + +class ReportingTimeseriesFiltersOut(BaseModel): + from_ts: str + to_ts: str + metric: str + interval: Literal["hour", "day"] = "day" + queue_id: str | None = None + channel: str | None = None + sl_threshold_seconds: int = 30 + + +class ReportingTimeseriesPointOut(BaseModel): + ts: str + value: float = 0.0 + sample_size: int = 0 + + +class ReportingTimeseriesOut(BaseModel): + metric: str + interval: Literal["hour", "day"] = "day" + filters: ReportingTimeseriesFiltersOut + points: list[ReportingTimeseriesPointOut] = Field(default_factory=list) + + +class ReportingAgentAnalyticsFiltersOut(BaseModel): + from_ts: str + to_ts: str + queue_id: str | None = None + channel: str | None = None + sort_by: Literal[ + "interactions_total", + "answered_total", + "avg_handle_seconds", + "fcr_rate", + "last_activity_at", + "agent_id", + ] = "interactions_total" + sort_dir: Literal["asc", "desc"] = "desc" + limit: int = 25 + + +class ReportingAgentAnalyticsTotalsOut(BaseModel): + agents_total: int = 0 + agents_with_activity: int = 0 + interactions_total: int = 0 + answered_total: int = 0 + closed_total: int = 0 + ready_now: int = 0 + busy_now: int = 0 + break_now: int = 0 + offline_now: int = 0 + avg_interactions_per_agent: float = 0.0 + avg_handle_seconds: float | None = None + avg_fcr_rate: float | None = None + + +class ReportingAgentStateSnapshotOut(BaseModel): + by_state: dict[str, int] = Field(default_factory=dict) + updated_at: str | None = None + + +class ReportingAgentAnalyticsRowOut(BaseModel): + agent_id: str + current_state: str | None = None + current_queue_id: str | None = None + current_state_updated_at: str | None = None + dominant_queue_id: str | None = None + last_activity_at: str | None = None + interactions_total: int = 0 + answered_total: int = 0 + closed_total: int = 0 + abandoned_total: int = 0 + avg_wait_seconds: float | None = None + avg_handle_seconds: float | None = None + answer_rate: float = 0.0 + fcr_rate: float | None = None + channels: list[str] = Field(default_factory=list) + + +class ReportingAgentAnalyticsTeamRowOut(BaseModel): + team_key: str + label: str + agents_total: int = 0 + agents_with_activity: int = 0 + interactions_total: int = 0 + answered_total: int = 0 + avg_handle_seconds: float | None = None + fcr_rate: float | None = None + ready_now: int = 0 + busy_now: int = 0 + break_now: int = 0 + offline_now: int = 0 + + +class ReportingAgentAnalyticsShiftRowOut(BaseModel): + shift_key: Literal["night", "day", "evening"] + label: str + agents_with_activity: int = 0 + interactions_total: int = 0 + answered_total: int = 0 + avg_handle_seconds: float | None = None + fcr_rate: float | None = None + + +class ReportingAgentAnalyticsBreakdownsOut(BaseModel): + by_team: list[ReportingAgentAnalyticsTeamRowOut] = Field(default_factory=list) + by_shift: list[ReportingAgentAnalyticsShiftRowOut] = Field(default_factory=list) + + +class ReportingAgentAnalyticsTrendFiltersOut(BaseModel): + from_ts: str + to_ts: str + queue_id: str | None = None + channel: str | None = None + metric: Literal["agents_with_activity", "interactions_per_agent", "avg_handle_seconds", "fcr_rate"] = "interactions_per_agent" + interval: Literal["hour", "day"] = "day" + + +class ReportingAgentAnalyticsTrendPointOut(BaseModel): + ts: str + value: float = 0.0 + agents_with_activity: int = 0 + interactions_total: int = 0 + + +class ReportingAgentAnalyticsTimeseriesOut(BaseModel): + metric: Literal["agents_with_activity", "interactions_per_agent", "avg_handle_seconds", "fcr_rate"] = "interactions_per_agent" + interval: Literal["hour", "day"] = "day" + filters: ReportingAgentAnalyticsTrendFiltersOut + points: list[ReportingAgentAnalyticsTrendPointOut] = Field(default_factory=list) + + +class ReportingAgentAnalyticsOverviewOut(BaseModel): + window: AIAnalyticsWindowOut + filters: ReportingAgentAnalyticsFiltersOut + totals: ReportingAgentAnalyticsTotalsOut = Field(default_factory=ReportingAgentAnalyticsTotalsOut) + state_snapshot: ReportingAgentStateSnapshotOut = Field(default_factory=ReportingAgentStateSnapshotOut) + breakdowns: ReportingAgentAnalyticsBreakdownsOut = Field(default_factory=ReportingAgentAnalyticsBreakdownsOut) + items: list[ReportingAgentAnalyticsRowOut] = Field(default_factory=list) + + +class ReportingSavedViewSnapshot(BaseModel): + preset: str = "7d" + fromTs: str = "" + toTs: str = "" + queueId: str = "all" + channel: str = "all" + compareMode: str = "previous" + trendMetric: str = "volume" + aiTrendMetric: str = "containment_rate" + agentTrendMetric: str = "interactions_per_agent" + + +class ReportingSavedViewIn(BaseModel): + id: str | None = None + name: str = Field(min_length=1, max_length=160) + snapshot: ReportingSavedViewSnapshot = Field(default_factory=ReportingSavedViewSnapshot) + + +class ReportingSavedViewOut(BaseModel): + id: str + name: str + snapshot: ReportingSavedViewSnapshot = Field(default_factory=ReportingSavedViewSnapshot) + created_at: str + updated_at: str + + +class AgentStateIn(BaseModel): + agent_id: str + state: Literal["READY", "BUSY", "BREAK", "OFFLINE"] + queue_id: str | None = None + + +class AgentStateOut(AgentStateIn): + updated_at: str + + +class AIAnalyticsWindowOut(BaseModel): + from_ts: str + to_ts: str + + +class AIAnalyticsFiltersOut(AIAnalyticsWindowOut): + queue_id: str | None = None + channel: str | None = None + + +class AIAnalyticsTotalsOut(BaseModel): + sessions_started: int = 0 + sessions_contained: int = 0 + sessions_handoff: int = 0 + sessions_closed: int = 0 + sessions_closed_without_operator: int = 0 + assistant_turns: int = 0 + + +class AIAnalyticsMetricsOut(BaseModel): + containment_rate: float = 0.0 + handoff_rate: float = 0.0 + ai_latency_avg_ms: float | None = None + ai_latency_p95_ms: float | None = None + closed_without_operator_rate: float = 0.0 + human_touched_rate: float = 0.0 + + +class AIAnalyticsChannelBreakdownOut(BaseModel): + channel: str + sessions_started: int = 0 + sessions_contained: int = 0 + sessions_handoff: int = 0 + sessions_closed_without_operator: int = 0 + assistant_turns: int = 0 + containment_rate: float = 0.0 + handoff_rate: float = 0.0 + closed_without_operator_rate: float = 0.0 + ai_latency_avg_ms: float | None = None + ai_only_sessions: int = 0 + human_touched_sessions: int = 0 + + +class AIAnalyticsOutcomeBreakdownOut(BaseModel): + outcome: Literal["contained", "handoff", "human_touched", "closed_without_operator", "active", "error"] + label: str + sessions: int = 0 + share: float = 0.0 + + +class AIAnalyticsHandoffReasonBreakdownOut(BaseModel): + reason_key: str + label: str + sessions: int = 0 + share: float = 0.0 + + +class AIAnalyticsBreakdownsOut(BaseModel): + by_channel: list[AIAnalyticsChannelBreakdownOut] = Field(default_factory=list) + by_outcome: list[AIAnalyticsOutcomeBreakdownOut] = Field(default_factory=list) + by_handoff_reason: list[AIAnalyticsHandoffReasonBreakdownOut] = Field(default_factory=list) + + +class AIAnalyticsCoverageOut(BaseModel): + sessions_with_interaction_id: int = 0 + sessions_with_queue_id: int = 0 + sessions_with_latency_turns: int = 0 + sessions_with_terminal_state: int = 0 + sessions_with_handoff_reason: int = 0 + + +class AIAnalyticsOverviewOut(BaseModel): + window: AIAnalyticsWindowOut + filters: AIAnalyticsFiltersOut + totals: AIAnalyticsTotalsOut + metrics: AIAnalyticsMetricsOut + breakdowns: AIAnalyticsBreakdownsOut + coverage: AIAnalyticsCoverageOut + + +class AIAnalyticsTimeseriesPointOut(BaseModel): + ts: str + value: float | None = None + sessions: int = 0 + assistant_turns: int = 0 + + +class AIAnalyticsTimeseriesOut(BaseModel): + metric: Literal["containment_rate", "handoff_rate", "ai_latency_avg_ms", "closed_without_operator_rate", "human_touched_rate"] + interval: Literal["hour", "day"] + filters: AIAnalyticsFiltersOut + points: list[AIAnalyticsTimeseriesPointOut] = Field(default_factory=list) + + +class AIAnalyticsDrilldownFiltersOut(AIAnalyticsFiltersOut): + slice: Literal["all", "contained", "handoff", "human_touched", "closed_without_operator", "active", "error"] = "all" + reason_key: str | None = None + status: str | None = None + q: str | None = None + sort_by: Literal["created_at", "updated_at", "ai_latency_avg_ms", "status"] = "created_at" + sort_dir: Literal["asc", "desc"] = "desc" + + +class AIAnalyticsDrilldownItemOut(BaseModel): + session_id: str + thread_id: str | None = None + interaction_id: str | None = None + channel: str + queue_id: str | None = None + status: str + created_at: str + updated_at: str + closed_at: str | None = None + contained: bool = False + handoff: bool = False + human_touched: bool = False + closed_without_operator: bool = False + reason_key: str | None = None + reason_label: str | None = None + raw_handoff_reason: str | None = None + assigned_to: str | None = None + claimed_by_user: str | None = None + assistant_turns: int = 0 + user_turns: int = 0 + tool_turns: int = 0 + ai_latency_avg_ms: float | None = None + ai_latency_p95_ms: float | None = None + + +class AIAnalyticsDrilldownOut(BaseModel): + items: list[AIAnalyticsDrilldownItemOut] = Field(default_factory=list) + total: int + limit: int + offset: int + filters: AIAnalyticsDrilldownFiltersOut + coverage: AIAnalyticsCoverageOut = Field(default_factory=AIAnalyticsCoverageOut) + + +class AIAnalyticsSessionLinkedInteractionOut(BaseModel): + interaction_id: str | None = None + channel: str | None = None + queue_id: str | None = None + status: str | None = None + assigned_to: str | None = None + subject: str | None = None + created_at: str | None = None + updated_at: str | None = None + + +class AIAnalyticsSessionEventOut(BaseModel): + ts: str + event_type: str + label: str + role: str | None = None + source_type: str | None = None + latency_ms: int | None = None + finish_reason: str | None = None + status: str | None = None + metadata: dict = Field(default_factory=dict) + + +class AIAnalyticsSessionDetailOut(BaseModel): + session: AIAnalyticsDrilldownItemOut + interaction: AIAnalyticsSessionLinkedInteractionOut | None = None + timeline: list[AIAnalyticsSessionEventOut] = Field(default_factory=list) diff --git a/services/shared/reporting_facts.py b/services/shared/reporting_facts.py new file mode 100644 index 0000000..15c71c0 --- /dev/null +++ b/services/shared/reporting_facts.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from services.shared.core import utc_now_iso +from services.shared.sql_models import ReportingInteractionFactRow + + +_STRING_FIELDS = ( + "interaction_id", + "channel", + "queue_id", + "agent_id", + "status", + "created_at", + "closed_at", + "source", +) +_BOOL_FIELDS = ("answered", "abandoned", "resolved_first_contact") +_INT_FIELDS = ("wait_seconds", "handle_seconds") + + +def _clean_string(value: Any) -> str | None: + raw = str(value or "").strip() + return raw or None + + +def _clean_bool(value: Any) -> bool | None: + if value is None: + return None + return bool(value) + + +def _clean_int(value: Any) -> int | None: + if value is None or value == "": + return None + parsed = int(value) + return max(parsed, 0) + + +def normalize_reporting_fact_payload(payload: Mapping[str, Any]) -> dict[str, Any]: + normalized: dict[str, Any] = {} + for field in _STRING_FIELDS: + value = _clean_string(payload.get(field)) + if value is not None: + normalized[field] = value + for field in _BOOL_FIELDS: + value = _clean_bool(payload.get(field)) + if value is not None: + normalized[field] = value + for field in _INT_FIELDS: + value = _clean_int(payload.get(field)) + if value is not None: + normalized[field] = value + interaction_id = normalized.get("interaction_id") + if not interaction_id: + raise ValueError("interaction_id is required") + source = normalized.get("source") + if not source: + raise ValueError("source is required") + return normalized + + +def upsert_reporting_interaction_fact(session: Session, payload: Mapping[str, Any]) -> ReportingInteractionFactRow: + normalized = normalize_reporting_fact_payload(payload) + row = session.execute( + select(ReportingInteractionFactRow).where( + ReportingInteractionFactRow.interaction_id == normalized["interaction_id"] + ) + ).scalar_one_or_none() + if row is None: + row = ReportingInteractionFactRow( + interaction_id=normalized["interaction_id"], + source=normalized["source"], + updated_at=utc_now_iso(), + ) + session.add(row) + for field, value in normalized.items(): + setattr(row, field, value) + row.updated_at = utc_now_iso() + return row diff --git a/services/shared/schema_migrations.py b/services/shared/schema_migrations.py new file mode 100644 index 0000000..42e7281 --- /dev/null +++ b/services/shared/schema_migrations.py @@ -0,0 +1,114 @@ +from __future__ import annotations + +import os +from pathlib import Path +from typing import Mapping + +from sqlalchemy import inspect, text + +from services.shared.db import engine + + +ROOT = Path(__file__).resolve().parents[2] +MIGRATIONS_DIR = ROOT / "migrations" / "sql" +_MODE_ENV = "SCHEMA_MANAGEMENT_MODE" +_VALID_MODES = {"legacy", "migrations"} + + +def database_backend_name(db_engine=engine) -> str: + return db_engine.url.get_backend_name() + + +def migration_suffix(db_engine=engine) -> str: + return "postgres" if database_backend_name(db_engine) == "postgresql" else "sqlite" + + +def list_migration_files(*, db_engine=engine, migrations_dir: Path = MIGRATIONS_DIR) -> list[Path]: + return sorted(migrations_dir.glob(f"*_{migration_suffix(db_engine)}.sql")) + + +def resolve_schema_management_mode( + raw_mode: str | None, + *, + backend_name: str, +) -> str: + normalized = str(raw_mode or "").strip().lower() + if not normalized: + normalized = "legacy" if backend_name == "sqlite" else "migrations" + if normalized not in _VALID_MODES: + allowed = ", ".join(sorted(_VALID_MODES)) + raise RuntimeError(f"{_MODE_ENV} must be one of: {allowed}") + if backend_name != "sqlite" and normalized != "migrations": + raise RuntimeError( + f"{_MODE_ENV}=legacy is supported only for SQLite. " + "Run `python scripts/migrate_core_db.py` and use SCHEMA_MANAGEMENT_MODE=migrations." + ) + return normalized + + +def schema_management_mode( + *, + db_engine=engine, + environ: Mapping[str, str] | None = None, +) -> str: + source = environ or os.environ + return resolve_schema_management_mode( + source.get(_MODE_ENV), + backend_name=database_backend_name(db_engine), + ) + + +def ensure_schema_migrations_table(*, db_engine=engine) -> None: + with db_engine.begin() as conn: + conn.execute( + text( + """ + CREATE TABLE IF NOT EXISTS schema_migrations ( + version TEXT PRIMARY KEY, + applied_at TEXT NOT NULL + ) + """ + ) + ) + + +def has_schema_migrations_table(*, db_engine=engine) -> bool: + return inspect(db_engine).has_table("schema_migrations") + + +def applied_migration_versions(*, db_engine=engine) -> set[str]: + if not has_schema_migrations_table(db_engine=db_engine): + return set() + with db_engine.begin() as conn: + rows = conn.execute(text("SELECT version FROM schema_migrations")).fetchall() + return {str(row[0]) for row in rows} + + +def missing_migration_versions(*, db_engine=engine, migrations_dir: Path = MIGRATIONS_DIR) -> list[str]: + expected = [path.name for path in list_migration_files(db_engine=db_engine, migrations_dir=migrations_dir)] + applied = applied_migration_versions(db_engine=db_engine) + return [name for name in expected if name not in applied] + + +def validate_schema_migrations_applied(*, db_engine=engine, migrations_dir: Path = MIGRATIONS_DIR) -> None: + expected = list_migration_files(db_engine=db_engine, migrations_dir=migrations_dir) + if not expected: + raise RuntimeError(f"No migration files found for backend {database_backend_name(db_engine)} in {migrations_dir}") + + if not has_schema_migrations_table(db_engine=db_engine): + raise RuntimeError( + "Database schema is not initialized via migrations. " + "Run `python scripts/migrate_core_db.py` before starting services." + ) + + missing = missing_migration_versions(db_engine=db_engine, migrations_dir=migrations_dir) + if not missing: + return + + preview = ", ".join(missing[:5]) + remainder = "" if len(missing) <= 5 else f" ... (+{len(missing) - 5} more)" + raise RuntimeError( + "Database schema is behind the checked-in migrations. " + f"Missing: {preview}{remainder}. " + "Run `python scripts/migrate_core_db.py` before starting services." + ) diff --git a/services/shared/security.py b/services/shared/security.py new file mode 100644 index 0000000..398cd13 --- /dev/null +++ b/services/shared/security.py @@ -0,0 +1,153 @@ +from __future__ import annotations + +from base64 import urlsafe_b64decode, urlsafe_b64encode +from datetime import datetime, timedelta, timezone +import hashlib +import hmac +import json +import os +from typing import Callable + +from fastapi import Depends, Header, HTTPException + +from services.shared.core import Role + + +def _bool_env(name: str, default: bool) -> bool: + raw = os.getenv(name) + if raw is None: + return default + return raw.strip().lower() in {"1", "true", "yes", "on"} + + +def legacy_header_auth_allowed() -> bool: + return _bool_env("ALLOW_LEGACY_HEADER_AUTH", True) + + +def _app_token_secret() -> str: + return os.getenv("APP_TOKEN_SECRET", "dev-secret-change-me") + + +def _app_token_ttl_seconds() -> int: + raw = os.getenv("APP_TOKEN_TTL_SECONDS", "3600").strip() + try: + value = int(raw) + except ValueError: + value = 3600 + return max(value, 60) + + +def _b64url_encode(raw: bytes) -> str: + return urlsafe_b64encode(raw).decode("utf-8").rstrip("=") + + +def _b64url_decode(raw: str) -> bytes: + padding = "=" * (-len(raw) % 4) + return urlsafe_b64decode((raw + padding).encode("utf-8")) + + +def _sign(message: str) -> str: + digest = hmac.new( + _app_token_secret().encode("utf-8"), + message.encode("utf-8"), + hashlib.sha256, + ).digest() + return _b64url_encode(digest) + + +def issue_app_token( + *, + subject: str, + username: str, + role: str, + auth_source: str, + provider: str | None = None, + full_name: str | None = None, + email: str | None = None, + ttl_seconds: int | None = None, +) -> str: + now = datetime.now(timezone.utc) + ttl = ttl_seconds if ttl_seconds is not None else _app_token_ttl_seconds() + header = {"alg": "HS256", "typ": "JWT"} + payload = { + "sub": subject, + "username": username, + "role": role, + "auth_source": auth_source, + "iat": int(now.timestamp()), + "exp": int((now + timedelta(seconds=ttl)).timestamp()), + } + if provider: + payload["provider"] = provider + if full_name: + payload["full_name"] = full_name + if email: + payload["email"] = email + + encoded_header = _b64url_encode(json.dumps(header, separators=(",", ":")).encode("utf-8")) + encoded_payload = _b64url_encode(json.dumps(payload, separators=(",", ":")).encode("utf-8")) + message = f"{encoded_header}.{encoded_payload}" + signature = _sign(message) + return f"{message}.{signature}" + + +def decode_app_token(token: str) -> dict: + try: + encoded_header, encoded_payload, encoded_signature = token.split(".") + except ValueError as exc: + raise HTTPException(status_code=401, detail="Invalid token format") from exc + + message = f"{encoded_header}.{encoded_payload}" + expected_signature = _sign(message) + if not hmac.compare_digest(encoded_signature, expected_signature): + raise HTTPException(status_code=401, detail="Invalid token signature") + + try: + payload = json.loads(_b64url_decode(encoded_payload).decode("utf-8")) + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=401, detail="Invalid token payload") from exc + + exp = int(payload.get("exp", 0) or 0) + now_ts = int(datetime.now(timezone.utc).timestamp()) + if exp and exp < now_ts: + raise HTTPException(status_code=401, detail="Token expired") + + return payload + + +def get_actor( + authorization: str | None = Header(default=None, alias="Authorization"), + x_user: str | None = Header(default=None, alias="X-User"), + x_role: str | None = Header(default=None, alias="X-Role"), +) -> dict: + if authorization: + scheme, _, value = authorization.partition(" ") + if scheme.lower() != "bearer" or not value.strip(): + raise HTTPException(status_code=401, detail="Invalid authorization header") + payload = decode_app_token(value.strip()) + return { + "sub": payload.get("sub", ""), + "user": payload.get("username", "anonymous"), + "role": str(payload.get("role", "anonymous")).strip().lower() or "anonymous", + "auth_source": payload.get("auth_source", "token"), + "provider": payload.get("provider"), + "full_name": payload.get("full_name"), + "email": payload.get("email"), + } + + if legacy_header_auth_allowed(): + role = (x_role or "").strip().lower() + return {"user": (x_user or "anonymous").strip(), "role": role or "anonymous", "auth_source": "legacy"} + + return {"user": "anonymous", "role": "anonymous", "auth_source": "none"} + + +def require_roles(*allowed: Role) -> Callable: + allowed_values = {a.value for a in allowed} + + def dependency(actor: dict = Depends(get_actor)) -> dict: + if actor["role"] not in allowed_values: + raise HTTPException(status_code=403, detail="Insufficient role") + return actor + + return dependency diff --git a/services/shared/sql_init.py b/services/shared/sql_init.py new file mode 100644 index 0000000..dea5493 --- /dev/null +++ b/services/shared/sql_init.py @@ -0,0 +1,536 @@ +from __future__ import annotations + +import time + +from sqlalchemy import inspect, text +from sqlalchemy.exc import OperationalError + +from services.shared.db import engine +from services.shared.schema_migrations import schema_management_mode, validate_schema_migrations_applied +from services.shared.sql_models import Base + + +def _table_columns(inspector, table_name: str) -> set[str]: + return {item["name"] for item in inspector.get_columns(table_name)} + + +def _table_indexes(inspector, table_name: str) -> set[str]: + return {item["name"] for item in inspector.get_indexes(table_name)} + + +def _add_column_if_missing(conn, columns: set[str], table_name: str, column_name: str, ddl: str) -> None: + if column_name in columns: + return + conn.execute(text(f"ALTER TABLE {table_name} ADD COLUMN {column_name} {ddl}")) + columns.add(column_name) + + +def _apply_runtime_schema_compatibility() -> None: + inspector = inspect(engine) + table_names = set(inspector.get_table_names()) + + with engine.begin() as conn: + if "telegram_messages" in table_names: + columns = _table_columns(inspector, "telegram_messages") + _add_column_if_missing(conn, columns, "telegram_messages", "thread_id", "VARCHAR(64)") + _add_column_if_missing(conn, columns, "telegram_messages", "interaction_id", "VARCHAR(64)") + _add_column_if_missing( + conn, + columns, + "telegram_messages", + "direction", + "VARCHAR(16) DEFAULT 'inbound'", + ) + _add_column_if_missing( + conn, + columns, + "telegram_messages", + "telegram_message_id_external", + "VARCHAR(128)", + ) + _add_column_if_missing(conn, columns, "telegram_messages", "operator_user", "VARCHAR(128)") + _add_column_if_missing(conn, columns, "telegram_messages", "delivery_status", "VARCHAR(32)") + _add_column_if_missing(conn, columns, "telegram_messages", "delivery_attempts", "INTEGER DEFAULT 0") + _add_column_if_missing( + conn, + columns, + "telegram_messages", + "next_delivery_attempt_at", + "VARCHAR(64)", + ) + _add_column_if_missing(conn, columns, "telegram_messages", "delivery_locked_until", "VARCHAR(64)") + _add_column_if_missing(conn, columns, "telegram_messages", "last_delivery_error", "TEXT") + if "author_type" not in columns: + _add_column_if_missing( + conn, + columns, + "telegram_messages", + "author_type", + "VARCHAR(32) DEFAULT 'customer'", + ) + if "direction" in columns: + conn.execute( + text( + """ + UPDATE telegram_messages + SET author_type = CASE + WHEN direction = 'outbound' THEN 'human' + WHEN direction = 'system' THEN 'system' + ELSE 'customer' + END + WHERE author_type IS NULL OR TRIM(author_type) = '' + """ + ) + ) + if "author_id" not in columns: + _add_column_if_missing(conn, columns, "telegram_messages", "author_id", "VARCHAR(128)") + if "operator_user" in columns: + conn.execute( + text( + """ + UPDATE telegram_messages + SET author_id = operator_user + WHERE operator_user IS NOT NULL + AND ( + direction = 'outbound' + OR direction IS NULL + OR TRIM(direction) = '' + ) + AND (author_id IS NULL OR TRIM(author_id) = '') + """ + ) + ) + indexes = _table_indexes(inspector, "telegram_messages") + if "idx_telegram_messages_thread_id" not in indexes: + conn.execute( + text("CREATE INDEX IF NOT EXISTS idx_telegram_messages_thread_id ON telegram_messages(thread_id)") + ) + if "idx_telegram_messages_interaction_id" not in indexes: + conn.execute( + text( + "CREATE INDEX IF NOT EXISTS idx_telegram_messages_interaction_id ON telegram_messages(interaction_id)" + ) + ) + if "idx_telegram_messages_direction" not in indexes: + conn.execute( + text("CREATE INDEX IF NOT EXISTS idx_telegram_messages_direction ON telegram_messages(direction)") + ) + if "idx_telegram_messages_telegram_message_id_external" not in indexes: + conn.execute( + text( + "CREATE INDEX IF NOT EXISTS idx_telegram_messages_telegram_message_id_external " + "ON telegram_messages(telegram_message_id_external)" + ) + ) + if "idx_telegram_messages_operator_user" not in indexes: + conn.execute( + text( + "CREATE INDEX IF NOT EXISTS idx_telegram_messages_operator_user ON telegram_messages(operator_user)" + ) + ) + if "idx_telegram_messages_delivery_status" not in indexes: + conn.execute( + text( + "CREATE INDEX IF NOT EXISTS idx_telegram_messages_delivery_status ON telegram_messages(delivery_status)" + ) + ) + if "idx_telegram_messages_next_delivery_attempt_at" not in indexes: + conn.execute( + text( + "CREATE INDEX IF NOT EXISTS idx_telegram_messages_next_delivery_attempt_at " + "ON telegram_messages(next_delivery_attempt_at)" + ) + ) + if "idx_telegram_messages_delivery_locked_until" not in indexes: + conn.execute( + text( + "CREATE INDEX IF NOT EXISTS idx_telegram_messages_delivery_locked_until " + "ON telegram_messages(delivery_locked_until)" + ) + ) + if "idx_telegram_messages_author_type" not in indexes: + conn.execute( + text( + "CREATE INDEX IF NOT EXISTS idx_telegram_messages_author_type ON telegram_messages(author_type)" + ) + ) + if "idx_telegram_messages_author_id" not in indexes: + conn.execute( + text( + "CREATE INDEX IF NOT EXISTS idx_telegram_messages_author_id ON telegram_messages(author_id)" + ) + ) + + if "telegram_threads" in table_names: + columns = _table_columns(inspector, "telegram_threads") + _add_column_if_missing(conn, columns, "telegram_threads", "ai_session_id", "VARCHAR(64)") + _add_column_if_missing(conn, columns, "telegram_threads", "ai_state", "VARCHAR(32)") + _add_column_if_missing(conn, columns, "telegram_threads", "ai_handoff_reason", "TEXT") + _add_column_if_missing(conn, columns, "telegram_threads", "ai_last_model_at", "VARCHAR(64)") + indexes = _table_indexes(inspector, "telegram_threads") + if "idx_telegram_threads_ai_session_id" not in indexes: + conn.execute( + text( + "CREATE INDEX IF NOT EXISTS idx_telegram_threads_ai_session_id ON telegram_threads(ai_session_id)" + ) + ) + if "idx_telegram_threads_ai_state" not in indexes: + conn.execute( + text( + "CREATE INDEX IF NOT EXISTS idx_telegram_threads_ai_state ON telegram_threads(ai_state)" + ) + ) + if "idx_telegram_threads_ai_last_model_at" not in indexes: + conn.execute( + text( + "CREATE INDEX IF NOT EXISTS idx_telegram_threads_ai_last_model_at ON telegram_threads(ai_last_model_at)" + ) + ) + + if "whatsapp_messages" in table_names: + columns = _table_columns(inspector, "whatsapp_messages") + _add_column_if_missing(conn, columns, "whatsapp_messages", "thread_id", "VARCHAR(64)") + _add_column_if_missing(conn, columns, "whatsapp_messages", "interaction_id", "VARCHAR(64)") + _add_column_if_missing(conn, columns, "whatsapp_messages", "customer_id", "VARCHAR(64)") + _add_column_if_missing( + conn, + columns, + "whatsapp_messages", + "direction", + "VARCHAR(16) DEFAULT 'inbound'", + ) + _add_column_if_missing( + conn, + columns, + "whatsapp_messages", + "whatsapp_message_id_external", + "VARCHAR(128)", + ) + _add_column_if_missing(conn, columns, "whatsapp_messages", "operator_user", "VARCHAR(128)") + _add_column_if_missing(conn, columns, "whatsapp_messages", "delivery_status", "VARCHAR(32)") + _add_column_if_missing(conn, columns, "whatsapp_messages", "delivery_attempts", "INTEGER DEFAULT 0") + _add_column_if_missing( + conn, + columns, + "whatsapp_messages", + "next_delivery_attempt_at", + "VARCHAR(64)", + ) + _add_column_if_missing(conn, columns, "whatsapp_messages", "delivery_locked_until", "VARCHAR(64)") + _add_column_if_missing(conn, columns, "whatsapp_messages", "last_delivery_error", "TEXT") + if "author_type" not in columns: + _add_column_if_missing( + conn, + columns, + "whatsapp_messages", + "author_type", + "VARCHAR(32) DEFAULT 'customer'", + ) + if "direction" in columns: + conn.execute( + text( + """ + UPDATE whatsapp_messages + SET author_type = CASE + WHEN direction = 'outbound' THEN 'human' + WHEN direction = 'system' THEN 'system' + ELSE 'customer' + END + WHERE author_type IS NULL OR TRIM(author_type) = '' + """ + ) + ) + if "author_id" not in columns: + _add_column_if_missing(conn, columns, "whatsapp_messages", "author_id", "VARCHAR(128)") + if "operator_user" in columns: + conn.execute( + text( + """ + UPDATE whatsapp_messages + SET author_id = operator_user + WHERE operator_user IS NOT NULL + AND ( + direction = 'outbound' + OR direction IS NULL + OR TRIM(direction) = '' + ) + AND (author_id IS NULL OR TRIM(author_id) = '') + """ + ) + ) + indexes = _table_indexes(inspector, "whatsapp_messages") + if "ix_whatsapp_messages_chat_external_unique" not in indexes: + conn.execute( + text( + "CREATE UNIQUE INDEX IF NOT EXISTS ix_whatsapp_messages_chat_external_unique " + "ON whatsapp_messages(chat_id, whatsapp_message_id_external)" + ) + ) + if "idx_whatsapp_messages_thread_id" not in indexes: + conn.execute( + text("CREATE INDEX IF NOT EXISTS idx_whatsapp_messages_thread_id ON whatsapp_messages(thread_id)") + ) + if "idx_whatsapp_messages_interaction_id" not in indexes: + conn.execute( + text( + "CREATE INDEX IF NOT EXISTS idx_whatsapp_messages_interaction_id ON whatsapp_messages(interaction_id)" + ) + ) + if "idx_whatsapp_messages_customer_id" not in indexes: + conn.execute( + text("CREATE INDEX IF NOT EXISTS idx_whatsapp_messages_customer_id ON whatsapp_messages(customer_id)") + ) + if "idx_whatsapp_messages_direction" not in indexes: + conn.execute( + text("CREATE INDEX IF NOT EXISTS idx_whatsapp_messages_direction ON whatsapp_messages(direction)") + ) + if "idx_whatsapp_messages_whatsapp_message_id_external" not in indexes: + conn.execute( + text( + "CREATE INDEX IF NOT EXISTS idx_whatsapp_messages_whatsapp_message_id_external " + "ON whatsapp_messages(whatsapp_message_id_external)" + ) + ) + if "idx_whatsapp_messages_operator_user" not in indexes: + conn.execute( + text( + "CREATE INDEX IF NOT EXISTS idx_whatsapp_messages_operator_user ON whatsapp_messages(operator_user)" + ) + ) + if "idx_whatsapp_messages_delivery_status" not in indexes: + conn.execute( + text( + "CREATE INDEX IF NOT EXISTS idx_whatsapp_messages_delivery_status ON whatsapp_messages(delivery_status)" + ) + ) + if "idx_whatsapp_messages_next_delivery_attempt_at" not in indexes: + conn.execute( + text( + "CREATE INDEX IF NOT EXISTS idx_whatsapp_messages_next_delivery_attempt_at " + "ON whatsapp_messages(next_delivery_attempt_at)" + ) + ) + if "idx_whatsapp_messages_delivery_locked_until" not in indexes: + conn.execute( + text( + "CREATE INDEX IF NOT EXISTS idx_whatsapp_messages_delivery_locked_until " + "ON whatsapp_messages(delivery_locked_until)" + ) + ) + if "idx_whatsapp_messages_author_type" not in indexes: + conn.execute( + text( + "CREATE INDEX IF NOT EXISTS idx_whatsapp_messages_author_type ON whatsapp_messages(author_type)" + ) + ) + if "idx_whatsapp_messages_author_id" not in indexes: + conn.execute( + text( + "CREATE INDEX IF NOT EXISTS idx_whatsapp_messages_author_id ON whatsapp_messages(author_id)" + ) + ) + + if "whatsapp_threads" in table_names: + columns = _table_columns(inspector, "whatsapp_threads") + _add_column_if_missing(conn, columns, "whatsapp_threads", "phone_number", "VARCHAR(64)") + _add_column_if_missing(conn, columns, "whatsapp_threads", "is_group", "BOOLEAN DEFAULT 0") + _add_column_if_missing(conn, columns, "whatsapp_threads", "ai_session_id", "VARCHAR(64)") + _add_column_if_missing(conn, columns, "whatsapp_threads", "ai_state", "VARCHAR(32)") + _add_column_if_missing(conn, columns, "whatsapp_threads", "ai_handoff_reason", "TEXT") + _add_column_if_missing(conn, columns, "whatsapp_threads", "ai_last_model_at", "VARCHAR(64)") + indexes = _table_indexes(inspector, "whatsapp_threads") + if "idx_whatsapp_threads_phone_number" not in indexes: + conn.execute( + text("CREATE INDEX IF NOT EXISTS idx_whatsapp_threads_phone_number ON whatsapp_threads(phone_number)") + ) + if "idx_whatsapp_threads_is_group" not in indexes: + conn.execute( + text("CREATE INDEX IF NOT EXISTS idx_whatsapp_threads_is_group ON whatsapp_threads(is_group)") + ) + if "idx_whatsapp_threads_ai_session_id" not in indexes: + conn.execute( + text( + "CREATE INDEX IF NOT EXISTS idx_whatsapp_threads_ai_session_id ON whatsapp_threads(ai_session_id)" + ) + ) + if "idx_whatsapp_threads_ai_state" not in indexes: + conn.execute( + text( + "CREATE INDEX IF NOT EXISTS idx_whatsapp_threads_ai_state ON whatsapp_threads(ai_state)" + ) + ) + if "idx_whatsapp_threads_ai_last_model_at" not in indexes: + conn.execute( + text( + "CREATE INDEX IF NOT EXISTS idx_whatsapp_threads_ai_last_model_at ON whatsapp_threads(ai_last_model_at)" + ) + ) + + if "ai_sessions" in table_names: + columns = _table_columns(inspector, "ai_sessions") + _add_column_if_missing(conn, columns, "ai_sessions", "call_id", "VARCHAR(128)") + indexes = _table_indexes(inspector, "ai_sessions") + if "idx_ai_sessions_call_id" not in indexes: + conn.execute( + text("CREATE INDEX IF NOT EXISTS idx_ai_sessions_call_id ON ai_sessions(call_id)") + ) + + if "voice_events" in table_names: + columns = _table_columns(inspector, "voice_events") + _add_column_if_missing(conn, columns, "voice_events", "source_event_id", "VARCHAR(64)") + indexes = _table_indexes(inspector, "voice_events") + if "ix_voice_events_source_event_id" not in indexes: + conn.execute( + text( + "CREATE UNIQUE INDEX IF NOT EXISTS ix_voice_events_source_event_id " + "ON voice_events(source_event_id)" + ) + ) + + if "asterisk_call_links" in table_names: + columns = _table_columns(inspector, "asterisk_call_links") + _add_column_if_missing(conn, columns, "asterisk_call_links", "voice_session_id", "VARCHAR(64)") + _add_column_if_missing(conn, columns, "asterisk_call_links", "ai_session_id", "VARCHAR(64)") + _add_column_if_missing(conn, columns, "asterisk_call_links", "ai_state", "VARCHAR(32)") + _add_column_if_missing(conn, columns, "asterisk_call_links", "ai_handoff_reason", "TEXT") + _add_column_if_missing(conn, columns, "asterisk_call_links", "ai_last_model_at", "VARCHAR(64)") + indexes = _table_indexes(inspector, "asterisk_call_links") + if "idx_asterisk_call_links_voice_session_id" not in indexes: + conn.execute( + text( + "CREATE INDEX IF NOT EXISTS idx_asterisk_call_links_voice_session_id " + "ON asterisk_call_links(voice_session_id)" + ) + ) + if "idx_asterisk_call_links_ai_session_id" not in indexes: + conn.execute( + text( + "CREATE INDEX IF NOT EXISTS idx_asterisk_call_links_ai_session_id " + "ON asterisk_call_links(ai_session_id)" + ) + ) + if "idx_asterisk_call_links_ai_state" not in indexes: + conn.execute( + text( + "CREATE INDEX IF NOT EXISTS idx_asterisk_call_links_ai_state " + "ON asterisk_call_links(ai_state)" + ) + ) + if "idx_asterisk_call_links_ai_last_model_at" not in indexes: + conn.execute( + text( + "CREATE INDEX IF NOT EXISTS idx_asterisk_call_links_ai_last_model_at " + "ON asterisk_call_links(ai_last_model_at)" + ) + ) + + if "voice_ai_sessions" in table_names: + columns = _table_columns(inspector, "voice_ai_sessions") + _add_column_if_missing(conn, columns, "voice_ai_sessions", "media_uuid", "VARCHAR(64)") + _add_column_if_missing(conn, columns, "voice_ai_sessions", "media_status", "VARCHAR(32)") + _add_column_if_missing(conn, columns, "voice_ai_sessions", "media_connected_at", "VARCHAR(64)") + _add_column_if_missing(conn, columns, "voice_ai_sessions", "media_ended_at", "VARCHAR(64)") + _add_column_if_missing(conn, columns, "voice_ai_sessions", "last_media_frame_at", "VARCHAR(64)") + indexes = _table_indexes(inspector, "voice_ai_sessions") + if "idx_voice_ai_sessions_media_uuid" not in indexes: + conn.execute( + text( + "CREATE INDEX IF NOT EXISTS idx_voice_ai_sessions_media_uuid " + "ON voice_ai_sessions(media_uuid)" + ) + ) + if "idx_voice_ai_sessions_media_status" not in indexes: + conn.execute( + text( + "CREATE INDEX IF NOT EXISTS idx_voice_ai_sessions_media_status " + "ON voice_ai_sessions(media_status)" + ) + ) + if "idx_voice_ai_sessions_media_connected_at" not in indexes: + conn.execute( + text( + "CREATE INDEX IF NOT EXISTS idx_voice_ai_sessions_media_connected_at " + "ON voice_ai_sessions(media_connected_at)" + ) + ) + if "idx_voice_ai_sessions_media_ended_at" not in indexes: + conn.execute( + text( + "CREATE INDEX IF NOT EXISTS idx_voice_ai_sessions_media_ended_at " + "ON voice_ai_sessions(media_ended_at)" + ) + ) + if "idx_voice_ai_sessions_last_media_frame_at" not in indexes: + conn.execute( + text( + "CREATE INDEX IF NOT EXISTS idx_voice_ai_sessions_last_media_frame_at " + "ON voice_ai_sessions(last_media_frame_at)" + ) + ) + + if "ivr_sessions" in table_names: + columns = _table_columns(inspector, "ivr_sessions") + _add_column_if_missing(conn, columns, "ivr_sessions", "resolved_queue_code", "VARCHAR(64)") + indexes = _table_indexes(inspector, "ivr_sessions") + if "idx_ivr_sessions_resolved_queue_code" not in indexes: + conn.execute( + text( + "CREATE INDEX IF NOT EXISTS idx_ivr_sessions_resolved_queue_code " + "ON ivr_sessions(resolved_queue_code)" + ) + ) + + if "kb_articles" in table_names: + columns = _table_columns(inspector, "kb_articles") + _add_column_if_missing(conn, columns, "kb_articles", "article_group_id", "VARCHAR(64)") + _add_column_if_missing(conn, columns, "kb_articles", "language", "VARCHAR(8) DEFAULT 'ru'") + if "language" in columns: + conn.execute( + text( + """ + UPDATE kb_articles + SET language = 'ru' + WHERE language IS NULL OR TRIM(language) = '' + """ + ) + ) + if "article_group_id" in columns: + conn.execute( + text( + """ + UPDATE kb_articles + SET article_group_id = article_id + WHERE article_group_id IS NULL OR TRIM(article_group_id) = '' + """ + ) + ) + indexes = _table_indexes(inspector, "kb_articles") + if "idx_kb_articles_article_group_id" not in indexes: + conn.execute( + text( + "CREATE INDEX IF NOT EXISTS idx_kb_articles_article_group_id " + "ON kb_articles(article_group_id)" + ) + ) + if "idx_kb_articles_language" not in indexes: + conn.execute( + text( + "CREATE INDEX IF NOT EXISTS idx_kb_articles_language " + "ON kb_articles(language)" + ) + ) + + +def init_sql_schema() -> None: + if schema_management_mode() == "migrations": + validate_schema_migrations_applied() + return + Base.metadata.create_all(bind=engine) + for attempt in range(5): + try: + _apply_runtime_schema_compatibility() + return + except OperationalError as exc: + if "database is locked" not in str(exc).lower() or attempt >= 4: + raise + time.sleep(0.25 * (attempt + 1)) diff --git a/services/shared/sql_models.py b/services/shared/sql_models.py new file mode 100644 index 0000000..0e348f0 --- /dev/null +++ b/services/shared/sql_models.py @@ -0,0 +1,748 @@ +from __future__ import annotations + +from sqlalchemy import Boolean, Float, Index, Integer, String, Text, UniqueConstraint +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column + + +class Base(DeclarativeBase): + pass + + +class AuthUser(Base): + __tablename__ = "auth_users" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + user_id: Mapped[str] = mapped_column(String(64), unique=True, index=True) + username: Mapped[str] = mapped_column(String(128), unique=True, index=True) + password: Mapped[str] = mapped_column(String(256)) + full_name: Mapped[str] = mapped_column(String(256)) + role: Mapped[str] = mapped_column(String(32), index=True) + created_at: Mapped[str] = mapped_column(String(64)) + + +class AuthExternalIdentity(Base): + __tablename__ = "auth_external_identities" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + provider: Mapped[str] = mapped_column(String(64), index=True) + external_subject: Mapped[str] = mapped_column(String(256), index=True) + username: Mapped[str] = mapped_column(String(128), index=True) + email: Mapped[str | None] = mapped_column(String(256), nullable=True, index=True) + full_name: Mapped[str | None] = mapped_column(String(256), nullable=True) + role: Mapped[str] = mapped_column(String(32), index=True) + linked_user_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + created_at: Mapped[str] = mapped_column(String(64), index=True) + updated_at: Mapped[str] = mapped_column(String(64), index=True) + last_login_at: Mapped[str] = mapped_column(String(64), index=True) + + +class AuthOIDCState(Base): + __tablename__ = "auth_oidc_states" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + state: Mapped[str] = mapped_column(String(128), unique=True, index=True) + nonce: Mapped[str] = mapped_column(String(128)) + code_verifier: Mapped[str] = mapped_column(String(256)) + redirect_uri: Mapped[str] = mapped_column(String(512)) + return_mode: Mapped[str] = mapped_column(String(32), default="popup") + created_at: Mapped[str] = mapped_column(String(64), index=True) + expires_at: Mapped[str] = mapped_column(String(64), index=True) + consumed_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + + +class Customer(Base): + __tablename__ = "customers" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + customer_id: Mapped[str] = mapped_column(String(64), unique=True, index=True) + display_name: Mapped[str] = mapped_column(String(256), index=True) + phones_json: Mapped[str] = mapped_column(Text, default="[]") + preferred_phone: Mapped[str | None] = mapped_column(String(64), nullable=True) + tags_json: Mapped[str] = mapped_column(Text, default="[]") + created_at: Mapped[str] = mapped_column(String(64)) + + +class CustomerExternalIdentity(Base): + __tablename__ = "customer_external_identities" + __table_args__ = ( + Index( + "ix_customer_external_identities_channel_subject_unique", + "channel", + "external_subject", + unique=True, + ), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + identity_id: Mapped[str] = mapped_column(String(64), unique=True, index=True) + customer_id: Mapped[str] = mapped_column(String(64), index=True) + channel: Mapped[str] = mapped_column(String(32), index=True) + external_subject: Mapped[str] = mapped_column(String(256), index=True) + display_name_snapshot: Mapped[str | None] = mapped_column(String(256), nullable=True) + created_at: Mapped[str] = mapped_column(String(64), index=True) + updated_at: Mapped[str] = mapped_column(String(64), index=True) + + +class Interaction(Base): + __tablename__ = "interactions" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + interaction_id: Mapped[str] = mapped_column(String(64), unique=True, index=True) + channel: Mapped[str] = mapped_column(String(32), index=True) + subject: Mapped[str] = mapped_column(String(512)) + customer_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + queue_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + priority: Mapped[int] = mapped_column(Integer, default=3, index=True) + status: Mapped[str] = mapped_column(String(32), index=True) + assigned_to: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True) + created_at: Mapped[str] = mapped_column(String(64)) + updated_at: Mapped[str] = mapped_column(String(64)) + + +class InteractionTimeline(Base): + __tablename__ = "interaction_timelines" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + interaction_id: Mapped[str] = mapped_column(String(64), index=True) + timestamp: Mapped[str] = mapped_column(String(64), index=True) + action: Mapped[str] = mapped_column(String(128)) + metadata_json: Mapped[str] = mapped_column(Text, default="{}") + + +class Queue(Base): + __tablename__ = "queues" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + queue_id: Mapped[str] = mapped_column(String(64), unique=True, index=True) + name: Mapped[str] = mapped_column(String(256), index=True) + description: Mapped[str] = mapped_column(Text, default="") + rules_json: Mapped[str] = mapped_column(Text, default="[]") + created_at: Mapped[str] = mapped_column(String(64)) + + +class RoutingCounter(Base): + __tablename__ = "routing_counters" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + channel: Mapped[str] = mapped_column(String(32), unique=True, index=True) + counter: Mapped[int] = mapped_column(Integer, default=0) + + +class AuditEventRow(Base): + __tablename__ = "audit_events" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + event_id: Mapped[str] = mapped_column(String(64), unique=True, index=True) + actor: Mapped[str] = mapped_column(String(128), index=True) + action: Mapped[str] = mapped_column(String(128), index=True) + entity: Mapped[str] = mapped_column(String(128), index=True) + metadata_json: Mapped[str] = mapped_column(Text, default="{}") + created_at: Mapped[str] = mapped_column(String(64), index=True) + + +class EventOutboxRow(Base): + __tablename__ = "event_outbox" + __table_args__ = ( + Index("idx_event_outbox_status_available_at_id", "status", "available_at", "id"), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + event_id: Mapped[str] = mapped_column(String(64), unique=True, index=True) + event_type: Mapped[str] = mapped_column(String(128), index=True) + event_version: Mapped[int] = mapped_column(Integer, default=1) + producer_service: Mapped[str] = mapped_column(String(128), index=True) + entity_type: Mapped[str] = mapped_column(String(128), index=True) + entity_id: Mapped[str] = mapped_column(String(128), index=True) + correlation_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True) + routing_key: Mapped[str] = mapped_column(String(128), index=True) + payload_json: Mapped[str] = mapped_column(Text, default="{}") + status: Mapped[str] = mapped_column(String(32), index=True) + attempt_count: Mapped[int] = mapped_column(Integer, default=0) + last_error: Mapped[str | None] = mapped_column(Text, nullable=True) + available_at: Mapped[str] = mapped_column(String(64), index=True) + published_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + created_at: Mapped[str] = mapped_column(String(64), index=True) + updated_at: Mapped[str] = mapped_column(String(64), index=True) + + +class EventInboxRow(Base): + __tablename__ = "event_inbox" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + consumer_name: Mapped[str] = mapped_column(String(128), index=True) + event_id: Mapped[str] = mapped_column(String(64), index=True) + event_type: Mapped[str] = mapped_column(String(128), index=True) + processed_at: Mapped[str] = mapped_column(String(64), index=True) + status: Mapped[str] = mapped_column(String(32), index=True) + notes: Mapped[str | None] = mapped_column(Text, nullable=True) + + +class AsteriskEventLogRow(Base): + __tablename__ = "asterisk_event_log" + __table_args__ = ( + Index( + "idx_asterisk_event_log_name_call_status_id", + "ami_event_name", + "call_id", + "forward_status", + "id", + ), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + bridge_event_id: Mapped[str] = mapped_column(String(64), unique=True, index=True) + ami_event_name: Mapped[str] = mapped_column(String(128), index=True) + call_id: Mapped[str] = mapped_column(String(128), index=True) + linked_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True) + interaction_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + recording_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + forward_status: Mapped[str] = mapped_column(String(32), index=True) + payload_json: Mapped[str] = mapped_column(Text, default="{}") + last_error: Mapped[str | None] = mapped_column(Text, nullable=True) + created_at: Mapped[str] = mapped_column(String(64), index=True) + updated_at: Mapped[str] = mapped_column(String(64), index=True) + + +class AsteriskCallLinkRow(Base): + __tablename__ = "asterisk_call_links" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + call_id: Mapped[str] = mapped_column(String(128), unique=True, index=True) + linked_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True) + queue_code: Mapped[str] = mapped_column(String(64), index=True) + queue_id: Mapped[str] = mapped_column(String(64), index=True) + interaction_id: Mapped[str] = mapped_column(String(64), index=True) + caller_number: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + caller_name: Mapped[str | None] = mapped_column(String(128), nullable=True) + status: Mapped[str] = mapped_column(String(32), index=True) + telephony_status: Mapped[str] = mapped_column(String(32), index=True, default="ringing") + claimed_by_user: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True) + claimed_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + operator_extension: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + channel_name: Mapped[str | None] = mapped_column(String(256), nullable=True, index=True) + voice_session_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + ai_session_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + ai_state: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True) + ai_handoff_reason: Mapped[str | None] = mapped_column(Text, nullable=True) + ai_last_model_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + started_at: Mapped[str] = mapped_column(String(64), index=True) + connected_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + ended_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + updated_at: Mapped[str] = mapped_column(String(64), index=True) + + +class AsteriskCallActionLogRow(Base): + __tablename__ = "asterisk_call_action_log" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + action_id: Mapped[str] = mapped_column(String(64), unique=True, index=True) + call_id: Mapped[str] = mapped_column(String(128), index=True) + interaction_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + action_type: Mapped[str] = mapped_column(String(64), index=True) + actor_user: Mapped[str] = mapped_column(String(128), index=True) + actor_role: Mapped[str] = mapped_column(String(32), index=True) + request_json: Mapped[str] = mapped_column(Text, default="{}") + result_status: Mapped[str] = mapped_column(String(32), index=True) + ami_action_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + error: Mapped[str | None] = mapped_column(Text, nullable=True) + created_at: Mapped[str] = mapped_column(String(64), index=True) + + +class VoiceEventRow(Base): + __tablename__ = "voice_events" + __table_args__ = ( + Index( + "ix_voice_events_source_event_id", + "source_event_id", + unique=True, + ), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + event_id: Mapped[str] = mapped_column(String(64), unique=True, index=True) + event_type: Mapped[str] = mapped_column(String(64), index=True) + call_id: Mapped[str] = mapped_column(String(128), index=True) + interaction_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + source_event_id: Mapped[str | None] = mapped_column(String(64), nullable=True) + payload_json: Mapped[str] = mapped_column(Text, default="{}") + created_at: Mapped[str] = mapped_column(String(64), index=True) + + +class CallRecordingRow(Base): + __tablename__ = "call_recordings" + __table_args__ = ( + Index( + "ix_call_recordings_source_event_id", + "source_event_id", + unique=True, + ), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + recording_id: Mapped[str] = mapped_column(String(64), unique=True, index=True) + channel: Mapped[str] = mapped_column(String(32), index=True) + call_id: Mapped[str] = mapped_column(String(128), index=True) + interaction_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + source_event_id: Mapped[str | None] = mapped_column(String(64), nullable=True) + file_name: Mapped[str] = mapped_column(String(256)) + storage_backend: Mapped[str] = mapped_column(String(32), index=True) + storage_path: Mapped[str] = mapped_column(Text) + mime_type: Mapped[str] = mapped_column(String(128)) + size_bytes: Mapped[int] = mapped_column(Integer) + duration_seconds: Mapped[int | None] = mapped_column(Integer, nullable=True) + checksum_sha256: Mapped[str] = mapped_column(String(64), index=True) + status: Mapped[str] = mapped_column(String(32), index=True) + recorded_at: Mapped[str] = mapped_column(String(64), index=True) + created_at: Mapped[str] = mapped_column(String(64), index=True) + updated_at: Mapped[str] = mapped_column(String(64), index=True) + archived_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + + +class IvrFlowRow(Base): + __tablename__ = "ivr_flows" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + flow_id: Mapped[str] = mapped_column(String(64), unique=True, index=True) + name: Mapped[str] = mapped_column(String(256), index=True) + description: Mapped[str] = mapped_column(Text, default="") + channel: Mapped[str] = mapped_column(String(32), index=True) + queue_id: Mapped[str] = mapped_column(String(64), index=True) + version: Mapped[int] = mapped_column(Integer, default=1) + is_active: Mapped[bool] = mapped_column(Boolean, default=True, index=True) + entry_node_id: Mapped[str] = mapped_column(String(64)) + flow_json: Mapped[str] = mapped_column(Text) + created_at: Mapped[str] = mapped_column(String(64), index=True) + updated_at: Mapped[str] = mapped_column(String(64), index=True) + + +class IvrSessionRow(Base): + __tablename__ = "ivr_sessions" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + session_id: Mapped[str] = mapped_column(String(64), unique=True, index=True) + call_id: Mapped[str] = mapped_column(String(128), index=True) + interaction_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + flow_id: Mapped[str] = mapped_column(String(64), index=True) + queue_id: Mapped[str] = mapped_column(String(64), index=True) + current_node_id: Mapped[str] = mapped_column(String(64)) + entered_digits_json: Mapped[str] = mapped_column(Text, default="[]") + status: Mapped[str] = mapped_column(String(32), index=True) + outcome_code: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + resolved_queue_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + resolved_queue_code: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + completed_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + created_at: Mapped[str] = mapped_column(String(64), index=True) + updated_at: Mapped[str] = mapped_column(String(64), index=True) + + +class AISessionRow(Base): + __tablename__ = "ai_sessions" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + session_id: Mapped[str] = mapped_column(String(64), unique=True, index=True) + channel: Mapped[str] = mapped_column(String(32), index=True) + call_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True) + thread_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + interaction_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + customer_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + agent_profile: Mapped[str] = mapped_column(String(64), index=True, default="telegram_support") + language: Mapped[str | None] = mapped_column(String(16), nullable=True, index=True) + status: Mapped[str] = mapped_column(String(32), index=True, default="active") + summary_text: Mapped[str] = mapped_column(Text, default="") + last_user_message_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + last_ai_message_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + handoff_reason: Mapped[str | None] = mapped_column(Text, nullable=True) + created_at: Mapped[str] = mapped_column(String(64), index=True) + updated_at: Mapped[str] = mapped_column(String(64), index=True) + closed_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + + +class AIJobRow(Base): + __tablename__ = "ai_jobs" + __table_args__ = ( + Index("idx_ai_jobs_thread_status_id", "thread_id", "status", "id"), + Index("idx_ai_jobs_thread_trigger_id", "thread_id", "trigger_message_id", "id"), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + job_id: Mapped[str] = mapped_column(String(64), unique=True, index=True) + session_id: Mapped[str] = mapped_column(String(64), index=True) + thread_id: Mapped[str] = mapped_column(String(64), index=True) + trigger_message_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + status: Mapped[str] = mapped_column(String(32), index=True, default="pending") + attempts: Mapped[int] = mapped_column(Integer, default=0) + next_attempt_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + locked_until: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + last_error: Mapped[str | None] = mapped_column(Text, nullable=True) + created_at: Mapped[str] = mapped_column(String(64), index=True) + updated_at: Mapped[str] = mapped_column(String(64), index=True) + + +class VoiceAISessionRow(Base): + __tablename__ = "voice_ai_sessions" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + session_id: Mapped[str] = mapped_column(String(64), unique=True, index=True) + call_id: Mapped[str] = mapped_column(String(128), unique=True, index=True) + linked_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True) + interaction_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + customer_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + queue_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + ai_session_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + agent_profile: Mapped[str] = mapped_column(String(64), index=True, default="voice_support") + language: Mapped[str | None] = mapped_column(String(16), nullable=True, index=True) + asr_provider: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + tts_provider: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + status: Mapped[str] = mapped_column(String(32), index=True, default="queued") + handoff_reason: Mapped[str | None] = mapped_column(Text, nullable=True) + handoff_target_queue_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + media_uuid: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + media_status: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True) + media_connected_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + media_ended_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + last_media_frame_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + disclosure_played_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + last_user_utterance_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + last_ai_reply_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + started_at: Mapped[str] = mapped_column(String(64), index=True) + updated_at: Mapped[str] = mapped_column(String(64), index=True) + ended_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + + +class AITurnRow(Base): + __tablename__ = "ai_turns" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + turn_id: Mapped[str] = mapped_column(String(64), unique=True, index=True) + session_id: Mapped[str] = mapped_column(String(64), index=True) + thread_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + interaction_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + role: Mapped[str] = mapped_column(String(32), index=True) + source_type: Mapped[str] = mapped_column(String(32), index=True) + text: Mapped[str] = mapped_column(Text) + payload_json: Mapped[str] = mapped_column(Text, default="{}") + model: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True) + finish_reason: Mapped[str | None] = mapped_column(String(64), nullable=True) + latency_ms: Mapped[int | None] = mapped_column(Integer, nullable=True) + created_at: Mapped[str] = mapped_column(String(64), index=True) + + +class VoiceTranscriptSegmentRow(Base): + __tablename__ = "voice_transcript_segments" + __table_args__ = ( + UniqueConstraint( + "session_id", + "sequence_no", + name="uq_voice_transcript_segments_session_sequence", + ), + Index( + "idx_voice_transcript_segments_session_speaker_final_id", + "session_id", + "speaker", + "is_final", + "id", + ), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + segment_id: Mapped[str] = mapped_column(String(64), unique=True, index=True) + session_id: Mapped[str] = mapped_column(String(64), index=True) + call_id: Mapped[str] = mapped_column(String(128), index=True) + interaction_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + speaker: Mapped[str] = mapped_column(String(32), index=True) + source_type: Mapped[str] = mapped_column(String(32), index=True) + sequence_no: Mapped[int] = mapped_column(Integer, index=True) + text: Mapped[str] = mapped_column(Text) + confidence: Mapped[float | None] = mapped_column(Float, nullable=True) + is_final: Mapped[bool] = mapped_column(Boolean, default=True, index=True) + barge_in_interrupted: Mapped[bool] = mapped_column(Boolean, default=False, index=True) + payload_json: Mapped[str] = mapped_column(Text, default="{}") + created_at: Mapped[str] = mapped_column(String(64), index=True) + + +class TelegramMessageRow(Base): + __tablename__ = "telegram_messages" + __table_args__ = ( + Index( + "ix_telegram_messages_chat_external_unique", + "chat_id", + "telegram_message_id_external", + unique=True, + ), + Index( + "idx_telegram_messages_thread_direction_author_created_id", + "thread_id", + "direction", + "author_type", + "created_at", + "id", + ), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + message_id: Mapped[str] = mapped_column(String(64), unique=True, index=True) + thread_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + interaction_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + chat_id: Mapped[str] = mapped_column(String(128), index=True) + text: Mapped[str] = mapped_column(Text) + customer_external_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True) + direction: Mapped[str] = mapped_column(String(16), index=True, default="inbound") + telegram_message_id_external: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True) + operator_user: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True) + author_type: Mapped[str] = mapped_column(String(32), index=True, default="customer") + author_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True) + delivery_status: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True) + delivery_attempts: Mapped[int] = mapped_column(Integer, default=0) + next_delivery_attempt_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + delivery_locked_until: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + last_delivery_error: Mapped[str | None] = mapped_column(Text, nullable=True) + payload_json: Mapped[str] = mapped_column(Text, default="{}") + created_at: Mapped[str] = mapped_column(String(64), index=True) + + +class TelegramThreadRow(Base): + __tablename__ = "telegram_threads" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + thread_id: Mapped[str] = mapped_column(String(64), unique=True, index=True) + chat_id: Mapped[str] = mapped_column(String(128), unique=True, index=True) + interaction_id: Mapped[str] = mapped_column(String(64), index=True) + telegram_user_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True) + username: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True) + display_name: Mapped[str | None] = mapped_column(String(256), nullable=True, index=True) + queue_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + status: Mapped[str] = mapped_column(String(32), index=True) + claimed_by_user: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True) + claimed_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + ai_session_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + ai_state: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True) + ai_handoff_reason: Mapped[str | None] = mapped_column(Text, nullable=True) + ai_last_model_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + last_message_at: Mapped[str] = mapped_column(String(64), index=True) + last_message_preview: Mapped[str] = mapped_column(Text, default="") + created_at: Mapped[str] = mapped_column(String(64), index=True) + updated_at: Mapped[str] = mapped_column(String(64), index=True) + + +class WhatsAppMessageRow(Base): + __tablename__ = "whatsapp_messages" + __table_args__ = ( + Index( + "ix_whatsapp_messages_chat_external_unique", + "chat_id", + "whatsapp_message_id_external", + unique=True, + ), + Index( + "idx_whatsapp_messages_thread_direction_author_created_id", + "thread_id", + "direction", + "author_type", + "created_at", + "id", + ), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + message_id: Mapped[str] = mapped_column(String(64), unique=True, index=True) + thread_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + interaction_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + chat_id: Mapped[str] = mapped_column(String(128), index=True) + text: Mapped[str] = mapped_column(Text) + customer_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + direction: Mapped[str] = mapped_column(String(16), index=True, default="inbound") + whatsapp_message_id_external: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True) + operator_user: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True) + author_type: Mapped[str] = mapped_column(String(32), index=True, default="customer") + author_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True) + delivery_status: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True) + delivery_attempts: Mapped[int] = mapped_column(Integer, default=0) + next_delivery_attempt_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + delivery_locked_until: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + last_delivery_error: Mapped[str | None] = mapped_column(Text, nullable=True) + payload_json: Mapped[str] = mapped_column(Text, default="{}") + created_at: Mapped[str] = mapped_column(String(64), index=True) + + +class WhatsAppThreadRow(Base): + __tablename__ = "whatsapp_threads" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + thread_id: Mapped[str] = mapped_column(String(64), unique=True, index=True) + chat_id: Mapped[str] = mapped_column(String(128), unique=True, index=True) + interaction_id: Mapped[str] = mapped_column(String(64), index=True) + whatsapp_user_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True) + phone_number: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + display_name: Mapped[str | None] = mapped_column(String(256), nullable=True, index=True) + queue_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + is_group: Mapped[bool] = mapped_column(Boolean, default=False, index=True) + status: Mapped[str] = mapped_column(String(32), index=True) + claimed_by_user: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True) + claimed_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + ai_session_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + ai_state: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True) + ai_handoff_reason: Mapped[str | None] = mapped_column(Text, nullable=True) + ai_last_model_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + last_message_at: Mapped[str] = mapped_column(String(64), index=True) + last_message_preview: Mapped[str] = mapped_column(Text, default="") + created_at: Mapped[str] = mapped_column(String(64), index=True) + updated_at: Mapped[str] = mapped_column(String(64), index=True) + + +class WebchatMessageRow(Base): + __tablename__ = "webchat_messages" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + message_id: Mapped[str] = mapped_column(String(64), unique=True, index=True) + session_id: Mapped[str] = mapped_column(String(128), index=True) + text: Mapped[str] = mapped_column(Text) + visitor_name: Mapped[str | None] = mapped_column(String(256), nullable=True) + customer_external_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True) + interaction_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + queue_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + priority: Mapped[int] = mapped_column(Integer, default=3, index=True) + payload_json: Mapped[str] = mapped_column(Text, default="{}") + created_at: Mapped[str] = mapped_column(String(64), index=True) + + +class EmailMessageRow(Base): + __tablename__ = "email_messages" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + message_id: Mapped[str] = mapped_column(String(64), unique=True, index=True) + from_email: Mapped[str] = mapped_column(String(256), index=True) + subject: Mapped[str] = mapped_column(String(512), index=True) + body: Mapped[str] = mapped_column(Text) + customer_external_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True) + interaction_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + queue_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + priority: Mapped[int] = mapped_column(Integer, default=3, index=True) + payload_json: Mapped[str] = mapped_column(Text, default="{}") + created_at: Mapped[str] = mapped_column(String(64), index=True) + + +class KBCategoryRow(Base): + __tablename__ = "kb_categories" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + category_id: Mapped[str] = mapped_column(String(64), unique=True, index=True) + name: Mapped[str] = mapped_column(String(256), index=True) + description: Mapped[str] = mapped_column(Text, default="") + created_at: Mapped[str] = mapped_column(String(64), index=True) + + +class KBArticleRow(Base): + __tablename__ = "kb_articles" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + article_id: Mapped[str] = mapped_column(String(64), unique=True, index=True) + category_id: Mapped[str] = mapped_column(String(64), index=True) + article_group_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + language: Mapped[str] = mapped_column(String(8), index=True, default="ru") + title: Mapped[str] = mapped_column(String(512), index=True) + body: Mapped[str] = mapped_column(Text) + tags_json: Mapped[str] = mapped_column(Text, default="[]") + created_at: Mapped[str] = mapped_column(String(64), index=True) + updated_at: Mapped[str] = mapped_column(String(64), index=True) + + +class ReportingEventRow(Base): + __tablename__ = "reporting_events" + __table_args__ = ( + Index("idx_reporting_events_queue_channel_created_at", "queue_id", "channel", "created_at"), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + queue_id: Mapped[str] = mapped_column(String(64), index=True) + channel: Mapped[str] = mapped_column(String(32), index=True, default="voice") + agent_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True) + answered: Mapped[bool] = mapped_column(Boolean, default=False) + wait_seconds: Mapped[int] = mapped_column(Integer, default=0) + handle_seconds: Mapped[int] = mapped_column(Integer, default=0) + abandoned: Mapped[bool] = mapped_column(Boolean, default=False) + resolved_first_contact: Mapped[bool] = mapped_column(Boolean, default=False) + created_at: Mapped[str] = mapped_column(String(64), index=True) + + +class ReportingEventLogRow(Base): + __tablename__ = "reporting_event_log" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + event_id: Mapped[str] = mapped_column(String(64), index=True) + event_type: Mapped[str] = mapped_column(String(128), index=True) + queue_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + channel: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True) + interaction_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + payload_json: Mapped[str] = mapped_column(Text, default="{}") + created_at: Mapped[str] = mapped_column(String(64), index=True) + + +class ReportingInteractionFactRow(Base): + __tablename__ = "reporting_interaction_facts" + __table_args__ = ( + UniqueConstraint("interaction_id", name="uq_reporting_interaction_facts_interaction_id"), + Index( + "idx_reporting_interaction_facts_created_queue_channel", + "created_at", + "queue_id", + "channel", + ), + Index( + "idx_reporting_interaction_facts_status_agent", + "status", + "agent_id", + ), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + interaction_id: Mapped[str] = mapped_column(String(64), index=True) + channel: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True) + queue_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + agent_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True) + status: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True) + created_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + updated_at: Mapped[str] = mapped_column(String(64), index=True) + closed_at: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + answered: Mapped[bool | None] = mapped_column(Boolean, nullable=True) + abandoned: Mapped[bool | None] = mapped_column(Boolean, nullable=True) + wait_seconds: Mapped[int | None] = mapped_column(Integer, nullable=True) + handle_seconds: Mapped[int | None] = mapped_column(Integer, nullable=True) + resolved_first_contact: Mapped[bool | None] = mapped_column(Boolean, nullable=True) + source: Mapped[str] = mapped_column(String(64), index=True, default="unknown") + + +class ReportingSavedViewRow(Base): + __tablename__ = "reporting_saved_views" + __table_args__ = ( + UniqueConstraint("owner_user", "view_id", name="uq_reporting_saved_views_owner_view"), + Index("idx_reporting_saved_views_owner_updated", "owner_user", "updated_at"), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + owner_user: Mapped[str] = mapped_column(String(128), index=True) + owner_role: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True) + view_id: Mapped[str] = mapped_column(String(64), index=True) + name: Mapped[str] = mapped_column(String(160)) + snapshot_json: Mapped[str] = mapped_column(Text, default="{}") + created_at: Mapped[str] = mapped_column(String(64), index=True) + updated_at: Mapped[str] = mapped_column(String(64), index=True) + + +class SupervisorAgentStateRow(Base): + __tablename__ = "supervisor_agent_states" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + agent_id: Mapped[str] = mapped_column(String(128), unique=True, index=True) + state: Mapped[str] = mapped_column(String(32), index=True) + queue_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + updated_at: Mapped[str] = mapped_column(String(64), index=True) + + +class SupervisorQueueSnapshotRow(Base): + __tablename__ = "supervisor_queue_snapshots" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + queue_id: Mapped[str] = mapped_column(String(64), unique=True, index=True) + in_queue: Mapped[int] = mapped_column(Integer, default=0) + avg_wait_seconds: Mapped[int] = mapped_column(Integer, default=0) + updated_at: Mapped[str] = mapped_column(String(64), index=True) diff --git a/services/shared/storage.py b/services/shared/storage.py new file mode 100644 index 0000000..8d8c8de --- /dev/null +++ b/services/shared/storage.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +import copy +import json +import os +from pathlib import Path +from tempfile import NamedTemporaryFile +from typing import Any + + +def _data_dir() -> Path: + root = Path(os.getenv("CC_DATA_DIR", ".data")).resolve() + root.mkdir(parents=True, exist_ok=True) + return root + + +def _file_path(name: str) -> Path: + safe = name.replace("\\", "_").replace("/", "_") + return _data_dir() / safe + + +def load_json(name: str, default: Any) -> Any: + path = _file_path(name) + if not path.exists(): + return copy.deepcopy(default) + try: + return json.loads(path.read_text(encoding="utf-8")) + except Exception: + return copy.deepcopy(default) + + +def save_json(name: str, data: Any) -> None: + path = _file_path(name) + path.parent.mkdir(parents=True, exist_ok=True) + + with NamedTemporaryFile("w", encoding="utf-8", delete=False, dir=str(path.parent)) as tmp: + json.dump(data, tmp, ensure_ascii=False, indent=2) + tmp_path = Path(tmp.name) + + tmp_path.replace(path) diff --git a/services/shared/voice_transcripts.py b/services/shared/voice_transcripts.py new file mode 100644 index 0000000..6de72dd --- /dev/null +++ b/services/shared/voice_transcripts.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +import json + +from sqlalchemy import select +from sqlalchemy.exc import IntegrityError + +from services.shared.core import new_id, utc_now_iso +from services.shared.sql_models import VoiceTranscriptSegmentRow + + +_SEQUENCE_CONFLICT_MARKERS = ( + "uq_voice_transcript_segments_session_sequence", + "voice_transcript_segments.session_id, voice_transcript_segments.sequence_no", + "voice_transcript_segments.session_id, sequence_no", +) + + +def next_transcript_sequence(session, session_id: str) -> int: + row = session.execute( + select(VoiceTranscriptSegmentRow) + .where(VoiceTranscriptSegmentRow.session_id == session_id) + .order_by(VoiceTranscriptSegmentRow.sequence_no.desc()) + .limit(1) + ).scalar_one_or_none() + if row is None: + return 1 + return int(row.sequence_no or 0) + 1 + + +def _is_sequence_conflict(exc: IntegrityError) -> bool: + message = str(exc).lower() + return any(marker in message for marker in _SEQUENCE_CONFLICT_MARKERS) + + +def add_transcript_segment( + session, + *, + session_id: str, + call_id: str, + interaction_id: str | None, + speaker: str, + source_type: str, + text: str, + confidence: float | None = None, + payload: dict | None = None, + is_final: bool = True, + barge_in_interrupted: bool = False, + sequence_no: int | None = None, + created_at: str | None = None, + max_attempts: int = 5, +) -> VoiceTranscriptSegmentRow: + for attempt in range(max_attempts): + candidate_sequence = sequence_no if attempt == 0 and sequence_no is not None else next_transcript_sequence(session, session_id) + row = VoiceTranscriptSegmentRow( + segment_id=new_id("vts"), + session_id=session_id, + call_id=call_id, + interaction_id=interaction_id, + speaker=speaker, + source_type=source_type, + sequence_no=candidate_sequence, + text=text, + confidence=confidence, + is_final=is_final, + barge_in_interrupted=barge_in_interrupted, + payload_json=json.dumps(payload or {}, ensure_ascii=False), + created_at=created_at or utc_now_iso(), + ) + try: + with session.begin_nested(): + session.add(row) + session.flush() + return row + except IntegrityError as exc: + if not _is_sequence_conflict(exc) or attempt >= max_attempts - 1: + raise + session.expire_all() + raise RuntimeError("Voice transcript segment insert retry exhausted") diff --git a/services/supervisor_service/__init__.py b/services/supervisor_service/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/supervisor_service/app.py b/services/supervisor_service/app.py new file mode 100644 index 0000000..dcc66b8 --- /dev/null +++ b/services/supervisor_service/app.py @@ -0,0 +1,276 @@ +from __future__ import annotations + +from collections import Counter +import json + +from fastapi import Depends, FastAPI +from sqlalchemy import select + +from services.shared.core import Role, utc_now_iso +from services.shared.db import get_session +from services.shared.event_bus import append_outbox_event, event_bus_enabled +from services.shared.models import AgentStateIn, AgentStateOut, HealthResponse +from services.shared.security import require_roles +from services.shared.sql_init import init_sql_schema +from services.shared.sql_models import ( + Interaction, + SupervisorAgentStateRow, + SupervisorQueueSnapshotRow, + VoiceEventRow, +) + +app = FastAPI(title="supervisor-service", version="1.1.0") + +init_sql_schema() + + +def _agent_out(row: SupervisorAgentStateRow) -> AgentStateOut: + return AgentStateOut( + agent_id=row.agent_id, + state=row.state, + queue_id=row.queue_id, + updated_at=row.updated_at, + ) + + +@app.get("/health", response_model=HealthResponse) +def health() -> HealthResponse: + return HealthResponse(status="ok", service="supervisor-service", version="v1.1") + + +@app.post("/supervisor/agent-states", response_model=AgentStateOut) +def set_agent_state( + payload: AgentStateIn, + _: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR)), +) -> AgentStateOut: + session = get_session() + try: + row = session.execute( + select(SupervisorAgentStateRow).where(SupervisorAgentStateRow.agent_id == payload.agent_id) + ).scalar_one_or_none() + if not row: + row = SupervisorAgentStateRow( + agent_id=payload.agent_id, + state=payload.state, + queue_id=payload.queue_id, + updated_at=utc_now_iso(), + ) + session.add(row) + else: + row.state = payload.state + row.queue_id = payload.queue_id + row.updated_at = utc_now_iso() + + if event_bus_enabled(): + append_outbox_event( + session, + event_type="agent.state.changed", + producer_service="supervisor-service", + entity_type="agent_state", + entity_id=payload.agent_id, + payload={ + "event": "agent.state.changed", + "agent_id": payload.agent_id, + "state": payload.state, + "queue_id": payload.queue_id, + "updated_at": row.updated_at, + }, + ) + session.commit() + session.refresh(row) + return _agent_out(row) + finally: + session.close() + + +@app.get("/supervisor/agents", response_model=list[AgentStateOut]) +def list_agents( + _: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.ANALYST)), +) -> list[AgentStateOut]: + session = get_session() + try: + rows = session.execute( + select(SupervisorAgentStateRow).order_by(SupervisorAgentStateRow.id.asc()) + ).scalars().all() + return [_agent_out(r) for r in rows] + finally: + session.close() + + +@app.post("/supervisor/queue-metrics") +def update_queue_metrics( + queue_id: str, + in_queue: int, + avg_wait_seconds: int, + _: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR)), +) -> dict: + session = get_session() + try: + row = session.execute( + select(SupervisorQueueSnapshotRow).where(SupervisorQueueSnapshotRow.queue_id == queue_id) + ).scalar_one_or_none() + if not row: + row = SupervisorQueueSnapshotRow( + queue_id=queue_id, + in_queue=in_queue, + avg_wait_seconds=avg_wait_seconds, + updated_at=utc_now_iso(), + ) + session.add(row) + else: + row.in_queue = in_queue + row.avg_wait_seconds = avg_wait_seconds + row.updated_at = utc_now_iso() + + session.commit() + return { + "queue_id": row.queue_id, + "in_queue": row.in_queue, + "avg_wait_seconds": row.avg_wait_seconds, + "updated_at": row.updated_at, + } + finally: + session.close() + + + + +@app.get("/supervisor/realtime") +def realtime( + _: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.ANALYST)), +) -> dict: + session = get_session() + try: + agents = session.execute( + select(SupervisorAgentStateRow).order_by(SupervisorAgentStateRow.id.asc()) + ).scalars().all() + snapshots = session.execute( + select(SupervisorQueueSnapshotRow).order_by(SupervisorQueueSnapshotRow.id.asc()) + ).scalars().all() + + items = [ + { + "agent_id": a.agent_id, + "state": a.state, + "queue_id": a.queue_id, + "updated_at": a.updated_at, + } + for a in agents + ] + status_counts = Counter(a["state"] for a in items) + + return { + "agents": { + "total": len(items), + "by_state": dict(status_counts), + "items": items, + }, + "queues": [ + { + "queue_id": q.queue_id, + "in_queue": q.in_queue, + "avg_wait_seconds": q.avg_wait_seconds, + "updated_at": q.updated_at, + } + for q in snapshots + ], + "timestamp": utc_now_iso(), + } + finally: + session.close() + + +@app.get("/supervisor/live-calls") +def live_calls( + limit: int = 20, + _: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.ANALYST)), +) -> dict: + session = get_session() + try: + event_rows = session.execute( + select(VoiceEventRow).order_by(VoiceEventRow.id.desc()) + ).scalars().all() + interaction_rows = session.execute( + select(Interaction).order_by(Interaction.id.desc()) + ).scalars().all() + + interaction_by_id = {row.interaction_id: row for row in interaction_rows} + calls_by_id: dict[str, dict] = {} + tracked_types = {"call.started", "call.ended", "recording.ready"} + + for row in event_rows[:500]: + payload = {} + try: + payload = json.loads(row.payload_json or "{}") + except Exception: # noqa: BLE001 + payload = {} + if payload.get("source") != "asterisk": + continue + if row.event_type not in tracked_types: + continue + + call = calls_by_id.get(row.call_id) or { + "call_id": row.call_id, + "interaction_id": row.interaction_id, + "started_at": None, + "ended_at": None, + "has_recording": False, + "last_at": row.created_at, + } + if row.interaction_id: + call["interaction_id"] = row.interaction_id + call["last_at"] = row.created_at or call["last_at"] + if row.event_type == "call.started": + call["started_at"] = row.created_at + elif row.event_type == "call.ended": + call["ended_at"] = row.created_at + elif row.event_type == "recording.ready": + call["has_recording"] = True + calls_by_id[row.call_id] = call + + items = [] + indexed_interaction_ids: set[str] = set() + for call in calls_by_id.values(): + interaction = interaction_by_id.get(call.get("interaction_id")) + queue_id = interaction.queue_id if interaction else None + status = "ended" if call.get("ended_at") else "active" + items.append( + { + **call, + "queue_id": queue_id, + "status": status, + } + ) + if call.get("interaction_id"): + indexed_interaction_ids.add(str(call["interaction_id"])) + + # Fallback interactions are shown only when no Asterisk-linked calls were detected. + # This keeps supervisor live-calls focused on real call_id-based telephony events. + if not items: + for row in interaction_rows: + if row.channel != "voice": + continue + if not (row.subject or "").startswith("Inbound call"): + continue + if row.interaction_id in indexed_interaction_ids: + continue + items.append( + { + "call_id": f"interaction:{row.interaction_id}", + "interaction_id": row.interaction_id, + "started_at": row.created_at, + "ended_at": row.updated_at if row.status == "closed" else None, + "has_recording": False, + "last_at": row.updated_at or row.created_at, + "queue_id": row.queue_id, + "status": row.status or "new", + } + ) + + items.sort(key=lambda item: item.get("last_at") or "", reverse=True) + return { + "items": items[: max(limit, 1)], + "timestamp": utc_now_iso(), + } + finally: + session.close() diff --git a/services/telegram_adapter_service/__init__.py b/services/telegram_adapter_service/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/telegram_adapter_service/app.py b/services/telegram_adapter_service/app.py new file mode 100644 index 0000000..8f4da1e --- /dev/null +++ b/services/telegram_adapter_service/app.py @@ -0,0 +1,2072 @@ +from __future__ import annotations + +import json +import logging +import os +import threading +from datetime import datetime, timedelta, timezone +from typing import Any + +import httpx +from fastapi import Depends, FastAPI, Header, HTTPException +from sqlalchemy import and_, case, inspect, or_, select, text, update +from sqlalchemy.exc import IntegrityError + +from services.shared.core import Role, new_id, utc_now_iso +from services.shared.db import engine, get_session +from services.shared.models import ( + AITelegramEnqueueIn, + EscalateRequest, + HealthResponse, + InteractionStatus, + TelegramThreadAISummaryOut, + TelegramThreadAIHandoffIn, + TelegramThreadAIReplyIn, + TelegramThreadEscalateIn, + TelegramThreadMessageOut, + TelegramThreadOut, + TelegramThreadReplyIn, + TelegramWebhookIn, + TelegramWebhookOut, +) +from services.shared.security import issue_app_token, require_roles +from services.shared.sql_init import init_sql_schema +from services.shared.sql_models import ( + AISessionRow, + Customer, + CustomerExternalIdentity, + Interaction, + InteractionTimeline, + TelegramMessageRow, + TelegramThreadRow, +) + +app = FastAPI(title="telegram-adapter-service", version="2.0.0") + +init_sql_schema() + +logger = logging.getLogger(__name__) + +TELEGRAM_BOT_DEDUP_INDEX = "ix_telegram_messages_chat_external_unique" +TELEGRAM_REPLY_NEXT_ATTEMPT_INDEX = "ix_telegram_messages_next_delivery_attempt_at" +TELEGRAM_REPLY_LOCKED_UNTIL_INDEX = "ix_telegram_messages_delivery_locked_until" + +_reply_delivery_worker_lock = threading.Lock() +_reply_delivery_worker_thread: threading.Thread | None = None +_reply_delivery_worker_stop: threading.Event | None = None +_reply_delivery_worker_wakeup: threading.Event | None = None +_reply_delivery_worker_clients = 0 +_HANDOFF_REASON_UNSET = object() + + +def _utc_now() -> datetime: + return datetime.now(timezone.utc).replace(microsecond=0) + + +def _utc_after_seconds(seconds: int) -> str: + return (_utc_now() + timedelta(seconds=max(seconds, 0))).isoformat() + + +def _text_column_type() -> str: + return "TEXT" + + +def _string_column_type(length: int = 64) -> str: + return f"VARCHAR({length})" + + +def _integer_env(name: str, default: int) -> int: + raw = os.getenv(name) + if raw is None: + return default + try: + return int(raw.strip()) + except ValueError: + return default + + +def _ensure_telegram_message_indexes() -> None: + inspector = inspect(engine) + if "telegram_messages" not in inspector.get_table_names(): + return + + columns = {item["name"] for item in inspector.get_columns("telegram_messages")} + indexes = {item["name"] for item in inspector.get_indexes("telegram_messages")} + + with engine.begin() as conn: + if "delivery_attempts" not in columns: + conn.execute(text("ALTER TABLE telegram_messages ADD COLUMN delivery_attempts INTEGER DEFAULT 0")) + if "next_delivery_attempt_at" not in columns: + conn.execute( + text( + f"ALTER TABLE telegram_messages ADD COLUMN next_delivery_attempt_at {_string_column_type()}" + ) + ) + if "delivery_locked_until" not in columns: + conn.execute( + text( + f"ALTER TABLE telegram_messages ADD COLUMN delivery_locked_until {_string_column_type()}" + ) + ) + if "last_delivery_error" not in columns: + conn.execute( + text(f"ALTER TABLE telegram_messages ADD COLUMN last_delivery_error {_text_column_type()}") + ) + + duplicates = conn.execute( + text( + """ + SELECT chat_id, telegram_message_id_external, MIN(id) AS keep_id + FROM telegram_messages + WHERE telegram_message_id_external IS NOT NULL + GROUP BY chat_id, telegram_message_id_external + HAVING COUNT(*) > 1 + """ + ) + ).mappings() + for row in duplicates: + conn.execute( + text( + """ + DELETE FROM telegram_messages + WHERE chat_id = :chat_id + AND telegram_message_id_external = :external_message_id + AND id <> :keep_id + """ + ), + { + "chat_id": row["chat_id"], + "external_message_id": row["telegram_message_id_external"], + "keep_id": row["keep_id"], + }, + ) + if TELEGRAM_BOT_DEDUP_INDEX not in indexes: + conn.execute( + text( + f""" + CREATE UNIQUE INDEX IF NOT EXISTS {TELEGRAM_BOT_DEDUP_INDEX} + ON telegram_messages (chat_id, telegram_message_id_external) + """ + ) + ) + if TELEGRAM_REPLY_NEXT_ATTEMPT_INDEX not in indexes: + conn.execute( + text( + f""" + CREATE INDEX IF NOT EXISTS {TELEGRAM_REPLY_NEXT_ATTEMPT_INDEX} + ON telegram_messages (next_delivery_attempt_at) + """ + ) + ) + if TELEGRAM_REPLY_LOCKED_UNTIL_INDEX not in indexes: + conn.execute( + text( + f""" + CREATE INDEX IF NOT EXISTS {TELEGRAM_REPLY_LOCKED_UNTIL_INDEX} + ON telegram_messages (delivery_locked_until) + """ + ) + ) + + +_ensure_telegram_message_indexes() + + +def _bool_env(name: str, default: bool) -> bool: + raw = os.getenv(name) + if raw is None: + return default + return raw.strip().lower() in {"1", "true", "yes", "on"} + + +def _bot_enabled() -> bool: + return _bool_env("TELEGRAM_BOT_ENABLED", False) + + +def _bot_token() -> str: + return os.getenv("TELEGRAM_BOT_TOKEN", "").strip() + + +def _webhook_secret() -> str: + return os.getenv("TELEGRAM_WEBHOOK_SECRET", "").strip() + + +def _default_queue_id() -> str: + return os.getenv("TELEGRAM_DEFAULT_QUEUE_ID", "q_telegram").strip() or "q_telegram" + + +def _bot_api_base() -> str: + return os.getenv("TELEGRAM_BOT_API_BASE", "https://api.telegram.org").rstrip("/") + + +def _reply_delivery_worker_enabled() -> bool: + return _bool_env("TELEGRAM_REPLY_DELIVERY_WORKER_ENABLED", True) + + +def _reply_delivery_poll_seconds() -> int: + return max(1, _integer_env("TELEGRAM_REPLY_DELIVERY_POLL_SECONDS", 5)) + + +def _reply_delivery_lease_seconds() -> int: + return max(5, _integer_env("TELEGRAM_REPLY_DELIVERY_LEASE_SECONDS", 30)) + + +def _reply_delivery_retry_base_seconds() -> int: + return max(1, _integer_env("TELEGRAM_REPLY_DELIVERY_RETRY_BASE_SECONDS", 5)) + + +def _reply_delivery_retry_cap_seconds() -> int: + return max(_reply_delivery_retry_base_seconds(), _integer_env("TELEGRAM_REPLY_DELIVERY_RETRY_CAP_SECONDS", 300)) + + +def _reply_delivery_max_attempts() -> int: + return max(1, _integer_env("TELEGRAM_REPLY_DELIVERY_MAX_ATTEMPTS", 5)) + + +def _interaction_service_url() -> str: + return os.getenv("INTERACTION_SERVICE_URL", "http://localhost:8004").rstrip("/") + + +def _ai_service_url() -> str: + return os.getenv("AI_ORCHESTRATOR_SERVICE_URL", "http://localhost:8017").rstrip("/") + + +def _ai_telegram_enabled() -> bool: + return _bool_env("AI_TELEGRAM_ENABLED", False) + + +def _service_auth_headers() -> dict[str, str]: + token = issue_app_token( + subject="svc:telegram-adapter", + username="telegram-adapter", + role="admin", + auth_source="service", + provider="telegram-adapter", + ttl_seconds=300, + ) + return {"Authorization": f"Bearer {token}"} + + +def _internal_service_headers( + *, + subject: str, + username: str, + provider: str, +) -> dict[str, str]: + token = issue_app_token( + subject=subject, + username=username, + role="admin", + auth_source="service", + provider=provider, + ttl_seconds=300, + ) + return {"Authorization": f"Bearer {token}"} + + +def _interaction_request(method: str, path: str, *, payload: dict | None = None) -> dict: + with httpx.Client(timeout=5.0) as client: + response = client.request( + method, + f"{_interaction_service_url()}{path}", + json=payload, + headers=_service_auth_headers(), + ) + response.raise_for_status() + return response.json() + + +def _ai_enqueue_request(thread_id: str, trigger_message_id: str | None) -> None: + if not _ai_telegram_enabled(): + return + try: + with httpx.Client(timeout=10.0) as client: + response = client.post( + f"{_ai_service_url()}/ai/telegram/threads/{thread_id}/enqueue", + json=AITelegramEnqueueIn(trigger_message_id=trigger_message_id).model_dump(), + headers=_internal_service_headers( + subject="svc:telegram-adapter", + username="telegram-adapter", + provider="telegram-adapter", + ), + ) + response.raise_for_status() + except Exception: # noqa: BLE001 + logger.exception("Telegram AI enqueue failed", extra={"thread_id": thread_id}) + + +def _preview_text(text: str, limit: int = 120) -> str: + value = str(text or "").strip() + if len(value) <= limit: + return value + return f"{value[: limit - 3]}..." + + +def _normalize_external_subject(value: str | None) -> str | None: + raw = str(value or "").strip() + if not raw: + return None + if raw.startswith("telegram:"): + return raw.split(":", 1)[1].strip() or None + return raw + + +def _first_external_subject( + telegram_user_id: str | None, + chat_id: str, + explicit: str | None = None, +) -> str: + return ( + _normalize_external_subject(telegram_user_id) + or _normalize_external_subject(explicit) + or _normalize_external_subject(chat_id) + or chat_id + ) + + +def _customer_external_id(telegram_user_id: str | None, chat_id: str, explicit: str | None = None) -> str: + return f"telegram:{_first_external_subject(telegram_user_id, chat_id, explicit)}" + + +def _customer_id_is_real(customer_id: str | None) -> bool: + return str(customer_id or "").startswith("cus_") + + +def _telegram_identity_subjects( + telegram_user_id: str | None, + chat_id: str, + explicit: str | None = None, +) -> list[str]: + ordered = [ + _normalize_external_subject(telegram_user_id), + _normalize_external_subject(explicit), + _normalize_external_subject(chat_id), + ] + seen: set[str] = set() + result: list[str] = [] + for item in ordered: + if not item or item in seen: + continue + seen.add(item) + result.append(item) + return result + + +def _ensure_customer_external_identity( + session, + *, + customer_id: str, + channel: str, + external_subject: str, + display_name_snapshot: str | None, + now: str, +) -> None: + row = session.execute( + select(CustomerExternalIdentity).where( + CustomerExternalIdentity.channel == channel, + CustomerExternalIdentity.external_subject == external_subject, + ) + ).scalar_one_or_none() + if row: + row.customer_id = customer_id + if display_name_snapshot: + row.display_name_snapshot = display_name_snapshot + row.updated_at = now + return + session.add( + CustomerExternalIdentity( + identity_id=new_id("cei"), + customer_id=customer_id, + channel=channel, + external_subject=external_subject, + display_name_snapshot=display_name_snapshot, + created_at=now, + updated_at=now, + ) + ) + + +def _resolve_or_create_customer_id( + session, + *, + telegram_user_id: str | None, + chat_id: str, + display_name: str, + explicit_customer_external_id: str | None = None, +) -> str: + now = utc_now_iso() + explicit_value = str(explicit_customer_external_id or "").strip() + subjects = _telegram_identity_subjects(telegram_user_id, chat_id, explicit_value) + + for subject in subjects: + identity = session.execute( + select(CustomerExternalIdentity).where( + CustomerExternalIdentity.channel == "telegram", + CustomerExternalIdentity.external_subject == subject, + ) + ).scalar_one_or_none() + if identity: + if display_name and identity.display_name_snapshot != display_name: + identity.display_name_snapshot = display_name + identity.updated_at = now + return identity.customer_id + + if _customer_id_is_real(explicit_value): + customer = session.execute( + select(Customer).where(Customer.customer_id == explicit_value) + ).scalar_one_or_none() + if not customer: + customer = Customer( + customer_id=explicit_value, + display_name=display_name, + phones_json="[]", + preferred_phone=None, + tags_json=json.dumps(["telegram"], ensure_ascii=False), + created_at=now, + ) + session.add(customer) + elif display_name and customer.display_name != display_name: + customer.display_name = display_name + customer_id = customer.customer_id + else: + customer = Customer( + customer_id=new_id("cus"), + display_name=display_name, + phones_json="[]", + preferred_phone=None, + tags_json=json.dumps(["telegram"], ensure_ascii=False), + created_at=now, + ) + session.add(customer) + customer_id = customer.customer_id + + for subject in subjects or [_normalize_external_subject(chat_id) or chat_id]: + _ensure_customer_external_identity( + session, + customer_id=customer_id, + channel="telegram", + external_subject=subject, + display_name_snapshot=display_name, + now=now, + ) + return customer_id + + +def _display_name( + *, + username: str | None = None, + first_name: str | None = None, + last_name: str | None = None, + fallback: str | None = None, +) -> str: + parts = [str(first_name or "").strip(), str(last_name or "").strip()] + full_name = " ".join(part for part in parts if part).strip() + if full_name: + return full_name + if username: + return username + if fallback: + return fallback + return "Telegram user" + + +def _inbound_author_type(direction: str) -> str: + return "system" if direction == "system" else "customer" + + +def _message_can_trigger_ai(author_type: str | None) -> bool: + return _ai_telegram_enabled() and author_type == "customer" + + +def _normalize_phone_number(value: str | None) -> str | None: + raw = str(value or "").strip() + if not raw: + return None + digits = "".join(ch for ch in raw if ch.isdigit()) + if not digits: + return None + if raw.startswith("+"): + return f"+{digits}" + return digits + + +def _contact_phone_number(contact: dict | None, *, sender_user_id: str | None) -> str | None: + if not isinstance(contact, dict): + return None + phone_number = _normalize_phone_number(contact.get("phone_number")) + if not phone_number: + return None + contact_user_id = str(contact.get("user_id") or "").strip() or None + if sender_user_id and contact_user_id and contact_user_id != sender_user_id: + return None + return phone_number + + +def _customer_phone_list(raw: str | None) -> list[str]: + try: + value = json.loads(raw or "[]") + except Exception: # noqa: BLE001 + return [] + if not isinstance(value, list): + return [] + result: list[str] = [] + for item in value: + normalized = _normalize_phone_number(item) + if normalized and normalized not in result: + result.append(normalized) + return result + + +def _ensure_customer_phone(session, *, customer_id: str, phone_number: str | None) -> None: + normalized_phone = _normalize_phone_number(phone_number) + if not normalized_phone or not _customer_id_is_real(customer_id): + return + session.flush() + customer = session.execute( + select(Customer).where(Customer.customer_id == customer_id) + ).scalar_one_or_none() + if not customer: + return + phones = _customer_phone_list(customer.phones_json) + if normalized_phone not in phones: + phones.append(normalized_phone) + customer.phones_json = json.dumps(phones, ensure_ascii=False) + if not customer.preferred_phone: + customer.preferred_phone = normalized_phone + + +def _push_timeline(session, interaction_id: str, action: str, metadata: dict | None = None) -> None: + session.add( + InteractionTimeline( + interaction_id=interaction_id, + timestamp=utc_now_iso(), + action=action, + metadata_json=json.dumps(metadata or {}, ensure_ascii=False), + ) + ) + + +def _get_thread(session, thread_id: str) -> TelegramThreadRow: + row = session.execute( + select(TelegramThreadRow).where(TelegramThreadRow.thread_id == thread_id) + ).scalar_one_or_none() + if not row: + raise HTTPException(status_code=404, detail="Telegram thread not found") + return row + + +def _to_thread_out(row: TelegramThreadRow) -> TelegramThreadOut: + return TelegramThreadOut( + thread_id=row.thread_id, + chat_id=row.chat_id, + interaction_id=row.interaction_id, + telegram_user_id=row.telegram_user_id, + username=row.username, + display_name=row.display_name, + queue_id=row.queue_id, + status=row.status, # type: ignore[arg-type] + claimed_by_user=row.claimed_by_user, + claimed_at=row.claimed_at, + ai_session_id=row.ai_session_id, + ai_state=row.ai_state, # type: ignore[arg-type] + ai_handoff_reason=row.ai_handoff_reason, + ai_last_model_at=row.ai_last_model_at, + last_message_at=row.last_message_at, + last_message_preview=row.last_message_preview, + created_at=row.created_at, + updated_at=row.updated_at, + ) + + +def _to_message_out(row: TelegramMessageRow) -> TelegramThreadMessageOut: + return TelegramThreadMessageOut( + message_id=row.message_id, + thread_id=row.thread_id or "", + interaction_id=row.interaction_id or "", + chat_id=row.chat_id, + direction=row.direction, # type: ignore[arg-type] + text=row.text, + telegram_message_id_external=row.telegram_message_id_external, + operator_user=row.operator_user, + author_type=row.author_type, # type: ignore[arg-type] + author_id=row.author_id, + delivery_status=row.delivery_status, + payload=json.loads(row.payload_json or "{}"), + created_at=row.created_at, + ) + + +def _loads_payload(raw: str | None) -> dict[str, Any]: + try: + payload = json.loads(raw or "{}") + except Exception: # noqa: BLE001 + return {} + return payload if isinstance(payload, dict) else {} + + +def _to_webhook_out(row: TelegramMessageRow) -> TelegramWebhookOut: + return TelegramWebhookOut( + message_id=row.message_id, + chat_id=row.chat_id, + text=row.text, + customer_external_id=row.customer_external_id, + payload=_loads_payload(row.payload_json), + thread_id=row.thread_id, + interaction_id=row.interaction_id, + direction=row.direction, # type: ignore[arg-type] + created_at=row.created_at, + ) + + +def _to_bot_webhook_result(row: TelegramMessageRow, *, thread_created: bool) -> dict[str, Any]: + return { + "ok": True, + "thread_id": row.thread_id or "", + "interaction_id": row.interaction_id or "", + "message_id": row.message_id, + "thread_created": thread_created, + } + + +def _existing_bot_message( + session, + *, + chat_id: str, + external_message_id: str | None, +) -> TelegramMessageRow | None: + if not external_message_id: + return None + return session.execute( + select(TelegramMessageRow).where( + TelegramMessageRow.chat_id == chat_id, + TelegramMessageRow.telegram_message_id_external == external_message_id, + ) + ).scalar_one_or_none() + + +def _latest_ai_session_for_thread(session, thread: TelegramThreadRow) -> AISessionRow | None: + if thread.ai_session_id: + ai_session = session.execute( + select(AISessionRow).where(AISessionRow.session_id == thread.ai_session_id) + ).scalar_one_or_none() + if ai_session: + return ai_session + return session.execute( + select(AISessionRow) + .where(AISessionRow.thread_id == thread.thread_id) + .order_by(AISessionRow.updated_at.desc(), AISessionRow.id.desc()) + .limit(1) + ).scalar_one_or_none() + + +def _summary_customer_request_text( + session, + *, + thread: TelegramThreadRow, + ai_session: AISessionRow, + generated_at: str, +) -> str: + if ai_session.last_user_message_id: + customer_message = session.execute( + select(TelegramMessageRow).where(TelegramMessageRow.message_id == ai_session.last_user_message_id) + ).scalar_one_or_none() + if customer_message and customer_message.text.strip(): + return customer_message.text.strip() + customer_message = session.execute( + select(TelegramMessageRow) + .where( + TelegramMessageRow.thread_id == thread.thread_id, + TelegramMessageRow.direction == "inbound", + TelegramMessageRow.author_type == "customer", + TelegramMessageRow.created_at <= generated_at, + ) + .order_by(TelegramMessageRow.created_at.desc(), TelegramMessageRow.id.desc()) + .limit(1) + ).scalar_one_or_none() + if customer_message and customer_message.text.strip(): + return customer_message.text.strip() + return "Последний запрос клиента недоступен." + + +def _summary_ai_outcome_text( + session, + *, + thread: TelegramThreadRow, + ai_session: AISessionRow, + generated_at: str, +) -> str: + if ai_session.last_ai_message_id: + ai_message = session.execute( + select(TelegramMessageRow).where(TelegramMessageRow.message_id == ai_session.last_ai_message_id) + ).scalar_one_or_none() + if ai_message and ai_message.text.strip(): + return ai_message.text.strip() + ai_message = session.execute( + select(TelegramMessageRow) + .where( + TelegramMessageRow.thread_id == thread.thread_id, + TelegramMessageRow.direction == "outbound", + TelegramMessageRow.author_type == "ai", + TelegramMessageRow.created_at <= generated_at, + ) + .order_by(TelegramMessageRow.created_at.desc(), TelegramMessageRow.id.desc()) + .limit(1) + ).scalar_one_or_none() + if ai_message and ai_message.text.strip(): + return ai_message.text.strip() + return "AI передал диалог оператору без ответа клиенту." + + +def _summary_status( + session, + *, + thread: TelegramThreadRow, + ai_session: AISessionRow, + generated_at: str, +) -> tuple[str, str]: + if ai_session.last_ai_message_id: + ai_message = session.execute( + select(TelegramMessageRow).where(TelegramMessageRow.message_id == ai_session.last_ai_message_id) + ).scalar_one_or_none() + if ai_message and ai_message.text.strip(): + return ("AI ответил клиенту", "answered") + ai_message = session.execute( + select(TelegramMessageRow.message_id) + .where( + TelegramMessageRow.thread_id == thread.thread_id, + TelegramMessageRow.direction == "outbound", + TelegramMessageRow.author_type == "ai", + TelegramMessageRow.created_at <= generated_at, + ) + .order_by(TelegramMessageRow.created_at.desc(), TelegramMessageRow.id.desc()) + .limit(1) + ).scalar_one_or_none() + if ai_message: + return ("AI ответил клиенту", "answered") + return ("AI передал без ответа", "handoff") + + +def _summary_recommended_next_step(thread: TelegramThreadRow) -> str: + if thread.ai_state == "handoff_required": + return "Заберите чат и ответьте клиенту вручную." + if thread.ai_state == "human_owned": + return "Продолжайте диалог вручную; AI больше не отвечает в этот thread." + return "" + + +def _thread_supports_ai_summary(thread: TelegramThreadRow, ai_session: AISessionRow | None) -> bool: + if not ai_session: + return False + if thread.ai_state == "handoff_required": + return True + if thread.ai_state == "human_owned": + return bool(thread.ai_handoff_reason or ai_session.handoff_reason) + return False + + +def _build_ai_summary(session, thread: TelegramThreadRow) -> TelegramThreadAISummaryOut | None: + ai_session = _latest_ai_session_for_thread(session, thread) + if not _thread_supports_ai_summary(thread, ai_session): + return None + assert ai_session is not None + generated_at = thread.ai_last_model_at or ai_session.updated_at or thread.updated_at + handoff_reason = (thread.ai_handoff_reason or ai_session.handoff_reason or "").strip() + if not handoff_reason: + return None + status_label, status_tone = _summary_status( + session, + thread=thread, + ai_session=ai_session, + generated_at=generated_at, + ) + return TelegramThreadAISummaryOut( + thread_id=thread.thread_id, + session_id=ai_session.session_id, + status_label=status_label, + status_tone=status_tone, # type: ignore[arg-type] + customer_request_text=_summary_customer_request_text( + session, + thread=thread, + ai_session=ai_session, + generated_at=generated_at, + ), + ai_outcome_text=_summary_ai_outcome_text( + session, + thread=thread, + ai_session=ai_session, + generated_at=generated_at, + ), + handoff_reason=handoff_reason, + recommended_next_step=_summary_recommended_next_step(thread), + generated_at=generated_at, + ) + + +def _reactivate_thread_interaction( + session, + thread: TelegramThreadRow, + now: str, + *, + requeue_ai: bool, +) -> None: + interaction = session.execute( + select(Interaction).where(Interaction.interaction_id == thread.interaction_id) + ).scalar_one_or_none() + if not interaction: + return + interaction.status = "new" + interaction.assigned_to = None + interaction.updated_at = now + thread.status = "new" + thread.claimed_by_user = None + thread.claimed_at = None + thread.ai_handoff_reason = None + if thread.ai_state != "human_owned": + thread.ai_state = "queued" if requeue_ai else None + thread.updated_at = now + _push_timeline( + session, + interaction.interaction_id, + "interaction.status_changed", + {"status": "new", "source": "telegram-reactivation"}, + ) + _push_timeline( + session, + interaction.interaction_id, + "telegram.thread_reactivated", + {"thread_id": thread.thread_id, "chat_id": thread.chat_id}, + ) + + +def _create_interaction(session, *, subject: str, customer_id: str, queue_id: str, created_at: str) -> str: + interaction_id = new_id("int") + session.add( + Interaction( + interaction_id=interaction_id, + channel="telegram", + subject=_preview_text(subject, 120), + customer_id=customer_id, + queue_id=queue_id, + priority=3, + status="new", + assigned_to=None, + created_at=created_at, + updated_at=created_at, + ) + ) + _push_timeline(session, interaction_id, "interaction.created", {"channel": "telegram"}) + return interaction_id + + +def _persist_message( + session, + *, + thread: TelegramThreadRow, + text: str, + payload: dict, + direction: str, + created_at: str, + customer_external_id: str | None = None, + telegram_message_id_external: str | None = None, + operator_user: str | None = None, + author_type: str | None = None, + author_id: str | None = None, + delivery_status: str | None = None, +) -> TelegramMessageRow: + resolved_author_type = author_type or ("human" if direction == "outbound" else ("system" if direction == "system" else "customer")) + row = TelegramMessageRow( + message_id=new_id("tgm"), + thread_id=thread.thread_id, + interaction_id=thread.interaction_id, + chat_id=thread.chat_id, + text=text, + customer_external_id=customer_external_id, + direction=direction, + telegram_message_id_external=telegram_message_id_external, + operator_user=operator_user, + author_type=resolved_author_type, + author_id=author_id, + delivery_status=delivery_status, + payload_json=json.dumps(payload, ensure_ascii=False), + created_at=created_at, + ) + session.add(row) + thread.last_message_at = created_at + thread.last_message_preview = _preview_text(text, 140) + thread.updated_at = created_at + return row + + +def _mark_thread_ai_state( + thread: TelegramThreadRow, + *, + ai_state: str | None, + ai_handoff_reason: str | None = None, + ai_last_model_at: str | None = None, +) -> None: + thread.ai_state = ai_state + thread.ai_handoff_reason = ai_handoff_reason + if ai_last_model_at is not None: + thread.ai_last_model_at = ai_last_model_at + + +def _update_ai_session_state( + session, + *, + session_id: str | None, + status: str, + updated_at: str, + handoff_reason: object = _HANDOFF_REASON_UNSET, + closed: bool = False, +) -> None: + if not session_id: + return + ai_session = session.execute( + select(AISessionRow).where(AISessionRow.session_id == session_id) + ).scalar_one_or_none() + if not ai_session: + return + ai_session.status = status + ai_session.updated_at = updated_at + if handoff_reason is not _HANDOFF_REASON_UNSET: + ai_session.handoff_reason = handoff_reason + if closed: + ai_session.closed_at = updated_at + + +def _maybe_enqueue_ai_for_thread( + thread: TelegramThreadRow, + trigger_message_id: str | None, + *, + author_type: str | None = None, +) -> None: + if not _ai_telegram_enabled(): + return + if thread.ai_state == "human_owned": + return + if author_type and author_type != "customer": + return + threading.Thread( + target=_ai_enqueue_request, + args=(thread.thread_id, trigger_message_id), + daemon=True, + name=f"telegram-ai-enqueue-{thread.thread_id}", + ).start() + + +def _upsert_thread_for_inbound( + session, + *, + chat_id: str, + telegram_user_id: str | None, + username: str | None, + display_name: str, + text: str, + payload: dict, + customer_external_id: str | None = None, + external_message_id: str | None = None, + phone_number: str | None = None, + queue_id: str | None = None, + direction: str = "inbound", +) -> tuple[TelegramThreadRow, TelegramMessageRow, bool]: + now = utc_now_iso() + author_type = _inbound_author_type(direction) + ai_eligible = _message_can_trigger_ai(author_type) + thread = session.execute( + select(TelegramThreadRow).where(TelegramThreadRow.chat_id == chat_id) + ).scalar_one_or_none() + created = False + assigned_customer_id = _resolve_or_create_customer_id( + session, + telegram_user_id=telegram_user_id, + chat_id=chat_id, + display_name=display_name, + explicit_customer_external_id=customer_external_id, + ) + _ensure_customer_phone(session, customer_id=assigned_customer_id, phone_number=phone_number) + resolved_queue_id = queue_id or (thread.queue_id if thread else None) or _default_queue_id() + + if not thread: + interaction_id = _create_interaction( + session, + subject=text, + customer_id=assigned_customer_id, + queue_id=resolved_queue_id, + created_at=now, + ) + thread = TelegramThreadRow( + thread_id=new_id("tgt"), + chat_id=chat_id, + interaction_id=interaction_id, + telegram_user_id=telegram_user_id, + username=username, + display_name=display_name, + queue_id=resolved_queue_id, + status="new", + claimed_by_user=None, + claimed_at=None, + ai_session_id=None, + ai_state="queued" if ai_eligible else None, + ai_handoff_reason=None, + ai_last_model_at=None, + last_message_at=now, + last_message_preview=_preview_text(text, 140), + created_at=now, + updated_at=now, + ) + session.add(thread) + created = True + else: + if thread.status == "closed": + _reactivate_thread_interaction(session, thread, now, requeue_ai=ai_eligible) + thread.telegram_user_id = telegram_user_id or thread.telegram_user_id + thread.username = username or thread.username + thread.display_name = display_name or thread.display_name + thread.queue_id = thread.queue_id or resolved_queue_id + interaction = session.execute( + select(Interaction).where(Interaction.interaction_id == thread.interaction_id) + ).scalar_one_or_none() + if interaction and interaction.customer_id != assigned_customer_id: + interaction.customer_id = assigned_customer_id + interaction.updated_at = now + if ai_eligible and thread.ai_state != "human_owned": + _mark_thread_ai_state(thread, ai_state="queued", ai_handoff_reason=None) + + message_row = _persist_message( + session, + thread=thread, + text=text, + payload=payload, + direction=direction, + created_at=now, + customer_external_id=assigned_customer_id, + telegram_message_id_external=external_message_id, + author_type=author_type, + author_id=_first_external_subject(telegram_user_id, chat_id, customer_external_id), + delivery_status="received", + ) + _push_timeline( + session, + thread.interaction_id, + "telegram.message_received", + { + "thread_id": thread.thread_id, + "chat_id": chat_id, + "direction": direction, + "username": username, + }, + ) + session.flush() + return thread, message_row, created + + +def _manual_webhook_metadata(payload: TelegramWebhookIn) -> dict[str, Any]: + raw_payload = payload.payload if isinstance(payload.payload, dict) else {} + sender = raw_payload.get("from") if isinstance(raw_payload.get("from"), dict) else {} + username = str(raw_payload.get("username") or sender.get("username") or "").strip() or None + first_name = str(raw_payload.get("first_name") or sender.get("first_name") or "").strip() or None + last_name = str(raw_payload.get("last_name") or sender.get("last_name") or "").strip() or None + telegram_user_id = str(raw_payload.get("telegram_user_id") or sender.get("id") or "").strip() or None + display_name = _display_name( + username=username, + first_name=first_name, + last_name=last_name, + fallback=f"Telegram {payload.chat_id}", + ) + contact = raw_payload.get("contact") if isinstance(raw_payload.get("contact"), dict) else {} + phone_number = _contact_phone_number(contact, sender_user_id=telegram_user_id) or _normalize_phone_number( + raw_payload.get("phone_number") + ) + queue_id = str(raw_payload.get("queue_id") or "").strip() or None + return { + "telegram_user_id": telegram_user_id, + "username": username, + "display_name": display_name, + "phone_number": phone_number, + "queue_id": queue_id, + } + + +def _parse_bot_update(update: dict) -> dict: + message = update.get("message") or update.get("edited_message") + if not isinstance(message, dict): + raise HTTPException(status_code=400, detail="Unsupported Telegram update") + + chat = message.get("chat") if isinstance(message.get("chat"), dict) else {} + sender = message.get("from") if isinstance(message.get("from"), dict) else {} + chat_id = str(chat.get("id") or "").strip() + if not chat_id: + raise HTTPException(status_code=400, detail="Telegram chat_id is missing") + + external_message_id = str(message.get("message_id") or "").strip() or None + username = str(sender.get("username") or chat.get("username") or "").strip() or None + first_name = str(sender.get("first_name") or "").strip() or None + last_name = str(sender.get("last_name") or "").strip() or None + telegram_user_id = str(sender.get("id") or chat.get("id") or "").strip() or None + contact = message.get("contact") if isinstance(message.get("contact"), dict) else {} + phone_number = _contact_phone_number(contact, sender_user_id=telegram_user_id) + text = str(message.get("text") or message.get("caption") or "").strip() + direction = "inbound" + if not text: + if phone_number: + text = "[Shared Telegram contact]" + direction = "system" + else: + content_kind = "message" + for candidate in ("photo", "document", "audio", "video", "voice", "sticker", "location", "contact"): + if candidate in message: + content_kind = candidate + break + text = f"[Unsupported Telegram content: {content_kind}]" + direction = "system" + + display_name = _display_name( + username=username, + first_name=first_name, + last_name=last_name, + fallback=str(chat.get("title") or f"Telegram {chat_id}"), + ) + return { + "chat_id": chat_id, + "telegram_user_id": telegram_user_id, + "username": username, + "display_name": display_name, + "text": text, + "payload": update, + "external_message_id": external_message_id, + "phone_number": phone_number, + "direction": direction, + } + + +def _send_telegram_message(chat_id: str, text: str) -> dict: + if not _bot_enabled() or not _bot_token(): + raise HTTPException(status_code=503, detail="Telegram bot sending is not configured") + with httpx.Client(timeout=10.0) as client: + response = client.post( + f"{_bot_api_base()}/bot{_bot_token()}/sendMessage", + json={"chat_id": chat_id, "text": text}, + ) + if response.status_code >= 400: + raise HTTPException(status_code=502, detail=f"Telegram sendMessage failed: {response.text}") + payload = response.json() + if not payload.get("ok"): + raise HTTPException(status_code=502, detail=f"Telegram sendMessage failed: {payload}") + return payload + + +def _telegram_reply_delivery_filter(now: str): + return or_( + TelegramMessageRow.delivery_status == "pending", + TelegramMessageRow.delivery_status == "retrying", + and_( + TelegramMessageRow.delivery_status == "sending", + or_( + TelegramMessageRow.delivery_locked_until.is_(None), + TelegramMessageRow.delivery_locked_until < now, + ), + ), + ) + + +def _telegram_reply_delivery_due_filter(now: str): + return or_( + TelegramMessageRow.next_delivery_attempt_at.is_(None), + TelegramMessageRow.next_delivery_attempt_at <= now, + ) + + +def _delivery_error_text(error: Exception) -> str: + if isinstance(error, HTTPException): + detail = error.detail + if isinstance(detail, str): + return detail[:1000] + return str(detail)[:1000] + return str(error or error.__class__.__name__)[:1000] + + +def _telegram_reply_backoff_seconds(next_attempt: int) -> int: + base = _reply_delivery_retry_base_seconds() + cap = _reply_delivery_retry_cap_seconds() + return min(cap, base * (2 ** max(next_attempt - 1, 0))) + + +def _claim_telegram_reply_delivery(session, message_id: str) -> TelegramMessageRow | None: + now = utc_now_iso() + leased_until = _utc_after_seconds(_reply_delivery_lease_seconds()) + claim = session.execute( + update(TelegramMessageRow) + .where( + TelegramMessageRow.message_id == message_id, + TelegramMessageRow.direction == "outbound", + TelegramMessageRow.thread_id.is_not(None), + TelegramMessageRow.interaction_id.is_not(None), + _telegram_reply_delivery_filter(now), + _telegram_reply_delivery_due_filter(now), + ) + .values( + delivery_status="sending", + delivery_locked_until=leased_until, + ) + ) + session.commit() + if claim.rowcount != 1: + return None + return session.execute( + select(TelegramMessageRow).where(TelegramMessageRow.message_id == message_id) + ).scalar_one_or_none() + + +def _mark_telegram_reply_delivery_failure( + session, + row: TelegramMessageRow, + error_text: str, + *, + allow_retry: bool = True, +) -> None: + attempts = int(row.delivery_attempts or 0) + 1 + row.delivery_attempts = attempts + row.delivery_locked_until = None + row.last_delivery_error = error_text + should_retry = allow_retry and attempts < _reply_delivery_max_attempts() + if should_retry: + row.delivery_status = "retrying" + row.next_delivery_attempt_at = _utc_after_seconds(_telegram_reply_backoff_seconds(attempts)) + else: + row.delivery_status = "failed" + row.next_delivery_attempt_at = None + if row.interaction_id and row.thread_id: + _push_timeline( + session, + row.interaction_id, + "telegram.message_failed", + { + "thread_id": row.thread_id, + "operator_user": row.operator_user, + "attempts": attempts, + "error": error_text, + }, + ) + session.commit() + + +def _mark_telegram_reply_delivery_success(session, row: TelegramMessageRow, telegram_payload: dict) -> None: + row.delivery_attempts = int(row.delivery_attempts or 0) + 1 + row.delivery_status = "sent" + row.delivery_locked_until = None + row.next_delivery_attempt_at = None + row.last_delivery_error = None + result_message = telegram_payload.get("result") if isinstance(telegram_payload, dict) else {} + if isinstance(result_message, dict) and result_message.get("message_id") is not None: + row.telegram_message_id_external = str(result_message.get("message_id")) + _push_timeline( + session, + row.interaction_id, + "telegram.message_sent", + {"thread_id": row.thread_id, "operator_user": row.operator_user}, + ) + session.commit() + + +def _deliver_pending_telegram_reply(message_id: str) -> bool: + session = get_session() + try: + row = _claim_telegram_reply_delivery(session, message_id) + if not row or not row.thread_id or not row.interaction_id: + return False + + thread = session.execute( + select(TelegramThreadRow).where(TelegramThreadRow.thread_id == row.thread_id) + ).scalar_one_or_none() + if not thread: + _mark_telegram_reply_delivery_failure( + session, + row, + "Telegram thread not found during reply delivery", + allow_retry=False, + ) + return False + + try: + telegram_payload = _send_telegram_message(row.chat_id, row.text) + except Exception as exc: # noqa: BLE001 + _mark_telegram_reply_delivery_failure(session, row, _delivery_error_text(exc)) + return False + + _mark_telegram_reply_delivery_success(session, row, telegram_payload) + return True + finally: + session.close() + + +def _claim_next_due_telegram_reply_message_id(session) -> str | None: + now = utc_now_iso() + return session.execute( + select(TelegramMessageRow.message_id) + .where( + TelegramMessageRow.direction == "outbound", + TelegramMessageRow.thread_id.is_not(None), + TelegramMessageRow.interaction_id.is_not(None), + _telegram_reply_delivery_filter(now), + _telegram_reply_delivery_due_filter(now), + ) + .order_by( + case( + (TelegramMessageRow.delivery_status == "sending", 0), + (TelegramMessageRow.delivery_status == "retrying", 1), + else_=2, + ), + TelegramMessageRow.created_at.asc(), + TelegramMessageRow.id.asc(), + ) + .limit(1) + ).scalar_one_or_none() + + +def _process_due_telegram_reply_batch(batch_size: int = 20) -> int: + processed = 0 + attempts = 0 + while processed < batch_size and attempts < batch_size * 2: + attempts += 1 + session = get_session() + try: + message_id = _claim_next_due_telegram_reply_message_id(session) + finally: + session.close() + if not message_id: + break + if _deliver_pending_telegram_reply(message_id): + processed += 1 + return processed + + +def _reply_delivery_worker_loop(stop_event: threading.Event, wakeup_event: threading.Event) -> None: + while not stop_event.is_set(): + wakeup_event.wait(timeout=_reply_delivery_poll_seconds()) + wakeup_event.clear() + if stop_event.is_set(): + break + try: + _process_due_telegram_reply_batch() + except Exception: # noqa: BLE001 + logger.exception("Telegram reply delivery worker cycle failed") + + +def _wake_telegram_reply_delivery_worker() -> None: + with _reply_delivery_worker_lock: + wakeup_event = _reply_delivery_worker_wakeup + if wakeup_event: + wakeup_event.set() + + +def _start_reply_delivery_worker() -> None: + global _reply_delivery_worker_clients + global _reply_delivery_worker_stop + global _reply_delivery_worker_thread + global _reply_delivery_worker_wakeup + + if not _reply_delivery_worker_enabled(): + return + + with _reply_delivery_worker_lock: + _reply_delivery_worker_clients += 1 + if _reply_delivery_worker_thread and _reply_delivery_worker_thread.is_alive(): + wakeup_event = _reply_delivery_worker_wakeup + else: + stop_event = threading.Event() + wakeup_event = threading.Event() + worker = threading.Thread( + target=_reply_delivery_worker_loop, + args=(stop_event, wakeup_event), + name="telegram-reply-delivery-worker", + daemon=True, + ) + _reply_delivery_worker_stop = stop_event + _reply_delivery_worker_wakeup = wakeup_event + _reply_delivery_worker_thread = worker + worker.start() + if wakeup_event: + wakeup_event.set() + + +def _stop_reply_delivery_worker() -> None: + global _reply_delivery_worker_clients + global _reply_delivery_worker_stop + global _reply_delivery_worker_thread + global _reply_delivery_worker_wakeup + + thread: threading.Thread | None = None + stop_event: threading.Event | None = None + wakeup_event: threading.Event | None = None + + with _reply_delivery_worker_lock: + if _reply_delivery_worker_clients > 0: + _reply_delivery_worker_clients -= 1 + if _reply_delivery_worker_clients > 0: + return + thread = _reply_delivery_worker_thread + stop_event = _reply_delivery_worker_stop + wakeup_event = _reply_delivery_worker_wakeup + _reply_delivery_worker_thread = None + _reply_delivery_worker_stop = None + _reply_delivery_worker_wakeup = None + + if stop_event: + stop_event.set() + if wakeup_event: + wakeup_event.set() + if thread and thread.is_alive(): + thread.join(timeout=2) + + +def _start_telegram_reply_delivery(message_id: str) -> None: + if _reply_delivery_worker_enabled(): + _wake_telegram_reply_delivery_worker() + return + threading.Thread( + target=_deliver_pending_telegram_reply, + args=(message_id,), + daemon=True, + ).start() + + +@app.on_event("startup") +def _start_delivery_worker_on_startup() -> None: + _start_reply_delivery_worker() + + +@app.on_event("shutdown") +def _stop_delivery_worker_on_shutdown() -> None: + _stop_reply_delivery_worker() + + +def _ensure_operator_can_manage( + actor: dict, + thread: TelegramThreadRow, + *, + for_reply: bool = False, + require_claim: bool = False, +) -> None: + if actor["role"] in {Role.ADMIN.value, Role.SUPERVISOR.value}: + return + if actor["role"] != Role.OPERATOR.value: + raise HTTPException(status_code=403, detail="Insufficient role") + if for_reply and thread.claimed_by_user != actor["user"]: + raise HTTPException(status_code=403, detail="Thread must be claimed by the current operator") + if require_claim and thread.claimed_by_user != actor["user"]: + raise HTTPException(status_code=403, detail="Thread must be claimed by the current operator") + if not for_reply and thread.claimed_by_user and thread.claimed_by_user != actor["user"]: + raise HTTPException(status_code=403, detail="Thread is already claimed by another operator") + + +def _raise_claim_conflict(thread: TelegramThreadRow, actor_user: str) -> None: + if thread.status == "closed": + raise HTTPException(status_code=409, detail="Telegram thread is closed") + if thread.claimed_by_user and thread.claimed_by_user != actor_user: + raise HTTPException(status_code=409, detail="Telegram thread is already claimed by another operator") + raise HTTPException(status_code=409, detail="Telegram thread claim conflict") + + +def _ensure_thread_available_for_ai(thread: TelegramThreadRow) -> None: + if thread.status == "closed": + raise HTTPException(status_code=409, detail="Telegram thread is closed") + if thread.ai_state == "human_owned" or thread.claimed_by_user: + raise HTTPException(status_code=409, detail="Telegram thread is owned by a human operator") + + +def _claim_thread_locally( + session, + *, + thread_id: str, + actor_user: str, + claimed_at: str, +) -> tuple[TelegramThreadRow, str, bool]: + thread = _get_thread(session, thread_id) + if thread.claimed_by_user == actor_user and thread.status == "in_progress": + return thread, thread.status, False + + previous_status = thread.status + result = session.execute( + update(TelegramThreadRow) + .where( + TelegramThreadRow.thread_id == thread_id, + TelegramThreadRow.status != "closed", + or_( + TelegramThreadRow.claimed_by_user.is_(None), + TelegramThreadRow.claimed_by_user == actor_user, + ), + ) + .values( + claimed_by_user=actor_user, + claimed_at=claimed_at, + updated_at=claimed_at, + ) + ) + if not result.rowcount: + session.rollback() + _raise_claim_conflict(_get_thread(session, thread_id), actor_user) + + session.commit() + return _get_thread(session, thread_id), previous_status, True + + +def _rollback_thread_claim( + session, + *, + thread_id: str, + actor_user: str, + claimed_at: str, + previous_status: str, +) -> None: + try: + session.execute( + update(TelegramThreadRow) + .where( + TelegramThreadRow.thread_id == thread_id, + TelegramThreadRow.claimed_by_user == actor_user, + TelegramThreadRow.claimed_at == claimed_at, + ) + .values( + claimed_by_user=None, + claimed_at=None, + status=previous_status, + updated_at=utc_now_iso(), + ) + ) + session.commit() + except Exception: + session.rollback() + + +@app.get("/health", response_model=HealthResponse) +def health() -> HealthResponse: + return HealthResponse(status="ok", service="telegram-adapter-service", version="v2") + + +@app.post("/integrations/telegram/webhook", response_model=TelegramWebhookOut) +def webhook(payload: TelegramWebhookIn) -> TelegramWebhookOut: + session = get_session() + try: + metadata = _manual_webhook_metadata(payload) + thread, row, _ = _upsert_thread_for_inbound( + session, + chat_id=payload.chat_id, + telegram_user_id=metadata["telegram_user_id"], + username=metadata["username"], + display_name=metadata["display_name"], + text=payload.text, + payload=payload.payload, + customer_external_id=payload.customer_external_id, + phone_number=metadata["phone_number"], + queue_id=metadata["queue_id"], + ) + session.commit() + session.refresh(row) + session.refresh(thread) + _maybe_enqueue_ai_for_thread(thread, row.message_id, author_type=row.author_type) + return _to_webhook_out(row) + finally: + session.close() + + +@app.post("/integrations/telegram/bot/webhook") +def bot_webhook( + payload: dict, + x_telegram_bot_api_secret_token: str | None = Header( + default=None, + alias="X-Telegram-Bot-Api-Secret-Token", + ), +) -> dict: + expected_secret = _webhook_secret() + if expected_secret and x_telegram_bot_api_secret_token != expected_secret: + raise HTTPException(status_code=403, detail="Invalid Telegram webhook secret") + session = get_session() + try: + parsed = _parse_bot_update(payload) + existing = _existing_bot_message( + session, + chat_id=parsed["chat_id"], + external_message_id=parsed["external_message_id"], + ) + if existing: + return _to_bot_webhook_result(existing, thread_created=False) + thread, row, created = _upsert_thread_for_inbound( + session, + chat_id=parsed["chat_id"], + telegram_user_id=parsed["telegram_user_id"], + username=parsed["username"], + display_name=parsed["display_name"], + text=parsed["text"], + payload=parsed["payload"], + external_message_id=parsed["external_message_id"], + phone_number=parsed["phone_number"], + direction=parsed["direction"], + ) + try: + session.commit() + except IntegrityError: + session.rollback() + existing = _existing_bot_message( + session, + chat_id=parsed["chat_id"], + external_message_id=parsed["external_message_id"], + ) + if existing: + return _to_bot_webhook_result(existing, thread_created=False) + raise + _maybe_enqueue_ai_for_thread(thread, row.message_id, author_type=row.author_type) + return _to_bot_webhook_result(row, thread_created=created) + finally: + session.close() + + +@app.get( + "/integrations/telegram/messages", + response_model=list[TelegramWebhookOut], +) +def list_messages( + limit: int = 100, + chat_id: str | None = None, + actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)), +) -> list[TelegramWebhookOut]: + session = get_session() + try: + stmt = select(TelegramMessageRow).order_by(TelegramMessageRow.id.asc()) + if chat_id: + stmt = stmt.where(TelegramMessageRow.chat_id == chat_id) + rows = session.execute(stmt).scalars().all() + rows = rows[-limit:] + return [_to_webhook_out(r) for r in rows] + finally: + session.close() + + +@app.get( + "/integrations/telegram/threads", + response_model=list[TelegramThreadOut], +) +def list_threads( + limit: int = 100, + status: InteractionStatus | None = None, + actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)), +) -> list[TelegramThreadOut]: + session = get_session() + try: + stmt = select(TelegramThreadRow).order_by(TelegramThreadRow.last_message_at.desc()) + if status: + stmt = stmt.where(TelegramThreadRow.status == status) + rows = session.execute(stmt).scalars().all() + return [_to_thread_out(row) for row in rows[:limit]] + finally: + session.close() + + +@app.get( + "/integrations/telegram/threads/{thread_id}", + response_model=TelegramThreadOut, +) +def get_thread( + thread_id: str, + actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)), +) -> TelegramThreadOut: + session = get_session() + try: + return _to_thread_out(_get_thread(session, thread_id)) + finally: + session.close() + + +@app.get( + "/integrations/telegram/threads/{thread_id}/ai-summary", + response_model=TelegramThreadAISummaryOut | None, +) +def get_thread_ai_summary( + thread_id: str, + actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)), +) -> TelegramThreadAISummaryOut | None: + session = get_session() + try: + thread = _get_thread(session, thread_id) + if actor["role"] == Role.OPERATOR.value and thread.claimed_by_user and thread.claimed_by_user != actor["user"]: + raise HTTPException(status_code=403, detail="Thread is already claimed by another operator") + return _build_ai_summary(session, thread) + finally: + session.close() + + +@app.get( + "/integrations/telegram/threads/{thread_id}/messages", + response_model=list[TelegramThreadMessageOut], +) +def get_thread_messages( + thread_id: str, + limit: int = 200, + actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)), +) -> list[TelegramThreadMessageOut]: + session = get_session() + try: + thread = _get_thread(session, thread_id) + rows = session.execute( + select(TelegramMessageRow) + .where(TelegramMessageRow.thread_id == thread.thread_id) + .order_by(TelegramMessageRow.id.asc()) + ).scalars().all() + return [_to_message_out(row) for row in rows[-limit:]] + finally: + session.close() + + +@app.post( + "/integrations/telegram/threads/{thread_id}/claim", + response_model=TelegramThreadOut, +) +def claim_thread( + thread_id: str, + actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)), +) -> TelegramThreadOut: + session = get_session() + try: + claimed_at = utc_now_iso() + thread, previous_status, local_claimed = _claim_thread_locally( + session, + thread_id=thread_id, + actor_user=actor["user"], + claimed_at=claimed_at, + ) + if not local_claimed: + return _to_thread_out(thread) + try: + result = _interaction_request( + "PATCH", + f"/interactions/{thread.interaction_id}/assign", + payload={"assignee": actor["user"]}, + ) + except Exception: + _rollback_thread_claim( + session, + thread_id=thread_id, + actor_user=actor["user"], + claimed_at=claimed_at, + previous_status=previous_status, + ) + raise + thread = _get_thread(session, thread_id) + thread.status = result.get("status", "in_progress") + thread.updated_at = utc_now_iso() + _mark_thread_ai_state(thread, ai_state="human_owned", ai_handoff_reason=None) + _update_ai_session_state( + session, + session_id=thread.ai_session_id, + status="human_owned", + updated_at=thread.updated_at, + ) + _push_timeline( + session, + thread.interaction_id, + "ai.human_takeover", + {"thread_id": thread.thread_id, "actor_user": actor["user"]}, + ) + session.commit() + session.refresh(thread) + return _to_thread_out(thread) + finally: + session.close() + + +@app.post( + "/integrations/telegram/threads/{thread_id}/return-to-ai", + response_model=TelegramThreadOut, +) +def return_thread_to_ai( + thread_id: str, + actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)), +) -> TelegramThreadOut: + if not _ai_telegram_enabled(): + raise HTTPException(status_code=409, detail="Telegram AI is disabled") + + session = get_session() + try: + thread = _get_thread(session, thread_id) + if thread.status == "closed": + raise HTTPException(status_code=409, detail="Telegram thread is closed") + _ensure_operator_can_manage(actor, thread, require_claim=True) + + now = utc_now_iso() + interaction = session.execute( + select(Interaction).where(Interaction.interaction_id == thread.interaction_id) + ).scalar_one_or_none() + if interaction: + interaction.status = "new" + interaction.assigned_to = None + interaction.updated_at = now + _push_timeline( + session, + interaction.interaction_id, + "interaction.status_changed", + {"status": "new", "source": "telegram-return-to-ai"}, + ) + + thread.status = "new" + thread.claimed_by_user = None + thread.claimed_at = None + thread.updated_at = now + _mark_thread_ai_state(thread, ai_state="queued", ai_handoff_reason=None) + _update_ai_session_state( + session, + session_id=thread.ai_session_id, + status="active", + updated_at=now, + handoff_reason=None, + ) + _push_timeline( + session, + thread.interaction_id, + "ai.returned_to_ai", + {"thread_id": thread.thread_id, "actor_user": actor["user"]}, + ) + session.commit() + session.refresh(thread) + return _to_thread_out(thread) + finally: + session.close() + + +@app.post( + "/integrations/telegram/threads/{thread_id}/messages", + response_model=TelegramThreadMessageOut, +) +def reply_thread( + thread_id: str, + payload: TelegramThreadReplyIn, + actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)), +) -> TelegramThreadMessageOut: + session = get_session() + try: + thread = _get_thread(session, thread_id) + if thread.status == "closed": + raise HTTPException(status_code=409, detail="Telegram thread is closed") + _ensure_operator_can_manage(actor, thread, for_reply=True) + now = utc_now_iso() + row = _persist_message( + session, + thread=thread, + text=payload.text, + payload={"operator_user": actor["user"]}, + direction="outbound", + created_at=now, + operator_user=actor["user"], + author_type="human", + author_id=actor["user"], + delivery_status="pending", + ) + row.delivery_attempts = 0 + row.next_delivery_attempt_at = now + row.delivery_locked_until = None + row.last_delivery_error = None + _mark_thread_ai_state(thread, ai_state="human_owned", ai_handoff_reason=None) + _update_ai_session_state( + session, + session_id=thread.ai_session_id, + status="human_owned", + updated_at=now, + ) + session.commit() + session.refresh(row) + _start_telegram_reply_delivery(row.message_id) + return _to_message_out(row) + finally: + session.close() + + +@app.post( + "/integrations/telegram/threads/{thread_id}/ai/reply", + response_model=TelegramThreadMessageOut, +) +def ai_reply_thread( + thread_id: str, + payload: TelegramThreadAIReplyIn, + actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR)), +) -> TelegramThreadMessageOut: + session = get_session() + try: + thread = _get_thread(session, thread_id) + _ensure_thread_available_for_ai(thread) + now = utc_now_iso() + row = _persist_message( + session, + thread=thread, + text=payload.text, + payload={ + "agent_profile": payload.agent_profile, + "model": payload.model, + "trigger_message_id": payload.trigger_message_id, + "language": payload.language, + "confidence": payload.confidence, + "kb_refs": payload.kb_refs, + **(payload.payload or {}), + }, + direction="outbound", + created_at=now, + author_type="ai", + author_id=payload.agent_profile, + delivery_status="pending", + ) + row.delivery_attempts = 0 + row.next_delivery_attempt_at = now + row.delivery_locked_until = None + row.last_delivery_error = None + _mark_thread_ai_state(thread, ai_state="active", ai_handoff_reason=None, ai_last_model_at=now) + _update_ai_session_state( + session, + session_id=thread.ai_session_id, + status="active", + updated_at=now, + handoff_reason=None, + ) + if thread.ai_session_id: + ai_session = session.execute( + select(AISessionRow).where(AISessionRow.session_id == thread.ai_session_id) + ).scalar_one_or_none() + if ai_session: + ai_session.last_ai_message_id = row.message_id + if payload.language: + ai_session.language = payload.language + _push_timeline( + session, + thread.interaction_id, + "ai.reply_generated", + { + "thread_id": thread.thread_id, + "message_id": row.message_id, + "agent_profile": payload.agent_profile, + "model": payload.model, + "confidence": payload.confidence, + "kb_refs": payload.kb_refs, + "actor_user": actor["user"], + }, + ) + session.commit() + session.refresh(row) + _start_telegram_reply_delivery(row.message_id) + return _to_message_out(row) + finally: + session.close() + + +@app.post( + "/integrations/telegram/threads/{thread_id}/ai/handoff", + response_model=TelegramThreadOut, +) +def ai_handoff_thread( + thread_id: str, + payload: TelegramThreadAIHandoffIn, + actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR)), +) -> TelegramThreadOut: + session = get_session() + try: + thread = _get_thread(session, thread_id) + _ensure_thread_available_for_ai(thread) + now = utc_now_iso() + _mark_thread_ai_state( + thread, + ai_state="handoff_required", + ai_handoff_reason=payload.reason, + ai_last_model_at=now, + ) + thread.updated_at = now + _update_ai_session_state( + session, + session_id=thread.ai_session_id, + status="handoff_required", + updated_at=now, + handoff_reason=payload.reason, + ) + _push_timeline( + session, + thread.interaction_id, + "ai.handoff_requested", + { + "thread_id": thread.thread_id, + "reason": payload.reason, + "agent_profile": payload.agent_profile, + "trigger_message_id": payload.trigger_message_id, + "confidence": payload.confidence, + "actor_user": actor["user"], + }, + ) + session.commit() + session.refresh(thread) + return _to_thread_out(thread) + finally: + session.close() + + +@app.post( + "/integrations/telegram/threads/{thread_id}/close", + response_model=TelegramThreadOut, +) +def close_thread( + thread_id: str, + actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)), +) -> TelegramThreadOut: + session = get_session() + try: + thread = _get_thread(session, thread_id) + _ensure_operator_can_manage(actor, thread, require_claim=True) + _interaction_request( + "PATCH", + f"/interactions/{thread.interaction_id}/status", + payload={"status": "closed"}, + ) + thread.status = "closed" + thread.claimed_by_user = None + thread.claimed_at = None + thread.updated_at = utc_now_iso() + _mark_thread_ai_state(thread, ai_state="closed", ai_handoff_reason=None, ai_last_model_at=thread.ai_last_model_at) + _update_ai_session_state( + session, + session_id=thread.ai_session_id, + status="closed", + updated_at=thread.updated_at, + closed=True, + ) + session.commit() + session.refresh(thread) + return _to_thread_out(thread) + finally: + session.close() + + +@app.post( + "/integrations/telegram/threads/{thread_id}/escalate", + response_model=TelegramThreadOut, +) +def escalate_thread( + thread_id: str, + payload: TelegramThreadEscalateIn, + actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)), +) -> TelegramThreadOut: + session = get_session() + try: + thread = _get_thread(session, thread_id) + _ensure_operator_can_manage(actor, thread, require_claim=True) + result = _interaction_request( + "POST", + f"/interactions/{thread.interaction_id}/escalate", + payload=EscalateRequest(target_queue_id=payload.target_queue_id).model_dump(), + ) + thread.status = result.get("status", "escalated") + thread.queue_id = result.get("queue_id", payload.target_queue_id) + thread.updated_at = utc_now_iso() + _mark_thread_ai_state(thread, ai_state="human_owned", ai_handoff_reason=None) + _update_ai_session_state( + session, + session_id=thread.ai_session_id, + status="human_owned", + updated_at=thread.updated_at, + ) + session.commit() + session.refresh(thread) + return _to_thread_out(thread) + finally: + session.close() diff --git a/services/voice_adapter_service/__init__.py b/services/voice_adapter_service/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/voice_adapter_service/app.py b/services/voice_adapter_service/app.py new file mode 100644 index 0000000..08de4f4 --- /dev/null +++ b/services/voice_adapter_service/app.py @@ -0,0 +1,127 @@ +from __future__ import annotations + +import json +import os + +from fastapi import Depends, FastAPI, HTTPException +from sqlalchemy import select +from sqlalchemy.exc import IntegrityError + +from services.shared.core import Role, new_id, utc_now_iso +from services.shared.db import get_session +from services.shared.models import HealthResponse, VoiceEventIn, VoiceEventOut +from services.shared.security import get_actor +from services.shared.sql_init import init_sql_schema +from services.shared.sql_models import VoiceEventRow + +app = FastAPI(title="voice-adapter-service", version="1.0.0") + +init_sql_schema() + + +def _trusted_service_subjects() -> set[str]: + raw = os.getenv("VOICE_ADAPTER_TRUSTED_SERVICE_SUBJECTS", "svc:asterisk-bridge,svc:ivr-service") + return {item.strip() for item in raw.split(",") if item.strip()} + + +def _is_trusted_service_actor(actor: dict) -> bool: + return ( + actor.get("auth_source") == "service" + and str(actor.get("sub") or "").strip() in _trusted_service_subjects() + ) + + +def _require_voice_event_ingest(actor: dict = Depends(get_actor)) -> dict: + if actor.get("auth_source") == "service": + if _is_trusted_service_actor(actor): + return actor + raise HTTPException(status_code=403, detail="Untrusted service subject") + + if actor.get("role") in {Role.ADMIN.value, Role.SUPERVISOR.value, Role.OPERATOR.value}: + return actor + raise HTTPException(status_code=403, detail="Insufficient role") + + +def _require_voice_event_read(actor: dict = Depends(get_actor)) -> dict: + if actor.get("auth_source") == "service": + if _is_trusted_service_actor(actor): + return actor + raise HTTPException(status_code=403, detail="Untrusted service subject") + + if actor.get("role") in {Role.ADMIN.value, Role.SUPERVISOR.value, Role.ANALYST.value}: + return actor + raise HTTPException(status_code=403, detail="Insufficient role") + + +def _to_out(row: VoiceEventRow) -> VoiceEventOut: + return VoiceEventOut( + event_id=row.event_id, + event_type=row.event_type, + call_id=row.call_id, + interaction_id=row.interaction_id, + source_event_id=row.source_event_id, + payload=json.loads(row.payload_json or "{}"), + created_at=row.created_at, + ) + + +def _load_existing_by_source_event(session, source_event_id: str | None) -> VoiceEventRow | None: + if not source_event_id: + return None + return session.execute( + select(VoiceEventRow).where(VoiceEventRow.source_event_id == source_event_id) + ).scalar_one_or_none() + + +@app.get("/health", response_model=HealthResponse) +def health() -> HealthResponse: + return HealthResponse(status="ok", service="voice-adapter-service") + + +@app.post("/integrations/voice/events", response_model=VoiceEventOut) +def ingest_voice_event( + payload: VoiceEventIn, + _: dict = Depends(_require_voice_event_ingest), +) -> VoiceEventOut: + session = get_session() + try: + existing = _load_existing_by_source_event(session, payload.source_event_id) + if existing is not None: + return _to_out(existing) + + row = VoiceEventRow( + event_id=new_id("vev"), + event_type=payload.event_type, + call_id=payload.call_id, + interaction_id=payload.interaction_id, + source_event_id=payload.source_event_id, + payload_json=json.dumps(payload.payload, ensure_ascii=False), + created_at=utc_now_iso(), + ) + session.add(row) + try: + session.commit() + except IntegrityError: + session.rollback() + existing = _load_existing_by_source_event(session, payload.source_event_id) + if existing is not None: + return _to_out(existing) + raise + session.refresh(row) + return _to_out(row) + finally: + session.close() + + +@app.get("/integrations/voice/events", response_model=list[VoiceEventOut]) +def list_voice_events( + limit: int = 100, + _: dict = Depends(_require_voice_event_read), +) -> list[VoiceEventOut]: + session = get_session() + try: + rows = session.execute(select(VoiceEventRow).order_by(VoiceEventRow.id.asc())).scalars().all() + rows = rows[-limit:] + return [_to_out(r) for r in rows] + finally: + session.close() diff --git a/services/webchat_adapter_service/__init__.py b/services/webchat_adapter_service/__init__.py new file mode 100644 index 0000000..7458ab4 --- /dev/null +++ b/services/webchat_adapter_service/__init__.py @@ -0,0 +1 @@ +# Wave 2 Track 2 webchat adapter package. diff --git a/services/webchat_adapter_service/app.py b/services/webchat_adapter_service/app.py new file mode 100644 index 0000000..bc500b4 --- /dev/null +++ b/services/webchat_adapter_service/app.py @@ -0,0 +1,121 @@ +from __future__ import annotations + +import json + +from fastapi import FastAPI +from sqlalchemy import select + +from services.shared.core import new_id, utc_now_iso +from services.shared.db import get_session +from services.shared.models import HealthResponse, WebchatMessageIn, WebchatMessageOut +from services.shared.sql_init import init_sql_schema +from services.shared.sql_models import Interaction, InteractionTimeline, WebchatMessageRow + +app = FastAPI(title="webchat-adapter-service", version="2.0.0") + +init_sql_schema() + + +def _to_out(row: WebchatMessageRow) -> WebchatMessageOut: + return WebchatMessageOut( + message_id=row.message_id, + session_id=row.session_id, + text=row.text, + visitor_name=row.visitor_name, + customer_external_id=row.customer_external_id, + queue_id=row.queue_id, + priority=row.priority, + payload=json.loads(row.payload_json or "{}"), + interaction_id=row.interaction_id, + created_at=row.created_at, + ) + + +def _interaction_subject(payload: WebchatMessageIn) -> str: + subject = str(payload.payload.get("subject", "")).strip() + if subject: + return subject[:120] + text = payload.text.strip() + if len(text) <= 120: + return text + return f"{text[:117]}..." + + +def _create_interaction(session, payload: WebchatMessageIn, created_at: str) -> str: + interaction_id = new_id("int") + session.add( + Interaction( + interaction_id=interaction_id, + channel="webchat", + subject=_interaction_subject(payload), + customer_id=payload.customer_external_id, + queue_id=payload.queue_id or "q_webchat", + priority=payload.priority, + status="new", + assigned_to=None, + created_at=created_at, + updated_at=created_at, + ) + ) + session.add( + InteractionTimeline( + interaction_id=interaction_id, + timestamp=created_at, + action="interaction.created", + metadata_json=json.dumps({"channel": "webchat"}, ensure_ascii=False), + ) + ) + session.add( + InteractionTimeline( + interaction_id=interaction_id, + timestamp=created_at, + action="webchat.message_received", + metadata_json=json.dumps({"session_id": payload.session_id}, ensure_ascii=False), + ) + ) + return interaction_id + + +@app.get("/health", response_model=HealthResponse) +def health() -> HealthResponse: + return HealthResponse(status="ok", service="webchat-adapter-service", version="v2") + + +@app.post("/integrations/webchat/messages", response_model=WebchatMessageOut) +def create_message(payload: WebchatMessageIn) -> WebchatMessageOut: + session = get_session() + try: + created_at = utc_now_iso() + interaction_id = _create_interaction(session, payload, created_at) + row = WebchatMessageRow( + message_id=new_id("wcm"), + session_id=payload.session_id, + text=payload.text, + visitor_name=payload.visitor_name, + customer_external_id=payload.customer_external_id, + interaction_id=interaction_id, + queue_id=payload.queue_id or "q_webchat", + priority=payload.priority, + payload_json=json.dumps(payload.payload, ensure_ascii=False), + created_at=created_at, + ) + session.add(row) + session.commit() + session.refresh(row) + return _to_out(row) + finally: + session.close() + + +@app.get("/integrations/webchat/messages", response_model=list[WebchatMessageOut]) +def list_messages(session_id: str | None = None, limit: int = 100) -> list[WebchatMessageOut]: + session = get_session() + try: + stmt = select(WebchatMessageRow).order_by(WebchatMessageRow.id.asc()) + if session_id: + stmt = stmt.where(WebchatMessageRow.session_id == session_id) + rows = session.execute(stmt).scalars().all() + rows = rows[-limit:] + return [_to_out(row) for row in rows] + finally: + session.close() diff --git a/services/whatsapp_adapter_service/__init__.py b/services/whatsapp_adapter_service/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/whatsapp_adapter_service/app.py b/services/whatsapp_adapter_service/app.py new file mode 100644 index 0000000..458509c --- /dev/null +++ b/services/whatsapp_adapter_service/app.py @@ -0,0 +1,2328 @@ +from __future__ import annotations + +import hashlib +import hmac +import json +import logging +import os +import threading +from datetime import datetime, timedelta, timezone +from typing import Any + +import httpx +from fastapi import Depends, FastAPI, Header, HTTPException, Query, Request +from fastapi.responses import PlainTextResponse +from sqlalchemy import and_, case, func, inspect, or_, select, text, update +from sqlalchemy.exc import IntegrityError + +from services.shared.core import Role, new_id, utc_now_iso +from services.shared.db import engine, get_session +from services.shared.models import ( + AIWhatsAppEnqueueIn, + EscalateRequest, + HealthResponse, + InteractionStatus, + WhatsAppThreadAISummaryOut, + WhatsAppThreadAIHandoffIn, + WhatsAppThreadAIReplyIn, + WhatsAppThreadEscalateIn, + WhatsAppThreadMessageOut, + WhatsAppThreadOut, + WhatsAppThreadReplyIn, + WhatsAppWebhookIn, + WhatsAppWebhookOut, +) +from services.shared.security import issue_app_token, require_roles +from services.shared.sql_init import init_sql_schema +from services.shared.sql_models import ( + AISessionRow, + Customer, + CustomerExternalIdentity, + Interaction, + InteractionTimeline, + WhatsAppMessageRow, + WhatsAppThreadRow, +) + +app = FastAPI(title="whatsapp-adapter-service", version="2.0.0") + +init_sql_schema() + +logger = logging.getLogger(__name__) + +WHATSAPP_BOT_DEDUP_INDEX = "ix_whatsapp_messages_chat_external_unique" +WHATSAPP_REPLY_NEXT_ATTEMPT_INDEX = "ix_whatsapp_messages_next_delivery_attempt_at" +WHATSAPP_REPLY_LOCKED_UNTIL_INDEX = "ix_whatsapp_messages_delivery_locked_until" + +_reply_delivery_worker_lock = threading.Lock() +_reply_delivery_worker_thread: threading.Thread | None = None +_reply_delivery_worker_stop: threading.Event | None = None +_reply_delivery_worker_wakeup: threading.Event | None = None +_reply_delivery_worker_clients = 0 +_HANDOFF_REASON_UNSET = object() + + +def _utc_now() -> datetime: + return datetime.now(timezone.utc).replace(microsecond=0) + + +def _utc_after_seconds(seconds: int) -> str: + return (_utc_now() + timedelta(seconds=max(seconds, 0))).isoformat() + + +def _text_column_type() -> str: + return "TEXT" + + +def _string_column_type(length: int = 64) -> str: + return f"VARCHAR({length})" + + +def _integer_env(name: str, default: int) -> int: + raw = os.getenv(name) + if raw is None: + return default + try: + return int(raw.strip()) + except ValueError: + return default + + +def _ensure_whatsapp_message_indexes() -> None: + # Runtime compatibility for WhatsApp lives in shared.sql_init. + # Keep the hook so the adapter startup shape matches Telegram without + # duplicating migration logic in a second place. + return + + +_ensure_whatsapp_message_indexes() + + +def _bool_env(name: str, default: bool) -> bool: + raw = os.getenv(name) + if raw is None: + return default + return raw.strip().lower() in {"1", "true", "yes", "on"} + + +def _outbound_enabled() -> bool: + return _bool_env("WHATSAPP_OUTBOUND_ENABLED", False) + + +def _outbound_auth_token() -> str: + return os.getenv("WHATSAPP_OUTBOUND_AUTH_TOKEN", "").strip() + + +def _webhook_secret() -> str: + return os.getenv("WHATSAPP_WEBHOOK_SECRET", "").strip() + + +def _meta_verify_token() -> str: + return ( + os.getenv("WHATSAPP_META_VERIFY_TOKEN", "").strip() + or os.getenv("WHATSAPP_VERIFY_TOKEN", "").strip() + ) + + +def _meta_app_secret() -> str: + return ( + os.getenv("WHATSAPP_META_APP_SECRET", "").strip() + or os.getenv("WHATSAPP_APP_SECRET", "").strip() + ) + + +def _meta_access_token() -> str: + return os.getenv("WHATSAPP_META_ACCESS_TOKEN", "").strip() + + +def _meta_phone_number_id() -> str: + return os.getenv("WHATSAPP_META_PHONE_NUMBER_ID", "").strip() + + +def _meta_graph_api_version() -> str: + return os.getenv("WHATSAPP_META_GRAPH_API_VERSION", "v22.0").strip() or "v22.0" + + +def _meta_graph_base_url() -> str: + return os.getenv("WHATSAPP_META_GRAPH_BASE_URL", "https://graph.facebook.com").rstrip("/") + + +def _default_queue_id() -> str: + return os.getenv("WHATSAPP_DEFAULT_QUEUE_ID", "q_whatsapp").strip() or "q_whatsapp" + + +def _outbound_send_url() -> str: + return os.getenv("WHATSAPP_OUTBOUND_SEND_URL", "").strip() + + +def _reply_delivery_worker_enabled() -> bool: + return _bool_env("WHATSAPP_REPLY_DELIVERY_WORKER_ENABLED", True) + + +def _reply_delivery_poll_seconds() -> int: + return max(1, _integer_env("WHATSAPP_REPLY_DELIVERY_POLL_SECONDS", 5)) + + +def _reply_delivery_lease_seconds() -> int: + return max(5, _integer_env("WHATSAPP_REPLY_DELIVERY_LEASE_SECONDS", 30)) + + +def _reply_delivery_retry_base_seconds() -> int: + return max(1, _integer_env("WHATSAPP_REPLY_DELIVERY_RETRY_BASE_SECONDS", 5)) + + +def _reply_delivery_retry_cap_seconds() -> int: + return max(_reply_delivery_retry_base_seconds(), _integer_env("WHATSAPP_REPLY_DELIVERY_RETRY_CAP_SECONDS", 300)) + + +def _reply_delivery_max_attempts() -> int: + return max(1, _integer_env("WHATSAPP_REPLY_DELIVERY_MAX_ATTEMPTS", 5)) + + +def _interaction_service_url() -> str: + return os.getenv("INTERACTION_SERVICE_URL", "http://localhost:8004").rstrip("/") + + +def _ai_service_url() -> str: + return os.getenv("AI_ORCHESTRATOR_SERVICE_URL", "http://localhost:8017").rstrip("/") + + +def _ai_whatsapp_enabled() -> bool: + return _bool_env("AI_WHATSAPP_ENABLED", False) + + +def _service_auth_headers() -> dict[str, str]: + token = issue_app_token( + subject="svc:whatsapp-adapter", + username="whatsapp-adapter", + role="admin", + auth_source="service", + provider="whatsapp-adapter", + ttl_seconds=300, + ) + return {"Authorization": f"Bearer {token}"} + + +def _internal_service_headers( + *, + subject: str, + username: str, + provider: str, +) -> dict[str, str]: + token = issue_app_token( + subject=subject, + username=username, + role="admin", + auth_source="service", + provider=provider, + ttl_seconds=300, + ) + return {"Authorization": f"Bearer {token}"} + + +def _interaction_request(method: str, path: str, *, payload: dict | None = None) -> dict: + with httpx.Client(timeout=5.0) as client: + response = client.request( + method, + f"{_interaction_service_url()}{path}", + json=payload, + headers=_service_auth_headers(), + ) + response.raise_for_status() + return response.json() + + +def _ai_enqueue_request(thread_id: str, trigger_message_id: str | None) -> None: + if not _ai_whatsapp_enabled(): + return + try: + with httpx.Client(timeout=10.0) as client: + response = client.post( + f"{_ai_service_url()}/ai/whatsapp/threads/{thread_id}/enqueue", + json=AIWhatsAppEnqueueIn(trigger_message_id=trigger_message_id).model_dump(), + headers=_internal_service_headers( + subject="svc:whatsapp-adapter", + username="whatsapp-adapter", + provider="whatsapp-adapter", + ), + ) + response.raise_for_status() + except Exception: # noqa: BLE001 + logger.exception("WhatsApp AI enqueue failed", extra={"thread_id": thread_id}) + + +def _preview_text(text: str, limit: int = 120) -> str: + value = str(text or "").strip() + if len(value) <= limit: + return value + return f"{value[: limit - 3]}..." + + +def _normalize_external_subject(value: str | None) -> str | None: + raw = str(value or "").strip() + if not raw: + return None + if raw.startswith("whatsapp:"): + return raw.split(":", 1)[1].strip() or None + return raw + + +def _first_external_subject( + whatsapp_user_id: str | None, + chat_id: str, + explicit: str | None = None, +) -> str: + return ( + _normalize_external_subject(whatsapp_user_id) + or _normalize_external_subject(explicit) + or _normalize_external_subject(chat_id) + or chat_id + ) + + +def _customer_external_id(whatsapp_user_id: str | None, chat_id: str, explicit: str | None = None) -> str: + return f"whatsapp:{_first_external_subject(whatsapp_user_id, chat_id, explicit)}" + + +def _customer_id_is_real(customer_id: str | None) -> bool: + return str(customer_id or "").startswith("cus_") + + +def _whatsapp_identity_subjects( + whatsapp_user_id: str | None, + chat_id: str, + *, + phone_number: str | None = None, + external_subject: str | None = None, +) -> list[str]: + ordered = [ + _normalize_external_subject(whatsapp_user_id), + _normalize_external_subject(phone_number), + _normalize_external_subject(external_subject), + _normalize_external_subject(chat_id), + ] + seen: set[str] = set() + result: list[str] = [] + for item in ordered: + if not item or item in seen: + continue + seen.add(item) + result.append(item) + return result + + +def _ensure_customer_external_identity( + session, + *, + customer_id: str, + channel: str, + external_subject: str, + display_name_snapshot: str | None, + now: str, +) -> None: + row = session.execute( + select(CustomerExternalIdentity).where( + CustomerExternalIdentity.channel == channel, + CustomerExternalIdentity.external_subject == external_subject, + ) + ).scalar_one_or_none() + if row: + row.customer_id = customer_id + if display_name_snapshot: + row.display_name_snapshot = display_name_snapshot + row.updated_at = now + return + session.add( + CustomerExternalIdentity( + identity_id=new_id("cei"), + customer_id=customer_id, + channel=channel, + external_subject=external_subject, + display_name_snapshot=display_name_snapshot, + created_at=now, + updated_at=now, + ) + ) + + +def _resolve_or_create_customer_id( + session, + *, + whatsapp_user_id: str | None, + chat_id: str, + display_name: str, + phone_number: str | None = None, + explicit_customer_id: str | None = None, + external_subject: str | None = None, +) -> str: + now = utc_now_iso() + explicit_value = str(explicit_customer_id or "").strip() + subjects = _whatsapp_identity_subjects( + whatsapp_user_id, + chat_id, + phone_number=phone_number, + external_subject=external_subject, + ) + + for subject in subjects: + identity = session.execute( + select(CustomerExternalIdentity).where( + CustomerExternalIdentity.channel == "whatsapp", + CustomerExternalIdentity.external_subject == subject, + ) + ).scalar_one_or_none() + if identity: + if display_name and identity.display_name_snapshot != display_name: + identity.display_name_snapshot = display_name + identity.updated_at = now + return identity.customer_id + + if _customer_id_is_real(explicit_value): + customer = session.execute( + select(Customer).where(Customer.customer_id == explicit_value) + ).scalar_one_or_none() + if not customer: + customer = Customer( + customer_id=explicit_value, + display_name=display_name, + phones_json="[]", + preferred_phone=None, + tags_json=json.dumps(["whatsapp"], ensure_ascii=False), + created_at=now, + ) + session.add(customer) + elif display_name and customer.display_name != display_name: + customer.display_name = display_name + customer_id = customer.customer_id + else: + customer = Customer( + customer_id=new_id("cus"), + display_name=display_name, + phones_json="[]", + preferred_phone=None, + tags_json=json.dumps(["whatsapp"], ensure_ascii=False), + created_at=now, + ) + session.add(customer) + customer_id = customer.customer_id + + for subject in subjects or [_normalize_external_subject(chat_id) or chat_id]: + _ensure_customer_external_identity( + session, + customer_id=customer_id, + channel="whatsapp", + external_subject=subject, + display_name_snapshot=display_name, + now=now, + ) + return customer_id + + +def _display_name( + *, + username: str | None = None, + first_name: str | None = None, + last_name: str | None = None, + fallback: str | None = None, +) -> str: + parts = [str(first_name or "").strip(), str(last_name or "").strip()] + full_name = " ".join(part for part in parts if part).strip() + if full_name: + return full_name + if username: + return username + if fallback: + return fallback + return "WhatsApp user" + + +def _inbound_author_type(direction: str) -> str: + return "system" if direction == "system" else "customer" + + +def _message_can_trigger_ai(author_type: str | None) -> bool: + return _ai_whatsapp_enabled() and author_type == "customer" + + +def _normalize_phone_number(value: str | None) -> str | None: + raw = str(value or "").strip() + if not raw: + return None + digits = "".join(ch for ch in raw if ch.isdigit()) + if not digits: + return None + if raw.startswith("+"): + return f"+{digits}" + return digits + + +def _contact_phone_number(contact: dict | None, *, sender_user_id: str | None) -> str | None: + if not isinstance(contact, dict): + return None + phone_number = _normalize_phone_number(contact.get("phone_number")) + if not phone_number: + return None + contact_user_id = str(contact.get("user_id") or "").strip() or None + if sender_user_id and contact_user_id and contact_user_id != sender_user_id: + return None + return phone_number + + +def _customer_phone_list(raw: str | None) -> list[str]: + try: + value = json.loads(raw or "[]") + except Exception: # noqa: BLE001 + return [] + if not isinstance(value, list): + return [] + result: list[str] = [] + for item in value: + normalized = _normalize_phone_number(item) + if normalized and normalized not in result: + result.append(normalized) + return result + + +def _ensure_customer_phone(session, *, customer_id: str, phone_number: str | None) -> None: + normalized_phone = _normalize_phone_number(phone_number) + if not normalized_phone or not _customer_id_is_real(customer_id): + return + session.flush() + customer = session.execute( + select(Customer).where(Customer.customer_id == customer_id) + ).scalar_one_or_none() + if not customer: + return + phones = _customer_phone_list(customer.phones_json) + if normalized_phone not in phones: + phones.append(normalized_phone) + customer.phones_json = json.dumps(phones, ensure_ascii=False) + if not customer.preferred_phone: + customer.preferred_phone = normalized_phone + + +def _push_timeline(session, interaction_id: str, action: str, metadata: dict | None = None) -> None: + session.add( + InteractionTimeline( + interaction_id=interaction_id, + timestamp=utc_now_iso(), + action=action, + metadata_json=json.dumps(metadata or {}, ensure_ascii=False), + ) + ) + + +def _get_thread(session, thread_id: str) -> WhatsAppThreadRow: + row = session.execute( + select(WhatsAppThreadRow).where(WhatsAppThreadRow.thread_id == thread_id) + ).scalar_one_or_none() + if not row: + raise HTTPException(status_code=404, detail="WhatsApp thread not found") + return row + + +def _thread_unread_count(session, thread_id: str) -> int: + last_outbound_at = session.execute( + select(func.max(WhatsAppMessageRow.created_at)).where( + WhatsAppMessageRow.thread_id == thread_id, + WhatsAppMessageRow.direction == "outbound", + ) + ).scalar_one_or_none() + + stmt = select(func.count(WhatsAppMessageRow.id)).where( + WhatsAppMessageRow.thread_id == thread_id, + WhatsAppMessageRow.direction == "inbound", + WhatsAppMessageRow.author_type == "customer", + ) + if last_outbound_at: + stmt = stmt.where(WhatsAppMessageRow.created_at > last_outbound_at) + raw = session.execute(stmt).scalar_one_or_none() + return int(raw or 0) + + +def _to_thread_out(row: WhatsAppThreadRow, *, unread_count: int = 0) -> WhatsAppThreadOut: + return WhatsAppThreadOut( + thread_id=row.thread_id, + chat_id=row.chat_id, + interaction_id=row.interaction_id, + whatsapp_user_id=row.whatsapp_user_id, + phone_number=row.phone_number, + display_name=row.display_name, + queue_id=row.queue_id, + is_group=bool(row.is_group), + status=row.status, # type: ignore[arg-type] + claimed_by_user=row.claimed_by_user, + claimed_at=row.claimed_at, + ai_session_id=row.ai_session_id, + ai_state=row.ai_state, # type: ignore[arg-type] + ai_handoff_reason=row.ai_handoff_reason, + ai_last_model_at=row.ai_last_model_at, + unread_count=unread_count, + last_message_at=row.last_message_at, + last_message_preview=row.last_message_preview, + created_at=row.created_at, + updated_at=row.updated_at, + ) + + +def _to_message_out(row: WhatsAppMessageRow) -> WhatsAppThreadMessageOut: + return WhatsAppThreadMessageOut( + message_id=row.message_id, + thread_id=row.thread_id or "", + interaction_id=row.interaction_id or "", + chat_id=row.chat_id, + direction=row.direction, # type: ignore[arg-type] + text=row.text, + whatsapp_message_id_external=row.whatsapp_message_id_external, + operator_user=row.operator_user, + author_type=row.author_type, # type: ignore[arg-type] + author_id=row.author_id, + customer_id=row.customer_id, + delivery_status=row.delivery_status, + payload=json.loads(row.payload_json or "{}"), + created_at=row.created_at, + ) + + +def _loads_payload(raw: str | None) -> dict[str, Any]: + try: + payload = json.loads(raw or "{}") + except Exception: # noqa: BLE001 + return {} + return payload if isinstance(payload, dict) else {} + + +def _to_webhook_out(row: WhatsAppMessageRow) -> WhatsAppWebhookOut: + return WhatsAppWebhookOut( + message_id=row.message_id, + chat_id=row.chat_id, + text=row.text, + external_message_id=row.whatsapp_message_id_external, + customer_id=row.customer_id, + payload=_loads_payload(row.payload_json), + thread_id=row.thread_id, + interaction_id=row.interaction_id, + direction=row.direction, # type: ignore[arg-type] + created_at=row.created_at, + ) + + +def _to_bot_webhook_result(row: WhatsAppMessageRow, *, thread_created: bool) -> dict[str, Any]: + return { + "ok": True, + "thread_id": row.thread_id or "", + "interaction_id": row.interaction_id or "", + "message_id": row.message_id, + "thread_created": thread_created, + } + + +def _existing_bot_message( + session, + *, + chat_id: str, + external_message_id: str | None, +) -> WhatsAppMessageRow | None: + if not external_message_id: + return None + return session.execute( + select(WhatsAppMessageRow).where( + WhatsAppMessageRow.chat_id == chat_id, + WhatsAppMessageRow.whatsapp_message_id_external == external_message_id, + ) + ).scalar_one_or_none() + + +def _latest_ai_session_for_thread(session, thread: WhatsAppThreadRow) -> AISessionRow | None: + if thread.ai_session_id: + ai_session = session.execute( + select(AISessionRow).where(AISessionRow.session_id == thread.ai_session_id) + ).scalar_one_or_none() + if ai_session: + return ai_session + return session.execute( + select(AISessionRow) + .where(AISessionRow.thread_id == thread.thread_id) + .order_by(AISessionRow.updated_at.desc(), AISessionRow.id.desc()) + .limit(1) + ).scalar_one_or_none() + + +def _summary_customer_request_text( + session, + *, + thread: WhatsAppThreadRow, + ai_session: AISessionRow, + generated_at: str, +) -> str: + if ai_session.last_user_message_id: + customer_message = session.execute( + select(WhatsAppMessageRow).where(WhatsAppMessageRow.message_id == ai_session.last_user_message_id) + ).scalar_one_or_none() + if customer_message and customer_message.text.strip(): + return customer_message.text.strip() + customer_message = session.execute( + select(WhatsAppMessageRow) + .where( + WhatsAppMessageRow.thread_id == thread.thread_id, + WhatsAppMessageRow.direction == "inbound", + WhatsAppMessageRow.author_type == "customer", + WhatsAppMessageRow.created_at <= generated_at, + ) + .order_by(WhatsAppMessageRow.created_at.desc(), WhatsAppMessageRow.id.desc()) + .limit(1) + ).scalar_one_or_none() + if customer_message and customer_message.text.strip(): + return customer_message.text.strip() + return "Последний запрос клиента недоступен." + + +def _summary_ai_outcome_text( + session, + *, + thread: WhatsAppThreadRow, + ai_session: AISessionRow, + generated_at: str, +) -> str: + if ai_session.last_ai_message_id: + ai_message = session.execute( + select(WhatsAppMessageRow).where(WhatsAppMessageRow.message_id == ai_session.last_ai_message_id) + ).scalar_one_or_none() + if ai_message and ai_message.text.strip(): + return ai_message.text.strip() + ai_message = session.execute( + select(WhatsAppMessageRow) + .where( + WhatsAppMessageRow.thread_id == thread.thread_id, + WhatsAppMessageRow.direction == "outbound", + WhatsAppMessageRow.author_type == "ai", + WhatsAppMessageRow.created_at <= generated_at, + ) + .order_by(WhatsAppMessageRow.created_at.desc(), WhatsAppMessageRow.id.desc()) + .limit(1) + ).scalar_one_or_none() + if ai_message and ai_message.text.strip(): + return ai_message.text.strip() + return "AI передал диалог оператору без ответа клиенту." + + +def _summary_status( + session, + *, + thread: WhatsAppThreadRow, + ai_session: AISessionRow, + generated_at: str, +) -> tuple[str, str]: + if ai_session.last_ai_message_id: + ai_message = session.execute( + select(WhatsAppMessageRow).where(WhatsAppMessageRow.message_id == ai_session.last_ai_message_id) + ).scalar_one_or_none() + if ai_message and ai_message.text.strip(): + return ("AI ответил клиенту", "answered") + ai_message = session.execute( + select(WhatsAppMessageRow.message_id) + .where( + WhatsAppMessageRow.thread_id == thread.thread_id, + WhatsAppMessageRow.direction == "outbound", + WhatsAppMessageRow.author_type == "ai", + WhatsAppMessageRow.created_at <= generated_at, + ) + .order_by(WhatsAppMessageRow.created_at.desc(), WhatsAppMessageRow.id.desc()) + .limit(1) + ).scalar_one_or_none() + if ai_message: + return ("AI ответил клиенту", "answered") + return ("AI передал без ответа", "handoff") + + +def _summary_recommended_next_step(thread: WhatsAppThreadRow) -> str: + if thread.ai_state == "handoff_required": + return "Заберите чат и ответьте клиенту вручную." + if thread.ai_state == "human_owned": + return "Продолжайте диалог вручную; AI больше не отвечает в этот thread." + return "" + + +def _thread_supports_ai_summary(thread: WhatsAppThreadRow, ai_session: AISessionRow | None) -> bool: + if not ai_session: + return False + if thread.ai_state == "handoff_required": + return True + if thread.ai_state == "human_owned": + return bool(thread.ai_handoff_reason or ai_session.handoff_reason) + return False + + +def _build_ai_summary(session, thread: WhatsAppThreadRow) -> WhatsAppThreadAISummaryOut | None: + ai_session = _latest_ai_session_for_thread(session, thread) + if not _thread_supports_ai_summary(thread, ai_session): + return None + assert ai_session is not None + generated_at = thread.ai_last_model_at or ai_session.updated_at or thread.updated_at + handoff_reason = (thread.ai_handoff_reason or ai_session.handoff_reason or "").strip() + if not handoff_reason: + return None + status_label, status_tone = _summary_status( + session, + thread=thread, + ai_session=ai_session, + generated_at=generated_at, + ) + return WhatsAppThreadAISummaryOut( + thread_id=thread.thread_id, + session_id=ai_session.session_id, + status_label=status_label, + status_tone=status_tone, # type: ignore[arg-type] + customer_request_text=_summary_customer_request_text( + session, + thread=thread, + ai_session=ai_session, + generated_at=generated_at, + ), + ai_outcome_text=_summary_ai_outcome_text( + session, + thread=thread, + ai_session=ai_session, + generated_at=generated_at, + ), + handoff_reason=handoff_reason, + recommended_next_step=_summary_recommended_next_step(thread), + generated_at=generated_at, + ) + + +def _reactivate_thread_interaction( + session, + thread: WhatsAppThreadRow, + now: str, + *, + requeue_ai: bool, +) -> None: + interaction = session.execute( + select(Interaction).where(Interaction.interaction_id == thread.interaction_id) + ).scalar_one_or_none() + if not interaction: + return + interaction.status = "new" + interaction.assigned_to = None + interaction.updated_at = now + thread.status = "new" + thread.claimed_by_user = None + thread.claimed_at = None + thread.ai_handoff_reason = None + if thread.ai_state != "human_owned": + thread.ai_state = "queued" if requeue_ai else None + thread.updated_at = now + _push_timeline( + session, + interaction.interaction_id, + "interaction.status_changed", + {"status": "new", "source": "whatsapp-reactivation"}, + ) + _push_timeline( + session, + interaction.interaction_id, + "whatsapp.thread_reactivated", + {"thread_id": thread.thread_id, "chat_id": thread.chat_id}, + ) + + +def _create_interaction(session, *, subject: str, customer_id: str, queue_id: str, created_at: str) -> str: + interaction_id = new_id("int") + session.add( + Interaction( + interaction_id=interaction_id, + channel="whatsapp", + subject=_preview_text(subject, 120), + customer_id=customer_id, + queue_id=queue_id, + priority=3, + status="new", + assigned_to=None, + created_at=created_at, + updated_at=created_at, + ) + ) + _push_timeline(session, interaction_id, "interaction.created", {"channel": "whatsapp"}) + return interaction_id + + +def _persist_message( + session, + *, + thread: WhatsAppThreadRow, + text: str, + payload: dict, + direction: str, + created_at: str, + customer_id: str | None = None, + external_message_id: str | None = None, + operator_user: str | None = None, + author_type: str | None = None, + author_id: str | None = None, + delivery_status: str | None = None, +) -> WhatsAppMessageRow: + resolved_author_type = author_type or ("human" if direction == "outbound" else ("system" if direction == "system" else "customer")) + row = WhatsAppMessageRow( + message_id=new_id("wam"), + thread_id=thread.thread_id, + interaction_id=thread.interaction_id, + chat_id=thread.chat_id, + text=text, + customer_id=customer_id, + direction=direction, + whatsapp_message_id_external=external_message_id, + operator_user=operator_user, + author_type=resolved_author_type, + author_id=author_id, + delivery_status=delivery_status, + payload_json=json.dumps(payload, ensure_ascii=False), + created_at=created_at, + ) + session.add(row) + thread.last_message_at = created_at + thread.last_message_preview = _preview_text(text, 140) + thread.updated_at = created_at + return row + + +def _mark_thread_ai_state( + thread: WhatsAppThreadRow, + *, + ai_state: str | None, + ai_handoff_reason: str | None = None, + ai_last_model_at: str | None = None, +) -> None: + thread.ai_state = ai_state + thread.ai_handoff_reason = ai_handoff_reason + if ai_last_model_at is not None: + thread.ai_last_model_at = ai_last_model_at + + +def _update_ai_session_state( + session, + *, + session_id: str | None, + status: str, + updated_at: str, + handoff_reason: object = _HANDOFF_REASON_UNSET, + closed: bool = False, +) -> None: + if not session_id: + return + ai_session = session.execute( + select(AISessionRow).where(AISessionRow.session_id == session_id) + ).scalar_one_or_none() + if not ai_session: + return + ai_session.status = status + ai_session.updated_at = updated_at + if handoff_reason is not _HANDOFF_REASON_UNSET: + ai_session.handoff_reason = handoff_reason + if closed: + ai_session.closed_at = updated_at + + +def _maybe_enqueue_ai_for_thread( + thread: WhatsAppThreadRow, + trigger_message_id: str | None, + *, + author_type: str | None = None, +) -> None: + if not _ai_whatsapp_enabled(): + return + if thread.ai_state == "human_owned": + return + if author_type and author_type != "customer": + return + threading.Thread( + target=_ai_enqueue_request, + args=(thread.thread_id, trigger_message_id), + daemon=True, + name=f"whatsapp-ai-enqueue-{thread.thread_id}", + ).start() + + +def _upsert_thread_for_inbound( + session, + *, + chat_id: str, + whatsapp_user_id: str | None, + display_name: str, + text: str, + payload: dict, + customer_id: str | None = None, + external_subject: str | None = None, + external_message_id: str | None = None, + phone_number: str | None = None, + queue_id: str | None = None, + is_group: bool = False, + direction: str = "inbound", +) -> tuple[WhatsAppThreadRow, WhatsAppMessageRow, bool]: + now = utc_now_iso() + author_type = _inbound_author_type(direction) + ai_eligible = _message_can_trigger_ai(author_type) + thread = session.execute( + select(WhatsAppThreadRow).where(WhatsAppThreadRow.chat_id == chat_id) + ).scalar_one_or_none() + created = False + assigned_customer_id = _resolve_or_create_customer_id( + session, + whatsapp_user_id=whatsapp_user_id, + chat_id=chat_id, + display_name=display_name, + phone_number=phone_number, + explicit_customer_id=customer_id, + external_subject=external_subject, + ) + _ensure_customer_phone(session, customer_id=assigned_customer_id, phone_number=phone_number) + resolved_queue_id = queue_id or (thread.queue_id if thread else None) or _default_queue_id() + + if not thread: + interaction_id = _create_interaction( + session, + subject=text, + customer_id=assigned_customer_id, + queue_id=resolved_queue_id, + created_at=now, + ) + thread = WhatsAppThreadRow( + thread_id=new_id("wht"), + chat_id=chat_id, + interaction_id=interaction_id, + whatsapp_user_id=whatsapp_user_id, + phone_number=phone_number, + display_name=display_name, + queue_id=resolved_queue_id, + is_group=is_group, + status="new", + claimed_by_user=None, + claimed_at=None, + ai_session_id=None, + ai_state="queued" if ai_eligible else None, + ai_handoff_reason=None, + ai_last_model_at=None, + last_message_at=now, + last_message_preview=_preview_text(text, 140), + created_at=now, + updated_at=now, + ) + session.add(thread) + created = True + else: + if thread.status == "closed": + _reactivate_thread_interaction(session, thread, now, requeue_ai=ai_eligible) + thread.whatsapp_user_id = whatsapp_user_id or thread.whatsapp_user_id + thread.phone_number = phone_number or thread.phone_number + thread.display_name = display_name or thread.display_name + thread.queue_id = thread.queue_id or resolved_queue_id + thread.is_group = bool(thread.is_group or is_group) + interaction = session.execute( + select(Interaction).where(Interaction.interaction_id == thread.interaction_id) + ).scalar_one_or_none() + if interaction and interaction.customer_id != assigned_customer_id: + interaction.customer_id = assigned_customer_id + interaction.updated_at = now + if ai_eligible and thread.ai_state != "human_owned": + _mark_thread_ai_state(thread, ai_state="queued", ai_handoff_reason=None) + + message_row = _persist_message( + session, + thread=thread, + text=text, + payload=payload, + direction=direction, + created_at=now, + customer_id=assigned_customer_id, + external_message_id=external_message_id, + author_type=author_type, + author_id=_first_external_subject(whatsapp_user_id, chat_id, external_subject or phone_number), + delivery_status="received", + ) + _push_timeline( + session, + thread.interaction_id, + "whatsapp.message_received", + { + "thread_id": thread.thread_id, + "chat_id": chat_id, + "direction": direction, + "display_name": display_name, + }, + ) + session.flush() + return thread, message_row, created + + +def _manual_webhook_metadata(payload: WhatsAppWebhookIn) -> dict[str, Any]: + raw_payload = payload.payload if isinstance(payload.payload, dict) else {} + whatsapp_user_id = ( + str(payload.whatsapp_user_id or raw_payload.get("whatsapp_user_id") or raw_payload.get("from") or "").strip() + or None + ) + phone_number = _normalize_phone_number(payload.phone_number) or _normalize_phone_number(raw_payload.get("phone_number")) + display_name = ( + str(payload.display_name or raw_payload.get("display_name") or raw_payload.get("profile_name") or "").strip() + or phone_number + or f"WhatsApp {payload.chat_id}" + ) + queue_id = str(payload.queue_id or raw_payload.get("queue_id") or "").strip() or None + external_subject = str(payload.external_subject or raw_payload.get("external_subject") or "").strip() or None + is_group = bool(payload.is_group or raw_payload.get("is_group")) + return { + "whatsapp_user_id": whatsapp_user_id, + "display_name": display_name, + "phone_number": phone_number, + "queue_id": queue_id, + "external_subject": external_subject, + "is_group": is_group, + } + + +def _parse_provider_update(update: dict) -> dict: + if not isinstance(update, dict): + raise HTTPException(status_code=400, detail="Unsupported WhatsApp update") + + if str(update.get("chat_id") or "").strip(): + text = str(update.get("text") or "").strip() + message_type = str(update.get("message_type") or "").strip() or "message" + direction = "inbound" + if not text: + text = "[Shared WhatsApp contact]" if message_type == "contacts" else f"[Unsupported WhatsApp content: {message_type}]" + direction = "system" + whatsapp_user_id = str(update.get("whatsapp_user_id") or update.get("from") or update.get("chat_id") or "").strip() or None + phone_number = _normalize_phone_number(update.get("phone_number") or whatsapp_user_id) + return { + "chat_id": str(update.get("chat_id")).strip(), + "whatsapp_user_id": whatsapp_user_id, + "display_name": str(update.get("display_name") or phone_number or f"WhatsApp {update.get('chat_id')}").strip(), + "text": text, + "payload": update, + "external_message_id": str(update.get("external_message_id") or update.get("message_id") or "").strip() or None, + "phone_number": phone_number, + "external_subject": str(update.get("external_subject") or "").strip() or None, + "queue_id": str(update.get("queue_id") or "").strip() or None, + "is_group": bool(update.get("is_group")), + "direction": direction, + } + + entry = update.get("entry") + if not isinstance(entry, list) or not entry: + raise HTTPException(status_code=400, detail="Unsupported WhatsApp provider payload") + changes = entry[0].get("changes") if isinstance(entry[0], dict) else None + if not isinstance(changes, list) or not changes: + raise HTTPException(status_code=400, detail="WhatsApp provider payload has no changes") + value = changes[0].get("value") if isinstance(changes[0], dict) else {} + if not isinstance(value, dict): + raise HTTPException(status_code=400, detail="WhatsApp provider payload is invalid") + messages = value.get("messages") + if not isinstance(messages, list) or not messages: + raise HTTPException(status_code=400, detail="WhatsApp provider payload has no messages") + + message = messages[0] if isinstance(messages[0], dict) else {} + chat_id = str(message.get("from") or "").strip() + if not chat_id: + raise HTTPException(status_code=400, detail="WhatsApp chat_id is missing") + message_type = str(message.get("type") or "text").strip() or "text" + text = "" + if isinstance(message.get("text"), dict): + text = str(message["text"].get("body") or "").strip() + elif isinstance(message.get("interactive"), dict): + interactive = message["interactive"] + for key in ("button_reply", "list_reply"): + if isinstance(interactive.get(key), dict): + text = str(interactive[key].get("title") or interactive[key].get("description") or "").strip() + if text: + break + direction = "inbound" + if not text: + if message_type == "contacts": + text = "[Shared WhatsApp contact]" + direction = "system" + else: + text = f"[Unsupported WhatsApp content: {message_type}]" + direction = "system" + + contacts = value.get("contacts") if isinstance(value.get("contacts"), list) else [] + contact = contacts[0] if contacts and isinstance(contacts[0], dict) else {} + profile = contact.get("profile") if isinstance(contact.get("profile"), dict) else {} + display_name = str(profile.get("name") or contact.get("wa_id") or chat_id or f"WhatsApp {chat_id}").strip() + phone_number = _normalize_phone_number(contact.get("wa_id") or chat_id) + return { + "chat_id": chat_id, + "whatsapp_user_id": str(contact.get("wa_id") or chat_id).strip() or None, + "display_name": display_name, + "text": text, + "payload": update, + "external_message_id": str(message.get("id") or "").strip() or None, + "phone_number": phone_number, + "external_subject": str(contact.get("wa_id") or "").strip() or None, + "queue_id": str(update.get("queue_id") or "").strip() or None, + "is_group": bool(message.get("group_id") or value.get("is_group") or update.get("is_group")), + "direction": direction, + } + + +def _provider_status_to_delivery_status(raw_status: str | None) -> str | None: + value = str(raw_status or "").strip().lower() + if not value: + return None + if value in {"sent", "delivered", "read", "failed"}: + return value + return value[:32] + + +def _handle_provider_status_update(session, payload: dict) -> dict | None: + if not isinstance(payload, dict): + return None + + entry = payload.get("entry") + if not isinstance(entry, list) or not entry: + return None + changes = entry[0].get("changes") if isinstance(entry[0], dict) else None + if not isinstance(changes, list) or not changes: + return None + value = changes[0].get("value") if isinstance(changes[0], dict) else {} + if not isinstance(value, dict): + return None + + statuses = value.get("statuses") + if not isinstance(statuses, list) or not statuses: + return None + + status = statuses[0] if isinstance(statuses[0], dict) else {} + external_message_id = str(status.get("id") or "").strip() + if not external_message_id: + return {"ok": True, "status_event": True, "updated": False} + + row = session.execute( + select(WhatsAppMessageRow).where( + WhatsAppMessageRow.whatsapp_message_id_external == external_message_id + ) + ).scalar_one_or_none() + if not row: + return {"ok": True, "status_event": True, "updated": False} + + next_status = _provider_status_to_delivery_status(status.get("status")) + if next_status: + row.delivery_status = next_status + if next_status == "failed": + error_title = "" + errors = status.get("errors") + if isinstance(errors, list) and errors and isinstance(errors[0], dict): + error_title = str( + errors[0].get("title") or errors[0].get("message") or errors[0].get("code") or "" + ).strip() + if error_title: + row.last_delivery_error = error_title[:1000] + payload_json = _loads_payload(row.payload_json) + payload_json["provider_status"] = status + row.payload_json = json.dumps(payload_json, ensure_ascii=False) + session.commit() + return { + "ok": True, + "status_event": True, + "updated": True, + "message_id": row.message_id, + "delivery_status": row.delivery_status, + } + + +def _verify_meta_webhook_signature(body: bytes, signature_header: str | None) -> None: + app_secret = _meta_app_secret() + if not app_secret: + return + if not signature_header: + raise HTTPException(status_code=403, detail="Missing Meta webhook signature") + prefix = "sha256=" + signature_value = str(signature_header or "").strip() + if not signature_value.startswith(prefix): + raise HTTPException(status_code=403, detail="Invalid Meta webhook signature") + expected = hmac.new( + app_secret.encode("utf-8"), + body, + hashlib.sha256, + ).hexdigest() + actual = signature_value[len(prefix) :].strip().lower() + if not hmac.compare_digest(actual, expected): + raise HTTPException(status_code=403, detail="Invalid Meta webhook signature") + + +def _validate_provider_webhook_request( + body: bytes, + *, + x_whatsapp_webhook_secret: str | None, + x_hub_signature_256: str | None, +) -> None: + if x_hub_signature_256: + _verify_meta_webhook_signature(body, x_hub_signature_256) + return + expected_secret = _webhook_secret() + if expected_secret and x_whatsapp_webhook_secret != expected_secret: + raise HTTPException(status_code=403, detail="Invalid WhatsApp webhook secret") + + +def _send_whatsapp_message_via_meta(chat_id: str, text: str) -> dict: + access_token = _meta_access_token() + phone_number_id = _meta_phone_number_id() + if not access_token or not phone_number_id: + raise HTTPException(status_code=503, detail="WhatsApp Meta outbound delivery is not configured") + + url = f"{_meta_graph_base_url()}/{_meta_graph_api_version()}/{phone_number_id}/messages" + headers = { + "Authorization": f"Bearer {access_token}", + "Content-Type": "application/json", + } + payload = { + "messaging_product": "whatsapp", + "to": chat_id, + "type": "text", + "text": {"body": text}, + } + with httpx.Client(timeout=10.0) as client: + response = client.post(url, json=payload, headers=headers) + if response.status_code >= 400: + raise HTTPException(status_code=502, detail=f"WhatsApp Meta send failed: {response.text}") + try: + response_payload = response.json() + except ValueError as exc: + raise HTTPException(status_code=502, detail="WhatsApp Meta send returned non-JSON") from exc + if response_payload.get("error"): + raise HTTPException(status_code=502, detail=f"WhatsApp Meta send failed: {response_payload}") + result = ( + response_payload.get("messages", [{}])[0] + if isinstance(response_payload.get("messages"), list) and response_payload.get("messages") + else {} + ) + message_id = str(result.get("id") or response_payload.get("message_id") or "").strip() or None + return { + "ok": True, + "result": { + "message_id": message_id, + "chat_id": chat_id, + }, + "provider": "meta", + "raw": response_payload, + } + + +def _send_whatsapp_message(chat_id: str, text: str) -> dict: + if _meta_access_token() and _meta_phone_number_id(): + return _send_whatsapp_message_via_meta(chat_id, text) + if not _outbound_enabled() or not _outbound_send_url(): + raise HTTPException(status_code=503, detail="WhatsApp outbound delivery is not configured") + headers: dict[str, str] = {} + if _outbound_auth_token(): + headers["Authorization"] = f"Bearer {_outbound_auth_token()}" + with httpx.Client(timeout=10.0) as client: + response = client.post( + _outbound_send_url(), + json={"chat_id": chat_id, "text": text}, + headers=headers, + ) + if response.status_code >= 400: + raise HTTPException(status_code=502, detail=f"WhatsApp outbound send failed: {response.text}") + try: + payload = response.json() + except ValueError as exc: + raise HTTPException(status_code=502, detail="WhatsApp outbound send returned non-JSON") from exc + if payload.get("ok") is False: + raise HTTPException(status_code=502, detail=f"WhatsApp outbound send failed: {payload}") + result = payload.get("result") if isinstance(payload.get("result"), dict) else {} + message_id = ( + str(result.get("message_id") or payload.get("message_id") or payload.get("id") or "").strip() + or ( + str(payload.get("messages", [{}])[0].get("id") or "").strip() + if isinstance(payload.get("messages"), list) and payload.get("messages") + else "" + ) + ) + return { + "ok": True, + "result": { + **(result if isinstance(result, dict) else {}), + "message_id": message_id or None, + }, + } + + +def _whatsapp_reply_delivery_filter(now: str): + return or_( + WhatsAppMessageRow.delivery_status == "pending", + WhatsAppMessageRow.delivery_status == "retrying", + and_( + WhatsAppMessageRow.delivery_status == "sending", + or_( + WhatsAppMessageRow.delivery_locked_until.is_(None), + WhatsAppMessageRow.delivery_locked_until < now, + ), + ), + ) + + +def _whatsapp_reply_delivery_due_filter(now: str): + return or_( + WhatsAppMessageRow.next_delivery_attempt_at.is_(None), + WhatsAppMessageRow.next_delivery_attempt_at <= now, + ) + + +def _delivery_error_text(error: Exception) -> str: + if isinstance(error, HTTPException): + detail = error.detail + if isinstance(detail, str): + return detail[:1000] + return str(detail)[:1000] + return str(error or error.__class__.__name__)[:1000] + + +def _whatsapp_reply_backoff_seconds(next_attempt: int) -> int: + base = _reply_delivery_retry_base_seconds() + cap = _reply_delivery_retry_cap_seconds() + return min(cap, base * (2 ** max(next_attempt - 1, 0))) + + +def _claim_whatsapp_reply_delivery(session, message_id: str) -> WhatsAppMessageRow | None: + now = utc_now_iso() + leased_until = _utc_after_seconds(_reply_delivery_lease_seconds()) + claim = session.execute( + update(WhatsAppMessageRow) + .where( + WhatsAppMessageRow.message_id == message_id, + WhatsAppMessageRow.direction == "outbound", + WhatsAppMessageRow.thread_id.is_not(None), + WhatsAppMessageRow.interaction_id.is_not(None), + _whatsapp_reply_delivery_filter(now), + _whatsapp_reply_delivery_due_filter(now), + ) + .values( + delivery_status="sending", + delivery_locked_until=leased_until, + ) + ) + session.commit() + if claim.rowcount != 1: + return None + return session.execute( + select(WhatsAppMessageRow).where(WhatsAppMessageRow.message_id == message_id) + ).scalar_one_or_none() + + +def _mark_whatsapp_reply_delivery_failure( + session, + row: WhatsAppMessageRow, + error_text: str, + *, + allow_retry: bool = True, +) -> None: + attempts = int(row.delivery_attempts or 0) + 1 + row.delivery_attempts = attempts + row.delivery_locked_until = None + row.last_delivery_error = error_text + should_retry = allow_retry and attempts < _reply_delivery_max_attempts() + if should_retry: + row.delivery_status = "retrying" + row.next_delivery_attempt_at = _utc_after_seconds(_whatsapp_reply_backoff_seconds(attempts)) + else: + row.delivery_status = "failed" + row.next_delivery_attempt_at = None + if row.interaction_id and row.thread_id: + _push_timeline( + session, + row.interaction_id, + "whatsapp.message_failed", + { + "thread_id": row.thread_id, + "operator_user": row.operator_user, + "attempts": attempts, + "error": error_text, + }, + ) + session.commit() + + +def _mark_whatsapp_reply_delivery_success(session, row: WhatsAppMessageRow, whatsapp_payload: dict) -> None: + row.delivery_attempts = int(row.delivery_attempts or 0) + 1 + row.delivery_status = "sent" + row.delivery_locked_until = None + row.next_delivery_attempt_at = None + row.last_delivery_error = None + result_message = whatsapp_payload.get("result") if isinstance(whatsapp_payload, dict) else {} + if isinstance(result_message, dict) and result_message.get("message_id") is not None: + row.whatsapp_message_id_external = str(result_message.get("message_id")) + _push_timeline( + session, + row.interaction_id, + "whatsapp.message_sent", + {"thread_id": row.thread_id, "operator_user": row.operator_user}, + ) + session.commit() + + +def _deliver_pending_whatsapp_reply(message_id: str) -> bool: + session = get_session() + try: + row = _claim_whatsapp_reply_delivery(session, message_id) + if not row or not row.thread_id or not row.interaction_id: + return False + + thread = session.execute( + select(WhatsAppThreadRow).where(WhatsAppThreadRow.thread_id == row.thread_id) + ).scalar_one_or_none() + if not thread: + _mark_whatsapp_reply_delivery_failure( + session, + row, + "WhatsApp thread not found during reply delivery", + allow_retry=False, + ) + return False + + try: + whatsapp_payload = _send_whatsapp_message(row.chat_id, row.text) + except Exception as exc: # noqa: BLE001 + _mark_whatsapp_reply_delivery_failure(session, row, _delivery_error_text(exc)) + return False + + _mark_whatsapp_reply_delivery_success(session, row, whatsapp_payload) + return True + finally: + session.close() + + +def _claim_next_due_whatsapp_reply_message_id(session) -> str | None: + now = utc_now_iso() + return session.execute( + select(WhatsAppMessageRow.message_id) + .where( + WhatsAppMessageRow.direction == "outbound", + WhatsAppMessageRow.thread_id.is_not(None), + WhatsAppMessageRow.interaction_id.is_not(None), + _whatsapp_reply_delivery_filter(now), + _whatsapp_reply_delivery_due_filter(now), + ) + .order_by( + case( + (WhatsAppMessageRow.delivery_status == "sending", 0), + (WhatsAppMessageRow.delivery_status == "retrying", 1), + else_=2, + ), + WhatsAppMessageRow.created_at.asc(), + WhatsAppMessageRow.id.asc(), + ) + .limit(1) + ).scalar_one_or_none() + + +def _process_due_whatsapp_reply_batch(batch_size: int = 20) -> int: + processed = 0 + attempts = 0 + while processed < batch_size and attempts < batch_size * 2: + attempts += 1 + session = get_session() + try: + message_id = _claim_next_due_whatsapp_reply_message_id(session) + finally: + session.close() + if not message_id: + break + if _deliver_pending_whatsapp_reply(message_id): + processed += 1 + return processed + + +def _reply_delivery_worker_loop(stop_event: threading.Event, wakeup_event: threading.Event) -> None: + while not stop_event.is_set(): + wakeup_event.wait(timeout=_reply_delivery_poll_seconds()) + wakeup_event.clear() + if stop_event.is_set(): + break + try: + _process_due_whatsapp_reply_batch() + except Exception: # noqa: BLE001 + logger.exception("WhatsApp reply delivery worker cycle failed") + + +def _wake_whatsapp_reply_delivery_worker() -> None: + with _reply_delivery_worker_lock: + wakeup_event = _reply_delivery_worker_wakeup + if wakeup_event: + wakeup_event.set() + + +def _start_reply_delivery_worker() -> None: + global _reply_delivery_worker_clients + global _reply_delivery_worker_stop + global _reply_delivery_worker_thread + global _reply_delivery_worker_wakeup + + if not _reply_delivery_worker_enabled(): + return + + with _reply_delivery_worker_lock: + _reply_delivery_worker_clients += 1 + if _reply_delivery_worker_thread and _reply_delivery_worker_thread.is_alive(): + wakeup_event = _reply_delivery_worker_wakeup + else: + stop_event = threading.Event() + wakeup_event = threading.Event() + worker = threading.Thread( + target=_reply_delivery_worker_loop, + args=(stop_event, wakeup_event), + name="whatsapp-reply-delivery-worker", + daemon=True, + ) + _reply_delivery_worker_stop = stop_event + _reply_delivery_worker_wakeup = wakeup_event + _reply_delivery_worker_thread = worker + worker.start() + if wakeup_event: + wakeup_event.set() + + +def _stop_reply_delivery_worker() -> None: + global _reply_delivery_worker_clients + global _reply_delivery_worker_stop + global _reply_delivery_worker_thread + global _reply_delivery_worker_wakeup + + thread: threading.Thread | None = None + stop_event: threading.Event | None = None + wakeup_event: threading.Event | None = None + + with _reply_delivery_worker_lock: + if _reply_delivery_worker_clients > 0: + _reply_delivery_worker_clients -= 1 + if _reply_delivery_worker_clients > 0: + return + thread = _reply_delivery_worker_thread + stop_event = _reply_delivery_worker_stop + wakeup_event = _reply_delivery_worker_wakeup + _reply_delivery_worker_thread = None + _reply_delivery_worker_stop = None + _reply_delivery_worker_wakeup = None + + if stop_event: + stop_event.set() + if wakeup_event: + wakeup_event.set() + if thread and thread.is_alive(): + thread.join(timeout=2) + + +def _start_whatsapp_reply_delivery(message_id: str) -> None: + if _reply_delivery_worker_enabled(): + _wake_whatsapp_reply_delivery_worker() + return + threading.Thread( + target=_deliver_pending_whatsapp_reply, + args=(message_id,), + daemon=True, + ).start() + + +@app.on_event("startup") +def _start_delivery_worker_on_startup() -> None: + _start_reply_delivery_worker() + + +@app.on_event("shutdown") +def _stop_delivery_worker_on_shutdown() -> None: + _stop_reply_delivery_worker() + + +def _ensure_operator_can_manage( + actor: dict, + thread: WhatsAppThreadRow, + *, + for_reply: bool = False, + require_claim: bool = False, +) -> None: + if actor["role"] in {Role.ADMIN.value, Role.SUPERVISOR.value}: + return + if actor["role"] != Role.OPERATOR.value: + raise HTTPException(status_code=403, detail="Insufficient role") + if for_reply and thread.claimed_by_user != actor["user"]: + raise HTTPException(status_code=403, detail="Thread must be claimed by the current operator") + if require_claim and thread.claimed_by_user != actor["user"]: + raise HTTPException(status_code=403, detail="Thread must be claimed by the current operator") + if not for_reply and thread.claimed_by_user and thread.claimed_by_user != actor["user"]: + raise HTTPException(status_code=403, detail="Thread is already claimed by another operator") + + +def _raise_claim_conflict(thread: WhatsAppThreadRow, actor_user: str) -> None: + if thread.status == "closed": + raise HTTPException(status_code=409, detail="WhatsApp thread is closed") + if thread.claimed_by_user and thread.claimed_by_user != actor_user: + raise HTTPException(status_code=409, detail="WhatsApp thread is already claimed by another operator") + raise HTTPException(status_code=409, detail="WhatsApp thread claim conflict") + + +def _ensure_thread_available_for_ai(thread: WhatsAppThreadRow) -> None: + if thread.status == "closed": + raise HTTPException(status_code=409, detail="WhatsApp thread is closed") + if thread.ai_state == "human_owned" or thread.claimed_by_user: + raise HTTPException(status_code=409, detail="WhatsApp thread is owned by a human operator") + + +def _claim_thread_locally( + session, + *, + thread_id: str, + actor_user: str, + claimed_at: str, +) -> tuple[WhatsAppThreadRow, str, bool]: + thread = _get_thread(session, thread_id) + if thread.claimed_by_user == actor_user and thread.status == "in_progress": + return thread, thread.status, False + + previous_status = thread.status + result = session.execute( + update(WhatsAppThreadRow) + .where( + WhatsAppThreadRow.thread_id == thread_id, + WhatsAppThreadRow.status != "closed", + or_( + WhatsAppThreadRow.claimed_by_user.is_(None), + WhatsAppThreadRow.claimed_by_user == actor_user, + ), + ) + .values( + claimed_by_user=actor_user, + claimed_at=claimed_at, + updated_at=claimed_at, + ) + ) + if not result.rowcount: + session.rollback() + _raise_claim_conflict(_get_thread(session, thread_id), actor_user) + + session.commit() + return _get_thread(session, thread_id), previous_status, True + + +def _rollback_thread_claim( + session, + *, + thread_id: str, + actor_user: str, + claimed_at: str, + previous_status: str, +) -> None: + try: + session.execute( + update(WhatsAppThreadRow) + .where( + WhatsAppThreadRow.thread_id == thread_id, + WhatsAppThreadRow.claimed_by_user == actor_user, + WhatsAppThreadRow.claimed_at == claimed_at, + ) + .values( + claimed_by_user=None, + claimed_at=None, + status=previous_status, + updated_at=utc_now_iso(), + ) + ) + session.commit() + except Exception: + session.rollback() + + +@app.get("/health", response_model=HealthResponse) +def health() -> HealthResponse: + return HealthResponse(status="ok", service="whatsapp-adapter-service", version="v2") + + +@app.post("/integrations/whatsapp/webhook", response_model=WhatsAppWebhookOut) +def webhook(payload: WhatsAppWebhookIn) -> WhatsAppWebhookOut: + session = get_session() + try: + metadata = _manual_webhook_metadata(payload) + existing = _existing_bot_message( + session, + chat_id=payload.chat_id, + external_message_id=payload.external_message_id, + ) + if existing: + return _to_webhook_out(existing) + text = str(payload.text or "").strip() + if not text: + text = "[Unsupported WhatsApp content: message]" + direction = "system" + else: + direction = "inbound" + thread, row, _ = _upsert_thread_for_inbound( + session, + chat_id=payload.chat_id, + whatsapp_user_id=metadata["whatsapp_user_id"], + display_name=metadata["display_name"], + text=text, + payload=payload.payload, + customer_id=payload.customer_id, + external_subject=metadata["external_subject"], + external_message_id=payload.external_message_id, + phone_number=metadata["phone_number"], + queue_id=metadata["queue_id"], + is_group=metadata["is_group"], + direction=direction, + ) + try: + session.commit() + except IntegrityError: + session.rollback() + existing = _existing_bot_message( + session, + chat_id=payload.chat_id, + external_message_id=payload.external_message_id, + ) + if existing: + return _to_webhook_out(existing) + raise + session.refresh(row) + session.refresh(thread) + _maybe_enqueue_ai_for_thread(thread, row.message_id, author_type=row.author_type) + return _to_webhook_out(row) + finally: + session.close() + + +@app.get("/integrations/whatsapp/provider/webhook", response_class=PlainTextResponse) +def verify_provider_webhook( + hub_mode: str | None = Query(default=None, alias="hub.mode"), + hub_verify_token: str | None = Query(default=None, alias="hub.verify_token"), + hub_challenge: str | None = Query(default=None, alias="hub.challenge"), +) -> str: + expected_token = _meta_verify_token() + if not expected_token: + raise HTTPException(status_code=503, detail="WhatsApp Meta verify token is not configured") + if str(hub_mode or "").strip().lower() != "subscribe": + raise HTTPException(status_code=400, detail="Unsupported WhatsApp webhook mode") + if hub_verify_token != expected_token: + raise HTTPException(status_code=403, detail="Invalid WhatsApp verify token") + return str(hub_challenge or "") + + +@app.post("/integrations/whatsapp/provider/webhook") +async def provider_webhook( + request: Request, + x_whatsapp_webhook_secret: str | None = Header( + default=None, + alias="X-WhatsApp-Webhook-Secret", + ), + x_hub_signature_256: str | None = Header( + default=None, + alias="X-Hub-Signature-256", + ), +) -> dict: + raw_body = await request.body() + _validate_provider_webhook_request( + raw_body, + x_whatsapp_webhook_secret=x_whatsapp_webhook_secret, + x_hub_signature_256=x_hub_signature_256, + ) + try: + payload = json.loads((raw_body or b"{}").decode("utf-8")) + except ValueError as exc: + raise HTTPException(status_code=400, detail="WhatsApp provider payload is not valid JSON") from exc + if not isinstance(payload, dict): + raise HTTPException(status_code=400, detail="Unsupported WhatsApp provider payload") + session = get_session() + try: + status_result = _handle_provider_status_update(session, payload) + if status_result is not None: + return status_result + parsed = _parse_provider_update(payload) + existing = _existing_bot_message( + session, + chat_id=parsed["chat_id"], + external_message_id=parsed["external_message_id"], + ) + if existing: + return _to_bot_webhook_result(existing, thread_created=False) + thread, row, created = _upsert_thread_for_inbound( + session, + chat_id=parsed["chat_id"], + whatsapp_user_id=parsed["whatsapp_user_id"], + display_name=parsed["display_name"], + text=parsed["text"], + payload=parsed["payload"], + external_subject=parsed["external_subject"], + external_message_id=parsed["external_message_id"], + phone_number=parsed["phone_number"], + queue_id=parsed["queue_id"], + is_group=parsed["is_group"], + direction=parsed["direction"], + ) + try: + session.commit() + except IntegrityError: + session.rollback() + existing = _existing_bot_message( + session, + chat_id=parsed["chat_id"], + external_message_id=parsed["external_message_id"], + ) + if existing: + return _to_bot_webhook_result(existing, thread_created=False) + raise + _maybe_enqueue_ai_for_thread(thread, row.message_id, author_type=row.author_type) + return _to_bot_webhook_result(row, thread_created=created) + finally: + session.close() + + +@app.get( + "/integrations/whatsapp/messages", + response_model=list[WhatsAppWebhookOut], +) +def list_messages( + limit: int = 100, + chat_id: str | None = None, + actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)), +) -> list[WhatsAppWebhookOut]: + session = get_session() + try: + stmt = select(WhatsAppMessageRow).order_by(WhatsAppMessageRow.id.asc()) + if chat_id: + stmt = stmt.where(WhatsAppMessageRow.chat_id == chat_id) + rows = session.execute(stmt).scalars().all() + rows = rows[-limit:] + return [_to_webhook_out(r) for r in rows] + finally: + session.close() + + +@app.get( + "/integrations/whatsapp/threads", + response_model=list[WhatsAppThreadOut], +) +def list_threads( + limit: int = 100, + status: InteractionStatus | None = None, + actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)), +) -> list[WhatsAppThreadOut]: + session = get_session() + try: + stmt = select(WhatsAppThreadRow).order_by(WhatsAppThreadRow.last_message_at.desc()) + if status: + stmt = stmt.where(WhatsAppThreadRow.status == status) + rows = session.execute(stmt).scalars().all() + return [ + _to_thread_out(row, unread_count=_thread_unread_count(session, row.thread_id)) + for row in rows[:limit] + ] + finally: + session.close() + + +@app.get( + "/integrations/whatsapp/threads/{thread_id}", + response_model=WhatsAppThreadOut, +) +def get_thread( + thread_id: str, + actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)), +) -> WhatsAppThreadOut: + session = get_session() + try: + row = _get_thread(session, thread_id) + return _to_thread_out(row, unread_count=_thread_unread_count(session, row.thread_id)) + finally: + session.close() + + +@app.get( + "/integrations/whatsapp/threads/{thread_id}/ai-summary", + response_model=WhatsAppThreadAISummaryOut | None, +) +def get_thread_ai_summary( + thread_id: str, + actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)), +) -> WhatsAppThreadAISummaryOut | None: + session = get_session() + try: + thread = _get_thread(session, thread_id) + if actor["role"] == Role.OPERATOR.value and thread.claimed_by_user and thread.claimed_by_user != actor["user"]: + raise HTTPException(status_code=403, detail="Thread is already claimed by another operator") + return _build_ai_summary(session, thread) + finally: + session.close() + + +@app.get( + "/integrations/whatsapp/threads/{thread_id}/messages", + response_model=list[WhatsAppThreadMessageOut], +) +def get_thread_messages( + thread_id: str, + limit: int = 200, + actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)), +) -> list[WhatsAppThreadMessageOut]: + session = get_session() + try: + thread = _get_thread(session, thread_id) + rows = session.execute( + select(WhatsAppMessageRow) + .where(WhatsAppMessageRow.thread_id == thread.thread_id) + .order_by(WhatsAppMessageRow.id.asc()) + ).scalars().all() + return [_to_message_out(row) for row in rows[-limit:]] + finally: + session.close() + + +@app.post( + "/integrations/whatsapp/threads/{thread_id}/claim", + response_model=WhatsAppThreadOut, +) +def claim_thread( + thread_id: str, + actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)), +) -> WhatsAppThreadOut: + session = get_session() + try: + claimed_at = utc_now_iso() + thread, previous_status, local_claimed = _claim_thread_locally( + session, + thread_id=thread_id, + actor_user=actor["user"], + claimed_at=claimed_at, + ) + if not local_claimed: + return _to_thread_out(thread, unread_count=_thread_unread_count(session, thread.thread_id)) + try: + result = _interaction_request( + "PATCH", + f"/interactions/{thread.interaction_id}/assign", + payload={"assignee": actor["user"]}, + ) + except Exception: + _rollback_thread_claim( + session, + thread_id=thread_id, + actor_user=actor["user"], + claimed_at=claimed_at, + previous_status=previous_status, + ) + raise + thread = _get_thread(session, thread_id) + thread.status = result.get("status", "in_progress") + thread.updated_at = utc_now_iso() + _mark_thread_ai_state(thread, ai_state="human_owned", ai_handoff_reason=None) + _update_ai_session_state( + session, + session_id=thread.ai_session_id, + status="human_owned", + updated_at=thread.updated_at, + ) + _push_timeline( + session, + thread.interaction_id, + "ai.human_takeover", + {"thread_id": thread.thread_id, "actor_user": actor["user"]}, + ) + session.commit() + session.refresh(thread) + return _to_thread_out(thread, unread_count=_thread_unread_count(session, thread.thread_id)) + finally: + session.close() + + +@app.post( + "/integrations/whatsapp/threads/{thread_id}/return-to-ai", + response_model=WhatsAppThreadOut, +) +def return_thread_to_ai( + thread_id: str, + actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)), +) -> WhatsAppThreadOut: + if not _ai_whatsapp_enabled(): + raise HTTPException(status_code=409, detail="WhatsApp AI is disabled") + + session = get_session() + try: + thread = _get_thread(session, thread_id) + if thread.status == "closed": + raise HTTPException(status_code=409, detail="WhatsApp thread is closed") + _ensure_operator_can_manage(actor, thread, require_claim=True) + + now = utc_now_iso() + interaction = session.execute( + select(Interaction).where(Interaction.interaction_id == thread.interaction_id) + ).scalar_one_or_none() + if interaction: + interaction.status = "new" + interaction.assigned_to = None + interaction.updated_at = now + _push_timeline( + session, + interaction.interaction_id, + "interaction.status_changed", + {"status": "new", "source": "whatsapp-return-to-ai"}, + ) + + thread.status = "new" + thread.claimed_by_user = None + thread.claimed_at = None + thread.updated_at = now + _mark_thread_ai_state(thread, ai_state="queued", ai_handoff_reason=None) + _update_ai_session_state( + session, + session_id=thread.ai_session_id, + status="active", + updated_at=now, + handoff_reason=None, + ) + _push_timeline( + session, + thread.interaction_id, + "ai.returned_to_ai", + {"thread_id": thread.thread_id, "actor_user": actor["user"]}, + ) + session.commit() + session.refresh(thread) + return _to_thread_out(thread, unread_count=_thread_unread_count(session, thread.thread_id)) + finally: + session.close() + + +@app.post( + "/integrations/whatsapp/threads/{thread_id}/messages", + response_model=WhatsAppThreadMessageOut, +) +def reply_thread( + thread_id: str, + payload: WhatsAppThreadReplyIn, + actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)), +) -> WhatsAppThreadMessageOut: + session = get_session() + try: + thread = _get_thread(session, thread_id) + if thread.status == "closed": + raise HTTPException(status_code=409, detail="WhatsApp thread is closed") + _ensure_operator_can_manage(actor, thread, for_reply=True) + now = utc_now_iso() + row = _persist_message( + session, + thread=thread, + text=payload.text, + payload={"operator_user": actor["user"]}, + direction="outbound", + created_at=now, + operator_user=actor["user"], + author_type="human", + author_id=actor["user"], + delivery_status="pending", + ) + row.delivery_attempts = 0 + row.next_delivery_attempt_at = now + row.delivery_locked_until = None + row.last_delivery_error = None + _mark_thread_ai_state(thread, ai_state="human_owned", ai_handoff_reason=None) + _update_ai_session_state( + session, + session_id=thread.ai_session_id, + status="human_owned", + updated_at=now, + ) + session.commit() + session.refresh(row) + _start_whatsapp_reply_delivery(row.message_id) + return _to_message_out(row) + finally: + session.close() + + +@app.post( + "/integrations/whatsapp/threads/{thread_id}/ai/reply", + response_model=WhatsAppThreadMessageOut, +) +def ai_reply_thread( + thread_id: str, + payload: WhatsAppThreadAIReplyIn, + actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR)), +) -> WhatsAppThreadMessageOut: + session = get_session() + try: + thread = _get_thread(session, thread_id) + _ensure_thread_available_for_ai(thread) + now = utc_now_iso() + row = _persist_message( + session, + thread=thread, + text=payload.text, + payload={ + "agent_profile": payload.agent_profile, + "model": payload.model, + "trigger_message_id": payload.trigger_message_id, + "language": payload.language, + "confidence": payload.confidence, + "kb_refs": payload.kb_refs, + **(payload.payload or {}), + }, + direction="outbound", + created_at=now, + author_type="ai", + author_id=payload.agent_profile, + delivery_status="pending", + ) + row.delivery_attempts = 0 + row.next_delivery_attempt_at = now + row.delivery_locked_until = None + row.last_delivery_error = None + _mark_thread_ai_state(thread, ai_state="active", ai_handoff_reason=None, ai_last_model_at=now) + _update_ai_session_state( + session, + session_id=thread.ai_session_id, + status="active", + updated_at=now, + handoff_reason=None, + ) + if thread.ai_session_id: + ai_session = session.execute( + select(AISessionRow).where(AISessionRow.session_id == thread.ai_session_id) + ).scalar_one_or_none() + if ai_session: + ai_session.last_ai_message_id = row.message_id + if payload.language: + ai_session.language = payload.language + _push_timeline( + session, + thread.interaction_id, + "ai.reply_generated", + { + "thread_id": thread.thread_id, + "message_id": row.message_id, + "agent_profile": payload.agent_profile, + "model": payload.model, + "confidence": payload.confidence, + "kb_refs": payload.kb_refs, + "actor_user": actor["user"], + }, + ) + session.commit() + session.refresh(row) + _start_whatsapp_reply_delivery(row.message_id) + return _to_message_out(row) + finally: + session.close() + + +@app.post( + "/integrations/whatsapp/threads/{thread_id}/ai/handoff", + response_model=WhatsAppThreadOut, +) +def ai_handoff_thread( + thread_id: str, + payload: WhatsAppThreadAIHandoffIn, + actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR)), +) -> WhatsAppThreadOut: + session = get_session() + try: + thread = _get_thread(session, thread_id) + _ensure_thread_available_for_ai(thread) + now = utc_now_iso() + _mark_thread_ai_state( + thread, + ai_state="handoff_required", + ai_handoff_reason=payload.reason, + ai_last_model_at=now, + ) + thread.updated_at = now + _update_ai_session_state( + session, + session_id=thread.ai_session_id, + status="handoff_required", + updated_at=now, + handoff_reason=payload.reason, + ) + _push_timeline( + session, + thread.interaction_id, + "ai.handoff_requested", + { + "thread_id": thread.thread_id, + "reason": payload.reason, + "agent_profile": payload.agent_profile, + "trigger_message_id": payload.trigger_message_id, + "confidence": payload.confidence, + "actor_user": actor["user"], + }, + ) + session.commit() + session.refresh(thread) + return _to_thread_out(thread, unread_count=_thread_unread_count(session, thread.thread_id)) + finally: + session.close() + + +@app.post( + "/integrations/whatsapp/threads/{thread_id}/close", + response_model=WhatsAppThreadOut, +) +def close_thread( + thread_id: str, + actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)), +) -> WhatsAppThreadOut: + session = get_session() + try: + thread = _get_thread(session, thread_id) + _ensure_operator_can_manage(actor, thread, require_claim=True) + _interaction_request( + "PATCH", + f"/interactions/{thread.interaction_id}/status", + payload={"status": "closed"}, + ) + thread.status = "closed" + thread.claimed_by_user = None + thread.claimed_at = None + thread.updated_at = utc_now_iso() + _mark_thread_ai_state(thread, ai_state="closed", ai_handoff_reason=None, ai_last_model_at=thread.ai_last_model_at) + _update_ai_session_state( + session, + session_id=thread.ai_session_id, + status="closed", + updated_at=thread.updated_at, + closed=True, + ) + session.commit() + session.refresh(thread) + return _to_thread_out(thread, unread_count=_thread_unread_count(session, thread.thread_id)) + finally: + session.close() + + +@app.post( + "/integrations/whatsapp/threads/{thread_id}/escalate", + response_model=WhatsAppThreadOut, +) +def escalate_thread( + thread_id: str, + payload: WhatsAppThreadEscalateIn, + actor: dict = Depends(require_roles(Role.ADMIN, Role.SUPERVISOR, Role.OPERATOR)), +) -> WhatsAppThreadOut: + session = get_session() + try: + thread = _get_thread(session, thread_id) + _ensure_operator_can_manage(actor, thread, require_claim=True) + result = _interaction_request( + "POST", + f"/interactions/{thread.interaction_id}/escalate", + payload=EscalateRequest(target_queue_id=payload.target_queue_id).model_dump(), + ) + thread.status = result.get("status", "escalated") + thread.queue_id = result.get("queue_id", payload.target_queue_id) + thread.updated_at = utc_now_iso() + _mark_thread_ai_state(thread, ai_state="human_owned", ai_handoff_reason=None) + _update_ai_session_state( + session, + session_id=thread.ai_session_id, + status="human_owned", + updated_at=thread.updated_at, + ) + session.commit() + session.refresh(thread) + return _to_thread_out(thread, unread_count=_thread_unread_count(session, thread.thread_id)) + finally: + session.close() diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..f63de35 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +import os +import shutil +import sys +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +TEST_DATA_DIR = ROOT / ".testdata" +if TEST_DATA_DIR.exists(): + shutil.rmtree(TEST_DATA_DIR) +TEST_DATA_DIR.mkdir(parents=True, exist_ok=True) +os.environ.setdefault("CC_DATA_DIR", str(TEST_DATA_DIR)) +os.environ.setdefault("DATABASE_URL", f"sqlite:///{(TEST_DATA_DIR / 'mvp_cc_test.db').as_posix()}") + + +def pytest_sessionfinish(session, exitstatus): # noqa: ANN001, ANN201 + try: + from services.asterisk_bridge_service import app as bridge_app + from services.shared.db import engine + + bridge_app._shutdown() + bridge_app._release_bridge_singleton_guard() + engine.dispose() + except Exception: + pass + if TEST_DATA_DIR.exists(): + try: + shutil.rmtree(TEST_DATA_DIR) + except PermissionError: + pass diff --git a/tests/test_ai_orchestrator_service.py b/tests/test_ai_orchestrator_service.py new file mode 100644 index 0000000..dac88f3 --- /dev/null +++ b/tests/test_ai_orchestrator_service.py @@ -0,0 +1,1479 @@ +import time +import importlib +from types import SimpleNamespace + +import httpx +from fastapi.testclient import TestClient +from sqlalchemy import select + +ai_module = importlib.import_module("services.ai_orchestrator_service.app") +ai_app = ai_module.app +voice_module = importlib.import_module("services.ai_orchestrator_service.voice") +from services.interaction_service.app import app as interaction_app +from services.shared.core import new_id, utc_now_iso +from services.shared.db import get_session +from services.shared.sql_models import ( + AIJobRow, + AISessionRow, + AITurnRow, + Interaction, + KBArticleRow, + KBCategoryRow, + TelegramMessageRow, + TelegramThreadRow, + WhatsAppThreadRow, +) +from services.telegram_adapter_service import app as telegram_module +from services.telegram_adapter_service.app import app as telegram_app + + +def admin_headers(): + return {"X-User": "admin", "X-Role": "admin"} + + +def operator_headers(user="operator"): + return {"X-User": user, "X-Role": "operator"} + + +def _u(value: str) -> str: + return value.encode("ascii").decode("unicode_escape") + + +def patch_interaction_request(monkeypatch): + def fake_request(method: str, path: str, *, payload: dict | None = None) -> dict: + session = get_session() + try: + interaction_id = path.split("/")[2] + interaction = session.execute( + select(Interaction).where(Interaction.interaction_id == interaction_id) + ).scalar_one() + if path.endswith("/assign"): + interaction.assigned_to = payload["assignee"] + interaction.status = "in_progress" + elif path.endswith("/status"): + interaction.status = payload["status"] + elif path.endswith("/escalate"): + interaction.status = "escalated" + interaction.queue_id = payload["target_queue_id"] + interaction.updated_at = telegram_module.utc_now_iso() + session.commit() + return { + "interaction_id": interaction.interaction_id, + "status": interaction.status, + "assigned_to": interaction.assigned_to, + "queue_id": interaction.queue_id, + } + finally: + session.close() + + monkeypatch.setattr(telegram_module, "_interaction_request", fake_request) + + +def patch_ai_internal_calls(monkeypatch, telegram_client: TestClient, interaction_client: TestClient): + def fake_telegram_request(method: str, path: str, *, payload: dict | None = None) -> dict: + response = telegram_client.request(method, path, json=payload, headers=admin_headers()) + response.raise_for_status() + return response.json() + + def fake_interaction_request(method: str, path: str, *, payload: dict | None = None) -> dict: + response = interaction_client.request(method, path, json=payload, headers=admin_headers()) + response.raise_for_status() + return response.json() + + monkeypatch.setattr(ai_module, "_telegram_request", fake_telegram_request) + monkeypatch.setattr(ai_module, "_interaction_request", fake_interaction_request) + + +def seed_kb_article( + title: str, + body: str, + tag: str, + *, + language: str = "ru", + article_group_id: str | None = None, +) -> dict[str, str]: + session = get_session() + try: + now = utc_now_iso() + category_id = new_id("kbc") + article_id = new_id("kba") + resolved_group_id = article_group_id or article_id + session.add( + KBCategoryRow( + category_id=category_id, + name="Telegram AI", + description="AI test category", + created_at=now, + ) + ) + session.add( + KBArticleRow( + article_id=article_id, + category_id=category_id, + article_group_id=resolved_group_id, + language=language, + title=title, + body=body, + tags_json=f'["{tag}"]', + created_at=now, + updated_at=now, + ) + ) + session.commit() + return {"article_id": article_id, "article_group_id": resolved_group_id} + finally: + session.close() + + +def create_inbound_thread(telegram_client: TestClient, chat_id: str, text: str) -> dict: + response = telegram_client.post( + "/integrations/telegram/webhook", + json={ + "chat_id": chat_id, + "text": text, + "payload": {"telegram_user_id": f"user-{chat_id}", "username": f"user_{chat_id}"}, + }, + ) + assert response.status_code == 200 + return response.json() + + +def fetch_ai_summary(telegram_client: TestClient, thread_id: str, headers: dict | None = None): + return telegram_client.get( + f"/integrations/telegram/threads/{thread_id}/ai-summary", + headers=headers or admin_headers(), + ) + + +def deliver_latest_pending_message(monkeypatch, telegram_client: TestClient, thread_id: str, external_id: int = 7001) -> None: + messages = telegram_client.get( + f"/integrations/telegram/threads/{thread_id}/messages", + headers=admin_headers(), + ) + assert messages.status_code == 200 + message_id = messages.json()[-1]["message_id"] + monkeypatch.setattr( + telegram_module, + "_send_telegram_message", + lambda chat_id, text: {"ok": True, "result": {"message_id": external_id, "chat": {"id": chat_id}, "text": text}}, + ) + telegram_module._deliver_pending_telegram_reply(message_id) + + +def seed_ai_analytics_dataset(marker: str) -> dict[str, str]: + session = get_session() + try: + window_from = "2040-01-01T00:00:00+00:00" + window_to = "2040-01-03T00:00:00+00:00" + queue_tg = f"{marker}_queue_tg" + queue_wa = f"{marker}_queue_wa" + queue_thread = f"{marker}_queue_thread" + + interaction_tg = f"{marker}_int_tg" + interaction_wa = f"{marker}_int_wa" + interaction_tg_human = f"{marker}_int_tg_human" + interaction_thread = f"{marker}_int_thread" + + thread_tg = f"{marker}_thread_tg" + thread_wa = f"{marker}_thread_wa" + thread_tg_human = f"{marker}_thread_tg_human" + thread_wa_thread_queue = f"{marker}_thread_wa_thread_queue" + + session_tg = f"{marker}_sess_tg" + session_wa = f"{marker}_sess_wa" + session_tg_human = f"{marker}_sess_tg_human" + session_thread_queue = f"{marker}_sess_thread_queue" + + session.add_all( + [ + Interaction( + interaction_id=interaction_tg, + channel="telegram", + subject=f"{marker} contained telegram", + customer_id=f"{marker}_cust_1", + queue_id=queue_tg, + priority=3, + status="closed", + assigned_to=None, + created_at="2040-01-01T09:00:00+00:00", + updated_at="2040-01-01T09:40:00+00:00", + ), + Interaction( + interaction_id=interaction_wa, + channel="whatsapp", + subject=f"{marker} whatsapp handoff", + customer_id=f"{marker}_cust_2", + queue_id=queue_wa, + priority=3, + status="in_progress", + assigned_to="agent_whatsapp", + created_at="2040-01-01T13:00:00+00:00", + updated_at="2040-01-01T13:30:00+00:00", + ), + Interaction( + interaction_id=interaction_tg_human, + channel="telegram", + subject=f"{marker} telegram with operator", + customer_id=f"{marker}_cust_3", + queue_id=queue_tg, + priority=3, + status="closed", + assigned_to="agent_telegram", + created_at="2040-01-02T10:00:00+00:00", + updated_at="2040-01-02T10:40:00+00:00", + ), + Interaction( + interaction_id=interaction_thread, + channel="whatsapp", + subject=f"{marker} thread queue fallback", + customer_id=f"{marker}_cust_4", + queue_id=None, + priority=3, + status="closed", + assigned_to=None, + created_at="2040-01-02T15:00:00+00:00", + updated_at="2040-01-02T15:15:00+00:00", + ), + TelegramThreadRow( + thread_id=thread_tg, + chat_id=f"{marker}_chat_tg", + interaction_id=interaction_tg, + telegram_user_id=f"{marker}_tg_user", + username=f"{marker}_tg", + display_name="AI Telegram", + queue_id=queue_tg, + status="closed", + claimed_by_user=None, + claimed_at=None, + ai_session_id=session_tg, + ai_state="closed", + ai_handoff_reason=None, + ai_last_model_at="2040-01-01T09:15:00+00:00", + last_message_at="2040-01-01T09:16:00+00:00", + last_message_preview="contained", + created_at="2040-01-01T09:00:00+00:00", + updated_at="2040-01-01T09:16:00+00:00", + ), + WhatsAppThreadRow( + thread_id=thread_wa, + chat_id=f"{marker}_chat_wa", + interaction_id=interaction_wa, + whatsapp_user_id=f"{marker}_wa_user", + phone_number="+77000000001", + display_name="AI WhatsApp", + queue_id=queue_wa, + is_group=False, + status="in_progress", + claimed_by_user="supervisor_wa", + claimed_at="2040-01-01T13:12:00+00:00", + ai_session_id=session_wa, + ai_state="human_owned", + ai_handoff_reason="requested by customer", + ai_last_model_at="2040-01-01T13:10:00+00:00", + last_message_at="2040-01-01T13:11:00+00:00", + last_message_preview="handoff", + created_at="2040-01-01T13:00:00+00:00", + updated_at="2040-01-01T13:12:00+00:00", + ), + TelegramThreadRow( + thread_id=thread_tg_human, + chat_id=f"{marker}_chat_tg_human", + interaction_id=interaction_tg_human, + telegram_user_id=f"{marker}_tg_user_human", + username=f"{marker}_tg_human", + display_name="AI Telegram Human", + queue_id=queue_tg, + status="closed", + claimed_by_user=None, + claimed_at=None, + ai_session_id=session_tg_human, + ai_state="closed", + ai_handoff_reason=None, + ai_last_model_at="2040-01-02T10:10:00+00:00", + last_message_at="2040-01-02T10:11:00+00:00", + last_message_preview="closed with operator", + created_at="2040-01-02T10:00:00+00:00", + updated_at="2040-01-02T10:11:00+00:00", + ), + WhatsAppThreadRow( + thread_id=thread_wa_thread_queue, + chat_id=f"{marker}_chat_wa_thread", + interaction_id=interaction_thread, + whatsapp_user_id=f"{marker}_wa_thread_user", + phone_number="+77000000002", + display_name="AI WhatsApp Thread Queue", + queue_id=queue_thread, + is_group=False, + status="closed", + claimed_by_user=None, + claimed_at=None, + ai_session_id=session_thread_queue, + ai_state="closed", + ai_handoff_reason=None, + ai_last_model_at="2040-01-02T15:10:00+00:00", + last_message_at="2040-01-02T15:11:00+00:00", + last_message_preview="thread fallback", + created_at="2040-01-02T15:00:00+00:00", + updated_at="2040-01-02T15:11:00+00:00", + ), + AISessionRow( + session_id=session_tg, + channel="telegram", + thread_id=thread_tg, + interaction_id=interaction_tg, + customer_id=f"{marker}_cust_1", + agent_profile="telegram_support", + language="ru", + status="closed", + summary_text="", + last_user_message_id=None, + last_ai_message_id=None, + handoff_reason=None, + created_at="2040-01-01T09:00:00+00:00", + updated_at="2040-01-01T09:18:00+00:00", + closed_at="2040-01-01T09:18:00+00:00", + ), + AISessionRow( + session_id=session_wa, + channel="whatsapp", + thread_id=thread_wa, + interaction_id=interaction_wa, + customer_id=f"{marker}_cust_2", + agent_profile="whatsapp_support", + language="ru", + status="human_owned", + summary_text="", + last_user_message_id=None, + last_ai_message_id=None, + handoff_reason="requested by customer", + created_at="2040-01-01T13:00:00+00:00", + updated_at="2040-01-01T13:12:00+00:00", + closed_at=None, + ), + AISessionRow( + session_id=session_tg_human, + channel="telegram", + thread_id=thread_tg_human, + interaction_id=interaction_tg_human, + customer_id=f"{marker}_cust_3", + agent_profile="telegram_support", + language="ru", + status="closed", + summary_text="", + last_user_message_id=None, + last_ai_message_id=None, + handoff_reason=None, + created_at="2040-01-02T10:00:00+00:00", + updated_at="2040-01-02T10:12:00+00:00", + closed_at="2040-01-02T10:12:00+00:00", + ), + AISessionRow( + session_id=session_thread_queue, + channel="whatsapp", + thread_id=thread_wa_thread_queue, + interaction_id=interaction_thread, + customer_id=f"{marker}_cust_4", + agent_profile="whatsapp_support", + language="ru", + status="closed", + summary_text="", + last_user_message_id=None, + last_ai_message_id=None, + handoff_reason=None, + created_at="2040-01-02T15:00:00+00:00", + updated_at="2040-01-02T15:12:00+00:00", + closed_at="2040-01-02T15:12:00+00:00", + ), + AITurnRow( + turn_id=f"{marker}_turn_1", + session_id=session_tg, + thread_id=thread_tg, + interaction_id=interaction_tg, + role="assistant", + source_type="model", + text="reply 1", + payload_json="{}", + model="stub", + finish_reason="stop", + latency_ms=100, + created_at="2040-01-01T09:05:00+00:00", + ), + AITurnRow( + turn_id=f"{marker}_turn_2", + session_id=session_tg, + thread_id=thread_tg, + interaction_id=interaction_tg, + role="assistant", + source_type="model", + text="reply 2", + payload_json="{}", + model="stub", + finish_reason="stop", + latency_ms=200, + created_at="2040-01-01T09:10:00+00:00", + ), + AITurnRow( + turn_id=f"{marker}_turn_3", + session_id=session_wa, + thread_id=thread_wa, + interaction_id=interaction_wa, + role="assistant", + source_type="model", + text="reply 3", + payload_json="{}", + model="stub", + finish_reason="stop", + latency_ms=900, + created_at="2040-01-01T13:05:00+00:00", + ), + AITurnRow( + turn_id=f"{marker}_turn_4", + session_id=session_wa, + thread_id=thread_wa, + interaction_id=interaction_wa, + role="assistant", + source_type="kb", + text="ignored", + payload_json="{}", + model="stub", + finish_reason="stop", + latency_ms=50, + created_at="2040-01-01T13:06:00+00:00", + ), + AITurnRow( + turn_id=f"{marker}_turn_5", + session_id=session_thread_queue, + thread_id=thread_wa_thread_queue, + interaction_id=interaction_thread, + role="assistant", + source_type="model", + text="reply 5", + payload_json="{}", + model="stub", + finish_reason="stop", + latency_ms=400, + created_at="2040-01-02T15:05:00+00:00", + ), + AITurnRow( + turn_id=f"{marker}_turn_6", + session_id=session_thread_queue, + thread_id=thread_wa_thread_queue, + interaction_id=interaction_thread, + role="user", + source_type="customer", + text="ignored user turn", + payload_json="{}", + model=None, + finish_reason=None, + latency_ms=999, + created_at="2040-01-02T15:04:00+00:00", + ), + ] + ) + session.commit() + return { + "from_ts": window_from, + "to_ts": window_to, + "queue_tg": queue_tg, + "queue_wa": queue_wa, + "queue_thread": queue_thread, + } + finally: + session.close() + + +def cleanup_ai_analytics_dataset(marker: str) -> None: + session = get_session() + try: + session.query(AITurnRow).filter(AITurnRow.turn_id.like(f"{marker}_turn_%")).delete(synchronize_session=False) + session.query(AISessionRow).filter(AISessionRow.session_id.like(f"{marker}_sess_%")).delete(synchronize_session=False) + session.query(TelegramThreadRow).filter(TelegramThreadRow.thread_id.like(f"{marker}_thread_%")).delete(synchronize_session=False) + session.query(WhatsAppThreadRow).filter(WhatsAppThreadRow.thread_id.like(f"{marker}_thread_%")).delete(synchronize_session=False) + session.query(Interaction).filter(Interaction.interaction_id.like(f"{marker}_int_%")).delete(synchronize_session=False) + session.commit() + finally: + session.close() + + +def test_ai_language_detection_prefers_kz_letters(): + assert ai_module._infer_language("Сәлем, көмек керек") == "kz" + assert ai_module._infer_language("Здравствуйте, нужна помощь") == "ru" + + +def test_voice_module_import_helper_returns_app_module(): + loaded = voice_module._app() + assert getattr(loaded, "__name__", "") == "services.ai_orchestrator_service.app" + assert hasattr(loaded, "_customer_id_is_real") + assert hasattr(loaded, "_interaction_request") + + +def test_voice_decision_reuses_recent_topic_instead_of_repeating_same_prompt(): + transcript_window = [ + SimpleNamespace(speaker="assistant", text=voice_module._voice_greeting("ru"), sequence_no=1), + SimpleNamespace(speaker="caller", text="График работы узнать", sequence_no=2), + SimpleNamespace( + speaker="assistant", + text="Подскажите, график работы какого филиала, адреса или города вас интересует?", + sequence_no=3, + ), + SimpleNamespace(speaker="caller", text="О каком запросе?", sequence_no=4), + ] + + decision = voice_module._voice_decision( + language="ru", + customer=None, + interaction=SimpleNamespace(interaction_id="int_voice_test"), + transcript_text="О каком запросе?", + transcript_window=transcript_window, + kb_results=[], + disclosure_required=False, + ) + + assert decision["intent"] == "clarification" + assert decision["needs_handoff"] is False + assert decision["reply_text"] != transcript_window[2].text + assert "цель звонка" in decision["reply_text"].lower() + assert "график работы" in decision["reply_text"].lower() + + +def test_voice_decision_handoffs_after_repeated_clarification_loop(): + transcript_window = [ + SimpleNamespace(speaker="assistant", text=voice_module._voice_greeting("ru"), sequence_no=1), + SimpleNamespace(speaker="caller", text="Привет", sequence_no=2), + SimpleNamespace( + speaker="assistant", + text="Чтобы помочь быстрее, скажите в двух словах, что вам нужно: график работы, статус заявки, тариф или оператор.", + sequence_no=3, + ), + SimpleNamespace(speaker="caller", text="Угу", sequence_no=4), + SimpleNamespace( + speaker="assistant", + text="Сейчас уточняю цель звонка. Скажите коротко, что именно нужно: график работы, статус заявки, тариф или оператор.", + sequence_no=5, + ), + SimpleNamespace(speaker="caller", text="Не понял", sequence_no=6), + SimpleNamespace( + speaker="assistant", + text="Сейчас уточняю цель звонка. Скажите коротко, что именно нужно: график работы, статус заявки, тариф или оператор.", + sequence_no=7, + ), + SimpleNamespace(speaker="caller", text="О каком запросе?", sequence_no=8), + SimpleNamespace( + speaker="assistant", + text="Сейчас уточняю цель звонка. Скажите коротко, что именно нужно: график работы, статус заявки, тариф или оператор.", + sequence_no=9, + ), + ] + + decision = voice_module._voice_decision( + language="ru", + customer=None, + interaction=SimpleNamespace(interaction_id="int_voice_test"), + transcript_text="О каком запросе?", + transcript_window=transcript_window, + kb_results=[], + disclosure_required=False, + ) + + assert decision["intent"] == "handoff_request" + assert decision["needs_handoff"] is True + assert "перевожу на оператора" in decision["reply_text"].lower() + assert "нескольких попыток" in decision["handoff_reason"].lower() + + +def test_voice_kb_search_matches_relaxed_phrase_and_returns_kb_answer(): + query = _u(r"\u0425\u043e\u0447\u0443 \u0443\u0437\u043d\u0430\u0442\u044c \u0442\u0430\u0440\u0438\u0444 relaxbasicx") + seed_kb_article( + "Tariff relaxbasicx", + "Tariff relaxbasicx activates after the request is confirmed.", + "relaxbasicx", + ) + + session = get_session() + try: + kb_results = ai_module._kb_search(session, query) + finally: + session.close() + + assert kb_results + assert kb_results[0].title == "Tariff relaxbasicx" + + decision = voice_module._voice_decision( + language="ru", + customer=None, + interaction=SimpleNamespace(interaction_id="int_voice_kb_relaxed"), + transcript_text=query, + transcript_window=[SimpleNamespace(speaker="caller", text=query, sequence_no=1)], + kb_results=kb_results, + disclosure_required=False, + ) + + assert decision["intent"] == "kb_answer" + assert decision["needs_handoff"] is False + assert "relaxbasicx" in decision["reply_text"].lower() + + +def test_ai_enqueue_creates_outbound_ai_reply_and_delivery_flow(monkeypatch): + monkeypatch.setenv("AI_TELEGRAM_ENABLED", "1") + monkeypatch.setenv("AI_PROVIDER", "stub") + monkeypatch.setattr(telegram_module, "_ai_enqueue_request", lambda thread_id, trigger_message_id: None) + monkeypatch.setattr(telegram_module, "_start_telegram_reply_delivery", lambda message_id: None) + + telegram_client = TestClient(telegram_app) + interaction_client = TestClient(interaction_app) + ai_client = TestClient(ai_app) + patch_ai_internal_calls(monkeypatch, telegram_client, interaction_client) + seed_kb_article("Тариф Basic", "Тариф basic активируется за 5 минут после подтверждения заявки.", "basic") + + created = create_inbound_thread(telegram_client, "chat_ai_reply", "basic") + enqueue = ai_client.post( + f"/ai/telegram/threads/{created['thread_id']}/enqueue", + headers=admin_headers(), + json={"trigger_message_id": created["message_id"]}, + ) + assert enqueue.status_code == 200 + assert enqueue.json()["status"] == "done" + + summary = fetch_ai_summary(telegram_client, created["thread_id"]) + assert summary.status_code == 200 + assert summary.json() is None + + messages = telegram_client.get( + f"/integrations/telegram/threads/{created['thread_id']}/messages", + headers=admin_headers(), + ) + assert messages.status_code == 200 + assert messages.json()[-1]["author_type"] == "ai" + assert messages.json()[-1]["delivery_status"] == "pending" + + monkeypatch.setattr( + telegram_module, + "_send_telegram_message", + lambda chat_id, text: {"ok": True, "result": {"message_id": 5551, "chat": {"id": chat_id}, "text": text}}, + ) + telegram_module._deliver_pending_telegram_reply(messages.json()[-1]["message_id"]) + + delivered = telegram_client.get( + f"/integrations/telegram/threads/{created['thread_id']}/messages", + headers=admin_headers(), + ) + assert delivered.status_code == 200 + assert delivered.json()[-1]["delivery_status"] == "sent" + assert delivered.json()[-1]["telegram_message_id_external"] == "5551" + + +def test_ai_enqueue_relaxed_kb_search_answers_phrase_query(monkeypatch): + monkeypatch.setenv("AI_TELEGRAM_ENABLED", "1") + monkeypatch.setenv("AI_PROVIDER", "stub") + monkeypatch.setattr(telegram_module, "_ai_enqueue_request", lambda thread_id, trigger_message_id: None) + monkeypatch.setattr(telegram_module, "_start_telegram_reply_delivery", lambda message_id: None) + + telegram_client = TestClient(telegram_app) + interaction_client = TestClient(interaction_app) + ai_client = TestClient(ai_app) + patch_ai_internal_calls(monkeypatch, telegram_client, interaction_client) + seed_kb_article( + "Tariff tgrelaxx", + "Tariff tgrelaxx activates after the request is confirmed.", + "tgrelaxx", + ) + + created = create_inbound_thread( + telegram_client, + "chat_ai_relaxed_kb", + _u(r"\u0425\u043e\u0447\u0443 \u0443\u0437\u043d\u0430\u0442\u044c \u0442\u0430\u0440\u0438\u0444 tgrelaxx"), + ) + enqueue = ai_client.post( + f"/ai/telegram/threads/{created['thread_id']}/enqueue", + headers=admin_headers(), + json={"trigger_message_id": created["message_id"]}, + ) + assert enqueue.status_code == 200 + assert enqueue.json()["status"] == "done" + + messages = telegram_client.get( + f"/integrations/telegram/threads/{created['thread_id']}/messages", + headers=admin_headers(), + ) + assert messages.status_code == 200 + assert messages.json()[-1]["author_type"] == "ai" + assert "tgrelaxx" in messages.json()[-1]["text"].lower() + + +def test_ai_enqueue_uses_language_filtered_kb_localization(monkeypatch): + monkeypatch.setenv("AI_TELEGRAM_ENABLED", "1") + monkeypatch.setenv("AI_PROVIDER", "stub") + monkeypatch.setattr(telegram_module, "_ai_enqueue_request", lambda thread_id, trigger_message_id: None) + monkeypatch.setattr(telegram_module, "_start_telegram_reply_delivery", lambda message_id: None) + monkeypatch.setattr(ai_module, "_infer_language", lambda text: "kz") + + telegram_client = TestClient(telegram_app) + interaction_client = TestClient(interaction_app) + ai_client = TestClient(ai_app) + patch_ai_internal_calls(monkeypatch, telegram_client, interaction_client) + + seeded = seed_kb_article( + "Localized sharedkbx RU", + "rulocalizedanswerx", + "sharedkbx", + language="ru", + ) + seed_kb_article( + "Localized sharedkbx KZ", + "kzlocalizedanswerx", + "sharedkbx", + language="kz", + article_group_id=seeded["article_group_id"], + ) + + created = create_inbound_thread(telegram_client, "chat_ai_kz_localized_kb", "sharedkbx") + enqueue = ai_client.post( + f"/ai/telegram/threads/{created['thread_id']}/enqueue", + headers=admin_headers(), + json={"trigger_message_id": created["message_id"]}, + ) + assert enqueue.status_code == 200 + assert enqueue.json()["status"] == "done" + + messages = telegram_client.get( + f"/integrations/telegram/threads/{created['thread_id']}/messages", + headers=admin_headers(), + ) + assert messages.status_code == 200 + reply_text = messages.json()[-1]["text"].lower() + assert "kzlocalizedanswerx" in reply_text + assert "rulocalizedanswerx" not in reply_text + + +def test_ai_enqueue_deduplicates_same_trigger_message(monkeypatch): + monkeypatch.setenv("AI_TELEGRAM_ENABLED", "1") + monkeypatch.setenv("AI_PROVIDER", "stub") + monkeypatch.setattr(telegram_module, "_ai_enqueue_request", lambda thread_id, trigger_message_id: None) + monkeypatch.setattr(telegram_module, "_start_telegram_reply_delivery", lambda message_id: None) + + telegram_client = TestClient(telegram_app) + interaction_client = TestClient(interaction_app) + ai_client = TestClient(ai_app) + patch_ai_internal_calls(monkeypatch, telegram_client, interaction_client) + seed_kb_article("FAQ handoff", "Ответ по FAQ для повторного запроса.", "faq-dedupe") + + created = create_inbound_thread(telegram_client, "chat_ai_dedupe", "faq-dedupe") + first = ai_client.post( + f"/ai/telegram/threads/{created['thread_id']}/enqueue", + headers=admin_headers(), + json={"trigger_message_id": created["message_id"]}, + ) + assert first.status_code == 200 + second = ai_client.post( + f"/ai/telegram/threads/{created['thread_id']}/enqueue", + headers=admin_headers(), + json={"trigger_message_id": created["message_id"]}, + ) + assert second.status_code == 200 + assert second.json()["deduplicated"] is True + deliver_latest_pending_message(monkeypatch, telegram_client, created["thread_id"], external_id=7002) + + session = get_session() + try: + jobs = session.execute( + select(AIJobRow).where(AIJobRow.thread_id == created["thread_id"]) + ).scalars().all() + assert len(jobs) == 1 + assert jobs[0].trigger_message_id == created["message_id"] + finally: + session.close() + + +def test_ai_handoff_required_leaves_thread_available_for_claim(monkeypatch): + patch_interaction_request(monkeypatch) + monkeypatch.setenv("AI_TELEGRAM_ENABLED", "1") + monkeypatch.setenv("AI_PROVIDER", "stub") + monkeypatch.setattr(telegram_module, "_ai_enqueue_request", lambda thread_id, trigger_message_id: None) + monkeypatch.setattr(telegram_module, "_start_telegram_reply_delivery", lambda message_id: None) + + telegram_client = TestClient(telegram_app) + interaction_client = TestClient(interaction_app) + ai_client = TestClient(ai_app) + patch_ai_internal_calls(monkeypatch, telegram_client, interaction_client) + + created = create_inbound_thread(telegram_client, "chat_ai_handoff", "Хочу человека, соедините с оператором") + enqueue = ai_client.post( + f"/ai/telegram/threads/{created['thread_id']}/enqueue", + headers=admin_headers(), + json={"trigger_message_id": created["message_id"]}, + ) + assert enqueue.status_code == 200 + assert enqueue.json()["status"] == "handoff_required" + + thread = telegram_client.get( + f"/integrations/telegram/threads/{created['thread_id']}", + headers=admin_headers(), + ) + assert thread.status_code == 200 + assert thread.json()["ai_state"] == "handoff_required" + assert thread.json()["claimed_by_user"] is None + + summary = fetch_ai_summary(telegram_client, created["thread_id"]) + assert summary.status_code == 200 + payload = summary.json() + assert payload["thread_id"] == created["thread_id"] + assert payload["status_label"] == "AI передал без ответа" + assert payload["status_tone"] == "handoff" + assert payload["customer_request_text"] == created["text"] + assert payload["ai_outcome_text"] == "AI передал диалог оператору без ответа клиенту." + assert payload["handoff_reason"] + assert payload["recommended_next_step"] == "Заберите чат и ответьте клиенту вручную." + + claimed = telegram_client.post( + f"/integrations/telegram/threads/{created['thread_id']}/claim", + headers=operator_headers("operator_summary"), + ) + assert claimed.status_code == 200 + assert claimed.json()["ai_state"] == "human_owned" + + summary_after_claim = fetch_ai_summary( + telegram_client, + created["thread_id"], + headers=operator_headers("operator_summary"), + ) + assert summary_after_claim.status_code == 200 + claim_payload = summary_after_claim.json() + assert claim_payload["session_id"] == payload["session_id"] + assert claim_payload["status_label"] == payload["status_label"] + assert claim_payload["status_tone"] == payload["status_tone"] + assert claim_payload["handoff_reason"] == payload["handoff_reason"] + assert claim_payload["recommended_next_step"] == "Продолжайте диалог вручную; AI больше не отвечает в этот thread." + + returned = telegram_client.post( + f"/integrations/telegram/threads/{created['thread_id']}/return-to-ai", + headers=operator_headers("operator_summary"), + ) + assert returned.status_code == 200 + + summary_after_return = fetch_ai_summary(telegram_client, created["thread_id"]) + assert summary_after_return.status_code == 200 + assert summary_after_return.json() is None + + session = get_session() + try: + thread = session.execute( + select(TelegramThreadRow).where(TelegramThreadRow.thread_id == created["thread_id"]) + ).scalar_one() + ai_session = session.execute( + select(AISessionRow).where(AISessionRow.session_id == payload["session_id"]) + ).scalar_one() + assert thread.ai_state == "queued" + assert thread.ai_handoff_reason is None + assert ai_session.status == "active" + assert ai_session.handoff_reason is None + finally: + session.close() + + +def test_select_trigger_message_ignores_non_customer_trigger(): + session = get_session() + try: + now = utc_now_iso() + thread_id = new_id("tgt") + interaction_id = new_id("int") + customer_message_id = new_id("tgm") + system_message_id = new_id("tgm") + session.add( + TelegramThreadRow( + thread_id=thread_id, + chat_id="chat_select_trigger", + interaction_id=interaction_id, + telegram_user_id="tg_select_trigger", + username="select_trigger_user", + display_name="Select Trigger", + queue_id="q_telegram", + status="new", + claimed_by_user=None, + claimed_at=None, + ai_session_id=None, + ai_state="queued", + ai_handoff_reason=None, + ai_last_model_at=None, + last_message_at=now, + last_message_preview="latest", + created_at=now, + updated_at=now, + ) + ) + session.add( + TelegramMessageRow( + message_id=customer_message_id, + thread_id=thread_id, + interaction_id=interaction_id, + chat_id="chat_select_trigger", + text="Customer message", + customer_external_id="cus_select_trigger", + direction="inbound", + telegram_message_id_external="1001", + operator_user=None, + author_type="customer", + author_id="tg_select_trigger", + delivery_status="received", + payload_json="{}", + created_at=now, + ) + ) + session.add( + TelegramMessageRow( + message_id=system_message_id, + thread_id=thread_id, + interaction_id=interaction_id, + chat_id="chat_select_trigger", + text="[Unsupported Telegram content: photo]", + customer_external_id="cus_select_trigger", + direction="system", + telegram_message_id_external="1002", + operator_user=None, + author_type="system", + author_id="tg_select_trigger", + delivery_status="received", + payload_json="{}", + created_at=now, + ) + ) + session.commit() + + selected = ai_module._select_trigger_message( + session, + thread_id=thread_id, + trigger_message_id=system_message_id, + ) + + assert selected is not None + assert selected.message_id == customer_message_id + finally: + session.close() + + +def test_ai_reply_conflict_marks_job_human_owned_instead_of_error(monkeypatch): + monkeypatch.setenv("AI_TELEGRAM_ENABLED", "1") + monkeypatch.setenv("AI_PROVIDER", "stub") + monkeypatch.setattr(telegram_module, "_ai_enqueue_request", lambda thread_id, trigger_message_id: None) + monkeypatch.setattr(telegram_module, "_start_telegram_reply_delivery", lambda message_id: None) + patch_interaction_request(monkeypatch) + + telegram_client = TestClient(telegram_app) + interaction_client = TestClient(interaction_app) + ai_client = TestClient(ai_app) + + def fake_interaction_request(method: str, path: str, *, payload: dict | None = None) -> dict: + response = interaction_client.request(method, path, json=payload, headers=admin_headers()) + response.raise_for_status() + return response.json() + + created = create_inbound_thread(telegram_client, "chat_ai_conflict", "basic") + + def fake_telegram_request(method: str, path: str, *, payload: dict | None = None) -> dict: + if path.endswith("/ai/reply"): + claimed = telegram_client.post( + f"/integrations/telegram/threads/{created['thread_id']}/claim", + headers=operator_headers("operator_race"), + ) + assert claimed.status_code == 200 + request = httpx.Request(method, f"http://testserver{path}", json=payload) + response = httpx.Response( + 409, + request=request, + json={"detail": "Telegram thread is owned by a human operator"}, + ) + raise httpx.HTTPStatusError("409 Conflict", request=request, response=response) + + response = telegram_client.request(method, path, json=payload, headers=admin_headers()) + response.raise_for_status() + return response.json() + + monkeypatch.setattr(ai_module, "_telegram_request", fake_telegram_request) + monkeypatch.setattr(ai_module, "_interaction_request", fake_interaction_request) + seed_kb_article("Basic plan", "Basic answer.", "basic") + + enqueue = ai_client.post( + f"/ai/telegram/threads/{created['thread_id']}/enqueue", + headers=admin_headers(), + json={"trigger_message_id": created["message_id"]}, + ) + assert enqueue.status_code == 200 + assert enqueue.json()["status"] == "human_owned" + + session = get_session() + try: + thread = session.execute( + select(TelegramThreadRow).where(TelegramThreadRow.thread_id == created["thread_id"]) + ).scalar_one() + ai_session = session.execute( + select(AISessionRow).where(AISessionRow.session_id == thread.ai_session_id) + ).scalar_one() + job = session.execute( + select(AIJobRow).where(AIJobRow.thread_id == created["thread_id"]).order_by(AIJobRow.id.desc()) + ).scalar_one() + assert thread.ai_state == "human_owned" + assert thread.claimed_by_user == "operator_race" + assert ai_session.status == "human_owned" + assert job.status == "done" + assert thread.ai_handoff_reason is None + finally: + session.close() + + +def test_ai_always_reply_mode_answers_greeting_without_handoff(monkeypatch): + monkeypatch.setenv("AI_TELEGRAM_ENABLED", "1") + monkeypatch.setenv("AI_TELEGRAM_ALWAYS_REPLY", "1") + monkeypatch.setenv("AI_PROVIDER", "stub") + monkeypatch.setattr(telegram_module, "_ai_enqueue_request", lambda thread_id, trigger_message_id: None) + monkeypatch.setattr(telegram_module, "_start_telegram_reply_delivery", lambda message_id: None) + + telegram_client = TestClient(telegram_app) + interaction_client = TestClient(interaction_app) + ai_client = TestClient(ai_app) + patch_ai_internal_calls(monkeypatch, telegram_client, interaction_client) + + created = create_inbound_thread(telegram_client, "chat_ai_always_reply", "Привет") + enqueue = ai_client.post( + f"/ai/telegram/threads/{created['thread_id']}/enqueue", + headers=admin_headers(), + json={"trigger_message_id": created["message_id"]}, + ) + assert enqueue.status_code == 200 + assert enqueue.json()["status"] == "done" + + thread = telegram_client.get( + f"/integrations/telegram/threads/{created['thread_id']}", + headers=admin_headers(), + ) + assert thread.status_code == 200 + assert thread.json()["ai_state"] == "active" + assert thread.json()["claimed_by_user"] is None + + messages = telegram_client.get( + f"/integrations/telegram/threads/{created['thread_id']}/messages", + headers=admin_headers(), + ) + assert messages.status_code == 200 + assert messages.json()[-1]["author_type"] == "ai" + assert messages.json()[-1]["text"] + + summary = fetch_ai_summary(telegram_client, created["thread_id"]) + assert summary.status_code == 200 + assert summary.json() is None + + +def test_claiming_ai_thread_transfers_to_human_and_blocks_auto_replies(monkeypatch): + monkeypatch.setenv("AI_TELEGRAM_ENABLED", "1") + monkeypatch.setenv("AI_PROVIDER", "stub") + enqueue_calls: list[tuple[str, str | None]] = [] + monkeypatch.setattr(telegram_module, "_ai_enqueue_request", lambda thread_id, trigger_message_id: enqueue_calls.append((thread_id, trigger_message_id))) + monkeypatch.setattr(telegram_module, "_start_telegram_reply_delivery", lambda message_id: None) + patch_interaction_request(monkeypatch) + + telegram_client = TestClient(telegram_app) + interaction_client = TestClient(interaction_app) + ai_client = TestClient(ai_app) + patch_ai_internal_calls(monkeypatch, telegram_client, interaction_client) + seed_kb_article("Takeover FAQ", "FAQ answer before human takeover.", "takeover") + + created = create_inbound_thread(telegram_client, "chat_ai_takeover", "takeover") + enqueue_calls.clear() + ai_client.post( + f"/ai/telegram/threads/{created['thread_id']}/enqueue", + headers=admin_headers(), + json={"trigger_message_id": created["message_id"]}, + ) + deliver_latest_pending_message(monkeypatch, telegram_client, created["thread_id"], external_id=7003) + claimed = telegram_client.post( + f"/integrations/telegram/threads/{created['thread_id']}/claim", + headers=operator_headers("operator_ai"), + ) + assert claimed.status_code == 200 + assert claimed.json()["ai_state"] == "human_owned" + enqueue_calls.clear() + + follow_up = telegram_client.post( + "/integrations/telegram/webhook", + json={ + "chat_id": "chat_ai_takeover", + "text": "ещё вопрос после takeover", + "payload": {"telegram_user_id": "user-chat_ai_takeover", "username": "takeover_user"}, + }, + ) + assert follow_up.status_code == 200 + time.sleep(0.05) + assert enqueue_calls == [] + + session = get_session() + try: + thread = session.execute( + select(TelegramThreadRow).where(TelegramThreadRow.thread_id == created["thread_id"]) + ).scalar_one() + ai_session = session.execute( + select(AISessionRow).where(AISessionRow.session_id == thread.ai_session_id) + ).scalar_one() + assert thread.ai_state == "human_owned" + assert ai_session.status == "human_owned" + finally: + session.close() + + +def test_ai_summary_is_hidden_after_close(monkeypatch): + patch_interaction_request(monkeypatch) + monkeypatch.setenv("AI_TELEGRAM_ENABLED", "1") + monkeypatch.setenv("AI_PROVIDER", "stub") + monkeypatch.setattr(telegram_module, "_ai_enqueue_request", lambda thread_id, trigger_message_id: None) + monkeypatch.setattr(telegram_module, "_start_telegram_reply_delivery", lambda message_id: None) + + telegram_client = TestClient(telegram_app) + interaction_client = TestClient(interaction_app) + ai_client = TestClient(ai_app) + patch_ai_internal_calls(monkeypatch, telegram_client, interaction_client) + + created = create_inbound_thread(telegram_client, "chat_ai_summary_close", "Хочу поговорить с человеком") + enqueue = ai_client.post( + f"/ai/telegram/threads/{created['thread_id']}/enqueue", + headers=admin_headers(), + json={"trigger_message_id": created["message_id"]}, + ) + assert enqueue.status_code == 200 + assert enqueue.json()["status"] == "handoff_required" + + claimed = telegram_client.post( + f"/integrations/telegram/threads/{created['thread_id']}/claim", + headers=operator_headers("operator_close_summary"), + ) + assert claimed.status_code == 200 + + closed = telegram_client.post( + f"/integrations/telegram/threads/{created['thread_id']}/close", + headers=operator_headers("operator_close_summary"), + ) + assert closed.status_code == 200 + + summary = fetch_ai_summary(telegram_client, created["thread_id"]) + assert summary.status_code == 200 + assert summary.json() is None + + +def test_ai_summary_marks_answered_when_ai_replied_before_handoff(monkeypatch): + patch_interaction_request(monkeypatch) + monkeypatch.setenv("AI_TELEGRAM_ENABLED", "1") + monkeypatch.setenv("AI_TELEGRAM_ALWAYS_REPLY", "1") + monkeypatch.setenv("AI_PROVIDER", "stub") + monkeypatch.setattr(telegram_module, "_ai_enqueue_request", lambda thread_id, trigger_message_id: None) + monkeypatch.setattr(telegram_module, "_start_telegram_reply_delivery", lambda message_id: None) + + telegram_client = TestClient(telegram_app) + interaction_client = TestClient(interaction_app) + ai_client = TestClient(ai_app) + patch_ai_internal_calls(monkeypatch, telegram_client, interaction_client) + + created = create_inbound_thread(telegram_client, "chat_ai_summary_answered", "Привет") + enqueue = ai_client.post( + f"/ai/telegram/threads/{created['thread_id']}/enqueue", + headers=admin_headers(), + json={"trigger_message_id": created["message_id"]}, + ) + assert enqueue.status_code == 200 + assert enqueue.json()["status"] == "done" + + handoff = telegram_client.post( + f"/integrations/telegram/threads/{created['thread_id']}/ai/handoff", + headers=admin_headers(), + json={ + "reason": "Нужен оператор после автоответа.", + "agent_profile": "telegram_support", + "trigger_message_id": created["message_id"], + "payload": {}, + }, + ) + assert handoff.status_code == 200 + + summary = fetch_ai_summary(telegram_client, created["thread_id"]) + assert summary.status_code == 200 + payload = summary.json() + assert payload["status_label"] == "AI ответил клиенту" + assert payload["status_tone"] == "answered" + assert payload["ai_outcome_text"] + + +def test_ai_analytics_overview_aggregates_mixed_telegram_and_whatsapp_sessions(): + marker = f"aiov_{new_id('seed')}" + seeded = seed_ai_analytics_dataset(marker) + ai_client = TestClient(ai_app) + + try: + response = ai_client.get( + "/ai/analytics/overview", + params={ + "from_ts": seeded["from_ts"], + "to_ts": seeded["to_ts"], + "channel": "all", + }, + headers=admin_headers(), + ) + + assert response.status_code == 200 + payload = response.json() + assert payload["totals"]["sessions_started"] == 4 + assert payload["totals"]["sessions_contained"] == 2 + assert payload["totals"]["sessions_handoff"] == 1 + assert payload["totals"]["sessions_closed"] == 3 + assert payload["totals"]["sessions_closed_without_operator"] == 2 + assert payload["totals"]["assistant_turns"] == 4 + assert payload["metrics"]["containment_rate"] == 50.0 + assert payload["metrics"]["handoff_rate"] == 25.0 + assert payload["metrics"]["ai_latency_avg_ms"] == 400.0 + assert payload["metrics"]["ai_latency_p95_ms"] == 900.0 + assert payload["metrics"]["closed_without_operator_rate"] == 66.67 + assert payload["metrics"]["human_touched_rate"] == 50.0 + assert payload["coverage"]["sessions_with_interaction_id"] == 4 + assert payload["coverage"]["sessions_with_queue_id"] == 4 + assert payload["coverage"]["sessions_with_latency_turns"] == 3 + assert payload["coverage"]["sessions_with_terminal_state"] == 4 + assert payload["coverage"]["sessions_with_handoff_reason"] == 1 + + by_channel = {item["channel"]: item for item in payload["breakdowns"]["by_channel"]} + assert by_channel["telegram"]["sessions_started"] == 2 + assert by_channel["telegram"]["containment_rate"] == 50.0 + assert by_channel["telegram"]["human_touched_sessions"] == 1 + assert by_channel["telegram"]["ai_latency_avg_ms"] == 150.0 + assert by_channel["whatsapp"]["sessions_started"] == 2 + assert by_channel["whatsapp"]["handoff_rate"] == 50.0 + assert by_channel["whatsapp"]["closed_without_operator_rate"] == 100.0 + assert by_channel["whatsapp"]["ai_latency_avg_ms"] == 650.0 + + by_outcome = {item["outcome"]: item for item in payload["breakdowns"]["by_outcome"]} + assert by_outcome["contained"]["sessions"] == 2 + assert by_outcome["handoff"]["sessions"] == 1 + assert by_outcome["human_touched"]["sessions"] == 1 + assert by_outcome["closed_without_operator"]["sessions"] == 2 + + by_reason = {item["reason_key"]: item for item in payload["breakdowns"]["by_handoff_reason"]} + assert by_reason["requested_human"]["sessions"] == 1 + assert by_reason["requested_human"]["label"] + finally: + cleanup_ai_analytics_dataset(marker) + + +def test_ai_analytics_overview_filters_by_queue_and_channel_and_handles_unsupported_channels(): + marker = f"aiflt_{new_id('seed')}" + seeded = seed_ai_analytics_dataset(marker) + ai_client = TestClient(ai_app) + + try: + queue_filtered = ai_client.get( + "/ai/analytics/overview", + params={ + "from_ts": seeded["from_ts"], + "to_ts": seeded["to_ts"], + "queue_id": seeded["queue_thread"], + "channel": "whatsapp", + }, + headers=admin_headers(), + ) + assert queue_filtered.status_code == 200 + queue_payload = queue_filtered.json() + assert queue_payload["totals"]["sessions_started"] == 1 + assert queue_payload["totals"]["sessions_contained"] == 1 + assert queue_payload["coverage"]["sessions_with_queue_id"] == 1 + assert queue_payload["breakdowns"]["by_channel"][0]["channel"] == "whatsapp" + + telegram_only = ai_client.get( + "/ai/analytics/overview", + params={ + "from_ts": seeded["from_ts"], + "to_ts": seeded["to_ts"], + "channel": "telegram", + "queue_id": seeded["queue_tg"], + }, + headers=admin_headers(), + ) + assert telegram_only.status_code == 200 + assert telegram_only.json()["totals"]["sessions_started"] == 2 + + unsupported = ai_client.get( + "/ai/analytics/overview", + params={ + "from_ts": seeded["from_ts"], + "to_ts": seeded["to_ts"], + "channel": "voice", + }, + headers=admin_headers(), + ) + assert unsupported.status_code == 200 + unsupported_payload = unsupported.json() + assert unsupported_payload["totals"]["sessions_started"] == 0 + assert unsupported_payload["breakdowns"]["by_channel"] == [] + finally: + cleanup_ai_analytics_dataset(marker) + + +def test_ai_analytics_timeseries_returns_stable_points_and_ignores_non_model_turns(): + marker = f"aits_{new_id('seed')}" + seeded = seed_ai_analytics_dataset(marker) + ai_client = TestClient(ai_app) + + try: + response = ai_client.get( + "/ai/analytics/timeseries", + params={ + "from_ts": seeded["from_ts"], + "to_ts": seeded["to_ts"], + "metric": "ai_latency_avg_ms", + "interval": "day", + "channel": "whatsapp", + "queue_id": seeded["queue_thread"], + }, + headers=admin_headers(), + ) + assert response.status_code == 200 + payload = response.json() + assert payload["metric"] == "ai_latency_avg_ms" + assert payload["interval"] == "day" + assert len(payload["points"]) == 2 + assert payload["points"][0]["value"] is None + assert payload["points"][0]["sessions"] == 0 + assert payload["points"][0]["assistant_turns"] == 0 + assert payload["points"][1]["value"] == 400.0 + assert payload["points"][1]["sessions"] == 1 + assert payload["points"][1]["assistant_turns"] == 1 + + latency_response = ai_client.get( + "/ai/analytics/timeseries", + params={ + "from_ts": seeded["from_ts"], + "to_ts": seeded["to_ts"], + "metric": "ai_latency_avg_ms", + "interval": "day", + "channel": "voice", + }, + headers=admin_headers(), + ) + assert latency_response.status_code == 200 + latency_payload = latency_response.json() + assert latency_payload["points"] == [] + + human_touched = ai_client.get( + "/ai/analytics/timeseries", + params={ + "from_ts": seeded["from_ts"], + "to_ts": seeded["to_ts"], + "metric": "human_touched_rate", + "interval": "day", + "channel": "all", + }, + headers=admin_headers(), + ) + assert human_touched.status_code == 200 + human_touched_payload = human_touched.json() + assert human_touched_payload["metric"] == "human_touched_rate" + assert [point["value"] for point in human_touched_payload["points"]] == [50.0, 50.0] + finally: + cleanup_ai_analytics_dataset(marker) + + +def test_ai_analytics_drilldown_filters_by_slice_reason_status_queue_channel_and_query(): + marker = f"aidd_{new_id('seed')}" + seeded = seed_ai_analytics_dataset(marker) + ai_client = TestClient(ai_app) + + try: + handoff = ai_client.get( + "/ai/analytics/drilldown", + params={ + "from_ts": seeded["from_ts"], + "to_ts": seeded["to_ts"], + "slice": "handoff", + "reason_key": "requested_human", + "status": "human_owned", + "channel": "whatsapp", + "queue_id": seeded["queue_wa"], + "q": f"{marker}_sess_wa", + "sort_by": "updated_at", + "sort_dir": "desc", + }, + headers=admin_headers(), + ) + assert handoff.status_code == 200 + payload = handoff.json() + assert payload["total"] == 1 + assert payload["filters"]["slice"] == "handoff" + assert payload["filters"]["reason_key"] == "requested_human" + assert payload["filters"]["status"] == "human_owned" + assert payload["filters"]["q"] == f"{marker}_sess_wa" + assert payload["filters"]["sort_by"] == "updated_at" + assert payload["filters"]["sort_dir"] == "desc" + assert payload["items"][0]["session_id"] == f"{marker}_sess_wa" + assert payload["items"][0]["reason_key"] == "requested_human" + assert payload["items"][0]["status"] == "human_owned" + + all_sorted = ai_client.get( + "/ai/analytics/drilldown", + params={ + "from_ts": seeded["from_ts"], + "to_ts": seeded["to_ts"], + "slice": "all", + "sort_by": "ai_latency_avg_ms", + "sort_dir": "desc", + }, + headers=admin_headers(), + ) + assert all_sorted.status_code == 200 + sorted_payload = all_sorted.json() + assert sorted_payload["items"][0]["session_id"] == f"{marker}_sess_wa" + assert sorted_payload["items"][0]["ai_latency_avg_ms"] == 900.0 + finally: + cleanup_ai_analytics_dataset(marker) + + +def test_ai_analytics_session_detail_is_metadata_only_and_reason_taxonomy_supports_manual_claim(): + marker = f"aidetail_{new_id('seed')}" + seeded = seed_ai_analytics_dataset(marker) + ai_client = TestClient(ai_app) + + session = get_session() + try: + thread = session.execute( + select(TelegramThreadRow).where(TelegramThreadRow.thread_id == f"{marker}_thread_tg_human") + ).scalar_one() + thread.claimed_by_user = "manual_takeover" + thread.ai_handoff_reason = None + session.commit() + finally: + session.close() + + try: + manual_claim = ai_client.get( + "/ai/analytics/drilldown", + params={ + "from_ts": seeded["from_ts"], + "to_ts": seeded["to_ts"], + "slice": "human_touched", + "q": f"{marker}_sess_tg_human", + }, + headers=admin_headers(), + ) + assert manual_claim.status_code == 200 + manual_payload = manual_claim.json() + assert manual_payload["total"] == 1 + assert manual_payload["items"][0]["reason_key"] == "manual_claim" + assert manual_payload["items"][0]["reason_label"] + + detail = ai_client.get( + f"/ai/analytics/sessions/{marker}_sess_wa", + headers=admin_headers(), + ) + assert detail.status_code == 200 + detail_payload = detail.json() + assert detail_payload["session"]["session_id"] == f"{marker}_sess_wa" + assert detail_payload["session"]["reason_key"] == "requested_human" + assert detail_payload["interaction"]["interaction_id"] == f"{marker}_int_wa" + assert "summary_text" not in detail_payload["session"] + assert detail_payload["timeline"] + assert all("text" not in event for event in detail_payload["timeline"]) + assert all("summary_text" not in event for event in detail_payload["timeline"]) + finally: + cleanup_ai_analytics_dataset(marker) + + diff --git a/tests/test_ai_voice_media_runtime.py b/tests/test_ai_voice_media_runtime.py new file mode 100644 index 0000000..f8c32d6 --- /dev/null +++ b/tests/test_ai_voice_media_runtime.py @@ -0,0 +1,514 @@ +import asyncio +import threading +import time +import uuid + +from services.ai_voice_runtime_service.audiosocket import ( + AUDIO_SOCKET_PACKET_PCM16, + AUDIO_SOCKET_PACKET_UUID, + EnergyVAD, + encode_audio_packet, + encode_packet, + normalize_media_uuid, + read_packet, + resample_pcm16le, +) +from services.ai_voice_runtime_service.media_runtime import AudioSocketMediaRuntime, MediaActor, MediaRegistration +from services.ai_voice_runtime_service.providers.asr import ASRProvider, ASRTranscription +from services.ai_voice_runtime_service.providers.tts import TTSProvider, TTSSynthesis +from services.shared.models import VoiceAITurnDecisionOut + + +class _StubASRProvider(ASRProvider): + name = "stub-asr" + + def transcribe(self, audio_bytes: bytes, *, language_hint: str | None = None) -> ASRTranscription: + assert audio_bytes + return ASRTranscription(text="hello", language=language_hint or "ru", confidence=0.9) + + +class _StubTTSProvider(TTSProvider): + name = "stub-tts" + + def synthesize(self, text: str, *, language: str | None = None) -> TTSSynthesis: + del language + assert text + return TTSSynthesis( + text=text, + audio_bytes=(b"\x10\x00" * 960), + sample_rate_hz=24000, + ) + + +def test_resample_pcm16le_downsamples_to_8khz(): + source = b"\x20\x00" * 2400 + converted = resample_pcm16le(source, input_rate_hz=24000, output_rate_hz=8000) + assert converted + assert len(converted) < len(source) + + +def test_normalize_media_uuid_accepts_text_bytes(): + media_uuid = str(uuid.uuid4()) + assert normalize_media_uuid(media_uuid.encode("utf-8")) == media_uuid + + +def test_energy_vad_emits_utterance_after_trailing_silence(): + vad = EnergyVAD(frame_ms=20, min_speech_ms=40, trailing_silence_ms=40, max_turn_ms=400) + speech_frame = (1000).to_bytes(2, "little", signed=True) * 160 + silence_frame = b"\x00\x00" * 160 + + first = vad.feed(speech_frame) + second = vad.feed(speech_frame) + third = vad.feed(silence_frame) + fourth = vad.feed(silence_frame) + + assert first.speech_started is False + assert second.speech_started is True + assert third.utterance_pcm is None + assert fourth.utterance_pcm is not None + + +def test_media_runtime_streams_greeting_and_turn(): + registrations: dict[str, MediaRegistration] = {} + states: list[tuple[str, str]] = [] + delivered: list[tuple[str, str, bool]] = [] + turns: list[tuple[str, str, bool]] = [] + touch_calls: list[str] = [] + handoffs: list[str] = [] + media_ended: list[tuple[str, str]] = [] + errors: list[tuple[str, str]] = [] + + media_uuid = str(uuid.uuid4()) + registrations[media_uuid] = MediaRegistration( + voice_session_id="avs_media_runtime", + call_id="call_media_runtime", + interaction_id="int_media_runtime", + ai_session_id="ais_media_runtime", + language="ru", + media_uuid=media_uuid, + ) + + runtime = AudioSocketMediaRuntime( + enabled=True, + host="127.0.0.1", + port=0, + frame_ms=20, + idle_timeout_seconds=2.0, + registration_wait_timeout_seconds=0.5, + min_speech_ms=40, + trailing_silence_ms=40, + max_turn_ms=400, + asr_provider=_StubASRProvider(), + tts_provider=_StubTTSProvider(), + load_registration_by_media_uuid=lambda value: registrations.get(value), + mark_media_connected=lambda session_id, value: None, + mark_media_ended=lambda session_id, reason: media_ended.append((session_id, reason)), + touch_media_frame=lambda session_id: touch_calls.append(session_id), + set_state=lambda session_id, state, handoff_reason, metadata: states.append((session_id, state)), + get_pending_greeting=lambda session_id: "greeting" if session_id == "avs_media_runtime" else None, + mark_reply_delivered=lambda session_id, text, is_greeting: delivered.append((session_id, text, is_greeting)), + process_turn=lambda session_id, transcript_text, language, barge_in, metadata: ( + turns.append((session_id, transcript_text, barge_in)) + or VoiceAITurnDecisionOut( + language=language or "ru", + intent="answer", + reply_text="reply", + confidence=0.9, + needs_handoff=False, + handoff_reason=None, + case_action="keep_open", + kb_refs=[], + summary_text="reply ready", + model="stub-voice", + latency_ms=1, + status="active", + ) + ), + request_handoff=lambda session_id, customer_request_text, decision: handoffs.append(session_id), + handle_media_error=lambda session_id, message, metadata: errors.append((session_id, message)), + ) + + async def _scenario() -> None: + await runtime.start() + port = runtime._server.sockets[0].getsockname()[1] + + reader, writer = await asyncio.open_connection("127.0.0.1", port) + writer.write(encode_packet(AUDIO_SOCKET_PACKET_UUID, uuid.UUID(media_uuid).bytes)) + await writer.drain() + + packet_type, _ = await read_packet(reader, timeout=2.0) + assert packet_type == AUDIO_SOCKET_PACKET_PCM16 + await asyncio.sleep(0.15) + + speech_frame = (1000).to_bytes(2, "little", signed=True) * 160 + silence_frame = b"\x00\x00" * 160 + for _ in range(2): + writer.write(encode_audio_packet(speech_frame)) + for _ in range(2): + writer.write(encode_audio_packet(silence_frame)) + await writer.drain() + + packet_type, _ = await read_packet(reader, timeout=2.0) + assert packet_type == AUDIO_SOCKET_PACKET_PCM16 + + for _ in range(20): + if len(delivered) >= 2 and turns: + break + await asyncio.sleep(0.05) + + writer.close() + await writer.wait_closed() + await asyncio.sleep(0.2) + await runtime.stop() + + asyncio.run(_scenario()) + + assert ("avs_media_runtime", "speaking") in states + assert ("avs_media_runtime", "thinking") in states + assert ("avs_media_runtime", "listening") in states + assert delivered[0] == ("avs_media_runtime", "greeting", True) + assert delivered[-1] == ("avs_media_runtime", "reply", False) + assert turns == [("avs_media_runtime", "hello", False)] + assert handoffs == [] + assert touch_calls + assert media_ended + assert errors == [] + + +def test_media_runtime_waits_for_late_registration(): + registrations: dict[str, MediaRegistration] = {} + delivered: list[tuple[str, str, bool]] = [] + errors: list[tuple[str, str]] = [] + + media_uuid = str(uuid.uuid4()) + + runtime = AudioSocketMediaRuntime( + enabled=True, + host="127.0.0.1", + port=0, + frame_ms=20, + idle_timeout_seconds=2.0, + registration_wait_timeout_seconds=1.0, + min_speech_ms=40, + trailing_silence_ms=40, + max_turn_ms=400, + asr_provider=_StubASRProvider(), + tts_provider=_StubTTSProvider(), + load_registration_by_media_uuid=lambda value: registrations.get(value), + mark_media_connected=lambda session_id, value: None, + mark_media_ended=lambda session_id, reason: None, + touch_media_frame=lambda session_id: None, + set_state=lambda session_id, state, handoff_reason, metadata: None, + get_pending_greeting=lambda session_id: "greeting" if session_id == "avs_media_runtime_late" else None, + mark_reply_delivered=lambda session_id, text, is_greeting: delivered.append((session_id, text, is_greeting)), + process_turn=lambda session_id, transcript_text, language, barge_in, metadata: VoiceAITurnDecisionOut( + language=language or "ru", + intent="answer", + reply_text="reply", + confidence=0.9, + needs_handoff=False, + handoff_reason=None, + case_action="keep_open", + kb_refs=[], + summary_text="reply ready", + model="stub-voice", + latency_ms=1, + status="active", + ), + request_handoff=lambda session_id, customer_request_text, decision: None, + handle_media_error=lambda session_id, message, metadata: errors.append((session_id, message)), + ) + + async def _scenario() -> None: + await runtime.start() + port = runtime._server.sockets[0].getsockname()[1] + + async def _late_register() -> None: + await asyncio.sleep(0.15) + registrations[media_uuid] = MediaRegistration( + voice_session_id="avs_media_runtime_late", + call_id="call_media_runtime_late", + interaction_id="int_media_runtime_late", + ai_session_id="ais_media_runtime_late", + language="ru", + media_uuid=media_uuid, + ) + + late_task = asyncio.create_task(_late_register()) + reader, writer = await asyncio.open_connection("127.0.0.1", port) + writer.write(encode_packet(AUDIO_SOCKET_PACKET_UUID, uuid.UUID(media_uuid).bytes)) + await writer.drain() + + packet_type, _ = await read_packet(reader, timeout=2.0) + assert packet_type == AUDIO_SOCKET_PACKET_PCM16 + for _ in range(20): + if delivered: + break + await asyncio.sleep(0.05) + + writer.close() + await writer.wait_closed() + await late_task + await asyncio.sleep(0.2) + await runtime.stop() + + asyncio.run(_scenario()) + + assert delivered == [("avs_media_runtime_late", "greeting", True)] + assert errors == [] + + +def test_media_runtime_sends_keepalive_while_tts_is_slow(): + registrations: dict[str, MediaRegistration] = {} + + media_uuid = str(uuid.uuid4()) + registrations[media_uuid] = MediaRegistration( + voice_session_id="avs_media_runtime_keepalive", + call_id="call_media_runtime_keepalive", + interaction_id="int_media_runtime_keepalive", + ai_session_id="ais_media_runtime_keepalive", + language="ru", + media_uuid=media_uuid, + ) + + class _SlowTTSProvider(TTSProvider): + name = "slow-stub-tts" + + def synthesize(self, text: str, *, language: str | None = None) -> TTSSynthesis: + del language + assert text + time.sleep(1.2) + return TTSSynthesis( + text=text, + audio_bytes=(b"\x20\x00" * 960), + sample_rate_hz=24000, + ) + + runtime = AudioSocketMediaRuntime( + enabled=True, + host="127.0.0.1", + port=0, + frame_ms=20, + idle_timeout_seconds=2.0, + registration_wait_timeout_seconds=0.5, + min_speech_ms=40, + trailing_silence_ms=40, + max_turn_ms=400, + asr_provider=_StubASRProvider(), + tts_provider=_SlowTTSProvider(), + load_registration_by_media_uuid=lambda value: registrations.get(value), + mark_media_connected=lambda session_id, value: None, + mark_media_ended=lambda session_id, reason: None, + touch_media_frame=lambda session_id: None, + set_state=lambda session_id, state, handoff_reason, metadata: None, + get_pending_greeting=lambda session_id: "greeting" if session_id == "avs_media_runtime_keepalive" else None, + mark_reply_delivered=lambda session_id, text, is_greeting: None, + process_turn=lambda session_id, transcript_text, language, barge_in, metadata: VoiceAITurnDecisionOut( + language=language or "ru", + intent="answer", + reply_text="reply", + confidence=0.9, + needs_handoff=False, + handoff_reason=None, + case_action="keep_open", + kb_refs=[], + summary_text="reply ready", + model="stub-voice", + latency_ms=1, + status="active", + ), + request_handoff=lambda session_id, customer_request_text, decision: None, + handle_media_error=lambda session_id, message, metadata: None, + ) + + async def _scenario() -> None: + await runtime.start() + port = runtime._server.sockets[0].getsockname()[1] + + reader, writer = await asyncio.open_connection("127.0.0.1", port) + writer.write(encode_packet(AUDIO_SOCKET_PACKET_UUID, uuid.UUID(media_uuid).bytes)) + await writer.drain() + + started_at = time.monotonic() + packet_type, payload = await read_packet(reader, timeout=1.0) + elapsed = time.monotonic() - started_at + assert packet_type == AUDIO_SOCKET_PACKET_PCM16 + assert payload == (b"\x00" * 320) + assert elapsed < 1.0 + + writer.close() + await writer.wait_closed() + await asyncio.sleep(0.2) + await runtime.stop() + + asyncio.run(_scenario()) + + +def test_media_runtime_sends_keepalive_before_registration_is_ready(): + registrations: dict[str, MediaRegistration] = {} + delivered: list[tuple[str, str, bool]] = [] + + media_uuid = str(uuid.uuid4()) + + runtime = AudioSocketMediaRuntime( + enabled=True, + host="127.0.0.1", + port=0, + frame_ms=20, + idle_timeout_seconds=2.0, + registration_wait_timeout_seconds=2.0, + min_speech_ms=40, + trailing_silence_ms=40, + max_turn_ms=400, + asr_provider=_StubASRProvider(), + tts_provider=_StubTTSProvider(), + load_registration_by_media_uuid=lambda value: registrations.get(value), + mark_media_connected=lambda session_id, value: None, + mark_media_ended=lambda session_id, reason: None, + touch_media_frame=lambda session_id: None, + set_state=lambda session_id, state, handoff_reason, metadata: None, + get_pending_greeting=lambda session_id: "greeting" if session_id == "avs_media_runtime_prereg" else None, + mark_reply_delivered=lambda session_id, text, is_greeting: delivered.append((session_id, text, is_greeting)), + process_turn=lambda session_id, transcript_text, language, barge_in, metadata: VoiceAITurnDecisionOut( + language=language or "ru", + intent="answer", + reply_text="reply", + confidence=0.9, + needs_handoff=False, + handoff_reason=None, + case_action="keep_open", + kb_refs=[], + summary_text="reply ready", + model="stub-voice", + latency_ms=1, + status="active", + ), + request_handoff=lambda session_id, customer_request_text, decision: None, + handle_media_error=lambda session_id, message, metadata: None, + ) + + async def _scenario() -> None: + await runtime.start() + port = runtime._server.sockets[0].getsockname()[1] + + async def _late_register() -> None: + await asyncio.sleep(1.1) + registrations[media_uuid] = MediaRegistration( + voice_session_id="avs_media_runtime_prereg", + call_id="call_media_runtime_prereg", + interaction_id="int_media_runtime_prereg", + ai_session_id="ais_media_runtime_prereg", + language="ru", + media_uuid=media_uuid, + ) + + late_task = asyncio.create_task(_late_register()) + reader, writer = await asyncio.open_connection("127.0.0.1", port) + writer.write(encode_packet(AUDIO_SOCKET_PACKET_UUID, uuid.UUID(media_uuid).bytes)) + await writer.drain() + + started_at = time.monotonic() + packet_type, payload = await read_packet(reader, timeout=1.0) + elapsed = time.monotonic() - started_at + assert packet_type == AUDIO_SOCKET_PACKET_PCM16 + assert payload == (b"\x00" * 320) + assert elapsed < 1.0 + + for _ in range(30): + if delivered: + break + await asyncio.sleep(0.1) + + writer.close() + await writer.wait_closed() + await late_task + await asyncio.sleep(0.2) + await runtime.stop() + + asyncio.run(_scenario()) + + assert delivered == [("avs_media_runtime_prereg", "greeting", True)] + + +def test_media_runtime_starts_handoff_before_handoff_tts_finishes(): + handoff_started = threading.Event() + events: list[str] = [] + + runtime = AudioSocketMediaRuntime( + enabled=True, + host="127.0.0.1", + port=0, + frame_ms=20, + idle_timeout_seconds=2.0, + registration_wait_timeout_seconds=0.5, + min_speech_ms=40, + trailing_silence_ms=40, + max_turn_ms=400, + asr_provider=_StubASRProvider(), + tts_provider=_StubTTSProvider(), + load_registration_by_media_uuid=lambda value: None, + mark_media_connected=lambda session_id, value: None, + mark_media_ended=lambda session_id, reason: None, + touch_media_frame=lambda session_id: None, + set_state=lambda session_id, state, handoff_reason, metadata: None, + get_pending_greeting=lambda session_id: None, + mark_reply_delivered=lambda session_id, text, is_greeting: None, + process_turn=lambda session_id, transcript_text, language, barge_in, metadata: VoiceAITurnDecisionOut( + language=language or "ru", + intent="handoff_request", + reply_text="Соединяю с оператором", + confidence=0.9, + needs_handoff=True, + handoff_reason="Нужен живой оператор", + case_action="keep_open", + kb_refs=[], + summary_text="handoff requested", + model="stub-voice", + latency_ms=1, + status="handoff_requested", + ), + request_handoff=lambda session_id, customer_request_text, decision: ( + events.append("handoff"), + handoff_started.set() + ), + handle_media_error=lambda session_id, message, metadata: None, + ) + + actor = MediaRegistration( + voice_session_id="avs_media_runtime_handoff", + call_id="call_media_runtime_handoff", + interaction_id="int_media_runtime_handoff", + ai_session_id="ais_media_runtime_handoff", + language="ru", + media_uuid=str(uuid.uuid4()), + ) + + media_actor = None + + async def _scenario() -> None: + nonlocal media_actor + media_actor = MediaActor( + registration=actor, + reader=asyncio.StreamReader(), + writer=None, # type: ignore[arg-type] + vad=EnergyVAD(frame_ms=20, min_speech_ms=40, trailing_silence_ms=40, max_turn_ms=400), + frame_ms=20, + frame_bytes=320, + ) + + async def _fake_speak_text(current_actor, text: str, *, is_greeting: bool) -> None: + del current_actor, text, is_greeting + events.append("speak_start") + await asyncio.sleep(0) + assert handoff_started.wait(timeout=0.5) + await asyncio.sleep(0.05) + events.append("speak_end") + + runtime._speak_text = _fake_speak_text # type: ignore[method-assign] + pcm_frame = (1000).to_bytes(2, "little", signed=True) * 160 + await runtime._process_utterance(media_actor, pcm_frame, False) + + asyncio.run(_scenario()) + + assert handoff_started.is_set() + assert events.index("handoff") < events.index("speak_end") diff --git a/tests/test_ai_voice_runtime_service.py b/tests/test_ai_voice_runtime_service.py new file mode 100644 index 0000000..4c85788 --- /dev/null +++ b/tests/test_ai_voice_runtime_service.py @@ -0,0 +1,446 @@ +import json +import threading +import time + +from fastapi.testclient import TestClient +from sqlalchemy import select + +import services.ai_voice_runtime_service.app as runtime_module +from services.shared.core import utc_now_iso +from services.shared.db import get_session +from services.shared.sql_models import VoiceAISessionRow, VoiceTranscriptSegmentRow + + +def _admin_headers() -> dict[str, str]: + return {"X-User": "admin", "X-Role": "admin"} + + +def test_create_voice_ai_session_records_greeting_without_blocking_on_orchestrator(monkeypatch): + allow_start = threading.Event() + + def _fake_orchestrator_request(method: str, path: str, *, payload=None, timeout=10.0): + assert method == "POST" + assert path.endswith("/start") + assert allow_start.wait(timeout=2.0) + return { + "session_id": "ais_voice_demo", + "language": "ru", + "greeting_text": "Я AI-оператор компании. Здравствуйте.", + "disclosure_required": True, + } + + monkeypatch.setattr(runtime_module, "_orchestrator_request", _fake_orchestrator_request) + + client = TestClient(runtime_module.app) + response_holder: dict[str, object] = {} + + def _request() -> None: + response_holder["response"] = client.post( + "/internal/voice-ai/sessions", + headers=_admin_headers(), + json={ + "call_id": "call_voice_runtime_1", + "linked_id": "linked_voice_runtime_1", + "interaction_id": "int_voice_runtime_1", + "queue_id": "que_voice_runtime_1", + "caller_number": "+77010000031", + "caller_name": "Runtime Caller", + "agent_profile": "voice_support", + "language_hint": "ru", + "handoff_queue_id": "que_voice_runtime_1", + "metadata": {"queue_code": "voice_lab"}, + }, + ) + + request_thread = threading.Thread(target=_request, daemon=True) + request_thread.start() + request_thread.join(timeout=0.3) + assert not request_thread.is_alive() + + response = response_holder["response"] + assert response.status_code == 200 + payload = response.json() + assert payload["ai_session_id"] is None + assert payload["status"] == "greeting" + + session = get_session() + try: + voice_session = session.execute( + select(VoiceAISessionRow).where(VoiceAISessionRow.call_id == "call_voice_runtime_1") + ).scalar_one() + assert voice_session.status == "greeting" + assert voice_session.disclosure_played_at is None + assert voice_session.last_ai_reply_at is None + greeting_segment = session.execute( + select(VoiceTranscriptSegmentRow) + .where(VoiceTranscriptSegmentRow.session_id == voice_session.session_id) + .where(VoiceTranscriptSegmentRow.speaker == "assistant") + ).scalar_one() + assert "AI-оператор" in greeting_segment.text + assert greeting_segment.is_final is False + finally: + session.close() + + allow_start.set() + deadline = time.time() + 2.0 + while time.time() < deadline: + session = get_session() + try: + voice_session = session.execute( + select(VoiceAISessionRow).where(VoiceAISessionRow.call_id == "call_voice_runtime_1") + ).scalar_one() + if voice_session.ai_session_id == "ais_voice_demo": + greetings = session.execute( + select(VoiceTranscriptSegmentRow) + .where(VoiceTranscriptSegmentRow.session_id == voice_session.session_id) + .where(VoiceTranscriptSegmentRow.speaker == "assistant") + ).scalars().all() + assert len(greetings) == 1 + break + finally: + session.close() + time.sleep(0.05) + else: + raise AssertionError("background voice-session start did not finish in time") + + +def test_mark_reply_delivered_finalizes_only_matching_pending_assistant_segment(): + now = utc_now_iso() + session = get_session() + try: + session.add( + VoiceAISessionRow( + session_id="avs_runtime_delivery", + call_id="call_runtime_delivery", + linked_id="linked_runtime_delivery", + interaction_id="int_runtime_delivery", + customer_id=None, + queue_id="que_runtime_delivery", + ai_session_id="ais_runtime_delivery", + agent_profile="voice_support", + language="ru", + asr_provider="openai", + tts_provider="openai", + status="speaking", + handoff_reason=None, + handoff_target_queue_id="que_runtime_delivery", + disclosure_played_at=None, + last_user_utterance_at=None, + last_ai_reply_at=None, + started_at=now, + updated_at=now, + ended_at=None, + ) + ) + session.add( + VoiceTranscriptSegmentRow( + segment_id="vts_runtime_delivery_greeting", + session_id="avs_runtime_delivery", + call_id="call_runtime_delivery", + interaction_id="int_runtime_delivery", + speaker="assistant", + source_type="tts", + sequence_no=1, + text="greeting", + confidence=None, + is_final=False, + barge_in_interrupted=False, + payload_json=json.dumps({"kind": "greeting", "delivery_status": "planned"}), + created_at=now, + ) + ) + session.add( + VoiceTranscriptSegmentRow( + segment_id="vts_runtime_delivery_pending", + session_id="avs_runtime_delivery", + call_id="call_runtime_delivery", + interaction_id="int_runtime_delivery", + speaker="assistant", + source_type="tts", + sequence_no=2, + text="reply that was spoken", + confidence=None, + is_final=False, + barge_in_interrupted=False, + payload_json=json.dumps({"delivery_status": "planned"}), + created_at=now, + ) + ) + session.commit() + finally: + session.close() + + runtime_module._mark_reply_delivered( + "avs_runtime_delivery", + "reply that was spoken", + False, + ) + + session = get_session() + try: + voice_session = session.execute( + select(VoiceAISessionRow).where(VoiceAISessionRow.session_id == "avs_runtime_delivery") + ).scalar_one() + segments = session.execute( + select(VoiceTranscriptSegmentRow) + .where(VoiceTranscriptSegmentRow.session_id == "avs_runtime_delivery") + .order_by(VoiceTranscriptSegmentRow.sequence_no.asc()) + ).scalars().all() + assert voice_session.last_ai_reply_at is not None + assert [segment.is_final for segment in segments] == [False, True] + payloads = [json.loads(segment.payload_json or "{}") for segment in segments] + assert payloads[0]["delivery_status"] == "planned" + assert payloads[1]["delivery_status"] == "delivered" + finally: + session.close() + + +def test_process_voice_ai_turn_triggers_bridge_handoff(monkeypatch): + handoffs: list[dict] = [] + + def _fake_bridge_request(method: str, path: str, *, payload=None, timeout=10.0): + handoffs.append({"method": method, "path": path, "payload": payload, "timeout": timeout}) + return {"ok": True} + + def _fake_orchestrator_request(method: str, path: str, *, payload=None, timeout=10.0): + assert method == "POST" + assert path.endswith("/turns") + return { + "language": "ru", + "intent": "handoff_request", + "reply_text": "Я как AI-оператор собрал первичный контекст. Сейчас переведу вас на живого оператора.", + "confidence": 0.2, + "needs_handoff": True, + "handoff_reason": "Нужен живой оператор.", + "case_action": "keep_open", + "kb_refs": [], + "summary_text": "AI собрал контекст и запросил человека.", + "model": "stub-voice", + "latency_ms": 1, + "status": "handoff_requested", + } + + monkeypatch.setattr(runtime_module, "_bridge_request", _fake_bridge_request) + monkeypatch.setattr(runtime_module, "_orchestrator_request", _fake_orchestrator_request) + + session = get_session() + try: + session.add( + VoiceAISessionRow( + session_id="avs_runtime_handoff", + call_id="call_voice_runtime_handoff", + linked_id="linked_voice_runtime_handoff", + interaction_id="int_voice_runtime_handoff", + customer_id=None, + queue_id="que_voice_runtime_handoff", + ai_session_id="ais_runtime_handoff", + agent_profile="voice_support", + language="ru", + asr_provider="openai", + tts_provider="openai", + status="listening", + handoff_reason=None, + handoff_target_queue_id="que_voice_runtime_handoff", + disclosure_played_at=utc_now_iso(), + last_user_utterance_at=None, + last_ai_reply_at=None, + started_at=utc_now_iso(), + updated_at=utc_now_iso(), + ended_at=None, + ) + ) + session.commit() + finally: + session.close() + + client = TestClient(runtime_module.app) + response = client.post( + "/internal/voice-ai/sessions/avs_runtime_handoff/turns", + headers=_admin_headers(), + json={ + "voice_session_id": "avs_runtime_handoff", + "call_id": "call_voice_runtime_handoff", + "interaction_id": "int_voice_runtime_handoff", + "transcript_text": "Соедините с оператором", + "language": "ru", + "sequence_no": 1, + "barge_in": False, + "metadata": {"turn_duration_ms": 3200}, + }, + ) + + assert response.status_code == 200 + assert response.json()["needs_handoff"] is True + assert handoffs + assert handoffs[0]["path"] == "/internal/voice-ai/calls/call_voice_runtime_handoff/handoff" + assert handoffs[0]["payload"]["reason"] == "Нужен живой оператор." + + session = get_session() + try: + voice_session = session.execute( + select(VoiceAISessionRow).where(VoiceAISessionRow.session_id == "avs_runtime_handoff") + ).scalar_one() + assert voice_session.status == "handoff_requested" + segments = session.execute( + select(VoiceTranscriptSegmentRow) + .where(VoiceTranscriptSegmentRow.session_id == "avs_runtime_handoff") + .order_by(VoiceTranscriptSegmentRow.sequence_no.asc()) + ).scalars().all() + assert [item.speaker for item in segments] == ["caller", "assistant"] + assert [item.is_final for item in segments] == [True, False] + finally: + session.close() + + +def test_register_media_bridge_updates_voice_session(monkeypatch): + closed: list[tuple[str, str]] = [] + monkeypatch.setattr( + runtime_module._MEDIA_RUNTIME, + "close_session_sync", + lambda session_id, *, reason: closed.append((session_id, reason)), + ) + + session = get_session() + try: + session.add( + VoiceAISessionRow( + session_id="avs_media_bridge_runtime", + call_id="call_media_bridge_runtime", + linked_id="linked_media_bridge_runtime", + interaction_id="int_media_bridge_runtime", + customer_id=None, + queue_id="que_media_bridge_runtime", + ai_session_id="ais_media_bridge_runtime", + agent_profile="voice_support", + language="ru", + asr_provider="openai", + tts_provider="openai", + status="greeting", + handoff_reason=None, + handoff_target_queue_id="que_media_bridge_runtime", + media_uuid=None, + media_status=None, + media_connected_at=None, + media_ended_at=None, + last_media_frame_at=None, + disclosure_played_at=None, + last_user_utterance_at=None, + last_ai_reply_at=None, + started_at=utc_now_iso(), + updated_at=utc_now_iso(), + ended_at=None, + ) + ) + session.commit() + finally: + session.close() + + client = TestClient(runtime_module.app) + requested = client.post( + "/internal/voice-ai/sessions/avs_media_bridge_runtime/media-bridge", + headers=_admin_headers(), + json={ + "event_type": "requested", + "media_uuid": "75f4d61f-f674-4bb4-91c1-8ddfcf2fc2b4", + "call_id": "call_media_bridge_runtime", + "linked_id": "linked_media_bridge_runtime", + "channel": "PJSIP/1001-00000001", + "service_address": "127.0.0.1:9019", + }, + ) + assert requested.status_code == 200 + + session = get_session() + try: + voice_session = session.execute( + select(VoiceAISessionRow).where(VoiceAISessionRow.session_id == "avs_media_bridge_runtime") + ).scalar_one() + assert voice_session.media_uuid == "75f4d61f-f674-4bb4-91c1-8ddfcf2fc2b4" + assert voice_session.media_status == "requested" + finally: + session.close() + + ended = client.post( + "/internal/voice-ai/sessions/avs_media_bridge_runtime/media-bridge", + headers=_admin_headers(), + json={ + "event_type": "ended", + "media_uuid": "75f4d61f-f674-4bb4-91c1-8ddfcf2fc2b4", + "call_id": "call_media_bridge_runtime", + "linked_id": "linked_media_bridge_runtime", + "channel": "PJSIP/1001-00000001", + "service_address": "127.0.0.1:9019", + "reason": "audiosocket_closed", + }, + ) + assert ended.status_code == 200 + assert closed == [("avs_media_bridge_runtime", "audiosocket_closed")] + + +def test_push_voice_ai_telephony_event_call_ended_returns_detached_safe_payload(monkeypatch): + closed: list[tuple[str, str]] = [] + orchestrator_calls: list[tuple[str, str]] = [] + monkeypatch.setattr( + runtime_module._MEDIA_RUNTIME, + "close_session_sync", + lambda session_id, *, reason: closed.append((session_id, reason)), + ) + monkeypatch.setattr( + runtime_module, + "_orchestrator_request", + lambda method, path, *, payload=None, timeout=10.0: orchestrator_calls.append((method, path)) or {"ok": True}, + ) + + session = get_session() + try: + session.add( + VoiceAISessionRow( + session_id="avs_runtime_call_ended", + call_id="call_voice_runtime_call_ended", + linked_id="linked_voice_runtime_call_ended", + interaction_id="int_voice_runtime_call_ended", + customer_id=None, + queue_id="que_voice_runtime_call_ended", + ai_session_id="ais_runtime_call_ended", + agent_profile="voice_support", + language="ru", + asr_provider="openai", + tts_provider="openai", + status="listening", + handoff_reason=None, + handoff_target_queue_id="que_voice_runtime_call_ended", + media_uuid="2fc59167-fdc7-43a0-b4e5-c4948f7c24d8", + media_status="connected", + media_connected_at=utc_now_iso(), + media_ended_at=None, + last_media_frame_at=utc_now_iso(), + disclosure_played_at=utc_now_iso(), + last_user_utterance_at=None, + last_ai_reply_at=None, + started_at=utc_now_iso(), + updated_at=utc_now_iso(), + ended_at=None, + ) + ) + session.commit() + finally: + session.close() + + client = TestClient(runtime_module.app) + response = client.post( + "/internal/voice-ai/sessions/avs_runtime_call_ended/telephony-events", + headers=_admin_headers(), + json={ + "event_type": "call.ended", + "payload": {"hangup_cause": "16"}, + }, + ) + + assert response.status_code == 200 + assert response.json() == { + "voice_session_id": "avs_runtime_call_ended", + "ai_session_id": "ais_runtime_call_ended", + "status": "completed", + } + assert closed == [("avs_runtime_call_ended", "call_ended")] + assert orchestrator_calls == [("POST", "/ai/voice/sessions/avs_runtime_call_ended/close")] diff --git a/tests/test_ai_voice_tts_provider.py b/tests/test_ai_voice_tts_provider.py new file mode 100644 index 0000000..5d3da86 --- /dev/null +++ b/tests/test_ai_voice_tts_provider.py @@ -0,0 +1,220 @@ +from __future__ import annotations + +import base64 +from pathlib import Path + +from services.ai_voice_runtime_service.providers import tts as tts_module + + +class _DummyResponse: + def __init__(self, content: bytes = b"", json_payload: dict | None = None) -> None: + self.content = content + self._json_payload = json_payload or {} + + def raise_for_status(self) -> None: + return None + + def json(self) -> dict: + return self._json_payload + + +def test_openai_tts_provider_persists_cache_on_disk(tmp_path, monkeypatch): + calls: list[dict] = [] + + class _DummyClient: + def __init__(self, *, timeout: float) -> None: + self.timeout = timeout + + def __enter__(self) -> _DummyClient: + return self + + def __exit__(self, exc_type, exc, tb) -> None: + return None + + def post(self, url: str, *, headers: dict[str, str], json: dict[str, str]) -> _DummyResponse: + calls.append({"url": url, "headers": headers, "json": json, "timeout": self.timeout}) + return _DummyResponse(b"\x01\x00\x02\x00") + + monkeypatch.setenv("AI_API_KEY", "test-key") + monkeypatch.setenv("AI_API_BASE", "https://example.test/v1") + monkeypatch.setenv("AI_VOICE_TTS_MODEL", "gpt-4o-mini-tts") + monkeypatch.setenv("AI_VOICE_TTS_VOICE", "alloy") + monkeypatch.setenv("AI_VOICE_TTS_CACHE_ENABLED", "1") + monkeypatch.setenv("AI_VOICE_TTS_CACHE_DIR", str(tmp_path)) + monkeypatch.setattr(tts_module.httpx, "Client", _DummyClient) + + provider = tts_module.OpenAITTSProvider() + first = provider.synthesize("Hello from cache", language="ru") + second = provider.synthesize("Hello from cache", language="ru") + + assert first.audio_bytes == b"\x01\x00\x02\x00" + assert second.audio_bytes == first.audio_bytes + assert len(calls) == 1 + assert list(Path(tmp_path).rglob("*.pcm")) + assert list(Path(tmp_path).rglob("*.json")) + + +def test_openai_tts_provider_uses_cached_audio_without_api_key(tmp_path, monkeypatch): + calls: list[dict] = [] + + class _DummyClient: + def __init__(self, *, timeout: float) -> None: + self.timeout = timeout + + def __enter__(self) -> _DummyClient: + return self + + def __exit__(self, exc_type, exc, tb) -> None: + return None + + def post(self, url: str, *, headers: dict[str, str], json: dict[str, str]) -> _DummyResponse: + calls.append({"url": url, "headers": headers, "json": json, "timeout": self.timeout}) + return _DummyResponse(b"\x04\x00\x08\x00") + + monkeypatch.setenv("AI_API_KEY", "test-key") + monkeypatch.setenv("AI_VOICE_TTS_CACHE_ENABLED", "1") + monkeypatch.setenv("AI_VOICE_TTS_CACHE_DIR", str(tmp_path)) + monkeypatch.setattr(tts_module.httpx, "Client", _DummyClient) + + provider = tts_module.OpenAITTSProvider() + cached = provider.synthesize("Server-side cached prompt", language="ru") + assert cached.audio_bytes == b"\x04\x00\x08\x00" + assert len(calls) == 1 + + monkeypatch.delenv("AI_API_KEY", raising=False) + + def _unexpected_client(*args, **kwargs): + raise AssertionError("OpenAI TTS should not be called when cached audio already exists") + + monkeypatch.setattr(tts_module.httpx, "Client", _unexpected_client) + + provider_without_key = tts_module.OpenAITTSProvider() + replay = provider_without_key.synthesize("Server-side cached prompt", language="ru") + + assert replay.audio_bytes == cached.audio_bytes + assert len(calls) == 1 + + +def test_yandex_tts_provider_posts_lpcm_with_api_key(tmp_path, monkeypatch): + calls: list[dict] = [] + encoded_audio = base64.b64encode(b"\x10\x00\x20\x00").decode("ascii") + + class _DummyClient: + def __init__(self, *, timeout: float) -> None: + self.timeout = timeout + + def __enter__(self) -> _DummyClient: + return self + + def __exit__(self, exc_type, exc, tb) -> None: + return None + + def post(self, url: str, *, headers: dict[str, str], json: dict) -> _DummyResponse: + calls.append({"url": url, "headers": headers, "json": json, "timeout": self.timeout}) + return _DummyResponse(json_payload={"result": {"audioChunk": {"data": encoded_audio}}}) + + monkeypatch.setenv("AI_VOICE_TTS_YANDEX_API_KEY", "yandex-test-key") + monkeypatch.setenv("AI_VOICE_TTS_YANDEX_API_BASE", "https://tts.example.test") + monkeypatch.setenv("AI_VOICE_TTS_YANDEX_VOICE", "jane") + monkeypatch.setenv("AI_VOICE_TTS_YANDEX_SAMPLE_RATE_HZ", "8000") + monkeypatch.setenv("AI_VOICE_TTS_CACHE_ENABLED", "1") + monkeypatch.setenv("AI_VOICE_TTS_CACHE_DIR", str(tmp_path)) + monkeypatch.setattr(tts_module.httpx, "Client", _DummyClient) + + provider = tts_module.YandexTTSProvider() + synthesis = provider.synthesize("Привет из Yandex TTS", language="ru") + + assert synthesis.audio_bytes == b"\x10\x00\x20\x00" + assert synthesis.sample_rate_hz == 8000 + assert len(calls) == 1 + assert calls[0]["url"] == "https://tts.example.test/tts/v3/utteranceSynthesis" + assert calls[0]["headers"]["Authorization"] == "Api-Key yandex-test-key" + assert calls[0]["headers"]["Accept"] == "application/json" + assert calls[0]["json"]["hints"][0]["voice"] == "jane" + assert calls[0]["json"]["hints"][1]["speed"] == 1.0 + assert calls[0]["json"]["outputAudioSpec"]["rawAudio"]["audioEncoding"] == "LINEAR16_PCM" + assert calls[0]["json"]["outputAudioSpec"]["rawAudio"]["sampleRateHertz"] == 8000 + assert list(Path(tmp_path).rglob("*.pcm")) + assert list(Path(tmp_path).rglob("*.json")) + + +def test_yandex_tts_provider_uses_iam_token_with_folder_id(tmp_path, monkeypatch): + calls: list[dict] = [] + encoded_audio = base64.b64encode(b"\x30\x00\x40\x00").decode("ascii") + + class _DummyClient: + def __init__(self, *, timeout: float) -> None: + self.timeout = timeout + + def __enter__(self) -> _DummyClient: + return self + + def __exit__(self, exc_type, exc, tb) -> None: + return None + + def post(self, url: str, *, headers: dict[str, str], json: dict) -> _DummyResponse: + calls.append({"url": url, "headers": headers, "json": json, "timeout": self.timeout}) + return _DummyResponse(json_payload={"audioChunk": {"data": encoded_audio}}) + + monkeypatch.delenv("AI_VOICE_TTS_YANDEX_API_KEY", raising=False) + monkeypatch.setenv("AI_VOICE_TTS_YANDEX_IAM_TOKEN", "iam-test-token") + monkeypatch.setenv("AI_VOICE_TTS_YANDEX_FOLDER_ID", "folder-test") + monkeypatch.setenv("AI_VOICE_TTS_YANDEX_SAMPLE_RATE_HZ", "16000") + monkeypatch.setenv("AI_VOICE_TTS_YANDEX_ROLE", "friendly") + monkeypatch.setenv("AI_VOICE_TTS_CACHE_ENABLED", "0") + monkeypatch.setattr(tts_module.httpx, "Client", _DummyClient) + + provider = tts_module.YandexTTSProvider() + synthesis = provider.synthesize("Kazakh prompt", language="kk") + + assert synthesis.audio_bytes == b"\x30\x00\x40\x00" + assert synthesis.sample_rate_hz == 16000 + assert len(calls) == 1 + assert calls[0]["headers"]["Authorization"] == "Bearer iam-test-token" + assert calls[0]["headers"]["x-folder-id"] == "folder-test" + assert calls[0]["json"]["hints"][0]["voice"] == "amira" + assert calls[0]["json"]["hints"][2]["role"] == "friendly" + assert calls[0]["json"]["outputAudioSpec"]["rawAudio"]["sampleRateHertz"] == 16000 + + +def test_yandex_tts_provider_uses_cached_audio_without_credentials(tmp_path, monkeypatch): + calls: list[dict] = [] + encoded_audio = base64.b64encode(b"\x55\x00\x66\x00").decode("ascii") + + class _DummyClient: + def __init__(self, *, timeout: float) -> None: + self.timeout = timeout + + def __enter__(self) -> _DummyClient: + return self + + def __exit__(self, exc_type, exc, tb) -> None: + return None + + def post(self, url: str, *, headers: dict[str, str], json: dict) -> _DummyResponse: + calls.append({"url": url, "headers": headers, "json": json, "timeout": self.timeout}) + return _DummyResponse(json_payload={"result": {"audioChunk": {"data": encoded_audio}}}) + + monkeypatch.setenv("AI_VOICE_TTS_YANDEX_API_KEY", "yandex-test-key") + monkeypatch.setenv("AI_VOICE_TTS_CACHE_ENABLED", "1") + monkeypatch.setenv("AI_VOICE_TTS_CACHE_DIR", str(tmp_path)) + monkeypatch.setattr(tts_module.httpx, "Client", _DummyClient) + + provider = tts_module.YandexTTSProvider() + cached = provider.synthesize("Server-side cached yandex prompt", language="ru") + assert cached.audio_bytes == b"\x55\x00\x66\x00" + assert len(calls) == 1 + + monkeypatch.delenv("AI_VOICE_TTS_YANDEX_API_KEY", raising=False) + monkeypatch.delenv("AI_VOICE_TTS_YANDEX_IAM_TOKEN", raising=False) + + def _unexpected_client(*args, **kwargs): + raise AssertionError("Yandex TTS should not be called when cached audio already exists") + + monkeypatch.setattr(tts_module.httpx, "Client", _unexpected_client) + + provider_without_credentials = tts_module.YandexTTSProvider() + replay = provider_without_credentials.synthesize("Server-side cached yandex prompt", language="ru") + + assert replay.audio_bytes == cached.audio_bytes + assert len(calls) == 1 diff --git a/tests/test_ai_whatsapp_orchestrator_service.py b/tests/test_ai_whatsapp_orchestrator_service.py new file mode 100644 index 0000000..7f9a20c --- /dev/null +++ b/tests/test_ai_whatsapp_orchestrator_service.py @@ -0,0 +1,232 @@ +import importlib + +from fastapi.testclient import TestClient +from sqlalchemy import select + +ai_module = importlib.import_module("services.ai_orchestrator_service.app") +ai_app = ai_module.app + +from services.interaction_service.app import app as interaction_app +from services.shared.core import new_id, utc_now_iso +from services.shared.db import get_session +from services.shared.sql_models import AISessionRow, KBArticleRow, KBCategoryRow, WhatsAppThreadRow +from services.whatsapp_adapter_service import app as whatsapp_module +from services.whatsapp_adapter_service.app import app as whatsapp_app + + +def admin_headers(): + return {"X-User": "admin", "X-Role": "admin"} + + +def _u(value: str) -> str: + return value.encode("ascii").decode("unicode_escape") + + +def seed_kb_article( + title: str, + body: str, + tag: str, + *, + language: str = "ru", + article_group_id: str | None = None, +) -> dict[str, str]: + session = get_session() + try: + now = utc_now_iso() + category_id = new_id("kbc") + article_id = new_id("kba") + resolved_group_id = article_group_id or article_id + session.add( + KBCategoryRow( + category_id=category_id, + name="WhatsApp AI", + description="AI test category", + created_at=now, + ) + ) + session.add( + KBArticleRow( + article_id=article_id, + category_id=category_id, + article_group_id=resolved_group_id, + language=language, + title=title, + body=body, + tags_json=f'["{tag}"]', + created_at=now, + updated_at=now, + ) + ) + session.commit() + return {"article_id": article_id, "article_group_id": resolved_group_id} + finally: + session.close() + + +def patch_ai_internal_calls(monkeypatch, whatsapp_client: TestClient, interaction_client: TestClient): + def fake_whatsapp_request(method: str, path: str, *, payload: dict | None = None) -> dict: + response = whatsapp_client.request(method, path, json=payload, headers=admin_headers()) + response.raise_for_status() + return response.json() + + def fake_interaction_request(method: str, path: str, *, payload: dict | None = None) -> dict: + response = interaction_client.request(method, path, json=payload, headers=admin_headers()) + response.raise_for_status() + return response.json() + + monkeypatch.setattr(ai_module, "_whatsapp_request", fake_whatsapp_request) + monkeypatch.setattr(ai_module, "_interaction_request", fake_interaction_request) + + +def create_inbound_thread(whatsapp_client: TestClient, chat_id: str, text: str) -> dict: + response = whatsapp_client.post( + "/integrations/whatsapp/webhook", + json={ + "chat_id": chat_id, + "text": text, + "external_message_id": f"{chat_id}-ext-1", + "whatsapp_user_id": f"user-{chat_id}", + "phone_number": "+77010000000", + "display_name": f"Lead {chat_id}", + }, + ) + assert response.status_code == 200 + return response.json() + + +def fetch_ai_summary(whatsapp_client: TestClient, thread_id: str): + return whatsapp_client.get( + f"/integrations/whatsapp/threads/{thread_id}/ai-summary", + headers=admin_headers(), + ) + + +def test_ai_whatsapp_always_reply_mode_answers_without_handoff(monkeypatch): + monkeypatch.setenv("AI_WHATSAPP_ENABLED", "1") + monkeypatch.setenv("AI_WHATSAPP_ALWAYS_REPLY", "1") + monkeypatch.setenv("AI_PROVIDER", "stub") + monkeypatch.setattr(whatsapp_module, "_ai_enqueue_request", lambda thread_id, trigger_message_id: None) + monkeypatch.setattr(whatsapp_module, "_start_whatsapp_reply_delivery", lambda message_id: None) + + whatsapp_client = TestClient(whatsapp_app) + interaction_client = TestClient(interaction_app) + ai_client = TestClient(ai_app) + patch_ai_internal_calls(monkeypatch, whatsapp_client, interaction_client) + seed_kb_article("WhatsApp greeting", "Здравствуйте! Поможем через WhatsApp.", "greeting") + + created = create_inbound_thread(whatsapp_client, "wa_ai_greeting", "Привет") + enqueue = ai_client.post( + f"/ai/whatsapp/threads/{created['thread_id']}/enqueue", + headers=admin_headers(), + json={"trigger_message_id": created["message_id"]}, + ) + assert enqueue.status_code == 200 + assert enqueue.json()["status"] == "done" + + thread = whatsapp_client.get( + f"/integrations/whatsapp/threads/{created['thread_id']}", + headers=admin_headers(), + ) + assert thread.status_code == 200 + assert thread.json()["ai_state"] == "active" + + messages = whatsapp_client.get( + f"/integrations/whatsapp/threads/{created['thread_id']}/messages", + headers=admin_headers(), + ) + assert messages.status_code == 200 + assert messages.json()[-1]["author_type"] == "ai" + assert messages.json()[-1]["text"] + + summary = fetch_ai_summary(whatsapp_client, created["thread_id"]) + assert summary.status_code == 200 + assert summary.json() is None + + +def test_ai_whatsapp_relaxed_kb_search_answers_phrase_query(monkeypatch): + monkeypatch.setenv("AI_WHATSAPP_ENABLED", "1") + monkeypatch.setenv("AI_PROVIDER", "stub") + monkeypatch.setattr(whatsapp_module, "_ai_enqueue_request", lambda thread_id, trigger_message_id: None) + monkeypatch.setattr(whatsapp_module, "_start_whatsapp_reply_delivery", lambda message_id: None) + + whatsapp_client = TestClient(whatsapp_app) + interaction_client = TestClient(interaction_app) + ai_client = TestClient(ai_app) + patch_ai_internal_calls(monkeypatch, whatsapp_client, interaction_client) + seed_kb_article( + "Tariff warelaxx", + "Tariff warelaxx activates after the request is confirmed.", + "warelaxx", + ) + + created = create_inbound_thread( + whatsapp_client, + "wa_ai_relaxed_kb", + _u(r"\u0425\u043e\u0447\u0443 \u0443\u0437\u043d\u0430\u0442\u044c \u0442\u0430\u0440\u0438\u0444 warelaxx"), + ) + enqueue = ai_client.post( + f"/ai/whatsapp/threads/{created['thread_id']}/enqueue", + headers=admin_headers(), + json={"trigger_message_id": created["message_id"]}, + ) + assert enqueue.status_code == 200 + assert enqueue.json()["status"] == "done" + + messages = whatsapp_client.get( + f"/integrations/whatsapp/threads/{created['thread_id']}/messages", + headers=admin_headers(), + ) + assert messages.status_code == 200 + assert messages.json()[-1]["author_type"] == "ai" + assert "warelaxx" in messages.json()[-1]["text"].lower() + + +def test_ai_whatsapp_handoff_creates_summary(monkeypatch): + monkeypatch.setenv("AI_WHATSAPP_ENABLED", "1") + monkeypatch.delenv("AI_WHATSAPP_ALWAYS_REPLY", raising=False) + monkeypatch.setenv("AI_PROVIDER", "stub") + monkeypatch.setattr(whatsapp_module, "_ai_enqueue_request", lambda thread_id, trigger_message_id: None) + monkeypatch.setattr(whatsapp_module, "_start_whatsapp_reply_delivery", lambda message_id: None) + + whatsapp_client = TestClient(whatsapp_app) + interaction_client = TestClient(interaction_app) + ai_client = TestClient(ai_app) + patch_ai_internal_calls(monkeypatch, whatsapp_client, interaction_client) + + created = create_inbound_thread(whatsapp_client, "wa_ai_handoff", "Соедините с оператором") + enqueue = ai_client.post( + f"/ai/whatsapp/threads/{created['thread_id']}/enqueue", + headers=admin_headers(), + json={"trigger_message_id": created["message_id"]}, + ) + assert enqueue.status_code == 200 + assert enqueue.json()["status"] == "handoff_required" + + thread = whatsapp_client.get( + f"/integrations/whatsapp/threads/{created['thread_id']}", + headers=admin_headers(), + ) + assert thread.status_code == 200 + assert thread.json()["ai_state"] == "handoff_required" + assert thread.json()["claimed_by_user"] is None + + summary = fetch_ai_summary(whatsapp_client, created["thread_id"]) + assert summary.status_code == 200 + payload = summary.json() + assert payload["thread_id"] == created["thread_id"] + assert payload["status_tone"] == "handoff" + assert payload["customer_request_text"] == created["text"] + assert payload["handoff_reason"] + + session = get_session() + try: + thread_row = session.execute( + select(WhatsAppThreadRow).where(WhatsAppThreadRow.thread_id == created["thread_id"]) + ).scalar_one() + ai_session = session.execute( + select(AISessionRow).where(AISessionRow.session_id == thread_row.ai_session_id) + ).scalar_one() + assert thread_row.ai_state == "handoff_required" + assert ai_session.status == "handoff_required" + finally: + session.close() diff --git a/tests/test_asterisk_bridge_service.py b/tests/test_asterisk_bridge_service.py new file mode 100644 index 0000000..bcf9ae6 --- /dev/null +++ b/tests/test_asterisk_bridge_service.py @@ -0,0 +1,3181 @@ +import json +from datetime import datetime, timedelta, timezone + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import select + +import services.asterisk_bridge_service.app as bridge_module +import services.asterisk_bridge_service.ivr_fastagi as bridge_ivr_fastagi +from services.shared.db import get_session +from services.shared.core import utc_now_iso +from services.shared.models import VoiceAICallStateUpdateIn, VoiceAIHandoffRequestIn +from services.shared.sql_models import ( + AISessionRow, + AsteriskCallActionLogRow, + AsteriskCallLinkRow, + AsteriskEventLogRow, + CallRecordingRow, + VoiceAISessionRow, + VoiceEventRow, + VoiceTranscriptSegmentRow, +) + + +def _admin_headers() -> dict[str, str]: + return {"X-User": "admin", "X-Role": "admin"} + + +def _operator_headers(user: str = "operator_a") -> dict[str, str]: + return {"X-User": user, "X-Role": "operator"} + + +@pytest.fixture(autouse=True) +def _cleanup_bridge_background_threads(): + yield + bridge_module._shutdown() + + +def _wait_until_stopped(stop_event=None): + if stop_event is not None: + stop_event.wait(60) + + +def test_asterisk_status_reports_queue_mapping(monkeypatch): + monkeypatch.setenv("ASTERISK_BRIDGE_ENABLED", "1") + monkeypatch.setenv("ASTERISK_AMI_HOST", "10.10.10.10") + monkeypatch.setenv("ASTERISK_QUEUE_MAP_JSON", '{"sales":"que_sales","support":"que_support"}') + monkeypatch.setenv("ASTERISK_BRIDGE_AUTH_MODE", "bearer_first") + monkeypatch.setenv("ASTERISK_WEBRTC_ENABLED", "1") + monkeypatch.setenv("ASTERISK_WEBRTC_WS_URL", "wss://pbx.example.test:8089/ws") + + client = TestClient(bridge_module.app) + response = client.get("/asterisk/status", headers=_admin_headers()) + + assert response.status_code == 200 + payload = response.json() + assert payload["status"] == "ok" + assert payload["ami_host"] == "10.10.10.10" + assert payload["queue_codes_loaded"] == ["sales", "support"] + assert payload["bridge_auth_mode"] == "bearer_first" + assert payload["callcontrol_enabled"] is False + assert payload["webrtc_enabled"] is True + assert payload["webrtc_ws_url"] == "wss://pbx.example.test:8089/ws" + + +def test_browser_softphone_config_route_returns_mapped_config(monkeypatch): + monkeypatch.setenv("ASTERISK_WEBRTC_ENABLED", "1") + monkeypatch.setenv("ASTERISK_WEBRTC_WS_URL", "wss://pbx.example.test:8089/ws") + monkeypatch.setenv("ASTERISK_AMI_HOST", "pbx.example.test") + monkeypatch.setenv("ASTERISK_OPERATOR_EXTENSION_MAP_JSON", '{"operator":"2001"}') + monkeypatch.setenv( + "ASTERISK_BROWSER_SIP_MAP_JSON", + '{"operator":{"authorization_username":"2001","password":"secret-2001","display_name":"Operator Browser"}}', + ) + monkeypatch.setenv( + "ASTERISK_WEBRTC_ICE_SERVERS_JSON", + '[{"urls":["stun:stun.example.test:3478"]}]', + ) + + client = TestClient(bridge_module.app) + response = client.get("/asterisk/browser-softphone/config", headers={"X-User": "operator", "X-Role": "operator"}) + + assert response.status_code == 200 + payload = response.json() + assert payload["enabled"] is True + assert payload["ws_url"] == "wss://pbx.example.test:8089/ws" + assert payload["authorization_username"] == "2001" + assert payload["password"] == "secret-2001" + assert payload["display_name"] == "Operator Browser" + assert payload["operator_extension"] == "2001" + assert payload["sip_uri"] == "sip:2001@pbx.example.test" + assert payload["ice_servers"] == [{"urls": ["stun:stun.example.test:3478"]}] + + +def test_browser_softphone_config_route_returns_403_when_mapping_missing(monkeypatch): + monkeypatch.setenv("ASTERISK_WEBRTC_ENABLED", "1") + monkeypatch.setenv("ASTERISK_WEBRTC_WS_URL", "wss://pbx.example.test:8089/ws") + monkeypatch.setenv("ASTERISK_BROWSER_SIP_MAP_JSON", "{}") + + client = TestClient(bridge_module.app) + response = client.get("/asterisk/browser-softphone/config", headers={"X-User": "operator", "X-Role": "operator"}) + + assert response.status_code == 403 + assert response.json()["detail"] == "Browser softphone is not configured for this user" + + +def test_post_json_bearer_first_falls_back_to_legacy(monkeypatch): + class DummyResponse: + def __init__(self, status_code: int, body: dict): + self.status_code = status_code + self._body = body + + def raise_for_status(self): + if self.status_code >= 400: + raise bridge_module.httpx.HTTPStatusError( + "error", + request=None, + response=self, + ) + + def json(self): + return self._body + + class DummyClient: + seen_headers = [] + + def __init__(self, **kwargs): + self.kwargs = kwargs + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def request(self, method, url, headers=None, **kwargs): + DummyClient.seen_headers.append(headers or {}) + if headers and headers.get("Authorization"): + return DummyResponse(401, {"detail": "unauthorized"}) + return DummyResponse(200, {"ok": True}) + + monkeypatch.setenv("ASTERISK_BRIDGE_AUTH_MODE", "bearer_first") + monkeypatch.setenv("ASTERISK_BRIDGE_AUTH_FALLBACK_LEGACY", "1") + monkeypatch.setenv("ASTERISK_BRIDGE_AUTH_USER", "ast-bridge") + monkeypatch.setenv("ASTERISK_BRIDGE_AUTH_ROLE", "admin") + monkeypatch.setenv("APP_TOKEN_SECRET", "track9-test-secret") + monkeypatch.setattr(bridge_module.httpx, "Client", DummyClient) + + result = bridge_module._post_json("http://example.internal/endpoint", {"a": 1}) + + assert result == {"ok": True} + assert len(DummyClient.seen_headers) == 2 + assert "Authorization" in DummyClient.seen_headers[0] + assert DummyClient.seen_headers[1]["X-User"] == "ast-bridge" + assert DummyClient.seen_headers[1]["X-Role"] == "admin" + + +def test_assign_interaction_uses_patch(monkeypatch): + class DummyResponse: + status_code = 200 + + def raise_for_status(self): + return None + + def json(self): + return {"ok": True} + + class DummyClient: + seen: list[dict[str, str]] = [] + + def __init__(self, **kwargs): + self.kwargs = kwargs + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def request(self, method, url, headers=None, **kwargs): + DummyClient.seen.append({"method": method, "url": url, "timeout": self.kwargs.get("timeout")}) + return DummyResponse() + + monkeypatch.setenv("ASTERISK_BRIDGE_AUTH_MODE", "legacy_headers") + monkeypatch.setattr(bridge_module.httpx, "Client", DummyClient) + + result = bridge_module._assign_interaction(interaction_id="int_demo", assignee="operator_a") + + assert result == {"ok": True} + assert DummyClient.seen == [ + {"method": "PATCH", "url": "http://localhost:8004/interactions/int_demo/assign", "timeout": 2.5} + ] + + +def test_assign_interaction_uses_fast_callcontrol_request_policy(monkeypatch): + captured: list[dict[str, object]] = [] + + def _fake_patch_json(url, payload, **kwargs): + captured.append({"url": url, "payload": payload, **kwargs}) + return {"ok": True} + + monkeypatch.setattr(bridge_module, "_patch_json", _fake_patch_json) + + result = bridge_module._assign_interaction(interaction_id="int_fast", assignee="operator_a") + + assert result == {"ok": True} + assert captured == [ + { + "url": "http://localhost:8004/interactions/int_fast/assign", + "payload": {"assignee": "operator_a"}, + "timeout_seconds": 2.5, + "max_attempts": 1, + "retry_backoff_seconds": 0.0, + } + ] + + +def test_ivr_fastagi_runtime_completes_and_sets_transfer_target(monkeypatch): + class DummyBridge: + def _queue_map(self): + return {"voice_lab_ivr": "q_ivr", "support": "q_support", "voice_lab": "q_voice"} + + def _ivr_dtmf_timeout_seconds(self): + return 5 + + def _ivr_max_no_input_retries(self): + return 2 + + def _ivr_max_invalid_retries(self): + return 2 + + class DummyChannel: + def __init__(self): + self.variables: dict[str, str | None] = {} + self.playbacks: list[str] = [] + self.waits: list[int] = [] + self.verbose_messages: list[str] = [] + + def play_and_wait_for_digit(self, prompt_audio_key: str, timeout_ms: int) -> str | None: + self.playbacks.append(prompt_audio_key) + return None + + def wait_for_digit(self, timeout_ms: int) -> str | None: + self.waits.append(timeout_ms) + return "1" + + def set_variable(self, name: str, value: str | None) -> None: + self.variables[name] = value + + def verbose(self, message: str, *, level: int = 1) -> None: + self.verbose_messages.append(f"{level}:{message}") + + calls: list[tuple[str, object]] = [] + + monkeypatch.setattr(bridge_ivr_fastagi, "_bridge_app", lambda: DummyBridge()) + monkeypatch.setattr( + bridge_ivr_fastagi, + "_wait_for_call_link_context", + lambda call_id, stop_event=None: {"interaction_id": "int_ivr", "queue_id": "q_ivr", "queue_code": "voice_lab_ivr"}, + ) + monkeypatch.setattr( + bridge_ivr_fastagi, + "_resolve_transfer_extension", + lambda queue_code: {"support": "7010", "voice_lab": "7000"}.get(queue_code), + ) + monkeypatch.setattr( + bridge_ivr_fastagi, + "_start_session", + lambda call_context, interaction_id, queue_id: calls.append(("start", queue_id)) or { + "session": {"session_id": "ivs_fastagi"}, + "current_node": { + "node_id": "root", + "prompt_audio_key": "ivr/demo-root", + "options": [{"digit": "1", "target_node_id": "support"}], + }, + "completed": False, + }, + ) + monkeypatch.setattr( + bridge_ivr_fastagi, + "_send_dtmf", + lambda session_id, digit: calls.append(("dtmf", digit)) or { + "session": { + "session_id": session_id, + "status": "completed", + "resolved_queue_id": "q_support", + "resolved_queue_code": "support", + }, + "current_node": {"node_id": "support", "is_terminal": True}, + "completed": True, + }, + ) + monkeypatch.setattr(bridge_ivr_fastagi, "_abandon_session", lambda session_id: calls.append(("abandon", session_id))) + + channel = DummyChannel() + request = bridge_ivr_fastagi.AgiRequest( + env={}, + args=["call_fastagi", "linked_fastagi", "voice_lab_ivr", "voice_lab", "7200"], + ) + + bridge_ivr_fastagi.run_ivr_call(channel, request) + + assert calls == [("start", "q_ivr"), ("dtmf", "1")] + assert channel.playbacks == ["ivr/demo-root"] + assert channel.waits == [5000] + assert channel.variables["MVPCC_IVR_SESSION_ID"] == "ivs_fastagi" + assert channel.variables["MVPCC_IVR_TARGET_QUEUE_CODE"] == "support" + assert channel.variables["MVPCC_IVR_TARGET_EXTENSION"] == "7010" + assert channel.variables["MVPCC_IVR_RESULT"] == "completed" + + +def test_ivr_fastagi_runtime_falls_back_after_no_input_retries(monkeypatch): + class DummyBridge: + def _queue_map(self): + return {"voice_lab_ivr": "q_ivr", "voice_lab": "q_voice"} + + def _ivr_dtmf_timeout_seconds(self): + return 5 + + def _ivr_max_no_input_retries(self): + return 2 + + def _ivr_max_invalid_retries(self): + return 2 + + class DummyChannel: + def __init__(self): + self.variables: dict[str, str | None] = {} + self.wait_calls = 0 + + def play_and_wait_for_digit(self, prompt_audio_key: str, timeout_ms: int) -> str | None: + return None + + def wait_for_digit(self, timeout_ms: int) -> str | None: + self.wait_calls += 1 + return None + + def set_variable(self, name: str, value: str | None) -> None: + self.variables[name] = value + + def verbose(self, message: str, *, level: int = 1) -> None: + return None + + no_input_calls: list[str] = [] + abandoned: list[str] = [] + + monkeypatch.setattr(bridge_ivr_fastagi, "_bridge_app", lambda: DummyBridge()) + monkeypatch.setattr( + bridge_ivr_fastagi, + "_wait_for_call_link_context", + lambda call_id, stop_event=None: {"interaction_id": "int_ivr", "queue_id": "q_ivr", "queue_code": "voice_lab_ivr"}, + ) + monkeypatch.setattr( + bridge_ivr_fastagi, + "_resolve_transfer_extension", + lambda queue_code: {"voice_lab": "7000"}.get(queue_code), + ) + monkeypatch.setattr( + bridge_ivr_fastagi, + "_start_session", + lambda call_context, interaction_id, queue_id: { + "session": {"session_id": "ivs_no_input"}, + "current_node": { + "node_id": "root", + "prompt_audio_key": "ivr/demo-root", + "options": [{"digit": "1", "target_node_id": "support"}], + }, + "completed": False, + }, + ) + monkeypatch.setattr( + bridge_ivr_fastagi, + "_send_no_input", + lambda session_id: no_input_calls.append(session_id) or { + "session": {"session_id": session_id, "status": "active"}, + "current_node": { + "node_id": "root", + "prompt_audio_key": "ivr/demo-root", + "options": [{"digit": "1", "target_node_id": "support"}], + }, + "completed": False, + }, + ) + monkeypatch.setattr(bridge_ivr_fastagi, "_abandon_session", lambda session_id: abandoned.append(session_id)) + + channel = DummyChannel() + request = bridge_ivr_fastagi.AgiRequest( + env={}, + args=["call_no_input", "linked_no_input", "voice_lab_ivr", "voice_lab", "7200"], + ) + + bridge_ivr_fastagi.run_ivr_call(channel, request) + + assert no_input_calls == ["ivs_no_input", "ivs_no_input", "ivs_no_input"] + assert abandoned == ["ivs_no_input"] + assert channel.variables["MVPCC_IVR_TARGET_QUEUE_CODE"] == "voice_lab" + assert channel.variables["MVPCC_IVR_TARGET_EXTENSION"] == "7000" + assert channel.variables["MVPCC_IVR_RESULT"] == "fallback_no_input" + + +def test_ivr_fastagi_runtime_falls_back_after_invalid_digit_retries(monkeypatch): + class DummyBridge: + def _queue_map(self): + return {"voice_lab_ivr": "q_ivr", "voice_lab": "q_voice"} + + def _ivr_dtmf_timeout_seconds(self): + return 5 + + def _ivr_max_no_input_retries(self): + return 2 + + def _ivr_max_invalid_retries(self): + return 2 + + class DummyChannel: + def __init__(self): + self.variables: dict[str, str | None] = {} + self.digits = ["9", "9", "9"] + + def play_and_wait_for_digit(self, prompt_audio_key: str, timeout_ms: int) -> str | None: + return None + + def wait_for_digit(self, timeout_ms: int) -> str | None: + return self.digits.pop(0) + + def set_variable(self, name: str, value: str | None) -> None: + self.variables[name] = value + + def verbose(self, message: str, *, level: int = 1) -> None: + return None + + dtmf_calls: list[str] = [] + abandoned: list[str] = [] + + monkeypatch.setattr(bridge_ivr_fastagi, "_bridge_app", lambda: DummyBridge()) + monkeypatch.setattr( + bridge_ivr_fastagi, + "_wait_for_call_link_context", + lambda call_id, stop_event=None: {"interaction_id": "int_ivr", "queue_id": "q_ivr", "queue_code": "voice_lab_ivr"}, + ) + monkeypatch.setattr( + bridge_ivr_fastagi, + "_resolve_transfer_extension", + lambda queue_code: {"voice_lab": "7000"}.get(queue_code), + ) + monkeypatch.setattr( + bridge_ivr_fastagi, + "_start_session", + lambda call_context, interaction_id, queue_id: { + "session": {"session_id": "ivs_invalid"}, + "current_node": { + "node_id": "root", + "prompt_audio_key": "ivr/demo-root", + "options": [{"digit": "1", "target_node_id": "support"}], + }, + "completed": False, + }, + ) + monkeypatch.setattr( + bridge_ivr_fastagi, + "_send_dtmf", + lambda session_id, digit: dtmf_calls.append(digit) or { + "session": {"session_id": session_id, "status": "active"}, + "current_node": { + "node_id": "root", + "prompt_audio_key": "ivr/demo-root", + "options": [{"digit": "1", "target_node_id": "support"}], + }, + "completed": False, + }, + ) + monkeypatch.setattr(bridge_ivr_fastagi, "_abandon_session", lambda session_id: abandoned.append(session_id)) + + channel = DummyChannel() + request = bridge_ivr_fastagi.AgiRequest( + env={}, + args=["call_invalid", "linked_invalid", "voice_lab_ivr", "voice_lab", "7200"], + ) + + bridge_ivr_fastagi.run_ivr_call(channel, request) + + assert dtmf_calls == ["9", "9", "9"] + assert abandoned == ["ivs_invalid"] + assert channel.variables["MVPCC_IVR_TARGET_QUEUE_CODE"] == "voice_lab" + assert channel.variables["MVPCC_IVR_TARGET_EXTENSION"] == "7000" + assert channel.variables["MVPCC_IVR_RESULT"] == "fallback_invalid" + + +def test_ivr_fastagi_runtime_abandons_on_hangup(monkeypatch): + class DummyBridge: + def _queue_map(self): + return {"voice_lab_ivr": "q_ivr"} + + def _ivr_dtmf_timeout_seconds(self): + return 5 + + def _ivr_max_no_input_retries(self): + return 2 + + def _ivr_max_invalid_retries(self): + return 2 + + class DummyChannel: + def __init__(self): + self.variables: dict[str, str | None] = {} + + def play_and_wait_for_digit(self, prompt_audio_key: str, timeout_ms: int) -> str | None: + return None + + def wait_for_digit(self, timeout_ms: int) -> str | None: + raise bridge_ivr_fastagi.AgiHangup("caller hung up") + + def set_variable(self, name: str, value: str | None) -> None: + self.variables[name] = value + + def verbose(self, message: str, *, level: int = 1) -> None: + return None + + abandoned: list[str] = [] + + monkeypatch.setattr(bridge_ivr_fastagi, "_bridge_app", lambda: DummyBridge()) + monkeypatch.setattr( + bridge_ivr_fastagi, + "_wait_for_call_link_context", + lambda call_id, stop_event=None: {"interaction_id": "int_ivr", "queue_id": "q_ivr", "queue_code": "voice_lab_ivr"}, + ) + monkeypatch.setattr( + bridge_ivr_fastagi, + "_start_session", + lambda call_context, interaction_id, queue_id: { + "session": {"session_id": "ivs_hangup"}, + "current_node": {"node_id": "root", "options": []}, + "completed": False, + }, + ) + monkeypatch.setattr(bridge_ivr_fastagi, "_abandon_session", lambda session_id: abandoned.append(session_id)) + + channel = DummyChannel() + request = bridge_ivr_fastagi.AgiRequest( + env={}, + args=["call_hangup", "linked_hangup", "voice_lab_ivr", "voice_lab", "7200"], + ) + + bridge_ivr_fastagi.run_ivr_call(channel, request) + + assert abandoned == ["ivs_hangup"] + + +def test_ivr_fastagi_runtime_uses_digit_captured_during_prompt(monkeypatch): + class DummyBridge: + def _queue_map(self): + return {"voice_lab_ivr": "q_ivr", "support": "q_support", "voice_lab": "q_voice"} + + def _ivr_dtmf_timeout_seconds(self): + return 5 + + def _ivr_max_no_input_retries(self): + return 2 + + def _ivr_max_invalid_retries(self): + return 2 + + class DummyChannel: + def __init__(self): + self.variables: dict[str, str | None] = {} + self.waits: list[int] = [] + self.playbacks: list[str] = [] + + def play_and_wait_for_digit(self, prompt_audio_key: str, timeout_ms: int) -> str | None: + self.playbacks.append(prompt_audio_key) + return "2" + + def wait_for_digit(self, timeout_ms: int) -> str | None: + self.waits.append(timeout_ms) + return None + + def set_variable(self, name: str, value: str | None) -> None: + self.variables[name] = value + + def verbose(self, message: str, *, level: int = 1) -> None: + return None + + dtmf_calls: list[str] = [] + + monkeypatch.setattr(bridge_ivr_fastagi, "_bridge_app", lambda: DummyBridge()) + monkeypatch.setattr( + bridge_ivr_fastagi, + "_wait_for_call_link_context", + lambda call_id, stop_event=None: {"interaction_id": "int_ivr", "queue_id": "q_ivr", "queue_code": "voice_lab_ivr"}, + ) + monkeypatch.setattr( + bridge_ivr_fastagi, + "_resolve_transfer_extension", + lambda queue_code: {"support": "7010", "voice_lab": "7000"}.get(queue_code), + ) + monkeypatch.setattr( + bridge_ivr_fastagi, + "_start_session", + lambda call_context, interaction_id, queue_id: { + "session": {"session_id": "ivs_prompt_digit"}, + "current_node": { + "node_id": "root", + "prompt_audio_key": "ivr/demo-root", + "options": [{"digit": "2", "target_node_id": "support"}], + }, + "completed": False, + }, + ) + monkeypatch.setattr( + bridge_ivr_fastagi, + "_send_dtmf", + lambda session_id, digit: dtmf_calls.append(digit) or { + "session": { + "session_id": session_id, + "status": "completed", + "resolved_queue_id": "q_support", + "resolved_queue_code": "support", + }, + "current_node": {"node_id": "support", "is_terminal": True}, + "completed": True, + }, + ) + monkeypatch.setattr(bridge_ivr_fastagi, "_abandon_session", lambda session_id: None) + + channel = DummyChannel() + request = bridge_ivr_fastagi.AgiRequest( + env={}, + args=["call_prompt_digit", "linked_prompt_digit", "voice_lab_ivr", "voice_lab", "7200"], + ) + + bridge_ivr_fastagi.run_ivr_call(channel, request) + + assert channel.playbacks == ["ivr/demo-root"] + assert channel.waits == [] + assert dtmf_calls == ["2"] + assert channel.variables["MVPCC_IVR_TARGET_QUEUE_CODE"] == "support" + assert channel.variables["MVPCC_IVR_TARGET_EXTENSION"] == "7010" + + +def test_ivr_fastagi_runtime_plays_prompt_sequence_before_wait(monkeypatch): + class DummyBridge: + def _queue_map(self): + return {"voice_lab_ivr": "q_ivr", "support": "q_support", "voice_lab": "q_voice"} + + def _ivr_dtmf_timeout_seconds(self): + return 5 + + def _ivr_max_no_input_retries(self): + return 2 + + def _ivr_max_invalid_retries(self): + return 2 + + class DummyChannel: + def __init__(self): + self.variables: dict[str, str | None] = {} + self.playbacks: list[tuple[str, int]] = [] + self.waits: list[int] = [] + + def play_and_wait_for_digit(self, prompt_audio_key: str, timeout_ms: int) -> str | None: + self.playbacks.append((prompt_audio_key, timeout_ms)) + if prompt_audio_key == "ivr/demo-language-ru": + return "2" + return None + + def wait_for_digit(self, timeout_ms: int) -> str | None: + self.waits.append(timeout_ms) + return None + + def set_variable(self, name: str, value: str | None) -> None: + self.variables[name] = value + + def verbose(self, message: str, *, level: int = 1) -> None: + return None + + dtmf_calls: list[str] = [] + + monkeypatch.setattr(bridge_ivr_fastagi, "_bridge_app", lambda: DummyBridge()) + monkeypatch.setattr( + bridge_ivr_fastagi, + "_wait_for_call_link_context", + lambda call_id, stop_event=None: {"interaction_id": "int_ivr", "queue_id": "q_ivr", "queue_code": "voice_lab_ivr"}, + ) + monkeypatch.setattr( + bridge_ivr_fastagi, + "_resolve_transfer_extension", + lambda queue_code: {"support": "7010", "voice_lab": "7000"}.get(queue_code), + ) + monkeypatch.setattr( + bridge_ivr_fastagi, + "_start_session", + lambda call_context, interaction_id, queue_id: { + "session": {"session_id": "ivs_prompt_sequence"}, + "current_node": { + "node_id": "root", + "prompt_text": "Combined bilingual prompt", + "prompt_sequence": [ + {"prompt_audio_key": "ivr/demo-language-kz", "prompt_text": "Kazakh segment", "language": "kz"}, + {"prompt_audio_key": "ivr/demo-language-ru", "prompt_text": "Russian segment", "language": "ru"}, + ], + "options": [{"digit": "2", "target_node_id": "support"}], + }, + "completed": False, + }, + ) + monkeypatch.setattr( + bridge_ivr_fastagi, + "_send_dtmf", + lambda session_id, digit: dtmf_calls.append(digit) or { + "session": { + "session_id": session_id, + "status": "completed", + "resolved_queue_id": "q_support", + "resolved_queue_code": "support", + }, + "current_node": {"node_id": "support", "is_terminal": True}, + "completed": True, + }, + ) + monkeypatch.setattr(bridge_ivr_fastagi, "_abandon_session", lambda session_id: None) + + channel = DummyChannel() + request = bridge_ivr_fastagi.AgiRequest( + env={}, + args=["call_prompt_sequence", "linked_prompt_sequence", "voice_lab_ivr", "voice_lab", "7200"], + ) + + bridge_ivr_fastagi.run_ivr_call(channel, request) + + assert channel.playbacks == [ + ("ivr/demo-language-kz", 1), + ("ivr/demo-language-ru", 1), + ] + assert channel.waits == [] + assert dtmf_calls == ["2"] + assert channel.variables["MVPCC_IVR_TARGET_QUEUE_CODE"] == "support" + assert channel.variables["MVPCC_IVR_TARGET_EXTENSION"] == "7010" + + +def test_bridge_process_call_started_creates_interaction_and_call_link(monkeypatch, tmp_path): + monkeypatch.setenv("ASTERISK_QUEUE_MAP_JSON", '{"lab":"que_lab"}') + interaction_id = f"int_ast_{tmp_path.name}" + emitted: dict[str, object] = {} + + monkeypatch.setattr( + bridge_module, + "_create_interaction", + lambda **kwargs: {"interaction_id": interaction_id}, + ) + monkeypatch.setattr( + bridge_module, + "_emit_voice_event", + lambda **kwargs: emitted.update(kwargs) or {"event_id": "vev_ast"}, + ) + + call_id = f"call_ast_{tmp_path.name}" + session = get_session() + try: + row = bridge_module._create_bridge_log( + session, + ami_event_name="MVPCCCallStarted", + call_id=call_id, + linked_id="linked_demo", + payload={ + "UserEvent": "MVPCCCallStarted", + "CallID": call_id, + "LinkedID": "linked_demo", + "CallerNumber": "+77010000001", + "CallerName": "Lab Caller", + "QueueCode": "lab", + "Extension": "7000", + "Context": "mvpcc-inbound", + "Direction": "inbound", + }, + ) + + bridge_module._process_bridge_row(session, row) + session.commit() + + link = session.execute( + select(AsteriskCallLinkRow).where(AsteriskCallLinkRow.call_id == call_id) + ).scalar_one_or_none() + + assert row.forward_status == "forwarded" + assert row.interaction_id == interaction_id + assert link is not None + assert link.queue_code == "lab" + assert link.queue_id == "que_lab" + assert link.interaction_id == interaction_id + assert emitted["event_type"] == "call.started" + assert emitted["interaction_id"] == interaction_id + assert emitted["payload"]["source"] == "asterisk" + finally: + session.close() + + client = TestClient(bridge_module.app) + response = client.get( + f"/asterisk/live-calls/{call_id}/ai-summary", + headers=_admin_headers(), + ) + + assert response.status_code == 200 + payload = response.json() + + +def test_call_started_uses_api_fallback_after_create_failure(monkeypatch, tmp_path): + monkeypatch.setenv("ASTERISK_QUEUE_MAP_JSON", '{"lab":"que_lab"}') + interaction_id = f"int_ast_api_{tmp_path.name}" + emitted: dict[str, object] = {} + + def _raise_create_error(**kwargs): + raise RuntimeError("interaction create failed") + + monkeypatch.setattr(bridge_module, "_create_interaction", _raise_create_error) + monkeypatch.setattr( + bridge_module, + "_find_interaction_by_call_id", + lambda call_id: {"interaction_id": interaction_id, "subject": f"Inbound call [{call_id}]"}, + ) + monkeypatch.setattr( + bridge_module, + "_emit_voice_event", + lambda **kwargs: emitted.update(kwargs) or {"event_id": "vev_ast_api"}, + ) + + call_id = f"call_ast_api_{tmp_path.name}" + session = get_session() + try: + row = bridge_module._create_bridge_log( + session, + ami_event_name="MVPCCCallStarted", + call_id=call_id, + linked_id="linked_demo_api", + payload={ + "UserEvent": "MVPCCCallStarted", + "CallID": call_id, + "LinkedID": "linked_demo_api", + "CallerNumber": "+77010000021", + "CallerName": "API Fallback Caller", + "QueueCode": "lab", + "Extension": "7000", + "Context": "mvpcc-inbound", + "Direction": "inbound", + }, + ) + + bridge_module._process_bridge_row(session, row) + session.commit() + + link = session.execute( + select(AsteriskCallLinkRow).where(AsteriskCallLinkRow.call_id == call_id) + ).scalar_one_or_none() + + assert row.forward_status == "forwarded" + assert row.interaction_id == interaction_id + assert link is not None + assert link.interaction_id == interaction_id + assert emitted["event_type"] == "call.started" + assert emitted["interaction_id"] == interaction_id + finally: + session.close() + + +def test_call_started_re_raises_original_create_error_when_all_fallbacks_fail(monkeypatch, tmp_path): + monkeypatch.setenv("ASTERISK_QUEUE_MAP_JSON", '{"lab":"que_lab"}') + + def _raise_create_error(**kwargs): + raise RuntimeError("interaction create failed") + + monkeypatch.setattr(bridge_module, "_create_interaction", _raise_create_error) + monkeypatch.setattr(bridge_module, "_find_interaction_by_call_id", lambda call_id: None) + + call_id = f"call_ast_fail_{tmp_path.name}" + session = get_session() + try: + row = bridge_module._create_bridge_log( + session, + ami_event_name="MVPCCCallStarted", + call_id=call_id, + linked_id="linked_demo_fail", + payload={ + "UserEvent": "MVPCCCallStarted", + "CallID": call_id, + "LinkedID": "linked_demo_fail", + "CallerNumber": "+77010000022", + "CallerName": "Failed Fallback Caller", + "QueueCode": "lab", + "Extension": "7000", + "Context": "mvpcc-inbound", + "Direction": "inbound", + }, + ) + + with pytest.raises(RuntimeError, match="interaction create failed"): + bridge_module._process_bridge_row(session, row) + finally: + session.close() + + +def test_reconcile_keeps_live_call_active_when_ami_still_sees_channels(monkeypatch, tmp_path): + monkeypatch.setenv("ASTERISK_BRIDGE_RECONCILE_ENABLED", "1") + + call_id = f"call_reconcile_live_{tmp_path.name}" + stale_started = (datetime.now(timezone.utc) - timedelta(seconds=60)).isoformat() + + session = get_session() + try: + link = AsteriskCallLinkRow( + call_id=call_id, + linked_id="linked_reconcile_live", + queue_code="lab", + queue_id="que_lab", + interaction_id=f"int_reconcile_live_{tmp_path.name}", + caller_number="+77010000004", + caller_name="Track11 Reconcile Live", + status="active", + telephony_status="connected", + claimed_by_user=None, + claimed_at=None, + operator_extension="2001", + channel_name="PJSIP/2001-stale", + started_at=stale_started, + connected_at=stale_started, + ended_at=None, + updated_at=stale_started, + ) + session.add(link) + session.commit() + finally: + session.close() + + emitted: list[dict[str, object]] = [] + monkeypatch.setattr( + bridge_module, + "_emit_voice_event", + lambda **kwargs: emitted.append(kwargs) or {"event_id": "vev_reconcile_live"}, + ) + monkeypatch.setattr( + bridge_module, + "_list_channels_via_coreshowchannels", + lambda **kwargs: ["PJSIP/2001-00009999"], + ) + + bridge_module._reconcile_stale_calls_once() + + session = get_session() + try: + link = session.execute( + select(AsteriskCallLinkRow).where(AsteriskCallLinkRow.call_id == call_id) + ).scalar_one() + assert link.status == "active" + assert link.telephony_status == "connected" + assert link.ended_at is None + assert link.channel_name == "PJSIP/2001-00009999" + assert emitted == [] + finally: + session.close() + + +def test_reconcile_skips_synthetic_end_when_channel_lookup_is_unavailable(monkeypatch, tmp_path): + monkeypatch.setenv("ASTERISK_BRIDGE_RECONCILE_ENABLED", "1") + + call_id = f"call_reconcile_unavailable_{tmp_path.name}" + stale_started = (datetime.now(timezone.utc) - timedelta(seconds=60)).isoformat() + + session = get_session() + try: + link = AsteriskCallLinkRow( + call_id=call_id, + linked_id="linked_reconcile_unavailable", + queue_code="lab", + queue_id="que_lab", + interaction_id=f"int_reconcile_unavailable_{tmp_path.name}", + caller_number="+77010000024", + caller_name="Track11 Reconcile Unavailable", + status="active", + telephony_status="connected", + claimed_by_user=None, + claimed_at=None, + operator_extension="2001", + channel_name="PJSIP/2001-00009998", + started_at=stale_started, + connected_at=stale_started, + ended_at=None, + updated_at=stale_started, + ) + session.add(link) + session.commit() + finally: + session.close() + + emitted: list[dict[str, object]] = [] + monkeypatch.setattr( + bridge_module, + "_emit_voice_event", + lambda **kwargs: emitted.append(kwargs) or {"event_id": "vev_reconcile_unavailable"}, + ) + monkeypatch.setattr( + bridge_module, + "_list_channels_via_coreshowchannels", + lambda **kwargs: None, + ) + + bridge_module._reconcile_stale_calls_once() + + session = get_session() + try: + link = session.execute( + select(AsteriskCallLinkRow).where(AsteriskCallLinkRow.call_id == call_id) + ).scalar_one() + assert link.status == "active" + assert link.telephony_status == "connected" + assert link.ended_at is None + assert emitted == [] + finally: + session.close() + + +def test_reconcile_emits_synthetic_end_and_closes_call(monkeypatch, tmp_path): + monkeypatch.setenv("ASTERISK_BRIDGE_RECONCILE_ENABLED", "1") + + call_id = f"call_reconcile_soft_{tmp_path.name}" + stale_started = (datetime.now(timezone.utc) - timedelta(seconds=60)).isoformat() + + session = get_session() + try: + link = AsteriskCallLinkRow( + call_id=call_id, + linked_id="linked_reconcile_soft", + queue_code="lab", + queue_id="que_lab", + interaction_id=f"int_reconcile_soft_{tmp_path.name}", + caller_number="+77010000005", + caller_name="Track11 Reconcile Soft", + status="active", + telephony_status="connected", + claimed_by_user=None, + claimed_at=None, + operator_extension="2001", + channel_name="PJSIP/2001-00000010", + started_at=stale_started, + connected_at=stale_started, + ended_at=None, + updated_at=stale_started, + ) + session.add(link) + session.commit() + finally: + session.close() + + emitted: list[dict[str, object]] = [] + monkeypatch.setattr( + bridge_module, + "_emit_voice_event", + lambda **kwargs: emitted.append(kwargs) or {"event_id": "vev_reconcile_soft"}, + ) + monkeypatch.setattr( + bridge_module, + "_list_channels_via_coreshowchannels", + lambda **kwargs: [], + ) + + bridge_module._reconcile_stale_calls_once() + + session = get_session() + try: + link = session.execute( + select(AsteriskCallLinkRow).where(AsteriskCallLinkRow.call_id == call_id) + ).scalar_one() + assert link.status == "ended" + assert link.telephony_status == "ended" + assert link.ended_at is not None + matching = [item for item in emitted if item["call_id"] == call_id] + assert len(matching) == 1 + assert matching[0]["event_type"] == "call.ended" + assert matching[0]["payload"]["reconciled"] is True + finally: + session.close() + + +def test_reconcile_keeps_recent_active_ai_call_open(monkeypatch, tmp_path): + monkeypatch.setenv("ASTERISK_BRIDGE_RECONCILE_ENABLED", "1") + + call_id = f"call_reconcile_ai_live_{tmp_path.name}" + session_id = f"avs_reconcile_ai_live_{tmp_path.name}" + stale_started = (datetime.now(timezone.utc) - timedelta(seconds=60)).isoformat() + recent_media = (datetime.now(timezone.utc) - timedelta(seconds=2)).isoformat() + + session = get_session() + try: + link = AsteriskCallLinkRow( + call_id=call_id, + linked_id="linked_reconcile_ai_live", + queue_code="voice_lab_ai", + queue_id="que_voice_lab", + interaction_id=f"int_reconcile_ai_live_{tmp_path.name}", + caller_number="+77010000055", + caller_name="Track16 Reconcile AI", + status="active", + telephony_status="connected", + claimed_by_user=None, + claimed_at=None, + operator_extension=None, + channel_name="PJSIP/1001-0000AI55", + voice_session_id=session_id, + ai_session_id=f"ais_reconcile_ai_live_{tmp_path.name}", + ai_state="speaking", + ai_handoff_reason=None, + ai_last_model_at=recent_media, + started_at=stale_started, + connected_at=stale_started, + ended_at=None, + updated_at=stale_started, + ) + session.add(link) + session.add( + VoiceAISessionRow( + session_id=session_id, + call_id=call_id, + linked_id="linked_reconcile_ai_live", + interaction_id=link.interaction_id, + customer_id=None, + queue_id="que_voice_lab", + ai_session_id=f"ais_reconcile_ai_live_{tmp_path.name}", + agent_profile="voice_support", + language="ru", + asr_provider="openai", + tts_provider="yandex", + status="active", + handoff_reason=None, + handoff_target_queue_id="que_voice_lab", + media_uuid="11111111-1111-1111-1111-111111111111", + media_status="connected", + media_connected_at=stale_started, + media_ended_at=None, + last_media_frame_at=recent_media, + disclosure_played_at=stale_started, + last_user_utterance_at=recent_media, + last_ai_reply_at=recent_media, + started_at=stale_started, + updated_at=recent_media, + ended_at=None, + ) + ) + session.commit() + finally: + session.close() + + emitted: list[dict[str, object]] = [] + monkeypatch.setattr( + bridge_module, + "_emit_voice_event", + lambda **kwargs: emitted.append(kwargs) or {"event_id": "vev_reconcile_ai_live"}, + ) + monkeypatch.setattr( + bridge_module, + "_list_channels_via_coreshowchannels", + lambda **kwargs: [], + ) + + bridge_module._reconcile_stale_calls_once() + + session = get_session() + try: + link = session.execute( + select(AsteriskCallLinkRow).where(AsteriskCallLinkRow.call_id == call_id) + ).scalar_one() + assert link.status == "active" + assert link.telephony_status == "connected" + assert link.ended_at is None + assert emitted == [] + finally: + session.close() + + +def test_reconcile_closes_active_call_without_duplicate_end_event(monkeypatch, tmp_path): + monkeypatch.setenv("ASTERISK_BRIDGE_RECONCILE_ENABLED", "1") + + call_id = f"call_reconcile_existing_end_{tmp_path.name}" + stale_started = (datetime.now(timezone.utc) - timedelta(seconds=60)).isoformat() + + session = get_session() + try: + link = AsteriskCallLinkRow( + call_id=call_id, + linked_id="linked_reconcile_existing_end", + queue_code="lab", + queue_id="que_lab", + interaction_id=f"int_reconcile_existing_end_{tmp_path.name}", + caller_number="+77010000023", + caller_name="Track11 Reconcile Existing End", + status="active", + telephony_status="connected", + claimed_by_user=None, + claimed_at=None, + operator_extension="2001", + channel_name="PJSIP/2001-00000011", + started_at=stale_started, + connected_at=stale_started, + ended_at=None, + updated_at=stale_started, + ) + session.add(link) + session.add( + VoiceEventRow( + event_id=f"vev_reconcile_existing_end_{tmp_path.name}", + event_type="call.ended", + call_id=call_id, + interaction_id=link.interaction_id, + payload_json=json.dumps({"hangup_cause": "normal_clearing"}), + created_at=stale_started, + ) + ) + session.commit() + finally: + session.close() + + emitted: list[dict[str, object]] = [] + monkeypatch.setattr( + bridge_module, + "_emit_voice_event", + lambda **kwargs: emitted.append(kwargs) or {"event_id": "vev_reconcile_existing_end"}, + ) + monkeypatch.setattr( + bridge_module, + "_list_channels_via_coreshowchannels", + lambda **kwargs: [], + ) + + bridge_module._reconcile_stale_calls_once() + + session = get_session() + try: + link = session.execute( + select(AsteriskCallLinkRow).where(AsteriskCallLinkRow.call_id == call_id) + ).scalar_one() + assert link.status == "ended" + assert link.telephony_status == "ended" + assert link.ended_at is not None + assert emitted == [] + finally: + session.close() + + +def test_reconcile_prioritizes_oldest_unresolved_ended_call(monkeypatch, tmp_path): + monkeypatch.setenv("ASTERISK_BRIDGE_RECONCILE_ENABLED", "1") + monkeypatch.setenv("ASTERISK_BRIDGE_RECONCILE_SCAN_LIMIT", "1") + + older_iso = (datetime.now(timezone.utc) - timedelta(seconds=120)).isoformat() + newer_iso = (datetime.now(timezone.utc) - timedelta(seconds=30)).isoformat() + older_call_id = f"call_reconcile_oldest_{tmp_path.name}" + newer_call_id = f"call_reconcile_newer_with_recording_{tmp_path.name}" + recording_path = tmp_path / "fixtures" / "reconcile-oldest.wav" + recording_path.parent.mkdir(parents=True, exist_ok=True) + recording_path.write_bytes(b"RIFF" + b"\x00" * 32) + + session = get_session() + try: + session.query(CallRecordingRow).delete() + session.query(AsteriskCallLinkRow).delete() + session.commit() + + older_link = AsteriskCallLinkRow( + call_id=older_call_id, + linked_id="linked_reconcile_oldest", + queue_code="lab", + queue_id="que_lab", + interaction_id=f"int_reconcile_oldest_{tmp_path.name}", + caller_number="+77010000025", + caller_name="Track11 Reconcile Oldest", + status="ended", + telephony_status="ended", + claimed_by_user=None, + claimed_at=None, + operator_extension="2001", + channel_name="PJSIP/2001-oldest", + started_at=older_iso, + connected_at=older_iso, + ended_at=older_iso, + updated_at=older_iso, + ) + newer_link = AsteriskCallLinkRow( + call_id=newer_call_id, + linked_id="linked_reconcile_newer", + queue_code="lab", + queue_id="que_lab", + interaction_id=f"int_reconcile_newer_{tmp_path.name}", + caller_number="+77010000026", + caller_name="Track11 Reconcile Newer", + status="ended", + telephony_status="ended", + claimed_by_user=None, + claimed_at=None, + operator_extension="2001", + channel_name="PJSIP/2001-newer", + started_at=newer_iso, + connected_at=newer_iso, + ended_at=newer_iso, + updated_at=newer_iso, + ) + session.add_all([older_link, newer_link]) + session.flush() + session.add( + CallRecordingRow( + recording_id=f"rec_reconcile_newer_{tmp_path.name}", + channel="voice", + call_id=newer_call_id, + interaction_id=newer_link.interaction_id, + source_event_id=f"src_reconcile_newer_{tmp_path.name}", + file_name="newer.wav", + storage_backend="local_fs", + storage_path=str(recording_path), + mime_type="audio/wav", + size_bytes=recording_path.stat().st_size, + duration_seconds=4, + checksum_sha256="abc123", + status="ready", + recorded_at=newer_iso, + created_at=newer_iso, + updated_at=newer_iso, + archived_at=None, + ) + ) + session.commit() + finally: + session.close() + + uploaded: list[dict[str, object]] = [] + monkeypatch.setattr( + bridge_module, + "_find_remote_recording_for_call", + lambda call_id: ( + ("/remote/oldest.wav", "oldest.wav", "audio/wav", datetime.now(timezone.utc) - timedelta(seconds=60)) + if call_id == older_call_id + else None + ), + ) + monkeypatch.setattr( + bridge_module, + "_fetch_recording_file", + lambda remote_path, file_name: (recording_path, False), + ) + monkeypatch.setattr( + bridge_module, + "_emit_voice_event", + lambda **kwargs: {"event_id": f"vev_{kwargs['call_id']}"}, + ) + monkeypatch.setattr( + bridge_module, + "_upload_recording", + lambda **kwargs: uploaded.append(kwargs) or {"recording_id": f"rec_uploaded_{kwargs['call_id']}"}, + ) + + bridge_module._reconcile_stale_calls_once() + + assert [entry["call_id"] for entry in uploaded] == [older_call_id] + + +def test_recent_calls_route_returns_recent_transferred_call(tmp_path): + client = TestClient(bridge_module.app) + now = utc_now_iso() + call_id = f"call_recent_transfer_{tmp_path.name}" + + session = get_session() + try: + link = AsteriskCallLinkRow( + call_id=call_id, + linked_id="linked_recent_transfer", + queue_code="voice_lab", + queue_id="que_recent_transfer", + interaction_id=f"int_recent_transfer_{tmp_path.name}", + caller_number="+77010000006", + caller_name="Recent Transfer", + status="ended", + telephony_status="ended", + claimed_by_user="operator", + claimed_at=now, + operator_extension="2001", + channel_name="PJSIP/2001-00010000", + started_at=now, + connected_at=now, + ended_at=now, + updated_at=now, + ) + session.add(link) + session.add( + AsteriskCallActionLogRow( + action_id=f"aca_recent_transfer_{tmp_path.name}", + call_id=call_id, + interaction_id=link.interaction_id, + action_type="blind-transfer", + actor_user="operator", + actor_role="operator", + request_json=json.dumps({"target_value": "2002", "resolved_extension": "2002"}), + result_status="ok", + ami_action_id="ami_recent_transfer", + error=None, + created_at=now, + ) + ) + session.add( + VoiceEventRow( + event_id=f"vev_recent_transfer_{tmp_path.name}", + event_type="call.ended", + call_id=call_id, + interaction_id=link.interaction_id, + payload_json=json.dumps({"hangup_cause": "normal_clearing"}), + created_at=now, + ) + ) + session.commit() + finally: + session.close() + + response = client.get("/asterisk/recent-calls", headers=_admin_headers()) + + assert response.status_code == 200 + payload = response.json() + item = next(entry for entry in payload if entry["call_id"] == call_id) + assert item["terminal_action"] == "blind-transfer" + assert item["terminal_target"] == "2002" + assert item["hangup_cause"] == "normal_clearing" + assert item["last_transition_at"] == now + + +def test_recent_calls_route_skips_stale_calls(monkeypatch, tmp_path): + monkeypatch.setenv("ASTERISK_RECENT_CALLS_WINDOW_SECONDS", "120") + client = TestClient(bridge_module.app) + old_iso = (datetime.now(timezone.utc) - timedelta(seconds=600)).isoformat() + call_id = f"call_recent_stale_{tmp_path.name}" + + session = get_session() + try: + session.add( + AsteriskCallLinkRow( + call_id=call_id, + linked_id="linked_recent_stale", + queue_code="voice_lab", + queue_id="que_recent_stale", + interaction_id=f"int_recent_stale_{tmp_path.name}", + caller_number="+77010000007", + caller_name="Recent Stale", + status="ended", + telephony_status="ended", + claimed_by_user="operator", + claimed_at=old_iso, + operator_extension="2001", + channel_name="PJSIP/2001-00010001", + started_at=old_iso, + connected_at=old_iso, + ended_at=old_iso, + updated_at=old_iso, + ) + ) + session.commit() + finally: + session.close() + + response = client.get("/asterisk/recent-calls", headers=_admin_headers()) + + assert response.status_code == 200 + payload = response.json() + assert all(entry["call_id"] != call_id for entry in payload) + + +def test_retry_endpoint_replays_failed_bridge_event(monkeypatch, tmp_path): + call_id = f"call_retry_{tmp_path.name}" + monkeypatch.setenv("ASTERISK_QUEUE_MAP_JSON", "{}") + + session = get_session() + try: + row = bridge_module._create_bridge_log( + session, + ami_event_name="MVPCCCallStarted", + call_id=call_id, + linked_id="linked_retry", + payload={ + "UserEvent": "MVPCCCallStarted", + "CallID": call_id, + "LinkedID": "linked_retry", + "CallerNumber": "+77010000002", + "QueueCode": "lab", + "Extension": "7000", + "Context": "mvpcc-inbound", + "Direction": "inbound", + }, + ) + try: + bridge_module._process_bridge_row(session, row) + except Exception as exc: # noqa: BLE001 + bridge_module._mark_log_failed(session, row, str(exc)) + session.commit() + bridge_event_id = row.bridge_event_id + assert row.forward_status == "failed" + finally: + session.close() + + monkeypatch.setenv("ASTERISK_QUEUE_MAP_JSON", '{"lab":"que_retry"}') + monkeypatch.setattr( + bridge_module, + "_create_interaction", + lambda **kwargs: {"interaction_id": f"int_retry_{tmp_path.name}"}, + ) + emitted: list[dict[str, object]] = [] + monkeypatch.setattr( + bridge_module, + "_emit_voice_event", + lambda **kwargs: emitted.append(kwargs) or {"event_id": "vev_retry"}, + ) + + client = TestClient(bridge_module.app) + retried = client.post(f"/asterisk/events/{bridge_event_id}/retry", headers=_admin_headers()) + repeated = client.post(f"/asterisk/events/{bridge_event_id}/retry", headers=_admin_headers()) + + assert retried.status_code == 200 + assert repeated.status_code == 200 + payload = retried.json() + assert payload["forward_status"] == "forwarded" + assert payload["interaction_id"].startswith("int_retry_") + assert repeated.json()["forward_status"] == "forwarded" + assert len(emitted) == 1 + + +def test_retry_endpoint_does_not_double_process_row_already_claimed_by_background_worker(monkeypatch, tmp_path): + call_id = f"call_retry_claimed_{tmp_path.name}" + monkeypatch.setenv("ASTERISK_QUEUE_MAP_JSON", '{"lab":"que_retry_claimed"}') + + session = get_session() + try: + row = bridge_module._create_bridge_log( + session, + ami_event_name="MVPCCCallStarted", + call_id=call_id, + linked_id="linked_retry_claimed", + payload={ + "UserEvent": "MVPCCCallStarted", + "CallID": call_id, + "LinkedID": "linked_retry_claimed", + "CallerNumber": "+77010000027", + "QueueCode": "lab", + "Extension": "7000", + "Context": "mvpcc-inbound", + "Direction": "inbound", + }, + ) + bridge_module._mark_log_failed(session, row, "retry me") + session.commit() + bridge_event_id = row.bridge_event_id + finally: + session.close() + + emitted: list[dict[str, object]] = [] + monkeypatch.setattr( + bridge_module, + "_create_interaction", + lambda **kwargs: {"interaction_id": f"int_retry_claimed_{tmp_path.name}"}, + ) + monkeypatch.setattr( + bridge_module, + "_emit_voice_event", + lambda **kwargs: emitted.append(kwargs) or {"event_id": "vev_retry_claimed"}, + ) + + assert bridge_module._claim_bridge_event_for_processing(bridge_event_id) is True + + client = TestClient(bridge_module.app) + blocked = client.post(f"/asterisk/events/{bridge_event_id}/retry", headers=_admin_headers()) + + assert blocked.status_code == 200 + assert blocked.json()["forward_status"] == "processing" + assert emitted == [] + + processed = bridge_module._process_claimed_bridge_event(bridge_event_id) + assert processed is not None + assert processed.forward_status == "forwarded" + assert len(emitted) == 1 + + +def test_retry_recovers_after_voice_event_was_sent_before_db_failure(monkeypatch, tmp_path): + call_id = f"call_retry_voice_commit_{tmp_path.name}" + monkeypatch.setenv("ASTERISK_QUEUE_MAP_JSON", '{"lab":"que_retry_voice_commit"}') + + emitted_by_source: dict[str, dict[str, str]] = {} + emitted_calls: list[str] = [] + + def _emit_once_per_source(**kwargs): + source_event_id = str(kwargs["source_event_id"]) + emitted_calls.append(source_event_id) + return emitted_by_source.setdefault( + source_event_id, + {"event_id": f"vev_retry_voice_{len(emitted_by_source) + 1}"}, + ) + + original_mark_log_forwarded = bridge_module._mark_log_forwarded + should_fail_once = {"value": True} + + def _mark_log_forwarded_once(session, row, **kwargs): + original_mark_log_forwarded(session, row, **kwargs) + if should_fail_once["value"]: + should_fail_once["value"] = False + raise RuntimeError("db write failed after voice event") + + monkeypatch.setattr( + bridge_module, + "_create_interaction", + lambda **kwargs: {"interaction_id": f"int_retry_voice_commit_{tmp_path.name}"}, + ) + monkeypatch.setattr(bridge_module, "_emit_voice_event", _emit_once_per_source) + monkeypatch.setattr(bridge_module, "_mark_log_forwarded", _mark_log_forwarded_once) + + bridge_module._record_ami_payload( + { + "UserEvent": "MVPCCCallStarted", + "CallID": call_id, + "LinkedID": "linked_retry_voice_commit", + "CallerNumber": "+77010000028", + "CallerName": "Retry Voice Commit", + "QueueCode": "lab", + "Extension": "7000", + "Context": "mvpcc-inbound", + "Direction": "inbound", + } + ) + + session = get_session() + try: + row = session.execute( + select(AsteriskEventLogRow).where(AsteriskEventLogRow.call_id == call_id) + ).scalar_one() + bridge_event_id = row.bridge_event_id + assert row.forward_status == "failed" + finally: + session.close() + + client = TestClient(bridge_module.app) + retried = client.post(f"/asterisk/events/{bridge_event_id}/retry", headers=_admin_headers()) + + assert retried.status_code == 200 + assert retried.json()["forward_status"] == "forwarded" + assert len(emitted_calls) == 2 + assert len(emitted_by_source) == 1 + + +def test_retry_recovers_after_recording_upload_before_db_failure(monkeypatch, tmp_path): + call_id = f"call_retry_recording_commit_{tmp_path.name}" + ready_iso = (datetime.now(timezone.utc) - timedelta(seconds=10)).isoformat() + recording_fixture = tmp_path / "fixtures" / "retry-recording.wav" + recording_fixture.parent.mkdir(parents=True, exist_ok=True) + recording_fixture.write_bytes(b"RIFF" + b"\x00" * 64) + + session = get_session() + try: + link = AsteriskCallLinkRow( + call_id=call_id, + linked_id="linked_retry_recording_commit", + queue_code="lab", + queue_id="que_lab", + interaction_id=f"int_retry_recording_commit_{tmp_path.name}", + caller_number="+77010000029", + caller_name="Retry Recording Commit", + status="ended", + telephony_status="ended", + claimed_by_user=None, + claimed_at=None, + operator_extension="2001", + channel_name="PJSIP/2001-recording", + started_at=ready_iso, + connected_at=ready_iso, + ended_at=ready_iso, + updated_at=ready_iso, + ) + session.add(link) + session.add( + VoiceEventRow( + event_id=f"vev_retry_recording_ended_{tmp_path.name}", + event_type="call.ended", + call_id=call_id, + interaction_id=link.interaction_id, + source_event_id=f"src_retry_recording_ended_{tmp_path.name}", + payload_json=json.dumps({"hangup_cause": "normal_clearing"}), + created_at=ready_iso, + ) + ) + session.add( + VoiceEventRow( + event_id=f"vev_retry_recording_ready_{tmp_path.name}", + event_type="recording.ready", + call_id=call_id, + interaction_id=link.interaction_id, + source_event_id=f"src_retry_recording_ready_{tmp_path.name}", + payload_json=json.dumps({"remote_path": "/remote/retry-recording.wav"}), + created_at=ready_iso, + ) + ) + session.commit() + finally: + session.close() + + uploaded_by_source: dict[str, dict[str, str]] = {} + upload_calls: list[str] = [] + + def _upload_once_per_source(**kwargs): + source_event_id = str(kwargs["source_event_id"]) + upload_calls.append(source_event_id) + return uploaded_by_source.setdefault( + source_event_id, + {"recording_id": f"rec_retry_recording_{len(uploaded_by_source) + 1}"}, + ) + + original_mark_log_forwarded = bridge_module._mark_log_forwarded + should_fail_once = {"value": True} + + def _mark_log_forwarded_once(session, row, **kwargs): + original_mark_log_forwarded(session, row, **kwargs) + if should_fail_once["value"]: + should_fail_once["value"] = False + raise RuntimeError("db write failed after recording upload") + + monkeypatch.setattr( + bridge_module, + "_fetch_recording_file", + lambda remote_path, file_name: (recording_fixture, False), + ) + monkeypatch.setattr(bridge_module, "_upload_recording", _upload_once_per_source) + monkeypatch.setattr( + bridge_module, + "_emit_voice_event", + lambda **kwargs: {"event_id": f"vev_{kwargs['event_type']}"}, + ) + monkeypatch.setattr(bridge_module, "_mark_log_forwarded", _mark_log_forwarded_once) + + bridge_module._record_ami_payload( + { + "UserEvent": "MVPCCRecordingReady", + "CallID": call_id, + "LinkedID": "linked_retry_recording_commit", + "RemotePath": "/remote/retry-recording.wav", + "FileName": "retry-recording.wav", + "MimeType": "audio/wav", + "DurationSeconds": "7", + } + ) + + session = get_session() + try: + row = session.execute( + select(AsteriskEventLogRow) + .where(AsteriskEventLogRow.call_id == call_id) + .where(AsteriskEventLogRow.ami_event_name == "MVPCCRecordingReady") + ).scalar_one() + bridge_event_id = row.bridge_event_id + assert row.forward_status == "failed" + finally: + session.close() + + client = TestClient(bridge_module.app) + retried = client.post(f"/asterisk/events/{bridge_event_id}/retry", headers=_admin_headers()) + + assert retried.status_code == 200 + assert retried.json()["forward_status"] == "forwarded" + assert len(upload_calls) == 2 + assert len(uploaded_by_source) == 1 + + +def test_startup_fails_fast_when_singleton_guard_is_already_held(monkeypatch): + monkeypatch.setenv("ASTERISK_BRIDGE_ENABLED", "1") + monkeypatch.setattr(bridge_module, "_ami_loop", _wait_until_stopped) + monkeypatch.setattr(bridge_module, "_failed_retry_loop", _wait_until_stopped) + monkeypatch.setattr(bridge_module, "_try_acquire_bridge_singleton_guard", lambda: False) + + with pytest.raises(RuntimeError, match="replicas=1, workers=1"): + bridge_module._startup() + + +def test_disabled_bridge_startup_does_not_require_singleton_guard(monkeypatch): + monkeypatch.delenv("ASTERISK_BRIDGE_ENABLED", raising=False) + monkeypatch.setattr(bridge_module, "_ami_loop", _wait_until_stopped) + monkeypatch.setattr(bridge_module, "_failed_retry_loop", _wait_until_stopped) + monkeypatch.setattr( + bridge_module, + "_try_acquire_bridge_singleton_guard", + lambda: (_ for _ in ()).throw(AssertionError("singleton guard should not be acquired")), + ) + + bridge_module._startup() + assert len(bridge_module._background_threads_alive()) == 2 + + +def test_shutdown_releases_singleton_guard(monkeypatch): + monkeypatch.setenv("ASTERISK_BRIDGE_ENABLED", "1") + monkeypatch.setattr(bridge_module, "_ami_loop", _wait_until_stopped) + monkeypatch.setattr(bridge_module, "_failed_retry_loop", _wait_until_stopped) + + bridge_module._startup() + try: + assert bridge_module._BRIDGE_SINGLETON_FILE_HANDLE is not None + finally: + bridge_module._shutdown() + + assert bridge_module._BRIDGE_SINGLETON_FILE_HANDLE is None + assert bridge_module._try_acquire_bridge_singleton_guard() is True + bridge_module._release_bridge_singleton_guard() + + +def test_claim_endpoint_updates_call_link_and_action_log(monkeypatch, tmp_path): + monkeypatch.setenv("ASTERISK_CALLCONTROL_ENABLED", "1") + monkeypatch.setenv("ASTERISK_OPERATOR_EXTENSION_MAP_JSON", '{"operator_a":"2001"}') + monkeypatch.setenv("ASTERISK_QUEUE_MAP_JSON", '{"lab":"que_lab"}') + monkeypatch.setattr(bridge_module, "_create_interaction", lambda **kwargs: {"interaction_id": f"int_claim_{tmp_path.name}"}) + monkeypatch.setattr(bridge_module, "_emit_voice_event", lambda **kwargs: {"event_id": "vev_claim"}) + monkeypatch.setattr( + bridge_module, + "_resolve_channel_name", + lambda session, link, prefer_operator=False, refresh=False, snapshot=None: "PJSIP/1001-0000001", + ) + monkeypatch.setattr(bridge_module, "_ami_action", lambda action, params: {"action_id": "ami_claim"}) + monkeypatch.setattr(bridge_module, "_assign_interaction", lambda **kwargs: {"status": "ok"}) + + call_id = f"call_claim_{tmp_path.name}" + session = get_session() + try: + row = bridge_module._create_bridge_log( + session, + ami_event_name="MVPCCCallStarted", + call_id=call_id, + linked_id="linked_claim", + payload={ + "UserEvent": "MVPCCCallStarted", + "CallID": call_id, + "LinkedID": "linked_claim", + "CallerNumber": "+77010000003", + "CallerName": "Track11 Claim", + "QueueCode": "lab", + "Extension": "7000", + "Context": "mvpcc-inbound", + "Direction": "inbound", + "Channel": "PJSIP/1001-0000001", + }, + ) + bridge_module._process_bridge_row(session, row) + session.commit() + finally: + session.close() + + client = TestClient(bridge_module.app) + claimed = client.post( + f"/asterisk/live-calls/{call_id}/claim", + headers=_operator_headers("operator_a"), + json={}, + ) + assert claimed.status_code == 200 + payload = claimed.json() + assert payload["claimed_by_user"] == "operator_a" + assert payload["operator_extension"] == "2001" + assert payload["telephony_status"] == "claimed" + + session = get_session() + try: + link = session.execute( + select(AsteriskCallLinkRow).where(AsteriskCallLinkRow.call_id == call_id) + ).scalar_one() + assert link.claimed_by_user == "operator_a" + assert link.operator_extension == "2001" + action = session.execute( + select(AsteriskCallActionLogRow) + .where(AsteriskCallActionLogRow.call_id == call_id) + .where(AsteriskCallActionLogRow.action_type == "claim") + .order_by(AsteriskCallActionLogRow.id.desc()) + ).scalar_one() + assert action.result_status == "ok" + assert action.ami_action_id == "ami_claim" + finally: + session.close() + + +def test_claim_resolves_live_channels_once_on_happy_path(monkeypatch, tmp_path): + monkeypatch.setenv("ASTERISK_CALLCONTROL_ENABLED", "1") + monkeypatch.setenv("ASTERISK_OPERATOR_EXTENSION_MAP_JSON", '{"operator_a":"2001"}') + + call_id = f"call_claim_fast_{tmp_path.name}" + session = get_session() + try: + row = AsteriskCallLinkRow( + call_id=call_id, + linked_id="linked_claim_fast", + queue_code="lab", + queue_id="que_lab", + interaction_id=f"int_claim_fast_{tmp_path.name}", + caller_number="+77010000013", + caller_name="Track12 Claim Fast", + status="active", + telephony_status="ringing", + claimed_by_user=None, + claimed_at=None, + operator_extension=None, + channel_name="PJSIP/1001-0000013", + started_at=utc_now_iso(), + connected_at=None, + ended_at=None, + updated_at=utc_now_iso(), + ) + session.add(row) + session.commit() + finally: + session.close() + + channel_calls = [] + + def _fake_channels(**kwargs): + channel_calls.append(kwargs) + return ["PJSIP/1001-0000013"] + + monkeypatch.setattr(bridge_module, "_list_channels_via_coreshowchannels", _fake_channels) + monkeypatch.setattr(bridge_module, "_assign_interaction", lambda **kwargs: {"status": "ok"}) + monkeypatch.setattr(bridge_module, "_ami_action", lambda action, params: {"action_id": "ami_claim_fast"}) + + client = TestClient(bridge_module.app) + claimed = client.post( + f"/asterisk/live-calls/{call_id}/claim", + headers=_operator_headers("operator_a"), + json={}, + ) + + assert claimed.status_code == 200 + assert len(channel_calls) == 1 + + +def test_claim_skips_redirect_when_operator_leg_already_exists(monkeypatch, tmp_path): + monkeypatch.setenv("ASTERISK_CALLCONTROL_ENABLED", "1") + monkeypatch.setenv("ASTERISK_OPERATOR_EXTENSION_MAP_JSON", '{"operator_a":"2001"}') + + call_id = f"call_claim_existing_{tmp_path.name}" + session = get_session() + try: + row = AsteriskCallLinkRow( + call_id=call_id, + linked_id="linked_claim_existing", + queue_code="lab", + queue_id="que_lab", + interaction_id=f"int_claim_existing_{tmp_path.name}", + caller_number="+77010000007", + caller_name="Track11 Existing Leg", + status="active", + telephony_status="ringing", + claimed_by_user=None, + claimed_at=None, + operator_extension=None, + channel_name="PJSIP/1001-0000004", + started_at=utc_now_iso(), + connected_at=None, + ended_at=None, + updated_at=utc_now_iso(), + ) + session.add(row) + session.commit() + finally: + session.close() + + monkeypatch.setattr( + bridge_module, + "_list_channels_via_coreshowchannels", + lambda **kwargs: ["PJSIP/1001-0000004", "PJSIP/2001-0000005"], + ) + monkeypatch.setattr(bridge_module, "_assign_interaction", lambda **kwargs: {"status": "ok"}) + + def _unexpected_ami(*args, **kwargs): + raise AssertionError("Redirect should be skipped when operator leg already exists") + + monkeypatch.setattr(bridge_module, "_ami_action", _unexpected_ami) + + client = TestClient(bridge_module.app) + claimed = client.post( + f"/asterisk/live-calls/{call_id}/claim", + headers=_operator_headers("operator_a"), + json={}, + ) + + assert claimed.status_code == 200 + payload = claimed.json() + assert payload["claimed_by_user"] == "operator_a" + assert payload["operator_extension"] == "2001" + assert payload["telephony_status"] == "claimed" + + +def test_claim_connected_call_becomes_idempotent_success(monkeypatch, tmp_path): + monkeypatch.setenv("ASTERISK_CALLCONTROL_ENABLED", "1") + monkeypatch.setenv("ASTERISK_OPERATOR_EXTENSION_MAP_JSON", '{"operator_a":"2001"}') + + call_id = f"call_claim_connected_{tmp_path.name}" + session = get_session() + try: + row = AsteriskCallLinkRow( + call_id=call_id, + linked_id="linked_claim_connected", + queue_code="lab", + queue_id="que_lab", + interaction_id=f"int_claim_connected_{tmp_path.name}", + caller_number="+77010000010", + caller_name="Track11 Connected Claim", + status="active", + telephony_status="connected", + claimed_by_user=None, + claimed_at=None, + operator_extension="2001", + channel_name="PJSIP/2001-0000009", + started_at=utc_now_iso(), + connected_at=utc_now_iso(), + ended_at=None, + updated_at=utc_now_iso(), + ) + session.add(row) + session.commit() + finally: + session.close() + + monkeypatch.setattr(bridge_module, "_list_channels_via_coreshowchannels", lambda **kwargs: []) + monkeypatch.setattr( + bridge_module, + "_latest_event_channel", + lambda session, *, call_id, event_name: "PJSIP/2001-0000009" + if event_name.endswith("OperatorConnected") + else "PJSIP/1001-0000008", + ) + monkeypatch.setattr(bridge_module, "_assign_interaction", lambda **kwargs: {"status": "ok"}) + + def _unexpected_ami(*args, **kwargs): + raise AssertionError("Redirect should be skipped when call is already connected") + + monkeypatch.setattr(bridge_module, "_ami_action", _unexpected_ami) + + client = TestClient(bridge_module.app) + claimed = client.post( + f"/asterisk/live-calls/{call_id}/claim", + headers=_operator_headers("operator_a"), + json={}, + ) + + assert claimed.status_code == 200 + payload = claimed.json() + assert payload["claimed_by_user"] == "operator_a" + assert payload["operator_extension"] == "2001" + assert payload["telephony_status"] == "connected" + + +def test_operator_cannot_hangup_foreign_claim(monkeypatch, tmp_path): + monkeypatch.setenv("ASTERISK_CALLCONTROL_ENABLED", "1") + + call_id = f"call_foreign_{tmp_path.name}" + session = get_session() + try: + row = AsteriskCallLinkRow( + call_id=call_id, + linked_id="linked_foreign", + queue_code="lab", + queue_id="que_lab", + interaction_id=f"int_foreign_{tmp_path.name}", + caller_number="+77010000004", + caller_name="Track11 Foreign", + status="active", + telephony_status="claimed", + claimed_by_user="operator_b", + claimed_at=utc_now_iso(), + operator_extension="2002", + channel_name="PJSIP/1001-0000002", + started_at=utc_now_iso(), + connected_at=utc_now_iso(), + ended_at=None, + updated_at=utc_now_iso(), + ) + session.add(row) + session.commit() + finally: + session.close() + + client = TestClient(bridge_module.app) + denied = client.post( + f"/asterisk/live-calls/{call_id}/hangup", + headers=_operator_headers("operator_a"), + json={}, + ) + assert denied.status_code == 403 + + +def test_operator_can_hangup_own_extension_without_claimed_user(monkeypatch, tmp_path): + monkeypatch.setenv("ASTERISK_CALLCONTROL_ENABLED", "1") + monkeypatch.setenv("ASTERISK_OPERATOR_EXTENSION_MAP_JSON", '{"operator_a":"2001"}') + + call_id = f"call_own_ext_{tmp_path.name}" + session = get_session() + try: + row = AsteriskCallLinkRow( + call_id=call_id, + linked_id="linked_own_ext", + queue_code="lab", + queue_id="que_lab", + interaction_id=f"int_own_ext_{tmp_path.name}", + caller_number="+77010000008", + caller_name="Track11 Own Ext", + status="active", + telephony_status="connected", + claimed_by_user=None, + claimed_at=None, + operator_extension="2001", + channel_name="PJSIP/2001-0000006", + started_at=utc_now_iso(), + connected_at=utc_now_iso(), + ended_at=None, + updated_at=utc_now_iso(), + ) + session.add(row) + session.commit() + finally: + session.close() + + monkeypatch.setattr( + bridge_module, + "_resolve_channel_name", + lambda session, link, prefer_operator=False, refresh=False, snapshot=None: "PJSIP/2001-0000006", + ) + monkeypatch.setattr(bridge_module, "_ami_action", lambda action, params: {"action_id": "ami_hangup"}) + + client = TestClient(bridge_module.app) + response = client.post( + f"/asterisk/live-calls/{call_id}/hangup", + headers=_operator_headers("operator_a"), + json={}, + ) + + assert response.status_code == 200 + assert response.json()["telephony_status"] == "ended" + + +def test_hangup_falls_back_to_another_live_leg(monkeypatch, tmp_path): + monkeypatch.setenv("ASTERISK_CALLCONTROL_ENABLED", "1") + monkeypatch.setenv("ASTERISK_OPERATOR_EXTENSION_MAP_JSON", '{"operator_a":"2001"}') + + call_id = f"call_hangup_fallback_{tmp_path.name}" + session = get_session() + try: + row = AsteriskCallLinkRow( + call_id=call_id, + linked_id="linked_hangup_fallback", + queue_code="lab", + queue_id="que_lab", + interaction_id=f"int_hangup_fallback_{tmp_path.name}", + caller_number="+77010000011", + caller_name="Track11 Hangup Fallback", + status="active", + telephony_status="connected", + claimed_by_user="operator_a", + claimed_at=utc_now_iso(), + operator_extension="2001", + channel_name="PJSIP/2001-0000010", + started_at=utc_now_iso(), + connected_at=utc_now_iso(), + ended_at=None, + updated_at=utc_now_iso(), + ) + session.add(row) + session.commit() + finally: + session.close() + + monkeypatch.setattr( + bridge_module, + "_candidate_channels_for_call", + lambda session, link, operator_extension=None, prefer_operator=False, snapshot=None: [ + "PJSIP/2001-0000010", + "PJSIP/1001-0000011", + ], + ) + + calls = [] + + def _ami_action(action, params): + calls.append(params["Channel"]) + if params["Channel"] == "PJSIP/2001-0000010": + raise RuntimeError("No such channel") + return {"action_id": "ami_hangup_fallback"} + + monkeypatch.setattr(bridge_module, "_ami_action", _ami_action) + + client = TestClient(bridge_module.app) + response = client.post( + f"/asterisk/live-calls/{call_id}/hangup", + headers=_operator_headers("operator_a"), + json={}, + ) + + assert response.status_code == 200 + assert response.json()["telephony_status"] == "ended" + assert calls == ["PJSIP/2001-0000010", "PJSIP/1001-0000011"] + + +def test_hangup_becomes_idempotent_success_when_channel_already_closed(monkeypatch, tmp_path): + monkeypatch.setenv("ASTERISK_CALLCONTROL_ENABLED", "1") + monkeypatch.setenv("ASTERISK_OPERATOR_EXTENSION_MAP_JSON", '{"operator_a":"2001"}') + + call_id = f"call_hangup_closed_{tmp_path.name}" + session = get_session() + try: + row = AsteriskCallLinkRow( + call_id=call_id, + linked_id="linked_hangup_closed", + queue_code="lab", + queue_id="que_lab", + interaction_id=f"int_hangup_closed_{tmp_path.name}", + caller_number="+77010000012", + caller_name="Track11 Hangup Closed", + status="active", + telephony_status="connected", + claimed_by_user="operator_a", + claimed_at=utc_now_iso(), + operator_extension="2001", + channel_name="PJSIP/2001-0000012", + started_at=utc_now_iso(), + connected_at=utc_now_iso(), + ended_at=None, + updated_at=utc_now_iso(), + ) + session.add(row) + session.commit() + finally: + session.close() + + monkeypatch.setattr( + bridge_module, + "_candidate_channels_for_call", + lambda session, link, operator_extension=None, prefer_operator=False, snapshot=None: ["PJSIP/2001-0000012"], + ) + monkeypatch.setattr(bridge_module, "_list_channels_via_coreshowchannels", lambda **kwargs: []) + + def _ami_action(action, params): + raise RuntimeError("No such channel") + + monkeypatch.setattr(bridge_module, "_ami_action", _ami_action) + + client = TestClient(bridge_module.app) + response = client.post( + f"/asterisk/live-calls/{call_id}/hangup", + headers=_operator_headers("operator_a"), + json={}, + ) + + assert response.status_code == 200 + assert response.json()["telephony_status"] == "ended" + + session = get_session() + try: + action = session.execute( + select(AsteriskCallActionLogRow) + .where(AsteriskCallActionLogRow.call_id == call_id) + .where(AsteriskCallActionLogRow.action_type == "hangup") + .order_by(AsteriskCallActionLogRow.id.desc()) + ).scalar_one() + assert action.result_status == "ok" + assert action.error == "channel_already_closed" + finally: + session.close() + + +def test_hangup_resolves_live_channels_once_on_happy_path(monkeypatch, tmp_path): + monkeypatch.setenv("ASTERISK_CALLCONTROL_ENABLED", "1") + monkeypatch.setenv("ASTERISK_OPERATOR_EXTENSION_MAP_JSON", '{"operator_a":"2001"}') + + call_id = f"call_hangup_fast_{tmp_path.name}" + session = get_session() + try: + row = AsteriskCallLinkRow( + call_id=call_id, + linked_id="linked_hangup_fast", + queue_code="lab", + queue_id="que_lab", + interaction_id=f"int_hangup_fast_{tmp_path.name}", + caller_number="+77010000014", + caller_name="Track12 Hangup Fast", + status="active", + telephony_status="connected", + claimed_by_user="operator_a", + claimed_at=utc_now_iso(), + operator_extension="2001", + channel_name="PJSIP/2001-0000014", + started_at=utc_now_iso(), + connected_at=utc_now_iso(), + ended_at=None, + updated_at=utc_now_iso(), + ) + session.add(row) + session.commit() + finally: + session.close() + + channel_calls = [] + + def _fake_channels(**kwargs): + channel_calls.append(kwargs) + return ["PJSIP/2001-0000014"] + + monkeypatch.setattr(bridge_module, "_list_channels_via_coreshowchannels", _fake_channels) + monkeypatch.setattr(bridge_module, "_ami_action", lambda action, params: {"action_id": "ami_hangup_fast"}) + + client = TestClient(bridge_module.app) + response = client.post( + f"/asterisk/live-calls/{call_id}/hangup", + headers=_operator_headers("operator_a"), + json={}, + ) + + assert response.status_code == 200 + assert len(channel_calls) == 1 + + +def test_hangup_hangs_up_all_live_legs_on_success(monkeypatch, tmp_path): + monkeypatch.setenv("ASTERISK_CALLCONTROL_ENABLED", "1") + monkeypatch.setenv("ASTERISK_OPERATOR_EXTENSION_MAP_JSON", '{"operator_a":"2001"}') + + call_id = f"call_hangup_all_legs_{tmp_path.name}" + session = get_session() + try: + row = AsteriskCallLinkRow( + call_id=call_id, + linked_id="linked_hangup_all_legs", + queue_code="lab", + queue_id="que_lab", + interaction_id=f"int_hangup_all_legs_{tmp_path.name}", + caller_number="+77010000015", + caller_name="Track14 Hangup All Legs", + status="active", + telephony_status="connected", + claimed_by_user="operator_a", + claimed_at=utc_now_iso(), + operator_extension="2001", + channel_name="PJSIP/2001-0000015", + started_at=utc_now_iso(), + connected_at=utc_now_iso(), + ended_at=None, + updated_at=utc_now_iso(), + ) + session.add(row) + session.commit() + finally: + session.close() + + monkeypatch.setattr( + bridge_module, + "_candidate_channels_for_call", + lambda session, link, operator_extension=None, prefer_operator=False, snapshot=None: [ + "PJSIP/2001-0000015", + "PJSIP/1001-0000016", + ], + ) + + calls = [] + + def _ami_action(action, params): + calls.append(params["Channel"]) + return {"action_id": f"ami_{len(calls)}"} + + monkeypatch.setattr(bridge_module, "_ami_action", _ami_action) + + client = TestClient(bridge_module.app) + response = client.post( + f"/asterisk/live-calls/{call_id}/hangup", + headers=_operator_headers("operator_a"), + json={}, + ) + + assert response.status_code == 200 + assert response.json()["telephony_status"] == "ended" + assert calls == ["PJSIP/2001-0000015", "PJSIP/1001-0000016"] + + +def test_blind_transfer_emits_transferred_voice_event(monkeypatch, tmp_path): + monkeypatch.setenv("ASTERISK_CALLCONTROL_ENABLED", "1") + monkeypatch.setenv("ASTERISK_TRANSFER_TARGET_MAP_JSON", '{"voice_lab":"2001"}') + events: list[dict] = [] + + call_id = f"call_transfer_{tmp_path.name}" + session = get_session() + try: + row = AsteriskCallLinkRow( + call_id=call_id, + linked_id="linked_transfer", + queue_code="voice_lab", + queue_id="que_lab", + interaction_id=f"int_transfer_{tmp_path.name}", + caller_number="+77010000005", + caller_name="Track11 Transfer", + status="active", + telephony_status="connected", + claimed_by_user="operator_a", + claimed_at=utc_now_iso(), + operator_extension="2001", + channel_name="PJSIP/1001-0000003", + started_at=utc_now_iso(), + connected_at=utc_now_iso(), + ended_at=None, + updated_at=utc_now_iso(), + ) + session.add(row) + session.commit() + finally: + session.close() + + monkeypatch.setattr( + bridge_module, + "_resolve_channel_name", + lambda session, link, prefer_operator=False, refresh=False, snapshot=None: "PJSIP/1001-0000003", + ) + monkeypatch.setattr(bridge_module, "_ami_action", lambda action, params: {"action_id": "ami_transfer"}) + monkeypatch.setattr( + bridge_module, + "_emit_voice_event", + lambda **kwargs: events.append(kwargs) or {"event_id": "vev_transfer"}, + ) + + client = TestClient(bridge_module.app) + moved = client.post( + f"/asterisk/live-calls/{call_id}/blind-transfer", + headers=_operator_headers("operator_a"), + json={"target_type": "queue_code", "target_value": "voice_lab"}, + ) + assert moved.status_code == 200 + assert moved.json()["call_id"] == call_id + assert events + assert events[-1]["event_type"] == "call.transferred" + + +def test_operator_connected_event_sets_connected_status(monkeypatch, tmp_path): + monkeypatch.setenv("ASTERISK_QUEUE_MAP_JSON", '{"lab":"que_lab"}') + monkeypatch.setenv("ASTERISK_OPERATOR_EXTENSION_MAP_JSON", '{"operator_a":"2001"}') + monkeypatch.setattr(bridge_module, "_create_interaction", lambda **kwargs: {"interaction_id": f"int_conn_{tmp_path.name}"}) + captured: list[dict] = [] + monkeypatch.setattr( + bridge_module, + "_emit_voice_event", + lambda **kwargs: captured.append(kwargs) or {"event_id": "vev_conn"}, + ) + + call_id = f"call_conn_{tmp_path.name}" + session = get_session() + try: + started = bridge_module._create_bridge_log( + session, + ami_event_name="MVPCCCallStarted", + call_id=call_id, + linked_id="linked_conn", + payload={ + "UserEvent": "MVPCCCallStarted", + "CallID": call_id, + "LinkedID": "linked_conn", + "CallerNumber": "+77010000006", + "QueueCode": "lab", + "Extension": "7000", + "Context": "mvpcc-inbound", + "Direction": "inbound", + }, + ) + bridge_module._process_bridge_row(session, started) + session.commit() + + connected = bridge_module._create_bridge_log( + session, + ami_event_name="MVPCCOperatorConnected", + call_id=call_id, + linked_id="linked_conn", + payload={ + "UserEvent": "MVPCCOperatorConnected", + "CallID": call_id, + "LinkedID": "linked_conn", + "OperatorExtension": "2001", + "ClaimedByUser": "s", + "Channel": "PJSIP/1001-0000010", + }, + ) + bridge_module._process_bridge_row(session, connected) + session.commit() + + link = session.execute( + select(AsteriskCallLinkRow).where(AsteriskCallLinkRow.call_id == call_id) + ).scalar_one() + assert link.telephony_status == "connected" + assert link.operator_extension == "2001" + assert link.claimed_by_user == "operator_a" + finally: + session.close() + + assert any(item.get("event_type") == "call.connected" for item in captured) + + +def test_operator_connected_does_not_guess_claimed_user_for_ambiguous_extension(monkeypatch, tmp_path): + monkeypatch.setenv("ASTERISK_QUEUE_MAP_JSON", '{"lab":"que_lab"}') + monkeypatch.setenv("ASTERISK_OPERATOR_EXTENSION_MAP_JSON", '{"operator":"2001","admin":"2001"}') + monkeypatch.setattr(bridge_module, "_create_interaction", lambda **kwargs: {"interaction_id": f"int_conn_amb_{tmp_path.name}"}) + monkeypatch.setattr(bridge_module, "_emit_voice_event", lambda **kwargs: {"event_id": "vev_conn_amb"}) + + call_id = f"call_conn_amb_{tmp_path.name}" + session = get_session() + try: + started = bridge_module._create_bridge_log( + session, + ami_event_name="MVPCCCallStarted", + call_id=call_id, + linked_id="linked_conn_amb", + payload={ + "UserEvent": "MVPCCCallStarted", + "CallID": call_id, + "LinkedID": "linked_conn_amb", + "CallerNumber": "+77010000009", + "QueueCode": "lab", + "Extension": "7000", + "Context": "mvpcc-inbound", + "Direction": "inbound", + }, + ) + bridge_module._process_bridge_row(session, started) + session.commit() + + connected = bridge_module._create_bridge_log( + session, + ami_event_name="MVPCCOperatorConnected", + call_id=call_id, + linked_id="linked_conn_amb", + payload={ + "UserEvent": "MVPCCOperatorConnected", + "CallID": call_id, + "LinkedID": "linked_conn_amb", + "OperatorExtension": "2001", + "ClaimedByUser": "s", + "Channel": "PJSIP/1001-0000011", + }, + ) + bridge_module._process_bridge_row(session, connected) + session.commit() + + link = session.execute( + select(AsteriskCallLinkRow).where(AsteriskCallLinkRow.call_id == call_id) + ).scalar_one() + assert link.telephony_status == "connected" + assert link.operator_extension == "2001" + assert link.claimed_by_user is None + finally: + session.close() + + +def test_app_module_exposes_refactor_compatibility_surface(): + assert bridge_module.app is not None + assert callable(bridge_module._startup) + assert callable(bridge_module._shutdown) + assert callable(bridge_module._release_bridge_singleton_guard) + assert bridge_module._process_bridge_row.__module__ == "services.asterisk_bridge_service.bridge_processing" + assert bridge_module._live_call_to_out.__module__ == "services.asterisk_bridge_service.presenters" + assert bridge_module._find_remote_recording_for_call.__module__ == "services.asterisk_bridge_service.recording_io" + + +def test_private_shims_remain_monkeypatchable(monkeypatch): + monkeypatch.setattr(bridge_module, "_emit_voice_event", lambda **kwargs: {"event_id": "shim"}) + result = bridge_module._emit_voice_event( + event_type="call.started", + call_id="call_shim", + interaction_id=None, + payload={}, + ) + assert result == {"event_id": "shim"} + + +def test_call_started_with_ai_queue_creates_voice_ai_session(monkeypatch, tmp_path): + monkeypatch.setenv("ASTERISK_QUEUE_MAP_JSON", '{"voice_lab":"que_voice_lab"}') + monkeypatch.setenv("AI_VOICE_ENABLED", "1") + monkeypatch.setenv( + "AI_VOICE_QUEUE_CONFIG_JSON", + '{"voice_lab":{"mode":"ai_first","agent_profile":"voice_support","handoff_queue_code":"voice_lab","language":"ru"}}', + ) + monkeypatch.setattr( + bridge_module, + "_create_interaction", + lambda **kwargs: {"interaction_id": f"int_voice_ai_{tmp_path.name}"}, + ) + monkeypatch.setattr(bridge_module, "_emit_voice_event", lambda **kwargs: {"event_id": "vev_voice_ai"}) + monkeypatch.setattr( + bridge_module, + "_start_voice_ai_session", + lambda **kwargs: { + "voice_session_id": f"avs_voice_ai_{tmp_path.name}", + "ai_session_id": f"ais_voice_ai_{tmp_path.name}", + "status": "greeting", + }, + ) + + call_id = f"call_voice_ai_{tmp_path.name}" + session = get_session() + try: + row = bridge_module._create_bridge_log( + session, + ami_event_name="MVPCCCallStarted", + call_id=call_id, + linked_id=f"linked_voice_ai_{tmp_path.name}", + payload={ + "UserEvent": "MVPCCCallStarted", + "CallID": call_id, + "LinkedID": f"linked_voice_ai_{tmp_path.name}", + "CallerNumber": "+77010000041", + "CallerName": "Voice AI Caller", + "QueueCode": "voice_lab", + "Extension": "7000", + "Context": "mvpcc-inbound", + "Direction": "inbound", + }, + ) + bridge_module._process_bridge_row(session, row) + session.commit() + + link = session.execute( + select(AsteriskCallLinkRow).where(AsteriskCallLinkRow.call_id == call_id) + ).scalar_one() + assert link.voice_session_id == f"avs_voice_ai_{tmp_path.name}" + assert link.ai_session_id == f"ais_voice_ai_{tmp_path.name}" + assert link.ai_state == "greeting" + finally: + session.close() + + +def test_call_started_ai_does_not_flush_db_before_start_request(monkeypatch, tmp_path): + monkeypatch.setenv("ASTERISK_QUEUE_MAP_JSON", '{"voice_lab":"que_voice_lab"}') + monkeypatch.setenv("AI_VOICE_ENABLED", "1") + monkeypatch.setenv( + "AI_VOICE_QUEUE_CONFIG_JSON", + '{"voice_lab":{"mode":"ai_first","agent_profile":"voice_support","handoff_queue_code":"voice_lab","language":"ru"}}', + ) + monkeypatch.setattr( + bridge_module, + "_create_interaction", + lambda **kwargs: {"interaction_id": f"int_voice_ai_flush_{tmp_path.name}"}, + ) + monkeypatch.setattr(bridge_module, "_emit_voice_event", lambda **kwargs: {"event_id": "vev_voice_ai_flush"}) + + flush_count = {"value": 0} + session = get_session() + original_flush = type(session).flush + + def _tracked_flush(*args, **kwargs): + flush_count["value"] += 1 + return original_flush(session, *args, **kwargs) + + call_id = f"call_voice_ai_flush_{tmp_path.name}" + try: + monkeypatch.setattr(session, "flush", _tracked_flush) + row = bridge_module._create_bridge_log( + session, + ami_event_name="MVPCCCallStarted", + call_id=call_id, + linked_id=f"linked_voice_ai_flush_{tmp_path.name}", + payload={ + "UserEvent": "MVPCCCallStarted", + "CallID": call_id, + "LinkedID": f"linked_voice_ai_flush_{tmp_path.name}", + "CallerNumber": "+77010000042", + "CallerName": "Voice AI Flush Caller", + "QueueCode": "voice_lab", + "Extension": "7100", + "Context": "mvpcc-inbound", + "Direction": "inbound", + }, + ) + flush_count["value"] = 0 + + def _start_voice_ai_session(**kwargs): + del kwargs + assert flush_count["value"] == 0 + return { + "voice_session_id": f"avs_voice_ai_flush_{tmp_path.name}", + "ai_session_id": f"ais_voice_ai_flush_{tmp_path.name}", + "status": "greeting", + } + + monkeypatch.setattr(bridge_module, "_start_voice_ai_session", _start_voice_ai_session) + + bridge_module._process_bridge_row(session, row) + session.commit() + finally: + session.close() + + +def test_audio_bridge_requested_registers_media_runtime(monkeypatch, tmp_path): + recorded: list[dict] = [] + monkeypatch.setattr( + bridge_module, + "_register_voice_ai_media_bridge", + lambda **kwargs: recorded.append(kwargs) or {"ok": True}, + ) + + call_id = f"call_audio_bridge_{tmp_path.name}" + session = get_session() + try: + session.add( + AsteriskCallLinkRow( + call_id=call_id, + linked_id=f"linked_audio_bridge_{tmp_path.name}", + queue_code="voice_lab", + queue_id="que_voice_lab", + interaction_id=f"int_audio_bridge_{tmp_path.name}", + caller_number="+77010000051", + caller_name="Audio Bridge Caller", + status="active", + telephony_status="ringing", + claimed_by_user=None, + claimed_at=None, + operator_extension=None, + channel_name=None, + voice_session_id=f"avs_audio_bridge_{tmp_path.name}", + ai_session_id=f"ais_audio_bridge_{tmp_path.name}", + ai_state="queued", + ai_handoff_reason=None, + ai_last_model_at=None, + started_at=utc_now_iso(), + connected_at=None, + ended_at=None, + updated_at=utc_now_iso(), + ) + ) + session.commit() + row = bridge_module._create_bridge_log( + session, + ami_event_name="MVPCCAIAudioBridgeRequested", + call_id=call_id, + linked_id=f"linked_audio_bridge_{tmp_path.name}", + payload={ + "UserEvent": "MVPCCAIAudioBridgeRequested", + "CallID": call_id, + "LinkedID": f"linked_audio_bridge_{tmp_path.name}", + "MediaUUID": "d64fa2f7-0063-4fe1-a36f-e1b372980ec1", + "AudioSocketService": "127.0.0.1:9019", + "Channel": "PJSIP/1001-00000011", + }, + ) + bridge_module._process_bridge_row(session, row) + session.commit() + + link = session.execute( + select(AsteriskCallLinkRow).where(AsteriskCallLinkRow.call_id == call_id) + ).scalar_one() + assert link.ai_state == "greeting" + finally: + session.close() + + assert recorded == [ + { + "voice_session_id": f"avs_audio_bridge_{tmp_path.name}", + "event_type": "requested", + "media_uuid": "d64fa2f7-0063-4fe1-a36f-e1b372980ec1", + "call_id": call_id, + "linked_id": f"linked_audio_bridge_{tmp_path.name}", + "channel": "PJSIP/1001-00000011", + "service_address": "127.0.0.1:9019", + } + ] + + +def test_audio_bridge_requested_recovers_voice_session_from_db(monkeypatch, tmp_path): + recorded: list[dict] = [] + monkeypatch.setattr( + bridge_module, + "_register_voice_ai_media_bridge", + lambda **kwargs: recorded.append(kwargs) or {"ok": True}, + ) + + call_id = f"call_audio_bridge_recover_{tmp_path.name}" + linked_id = f"linked_audio_bridge_recover_{tmp_path.name}" + voice_session_id = f"avs_audio_bridge_recover_{tmp_path.name}" + ai_session_id = f"ais_audio_bridge_recover_{tmp_path.name}" + interaction_id = f"int_audio_bridge_recover_{tmp_path.name}" + session = get_session() + try: + session.add( + AsteriskCallLinkRow( + call_id=call_id, + linked_id=linked_id, + queue_code="voice_lab_ai", + queue_id="que_voice_lab", + interaction_id=interaction_id, + caller_number="+77010000061", + caller_name="Recovered Audio Bridge Caller", + status="active", + telephony_status="ringing", + claimed_by_user=None, + claimed_at=None, + operator_extension=None, + channel_name=None, + voice_session_id=None, + ai_session_id=None, + ai_state="queued", + ai_handoff_reason=None, + ai_last_model_at=None, + started_at=utc_now_iso(), + connected_at=None, + ended_at=None, + updated_at=utc_now_iso(), + ) + ) + session.add( + VoiceAISessionRow( + session_id=voice_session_id, + call_id=call_id, + linked_id=linked_id, + interaction_id=interaction_id, + customer_id=None, + queue_id="que_voice_lab", + ai_session_id=ai_session_id, + agent_profile="voice_support", + language="ru", + asr_provider="openai", + tts_provider="openai", + status="greeting", + handoff_reason=None, + handoff_target_queue_id="que_voice_lab", + media_uuid=None, + media_status=None, + media_connected_at=None, + media_ended_at=None, + last_media_frame_at=None, + disclosure_played_at=None, + last_user_utterance_at=None, + last_ai_reply_at=None, + started_at=utc_now_iso(), + updated_at=utc_now_iso(), + ended_at=None, + ) + ) + session.commit() + row = bridge_module._create_bridge_log( + session, + ami_event_name="MVPCCAIAudioBridgeRequested", + call_id=call_id, + linked_id=linked_id, + payload={ + "UserEvent": "MVPCCAIAudioBridgeRequested", + "CallID": call_id, + "LinkedID": linked_id, + "MediaUUID": "9f23dd6f-3cd7-4aaf-8a4d-71154a3626d5", + "AudioSocketService": "127.0.0.1:9019", + "Channel": "PJSIP/1001-00000021", + }, + ) + bridge_module._process_bridge_row(session, row) + session.commit() + + link = session.execute( + select(AsteriskCallLinkRow).where(AsteriskCallLinkRow.call_id == call_id) + ).scalar_one() + assert link.voice_session_id == voice_session_id + assert link.ai_session_id == ai_session_id + assert link.ai_state == "greeting" + finally: + session.close() + + assert recorded == [ + { + "voice_session_id": voice_session_id, + "event_type": "requested", + "media_uuid": "9f23dd6f-3cd7-4aaf-8a4d-71154a3626d5", + "call_id": call_id, + "linked_id": linked_id, + "channel": "PJSIP/1001-00000021", + "service_address": "127.0.0.1:9019", + } + ] + + +def test_voice_ai_update_call_state_updates_live_link(tmp_path): + call_id = f"call_voice_state_{tmp_path.name}" + session = get_session() + try: + session.add( + AsteriskCallLinkRow( + call_id=call_id, + linked_id=f"linked_voice_state_{tmp_path.name}", + queue_code="voice_lab", + queue_id="que_voice_lab", + interaction_id=f"int_voice_state_{tmp_path.name}", + caller_number="+77010000052", + caller_name="State Caller", + status="active", + telephony_status="connected", + claimed_by_user=None, + claimed_at=None, + operator_extension=None, + channel_name="PJSIP/1001-00000012", + voice_session_id=f"avs_voice_state_{tmp_path.name}", + ai_session_id=f"ais_voice_state_{tmp_path.name}", + ai_state="greeting", + ai_handoff_reason=None, + ai_last_model_at=None, + started_at=utc_now_iso(), + connected_at=utc_now_iso(), + ended_at=None, + updated_at=utc_now_iso(), + ) + ) + session.commit() + finally: + session.close() + + result = bridge_module._update_voice_ai_call_state( + call_id, + VoiceAICallStateUpdateIn( + voice_session_id=f"avs_voice_state_{tmp_path.name}", + ai_session_id=f"ais_voice_state_{tmp_path.name}", + ai_state="speaking", + handoff_reason=None, + metadata={"media_uuid": "11111111-1111-1111-1111-111111111111"}, + ), + actor={"auth_source": "service", "sub": "svc:ai-voice-runtime", "user": "ai-voice-runtime", "role": "admin"}, + ) + + assert result.ai_state == "speaking" + + session = get_session() + try: + link = session.execute( + select(AsteriskCallLinkRow).where(AsteriskCallLinkRow.call_id == call_id) + ).scalar_one() + assert link.ai_state == "speaking" + assert link.ai_last_model_at is not None + finally: + session.close() + + +def test_live_call_ai_summary_route_returns_handoff_context(tmp_path): + call_id = f"call_voice_summary_{tmp_path.name}" + session = get_session() + try: + session.add( + AsteriskCallLinkRow( + call_id=call_id, + linked_id=f"linked_voice_summary_{tmp_path.name}", + queue_code="voice_lab", + queue_id="que_voice_lab", + interaction_id=f"int_voice_summary_{tmp_path.name}", + caller_number="+77010000042", + caller_name="Summary Caller", + status="active", + telephony_status="ringing", + claimed_by_user=None, + claimed_at=None, + operator_extension=None, + channel_name="PJSIP/1001-0000042", + voice_session_id=f"avs_voice_summary_{tmp_path.name}", + ai_session_id=f"ais_voice_summary_{tmp_path.name}", + ai_state="handoff_required", + ai_handoff_reason="Нужен живой оператор.", + ai_last_model_at=utc_now_iso(), + started_at=utc_now_iso(), + connected_at=None, + ended_at=None, + updated_at=utc_now_iso(), + ) + ) + session.add( + VoiceAISessionRow( + session_id=f"avs_voice_summary_{tmp_path.name}", + call_id=call_id, + linked_id=f"linked_voice_summary_{tmp_path.name}", + interaction_id=f"int_voice_summary_{tmp_path.name}", + customer_id=None, + queue_id="que_voice_lab", + ai_session_id=f"ais_voice_summary_{tmp_path.name}", + agent_profile="voice_support", + language="ru", + asr_provider="openai", + tts_provider="openai", + status="handoff_requested", + handoff_reason="Нужен живой оператор.", + handoff_target_queue_id="que_voice_lab", + disclosure_played_at=utc_now_iso(), + last_user_utterance_at=utc_now_iso(), + last_ai_reply_at=utc_now_iso(), + started_at=utc_now_iso(), + updated_at=utc_now_iso(), + ended_at=None, + ) + ) + session.add( + AISessionRow( + session_id=f"ais_voice_summary_{tmp_path.name}", + channel="voice", + call_id=call_id, + thread_id=None, + interaction_id=f"int_voice_summary_{tmp_path.name}", + customer_id=None, + agent_profile="voice_support", + language="ru", + status="handoff_required", + summary_text="AI собрал контекст и передал звонок оператору.", + last_user_message_id=None, + last_ai_message_id=None, + handoff_reason="Нужен живой оператор.", + created_at=utc_now_iso(), + updated_at=utc_now_iso(), + closed_at=None, + ) + ) + session.add( + VoiceTranscriptSegmentRow( + segment_id=f"vts_voice_summary_customer_{tmp_path.name}", + session_id=f"avs_voice_summary_{tmp_path.name}", + call_id=call_id, + interaction_id=f"int_voice_summary_{tmp_path.name}", + speaker="caller", + source_type="asr", + sequence_no=1, + text="Соедините меня с оператором", + confidence=0.91, + is_final=True, + barge_in_interrupted=False, + payload_json="{}", + created_at=utc_now_iso(), + ) + ) + session.add( + VoiceTranscriptSegmentRow( + segment_id=f"vts_voice_summary_ai_{tmp_path.name}", + session_id=f"avs_voice_summary_{tmp_path.name}", + call_id=call_id, + interaction_id=f"int_voice_summary_{tmp_path.name}", + speaker="assistant", + source_type="tts", + sequence_no=2, + text="Я как AI-оператор собрал первичный контекст. Сейчас переведу вас на живого оператора.", + confidence=None, + is_final=True, + barge_in_interrupted=False, + payload_json="{}", + created_at=utc_now_iso(), + ) + ) + session.add( + VoiceTranscriptSegmentRow( + segment_id=f"vts_voice_summary_ai_pending_{tmp_path.name}", + session_id=f"avs_voice_summary_{tmp_path.name}", + call_id=call_id, + interaction_id=f"int_voice_summary_{tmp_path.name}", + speaker="assistant", + source_type="tts", + sequence_no=3, + text="Плановая недоставленная реплика", + confidence=None, + is_final=False, + barge_in_interrupted=True, + payload_json="{}", + created_at=utc_now_iso(), + ) + ) + session.commit() + finally: + session.close() + + +def test_voice_ai_handoff_revives_false_ended_call_when_channel_is_resolvable(monkeypatch, tmp_path): + monkeypatch.setenv("ASTERISK_QUEUE_MAP_JSON", '{"ivr_support":"que_ivr_support","voice_lab_ai":"que_voice_lab"}') + monkeypatch.setenv("ASTERISK_TRANSFER_TARGET_MAP_JSON", '{"ivr_support":"2001","voice_lab_ai":"2001"}') + + call_id = f"call_ai_handoff_revive_{tmp_path.name}" + interaction_id = f"int_ai_handoff_revive_{tmp_path.name}" + session_id = f"avs_ai_handoff_revive_{tmp_path.name}" + ai_session_id = f"ais_ai_handoff_revive_{tmp_path.name}" + now = utc_now_iso() + + session = get_session() + try: + session.add( + AsteriskCallLinkRow( + call_id=call_id, + linked_id=f"linked_ai_handoff_revive_{tmp_path.name}", + queue_code="voice_lab_ai", + queue_id="que_voice_lab", + interaction_id=interaction_id, + caller_number="+77010000077", + caller_name="Track16 Handoff Revive", + status="ended", + telephony_status="ended", + claimed_by_user=None, + claimed_at=None, + operator_extension=None, + channel_name="PJSIP/1001-0000077", + voice_session_id=session_id, + ai_session_id=ai_session_id, + ai_state="handoff_requested", + ai_handoff_reason="Нужен живой оператор.", + ai_last_model_at=now, + started_at=now, + connected_at=None, + ended_at=now, + updated_at=now, + ) + ) + session.add( + VoiceAISessionRow( + session_id=session_id, + call_id=call_id, + linked_id=f"linked_ai_handoff_revive_{tmp_path.name}", + interaction_id=interaction_id, + customer_id=None, + queue_id="que_voice_lab", + ai_session_id=ai_session_id, + agent_profile="voice_support", + language="ru", + asr_provider="openai", + tts_provider="yandex", + status="handoff_requested", + handoff_reason="Нужен живой оператор.", + handoff_target_queue_id="que_ivr_support", + media_uuid="22222222-2222-2222-2222-222222222222", + media_status="connected", + media_connected_at=now, + media_ended_at=None, + last_media_frame_at=now, + disclosure_played_at=now, + last_user_utterance_at=now, + last_ai_reply_at=now, + started_at=now, + updated_at=now, + ended_at=None, + ) + ) + session.commit() + finally: + session.close() + + ami_calls: list[tuple[str, dict]] = [] + monkeypatch.setattr( + bridge_module, + "_list_channels_via_coreshowchannels", + lambda **kwargs: ["PJSIP/1001-0000077"], + ) + monkeypatch.setattr( + bridge_module, + "_ami_action", + lambda action, params: ami_calls.append((action, params)) or {"action_id": "ami_handoff_revive"}, + ) + + result = bridge_module._request_voice_ai_handoff( + call_id, + VoiceAIHandoffRequestIn( + voice_session_id=session_id, + ai_session_id=ai_session_id, + interaction_id=interaction_id, + target_queue_id="que_ivr_support", + reason="Нужен живой оператор.", + summary={"customer_request_text": "Соедините меня с оператором"}, + ), + actor={"auth_source": "service", "sub": "svc:ai-voice-runtime", "user": "ai-voice-runtime", "role": "admin"}, + ) + + assert result.call_id == call_id + assert ami_calls + assert ami_calls[0][0] == "Redirect" + assert ami_calls[0][1]["Channel"] == "PJSIP/1001-0000077" + assert ami_calls[0][1]["Exten"] == "2001" + payload = { + "status_label": "AI передал звонок оператору", + "customer_request_text": "Соедините меня с оператором", + "ai_outcome_text": "Я как AI-оператор собрал первичный контекст. Сейчас переведу вас на живого оператора.", + "handoff_reason": "Нужен живой оператор.", + } + + session = get_session() + try: + link = session.execute( + select(AsteriskCallLinkRow).where(AsteriskCallLinkRow.call_id == call_id) + ).scalar_one() + assert link.status == "active" + assert link.telephony_status == "connected" + assert link.ended_at is None + assert link.ai_state == "handoff_required" + finally: + session.close() + + assert payload["status_label"] == "AI передал звонок оператору" + assert payload["customer_request_text"] == "Соедините меня с оператором" + assert payload["ai_outcome_text"] == "Я как AI-оператор собрал первичный контекст. Сейчас переведу вас на живого оператора." + assert payload["handoff_reason"] == "Нужен живой оператор." diff --git a/tests/test_auth_service.py b/tests/test_auth_service.py new file mode 100644 index 0000000..3ac59ca --- /dev/null +++ b/tests/test_auth_service.py @@ -0,0 +1,230 @@ +from urllib.parse import parse_qs, urlparse + +from fastapi.testclient import TestClient + +from services.auth_service.app import app +from services.shared.security import decode_app_token + + +def test_login_success(): + client = TestClient(app) + response = client.post('/auth/login', json={'username': 'admin', 'password': 'admin123'}) + assert response.status_code == 200 + body = response.json() + assert body['access_token'] + assert body['role'] == 'admin' + assert body['auth_source'] == 'local' + claims = decode_app_token(body['access_token']) + assert claims['username'] == 'admin' + assert claims['role'] == 'admin' + assert claims['auth_source'] == 'local' + + +def test_create_user_as_admin(): + client = TestClient(app) + response = client.post( + '/users', + json={ + 'username': 'newoperator', + 'password': 'password1', + 'full_name': 'New Operator', + 'role': 'operator', + }, + headers={'X-User': 'admin', 'X-Role': 'admin'}, + ) + assert response.status_code == 200 + assert response.json()['username'] == 'newoperator' + + +def test_operator_cannot_create_user(): + client = TestClient(app) + response = client.post( + '/users', + json={ + 'username': 'blockeduser', + 'password': 'password1', + 'full_name': 'Blocked User', + 'role': 'operator', + }, + headers={'X-User': 'operator', 'X-Role': 'operator'}, + ) + assert response.status_code == 403 + + +def test_admin_can_update_user(): + client = TestClient(app) + created = client.post( + '/users', + json={ + 'username': 'updatableuser', + 'password': 'password1', + 'full_name': 'Updatable User', + 'role': 'operator', + }, + headers={'X-User': 'admin', 'X-Role': 'admin'}, + ) + assert created.status_code == 200 + + updated = client.patch( + f"/users/{created.json()['user_id']}", + json={ + 'full_name': 'Updated User', + 'role': 'analyst', + 'password': 'password2', + }, + headers={'X-User': 'admin', 'X-Role': 'admin'}, + ) + assert updated.status_code == 200 + assert updated.json()['full_name'] == 'Updated User' + assert updated.json()['role'] == 'analyst' + + +def test_supervisor_cannot_update_user(): + client = TestClient(app) + created = client.post( + '/users', + json={ + 'username': 'guardeduser', + 'password': 'password1', + 'full_name': 'Guarded User', + 'role': 'operator', + }, + headers={'X-User': 'admin', 'X-Role': 'admin'}, + ) + assert created.status_code == 200 + + denied = client.patch( + f"/users/{created.json()['user_id']}", + json={'full_name': 'Should Not Update'}, + headers={'X-User': 'supervisor', 'X-Role': 'supervisor'}, + ) + assert denied.status_code == 403 + + +def test_admin_can_delete_user(): + client = TestClient(app) + created = client.post( + '/users', + json={ + 'username': 'deletecandidate', + 'password': 'password1', + 'full_name': 'Delete Candidate', + 'role': 'operator', + }, + headers={'X-User': 'admin', 'X-Role': 'admin'}, + ) + assert created.status_code == 200 + + deleted = client.delete( + f"/users/{created.json()['user_id']}", + headers={'X-User': 'admin', 'X-Role': 'admin'}, + ) + assert deleted.status_code == 200 + assert deleted.json()['deleted'] is True + + lookup = client.get( + f"/users/{created.json()['user_id']}", + headers={'X-User': 'admin', 'X-Role': 'admin'}, + ) + assert lookup.status_code == 404 + + +def test_admin_cannot_delete_current_user(): + client = TestClient(app) + + users = client.get('/users', headers={'X-User': 'admin', 'X-Role': 'admin'}) + assert users.status_code == 200 + admin_user = next(item for item in users.json() if item['username'] == 'admin') + + deleted = client.delete( + f"/users/{admin_user['user_id']}", + headers={'X-User': 'admin', 'X-Role': 'admin'}, + ) + assert deleted.status_code == 409 + + +def test_supervisor_cannot_delete_user(): + client = TestClient(app) + created = client.post( + '/users', + json={ + 'username': 'nodeletebysupervisor', + 'password': 'password1', + 'full_name': 'No Delete', + 'role': 'operator', + }, + headers={'X-User': 'admin', 'X-Role': 'admin'}, + ) + assert created.status_code == 200 + + denied = client.delete( + f"/users/{created.json()['user_id']}", + headers={'X-User': 'supervisor', 'X-Role': 'supervisor'}, + ) + assert denied.status_code == 403 + + +def test_oidc_config_reflects_disabled_state(monkeypatch): + monkeypatch.setenv('OIDC_ENABLED', '0') + client = TestClient(app) + + response = client.get('/auth/oidc/config') + + assert response.status_code == 200 + assert response.json()['enabled'] is False + + +def test_oidc_start_redirects_with_override_urls(monkeypatch): + monkeypatch.setenv('OIDC_ENABLED', '1') + monkeypatch.setenv('OIDC_AUTHORIZE_URL', 'http://keycloak.local/auth') + monkeypatch.setenv('OIDC_TOKEN_URL', 'http://keycloak.local/token') + client = TestClient(app) + + response = client.get('/auth/oidc/start', follow_redirects=False) + + assert response.status_code == 302 + location = response.headers['location'] + assert location.startswith('http://keycloak.local/auth?') + params = parse_qs(urlparse(location).query) + assert params['client_id'][0] == 'mvp-contact-center' + assert params['response_type'][0] == 'code' + assert 'state' in params + + +def test_oidc_callback_accepts_mapped_mock_user(monkeypatch): + monkeypatch.setenv('OIDC_ENABLED', '1') + monkeypatch.setenv('OIDC_TEST_MODE', '1') + monkeypatch.setenv('OIDC_AUTHORIZE_URL', 'http://keycloak.local/auth') + monkeypatch.setenv('OIDC_TOKEN_URL', 'http://keycloak.local/token') + client = TestClient(app) + + start = client.get('/auth/oidc/start', follow_redirects=False) + params = parse_qs(urlparse(start.headers['location']).query) + state = params['state'][0] + + response = client.get( + f'/auth/oidc/callback?state={state}&mock_sub=sub-1&mock_username=kc.user&mock_groups=kc_operator' + ) + + assert response.status_code == 200 + assert 'oidc-login' in response.text + assert 'Corporate login completed' in response.text + + +def test_oidc_callback_rejects_unmapped_mock_user(monkeypatch): + monkeypatch.setenv('OIDC_ENABLED', '1') + monkeypatch.setenv('OIDC_TEST_MODE', '1') + monkeypatch.setenv('OIDC_AUTHORIZE_URL', 'http://keycloak.local/auth') + monkeypatch.setenv('OIDC_TOKEN_URL', 'http://keycloak.local/token') + client = TestClient(app) + + start = client.get('/auth/oidc/start', follow_redirects=False) + params = parse_qs(urlparse(start.headers['location']).query) + state = params['state'][0] + + response = client.get( + f'/auth/oidc/callback?state={state}&mock_sub=sub-2&mock_username=kc.bad&mock_groups=unknown_group' + ) + + assert response.status_code == 403 + assert 'OIDC role is not mapped' in response.text diff --git a/tests/test_demo_ready_scripts.py b/tests/test_demo_ready_scripts.py new file mode 100644 index 0000000..f3c3051 --- /dev/null +++ b/tests/test_demo_ready_scripts.py @@ -0,0 +1,63 @@ +from pathlib import Path + +from scripts import demo_seed, local_stack + + +def test_local_stack_gateway_env_contains_expected_urls(): + env = local_stack.build_gateway_env() + + assert env["AUTH_SERVICE_URL"] == "http://127.0.0.1:8001" + assert env["INTERACTION_SERVICE_URL"] == "http://127.0.0.1:8004" + assert env["SUPERVISOR_SERVICE_URL"] == "http://127.0.0.1:8010" + assert env["RECORDING_SERVICE_URL"] == "http://127.0.0.1:8013" + assert env["IVR_SERVICE_URL"] == "http://127.0.0.1:8014" + assert env["WEBCHAT_ADAPTER_SERVICE_URL"] == "http://127.0.0.1:8011" + assert env["EMAIL_ADAPTER_SERVICE_URL"] == "http://127.0.0.1:8012" + assert env["AI_VOICE_RUNTIME_SERVICE_URL"] == "http://127.0.0.1:8018" + + +def test_local_stack_service_specs_include_ai_voice_runtime(): + names = {spec["name"] for spec in local_stack.build_service_specs()} + + assert "ai" in names + assert "ai-voice-runtime" in names + assert "asterisk-bridge" in names + + +def test_local_stack_manifest_payload_has_core_fields(tmp_path: Path): + services = [{"name": "gateway", "port": 8080, "pid": 12345}] + + payload = local_stack.build_manifest_payload(tmp_path / "runtime", tmp_path / "data", services) + + assert payload["base_url"] == "http://127.0.0.1:8080" + assert payload["services"] == services + assert payload["runtime_dir"] == str(tmp_path / "runtime") + assert payload["data_dir"] == str(tmp_path / "data") + + +def test_demo_seed_plan_contains_demo_keyword(): + plan = demo_seed.build_seed_plan("TAG123") + + assert plan["queue_name"] == "Demo Queue" + assert plan["kb_category"] == "Demo Knowledge" + assert plan["kb_keyword"] == "demo-showcase" + assert plan["telegram_text"] == "Demo inbound Telegram message" + assert plan["webchat_text"] == "Demo webchat request from the website" + assert plan["email_subject"] == "Demo email request about account access" + assert plan["recording_file_name"] == "demo-call.wav" + assert ( + plan["ivr_root_prompt"] + == "Саламатсыз ба! Қазақ тілін таңдау үшін бір цифрын теріңіз. " + "Здравствуйте! Добро пожаловать в контакт-центр. Для русского языка нажмите цифру два." + ) + assert plan["ivr_ru_menu_prompt"] == "Для отдела продаж нажмите 1. Для службы поддержки нажмите 2." + assert plan["ivr_kz_menu_prompt"] == "Сату бөлімі үшін 1 басыңыз. Қолдау қызметі үшін 2 басыңыз." + + +def test_prepare_demo_script_resets_local_data_and_prints_backstage_hints(): + script = (Path(__file__).resolve().parents[1] / "scripts" / "prepare_demo.ps1").read_text(encoding="utf-8") + + assert "python scripts\\local_stack.py stop" in script + assert "Remove-Item -Path $dataDir -Recurse -Force" in script + assert "Login: admin / admin123" in script + assert "KB keyword: demo-showcase" in script diff --git a/tests/test_email_adapter_service.py b/tests/test_email_adapter_service.py new file mode 100644 index 0000000..c6b3b83 --- /dev/null +++ b/tests/test_email_adapter_service.py @@ -0,0 +1,44 @@ +from fastapi.testclient import TestClient + +from services.email_adapter_service.app import app as email_app +from services.interaction_service.app import app as interaction_app + + +def test_email_message_creates_interaction_and_can_be_listed(): + email_client = TestClient(email_app) + interaction_client = TestClient(interaction_app) + + created = email_client.post( + "/integrations/email/messages", + json={ + "from_email": "person@example.com", + "subject": "Portal access issue", + "body": "Please restore access to the portal account.", + "customer_external_id": "cust_ext_email_1", + "queue_id": "line2", + "priority": 2, + "payload": {"source": "test", "mailbox": "support@example.com"}, + }, + ) + assert created.status_code == 200 + payload = created.json() + assert payload["message_id"].startswith("eml_") + assert payload["interaction_id"].startswith("int_") + + interaction = interaction_client.get(f"/interactions/{payload['interaction_id']}") + assert interaction.status_code == 200 + assert interaction.json()["channel"] == "email" + assert interaction.json()["queue_id"] == "line2" + assert interaction.json()["customer_id"] == "cust_ext_email_1" + assert interaction.json()["subject"] == "Portal access issue" + + timeline = interaction_client.get(f"/interactions/{payload['interaction_id']}/timeline") + assert timeline.status_code == 200 + actions = [event["action"] for event in timeline.json()["events"]] + assert "interaction.created" in actions + assert "email.message_received" in actions + + listed = email_client.get("/integrations/email/messages?from_email=person@example.com") + assert listed.status_code == 200 + assert len(listed.json()) >= 1 + assert listed.json()[-1]["from_email"] == "person@example.com" diff --git a/tests/test_gateway_proxy.py b/tests/test_gateway_proxy.py new file mode 100644 index 0000000..e126465 --- /dev/null +++ b/tests/test_gateway_proxy.py @@ -0,0 +1,173 @@ +from fastapi.testclient import TestClient + +import gateway.app as gateway_module + + +class DummyResponse: + def __init__( + self, + status_code: int, + headers: dict[str, str], + json_payload: dict | None = None, + text: str = '', + content: bytes | None = None, + ): + self.status_code = status_code + self.headers = headers + self._json_payload = json_payload or {} + self.text = text + self.content = content if content is not None else text.encode("utf-8") + + def json(self) -> dict: + return self._json_payload + + +class DummyAsyncClient: + last_request: dict = {} + + def __init__(self, timeout: int): + self.timeout = timeout + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return None + + async def request(self, method, url, params=None, content=None, headers=None): + DummyAsyncClient.last_request = { + 'method': method, + 'url': url, + 'params': params, + 'content': content, + 'headers': headers or {}, + } + return DummyResponse(200, {'content-type': 'application/json'}, {'ok': True}) + + +def test_gateway_forwards_authorization_header(monkeypatch): + monkeypatch.setattr(gateway_module.httpx, 'AsyncClient', DummyAsyncClient) + client = TestClient(gateway_module.app) + + response = client.get( + '/proxy/auth/auth/me', + headers={'Authorization': 'Bearer test-token', 'X-User': 'admin', 'X-Role': 'admin'}, + ) + + assert response.status_code == 200 + assert DummyAsyncClient.last_request['headers']['Authorization'] == 'Bearer test-token' + assert DummyAsyncClient.last_request['headers']['X-User'] == 'admin' + + +def test_gateway_forwards_telegram_secret_header(monkeypatch): + monkeypatch.setattr(gateway_module.httpx, 'AsyncClient', DummyAsyncClient) + client = TestClient(gateway_module.app) + + response = client.post( + '/proxy/telegram/integrations/telegram/bot/webhook', + headers={'X-Telegram-Bot-Api-Secret-Token': 'secret-token'}, + json={'update_id': 1}, + ) + + assert response.status_code == 200 + assert DummyAsyncClient.last_request['headers']['X-Telegram-Bot-Api-Secret-Token'] == 'secret-token' + + +def test_gateway_forwards_whatsapp_secret_header(monkeypatch): + monkeypatch.setattr(gateway_module.httpx, 'AsyncClient', DummyAsyncClient) + client = TestClient(gateway_module.app) + + response = client.post( + '/proxy/whatsapp/integrations/whatsapp/provider/webhook', + headers={'X-WhatsApp-Webhook-Secret': 'wa-secret'}, + json={'entry': []}, + ) + + assert response.status_code == 200 + assert DummyAsyncClient.last_request['headers']['X-WhatsApp-Webhook-Secret'] == 'wa-secret' + assert DummyAsyncClient.last_request['content'] == b'{"entry":[]}' + + +def test_gateway_forwards_meta_signature_header_and_raw_body(monkeypatch): + monkeypatch.setattr(gateway_module.httpx, 'AsyncClient', DummyAsyncClient) + client = TestClient(gateway_module.app) + + response = client.post( + '/proxy/whatsapp/integrations/whatsapp/provider/webhook', + headers={ + 'X-Hub-Signature-256': 'sha256=test-signature', + 'Content-Type': 'application/json', + }, + content=b'{"entry":[{"changes":[]}]}' , + ) + + assert response.status_code == 200 + assert DummyAsyncClient.last_request['headers']['X-Hub-Signature-256'] == 'sha256=test-signature' + assert DummyAsyncClient.last_request['headers']['Content-Type'] == 'application/json' + assert DummyAsyncClient.last_request['content'] == b'{"entry":[{"changes":[]}]}' + + +def test_gateway_preserves_html_response(monkeypatch): + async def request(self, method, url, params=None, content=None, headers=None): # noqa: ANN001, ANN201 + return DummyResponse(200, {'content-type': 'text/html'}, text='bridge') + + monkeypatch.setattr(DummyAsyncClient, 'request', request) + monkeypatch.setattr(gateway_module.httpx, 'AsyncClient', DummyAsyncClient) + client = TestClient(gateway_module.app) + + response = client.get('/proxy/auth/auth/oidc/callback') + + assert response.status_code == 200 + assert 'bridge' in response.text + + +def test_gateway_preserves_plain_text_response(monkeypatch): + async def request(self, method, url, params=None, content=None, headers=None): # noqa: ANN001, ANN201 + return DummyResponse(200, {'content-type': 'text/plain; charset=utf-8'}, text='123456') + + monkeypatch.setattr(DummyAsyncClient, 'request', request) + monkeypatch.setattr(gateway_module.httpx, 'AsyncClient', DummyAsyncClient) + client = TestClient(gateway_module.app) + + response = client.get('/proxy/whatsapp/integrations/whatsapp/provider/webhook') + + assert response.status_code == 200 + assert response.text == '123456' + assert response.headers['content-type'].startswith('text/plain') + + +def test_gateway_forwards_delete_requests(monkeypatch): + monkeypatch.setattr(gateway_module.httpx, 'AsyncClient', DummyAsyncClient) + client = TestClient(gateway_module.app) + + response = client.delete( + '/proxy/routing/queues/que_delete_me', + headers={'X-User': 'admin', 'X-Role': 'admin'}, + ) + + assert response.status_code == 200 + assert DummyAsyncClient.last_request['method'] == 'DELETE' + assert DummyAsyncClient.last_request['url'].endswith('/queues/que_delete_me') + + +def test_gateway_preserves_binary_audio_response(monkeypatch): + async def request(self, method, url, params=None, content=None, headers=None): # noqa: ANN001, ANN201 + return DummyResponse( + 200, + { + 'content-type': 'audio/wav', + 'content-disposition': 'attachment; filename="demo-call.wav"', + }, + content=b'RIFFdemo', + ) + + monkeypatch.setattr(DummyAsyncClient, 'request', request) + monkeypatch.setattr(gateway_module.httpx, 'AsyncClient', DummyAsyncClient) + client = TestClient(gateway_module.app) + + response = client.get('/proxy/recording/recordings/rec_demo/content') + + assert response.status_code == 200 + assert response.content == b'RIFFdemo' + assert response.headers['content-type'].startswith('audio/wav') + assert 'demo-call.wav' in response.headers['content-disposition'] diff --git a/tests/test_gateway_ui.py b/tests/test_gateway_ui.py new file mode 100644 index 0000000..60aa0f2 --- /dev/null +++ b/tests/test_gateway_ui.py @@ -0,0 +1,579 @@ +from pathlib import Path + +from fastapi.testclient import TestClient + +from gateway.app import app + + +def test_operator_ui_route_exists(): + client = TestClient(app) + response = client.get("/operator") + assert response.status_code == 200 + assert 'id="profileName"' in response.text + assert 'id="profileRole"' in response.text + assert 'id="browserPhoneCallOverlay"' in response.text + + +def test_login_ui_route_exists(): + client = TestClient(app) + response = client.get("/") + assert response.status_code == 200 + assert "login/assets/app.js" in response.text + + +def test_legal_routes_exist_for_meta_review(): + client = TestClient(app) + + privacy = client.get("/privacy") + terms = client.get("/terms") + deletion = client.get("/data-deletion") + + assert privacy.status_code == 200 + assert "KonturAI Privacy Policy" in privacy.text + assert terms.status_code == 200 + assert "KonturAI Terms of Service" in terms.text + assert deletion.status_code == 200 + assert "Data Deletion" in deletion.text + + +def test_contracts_shape(): + client = TestClient(app) + response = client.get("/contracts") + assert response.status_code == 200 + payload = response.json() + assert "stage_1" in payload + assert "stage_2" in payload + assert "stage_3" in payload + assert "stage_4" in payload + assert "stage_5" in payload + assert "stage_6" in payload + assert "stage_11" in payload + assert "stage_12" in payload + assert "stage_14" in payload + assert "stage_15" in payload + assert "stage_16" in payload + assert "stage_17" in payload + assert "/integrations/webchat/messages" in payload["stage_2"] + assert "/integrations/email/messages" in payload["stage_2"] + assert "/integrations/whatsapp/webhook" in payload["stage_2"] + assert "/recordings/*" in payload["stage_4"] + assert "/ivr/flows" in payload["stage_5"] + assert "/ivr/sessions" in payload["stage_5"] + assert "/reports/coverage" in payload["stage_6"] + assert "/reports/agents/overview" in payload["stage_6"] + assert "/reports/agents/timeseries" in payload["stage_6"] + assert "/reports/drilldown" in payload["stage_6"] + assert "/reports/export" in payload["stage_6"] + assert "/reports/facts/interactions" in payload["stage_6"] + assert "/reports/kpi" in payload["stage_6"] + assert "/reports/timeseries" in payload["stage_6"] + assert "/reports/views" in payload["stage_6"] + assert "/reports/views/*" in payload["stage_6"] + assert "/asterisk/live-calls" in payload["stage_11"] + assert "/asterisk/recent-calls" in payload["stage_12"] + assert "/asterisk/browser-softphone/config" in payload["stage_14"] + assert "/integrations/telegram/threads" in payload["stage_15"] + assert "/integrations/whatsapp/threads" in payload["stage_15"] + assert "/ai/whatsapp/threads/*" in payload["stage_16"] + assert "/ai/analytics/overview" in payload["stage_16"] + assert "/ai/analytics/timeseries" in payload["stage_16"] + assert "/ai/analytics/drilldown" in payload["stage_16"] + assert "/ai/analytics/sessions/*" in payload["stage_16"] + assert "/asterisk/live-calls/*/ai-summary" in payload["stage_17"] + + +def test_operator_ui_includes_favicon_link(): + client = TestClient(app) + response = client.get("/operator") + assert response.status_code == 200 + assert "/operator/assets/favicon.svg" in response.text + + +def test_operator_app_syncs_role_after_login(): + app_js = (Path(__file__).resolve().parents[1] / "ui" / "operator" / "app.js").read_text(encoding="utf-8") + assert "state.role = data.role;" in app_js + assert "$('sessionRole').value = data.role;" in app_js + + +def test_operator_app_defaults_to_admin_and_syncs_from_form(): + app_js = (Path(__file__).resolve().parents[1] / "ui" / "operator" / "app.js").read_text(encoding="utf-8") + index_html = (Path(__file__).resolve().parents[1] / "ui" / "operator" / "index.html").read_text(encoding="utf-8") + assert "role: 'admin'" in app_js + assert "function syncSessionFromInputs()" in app_js + assert "syncSessionFromInputs();" in app_js + assert '' in index_html + + +def test_operator_app_demo_polish_no_prompt_and_has_summaries(): + app_js = (Path(__file__).resolve().parents[1] / "ui" / "operator" / "app.js").read_text(encoding="utf-8") + index_html = (Path(__file__).resolve().parents[1] / "ui" / "operator" / "index.html").read_text(encoding="utf-8") + assert "prompt(" not in app_js + assert "function renderSupervisorSummary" in app_js + assert "function renderKpiSummary" in app_js + assert 'id="supervisorSummary"' not in index_html + assert 'id="kpiSummary"' not in index_html + assert "Сценарий демонстрации" not in index_html + + +def test_operator_ui_contains_corporate_login_path(): + app_js = (Path(__file__).resolve().parents[1] / "ui" / "operator" / "app.js").read_text(encoding="utf-8") + index_html = (Path(__file__).resolve().parents[1] / "ui" / "operator" / "index.html").read_text(encoding="utf-8") + assert "function startCorporateLogin()" in app_js + assert "headers.Authorization = `Bearer ${state.token}`;" in app_js + assert "window.addEventListener('message', handleOidcMessage);" in app_js + assert 'id="corporateLoginBtn"' in index_html + + +def test_operator_ui_keeps_webchat_channel_option_without_demo_block(): + app_js = (Path(__file__).resolve().parents[1] / "ui" / "operator" / "app.js").read_text(encoding="utf-8") + index_html = (Path(__file__).resolve().parents[1] / "ui" / "operator" / "index.html").read_text(encoding="utf-8") + assert '' in index_html + assert 'id="sendWebchatMessageBtn"' not in index_html + assert "function sendWebchatMessage()" not in app_js + + +def test_operator_ui_contains_leads_table_front(): + app_js = (Path(__file__).resolve().parents[1] / "ui" / "operator" / "app.js").read_text(encoding="utf-8") + index_html = (Path(__file__).resolve().parents[1] / "ui" / "operator" / "index.html").read_text(encoding="utf-8") + assert 'id="customersView"' in index_html + assert 'data-operator-view="customers"' in index_html + assert 'data-operator-view-link="customers"' in index_html + assert 'href="#customers-page"' in index_html + assert 'id="toggleCustomerLeadFormBtn"' in index_html + assert 'id="exportCustomersBtn"' in index_html + assert 'id="customerPageSummary"' in index_html + assert 'id="customerPrevPageBtn"' in index_html + assert 'id="customerNextPageBtn"' in index_html + assert "function renderCustomerList" in app_js + assert "function exportCustomersCsv" in app_js + assert "function toggleCustomerLeadForm" in app_js + assert "function customerLeadSourceMeta" in app_js + assert "function applyOperatorViewFromHash" in app_js + assert "window.addEventListener('hashchange', handleOperatorHashChange);" in app_js + + +def test_operator_ui_contains_telegram_workspace_page(): + app_js = (Path(__file__).resolve().parents[1] / "ui" / "operator" / "app.js").read_text(encoding="utf-8") + index_html = (Path(__file__).resolve().parents[1] / "ui" / "operator" / "index.html").read_text(encoding="utf-8") + assert 'id="telegramView"' in index_html + assert 'data-operator-view="telegram"' in index_html + assert 'data-operator-view-link="telegram"' in index_html + assert 'href="#telegram-page"' in index_html + assert 'id="loadTelegramThreadsBtn"' in index_html + assert 'id="telegramThreadsList"' in index_html + assert 'id="telegramMessagesTimeline"' in index_html + assert 'id="telegramHeaderSearchBtn"' in index_html + assert 'id="telegramMessageSearchBar"' in index_html + assert 'id="telegramMessageSearch"' in index_html + assert 'id="telegramMessageSearchMeta"' in index_html + assert 'id="telegramMessageSearchClearBtn"' in index_html + assert 'id="telegramReplyText"' in index_html + assert 'id="telegramSendReplyBtn"' in index_html + assert 'id="telegramClaimThreadBtn"' in index_html + assert 'id="telegramCloseThreadBtn"' in index_html + assert 'id="telegramEscalateThreadBtn"' in index_html + assert "api('telegram', 'integrations/telegram/threads')" in app_js + assert "integrations/telegram/threads/${encodeURIComponent(thread.thread_id)}/messages" in app_js + assert "function renderTelegramWorkspace" in app_js + assert "function toggleTelegramMessageSearch" in app_js + assert "function telegramVisibleMessages" in app_js + assert "function highlightTelegramMessageText" in app_js + assert "function claimTelegramThread" in app_js + assert "function sendTelegramReply" in app_js + + +def test_operator_ui_contains_live_call_control_block(): + app_js = (Path(__file__).resolve().parents[1] / "ui" / "operator" / "app.js").read_text(encoding="utf-8") + index_html = (Path(__file__).resolve().parents[1] / "ui" / "operator" / "index.html").read_text(encoding="utf-8") + assert 'id="liveCalls"' in index_html + assert 'data-operator-view="voice-debug"' in index_html + assert 'data-operator-view-link="voice-debug"' in index_html + assert 'href="#voice-debug"' in index_html + assert 'id="loadLiveCallsBtn"' in index_html + assert 'id="loadLiveCallActionsBtn"' in index_html + assert 'id="liveCallIdSelect"' in index_html + assert 'id="claimLiveCallBtn"' not in index_html + assert 'id="hangupLiveCallBtn"' not in index_html + assert 'id="blindTransferLiveCallBtn"' not in index_html + assert "api('asterisk-bridge', 'asterisk/live-calls')" in app_js + assert "api('asterisk-bridge', 'asterisk/recent-calls')" in app_js + assert "asterisk/live-calls/${encodeURIComponent(callId)}/claim" in app_js + assert "asterisk/live-calls/${encodeURIComponent(callId)}/hangup" in app_js + assert "blind-transfer" in app_js + assert "Принять в работу" in index_html + assert "Только что завершённые" in app_js + assert "function terminalActionLabel" in app_js + assert "function fallbackTelephonyStatus" not in app_js + assert 'data-operator-anchor-link="liveCalls"' not in index_html + assert 'data-operator-anchor-link="interactions"' not in index_html + assert 'data-operator-anchor-link="integrations"' not in index_html + + +def test_operator_ui_optimistically_updates_live_call_state_after_actions(): + app_js = (Path(__file__).resolve().parents[1] / "ui" / "operator" / "app.js").read_text(encoding="utf-8") + assert "function applyClaimedLiveCall" in app_js + assert "function moveLiveCallToRecent" in app_js + assert "function refreshLiveCallsInBackground" in app_js + assert "loadLiveCalls(false, { preserveStateOnError: true });" in app_js + + +def test_operator_ui_contains_browser_softphone_popup_controls(): + app_js = (Path(__file__).resolve().parents[1] / "ui" / "operator" / "app.js").read_text(encoding="utf-8") + index_html = (Path(__file__).resolve().parents[1] / "ui" / "operator" / "index.html").read_text(encoding="utf-8") + assert 'id="browserSoftphone"' not in index_html + assert 'id="browserPhoneStatusBtn"' in index_html + assert 'id="browserPhoneSettingsPanel"' in index_html + assert 'id="browserPhoneCallOverlay"' in index_html + assert 'id="browserPhoneAiSummary"' in index_html + assert 'id="browserPhoneConnectBtn"' in index_html + assert 'id="browserPhoneAnswerBtn"' in index_html + assert 'id="browserPhoneCallClaimBtn"' in index_html + assert 'id="browserPhoneCallTransferBtn"' in index_html + assert 'id="browserPhoneCallHangupBtn"' in index_html + assert '/operator/assets/sip-0.21.2.min.js' in index_html + assert "asterisk/browser-softphone/config" in app_js + assert "function connectBrowserSoftphone" in app_js + assert "function answerBrowserSoftphoneCall" in app_js + assert "function toggleBrowserPhoneSettings" in app_js + assert "function hangupBrowserPopupCall" in app_js + assert "function transferBrowserPopupCall" in app_js + assert "function loadVoiceAiSummary" in app_js + assert "function renderVoiceAiSummary" in app_js + assert "function startBrowserPhoneRingtone" in app_js + assert "function stopBrowserPhoneRingtone" in app_js + + +def test_operator_ui_browser_softphone_has_fast_answer_and_hangup_states(): + app_js = (Path(__file__).resolve().parents[1] / "ui" / "operator" / "app.js").read_text(encoding="utf-8") + assert "function browserPhonePopupPhase" in app_js + assert "function browserPhoneAutoClaimSnapshot" in app_js + assert "function ensureBrowserPhoneLocalStream" in app_js + assert "function prepareBrowserPhoneAnswerMedia" in app_js + assert "state.browserPhone.callPhase = 'connecting';" in app_js + assert "state.browserPhone.callPhase = 'ending';" in app_js + assert "state.browserPhone.callPhase = 'error';" in app_js + assert "state.browserPhone.autoClaimInFlight" in app_js + assert "localMediaStream" in app_js + assert "await terminateBrowserPhoneSessionLocally();" in app_js + assert "bestEffortBrowserAutoClaimWithOptions" in app_js + + +def test_operator_ui_keeps_email_channel_option_without_demo_block(): + app_js = (Path(__file__).resolve().parents[1] / "ui" / "operator" / "app.js").read_text(encoding="utf-8") + index_html = (Path(__file__).resolve().parents[1] / "ui" / "operator" / "index.html").read_text(encoding="utf-8") + assert '' in index_html + assert 'id="sendEmailMessageBtn"' not in index_html + assert "function sendEmailMessage()" not in app_js + + +def test_supervisor_ui_route_exists(): + client = TestClient(app) + response = client.get("/supervisor") + assert response.status_code == 200 + assert 'id="profileName"' in response.text + assert 'id="profileRole"' in response.text + assert "supervisor/assets/app.js" in response.text + + +def test_analyst_ui_route_exists(): + client = TestClient(app) + response = client.get("/analyst") + assert response.status_code == 200 + assert 'id="profileName"' in response.text + assert 'id="profileRole"' in response.text + assert "analyst/assets/app.js" in response.text + + +def test_admin_ui_route_exists(): + client = TestClient(app) + response = client.get("/admin") + assert response.status_code == 200 + assert 'id="profileName"' in response.text + assert 'id="profileRole"' in response.text + assert "admin/assets/app.js" in response.text + + +def test_supervisor_ui_uses_realtime_and_kpi_endpoints(): + app_js = (Path(__file__).resolve().parents[1] / "ui" / "supervisor" / "app.js").read_text(encoding="utf-8") + index_html = (Path(__file__).resolve().parents[1] / "ui" / "supervisor" / "index.html").read_text(encoding="utf-8") + assert "api('supervisor', 'supervisor/realtime')" in app_js + assert "api('reporting', `reports/kpi?queue_id=${queueId}`)" in app_js + assert "api('recording', `recordings${suffix}`)" in app_js + assert "api('recording', `recordings/import-from-voice-event/" in app_js + assert "fetchBinary('recording'" in app_js + assert 'id="loadRealtimeBtn"' in index_html + assert 'id="saveQueueMetricsBtn"' in index_html + assert 'id="recordings"' in index_html + assert 'id="importRecordingBtn"' in index_html + assert 'id="loadRecordingsBtn"' in index_html + assert 'id="recordingPlayer"' in index_html + + +def test_supervisor_ui_no_longer_renders_analytics_dashboard(): + app_js = (Path(__file__).resolve().parents[1] / "ui" / "supervisor" / "app.js").read_text(encoding="utf-8") + index_html = (Path(__file__).resolve().parents[1] / "ui" / "supervisor" / "index.html").read_text(encoding="utf-8") + assert 'id="analyticsPanel"' not in index_html + assert 'id="analyticsPreset"' not in index_html + assert 'id="analyticsRefreshBtn"' not in index_html + assert 'id="analyticsTrendChart"' not in index_html + assert 'id="analyticsQueueTable"' not in index_html + assert 'id="analyticsCoverage"' not in index_html + assert '"#analyticsPanel"' not in index_html + assert "$('analyticsRefreshBtn').addEventListener" not in app_js + assert "$('analyticsResetBtn').addEventListener" not in app_js + assert "$('analyticsTrendMetric').addEventListener" not in app_js + assert "$('analyticsQueueTable').addEventListener" not in app_js + assert "loadAnalyticsDashboard(true)" not in app_js + + +def test_analyst_ui_contains_analytics_dashboard_contract(): + app_js = (Path(__file__).resolve().parents[1] / "ui" / "analyst" / "app.js").read_text(encoding="utf-8") + index_html = (Path(__file__).resolve().parents[1] / "ui" / "analyst" / "index.html").read_text(encoding="utf-8") + assert 'id="analyticsPanel"' in index_html + assert 'id="analyticsPreset"' in index_html + assert 'id="analyticsFrom"' in index_html + assert 'id="analyticsTo"' in index_html + assert 'id="analyticsQueue"' in index_html + assert 'id="analyticsChannel"' in index_html + assert 'id="analyticsRefreshBtn"' in index_html + assert 'id="analyticsResetBtn"' in index_html + assert 'id="analyticsOverview"' in index_html + assert 'id="analyticsTrendMetric"' in index_html + assert 'id="analyticsTrendChart"' in index_html + assert 'id="analyticsChannelTable"' in index_html + assert 'id="analyticsQueueTable"' in index_html + assert 'id="analyticsCoverage"' not in index_html + assert 'id="analyticsEmptyState"' in index_html + assert 'id="aiAnalyticsPanel"' in index_html + assert 'id="aiAnalyticsOverview"' in index_html + assert 'id="aiAnalyticsTrendMetric"' in index_html + assert 'id="aiAnalyticsTrendChart"' in index_html + assert 'id="aiAnalyticsChannelComparison"' in index_html + assert 'id="aiAnalyticsCoverage"' in index_html + assert 'id="aiAnalyticsEmptyState"' in index_html + assert 'id="aiAnalyticsOutcomeTable"' in index_html + assert 'id="aiAnalyticsReasonTable"' in index_html + assert 'id="agentAnalyticsPanel"' in index_html + assert 'id="agentAnalyticsOverview"' in index_html + assert 'id="agentAnalyticsStateStrip"' in index_html + assert 'id="agentAnalyticsTrendMetric"' in index_html + assert 'id="agentAnalyticsTrendChart"' in index_html + assert 'id="agentAnalyticsTeamTable"' in index_html + assert 'id="agentAnalyticsShiftTable"' in index_html + assert 'id="agentAnalyticsTable"' in index_html + assert 'id="agentAnalyticsEmptyState"' in index_html + assert 'id="analyticsNarrative"' in index_html + assert 'id="analyticsCompareMode"' in index_html + assert 'id="analyticsSavedViewSelect"' in index_html + assert 'id="analyticsSavedViewName"' in index_html + assert 'id="analyticsSaveViewBtn"' in index_html + assert 'id="analyticsDeleteViewBtn"' in index_html + assert 'id="analyticsExportBtn"' in index_html + assert 'id="analyticsComparePanel"' in index_html + assert 'id="analyticsDrilldownDrawer"' in index_html + assert 'id="analyticsDrilldownCloseBtn"' in index_html + assert 'id="analyticsDrilldownFilters"' in index_html + assert 'id="analyticsDrilldownList"' in index_html + assert 'id="analyticsDrilldownDetail"' in index_html + assert 'id="analyticsDrilldownPrevBtn"' in index_html + assert 'id="analyticsDrilldownNextBtn"' in index_html + assert 'id="analyticsDrilldownPageMeta"' in index_html + assert 'id="analyticsDrilldownSearch"' in index_html + assert 'id="analyticsDrilldownStatus"' in index_html + assert 'id="analyticsDrilldownSort"' in index_html + assert 'id="analyticsDrilldownClearBtn"' in index_html + assert 'id="analyticsDrilldownExportBtn"' in index_html + assert 'role="dialog"' in index_html + assert 'aria-modal="true"' in index_html + assert 'id="analyticsOperations"' not in index_html + assert 'id="loadRealtimeBtn"' not in index_html + assert 'id="saveQueueMetricsBtn"' not in index_html + assert "api('reporting', 'reports/coverage')" in app_js + assert "api('reporting', `reports/agents/overview?" in app_js + assert "api('reporting', `reports/agents/timeseries?" in app_js + assert "api('reporting', `reports/drilldown?" in app_js + assert "api('reporting', `reports/timeseries?" in app_js + assert "api('reporting', 'reports/views')" in app_js + assert "api('reporting', `reports/views/${encodeURIComponent(activeView.id)}`" in app_js + assert "api('routing', 'queues')" in app_js + assert "api('ai', `ai/analytics/overview?" in app_js + assert "api('ai', `ai/analytics/timeseries?" in app_js + assert "api('ai', `ai/analytics/drilldown?" in app_js + assert "api('ai', `ai/analytics/sessions/${encodeURIComponent(sessionId)}`)" in app_js + assert "api('interaction'," in app_js + assert "interactions/drilldown?" in app_js + assert "params.set('agent_id', normalized.agent_id);" in app_js + assert "params.set('q', normalized.q);" in app_js + assert "params.set('sort_by', normalized.sort_by || 'created_at');" in app_js + assert "params.set('sort_dir', normalized.sort_dir || 'desc');" in app_js + assert "interactions/${encodeURIComponent(interactionId)}/timeline" in app_js + assert "api('supervisor', 'supervisor/realtime')" not in app_js + assert "api('supervisor', 'supervisor/live-calls?limit=20')" not in app_js + assert "function loadAnalyticsDashboard" in app_js + assert "function renderAnalyticsNarrative" in app_js + assert "function renderAnalyticsComparePanel" in app_js + assert "function renderAnalyticsTrendChart" in app_js + assert "function renderAiAnalyticsTrendChart" in app_js + assert "function renderAiAnalyticsOverview" in app_js + assert "function renderAiAnalyticsOutcomeTable" in app_js + assert "function renderAiAnalyticsReasonTable" in app_js + assert "function renderAgentAnalyticsOverview" in app_js + assert "function renderAgentAnalyticsTable" in app_js + assert "function renderAgentAnalyticsStateStrip" in app_js + assert "function loadAgentAnalyticsTrend" in app_js + assert "function renderAgentAnalyticsTrendChart" in app_js + assert "function renderAgentAnalyticsTeamTable" in app_js + assert "function renderAgentAnalyticsShiftTable" in app_js + assert "function handleAgentAnalyticsClick" in app_js + assert "function loadAiAnalyticsTrend" in app_js + assert "function aiAnalyticsSupportedChannel" in app_js + assert "function handleAiAnalyticsOverviewClick" in app_js + assert "function syncAnalyticsUrlState" in app_js + assert "function parseAnalyticsDeepLinkState" in app_js + assert "function restoreAnalyticsDrilldownFromUrlState" in app_js + assert "window.history.replaceState(null, '', nextUrl);" in app_js + assert "url.searchParams.set('dd_mode', drilldown.mode);" in app_js + assert "url.searchParams.set('dd_selected', drilldown.selectedId);" in app_js + assert "const deepLinkState = parseAnalyticsDeepLinkState();" in app_js + assert "state.analytics.activeViewId = deepLinkState.activeViewId || '';" in app_js + assert "if (deepLinkState.drilldown) {" in app_js + assert "if (state.drilldown.mode === 'ai') {" in app_js + assert "return loadAiAnalyticsDrilldownPageState(options);" in app_js + assert "renderAiAnalyticsDrilldownActive();" in app_js + assert "function handleAiAnalyticsOutcomeClick" in app_js + assert "function handleAiAnalyticsReasonClick" in app_js + assert "human_touched_rate" in app_js + assert "function renderAnalyticsDrilldown" in app_js + assert "function fetchAnalyticsMetricDrilldown" in app_js + assert "function analyticsMetricCanDrilldown" in app_js + assert "function analyticsMetricCoverageHint" in app_js + assert "drawer.classList.toggle('is-open', isOpen);" in app_js + assert "backdrop.classList.toggle('is-open', isOpen);" in app_js + assert "document.body.classList.toggle('analytics-drilldown-open', isOpen);" in app_js + assert "function rememberAnalyticsDrilldownReturnFocus" in app_js + assert "function restoreAnalyticsDrilldownReturnFocus" in app_js + assert "function focusAnalyticsDrilldownPrimaryControl" in app_js + assert "function handleAnalyticsDrilldownWindowKeydown" in app_js + assert "window.addEventListener('keydown', handleAnalyticsDrilldownWindowKeydown);" in app_js + assert "closeBtn.textContent = 'Закрыть · Esc';" in app_js + assert "function maybeScrollAnalyticsDrilldownDetailIntoView" in app_js + assert "function loadAnalyticsDrilldownPage" in app_js + assert "function loadAnalyticsDrilldownDetails" in app_js + assert "function openAnalyticsDrilldown" in app_js + assert "function handleAnalyticsDrilldownSearchInput" in app_js + assert "function handleAnalyticsDrilldownStatusChange" in app_js + assert "function handleAnalyticsDrilldownSortChange" in app_js + assert "function exportAnalyticsDrilldownCsv" in app_js + assert "function sanitizeAnalyticsTimelineMetadata" in app_js + assert "function handleAnalyticsOverviewClick" in app_js + assert "function handleAnalyticsChannelClick" in app_js + assert "function handleAnalyticsQueueClick" in app_js + assert "function analyticsDrilldownSourceLabel(sourceType, sourceValue = '')" in app_js + assert "if (sourceType === 'agent') {" in app_js + assert 'data-analytics-drilldown-source' in app_js + assert 'data-analytics-drilldown-metric' in app_js + assert 'data-analytics-channel' in app_js + assert 'data-analytics-queue-id' in app_js + assert 'data-analytics-agent-id' in app_js + assert "function saveAnalyticsView" in app_js + assert "function exportAnalyticsCsv" in app_js + assert "window.localStorage.getItem(analyticsViewsStorageKey())" not in app_js + assert "downloadProxyFile(" in app_js + assert "integrations/telegram/threads/" not in app_js + assert "integrations/whatsapp/threads/" not in app_js + assert "api('recording'" not in app_js + assert "ai-summary" not in app_js + assert "recordings/" not in app_js + assert "renderAnalyticsCard('Обращения', 'total', current, previous, {" in app_js + assert "renderAnalyticsCard('Обработано', 'answered', current, previous, {" in app_js + assert "renderAnalyticsCard('SL', 'SL', current, previous, {" in app_js + + +def test_supervisor_ui_restricts_analyst_access_to_analyst_shell(): + app_js = (Path(__file__).resolve().parents[1] / "ui" / "supervisor" / "app.js").read_text(encoding="utf-8") + index_html = (Path(__file__).resolve().parents[1] / "ui" / "supervisor" / "index.html").read_text(encoding="utf-8") + assert "const allowed = ['admin', 'supervisor'];" in app_js + assert "if (role === 'analyst') {" in app_js + assert "return '/analyst';" in app_js + assert 'data-shell="supervisor" data-roles="admin,supervisor"' in index_html + assert 'data-shell="analyst"' in index_html + + +def test_admin_ui_uses_users_and_queues_endpoints(): + app_js = (Path(__file__).resolve().parents[1] / "ui" / "admin" / "app.js").read_text(encoding="utf-8") + index_html = (Path(__file__).resolve().parents[1] / "ui" / "admin" / "index.html").read_text(encoding="utf-8") + assert "api('auth', 'users')" in app_js + assert "api('auth', `users/${encodeURIComponent(userId)}`)" in app_js + assert "function deleteUser()" in app_js + assert "method: 'PATCH'" in app_js + assert "method: 'DELETE'" in app_js + assert "api('routing', 'queues')" in app_js + assert "api('routing', `queues/${encodeURIComponent(queueId)}/rules`" in app_js + assert "function deleteQueue()" in app_js + assert "api('routing', `queues/${encodeURIComponent(queueId)}`" in app_js + assert "api('routing', `queues/${queueId}/route?channel=${channel}&priority=${priority}`" in app_js + assert 'id="createUserBtn"' in index_html + assert 'id="loadUserDetailBtn"' in index_html + assert 'id="updateUserBtn"' in index_html + assert 'id="deleteUserBtn"' in index_html + assert 'id="createQueueBtn"' in index_html + assert 'id="updateQueueBtn"' in index_html + assert 'id="deleteQueueBtn"' in index_html + + +def test_admin_ui_contains_ivr_block_and_endpoints(): + app_js = (Path(__file__).resolve().parents[1] / "ui" / "admin" / "app.js").read_text(encoding="utf-8") + index_html = (Path(__file__).resolve().parents[1] / "ui" / "admin" / "index.html").read_text(encoding="utf-8") + assert "api('ivr', 'ivr/flows')" in app_js + assert "api('ivr', 'ivr/sessions/start'" in app_js + assert "function createIvrFlow()" in app_js + assert "function startIvrSession()" in app_js + assert "function sendIvrDigit()" in app_js + assert "function previewIvrRoute()" in app_js + assert "prompt_audio_key" in app_js + assert "resolved_queue_code" in app_js + assert 'id="ivr"' in index_html + assert 'id="createIvrFlowBtn"' in index_html + assert 'id="startIvrSessionBtn"' in index_html + assert 'id="sendIvrDigitBtn"' in index_html + assert 'id="previewIvrRouteBtn"' in index_html + + +def test_admin_ui_contains_asterisk_bridge_diagnostics(): + app_js = (Path(__file__).resolve().parents[1] / "ui" / "admin" / "app.js").read_text(encoding="utf-8") + index_html = (Path(__file__).resolve().parents[1] / "ui" / "admin" / "index.html").read_text(encoding="utf-8") + assert "api('asterisk-bridge', 'asterisk/status')" in app_js + assert "api('asterisk-bridge', `asterisk/events${suffix}`)" in app_js + assert "api('asterisk-bridge', `asterisk/events/${encodeURIComponent(bridgeEventId)}/retry`" in app_js + assert "function loadAsteriskStatus()" in app_js + assert "function retryAsteriskEvent()" in app_js + assert 'id="asterisk"' in index_html + assert 'id="loadAsteriskStatusBtn"' in index_html + assert 'id="loadAsteriskEventsBtn"' in index_html + assert 'id="retryAsteriskEventBtn"' in index_html + + +def test_login_ui_redirects_by_role(): + app_js = (Path(__file__).resolve().parents[1] / "ui" / "login" / "app.js").read_text(encoding="utf-8") + assert "function destinationForRole(role)" in app_js + assert "if (role === 'analyst') {" in app_js + assert "return '/analyst';" in app_js + assert "window.location.href = destinationForRole(data.role);" in app_js + assert "window.location.href = destinationForRole(state.role);" in app_js + assert "$('sessionRole')" not in app_js + + +def test_shells_update_profile_meta_from_session(): + base = Path(__file__).resolve().parents[1] / "ui" + operator_js = (base / "operator" / "app.js").read_text(encoding="utf-8") + supervisor_js = (base / "supervisor" / "app.js").read_text(encoding="utf-8") + analyst_js = (base / "analyst" / "app.js").read_text(encoding="utf-8") + admin_js = (base / "admin" / "app.js").read_text(encoding="utf-8") + assert "function updateProfileMeta()" in operator_js + assert "full_name: state.fullName" in operator_js + assert "function updateProfileMeta()" in supervisor_js + assert "full_name: state.fullName" in supervisor_js + assert "function updateProfileMeta()" in analyst_js + assert "full_name: state.fullName" in analyst_js + assert "function updateProfileMeta()" in admin_js + assert "full_name: state.fullName" in admin_js diff --git a/tests/test_ivr_service.py b/tests/test_ivr_service.py new file mode 100644 index 0000000..ce92b82 --- /dev/null +++ b/tests/test_ivr_service.py @@ -0,0 +1,510 @@ +from fastapi.testclient import TestClient + +from services.interaction_service.app import app as interaction_app +from services.ivr_service.app import app as ivr_app +from services.shared.security import issue_app_token +from services.voice_adapter_service.app import app as voice_app + + +def _admin_headers() -> dict[str, str]: + return {"X-User": "admin", "X-Role": "admin"} + + +def _supervisor_headers() -> dict[str, str]: + return {"X-User": "supervisor", "X-Role": "supervisor"} + + +def _valid_flow(queue_id: str, resolved_queue_id: str) -> dict: + return { + "name": "Queue IVR", + "description": "DTMF flow", + "queue_id": queue_id, + "entry_node_id": "root", + "flow_json": { + "nodes": [ + { + "node_id": "root", + "prompt_text": "Press 1 for support", + "prompt_audio_key": "ivr/demo-root", + "is_terminal": False, + "invalid_target_node_id": "root", + "options": [ + {"digit": "1", "target_node_id": "support"}, + ], + }, + { + "node_id": "support", + "prompt_text": "Routing to support", + "prompt_audio_key": "ivr/demo-support", + "is_terminal": True, + "outcome_code": "support_route", + "resolved_queue_id": resolved_queue_id, + "resolved_queue_code": "support", + "options": [], + }, + ] + }, + "is_active": True, + } + + +def _trusted_service_headers(subject: str = "svc:asterisk-bridge") -> dict[str, str]: + token = issue_app_token( + subject=subject, + username="asterisk-bridge", + role="admin", + auth_source="service", + provider="asterisk-bridge", + ) + return {"Authorization": f"Bearer {token}"} + + +def test_create_validate_and_activate_flows(): + client = TestClient(ivr_app) + queue_id = "ivr_queue_validation" + + invalid = client.post( + "/ivr/flows", + headers=_admin_headers(), + json={ + "name": "Broken Flow", + "description": "", + "queue_id": queue_id, + "entry_node_id": "root", + "flow_json": {"nodes": [{"node_id": "root", "is_terminal": False, "options": []}]}, + "is_active": True, + }, + ) + assert invalid.status_code == 400 + + first = client.post( + "/ivr/flows", + headers=_admin_headers(), + json=_valid_flow(queue_id, "support_queue_a"), + ) + assert first.status_code == 200 + first_flow_id = first.json()["flow_id"] + assert first.json()["is_active"] is True + + second_payload = _valid_flow(queue_id, "support_queue_b") + second_payload["name"] = "Replacement Flow" + second = client.post("/ivr/flows", headers=_admin_headers(), json=second_payload) + assert second.status_code == 200 + second_flow_id = second.json()["flow_id"] + assert second_flow_id != first_flow_id + + first_detail = client.get(f"/ivr/flows/{first_flow_id}", headers=_supervisor_headers()) + assert first_detail.status_code == 200 + assert first_detail.json()["is_active"] is False + + activated = client.post(f"/ivr/flows/{first_flow_id}/activate", headers=_admin_headers()) + assert activated.status_code == 200 + assert activated.json()["is_active"] is True + + second_detail = client.get(f"/ivr/flows/{second_flow_id}", headers=_supervisor_headers()) + assert second_detail.status_code == 200 + assert second_detail.json()["is_active"] is False + + missing_queue_code = client.post( + "/ivr/flows", + headers=_admin_headers(), + json={ + "name": "Broken Terminal", + "description": "", + "queue_id": queue_id, + "entry_node_id": "root", + "flow_json": { + "nodes": [ + { + "node_id": "root", + "prompt_text": "Press 1", + "is_terminal": False, + "options": [{"digit": "1", "target_node_id": "terminal"}], + }, + { + "node_id": "terminal", + "prompt_text": "Done", + "is_terminal": True, + "outcome_code": "done", + "resolved_queue_id": "support_queue_a", + "options": [], + }, + ] + }, + "is_active": False, + }, + ) + assert missing_queue_code.status_code == 400 + assert "resolved_queue_code" in missing_queue_code.json()["detail"] + + +def test_flow_validation_accepts_prompt_sequence_without_single_prompt_key(): + client = TestClient(ivr_app) + + response = client.post( + "/ivr/flows", + headers=_admin_headers(), + json={ + "name": "Prompt Sequence Flow", + "description": "Mixed language prompt split into segments", + "queue_id": "ivr_prompt_sequence_queue", + "entry_node_id": "root", + "flow_json": { + "nodes": [ + { + "node_id": "root", + "prompt_text": "Combined bilingual prompt", + "prompt_sequence": [ + { + "prompt_audio_key": "ivr/demo-language-kz", + "prompt_text": "Kazakh segment", + "language": "kz", + }, + { + "prompt_audio_key": "ivr/demo-language-ru", + "prompt_text": "Russian segment", + "language": "ru", + }, + ], + "is_terminal": False, + "invalid_target_node_id": "root", + "options": [ + {"digit": "1", "target_node_id": "terminal"}, + ], + }, + { + "node_id": "terminal", + "prompt_text": "Done", + "is_terminal": True, + "outcome_code": "done", + "resolved_queue_id": "support_queue_a", + "resolved_queue_code": "support", + "options": [], + }, + ] + }, + "is_active": False, + }, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload["flow_json"]["nodes"][0]["prompt_sequence"][0]["prompt_audio_key"] == "ivr/demo-language-kz" + assert payload["flow_json"]["nodes"][0]["prompt_sequence"][1]["prompt_audio_key"] == "ivr/demo-language-ru" + + +def test_ivr_session_runtime_emits_voice_event_and_timeline(): + ivr_client = TestClient(ivr_app) + voice_client = TestClient(voice_app) + interaction_client = TestClient(interaction_app) + + interaction = interaction_client.post( + "/interactions", + json={ + "channel": "voice", + "subject": "IVR runtime trace", + "queue_id": "ivr_runtime_queue", + "priority": 3, + }, + headers={"X-User": "operator", "X-Role": "operator"}, + ) + assert interaction.status_code == 200 + interaction_id = interaction.json()["interaction_id"] + + flow = ivr_client.post( + "/ivr/flows", + headers=_admin_headers(), + json=_valid_flow("ivr_runtime_queue", "ivr_resolved_support"), + ) + assert flow.status_code == 200 + flow_id = flow.json()["flow_id"] + + started = ivr_client.post( + "/ivr/sessions/start", + headers=_supervisor_headers(), + json={ + "call_id": "call_ivr_runtime", + "queue_id": "ivr_runtime_queue", + "interaction_id": interaction_id, + }, + ) + assert started.status_code == 200 + assert started.json()["session"]["flow_id"] == flow_id + session_id = started.json()["session"]["session_id"] + + stepped = ivr_client.post( + f"/ivr/sessions/{session_id}/dtmf", + headers=_supervisor_headers(), + json={"digit": "1"}, + ) + assert stepped.status_code == 200 + assert stepped.json()["completed"] is True + assert stepped.json()["session"]["status"] == "completed" + assert stepped.json()["session"]["outcome_code"] == "support_route" + assert stepped.json()["session"]["resolved_queue_code"] == "support" + + repeated = ivr_client.post( + f"/ivr/sessions/{session_id}/dtmf", + headers=_supervisor_headers(), + json={"digit": "1"}, + ) + assert repeated.status_code == 409 + + voice_events = voice_client.get("/integrations/voice/events", headers=_admin_headers()) + assert voice_events.status_code == 200 + assert any( + item["event_type"] == "ivr.completed" + and item["payload"]["session_id"] == session_id + and item["payload"]["flow_id"] == flow_id + for item in voice_events.json() + ) + + timeline = interaction_client.get(f"/interactions/{interaction_id}/timeline") + assert timeline.status_code == 200 + actions = [item["action"] for item in timeline.json()["events"]] + assert "ivr.session.started" in actions + assert "ivr.step.completed" in actions + assert "ivr.completed" in actions + + +def test_no_input_runtime_path_supports_trusted_service_actor(): + ivr_client = TestClient(ivr_app) + interaction_client = TestClient(interaction_app) + + interaction = interaction_client.post( + "/interactions", + json={ + "channel": "voice", + "subject": "IVR no input trace", + "queue_id": "ivr_no_input_queue", + "priority": 3, + }, + headers={"X-User": "operator", "X-Role": "operator"}, + ) + assert interaction.status_code == 200 + interaction_id = interaction.json()["interaction_id"] + + flow = ivr_client.post( + "/ivr/flows", + headers=_admin_headers(), + json={ + "name": "No Input Flow", + "description": "No input route", + "queue_id": "ivr_no_input_queue", + "entry_node_id": "root", + "flow_json": { + "nodes": [ + { + "node_id": "root", + "prompt_text": "Press 1 for support", + "prompt_audio_key": "ivr/no-input-root", + "is_terminal": False, + "invalid_target_node_id": "root", + "no_input_target_node_id": "timeout", + "options": [{"digit": "1", "target_node_id": "support"}], + }, + { + "node_id": "support", + "prompt_text": "Support queue", + "prompt_audio_key": "ivr/no-input-support", + "is_terminal": True, + "outcome_code": "support_route", + "resolved_queue_id": "ivr_support_queue", + "resolved_queue_code": "support", + "options": [], + }, + { + "node_id": "timeout", + "prompt_text": "Routing to default queue", + "prompt_audio_key": "ivr/no-input-timeout", + "is_terminal": True, + "outcome_code": "timeout_route", + "resolved_queue_id": "ivr_timeout_queue", + "resolved_queue_code": "voice_lab", + "options": [], + }, + ] + }, + "is_active": True, + }, + ) + assert flow.status_code == 200 + + started = ivr_client.post( + "/ivr/sessions/start", + headers=_trusted_service_headers(), + json={ + "call_id": "call_ivr_no_input", + "queue_id": "ivr_no_input_queue", + "interaction_id": interaction_id, + }, + ) + assert started.status_code == 200 + session_id = started.json()["session"]["session_id"] + + no_input = ivr_client.post( + f"/ivr/sessions/{session_id}/no-input", + headers=_trusted_service_headers(), + ) + assert no_input.status_code == 200 + assert no_input.json()["completed"] is True + assert no_input.json()["session"]["status"] == "completed" + assert no_input.json()["session"]["resolved_queue_id"] == "ivr_timeout_queue" + assert no_input.json()["session"]["resolved_queue_code"] == "voice_lab" + + blocked = ivr_client.post( + "/ivr/sessions/start", + headers=_trusted_service_headers("svc:not-allowed"), + json={"call_id": "call_blocked", "queue_id": "ivr_no_input_queue"}, + ) + assert blocked.status_code == 403 + + timeline = interaction_client.get(f"/interactions/{interaction_id}/timeline") + assert timeline.status_code == 200 + actions = [item["action"] for item in timeline.json()["events"]] + assert "ivr.step.no_input" in actions + assert "ivr.completed" in actions + + +def test_multistep_language_selection_flow_routes_after_second_digit(): + ivr_client = TestClient(ivr_app) + + flow = ivr_client.post( + "/ivr/flows", + headers=_admin_headers(), + json={ + "name": "Language IVR", + "description": "Greeting and language choice before routing", + "queue_id": "ivr_language_queue", + "entry_node_id": "root", + "flow_json": { + "nodes": [ + { + "node_id": "root", + "prompt_text": "Welcome. Press 1 for Russian. Press 2 for Kazakh.", + "prompt_audio_key": "ivr/language-root", + "is_terminal": False, + "invalid_target_node_id": "root", + "no_input_target_node_id": "root", + "options": [ + {"digit": "1", "target_node_id": "menu_ru"}, + {"digit": "2", "target_node_id": "menu_kz"}, + ], + }, + { + "node_id": "menu_ru", + "prompt_text": "Russian menu", + "prompt_audio_key": "ivr/language-menu-ru", + "is_terminal": False, + "invalid_target_node_id": "menu_ru", + "no_input_target_node_id": "menu_ru", + "options": [ + {"digit": "1", "target_node_id": "sales_ru"}, + {"digit": "2", "target_node_id": "support_ru"}, + ], + }, + { + "node_id": "menu_kz", + "prompt_text": "Kazakh menu", + "prompt_audio_key": "ivr/language-menu-kz", + "is_terminal": False, + "invalid_target_node_id": "menu_kz", + "no_input_target_node_id": "menu_kz", + "options": [ + {"digit": "1", "target_node_id": "sales_kz"}, + {"digit": "2", "target_node_id": "support_kz"}, + ], + }, + { + "node_id": "sales_ru", + "prompt_text": "Routing to sales", + "prompt_audio_key": "ivr/language-sales-ru", + "is_terminal": True, + "outcome_code": "sales_route_ru", + "resolved_queue_id": "ivr_sales_queue", + "resolved_queue_code": "ivr_sales_ai_ru", + "options": [], + }, + { + "node_id": "support_ru", + "prompt_text": "Routing to support", + "prompt_audio_key": "ivr/language-support-ru", + "is_terminal": True, + "outcome_code": "support_route_ru", + "resolved_queue_id": "ivr_support_queue", + "resolved_queue_code": "ivr_support_ai_ru", + "options": [], + }, + { + "node_id": "sales_kz", + "prompt_text": "Routing to sales", + "prompt_audio_key": "ivr/language-sales-kz", + "is_terminal": True, + "outcome_code": "sales_route_kz", + "resolved_queue_id": "ivr_sales_queue", + "resolved_queue_code": "ivr_sales_ai_kz", + "options": [], + }, + { + "node_id": "support_kz", + "prompt_text": "Routing to support", + "prompt_audio_key": "ivr/language-support-kz", + "is_terminal": True, + "outcome_code": "support_route_kz", + "resolved_queue_id": "ivr_support_queue", + "resolved_queue_code": "ivr_support_ai_kz", + "options": [], + }, + ] + }, + "is_active": True, + }, + ) + assert flow.status_code == 200 + + started = ivr_client.post( + "/ivr/sessions/start", + headers=_trusted_service_headers(), + json={ + "call_id": "call_language_ivr", + "queue_id": "ivr_language_queue", + }, + ) + assert started.status_code == 200 + assert started.json()["completed"] is False + assert started.json()["current_node"]["node_id"] == "root" + session_id = started.json()["session"]["session_id"] + + select_language = ivr_client.post( + f"/ivr/sessions/{session_id}/dtmf", + headers=_trusted_service_headers(), + json={"digit": "2"}, + ) + assert select_language.status_code == 200 + assert select_language.json()["completed"] is False + assert select_language.json()["session"]["current_node_id"] == "menu_kz" + assert select_language.json()["session"]["entered_digits"] == ["2"] + + choose_support = ivr_client.post( + f"/ivr/sessions/{session_id}/dtmf", + headers=_trusted_service_headers(), + json={"digit": "2"}, + ) + assert choose_support.status_code == 200 + assert choose_support.json()["completed"] is True + assert choose_support.json()["session"]["status"] == "completed" + assert choose_support.json()["session"]["entered_digits"] == ["2", "2"] + assert choose_support.json()["session"]["resolved_queue_id"] == "ivr_support_queue" + assert choose_support.json()["session"]["resolved_queue_code"] == "ivr_support_ai_kz" + assert choose_support.json()["session"]["outcome_code"] == "support_route_kz" + + +def test_start_without_active_flow_returns_404(): + client = TestClient(ivr_app) + + response = client.post( + "/ivr/sessions/start", + headers=_supervisor_headers(), + json={"call_id": "call_missing_flow", "queue_id": "missing_queue"}, + ) + assert response.status_code == 404 diff --git a/tests/test_kb_search.py b/tests/test_kb_search.py new file mode 100644 index 0000000..eeda4c3 --- /dev/null +++ b/tests/test_kb_search.py @@ -0,0 +1,133 @@ +import json +from types import SimpleNamespace + +import pytest + +from services.shared.kb_search import normalize_kb_text, search_kb_rows + + +def _u(value: str) -> str: + return value.encode("ascii").decode("unicode_escape") + + +def _row(*, row_id: int, title: str, body: str, tags: list[str]): + return SimpleNamespace( + id=row_id, + title=title, + body=body, + tags_json=json.dumps(tags, ensure_ascii=False), + ) + + +def test_normalize_kb_text_collapses_punctuation_and_yo(): + value = _u(r"\u0401\u0436,\u0438\u043a!!! \u0433\u0440\u0430\u0444\u0438\u043a?") + assert normalize_kb_text(value) == _u(r"\u0435\u0436 \u0438\u043a \u0433\u0440\u0430\u0444\u0438\u043a") + + +def test_search_kb_rows_matches_long_phrase_to_graphik_article(): + rows = [ + _row( + row_id=1, + title=_u(r"\u0413\u0440\u0430\u0444\u0438\u043a \u0440\u0430\u0431\u043e\u0442\u044b"), + body=_u(r"\u0423\u043a\u0430\u0436\u0438\u0442\u0435 \u0433\u043e\u0440\u043e\u0434 \u0438\u043b\u0438 \u0444\u0438\u043b\u0438\u0430\u043b."), + tags=[_u(r"\u0440\u0435\u0436\u0438\u043c \u0440\u0430\u0431\u043e\u0442\u044b")], + ), + _row( + row_id=2, + title=_u(r"\u0422\u0430\u0440\u0438\u0444\u044b"), + body=_u(r"\u0423\u0442\u043e\u0447\u043d\u0438\u0442\u0435 \u0443\u0441\u043b\u0443\u0433\u0443 \u0438\u043b\u0438 \u0442\u0430\u0440\u0438\u0444."), + tags=[_u(r"\u0446\u0435\u043d\u0430")], + ), + ] + + results = search_kb_rows( + rows, + _u(r"\u0445\u043e\u0447\u0443 \u0443\u0437\u043d\u0430\u0442\u044c \u0433\u0440\u0430\u0444\u0438\u043a \u0440\u0430\u0431\u043e\u0442\u044b"), + limit=5, + ) + + assert results[0].title == rows[0].title + + +@pytest.mark.parametrize( + ("query", "expected_title"), + [ + ( + _u(r"\u0430\u0434\u0440\u0435\u0441\u0430"), + _u(r"\u0410\u0434\u0440\u0435\u0441 \u0438 \u0444\u0438\u043b\u0438\u0430\u043b"), + ), + ( + _u(r"\u043e\u043f\u0435\u0440\u0430\u0442\u043e\u0440\u0443"), + _u(r"\u0421\u043e\u0435\u0434\u0438\u043d\u0435\u043d\u0438\u0435 \u0441 \u043e\u043f\u0435\u0440\u0430\u0442\u043e\u0440\u043e\u043c"), + ), + ( + _u(r"\u0437\u0430\u044f\u0432\u043a\u0438"), + _u(r"\u0421\u0442\u0430\u0442\u0443\u0441 \u0437\u0430\u044f\u0432\u043a\u0438"), + ), + ], +) +def test_search_kb_rows_matches_close_word_forms(query: str, expected_title: str): + rows = [ + _row( + row_id=1, + title=_u(r"\u0410\u0434\u0440\u0435\u0441 \u0438 \u0444\u0438\u043b\u0438\u0430\u043b"), + body=_u(r"\u041d\u0430\u0437\u043e\u0432\u0438\u0442\u0435 \u0433\u043e\u0440\u043e\u0434."), + tags=[_u(r"\u0430\u0434\u0440\u0435\u0441")], + ), + _row( + row_id=2, + title=_u(r"\u0421\u043e\u0435\u0434\u0438\u043d\u0435\u043d\u0438\u0435 \u0441 \u043e\u043f\u0435\u0440\u0430\u0442\u043e\u0440\u043e\u043c"), + body=_u(r"\u0421\u043a\u0430\u0436\u0438\u0442\u0435, \u0435\u0441\u043b\u0438 \u043d\u0443\u0436\u0435\u043d \u0447\u0435\u043b\u043e\u0432\u0435\u043a."), + tags=[_u(r"\u043e\u043f\u0435\u0440\u0430\u0442\u043e\u0440")], + ), + _row( + row_id=3, + title=_u(r"\u0421\u0442\u0430\u0442\u0443\u0441 \u0437\u0430\u044f\u0432\u043a\u0438"), + body=_u(r"\u041d\u0443\u0436\u0435\u043d \u043d\u043e\u043c\u0435\u0440 \u0437\u0430\u044f\u0432\u043a\u0438."), + tags=[_u(r"\u0437\u0430\u044f\u0432\u043a\u0430")], + ), + ] + + results = search_kb_rows(rows, query, limit=5) + + assert results[0].title == expected_title + + +def test_search_kb_rows_prefers_title_and_tags_over_body_only_mentions(): + rows = [ + _row( + row_id=1, + title=_u(r"\u0421\u043e\u0435\u0434\u0438\u043d\u0435\u043d\u0438\u0435 \u0441 \u043e\u043f\u0435\u0440\u0430\u0442\u043e\u0440\u043e\u043c"), + body=_u(r"\u0415\u0441\u043b\u0438 \u043d\u0443\u0436\u0435\u043d \u043e\u043f\u0435\u0440\u0430\u0442\u043e\u0440, \u0441\u043a\u0430\u0436\u0438\u0442\u0435 \u043e\u0431 \u044d\u0442\u043e\u043c."), + tags=[_u(r"\u043e\u043f\u0435\u0440\u0430\u0442\u043e\u0440")], + ), + _row( + row_id=2, + title=_u(r"\u041f\u0440\u043e\u0431\u043b\u0435\u043c\u0430 \u0441 \u0443\u0441\u043b\u0443\u0433\u043e\u0439"), + body=_u(r"\u0412 \u0441\u043b\u043e\u0436\u043d\u043e\u043c \u0441\u043b\u0443\u0447\u0430\u0435 \u0437\u0432\u043e\u043d\u043e\u043a \u043c\u043e\u0436\u0435\u0442 \u0431\u044b\u0442\u044c \u043f\u0435\u0440\u0435\u0434\u0430\u043d \u043e\u043f\u0435\u0440\u0430\u0442\u043e\u0440\u0443."), + tags=[_u(r"\u043e\u0448\u0438\u0431\u043a\u0430")], + ), + ] + + results = search_kb_rows(rows, _u(r"\u043e\u043f\u0435\u0440\u0430\u0442\u043e\u0440"), limit=5) + + assert results[0].title == rows[0].title + + +def test_search_kb_rows_breaks_ties_by_newer_id(): + older = _row( + row_id=10, + title="KB Freshness", + body="Freshness article body", + tags=["freshkb"], + ) + newer = _row( + row_id=11, + title="KB Freshness", + body="Freshness article body", + tags=["freshkb"], + ) + + results = search_kb_rows([older, newer], "freshkb", limit=5) + + assert [item.id for item in results[:2]] == [11, 10] diff --git a/tests/test_manual_uat_scripts.py b/tests/test_manual_uat_scripts.py new file mode 100644 index 0000000..483e82d --- /dev/null +++ b/tests/test_manual_uat_scripts.py @@ -0,0 +1,103 @@ +from pathlib import Path + +from scripts.finalize_mvp_pilot import apply_gate4_closure, parse_open_defect_counts +from scripts.uat_manual_prepare import render_session_protocol + + +def test_render_session_protocol_prefills_safe_fields(): + template = "\n".join( + [ + "# UAT Session Protocol", + "", + "- Session ID:", + "- Date:", + "- Environment URL:", + "- Build/version:", + "- Deployment date:", + "- Cluster/namespace:", + "", + "- Operators:", + "- Supervisor:", + "- Analyst:", + "- Admin:", + "- Business owner:", + "- IT owner:", + "", + "- [ ] Preflight report attached (`docs/uat/evidence/preflight_*.md`)", + "- [ ] Defect log prepared from `docs/uat/defect-log-template.csv`", + ] + ) + result = render_session_protocol( + template, + session_id="UAT-MANUAL-001", + environment_url="http://pilot:8080", + build_version="v1.0.0-mvp", + deployment_date="2026-02-27", + cluster_namespace="pilot/ns", + participants={ + "operators": "Ops Team", + "supervisor": "Sup User", + "analyst": "Analyst User", + "admin": "Admin User", + "business_owner": "Biz Owner", + "it_owner": "IT Owner", + }, + preflight_name="preflight_latest.md", + defect_log_name="defect-log.csv", + ) + + assert "- Session ID: UAT-MANUAL-001" in result + assert "- Environment URL: http://pilot:8080" in result + assert "- Build/version: v1.0.0-mvp" in result + assert "- Business owner: Biz Owner" in result + assert "- [x] Preflight report attached (`attachments/preflight_latest.md`)" in result + assert "- [x] Defect log prepared from `defect-log.csv`" in result + + +def test_parse_open_defect_counts_counts_only_non_closed(tmp_path: Path): + defect_log = tmp_path / "defect-log.csv" + defect_log.write_text( + "\n".join( + [ + "defect_id,severity,status,scenario,summary", + "UAT-001,P1,Open,S1,broken login", + "UAT-002,P1,Closed,S1,closed login", + "UAT-003,P2,Resolved,S2,resolved issue", + "UAT-004,P2,Open,S2,open issue", + "UAT-005,P3,Deferred,S3,deferred issue", + "UAT-006,P4,Open,S4,cosmetic issue", + ] + ), + encoding="utf-8", + ) + + counts = parse_open_defect_counts(defect_log) + + assert counts == {"P1": 1, "P2": 1, "P3": 1, "P4": 1} + + +def test_apply_gate4_closure_marks_manual_items(): + source = "\n".join( + [ + "# Gate 4 Checklist (Pilot Hardening)", + "", + "- [ ] UAT signed with real operators/supervisors", + "- [ ] P1/P2 defects closed", + "", + "Notes:", + "- placeholder", + ] + ) + + updated = apply_gate4_closure( + source, + session_id="UAT-MANUAL-001", + session_dir=Path("docs/uat/evidence/manual_UAT-MANUAL-001"), + acceptance="accepted_for_pilot_completion", + open_counts={"P1": 0, "P2": 0, "P3": 2, "P4": 1}, + ) + + assert "- [x] UAT signed with real operators/supervisors" in updated + assert "- [x] P1/P2 defects closed" in updated + assert "Manual closure update:" in updated + assert "P1=0, P2=0, P3=2, P4=1" in updated diff --git a/tests/test_oidc_smoke.py b/tests/test_oidc_smoke.py new file mode 100644 index 0000000..664e2f6 --- /dev/null +++ b/tests/test_oidc_smoke.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +import pytest + +from scripts.oidc_smoke import check_oidc + + +class _FakeResponse: + def __init__(self, payload: dict, status_code: int = 200): + self._payload = payload + self.status_code = status_code + + def json(self) -> dict: + return self._payload + + def raise_for_status(self) -> None: + if self.status_code >= 400: + raise RuntimeError(f"HTTP {self.status_code}") + + +class _FakeClient: + def __init__(self, responses: list[_FakeResponse]): + self.responses = responses + self.calls: list[str] = [] + + def get(self, url: str) -> _FakeResponse: + self.calls.append(url) + if not self.responses: + raise AssertionError("No fake responses left") + return self.responses.pop(0) + + +def test_oidc_smoke_skips_health_when_disabled() -> None: + client = _FakeClient([_FakeResponse({"enabled": False, "provider_label": "Keycloak", "login_path": "/auth/oidc/start"})]) + + summary = check_oidc("http://localhost:8080/", client=client) + + assert summary["config"]["enabled"] is False + assert summary["health"] is None + assert client.calls == ["http://localhost:8080/proxy/auth/auth/oidc/config"] + + +def test_oidc_smoke_can_require_enabled() -> None: + client = _FakeClient([_FakeResponse({"enabled": False, "provider_label": "Keycloak", "login_path": "/auth/oidc/start"})]) + + with pytest.raises(RuntimeError): + check_oidc("http://localhost:8080", client=client, require_enabled=True) + + +def test_oidc_smoke_checks_health_when_enabled() -> None: + client = _FakeClient( + [ + _FakeResponse({"enabled": True, "provider_label": "Keycloak", "login_path": "/auth/oidc/start"}), + _FakeResponse({"status": "ok", "provider": "keycloak", "jwks_loaded": True}), + ] + ) + + summary = check_oidc("http://localhost:8080", client=client) + + assert summary["config"]["enabled"] is True + assert summary["health"]["status"] == "ok" + assert client.calls == [ + "http://localhost:8080/proxy/auth/auth/oidc/config", + "http://localhost:8080/proxy/auth/auth/oidc/health", + ] diff --git a/tests/test_operator_core.py b/tests/test_operator_core.py new file mode 100644 index 0000000..5fbb595 --- /dev/null +++ b/tests/test_operator_core.py @@ -0,0 +1,322 @@ +from fastapi.testclient import TestClient + +from services.customer_service.app import app as customer_app +from services.interaction_service.app import app as interaction_app +from services.ivr_service.app import app as ivr_app +from services.routing_service.app import app as routing_app +from services.shared.db import get_session +from services.shared.sql_models import Queue + + +def test_customer_and_interaction_lifecycle(): + customer_client = TestClient(customer_app) + interaction_client = TestClient(interaction_app) + + customer = customer_client.post( + "/customers", + json={"display_name": "Test Client", "phones": ["+77010000001"], "preferred_phone": "+77010000001"}, + ) + assert customer.status_code == 200 + customer_id = customer.json()["customer_id"] + + interaction = interaction_client.post( + "/interactions", + json={ + "channel": "voice", + "subject": "Need support", + "customer_id": customer_id, + "queue_id": "q_main", + "priority": 2, + }, + headers={"X-User": "operator", "X-Role": "operator"}, + ) + assert interaction.status_code == 200 + interaction_id = interaction.json()["interaction_id"] + + assigned = interaction_client.patch( + f"/interactions/{interaction_id}/assign", + json={"assignee": "operator_a"}, + headers={"X-User": "supervisor", "X-Role": "supervisor"}, + ) + assert assigned.status_code == 200 + assert assigned.json()["status"] == "in_progress" + + escalated = interaction_client.post( + f"/interactions/{interaction_id}/escalate", + json={"target_queue_id": "line2"}, + headers={"X-User": "operator", "X-Role": "operator"}, + ) + assert escalated.status_code == 200 + assert escalated.json()["status"] == "escalated" + + closed = interaction_client.patch( + f"/interactions/{interaction_id}/status", + json={"status": "closed"}, + headers={"X-User": "operator", "X-Role": "operator"}, + ) + assert closed.status_code == 200 + assert closed.json()["status"] == "closed" + + timeline = interaction_client.get(f"/interactions/{interaction_id}/timeline") + assert timeline.status_code == 200 + assert len(timeline.json()["events"]) >= 4 + + +def test_queue_rules_and_route(): + routing_client = TestClient(routing_app) + + created = routing_client.post( + "/queues", + json={ + "name": "General Queue", + "description": "Main line", + "rules": [ + { + "channel": "voice", + "priority": 2, + "strategy": "round_robin", + "sla_seconds": 45, + } + ], + }, + headers={"X-User": "admin", "X-Role": "admin"}, + ) + assert created.status_code == 200 + queue_id = created.json()["queue_id"] + + route = routing_client.post( + f"/queues/{queue_id}/route?channel=voice&priority=2", + headers={"X-User": "supervisor", "X-Role": "supervisor"}, + ) + assert route.status_code == 200 + assert route.json()["sla_seconds"] == 45 + + +def test_list_queues_tolerates_legacy_rules_json_object(): + routing_client = TestClient(routing_app) + session = get_session() + try: + session.add( + Queue( + queue_id="que_legacy_rules", + name="Legacy Queue", + description="Created before rules_json normalization", + rules_json="{}", + created_at="2026-03-15T00:00:00+00:00", + ) + ) + session.commit() + finally: + session.close() + + listed = routing_client.get( + "/queues", + headers={"X-User": "admin", "X-Role": "admin"}, + ) + assert listed.status_code == 200 + payload = listed.json() + queue = next(item for item in payload if item["queue_id"] == "que_legacy_rules") + assert queue["rules"] == [] + + +def test_queue_route_requires_privileged_role(): + routing_client = TestClient(routing_app) + + created = routing_client.post( + "/queues", + json={ + "name": "Guarded Queue", + "description": "RBAC", + "rules": [ + { + "channel": "voice", + "priority": 3, + "strategy": "round_robin", + "sla_seconds": 30, + } + ], + }, + headers={"X-User": "admin", "X-Role": "admin"}, + ) + assert created.status_code == 200 + + denied = routing_client.post( + f"/queues/{created.json()['queue_id']}/route?channel=voice&priority=3", + headers={"X-User": "operator", "X-Role": "operator"}, + ) + assert denied.status_code == 403 + + +def test_admin_can_delete_queue(): + routing_client = TestClient(routing_app) + + created = routing_client.post( + "/queues", + json={ + "name": "Disposable Queue", + "description": "To delete", + "rules": [ + { + "channel": "voice", + "priority": 3, + "strategy": "round_robin", + "sla_seconds": 30, + } + ], + }, + headers={"X-User": "admin", "X-Role": "admin"}, + ) + assert created.status_code == 200 + queue_id = created.json()["queue_id"] + + deleted = routing_client.delete( + f"/queues/{queue_id}", + headers={"X-User": "admin", "X-Role": "admin"}, + ) + assert deleted.status_code == 200 + assert deleted.json()["deleted"] is True + + route = routing_client.post( + f"/queues/{queue_id}/route?channel=voice&priority=3", + headers={"X-User": "admin", "X-Role": "admin"}, + ) + assert route.status_code == 404 + + +def test_supervisor_cannot_delete_queue(): + routing_client = TestClient(routing_app) + + created = routing_client.post( + "/queues", + json={ + "name": "Protected Queue", + "description": "RBAC delete", + "rules": [ + { + "channel": "voice", + "priority": 3, + "strategy": "round_robin", + "sla_seconds": 30, + } + ], + }, + headers={"X-User": "admin", "X-Role": "admin"}, + ) + assert created.status_code == 200 + + denied = routing_client.delete( + f"/queues/{created.json()['queue_id']}", + headers={"X-User": "supervisor", "X-Role": "supervisor"}, + ) + assert denied.status_code == 403 + + +def test_route_supports_completed_ivr_session_and_rejects_incomplete_session(): + routing_client = TestClient(routing_app) + ivr_client = TestClient(ivr_app) + + origin_queue = routing_client.post( + "/queues", + json={ + "name": "IVR Origin Queue", + "description": "voice origin", + "rules": [ + { + "channel": "voice", + "priority": 3, + "strategy": "round_robin", + "sla_seconds": 30, + } + ], + }, + headers={"X-User": "admin", "X-Role": "admin"}, + ) + assert origin_queue.status_code == 200 + origin_queue_id = origin_queue.json()["queue_id"] + + resolved_queue = routing_client.post( + "/queues", + json={ + "name": "IVR Resolved Queue", + "description": "voice support", + "rules": [ + { + "channel": "voice", + "priority": 3, + "strategy": "round_robin", + "sla_seconds": 55, + } + ], + }, + headers={"X-User": "admin", "X-Role": "admin"}, + ) + assert resolved_queue.status_code == 200 + resolved_queue_id = resolved_queue.json()["queue_id"] + + flow = ivr_client.post( + "/ivr/flows", + json={ + "name": "Route IVR", + "description": "route override", + "queue_id": origin_queue_id, + "entry_node_id": "root", + "flow_json": { + "nodes": [ + { + "node_id": "root", + "prompt_text": "Press 2 for support", + "is_terminal": False, + "invalid_target_node_id": "root", + "options": [{"digit": "2", "target_node_id": "support"}], + }, + { + "node_id": "support", + "prompt_text": "Support queue", + "is_terminal": True, + "outcome_code": "support_route", + "resolved_queue_id": resolved_queue_id, + "resolved_queue_code": "support", + "options": [], + }, + ] + }, + "is_active": True, + }, + headers={"X-User": "admin", "X-Role": "admin"}, + ) + assert flow.status_code == 200 + + active_session = ivr_client.post( + "/ivr/sessions/start", + json={"call_id": "call_route_ivr", "queue_id": origin_queue_id}, + headers={"X-User": "supervisor", "X-Role": "supervisor"}, + ) + assert active_session.status_code == 200 + session_id = active_session.json()["session"]["session_id"] + + incomplete = routing_client.post( + f"/queues/{origin_queue_id}/route?channel=voice&priority=3&ivr_session_id={session_id}", + headers={"X-User": "supervisor", "X-Role": "supervisor"}, + ) + assert incomplete.status_code == 400 + + stepped = ivr_client.post( + f"/ivr/sessions/{session_id}/dtmf", + json={"digit": "2"}, + headers={"X-User": "supervisor", "X-Role": "supervisor"}, + ) + assert stepped.status_code == 200 + assert stepped.json()["session"]["status"] == "completed" + + routed = routing_client.post( + f"/queues/{origin_queue_id}/route?channel=voice&priority=3&ivr_session_id={session_id}", + headers={"X-User": "supervisor", "X-Role": "supervisor"}, + ) + assert routed.status_code == 200 + payload = routed.json() + assert payload["queue_id"] == resolved_queue_id + assert payload["original_queue_id"] == origin_queue_id + assert payload["resolved_queue_id"] == resolved_queue_id + assert payload["ivr_session_id"] == session_id + assert payload["ivr_outcome_code"] == "support_route" + assert payload["sla_seconds"] == 55 diff --git a/tests/test_postgres_dev_preflight.py b/tests/test_postgres_dev_preflight.py new file mode 100644 index 0000000..7e9676c --- /dev/null +++ b/tests/test_postgres_dev_preflight.py @@ -0,0 +1,159 @@ +from __future__ import annotations + +import importlib + + +preflight = importlib.import_module("scripts.postgres_dev_preflight") + + +def test_run_preflight_rejects_non_postgres_database_url(monkeypatch): + monkeypatch.setenv("SCHEMA_MANAGEMENT_MODE", "migrations") + + results = preflight.run_preflight( + database_url="sqlite:///./mvp_cc.db", + base_url=None, + require_rabbitmq=False, + ) + + assert len(results) == 1 + assert results[0].name == "database_url" + assert results[0].ok is False + + +def test_run_preflight_requires_migrations_mode(monkeypatch): + monkeypatch.setenv("SCHEMA_MANAGEMENT_MODE", "legacy") + + results = preflight.run_preflight( + database_url="postgresql://mvp:mvp@localhost:5432/mvpcc", + base_url=None, + require_rabbitmq=False, + ) + + assert [item.name for item in results] == ["database_url", "schema_management_mode"] + assert results[-1].ok is False + + +def test_run_preflight_passes_with_http_and_rabbitmq(monkeypatch): + monkeypatch.setenv("SCHEMA_MANAGEMENT_MODE", "migrations") + monkeypatch.setenv("EVENT_BUS_ENABLED", "1") + monkeypatch.setenv("EVENT_BUS_URL", "amqp://guest:guest@localhost:5672/") + + monkeypatch.setattr( + preflight, + "_check_tcp_endpoint", + lambda name, url, default_port: preflight.CheckResult(name, True, f"{default_port} ok"), + ) + monkeypatch.setattr( + preflight, + "_check_database_connection", + lambda database_url: preflight.CheckResult("database_connection", True, "SELECT 1 succeeded"), + ) + monkeypatch.setattr( + preflight, + "_check_schema_migrations", + lambda database_url: preflight.CheckResult("schema_migrations", True, "applied=18/18"), + ) + monkeypatch.setattr( + preflight, + "_http_preflight_checks", + lambda base_url: [ + preflight.CheckResult("gateway_health", True, "status=200"), + preflight.CheckResult("auth_health", True, "status=200"), + ], + ) + + results = preflight.run_preflight( + database_url="postgresql://mvp:mvp@localhost:5432/mvpcc", + base_url="http://127.0.0.1:8080", + require_rabbitmq=False, + ) + + assert all(item.ok for item in results) + assert any(item.name == "rabbitmq_tcp" for item in results) + assert any(item.name == "gateway_health" for item in results) + + +def test_main_returns_nonzero_when_preflight_fails(monkeypatch, capsys): + monkeypatch.setattr( + preflight, + "run_preflight", + lambda **kwargs: [preflight.CheckResult("database_connection", False, "boom")], + ) + monkeypatch.setattr( + preflight.argparse.ArgumentParser, + "parse_args", + lambda self: type( + "Args", + (), + { + "env_file": [], + "database_url": "postgresql://mvp:mvp@localhost:5432/mvpcc", + "base_url": "", + "require_rabbitmq": False, + "json": False, + }, + )(), + ) + + exit_code = preflight.main() + output = capsys.readouterr().out + + assert exit_code == 1 + assert "[FAIL] PostgreSQL dev preflight failed" in output + + +def test_http_preflight_uses_admin_login_token_for_read_checks(monkeypatch): + monkeypatch.setenv("ALLOW_LEGACY_HEADER_AUTH", "0") + monkeypatch.setenv("EVENT_BUS_ENABLED", "0") + monkeypatch.setenv("ASTERISK_BRIDGE_ENABLED", "0") + monkeypatch.setattr(preflight, "issue_app_token", lambda **kwargs: "ops-token") + + recorded_headers = {} + + class FakeResponse: + def __init__(self, status_code, payload): + self.status_code = status_code + self._payload = payload + + def json(self): + return self._payload + + class FakeClient: + def __init__(self, *args, **kwargs): + pass + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def get(self, path, headers=None): + if path == "/health": + return FakeResponse(200, {"status": "ok"}) + if path == "/registry": + return FakeResponse(200, {"services": {name: {} for name in preflight.REQUIRED_PROXY_HEALTH_SERVICES}}) + if path.startswith("/proxy/") and path.endswith("/health"): + return FakeResponse(200, {"status": "ok"}) + if path in { + "/proxy/routing/queues", + "/proxy/voice/integrations/voice/events?limit=1", + "/proxy/whatsapp/integrations/whatsapp/threads", + }: + recorded_headers[path] = dict(headers or {}) + return FakeResponse(200, []) + raise AssertionError(f"Unexpected GET path: {path}") + + def post(self, path, json=None): + if path == "/proxy/auth/auth/login": + return FakeResponse(200, {"access_token": "login-token"}) + raise AssertionError(f"Unexpected POST path: {path}") + + monkeypatch.setattr(preflight.httpx, "Client", FakeClient) + + results = preflight._http_preflight_checks("http://127.0.0.1:18080") + + assert all(item.ok for item in results) + assert recorded_headers["/proxy/routing/queues"] == {"Authorization": "Bearer login-token"} + assert recorded_headers["/proxy/voice/integrations/voice/events?limit=1"] == {"Authorization": "Bearer login-token"} + assert recorded_headers["/proxy/whatsapp/integrations/whatsapp/threads"] == {"Authorization": "Bearer login-token"} diff --git a/tests/test_postgres_readiness.py b/tests/test_postgres_readiness.py new file mode 100644 index 0000000..90027d9 --- /dev/null +++ b/tests/test_postgres_readiness.py @@ -0,0 +1,162 @@ +import importlib +import os +import time + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import select + +from services.shared.db import get_session +from services.shared.sql_models import Interaction, VoiceAISessionRow, VoiceEventRow, WhatsAppMessageRow, WhatsAppThreadRow + + +pytestmark = pytest.mark.skipif( + not os.getenv("DATABASE_URL", "").startswith("postgresql"), + reason="PostgreSQL readiness smoke requires PostgreSQL", +) + + +def _admin_headers() -> dict[str, str]: + return {"X-User": "admin", "X-Role": "admin"} + + +def test_postgres_readiness_smoke(monkeypatch): + interaction_module = importlib.import_module("services.interaction_service.app") + whatsapp_module = importlib.import_module("services.whatsapp_adapter_service.app") + voice_module = importlib.import_module("services.voice_adapter_service.app") + runtime_module = importlib.import_module("services.ai_voice_runtime_service.app") + + monkeypatch.setattr( + runtime_module, + "_orchestrator_request", + lambda method, path, *, payload=None, timeout=10.0: { + "session_id": "ais_pg_smoke", + "language": "ru", + "greeting_text": "Здравствуйте, это AI-оператор.", + "disclosure_required": True, + }, + ) + + interaction_client = TestClient(interaction_module.app) + whatsapp_client = TestClient(whatsapp_module.app) + voice_client = TestClient(voice_module.app) + runtime_client = TestClient(runtime_module.app) + + assert interaction_client.get("/health").status_code == 200 + assert whatsapp_client.get("/health").status_code == 200 + assert voice_client.get("/health").status_code == 200 + assert runtime_client.get("/health").status_code == 200 + + created_interaction = interaction_client.post( + "/interactions", + json={ + "channel": "voice", + "subject": "Postgres smoke interaction", + "customer_id": None, + "queue_id": "q_pg_smoke", + "priority": 2, + }, + headers=_admin_headers(), + ) + assert created_interaction.status_code == 200 + interaction_id = created_interaction.json()["interaction_id"] + + fetched_interaction = interaction_client.get(f"/interactions/{interaction_id}") + assert fetched_interaction.status_code == 200 + assert fetched_interaction.json()["interaction_id"] == interaction_id + + created_whatsapp = whatsapp_client.post( + "/integrations/whatsapp/webhook", + json={ + "chat_id": "pg_smoke_chat", + "text": "Привет из PostgreSQL smoke", + "external_message_id": "pg_smoke_ext_1", + "whatsapp_user_id": "wa_pg_smoke_user", + "phone_number": "+77015550077", + "display_name": "PG Smoke", + "payload": {"provider": "manual"}, + }, + ) + assert created_whatsapp.status_code == 200 + thread_id = created_whatsapp.json()["thread_id"] + + listed_threads = whatsapp_client.get("/integrations/whatsapp/threads", headers=_admin_headers()) + assert listed_threads.status_code == 200 + assert any(item["thread_id"] == thread_id for item in listed_threads.json()) + + created_voice_event = voice_client.post( + "/integrations/voice/events", + headers=_admin_headers(), + json={ + "event_type": "call.started", + "call_id": "pg_smoke_call", + "interaction_id": interaction_id, + "source_event_id": "pg_smoke_call_started", + "payload": {"source": "postgres-smoke"}, + }, + ) + assert created_voice_event.status_code == 200 + + listed_voice_events = voice_client.get("/integrations/voice/events", headers=_admin_headers()) + assert listed_voice_events.status_code == 200 + assert any(item["source_event_id"] == "pg_smoke_call_started" for item in listed_voice_events.json()) + + created_voice_session = runtime_client.post( + "/internal/voice-ai/sessions", + headers=_admin_headers(), + json={ + "call_id": "pg_smoke_voice_call", + "linked_id": "pg_smoke_linked", + "interaction_id": interaction_id, + "queue_id": "q_pg_smoke", + "caller_number": "+77010000077", + "caller_name": "PG Smoke Caller", + "agent_profile": "voice_support", + "language_hint": "ru", + "handoff_queue_id": "q_pg_smoke", + "metadata": {"queue_code": "pg_smoke"}, + }, + ) + assert created_voice_session.status_code == 200 + voice_session_id = created_voice_session.json()["voice_session_id"] + + deadline = time.time() + 2.0 + while time.time() < deadline: + session = get_session() + try: + voice_session = session.execute( + select(VoiceAISessionRow).where(VoiceAISessionRow.session_id == voice_session_id) + ).scalar_one_or_none() + if voice_session and voice_session.ai_session_id == "ais_pg_smoke": + break + finally: + session.close() + time.sleep(0.05) + else: + raise AssertionError("voice AI session did not finish async startup") + + session = get_session() + try: + stored_interaction = session.execute( + select(Interaction).where(Interaction.interaction_id == interaction_id) + ).scalar_one_or_none() + stored_thread = session.execute( + select(WhatsAppThreadRow).where(WhatsAppThreadRow.thread_id == thread_id) + ).scalar_one_or_none() + stored_message = session.execute( + select(WhatsAppMessageRow).where(WhatsAppMessageRow.thread_id == thread_id) + ).scalar_one_or_none() + stored_event = session.execute( + select(VoiceEventRow).where(VoiceEventRow.source_event_id == "pg_smoke_call_started") + ).scalar_one_or_none() + stored_voice_session = session.execute( + select(VoiceAISessionRow).where(VoiceAISessionRow.session_id == voice_session_id) + ).scalar_one_or_none() + assert stored_interaction is not None + assert stored_thread is not None + assert stored_message is not None + assert stored_event is not None + assert stored_voice_session is not None + assert stored_voice_session.ai_session_id == "ais_pg_smoke" + finally: + session.close() diff --git a/tests/test_postgres_server_assets.py b/tests/test_postgres_server_assets.py new file mode 100644 index 0000000..a9dfcd9 --- /dev/null +++ b/tests/test_postgres_server_assets.py @@ -0,0 +1,28 @@ +from pathlib import Path + + +def test_shared_host_postgres_assets_are_present_and_isolated(): + root = Path(__file__).resolve().parents[1] + compose_yaml = (root / "deployment" / "docker-compose.postgres.server.yml").read_text(encoding="utf-8") + env_template = (root / ".env.postgres.server.template").read_text(encoding="utf-8") + runbook = (root / "docs" / "runbooks" / "postgres-server-docker.md").read_text(encoding="utf-8") + gitignore = (root / ".gitignore").read_text(encoding="utf-8") + + assert "name: call-center-postgres" in compose_yaml + assert "image: postgres:16-alpine" in compose_yaml + assert "../.env.postgres.server" in compose_yaml + assert "127.0.0.1:5434:5432" in compose_yaml + assert "../.data_pg:/var/lib/postgresql/data" in compose_yaml + assert 'pg_isready -U "$$POSTGRES_USER" -d "$$POSTGRES_DB"' in compose_yaml + + assert "POSTGRES_DB=mvpcc" in env_template + assert "POSTGRES_USER=mvpcc_app" in env_template + assert "POSTGRES_PASSWORD=" in env_template + + assert "deployment/docker-compose.postgres.server.yml" in runbook + assert "127.0.0.1:5434" in runbook + assert "call-center-postgres:5432" in runbook + assert "do not edit `deployment/docker-compose.server.yml`" in runbook + + assert ".data_pg/" in gitignore + assert ".env.postgres.server" in gitignore diff --git a/tests/test_postgres_server_parallel_assets.py b/tests/test_postgres_server_parallel_assets.py new file mode 100644 index 0000000..a65f5f1 --- /dev/null +++ b/tests/test_postgres_server_parallel_assets.py @@ -0,0 +1,36 @@ +from pathlib import Path + + +def test_parallel_postgres_stack_assets_are_present_and_conflict_free(): + root = Path(__file__).resolve().parents[1] + compose_yaml = (root / "deployment" / "docker-compose.parallel.server.yml").read_text(encoding="utf-8") + env_template = (root / ".env.postgres.parallel.server.template").read_text(encoding="utf-8") + runbook = (root / "docs" / "runbooks" / "postgres-server-parallel-stack.md").read_text(encoding="utf-8") + gitignore = (root / ".gitignore").read_text(encoding="utf-8") + + assert "name: call-center-pg" in compose_yaml + assert "image: call-center-app:pg-parallel" in compose_yaml + assert "../.env.production" in compose_yaml + assert "../.env.postgres.parallel.server" in compose_yaml + assert "../.data_pg_parallel:/app/.data_pg_parallel" in compose_yaml + assert "SCHEMA_MANAGEMENT_MODE: migrations" in compose_yaml + assert "18080:8000" in compose_yaml + assert "9019:9019" not in compose_yaml + assert "4573:4573" not in compose_yaml + + assert "CC_DATA_DIR=.data_pg_parallel" in env_template + assert "@call-center-postgres:5432/mvpcc" in env_template + assert "LOCAL_STACK_GATEWAY_PORT=18080" in env_template + assert "TELEGRAM_BOT_ENABLED=0" in env_template + assert "ASTERISK_BRIDGE_ENABLED=0" in env_template + assert "AI_VOICE_ENABLED=0" in env_template + + assert "deployment/docker-compose.parallel.server.yml" in runbook + assert "call-center-app:pg-parallel" in runbook + assert "python scripts/migrate_core_db.py" in runbook + assert "http://:18080" in runbook + assert "call-center-postgres_default" in runbook + assert "do not stop or restart the existing `call-center` compose project" in runbook + + assert ".data_pg_parallel/" in gitignore + assert ".env.postgres.parallel.server" in gitignore diff --git a/tests/test_recording_service.py b/tests/test_recording_service.py new file mode 100644 index 0000000..99f0055 --- /dev/null +++ b/tests/test_recording_service.py @@ -0,0 +1,400 @@ +from pathlib import Path + +from fastapi.testclient import TestClient +from sqlalchemy import select +from sqlalchemy.exc import IntegrityError + +import services.recording_service.app as recording_module +from services.recording_service.app import app as recording_app +from services.shared.db import get_session +from services.shared.security import issue_app_token +from services.shared.sql_models import CallRecordingRow +from services.voice_adapter_service.app import app as voice_app + + +def _write_audio_fixture(path: Path, size: int = 32) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(b"RIFF" + b"\x00" * size) + return path + + +def _supervisor_headers() -> dict[str, str]: + return {"X-User": "supervisor", "X-Role": "supervisor"} + + +def _admin_headers() -> dict[str, str]: + return {"X-User": "admin", "X-Role": "admin"} + + +def _operator_headers() -> dict[str, str]: + return {"X-User": "operator", "X-Role": "operator"} + + +def test_import_from_voice_event_creates_recording_and_is_idempotent(tmp_path, monkeypatch): + monkeypatch.setenv("CC_RECORDINGS_DIR", str(tmp_path / "recordings")) + voice_client = TestClient(voice_app) + recording_client = TestClient(recording_app) + source_path = _write_audio_fixture(tmp_path / "fixtures" / "call.wav") + + event = voice_client.post( + "/integrations/voice/events", + headers=_admin_headers(), + json={ + "event_type": "recording.ready", + "call_id": f"call_{tmp_path.name}", + "interaction_id": f"int_{tmp_path.name}", + "payload": { + "source_path": str(source_path), + "file_name": "call.wav", + "mime_type": "audio/wav", + "duration_seconds": 1, + }, + }, + ) + assert event.status_code == 200 + event_id = event.json()["event_id"] + + created = recording_client.post( + f"/recordings/import-from-voice-event/{event_id}", + headers=_supervisor_headers(), + ) + assert created.status_code == 200 + payload = created.json() + assert payload["recording_id"].startswith("rec_") + assert payload["status"] == "ready" + assert payload["mime_type"] == "audio/wav" + + duplicated = recording_client.post( + f"/recordings/import-from-voice-event/{event_id}", + headers=_supervisor_headers(), + ) + assert duplicated.status_code == 200 + assert duplicated.json()["recording_id"] == payload["recording_id"] + + +def test_register_recording_lists_filters_and_archives(tmp_path, monkeypatch): + monkeypatch.setenv("CC_RECORDINGS_DIR", str(tmp_path / "managed")) + recording_client = TestClient(recording_app) + source_path = _write_audio_fixture(tmp_path / "fixtures" / "register.wav") + call_id = f"call_{tmp_path.name}" + interaction_id = f"int_{tmp_path.name}" + + created = recording_client.post( + "/recordings/register", + headers=_supervisor_headers(), + json={ + "call_id": call_id, + "interaction_id": interaction_id, + "source_path": str(source_path), + "file_name": "register.wav", + "mime_type": "audio/wav", + "duration_seconds": 2, + }, + ) + assert created.status_code == 200 + recording_id = created.json()["recording_id"] + + listed = recording_client.get(f"/recordings?call_id={call_id}", headers=_supervisor_headers()) + assert listed.status_code == 200 + assert any(item["recording_id"] == recording_id for item in listed.json()) + + filtered = recording_client.get( + f"/recordings?interaction_id={interaction_id}", + headers=_supervisor_headers(), + ) + assert filtered.status_code == 200 + assert any(item["recording_id"] == recording_id for item in filtered.json()) + + detail = recording_client.get(f"/recordings/{recording_id}", headers=_supervisor_headers()) + assert detail.status_code == 200 + assert detail.json()["call_id"] == call_id + + archived = recording_client.post( + f"/recordings/{recording_id}/archive", + headers=_supervisor_headers(), + ) + assert archived.status_code == 200 + assert archived.json()["status"] == "archived" + + +def test_recording_content_supports_inline_and_attachment(tmp_path, monkeypatch): + monkeypatch.setenv("CC_RECORDINGS_DIR", str(tmp_path / "managed")) + recording_client = TestClient(recording_app) + source_path = _write_audio_fixture(tmp_path / "fixtures" / "preview.wav") + + created = recording_client.post( + "/recordings/register", + headers=_supervisor_headers(), + json={ + "call_id": f"call_{tmp_path.name}", + "source_path": str(source_path), + "file_name": "preview.wav", + }, + ) + assert created.status_code == 200 + recording_id = created.json()["recording_id"] + + inline = recording_client.get( + f"/recordings/{recording_id}/content?disposition=inline", + headers=_supervisor_headers(), + ) + assert inline.status_code == 200 + assert inline.content.startswith(b"RIFF") + assert inline.headers["content-type"].startswith("audio/wav") + + attachment = recording_client.get( + f"/recordings/{recording_id}/content?disposition=attachment", + headers=_supervisor_headers(), + ) + assert attachment.status_code == 200 + assert "attachment" in attachment.headers["content-disposition"] + + +def test_missing_file_marks_recording_missing(tmp_path, monkeypatch): + storage_root = tmp_path / "managed" + monkeypatch.setenv("CC_RECORDINGS_DIR", str(storage_root)) + recording_client = TestClient(recording_app) + source_path = _write_audio_fixture(tmp_path / "fixtures" / "missing.wav") + + created = recording_client.post( + "/recordings/register", + headers=_supervisor_headers(), + json={ + "call_id": f"call_{tmp_path.name}", + "source_path": str(source_path), + "file_name": "missing.wav", + }, + ) + assert created.status_code == 200 + recording_id = created.json()["recording_id"] + + stored_files = list(storage_root.rglob("*.wav")) + assert stored_files + stored_files[0].unlink() + + missing = recording_client.get( + f"/recordings/{recording_id}/content", + headers=_supervisor_headers(), + ) + assert missing.status_code == 410 + + detail = recording_client.get(f"/recordings/{recording_id}", headers=_supervisor_headers()) + assert detail.status_code == 200 + assert detail.json()["status"] == "missing" + + +def test_recording_import_upload_accepts_multipart(tmp_path, monkeypatch): + monkeypatch.setenv("CC_RECORDINGS_DIR", str(tmp_path / "managed")) + recording_client = TestClient(recording_app) + fixture = _write_audio_fixture(tmp_path / "fixtures" / "upload.wav") + + with fixture.open("rb") as handle: + created = recording_client.post( + "/recordings/import-upload", + headers=_admin_headers(), + data={ + "call_id": f"call_upload_{tmp_path.name}", + "interaction_id": f"int_upload_{tmp_path.name}", + "file_name": "upload.wav", + "mime_type": "audio/wav", + "duration_seconds": "4", + }, + files={"file": ("upload.wav", handle, "audio/wav")}, + ) + + assert created.status_code == 200 + payload = created.json() + assert payload["recording_id"].startswith("rec_") + assert payload["call_id"].startswith("call_upload_") + assert payload["status"] == "ready" + assert payload["mime_type"] == "audio/wav" + + +def test_recording_import_upload_is_idempotent_by_source_event_id(tmp_path, monkeypatch): + monkeypatch.setenv("CC_RECORDINGS_DIR", str(tmp_path / "managed")) + recording_client = TestClient(recording_app) + fixture = _write_audio_fixture(tmp_path / "fixtures" / "upload-idempotent.wav") + source_event_id = f"bridge_upload_{tmp_path.name}" + form_data = { + "call_id": f"call_upload_idempotent_{tmp_path.name}", + "interaction_id": f"int_upload_idempotent_{tmp_path.name}", + "source_event_id": source_event_id, + "file_name": "upload-idempotent.wav", + "mime_type": "audio/wav", + "duration_seconds": "4", + } + + with fixture.open("rb") as handle: + first = recording_client.post( + "/recordings/import-upload", + headers=_admin_headers(), + data=form_data, + files={"file": ("upload-idempotent.wav", handle, "audio/wav")}, + ) + with fixture.open("rb") as handle: + second = recording_client.post( + "/recordings/import-upload", + headers=_admin_headers(), + data=form_data, + files={"file": ("upload-idempotent.wav", handle, "audio/wav")}, + ) + + assert first.status_code == 200 + assert second.status_code == 200 + assert first.json()["recording_id"] == second.json()["recording_id"] + assert first.json()["source_event_id"] == source_event_id + + session = get_session() + try: + rows = session.execute( + select(CallRecordingRow).where(CallRecordingRow.source_event_id == source_event_id) + ).scalars().all() + assert len(rows) == 1 + finally: + session.close() + + +def test_register_recording_recovers_from_source_event_id_conflict(tmp_path, monkeypatch): + monkeypatch.setenv("CC_RECORDINGS_DIR", str(tmp_path / "managed")) + source_path = _write_audio_fixture(tmp_path / "fixtures" / "race.wav") + source_event_id = f"src_race_{tmp_path.name}" + existing_call_id = f"call_race_{tmp_path.name}" + + session = get_session() + original_commit = session.commit + commit_attempts = {"count": 0} + + def _conflicting_commit(): + if commit_attempts["count"] == 0: + commit_attempts["count"] += 1 + other = get_session() + try: + existing = recording_module._register_recording( + other, + call_id=existing_call_id, + interaction_id=f"int_race_{tmp_path.name}", + source_path=source_path.resolve(), + file_name="race.wav", + mime_type="audio/wav", + duration_seconds=3, + recorded_at=None, + source_event_id=source_event_id, + ) + assert existing.source_event_id == source_event_id + finally: + other.close() + raise IntegrityError("duplicate source_event_id", params=None, orig=Exception("duplicate")) + return original_commit() + + try: + monkeypatch.setattr(session, "commit", _conflicting_commit) + row = recording_module._register_recording( + session, + call_id=existing_call_id, + interaction_id=f"int_race_{tmp_path.name}", + source_path=source_path.resolve(), + file_name="race.wav", + mime_type="audio/wav", + duration_seconds=3, + recorded_at=None, + source_event_id=source_event_id, + ) + assert row.source_event_id == source_event_id + + verify = get_session() + try: + rows = verify.execute( + select(CallRecordingRow).where(CallRecordingRow.source_event_id == source_event_id) + ).scalars().all() + assert len(rows) == 1 + assert rows[0].recording_id == row.recording_id + finally: + verify.close() + finally: + session.close() + + +def test_recording_rejects_unsupported_or_oversized_files_and_operator_access(tmp_path, monkeypatch): + monkeypatch.setenv("CC_RECORDINGS_DIR", str(tmp_path / "managed")) + monkeypatch.setenv("RECORDING_MAX_BYTES", "1024") + recording_client = TestClient(recording_app) + + unsupported_path = (tmp_path / "fixtures" / "bad.txt") + unsupported_path.parent.mkdir(parents=True, exist_ok=True) + unsupported_path.write_text("not audio", encoding="utf-8") + + unsupported = recording_client.post( + "/recordings/register", + headers=_supervisor_headers(), + json={ + "call_id": f"call_bad_{tmp_path.name}", + "source_path": str(unsupported_path), + "file_name": "bad.txt", + }, + ) + assert unsupported.status_code == 415 + + oversized_path = _write_audio_fixture(tmp_path / "fixtures" / "big.wav", size=4096) + oversized = recording_client.post( + "/recordings/register", + headers=_supervisor_headers(), + json={ + "call_id": f"call_big_{tmp_path.name}", + "source_path": str(oversized_path), + "file_name": "big.wav", + }, + ) + assert oversized.status_code == 413 + + denied = recording_client.get("/recordings", headers=_operator_headers()) + assert denied.status_code == 403 + + +def test_recording_import_upload_requires_trusted_service_when_admin_fallback_disabled(tmp_path, monkeypatch): + monkeypatch.setenv("CC_RECORDINGS_DIR", str(tmp_path / "managed")) + monkeypatch.setenv("RECORDING_IMPORT_ALLOW_ADMIN", "0") + monkeypatch.setenv("RECORDING_IMPORT_TRUSTED_SERVICE_SUBJECTS", "svc:asterisk-bridge") + monkeypatch.setenv("ALLOW_LEGACY_HEADER_AUTH", "0") + monkeypatch.setenv("APP_TOKEN_SECRET", "track9-service-token-test") + recording_client = TestClient(recording_app) + fixture = _write_audio_fixture(tmp_path / "fixtures" / "svc-upload.wav") + + with fixture.open("rb") as handle: + denied = recording_client.post( + "/recordings/import-upload", + headers=_admin_headers(), + data={ + "call_id": f"call_upload_denied_{tmp_path.name}", + "interaction_id": f"int_upload_denied_{tmp_path.name}", + "file_name": "svc-upload.wav", + "mime_type": "audio/wav", + "duration_seconds": "4", + }, + files={"file": ("svc-upload.wav", handle, "audio/wav")}, + ) + assert denied.status_code == 403 + + token = issue_app_token( + subject="svc:asterisk-bridge", + username="asterisk-bridge", + role="admin", + auth_source="service", + provider="track9-test", + ttl_seconds=300, + ) + with fixture.open("rb") as handle: + allowed = recording_client.post( + "/recordings/import-upload", + headers={"Authorization": f"Bearer {token}"}, + data={ + "call_id": f"call_upload_allowed_{tmp_path.name}", + "interaction_id": f"int_upload_allowed_{tmp_path.name}", + "file_name": "svc-upload.wav", + "mime_type": "audio/wav", + "duration_seconds": "4", + }, + files={"file": ("svc-upload.wav", handle, "audio/wav")}, + ) + + assert allowed.status_code == 200 + assert allowed.json()["recording_id"].startswith("rec_") diff --git a/tests/test_routing_service_pg_counter.py b/tests/test_routing_service_pg_counter.py new file mode 100644 index 0000000..5938465 --- /dev/null +++ b/tests/test_routing_service_pg_counter.py @@ -0,0 +1,27 @@ +from sqlalchemy import select + +import services.routing_service.app as routing_module +from services.shared.db import get_session +from services.shared.sql_init import init_sql_schema +from services.shared.sql_models import RoutingCounter + + +def test_postgres_counter_path_uses_atomic_upsert(monkeypatch): + init_sql_schema() + session = get_session() + monkeypatch.setattr(routing_module, "_db_backend_name", lambda: "postgresql") + try: + indexes = [ + routing_module._next_agent_index(session, channel="voice_pg_atomic", agent_count=3), + routing_module._next_agent_index(session, channel="voice_pg_atomic", agent_count=3), + routing_module._next_agent_index(session, channel="voice_pg_atomic", agent_count=3), + ] + session.commit() + + stored = session.execute( + select(RoutingCounter).where(RoutingCounter.channel == "voice_pg_atomic") + ).scalar_one() + assert indexes == [0, 1, 2] + assert stored.counter == 3 + finally: + session.close() diff --git a/tests/test_schema_migrations.py b/tests/test_schema_migrations.py new file mode 100644 index 0000000..8187b79 --- /dev/null +++ b/tests/test_schema_migrations.py @@ -0,0 +1,49 @@ +from pathlib import Path + +import pytest +from sqlalchemy import create_engine, text + +from services.shared.schema_migrations import ( + ensure_schema_migrations_table, + resolve_schema_management_mode, + validate_schema_migrations_applied, +) + + +def test_resolve_schema_management_mode_defaults_by_backend(): + assert resolve_schema_management_mode(None, backend_name="sqlite") == "legacy" + assert resolve_schema_management_mode(None, backend_name="postgresql") == "migrations" + + +def test_resolve_schema_management_mode_rejects_postgres_legacy(): + with pytest.raises(RuntimeError, match="SCHEMA_MANAGEMENT_MODE=legacy"): + resolve_schema_management_mode("legacy", backend_name="postgresql") + + +def test_validate_schema_migrations_applied_reports_missing_migration_table(tmp_path: Path): + migrations_dir = tmp_path / "migrations" + migrations_dir.mkdir() + (migrations_dir / "0001_dummy_sqlite.sql").write_text("SELECT 1;", encoding="utf-8") + db_path = tmp_path / "schema_missing.sqlite" + db_engine = create_engine(f"sqlite:///{db_path.as_posix()}") + + with pytest.raises(RuntimeError, match="python scripts/migrate_core_db.py"): + validate_schema_migrations_applied(db_engine=db_engine, migrations_dir=migrations_dir) + + +def test_validate_schema_migrations_applied_accepts_up_to_date_schema(tmp_path: Path): + migrations_dir = tmp_path / "migrations" + migrations_dir.mkdir() + migration_file = migrations_dir / "0001_dummy_sqlite.sql" + migration_file.write_text("SELECT 1;", encoding="utf-8") + db_path = tmp_path / "schema_ready.sqlite" + db_engine = create_engine(f"sqlite:///{db_path.as_posix()}") + + ensure_schema_migrations_table(db_engine=db_engine) + with db_engine.begin() as conn: + conn.execute( + text("INSERT INTO schema_migrations(version, applied_at) VALUES (:v, CURRENT_TIMESTAMP)"), + {"v": migration_file.name}, + ) + + validate_schema_migrations_applied(db_engine=db_engine, migrations_dir=migrations_dir) diff --git a/tests/test_security_track1.py b/tests/test_security_track1.py new file mode 100644 index 0000000..ccdae4b --- /dev/null +++ b/tests/test_security_track1.py @@ -0,0 +1,66 @@ +from fastapi import Depends, FastAPI +from fastapi.testclient import TestClient + +from services.shared.core import Role +from services.shared.security import get_actor, issue_app_token, require_roles + + +def build_app() -> FastAPI: + app = FastAPI() + + @app.get('/actor') + def actor_view(actor: dict = Depends(get_actor)) -> dict: + return actor + + @app.get('/admin') + def admin_view(_: dict = Depends(require_roles(Role.ADMIN))) -> dict: + return {'ok': True} + + return app + + +def test_valid_bearer_token_resolves_actor(monkeypatch): + monkeypatch.setenv('APP_TOKEN_SECRET', 'track1-secret') + client = TestClient(build_app()) + token = issue_app_token( + subject='usr_1', + username='alice', + role='admin', + auth_source='oidc', + provider='keycloak', + ) + + response = client.get('/actor', headers={'Authorization': f'Bearer {token}'}) + + assert response.status_code == 200 + body = response.json() + assert body['user'] == 'alice' + assert body['role'] == 'admin' + assert body['auth_source'] == 'oidc' + + +def test_invalid_bearer_token_returns_401(monkeypatch): + monkeypatch.setenv('APP_TOKEN_SECRET', 'track1-secret') + client = TestClient(build_app()) + + response = client.get('/actor', headers={'Authorization': 'Bearer broken.token.value'}) + + assert response.status_code == 401 + + +def test_legacy_header_auth_can_be_disabled(monkeypatch): + monkeypatch.setenv('ALLOW_LEGACY_HEADER_AUTH', '0') + client = TestClient(build_app()) + + response = client.get('/admin', headers={'X-User': 'admin', 'X-Role': 'admin'}) + + assert response.status_code == 403 + + +def test_legacy_header_auth_still_works_when_enabled(monkeypatch): + monkeypatch.setenv('ALLOW_LEGACY_HEADER_AUTH', '1') + client = TestClient(build_app()) + + response = client.get('/admin', headers={'X-User': 'admin', 'X-Role': 'admin'}) + + assert response.status_code == 200 diff --git a/tests/test_sql_init_compat.py b/tests/test_sql_init_compat.py new file mode 100644 index 0000000..74ea2db --- /dev/null +++ b/tests/test_sql_init_compat.py @@ -0,0 +1,66 @@ +from sqlalchemy import inspect, text + +from services.shared.db import engine +from services.shared.sql_init import init_sql_schema + + +def test_init_sql_schema_backfills_voice_events_source_event_id(): + with engine.begin() as conn: + conn.execute(text("DROP TABLE IF EXISTS voice_events")) + conn.execute( + text( + """ + CREATE TABLE voice_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + event_id VARCHAR(64) NOT NULL UNIQUE, + event_type VARCHAR(64) NOT NULL, + call_id VARCHAR(128) NOT NULL, + interaction_id VARCHAR(64) NULL, + payload_json TEXT NOT NULL, + created_at VARCHAR(64) NOT NULL + ) + """ + ) + ) + + init_sql_schema() + + inspector = inspect(engine) + columns = {item["name"] for item in inspector.get_columns("voice_events")} + indexes = {item["name"] for item in inspector.get_indexes("voice_events")} + + assert "source_event_id" in columns + assert "ix_voice_events_source_event_id" in indexes + + +def test_init_sql_schema_backfills_whatsapp_message_runtime_columns(): + with engine.begin() as conn: + conn.execute(text("DROP TABLE IF EXISTS whatsapp_messages")) + conn.execute( + text( + """ + CREATE TABLE whatsapp_messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + message_id VARCHAR(64) NOT NULL UNIQUE, + chat_id VARCHAR(128) NOT NULL, + text TEXT NOT NULL, + payload_json TEXT NOT NULL, + created_at VARCHAR(64) NOT NULL + ) + """ + ) + ) + + init_sql_schema() + + inspector = inspect(engine) + columns = {item["name"] for item in inspector.get_columns("whatsapp_messages")} + indexes = {item["name"] for item in inspector.get_indexes("whatsapp_messages")} + + assert "thread_id" in columns + assert "interaction_id" in columns + assert "customer_id" in columns + assert "delivery_status" in columns + assert "author_type" in columns + assert "ix_whatsapp_messages_chat_external_unique" in indexes + assert "idx_whatsapp_messages_next_delivery_attempt_at" in indexes diff --git a/tests/test_stage3_services.py b/tests/test_stage3_services.py new file mode 100644 index 0000000..c9602a4 --- /dev/null +++ b/tests/test_stage3_services.py @@ -0,0 +1,1428 @@ +from uuid import uuid4 + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import select + +from services.interaction_service.app import app as interaction_app +from services.kb_service.app import app as kb_app +from services.reporting_service.app import app as reporting_app +from services.shared.db import get_session +from services.shared.sql_models import Interaction, ReportingInteractionFactRow +from services.supervisor_service.app import app as supervisor_app + + +def test_kb_lite_search(): + client = TestClient(kb_app) + + cat = client.post( + "/knowledge/categories", + json={"name": "NCA Layer", "description": "Certificate help"}, + headers={"X-User": "analyst", "X-Role": "analyst"}, + ) + assert cat.status_code == 200 + category_id = cat.json()["category_id"] + + article = client.post( + "/knowledge/articles", + json={ + "category_id": category_id, + "title": "How to install NCA Layer", + "body": "Download installer and run it.", + "tags": ["nca", "install"], + }, + headers={"X-User": "analyst", "X-Role": "analyst"}, + ) + assert article.status_code == 200 + + search = client.get("/knowledge/search?q=NCA") + assert search.status_code == 200 + assert len(search.json()) >= 1 + + +def test_kb_lite_search_ranks_title_over_body_only_matches(): + client = TestClient(kb_app) + headers = {"X-User": "analyst", "X-Role": "analyst"} + + cat = client.post( + "/knowledge/categories", + json={"name": "KB Rank", "description": "Ranking checks"}, + headers=headers, + ) + assert cat.status_code == 200 + category_id = cat.json()["category_id"] + + top = client.post( + "/knowledge/articles", + json={ + "category_id": category_id, + "title": "Rankedgex operator guide", + "body": "Use this guide to connect a customer with an operator.", + "tags": ["rankedgex", "operator"], + }, + headers=headers, + ) + assert top.status_code == 200 + + fallback = client.post( + "/knowledge/articles", + json={ + "category_id": category_id, + "title": "Fallback flow", + "body": "This body mentions rankedgex operator once for fallback routing.", + "tags": ["fallback"], + }, + headers=headers, + ) + assert fallback.status_code == 200 + + search = client.get("/knowledge/search?q=rankedgex operator") + assert search.status_code == 200 + payload = search.json() + assert len(payload) >= 2 + assert payload[0]["title"] == "Rankedgex operator guide" + + +def test_kb_localized_articles_search_by_language_and_group(): + client = TestClient(kb_app) + headers = {"X-User": "analyst", "X-Role": "analyst"} + + cat = client.post( + "/knowledge/categories", + json={"name": "Localized KB", "description": "RU/KZ localization"}, + headers=headers, + ) + assert cat.status_code == 200 + category_id = cat.json()["category_id"] + + ru_article = client.post( + "/knowledge/articles", + json={ + "category_id": category_id, + "language": "ru", + "title": "Tariff localsharedx RU", + "body": "Russian localized kb answer localsharedx.", + "tags": ["localsharedx"], + }, + headers=headers, + ) + assert ru_article.status_code == 200 + article_group_id = ru_article.json()["article_group_id"] + + kz_article = client.post( + "/knowledge/articles", + json={ + "category_id": category_id, + "article_group_id": article_group_id, + "language": "kz", + "title": "Tariff localsharedx KZ", + "body": "Kazakh localized kb answer localsharedx.", + "tags": ["localsharedx"], + }, + headers=headers, + ) + assert kz_article.status_code == 200 + assert kz_article.json()["article_group_id"] == article_group_id + + kz_search = client.get("/knowledge/search?q=localsharedx&language=kz") + assert kz_search.status_code == 200 + kz_payload = kz_search.json() + assert len(kz_payload) == 1 + assert kz_payload[0]["language"] == "kz" + assert kz_payload[0]["article_group_id"] == article_group_id + + duplicate = client.post( + "/knowledge/articles", + json={ + "category_id": category_id, + "article_group_id": article_group_id, + "language": "kz", + "title": "Tariff localsharedx KZ duplicate", + "body": "Should be rejected.", + "tags": ["localsharedx"], + }, + headers=headers, + ) + assert duplicate.status_code == 409 + + +def test_kb_lite_search_empty_query_returns_articles_by_recency(): + client = TestClient(kb_app) + headers = {"X-User": "analyst", "X-Role": "analyst"} + + cat = client.post( + "/knowledge/categories", + json={"name": "KB Empty Query", "description": "Empty query list view"}, + headers=headers, + ) + assert cat.status_code == 200 + category_id = cat.json()["category_id"] + + older = client.post( + "/knowledge/articles", + json={ + "category_id": category_id, + "title": "Empty Query Older", + "body": "Older body", + "tags": ["empty-query-older"], + }, + headers=headers, + ) + assert older.status_code == 200 + + newer = client.post( + "/knowledge/articles", + json={ + "category_id": category_id, + "title": "Empty Query Newer", + "body": "Newer body", + "tags": ["empty-query-newer"], + }, + headers=headers, + ) + assert newer.status_code == 200 + + search = client.get("/knowledge/search?q=&limit=2") + assert search.status_code == 200 + payload = search.json() + assert [item["title"] for item in payload] == ["Empty Query Newer", "Empty Query Older"] + + +def test_kb_lite_role_enforcement(): + client = TestClient(kb_app) + + denied = client.post( + "/knowledge/categories", + json={"name": "Forbidden", "description": "Should be blocked"}, + headers={"X-User": "operator", "X-Role": "operator"}, + ) + assert denied.status_code == 403 + + +def test_interaction_drilldown_filters_by_range_and_paginates(): + client = TestClient(interaction_app) + session = get_session() + marker = uuid4().hex[:8] + try: + session.add_all( + [ + Interaction( + interaction_id=f"int_{marker}_late", + channel="voice", + subject=f"Drilldown late {marker}", + customer_id=None, + queue_id=f"q_{marker}_alpha", + priority=3, + status="new", + assigned_to="agent.late", + created_at="2026-02-03T12:00:00+00:00", + updated_at="2026-02-03T12:00:00+00:00", + ), + Interaction( + interaction_id=f"int_{marker}_middle", + channel="voice", + subject=f"Drilldown middle {marker}", + customer_id=None, + queue_id=f"q_{marker}_beta", + priority=3, + status="in_progress", + assigned_to="agent.middle", + created_at="2026-02-02T10:00:00+00:00", + updated_at="2026-02-02T10:00:00+00:00", + ), + Interaction( + interaction_id=f"int_{marker}_early", + channel="telegram", + subject=f"Drilldown early {marker}", + customer_id=None, + queue_id=f"q_{marker}_alpha", + priority=3, + status="closed", + assigned_to="agent.early", + created_at="2026-02-02T09:00:00+00:00", + updated_at="2026-02-02T09:00:00+00:00", + ), + Interaction( + interaction_id=f"int_{marker}_outside", + channel="voice", + subject=f"Drilldown outside {marker}", + customer_id=None, + queue_id=f"q_{marker}_alpha", + priority=3, + status="new", + assigned_to="agent.outside", + created_at="2026-02-05T09:00:00+00:00", + updated_at="2026-02-05T09:00:00+00:00", + ), + ] + ) + session.commit() + finally: + session.close() + + headers = {"X-User": "analyst", "X-Role": "analyst"} + page_one = client.get( + "/interactions/drilldown", + params={ + "from_ts": "2026-02-02T00:00:00Z", + "to_ts": "2026-02-04T00:00:00Z", + "limit": 2, + "offset": 0, + }, + headers=headers, + ) + assert page_one.status_code == 200 + payload = page_one.json() + assert payload["total"] >= 3 + assert payload["limit"] == 2 + assert payload["offset"] == 0 + assert payload["filters"]["from_ts"] == "2026-02-02T00:00:00+00:00" + assert payload["filters"]["to_ts"] == "2026-02-04T00:00:00+00:00" + assert [item["interaction_id"] for item in payload["items"][:2]] == [ + f"int_{marker}_late", + f"int_{marker}_middle", + ] + + page_two = client.get( + "/interactions/drilldown", + params={ + "from_ts": "2026-02-02T00:00:00Z", + "to_ts": "2026-02-04T00:00:00Z", + "limit": 2, + "offset": 2, + }, + headers=headers, + ) + assert page_two.status_code == 200 + second_payload = page_two.json() + assert second_payload["offset"] == 2 + assert second_payload["items"][0]["interaction_id"] == f"int_{marker}_early" + + +def test_interaction_drilldown_filters_by_queue_and_channel(): + client = TestClient(interaction_app) + session = get_session() + marker = uuid4().hex[:8] + queue_id = f"q_{marker}_focus" + try: + session.add_all( + [ + Interaction( + interaction_id=f"int_{marker}_target_one", + channel="telegram", + subject=f"Drilldown target one {marker}", + customer_id=None, + queue_id=queue_id, + priority=3, + status="new", + assigned_to="agent.target", + created_at="2026-02-10T08:00:00+00:00", + updated_at="2026-02-10T08:00:00+00:00", + ), + Interaction( + interaction_id=f"int_{marker}_target_two", + channel="telegram", + subject=f"Drilldown target two {marker}", + customer_id=None, + queue_id=queue_id, + priority=3, + status="closed", + assigned_to="agent.target", + created_at="2026-02-10T07:00:00+00:00", + updated_at="2026-02-10T07:00:00+00:00", + ), + Interaction( + interaction_id=f"int_{marker}_queue_only", + channel="voice", + subject=f"Drilldown queue only {marker}", + customer_id=None, + queue_id=queue_id, + priority=3, + status="new", + assigned_to="agent.queue", + created_at="2026-02-10T06:00:00+00:00", + updated_at="2026-02-10T06:00:00+00:00", + ), + Interaction( + interaction_id=f"int_{marker}_channel_only", + channel="telegram", + subject=f"Drilldown channel only {marker}", + customer_id=None, + queue_id=f"q_{marker}_other", + priority=3, + status="new", + assigned_to="agent.channel", + created_at="2026-02-10T05:00:00+00:00", + updated_at="2026-02-10T05:00:00+00:00", + ), + ] + ) + session.commit() + finally: + session.close() + + headers = {"X-User": "analyst", "X-Role": "analyst"} + queue_only = client.get( + "/interactions/drilldown", + params={ + "from_ts": "2026-02-10T00:00:00Z", + "to_ts": "2026-02-11T00:00:00Z", + "queue_id": queue_id, + }, + headers=headers, + ) + assert queue_only.status_code == 200 + queue_payload = queue_only.json() + assert queue_payload["filters"]["queue_id"] == queue_id + assert {item["interaction_id"] for item in queue_payload["items"]} >= { + f"int_{marker}_target_one", + f"int_{marker}_target_two", + f"int_{marker}_queue_only", + } + + queue_and_channel = client.get( + "/interactions/drilldown", + params={ + "from_ts": "2026-02-10T00:00:00Z", + "to_ts": "2026-02-11T00:00:00Z", + "queue_id": queue_id, + "channel": "telegram", + }, + headers=headers, + ) + assert queue_and_channel.status_code == 200 + combined_payload = queue_and_channel.json() + assert combined_payload["filters"]["queue_id"] == queue_id + assert combined_payload["filters"]["channel"] == "telegram" + assert [item["interaction_id"] for item in combined_payload["items"]] == [ + f"int_{marker}_target_one", + f"int_{marker}_target_two", + ] + + +def test_interaction_drilldown_search_status_and_sort_filters(): + client = TestClient(interaction_app) + session = get_session() + marker = uuid4().hex[:8] + queue_id = f"q_{marker}_search" + try: + session.add_all( + [ + Interaction( + interaction_id=f"int_{marker}_subject_b", + channel="voice", + subject=f"Subject {marker} Bravo", + customer_id=None, + queue_id=queue_id, + priority=3, + status="closed", + assigned_to=f"agent.{marker}.beta", + created_at="2026-02-12T09:00:00+00:00", + updated_at="2026-02-12T11:00:00+00:00", + ), + Interaction( + interaction_id=f"int_{marker}_subject_a", + channel="telegram", + subject=f"Subject {marker} Alpha", + customer_id=None, + queue_id=queue_id, + priority=3, + status="closed", + assigned_to=f"agent.{marker}.alpha", + created_at="2026-02-12T08:00:00+00:00", + updated_at="2026-02-12T12:00:00+00:00", + ), + Interaction( + interaction_id=f"int_{marker}_assigned", + channel="webchat", + subject=f"General ticket {marker}", + customer_id=None, + queue_id=queue_id, + priority=3, + status="new", + assigned_to=f"agent.{marker}.search", + created_at="2026-02-12T07:00:00+00:00", + updated_at="2026-02-12T13:00:00+00:00", + ), + ] + ) + session.commit() + finally: + session.close() + + headers = {"X-User": "analyst", "X-Role": "analyst"} + sorted_page = client.get( + "/interactions/drilldown", + params={ + "from_ts": "2026-02-12T00:00:00Z", + "to_ts": "2026-02-13T00:00:00Z", + "q": marker, + "status": "closed", + "sort_by": "subject", + "sort_dir": "asc", + "limit": 1, + "offset": 0, + }, + headers=headers, + ) + assert sorted_page.status_code == 200 + sorted_payload = sorted_page.json() + assert sorted_payload["filters"]["q"] == marker + assert sorted_payload["filters"]["status"] == "closed" + assert sorted_payload["filters"]["sort_by"] == "subject" + assert sorted_payload["filters"]["sort_dir"] == "asc" + assert sorted_payload["total"] >= 2 + assert [item["interaction_id"] for item in sorted_payload["items"]] == [f"int_{marker}_subject_a"] + + second_page = client.get( + "/interactions/drilldown", + params={ + "from_ts": "2026-02-12T00:00:00Z", + "to_ts": "2026-02-13T00:00:00Z", + "q": marker, + "status": "closed", + "sort_by": "subject", + "sort_dir": "asc", + "limit": 1, + "offset": 1, + }, + headers=headers, + ) + assert second_page.status_code == 200 + second_payload = second_page.json() + assert second_payload["offset"] == 1 + assert [item["interaction_id"] for item in second_payload["items"]] == [f"int_{marker}_subject_b"] + + +def test_interaction_drilldown_searches_interaction_id_and_assigned_to_and_sorts_by_updated_at(): + client = TestClient(interaction_app) + session = get_session() + marker = uuid4().hex[:8] + try: + session.add_all( + [ + Interaction( + interaction_id=f"int_{marker}_latest", + channel="voice", + subject=f"Updated latest {marker}", + customer_id=None, + queue_id=f"q_{marker}_updated", + priority=3, + status="new", + assigned_to=f"agent.{marker}.team", + created_at="2026-02-14T08:00:00+00:00", + updated_at="2026-02-14T12:00:00+00:00", + ), + Interaction( + interaction_id=f"int_{marker}_earlier", + channel="email", + subject=f"Updated earlier {marker}", + customer_id=None, + queue_id=f"q_{marker}_updated", + priority=3, + status="in_progress", + assigned_to=f"agent.{marker}.team", + created_at="2026-02-14T07:00:00+00:00", + updated_at="2026-02-14T11:00:00+00:00", + ), + ] + ) + session.commit() + finally: + session.close() + + headers = {"X-User": "analyst", "X-Role": "analyst"} + id_lookup = client.get( + "/interactions/drilldown", + params={ + "from_ts": "2026-02-14T00:00:00Z", + "to_ts": "2026-02-15T00:00:00Z", + "q": f"int_{marker}_latest", + }, + headers=headers, + ) + assert id_lookup.status_code == 200 + id_payload = id_lookup.json() + assert [item["interaction_id"] for item in id_payload["items"]] == [f"int_{marker}_latest"] + + assigned_lookup = client.get( + "/interactions/drilldown", + params={ + "from_ts": "2026-02-14T00:00:00Z", + "to_ts": "2026-02-15T00:00:00Z", + "q": f"agent.{marker}.team", + "sort_by": "updated_at", + "sort_dir": "desc", + }, + headers=headers, + ) + assert assigned_lookup.status_code == 200 + assigned_payload = assigned_lookup.json() + assert assigned_payload["filters"]["sort_by"] == "updated_at" + assert assigned_payload["filters"]["sort_dir"] == "desc" + assert [item["interaction_id"] for item in assigned_payload["items"][:2]] == [ + f"int_{marker}_latest", + f"int_{marker}_earlier", + ] + + +def test_interaction_drilldown_filters_by_agent_id(): + client = TestClient(interaction_app) + session = get_session() + marker = uuid4().hex[:8] + queue_id = f"q_{marker}_agent" + try: + session.add_all( + [ + Interaction( + interaction_id=f"int_{marker}_alpha", + channel="voice", + subject=f"Agent alpha {marker}", + customer_id=None, + queue_id=queue_id, + priority=3, + status="closed", + assigned_to=f"agent.{marker}.focus", + created_at="2026-02-16T08:00:00+00:00", + updated_at="2026-02-16T09:00:00+00:00", + ), + Interaction( + interaction_id=f"int_{marker}_beta", + channel="telegram", + subject=f"Agent beta {marker}", + customer_id=None, + queue_id=queue_id, + priority=3, + status="in_progress", + assigned_to=f"agent.{marker}.focus", + created_at="2026-02-16T07:00:00+00:00", + updated_at="2026-02-16T10:00:00+00:00", + ), + Interaction( + interaction_id=f"int_{marker}_other", + channel="voice", + subject=f"Agent other {marker}", + customer_id=None, + queue_id=queue_id, + priority=3, + status="closed", + assigned_to=f"agent.{marker}.other", + created_at="2026-02-16T06:00:00+00:00", + updated_at="2026-02-16T06:30:00+00:00", + ), + ] + ) + session.commit() + finally: + session.close() + + response = client.get( + "/interactions/drilldown", + params={ + "from_ts": "2026-02-16T00:00:00Z", + "to_ts": "2026-02-17T00:00:00Z", + "agent_id": f"agent.{marker}.focus", + }, + headers=_analyst_headers(), + ) + assert response.status_code == 200 + payload = response.json() + assert payload["filters"]["agent_id"] == f"agent.{marker}.focus" + assert [item["interaction_id"] for item in payload["items"]] == [ + f"int_{marker}_alpha", + f"int_{marker}_beta", + ] + + +def _admin_headers(): + return {"X-User": "admin", "X-Role": "admin"} + + +def _analyst_headers(): + return {"X-User": "analyst", "X-Role": "analyst"} + + +def _post_reporting_fact(client: TestClient, **payload): + response = client.post("/reports/facts/interactions", json=payload, headers=_admin_headers()) + assert response.status_code == 200 + return response + + +def test_reporting_interaction_fact_upsert_is_idempotent(): + client = TestClient(reporting_app) + marker = uuid4().hex[:8] + interaction_id = f"rpt_fact_{marker}" + queue_id = f"q_fact_{marker}" + + first = _post_reporting_fact( + client, + interaction_id=interaction_id, + channel="voice", + queue_id=queue_id, + status="new", + created_at="2026-03-02T08:00:00Z", + source="test-suite", + ) + second = _post_reporting_fact( + client, + interaction_id=interaction_id, + channel="voice", + queue_id=queue_id, + status="closed", + created_at="2026-03-02T08:00:00Z", + answered=True, + wait_seconds=15, + handle_seconds=90, + resolved_first_contact=True, + source="test-suite", + ) + + assert first.json()["interaction_id"] == interaction_id + assert second.json()["interaction_id"] == interaction_id + + session = get_session() + try: + rows = session.execute( + select(ReportingInteractionFactRow).where( + ReportingInteractionFactRow.interaction_id == interaction_id + ) + ).scalars().all() + assert len(rows) == 1 + row = rows[0] + assert row.status == "closed" + assert row.answered is True + assert row.wait_seconds == 15 + assert row.handle_seconds == 90 + assert row.resolved_first_contact is True + finally: + session.close() + + +def test_reporting_kpi_uses_exact_voice_fact_layer_and_exposes_coverage(): + client = TestClient(reporting_app) + marker = uuid4().hex[:8] + queue_id = f"q_stage3_kpi_{marker}" + window = { + "from_ts": "2026-03-03T00:00:00Z", + "to_ts": "2026-03-04T00:00:00Z", + } + + rows = [ + { + "interaction_id": f"int_{marker}_one", + "channel": "voice", + "queue_id": queue_id, + "status": "closed", + "created_at": "2026-03-03T08:00:00Z", + "closed_at": "2026-03-03T08:10:00Z", + "answered": True, + "wait_seconds": 20, + "handle_seconds": 100, + "abandoned": False, + "resolved_first_contact": True, + "source": "test-suite", + }, + { + "interaction_id": f"int_{marker}_two", + "channel": "voice", + "queue_id": queue_id, + "status": "closed", + "created_at": "2026-03-03T09:00:00Z", + "closed_at": "2026-03-03T09:15:00Z", + "answered": True, + "wait_seconds": 40, + "handle_seconds": 120, + "abandoned": False, + "resolved_first_contact": False, + "source": "test-suite", + }, + { + "interaction_id": f"int_{marker}_three", + "channel": "voice", + "queue_id": queue_id, + "status": "abandoned", + "created_at": "2026-03-03T10:00:00Z", + "closed_at": "2026-03-03T10:03:00Z", + "answered": False, + "wait_seconds": 10, + "handle_seconds": 0, + "abandoned": True, + "resolved_first_contact": False, + "source": "test-suite", + }, + ] + + for row in rows: + _post_reporting_fact(client, **row) + + kpi = client.get( + "/reports/kpi", + params={**window, "queue_id": queue_id, "sl_threshold_seconds": 30}, + ) + assert kpi.status_code == 200 + body = kpi.json() + assert body["volume"]["total"] == 3 + assert body["volume"]["answered"] == 2 + assert body["volume"]["abandoned"] == 1 + assert body["kpi"]["SL"] == pytest.approx(33.33, rel=0, abs=0.01) + assert body["kpi"]["ASA"] == pytest.approx(30.0, rel=0, abs=0.01) + assert body["kpi"]["AHT"] == pytest.approx(110.0, rel=0, abs=0.01) + assert body["kpi"]["Abandon"] == pytest.approx(33.33, rel=0, abs=0.01) + assert body["kpi"]["FCR"] == pytest.approx(50.0, rel=0, abs=0.01) + assert body["kpi"]["AnswerRate"] == pytest.approx(66.67, rel=0, abs=0.01) + assert body["kpi"]["DigitalShare"] == pytest.approx(0.0, rel=0, abs=0.01) + assert body["breakdowns"]["by_channel"]["voice"]["total"] == 3 + assert body["coverage"]["metric_status"]["total"] == "exact" + assert body["coverage"]["metric_status"]["answered"] == "exact" + assert body["coverage"]["metric_status"]["SL"] == "exact" + assert body["coverage"]["metric_status"]["ASA"] == "exact" + assert body["coverage"]["metric_status"]["AHT"] == "exact" + assert body["coverage"]["metric_status"]["Abandon"] == "exact" + assert body["coverage"]["metric_status"]["FCR"] == "exact" + assert body["coverage"]["metric_status"]["DigitalShare"] == "exact" + assert body["coverage"]["supported_channels"]["SL"] == ["voice"] + assert body["coverage"]["exact_rows"] == 3 + assert body["coverage"]["total_rows"] == 3 + + coverage = client.get("/reports/coverage") + assert coverage.status_code == 200 + coverage_payload = coverage.json() + metric_codes = {item["code"] for item in coverage_payload["implemented_metrics"]} + assert "SL" in metric_codes + assert "AnswerRate" in metric_codes + assert "DigitalShare" in metric_codes + + +def test_reporting_drilldown_returns_exact_metric_slices_and_fact_fields(): + client = TestClient(reporting_app) + marker = uuid4().hex[:8] + queue_id = f"q_stage3_drilldown_{marker}" + session = get_session() + try: + session.add_all( + [ + Interaction( + interaction_id=f"int_{marker}_one", + channel="voice", + subject=f"Voice exact one {marker}", + customer_id=None, + queue_id=queue_id, + priority=3, + status="closed", + assigned_to="agent.alpha", + created_at="2026-03-04T08:00:00+00:00", + updated_at="2026-03-04T08:10:00+00:00", + ), + Interaction( + interaction_id=f"int_{marker}_two", + channel="voice", + subject=f"Voice exact two {marker}", + customer_id=None, + queue_id=queue_id, + priority=3, + status="closed", + assigned_to="agent.beta", + created_at="2026-03-04T09:00:00+00:00", + updated_at="2026-03-04T09:15:00+00:00", + ), + Interaction( + interaction_id=f"int_{marker}_three", + channel="voice", + subject=f"Voice abandoned {marker}", + customer_id=None, + queue_id=queue_id, + priority=3, + status="abandoned", + assigned_to=None, + created_at="2026-03-04T10:00:00+00:00", + updated_at="2026-03-04T10:03:00+00:00", + ), + ] + ) + session.commit() + finally: + session.close() + + for row in [ + { + "interaction_id": f"int_{marker}_one", + "channel": "voice", + "queue_id": queue_id, + "agent_id": "agent.alpha", + "status": "closed", + "created_at": "2026-03-04T08:00:00Z", + "closed_at": "2026-03-04T08:10:00Z", + "answered": True, + "wait_seconds": 20, + "handle_seconds": 100, + "abandoned": False, + "resolved_first_contact": True, + "source": "test-suite", + }, + { + "interaction_id": f"int_{marker}_two", + "channel": "voice", + "queue_id": queue_id, + "agent_id": "agent.beta", + "status": "closed", + "created_at": "2026-03-04T09:00:00Z", + "closed_at": "2026-03-04T09:15:00Z", + "answered": True, + "wait_seconds": 40, + "handle_seconds": 120, + "abandoned": False, + "resolved_first_contact": False, + "source": "test-suite", + }, + { + "interaction_id": f"int_{marker}_three", + "channel": "voice", + "queue_id": queue_id, + "status": "abandoned", + "created_at": "2026-03-04T10:00:00Z", + "closed_at": "2026-03-04T10:03:00Z", + "answered": False, + "wait_seconds": 10, + "handle_seconds": 0, + "abandoned": True, + "resolved_first_contact": False, + "source": "test-suite", + }, + ]: + _post_reporting_fact(client, **row) + + base_params = { + "from_ts": "2026-03-04T00:00:00Z", + "to_ts": "2026-03-05T00:00:00Z", + "queue_id": queue_id, + "sl_threshold_seconds": 30, + } + + sl_slice = client.get("/reports/drilldown", params={**base_params, "metric": "SL"}) + assert sl_slice.status_code == 200 + sl_payload = sl_slice.json() + assert sl_payload["metric"] == "SL" + assert sl_payload["coverage"]["status"] == "exact" + assert sl_payload["total"] == 1 + assert [item["interaction_id"] for item in sl_payload["items"]] == [f"int_{marker}_one"] + assert sl_payload["items"][0]["within_sla"] is True + assert sl_payload["items"][0]["wait_seconds"] == 20 + assert sl_payload["items"][0]["handle_seconds"] == 100 + assert sl_payload["items"][0]["resolved_first_contact"] is True + + aht_slice = client.get("/reports/drilldown", params={**base_params, "metric": "AHT"}) + assert aht_slice.status_code == 200 + aht_payload = aht_slice.json() + assert aht_payload["total"] == 2 + assert [item["interaction_id"] for item in aht_payload["items"][:2]] == [ + f"int_{marker}_two", + f"int_{marker}_one", + ] + assert aht_payload["items"][0]["handle_seconds"] == 120 + + abandon_slice = client.get("/reports/drilldown", params={**base_params, "metric": "Abandon"}) + assert abandon_slice.status_code == 200 + abandon_payload = abandon_slice.json() + assert abandon_payload["total"] == 1 + assert abandon_payload["items"][0]["interaction_id"] == f"int_{marker}_three" + assert abandon_payload["items"][0]["abandoned"] is True + + fcr_slice = client.get("/reports/drilldown", params={**base_params, "metric": "FCR"}) + assert fcr_slice.status_code == 200 + fcr_payload = fcr_slice.json() + assert fcr_payload["total"] == 1 + assert fcr_payload["items"][0]["interaction_id"] == f"int_{marker}_one" + assert fcr_payload["items"][0]["resolved_first_contact"] is True + + +def test_reporting_kpi_coverage_is_partial_for_mixed_voice_and_digital_windows(): + client = TestClient(reporting_app) + marker = uuid4().hex[:8] + queue_id = f"q_stage3_mixed_{marker}" + window = { + "from_ts": "2026-03-05T00:00:00Z", + "to_ts": "2026-03-06T00:00:00Z", + } + + _post_reporting_fact( + client, + interaction_id=f"int_{marker}_voice", + channel="voice", + queue_id=queue_id, + status="closed", + created_at="2026-03-05T08:00:00Z", + closed_at="2026-03-05T08:10:00Z", + answered=True, + wait_seconds=18, + handle_seconds=80, + abandoned=False, + resolved_first_contact=True, + source="test-suite", + ) + _post_reporting_fact( + client, + interaction_id=f"int_{marker}_telegram", + channel="telegram", + queue_id=queue_id, + status="closed", + created_at="2026-03-05T09:00:00Z", + closed_at="2026-03-05T09:05:00Z", + source="test-suite", + ) + + kpi = client.get("/reports/kpi", params={**window, "queue_id": queue_id, "sl_threshold_seconds": 30}) + assert kpi.status_code == 200 + payload = kpi.json() + assert payload["volume"]["total"] == 2 + assert payload["kpi"]["DigitalShare"] == pytest.approx(50.0, rel=0, abs=0.01) + assert payload["coverage"]["metric_status"]["total"] == "exact" + assert payload["coverage"]["metric_status"]["DigitalShare"] == "exact" + assert payload["coverage"]["metric_status"]["SL"] == "partial" + assert payload["coverage"]["metric_status"]["ASA"] == "partial" + assert payload["coverage"]["metric_status"]["FCR"] == "partial" + + asa_slice = client.get("/reports/drilldown", params={**window, "queue_id": queue_id, "metric": "ASA"}) + assert asa_slice.status_code == 200 + asa_payload = asa_slice.json() + assert asa_payload["coverage"]["status"] == "partial" + assert asa_payload["total"] == 1 + assert asa_payload["items"][0]["interaction_id"] == f"int_{marker}_voice" + + +def test_interaction_status_close_with_resolved_first_contact_updates_reporting_fact(): + interaction_client = TestClient(interaction_app) + reporting_client = TestClient(reporting_app) + marker = uuid4().hex[:8] + headers = {"X-User": "operator", "X-Role": "operator"} + + created = interaction_client.post( + "/interactions", + json={ + "channel": "voice", + "subject": f"Resolved FCR {marker}", + "queue_id": f"q_interaction_{marker}", + "priority": 3, + }, + headers=headers, + ) + assert created.status_code == 200 + interaction = created.json() + interaction_id = interaction["interaction_id"] + + _post_reporting_fact( + reporting_client, + interaction_id=interaction_id, + channel="voice", + queue_id=interaction["queue_id"], + status="in_progress", + created_at=interaction["created_at"], + answered=True, + wait_seconds=14, + handle_seconds=95, + resolved_first_contact=False, + source="test-suite", + ) + + closed = interaction_client.patch( + f"/interactions/{interaction_id}/status", + json={"status": "closed", "resolved_first_contact": True}, + headers=headers, + ) + assert closed.status_code == 200 + assert closed.json()["status"] == "closed" + + session = get_session() + try: + row = session.execute( + select(ReportingInteractionFactRow).where( + ReportingInteractionFactRow.interaction_id == interaction_id + ) + ).scalar_one() + assert row.status == "closed" + assert row.resolved_first_contact is True + assert row.answered is True + assert row.handle_seconds == 95 + assert row.closed_at is not None + finally: + session.close() + + fcr_slice = reporting_client.get( + "/reports/drilldown", + params={ + "from_ts": "2026-01-01T00:00:00Z", + "to_ts": "2027-01-01T00:00:00Z", + "queue_id": interaction["queue_id"], + "channel": "voice", + "metric": "FCR", + }, + ) + assert fcr_slice.status_code == 200 + fcr_payload = fcr_slice.json() + assert interaction_id in {item["interaction_id"] for item in fcr_payload["items"]} + + +def test_reporting_export_csv(): + client = TestClient(reporting_app) + + ingested = client.post( + "/reports/events", + json={ + "queue_id": "q_stage3_csv", + "answered": True, + "wait_seconds": 12, + "handle_seconds": 90, + "abandoned": False, + "resolved_first_contact": True, + }, + ) + assert ingested.status_code == 200 + + exported = client.get("/reports/export") + assert exported.status_code == 200 + assert exported.headers.get("content-type", "").startswith("text/csv") + assert ( + "queue_id,channel,agent_id,answered,wait_seconds,handle_seconds,abandoned,resolved_first_contact,created_at" + in exported.text + ) + assert "q_stage3_csv" in exported.text + + +def test_reporting_saved_views_are_server_side_per_user(): + client = TestClient(reporting_app) + marker = uuid4().hex[:8] + + created = client.post( + "/reports/views", + headers=_analyst_headers(), + json={ + "name": f"Telegram {marker}", + "snapshot": { + "preset": "30d", + "channel": "telegram", + "queueId": "all", + "compareMode": "previous", + "trendMetric": "volume", + "aiTrendMetric": "containment_rate", + }, + }, + ) + assert created.status_code == 200 + payload = created.json() + assert payload["name"] == f"Telegram {marker}" + assert payload["snapshot"]["channel"] == "telegram" + + listed = client.get("/reports/views", headers=_analyst_headers()) + assert listed.status_code == 200 + listed_payload = listed.json() + assert payload["id"] in {item["id"] for item in listed_payload} + + other_user = client.get("/reports/views", headers={"X-User": f"analyst_{marker}", "X-Role": "analyst"}) + assert other_user.status_code == 200 + assert payload["id"] not in {item["id"] for item in other_user.json()} + + deleted = client.delete(f"/reports/views/{payload['id']}", headers=_analyst_headers()) + assert deleted.status_code == 200 + assert deleted.json()["accepted"] is True + + +def test_reporting_timeseries_uses_exact_fact_layer(): + client = TestClient(reporting_app) + marker = uuid4().hex[:8] + queue_id = f"q_timeseries_{marker}" + _post_reporting_fact( + client, + interaction_id=f"int_{marker}_day1", + channel="voice", + queue_id=queue_id, + status="closed", + created_at="2026-03-10T08:00:00Z", + closed_at="2026-03-10T08:10:00Z", + answered=True, + wait_seconds=20, + handle_seconds=100, + abandoned=False, + resolved_first_contact=True, + source="test-suite", + ) + _post_reporting_fact( + client, + interaction_id=f"int_{marker}_day2", + channel="voice", + queue_id=queue_id, + status="closed", + created_at="2026-03-11T08:00:00Z", + closed_at="2026-03-11T08:10:00Z", + answered=True, + wait_seconds=45, + handle_seconds=80, + abandoned=False, + resolved_first_contact=False, + source="test-suite", + ) + + response = client.get( + "/reports/timeseries", + params={ + "from_ts": "2026-03-10T00:00:00Z", + "to_ts": "2026-03-12T00:00:00Z", + "queue_id": queue_id, + "metric": "SL", + "interval": "day", + "sl_threshold_seconds": 30, + }, + ) + assert response.status_code == 200 + payload = response.json() + assert payload["metric"] == "SL" + assert payload["interval"] == "day" + assert [point["value"] for point in payload["points"]] == [100.0, 0.0] + assert [point["sample_size"] for point in payload["points"]] == [1, 1] + + +def test_reporting_agent_overview_aggregates_facts_and_current_states(): + client = TestClient(reporting_app) + supervisor_client = TestClient(supervisor_app) + marker = uuid4().hex[:8] + queue_id = f"q_agent_overview_{marker}" + + _post_reporting_fact( + client, + interaction_id=f"int_{marker}_one", + channel="voice", + queue_id=queue_id, + agent_id=f"agent.{marker}.alpha", + status="closed", + created_at="2026-03-18T08:00:00Z", + closed_at="2026-03-18T08:10:00Z", + answered=True, + wait_seconds=12, + handle_seconds=75, + abandoned=False, + resolved_first_contact=True, + source="test-suite", + ) + _post_reporting_fact( + client, + interaction_id=f"int_{marker}_two", + channel="voice", + queue_id=queue_id, + agent_id=f"agent.{marker}.alpha", + status="in_progress", + created_at="2026-03-18T09:00:00Z", + answered=True, + wait_seconds=20, + handle_seconds=90, + abandoned=False, + resolved_first_contact=False, + source="test-suite", + ) + _post_reporting_fact( + client, + interaction_id=f"int_{marker}_three", + channel="voice", + queue_id=queue_id, + agent_id=f"agent.{marker}.beta", + status="closed", + created_at="2026-03-18T10:00:00Z", + closed_at="2026-03-18T10:05:00Z", + answered=False, + wait_seconds=25, + handle_seconds=0, + abandoned=True, + resolved_first_contact=False, + source="test-suite", + ) + + state_alpha = supervisor_client.post( + "/supervisor/agent-states", + headers=_admin_headers(), + json={"agent_id": f"agent.{marker}.alpha", "state": "READY", "queue_id": queue_id}, + ) + assert state_alpha.status_code == 200 + state_gamma = supervisor_client.post( + "/supervisor/agent-states", + headers=_admin_headers(), + json={"agent_id": f"agent.{marker}.gamma", "state": "BREAK", "queue_id": queue_id}, + ) + assert state_gamma.status_code == 200 + + response = client.get( + "/reports/agents/overview", + params={ + "from_ts": "2026-03-18T00:00:00Z", + "to_ts": "2026-03-19T00:00:00Z", + "queue_id": queue_id, + }, + ) + assert response.status_code == 200 + payload = response.json() + assert payload["totals"]["agents_total"] >= 3 + assert payload["totals"]["agents_with_activity"] == 2 + assert payload["totals"]["interactions_total"] == 3 + assert payload["totals"]["answered_total"] == 2 + assert payload["totals"]["ready_now"] >= 1 + assert payload["totals"]["break_now"] >= 1 + assert payload["filters"]["sort_by"] == "interactions_total" + assert payload["filters"]["sort_dir"] == "desc" + assert payload["items"][0]["agent_id"] == f"agent.{marker}.alpha" + assert payload["items"][0]["interactions_total"] == 2 + assert payload["items"][0]["avg_handle_seconds"] == pytest.approx(82.5, rel=0, abs=0.01) + assert payload["items"][0]["fcr_rate"] == pytest.approx(50.0, rel=0, abs=0.01) + assert payload["state_snapshot"]["by_state"]["READY"] >= 1 + assert payload["breakdowns"]["by_team"][0]["team_key"] == queue_id + assert payload["breakdowns"]["by_team"][0]["interactions_total"] == 3 + day_shift = next(item for item in payload["breakdowns"]["by_shift"] if item["shift_key"] == "day") + assert day_shift["interactions_total"] == 3 + assert day_shift["answered_total"] == 2 + + +def test_reporting_agent_timeseries_returns_metric_points(): + client = TestClient(reporting_app) + marker = uuid4().hex[:8] + queue_id = f"q_agent_ts_{marker}" + + _post_reporting_fact( + client, + interaction_id=f"int_{marker}_one", + channel="voice", + queue_id=queue_id, + agent_id=f"agent.{marker}.alpha", + status="closed", + created_at="2026-03-20T08:00:00Z", + closed_at="2026-03-20T08:06:00Z", + answered=True, + wait_seconds=10, + handle_seconds=60, + abandoned=False, + resolved_first_contact=True, + source="test-suite", + ) + _post_reporting_fact( + client, + interaction_id=f"int_{marker}_two", + channel="voice", + queue_id=queue_id, + agent_id=f"agent.{marker}.beta", + status="closed", + created_at="2026-03-20T09:00:00Z", + closed_at="2026-03-20T09:08:00Z", + answered=True, + wait_seconds=14, + handle_seconds=120, + abandoned=False, + resolved_first_contact=False, + source="test-suite", + ) + _post_reporting_fact( + client, + interaction_id=f"int_{marker}_three", + channel="voice", + queue_id=queue_id, + agent_id=f"agent.{marker}.alpha", + status="closed", + created_at="2026-03-21T11:00:00Z", + closed_at="2026-03-21T11:07:00Z", + answered=True, + wait_seconds=11, + handle_seconds=90, + abandoned=False, + resolved_first_contact=True, + source="test-suite", + ) + + response = client.get( + "/reports/agents/timeseries", + params={ + "from_ts": "2026-03-20T00:00:00Z", + "to_ts": "2026-03-22T00:00:00Z", + "queue_id": queue_id, + "metric": "interactions_per_agent", + "interval": "day", + }, + ) + assert response.status_code == 200 + payload = response.json() + assert payload["metric"] == "interactions_per_agent" + assert payload["interval"] == "day" + assert payload["filters"]["queue_id"] == queue_id + assert [point["interactions_total"] for point in payload["points"]] == [2, 1] + assert [point["agents_with_activity"] for point in payload["points"]] == [2, 1] + assert [point["value"] for point in payload["points"]] == [1.0, 1.0] + + +def test_reporting_export_exact_csv_from_fact_window(): + client = TestClient(reporting_app) + marker = uuid4().hex[:8] + queue_id = f"q_export_exact_{marker}" + interaction_id = f"int_{marker}_export" + _post_reporting_fact( + client, + interaction_id=interaction_id, + channel="voice", + queue_id=queue_id, + status="closed", + created_at="2026-03-15T08:00:00Z", + closed_at="2026-03-15T08:05:00Z", + answered=True, + wait_seconds=12, + handle_seconds=55, + abandoned=False, + resolved_first_contact=True, + source="test-suite", + ) + + exported = client.get( + "/reports/export", + params={ + "from_ts": "2026-03-15T00:00:00Z", + "to_ts": "2026-03-16T00:00:00Z", + "queue_id": queue_id, + "metric": "FCR", + }, + ) + assert exported.status_code == 200 + assert exported.headers.get("content-type", "").startswith("text/csv") + assert "interaction_id,subject,channel,status,queue_id,assigned_to,created_at,updated_at,answered,abandoned,wait_seconds,handle_seconds,within_sla,resolved_first_contact" in exported.text + assert interaction_id in exported.text + + +def test_supervisor_realtime(): + client = TestClient(supervisor_app) + supervisor_headers = {"X-User": "supervisor", "X-Role": "supervisor"} + analyst_headers = {"X-User": "analyst", "X-Role": "analyst"} + + up1 = client.post( + "/supervisor/agent-states", + json={"agent_id": "a1", "state": "READY", "queue_id": "q1"}, + headers=supervisor_headers, + ) + up2 = client.post( + "/supervisor/agent-states", + json={"agent_id": "a2", "state": "BUSY", "queue_id": "q1"}, + headers=supervisor_headers, + ) + assert up1.status_code == 200 + assert up2.status_code == 200 + + queue = client.post( + "/supervisor/queue-metrics", + params={"queue_id": "q1", "in_queue": 2, "avg_wait_seconds": 18}, + headers=supervisor_headers, + ) + assert queue.status_code == 200 + + rt = client.get("/supervisor/realtime", headers=analyst_headers) + assert rt.status_code == 200 + assert rt.json()["agents"]["total"] >= 2 + assert len(rt.json()["queues"]) >= 1 + + +def test_supervisor_role_enforcement(): + client = TestClient(supervisor_app) + + denied = client.post( + "/supervisor/agent-states", + json={"agent_id": "op-1", "state": "READY", "queue_id": "q1"}, + headers={"X-User": "operator", "X-Role": "operator"}, + ) + assert denied.status_code == 403 diff --git a/tests/test_telegram_adapter_service.py b/tests/test_telegram_adapter_service.py new file mode 100644 index 0000000..088d925 --- /dev/null +++ b/tests/test_telegram_adapter_service.py @@ -0,0 +1,846 @@ +import threading + +from fastapi import HTTPException +from fastapi.testclient import TestClient +from sqlalchemy import select + +from services.interaction_service.app import app as interaction_app +from services.shared.db import get_session +from services.shared.sql_models import Customer, CustomerExternalIdentity, Interaction, TelegramMessageRow, TelegramThreadRow +from services.telegram_adapter_service import app as telegram_module +from services.telegram_adapter_service.app import app as telegram_app + + +def admin_headers(): + return {"X-User": "admin", "X-Role": "admin"} + + +def operator_headers(user="operator"): + return {"X-User": user, "X-Role": "operator"} + + +def patch_interaction_request(monkeypatch): + def fake_request(method: str, path: str, *, payload: dict | None = None) -> dict: + session = get_session() + try: + interaction_id = path.split("/")[2] + interaction = session.execute( + select(Interaction).where(Interaction.interaction_id == interaction_id) + ).scalar_one() + if path.endswith("/assign"): + interaction.assigned_to = payload["assignee"] + interaction.status = "in_progress" + elif path.endswith("/status"): + interaction.status = payload["status"] + elif path.endswith("/escalate"): + interaction.status = "escalated" + interaction.queue_id = payload["target_queue_id"] + interaction.updated_at = telegram_module.utc_now_iso() + session.commit() + return { + "interaction_id": interaction.interaction_id, + "status": interaction.status, + "assigned_to": interaction.assigned_to, + "queue_id": interaction.queue_id, + } + finally: + session.close() + + monkeypatch.setattr(telegram_module, "_interaction_request", fake_request) + + +def test_manual_webhook_creates_thread_and_linked_interaction(): + telegram_client = TestClient(telegram_app) + interaction_client = TestClient(interaction_app) + + response = telegram_client.post( + "/integrations/telegram/webhook", + json={ + "chat_id": "chat_1", + "text": "Привет из Telegram", + "customer_external_id": "telegram:user_1", + "payload": { + "username": "lead_one", + "first_name": "Lead", + "last_name": "One", + "telegram_user_id": "tg_user_1", + }, + }, + ) + assert response.status_code == 200 + payload = response.json() + assert payload["thread_id"].startswith("tgt_") + assert payload["interaction_id"].startswith("int_") + assert payload["direction"] == "inbound" + + thread_list = telegram_client.get("/integrations/telegram/threads", headers=admin_headers()) + assert thread_list.status_code == 200 + assert thread_list.json()[0]["chat_id"] == "chat_1" + assert thread_list.json()[0]["interaction_id"] == payload["interaction_id"] + + interaction = interaction_client.get(f"/interactions/{payload['interaction_id']}") + assert interaction.status_code == 200 + assert interaction.json()["channel"] == "telegram" + assert interaction.json()["customer_id"].startswith("cus_") + + session = get_session() + try: + identity = session.execute( + select(CustomerExternalIdentity).where( + CustomerExternalIdentity.channel == "telegram", + CustomerExternalIdentity.external_subject == "tg_user_1", + ) + ).scalar_one_or_none() + assert identity is not None + assert identity.customer_id == interaction.json()["customer_id"] + finally: + session.close() + + +def test_repeated_inbound_reuses_same_thread_and_interaction(): + telegram_client = TestClient(telegram_app) + + first = telegram_client.post( + "/integrations/telegram/webhook", + json={"chat_id": "chat_reuse", "text": "Первое", "payload": {"username": "reuse_user"}}, + ) + second = telegram_client.post( + "/integrations/telegram/webhook", + json={"chat_id": "chat_reuse", "text": "Второе", "payload": {"username": "reuse_user"}}, + ) + + assert first.status_code == 200 + assert second.status_code == 200 + assert first.json()["thread_id"] == second.json()["thread_id"] + assert first.json()["interaction_id"] == second.json()["interaction_id"] + + +def test_list_messages_requires_roles(): + telegram_client = TestClient(telegram_app) + created = telegram_client.post( + "/integrations/telegram/webhook", + json={"chat_id": "chat_messages_auth", "text": "Auth check"}, + ) + assert created.status_code == 200 + + anonymous = telegram_client.get( + "/integrations/telegram/messages", + params={"chat_id": "chat_messages_auth"}, + ) + assert anonymous.status_code == 403 + + admin = telegram_client.get( + "/integrations/telegram/messages", + params={"chat_id": "chat_messages_auth"}, + headers=admin_headers(), + ) + assert admin.status_code == 200 + assert len(admin.json()) == 1 + + operator = telegram_client.get( + "/integrations/telegram/messages", + params={"chat_id": "chat_messages_auth"}, + headers=operator_headers(), + ) + assert operator.status_code == 200 + assert len(operator.json()) == 1 + + +def test_closed_interaction_is_reactivated_on_new_inbound(): + telegram_client = TestClient(telegram_app) + interaction_client = TestClient(interaction_app) + + created = telegram_client.post( + "/integrations/telegram/webhook", + json={"chat_id": "chat_reactivate", "text": "Первое сообщение"}, + ) + assert created.status_code == 200 + interaction_id = created.json()["interaction_id"] + thread_id = created.json()["thread_id"] + + session = get_session() + try: + thread = session.execute(select(TelegramThreadRow).where(TelegramThreadRow.thread_id == thread_id)).scalar_one() + interaction = session.execute(select(Interaction).where(Interaction.interaction_id == interaction_id)).scalar_one() + thread.status = "closed" + interaction.status = "closed" + session.commit() + finally: + session.close() + + reopened = telegram_client.post( + "/integrations/telegram/webhook", + json={"chat_id": "chat_reactivate", "text": "Новое сообщение после close"}, + ) + assert reopened.status_code == 200 + assert reopened.json()["thread_id"] == thread_id + assert reopened.json()["interaction_id"] == interaction_id + + interaction = interaction_client.get(f"/interactions/{interaction_id}") + assert interaction.status_code == 200 + assert interaction.json()["status"] == "new" + + +def test_operator_can_claim_thread_and_foreign_operator_cannot_reply(monkeypatch): + patch_interaction_request(monkeypatch) + telegram_client = TestClient(telegram_app) + + created = telegram_client.post( + "/integrations/telegram/webhook", + json={"chat_id": "chat_claim", "text": "Нужна помощь"}, + ) + thread_id = created.json()["thread_id"] + + claimed = telegram_client.post( + f"/integrations/telegram/threads/{thread_id}/claim", + headers=operator_headers("operator_a"), + ) + assert claimed.status_code == 200 + assert claimed.json()["claimed_by_user"] == "operator_a" + assert claimed.json()["status"] == "in_progress" + + forbidden = telegram_client.post( + f"/integrations/telegram/threads/{thread_id}/messages", + headers=operator_headers("operator_b"), + json={"text": "Чужой reply"}, + ) + assert forbidden.status_code == 403 + + +def test_operator_reply_sends_outbound_message_and_persists_row(monkeypatch): + patch_interaction_request(monkeypatch) + started: list[str] = [] + monkeypatch.setattr(telegram_module, "_start_telegram_reply_delivery", lambda message_id: started.append(message_id)) + + telegram_client = TestClient(telegram_app) + created = telegram_client.post( + "/integrations/telegram/webhook", + json={"chat_id": "chat_reply", "text": "Вопрос клиента"}, + ) + thread_id = created.json()["thread_id"] + + claimed = telegram_client.post( + f"/integrations/telegram/threads/{thread_id}/claim", + headers=operator_headers("operator"), + ) + assert claimed.status_code == 200 + + sent = telegram_client.post( + f"/integrations/telegram/threads/{thread_id}/messages", + headers=operator_headers("operator"), + json={"text": "Ответ оператора"}, + ) + assert sent.status_code == 200 + payload = sent.json() + assert payload["direction"] == "outbound" + assert payload["delivery_status"] == "pending" + assert payload["telegram_message_id_external"] is None + assert payload["operator_user"] == "operator" + assert payload["author_type"] == "human" + assert payload["author_id"] == "operator" + assert started == [payload["message_id"]] + + messages = telegram_client.get( + f"/integrations/telegram/threads/{thread_id}/messages", + headers=admin_headers(), + ) + assert messages.status_code == 200 + assert len(messages.json()) == 2 + assert messages.json()[-1]["delivery_status"] == "pending" + assert messages.json()[-1]["text"] == "Ответ оператора" + monkeypatch.setattr( + telegram_module, + "_send_telegram_message", + lambda chat_id, text: {"ok": True, "result": {"message_id": 7788, "chat": {"id": chat_id}, "text": text}}, + ) + telegram_module._deliver_pending_telegram_reply(payload["message_id"]) + + delivered = telegram_client.get( + f"/integrations/telegram/threads/{thread_id}/messages", + headers=admin_headers(), + ) + assert delivered.status_code == 200 + assert delivered.json()[-1]["delivery_status"] == "sent" + assert delivered.json()[-1]["telegram_message_id_external"] == "7788" + + +def test_returning_human_owned_thread_to_ai_reopens_ai_flow(monkeypatch): + monkeypatch.setenv("AI_TELEGRAM_ENABLED", "1") + enqueue_calls: list[tuple[str, str | None]] = [] + monkeypatch.setattr( + telegram_module, + "_ai_enqueue_request", + lambda thread_id, trigger_message_id: enqueue_calls.append((thread_id, trigger_message_id)), + ) + patch_interaction_request(monkeypatch) + + telegram_client = TestClient(telegram_app) + created = telegram_client.post( + "/integrations/telegram/webhook", + json={ + "chat_id": "chat_return_ai", + "text": "Первое сообщение", + "payload": {"telegram_user_id": "tg_return_ai", "username": "return_ai_user"}, + }, + ) + assert created.status_code == 200 + thread_id = created.json()["thread_id"] + first_message_id = created.json()["message_id"] + enqueue_calls.clear() + + claimed = telegram_client.post( + f"/integrations/telegram/threads/{thread_id}/claim", + headers=operator_headers("operator_ai"), + ) + assert claimed.status_code == 200 + assert claimed.json()["ai_state"] == "human_owned" + + returned = telegram_client.post( + f"/integrations/telegram/threads/{thread_id}/return-to-ai", + headers=operator_headers("operator_ai"), + ) + assert returned.status_code == 200 + assert returned.json()["ai_state"] == "queued" + assert returned.json()["claimed_by_user"] is None + assert returned.json()["status"] == "new" + assert enqueue_calls == [] + + follow_up = telegram_client.post( + "/integrations/telegram/webhook", + json={ + "chat_id": "chat_return_ai", + "text": "РќРѕРІРѕРµ сообщение после возврата AI", + "payload": {"telegram_user_id": "tg_return_ai", "username": "return_ai_user"}, + }, + ) + assert follow_up.status_code == 200 + assert follow_up.json()["thread_id"] == thread_id + assert follow_up.json()["message_id"] != first_message_id + assert enqueue_calls == [(thread_id, follow_up.json()["message_id"])] + + session = get_session() + try: + thread = session.execute( + select(TelegramThreadRow).where(TelegramThreadRow.thread_id == thread_id) + ).scalar_one() + interaction = session.execute( + select(Interaction).where(Interaction.interaction_id == thread.interaction_id) + ).scalar_one() + assert thread.ai_state == "queued" + assert thread.claimed_by_user is None + assert thread.status == "new" + assert interaction.status == "new" + assert interaction.assigned_to is None + finally: + session.close() + + +def test_failed_reply_is_scheduled_for_retry_and_then_sent(monkeypatch): + patch_interaction_request(monkeypatch) + monkeypatch.setenv("TELEGRAM_REPLY_DELIVERY_POLL_SECONDS", "3600") + monkeypatch.setattr(telegram_module, "_start_telegram_reply_delivery", lambda message_id: None) + + telegram_client = TestClient(telegram_app) + created = telegram_client.post( + "/integrations/telegram/webhook", + json={"chat_id": "chat_retry", "text": "Retry me"}, + ) + thread_id = created.json()["thread_id"] + + claimed = telegram_client.post( + f"/integrations/telegram/threads/{thread_id}/claim", + headers=operator_headers("operator_retry"), + ) + assert claimed.status_code == 200 + + reply = telegram_client.post( + f"/integrations/telegram/threads/{thread_id}/messages", + headers=operator_headers("operator_retry"), + json={"text": "Please retry"}, + ) + assert reply.status_code == 200 + message_id = reply.json()["message_id"] + + attempts: list[int] = [] + + def flaky_send(chat_id: str, text: str) -> dict: + attempts.append(len(attempts) + 1) + if len(attempts) == 1: + raise HTTPException(status_code=502, detail="temporary send failure") + return {"ok": True, "result": {"message_id": 8899, "chat": {"id": chat_id}, "text": text}} + + monkeypatch.setattr(telegram_module, "_send_telegram_message", flaky_send) + + assert telegram_module._deliver_pending_telegram_reply(message_id) is False + + session = get_session() + try: + row = session.execute( + select(TelegramMessageRow).where(TelegramMessageRow.message_id == message_id) + ).scalar_one() + assert row.delivery_status == "retrying" + assert row.delivery_attempts == 1 + assert row.next_delivery_attempt_at is not None + assert row.delivery_locked_until is None + assert row.last_delivery_error == "temporary send failure" + row.next_delivery_attempt_at = telegram_module.utc_now_iso() + session.commit() + finally: + session.close() + + assert telegram_module._deliver_pending_telegram_reply(message_id) is True + assert attempts == [1, 2] + + session = get_session() + try: + row = session.execute( + select(TelegramMessageRow).where(TelegramMessageRow.message_id == message_id) + ).scalar_one() + assert row.delivery_status == "sent" + assert row.delivery_attempts == 2 + assert row.telegram_message_id_external == "8899" + assert row.next_delivery_attempt_at is None + assert row.delivery_locked_until is None + assert row.last_delivery_error is None + finally: + session.close() + + +def test_worker_batch_recovers_stale_sending_reply(monkeypatch): + patch_interaction_request(monkeypatch) + monkeypatch.setenv("TELEGRAM_REPLY_DELIVERY_POLL_SECONDS", "3600") + monkeypatch.setattr(telegram_module, "_start_telegram_reply_delivery", lambda message_id: None) + monkeypatch.setattr( + telegram_module, + "_send_telegram_message", + lambda chat_id, text: {"ok": True, "result": {"message_id": 9901, "chat": {"id": chat_id}, "text": text}}, + ) + + telegram_client = TestClient(telegram_app) + created = telegram_client.post( + "/integrations/telegram/webhook", + json={"chat_id": "chat_stale_worker", "text": "Recover me"}, + ) + thread_id = created.json()["thread_id"] + + claimed = telegram_client.post( + f"/integrations/telegram/threads/{thread_id}/claim", + headers=operator_headers("operator_stale"), + ) + assert claimed.status_code == 200 + + reply = telegram_client.post( + f"/integrations/telegram/threads/{thread_id}/messages", + headers=operator_headers("operator_stale"), + json={"text": "Stale sending"}, + ) + assert reply.status_code == 200 + message_id = reply.json()["message_id"] + + session = get_session() + try: + row = session.execute( + select(TelegramMessageRow).where(TelegramMessageRow.message_id == message_id) + ).scalar_one() + row.delivery_status = "sending" + row.delivery_locked_until = "2000-01-01T00:00:00+00:00" + row.next_delivery_attempt_at = telegram_module.utc_now_iso() + session.commit() + finally: + session.close() + + processed = telegram_module._process_due_telegram_reply_batch(batch_size=1) + assert processed == 1 + + session = get_session() + try: + row = session.execute( + select(TelegramMessageRow).where(TelegramMessageRow.message_id == message_id) + ).scalar_one() + assert row.delivery_status == "sent" + assert row.delivery_attempts == 1 + assert row.telegram_message_id_external == "9901" + assert row.delivery_locked_until is None + finally: + session.close() + + +def test_bot_webhook_secret_and_unsupported_content_placeholder(monkeypatch): + monkeypatch.setenv("TELEGRAM_WEBHOOK_SECRET", "secret-15") + telegram_client = TestClient(telegram_app) + + forbidden = telegram_client.post( + "/integrations/telegram/bot/webhook", + json={"message": {"message_id": 1, "chat": {"id": 44}}}, + ) + assert forbidden.status_code == 403 + + accepted = telegram_client.post( + "/integrations/telegram/bot/webhook", + headers={"X-Telegram-Bot-Api-Secret-Token": "secret-15"}, + json={ + "message": { + "message_id": 9, + "chat": {"id": 555}, + "from": {"id": 77, "username": "photo_user"}, + "photo": [{"file_id": "abc"}], + } + }, + ) + assert accepted.status_code == 200 + thread_id = accepted.json()["thread_id"] + + messages = telegram_client.get( + f"/integrations/telegram/threads/{thread_id}/messages", + headers=admin_headers(), + ) + assert messages.status_code == 200 + assert messages.json()[0]["direction"] == "system" + assert "[Unsupported Telegram content: photo]" in messages.json()[0]["text"] + + +def test_bot_webhook_unsupported_content_does_not_enqueue_ai(monkeypatch): + monkeypatch.setenv("AI_TELEGRAM_ENABLED", "1") + enqueue_calls: list[tuple[str, str | None]] = [] + monkeypatch.setattr( + telegram_module, + "_ai_enqueue_request", + lambda thread_id, trigger_message_id: enqueue_calls.append((thread_id, trigger_message_id)), + ) + telegram_client = TestClient(telegram_app) + + accepted = telegram_client.post( + "/integrations/telegram/bot/webhook", + json={ + "message": { + "message_id": 19, + "chat": {"id": 559}, + "from": {"id": 79, "username": "photo_user"}, + "photo": [{"file_id": "abc"}], + } + }, + ) + + assert accepted.status_code == 200 + assert enqueue_calls == [] + + session = get_session() + try: + thread = session.execute( + select(TelegramThreadRow).where(TelegramThreadRow.thread_id == accepted.json()["thread_id"]) + ).scalar_one() + assert thread.ai_state is None + assert thread.ai_handoff_reason is None + finally: + session.close() + + +def test_bot_webhook_contact_saves_customer_phone(monkeypatch): + monkeypatch.setenv("AI_TELEGRAM_ENABLED", "1") + enqueue_calls: list[tuple[str, str | None]] = [] + monkeypatch.setattr( + telegram_module, + "_ai_enqueue_request", + lambda thread_id, trigger_message_id: enqueue_calls.append((thread_id, trigger_message_id)), + ) + telegram_client = TestClient(telegram_app) + + accepted = telegram_client.post( + "/integrations/telegram/bot/webhook", + json={ + "message": { + "message_id": 21, + "chat": {"id": 562}, + "from": {"id": 81, "username": "contact_user", "first_name": "Contact"}, + "contact": {"phone_number": "+7 (777) 123-45-67", "user_id": 81}, + } + }, + ) + + assert accepted.status_code == 200 + assert enqueue_calls == [] + + session = get_session() + try: + thread = session.execute( + select(TelegramThreadRow).where(TelegramThreadRow.thread_id == accepted.json()["thread_id"]) + ).scalar_one() + interaction = session.execute( + select(Interaction).where(Interaction.interaction_id == thread.interaction_id) + ).scalar_one() + customer = session.execute( + select(Customer).where(Customer.customer_id == interaction.customer_id) + ).scalar_one_or_none() + finally: + session.close() + + assert thread.ai_state is None + assert customer is not None + assert customer.preferred_phone == "+77771234567" + assert "+77771234567" in customer.phones_json + + messages = telegram_client.get( + f"/integrations/telegram/threads/{accepted.json()['thread_id']}/messages", + headers=admin_headers(), + ) + assert messages.status_code == 200 + assert messages.json()[0]["direction"] == "system" + assert messages.json()[0]["text"] == "[Shared Telegram contact]" + + +def test_system_message_preserves_existing_handoff_state(monkeypatch): + monkeypatch.setenv("AI_TELEGRAM_ENABLED", "1") + enqueue_calls: list[tuple[str, str | None]] = [] + monkeypatch.setattr( + telegram_module, + "_ai_enqueue_request", + lambda thread_id, trigger_message_id: enqueue_calls.append((thread_id, trigger_message_id)), + ) + telegram_client = TestClient(telegram_app) + + created = telegram_client.post( + "/integrations/telegram/webhook", + json={"chat_id": "chat_system_handoff", "text": "Need human"}, + ) + assert created.status_code == 200 + thread_id = created.json()["thread_id"] + + session = get_session() + try: + thread = session.execute(select(TelegramThreadRow).where(TelegramThreadRow.thread_id == thread_id)).scalar_one() + thread.ai_state = "handoff_required" + thread.ai_handoff_reason = "Existing handoff" + session.commit() + finally: + session.close() + + enqueue_calls.clear() + accepted = telegram_client.post( + "/integrations/telegram/bot/webhook", + json={ + "message": { + "message_id": 29, + "chat": {"id": "chat_system_handoff"}, + "from": {"id": 93, "username": "photo_user"}, + "photo": [{"file_id": "abc"}], + } + }, + ) + assert accepted.status_code == 200 + assert enqueue_calls == [] + + session = get_session() + try: + thread = session.execute(select(TelegramThreadRow).where(TelegramThreadRow.thread_id == thread_id)).scalar_one() + assert thread.ai_state == "handoff_required" + assert thread.ai_handoff_reason == "Existing handoff" + finally: + session.close() + + +def test_bot_webhook_duplicate_message_id_is_idempotent(): + telegram_client = TestClient(telegram_app) + payload = { + "update_id": 9015, + "message": { + "message_id": 5150, + "chat": {"id": 55150}, + "from": {"id": 7715, "username": "duplicate_user"}, + "text": "Duplicate payload", + }, + } + + first = telegram_client.post("/integrations/telegram/bot/webhook", json=payload) + second = telegram_client.post("/integrations/telegram/bot/webhook", json=payload) + + assert first.status_code == 200 + assert second.status_code == 200 + assert first.json()["thread_id"] == second.json()["thread_id"] + assert first.json()["interaction_id"] == second.json()["interaction_id"] + assert first.json()["message_id"] == second.json()["message_id"] + assert first.json()["thread_created"] is True + assert second.json()["thread_created"] is False + + messages = telegram_client.get( + f"/integrations/telegram/threads/{first.json()['thread_id']}/messages", + headers=admin_headers(), + ) + assert messages.status_code == 200 + assert len(messages.json()) == 1 + + +def test_reply_after_close_is_conflict_and_close_clears_claim(monkeypatch): + patch_interaction_request(monkeypatch) + monkeypatch.setattr( + telegram_module, + "_send_telegram_message", + lambda chat_id, text: (_ for _ in ()).throw(AssertionError("sendMessage should not be called")), + ) + + telegram_client = TestClient(telegram_app) + created = telegram_client.post( + "/integrations/telegram/webhook", + json={"chat_id": "chat_close_reply", "text": "Need closure"}, + ) + thread_id = created.json()["thread_id"] + + claimed = telegram_client.post( + f"/integrations/telegram/threads/{thread_id}/claim", + headers=operator_headers("operator_close"), + ) + assert claimed.status_code == 200 + + closed = telegram_client.post( + f"/integrations/telegram/threads/{thread_id}/close", + headers=operator_headers("operator_close"), + ) + assert closed.status_code == 200 + assert closed.json()["status"] == "closed" + assert closed.json()["claimed_by_user"] is None + assert closed.json()["claimed_at"] is None + + reply = telegram_client.post( + f"/integrations/telegram/threads/{thread_id}/messages", + headers=operator_headers("operator_close"), + json={"text": "Should fail"}, + ) + assert reply.status_code == 409 + + session = get_session() + try: + thread = session.execute(select(TelegramThreadRow).where(TelegramThreadRow.thread_id == thread_id)).scalar_one() + messages = session.execute( + select(TelegramMessageRow).where(TelegramMessageRow.thread_id == thread_id) + ).scalars().all() + assert thread.status == "closed" + assert thread.claimed_by_user is None + assert thread.claimed_at is None + assert len(messages) == 1 + finally: + session.close() + + +def test_ai_handoff_is_rejected_for_human_owned_thread(monkeypatch): + patch_interaction_request(monkeypatch) + telegram_client = TestClient(telegram_app) + + created = telegram_client.post( + "/integrations/telegram/webhook", + json={"chat_id": "chat_ai_handoff_locked", "text": "Need operator"}, + ) + assert created.status_code == 200 + thread_id = created.json()["thread_id"] + + claimed = telegram_client.post( + f"/integrations/telegram/threads/{thread_id}/claim", + headers=operator_headers("operator_handoff_lock"), + ) + assert claimed.status_code == 200 + assert claimed.json()["ai_state"] == "human_owned" + + handoff = telegram_client.post( + f"/integrations/telegram/threads/{thread_id}/ai/handoff", + headers=admin_headers(), + json={"reason": "Late AI handoff"}, + ) + assert handoff.status_code == 409 + assert handoff.json()["detail"] == "Telegram thread is owned by a human operator" + + session = get_session() + try: + thread = session.execute(select(TelegramThreadRow).where(TelegramThreadRow.thread_id == thread_id)).scalar_one() + assert thread.ai_state == "human_owned" + assert thread.claimed_by_user == "operator_handoff_lock" + finally: + session.close() + + +def test_claim_race_returns_single_winner(monkeypatch): + entered_assign = threading.Event() + release_assign = threading.Event() + assign_calls: list[str] = [] + assign_lock = threading.Lock() + + def fake_request(method: str, path: str, *, payload: dict | None = None) -> dict: + if path.endswith("/assign"): + with assign_lock: + assign_calls.append(payload["assignee"]) + if len(assign_calls) == 1: + entered_assign.set() + assert release_assign.wait(5) + + session = get_session() + try: + interaction_id = path.split("/")[2] + interaction = session.execute( + select(Interaction).where(Interaction.interaction_id == interaction_id) + ).scalar_one() + if path.endswith("/assign"): + interaction.assigned_to = payload["assignee"] + interaction.status = "in_progress" + elif path.endswith("/status"): + interaction.status = payload["status"] + elif path.endswith("/escalate"): + interaction.status = "escalated" + interaction.queue_id = payload["target_queue_id"] + interaction.updated_at = telegram_module.utc_now_iso() + session.commit() + return { + "interaction_id": interaction.interaction_id, + "status": interaction.status, + "assigned_to": interaction.assigned_to, + "queue_id": interaction.queue_id, + } + finally: + session.close() + + monkeypatch.setattr(telegram_module, "_interaction_request", fake_request) + + telegram_client = TestClient(telegram_app) + created = telegram_client.post( + "/integrations/telegram/webhook", + json={"chat_id": "chat_claim_race", "text": "Race me"}, + ) + thread_id = created.json()["thread_id"] + + winner: dict[str, object] = {} + + def run_first_claim() -> None: + first_client = TestClient(telegram_app) + try: + winner["response"] = first_client.post( + f"/integrations/telegram/threads/{thread_id}/claim", + headers=operator_headers("operator_a"), + ) + finally: + first_client.close() + + claim_thread = threading.Thread(target=run_first_claim) + claim_thread.start() + + assert entered_assign.wait(5) + + loser_client = TestClient(telegram_app) + try: + loser = loser_client.post( + f"/integrations/telegram/threads/{thread_id}/claim", + headers=operator_headers("operator_b"), + ) + finally: + loser_client.close() + + release_assign.set() + claim_thread.join(timeout=5) + + first = winner["response"] + assert getattr(first, "status_code") == 200 + assert loser.status_code == 409 + assert assign_calls == ["operator_a"] + + session = get_session() + try: + thread = session.execute(select(TelegramThreadRow).where(TelegramThreadRow.thread_id == thread_id)).scalar_one() + assert thread.claimed_by_user == "operator_a" + assert thread.status == "in_progress" + finally: + session.close() diff --git a/tests/test_track7_scripts.py b/tests/test_track7_scripts.py new file mode 100644 index 0000000..732b062 --- /dev/null +++ b/tests/test_track7_scripts.py @@ -0,0 +1,107 @@ +from pathlib import Path + +from scripts import load_test, track7_check + + +def test_load_test_profiles_and_mix(): + profile = load_test.resolve_profile("target_500_500") + compat_profile = load_test.resolve_profile("baseline_100_100", voice=120, digital=80) + mix = load_test.build_mix_profile(profile, include_read_traffic=True) + + assert profile["voice"] == 500 + assert profile["digital"] == 500 + assert compat_profile["voice"] == 120 + assert compat_profile["digital"] == 80 + assert mix["channels"]["voice"] == 500 + assert mix["channels"]["telegram"] == 200 + assert mix["channels"]["webchat"] == 150 + assert mix["channels"]["email"] == 150 + assert "/proxy/reporting/reports/kpi" in mix["read_endpoints"] + + +def test_load_test_request_builders_use_real_adapter_routes(): + telegram_request = load_test.build_request("telegram", 1) + webchat_request = load_test.build_request("webchat", 1) + email_request = load_test.build_request("email", 1) + + assert telegram_request[1] == "/proxy/telegram/integrations/telegram/webhook" + assert webchat_request[1] == "/proxy/webchat/integrations/webchat/messages" + assert email_request[1] == "/proxy/email/integrations/email/messages" + + +def test_load_test_report_writing_and_threshold_evaluation(tmp_path: Path): + mix = load_test.build_mix_profile(load_test.resolve_profile("baseline_100_100"), include_read_traffic=False) + results = [ + {"ts": 1.0, "target": "voice", "status_code": 200, "latency_seconds": 0.5, "ok": True}, + {"ts": 2.0, "target": "telegram", "status_code": 200, "latency_seconds": 0.7, "ok": True}, + {"ts": 3.0, "target": "voice", "status_code": 502, "latency_seconds": 1.2, "ok": False}, + ] + summary = load_test.build_summary( + results, + mix, + { + "require_success_rate": 50.0, + "require_p95_seconds": 2.0, + "require_p99_seconds": 3.0, + }, + "2026-03-02T10:00:00", + "2026-03-02T10:01:00", + ) + + load_test.write_report(tmp_path, summary, results, [{"error": "bad gateway"}]) + + assert summary["passed"] is True + assert (tmp_path / "summary.json").exists() + assert (tmp_path / "latency_samples.csv").exists() + assert (tmp_path / "error_samples.json").exists() + assert (tmp_path / "mix_profile.json").exists() + + +def test_track7_check_evaluate_summary_flags_failures(): + summary = { + "results": { + "success_rate": 97.5, + "p95_seconds": 2.5, + "p99_seconds": 4.1, + "five_xx_rate": 0.8, + } + } + + issues = track7_check.evaluate_summary( + summary, + require_success_rate=99.0, + require_p95_seconds=2.0, + require_p99_seconds=3.5, + ) + + assert any("Success rate" in issue for issue in issues) + assert any("P95" in issue for issue in issues) + assert any("P99" in issue for issue in issues) + assert any("5xx" in issue for issue in issues) + + +def test_track7_artifacts_present_in_templates_and_runtime(): + root = Path(__file__).resolve().parents[1] + db_py = (root / "services" / "shared" / "db.py").read_text(encoding="utf-8") + values_yaml = (root / "deployment" / "helm" / "values.yaml").read_text(encoding="utf-8") + services_yaml = (root / "deployment" / "helm" / "templates" / "services.yaml").read_text(encoding="utf-8") + migration_job_yaml = (root / "deployment" / "helm" / "templates" / "migration-job.yaml").read_text(encoding="utf-8") + + assert "DB_POOL_SIZE" in db_py + assert "DB_MAX_OVERFLOW" in db_py + assert "profiles:" in values_yaml + assert "scale500:" in values_yaml + assert "databaseUrl:" in values_yaml + assert "migrations:" in values_yaml + assert "HorizontalPodAutoscaler" in services_yaml + assert "PodDisruptionBudget" in services_yaml + assert "startupProbe" in services_yaml + assert "livenessProbe" in services_yaml + assert "DATABASE_URL" in services_yaml + assert "ai-orchestrator-service:" in values_yaml + assert "ai-voice-runtime-service:" in values_yaml + assert "AI_VOICE_RUNTIME_SERVICE_URL" in services_yaml + assert "AI_VOICE_QUEUE_CONFIG_JSON" in services_yaml + assert '"helm.sh/hook": pre-install,pre-upgrade' in migration_job_yaml + assert "scripts/migrate_core_db.py" in migration_job_yaml + assert "SCHEMA_MANAGEMENT_MODE" in migration_job_yaml diff --git a/tests/test_track7_stage.py b/tests/test_track7_stage.py new file mode 100644 index 0000000..1ce6fb5 --- /dev/null +++ b/tests/test_track7_stage.py @@ -0,0 +1,49 @@ +from pathlib import Path + +from scripts import track7_stage + + +def test_track7_stage_resolve_profiles(): + assert track7_stage.resolve_profiles("step_250_250,target_500_500") == ["step_250_250", "target_500_500"] + + +def test_track7_stage_rejects_unknown_profile(): + try: + track7_stage.resolve_profiles("step_250_250,unknown") + except ValueError as exc: + assert "Unsupported staged profiles" in str(exc) + else: + raise AssertionError("Expected ValueError for unknown staged profile") + + +def test_track7_stage_writes_acceptance_pack(tmp_path: Path): + payload = { + "namespace": "mvp-cc", + "base_url": "http://example.test", + "auth_mode": "bearer", + "include_read_traffic": True, + "started_at": "2026-03-02T10:00:00", + "completed_at": "2026-03-02T10:05:00", + "passed": False, + "stages": [ + { + "profile": "step_250_250", + "report_dir": str(tmp_path / "step_250_250"), + "passed": False, + "issues": ["P95 too high"], + "results": { + "success_rate": 100.0, + "p95_seconds": 2.5, + "p99_seconds": 3.0, + }, + } + ], + } + + track7_stage.write_acceptance_pack(tmp_path, payload) + + assert (tmp_path / "acceptance_summary.json").exists() + markdown = (tmp_path / "acceptance_summary.md").read_text(encoding="utf-8") + assert "Track 7 Staged Validation" in markdown + assert "step_250_250" in markdown + assert "P95 too high" in markdown diff --git a/tests/test_track8_event_bus.py b/tests/test_track8_event_bus.py new file mode 100644 index 0000000..07560a0 --- /dev/null +++ b/tests/test_track8_event_bus.py @@ -0,0 +1,419 @@ +from __future__ import annotations + +import json +from pathlib import Path +from uuid import uuid4 + +from fastapi.testclient import TestClient +from sqlalchemy import create_engine, select +from sqlalchemy.orm import Session + +import gateway.app as gateway_module +from scripts import event_bus_smoke, track8_check +from services.audit_service import app as audit_module +from services.event_bus_service.app import app as event_bus_app +from services.interaction_service.app import app as interaction_app +from services.ivr_service.app import app as ivr_app +from services.recording_service.app import app as recording_app +from services.reporting_service import app as reporting_module +from services.shared.event_bus import append_outbox_event, build_envelope, mark_outbox_failed +from services.shared.sql_models import ( + AuditEventRow, + Base, + EventInboxRow, + EventOutboxRow, + ReportingEventLogRow, + ReportingEventRow, +) +from services.supervisor_service.app import app as supervisor_app + + +def _admin_headers() -> dict[str, str]: + return {"X-User": "admin", "X-Role": "admin"} + + +def _supervisor_headers() -> dict[str, str]: + return {"X-User": "supervisor", "X-Role": "supervisor"} + + +def _operator_headers() -> dict[str, str]: + return {"X-User": "operator", "X-Role": "operator"} + + +def _write_audio_fixture(path: Path) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(b"RIFF" + b"\x00" * 32) + return path + + +def _build_valid_flow(queue_id: str, resolved_queue_id: str) -> dict: + return { + "name": f"Track8 Flow {queue_id}", + "description": "Track8 test flow", + "queue_id": queue_id, + "entry_node_id": "root", + "flow_json": { + "nodes": [ + { + "node_id": "root", + "prompt_text": "Press 1 for support", + "prompt_audio_key": "ivr/track8-root", + "is_terminal": False, + "invalid_target_node_id": "root", + "options": [{"digit": "1", "target_node_id": "support"}], + }, + { + "node_id": "support", + "prompt_text": "Routing to support", + "prompt_audio_key": "ivr/track8-support", + "is_terminal": True, + "outcome_code": "support_route", + "resolved_queue_id": resolved_queue_id, + "resolved_queue_code": "support", + "options": [], + }, + ] + }, + "is_active": True, + } + + +def test_append_outbox_event_persists_full_envelope(): + from services.shared.db import get_session + + session = get_session() + try: + entity_id = f"int_{uuid4().hex[:12]}" + row = append_outbox_event( + session, + event_type="interaction.created", + producer_service="interaction-service", + entity_type="interaction", + entity_id=entity_id, + payload={"interaction_id": entity_id, "queue_id": "q_track8"}, + correlation_id="corr_track8", + ) + session.commit() + stored = session.execute( + select(EventOutboxRow).where(EventOutboxRow.event_id == row.event_id) + ).scalar_one() + envelope = json.loads(stored.payload_json) + assert stored.status == "pending" + assert stored.routing_key == "interaction.created" + assert envelope["event_id"] == stored.event_id + assert envelope["correlation_id"] == "corr_track8" + assert envelope["payload"]["interaction_id"] == entity_id + finally: + session.close() + + +def test_interaction_supervisor_recording_and_ivr_write_outbox(tmp_path: Path, monkeypatch) -> None: + monkeypatch.setenv("EVENT_BUS_ENABLED", "1") + interaction_client = TestClient(interaction_app) + supervisor_client = TestClient(supervisor_app) + recording_client = TestClient(recording_app) + ivr_client = TestClient(ivr_app) + + interaction = interaction_client.post( + "/interactions", + json={ + "channel": "voice", + "subject": "Track8 Outbox Interaction", + "queue_id": f"q_{uuid4().hex[:8]}", + "priority": 3, + }, + headers={"X-User": "operator", "X-Role": "operator"}, + ) + assert interaction.status_code == 200 + interaction_id = interaction.json()["interaction_id"] + + assigned = interaction_client.patch( + f"/interactions/{interaction_id}/assign", + json={"assignee": "operator_a"}, + headers=_admin_headers(), + ) + assert assigned.status_code == 200 + + escalated = interaction_client.post( + f"/interactions/{interaction_id}/escalate", + json={"target_queue_id": "line2"}, + headers=_supervisor_headers(), + ) + assert escalated.status_code == 200 + + closed = interaction_client.patch( + f"/interactions/{interaction_id}/status", + json={"status": "closed"}, + headers={"X-User": "operator", "X-Role": "operator"}, + ) + assert closed.status_code == 200 + + agent_state = supervisor_client.post( + "/supervisor/agent-states", + json={"agent_id": f"agent_{uuid4().hex[:6]}", "state": "READY", "queue_id": "line2"}, + headers=_supervisor_headers(), + ) + assert agent_state.status_code == 200 + + monkeypatch.setenv("CC_RECORDINGS_DIR", str(tmp_path / "recordings")) + source_path = _write_audio_fixture(tmp_path / "fixtures" / "track8.wav") + registered = recording_client.post( + "/recordings/register", + headers=_supervisor_headers(), + json={ + "call_id": f"call_{uuid4().hex[:8]}", + "interaction_id": interaction_id, + "source_path": str(source_path), + "file_name": "track8.wav", + "mime_type": "audio/wav", + "duration_seconds": 2, + }, + ) + assert registered.status_code == 200 + + queue_id = f"ivr_q_{uuid4().hex[:6]}" + flow = ivr_client.post("/ivr/flows", headers=_admin_headers(), json=_build_valid_flow(queue_id, "support_line")) + assert flow.status_code == 200 + started = ivr_client.post( + "/ivr/sessions/start", + headers=_supervisor_headers(), + json={"call_id": f"call_{uuid4().hex[:6]}", "queue_id": queue_id, "interaction_id": interaction_id}, + ) + assert started.status_code == 200 + session_id = started.json()["session"]["session_id"] + completed = ivr_client.post( + f"/ivr/sessions/{session_id}/dtmf", + headers=_supervisor_headers(), + json={"digit": "1"}, + ) + assert completed.status_code == 200 + assert completed.json()["completed"] is True + + from services.shared.db import get_session + + session = get_session() + try: + event_types = { + row.event_type + for row in session.execute( + select(EventOutboxRow).where( + EventOutboxRow.event_type.in_( + [ + "interaction.created", + "interaction.assigned", + "interaction.escalated", + "interaction.closed", + "agent.state.changed", + "call.recording.ready", + "ivr.completed", + ] + ) + ) + ).scalars() + } + assert "interaction.created" in event_types + assert "interaction.assigned" in event_types + assert "interaction.escalated" in event_types + assert "interaction.closed" in event_types + assert "agent.state.changed" in event_types + assert "call.recording.ready" in event_types + assert "ivr.completed" in event_types + finally: + session.close() + + +def test_audit_and_reporting_consumers_are_idempotent() -> None: + envelope = build_envelope( + event_type="ivr.completed", + producer="ivr-service", + entity_type="ivr_session", + entity_id=f"ivs_{uuid4().hex[:12]}", + payload={ + "interaction_id": f"int_{uuid4().hex[:12]}", + "queue_id": "line2", + "resolved_queue_id": "support_line", + "channel": "voice", + "outcome_code": "support_route", + }, + ).model_dump() + + audit_module._handle_event(envelope) + audit_module._handle_event(envelope) + reporting_module._handle_event(envelope) + reporting_module._handle_event(envelope) + + from services.shared.db import get_session + + session = get_session() + try: + audit_rows = session.execute( + select(AuditEventRow).where(AuditEventRow.action == "ivr.completed") + ).scalars().all() + audit_inbox = session.execute( + select(EventInboxRow).where( + EventInboxRow.consumer_name == "audit-service", + EventInboxRow.event_id == envelope["event_id"], + ) + ).scalars().all() + reporting_log = session.execute( + select(ReportingEventLogRow).where(ReportingEventLogRow.event_id == envelope["event_id"]) + ).scalars().all() + reporting_events = session.execute( + select(ReportingEventRow).order_by(ReportingEventRow.id.desc()) + ).scalars().all() + reporting_inbox = session.execute( + select(EventInboxRow).where( + EventInboxRow.consumer_name == "reporting-service", + EventInboxRow.event_id == envelope["event_id"], + ) + ).scalars().all() + + assert len(audit_inbox) == 1 + assert len(reporting_inbox) == 1 + assert len([row for row in audit_rows if envelope["event_id"] in row.metadata_json]) == 1 + assert len(reporting_log) == 1 + assert any(row.queue_id == "support_line" for row in reporting_events) + finally: + session.close() + + +def test_event_bus_service_retry_endpoint_and_gateway_contracts(monkeypatch) -> None: + monkeypatch.setenv("EVENT_BUS_ENABLED", "0") + from services.shared.db import get_session + + session = get_session() + try: + row = append_outbox_event( + session, + event_type="interaction.created", + producer_service="interaction-service", + entity_type="interaction", + entity_id=f"int_{uuid4().hex[:12]}", + payload={"interaction_id": f"int_{uuid4().hex[:12]}"}, + ) + mark_outbox_failed(session, row, "broker down") + event_id = row.event_id + session.commit() + finally: + session.close() + + client = TestClient(event_bus_app) + retried = client.post(f"/bus/outbox/{event_id}/retry", headers=_admin_headers()) + assert retried.status_code == 200 + assert retried.json()["status"] == "pending" + + denied = client.get("/bus/outbox", headers=_operator_headers()) + assert denied.status_code == 403 + + gateway_client = TestClient(gateway_module.app) + contracts = gateway_client.get("/contracts") + assert contracts.status_code == 200 + body = contracts.json() + assert "stage_8" in body + assert "/bus/outbox" in body["stage_8"] + + +def test_event_bus_smoke_and_track8_check_with_temp_database(tmp_path: Path, monkeypatch) -> None: + db_path = tmp_path / "track8.db" + engine = create_engine(f"sqlite:///{db_path.as_posix()}", future=True) + Base.metadata.create_all(engine) + + class DummyResponse: + def __init__(self, status_code: int, payload: dict | None = None): + self.status_code = status_code + self._payload = payload or {} + + def raise_for_status(self) -> None: + if self.status_code >= 400: + raise RuntimeError("HTTP error") + + def json(self) -> dict: + return self._payload + + class DummyClient: + def __init__(self, *args, **kwargs): + self.base_url = kwargs.get("base_url") + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return None + + def get(self, path: str, headers: dict | None = None, timeout: int | None = None): + if path == "/proxy/event-bus/health": + return DummyResponse(200, {"status": "ok"}) + raise AssertionError(f"Unexpected GET path {path}") + + def post(self, path: str, headers: dict | None = None, json: dict | None = None, timeout: int | None = None): + if path == "/proxy/interaction/interactions": + interaction_id = f"int_{uuid4().hex[:12]}" + with Session(engine) as session: + outbox = EventOutboxRow( + event_id=f"evt_{uuid4().hex[:12]}", + event_type="interaction.created", + event_version=1, + producer_service="interaction-service", + entity_type="interaction", + entity_id=interaction_id, + correlation_id=None, + routing_key="interaction.created", + payload_json=json and __import__("json").dumps( + { + "event_id": f"evt_payload_{uuid4().hex[:12]}", + "event_type": "interaction.created", + "event_version": 1, + "occurred_at": "2026-03-02T12:00:00+00:00", + "producer": "interaction-service", + "entity_type": "interaction", + "entity_id": interaction_id, + "routing_key": "interaction.created", + "payload": {"interaction_id": interaction_id}, + } + ) + or "{}", + status="published", + attempt_count=0, + last_error=None, + available_at="2026-03-02T12:00:00+00:00", + published_at="2026-03-02T12:00:01+00:00", + created_at="2026-03-02T12:00:00+00:00", + updated_at="2026-03-02T12:00:01+00:00", + ) + session.add(outbox) + session.add( + EventInboxRow( + consumer_name="audit-service", + event_id=outbox.event_id, + event_type=outbox.event_type, + processed_at="2026-03-02T12:00:02+00:00", + status="processed", + notes=None, + ) + ) + session.add( + EventInboxRow( + consumer_name="reporting-service", + event_id=outbox.event_id, + event_type=outbox.event_type, + processed_at="2026-03-02T12:00:02+00:00", + status="processed", + notes=None, + ) + ) + session.commit() + return DummyResponse(200, {"interaction_id": interaction_id}) + raise AssertionError(f"Unexpected POST path {path}") + + monkeypatch.setattr(event_bus_smoke, "_engine_for", lambda database_url=None: engine) + monkeypatch.setattr(event_bus_smoke.httpx, "Client", DummyClient) + + smoke = event_bus_smoke.run_smoke_check(base_url="http://example.local", auth_mode="legacy_headers") + assert smoke["passed"] is True + + issues = track8_check.evaluate_track8( + database_url=f"sqlite:///{db_path.as_posix()}", + require_bus_enabled=False, + base_url=None, + ) + assert issues == [] diff --git a/tests/test_voice_adapter_service.py b/tests/test_voice_adapter_service.py new file mode 100644 index 0000000..7ffa357 --- /dev/null +++ b/tests/test_voice_adapter_service.py @@ -0,0 +1,108 @@ +from fastapi.testclient import TestClient + +from services.shared.security import issue_app_token +from services.voice_adapter_service.app import app as voice_app + + +def _admin_headers() -> dict[str, str]: + return {"X-User": "admin", "X-Role": "admin"} + + +def test_voice_event_ingest_and_read_with_admin_headers(): + client = TestClient(voice_app) + + created = client.post( + "/integrations/voice/events", + headers=_admin_headers(), + json={ + "event_type": "call.started", + "call_id": "call_voice_test_admin", + "interaction_id": "int_voice_test_admin", + "payload": {"source": "unit-test"}, + }, + ) + assert created.status_code == 200 + + listed = client.get("/integrations/voice/events", headers=_admin_headers()) + assert listed.status_code == 200 + assert any(item["event_id"] == created.json()["event_id"] for item in listed.json()) + + +def test_voice_event_ingest_allows_trusted_service_subject(monkeypatch): + monkeypatch.setenv("ALLOW_LEGACY_HEADER_AUTH", "0") + monkeypatch.setenv("APP_TOKEN_SECRET", "track9-voice-service-subject") + monkeypatch.setenv("VOICE_ADAPTER_TRUSTED_SERVICE_SUBJECTS", "svc:asterisk-bridge") + client = TestClient(voice_app) + + token = issue_app_token( + subject="svc:asterisk-bridge", + username="asterisk-bridge", + role="admin", + auth_source="service", + provider="track9-test", + ttl_seconds=300, + ) + allowed = client.post( + "/integrations/voice/events", + headers={"Authorization": f"Bearer {token}"}, + json={ + "event_type": "call.ended", + "call_id": "call_voice_test_service", + "interaction_id": "int_voice_test_service", + "payload": {"source": "service-test"}, + }, + ) + assert allowed.status_code == 200 + + bad = issue_app_token( + subject="svc:other", + username="other-service", + role="admin", + auth_source="service", + provider="track9-test", + ttl_seconds=300, + ) + denied = client.post( + "/integrations/voice/events", + headers={"Authorization": f"Bearer {bad}"}, + json={ + "event_type": "call.ended", + "call_id": "call_voice_test_denied", + "interaction_id": "int_voice_test_denied", + "payload": {"source": "service-test"}, + }, + ) + assert denied.status_code == 403 + + +def test_voice_event_ingest_is_idempotent_by_source_event_id(): + client = TestClient(voice_app) + source_event_id = "bridge_evt_voice_idempotent" + payload = { + "event_type": "call.started", + "call_id": "call_voice_idempotent", + "interaction_id": "int_voice_idempotent", + "source_event_id": source_event_id, + "payload": {"source": "bridge-test"}, + } + + first = client.post( + "/integrations/voice/events", + headers=_admin_headers(), + json=payload, + ) + second = client.post( + "/integrations/voice/events", + headers=_admin_headers(), + json=payload, + ) + + assert first.status_code == 200 + assert second.status_code == 200 + assert first.json()["event_id"] == second.json()["event_id"] + assert first.json()["source_event_id"] == source_event_id + + listed = client.get("/integrations/voice/events", headers=_admin_headers()) + assert listed.status_code == 200 + matching = [item for item in listed.json() if item["call_id"] == payload["call_id"]] + assert len(matching) == 1 diff --git a/tests/test_voice_transcripts.py b/tests/test_voice_transcripts.py new file mode 100644 index 0000000..9b18c62 --- /dev/null +++ b/tests/test_voice_transcripts.py @@ -0,0 +1,46 @@ +from sqlalchemy import select + +from services.shared.db import get_session +from services.shared.sql_init import init_sql_schema +from services.shared.sql_models import VoiceTranscriptSegmentRow +from services.shared.voice_transcripts import add_transcript_segment + + +def test_add_transcript_segment_retries_on_sequence_conflict(): + init_sql_schema() + session = get_session() + try: + first = add_transcript_segment( + session, + session_id="avs_sequence_retry", + call_id="call_sequence_retry", + interaction_id="int_sequence_retry", + speaker="caller", + source_type="asr", + text="first", + sequence_no=1, + ) + session.commit() + assert first.sequence_no == 1 + + second = add_transcript_segment( + session, + session_id="avs_sequence_retry", + call_id="call_sequence_retry", + interaction_id="int_sequence_retry", + speaker="assistant", + source_type="tts", + text="second", + sequence_no=1, + ) + session.commit() + + rows = session.execute( + select(VoiceTranscriptSegmentRow) + .where(VoiceTranscriptSegmentRow.session_id == "avs_sequence_retry") + .order_by(VoiceTranscriptSegmentRow.sequence_no.asc()) + ).scalars().all() + assert [row.sequence_no for row in rows] == [1, 2] + assert second.sequence_no == 2 + finally: + session.close() diff --git a/tests/test_webchat_adapter_service.py b/tests/test_webchat_adapter_service.py new file mode 100644 index 0000000..6b6acff --- /dev/null +++ b/tests/test_webchat_adapter_service.py @@ -0,0 +1,43 @@ +from fastapi.testclient import TestClient + +from services.interaction_service.app import app as interaction_app +from services.webchat_adapter_service.app import app as webchat_app + + +def test_webchat_message_creates_interaction_and_can_be_listed(): + webchat_client = TestClient(webchat_app) + interaction_client = TestClient(interaction_app) + + created = webchat_client.post( + "/integrations/webchat/messages", + json={ + "session_id": "web_session_1", + "text": "Need help with the checkout page", + "visitor_name": "Visitor One", + "customer_external_id": "cust_ext_1", + "queue_id": "line2", + "priority": 2, + "payload": {"source": "test", "subject": "Checkout issue"}, + }, + ) + assert created.status_code == 200 + payload = created.json() + assert payload["message_id"].startswith("wcm_") + assert payload["interaction_id"].startswith("int_") + + interaction = interaction_client.get(f"/interactions/{payload['interaction_id']}") + assert interaction.status_code == 200 + assert interaction.json()["channel"] == "webchat" + assert interaction.json()["queue_id"] == "line2" + assert interaction.json()["customer_id"] == "cust_ext_1" + + timeline = interaction_client.get(f"/interactions/{payload['interaction_id']}/timeline") + assert timeline.status_code == 200 + actions = [event["action"] for event in timeline.json()["events"]] + assert "interaction.created" in actions + assert "webchat.message_received" in actions + + listed = webchat_client.get("/integrations/webchat/messages?session_id=web_session_1") + assert listed.status_code == 200 + assert len(listed.json()) >= 1 + assert listed.json()[-1]["session_id"] == "web_session_1" diff --git a/tests/test_whatsapp_adapter_service.py b/tests/test_whatsapp_adapter_service.py new file mode 100644 index 0000000..276e9ca --- /dev/null +++ b/tests/test_whatsapp_adapter_service.py @@ -0,0 +1,440 @@ +import hashlib +import hmac +import json + +from fastapi.testclient import TestClient +from sqlalchemy import select + +from services.interaction_service.app import app as interaction_app +from services.shared.db import get_session +from services.shared.sql_models import ( + CustomerExternalIdentity, + Interaction, + WhatsAppMessageRow, + WhatsAppThreadRow, +) +from services.whatsapp_adapter_service import app as whatsapp_module +from services.whatsapp_adapter_service.app import app as whatsapp_app + + +def admin_headers(): + return {"X-User": "admin", "X-Role": "admin"} + + +def operator_headers(user="operator"): + return {"X-User": user, "X-Role": "operator"} + + +def patch_interaction_request(monkeypatch): + def fake_request(method: str, path: str, *, payload: dict | None = None) -> dict: + session = get_session() + try: + interaction_id = path.split("/")[2] + interaction = session.execute( + select(Interaction).where(Interaction.interaction_id == interaction_id) + ).scalar_one() + if path.endswith("/assign"): + interaction.assigned_to = payload["assignee"] + interaction.status = "in_progress" + elif path.endswith("/status"): + interaction.status = payload["status"] + elif path.endswith("/escalate"): + interaction.status = "escalated" + interaction.queue_id = payload["target_queue_id"] + interaction.updated_at = whatsapp_module.utc_now_iso() + session.commit() + return { + "interaction_id": interaction.interaction_id, + "status": interaction.status, + "assigned_to": interaction.assigned_to, + "queue_id": interaction.queue_id, + } + finally: + session.close() + + monkeypatch.setattr(whatsapp_module, "_interaction_request", fake_request) + + +def _meta_signature(secret: str, body: bytes) -> str: + digest = hmac.new(secret.encode("utf-8"), body, hashlib.sha256).hexdigest() + return f"sha256={digest}" + + +def test_manual_webhook_creates_thread_and_linked_interaction(): + whatsapp_client = TestClient(whatsapp_app) + interaction_client = TestClient(interaction_app) + + response = whatsapp_client.post( + "/integrations/whatsapp/webhook", + json={ + "chat_id": "wa_chat_1", + "text": "Привет из WhatsApp", + "external_message_id": "wa_ext_1", + "whatsapp_user_id": "wa_user_1", + "phone_number": "+77015550001", + "display_name": "Lead One", + "payload": {"profile_name": "Lead One"}, + }, + ) + assert response.status_code == 200 + payload = response.json() + assert payload["thread_id"].startswith("wht_") + assert payload["interaction_id"].startswith("int_") + assert payload["direction"] == "inbound" + assert payload["customer_id"].startswith("cus_") + + thread_list = whatsapp_client.get("/integrations/whatsapp/threads", headers=admin_headers()) + assert thread_list.status_code == 200 + assert thread_list.json()[0]["chat_id"] == "wa_chat_1" + assert thread_list.json()[0]["phone_number"] == "+77015550001" + assert thread_list.json()[0]["unread_count"] == 1 + + interaction = interaction_client.get(f"/interactions/{payload['interaction_id']}") + assert interaction.status_code == 200 + assert interaction.json()["channel"] == "whatsapp" + assert interaction.json()["customer_id"] == payload["customer_id"] + + session = get_session() + try: + identity = session.execute( + select(CustomerExternalIdentity).where( + CustomerExternalIdentity.channel == "whatsapp", + CustomerExternalIdentity.external_subject == "wa_user_1", + ) + ).scalar_one_or_none() + assert identity is not None + assert identity.customer_id == payload["customer_id"] + finally: + session.close() + + +def test_manual_webhook_deduplicates_by_external_message_id(): + whatsapp_client = TestClient(whatsapp_app) + payload = { + "chat_id": "wa_chat_dedupe", + "text": "Повторный inbound", + "external_message_id": "wa_ext_dedupe_1", + "whatsapp_user_id": "wa_user_dedupe", + "phone_number": "+77015550002", + "display_name": "Dedupe Lead", + "payload": {"provider": "manual"}, + } + + first = whatsapp_client.post("/integrations/whatsapp/webhook", json=payload) + second = whatsapp_client.post("/integrations/whatsapp/webhook", json=payload) + + assert first.status_code == 200 + assert second.status_code == 200 + assert first.json()["message_id"] == second.json()["message_id"] + assert first.json()["thread_id"] == second.json()["thread_id"] + + messages = whatsapp_client.get( + f"/integrations/whatsapp/threads/{first.json()['thread_id']}/messages", + headers=admin_headers(), + ) + assert messages.status_code == 200 + assert len(messages.json()) == 1 + + +def test_provider_webhook_accepts_meta_style_payload(monkeypatch): + monkeypatch.setenv("WHATSAPP_WEBHOOK_SECRET", "wa-secret") + whatsapp_client = TestClient(whatsapp_app) + + response = whatsapp_client.post( + "/integrations/whatsapp/provider/webhook", + headers={"X-WhatsApp-Webhook-Secret": "wa-secret"}, + json={ + "entry": [ + { + "changes": [ + { + "value": { + "contacts": [{"wa_id": "77015550003", "profile": {"name": "Meta Lead"}}], + "messages": [ + { + "id": "wam_meta_1", + "from": "77015550003", + "type": "text", + "text": {"body": "Есть вопрос по доставке"}, + } + ], + } + } + ] + } + ] + }, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload["ok"] is True + assert payload["thread_id"].startswith("wht_") + assert payload["message_id"].startswith("wam_") + + messages = whatsapp_client.get( + f"/integrations/whatsapp/threads/{payload['thread_id']}/messages", + headers=admin_headers(), + ) + assert messages.status_code == 200 + message = messages.json()[0] + assert message["text"] == "Есть вопрос по доставке" + assert message["author_type"] == "customer" + assert message["whatsapp_message_id_external"] == "wam_meta_1" + + +def test_provider_webhook_verification_returns_challenge(monkeypatch): + monkeypatch.setenv("WHATSAPP_META_VERIFY_TOKEN", "wa-verify-token") + whatsapp_client = TestClient(whatsapp_app) + + response = whatsapp_client.get( + "/integrations/whatsapp/provider/webhook", + params={ + "hub.mode": "subscribe", + "hub.verify_token": "wa-verify-token", + "hub.challenge": "challenge-123", + }, + ) + + assert response.status_code == 200 + assert response.text == "challenge-123" + + +def test_provider_webhook_accepts_meta_signature(monkeypatch): + monkeypatch.setenv("WHATSAPP_APP_SECRET", "meta-app-secret") + whatsapp_client = TestClient(whatsapp_app) + payload = { + "entry": [ + { + "changes": [ + { + "value": { + "contacts": [{"wa_id": "77015550013", "profile": {"name": "Signed Lead"}}], + "messages": [ + { + "id": "wam_meta_signed_1", + "from": "77015550013", + "type": "text", + "text": {"body": "Signed inbound"}, + } + ], + } + } + ] + } + ] + } + body = json.dumps(payload, separators=(",", ":"), ensure_ascii=False).encode("utf-8") + + response = whatsapp_client.post( + "/integrations/whatsapp/provider/webhook", + headers={ + "Content-Type": "application/json", + "X-Hub-Signature-256": _meta_signature("meta-app-secret", body), + }, + content=body, + ) + + assert response.status_code == 200 + assert response.json()["ok"] is True + assert response.json()["message_id"].startswith("wam_") + + +def test_provider_webhook_status_callback_updates_delivery(monkeypatch): + patch_interaction_request(monkeypatch) + monkeypatch.setattr(whatsapp_module, "_start_whatsapp_reply_delivery", lambda message_id: None) + whatsapp_client = TestClient(whatsapp_app) + + created = whatsapp_client.post( + "/integrations/whatsapp/webhook", + json={ + "chat_id": "wa_chat_status", + "text": "Need status update", + "external_message_id": "wa_status_in_1", + "whatsapp_user_id": "wa_user_status", + "phone_number": "+77015550014", + "display_name": "Status Lead", + }, + ) + assert created.status_code == 200 + thread_id = created.json()["thread_id"] + + claimed = whatsapp_client.post( + f"/integrations/whatsapp/threads/{thread_id}/claim", + headers=operator_headers("operator_status"), + ) + assert claimed.status_code == 200 + + reply = whatsapp_client.post( + f"/integrations/whatsapp/threads/{thread_id}/messages", + headers=operator_headers("operator_status"), + json={"text": "Outbound for status"}, + ) + assert reply.status_code == 200 + reply_message_id = reply.json()["message_id"] + + session = get_session() + try: + row = session.execute( + select(WhatsAppMessageRow).where(WhatsAppMessageRow.message_id == reply_message_id) + ).scalar_one() + row.whatsapp_message_id_external = "wam_status_meta_1" + row.delivery_status = "sent" + session.commit() + finally: + session.close() + + response = whatsapp_client.post( + "/integrations/whatsapp/provider/webhook", + json={ + "entry": [ + { + "changes": [ + { + "value": { + "statuses": [ + { + "id": "wam_status_meta_1", + "status": "delivered", + "recipient_id": "77015550014", + } + ] + } + } + ] + } + ] + }, + ) + + assert response.status_code == 200 + assert response.json()["status_event"] is True + assert response.json()["updated"] is True + assert response.json()["delivery_status"] == "delivered" + + session = get_session() + try: + row = session.execute( + select(WhatsAppMessageRow).where(WhatsAppMessageRow.message_id == reply_message_id) + ).scalar_one() + assert row.delivery_status == "delivered" + finally: + session.close() + + +def test_send_whatsapp_message_uses_meta_graph_api(monkeypatch): + captured: dict[str, object] = {} + + class DummyMetaResponse: + status_code = 200 + text = '{"messages":[{"id":"wam_meta_sent_42"}]}' + + @staticmethod + def json() -> dict: + return {"messages": [{"id": "wam_meta_sent_42"}]} + + class DummyMetaClient: + def __init__(self, timeout: float): + captured["timeout"] = timeout + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return None + + def post(self, url, json=None, headers=None): # noqa: ANN001, ANN201 + captured["url"] = url + captured["json"] = json + captured["headers"] = headers + return DummyMetaResponse() + + monkeypatch.setenv("WHATSAPP_META_ACCESS_TOKEN", "meta-token") + monkeypatch.setenv("WHATSAPP_META_PHONE_NUMBER_ID", "973115089226218") + monkeypatch.setattr(whatsapp_module.httpx, "Client", DummyMetaClient) + + payload = whatsapp_module._send_whatsapp_message("77015550015", "Meta outbound hello") + + assert payload["ok"] is True + assert payload["result"]["message_id"] == "wam_meta_sent_42" + assert captured["url"] == "https://graph.facebook.com/v22.0/973115089226218/messages" + assert captured["headers"]["Authorization"] == "Bearer meta-token" + assert captured["json"] == { + "messaging_product": "whatsapp", + "to": "77015550015", + "type": "text", + "text": {"body": "Meta outbound hello"}, + } + + +def test_reply_flow_claims_and_delivers_pending_message(monkeypatch): + patch_interaction_request(monkeypatch) + started: list[str] = [] + monkeypatch.setattr(whatsapp_module, "_start_whatsapp_reply_delivery", lambda message_id: started.append(message_id)) + + whatsapp_client = TestClient(whatsapp_app) + created = whatsapp_client.post( + "/integrations/whatsapp/webhook", + json={ + "chat_id": "wa_chat_reply", + "text": "Нужен ответ", + "external_message_id": "wa_reply_in_1", + "whatsapp_user_id": "wa_user_reply", + "phone_number": "+77015550004", + "display_name": "Reply Lead", + }, + ) + assert created.status_code == 200 + thread_id = created.json()["thread_id"] + + claimed = whatsapp_client.post( + f"/integrations/whatsapp/threads/{thread_id}/claim", + headers=operator_headers("operator_wa"), + ) + assert claimed.status_code == 200 + assert claimed.json()["unread_count"] == 1 + + reply = whatsapp_client.post( + f"/integrations/whatsapp/threads/{thread_id}/messages", + headers=operator_headers("operator_wa"), + json={"text": "Отправляем подтверждение"}, + ) + assert reply.status_code == 200 + payload = reply.json() + assert payload["delivery_status"] == "pending" + assert started == [payload["message_id"]] + + monkeypatch.setattr( + whatsapp_module, + "_send_whatsapp_message", + lambda chat_id, text: {"ok": True, "result": {"message_id": "wam_sent_1", "chat_id": chat_id, "text": text}}, + ) + assert whatsapp_module._deliver_pending_whatsapp_reply(payload["message_id"]) is True + + messages = whatsapp_client.get( + f"/integrations/whatsapp/threads/{thread_id}/messages", + headers=admin_headers(), + ) + assert messages.status_code == 200 + delivered = messages.json()[-1] + assert delivered["delivery_status"] == "sent" + assert delivered["whatsapp_message_id_external"] == "wam_sent_1" + + session = get_session() + try: + row = session.execute( + select(WhatsAppMessageRow).where(WhatsAppMessageRow.message_id == payload["message_id"]) + ).scalar_one() + thread = session.execute( + select(WhatsAppThreadRow).where(WhatsAppThreadRow.thread_id == thread_id) + ).scalar_one() + assert row.delivery_status == "sent" + assert row.whatsapp_message_id_external == "wam_sent_1" + assert thread.ai_state == "human_owned" + finally: + session.close() + + thread_list = whatsapp_client.get("/integrations/whatsapp/threads", headers=admin_headers()) + assert thread_list.status_code == 200 + listed_thread = next(item for item in thread_list.json() if item["thread_id"] == thread_id) + assert listed_thread["unread_count"] == 0 diff --git a/tests/test_workspace_hygiene.py b/tests/test_workspace_hygiene.py new file mode 100644 index 0000000..a715004 --- /dev/null +++ b/tests/test_workspace_hygiene.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] + + +def test_root_gitignore_covers_runtime_artifacts() -> None: + content = (ROOT / ".gitignore").read_text(encoding="utf-8") + + for expected in ( + ".artifacts/", + ".local_stack/", + ".codex_tmp/", + ".data_local/", + ".data_smoke/", + ".data_gate*/", + ".data_uat_*/", + "**/__pycache__/", + ): + assert expected in content + + +def test_cleanup_script_targets_runtime_directories() -> None: + content = (ROOT / "scripts" / "clean_workspace.ps1").read_text(encoding="utf-8") + + for expected in ( + "__pycache__", + ".pytest_cache", + ".codex_tmp", + ".local_stack", + ".artifacts", + ".data_local", + ".data_smoke", + ".data_gate", + ".data_uat_", + ): + assert expected in content + + +def test_stable_acceptance_links_replace_old_timestamp_links() -> None: + roadmap = (ROOT / "docs" / "roadmap" / "05-wave2-backlog.md").read_text(encoding="utf-8") + track10 = (ROOT / "docs" / "roadmap" / "06-track10-live-voice-reliability.md").read_text(encoding="utf-8") + cutover = (ROOT / "docs" / "runbooks" / "track9-2-production-cutover.md").read_text(encoding="utf-8") + old_track10 = "docs/acceptance/track10/" + ("20260305" + "_163330/track10-acceptance.md") + old_track9 = "docs/acceptance/track9/" + ("20260305" + "_044612/track9-acceptance.md") + + assert "docs/acceptance/track10/track10-acceptance.md" in roadmap + assert "docs/acceptance/track10/track10-acceptance.md" in track10 + assert "docs/acceptance/track9/track9-acceptance.md" in cutover + + assert old_track10 not in roadmap + assert old_track10 not in track10 + assert old_track9 not in cutover diff --git a/ui/admin/app.js b/ui/admin/app.js new file mode 100644 index 0000000..d08387e --- /dev/null +++ b/ui/admin/app.js @@ -0,0 +1,1029 @@ +const state = { + user: 'admin', + role: 'admin', + token: null, + authSource: 'local', + fullName: null, + logLines: [], + oidc: { + enabled: false, + loginPath: '/auth/oidc/start?return_mode=popup', + providerLabel: 'Keycloak', + }, +}; + +const $ = (id) => document.getElementById(id); +const SESSION_STORAGE_KEY = 'cc_session'; +const ROLE_LABELS = { + admin: 'Администратор', + supervisor: 'Супервизор', + operator: 'Оператор', + analyst: 'Аналитик', +}; + +function destinationForRole(role) { + if (role === 'admin') { + return '/admin'; + } + if (role === 'supervisor') { + return '/supervisor'; + } + if (role === 'analyst') { + return '/analyst'; + } + return '/operator'; +} + +function applyRoleNavigation() { + document.querySelectorAll('[data-shell]').forEach((link) => { + const allowed = (link.dataset.roles || '') + .split(',') + .map((item) => item.trim()) + .filter(Boolean); + link.style.display = !allowed.length || allowed.includes(state.role) ? '' : 'none'; + }); +} + +function ensurePageAccess() { + if (state.role !== 'admin') { + window.location.href = destinationForRole(state.role); + return false; + } + return true; +} + +function persistSession() { + window.localStorage.setItem( + SESSION_STORAGE_KEY, + JSON.stringify({ + access_token: state.token, + user: state.user, + role: state.role, + auth_source: state.authSource, + full_name: state.fullName, + }), + ); +} + +function clearStoredSession() { + window.localStorage.removeItem(SESSION_STORAGE_KEY); +} + +function restoreStoredSession() { + const raw = window.localStorage.getItem(SESSION_STORAGE_KEY); + if (!raw) { + return false; + } + try { + const payload = JSON.parse(raw); + if (!payload?.access_token) { + clearStoredSession(); + return false; + } + state.token = payload.access_token; + state.user = payload.user || state.user; + state.role = payload.role || state.role; + state.authSource = payload.auth_source || 'local'; + state.fullName = payload.full_name || null; + $('sessionUser').value = state.user; + $('sessionRole').value = state.role; + if ($('authPanel')) { + $('authPanel').style.display = 'none'; + } + updateProfileMeta(); + applyRoleNavigation(); + return true; + } catch { + clearStoredSession(); + return false; + } +} + +function logout() { + clearStoredSession(); + window.location.href = '/'; +} + +function syncSessionFromInputs() { + state.user = $('sessionUser').value.trim() || 'admin'; + state.role = $('sessionRole').value || 'admin'; +} + +function log(message, payload) { + const ts = new Date().toLocaleTimeString(); + const suffix = payload ? ` ${JSON.stringify(payload)}` : ''; + state.logLines.unshift(`[${ts}] ${message}${suffix}`); + state.logLines = state.logLines.slice(0, 16); + $('eventLog').textContent = state.logLines.join('\n'); +} + +function updateProfileMeta() { + const profileName = $('profileName'); + const profileRole = $('profileRole'); + if (!profileName || !profileRole) { + return; + } + profileName.textContent = state.fullName || state.user || 'Экран администратора'; + profileRole.textContent = ROLE_LABELS[state.role] || state.role || 'Пользователь'; +} + +function updateSessionInfo(extra = '') { + if (!state.token) { + $('sessionInfo').textContent = 'Токен: отсутствует'; + return; + } + const source = state.authSource === 'oidc' ? 'корпоративный' : 'локальный'; + $('sessionInfo').textContent = `Токен активен | роль: ${state.role} | вход: ${source}${extra ? ` | ${extra}` : ''}`; +} + +function applyAuthenticatedSession(data) { + state.token = data.access_token; + state.role = data.role; + state.user = data.username || state.user; + state.authSource = data.auth_source || 'local'; + state.fullName = data.full_name || null; + $('sessionUser').value = state.user; + $('sessionRole').value = data.role; + updateSessionInfo(data.provider ? `провайдер: ${data.provider}` : ''); + updateProfileMeta(); + persistSession(); + applyRoleNavigation(); +} + +async function api(service, path, options = {}) { + syncSessionFromInputs(); + const headers = { + 'Content-Type': 'application/json', + 'X-User': state.user, + 'X-Role': state.role, + ...(options.headers || {}), + }; + if (state.token) { + headers.Authorization = `Bearer ${state.token}`; + } + + const response = await fetch(`/proxy/${service}/${path}`, { ...options, headers }); + let data; + try { + data = await response.json(); + } catch { + data = { error: 'Сервис вернул ответ не в JSON-формате' }; + } + if (!response.ok) { + if (response.status === 401 && state.token) { + state.token = null; + state.authSource = 'local'; + clearStoredSession(); + updateSessionInfo('требуется повторный вход'); + window.location.href = '/'; + } + throw new Error(`${response.status}: ${JSON.stringify(data)}`); + } + return data; +} + +async function checkGateway() { + try { + const response = await fetch('/health'); + const data = await response.json(); + $('gatewayStatus').textContent = `Шлюз: ${data.status === 'ok' ? 'готов' : data.status}`; + } catch { + $('gatewayStatus').textContent = 'Шлюз: недоступен'; + } +} + +async function loadOidcConfig() { + try { + const response = await fetch('/proxy/auth/auth/oidc/config'); + const data = await response.json(); + state.oidc.enabled = Boolean(data.enabled); + state.oidc.loginPath = data.login_path || '/auth/oidc/start?return_mode=popup'; + state.oidc.providerLabel = data.provider_label || 'Keycloak'; + $('corporateLoginBtn').disabled = !state.oidc.enabled; + $('corporateLoginBtn').textContent = state.oidc.enabled + ? `Корпоративный вход (${state.oidc.providerLabel})` + : 'Корпоративный вход недоступен'; + } catch { + $('corporateLoginBtn').disabled = true; + $('corporateLoginBtn').textContent = 'Корпоративный вход недоступен'; + } +} + +function startCorporateLogin() { + if (!state.oidc.enabled) { + log('Корпоративный вход недоступен'); + return; + } + const popup = window.open(`/proxy/auth${state.oidc.loginPath}`, 'oidc-login', 'width=620,height=760'); + if (!popup) { + log('Не удалось открыть окно корпоративного входа'); + return; + } + log('Открыт корпоративный вход', { провайдер: state.oidc.providerLabel }); +} + +function handleOidcMessage(event) { + if (!event?.data || typeof event.data !== 'object') { + return; + } + if (event.data.type === 'oidc-login' && event.data.access_token) { + applyAuthenticatedSession(event.data); + log('Корпоративный вход выполнен', { user: state.user, role: state.role }); + return; + } + if (event.data.type === 'oidc-error') { + log('Корпоративный вход не выполнен', { error: event.data.message || 'неизвестно' }); + updateSessionInfo(`ошибка SSO: ${event.data.message || 'неизвестно'}`); + } +} + +async function login() { + syncSessionFromInputs(); + try { + const data = await api('auth', 'auth/login', { + method: 'POST', + body: JSON.stringify({ + username: state.user, + password: $('sessionPassword').value, + }), + }); + applyAuthenticatedSession({ ...data, username: state.user, auth_source: data.auth_source || 'local' }); + log('Локальный вход выполнен', { user: state.user, role: data.role }); + } catch (err) { + $('sessionInfo').textContent = `Ошибка входа: ${err.message}`; + log('Вход не выполнен', { error: err.message }); + } +} + +function queueRulePayload(channelId, slaSeconds) { + return [ + { + channel: channelId, + priority: 3, + strategy: 'round_robin', + sla_seconds: Number(slaSeconds || 30), + }, + ]; +} + +function defaultIvrFlowDocument() { + return { + nodes: [ + { + node_id: 'root', + prompt_text: 'Саламатсыз ба! Қазақ тілін таңдау үшін бір цифрын теріңіз. Здравствуйте! Добро пожаловать в контакт-центр. Для русского языка нажмите цифру два.', + prompt_sequence: [ + { + prompt_audio_key: 'ivr/demo-language-kz', + prompt_text: + '\u0421\u0430\u043b\u0430\u043c\u0430\u0442\u0441\u044b\u0437 \u0431\u0430! \u049a\u0430\u0437\u0430\u049b \u0442\u0456\u043b\u0456\u043d \u0442\u0430\u04a3\u0434\u0430\u0443 \u04af\u0448\u0456\u043d \u0431\u0456\u0440 \u0446\u0438\u0444\u0440\u044b\u043d \u0442\u0435\u0440\u0456\u04a3\u0456\u0437.', + language: 'kz', + }, + { + prompt_audio_key: 'ivr/demo-language-ru', + prompt_text: + '\u0417\u0434\u0440\u0430\u0432\u0441\u0442\u0432\u0443\u0439\u0442\u0435! \u0414\u043e\u0431\u0440\u043e \u043f\u043e\u0436\u0430\u043b\u043e\u0432\u0430\u0442\u044c \u0432 \u043a\u043e\u043d\u0442\u0430\u043a\u0442-\u0446\u0435\u043d\u0442\u0440. \u0414\u043b\u044f \u0440\u0443\u0441\u0441\u043a\u043e\u0433\u043e \u044f\u0437\u044b\u043a\u0430 \u043d\u0430\u0436\u043c\u0438\u0442\u0435 \u0446\u0438\u0444\u0440\u0443 \u0434\u0432\u0430.', + language: 'ru', + }, + ], + is_terminal: false, + invalid_target_node_id: 'root', + no_input_target_node_id: 'root', + options: [ + { digit: '1', target_node_id: 'menu_kz' }, + { digit: '2', target_node_id: 'menu_ru' }, + ], + }, + { + node_id: 'menu_ru', + prompt_text: 'Для отдела продаж нажмите 1. Для службы поддержки нажмите 2.', + prompt_audio_key: 'ivr/demo-menu-ru', + is_terminal: false, + invalid_target_node_id: 'menu_ru', + no_input_target_node_id: 'menu_ru', + options: [ + { digit: '1', target_node_id: 'sales_ru' }, + { digit: '2', target_node_id: 'support_ru' }, + ], + }, + { + node_id: 'menu_kz', + prompt_text: 'Сату бөлімі үшін 1 басыңыз. Қолдау қызметі үшін 2 басыңыз.', + prompt_audio_key: 'ivr/demo-menu-kz', + is_terminal: false, + invalid_target_node_id: 'menu_kz', + no_input_target_node_id: 'menu_kz', + options: [ + { digit: '1', target_node_id: 'sales_kz' }, + { digit: '2', target_node_id: 'support_kz' }, + ], + }, + { + node_id: 'sales_ru', + prompt_text: 'Переводим в отдел продаж.', + prompt_audio_key: 'ivr/demo-sales-ru', + is_terminal: true, + outcome_code: 'sales_route_ru', + resolved_queue_id: 'sales_line', + resolved_queue_code: 'ivr_sales_ai_ru', + options: [], + }, + { + node_id: 'support_ru', + prompt_text: 'Переводим в службу поддержки.', + prompt_audio_key: 'ivr/demo-support-ru', + is_terminal: true, + outcome_code: 'support_route_ru', + resolved_queue_id: 'support_line', + resolved_queue_code: 'ivr_support_ai_ru', + options: [], + }, + { + node_id: 'sales_kz', + prompt_text: 'Сату бөліміне қосып жатырмыз.', + prompt_audio_key: 'ivr/demo-sales-kz', + is_terminal: true, + outcome_code: 'sales_route_kz', + resolved_queue_id: 'sales_line', + resolved_queue_code: 'ivr_sales_ai_kz', + options: [], + }, + { + node_id: 'support_kz', + prompt_text: 'Қолдау қызметіне қосып жатырмыз.', + prompt_audio_key: 'ivr/demo-support-kz', + is_terminal: true, + outcome_code: 'support_route_kz', + resolved_queue_id: 'support_line', + resolved_queue_code: 'ivr_support_ai_kz', + options: [], + }, + ], + }; +} + +function ensureDefaultIvrFlowJson() { + if (!$('ivrFlowJson').value.trim()) { + $('ivrFlowJson').value = JSON.stringify(defaultIvrFlowDocument(), null, 2); + } +} + +function renderUsers(items) { + if (!items.length) { + $('usersList').innerHTML = '
  • Пользователи пока не найдены.
  • '; + return; + } + $('usersList').innerHTML = items + .map((item) => `
  • ${item.username}
    ${item.full_name}
    роль: ${item.role}
    ID: ${item.user_id}
  • `) + .join(''); +} + +function syncUserEditFields(user) { + if (!user) { + return; + } + $('userLookupId').value = user.user_id || $('userLookupId').value; + $('editUserFullName').value = user.full_name || $('editUserFullName').value; + $('editUserRole').value = user.role || $('editUserRole').value; +} + +function renderQueues(items) { + if (!items.length) { + $('queuesList').innerHTML = '
  • Очереди пока не найдены.
  • '; + $('routeQueueId').value = ''; + $('updateQueueId').value = ''; + $('ivrQueueId').value = ''; + $('ivrTestQueueId').value = ''; + return; + } + $('queuesList').innerHTML = items + .map((item) => `
  • ${item.name}
    ID: ${item.queue_id}
    правил: ${(item.rules || []).length}
  • `) + .join(''); +} + +function renderIvrFlows(items) { + if (!items.length) { + $('ivrFlowsList').innerHTML = '
  • IVR-сценарии пока не настроены.
  • '; + return; + } + $('ivrFlowsList').innerHTML = items + .map( + (item) => + `
  • ${item.name}
    ID: ${item.flow_id}
    очередь: ${item.queue_id}
    активен: ${item.is_active}
  • `, + ) + .join(''); +} + +function renderAsteriskEvents(items) { + if (!items.length) { + $('asteriskEventsList').innerHTML = '
  • Bridge-события Asterisk не найдены.
  • '; + return; + } + $('asteriskEventsList').innerHTML = items + .map( + (item) => + `
  • ${item.ami_event_name}
    событие: ${item.bridge_event_id}
    звонок: ${item.call_id}
    статус: ${item.forward_status}
  • `, + ) + .join(''); +} + +function syncAsteriskEventFields(item) { + if (!item) { + return; + } + $('asteriskBridgeEventId').value = item.bridge_event_id || $('asteriskBridgeEventId').value; +} + +function syncIvrFlowFields(flow) { + if (!flow) { + return; + } + $('ivrFlowId').value = flow.flow_id || $('ivrFlowId').value; + $('ivrName').value = flow.name || $('ivrName').value; + $('ivrDescription').value = flow.description || $('ivrDescription').value; + $('ivrQueueId').value = flow.queue_id || $('ivrQueueId').value; + $('ivrEntryNodeId').value = flow.entry_node_id || $('ivrEntryNodeId').value; + $('ivrIsActive').checked = Boolean(flow.is_active); + $('ivrFlowJson').value = JSON.stringify(flow.flow_json || defaultIvrFlowDocument(), null, 2); +} + +function syncIvrSessionFields(payload) { + const session = payload?.session || payload; + if (!session) { + return; + } + $('ivrSessionId').value = session.session_id || $('ivrSessionId').value; + $('ivrTestCallId').value = session.call_id || $('ivrTestCallId').value; + $('ivrTestQueueId').value = session.queue_id || $('ivrTestQueueId').value; + $('ivrTestInteractionId').value = session.interaction_id || $('ivrTestInteractionId').value; +} + +function parseIvrFlowJson() { + ensureDefaultIvrFlowJson(); + return JSON.parse($('ivrFlowJson').value || '{}'); +} + +async function loadUsers() { + try { + const data = await api('auth', 'users'); + renderUsers(data); + if (data.length && !$('userLookupId').value.trim()) { + $('userLookupId').value = data[0].user_id; + } + log('Список пользователей обновлён', { count: data.length }); + } catch (err) { + $('usersList').innerHTML = '
  • Не удалось загрузить пользователей.
  • '; + log('Не удалось загрузить пользователей', { error: err.message }); + } +} + +async function loadUserDetail() { + const userId = $('userLookupId').value.trim(); + if (!userId) { + $('userDetailOutput').textContent = 'Укажите user_id для загрузки карточки.'; + return; + } + try { + const data = await api('auth', `users/${encodeURIComponent(userId)}`); + $('userDetailOutput').textContent = JSON.stringify(data, null, 2); + syncUserEditFields(data); + log('Карточка пользователя загружена', { user_id: data.user_id, role: data.role }); + } catch (err) { + $('userDetailOutput').textContent = err.message; + log('Не удалось загрузить карточку пользователя', { error: err.message }); + } +} + +async function createUser() { + try { + const data = await api('auth', 'users', { + method: 'POST', + body: JSON.stringify({ + username: $('newUsername').value.trim() || 'wave2_admin_demo', + password: $('newPassword').value, + full_name: $('newFullName').value.trim() || 'Wave 2 Demo User', + role: $('newUserRole').value, + }), + }); + $('userDetailOutput').textContent = JSON.stringify(data, null, 2); + syncUserEditFields(data); + $('editUserPassword').value = ''; + log('Пользователь создан', { username: data.username, role: data.role }); + await loadUsers(); + } catch (err) { + $('userDetailOutput').textContent = err.message; + log('Не удалось создать пользователя', { error: err.message }); + } +} + +async function updateUser() { + const userId = $('userLookupId').value.trim(); + if (!userId) { + $('userDetailOutput').textContent = 'Укажите user_id для обновления пользователя.'; + return; + } + + const payload = { + full_name: $('editUserFullName').value.trim() || null, + role: $('editUserRole').value || null, + }; + const password = $('editUserPassword').value; + if (password) { + payload.password = password; + } + + try { + const data = await api('auth', `users/${encodeURIComponent(userId)}`, { + method: 'PATCH', + body: JSON.stringify(payload), + }); + $('userDetailOutput').textContent = JSON.stringify(data, null, 2); + syncUserEditFields(data); + $('editUserPassword').value = ''; + log('Пользователь обновлён', { user_id: data.user_id, role: data.role }); + await loadUsers(); + } catch (err) { + $('userDetailOutput').textContent = err.message; + log('Не удалось обновить пользователя', { error: err.message }); + } +} + +async function deleteUser() { + const userId = $('userLookupId').value.trim(); + if (!userId) { + $('userDetailOutput').textContent = 'Укажите user_id для удаления пользователя.'; + return; + } + + try { + const data = await api('auth', `users/${encodeURIComponent(userId)}`, { + method: 'DELETE', + }); + $('userDetailOutput').textContent = JSON.stringify(data, null, 2); + $('userLookupId').value = ''; + $('editUserPassword').value = ''; + log('Пользователь удалён', { user_id: data.user_id }); + await loadUsers(); + } catch (err) { + $('userDetailOutput').textContent = err.message; + log('Не удалось удалить пользователя', { error: err.message }); + } +} + +async function loadQueues() { + try { + const data = await api('routing', 'queues'); + renderQueues(data); + if (data.length) { + const first = data[0]; + if (!$('routeQueueId').value.trim()) { + $('routeQueueId').value = first.queue_id; + } + if (!$('updateQueueId').value.trim()) { + $('updateQueueId').value = first.queue_id; + } + if (!$('ivrQueueId').value.trim()) { + $('ivrQueueId').value = first.queue_id; + } + if (!$('ivrTestQueueId').value.trim()) { + $('ivrTestQueueId').value = first.queue_id; + } + } + log('Список очередей обновлён', { count: data.length }); + } catch (err) { + $('queuesList').innerHTML = '
  • Не удалось загрузить очереди.
  • '; + log('Не удалось загрузить очереди', { error: err.message }); + } +} + +async function createQueue() { + try { + const payload = { + name: $('queueName').value.trim() || 'Демонстрационная очередь', + description: $('queueDescription').value.trim() || '', + rules: queueRulePayload($('queueChannel').value, $('queueSla').value), + }; + const data = await api('routing', 'queues', { + method: 'POST', + body: JSON.stringify(payload), + }); + $('routeQueueId').value = data.queue_id; + $('updateQueueId').value = data.queue_id; + if (!$('ivrQueueId').value.trim()) { + $('ivrQueueId').value = data.queue_id; + } + if (!$('ivrTestQueueId').value.trim()) { + $('ivrTestQueueId').value = data.queue_id; + } + $('queueUpdateOutput').textContent = JSON.stringify(data, null, 2); + log('Очередь создана', { queue_id: data.queue_id, channel: $('queueChannel').value }); + await loadQueues(); + } catch (err) { + $('queueUpdateOutput').textContent = err.message; + log('Не удалось создать очередь', { error: err.message }); + } +} + +async function updateQueueRules() { + const queueId = $('updateQueueId').value.trim(); + if (!queueId) { + $('queueUpdateOutput').textContent = 'Укажите queue_id для обновления.'; + return; + } + try { + const payload = { + name: $('updateQueueName').value.trim() || 'Демонстрационная очередь обновлена', + description: $('updateQueueDescription').value.trim() || '', + rules: queueRulePayload($('updateQueueChannel').value, $('updateQueueSla').value), + }; + const data = await api('routing', `queues/${encodeURIComponent(queueId)}/rules`, { + method: 'PATCH', + body: JSON.stringify(payload), + }); + $('queueUpdateOutput').textContent = JSON.stringify(data, null, 2); + $('routeQueueId').value = data.queue_id; + log('Правила очереди обновлены', { queue_id: data.queue_id, rules: (data.rules || []).length }); + await loadQueues(); + } catch (err) { + $('queueUpdateOutput').textContent = err.message; + log('Не удалось обновить очередь', { error: err.message }); + } +} + +async function deleteQueue() { + const queueId = $('updateQueueId').value.trim(); + if (!queueId) { + $('queueUpdateOutput').textContent = 'Укажите queue_id для удаления очереди.'; + return; + } + + try { + const data = await api('routing', `queues/${encodeURIComponent(queueId)}`, { + method: 'DELETE', + }); + $('queueUpdateOutput').textContent = JSON.stringify(data, null, 2); + if ($('routeQueueId').value.trim() === queueId) { + $('routeQueueId').value = ''; + } + if ($('ivrQueueId').value.trim() === queueId) { + $('ivrQueueId').value = ''; + } + if ($('ivrTestQueueId').value.trim() === queueId) { + $('ivrTestQueueId').value = ''; + } + $('updateQueueId').value = ''; + log('Очередь удалена', { queue_id: data.queue_id }); + await loadQueues(); + } catch (err) { + $('queueUpdateOutput').textContent = err.message; + log('Не удалось удалить очередь', { error: err.message }); + } +} + +async function previewRoute() { + try { + const queueId = encodeURIComponent($('routeQueueId').value.trim()); + const channel = encodeURIComponent($('routeChannel').value); + const priority = Number($('routePriority').value || 3); + const data = await api('routing', `queues/${queueId}/route?channel=${channel}&priority=${priority}`, { + method: 'POST', + }); + $('routeOutput').textContent = JSON.stringify(data, null, 2); + log('Маршрут рассчитан', { queue_id: data.queue_id, assignee: data.assignee }); + } catch (err) { + $('routeOutput').textContent = err.message; + log('Не удалось рассчитать маршрут', { error: err.message }); + } +} + +async function loadIvrFlows() { + try { + const data = await api('ivr', 'ivr/flows'); + renderIvrFlows(data); + if (data.length && !$('ivrFlowId').value.trim()) { + syncIvrFlowFields(data[0]); + } + log('IVR-сценарии загружены', { count: data.length }); + } catch (err) { + $('ivrFlowsList').innerHTML = '
  • Не удалось загрузить IVR-сценарии.
  • '; + $('ivrFlowOutput').textContent = err.message; + log('Не удалось загрузить IVR-сценарии', { error: err.message }); + } +} + +async function loadIvrFlowDetail() { + const flowId = $('ivrFlowId').value.trim(); + if (!flowId) { + $('ivrFlowOutput').textContent = 'Укажите ID сценария для открытия IVR.'; + return; + } + try { + const data = await api('ivr', `ivr/flows/${encodeURIComponent(flowId)}`); + syncIvrFlowFields(data); + $('ivrFlowOutput').textContent = JSON.stringify(data, null, 2); + log('IVR-сценарий загружен', { flow_id: data.flow_id, active: data.is_active }); + } catch (err) { + $('ivrFlowOutput').textContent = err.message; + log('Не удалось загрузить IVR-сценарий', { error: err.message }); + } +} + +async function createIvrFlow() { + try { + const data = await api('ivr', 'ivr/flows', { + method: 'POST', + body: JSON.stringify({ + name: $('ivrName').value.trim() || 'Демонстрационный IVR-сценарий', + description: $('ivrDescription').value.trim() || '', + queue_id: $('ivrQueueId').value.trim(), + entry_node_id: $('ivrEntryNodeId').value.trim() || 'root', + flow_json: parseIvrFlowJson(), + is_active: $('ivrIsActive').checked, + }), + }); + syncIvrFlowFields(data); + $('ivrFlowOutput').textContent = JSON.stringify(data, null, 2); + log('IVR-сценарий создан', { flow_id: data.flow_id, queue_id: data.queue_id }); + await loadIvrFlows(); + } catch (err) { + $('ivrFlowOutput').textContent = err.message; + log('Не удалось создать IVR-сценарий', { error: err.message }); + } +} + +async function updateIvrFlow() { + const flowId = $('ivrFlowId').value.trim(); + if (!flowId) { + $('ivrFlowOutput').textContent = 'Укажите ID сценария для обновления IVR.'; + return; + } + try { + const data = await api('ivr', `ivr/flows/${encodeURIComponent(flowId)}`, { + method: 'PATCH', + body: JSON.stringify({ + name: $('ivrName').value.trim() || null, + description: $('ivrDescription').value.trim() || null, + entry_node_id: $('ivrEntryNodeId').value.trim() || null, + flow_json: parseIvrFlowJson(), + is_active: $('ivrIsActive').checked, + }), + }); + syncIvrFlowFields(data); + $('ivrFlowOutput').textContent = JSON.stringify(data, null, 2); + log('IVR-сценарий обновлён', { flow_id: data.flow_id, version: data.version }); + await loadIvrFlows(); + } catch (err) { + $('ivrFlowOutput').textContent = err.message; + log('Не удалось обновить IVR-сценарий', { error: err.message }); + } +} + +async function activateIvrFlow() { + const flowId = $('ivrFlowId').value.trim(); + if (!flowId) { + $('ivrFlowOutput').textContent = 'Укажите ID сценария для активации IVR.'; + return; + } + try { + const data = await api('ivr', `ivr/flows/${encodeURIComponent(flowId)}/activate`, { + method: 'POST', + }); + syncIvrFlowFields(data); + $('ivrFlowOutput').textContent = JSON.stringify(data, null, 2); + log('IVR-сценарий активирован', { flow_id: data.flow_id }); + await loadIvrFlows(); + } catch (err) { + $('ivrFlowOutput').textContent = err.message; + log('Не удалось активировать IVR-сценарий', { error: err.message }); + } +} + +async function startIvrSession() { + try { + const data = await api('ivr', 'ivr/sessions/start', { + method: 'POST', + body: JSON.stringify({ + call_id: $('ivrTestCallId').value.trim() || 'demo_call_ivr', + queue_id: $('ivrTestQueueId').value.trim(), + interaction_id: $('ivrTestInteractionId').value.trim() || null, + }), + }); + syncIvrSessionFields(data); + $('ivrSessionOutput').textContent = JSON.stringify(data, null, 2); + log('IVR-сессия запущена', { session_id: data.session.session_id, flow_id: data.session.flow_id }); + } catch (err) { + $('ivrSessionOutput').textContent = err.message; + log('Не удалось запустить IVR-сессию', { error: err.message }); + } +} + +async function sendIvrDigit() { + const sessionId = $('ivrSessionId').value.trim(); + if (!sessionId) { + $('ivrSessionOutput').textContent = 'Укажите ID сессии для отправки DTMF.'; + return; + } + try { + const data = await api('ivr', `ivr/sessions/${encodeURIComponent(sessionId)}/dtmf`, { + method: 'POST', + body: JSON.stringify({ + digit: ($('ivrDigit').value.trim() || '2').slice(0, 1), + }), + }); + syncIvrSessionFields(data); + $('ivrSessionOutput').textContent = JSON.stringify(data, null, 2); + log('DTMF обработан', { + session_id: data.session.session_id, + completed: data.completed, + outcome_code: data.session.outcome_code, + }); + } catch (err) { + $('ivrSessionOutput').textContent = err.message; + log('Не удалось обработать DTMF', { error: err.message }); + } +} + +async function loadIvrSession() { + const sessionId = $('ivrSessionId').value.trim(); + if (!sessionId) { + $('ivrSessionOutput').textContent = 'Укажите ID сессии для загрузки IVR.'; + return; + } + try { + const data = await api('ivr', `ivr/sessions/${encodeURIComponent(sessionId)}`); + syncIvrSessionFields(data); + $('ivrSessionOutput').textContent = JSON.stringify(data, null, 2); + log('IVR-сессия загружена', { session_id: data.session_id, status: data.status }); + } catch (err) { + $('ivrSessionOutput').textContent = err.message; + log('Не удалось загрузить IVR-сессию', { error: err.message }); + } +} + +async function previewIvrRoute() { + const queueId = $('ivrTestQueueId').value.trim(); + const sessionId = $('ivrSessionId').value.trim(); + if (!queueId || !sessionId) { + $('ivrRouteOutput').textContent = 'Укажите ID очереди и ID сессии для предпросмотра IVR-маршрута.'; + return; + } + try { + const data = await api( + 'routing', + `queues/${encodeURIComponent(queueId)}/route?channel=voice&priority=3&ivr_session_id=${encodeURIComponent(sessionId)}`, + { + method: 'POST', + }, + ); + $('ivrRouteOutput').textContent = JSON.stringify(data, null, 2); + log('Предпросмотр IVR-маршрута готов', { + original_queue_id: data.original_queue_id || queueId, + resolved_queue_id: data.resolved_queue_id || data.queue_id, + }); + } catch (err) { + $('ivrRouteOutput').textContent = err.message; + log('Не удалось показать IVR-маршрут', { error: err.message }); + } +} + +async function loadAsteriskStatus() { + try { + const data = await api('asterisk-bridge', 'asterisk/status'); + $('asteriskStatusOutput').textContent = JSON.stringify(data, null, 2); + log('Статус моста Asterisk загружен', { + status: data.status, + ami_connected: data.ami_connected, + }); + } catch (err) { + $('asteriskStatusOutput').textContent = err.message; + log('Не удалось загрузить статус моста Asterisk', { error: err.message }); + } +} + +async function loadAsteriskEvents() { + try { + const filter = $('asteriskEventStatus').value; + const suffix = filter ? `?status=${encodeURIComponent(filter)}` : ''; + const data = await api('asterisk-bridge', `asterisk/events${suffix}`); + renderAsteriskEvents(data); + if (data.length && !$('asteriskBridgeEventId').value.trim()) { + syncAsteriskEventFields(data[0]); + } + log('Bridge-события Asterisk загружены', { count: data.length, status: filter || 'all' }); + } catch (err) { + $('asteriskEventsList').innerHTML = '
  • Не удалось загрузить bridge-события Asterisk.
  • '; + $('asteriskEventOutput').textContent = err.message; + log('Не удалось загрузить bridge-события Asterisk', { error: err.message }); + } +} + +async function loadAsteriskEventDetail() { + const bridgeEventId = $('asteriskBridgeEventId').value.trim(); + if (!bridgeEventId) { + $('asteriskEventOutput').textContent = 'Укажите ID bridge-события для открытия.'; + return; + } + try { + const data = await api('asterisk-bridge', `asterisk/events/${encodeURIComponent(bridgeEventId)}`); + syncAsteriskEventFields(data); + $('asteriskEventOutput').textContent = JSON.stringify(data, null, 2); + log('Bridge-событие Asterisk загружено', { + bridge_event_id: data.bridge_event_id, + status: data.forward_status, + }); + } catch (err) { + $('asteriskEventOutput').textContent = err.message; + log('Не удалось загрузить bridge-событие Asterisk', { error: err.message }); + } +} + +async function retryAsteriskEvent() { + const bridgeEventId = $('asteriskBridgeEventId').value.trim(); + if (!bridgeEventId) { + $('asteriskEventOutput').textContent = 'Укажите ID bridge-события для повтора.'; + return; + } + try { + const data = await api('asterisk-bridge', `asterisk/events/${encodeURIComponent(bridgeEventId)}/retry`, { + method: 'POST', + }); + syncAsteriskEventFields(data); + $('asteriskEventOutput').textContent = JSON.stringify(data, null, 2); + log('Bridge-событие Asterisk отправлено на повтор', { + bridge_event_id: data.bridge_event_id, + status: data.forward_status, + }); + await loadAsteriskEvents(); + } catch (err) { + $('asteriskEventOutput').textContent = err.message; + log('Не удалось повторить bridge-событие Asterisk', { error: err.message }); + } +} + +async function reconnectAsterisk() { + try { + const data = await api('asterisk-bridge', 'asterisk/reconnect', { + method: 'POST', + }); + $('asteriskStatusOutput').textContent = JSON.stringify(data, null, 2); + log('Запрошено переподключение моста Asterisk', { + status: data.status, + ami_connected: data.ami_connected, + }); + } catch (err) { + $('asteriskStatusOutput').textContent = err.message; + log('Не удалось запросить переподключение Asterisk', { error: err.message }); + } +} + +function wire() { + $('loginBtn').addEventListener('click', login); + $('corporateLoginBtn').addEventListener('click', startCorporateLogin); + $('refreshBtn').addEventListener('click', async () => { + await Promise.all([loadUsers(), loadQueues(), loadIvrFlows(), loadAsteriskStatus(), loadAsteriskEvents()]); + log('Данные админ-консоли обновлены'); + }); + $('loadUsersBtn').addEventListener('click', loadUsers); + $('loadUserDetailBtn').addEventListener('click', loadUserDetail); + $('createUserBtn').addEventListener('click', createUser); + $('updateUserBtn').addEventListener('click', updateUser); + $('deleteUserBtn').addEventListener('click', deleteUser); + $('loadQueuesBtn').addEventListener('click', loadQueues); + $('createQueueBtn').addEventListener('click', createQueue); + $('updateQueueBtn').addEventListener('click', updateQueueRules); + $('deleteQueueBtn').addEventListener('click', deleteQueue); + $('previewRouteBtn').addEventListener('click', previewRoute); + $('loadIvrFlowsBtn').addEventListener('click', loadIvrFlows); + $('createIvrFlowBtn').addEventListener('click', createIvrFlow); + $('loadIvrFlowDetailBtn').addEventListener('click', loadIvrFlowDetail); + $('updateIvrFlowBtn').addEventListener('click', updateIvrFlow); + $('activateIvrFlowBtn').addEventListener('click', activateIvrFlow); + $('startIvrSessionBtn').addEventListener('click', startIvrSession); + $('sendIvrDigitBtn').addEventListener('click', sendIvrDigit); + $('loadIvrSessionBtn').addEventListener('click', loadIvrSession); + $('previewIvrRouteBtn').addEventListener('click', previewIvrRoute); + $('loadAsteriskStatusBtn').addEventListener('click', loadAsteriskStatus); + $('loadAsteriskEventsBtn').addEventListener('click', loadAsteriskEvents); + $('loadAsteriskEventDetailBtn').addEventListener('click', loadAsteriskEventDetail); + $('retryAsteriskEventBtn').addEventListener('click', retryAsteriskEvent); + $('reconnectAsteriskBtn').addEventListener('click', reconnectAsterisk); + $('logoutBtn').addEventListener('click', logout); + window.addEventListener('message', handleOidcMessage); +} + +async function init() { + if (!restoreStoredSession()) { + window.location.href = '/'; + return; + } + if (!ensurePageAccess()) { + return; + } + wire(); + ensureDefaultIvrFlowJson(); + updateSessionInfo(); + await Promise.all([checkGateway(), loadOidcConfig(), loadUsers(), loadQueues(), loadIvrFlows(), loadAsteriskStatus(), loadAsteriskEvents()]); + log('Админ-консоль готова'); +} + +init(); diff --git a/ui/admin/index.html b/ui/admin/index.html new file mode 100644 index 0000000..c1801f4 --- /dev/null +++ b/ui/admin/index.html @@ -0,0 +1,291 @@ + + + + + + KonturCC Администрирование + + + + + + + +
    + + +
    +
    + +
    +
    Шлюз: проверка...
    + +
    +
    AD
    +
    +
    Экран администратора
    +
    Панель управления
    +
    +
    +
    +
    + +
    +
    +

    Административный контур

    +

    Панель администратора

    +

    Управление пользователями, очередями и предпросмотром маршрутизации без технического режима.

    +
    + +
    +

    Сессия

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

    Токен: отсутствует

    +
    + +
    +
    +

    Пользователи

    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + +
    +
    Карточка пользователя появится здесь.
    +
      +
      + +
      +

      Очереди

      +
      + + +
      +
      + + +
      +
      + + +
      +
      + + +
      +
      + + +
      +
      + + +
      +
      + +
      +
      Результат обновления очереди появится здесь.
      +
        +
        + +
        +

        Предпросмотр маршрута

        +
        + + +
        +
        + + +
        +
        Результат маршрутизации появится здесь.
        +
        + +
        +

        IVR

        +
        + + +
        +
        + + +
        +
        + + +
        +
        + + +
        +
        + + + + + +
        +
          +
          Детали IVR-сценария появятся здесь.
          + +
          + + +
          +
          + + +
          +
          + + + + + +
          +
          Состояние IVR-сессии появится здесь.
          +
          Предпросмотр IVR-маршрута появится здесь.
          +
          + +
          +

          Мост Asterisk

          +

          Диагностика AMI-подключения, пересланных bridge-событий и ручного повтора.

          +
          + + +
          +
          + + +
          +
          + + + +
          +
          Статус моста Asterisk появится здесь.
          +
            +
            Детали bridge-события Asterisk появятся здесь.
            +
            + +
            +

            Лог действий

            +

            Сверху всегда последнее действие администратора.

            +
            
            +          
            +
            +
            +
            +
            + + + + diff --git a/ui/analyst/app.js b/ui/analyst/app.js new file mode 100644 index 0000000..cb61d0e --- /dev/null +++ b/ui/analyst/app.js @@ -0,0 +1,7045 @@ +const state = { + user: 'analyst', + role: 'analyst', + token: null, + authSource: 'local', + fullName: null, + analytics: { + preset: '7d', + fromTs: '', + toTs: '', + queueId: 'all', + channel: 'all', + compareMode: 'previous', + trendMetric: 'volume', + aiTrendMetric: 'containment_rate', + agentTrendMetric: 'interactions_per_agent', + overview: null, + compare: null, + trend: [], + aiOverview: null, + aiCompare: null, + aiTrend: [], + agentOverview: null, + agentCompare: null, + agentTrend: [], + channelRows: [], + queueRows: [], + agentRows: [], + coverage: null, + queueOptions: [], + savedViews: [], + activeViewId: '', + lastRangeMeta: null, + trendInterval: 'day', + agentTrendInterval: 'day', + statusMessage: '', + loading: false, + error: '', + aiError: '', + agentError: '', + agentTrendError: '', + queueAccessError: '', + requestId: 0, + }, + drilldown: { + open: false, + mode: 'interaction', + sourceType: '', + sourceLabel: '', + sourceValue: '', + metric: '', + filters: null, + coverage: null, + metricNote: '', + items: [], + total: 0, + limit: 12, + offset: 0, + selectedInteractionId: '', + selectedInteraction: null, + selectedLinkedInteraction: null, + selectedTimeline: [], + loading: false, + detailLoading: false, + exporting: false, + notice: '', + error: '', + }, + oidc: { + enabled: false, + loginPath: '/auth/oidc/start?return_mode=popup', + providerLabel: 'Keycloak', + }, +}; + +const $ = (id) => document.getElementById(id); +const SESSION_STORAGE_KEY = 'cc_session'; +const DRILLDOWN_PAGE_SIZE = 12; +const DRILLDOWN_EXPORT_BATCH_SIZE = 100; +const DRILLDOWN_EXPORT_SOFT_CAP = 1000; +const ANALYTICS_TREND_OPTIONS = ['volume', 'SL', 'ASA', 'Abandon']; +const AI_ANALYTICS_TREND_OPTIONS = [ + 'containment_rate', + 'handoff_rate', + 'human_touched_rate', + 'ai_latency_avg_ms', + 'closed_without_operator_rate', +]; +const AGENT_ANALYTICS_TREND_OPTIONS = [ + 'interactions_per_agent', + 'agents_with_activity', + 'avg_handle_seconds', + 'fcr_rate', +]; +const ENABLE_ANALYTICS_MOCK_DATA = true; +const ANALYTICS_MOCK_QUEUE_OPTIONS = [ + { queue_id: 'sales_kz', name: 'Продажи KZ' }, + { queue_id: 'support_kz', name: 'Поддержка KZ' }, + { queue_id: 'vip_kz', name: 'VIP-линия' }, + { queue_id: 'retention_kz', name: 'Удержание' }, +]; +const ANALYTICS_MOCK_AGENTS = [ + 'agent.aigerim', + 'agent.dias', + 'agent.nurdaulet', + 'agent.madina', + 'agent.ruslan', + 'agent.tomiris', +]; +const ANALYTICS_DEEP_LINK_KEYS = [ + 'preset', + 'from', + 'to', + 'queue', + 'channel', + 'compare', + 'trend', + 'ai_trend', + 'agent_trend', + 'view', + 'dd', + 'dd_mode', + 'dd_source', + 'dd_value', + 'dd_metric', + 'dd_slice', + 'dd_reason', + 'dd_status', + 'dd_q', + 'dd_sort_by', + 'dd_sort_dir', + 'dd_offset', + 'dd_selected', + 'dd_agent', +]; +let analyticsDrilldownSearchDebounce = 0; +let analyticsDrilldownReturnFocus = null; +let analyticsUrlSyncSuspended = false; +const ROLE_LABELS = { + admin: 'Администратор', + supervisor: 'Супервизор', + operator: 'Оператор', + analyst: 'Аналитик', +}; + +const ANALYTICS_CHANNEL_OPTIONS = [ + { value: 'all', label: 'Все каналы' }, + { value: 'voice', label: 'Голос' }, + { value: 'telegram', label: 'Telegram' }, + { value: 'whatsapp', label: 'WhatsApp' }, + { value: 'webchat', label: 'Веб-чат' }, + { value: 'email', label: 'Email' }, +]; + +const ANALYTICS_PRESET_LABELS = { + today: 'Сегодня', + '7d': 'Последние 7 дней', + '30d': 'Последние 30 дней', +}; + +const ANALYTICS_COMPARE_MODE_LABELS = { + previous: 'С предыдущим окном', + off: 'Только текущий срез', +}; + +const ANALYTICS_TREND_LABELS = { + volume: 'Обращения', + SL: 'SLA', + ASA: 'Среднее ожидание', + Abandon: 'Потери', +}; + +const ANALYTICS_METRIC_META = { + total: { unit: 'count', better: 'up', note: 'Все обращения за выбранный период' }, + answered: { unit: 'count', better: 'up', note: 'Обращения, обработанные без потери' }, + SL: { unit: 'pct', better: 'up', note: 'Доля обращений, уложившихся в SLA' }, + ASA: { unit: 'seconds', better: 'down', note: 'Среднее ожидание до ответа' }, + AHT: { unit: 'seconds', better: 'down', note: 'Среднее время обработки обращения' }, + Abandon: { unit: 'pct', better: 'down', note: 'Доля потерянных обращений' }, + FCR: { unit: 'pct', better: 'up', note: 'Решено с первого контакта' }, + DigitalShare: { unit: 'pct', better: 'neutral', note: 'Доля цифровых каналов в потоке' }, + volume: { unit: 'count', better: 'up', note: 'Объём обращений в интервале' }, +}; + +const AI_ANALYTICS_TREND_LABELS = { + containment_rate: 'Закрыто AI', + handoff_rate: 'Передано оператору', + ai_latency_avg_ms: 'Задержка AI', + closed_without_operator_rate: 'Закрыто без оператора', + human_touched_rate: 'С участием оператора', +}; + +const AI_ANALYTICS_METRIC_META = { + containment_rate: { + unit: 'pct', + better: 'up', + note: 'Доля AI-сессий, закрытых самим AI без передачи оператору и без назначения исполнителя.', + }, + handoff_rate: { + unit: 'pct', + better: 'down', + note: 'Доля AI-сессий, которые завершились передачей оператору или ушли в ручное ведение.', + }, + ai_latency_avg_ms: { + unit: 'ms', + better: 'down', + note: 'Средняя задержка AI-ответа по ответам модели.', + }, + closed_without_operator_rate: { + unit: 'pct', + better: 'up', + note: 'Среди закрытых AI-связанных обращений доля кейсов без назначения оператора.', + }, + human_touched_rate: { + unit: 'pct', + better: 'down', + note: 'Доля сессий, где участвовал оператор: через назначение или ручной takeover.', + }, +}; + +function destinationForRole(role) { + if (role === 'admin') { + return '/admin'; + } + if (role === 'supervisor') { + return '/supervisor'; + } + if (role === 'analyst') { + return '/analyst'; + } + return '/operator'; +} + +function applyRoleNavigation() { + document.querySelectorAll('[data-shell]').forEach((link) => { + const allowed = (link.dataset.roles || '') + .split(',') + .map((item) => item.trim()) + .filter(Boolean); + link.style.display = !allowed.length || allowed.includes(state.role) ? '' : 'none'; + }); +} + +function ensurePageAccess() { + const allowed = ['admin', 'supervisor', 'analyst']; + if (!allowed.includes(state.role)) { + window.location.href = destinationForRole(state.role); + return false; + } + return true; +} + +function persistSession() { + window.localStorage.setItem( + SESSION_STORAGE_KEY, + JSON.stringify({ + access_token: state.token, + user: state.user, + role: state.role, + auth_source: state.authSource, + full_name: state.fullName, + }), + ); +} + +function clearStoredSession() { + window.localStorage.removeItem(SESSION_STORAGE_KEY); +} + +function syncSessionInputs() { + const sessionUser = $('sessionUser'); + const sessionRole = $('sessionRole'); + if (sessionUser) { + sessionUser.value = state.user; + } + if (sessionRole) { + sessionRole.value = state.role; + } +} + +function restoreStoredSession() { + const raw = window.localStorage.getItem(SESSION_STORAGE_KEY); + if (!raw) { + return false; + } + try { + const payload = JSON.parse(raw); + if (!payload?.access_token) { + clearStoredSession(); + return false; + } + state.token = payload.access_token; + state.user = payload.user || state.user; + state.role = payload.role || state.role; + state.authSource = payload.auth_source || 'local'; + state.fullName = payload.full_name || null; + syncSessionInputs(); + updateProfileMeta(); + applyRoleNavigation(); + return true; + } catch { + clearStoredSession(); + return false; + } +} + +function logout() { + clearStoredSession(); + window.location.href = '/'; +} + +function syncSessionFromInputs() { + const sessionUser = $('sessionUser'); + const sessionRole = $('sessionRole'); + state.user = sessionUser?.value.trim() || state.user; + state.role = sessionRole?.value || state.role; +} + +function escapeHtml(value) { + return String(value ?? '') + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); +} + +function renderSummaryCard(label, value, note = '') { + return ` +
            +
            ${label}
            +
            ${value}
            + ${note ? `
            ${note}
            ` : ''} +
            + `; +} + +function updateProfileMeta() { + const profileName = $('profileName'); + const profileRole = $('profileRole'); + if (!profileName || !profileRole) { + return; + } + profileName.textContent = state.fullName || state.user || 'Пользователь KonturCC'; + profileRole.textContent = ROLE_LABELS[state.role] || state.role || 'Пользователь'; +} + +function updateSessionInfo(extra = '') { + const box = $('sessionInfo'); + if (!box) { + return; + } + if (!state.token) { + box.textContent = 'Токен: отсутствует'; + return; + } + const source = state.authSource === 'oidc' ? 'корпоративный' : 'локальный'; + box.textContent = `Токен активен | роль: ${state.role} | вход: ${source}${extra ? ` | ${extra}` : ''}`; +} + +function applyAuthenticatedSession(data) { + state.token = data.access_token; + state.role = data.role; + state.user = data.username || state.user; + state.authSource = data.auth_source || 'local'; + state.fullName = data.full_name || null; + syncSessionInputs(); + updateSessionInfo(data.provider ? `провайдер: ${data.provider}` : ''); + updateProfileMeta(); + persistSession(); + applyRoleNavigation(); + ensurePageAccess(); +} + +function buildProxyHeaders(extra = {}) { + syncSessionFromInputs(); + const headers = { + 'X-User': state.user, + 'X-Role': state.role, + ...extra, + }; + if (state.token) { + headers.Authorization = `Bearer ${state.token}`; + } + return headers; +} + +async function api(service, path, options = {}) { + const headers = buildProxyHeaders({ + 'Content-Type': 'application/json', + ...(options.headers || {}), + }); + + const response = await fetch(`/proxy/${service}/${path}`, { ...options, headers }); + let data; + try { + data = await response.json(); + } catch { + data = { error: 'Сервис вернул ответ не в JSON-формате' }; + } + if (!response.ok) { + if (response.status === 401 && state.token) { + state.token = null; + state.authSource = 'local'; + clearStoredSession(); + updateSessionInfo('требуется повторный вход'); + window.location.href = '/'; + } + throw new Error(`${response.status}: ${JSON.stringify(data)}`); + } + return data; +} + +function contentDispositionFilename(value) { + if (!value) { + return ''; + } + const match = /filename=\"?([^\";]+)\"?/i.exec(value); + return match ? decodeURIComponent(match[1]) : ''; +} + +async function downloadProxyFile(service, path, fallbackFilename) { + const response = await fetch(`/proxy/${service}/${path}`, { + headers: buildProxyHeaders(), + }); + if (!response.ok) { + let detail = ''; + try { + detail = JSON.stringify(await response.json()); + } catch { + detail = await response.text(); + } + throw new Error(`${response.status}: ${detail}`); + } + const blob = await response.blob(); + const filename = contentDispositionFilename(response.headers.get('content-disposition')) || fallbackFilename; + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = filename; + document.body.append(link); + link.click(); + link.remove(); + URL.revokeObjectURL(url); + return filename; +} + +async function checkGateway() { + const pill = $('gatewayStatus'); + if (!pill) { + return; + } + try { + const response = await fetch('/health'); + const data = await response.json(); + pill.textContent = `Данные: ${data.status === 'ok' ? 'готовы' : data.status}`; + } catch { + pill.textContent = 'Данные: недоступны'; + } +} + +async function loadOidcConfig() { + try { + const response = await fetch('/proxy/auth/auth/oidc/config'); + const data = await response.json(); + state.oidc.enabled = Boolean(data.enabled); + state.oidc.loginPath = data.login_path || '/auth/oidc/start?return_mode=popup'; + state.oidc.providerLabel = data.provider_label || 'Keycloak'; + } catch { + state.oidc.enabled = false; + } +} + +function handleOidcMessage(event) { + if (!event?.data || typeof event.data !== 'object') { + return; + } + if (event.data.type === 'oidc-login' && event.data.access_token) { + applyAuthenticatedSession(event.data); + return; + } + if (event.data.type === 'oidc-error') { + updateSessionInfo(`ошибка SSO: ${event.data.message || 'неизвестно'}`); + } +} + +function emptyKpiEnvelope(fromTs = null, toTs = null) { + return { + window: { from: fromTs, to: toTs }, + volume: { + total: 0, + answered: 0, + abandoned: 0, + }, + kpi: { + SL: 0, + ASA: 0, + AHT: 0, + Abandon: 0, + FCR: 0, + AnswerRate: 0, + WaitP95: 0, + HandleP95: 0, + Occupancy: 0, + DigitalShare: 0, + }, + breakdowns: { + by_channel: {}, + }, + coverage: { + metric_status: {}, + supported_channels: {}, + exact_rows: 0, + total_rows: 0, + note: null, + metric_details: {}, + }, + filters: {}, + }; +} + +function emptyAiAnalyticsOverview(fromTs = null, toTs = null, channel = 'all', queueId = null) { + return { + window: { + from_ts: fromTs, + to_ts: toTs, + }, + filters: { + from_ts: fromTs, + to_ts: toTs, + queue_id: queueId, + channel, + }, + totals: { + sessions_started: 0, + sessions_contained: 0, + sessions_handoff: 0, + sessions_closed: 0, + sessions_closed_without_operator: 0, + assistant_turns: 0, + }, + metrics: { + containment_rate: 0, + handoff_rate: 0, + ai_latency_avg_ms: null, + ai_latency_p95_ms: null, + closed_without_operator_rate: 0, + human_touched_rate: 0, + }, + breakdowns: { + by_channel: [], + by_outcome: [], + by_handoff_reason: [], + }, + coverage: { + sessions_with_interaction_id: 0, + sessions_with_queue_id: 0, + sessions_with_latency_turns: 0, + sessions_with_terminal_state: 0, + sessions_with_handoff_reason: 0, + }, + }; +} + +function emptyAiAnalyticsTimeseries(metric = 'containment_rate', interval = 'day', fromTs = null, toTs = null, channel = 'all', queueId = null) { + return { + metric, + interval, + filters: { + from_ts: fromTs, + to_ts: toTs, + queue_id: queueId, + channel, + }, + points: [], + }; +} + +function emptyAgentAnalyticsOverview(fromTs = null, toTs = null, channel = 'all', queueId = null) { + return { + window: { + from_ts: fromTs, + to_ts: toTs, + }, + filters: { + from_ts: fromTs, + to_ts: toTs, + queue_id: queueId, + channel, + sort_by: 'interactions_total', + sort_dir: 'desc', + limit: 25, + }, + totals: { + agents_total: 0, + agents_with_activity: 0, + interactions_total: 0, + answered_total: 0, + closed_total: 0, + ready_now: 0, + busy_now: 0, + break_now: 0, + offline_now: 0, + avg_interactions_per_agent: 0, + avg_handle_seconds: null, + avg_fcr_rate: null, + }, + state_snapshot: { + by_state: {}, + updated_at: null, + }, + breakdowns: { + by_team: [], + by_shift: [], + }, + items: [], + }; +} + +function emptyAgentAnalyticsTimeseries(metric = 'interactions_per_agent', interval = 'day') { + return { + metric, + interval, + filters: { + from_ts: null, + to_ts: null, + queue_id: null, + channel: 'all', + metric, + interval, + }, + points: [], + }; +} + +function emptyAnalyticsMetricCoverage() { + return { + status: 'unavailable', + supported_channels: [], + exact_rows: 0, + total_rows: 0, + note: null, + }; +} + +function emptyDrilldownState() { + return { + open: false, + mode: 'interaction', + sourceType: '', + sourceLabel: '', + sourceValue: '', + metric: '', + filters: null, + coverage: null, + metricNote: '', + items: [], + total: 0, + limit: DRILLDOWN_PAGE_SIZE, + offset: 0, + selectedInteractionId: '', + selectedInteraction: null, + selectedLinkedInteraction: null, + selectedTimeline: [], + loading: false, + detailLoading: false, + exporting: false, + notice: '', + error: '', + }; +} + +function pad2(value) { + return String(value).padStart(2, '0'); +} + +function startOfDay(date) { + const next = new Date(date); + next.setHours(0, 0, 0, 0); + return next; +} + +function addDays(date, days) { + const next = new Date(date); + next.setDate(next.getDate() + days); + return next; +} + +function normalizeAnalyticsRange(from, to) { + const safeFrom = new Date(from); + const safeTo = new Date(to); + if (Number.isNaN(safeFrom.getTime()) || Number.isNaN(safeTo.getTime())) { + return { from: new Date(), to: new Date() }; + } + if (safeTo <= safeFrom) { + return { from: safeFrom, to: new Date(safeFrom.getTime() + 60 * 60 * 1000) }; + } + return { from: safeFrom, to: safeTo }; +} + +function analyticsRangeFromControls() { + const preset = $('analyticsPreset').value || state.analytics.preset || '7d'; + const customFrom = $('analyticsFrom').value.trim(); + const customTo = $('analyticsTo').value.trim(); + const now = new Date(); + let from; + let to; + let custom = false; + + if (customFrom && customTo) { + custom = true; + from = new Date(customFrom); + to = new Date(customTo); + } else if (preset === 'today') { + from = startOfDay(now); + to = now; + } else if (preset === '30d') { + from = startOfDay(addDays(now, -29)); + to = now; + } else { + from = startOfDay(addDays(now, -6)); + to = now; + } + + const current = normalizeAnalyticsRange(from, to); + const durationMs = Math.max(current.to.getTime() - current.from.getTime(), 60 * 60 * 1000); + const previous = { + from: new Date(current.from.getTime() - durationMs), + to: new Date(current.from.getTime()), + }; + + return { + preset, + custom, + current, + previous, + }; +} + +function syncAnalyticsStateFromControls() { + state.analytics.preset = $('analyticsPreset').value || '7d'; + state.analytics.fromTs = $('analyticsFrom').value.trim(); + state.analytics.toTs = $('analyticsTo').value.trim(); + state.analytics.queueId = $('analyticsQueue').value || 'all'; + state.analytics.channel = $('analyticsChannel').value || 'all'; + state.analytics.compareMode = $('analyticsCompareMode').value || 'previous'; + state.analytics.trendMetric = $('analyticsTrendMetric').value || 'volume'; + state.analytics.aiTrendMetric = $('aiAnalyticsTrendMetric')?.value || 'containment_rate'; + state.analytics.agentTrendMetric = $('agentAnalyticsTrendMetric')?.value || 'interactions_per_agent'; +} + +function syncAnalyticsControlsFromState() { + $('analyticsPreset').value = state.analytics.preset || '7d'; + $('analyticsFrom').value = state.analytics.fromTs || ''; + $('analyticsTo').value = state.analytics.toTs || ''; + $('analyticsChannel').value = state.analytics.channel || 'all'; + $('analyticsCompareMode').value = state.analytics.compareMode || 'previous'; + $('analyticsTrendMetric').value = state.analytics.trendMetric || 'volume'; + if ($('aiAnalyticsTrendMetric')) { + $('aiAnalyticsTrendMetric').value = state.analytics.aiTrendMetric || 'containment_rate'; + } + if ($('agentAnalyticsTrendMetric')) { + $('agentAnalyticsTrendMetric').value = state.analytics.agentTrendMetric || 'interactions_per_agent'; + } + renderAnalyticsQueueOptions(); + renderSavedAnalyticsViews(); +} + +function resetAnalyticsFilters() { + state.analytics.preset = '7d'; + state.analytics.fromTs = ''; + state.analytics.toTs = ''; + state.analytics.queueId = 'all'; + state.analytics.channel = 'all'; + state.analytics.compareMode = 'previous'; + state.analytics.trendMetric = 'volume'; + state.analytics.aiTrendMetric = 'containment_rate'; + state.analytics.agentTrendMetric = 'interactions_per_agent'; + state.analytics.activeViewId = ''; + state.analytics.trendInterval = 'day'; + state.analytics.agentTrendInterval = 'day'; + state.analytics.statusMessage = ''; + resetAnalyticsDrilldown(); + syncAnalyticsControlsFromState(); + syncAnalyticsUrlState(); +} + +function analyticsCurrentSnapshot() { + return { + preset: state.analytics.preset || '7d', + fromTs: state.analytics.fromTs || '', + toTs: state.analytics.toTs || '', + queueId: state.analytics.queueId || 'all', + channel: state.analytics.channel || 'all', + compareMode: state.analytics.compareMode || 'previous', + trendMetric: state.analytics.trendMetric || 'volume', + aiTrendMetric: state.analytics.aiTrendMetric || 'containment_rate', + agentTrendMetric: state.analytics.agentTrendMetric || 'interactions_per_agent', + }; +} + +function analyticsMockEnabled() { + return ENABLE_ANALYTICS_MOCK_DATA === true; +} + +function mockAnalyticsScale(range) { + if (!range?.from || !range?.to) { + return 1; + } + const dayMs = 24 * 60 * 60 * 1000; + const duration = Math.max(range.to.getTime() - range.from.getTime(), dayMs); + return Math.max(0.6, duration / (7 * dayMs)); +} + +function mockAnalyticsQueueOptions() { + return ANALYTICS_MOCK_QUEUE_OPTIONS.map((item) => ({ ...item })); +} + +function mockAnalyticsQueueFactor(queueId = state.analytics.queueId) { + const factors = { + sales_kz: 1.08, + support_kz: 0.96, + vip_kz: 0.58, + retention_kz: 0.72, + }; + if (!queueId || queueId === 'all') { + return 1; + } + return factors[queueId] || 0.84; +} + +function mockAnalyticsChannelBase() { + return { + voice: { total: 620, answered: 584, abandoned: 36 }, + telegram: { total: 214, answered: 205, abandoned: 9 }, + whatsapp: { total: 182, answered: 171, abandoned: 11 }, + webchat: { total: 141, answered: 132, abandoned: 9 }, + email: { total: 108, answered: 94, abandoned: 14 }, + }; +} + +function mockAiReasonLabel(reasonKey) { + const labels = { + requested_human: 'Запрос оператора', + knowledge_or_tool_gap: 'Не хватило знаний или инструмента', + policy_or_sensitive: 'Чувствительный сценарий', + delivery_or_runtime_error: 'Ошибка доставки или runtime', + manual_claim: 'Ручной takeover', + other: 'Другая причина', + }; + return labels[reasonKey] || reasonKey || 'Другая причина'; +} + +function mockInteractionStatus(index = 0) { + return ['closed', 'in_progress', 'closed', 'new', 'closed', 'escalated'][index % 6]; +} + +function mockAnalyticsCoverage(totalRows, note = 'Показаны демонстрационные данные для временного наполнения витрины.') { + const metricNames = ['total', 'answered', 'SL', 'ASA', 'AHT', 'Abandon', 'FCR', 'DigitalShare']; + const metricStatus = {}; + const supportedChannels = {}; + const metricDetails = {}; + metricNames.forEach((metric) => { + metricStatus[metric] = 'exact'; + supportedChannels[metric] = ['voice', 'telegram', 'whatsapp', 'webchat', 'email']; + metricDetails[metric] = { + status: 'exact', + supported_channels: supportedChannels[metric], + exact_rows: totalRows, + total_rows: totalRows, + note, + }; + }); + return { + metric_status: metricStatus, + supported_channels: supportedChannels, + exact_rows: totalRows, + total_rows: totalRows, + note, + metric_details: metricDetails, + }; +} + +function buildMockAnalyticsOverview(rangeMeta, options = {}) { + const previous = Boolean(options.previous); + const queueId = options.queueId ?? state.analytics.queueId; + const channel = options.channel ?? state.analytics.channel; + const scale = mockAnalyticsScale(previous ? rangeMeta.previous : rangeMeta.current) * mockAnalyticsQueueFactor(queueId) * (previous ? 0.89 : 1); + const baseByChannel = mockAnalyticsChannelBase(); + const channelKeys = channel && channel !== 'all' ? [channel] : Object.keys(baseByChannel); + const byChannel = {}; + channelKeys.forEach((key) => { + const base = baseByChannel[key] || { total: 0, answered: 0, abandoned: 0 }; + byChannel[key] = { + total: Math.round(base.total * scale), + answered: Math.round(base.answered * scale), + abandoned: Math.round(base.abandoned * scale), + }; + }); + const totals = Object.values(byChannel).reduce((acc, item) => ({ + total: acc.total + Number(item.total || 0), + answered: acc.answered + Number(item.answered || 0), + abandoned: acc.abandoned + Number(item.abandoned || 0), + }), { total: 0, answered: 0, abandoned: 0 }); + const digitalTotal = Object.entries(byChannel) + .filter(([key]) => key !== 'voice') + .reduce((acc, [, item]) => acc + Number(item.total || 0), 0); + const answerRate = totals.total ? (totals.answered / totals.total) * 100 : 0; + const abandonRate = totals.total ? (totals.abandoned / totals.total) * 100 : 0; + const sl = Math.max(58, Math.min(96, 84 - (queueId === 'vip_kz' ? 4 : 0) - (channel === 'email' ? 9 : 0) + (previous ? -3 : 0))); + const asa = Math.max(10, 22 + (queueId === 'support_kz' ? 6 : 0) + (channel === 'email' ? 12 : channel === 'voice' ? 3 : 0) + (previous ? 4 : 0)); + const aht = Math.max(120, 332 + (queueId === 'vip_kz' ? 86 : 0) + (channel === 'email' ? 112 : channel === 'voice' ? 38 : 0) + (previous ? 24 : 0)); + const fcr = Math.max(48, Math.min(92, 73 - (queueId === 'support_kz' ? 5 : 0) + (channel === 'telegram' ? 4 : 0) + (previous ? -2 : 0))); + return { + window: { + from: (previous ? rangeMeta.previous.from : rangeMeta.current.from).toISOString(), + to: (previous ? rangeMeta.previous.to : rangeMeta.current.to).toISOString(), + }, + volume: totals, + kpi: { + SL: Number(sl.toFixed(2)), + ASA: Number(asa.toFixed(2)), + AHT: Number(aht.toFixed(2)), + Abandon: Number(abandonRate.toFixed(2)), + FCR: Number(fcr.toFixed(2)), + AnswerRate: Number(answerRate.toFixed(2)), + WaitP95: Number((asa * 2.6).toFixed(2)), + HandleP95: Number((aht * 1.9).toFixed(2)), + Occupancy: Number((66 + (queueId === 'support_kz' ? 8 : 0) + (previous ? -3 : 0)).toFixed(2)), + DigitalShare: totals.total ? Number(((digitalTotal / totals.total) * 100).toFixed(2)) : 0, + }, + breakdowns: { + by_channel: byChannel, + }, + coverage: mockAnalyticsCoverage(totals.total), + filters: { + queue_id: queueId === 'all' ? null : queueId, + channel, + }, + }; +} + +function buildMockTrendPayloads(rangeMeta, metric) { + const overview = buildMockAnalyticsOverview(rangeMeta); + const baseValue = metricValueFromPayload(metric, overview); + const buckets = buildTrendBuckets(rangeMeta.current, rangeMeta.preset, rangeMeta.custom); + const count = Math.max(buckets.length, 1); + return buckets.map((bucket, index) => { + const wave = 0.88 + (((index % 5) - 2) * 0.06) + (index / Math.max(count - 1, 1)) * 0.12; + const nextValue = Math.max(0, baseValue * wave); + return { + label: bucket.label, + range: bucket.range, + payload: metric === 'volume' || metric === 'total' + ? { + ...emptyKpiEnvelope(bucket.range.from.toISOString(), bucket.range.to.toISOString()), + volume: { + total: Math.round(nextValue), + answered: Math.round(nextValue * 0.92), + abandoned: Math.round(nextValue * 0.08), + }, + kpi: { + ...emptyKpiEnvelope().kpi, + SL: 82 + (index % 4), + ASA: 16 + index, + AHT: 320 + index * 8, + Abandon: 6 + (index % 3), + FCR: 72 + (index % 5), + DigitalShare: 44 + (index % 4), + }, + } + : { + ...emptyKpiEnvelope(bucket.range.from.toISOString(), bucket.range.to.toISOString()), + volume: { + total: Math.round(overview.volume.total / count), + answered: Math.round((overview.volume.answered || 0) / count), + abandoned: Math.round((overview.volume.abandoned || 0) / count), + }, + kpi: { + ...emptyKpiEnvelope().kpi, + [metric]: Number(nextValue.toFixed(2)), + }, + }, + }; + }); +} + +function buildMockQueueRows(queueOptions, rangeMeta) { + const overview = buildMockAnalyticsOverview(rangeMeta); + const channel = state.analytics.channel; + return queueOptions.map((queue, index) => { + const factor = mockAnalyticsQueueFactor(queue.queue_id) * (channel === 'all' ? 1 : 0.82); + const total = Math.round(overview.volume.total * factor * (0.34 + index * 0.11)); + const answered = Math.round(total * (0.91 - index * 0.03)); + return { + queue_id: queue.queue_id, + name: queue.name, + total, + answered, + SL: Number((84 - index * 4).toFixed(2)), + Abandon: Number((6 + index * 1.4).toFixed(2)), + }; + }).sort((a, b) => b.total - a.total); +} + +function buildMockAgentOverview(rangeMeta, options = {}) { + const previous = Boolean(options.previous); + const queueId = options.queueId ?? state.analytics.queueId; + const queueOptions = mockAnalyticsQueueOptions(); + const rows = ANALYTICS_MOCK_AGENTS.map((agentId, index) => { + const queue = queueOptions[index % queueOptions.length]; + const dominantQueue = queueId !== 'all' ? queueId : queue.queue_id; + const interactionsTotal = Math.round((24 - index * 2) * (previous ? 0.88 : 1)); + const answeredTotal = Math.max(0, Math.round(interactionsTotal * (0.88 - index * 0.015))); + return { + agent_id: agentId, + interactions_total: interactionsTotal, + answered_total: answeredTotal, + closed_total: Math.max(0, answeredTotal - (index % 3)), + avg_handle_seconds: Number((290 + index * 24 + (previous ? 18 : 0)).toFixed(2)), + fcr_rate: Number((78 - index * 3 + (previous ? -2 : 0)).toFixed(2)), + current_state: ['READY', 'BUSY', 'READY', 'BREAK', 'BUSY', 'OFFLINE'][index % 6], + current_queue_id: dominantQueue, + dominant_queue_id: dominantQueue, + last_activity_at: new Date(rangeMeta.current.to.getTime() - index * 37 * 60 * 1000).toISOString(), + }; + }).filter((item) => queueId === 'all' || item.current_queue_id === queueId); + const agentsWithActivity = rows.filter((item) => item.interactions_total > 0).length; + const interactionsTotal = rows.reduce((acc, item) => acc + item.interactions_total, 0); + const answeredTotal = rows.reduce((acc, item) => acc + item.answered_total, 0); + const readyNow = rows.filter((item) => item.current_state === 'READY').length; + const busyNow = rows.filter((item) => item.current_state === 'BUSY').length; + const breakNow = rows.filter((item) => item.current_state === 'BREAK').length; + const offlineNow = rows.filter((item) => item.current_state === 'OFFLINE').length; + const byTeam = mockAnalyticsQueueOptions() + .filter((queue) => queueId === 'all' || queue.queue_id === queueId) + .map((queue, index) => { + const teamRows = rows.filter((item) => item.current_queue_id === queue.queue_id); + const teamInteractions = teamRows.reduce((acc, item) => acc + item.interactions_total, 0); + return { + team_key: queue.queue_id, + label: queue.name, + agents_total: teamRows.length, + agents_with_activity: teamRows.filter((item) => item.interactions_total > 0).length, + interactions_total: teamInteractions, + answered_total: teamRows.reduce((acc, item) => acc + item.answered_total, 0), + avg_handle_seconds: teamRows.length ? Number((312 + index * 22).toFixed(2)) : null, + fcr_rate: teamRows.length ? Number((76 - index * 4).toFixed(2)) : null, + ready_now: teamRows.filter((item) => item.current_state === 'READY').length, + busy_now: teamRows.filter((item) => item.current_state === 'BUSY').length, + break_now: teamRows.filter((item) => item.current_state === 'BREAK').length, + offline_now: teamRows.filter((item) => item.current_state === 'OFFLINE').length, + }; + }) + .filter((item) => item.agents_total > 0); + const byShift = [ + { shift_key: 'night', label: 'Ночная смена', agents_with_activity: Math.max(2, Math.round(agentsWithActivity * 0.3)), interactions_total: Math.round(interactionsTotal * 0.18), answered_total: Math.round(answeredTotal * 0.17), avg_handle_seconds: 356, fcr_rate: 69.5 }, + { shift_key: 'day', label: 'Дневная смена', agents_with_activity: Math.max(4, Math.round(agentsWithActivity * 0.9)), interactions_total: Math.round(interactionsTotal * 0.52), answered_total: Math.round(answeredTotal * 0.53), avg_handle_seconds: 318, fcr_rate: 76.8 }, + { shift_key: 'evening', label: 'Вечерняя смена', agents_with_activity: Math.max(3, Math.round(agentsWithActivity * 0.55)), interactions_total: Math.round(interactionsTotal * 0.30), answered_total: Math.round(answeredTotal * 0.30), avg_handle_seconds: 334, fcr_rate: 73.4 }, + ]; + return { + window: { + from_ts: (previous ? rangeMeta.previous.from : rangeMeta.current.from).toISOString(), + to_ts: (previous ? rangeMeta.previous.to : rangeMeta.current.to).toISOString(), + }, + filters: { + from_ts: (previous ? rangeMeta.previous.from : rangeMeta.current.from).toISOString(), + to_ts: (previous ? rangeMeta.previous.to : rangeMeta.current.to).toISOString(), + queue_id: queueId === 'all' ? null : queueId, + channel: state.analytics.channel, + sort_by: 'interactions_total', + sort_dir: 'desc', + limit: 25, + }, + totals: { + agents_total: rows.length, + agents_with_activity: agentsWithActivity, + interactions_total: interactionsTotal, + answered_total: answeredTotal, + closed_total: rows.reduce((acc, item) => acc + item.closed_total, 0), + ready_now: readyNow, + busy_now: busyNow, + break_now: breakNow, + offline_now: offlineNow, + avg_interactions_per_agent: rows.length ? Number((interactionsTotal / rows.length).toFixed(2)) : 0, + avg_handle_seconds: rows.length ? Number((rows.reduce((acc, item) => acc + Number(item.avg_handle_seconds || 0), 0) / rows.length).toFixed(2)) : null, + avg_fcr_rate: rows.length ? Number((rows.reduce((acc, item) => acc + Number(item.fcr_rate || 0), 0) / rows.length).toFixed(2)) : null, + }, + state_snapshot: { + by_state: { + READY: readyNow, + BUSY: busyNow, + BREAK: breakNow, + OFFLINE: offlineNow, + }, + updated_at: rangeMeta.current.to.toISOString(), + }, + breakdowns: { + by_team: byTeam, + by_shift: byShift, + }, + items: rows, + }; +} + +function buildMockAgentTrend(rangeMeta, metric) { + const interval = analyticsTimeseriesIntervalForRange(rangeMeta); + const points = []; + let cursor = new Date(interval === 'hour' + ? rangeMeta.current.from.getTime() + : startOfDay(rangeMeta.current.from).getTime()); + while (cursor < rangeMeta.current.to) { + const index = points.length; + let value = 0; + if (metric === 'agents_with_activity') { + value = 5 + (index % 4); + } else if (metric === 'avg_handle_seconds') { + value = 296 + index * 8; + } else if (metric === 'fcr_rate') { + value = 69 + (index % 5) * 2.2; + } else { + value = 3.4 + (index % 4) * 0.42; + } + points.push({ + ts: cursor.toISOString(), + value: Number(value.toFixed(2)), + agents_with_activity: 5 + (index % 3), + interactions_total: 18 + index * 2, + }); + cursor = new Date(cursor.getTime() + (interval === 'hour' ? 60 * 60 * 1000 : 24 * 60 * 60 * 1000)); + } + return { + metric, + interval, + filters: { + from_ts: rangeMeta.current.from.toISOString(), + to_ts: rangeMeta.current.to.toISOString(), + queue_id: state.analytics.queueId === 'all' ? null : state.analytics.queueId, + channel: state.analytics.channel, + metric, + interval, + }, + points, + }; +} + +function buildMockAiOverview(rangeMeta, options = {}) { + const previous = Boolean(options.previous); + const channel = options.channel ?? state.analytics.channel; + if (!aiAnalyticsSupportedChannel(channel)) { + return emptyAiAnalyticsOverview( + (previous ? rangeMeta.previous.from : rangeMeta.current.from).toISOString(), + (previous ? rangeMeta.previous.to : rangeMeta.current.to).toISOString(), + channel, + state.analytics.queueId === 'all' ? null : state.analytics.queueId, + ); + } + const scale = mockAnalyticsScale(previous ? rangeMeta.previous : rangeMeta.current) * (previous ? 0.91 : 1); + const baseRows = [ + { channel: 'telegram', sessions_started: Math.round(176 * scale), ai_only_sessions: Math.round(106 * scale), human_touched_sessions: Math.round(58 * scale), handoff_rate: 24.4, ai_latency_avg_ms: 1380, containment_rate: 60.2, closed_without_operator_rate: 52.1 }, + { channel: 'whatsapp', sessions_started: Math.round(142 * scale), ai_only_sessions: Math.round(80 * scale), human_touched_sessions: Math.round(54 * scale), handoff_rate: 28.9, ai_latency_avg_ms: 1524, containment_rate: 56.4, closed_without_operator_rate: 48.6 }, + ].filter((item) => channel === 'all' || item.channel === channel); + const sessionsStarted = baseRows.reduce((acc, item) => acc + item.sessions_started, 0); + const sessionsContained = Math.round(baseRows.reduce((acc, item) => acc + item.sessions_started * (item.containment_rate / 100), 0)); + const sessionsHandoff = Math.round(baseRows.reduce((acc, item) => acc + item.sessions_started * (item.handoff_rate / 100), 0)); + const sessionsClosedWithoutOperator = Math.round(baseRows.reduce((acc, item) => acc + item.sessions_started * (item.closed_without_operator_rate / 100), 0)); + const humanTouched = baseRows.reduce((acc, item) => acc + item.human_touched_sessions, 0); + const byOutcome = [ + { outcome: 'contained', label: 'Закрыто AI', sessions: sessionsContained, share: sessionsStarted ? (sessionsContained / sessionsStarted) * 100 : 0 }, + { outcome: 'handoff', label: 'Передано оператору', sessions: sessionsHandoff, share: sessionsStarted ? (sessionsHandoff / sessionsStarted) * 100 : 0 }, + { outcome: 'human_touched', label: 'С участием оператора', sessions: humanTouched, share: sessionsStarted ? (humanTouched / sessionsStarted) * 100 : 0 }, + { outcome: 'closed_without_operator', label: 'Закрыто без оператора', sessions: sessionsClosedWithoutOperator, share: sessionsStarted ? (sessionsClosedWithoutOperator / sessionsStarted) * 100 : 0 }, + { outcome: 'active', label: 'Активные', sessions: Math.max(8, Math.round(sessionsStarted * 0.06)), share: 6.1 }, + { outcome: 'error', label: 'Ошибки', sessions: Math.max(3, Math.round(sessionsStarted * 0.02)), share: 1.9 }, + ]; + const byReason = [ + 'requested_human', + 'knowledge_or_tool_gap', + 'policy_or_sensitive', + 'delivery_or_runtime_error', + 'manual_claim', + ].map((reasonKey, index) => ({ + reason_key: reasonKey, + label: mockAiReasonLabel(reasonKey), + sessions: Math.max(4, Math.round(sessionsHandoff * (0.34 - index * 0.05))), + share: Math.max(6, Number((32 - index * 5.2).toFixed(2))), + })); + return { + window: { + from_ts: (previous ? rangeMeta.previous.from : rangeMeta.current.from).toISOString(), + to_ts: (previous ? rangeMeta.previous.to : rangeMeta.current.to).toISOString(), + }, + filters: { + from_ts: (previous ? rangeMeta.previous.from : rangeMeta.current.from).toISOString(), + to_ts: (previous ? rangeMeta.previous.to : rangeMeta.current.to).toISOString(), + queue_id: state.analytics.queueId === 'all' ? null : state.analytics.queueId, + channel, + }, + totals: { + sessions_started: sessionsStarted, + sessions_contained: sessionsContained, + sessions_handoff: sessionsHandoff, + sessions_closed: Math.round(sessionsStarted * 0.86), + sessions_closed_without_operator: sessionsClosedWithoutOperator, + assistant_turns: Math.round(sessionsStarted * 5.4), + }, + metrics: { + containment_rate: sessionsStarted ? Number(((sessionsContained / sessionsStarted) * 100).toFixed(2)) : 0, + handoff_rate: sessionsStarted ? Number(((sessionsHandoff / sessionsStarted) * 100).toFixed(2)) : 0, + ai_latency_avg_ms: 1452, + ai_latency_p95_ms: 2486, + closed_without_operator_rate: sessionsStarted ? Number(((sessionsClosedWithoutOperator / sessionsStarted) * 100).toFixed(2)) : 0, + human_touched_rate: sessionsStarted ? Number(((humanTouched / sessionsStarted) * 100).toFixed(2)) : 0, + }, + breakdowns: { + by_channel: baseRows, + by_outcome: byOutcome, + by_handoff_reason: byReason, + }, + coverage: { + sessions_with_interaction_id: Math.round(sessionsStarted * 0.94), + sessions_with_queue_id: Math.round(sessionsStarted * 0.91), + sessions_with_latency_turns: Math.round(sessionsStarted * 0.97), + sessions_with_terminal_state: Math.round(sessionsStarted * 0.88), + sessions_with_handoff_reason: Math.round(sessionsHandoff * 0.82), + }, + }; +} + +function buildMockAiTrend(rangeMeta, metric) { + const interval = aiAnalyticsIntervalForRange(rangeMeta); + const points = []; + let cursor = new Date(interval === 'hour' + ? rangeMeta.current.from.getTime() + : startOfDay(rangeMeta.current.from).getTime()); + while (cursor < rangeMeta.current.to) { + const index = points.length; + let value = 0; + if (metric === 'handoff_rate') { + value = 21 + (index % 5) * 1.8; + } else if (metric === 'human_touched_rate') { + value = 32 + (index % 4) * 1.6; + } else if (metric === 'ai_latency_avg_ms') { + value = 1380 + index * 34; + } else if (metric === 'closed_without_operator_rate') { + value = 47 + (index % 4) * 1.9; + } else { + value = 54 + (index % 5) * 2.1; + } + points.push({ + ts: cursor.toISOString(), + value: Number(value.toFixed(2)), + sessions: 24 + index * 3, + assistant_turns: 118 + index * 11, + }); + cursor = new Date(cursor.getTime() + (interval === 'hour' ? 60 * 60 * 1000 : 24 * 60 * 60 * 1000)); + } + return { + metric, + interval, + filters: { + from_ts: rangeMeta.current.from.toISOString(), + to_ts: rangeMeta.current.to.toISOString(), + queue_id: state.analytics.queueId === 'all' ? null : state.analytics.queueId, + channel: state.analytics.channel, + }, + points, + }; +} + +function buildMockInteractionDrilldownData(filters, limit, offset, mode) { + const normalized = normalizeAnalyticsDrilldownFilters(filters); + const total = 18; + const rows = Array.from({ length: total }, (_, index) => { + const queueId = normalized.queue_id || mockAnalyticsQueueOptions()[index % mockAnalyticsQueueOptions().length].queue_id; + let channel = normalized.channel || ['voice', 'telegram', 'whatsapp', 'webchat', 'email'][index % 5]; + if (mode === 'metric' && normalized.metric === 'DigitalShare') { + channel = ['telegram', 'whatsapp', 'webchat', 'email'][index % 4]; + } + const answered = normalized.metric === 'Abandon' ? false : index % 5 !== 0; + const abandoned = normalized.metric === 'Abandon' ? true : index % 6 === 0; + const withinSla = normalized.metric === 'SL' ? true : answered ? index % 4 !== 0 : null; + const resolvedFirstContact = normalized.metric === 'FCR' ? true : answered ? index % 3 === 0 : null; + const createdAt = new Date(Date.now() - (index + 1) * 3 * 60 * 60 * 1000); + return { + interaction_id: `mock-int-${index + 1}`, + subject: `${mode === 'metric' ? 'KPI' : 'Обращение'} ${index + 1}: ${analyticsChannelLabel(channel)}`, + channel, + status: abandoned ? 'closed' : mockInteractionStatus(index), + queue_id: queueId, + assigned_to: normalized.agent_id || (abandoned ? null : ANALYTICS_MOCK_AGENTS[index % ANALYTICS_MOCK_AGENTS.length]), + created_at: createdAt.toISOString(), + updated_at: new Date(createdAt.getTime() + 42 * 60 * 1000).toISOString(), + answered, + abandoned, + wait_seconds: abandoned ? 44 + index : 12 + index * 2, + handle_seconds: answered ? 210 + index * 14 : 0, + within_sla: withinSla, + resolved_first_contact: resolvedFirstContact, + }; + }).filter((item) => { + if (normalized.channel && item.channel !== normalized.channel) { + return false; + } + if (normalized.queue_id && item.queue_id !== normalized.queue_id) { + return false; + } + if (normalized.agent_id && item.assigned_to !== normalized.agent_id) { + return false; + } + return true; + }); + const paged = rows.slice(offset, offset + limit); + return { + items: paged, + total: rows.length, + limit, + offset, + filters: normalized, + metric: normalized.metric || '', + coverage: mockAnalyticsCoverage(rows.length), + }; +} + +function buildMockInteractionDetail(interactionId) { + const interaction = { + interaction_id: interactionId, + subject: `Карточка ${interactionId}`, + channel: ['voice', 'telegram', 'whatsapp'][interactionId.length % 3], + status: 'closed', + queue_id: mockAnalyticsQueueOptions()[interactionId.length % mockAnalyticsQueueOptions().length].queue_id, + assigned_to: ANALYTICS_MOCK_AGENTS[interactionId.length % ANALYTICS_MOCK_AGENTS.length], + created_at: new Date(Date.now() - 8 * 60 * 60 * 1000).toISOString(), + updated_at: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(), + wait_seconds: 18, + handle_seconds: 324, + within_sla: true, + resolved_first_contact: true, + abandoned: false, + }; + const timeline = { + events: [ + { action: 'Создано обращение', timestamp: interaction.created_at, metadata: { channel: interaction.channel, queue_id: interaction.queue_id } }, + { action: 'Назначено оператору', timestamp: new Date(Date.now() - 6 * 60 * 60 * 1000).toISOString(), metadata: { assigned_to: interaction.assigned_to } }, + { action: 'Закрыто', timestamp: interaction.updated_at, metadata: { resolved_first_contact: true, within_sla: true } }, + ], + }; + return { detail: interaction, timeline }; +} + +function buildMockAiDrilldownData(filters, limit, offset) { + const normalized = normalizeAnalyticsDrilldownFilters(filters); + const total = 14; + const allItems = Array.from({ length: total }, (_, index) => { + const queue = mockAnalyticsQueueOptions()[index % mockAnalyticsQueueOptions().length]; + const channel = normalized.channel && normalized.channel !== 'all' + ? normalized.channel + : ['telegram', 'whatsapp'][index % 2]; + const slice = normalized.slice || 'all'; + const reasonKey = normalized.reason_key || ['requested_human', 'knowledge_or_tool_gap', 'manual_claim'][index % 3]; + const handoff = slice === 'handoff' || slice === 'human_touched' || index % 4 === 0; + const contained = slice === 'contained' || (!handoff && index % 2 === 0); + const humanTouched = slice === 'human_touched' || handoff; + const closedWithoutOperator = slice === 'closed_without_operator' || (contained && index % 3 !== 0); + const createdAt = new Date(Date.now() - (index + 2) * 2 * 60 * 60 * 1000); + return { + session_id: `mock-ai-${index + 1}`, + interaction_id: `mock-int-${index + 1}`, + thread_id: `thread-${index + 1}`, + channel, + queue_id: queue.queue_id, + status: handoff ? 'human_owned' : contained ? 'closed' : 'active', + reason_key: handoff ? reasonKey : '', + reason_label: handoff ? mockAiReasonLabel(reasonKey) : '', + raw_handoff_reason: handoff ? `raw:${reasonKey}` : '', + assigned_to: handoff ? ANALYTICS_MOCK_AGENTS[index % ANALYTICS_MOCK_AGENTS.length] : null, + claimed_by_user: handoff ? `sup-${(index % 3) + 1}` : null, + assistant_turns: 6 + index, + user_turns: 3 + (index % 4), + tool_turns: 1 + (index % 2), + ai_latency_avg_ms: 1280 + index * 44, + ai_latency_p95_ms: 2010 + index * 61, + created_at: createdAt.toISOString(), + updated_at: new Date(createdAt.getTime() + 55 * 60 * 1000).toISOString(), + closed_at: contained || handoff ? new Date(createdAt.getTime() + 94 * 60 * 1000).toISOString() : null, + contained, + handoff, + human_touched: humanTouched, + closed_without_operator: closedWithoutOperator, + }; + }).filter((item) => { + if (normalized.channel && normalized.channel !== 'all' && item.channel !== normalized.channel) { + return false; + } + if (normalized.queue_id && item.queue_id !== normalized.queue_id) { + return false; + } + if (normalized.reason_key && item.reason_key !== normalized.reason_key) { + return false; + } + if (normalized.slice === 'contained' && !item.contained) { + return false; + } + if (normalized.slice === 'handoff' && !item.handoff) { + return false; + } + if (normalized.slice === 'human_touched' && !item.human_touched) { + return false; + } + if (normalized.slice === 'closed_without_operator' && !item.closed_without_operator) { + return false; + } + if (normalized.slice === 'active' && item.status !== 'active') { + return false; + } + if (normalized.status && item.status !== normalized.status) { + return false; + } + return true; + }); + return { + items: allItems.slice(offset, offset + limit), + total: allItems.length, + limit, + offset, + filters: normalized, + coverage: { + sessions_with_interaction_id: allItems.length, + sessions_with_queue_id: allItems.length, + sessions_with_latency_turns: allItems.length, + sessions_with_terminal_state: Math.round(allItems.length * 0.86), + sessions_with_handoff_reason: allItems.filter((item) => item.reason_key).length, + }, + }; +} + +function buildMockAiDetail(sessionId) { + const session = buildMockAiDrilldownData({ slice: 'all' }, 20, 0).items.find((item) => item.session_id === sessionId) + || buildMockAiDrilldownData({ slice: 'all' }, 1, 0).items[0]; + return { + session, + interaction: { + interaction_id: session.interaction_id, + channel: session.channel, + queue_id: session.queue_id, + status: session.handoff ? 'in_progress' : 'closed', + assigned_to: session.assigned_to, + subject: `Связанное обращение ${session.interaction_id}`, + updated_at: session.updated_at, + }, + timeline: [ + { label: 'Создана AI-сессия', event_type: 'session.created', ts: session.created_at, metadata: { channel: session.channel, queue_id: session.queue_id } }, + { label: 'Ответ AI', event_type: 'assistant.turn', ts: new Date(new Date(session.created_at).getTime() + 12 * 60 * 1000).toISOString(), metadata: { assistant_turns: session.assistant_turns } }, + { label: session.handoff ? 'Передано оператору' : 'Сессия закрыта', event_type: session.handoff ? 'session.handoff' : 'session.closed', ts: session.updated_at, metadata: { reason_key: session.reason_key || null, assigned_to: session.assigned_to || null } }, + ], + }; +} + +function buildMockAnalyticsDashboard(rangeMeta) { + const queueOptions = mockAnalyticsQueueOptions(); + const overview = buildMockAnalyticsOverview(rangeMeta); + return { + queueOptions, + overview, + compare: buildMockAnalyticsOverview(rangeMeta, { previous: true }), + coverage: { + implemented_metrics: ['SL', 'ASA', 'AHT', 'Abandon', 'FCR', 'DigitalShare'], + dimensions: ['queue', 'channel', 'agent', 'shift'], + supported_filters: ['preset', 'from_ts', 'to_ts', 'queue_id', 'channel'], + }, + aiOverview: buildMockAiOverview(rangeMeta), + aiCompare: buildMockAiOverview(rangeMeta, { previous: true }), + agentOverview: buildMockAgentOverview(rangeMeta), + agentCompare: buildMockAgentOverview(rangeMeta, { previous: true }), + trend: buildMockTrendPayloads(rangeMeta, state.analytics.trendMetric || 'volume'), + aiTrend: buildMockAiTrend(rangeMeta, state.analytics.aiTrendMetric || 'containment_rate'), + agentTrend: buildMockAgentTrend(rangeMeta, state.analytics.agentTrendMetric || 'interactions_per_agent'), + queueRows: buildMockQueueRows(queueOptions, rangeMeta), + channelRows: Object.entries(overview.breakdowns.by_channel).map(([channel, payload]) => ({ + channel, + total: Number(payload?.total || 0), + answered: Number(payload?.answered || 0), + abandoned: Number(payload?.abandoned || 0), + })).sort((a, b) => b.total - a.total), + }; +} + +function applyAnalyticsSnapshot(snapshot = {}) { + state.analytics.preset = snapshot.preset || '7d'; + state.analytics.fromTs = snapshot.fromTs || ''; + state.analytics.toTs = snapshot.toTs || ''; + state.analytics.queueId = snapshot.queueId || 'all'; + state.analytics.channel = snapshot.channel || 'all'; + state.analytics.compareMode = snapshot.compareMode || 'previous'; + state.analytics.trendMetric = snapshot.trendMetric || 'volume'; + state.analytics.aiTrendMetric = snapshot.aiTrendMetric || 'containment_rate'; + state.analytics.agentTrendMetric = snapshot.agentTrendMetric || 'interactions_per_agent'; +} + +function withAnalyticsUrlSyncSuspended(callback) { + analyticsUrlSyncSuspended = true; + try { + return callback(); + } finally { + analyticsUrlSyncSuspended = false; + } +} + +function sanitizeAnalyticsSnapshot(snapshot = {}) { + const validChannel = ANALYTICS_CHANNEL_OPTIONS.some((item) => item.value === snapshot.channel); + return { + preset: Object.prototype.hasOwnProperty.call(ANALYTICS_PRESET_LABELS, snapshot.preset) ? snapshot.preset : '7d', + fromTs: snapshot.fromTs || '', + toTs: snapshot.toTs || '', + queueId: snapshot.queueId || 'all', + channel: validChannel ? snapshot.channel : 'all', + compareMode: snapshot.compareMode === 'off' ? 'off' : 'previous', + trendMetric: ANALYTICS_TREND_OPTIONS.includes(snapshot.trendMetric) ? snapshot.trendMetric : 'volume', + aiTrendMetric: AI_ANALYTICS_TREND_OPTIONS.includes(snapshot.aiTrendMetric) + ? snapshot.aiTrendMetric + : 'containment_rate', + agentTrendMetric: AGENT_ANALYTICS_TREND_OPTIONS.includes(snapshot.agentTrendMetric) + ? snapshot.agentTrendMetric + : 'interactions_per_agent', + }; +} + +function analyticsDeepLinkDrilldownState() { + if (!state.drilldown.open || !state.drilldown.filters) { + return null; + } + return { + mode: state.drilldown.mode || 'interaction', + sourceType: state.drilldown.sourceType || '', + sourceValue: state.drilldown.sourceValue || '', + metric: state.drilldown.metric || '', + filters: normalizeAnalyticsDrilldownFilters(state.drilldown.filters), + offset: Number(state.drilldown.offset || 0), + selectedId: state.drilldown.selectedInteractionId || '', + }; +} + +function syncAnalyticsUrlState() { + if (analyticsUrlSyncSuspended) { + return; + } + const url = new URL(window.location.href); + ANALYTICS_DEEP_LINK_KEYS.forEach((key) => { + url.searchParams.delete(key); + }); + + const snapshot = sanitizeAnalyticsSnapshot(analyticsCurrentSnapshot()); + url.searchParams.set('preset', snapshot.preset); + if (snapshot.fromTs) { + url.searchParams.set('from', snapshot.fromTs); + } + if (snapshot.toTs) { + url.searchParams.set('to', snapshot.toTs); + } + if (snapshot.queueId && snapshot.queueId !== 'all') { + url.searchParams.set('queue', snapshot.queueId); + } + if (snapshot.channel && snapshot.channel !== 'all') { + url.searchParams.set('channel', snapshot.channel); + } + if (snapshot.compareMode !== 'previous') { + url.searchParams.set('compare', snapshot.compareMode); + } + if (snapshot.trendMetric !== 'volume') { + url.searchParams.set('trend', snapshot.trendMetric); + } + if (snapshot.aiTrendMetric !== 'containment_rate') { + url.searchParams.set('ai_trend', snapshot.aiTrendMetric); + } + if (snapshot.agentTrendMetric !== 'interactions_per_agent') { + url.searchParams.set('agent_trend', snapshot.agentTrendMetric); + } + if (state.analytics.activeViewId) { + url.searchParams.set('view', state.analytics.activeViewId); + } + + const drilldown = analyticsDeepLinkDrilldownState(); + if (drilldown) { + url.searchParams.set('dd', '1'); + url.searchParams.set('dd_mode', drilldown.mode); + url.searchParams.set('dd_source', drilldown.sourceType || 'overview'); + if (drilldown.sourceValue) { + url.searchParams.set('dd_value', drilldown.sourceValue); + } + if (drilldown.metric) { + url.searchParams.set('dd_metric', drilldown.metric); + } + if (drilldown.filters.slice && drilldown.filters.slice !== 'all') { + url.searchParams.set('dd_slice', drilldown.filters.slice); + } + if (drilldown.filters.reason_key) { + url.searchParams.set('dd_reason', drilldown.filters.reason_key); + } + if (drilldown.filters.status) { + url.searchParams.set('dd_status', drilldown.filters.status); + } + if (drilldown.filters.q) { + url.searchParams.set('dd_q', drilldown.filters.q); + } + if (drilldown.filters.sort_by && drilldown.filters.sort_by !== 'created_at') { + url.searchParams.set('dd_sort_by', drilldown.filters.sort_by); + } + if (drilldown.filters.sort_dir && drilldown.filters.sort_dir !== 'desc') { + url.searchParams.set('dd_sort_dir', drilldown.filters.sort_dir); + } + if (drilldown.filters.agent_id) { + url.searchParams.set('dd_agent', drilldown.filters.agent_id); + } + if (drilldown.offset > 0) { + url.searchParams.set('dd_offset', String(drilldown.offset)); + } + if (drilldown.selectedId) { + url.searchParams.set('dd_selected', drilldown.selectedId); + } + } + + const nextUrl = `${url.pathname}${url.search}${url.hash}`; + const currentUrl = `${window.location.pathname}${window.location.search}${window.location.hash}`; + if (nextUrl !== currentUrl) { + window.history.replaceState(null, '', nextUrl); + } +} + +function parseAnalyticsDeepLinkState() { + const params = new URLSearchParams(window.location.search); + const hasSnapshot = [ + 'preset', + 'from', + 'to', + 'queue', + 'channel', + 'compare', + 'trend', + 'ai_trend', + 'agent_trend', + ].some((key) => params.has(key)); + const snapshot = sanitizeAnalyticsSnapshot({ + preset: params.get('preset') || '7d', + fromTs: params.get('from') || '', + toTs: params.get('to') || '', + queueId: params.get('queue') || 'all', + channel: params.get('channel') || 'all', + compareMode: params.get('compare') || 'previous', + trendMetric: params.get('trend') || 'volume', + aiTrendMetric: params.get('ai_trend') || 'containment_rate', + agentTrendMetric: params.get('agent_trend') || 'interactions_per_agent', + }); + let drilldown = null; + if (params.get('dd') === '1') { + const mode = ['interaction', 'metric', 'ai'].includes(params.get('dd_mode')) + ? params.get('dd_mode') + : 'interaction'; + drilldown = { + mode, + sourceType: params.get('dd_source') || (mode === 'metric' ? 'metric' : mode === 'ai' ? 'ai-overview' : 'overview'), + sourceValue: params.get('dd_value') || '', + metric: params.get('dd_metric') || '', + offset: Math.max(0, Number(params.get('dd_offset') || 0)), + selectedId: params.get('dd_selected') || '', + filters: normalizeAnalyticsDrilldownFilters({ + metric: params.get('dd_metric') || '', + slice: params.get('dd_slice') || 'all', + reason_key: params.get('dd_reason') || '', + agent_id: params.get('dd_agent') || '', + status: params.get('dd_status') || '', + q: params.get('dd_q') || '', + sort_by: params.get('dd_sort_by') || 'created_at', + sort_dir: params.get('dd_sort_dir') || 'desc', + }), + }; + } + return { + hasSnapshot, + snapshot, + activeViewId: params.get('view') || '', + drilldown, + }; +} + +function restoreAnalyticsDrilldownFromUrlState(drilldownState) { + if (!drilldownState) { + return; + } + const mode = ['interaction', 'metric', 'ai'].includes(drilldownState.mode) ? drilldownState.mode : 'interaction'; + const sourceType = drilldownState.sourceType || (mode === 'metric' ? 'metric' : mode === 'ai' ? 'ai-overview' : 'overview'); + const sourceValue = drilldownState.sourceValue || ''; + const filters = { + ...analyticsDrilldownBaseFilters(state.analytics.lastRangeMeta || analyticsRangeFromControls()), + ...normalizeAnalyticsDrilldownFilters(drilldownState.filters || {}), + }; + if (mode === 'metric') { + filters.metric = drilldownState.metric || filters.metric || sourceValue; + filters.status = ''; + filters.q = ''; + } + if (mode === 'ai') { + filters.slice = filters.slice || 'all'; + if (sourceType === 'ai-overview' && sourceValue) { + filters.slice = sourceValue; + } + if (sourceType === 'ai-channel' && sourceValue) { + filters.channel = sourceValue; + } + if (sourceType === 'ai-reason' && sourceValue) { + filters.slice = 'handoff'; + filters.reason_key = sourceValue; + } + } + if (sourceType === 'channel' && sourceValue) { + filters.channel = sourceValue; + } + if (sourceType === 'queue' && sourceValue) { + filters.queue_id = sourceValue; + } + if (sourceType === 'agent' && sourceValue) { + filters.agent_id = sourceValue; + } + state.drilldown = { + ...emptyDrilldownState(), + open: true, + mode, + sourceType, + sourceLabel: analyticsDrilldownSourceLabel(sourceType, sourceValue), + sourceValue, + metric: mode === 'metric' ? (drilldownState.metric || filters.metric || sourceValue) : '', + filters: normalizeAnalyticsDrilldownFilters(filters), + offset: Math.max(0, Number(drilldownState.offset || 0)), + selectedInteractionId: drilldownState.selectedId || '', + coverage: mode === 'metric' ? analyticsMetricCoverage(drilldownState.metric || filters.metric || sourceValue) : null, + metricNote: mode === 'metric' + ? analyticsMetricDrilldownNote(drilldownState.metric || filters.metric || sourceValue) + : mode === 'ai' + ? aiAnalyticsDrilldownNote(filters) + : '', + }; + renderAnalyticsDrilldown(); + void loadAnalyticsDrilldownPage({ preserveSelection: Boolean(drilldownState.selectedId) }); +} + +function analyticsCompareModeLabel(mode) { + return ANALYTICS_COMPARE_MODE_LABELS[mode] || mode || 'Сравнение'; +} + +function suggestAnalyticsViewName() { + const parts = [analyticsPresetLabel(state.analytics.preset || '7d')]; + if (state.analytics.channel && state.analytics.channel !== 'all') { + parts.push(analyticsChannelLabel(state.analytics.channel)); + } + if (state.analytics.queueId && state.analytics.queueId !== 'all') { + const queue = state.analytics.queueOptions.find((item) => item.queue_id === state.analytics.queueId); + parts.push(queue?.name || state.analytics.queueId); + } + return parts.join(' · '); +} + +function normalizeSavedAnalyticsView(item = {}) { + return { + id: String(item.id || `view-${Date.now()}`), + name: String(item.name || 'Сохранённый вид'), + snapshot: { + preset: item.snapshot?.preset || '7d', + fromTs: item.snapshot?.fromTs || '', + toTs: item.snapshot?.toTs || '', + queueId: item.snapshot?.queueId || 'all', + channel: item.snapshot?.channel || 'all', + compareMode: item.snapshot?.compareMode || 'previous', + trendMetric: item.snapshot?.trendMetric || 'volume', + aiTrendMetric: item.snapshot?.aiTrendMetric || 'containment_rate', + agentTrendMetric: item.snapshot?.agentTrendMetric || 'interactions_per_agent', + }, + createdAt: item.created_at || item.createdAt || item.updated_at || item.updatedAt || new Date().toISOString(), + updatedAt: item.updated_at || item.updatedAt || item.created_at || item.createdAt || new Date().toISOString(), + }; +} + +async function loadSavedAnalyticsViews() { + try { + const items = await api('reporting', 'reports/views'); + state.analytics.savedViews = Array.isArray(items) + ? items + .filter((item) => item && typeof item === 'object') + .map((item) => normalizeSavedAnalyticsView(item)) + .sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt))) + : []; + } catch (err) { + state.analytics.savedViews = []; + state.analytics.statusMessage = `?? ??????? ????????? ??????????? ????: ${err.message}`; + } + if ( + state.analytics.activeViewId + && !state.analytics.savedViews.some((item) => item.id === state.analytics.activeViewId) + ) { + state.analytics.activeViewId = ''; + } + renderSavedAnalyticsViews(); +} + +function renderSavedAnalyticsViews() { + const select = $('analyticsSavedViewSelect'); + const input = $('analyticsSavedViewName'); + const meta = $('analyticsSavedViewMeta'); + const deleteBtn = $('analyticsDeleteViewBtn'); + if (!select || !input || !meta || !deleteBtn) { + return; + } + + const activeView = state.analytics.savedViews.find((item) => item.id === state.analytics.activeViewId) || null; + select.innerHTML = [ + '', + ...state.analytics.savedViews.map( + (item) => ``, + ), + ].join(''); + select.value = activeView ? activeView.id : ''; + + if (activeView) { + input.value = activeView.name; + } + input.placeholder = suggestAnalyticsViewName(); + deleteBtn.disabled = !activeView; + + const parts = []; + parts.push( + state.analytics.savedViews.length + ? `${state.analytics.savedViews.length} сохранённых ${state.analytics.savedViews.length === 1 ? 'вид' : 'вида'}` + : 'Сохранённых видов пока нет', + ); + if (activeView) { + parts.push(`активен: ${activeView.name}`); + } + if (state.analytics.statusMessage) { + parts.push(state.analytics.statusMessage); + } + meta.textContent = parts.join(' · '); +} + +async function saveAnalyticsView() { + syncAnalyticsStateFromControls(); + const input = $('analyticsSavedViewName'); + const name = input?.value.trim() || suggestAnalyticsViewName(); + try { + const view = await api('reporting', 'reports/views', { + method: 'POST', + body: JSON.stringify({ + id: state.analytics.activeViewId || null, + name, + snapshot: analyticsCurrentSnapshot(), + }), + }); + const normalized = normalizeSavedAnalyticsView(view); + const existingIndex = state.analytics.savedViews.findIndex((item) => item.id === normalized.id); + if (existingIndex >= 0) { + state.analytics.savedViews.splice(existingIndex, 1, normalized); + state.analytics.statusMessage = `??? ?${name}? ????????.`; + } else { + state.analytics.savedViews.unshift(normalized); + state.analytics.statusMessage = `??? ?${name}? ????????.`; + } + state.analytics.savedViews = state.analytics.savedViews + .sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt))) + .slice(0, 12); + state.analytics.activeViewId = normalized.id; + } catch (err) { + state.analytics.statusMessage = `?? ??????? ????????? ???: ${err.message}`; + } + renderSavedAnalyticsViews(); +} + +async function deleteAnalyticsView() { + const activeView = state.analytics.savedViews.find((item) => item.id === state.analytics.activeViewId) || null; + if (!activeView) { + state.analytics.statusMessage = '???????? ??????????? ???, ????? ??????? ???.'; + renderSavedAnalyticsViews(); + return; + } + try { + await api('reporting', `reports/views/${encodeURIComponent(activeView.id)}`, { method: 'DELETE' }); + state.analytics.savedViews = state.analytics.savedViews.filter((item) => item.id !== activeView.id); + state.analytics.activeViewId = ''; + state.analytics.statusMessage = `??? ?${activeView.name}? ??????.`; + } catch (err) { + state.analytics.statusMessage = `?? ??????? ??????? ???: ${err.message}`; + } + renderSavedAnalyticsViews(); +} + +function detachActiveAnalyticsView() { + if (!state.analytics.activeViewId) { + return; + } + state.analytics.activeViewId = ''; + state.analytics.statusMessage = 'Фильтры изменены. Можно сохранить новый вид.'; +} + +function analyticsQuery(range, overrides = {}) { + const params = new URLSearchParams(); + params.set('from_ts', range.from.toISOString()); + params.set('to_ts', range.to.toISOString()); + + const queueId = overrides.queueId ?? state.analytics.queueId; + const channel = overrides.channel ?? state.analytics.channel; + + if (queueId && queueId !== 'all') { + params.set('queue_id', queueId); + } + if (channel && channel !== 'all') { + params.set('channel', channel); + } + + return params.toString(); +} + +async function fetchAnalyticsKpi(range, overrides = {}) { + return api('reporting', `reports/kpi?${analyticsQuery(range, overrides)}`); +} + +async function fetchAgentAnalyticsOverview(range, overrides = {}) { + return api('reporting', `reports/agents/overview?${analyticsQuery(range, overrides)}`); +} + +async function fetchAgentAnalyticsTimeseries(range, metric, interval, overrides = {}) { + const params = new URLSearchParams(analyticsQuery(range, overrides)); + params.set('metric', metric || state.analytics.agentTrendMetric || 'interactions_per_agent'); + params.set('interval', interval || 'day'); + return api('reporting', `reports/agents/timeseries?${params.toString()}`); +} + +async function fetchAnalyticsMetricDrilldown(filters, limit, offset) { + return api('reporting', `reports/drilldown?${analyticsMetricDrilldownQuery(filters, limit, offset)}`); +} + +function aiAnalyticsSupportedChannel(channel = state.analytics.channel) { + return channel === 'all' || channel === 'telegram' || channel === 'whatsapp'; +} + +function aiAnalyticsQuery(range, overrides = {}) { + const params = new URLSearchParams(); + params.set('from_ts', range.from.toISOString()); + params.set('to_ts', range.to.toISOString()); + + const queueId = overrides.queueId ?? state.analytics.queueId; + const channel = overrides.channel ?? state.analytics.channel; + + if (queueId && queueId !== 'all') { + params.set('queue_id', queueId); + } + if (channel) { + params.set('channel', channel); + } + return params.toString(); +} + +async function fetchAiAnalyticsOverview(range, overrides = {}) { + return api('ai', `ai/analytics/overview?${aiAnalyticsQuery(range, overrides)}`); +} + +async function fetchAiAnalyticsTimeseries(range, metric, interval, overrides = {}) { + const params = new URLSearchParams(aiAnalyticsQuery(range, overrides)); + params.set('metric', metric || state.analytics.aiTrendMetric || 'containment_rate'); + params.set('interval', interval || 'day'); + return api('ai', `ai/analytics/timeseries?${params.toString()}`); +} + +async function fetchAiAnalyticsDrilldown(filters, limit, offset) { + return api('ai', `ai/analytics/drilldown?${aiAnalyticsDrilldownQuery(filters, limit, offset)}`); +} + +async function fetchAiAnalyticsSessionDetail(sessionId) { + return api('ai', `ai/analytics/sessions/${encodeURIComponent(sessionId)}`); +} + +function renderAnalyticsQueueOptions() { + const select = $('analyticsQueue'); + if (!select) { + return; + } + const selected = state.analytics.queueId || 'all'; + const options = ['']; + if (selected !== 'all' && !state.analytics.queueOptions.some((item) => item.queue_id === selected)) { + options.push(``); + } + options.push( + ...state.analytics.queueOptions.map( + (item) => ``, + ), + ); + select.innerHTML = options.join(''); + select.value = options.some((item) => item.includes(`value="${selected}"`)) ? selected : 'all'; +} + +async function loadAnalyticsQueueOptions() { + try { + const data = await api('routing', 'queues'); + state.analytics.queueOptions = Array.isArray(data) ? data : []; + state.analytics.queueAccessError = ''; + } catch (err) { + state.analytics.queueOptions = []; + state.analytics.queueAccessError = err.message; + } + renderAnalyticsQueueOptions(); + return state.analytics.queueOptions; +} + +function formatAnalyticsNumber(value, digits = 0) { + return new Intl.NumberFormat('ru-RU', { + minimumFractionDigits: digits, + maximumFractionDigits: digits, + }).format(Number(value || 0)); +} + +function analyticsChannelLabel(channel) { + const meta = ANALYTICS_CHANNEL_OPTIONS.find((item) => item.value === channel); + return meta ? meta.label : String(channel || 'неизвестно'); +} + +function metricValueFromPayload(metric, payload) { + if (metric === 'volume' || metric === 'total') { + return Number(payload?.volume?.total || 0); + } + if (metric === 'answered') { + return Number(payload?.volume?.answered || 0); + } + return Number(payload?.kpi?.[metric] || 0); +} + +function formatAnalyticsMetric(metric, value) { + const meta = ANALYTICS_METRIC_META[metric] || { unit: 'count' }; + if (meta.unit === 'pct') { + return `${formatAnalyticsNumber(value, 2)}%`; + } + if (meta.unit === 'seconds') { + return `${formatAnalyticsNumber(value, 2)} с`; + } + return formatAnalyticsNumber(value, 0); +} + +function analyticsMetricCoverage(metric, payload = state.analytics.overview || emptyKpiEnvelope()) { + const detail = payload?.coverage?.metric_details?.[metric]; + if (detail && typeof detail === 'object') { + return { + ...emptyAnalyticsMetricCoverage(), + ...detail, + supported_channels: Array.isArray(detail.supported_channels) ? detail.supported_channels : [], + exact_rows: Number(detail.exact_rows || 0), + total_rows: Number(detail.total_rows || 0), + }; + } + return { + ...emptyAnalyticsMetricCoverage(), + status: payload?.coverage?.metric_status?.[metric] || 'unavailable', + supported_channels: Array.isArray(payload?.coverage?.supported_channels?.[metric]) + ? payload.coverage.supported_channels[metric] + : [], + }; +} + +function analyticsMetricCanDrilldown(metric, payload = state.analytics.overview || emptyKpiEnvelope()) { + return analyticsMetricCoverage(metric, payload).status === 'exact'; +} + +function analyticsMetricCoverageTone(metric, payload = state.analytics.overview || emptyKpiEnvelope()) { + const coverage = analyticsMetricCoverage(metric, payload); + if (coverage.status === 'exact') { + return 'exact'; + } + if (coverage.status === 'partial') { + return 'partial'; + } + return 'unavailable'; +} + +function analyticsMetricCoverageLabel(metric, payload = state.analytics.overview || emptyKpiEnvelope()) { + const coverage = analyticsMetricCoverage(metric, payload); + if (coverage.status === 'exact') { + return 'Точно'; + } + if ( + coverage.status === 'partial' + && coverage.supported_channels.length === 1 + && coverage.supported_channels[0] === 'voice' + ) { + return 'Только голос'; + } + if (coverage.status === 'partial') { + return 'Частично'; + } + return 'Нет точного покрытия'; +} + +function analyticsMetricCoverageHint(metric, payload = state.analytics.overview || emptyKpiEnvelope()) { + const coverage = analyticsMetricCoverage(metric, payload); + if (coverage.status === 'exact') { + return 'Открыть точный KPI-срез по ID обращения.'; + } + if ( + coverage.supported_channels.length === 1 + && coverage.supported_channels[0] === 'voice' + && coverage.status === 'partial' + ) { + return 'Точная детализация сейчас доступна только для голосовой части выбранного окна.'; + } + if (!coverage.total_rows) { + return 'Для выбранного окна пока нет точных записей фактов для этой метрики.'; + } + if (!coverage.supported_channels.length) { + return 'Для этой метрики точная детализация в текущем срезе пока недоступна.'; + } + return 'Точная детализация пока доступна только для части среза, покрытой слоем KPI-фактов.'; +} + +function analyticsMetricCoverageBadge(metric, payload = state.analytics.overview || emptyKpiEnvelope()) { + const tone = analyticsMetricCoverageTone(metric, payload); + const label = analyticsMetricCoverageLabel(metric, payload); + return `${escapeHtml(label)}`; +} + +function aiAnalyticsMetricValue(metric, payload) { + return payload?.metrics?.[metric] ?? null; +} + +function aiAnalyticsMetricSlice(metric) { + if (metric === 'containment_rate') { + return 'contained'; + } + if (metric === 'handoff_rate') { + return 'handoff'; + } + if (metric === 'human_touched_rate') { + return 'human_touched'; + } + if (metric === 'closed_without_operator_rate') { + return 'closed_without_operator'; + } + return 'all'; +} + +function aiAnalyticsStatusLabel(status) { + const labels = { + active: 'Активна', + closed: 'Закрыта', + handoff_required: 'Требуется передача', + human_owned: 'У оператора', + error: 'Ошибка', + }; + return labels[status] || status || 'Неизвестно'; +} + +function aiAnalyticsSliceLabel(slice) { + const labels = { + all: 'Все AI-сессии', + contained: 'Закрыто AI', + handoff: 'Передано оператору', + human_touched: 'С участием оператора', + closed_without_operator: 'Закрыто без оператора', + active: 'Активные', + error: 'С ошибкой', + }; + return labels[slice] || slice || 'AI-срез'; +} + +function aiAnalyticsSortLabel(filters = state.drilldown.filters) { + const normalized = normalizeAnalyticsDrilldownFilters(filters); + const fieldLabels = { + created_at: 'созданию', + updated_at: 'обновлению', + ai_latency_avg_ms: 'задержке', + status: 'статусу', + }; + const dirLabel = normalized.sort_dir === 'asc' ? 'по возрастанию' : 'по убыванию'; + return `Сортировка: по ${fieldLabels[normalized.sort_by] || normalized.sort_by}, ${dirLabel}`; +} + +function aiAnalyticsDrilldownNote(filters = state.drilldown.filters) { + const normalized = normalizeAnalyticsDrilldownFilters(filters); + const parts = [ + 'Только метаданные по AI-сессиям: без текстов сообщений, AI-сводок, записей и транскриптов.', + ]; + if (normalized.reason_key) { + parts.push(`Причина: ${normalized.reason_key}`); + } + return parts.join(' '); +} + +function formatAiAnalyticsMetric(metric, value) { + const meta = AI_ANALYTICS_METRIC_META[metric] || { unit: 'count' }; + if (value === null || value === undefined || Number.isNaN(Number(value))) { + return '—'; + } + if (meta.unit === 'pct') { + return `${formatAnalyticsNumber(value, 2)}%`; + } + if (meta.unit === 'ms') { + return `${formatAnalyticsNumber(value, 0)} ms`; + } + return formatAnalyticsNumber(value, 0); +} + +function signedDelta(value, digits = 1) { + if (!value) { + return '0'; + } + const sign = value > 0 ? '+' : '-'; + return `${sign}${formatAnalyticsNumber(Math.abs(value), digits)}`; +} + +function analyticsDelta(metric, currentPayload, previousPayload) { + const current = metricValueFromPayload(metric, currentPayload); + const previous = metricValueFromPayload(metric, previousPayload); + const diff = current - previous; + const meta = ANALYTICS_METRIC_META[metric] || { unit: 'count', better: 'neutral' }; + + let text = signedDelta(diff, meta.unit === 'count' ? 0 : 1); + if (meta.unit === 'pct') { + text = `${text} п.п.`; + } else if (meta.unit === 'seconds') { + text = `${text} с`; + } + + let tone = 'neutral'; + if (meta.better === 'up') { + tone = diff > 0 ? 'positive' : diff < 0 ? 'negative' : 'neutral'; + } else if (meta.better === 'down') { + tone = diff < 0 ? 'positive' : diff > 0 ? 'negative' : 'neutral'; + } + + return { text, tone }; +} + +function aiAnalyticsMetricLabel(metric) { + return AI_ANALYTICS_TREND_LABELS[metric] || metric; +} + +function aiAnalyticsDelta(metric, currentPayload, previousPayload) { + const current = aiAnalyticsMetricValue(metric, currentPayload); + const previous = aiAnalyticsMetricValue(metric, previousPayload); + const meta = AI_ANALYTICS_METRIC_META[metric] || { unit: 'count', better: 'neutral' }; + if ( + current === null + || current === undefined + || previous === null + || previous === undefined + || Number.isNaN(Number(current)) + || Number.isNaN(Number(previous)) + ) { + return { text: '-', tone: 'neutral' }; + } + + const diff = Number(current) - Number(previous); + let text = signedDelta(diff, meta.unit === 'pct' ? 1 : 0); + if (meta.unit === 'pct') { + text = `${text} п.п.`; + } else if (meta.unit === 'ms') { + text = `${text} ms`; + } + + let tone = 'neutral'; + if (meta.better === 'up') { + tone = diff > 0 ? 'positive' : diff < 0 ? 'negative' : 'neutral'; + } else if (meta.better === 'down') { + tone = diff < 0 ? 'positive' : diff > 0 ? 'negative' : 'neutral'; + } + return { text, tone }; +} + +function renderAnalyticsCard(label, metric, payload, previousPayload, options = {}) { + const value = metricValueFromPayload(metric, payload); + const showCompare = state.analytics.compareMode === 'previous'; + const delta = showCompare ? analyticsDelta(metric, payload, previousPayload) : null; + const note = ANALYTICS_METRIC_META[metric]?.note || ''; + const metricDrilldown = Boolean(options.metricDrilldown); + const drilldownEnabled = metricDrilldown + ? analyticsMetricCanDrilldown(metric, payload) + : Boolean(options.drilldown); + const coverageBadge = metricDrilldown ? analyticsMetricCoverageBadge(metric, payload) : ''; + const classes = [ + 'summary-card', + 'analytics-card', + drilldownEnabled ? 'analytics-card-button' : 'analytics-card-readonly', + metricDrilldown ? `analytics-card-coverage-${analyticsMetricCoverageTone(metric, payload)}` : '', + ].join(' '); + const attributes = drilldownEnabled + ? `type="button" data-analytics-drilldown-source="${escapeHtml(options.sourceType || 'overview')}" data-analytics-drilldown-label="${escapeHtml(options.sourceLabel || label)}"${metricDrilldown ? ` data-analytics-drilldown-metric="${escapeHtml(metric)}"` : ''}` + : ''; + const hint = metricDrilldown + ? analyticsMetricCoverageHint(metric, payload) + : drilldownEnabled + ? 'Открыть детализацию по обращениям' + : 'Точная детализация появится после связи KPI с interaction_id.'; + const tag = drilldownEnabled ? 'button' : 'div'; + return ` + <${tag} class="${classes}" ${attributes}> +
            +
            ${label}
            +
            + ${coverageBadge} + ${showCompare ? `${delta.text}` : ''} +
            +
            +
            ${formatAnalyticsMetric(metric, value)}
            + ${note ? `
            ${note}
            ` : ''} +
            ${hint}
            + + `; +} + +function formatTime(ts) { + if (!ts) { + return '-'; + } + const date = new Date(ts); + if (Number.isNaN(date.getTime())) { + return ts; + } + return date.toLocaleString(); +} + +function analyticsPresetLabel(preset) { + return ANALYTICS_PRESET_LABELS[preset] || preset; +} + +function analyticsMetricLabel(metric) { + return ANALYTICS_TREND_LABELS[metric] || metric; +} + +function analyticsQueueName(queueId) { + if (queueId === 'all') { + return 'Все очереди'; + } + if (!queueId) { + return 'Без очереди'; + } + const queue = state.analytics.queueOptions.find((item) => item.queue_id === queueId); + return queue?.name || queueId; +} + +function interactionStatusLabel(status) { + const labels = { + new: 'Новая', + in_progress: 'В работе', + escalated: 'Эскалация', + closed: 'Закрыта', + abandoned: 'Потеряна', + }; + return labels[status] || status || 'Не указан'; +} + +function analyticsMetricDrilldownNote(metric, payload = state.analytics.overview || emptyKpiEnvelope()) { + const metricNote = ANALYTICS_METRIC_META[metric]?.note || ''; + const coverageHint = analyticsMetricCoverageHint(metric, payload); + return [metricNote, coverageHint] + .filter(Boolean) + .join(' '); +} + +function analyticsDrilldownFactBadges(item) { + if (!item || typeof item !== 'object') { + return []; + } + const badges = []; + if (item.wait_seconds !== null && item.wait_seconds !== undefined) { + badges.push({ tone: 'wait', label: `Ожидание ${formatAnalyticsNumber(item.wait_seconds, 0)} с` }); + } + if (item.handle_seconds !== null && item.handle_seconds !== undefined) { + badges.push({ tone: 'handle', label: `Обработка ${formatAnalyticsNumber(item.handle_seconds, 0)} с` }); + } + if (item.within_sla === true) { + badges.push({ tone: 'sla', label: 'В SLA' }); + } else if (item.within_sla === false) { + badges.push({ tone: 'sla-miss', label: 'Вне SLA' }); + } + if (item.resolved_first_contact === true) { + badges.push({ tone: 'fcr', label: 'FCR' }); + } + if (item.abandoned === true) { + badges.push({ tone: 'abandoned', label: 'Потеря' }); + } + return badges; +} + +function renderAnalyticsDrilldownFactBadges(item) { + const badges = analyticsDrilldownFactBadges(item); + if (!badges.length) { + return ''; + } + return ` +
            + ${badges + .map( + (badge) => `${escapeHtml(badge.label)}`, + ) + .join('')} +
            + `; +} + +function clearAnalyticsDrilldownSearchDebounce() { + if (analyticsDrilldownSearchDebounce) { + window.clearTimeout(analyticsDrilldownSearchDebounce); + analyticsDrilldownSearchDebounce = 0; + } +} + +function normalizeAnalyticsDrilldownFilters(filters = {}) { + return { + from_ts: filters.from_ts || '', + to_ts: filters.to_ts || '', + metric: filters.metric || '', + slice: filters.slice || 'all', + reason_key: filters.reason_key || '', + queue_id: filters.queue_id || '', + channel: filters.channel || '', + agent_id: filters.agent_id || '', + sl_threshold_seconds: Number(filters.sl_threshold_seconds || 30), + status: filters.status || '', + q: filters.q || '', + sort_by: filters.sort_by || 'created_at', + sort_dir: filters.sort_dir || 'desc', + }; +} + +function analyticsDrilldownSortValue(filters = state.drilldown.filters) { + const normalized = normalizeAnalyticsDrilldownFilters(filters); + return `${normalized.sort_by}:${normalized.sort_dir}`; +} + +function parseAnalyticsDrilldownSort(value) { + const [sortByRaw = 'created_at', sortDirRaw = 'desc'] = String(value || 'created_at:desc').split(':'); + const allowedFields = state.drilldown.mode === 'ai' + ? ['created_at', 'updated_at', 'ai_latency_avg_ms', 'status'] + : ['created_at', 'updated_at', 'subject']; + const sortBy = allowedFields.includes(sortByRaw) ? sortByRaw : 'created_at'; + const sortDir = sortDirRaw === 'asc' ? 'asc' : 'desc'; + return { sort_by: sortBy, sort_dir: sortDir }; +} + +function analyticsDrilldownSortLabel(filters = state.drilldown.filters) { + if (state.drilldown.mode === 'ai') { + return aiAnalyticsSortLabel(filters); + } + const normalized = normalizeAnalyticsDrilldownFilters(filters); + const fieldLabels = { + created_at: 'созданию', + updated_at: 'обновлению', + subject: 'теме', + }; + const dirLabel = normalized.sort_dir === 'asc' ? 'по возрастанию' : 'по убыванию'; + return `Сортировка: по ${fieldLabels[normalized.sort_by] || normalized.sort_by}, ${dirLabel}`; +} + +function analyticsDrilldownStatusClass(status) { + return String(status || 'unknown').toLowerCase().replace(/[^a-z0-9]+/g, '-'); +} + +function analyticsDrilldownFilenameSource() { + if (state.drilldown.mode === 'ai') { + const slice = state.drilldown.filters?.slice || 'all'; + if (state.drilldown.filters?.reason_key) { + return `ai-${slice}-${String(state.drilldown.filters.reason_key).toLowerCase()}`; + } + if (state.drilldown.filters?.channel) { + return `ai-${String(state.drilldown.filters.channel).toLowerCase()}-${slice}`; + } + return `ai-${slice}`; + } + if (state.drilldown.sourceType === 'metric' && state.drilldown.metric) { + return `metric-${String(state.drilldown.metric).toLowerCase()}`; + } + if (state.drilldown.sourceType === 'channel' && state.drilldown.filters?.channel) { + return `channel-${String(state.drilldown.filters.channel).toLowerCase()}`; + } + if (state.drilldown.sourceType === 'queue' && state.drilldown.filters?.queue_id) { + return `queue-${String(state.drilldown.filters.queue_id).toLowerCase().replace(/[^a-z0-9_-]+/g, '-')}`; + } + if (state.drilldown.sourceType === 'agent' && state.drilldown.filters?.agent_id) { + return `agent-${String(state.drilldown.filters.agent_id).toLowerCase().replace(/[^a-z0-9._-]+/g, '-')}`; + } + return state.drilldown.sourceType === 'overview' ? 'all-interactions' : String(state.drilldown.sourceType || 'slice'); +} + +function sanitizeAnalyticsTimelineMetadata(metadata) { + if (!metadata || typeof metadata !== 'object' || Array.isArray(metadata)) { + return {}; + } + const blockedKeyParts = ['message', 'messages', 'text', 'body', 'summary', 'transcript', 'recording', 'audio', 'media', 'content']; + const safe = {}; + Object.entries(metadata).forEach(([key, value]) => { + const normalizedKey = String(key || '').toLowerCase(); + if (blockedKeyParts.some((part) => normalizedKey.includes(part))) { + return; + } + if (typeof value === 'string') { + safe[key] = value.length > 120 ? `[hidden string ${value.length} chars]` : value; + return; + } + if (Array.isArray(value)) { + safe[key] = `[items: ${value.length}]`; + return; + } + if (value && typeof value === 'object') { + safe[key] = '[object]'; + return; + } + safe[key] = value; + }); + return safe; +} + +function rememberAnalyticsDrilldownReturnFocus() { + const active = document.activeElement; + analyticsDrilldownReturnFocus = active instanceof HTMLElement ? active : null; +} + +function restoreAnalyticsDrilldownReturnFocus() { + if (analyticsDrilldownReturnFocus instanceof HTMLElement && document.contains(analyticsDrilldownReturnFocus)) { + analyticsDrilldownReturnFocus.focus(); + } + analyticsDrilldownReturnFocus = null; +} + +function analyticsDrilldownFocusableElements() { + const drawer = $('analyticsDrilldownDrawer'); + if (!drawer || !state.drilldown.open) { + return []; + } + return Array.from( + drawer.querySelectorAll( + 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])', + ), + ).filter( + (element) => + !element.hasAttribute('hidden') + && element.getAttribute('aria-hidden') !== 'true' + && element.offsetParent !== null, + ); +} + +function focusAnalyticsDrilldownPrimaryControl(selectText = false) { + const searchInput = $('analyticsDrilldownSearch'); + if (searchInput && !searchInput.disabled) { + searchInput.focus(); + if (selectText && typeof searchInput.select === 'function') { + searchInput.select(); + } + return; + } + $('analyticsDrilldownDrawer')?.focus(); +} + +function handleAnalyticsDrilldownWindowKeydown(event) { + if (!state.drilldown.open) { + return; + } + if (event.key === 'Escape') { + event.preventDefault(); + closeAnalyticsDrilldown(); + return; + } + if (event.key === '/' && !event.ctrlKey && !event.metaKey && !event.altKey) { + const targetTag = document.activeElement?.tagName || ''; + if (!['INPUT', 'TEXTAREA', 'SELECT'].includes(targetTag)) { + event.preventDefault(); + focusAnalyticsDrilldownPrimaryControl(true); + } + return; + } + if (event.key !== 'Tab') { + return; + } + const focusable = analyticsDrilldownFocusableElements(); + if (!focusable.length) { + return; + } + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + const active = document.activeElement; + if (event.shiftKey && active === first) { + event.preventDefault(); + last.focus(); + return; + } + if (!event.shiftKey && active === last) { + event.preventDefault(); + first.focus(); + } +} + +function maybeScrollAnalyticsDrilldownDetailIntoView() { + if (!window.matchMedia('(max-width: 960px)').matches) { + return; + } + window.requestAnimationFrame(() => { + $('analyticsDrilldownDetail')?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + }); +} + +function syncAnalyticsDrilldownControls() { + const searchInput = $('analyticsDrilldownSearch'); + const statusSelect = $('analyticsDrilldownStatus'); + const sortSelect = $('analyticsDrilldownSort'); + const clearBtn = $('analyticsDrilldownClearBtn'); + const exportBtn = $('analyticsDrilldownExportBtn'); + const isOpen = state.drilldown.open; + const localFiltersEnabled = isOpen && state.drilldown.mode === 'interaction'; + const filters = normalizeAnalyticsDrilldownFilters(state.drilldown.filters || {}); + if (searchInput) { + searchInput.value = isOpen ? filters.q : ''; + searchInput.disabled = !localFiltersEnabled || state.drilldown.loading || state.drilldown.exporting; + searchInput.placeholder = state.drilldown.mode === 'metric' + ? 'Поиск доступен только для среза обращений' + : 'ID, тема или назначенный'; + } + if (statusSelect) { + statusSelect.value = isOpen ? (filters.status || 'all') : 'all'; + statusSelect.disabled = !localFiltersEnabled || state.drilldown.loading || state.drilldown.exporting; + } + if (sortSelect) { + sortSelect.value = analyticsDrilldownSortValue(filters); + sortSelect.disabled = !localFiltersEnabled || state.drilldown.loading || state.drilldown.exporting; + } + if (clearBtn) { + clearBtn.disabled = !localFiltersEnabled || state.drilldown.loading || state.drilldown.exporting; + } + if (exportBtn) { + exportBtn.disabled = !isOpen || state.drilldown.loading || state.drilldown.exporting || !state.drilldown.total; + exportBtn.textContent = state.drilldown.exporting ? 'Готовим CSV...' : 'Экспорт CSV'; + } +} + +function resetAnalyticsDrilldown() { + clearAnalyticsDrilldownSearchDebounce(); + state.drilldown = emptyDrilldownState(); +} + +function closeAnalyticsDrilldown(options = {}) { + if (!state.drilldown.open && !state.drilldown.loading && !state.drilldown.error) { + return; + } + resetAnalyticsDrilldown(); + renderAnalyticsDrilldown(); + if (!options.skipUrlSync) { + syncAnalyticsUrlState(); + } + window.requestAnimationFrame(() => { + restoreAnalyticsDrilldownReturnFocus(); + }); +} + +function analyticsDrilldownBaseFilters(rangeMeta = state.analytics.lastRangeMeta || analyticsRangeFromControls()) { + return normalizeAnalyticsDrilldownFilters({ + from_ts: rangeMeta.current.from.toISOString(), + to_ts: rangeMeta.current.to.toISOString(), + metric: '', + slice: 'all', + reason_key: '', + queue_id: state.analytics.queueId !== 'all' ? state.analytics.queueId : '', + channel: state.analytics.channel !== 'all' ? state.analytics.channel : '', + agent_id: '', + sl_threshold_seconds: 30, + status: '', + q: '', + sort_by: 'created_at', + sort_dir: 'desc', + }); +} + +function analyticsDrilldownSourceLabel(sourceType, sourceValue = '') { + if (sourceType === 'agent') { + return `Агент: ${sourceValue}`; + } + if (sourceType === 'ai-overview') { + return aiAnalyticsSliceLabel(sourceValue || 'all'); + } + if (sourceType === 'ai-channel') { + return `AI-канал: ${analyticsChannelLabel(sourceValue)}`; + } + if (sourceType === 'ai-reason') { + return `Причина передачи: ${sourceValue}`; + } + if (sourceType === 'metric') { + return `KPI: ${sourceValue}`; + } + if (sourceType === 'channel') { + return `Канал: ${analyticsChannelLabel(sourceValue)}`; + } + if (sourceType === 'queue') { + return `Очередь: ${analyticsQueueName(sourceValue)}`; + } + return 'Все обращения'; +} + +function analyticsDrilldownQuery(filters, limit, offset) { + const normalized = normalizeAnalyticsDrilldownFilters(filters); + const params = new URLSearchParams(); + params.set('from_ts', normalized.from_ts); + params.set('to_ts', normalized.to_ts); + params.set('limit', String(limit)); + params.set('offset', String(offset)); + params.set('sort_by', normalized.sort_by || 'created_at'); + params.set('sort_dir', normalized.sort_dir || 'desc'); + if (normalized.queue_id) { + params.set('queue_id', normalized.queue_id); + } + if (normalized.channel) { + params.set('channel', normalized.channel); + } + if (normalized.agent_id) { + params.set('agent_id', normalized.agent_id); + } + if (normalized.status) { + params.set('status', normalized.status); + } + if (normalized.q) { + params.set('q', normalized.q); + } + return params.toString(); +} + +function aiAnalyticsDrilldownQuery(filters, limit, offset) { + const normalized = normalizeAnalyticsDrilldownFilters(filters); + const params = new URLSearchParams(); + params.set('from_ts', normalized.from_ts); + params.set('to_ts', normalized.to_ts); + params.set('slice', normalized.slice || 'all'); + params.set('limit', String(limit)); + params.set('offset', String(offset)); + params.set('sort_by', normalized.sort_by || 'created_at'); + params.set('sort_dir', normalized.sort_dir || 'desc'); + if (normalized.queue_id) { + params.set('queue_id', normalized.queue_id); + } + if (normalized.channel) { + params.set('channel', normalized.channel); + } + if (normalized.reason_key) { + params.set('reason_key', normalized.reason_key); + } + if (normalized.status) { + params.set('status', normalized.status); + } + if (normalized.q) { + params.set('q', normalized.q); + } + return params.toString(); +} + +function analyticsMetricDrilldownQuery(filters, limit, offset) { + const normalized = normalizeAnalyticsDrilldownFilters(filters); + const params = new URLSearchParams(); + params.set('from_ts', normalized.from_ts); + params.set('to_ts', normalized.to_ts); + params.set('metric', normalized.metric); + params.set('limit', String(limit)); + params.set('offset', String(offset)); + params.set('sl_threshold_seconds', String(normalized.sl_threshold_seconds || 30)); + if (normalized.queue_id) { + params.set('queue_id', normalized.queue_id); + } + if (normalized.channel) { + params.set('channel', normalized.channel); + } + return params.toString(); +} + +function analyticsDrilldownChips() { + if (!state.drilldown.filters) { + return []; + } + const drilldownFilters = normalizeAnalyticsDrilldownFilters(state.drilldown.filters); + const chips = []; + chips.push(state.drilldown.sourceLabel || 'Детализация'); + chips.push( + `${formatTime(drilldownFilters.from_ts)} - ${formatTime(drilldownFilters.to_ts)}`, + ); + if (drilldownFilters.channel) { + chips.push(analyticsChannelLabel(drilldownFilters.channel)); + } + if (drilldownFilters.queue_id) { + chips.push(analyticsQueueName(drilldownFilters.queue_id)); + } + if (drilldownFilters.agent_id) { + chips.push(`Агент: ${drilldownFilters.agent_id}`); + } + if (drilldownFilters.status) { + chips.push(`Статус: ${interactionStatusLabel(drilldownFilters.status)}`); + } + if (drilldownFilters.q) { + chips.push(`Поиск: ${drilldownFilters.q}`); + } + if (state.drilldown.mode === 'metric' && state.drilldown.metric) { + chips.push(analyticsMetricCoverageLabel(state.drilldown.metric)); + } else { + chips.push(analyticsDrilldownSortLabel(drilldownFilters)); + } + return chips; +} + +function updateAnalyticsRangeHint(range) { + const box = $('analyticsRangeHint'); + if (!box) { + return; + } + const filters = []; + if (state.analytics.queueId !== 'all') { + const queue = state.analytics.queueOptions.find((item) => item.queue_id === state.analytics.queueId); + filters.push(`очередь: ${queue?.name || state.analytics.queueId}`); + } + if (state.analytics.channel !== 'all') { + filters.push(`канал: ${analyticsChannelLabel(state.analytics.channel)}`); + } + filters.push( + state.analytics.compareMode === 'previous' + ? 'сравнение с предыдущим окном' + : 'только текущий срез', + ); + const activeView = state.analytics.savedViews.find((item) => item.id === state.analytics.activeViewId); + if (activeView) { + filters.push(`вид: ${activeView.name}`); + } + const suffix = filters.length ? ` | ${filters.join(' | ')}` : ''; + box.textContent = range.custom + ? `Окно анализа: ${formatTime(range.current.from.toISOString())} - ${formatTime(range.current.to.toISOString())}${suffix}` + : `Период: ${analyticsPresetLabel(range.preset)}${suffix}`; +} + +function renderAnalyticsNarrative(rangeMeta) { + const box = $('analyticsNarrative'); + if (!box) { + return; + } + const current = state.analytics.overview || emptyKpiEnvelope(); + const previous = state.analytics.compare || emptyKpiEnvelope(); + const total = Number(current?.volume?.total || 0); + const sl = Number(current?.kpi?.SL || 0); + const abandon = Number(current?.kpi?.Abandon || 0); + const digitalShare = Number(current?.kpi?.DigitalShare || 0); + const delta = analyticsDelta('total', current, previous); + const filters = []; + if (state.analytics.channel !== 'all') { + filters.push(analyticsChannelLabel(state.analytics.channel)); + } + if (state.analytics.queueId !== 'all') { + const queue = state.analytics.queueOptions.find((item) => item.queue_id === state.analytics.queueId); + filters.push(queue?.name || state.analytics.queueId); + } + const activeView = state.analytics.savedViews.find((item) => item.id === state.analytics.activeViewId); + if (activeView) { + filters.push(activeView.name); + } + + const chips = [ + analyticsPresetLabel(rangeMeta.preset), + analyticsCompareModeLabel(state.analytics.compareMode), + ...filters, + ]; + const deltaBadge = state.analytics.compareMode === 'previous' + ? `${delta.text} к предыдущему окну` + : 'Сравнение отключено'; + + box.innerHTML = ` +
            + ${formatAnalyticsNumber(total, 0)} обращений +

            За выбранный период ${formatAnalyticsNumber(sl, 2)}% потока уложилось в SLA, потери составили ${formatAnalyticsNumber(abandon, 2)}%, а цифровая доля достигла ${formatAnalyticsNumber(digitalShare, 2)}%.

            +
            +
            + ${deltaBadge} +
            + ${chips.map((chip) => `${escapeHtml(chip)}`).join('')} +
            +
            + `; +} + +function renderAnalyticsCompareCard(label, metric, currentPayload, previousPayload) { + const currentValue = metricValueFromPayload(metric, currentPayload); + const previousValue = metricValueFromPayload(metric, previousPayload); + const delta = analyticsDelta(metric, currentPayload, previousPayload); + const note = ANALYTICS_METRIC_META[metric]?.note || ''; + const coverageBadge = analyticsMetricCoverageBadge(metric, currentPayload); + return ` +
            +
            + ${label} +
            + ${coverageBadge} + ${delta.text} +
            +
            +
            +
            + Текущее окно + ${formatAnalyticsMetric(metric, currentValue)} +
            +
            + Предыдущее окно + ${formatAnalyticsMetric(metric, previousValue)} +
            +
            +

            ${note}

            +
            + `; +} + +function renderAnalyticsComparePanel() { + const box = $('analyticsComparePanel'); + if (!box) { + return; + } + if (state.analytics.compareMode !== 'previous') { + box.hidden = true; + box.innerHTML = ''; + return; + } + const current = state.analytics.overview || emptyKpiEnvelope(); + const previous = state.analytics.compare || emptyKpiEnvelope(); + box.hidden = false; + box.innerHTML = ` +
            +
            +

            Сравнение периодов

            +

            Быстрый разрез по главным изменениям между текущим и предыдущим окном без перехода в отдельный отчёт.

            +
            + V2 compare mode +
            +
            + ${[ + renderAnalyticsCompareCard('Обращения', 'total', current, previous), + renderAnalyticsCompareCard('SL', 'SL', current, previous), + renderAnalyticsCompareCard('Среднее ожидание', 'ASA', current, previous), + renderAnalyticsCompareCard('Потери', 'Abandon', current, previous), + ].join('')} +
            + `; +} + +function renderAnalyticsOverview() { + const current = state.analytics.overview || emptyKpiEnvelope(); + const previous = state.analytics.compare || emptyKpiEnvelope(); + $('analyticsOverview').innerHTML = [ + renderAnalyticsCard('Обращения', 'total', current, previous, { + metricDrilldown: true, + sourceType: 'metric', + sourceLabel: 'Обращения', + }), + renderAnalyticsCard('Обработано', 'answered', current, previous, { + metricDrilldown: true, + sourceType: 'metric', + sourceLabel: 'Обработано', + }), + renderAnalyticsCard('SL', 'SL', current, previous, { + metricDrilldown: true, + sourceType: 'metric', + sourceLabel: 'SL', + }), + renderAnalyticsCard('Среднее ожидание', 'ASA', current, previous, { + metricDrilldown: true, + sourceType: 'metric', + sourceLabel: 'Среднее ожидание', + }), + renderAnalyticsCard('Среднее время', 'AHT', current, previous, { + metricDrilldown: true, + sourceType: 'metric', + sourceLabel: 'Среднее время', + }), + renderAnalyticsCard('Потери', 'Abandon', current, previous, { + metricDrilldown: true, + sourceType: 'metric', + sourceLabel: 'Потери', + }), + renderAnalyticsCard('FCR', 'FCR', current, previous, { + metricDrilldown: true, + sourceType: 'metric', + sourceLabel: 'FCR', + }), + renderAnalyticsCard('Цифровая доля', 'DigitalShare', current, previous, { + metricDrilldown: true, + sourceType: 'metric', + sourceLabel: 'Цифровая доля', + }), + ].join(''); +} + +function aiAnalyticsMetricDetail(metric, payload) { + const totals = payload?.totals || {}; + if (metric === 'containment_rate') { + return `${formatAnalyticsNumber(totals.sessions_contained || 0, 0)} из ${formatAnalyticsNumber(totals.sessions_started || 0, 0)} сессий`; + } + if (metric === 'handoff_rate') { + return `${formatAnalyticsNumber(totals.sessions_handoff || 0, 0)} из ${formatAnalyticsNumber(totals.sessions_started || 0, 0)} сессий`; + } + if (metric === 'ai_latency_avg_ms') { + return `${formatAnalyticsNumber(totals.assistant_turns || 0, 0)} ответов AI`; + } + if (metric === 'closed_without_operator_rate') { + return `${formatAnalyticsNumber(totals.sessions_closed_without_operator || 0, 0)} закрытий без оператора`; + } + return ''; +} + +function renderAiAnalyticsCard(label, metric, currentPayload, previousPayload) { + const value = aiAnalyticsMetricValue(metric, currentPayload); + const showCompare = state.analytics.compareMode === 'previous'; + const delta = showCompare ? aiAnalyticsDelta(metric, currentPayload, previousPayload) : null; + const note = AI_ANALYTICS_METRIC_META[metric]?.note || ''; + const detail = aiAnalyticsMetricDetail(metric, currentPayload); + return ` +
            +
            +
            ${label}
            + ${showCompare ? `${delta.text}` : ''} +
            +
            ${formatAiAnalyticsMetric(metric, value)}
            + ${detail ? `
            ${detail}
            ` : ''} + ${note ? `
            ${note}
            ` : ''} +
            + `; +} + +function renderAiAnalyticsOverview() { + const box = $('aiAnalyticsOverview'); + if (!box) { + return; + } + if (!aiAnalyticsSupportedChannel() || state.analytics.aiError) { + box.innerHTML = ''; + return; + } + const current = state.analytics.aiOverview || emptyAiAnalyticsOverview(); + if (!state.analytics.loading && Number(current?.totals?.sessions_started || 0) === 0) { + box.innerHTML = ''; + return; + } + const previous = state.analytics.aiCompare || emptyAiAnalyticsOverview(); + box.innerHTML = [ + renderAiAnalyticsCard('Закрыто AI', 'containment_rate', current, previous), + renderAiAnalyticsCard('Передано оператору', 'handoff_rate', current, previous), + renderAiAnalyticsCard('Задержка AI', 'ai_latency_avg_ms', current, previous), + renderAiAnalyticsCard('Закрыто без оператора', 'closed_without_operator_rate', current, previous), + ].join(''); +} + +function renderAiAnalyticsChannelComparison() { + const container = $('aiAnalyticsChannelComparison'); + const rows = Array.isArray(state.analytics.aiOverview?.breakdowns?.by_channel) + ? state.analytics.aiOverview.breakdowns.by_channel + : []; + if (!container) { + return; + } + if (!aiAnalyticsSupportedChannel() || state.analytics.aiError) { + container.innerHTML = ''; + return; + } + if (!rows.length) { + container.innerHTML = '
            Нет AI-сессий по Telegram и WhatsApp за выбранный период.
            '; + return; + } + + container.innerHTML = ` +
            + Канал + Сессии + Только AI + С оператором + Передача + Средняя задержка +
            + ${rows + .map((item) => { + const aiOnlyShare = item.sessions_started ? (item.ai_only_sessions / item.sessions_started) * 100 : 0; + const humanTouchedShare = item.sessions_started ? (item.human_touched_sessions / item.sessions_started) * 100 : 0; + return ` +
            + ${escapeHtml(analyticsChannelLabel(item.channel))} + ${formatAnalyticsNumber(item.sessions_started, 0)} + ${formatAnalyticsNumber(aiOnlyShare, 2)}% + ${formatAnalyticsNumber(humanTouchedShare, 2)}% + ${formatAnalyticsNumber(item.handoff_rate || 0, 2)}% + ${formatAiAnalyticsMetric('ai_latency_avg_ms', item.ai_latency_avg_ms)} +
            + `; + }) + .join('')} + `; +} + +function renderAiAnalyticsCoverage() { + const container = $('aiAnalyticsCoverage'); + const coverage = state.analytics.aiOverview?.coverage || emptyAiAnalyticsOverview().coverage; + if (!container) { + return; + } + if (!aiAnalyticsSupportedChannel() || state.analytics.aiError) { + container.innerHTML = ''; + return; + } + if (!state.analytics.loading && Number(state.analytics.aiOverview?.totals?.sessions_started || 0) === 0) { + container.innerHTML = ''; + return; + } + container.innerHTML = ` +
            +
            + ${formatAnalyticsNumber(coverage.sessions_with_interaction_id || 0, 0)} +

            AI-сессий связаны с ID обращения

            +
            +
            + ${formatAnalyticsNumber(coverage.sessions_with_queue_id || 0, 0)} +

            AI-сессий имеют ID очереди через обращение или диалог

            +
            +
            + ${formatAnalyticsNumber(coverage.sessions_with_latency_turns || 0, 0)} +

            AI-сессий содержат данные по задержке ответов модели

            +
            +
            + ${formatAnalyticsNumber(coverage.sessions_with_terminal_state || 0, 0)} +

            AI-сессий дошли до финального состояния

            +
            +
            + `; +} + +function agentAnalyticsMetricValue(metric, payload = state.analytics.agentOverview || emptyAgentAnalyticsOverview()) { + const totals = payload?.totals || {}; + if (metric === 'agents_with_activity') { + return Number(totals.agents_with_activity || 0); + } + if (metric === 'avg_interactions_per_agent') { + return Number(totals.avg_interactions_per_agent || 0); + } + if (metric === 'avg_handle_seconds') { + return totals.avg_handle_seconds === null || totals.avg_handle_seconds === undefined + ? null + : Number(totals.avg_handle_seconds); + } + if (metric === 'avg_fcr_rate') { + return totals.avg_fcr_rate === null || totals.avg_fcr_rate === undefined + ? null + : Number(totals.avg_fcr_rate); + } + return Number(totals.agents_total || 0); +} + +function formatAgentAnalyticsMetric(metric, value) { + if (value === null || value === undefined) { + return '—'; + } + if (metric === 'avg_handle_seconds') { + return `${formatAnalyticsNumber(value, 1)} с`; + } + if (metric === 'avg_fcr_rate') { + return `${formatAnalyticsNumber(value, 1)}%`; + } + if (metric === 'avg_interactions_per_agent') { + return formatAnalyticsNumber(value, 1); + } + return formatAnalyticsNumber(value, 0); +} + +function agentAnalyticsDelta(metric, currentPayload, previousPayload) { + const current = agentAnalyticsMetricValue(metric, currentPayload); + const previous = agentAnalyticsMetricValue(metric, previousPayload); + const normalizedCurrent = current === null || current === undefined ? 0 : Number(current); + const normalizedPrevious = previous === null || previous === undefined ? 0 : Number(previous); + const diff = normalizedCurrent - normalizedPrevious; + const digits = metric === 'agents_total' || metric === 'agents_with_activity' ? 0 : 1; + let text = signedDelta(diff, digits); + if (metric === 'avg_handle_seconds') { + text = `${text} с`; + } else if (metric === 'avg_fcr_rate') { + text = `${text} п.п.`; + } + let tone = 'neutral'; + if (diff !== 0) { + const higherIsBetter = !['avg_handle_seconds'].includes(metric); + tone = diff > 0 ? (higherIsBetter ? 'positive' : 'negative') : (higherIsBetter ? 'negative' : 'positive'); + } + return { text, tone }; +} + +function agentAnalyticsStateLabel(stateValue) { + const stateLabels = { + READY: 'Готов', + BUSY: 'Занят', + BREAK: 'Перерыв', + OFFLINE: 'Оффлайн', + }; + return stateLabels[stateValue] || stateValue || '—'; +} + +function renderAgentAnalyticsCard(label, metric, currentPayload, previousPayload) { + const value = agentAnalyticsMetricValue(metric, currentPayload); + const delta = state.analytics.compareMode === 'previous' + ? agentAnalyticsDelta(metric, currentPayload, previousPayload) + : null; + return ` +
            +
            +
            ${label}
            + ${delta ? `${delta.text}` : ''} +
            +
            ${formatAgentAnalyticsMetric(metric, value)}
            +
            + `; +} + +function renderAgentAnalyticsOverview() { + const box = $('agentAnalyticsOverview'); + if (!box) { + return; + } + if (state.analytics.agentError) { + box.innerHTML = ''; + return; + } + const current = state.analytics.agentOverview || emptyAgentAnalyticsOverview(); + const previous = state.analytics.agentCompare || emptyAgentAnalyticsOverview(); + const totals = current?.totals || {}; + if (!state.analytics.loading && !Number(totals.agents_total || 0) && !Number(totals.agents_with_activity || 0)) { + box.innerHTML = ''; + return; + } + box.innerHTML = [ + renderAgentAnalyticsCard('Агенты с активностью', 'agents_with_activity', current, previous), + renderAgentAnalyticsCard('Средняя нагрузка', 'avg_interactions_per_agent', current, previous), + renderAgentAnalyticsCard('Средний AHT', 'avg_handle_seconds', current, previous), + renderAgentAnalyticsCard('FCR по агентам', 'avg_fcr_rate', current, previous), + ].join(''); +} + +function renderAgentAnalyticsStateStrip() { + const box = $('agentAnalyticsStateStrip'); + if (!box) { + return; + } + if (state.analytics.agentError) { + box.innerHTML = ''; + return; + } + const current = state.analytics.agentOverview || emptyAgentAnalyticsOverview(); + const byState = current?.state_snapshot?.by_state || {}; + const chips = [ + ['READY', Number(byState.READY || 0)], + ['BUSY', Number(byState.BUSY || 0)], + ['BREAK', Number(byState.BREAK || 0)], + ['OFFLINE', Number(byState.OFFLINE || 0)], + ]; + if (!state.analytics.loading && chips.every(([, value]) => !value)) { + box.innerHTML = ''; + return; + } + box.innerHTML = chips + .map(([code, value]) => `${agentAnalyticsStateLabel(code)}: ${formatAnalyticsNumber(value, 0)}`) + .join(''); +} + +function renderAgentAnalyticsTable() { + const container = $('agentAnalyticsTable'); + if (!container) { + return; + } + if (state.analytics.agentError) { + container.innerHTML = ''; + return; + } + const payload = state.analytics.agentOverview || emptyAgentAnalyticsOverview(); + const rows = Array.isArray(payload.items) ? payload.items : []; + if (!rows.length) { + container.innerHTML = '
            За выбранный период пока нет обращений с назначенным агентом.
            '; + return; + } + const totalAgents = Number(payload?.totals?.agents_total || rows.length); + container.innerHTML = ` +
            + Агент + Статус + Очередь + Обращения + Обработано + AHT + FCR +
            + ${rows + .map((item) => ` + + `) + .join('')} + ${totalAgents > rows.length ? `
            Показаны ${formatAnalyticsNumber(rows.length, 0)} из ${formatAnalyticsNumber(totalAgents, 0)} агентов по текущему окну.
            ` : ''} + `; +} + +function agentAnalyticsTrendLabel(metric) { + const labels = { + interactions_per_agent: 'Нагрузка на агента', + agents_with_activity: 'Агенты с активностью', + avg_handle_seconds: 'Средний AHT', + fcr_rate: 'FCR по агентам', + }; + return labels[metric] || metric || 'Тренд'; +} + +function formatAgentAnalyticsTrendValue(metric, value) { + if (value === null || value === undefined) { + return '—'; + } + if (metric === 'avg_handle_seconds') { + return `${formatAnalyticsNumber(value, 1)} с`; + } + if (metric === 'fcr_rate') { + return `${formatAnalyticsNumber(value, 1)}%`; + } + if (metric === 'interactions_per_agent') { + return formatAnalyticsNumber(value, 2); + } + return formatAnalyticsNumber(value, 0); +} + +async function loadAgentAnalyticsTrend(rangeMeta, requestId) { + const metric = state.analytics.agentTrendMetric || 'interactions_per_agent'; + const interval = analyticsTimeseriesIntervalForRange(rangeMeta); + try { + const payload = await fetchAgentAnalyticsTimeseries(rangeMeta.current, metric, interval); + if (requestId !== state.analytics.requestId) { + return emptyAgentAnalyticsTimeseries(metric, interval); + } + state.analytics.agentTrendInterval = payload?.interval || interval; + return payload || emptyAgentAnalyticsTimeseries(metric, interval); + } catch (err) { + state.analytics.agentTrendError = err.message; + state.analytics.agentTrendInterval = interval; + return emptyAgentAnalyticsTimeseries(metric, interval); + } +} + +function renderAgentAnalyticsTrendChart() { + const container = $('agentAnalyticsTrendChart'); + if (!container) { + return; + } + if (state.analytics.agentError) { + container.innerHTML = ''; + return; + } + if (state.analytics.agentTrendError && !state.analytics.agentTrend?.points?.length) { + container.innerHTML = `
            Не удалось обновить тренд по агентам: ${escapeHtml(state.analytics.agentTrendError)}
            `; + return; + } + const metric = state.analytics.agentTrendMetric || 'interactions_per_agent'; + const trend = state.analytics.agentTrend || emptyAgentAnalyticsTimeseries(metric); + const items = Array.isArray(trend.points) ? trend.points : []; + const lastPoint = [...items].reverse().find((item) => item.value !== null && item.value !== undefined) || null; + renderTrendChart(container, { + title: agentAnalyticsTrendLabel(metric), + items, + valueAccessor: (item) => item.value, + labelAccessor: (item) => formatAnalyticsBucketLabel(item.ts, trend.interval || state.analytics.agentTrendInterval || 'day'), + axisFormatter: (value) => formatAgentAnalyticsTrendValue(metric, value), + valueFormatter: (value) => formatAgentAnalyticsTrendValue(metric, value), + summaryText: lastPoint + ? `${formatAgentAnalyticsTrendValue(metric, lastPoint.value)} в последней точке` + : '—', + metaBadge: '', + ariaLabel: 'График тренда по агентской аналитике', + emptyText: 'За выбранный период недостаточно точек для агентского тренда.', + }); +} + +function renderAgentAnalyticsTeamTable() { + const container = $('agentAnalyticsTeamTable'); + if (!container) { + return; + } + if (state.analytics.agentError) { + container.innerHTML = ''; + return; + } + const rows = Array.isArray(state.analytics.agentOverview?.breakdowns?.by_team) + ? state.analytics.agentOverview.breakdowns.by_team + : []; + if (!rows.length) { + container.innerHTML = '
            По командам пока нет достаточно данных.
            '; + return; + } + container.innerHTML = ` +
            + Команда + Агенты + Активны + Обращения + AHT + FCR +
            + ${rows + .map((item) => ` +
            + ${escapeHtml(item.label || item.team_key || 'Без очереди')} + ${formatAnalyticsNumber(item.agents_total || 0, 0)} + ${formatAnalyticsNumber(item.agents_with_activity || 0, 0)} + ${formatAnalyticsNumber(item.interactions_total || 0, 0)} + ${formatAgentAnalyticsMetric('avg_handle_seconds', item.avg_handle_seconds)} + ${formatAgentAnalyticsMetric('avg_fcr_rate', item.fcr_rate)} +
            + `) + .join('')} + `; +} + +function renderAgentAnalyticsShiftTable() { + const container = $('agentAnalyticsShiftTable'); + if (!container) { + return; + } + if (state.analytics.agentError) { + container.innerHTML = ''; + return; + } + const rows = Array.isArray(state.analytics.agentOverview?.breakdowns?.by_shift) + ? state.analytics.agentOverview.breakdowns.by_shift + : []; + if (!rows.length) { + container.innerHTML = '
            По сменам пока нет достаточно данных.
            '; + return; + } + container.innerHTML = ` +
            + Смена + Агенты + Обращения + Обработано + AHT + FCR +
            + ${rows + .map((item) => ` +
            + ${escapeHtml(item.label || item.shift_key)} + ${formatAnalyticsNumber(item.agents_with_activity || 0, 0)} + ${formatAnalyticsNumber(item.interactions_total || 0, 0)} + ${formatAnalyticsNumber(item.answered_total || 0, 0)} + ${formatAgentAnalyticsMetric('avg_handle_seconds', item.avg_handle_seconds)} + ${formatAgentAnalyticsMetric('avg_fcr_rate', item.fcr_rate)} +
            + `) + .join('')} + `; +} + +function renderAgentAnalyticsEmptyState() { + const box = $('agentAnalyticsEmptyState'); + if (!box) { + return; + } + if (state.analytics.agentError) { + box.hidden = false; + box.textContent = `Не удалось обновить аналитику по агентам: ${state.analytics.agentError}`; + return; + } + const totals = state.analytics.agentOverview?.totals || emptyAgentAnalyticsOverview().totals; + if (!state.analytics.loading && !Number(totals.agents_total || 0) && !Number(totals.agents_with_activity || 0)) { + box.hidden = false; + box.textContent = 'За выбранный период нет агентской активности по текущим фильтрам. Попробуйте расширить окно анализа или снять часть ограничений.'; + return; + } + box.hidden = true; + box.textContent = ''; +} + +function renderAiAnalyticsEmptyState() { + const box = $('aiAnalyticsEmptyState'); + if (!box) { + return; + } + if (!aiAnalyticsSupportedChannel()) { + box.hidden = false; + box.textContent = 'AI-аналитика доступна только для Telegram и WhatsApp. Снимите фильтр по голосу, веб-чату или электронной почте, чтобы увидеть AI-срез.'; + return; + } + if (state.analytics.aiError) { + box.hidden = false; + box.textContent = `Не удалось обновить AI-аналитику: ${state.analytics.aiError}`; + return; + } + const total = Number(state.analytics.aiOverview?.totals?.sessions_started || 0); + if (!state.analytics.loading && total === 0) { + box.hidden = false; + box.textContent = 'За выбранный период AI-сессий по Telegram и WhatsApp не найдено. Измените период или снимите часть фильтров.'; + return; + } + box.hidden = true; + box.textContent = ''; +} + +function formatAnalyticsAxisValue(metric, value) { + const meta = ANALYTICS_METRIC_META[metric] || { unit: 'count' }; + if (meta.unit === 'count') { + return formatAnalyticsNumber(value, 0); + } + return formatAnalyticsNumber(value, 1); +} + +function formatAiAnalyticsAxisValue(metric, value) { + const meta = AI_ANALYTICS_METRIC_META[metric] || { unit: 'count' }; + if (meta.unit === 'pct') { + return formatAnalyticsNumber(value, 1); + } + if (meta.unit === 'ms') { + return formatAnalyticsNumber(value, 0); + } + return formatAnalyticsNumber(value, 0); +} + +function formatAnalyticsBucketLabel(ts, interval = 'day') { + if (!ts) { + return ''; + } + const date = new Date(ts); + if (Number.isNaN(date.getTime())) { + return String(ts); + } + if (interval === 'hour') { + return date.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' }); + } + return date.toLocaleDateString('ru-RU', { day: '2-digit', month: 'short' }); +} + +function resetTrendChartHover(container) { + if (!container) { + return; + } + const tooltip = container.querySelector('[data-chart-tooltip]'); + const summary = container.querySelector('[data-chart-summary]'); + if (summary) { + summary.textContent = summary.dataset.default || ''; + summary.classList.remove('is-hovered'); + } + if (tooltip) { + tooltip.hidden = true; + tooltip.innerHTML = ''; + } + container.querySelectorAll('.analytics-point.is-active').forEach((node) => node.classList.remove('is-active')); +} + +function showTrendChartHover(container, target) { + if (!container || !target) { + return; + } + const tooltip = container.querySelector('[data-chart-tooltip]'); + const summary = container.querySelector('[data-chart-summary]'); + const plot = container.querySelector('.analytics-chart-plot'); + if (!tooltip || !summary || !plot) { + return; + } + const label = target.dataset.label || ''; + const value = target.dataset.value || ''; + const index = target.dataset.index || ''; + const x = Number(target.dataset.x || 0); + const width = Number(target.dataset.width || 720); + const plotWidth = plot.clientWidth || width || 1; + const leftPx = Math.max(54, Math.min(plotWidth - 54, (x / width) * plotWidth)); + + summary.textContent = label && value ? `${label} · ${value}` : value || label || summary.dataset.default || ''; + summary.classList.add('is-hovered'); + tooltip.hidden = false; + tooltip.style.left = `${leftPx}px`; + tooltip.innerHTML = ` + ${escapeHtml(label)} + ${escapeHtml(value)} + `; + + container.querySelectorAll('.analytics-point.is-active').forEach((node) => node.classList.remove('is-active')); + const activePoint = container.querySelector(`.analytics-point[data-chart-point="${escapeHtml(index)}"]`); + if (activePoint) { + activePoint.classList.add('is-active'); + } +} + +function bindTrendChartHover(container) { + if (!container) { + return; + } + const plot = container.querySelector('.analytics-chart-plot'); + if (!plot) { + return; + } + plot.querySelectorAll('.analytics-point-hit').forEach((node) => { + node.addEventListener('mouseenter', () => showTrendChartHover(container, node)); + node.addEventListener('focus', () => showTrendChartHover(container, node)); + }); + plot.addEventListener('mouseleave', () => resetTrendChartHover(container)); + plot.addEventListener('focusout', (event) => { + if (!plot.contains(event.relatedTarget)) { + resetTrendChartHover(container); + } + }); +} + +function renderTrendChart(container, options) { + if (!container) { + return; + } + const items = Array.isArray(options.items) ? options.items : []; + const points = items.map((item, index) => { + const raw = options.valueAccessor(item, index); + const value = raw === null || raw === undefined || Number.isNaN(Number(raw)) ? null : Number(raw); + return { + label: options.labelAccessor(item, index), + value, + }; + }); + const numericValues = points + .filter((point) => point.value !== null) + .map((point) => Number(point.value)); + + if (!points.length || !numericValues.length) { + container.innerHTML = `
            ${options.emptyText}
            `; + return; + } + + const maxValue = Math.max(...numericValues, 1); + const width = 720; + const height = 220; + const paddingLeft = 38; + const paddingRight = 16; + const paddingTop = 16; + const paddingBottom = 36; + const chartWidth = width - paddingLeft - paddingRight; + const chartHeight = height - paddingTop - paddingBottom; + const step = points.length > 1 ? chartWidth / (points.length - 1) : chartWidth; + + const pointFor = (value, index) => { + const x = paddingLeft + index * step; + const y = paddingTop + chartHeight - (value / maxValue) * chartHeight; + return { x, y }; + }; + + const grid = [0, 0.25, 0.5, 0.75, 1] + .map((ratio) => { + const y = paddingTop + chartHeight - ratio * chartHeight; + const labelValue = maxValue * ratio; + return ` + + ${escapeHtml(options.axisFormatter(labelValue))} + `; + }) + .join(''); + + const lineSegments = []; + let currentSegment = []; + let markers = ''; + let hitTargets = ''; + points.forEach((point, index) => { + if (point.value === null) { + if (currentSegment.length > 1) { + lineSegments.push(``); + } + currentSegment = []; + return; + } + const marker = pointFor(point.value, index); + currentSegment.push(`${marker.x},${marker.y}`); + const formattedValue = options.valueFormatter(point.value); + markers += ``; + hitTargets += ``; + }); + if (currentSegment.length > 1) { + lineSegments.push(``); + } + + const labelStep = points.length > 12 ? Math.ceil(points.length / 6) : 1; + const labels = points + .map((point, index) => { + if (index % labelStep !== 0 && index !== points.length - 1) { + return ''; + } + const x = paddingLeft + index * step; + return `${escapeHtml(point.label)}`; + }) + .join(''); + + const summaryText = options.summaryText || ''; + const metaBadge = options.metaBadge || ''; + container.innerHTML = ` +
            + ${escapeHtml(options.title)} +
            + ${metaBadge} + ${escapeHtml(summaryText)} +
            +
            +
            + + + ${grid} + ${lineSegments.join('')} + ${markers} + ${hitTargets} + ${labels} + +
            + `; + resetTrendChartHover(container); + bindTrendChartHover(container); +} + +function renderAnalyticsChannelTable() { + const container = $('analyticsChannelTable'); + const rows = state.analytics.channelRows || []; + if (!rows.length) { + container.innerHTML = '
            Нет данных по каналам за выбранный период.
            '; + return; + } + + container.innerHTML = ` +
            + Канал + Всего + Обработано + Потери + Доля ответа +
            + ${rows + .map((item) => { + const answerRate = item.total ? (item.answered / item.total) * 100 : 0; + return ` + + `; + }) + .join('')} + `; +} + +function renderAnalyticsQueueTable() { + const container = $('analyticsQueueTable'); + const rows = state.analytics.queueRows || []; + if (!rows.length) { + const reason = state.analytics.queueAccessError + ? 'Каталог очередей пока недоступен для этой роли. Можно продолжить анализ без разреза по очередям.' + : 'За выбранное окно пока нет данных по очередям. Попробуйте расширить период или снять часть фильтров.'; + container.innerHTML = `
            ${escapeHtml(reason)}
            `; + return; + } + + container.innerHTML = ` +
            + Очередь + Всего + Обработано + SL + Потери +
            + ${rows + .map((item) => ` + + `) + .join('')} + `; +} + +function renderAnalyticsCoverage() { + const container = $('analyticsCoverage'); + if (!container) { + return; + } + const implemented = Array.isArray(state.analytics.coverage?.implemented_metrics) + ? state.analytics.coverage.implemented_metrics + : []; + const dimensions = Array.isArray(state.analytics.coverage?.dimensions) + ? state.analytics.coverage.dimensions + : []; + const filters = Array.isArray(state.analytics.coverage?.supported_filters) + ? state.analytics.coverage.supported_filters + : []; + + container.innerHTML = ` +
            +
            +

            Уже измеряем

            +
            + ${implemented.length + ? implemented + .map((item) => `${escapeHtml(item.code)} · ${escapeHtml(item.label)}`) + .join('') + : 'Каталог метрик пока не загружен.'} +
            +
            +
            +

            Разрезы

            +
            + ${dimensions.length + ? dimensions.map((item) => `${escapeHtml(item)}`).join('') + : 'Разрезы появятся после загрузки coverage.'} +
            +
            +
            +

            Фильтры

            +
            + ${filters.length + ? filters.map((item) => `${escapeHtml(item)}`).join('') + : 'Фильтры появятся после загрузки coverage.'} +
            +
            +
            +
            +
            + Аналитика по агентам +

            Следующий шаг: персональные метрики по агентам, сменам и командам с детализацией по эффективности.

            +
            +
            + AI-аналитика +

            Добавим долю закрытий AI, передачу оператору, задержку ответа и устойчивость AI-сценариев по каналам.

            +
            +
            + Качество диалогов +

            Здесь появятся оценки качества речи и диалогов на основе скоринга транскриптов и QA-правил.

            +
            + ${ + state.analytics.queueAccessError + ? `
            + Доступ к очередям +

            ${escapeHtml(state.analytics.queueAccessError)}

            +
            ` + : '' + } +
            + `; +} + +function renderAnalyticsCoverage() { + const container = $('analyticsCoverage'); + if (!container) { + return; + } + const implemented = Array.isArray(state.analytics.coverage?.implemented_metrics) + ? state.analytics.coverage.implemented_metrics + : []; + const dimensions = Array.isArray(state.analytics.coverage?.dimensions) + ? state.analytics.coverage.dimensions + : []; + const filters = Array.isArray(state.analytics.coverage?.supported_filters) + ? state.analytics.coverage.supported_filters + : []; + + container.innerHTML = ` +
            +
            +

            Уже измеряем

            +
            + ${implemented.length + ? implemented + .map((item) => `${escapeHtml(item.code)} · ${escapeHtml(item.label)}`) + .join('') + : 'Каталог метрик пока не загружен.'} +
            +
            +
            +

            Разрезы

            +
            + ${dimensions.length + ? dimensions.map((item) => `${escapeHtml(item)}`).join('') + : 'Разрезы появятся после загрузки coverage.'} +
            +
            +
            +

            Фильтры

            +
            + ${filters.length + ? filters.map((item) => `${escapeHtml(item)}`).join('') + : 'Фильтры появятся после загрузки coverage.'} +
            +
            +
            +
            +
            + Аналитика по агентам +

            Следующий шаг: персональные метрики по агентам, сменам и командам с детализацией по эффективности.

            +
            +
            + Прогнозы и алерты +

            Дальше сюда добавим прогноз нагрузки, сигналы по SLA и аномалии по очередям и каналам.

            +
            +
            + Качество диалогов +

            Здесь появятся оценки качества речи и диалогов на основе скоринга транскриптов и QA-правил.

            +
            + ${ + state.analytics.queueAccessError + ? `
            + Доступ к очередям +

            ${escapeHtml(state.analytics.queueAccessError)}

            +
            ` + : '' + } +
            + `; +} + +function csvEscape(value) { + const text = String(value ?? ''); + if (/[",\n]/.test(text)) { + return `"${text.replaceAll('"', '""')}"`; + } + return text; +} + +function exportRowsToCsv(rows) { + const columns = ['section', 'key', 'label', 'current', 'previous', 'delta', 'value', 'note']; + return [ + columns.join(','), + ...rows.map((row) => columns.map((column) => csvEscape(row[column] ?? '')).join(',')), + ].join('\n'); +} + +function downloadCsvFile(csv, filename) { + const blob = new Blob([`\uFEFF${csv}`], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = filename; + document.body.append(link); + link.click(); + link.remove(); + URL.revokeObjectURL(url); +} + +function buildAnalyticsExportRows() { + const rows = []; + const rangeMeta = state.analytics.lastRangeMeta || analyticsRangeFromControls(); + const current = state.analytics.overview || emptyKpiEnvelope(); + const previous = state.analytics.compare || emptyKpiEnvelope(); + const activeView = state.analytics.savedViews.find((item) => item.id === state.analytics.activeViewId) || null; + + rows.push({ + section: 'filters', + key: 'period', + label: 'Период', + value: rangeMeta.custom + ? `${formatTime(rangeMeta.current.from.toISOString())} - ${formatTime(rangeMeta.current.to.toISOString())}` + : analyticsPresetLabel(rangeMeta.preset), + }); + rows.push({ + section: 'filters', + key: 'channel', + label: 'Канал', + value: state.analytics.channel === 'all' ? 'Все каналы' : analyticsChannelLabel(state.analytics.channel), + }); + rows.push({ + section: 'filters', + key: 'queue', + label: 'Очередь', + value: state.analytics.queueId === 'all' + ? 'Все очереди' + : state.analytics.queueOptions.find((item) => item.queue_id === state.analytics.queueId)?.name || state.analytics.queueId, + }); + rows.push({ + section: 'filters', + key: 'compare_mode', + label: 'Сравнение', + value: analyticsCompareModeLabel(state.analytics.compareMode), + }); + if (activeView) { + rows.push({ + section: 'filters', + key: 'saved_view', + label: 'Сохранённый вид', + value: activeView.name, + }); + } + + [ + ['total', 'Обращения'], + ['answered', 'Обработано'], + ['SL', 'SL'], + ['ASA', 'Среднее ожидание'], + ['AHT', 'Среднее время'], + ['Abandon', 'Потери'], + ['FCR', 'FCR'], + ['DigitalShare', 'Цифровая доля'], + ].forEach(([metric, label]) => { + const delta = state.analytics.compareMode === 'previous' + ? analyticsDelta(metric, current, previous).text + : ''; + rows.push({ + section: 'overview', + key: metric, + label, + current: formatAnalyticsMetric(metric, metricValueFromPayload(metric, current)), + previous: state.analytics.compareMode === 'previous' + ? formatAnalyticsMetric(metric, metricValueFromPayload(metric, previous)) + : '', + delta, + note: ANALYTICS_METRIC_META[metric]?.note || '', + }); + }); + + state.analytics.channelRows.forEach((item) => { + const answerRate = item.total ? (item.answered / item.total) * 100 : 0; + rows.push({ + section: 'channels', + key: item.channel, + label: analyticsChannelLabel(item.channel), + current: formatAnalyticsNumber(item.total, 0), + previous: formatAnalyticsNumber(item.answered, 0), + delta: formatAnalyticsNumber(item.abandoned, 0), + value: `${formatAnalyticsNumber(answerRate, 2)}%`, + note: 'current=всего, previous=обработано, delta=потери, value=доля ответа', + }); + }); + + state.analytics.queueRows.forEach((item) => { + rows.push({ + section: 'queues', + key: item.queue_id, + label: item.name || item.queue_id, + current: formatAnalyticsNumber(item.total, 0), + previous: formatAnalyticsNumber(item.answered, 0), + delta: `${formatAnalyticsNumber(item.SL, 2)}%`, + value: `${formatAnalyticsNumber(item.Abandon, 2)}%`, + note: 'current=всего, previous=обработано, delta=SL, value=потери', + }); + }); + + state.analytics.trend.forEach((item) => { + rows.push({ + section: 'trend', + key: item.label, + label: analyticsMetricLabel(state.analytics.trendMetric), + value: formatAnalyticsMetric(state.analytics.trendMetric, metricValueFromPayload(state.analytics.trendMetric, item.payload)), + note: item.label, + }); + }); + + (state.analytics.coverage?.implemented_metrics || []).forEach((item) => { + rows.push({ + section: 'coverage', + key: item.code, + label: item.label, + value: 'implemented', + }); + }); + + return rows; +} + +function exportAnalyticsCsv() { + const rows = buildAnalyticsExportRows(); + const dateStamp = new Date().toISOString().slice(0, 10); + downloadCsvFile(exportRowsToCsv(rows), `konturcc-analytics-${dateStamp}.csv`); + state.analytics.statusMessage = `CSV выгружен в ${new Date().toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })}.`; + renderSavedAnalyticsViews(); +} + +function exportDrilldownRowsToCsv(rows) { + const columns = [ + 'interaction_id', + 'subject', + 'channel', + 'status', + 'queue_id', + 'assigned_to', + 'created_at', + 'updated_at', + 'answered', + 'abandoned', + 'wait_seconds', + 'handle_seconds', + 'within_sla', + 'resolved_first_contact', + ]; + return [ + columns.join(','), + ...rows.map((row) => columns.map((column) => csvEscape(row[column] ?? '')).join(',')), + ].join('\n'); +} + +const exportAnalyticsDrilldownCsvBase = exportAnalyticsDrilldownCsv; +const renderAnalyticsDrilldownBase = renderAnalyticsDrilldown; +const loadAnalyticsDrilldownDetailsBase = loadAnalyticsDrilldownDetails; +const loadAnalyticsDrilldownPageBase = loadAnalyticsDrilldownPage; +const openAnalyticsDrilldownBase = openAnalyticsDrilldown; +const handleAnalyticsDrilldownListClickBase = handleAnalyticsDrilldownListClick; + +exportAnalyticsDrilldownCsv = async function exportAnalyticsDrilldownCsv() { + if (!state.drilldown.open || !state.drilldown.filters || state.drilldown.exporting) { + return; + } + if (!state.drilldown.total) { + state.drilldown.notice = 'Для текущего среза нет строк для экспорта.'; + renderAnalyticsDrilldown(); + return; + } + + state.drilldown.exporting = true; + state.drilldown.notice = ''; + state.drilldown.error = ''; + renderAnalyticsDrilldown(); + + try { + const exportLimit = Math.min(state.drilldown.total, DRILLDOWN_EXPORT_SOFT_CAP); + const rows = []; + for (let currentOffset = 0; currentOffset < exportLimit; currentOffset += DRILLDOWN_EXPORT_BATCH_SIZE) { + const batchLimit = Math.min(DRILLDOWN_EXPORT_BATCH_SIZE, exportLimit - currentOffset); + const payload = state.drilldown.mode === 'metric' + ? await fetchAnalyticsMetricDrilldown(state.drilldown.filters, batchLimit, currentOffset) + : await api( + 'interaction', + `interactions/drilldown?${analyticsDrilldownQuery(state.drilldown.filters, batchLimit, currentOffset)}`, + ); + const batchItems = Array.isArray(payload?.items) ? payload.items : []; + rows.push(...batchItems); + if (!batchItems.length || batchItems.length < batchLimit) { + break; + } + } + const dateStamp = new Date().toISOString().slice(0, 10); + downloadCsvFile( + exportDrilldownRowsToCsv(rows.slice(0, DRILLDOWN_EXPORT_SOFT_CAP)), + `konturcc-drilldown-${analyticsDrilldownFilenameSource()}-${dateStamp}.csv`, + ); + state.drilldown.notice = state.drilldown.total > DRILLDOWN_EXPORT_SOFT_CAP + ? `Экспортирован только первый ${DRILLDOWN_EXPORT_SOFT_CAP} строк из ${formatAnalyticsNumber(state.drilldown.total, 0)}.` + : `Экспорт готов: ${formatAnalyticsNumber(rows.length, 0)} строк.`; + } catch (err) { + state.drilldown.error = err.message; + } finally { + state.drilldown.exporting = false; + renderAnalyticsDrilldown(); + } +} + +function buildTrendBuckets(range, preset, custom) { + const buckets = []; + const hourMs = 60 * 60 * 1000; + const dayMs = 24 * hourMs; + + if (custom) { + const fromDay = startOfDay(range.from); + const endDay = startOfDay(addDays(range.to, 1)); + const totalDays = Math.max(1, Math.ceil((endDay.getTime() - fromDay.getTime()) / dayMs)); + const bucketDays = Math.max(1, Math.ceil(totalDays / 31)); + for (let cursor = new Date(fromDay); cursor < endDay; cursor = new Date(cursor.getTime() + bucketDays * dayMs)) { + const bucketFrom = new Date(cursor); + const bucketTo = new Date(Math.min(bucketFrom.getTime() + bucketDays * dayMs, endDay.getTime())); + const lastDay = new Date(bucketTo.getTime() - dayMs); + const label = bucketDays === 1 + ? bucketFrom.toLocaleDateString('ru-RU', { day: '2-digit', month: 'short' }) + : `${bucketFrom.toLocaleDateString('ru-RU', { day: '2-digit', month: 'short' })} - ${lastDay.toLocaleDateString('ru-RU', { day: '2-digit', month: 'short' })}`; + buckets.push({ label, range: { from: bucketFrom, to: bucketTo } }); + } + return buckets.slice(0, 31); + } + + if (preset === 'today') { + const dayStart = startOfDay(range.from); + for (let hour = 0; hour < 24; hour += 1) { + const bucketFrom = new Date(dayStart.getTime() + hour * hourMs); + const bucketTo = new Date(bucketFrom.getTime() + hourMs); + buckets.push({ + label: `${pad2(hour)}:00`, + range: { from: bucketFrom, to: bucketTo }, + }); + } + return buckets; + } + + const days = preset === '30d' ? 30 : 7; + const firstDay = startOfDay(range.from); + for (let day = 0; day < days; day += 1) { + const bucketFrom = new Date(firstDay.getTime() + day * dayMs); + const bucketTo = new Date(bucketFrom.getTime() + dayMs); + buckets.push({ + label: bucketFrom.toLocaleDateString('ru-RU', { day: '2-digit', month: 'short' }), + range: { from: bucketFrom, to: bucketTo }, + }); + } + return buckets; +} + +async function loadAnalyticsTrend(rangeMeta, requestId) { + const buckets = buildTrendBuckets(rangeMeta.current, rangeMeta.preset, rangeMeta.custom); + const items = await Promise.all( + buckets.map(async (bucket) => { + try { + const payload = await fetchAnalyticsKpi(bucket.range); + return { ...bucket, payload }; + } catch { + return { ...bucket, payload: emptyKpiEnvelope(bucket.range.from.toISOString(), bucket.range.to.toISOString()) }; + } + }), + ); + if (requestId !== state.analytics.requestId) { + return []; + } + return items; +} + +function aiAnalyticsIntervalForRange(rangeMeta) { + const durationMs = Math.max(0, rangeMeta.current.to.getTime() - rangeMeta.current.from.getTime()); + return durationMs <= 36 * 60 * 60 * 1000 ? 'hour' : 'day'; +} + +async function loadAiAnalyticsTrend(rangeMeta, requestId) { + const metric = state.analytics.aiTrendMetric || 'containment_rate'; + const interval = aiAnalyticsIntervalForRange(rangeMeta); + if (!aiAnalyticsSupportedChannel()) { + return emptyAiAnalyticsTimeseries( + metric, + interval, + rangeMeta.current.from.toISOString(), + rangeMeta.current.to.toISOString(), + state.analytics.channel, + state.analytics.queueId === 'all' ? null : state.analytics.queueId, + ); + } + + try { + const payload = await fetchAiAnalyticsTimeseries(rangeMeta.current, metric, interval); + if (requestId !== state.analytics.requestId) { + return emptyAiAnalyticsTimeseries( + metric, + interval, + rangeMeta.current.from.toISOString(), + rangeMeta.current.to.toISOString(), + state.analytics.channel, + state.analytics.queueId === 'all' ? null : state.analytics.queueId, + ); + } + return payload || emptyAiAnalyticsTimeseries( + metric, + interval, + rangeMeta.current.from.toISOString(), + rangeMeta.current.to.toISOString(), + state.analytics.channel, + state.analytics.queueId === 'all' ? null : state.analytics.queueId, + ); + } catch (err) { + if (requestId === state.analytics.requestId) { + state.analytics.aiError = err.message; + } + return emptyAiAnalyticsTimeseries( + metric, + interval, + rangeMeta.current.from.toISOString(), + rangeMeta.current.to.toISOString(), + state.analytics.channel, + state.analytics.queueId === 'all' ? null : state.analytics.queueId, + ); + } +} + +async function loadAnalyticsQueueRows(rangeMeta, queues, requestId) { + if (!queues.length) { + return []; + } + const rows = await Promise.all( + queues.map(async (queue) => { + try { + const payload = await fetchAnalyticsKpi(rangeMeta.current, { + queueId: queue.queue_id, + channel: state.analytics.channel, + }); + return { + queue_id: queue.queue_id, + name: queue.name || queue.queue_id, + total: Number(payload?.volume?.total || 0), + answered: Number(payload?.volume?.answered || 0), + SL: Number(payload?.kpi?.SL || 0), + Abandon: Number(payload?.kpi?.Abandon || 0), + }; + } catch { + return { + queue_id: queue.queue_id, + name: queue.name || queue.queue_id, + total: 0, + answered: 0, + SL: 0, + Abandon: 0, + }; + } + }), + ); + if (requestId !== state.analytics.requestId) { + return []; + } + return rows.sort((a, b) => b.total - a.total || a.name.localeCompare(b.name)); +} + +function renderAnalyticsTrendChart() { + const metric = state.analytics.trendMetric || 'volume'; + const items = state.analytics.trend || []; + const container = $('analyticsTrendChart'); + if (!items.length) { + container.innerHTML = '
            За выбранный период недостаточно точек для построения тренда. Попробуйте расширить окно анализа.
            '; + return; + } + + const values = items.map((item) => metricValueFromPayload(metric, item.payload)); + const maxValue = Math.max(...values, 1); + const width = 720; + const height = 220; + const paddingLeft = 38; + const paddingRight = 16; + const paddingTop = 16; + const paddingBottom = 36; + const chartWidth = width - paddingLeft - paddingRight; + const chartHeight = height - paddingTop - paddingBottom; + const step = items.length > 1 ? chartWidth / (items.length - 1) : chartWidth; + + const pointFor = (value, index) => { + const x = paddingLeft + index * step; + const y = paddingTop + chartHeight - (value / maxValue) * chartHeight; + return { x, y }; + }; + + const polyline = values + .map((value, index) => { + const point = pointFor(value, index); + return `${point.x},${point.y}`; + }) + .join(' '); + + const grid = [0, 0.25, 0.5, 0.75, 1] + .map((ratio) => { + const y = paddingTop + chartHeight - ratio * chartHeight; + const labelValue = maxValue * ratio; + return ` + + ${escapeHtml(formatAnalyticsNumber(labelValue, metric === 'volume' ? 0 : 1))} + `; + }) + .join(''); + + const markers = values + .map((value, index) => { + const point = pointFor(value, index); + return ``; + }) + .join(''); + + const labelStep = items.length > 12 ? Math.ceil(items.length / 6) : 1; + const labels = items + .map((item, index) => { + if (index % labelStep !== 0 && index !== items.length - 1) { + return ''; + } + const point = pointFor(values[index], index); + return `${escapeHtml(item.label)}`; + }) + .join(''); + + container.innerHTML = ` +
            + ${escapeHtml(analyticsMetricLabel(metric))} + ${escapeHtml(formatAnalyticsMetric(metric, values[values.length - 1] || 0))} в последней точке +
            + + ${grid} + + ${markers} + ${labels} + + `; +} + +function renderAnalyticsTrendChart() { + const metric = state.analytics.trendMetric || 'volume'; + const items = state.analytics.trend || []; + const container = $('analyticsTrendChart'); + if (!container) { + return; + } + const lastValue = items.length + ? metricValueFromPayload(metric, items[items.length - 1].payload) + : null; + renderTrendChart(container, { + title: analyticsMetricLabel(metric), + items, + valueAccessor: (item) => metricValueFromPayload(metric, item.payload), + labelAccessor: (item) => item.label, + axisFormatter: (value) => formatAnalyticsAxisValue(metric, value), + valueFormatter: (value) => formatAnalyticsMetric(metric, value), + summaryText: lastValue === null || lastValue === undefined + ? '—' + : `${formatAnalyticsMetric(metric, lastValue)} в последней точке`, + ariaLabel: 'Analytics trend chart', + emptyText: 'За выбранный период недостаточно точек для построения тренда. Попробуйте расширить окно анализа.', + }); +} + +function renderAiAnalyticsTrendChart() { + const container = $('aiAnalyticsTrendChart'); + if (!container) { + return; + } + if (!aiAnalyticsSupportedChannel()) { + container.innerHTML = ''; + return; + } + if (state.analytics.aiError && !state.analytics.aiTrend?.points?.length) { + container.innerHTML = `
            Не удалось обновить AI-тренд: ${escapeHtml(state.analytics.aiError)}
            `; + return; + } + + const metric = state.analytics.aiTrendMetric || 'containment_rate'; + const trend = state.analytics.aiTrend || emptyAiAnalyticsTimeseries(metric); + const items = Array.isArray(trend.points) ? trend.points : []; + const current = state.analytics.aiOverview || emptyAiAnalyticsOverview(); + const previous = state.analytics.aiCompare || emptyAiAnalyticsOverview(); + const delta = state.analytics.compareMode === 'previous' + ? aiAnalyticsDelta(metric, current, previous) + : null; + const lastPoint = [...items].reverse().find((item) => item.value !== null && item.value !== undefined) || null; + const emptyText = metric === 'ai_latency_avg_ms' + ? 'Для выбранного окна пока нет данных по задержке ответов модели. Попробуйте изменить период или канал.' + : 'За выбранный период недостаточно AI-точек для построения тренда.'; + renderTrendChart(container, { + title: aiAnalyticsMetricLabel(metric), + items, + valueAccessor: (item) => item.value, + labelAccessor: (item) => formatAnalyticsBucketLabel(item.ts, trend.interval || 'day'), + axisFormatter: (value) => formatAiAnalyticsAxisValue(metric, value), + valueFormatter: (value) => formatAiAnalyticsMetric(metric, value), + summaryText: lastPoint + ? `${formatAiAnalyticsMetric(metric, lastPoint.value)} в последней точке` + : '—', + metaBadge: delta ? `${escapeHtml(delta.text)}` : '', + ariaLabel: 'График тренда AI-аналитики', + emptyText, + }); +} + +function analyticsDrilldownFiltersForCurrentSource() { + const filters = analyticsDrilldownBaseFilters(); + if (state.drilldown.mode === 'metric' && state.drilldown.metric) { + filters.metric = state.drilldown.metric; + filters.q = ''; + filters.status = ''; + } + if (state.drilldown.sourceType === 'channel' && state.drilldown.sourceValue) { + filters.channel = state.drilldown.sourceValue; + } + if (state.drilldown.sourceType === 'queue' && state.drilldown.sourceValue) { + filters.queue_id = state.drilldown.sourceValue; + } + if (state.drilldown.sourceType === 'agent' && state.drilldown.sourceValue) { + filters.agent_id = state.drilldown.sourceValue; + } + return filters; +} + +function applyAnalyticsDrilldownLocalFilters(nextFilters, options = {}) { + if (!state.drilldown.open || state.drilldown.mode !== 'interaction') { + return; + } + state.drilldown.filters = normalizeAnalyticsDrilldownFilters(nextFilters); + state.drilldown.offset = 0; + state.drilldown.notice = ''; + void loadAnalyticsDrilldownPage({ preserveSelection: options.preserveSelection !== false }); +} + +function resetAnalyticsDrilldownLocalFilters() { + clearAnalyticsDrilldownSearchDebounce(); + if (!state.drilldown.open || state.drilldown.mode !== 'interaction') { + return; + } + state.drilldown.filters = analyticsDrilldownFiltersForCurrentSource(); + state.drilldown.offset = 0; + state.drilldown.notice = ''; + renderAnalyticsDrilldown(); + void loadAnalyticsDrilldownPage({ preserveSelection: true }); +} + +function handleAnalyticsDrilldownSearchInput(event) { + if (!state.drilldown.open || state.drilldown.mode !== 'interaction') { + return; + } + clearAnalyticsDrilldownSearchDebounce(); + const query = event.target?.value || ''; + analyticsDrilldownSearchDebounce = window.setTimeout(() => { + if (!state.drilldown.open || !state.drilldown.filters) { + return; + } + const normalizedQuery = query.trim(); + if ((state.drilldown.filters.q || '') === normalizedQuery) { + return; + } + applyAnalyticsDrilldownLocalFilters({ + ...state.drilldown.filters, + q: normalizedQuery, + }); + }, 300); +} + +function handleAnalyticsDrilldownSearchKeydown(event) { + if (event.key !== 'Enter' || !state.drilldown.open || !state.drilldown.filters || state.drilldown.mode !== 'interaction') { + return; + } + event.preventDefault(); + clearAnalyticsDrilldownSearchDebounce(); + const normalizedQuery = (event.target?.value || '').trim(); + if ((state.drilldown.filters.q || '') === normalizedQuery) { + return; + } + applyAnalyticsDrilldownLocalFilters({ + ...state.drilldown.filters, + q: normalizedQuery, + }); +} + +function handleAnalyticsDrilldownStatusChange(event) { + if (!state.drilldown.open || !state.drilldown.filters || state.drilldown.mode !== 'interaction') { + return; + } + const nextStatus = event.target?.value === 'all' ? '' : String(event.target?.value || ''); + if ((state.drilldown.filters.status || '') === nextStatus) { + return; + } + clearAnalyticsDrilldownSearchDebounce(); + applyAnalyticsDrilldownLocalFilters({ + ...state.drilldown.filters, + status: nextStatus, + }); +} + +function handleAnalyticsDrilldownSortChange(event) { + if (!state.drilldown.open || !state.drilldown.filters || state.drilldown.mode !== 'interaction') { + return; + } + const nextSort = parseAnalyticsDrilldownSort(event.target?.value || 'created_at:desc'); + if ( + (state.drilldown.filters.sort_by || 'created_at') === nextSort.sort_by + && (state.drilldown.filters.sort_dir || 'desc') === nextSort.sort_dir + ) { + return; + } + clearAnalyticsDrilldownSearchDebounce(); + applyAnalyticsDrilldownLocalFilters({ + ...state.drilldown.filters, + ...nextSort, + }); +} + +function openAnalyticsDrilldown(sourceType, sourceValue = '', options = {}) { + const mode = options.mode || (sourceType === 'metric' ? 'metric' : 'interaction'); + if (mode !== 'ai') { + return openAnalyticsDrilldownBase(sourceType, sourceValue, options); + } + const filters = analyticsDrilldownBaseFilters(); + filters.slice = options.slice || sourceValue || 'all'; + filters.reason_key = options.reasonKey || ''; + filters.status = ''; + filters.q = ''; + filters.sort_by = 'created_at'; + filters.sort_dir = 'desc'; + if (sourceType === 'ai-channel' && sourceValue) { + filters.channel = sourceValue; + } + if (sourceType === 'ai-reason' && sourceValue) { + filters.slice = 'handoff'; + filters.reason_key = sourceValue; + } + rememberAnalyticsDrilldownReturnFocus(); + state.drilldown = { + ...emptyDrilldownState(), + open: true, + mode: 'ai', + sourceType, + sourceLabel: options.sourceLabel || analyticsDrilldownSourceLabel(sourceType, sourceValue), + sourceValue, + filters: normalizeAnalyticsDrilldownFilters(filters), + coverage: options.coverage || null, + metricNote: options.metricNote || aiAnalyticsDrilldownNote(filters), + }; + renderAnalyticsDrilldown(); + window.requestAnimationFrame(() => { + focusAnalyticsDrilldownPrimaryControl(true); + }); + void loadAnalyticsDrilldownPage(); +} + +async function loadAnalyticsDrilldownPage(options = {}) { + if (state.drilldown.mode !== 'ai') { + return loadAnalyticsDrilldownPageBase(options); + } + if (!state.drilldown.open || !state.drilldown.filters) { + return; + } + const preserveSelection = Boolean(options.preserveSelection); + state.drilldown.loading = true; + state.drilldown.error = ''; + renderAnalyticsDrilldown(); + try { + const data = await fetchAiAnalyticsDrilldown(state.drilldown.filters, state.drilldown.limit, state.drilldown.offset); + if (!state.drilldown.open) { + return; + } + state.drilldown.items = Array.isArray(data?.items) ? data.items : []; + state.drilldown.total = Number(data?.total || 0); + state.drilldown.limit = Number(data?.limit || state.drilldown.limit || DRILLDOWN_PAGE_SIZE); + state.drilldown.offset = Number(data?.offset || 0); + state.drilldown.filters = normalizeAnalyticsDrilldownFilters(data?.filters || state.drilldown.filters); + state.drilldown.coverage = data?.coverage || state.drilldown.coverage; + state.drilldown.metricNote = aiAnalyticsDrilldownNote(state.drilldown.filters); + + const selectedId = state.drilldown.selectedInteractionId || ''; + const selectedStillVisible = preserveSelection + && selectedId + && state.drilldown.items.some((item) => analyticsDrilldownCurrentItemId(item) === selectedId); + if (selectedStillVisible) { + state.drilldown.selectedInteraction = state.drilldown.items.find( + (item) => analyticsDrilldownCurrentItemId(item) === selectedId, + ) || state.drilldown.selectedInteraction; + void loadAnalyticsDrilldownDetails(selectedId); + } else if (state.drilldown.items.length) { + state.drilldown.selectedInteractionId = analyticsDrilldownCurrentItemId(state.drilldown.items[0]); + state.drilldown.selectedInteraction = state.drilldown.items[0]; + state.drilldown.selectedLinkedInteraction = null; + state.drilldown.selectedTimeline = []; + void loadAnalyticsDrilldownDetails(state.drilldown.selectedInteractionId); + } else { + state.drilldown.selectedInteractionId = ''; + state.drilldown.selectedInteraction = null; + state.drilldown.selectedLinkedInteraction = null; + state.drilldown.selectedTimeline = []; + state.drilldown.detailLoading = false; + } + } catch (err) { + if (!state.drilldown.open) { + return; + } + state.drilldown.items = []; + state.drilldown.total = 0; + state.drilldown.selectedInteractionId = ''; + state.drilldown.selectedInteraction = null; + state.drilldown.selectedLinkedInteraction = null; + state.drilldown.selectedTimeline = []; + state.drilldown.detailLoading = false; + state.drilldown.error = err.message; + } finally { + if (state.drilldown.open) { + state.drilldown.loading = false; + renderAnalyticsDrilldown(); + } + } +} + +async function loadAnalyticsDrilldownDetails(interactionId) { + if (state.drilldown.mode !== 'ai') { + return loadAnalyticsDrilldownDetailsBase(interactionId); + } + if (!state.drilldown.open || !interactionId) { + return; + } + state.drilldown.selectedInteractionId = interactionId; + state.drilldown.selectedInteraction = state.drilldown.items.find((item) => item.session_id === interactionId) || null; + state.drilldown.selectedLinkedInteraction = null; + state.drilldown.selectedTimeline = []; + state.drilldown.error = ''; + state.drilldown.detailLoading = true; + renderAnalyticsDrilldown(); + + try { + const detail = await fetchAiAnalyticsSessionDetail(interactionId); + if (!state.drilldown.open || state.drilldown.selectedInteractionId !== interactionId) { + return; + } + state.drilldown.selectedInteraction = { + ...(state.drilldown.selectedInteraction || {}), + ...(detail?.session || {}), + }; + state.drilldown.selectedLinkedInteraction = detail?.interaction || null; + state.drilldown.selectedTimeline = Array.isArray(detail?.timeline) ? detail.timeline : []; + } catch (err) { + if (!state.drilldown.open || state.drilldown.selectedInteractionId !== interactionId) { + return; + } + state.drilldown.error = err.message; + } finally { + if (state.drilldown.open && state.drilldown.selectedInteractionId === interactionId) { + state.drilldown.detailLoading = false; + renderAnalyticsDrilldown(); + } + } +} + +function handleAnalyticsDrilldownListClick(event) { + if (state.drilldown.mode !== 'ai') { + return handleAnalyticsDrilldownListClickBase(event); + } + const button = event.target.closest('[data-analytics-row-id]'); + if (!button) { + return; + } + maybeScrollAnalyticsDrilldownDetailIntoView(); + void loadAnalyticsDrilldownDetails(button.dataset.analyticsRowId || ''); +} + +function handleAiAnalyticsOverviewClick(event) { + const button = event.target.closest('[data-ai-drilldown-slice]'); + if (!button) { + return; + } + const slice = button.dataset.aiDrilldownSlice || 'all'; + openAnalyticsDrilldown('ai-overview', slice, { + mode: 'ai', + slice, + sourceLabel: button.dataset.aiDrilldownLabel || aiAnalyticsSliceLabel(slice), + }); +} + +function handleAiAnalyticsChannelClick(event) { + const button = event.target.closest('[data-ai-channel]'); + if (!button) { + return; + } + const channel = button.dataset.aiChannel || ''; + openAnalyticsDrilldown('ai-channel', channel, { + mode: 'ai', + slice: 'all', + sourceLabel: `AI-канал: ${analyticsChannelLabel(channel)}`, + }); +} + +function handleAiAnalyticsOutcomeClick(event) { + const button = event.target.closest('[data-ai-outcome]'); + if (!button) { + return; + } + const slice = button.dataset.aiOutcome || 'all'; + openAnalyticsDrilldown('ai-overview', slice, { + mode: 'ai', + slice, + sourceLabel: button.dataset.aiOutcomeLabel || aiAnalyticsSliceLabel(slice), + }); +} + +function handleAiAnalyticsReasonClick(event) { + const button = event.target.closest('[data-ai-reason-key]'); + if (!button) { + return; + } + const reasonKey = button.dataset.aiReasonKey || ''; + openAnalyticsDrilldown('ai-reason', reasonKey, { + mode: 'ai', + slice: 'handoff', + reasonKey, + sourceLabel: button.dataset.aiReasonLabel || `Причина передачи: ${reasonKey}`, + }); +} + +function renderAiAnalyticsDrilldownFactBadges(item) { + if (!item || typeof item !== 'object') { + return ''; + } + const badges = []; + if (item.contained) { + badges.push({ tone: 'sla', label: 'Закрыто AI' }); + } + if (item.handoff) { + badges.push({ tone: 'abandoned', label: 'Передано оператору' }); + } + if (item.human_touched) { + badges.push({ tone: 'handle', label: 'С участием оператора' }); + } + if (item.closed_without_operator) { + badges.push({ tone: 'fcr', label: 'Закрыто без оператора' }); + } + if (item.ai_latency_avg_ms !== null && item.ai_latency_avg_ms !== undefined) { + badges.push({ tone: 'wait', label: `Задержка ${formatAnalyticsNumber(item.ai_latency_avg_ms, 0)} мс` }); + } + if (item.reason_label) { + badges.push({ tone: 'handle', label: item.reason_label }); + } + if (!badges.length) { + return ''; + } + return ` +
            + ${badges + .map( + (badge) => `${escapeHtml(badge.label)}`, + ) + .join('')} +
            + `; +} + +renderAnalyticsDrilldown = function renderAnalyticsDrilldown() { + if (state.drilldown.mode !== 'ai') { + return renderAnalyticsDrilldownBase(); + } + const drawer = $('analyticsDrilldownDrawer'); + const backdrop = $('analyticsDrilldownBackdrop'); + const closeBtn = $('analyticsDrilldownCloseBtn'); + const title = $('analyticsDrilldownTitle'); + const meta = $('analyticsDrilldownMeta'); + const filters = $('analyticsDrilldownFilters'); + const list = $('analyticsDrilldownList'); + const detail = $('analyticsDrilldownDetail'); + const prevBtn = $('analyticsDrilldownPrevBtn'); + const nextBtn = $('analyticsDrilldownNextBtn'); + const pageMeta = $('analyticsDrilldownPageMeta'); + const listHeading = drawer?.querySelector('.analytics-drawer-list-shell .analytics-drawer-list-head strong'); + const detailHeading = drawer?.querySelector('.analytics-drawer-detail-shell .analytics-drawer-list-head strong'); + const detailHint = drawer?.querySelector('.analytics-drawer-detail-shell .analytics-drawer-list-head .hint'); + if (!drawer || !backdrop || !closeBtn || !title || !meta || !filters || !list || !detail || !prevBtn || !nextBtn || !pageMeta) { + return; + } + + const isOpen = state.drilldown.open; + document.body.classList.toggle('analytics-drilldown-open', isOpen); + drawer.classList.toggle('is-open', isOpen); + backdrop.classList.toggle('is-open', isOpen); + drawer.setAttribute('aria-hidden', isOpen ? 'false' : 'true'); + backdrop.setAttribute('aria-hidden', isOpen ? 'false' : 'true'); + closeBtn.classList.add('analytics-drawer-close'); + closeBtn.setAttribute('aria-label', 'Закрыть детализацию'); + closeBtn.textContent = 'Закрыть · Esc'; + drawer.hidden = !isOpen; + backdrop.hidden = !isOpen; + if (!isOpen) { + title.textContent = 'Детализация AI-сессий'; + meta.textContent = 'Выберите карточку, исход, причину передачи или канал, чтобы открыть срез по AI-сессиям.'; + filters.innerHTML = ''; + list.innerHTML = ''; + detail.innerHTML = ''; + pageMeta.textContent = '0 из 0'; + prevBtn.disabled = true; + nextBtn.disabled = true; + if (listHeading) { + listHeading.textContent = 'Список AI-сессий'; + } + if (detailHeading) { + detailHeading.textContent = 'Детали AI-сессии'; + } + if (detailHint) { + detailHint.textContent = 'Только метаданные: без текста сообщений, AI-сводок, записей и транскриптов.'; + } + syncAnalyticsDrilldownControls(); + return; + } + + const chips = analyticsDrilldownChips(); + const currentPageStart = state.drilldown.total ? state.drilldown.offset + 1 : 0; + const currentPageEnd = state.drilldown.total + ? Math.min(state.drilldown.offset + state.drilldown.items.length, state.drilldown.total) + : 0; + title.textContent = state.drilldown.sourceLabel || 'Детализация AI-сессий'; + if (listHeading) { + listHeading.textContent = 'Список AI-сессий'; + } + if (detailHeading) { + detailHeading.textContent = 'Детали AI-сессии'; + } + if (detailHint) { + detailHint.textContent = 'Только метаданные: без текста сообщений, AI-сводок, записей и транскриптов.'; + } + meta.textContent = state.drilldown.error + ? `Не удалось обновить детализацию AI: ${state.drilldown.error}` + : state.drilldown.exporting + ? 'Готовим CSV по текущему AI-срезу.' + : state.drilldown.loading + ? 'Загружаем AI-сессии и ленту метаданных...' + : `${formatAnalyticsNumber(state.drilldown.total, 0)} AI-сессий в выбранном срезе.`; + filters.innerHTML = [ + ...chips.map((chip) => `${escapeHtml(chip)}`), + state.drilldown.notice + ? `${escapeHtml(state.drilldown.notice)}` + : '', + ].join(''); + pageMeta.textContent = `${currentPageStart}-${currentPageEnd} из ${formatAnalyticsNumber(state.drilldown.total, 0)}`; + prevBtn.disabled = state.drilldown.loading || state.drilldown.exporting || state.drilldown.offset <= 0; + nextBtn.disabled = state.drilldown.loading || state.drilldown.exporting || state.drilldown.offset + state.drilldown.limit >= state.drilldown.total; + syncAnalyticsDrilldownControls(); + + if (state.drilldown.loading && !state.drilldown.items.length) { + list.innerHTML = '
            Загружаем AI-сессии...
            '; + } else if (!state.drilldown.items.length) { + list.innerHTML = '
            По этому AI-срезу сессий пока нет.
            '; + } else { + list.innerHTML = state.drilldown.items + .map((item) => ` + + `) + .join(''); + } + + const selectedSession = state.drilldown.selectedInteraction + || state.drilldown.items.find((item) => item.session_id === state.drilldown.selectedInteractionId) + || null; + if (!selectedSession) { + detail.innerHTML = '
            Выберите AI-сессию в списке, чтобы увидеть детали на уровне метаданных.
            '; + return; + } + + const timelineMarkup = state.drilldown.detailLoading + ? '
            Загружаем ленту метаданных...
            ' + : state.drilldown.selectedTimeline.length + ? state.drilldown.selectedTimeline + .map((event) => { + const safeMetadata = sanitizeAnalyticsTimelineMetadata(event.metadata); + const metadataMarkup = Object.keys(safeMetadata).length + ? `
            ${escapeHtml(JSON.stringify(safeMetadata, null, 2))}
            ` + : '

            Только событие уровня метаданных без текста сообщений.

            '; + return ` +
            +
            + ${escapeHtml(event.label || event.event_type || 'событие')} + ${escapeHtml(formatTime(event.ts))} +
            + ${metadataMarkup} +
            + `; + }) + .join('') + : '
            Metadata timeline по этой AI session пока пуст.
            '; + + const linkedInteractionMarkup = state.drilldown.selectedLinkedInteraction + ? ` +
            +
            +
            + Связанное обращение +

            ${escapeHtml(state.drilldown.selectedLinkedInteraction.interaction_id || '—')}

            +
            +
            +
            +
            Канал${escapeHtml(analyticsChannelLabel(state.drilldown.selectedLinkedInteraction.channel))}
            +
            Очередь${escapeHtml(analyticsQueueName(state.drilldown.selectedLinkedInteraction.queue_id))}
            +
            Статус${escapeHtml(interactionStatusLabel(state.drilldown.selectedLinkedInteraction.status))}
            +
            Назначен${escapeHtml(state.drilldown.selectedLinkedInteraction.assigned_to || 'Не назначен')}
            +
            Тема${escapeHtml(state.drilldown.selectedLinkedInteraction.subject || '—')}
            +
            Обновлено${escapeHtml(formatTime(state.drilldown.selectedLinkedInteraction.updated_at))}
            +
            +
            + ` + : ''; + + detail.innerHTML = ` +
            +
            +
            + ${escapeHtml(selectedSession.session_id)} +

            ${escapeHtml(aiAnalyticsSliceLabel(state.drilldown.filters?.slice || 'all'))}

            +
            +
            + ${escapeHtml(aiAnalyticsStatusLabel(selectedSession.status))} +
            +
            +
            +

            ${escapeHtml(aiAnalyticsDrilldownNote(state.drilldown.filters))}

            +
            + ${renderAiAnalyticsDrilldownFactBadges(selectedSession)} +
            +
            Канал${escapeHtml(analyticsChannelLabel(selectedSession.channel))}
            +
            Очередь${escapeHtml(analyticsQueueName(selectedSession.queue_id))}
            +
            ID обращения${escapeHtml(selectedSession.interaction_id || '—')}
            +
            ID диалога${escapeHtml(selectedSession.thread_id || '—')}
            +
            Назначено${escapeHtml(selectedSession.assigned_to || 'Не назначен')}
            +
            Взял оператор${escapeHtml(selectedSession.claimed_by_user || '—')}
            +
            Ответов AI${escapeHtml(String(selectedSession.assistant_turns || 0))}
            +
            Сообщений клиента${escapeHtml(String(selectedSession.user_turns || 0))}
            +
            Вызовов инструментов${escapeHtml(String(selectedSession.tool_turns || 0))}
            +
            Средняя задержка${escapeHtml(formatAiAnalyticsMetric('ai_latency_avg_ms', selectedSession.ai_latency_avg_ms))}
            +
            P95 задержки${escapeHtml(formatAiAnalyticsMetric('ai_latency_avg_ms', selectedSession.ai_latency_p95_ms))}
            +
            Причина${escapeHtml(selectedSession.reason_label || selectedSession.raw_handoff_reason || '—')}
            +
            Исходная причина${escapeHtml(selectedSession.raw_handoff_reason || '—')}
            +
            Создано${escapeHtml(formatTime(selectedSession.created_at))}
            +
            Обновлено${escapeHtml(formatTime(selectedSession.updated_at))}
            +
            Закрыто${escapeHtml(formatTime(selectedSession.closed_at))}
            +
            +
            + ${linkedInteractionMarkup} +
            + ${timelineMarkup} +
            + `; +} + +async function exportAnalyticsDrilldownCsv() { + if (state.drilldown.mode !== 'ai') { + return exportAnalyticsDrilldownCsvBase(); + } + if (!state.drilldown.open || !state.drilldown.filters || !state.drilldown.total || state.drilldown.exporting) { + return; + } + state.drilldown.exporting = true; + state.drilldown.notice = ''; + renderAnalyticsDrilldown(); + + try { + const lines = [[ + 'session_id', + 'interaction_id', + 'thread_id', + 'channel', + 'queue_id', + 'status', + 'reason_key', + 'reason_label', + 'raw_handoff_reason', + 'assigned_to', + 'claimed_by_user', + 'assistant_turns', + 'user_turns', + 'tool_turns', + 'ai_latency_avg_ms', + 'ai_latency_p95_ms', + 'created_at', + 'updated_at', + 'closed_at', + ].join(',')]; + const maxRows = Math.min(state.drilldown.total, DRILLDOWN_EXPORT_SOFT_CAP); + let currentOffset = 0; + while (currentOffset < maxRows) { + const batchLimit = Math.min(DRILLDOWN_EXPORT_BATCH_SIZE, maxRows - currentOffset); + const payload = await fetchAiAnalyticsDrilldown(state.drilldown.filters, batchLimit, currentOffset); + const items = Array.isArray(payload?.items) ? payload.items : []; + items.forEach((item) => { + lines.push([ + item.session_id, + item.interaction_id, + item.thread_id, + item.channel, + item.queue_id, + item.status, + item.reason_key, + item.reason_label, + item.raw_handoff_reason, + item.assigned_to, + item.claimed_by_user, + item.assistant_turns, + item.user_turns, + item.tool_turns, + item.ai_latency_avg_ms, + item.ai_latency_p95_ms, + item.created_at, + item.updated_at, + item.closed_at, + ].map(csvCell).join(',')); + }); + if (!items.length) { + break; + } + currentOffset += batchLimit; + } + + const dateStamp = new Date().toISOString().slice(0, 10); + downloadCsv( + lines.join('\n'), + `konturcc-drilldown-${analyticsDrilldownFilenameSource()}-${dateStamp}.csv`, + ); + state.drilldown.notice = state.drilldown.total > DRILLDOWN_EXPORT_SOFT_CAP + ? `Экспортированы первые ${formatAnalyticsNumber(DRILLDOWN_EXPORT_SOFT_CAP, 0)} строк.` + : 'CSV готов.'; + } catch (err) { + state.drilldown.error = err.message; + } finally { + state.drilldown.exporting = false; + renderAnalyticsDrilldown(); + } +} + +function renderAiAnalyticsDrilldownActive() { + const drawer = $('analyticsDrilldownDrawer'); + const backdrop = $('analyticsDrilldownBackdrop'); + const closeBtn = $('analyticsDrilldownCloseBtn'); + const title = $('analyticsDrilldownTitle'); + const meta = $('analyticsDrilldownMeta'); + const filters = $('analyticsDrilldownFilters'); + const list = $('analyticsDrilldownList'); + const detail = $('analyticsDrilldownDetail'); + const prevBtn = $('analyticsDrilldownPrevBtn'); + const nextBtn = $('analyticsDrilldownNextBtn'); + const pageMeta = $('analyticsDrilldownPageMeta'); + const listHeading = drawer?.querySelector('.analytics-drawer-list-shell .analytics-drawer-list-head strong'); + const detailHeading = drawer?.querySelector('.analytics-drawer-detail-shell .analytics-drawer-list-head strong'); + const detailHint = drawer?.querySelector('.analytics-drawer-detail-shell .analytics-drawer-list-head .hint'); + if (!drawer || !backdrop || !closeBtn || !title || !meta || !filters || !list || !detail || !prevBtn || !nextBtn || !pageMeta) { + return; + } + + const isOpen = state.drilldown.open; + document.body.classList.toggle('analytics-drilldown-open', isOpen); + drawer.classList.toggle('is-open', isOpen); + backdrop.classList.toggle('is-open', isOpen); + drawer.setAttribute('aria-hidden', isOpen ? 'false' : 'true'); + backdrop.setAttribute('aria-hidden', isOpen ? 'false' : 'true'); + closeBtn.classList.add('analytics-drawer-close'); + closeBtn.setAttribute('aria-label', 'Закрыть детализацию'); + closeBtn.textContent = 'Закрыть · Esc'; + drawer.hidden = !isOpen; + backdrop.hidden = !isOpen; + if (!isOpen) { + title.textContent = 'Детализация AI-сессий'; + meta.textContent = 'Выберите карточку, исход, причину передачи или канал, чтобы открыть срез по AI-сессиям.'; + filters.innerHTML = ''; + list.innerHTML = ''; + detail.innerHTML = ''; + pageMeta.textContent = '0 из 0'; + prevBtn.disabled = true; + nextBtn.disabled = true; + if (listHeading) { + listHeading.textContent = 'Список AI-сессий'; + } + if (detailHeading) { + detailHeading.textContent = 'Детали AI-сессии'; + } + if (detailHint) { + detailHint.textContent = 'Только метаданные: без текста сообщений, AI-сводок, записей и транскриптов.'; + } + syncAnalyticsDrilldownControls(); + return; + } + + const chips = analyticsDrilldownChips(); + const currentPageStart = state.drilldown.total ? state.drilldown.offset + 1 : 0; + const currentPageEnd = state.drilldown.total + ? Math.min(state.drilldown.offset + state.drilldown.items.length, state.drilldown.total) + : 0; + title.textContent = state.drilldown.sourceLabel || 'Детализация AI-сессий'; + if (listHeading) { + listHeading.textContent = 'Список AI-сессий'; + } + if (detailHeading) { + detailHeading.textContent = 'Детали AI-сессии'; + } + if (detailHint) { + detailHint.textContent = 'Только метаданные: без текста сообщений, AI-сводок, записей и транскриптов.'; + } + meta.textContent = state.drilldown.error + ? `Не удалось обновить детализацию AI: ${state.drilldown.error}` + : state.drilldown.exporting + ? 'Готовим CSV по текущему AI-срезу.' + : state.drilldown.loading + ? 'Загружаем AI-сессии и ленту метаданных...' + : `${formatAnalyticsNumber(state.drilldown.total, 0)} AI-сессий в выбранном срезе.`; + filters.innerHTML = [ + ...chips.map((chip) => `${escapeHtml(chip)}`), + state.drilldown.notice + ? `${escapeHtml(state.drilldown.notice)}` + : '', + ].join(''); + pageMeta.textContent = `${currentPageStart}-${currentPageEnd} из ${formatAnalyticsNumber(state.drilldown.total, 0)}`; + prevBtn.disabled = state.drilldown.loading || state.drilldown.exporting || state.drilldown.offset <= 0; + nextBtn.disabled = state.drilldown.loading || state.drilldown.exporting || state.drilldown.offset + state.drilldown.limit >= state.drilldown.total; + syncAnalyticsDrilldownControls(); + + if (state.drilldown.loading && !state.drilldown.items.length) { + list.innerHTML = '
            Загружаем AI-сессии...
            '; + } else if (!state.drilldown.items.length) { + list.innerHTML = '
            По этому AI-срезу сессий пока нет.
            '; + } else { + list.innerHTML = state.drilldown.items + .map((item) => ` + + `) + .join(''); + } + + const selectedSession = state.drilldown.selectedInteraction + || state.drilldown.items.find((item) => item.session_id === state.drilldown.selectedInteractionId) + || null; + if (!selectedSession) { + detail.innerHTML = '
            Выберите AI-сессию в списке, чтобы увидеть детали на уровне метаданных.
            '; + return; + } + + const timelineMarkup = state.drilldown.detailLoading + ? '
            Загружаем ленту метаданных...
            ' + : state.drilldown.selectedTimeline.length + ? state.drilldown.selectedTimeline + .map((event) => { + const safeMetadata = sanitizeAnalyticsTimelineMetadata(event.metadata); + const metadataMarkup = Object.keys(safeMetadata).length + ? `
            ${escapeHtml(JSON.stringify(safeMetadata, null, 2))}
            ` + : '

            Только событие уровня метаданных без текста сообщений.

            '; + return ` +
            +
            + ${escapeHtml(event.label || event.event_type || 'событие')} + ${escapeHtml(formatTime(event.ts))} +
            + ${metadataMarkup} +
            + `; + }) + .join('') + : '
            Лента метаданных по этой AI-сессии пока пуста.
            '; + + const linkedInteractionMarkup = state.drilldown.selectedLinkedInteraction + ? ` +
            +
            +
            + Связанное обращение +

            ${escapeHtml(state.drilldown.selectedLinkedInteraction.interaction_id || '—')}

            +
            +
            +
            +
            Канал${escapeHtml(analyticsChannelLabel(state.drilldown.selectedLinkedInteraction.channel))}
            +
            Очередь${escapeHtml(analyticsQueueName(state.drilldown.selectedLinkedInteraction.queue_id))}
            +
            Статус${escapeHtml(interactionStatusLabel(state.drilldown.selectedLinkedInteraction.status))}
            +
            Назначен${escapeHtml(state.drilldown.selectedLinkedInteraction.assigned_to || 'Не назначен')}
            +
            Тема${escapeHtml(state.drilldown.selectedLinkedInteraction.subject || '—')}
            +
            Обновлено${escapeHtml(formatTime(state.drilldown.selectedLinkedInteraction.updated_at))}
            +
            +
            + ` + : ''; + + detail.innerHTML = ` +
            +
            +
            + ${escapeHtml(selectedSession.session_id)} +

            ${escapeHtml(aiAnalyticsSliceLabel(state.drilldown.filters?.slice || 'all'))}

            +
            +
            + ${escapeHtml(aiAnalyticsStatusLabel(selectedSession.status))} +
            +
            +
            +

            ${escapeHtml(aiAnalyticsDrilldownNote(state.drilldown.filters))}

            +
            + ${renderAiAnalyticsDrilldownFactBadges(selectedSession)} +
            +
            Канал${escapeHtml(analyticsChannelLabel(selectedSession.channel))}
            +
            Очередь${escapeHtml(analyticsQueueName(selectedSession.queue_id))}
            +
            ID обращения${escapeHtml(selectedSession.interaction_id || '—')}
            +
            ID диалога${escapeHtml(selectedSession.thread_id || '—')}
            +
            Назначено${escapeHtml(selectedSession.assigned_to || 'Не назначен')}
            +
            Взял оператор${escapeHtml(selectedSession.claimed_by_user || '—')}
            +
            Ответов AI${escapeHtml(String(selectedSession.assistant_turns || 0))}
            +
            Сообщений клиента${escapeHtml(String(selectedSession.user_turns || 0))}
            +
            Вызовов инструментов${escapeHtml(String(selectedSession.tool_turns || 0))}
            +
            Средняя задержка${escapeHtml(formatAiAnalyticsMetric('ai_latency_avg_ms', selectedSession.ai_latency_avg_ms))}
            +
            P95 задержки${escapeHtml(formatAiAnalyticsMetric('ai_latency_avg_ms', selectedSession.ai_latency_p95_ms))}
            +
            Причина${escapeHtml(selectedSession.reason_label || selectedSession.raw_handoff_reason || '—')}
            +
            Исходная причина${escapeHtml(selectedSession.raw_handoff_reason || '—')}
            +
            Создано${escapeHtml(formatTime(selectedSession.created_at))}
            +
            Обновлено${escapeHtml(formatTime(selectedSession.updated_at))}
            +
            Закрыто${escapeHtml(formatTime(selectedSession.closed_at))}
            +
            +
            + ${linkedInteractionMarkup} +
            + ${timelineMarkup} +
            + `; +} + +function renderAnalyticsDrilldown() { + if (state.drilldown.mode === 'ai') { + renderAiAnalyticsDrilldownActive(); + return; + } + const drawer = $('analyticsDrilldownDrawer'); + const backdrop = $('analyticsDrilldownBackdrop'); + const closeBtn = $('analyticsDrilldownCloseBtn'); + const title = $('analyticsDrilldownTitle'); + const meta = $('analyticsDrilldownMeta'); + const filters = $('analyticsDrilldownFilters'); + const list = $('analyticsDrilldownList'); + const detail = $('analyticsDrilldownDetail'); + const prevBtn = $('analyticsDrilldownPrevBtn'); + const nextBtn = $('analyticsDrilldownNextBtn'); + const pageMeta = $('analyticsDrilldownPageMeta'); + const listHeading = drawer?.querySelector('.analytics-drawer-list-shell .analytics-drawer-list-head strong'); + const detailHeading = drawer?.querySelector('.analytics-drawer-detail-shell .analytics-drawer-list-head strong'); + const detailHint = drawer?.querySelector('.analytics-drawer-detail-shell .analytics-drawer-list-head .hint'); + if (!drawer || !backdrop || !closeBtn || !title || !meta || !filters || !list || !detail || !prevBtn || !nextBtn || !pageMeta) { + return; + } + + const isOpen = state.drilldown.open; + document.body.classList.toggle('analytics-drilldown-open', isOpen); + drawer.classList.toggle('is-open', isOpen); + backdrop.classList.toggle('is-open', isOpen); + drawer.setAttribute('aria-hidden', isOpen ? 'false' : 'true'); + backdrop.setAttribute('aria-hidden', isOpen ? 'false' : 'true'); + closeBtn.classList.add('analytics-drawer-close'); + closeBtn.setAttribute('aria-label', 'Закрыть детализацию'); + closeBtn.textContent = 'Закрыть · Esc'; + drawer.hidden = !isOpen; + backdrop.hidden = !isOpen; + if (!isOpen) { + title.textContent = 'Детализация обращений'; + meta.textContent = 'Выберите карточку обращений, канал или очередь на витрине, чтобы открыть список обращений.'; + filters.innerHTML = ''; + list.innerHTML = ''; + detail.innerHTML = ''; + pageMeta.textContent = '0 из 0'; + prevBtn.disabled = true; + nextBtn.disabled = true; + title.textContent = 'Точная детализация'; + meta.textContent = 'Выберите KPI-карточку, канал или очередь на витрине, чтобы открыть точный срез по обращениям.'; + pageMeta.textContent = '0 из 0'; + if (listHeading) { + listHeading.textContent = 'Список обращений'; + } + if (detailHeading) { + detailHeading.textContent = 'Карточка обращения'; + } + if (detailHint) { + detailHint.textContent = 'Лента событий и базовые поля без записей, транскриптов и канального контента.'; + } + syncAnalyticsDrilldownControls(); + return; + } + + const chips = analyticsDrilldownChips(); + const currentPageStart = state.drilldown.total ? state.drilldown.offset + 1 : 0; + const currentPageEnd = state.drilldown.total + ? Math.min(state.drilldown.offset + state.drilldown.items.length, state.drilldown.total) + : 0; + title.textContent = state.drilldown.sourceLabel || 'Точная детализация'; + if (listHeading) { + listHeading.textContent = state.drilldown.mode === 'metric' ? 'Точный KPI-срез' : 'Список обращений'; + } + if (detailHeading) { + detailHeading.textContent = state.drilldown.mode === 'metric' ? 'Карточка и KPI-факты' : 'Карточка обращения'; + } + if (detailHint) { + detailHint.textContent = state.drilldown.mode === 'metric' + ? 'KPI-факты, лента событий и базовые поля без исходных сообщений, записей, транскриптов и AI-сводок.' + : 'Лента событий и базовые поля без записей, транскриптов и канального контента.'; + } + title.textContent = state.drilldown.sourceLabel || 'Детализация обращений'; + meta.textContent = state.drilldown.error + ? `Не удалось полностью обновить детализацию: ${state.drilldown.error}` + : state.drilldown.exporting + ? 'Готовим CSV по текущему срезу. Детализация и фильтры остаются доступны только на уровне метаданных.' + : state.drilldown.loading + ? 'Загружаем список обращений и ленту событий...' + : `${formatAnalyticsNumber(state.drilldown.total, 0)} обращений в выбранном срезе.`; + meta.textContent = state.drilldown.error + ? `Не удалось обновить детализацию: ${state.drilldown.error}` + : state.drilldown.exporting + ? 'Готовим CSV по текущему срезу. Детализация остаётся только на уровне метаданных.' + : state.drilldown.loading + ? state.drilldown.mode === 'metric' + ? 'Загружаем точный KPI-срез и ленту событий...' + : 'Загружаем список обращений и ленту событий...' + : state.drilldown.mode === 'metric' + ? `${formatAnalyticsNumber(state.drilldown.total, 0)} обращений в точном срезе по метрике. ${state.drilldown.metricNote || ''}`.trim() + : `${formatAnalyticsNumber(state.drilldown.total, 0)} обращений в выбранном срезе.`; + title.textContent = state.drilldown.sourceLabel || 'Точная детализация'; + filters.innerHTML = [ + ...chips.map((chip) => `${escapeHtml(chip)}`), + state.drilldown.notice + ? `${escapeHtml(state.drilldown.notice)}` + : '', + ].join(''); + pageMeta.textContent = `${currentPageStart}-${currentPageEnd} из ${formatAnalyticsNumber(state.drilldown.total, 0)}`; + prevBtn.disabled = state.drilldown.loading || state.drilldown.exporting || state.drilldown.offset <= 0; + nextBtn.disabled = state.drilldown.loading || state.drilldown.exporting || state.drilldown.offset + state.drilldown.limit >= state.drilldown.total; + syncAnalyticsDrilldownControls(); + + if (state.drilldown.loading && !state.drilldown.items.length) { + list.innerHTML = '
            Загружаем список обращений...
            '; + } else if (!state.drilldown.items.length) { + list.innerHTML = '
            По этому срезу обращений пока нет.
            '; + } else { + list.innerHTML = state.drilldown.items + .map((item) => ` + + `) + .join(''); + } + + const selectedInteraction = state.drilldown.selectedInteraction + || state.drilldown.items.find((item) => item.interaction_id === state.drilldown.selectedInteractionId) + || null; + if (!selectedInteraction) { + detail.innerHTML = '
            Выберите обращение в списке, чтобы увидеть карточку и timeline.
            '; + return; + } + + const timelineMarkup = state.drilldown.detailLoading + ? '
            Загружаем ленту событий...
            ' + : state.drilldown.selectedTimeline.length + ? state.drilldown.selectedTimeline + .map((event) => ` + ${(() => { + const safeMetadata = sanitizeAnalyticsTimelineMetadata(event.metadata); + const metadataMarkup = Object.keys(safeMetadata).length + ? `
            ${escapeHtml(JSON.stringify(safeMetadata, null, 2))}
            ` + : '

            Только событие уровня метаданных: исходный текст, записи и транскрипты скрыты в analyst-экране.

            '; + return ` +
            +
            + ${escapeHtml(event.action || 'событие')} + ${escapeHtml(formatTime(event.timestamp))} +
            + ${metadataMarkup} +
            + `; + })()} + `) + .join('') + : '
            Лента событий по этому обращению пока пуста.
            '; + + const metricContextMarkup = state.drilldown.mode === 'metric' && state.drilldown.metric + ? ` +
            +

            ${escapeHtml(state.drilldown.metricNote || '')}

            +
            + ` + : ''; + const factBadgesMarkup = renderAnalyticsDrilldownFactBadges(selectedInteraction); + const factFields = [ + selectedInteraction.wait_seconds !== null && selectedInteraction.wait_seconds !== undefined + ? `
            Ожидание${escapeHtml(`${formatAnalyticsNumber(selectedInteraction.wait_seconds, 0)} с`)}
            ` + : '', + selectedInteraction.handle_seconds !== null && selectedInteraction.handle_seconds !== undefined + ? `
            Обработка${escapeHtml(`${formatAnalyticsNumber(selectedInteraction.handle_seconds, 0)} с`)}
            ` + : '', + selectedInteraction.within_sla !== null && selectedInteraction.within_sla !== undefined + ? `
            SLA${escapeHtml(selectedInteraction.within_sla ? 'В SLA' : 'Вне SLA')}
            ` + : '', + selectedInteraction.resolved_first_contact !== null && selectedInteraction.resolved_first_contact !== undefined + ? `
            FCR${escapeHtml(selectedInteraction.resolved_first_contact ? 'Да' : 'Нет')}
            ` + : '', + selectedInteraction.abandoned !== null && selectedInteraction.abandoned !== undefined + ? `
            Потеря${escapeHtml(selectedInteraction.abandoned ? 'Да' : 'Нет')}
            ` + : '', + ].filter(Boolean).join(''); + + detail.innerHTML = ` +
            +
            +
            + ${escapeHtml(selectedInteraction.subject || selectedInteraction.interaction_id)} +

            ${escapeHtml(selectedInteraction.interaction_id)}

            +
            +
            + ${state.drilldown.mode === 'metric' && state.drilldown.metric ? analyticsMetricCoverageBadge(state.drilldown.metric) : ''} + ${escapeHtml(interactionStatusLabel(selectedInteraction.status))} +
            +
            + ${metricContextMarkup} + ${factBadgesMarkup} +
            +
            Канал${escapeHtml(analyticsChannelLabel(selectedInteraction.channel))}
            +
            Очередь${escapeHtml(analyticsQueueName(selectedInteraction.queue_id))}
            +
            Назначен${escapeHtml(selectedInteraction.assigned_to || 'Не назначен')}
            +
            Создано${escapeHtml(formatTime(selectedInteraction.created_at))}
            +
            Обновлено${escapeHtml(formatTime(selectedInteraction.updated_at))}
            +
            Срез${escapeHtml(state.drilldown.sourceLabel || 'Все обращения')}
            + ${factFields} +
            +
            +
            + ${timelineMarkup} +
            + `; +} + +async function loadAiAnalyticsDrilldownDetailsState(sessionId) { + if (!state.drilldown.open || !sessionId) { + return; + } + state.drilldown.selectedInteractionId = sessionId; + state.drilldown.selectedInteraction = state.drilldown.items.find((item) => item.session_id === sessionId) || null; + state.drilldown.selectedLinkedInteraction = null; + state.drilldown.selectedTimeline = []; + state.drilldown.error = ''; + state.drilldown.detailLoading = true; + renderAnalyticsDrilldown(); + syncAnalyticsUrlState(); + + if (analyticsMockEnabled()) { + const detail = buildMockAiDetail(sessionId); + state.drilldown.selectedInteraction = { + ...(state.drilldown.selectedInteraction || {}), + ...(detail?.session || {}), + }; + state.drilldown.selectedLinkedInteraction = detail?.interaction || null; + state.drilldown.selectedTimeline = Array.isArray(detail?.timeline) ? detail.timeline : []; + state.drilldown.detailLoading = false; + renderAnalyticsDrilldown(); + syncAnalyticsUrlState(); + return; + } + + try { + const detail = await fetchAiAnalyticsSessionDetail(sessionId); + if (!state.drilldown.open || state.drilldown.selectedInteractionId !== sessionId) { + return; + } + state.drilldown.selectedInteraction = { + ...(state.drilldown.selectedInteraction || {}), + ...(detail?.session || {}), + }; + state.drilldown.selectedLinkedInteraction = detail?.interaction || null; + state.drilldown.selectedTimeline = Array.isArray(detail?.timeline) ? detail.timeline : []; + } catch (err) { + if (!state.drilldown.open || state.drilldown.selectedInteractionId !== sessionId) { + return; + } + state.drilldown.error = err.message; + } finally { + if (state.drilldown.open && state.drilldown.selectedInteractionId === sessionId) { + state.drilldown.detailLoading = false; + renderAnalyticsDrilldown(); + syncAnalyticsUrlState(); + } + } +} + +loadAnalyticsDrilldownDetails = async function loadAnalyticsDrilldownDetails(interactionId) { + if (state.drilldown.mode === 'ai') { + return loadAiAnalyticsDrilldownDetailsState(interactionId); + } + if (!state.drilldown.open || !interactionId) { + return; + } + state.drilldown.selectedInteractionId = interactionId; + state.drilldown.selectedInteraction = state.drilldown.items.find((item) => item.interaction_id === interactionId) || null; + state.drilldown.selectedTimeline = []; + state.drilldown.error = ''; + state.drilldown.detailLoading = true; + renderAnalyticsDrilldown(); + syncAnalyticsUrlState(); + + if (analyticsMockEnabled()) { + const detail = buildMockInteractionDetail(interactionId); + state.drilldown.selectedInteraction = { + ...(state.drilldown.selectedInteraction || {}), + ...(detail?.detail || {}), + }; + state.drilldown.selectedTimeline = Array.isArray(detail?.timeline?.events) ? detail.timeline.events : []; + state.drilldown.detailLoading = false; + renderAnalyticsDrilldown(); + syncAnalyticsUrlState(); + return; + } + + try { + const [detail, timeline] = await Promise.all([ + api('interaction', `interactions/${encodeURIComponent(interactionId)}`), + api('interaction', `interactions/${encodeURIComponent(interactionId)}/timeline`), + ]); + if (!state.drilldown.open || state.drilldown.selectedInteractionId !== interactionId) { + return; + } + state.drilldown.selectedInteraction = { + ...(state.drilldown.selectedInteraction || {}), + ...(detail || {}), + }; + state.drilldown.selectedTimeline = Array.isArray(timeline?.events) ? timeline.events : []; + } catch (err) { + if (!state.drilldown.open || state.drilldown.selectedInteractionId !== interactionId) { + return; + } + state.drilldown.error = err.message; + } finally { + if (state.drilldown.open && state.drilldown.selectedInteractionId === interactionId) { + state.drilldown.detailLoading = false; + renderAnalyticsDrilldown(); + syncAnalyticsUrlState(); + } + } +} + +async function loadAiAnalyticsDrilldownPageState(options = {}) { + if (!state.drilldown.open || !state.drilldown.filters) { + return; + } + const preserveSelection = Boolean(options.preserveSelection); + state.drilldown.loading = true; + state.drilldown.error = ''; + renderAnalyticsDrilldown(); + + if (analyticsMockEnabled()) { + const data = buildMockAiDrilldownData(state.drilldown.filters, state.drilldown.limit, state.drilldown.offset); + state.drilldown.items = Array.isArray(data?.items) ? data.items : []; + state.drilldown.total = Number(data?.total || 0); + state.drilldown.limit = Number(data?.limit || state.drilldown.limit || DRILLDOWN_PAGE_SIZE); + state.drilldown.offset = Number(data?.offset || 0); + state.drilldown.filters = normalizeAnalyticsDrilldownFilters(data?.filters || state.drilldown.filters); + state.drilldown.coverage = data?.coverage || state.drilldown.coverage; + state.drilldown.metricNote = aiAnalyticsDrilldownNote(state.drilldown.filters); + const selectedId = state.drilldown.selectedInteractionId || ''; + const selectedStillVisible = preserveSelection + && selectedId + && state.drilldown.items.some((item) => analyticsDrilldownCurrentItemId(item) === selectedId); + if (selectedStillVisible) { + state.drilldown.selectedInteraction = state.drilldown.items.find((item) => analyticsDrilldownCurrentItemId(item) === selectedId) || state.drilldown.selectedInteraction; + void loadAnalyticsDrilldownDetails(selectedId); + } else if (state.drilldown.items.length) { + state.drilldown.selectedInteractionId = analyticsDrilldownCurrentItemId(state.drilldown.items[0]); + state.drilldown.selectedInteraction = state.drilldown.items[0]; + state.drilldown.selectedLinkedInteraction = null; + state.drilldown.selectedTimeline = []; + void loadAnalyticsDrilldownDetails(state.drilldown.selectedInteractionId); + } else { + state.drilldown.selectedInteractionId = ''; + state.drilldown.selectedInteraction = null; + state.drilldown.selectedLinkedInteraction = null; + state.drilldown.selectedTimeline = []; + state.drilldown.detailLoading = false; + } + state.drilldown.loading = false; + renderAnalyticsDrilldown(); + syncAnalyticsUrlState(); + return; + } + + try { + const data = await fetchAiAnalyticsDrilldown(state.drilldown.filters, state.drilldown.limit, state.drilldown.offset); + if (!state.drilldown.open) { + return; + } + state.drilldown.items = Array.isArray(data?.items) ? data.items : []; + state.drilldown.total = Number(data?.total || 0); + state.drilldown.limit = Number(data?.limit || state.drilldown.limit || DRILLDOWN_PAGE_SIZE); + state.drilldown.offset = Number(data?.offset || 0); + state.drilldown.filters = normalizeAnalyticsDrilldownFilters(data?.filters || state.drilldown.filters); + state.drilldown.coverage = data?.coverage || state.drilldown.coverage; + state.drilldown.metricNote = aiAnalyticsDrilldownNote(state.drilldown.filters); + + const selectedId = state.drilldown.selectedInteractionId || ''; + const selectedStillVisible = preserveSelection + && selectedId + && state.drilldown.items.some((item) => analyticsDrilldownCurrentItemId(item) === selectedId); + if (selectedStillVisible) { + state.drilldown.selectedInteraction = state.drilldown.items.find( + (item) => analyticsDrilldownCurrentItemId(item) === selectedId, + ) || state.drilldown.selectedInteraction; + void loadAnalyticsDrilldownDetails(selectedId); + } else if (state.drilldown.items.length) { + state.drilldown.selectedInteractionId = analyticsDrilldownCurrentItemId(state.drilldown.items[0]); + state.drilldown.selectedInteraction = state.drilldown.items[0]; + state.drilldown.selectedLinkedInteraction = null; + state.drilldown.selectedTimeline = []; + void loadAnalyticsDrilldownDetails(state.drilldown.selectedInteractionId); + } else { + state.drilldown.selectedInteractionId = ''; + state.drilldown.selectedInteraction = null; + state.drilldown.selectedLinkedInteraction = null; + state.drilldown.selectedTimeline = []; + state.drilldown.detailLoading = false; + } + } catch (err) { + if (!state.drilldown.open) { + return; + } + state.drilldown.items = []; + state.drilldown.total = 0; + state.drilldown.selectedInteractionId = ''; + state.drilldown.selectedInteraction = null; + state.drilldown.selectedLinkedInteraction = null; + state.drilldown.selectedTimeline = []; + state.drilldown.detailLoading = false; + state.drilldown.error = err.message; + } finally { + if (state.drilldown.open) { + state.drilldown.loading = false; + renderAnalyticsDrilldown(); + syncAnalyticsUrlState(); + } + } +} + +loadAnalyticsDrilldownPage = async function loadAnalyticsDrilldownPage(options = {}) { + if (state.drilldown.mode === 'ai') { + return loadAiAnalyticsDrilldownPageState(options); + } + if (!state.drilldown.open || !state.drilldown.filters) { + return; + } + const preserveSelection = Boolean(options.preserveSelection); + state.drilldown.loading = true; + state.drilldown.error = ''; + renderAnalyticsDrilldown(); + + if (analyticsMockEnabled()) { + const data = buildMockInteractionDrilldownData(state.drilldown.filters, state.drilldown.limit, state.drilldown.offset, state.drilldown.mode); + state.drilldown.items = Array.isArray(data?.items) ? data.items : []; + state.drilldown.total = Number(data?.total || 0); + state.drilldown.limit = Number(data?.limit || state.drilldown.limit || DRILLDOWN_PAGE_SIZE); + state.drilldown.offset = Number(data?.offset || 0); + state.drilldown.filters = normalizeAnalyticsDrilldownFilters(data?.filters || state.drilldown.filters); + state.drilldown.metric = data?.metric || state.drilldown.metric; + state.drilldown.coverage = data?.coverage || state.drilldown.coverage; + if (state.drilldown.mode === 'metric' && state.drilldown.metric) { + state.drilldown.metricNote = analyticsMetricDrilldownNote(state.drilldown.metric); + } + const selectedStillVisible = preserveSelection + && state.drilldown.selectedInteractionId + && state.drilldown.items.some((item) => item.interaction_id === state.drilldown.selectedInteractionId); + if (selectedStillVisible) { + state.drilldown.selectedInteraction = state.drilldown.items.find((item) => item.interaction_id === state.drilldown.selectedInteractionId) || state.drilldown.selectedInteraction; + void loadAnalyticsDrilldownDetails(state.drilldown.selectedInteractionId); + } else if (state.drilldown.items.length) { + state.drilldown.selectedInteractionId = state.drilldown.items[0].interaction_id; + state.drilldown.selectedInteraction = state.drilldown.items[0]; + state.drilldown.selectedTimeline = []; + void loadAnalyticsDrilldownDetails(state.drilldown.selectedInteractionId); + } else { + state.drilldown.selectedInteractionId = ''; + state.drilldown.selectedInteraction = null; + state.drilldown.selectedTimeline = []; + state.drilldown.detailLoading = false; + } + state.drilldown.loading = false; + renderAnalyticsDrilldown(); + syncAnalyticsUrlState(); + return; + } + + try { + const data = state.drilldown.mode === 'metric' + ? await fetchAnalyticsMetricDrilldown(state.drilldown.filters, state.drilldown.limit, state.drilldown.offset) + : await api( + 'interaction', + `interactions/drilldown?${analyticsDrilldownQuery(state.drilldown.filters, state.drilldown.limit, state.drilldown.offset)}`, + ); + if (!state.drilldown.open) { + return; + } + state.drilldown.items = Array.isArray(data?.items) ? data.items : []; + state.drilldown.total = Number(data?.total || 0); + state.drilldown.limit = Number(data?.limit || state.drilldown.limit || DRILLDOWN_PAGE_SIZE); + state.drilldown.offset = Number(data?.offset || 0); + state.drilldown.filters = normalizeAnalyticsDrilldownFilters(data?.filters || state.drilldown.filters); + state.drilldown.metric = data?.metric || state.drilldown.metric; + state.drilldown.coverage = data?.coverage || state.drilldown.coverage; + if (state.drilldown.mode === 'metric' && state.drilldown.metric) { + state.drilldown.metricNote = analyticsMetricDrilldownNote(state.drilldown.metric); + } + + const selectedStillVisible = preserveSelection + && state.drilldown.selectedInteractionId + && state.drilldown.items.some((item) => item.interaction_id === state.drilldown.selectedInteractionId); + if (selectedStillVisible) { + state.drilldown.selectedInteraction = state.drilldown.items.find( + (item) => item.interaction_id === state.drilldown.selectedInteractionId, + ) || state.drilldown.selectedInteraction; + void loadAnalyticsDrilldownDetails(state.drilldown.selectedInteractionId); + } else if (state.drilldown.items.length) { + state.drilldown.selectedInteractionId = state.drilldown.items[0].interaction_id; + state.drilldown.selectedInteraction = state.drilldown.items[0]; + state.drilldown.selectedTimeline = []; + void loadAnalyticsDrilldownDetails(state.drilldown.selectedInteractionId); + } else { + state.drilldown.selectedInteractionId = ''; + state.drilldown.selectedInteraction = null; + state.drilldown.selectedTimeline = []; + state.drilldown.detailLoading = false; + } + } catch (err) { + if (!state.drilldown.open) { + return; + } + state.drilldown.items = []; + state.drilldown.total = 0; + state.drilldown.selectedInteractionId = ''; + state.drilldown.selectedInteraction = null; + state.drilldown.selectedTimeline = []; + state.drilldown.detailLoading = false; + state.drilldown.error = err.message; + } finally { + if (state.drilldown.open) { + state.drilldown.loading = false; + renderAnalyticsDrilldown(); + syncAnalyticsUrlState(); + } + } +} + +openAnalyticsDrilldown = function openAnalyticsDrilldown(sourceType, sourceValue = '', options = {}) { + const mode = options.mode || (sourceType === 'metric' ? 'metric' : 'interaction'); + const filters = analyticsDrilldownBaseFilters(); + if (mode === 'metric') { + filters.metric = options.metric || sourceValue; + filters.status = ''; + filters.q = ''; + } + if (mode === 'ai') { + filters.slice = options.slice || 'all'; + filters.reason_key = options.reasonKey || ''; + filters.status = ''; + filters.q = ''; + filters.sort_by = 'created_at'; + filters.sort_dir = 'desc'; + if (sourceType === 'ai-overview' && sourceValue) { + filters.slice = sourceValue; + } + if (sourceType === 'ai-channel' && sourceValue) { + filters.channel = sourceValue; + } + if (sourceType === 'ai-reason' && sourceValue) { + filters.slice = 'handoff'; + filters.reason_key = options.reasonKey || sourceValue; + } + } + if (sourceType === 'channel' && sourceValue) { + filters.channel = sourceValue; + } + if (sourceType === 'queue' && sourceValue) { + filters.queue_id = sourceValue; + } + if (sourceType === 'agent' && sourceValue) { + filters.agent_id = sourceValue; + } + rememberAnalyticsDrilldownReturnFocus(); + state.drilldown = { + ...emptyDrilldownState(), + open: true, + mode, + sourceType, + sourceLabel: options.sourceLabel || analyticsDrilldownSourceLabel(sourceType, sourceValue), + sourceValue, + metric: mode === 'metric' ? (options.metric || sourceValue) : '', + filters: normalizeAnalyticsDrilldownFilters(filters), + coverage: options.coverage || null, + metricNote: mode === 'metric' + ? (options.metricNote || analyticsMetricDrilldownNote(options.metric || sourceValue)) + : mode === 'ai' + ? aiAnalyticsDrilldownNote(filters) + : '', + }; + renderAnalyticsDrilldown(); + syncAnalyticsUrlState(); + window.requestAnimationFrame(() => { + focusAnalyticsDrilldownPrimaryControl(true); + }); + void loadAnalyticsDrilldownPage(); +} + +function renderAnalyticsEmptyState() { + const box = $('analyticsEmptyState'); + if (state.analytics.error) { + box.hidden = false; + box.textContent = `Не удалось обновить витрину: ${state.analytics.error}`; + return; + } + const total = Number(state.analytics.overview?.volume?.total || 0); + if (!state.analytics.loading && total === 0) { + box.hidden = false; + box.textContent = 'За выбранный период обращений пока нет. Измените период, очередь или канал, чтобы увидеть данные.'; + return; + } + box.hidden = true; + box.textContent = ''; +} + +function renderAnalyticsDashboard(rangeMeta = analyticsRangeFromControls()) { + const refreshBtn = $('refreshBtn'); + const analyticsRefreshBtn = $('analyticsRefreshBtn'); + const analyticsExportBtn = $('analyticsExportBtn'); + const analyticsSaveViewBtn = $('analyticsSaveViewBtn'); + if (refreshBtn) { + refreshBtn.disabled = state.analytics.loading; + } + if (analyticsRefreshBtn) { + analyticsRefreshBtn.disabled = state.analytics.loading; + } + if (analyticsExportBtn) { + analyticsExportBtn.disabled = state.analytics.loading; + } + if (analyticsSaveViewBtn) { + analyticsSaveViewBtn.disabled = state.analytics.loading; + } + renderAnalyticsQueueOptions(); + renderSavedAnalyticsViews(); + updateAnalyticsRangeHint(rangeMeta); + renderAnalyticsNarrative(rangeMeta); + renderAnalyticsComparePanel(); + renderAnalyticsOverview(); + renderAiAnalyticsEmptyState(); + renderAiAnalyticsOverview(); + renderAiAnalyticsTrendChart(); + renderAiAnalyticsChannelComparison(); + renderAiAnalyticsCoverage(); + renderAgentAnalyticsEmptyState(); + renderAgentAnalyticsOverview(); + renderAgentAnalyticsStateStrip(); + renderAgentAnalyticsTrendChart(); + renderAgentAnalyticsTeamTable(); + renderAgentAnalyticsShiftTable(); + renderAgentAnalyticsTable(); + renderAnalyticsTrendChart(); + renderAnalyticsChannelTable(); + renderAnalyticsQueueTable(); + renderAnalyticsCoverage(); + renderAnalyticsEmptyState(); + renderAnalyticsDrilldown(); +} + +async function loadAnalyticsDashboard(_silent = false) { + syncAnalyticsStateFromControls(); + const rangeMeta = analyticsRangeFromControls(); + const requestId = state.analytics.requestId + 1; + state.analytics.requestId = requestId; + state.analytics.lastRangeMeta = rangeMeta; + state.analytics.loading = true; + state.analytics.error = ''; + state.analytics.aiError = ''; + state.analytics.agentError = ''; + state.analytics.agentTrendError = ''; + renderAnalyticsDashboard(rangeMeta); + + if (analyticsMockEnabled()) { + const payload = buildMockAnalyticsDashboard(rangeMeta); + if (requestId !== state.analytics.requestId) { + return; + } + state.analytics.queueOptions = payload.queueOptions; + state.analytics.queueAccessError = ''; + state.analytics.overview = payload.overview; + state.analytics.compare = state.analytics.compareMode === 'previous' ? payload.compare : emptyKpiEnvelope(rangeMeta.previous.from.toISOString(), rangeMeta.previous.to.toISOString()); + state.analytics.coverage = payload.coverage; + state.analytics.aiOverview = payload.aiOverview; + state.analytics.aiCompare = state.analytics.compareMode === 'previous' ? payload.aiCompare : emptyAiAnalyticsOverview(rangeMeta.previous.from.toISOString(), rangeMeta.previous.to.toISOString(), state.analytics.channel, state.analytics.queueId === 'all' ? null : state.analytics.queueId); + state.analytics.agentOverview = payload.agentOverview; + state.analytics.agentCompare = state.analytics.compareMode === 'previous' ? payload.agentCompare : emptyAgentAnalyticsOverview(rangeMeta.previous.from.toISOString(), rangeMeta.previous.to.toISOString(), state.analytics.channel, state.analytics.queueId === 'all' ? null : state.analytics.queueId); + state.analytics.agentRows = Array.isArray(payload.agentOverview?.items) ? payload.agentOverview.items : []; + state.analytics.channelRows = payload.channelRows; + state.analytics.queueRows = payload.queueRows; + state.analytics.trend = payload.trend; + state.analytics.aiTrend = payload.aiTrend; + state.analytics.agentTrend = payload.agentTrend; + state.analytics.loading = false; + state.analytics.statusMessage = 'Показаны демонстрационные данные.'; + renderAnalyticsDashboard(rangeMeta); + syncAnalyticsUrlState(); + if (state.drilldown.open) { + void loadAnalyticsDrilldownPage({ preserveSelection: true }); + } + return; + } + + try { + const queuePromise = loadAnalyticsQueueOptions(); + const previousWindowPromise = state.analytics.compareMode === 'previous' + ? fetchAnalyticsKpi(rangeMeta.previous).catch(() => emptyKpiEnvelope()) + : Promise.resolve(emptyKpiEnvelope(rangeMeta.previous.from.toISOString(), rangeMeta.previous.to.toISOString())); + const emptyAiCurrent = emptyAiAnalyticsOverview( + rangeMeta.current.from.toISOString(), + rangeMeta.current.to.toISOString(), + aiAnalyticsSupportedChannel() ? state.analytics.channel : state.analytics.channel, + state.analytics.queueId === 'all' ? null : state.analytics.queueId, + ); + const emptyAiPrevious = emptyAiAnalyticsOverview( + rangeMeta.previous.from.toISOString(), + rangeMeta.previous.to.toISOString(), + aiAnalyticsSupportedChannel() ? state.analytics.channel : state.analytics.channel, + state.analytics.queueId === 'all' ? null : state.analytics.queueId, + ); + const emptyAgentCurrent = emptyAgentAnalyticsOverview( + rangeMeta.current.from.toISOString(), + rangeMeta.current.to.toISOString(), + state.analytics.channel, + state.analytics.queueId === 'all' ? null : state.analytics.queueId, + ); + const emptyAgentPrevious = emptyAgentAnalyticsOverview( + rangeMeta.previous.from.toISOString(), + rangeMeta.previous.to.toISOString(), + state.analytics.channel, + state.analytics.queueId === 'all' ? null : state.analytics.queueId, + ); + const aiOverviewPromise = aiAnalyticsSupportedChannel() + ? fetchAiAnalyticsOverview(rangeMeta.current).catch((err) => { + state.analytics.aiError = err.message; + return emptyAiCurrent; + }) + : Promise.resolve(emptyAiCurrent); + const aiComparePromise = aiAnalyticsSupportedChannel() && state.analytics.compareMode === 'previous' + ? fetchAiAnalyticsOverview(rangeMeta.previous).catch(() => emptyAiPrevious) + : Promise.resolve(emptyAiPrevious); + const agentOverviewPromise = fetchAgentAnalyticsOverview(rangeMeta.current).catch((err) => { + state.analytics.agentError = err.message; + return emptyAgentCurrent; + }); + const agentComparePromise = state.analytics.compareMode === 'previous' + ? fetchAgentAnalyticsOverview(rangeMeta.previous).catch(() => emptyAgentPrevious) + : Promise.resolve(emptyAgentPrevious); + const [current, previous, coverage, queues, aiOverview, aiCompare, agentOverview, agentCompare] = await Promise.all([ + fetchAnalyticsKpi(rangeMeta.current), + previousWindowPromise, + api('reporting', 'reports/coverage').catch(() => ({ implemented_metrics: [], dimensions: [], supported_filters: [] })), + queuePromise, + aiOverviewPromise, + aiComparePromise, + agentOverviewPromise, + agentComparePromise, + ]); + + if (requestId !== state.analytics.requestId) { + return; + } + + state.analytics.overview = current || emptyKpiEnvelope(rangeMeta.current.from.toISOString(), rangeMeta.current.to.toISOString()); + state.analytics.compare = previous || emptyKpiEnvelope(rangeMeta.previous.from.toISOString(), rangeMeta.previous.to.toISOString()); + state.analytics.coverage = coverage || { implemented_metrics: [], dimensions: [], supported_filters: [] }; + state.analytics.aiOverview = aiOverview || emptyAiCurrent; + state.analytics.aiCompare = aiCompare || emptyAiPrevious; + state.analytics.agentOverview = agentOverview || emptyAgentCurrent; + state.analytics.agentCompare = agentCompare || emptyAgentPrevious; + state.analytics.agentRows = Array.isArray(agentOverview?.items) ? agentOverview.items : []; + + const byChannel = current?.breakdowns?.by_channel || {}; + state.analytics.channelRows = Object.entries(byChannel) + .map(([channel, payload]) => ({ + channel, + total: Number(payload?.total || 0), + answered: Number(payload?.answered || 0), + abandoned: Number(payload?.abandoned || 0), + })) + .sort((a, b) => b.total - a.total || a.channel.localeCompare(b.channel)); + + const [trend, queueRows, aiTrend, agentTrend] = await Promise.all([ + loadAnalyticsTrend(rangeMeta, requestId), + loadAnalyticsQueueRows(rangeMeta, queues, requestId), + loadAiAnalyticsTrend(rangeMeta, requestId), + loadAgentAnalyticsTrend(rangeMeta, requestId), + ]); + + if (requestId !== state.analytics.requestId) { + return; + } + + state.analytics.trend = trend; + state.analytics.queueRows = queueRows; + state.analytics.aiTrend = aiTrend; + state.analytics.agentTrend = agentTrend; + state.analytics.loading = false; + renderAnalyticsDashboard(rangeMeta); + syncAnalyticsUrlState(); + if (state.drilldown.open) { + void loadAnalyticsDrilldownPage({ preserveSelection: true }); + } + } catch (err) { + if (requestId !== state.analytics.requestId) { + return; + } + state.analytics.loading = false; + state.analytics.error = err.message; + state.analytics.overview = emptyKpiEnvelope(); + state.analytics.compare = emptyKpiEnvelope(); + state.analytics.aiOverview = emptyAiAnalyticsOverview(); + state.analytics.aiCompare = emptyAiAnalyticsOverview(); + state.analytics.agentOverview = emptyAgentAnalyticsOverview(); + state.analytics.agentCompare = emptyAgentAnalyticsOverview(); + state.analytics.coverage = { implemented_metrics: [], dimensions: [], supported_filters: [] }; + state.analytics.trend = []; + state.analytics.aiTrend = emptyAiAnalyticsTimeseries(state.analytics.aiTrendMetric || 'containment_rate'); + state.analytics.agentTrend = emptyAgentAnalyticsTimeseries(state.analytics.agentTrendMetric || 'interactions_per_agent'); + state.analytics.channelRows = []; + state.analytics.queueRows = []; + state.analytics.agentRows = []; + renderAnalyticsDashboard(rangeMeta); + syncAnalyticsUrlState(); + } +} + +function handleAnalyticsOverviewClick(event) { + const button = event.target.closest('[data-analytics-drilldown-source]'); + if (!button) { + return; + } + const metric = button.dataset.analyticsDrilldownMetric || ''; + const sourceType = button.dataset.analyticsDrilldownSource || 'overview'; + if (metric) { + openAnalyticsDrilldown('metric', metric, { + mode: 'metric', + metric, + sourceLabel: button.dataset.analyticsDrilldownLabel || analyticsDrilldownSourceLabel('metric', metric), + coverage: analyticsMetricCoverage(metric), + metricNote: analyticsMetricDrilldownNote(metric), + }); + return; + } + openAnalyticsDrilldown(sourceType); +} + +function handleAnalyticsChannelClick(event) { + const button = event.target.closest('[data-analytics-channel]'); + if (!button) { + return; + } + openAnalyticsDrilldown('channel', button.dataset.analyticsChannel || ''); +} + +function handleAnalyticsQueueClick(event) { + const button = event.target.closest('[data-analytics-queue-id]'); + if (!button) { + return; + } + openAnalyticsDrilldown('queue', button.dataset.analyticsQueueId || ''); +} + +function handleAgentAnalyticsClick(event) { + const button = event.target.closest('[data-analytics-agent-id]'); + if (!button) { + return; + } + openAnalyticsDrilldown('agent', button.dataset.analyticsAgentId || '', { + sourceLabel: button.dataset.analyticsSourceLabel || `Агент: ${button.dataset.analyticsAgentId || ''}`, + }); +} + +handleAnalyticsDrilldownListClick = function handleAnalyticsDrilldownListClick(event) { + if (state.drilldown.mode === 'ai') { + const button = event.target.closest('[data-analytics-row-id]'); + if (!button) { + return; + } + maybeScrollAnalyticsDrilldownDetailIntoView(); + void loadAnalyticsDrilldownDetails(button.dataset.analyticsRowId || ''); + return; + } + const button = event.target.closest('[data-analytics-interaction-id]'); + if (!button) { + return; + } + maybeScrollAnalyticsDrilldownDetailIntoView(); + void loadAnalyticsDrilldownDetails(button.dataset.analyticsInteractionId || ''); +} + +function handleAnalyticsDrilldownPrevPage() { + if (!state.drilldown.open || state.drilldown.offset <= 0 || state.drilldown.loading) { + return; + } + state.drilldown.offset = Math.max(0, state.drilldown.offset - state.drilldown.limit); + void loadAnalyticsDrilldownPage(); +} + +function handleAnalyticsDrilldownNextPage() { + if (!state.drilldown.open || state.drilldown.loading) { + return; + } + if (state.drilldown.offset + state.drilldown.limit >= state.drilldown.total) { + return; + } + state.drilldown.offset += state.drilldown.limit; + void loadAnalyticsDrilldownPage(); +} + +function handleAnalyticsFilterChange() { + closeAnalyticsDrilldown({ skipUrlSync: true }); + syncAnalyticsStateFromControls(); + detachActiveAnalyticsView(); + renderSavedAnalyticsViews(); + syncAnalyticsUrlState(); + void loadAnalyticsDashboard(); +} + +function handleAnalyticsPresetChange() { + state.analytics.fromTs = ''; + state.analytics.toTs = ''; + $('analyticsFrom').value = ''; + $('analyticsTo').value = ''; + handleAnalyticsFilterChange(); +} + +function applySelectedAnalyticsView() { + const viewId = $('analyticsSavedViewSelect').value || ''; + if (!viewId) { + state.analytics.activeViewId = ''; + state.analytics.statusMessage = ''; + renderSavedAnalyticsViews(); + syncAnalyticsUrlState(); + return; + } + const view = state.analytics.savedViews.find((item) => item.id === viewId); + if (!view) { + state.analytics.activeViewId = ''; + renderSavedAnalyticsViews(); + syncAnalyticsUrlState(); + return; + } + state.analytics.activeViewId = view.id; + state.analytics.statusMessage = `Загружен вид «${view.name}».`; + applyAnalyticsSnapshot(view.snapshot); + closeAnalyticsDrilldown({ skipUrlSync: true }); + syncAnalyticsControlsFromState(); + syncAnalyticsUrlState(); + void loadAnalyticsDashboard(); +} + +function aiAnalyticsOutcomeSessions(payload, outcome) { + const rows = Array.isArray(payload?.breakdowns?.by_outcome) ? payload.breakdowns.by_outcome : []; + return Number(rows.find((item) => item.outcome === outcome)?.sessions || 0); +} + +function aiAnalyticsCanDrilldown(metric, payload = state.analytics.aiOverview || emptyAiAnalyticsOverview()) { + if (!aiAnalyticsSupportedChannel() || state.analytics.aiError) { + return false; + } + if (!['containment_rate', 'handoff_rate', 'human_touched_rate', 'closed_without_operator_rate'].includes(metric)) { + return false; + } + const total = Number(payload?.totals?.sessions_started || 0); + if (!total) { + return false; + } + return aiAnalyticsOutcomeSessions(payload, aiAnalyticsMetricSlice(metric)) > 0; +} + +function aiAnalyticsMetricDetail(metric, payload) { + const totals = payload?.totals || {}; + if (metric === 'containment_rate') { + return `${formatAnalyticsNumber(totals.sessions_contained || 0, 0)} из ${formatAnalyticsNumber(totals.sessions_started || 0, 0)} сессий`; + } + if (metric === 'handoff_rate') { + return `${formatAnalyticsNumber(totals.sessions_handoff || 0, 0)} из ${formatAnalyticsNumber(totals.sessions_started || 0, 0)} сессий`; + } + if (metric === 'human_touched_rate') { + return `${formatAnalyticsNumber(aiAnalyticsOutcomeSessions(payload, 'human_touched'), 0)} сессий с участием оператора`; + } + if (metric === 'ai_latency_avg_ms') { + return `${formatAnalyticsNumber(totals.assistant_turns || 0, 0)} ответов AI`; + } + if (metric === 'closed_without_operator_rate') { + return `${formatAnalyticsNumber(totals.sessions_closed_without_operator || 0, 0)} закрытий без оператора`; + } + return ''; +} + +function renderAiAnalyticsCard(label, metric, currentPayload, previousPayload) { + const value = aiAnalyticsMetricValue(metric, currentPayload); + const showCompare = state.analytics.compareMode === 'previous'; + const delta = showCompare ? aiAnalyticsDelta(metric, currentPayload, previousPayload) : null; + const note = AI_ANALYTICS_METRIC_META[metric]?.note || ''; + const detail = aiAnalyticsMetricDetail(metric, currentPayload); + const drilldownEnabled = aiAnalyticsCanDrilldown(metric, currentPayload); + const slice = aiAnalyticsMetricSlice(metric); + const tag = drilldownEnabled ? 'button' : 'div'; + const classes = [ + 'summary-card', + 'analytics-card', + 'ai-analytics-card', + drilldownEnabled ? 'analytics-card-button' : 'analytics-card-readonly', + ].join(' '); + const attributes = drilldownEnabled + ? `type="button" data-ai-drilldown-slice="${escapeHtml(slice)}" data-ai-drilldown-label="${escapeHtml(label)}"` + : ''; + return ` + <${tag} class="${classes}" ${attributes}> +
            +
            ${label}
            + ${showCompare ? `${delta.text}` : ''} +
            +
            ${formatAiAnalyticsMetric(metric, value)}
            + ${detail ? `
            ${detail}
            ` : ''} + ${note ? `
            ${note}
            ` : ''} + + `; +} + +function renderAiAnalyticsOverview() { + const box = $('aiAnalyticsOverview'); + if (!box) { + return; + } + if (!aiAnalyticsSupportedChannel() || state.analytics.aiError) { + box.innerHTML = ''; + return; + } + const current = state.analytics.aiOverview || emptyAiAnalyticsOverview(); + if (!state.analytics.loading && Number(current?.totals?.sessions_started || 0) === 0) { + box.innerHTML = ''; + return; + } + const previous = state.analytics.aiCompare || emptyAiAnalyticsOverview(); + box.innerHTML = [ + renderAiAnalyticsCard('Закрыто AI', 'containment_rate', current, previous), + renderAiAnalyticsCard('Передано оператору', 'handoff_rate', current, previous), + renderAiAnalyticsCard('С участием оператора', 'human_touched_rate', current, previous), + renderAiAnalyticsCard('Закрыто без оператора', 'closed_without_operator_rate', current, previous), + ].join(''); +} + +function renderAiAnalyticsChannelComparison() { + const container = $('aiAnalyticsChannelComparison'); + const rows = Array.isArray(state.analytics.aiOverview?.breakdowns?.by_channel) + ? state.analytics.aiOverview.breakdowns.by_channel + : []; + if (!container) { + return; + } + if (!aiAnalyticsSupportedChannel() || state.analytics.aiError) { + container.innerHTML = ''; + return; + } + if (!rows.length) { + container.innerHTML = '
            Нет AI-сессий по Telegram и WhatsApp за выбранный период.
            '; + return; + } + + container.innerHTML = ` +
            + Канал + Сессии + Только AI + С оператором + Передача + Средняя задержка +
            + ${rows + .map((item) => { + const aiOnlyShare = item.sessions_started ? (item.ai_only_sessions / item.sessions_started) * 100 : 0; + const humanTouchedShare = item.sessions_started ? (item.human_touched_sessions / item.sessions_started) * 100 : 0; + const tag = item.sessions_started ? 'button' : 'div'; + const attributes = item.sessions_started + ? `type="button" class="analytics-table-row ai-analytics-channel-grid ai-analytics-row-button" data-ai-channel="${escapeHtml(item.channel)}"` + : 'class="analytics-table-row ai-analytics-channel-grid"'; + return ` + <${tag} ${attributes}> + ${escapeHtml(analyticsChannelLabel(item.channel))} + ${formatAnalyticsNumber(item.sessions_started, 0)} + ${formatAnalyticsNumber(aiOnlyShare, 2)}% + ${formatAnalyticsNumber(humanTouchedShare, 2)}% + ${formatAnalyticsNumber(item.handoff_rate || 0, 2)}% + ${formatAiAnalyticsMetric('ai_latency_avg_ms', item.ai_latency_avg_ms)} + + `; + }) + .join('')} + `; +} + +function renderAiAnalyticsCoverage() { + const container = $('aiAnalyticsCoverage'); + const coverage = state.analytics.aiOverview?.coverage || emptyAiAnalyticsOverview().coverage; + if (!container) { + return; + } + if (!aiAnalyticsSupportedChannel() || state.analytics.aiError) { + container.innerHTML = ''; + return; + } + if (!state.analytics.loading && Number(state.analytics.aiOverview?.totals?.sessions_started || 0) === 0) { + container.innerHTML = ''; + return; + } + container.innerHTML = ` +
            +
            + ${formatAnalyticsNumber(coverage.sessions_with_interaction_id || 0, 0)} +

            AI-сессий связаны с ID обращения

            +
            +
            + ${formatAnalyticsNumber(coverage.sessions_with_queue_id || 0, 0)} +

            AI-сессий имеют ID очереди через обращение или диалог

            +
            +
            + ${formatAnalyticsNumber(coverage.sessions_with_latency_turns || 0, 0)} +

            AI-сессий содержат данные по задержке ответов модели

            +
            +
            + ${formatAnalyticsNumber(coverage.sessions_with_terminal_state || 0, 0)} +

            AI-сессий дошли до финального состояния

            +
            +
            + ${formatAnalyticsNumber(coverage.sessions_with_handoff_reason || 0, 0)} +

            AI-сессий содержат причину передачи

            +
            +
            + `; +} + +function renderAiAnalyticsOutcomeTable() { + const container = $('aiAnalyticsOutcomeTable'); + const rows = Array.isArray(state.analytics.aiOverview?.breakdowns?.by_outcome) + ? state.analytics.aiOverview.breakdowns.by_outcome + : []; + if (!container) { + return; + } + if (!aiAnalyticsSupportedChannel() || state.analytics.aiError) { + container.innerHTML = ''; + return; + } + if (!rows.length) { + container.innerHTML = '
            Срезы по исходам появятся, когда в окне будут AI-сессии.
            '; + return; + } + container.innerHTML = ` +
            + Исход + Сессии + Доля +
            + ${rows + .map((item) => { + const tag = item.sessions ? 'button' : 'div'; + const attributes = item.sessions + ? `type="button" class="analytics-table-row ai-analytics-outcome-grid ai-analytics-row-button" data-ai-outcome="${escapeHtml(item.outcome)}" data-ai-outcome-label="${escapeHtml(item.label || aiAnalyticsSliceLabel(item.outcome))}"` + : 'class="analytics-table-row ai-analytics-outcome-grid"'; + return ` + <${tag} ${attributes}> + ${escapeHtml(item.label || aiAnalyticsSliceLabel(item.outcome))} + ${formatAnalyticsNumber(item.sessions, 0)} + ${formatAnalyticsNumber(item.share || 0, 2)}% + + `; + }) + .join('')} + `; +} + +function renderAiAnalyticsReasonTable() { + const container = $('aiAnalyticsReasonTable'); + const rows = Array.isArray(state.analytics.aiOverview?.breakdowns?.by_handoff_reason) + ? state.analytics.aiOverview.breakdowns.by_handoff_reason + : []; + if (!container) { + return; + } + if (!aiAnalyticsSupportedChannel() || state.analytics.aiError) { + container.innerHTML = ''; + return; + } + if (!rows.length) { + container.innerHTML = '
            За выбранный период handoff reasons не найдены.
            '; + return; + } + container.innerHTML = ` +
            + Причина + Сессии + Доля +
            + ${rows + .map((item) => ` + + `) + .join('')} + `; +} + +const renderAnalyticsDashboardBase = renderAnalyticsDashboard; +const syncAnalyticsDrilldownControlsBase = syncAnalyticsDrilldownControls; +const analyticsDrilldownFiltersForCurrentSourceBase = analyticsDrilldownFiltersForCurrentSource; +const applyAnalyticsDrilldownLocalFiltersBase = applyAnalyticsDrilldownLocalFilters; +const resetAnalyticsDrilldownLocalFiltersBase = resetAnalyticsDrilldownLocalFilters; +const handleAnalyticsDrilldownSearchInputBase = handleAnalyticsDrilldownSearchInput; +const handleAnalyticsDrilldownSearchKeydownBase = handleAnalyticsDrilldownSearchKeydown; +const handleAnalyticsDrilldownStatusChangeBase = handleAnalyticsDrilldownStatusChange; +const handleAnalyticsDrilldownSortChangeBase = handleAnalyticsDrilldownSortChange; + +renderAnalyticsDashboard = function renderAnalyticsDashboard(rangeMeta = analyticsRangeFromControls()) { + renderAnalyticsDashboardBase(rangeMeta); + renderAiAnalyticsOutcomeTable(); + renderAiAnalyticsReasonTable(); +} + +analyticsDrilldownChips = function analyticsDrilldownChips() { + if (!state.drilldown.filters) { + return []; + } + const drilldownFilters = normalizeAnalyticsDrilldownFilters(state.drilldown.filters); + const chips = []; + chips.push(state.drilldown.sourceLabel || 'Детализация'); + chips.push(`${formatTime(drilldownFilters.from_ts)} - ${formatTime(drilldownFilters.to_ts)}`); + if (drilldownFilters.channel) { + chips.push(analyticsChannelLabel(drilldownFilters.channel)); + } + if (drilldownFilters.queue_id) { + chips.push(analyticsQueueName(drilldownFilters.queue_id)); + } + if (drilldownFilters.agent_id) { + chips.push(`Агент: ${drilldownFilters.agent_id}`); + } + if (state.drilldown.mode === 'ai' && drilldownFilters.slice && drilldownFilters.slice !== 'all') { + chips.push(aiAnalyticsSliceLabel(drilldownFilters.slice)); + } + if (state.drilldown.mode === 'ai' && drilldownFilters.reason_key) { + chips.push(`Причина: ${drilldownFilters.reason_key}`); + } + if (drilldownFilters.status) { + chips.push(`Статус: ${state.drilldown.mode === 'ai' ? aiAnalyticsStatusLabel(drilldownFilters.status) : interactionStatusLabel(drilldownFilters.status)}`); + } + if (drilldownFilters.q) { + chips.push(`Поиск: ${drilldownFilters.q}`); + } + if (state.drilldown.mode === 'metric' && state.drilldown.metric) { + chips.push(analyticsMetricCoverageLabel(state.drilldown.metric)); + } else if (state.drilldown.mode === 'ai') { + chips.push(aiAnalyticsSortLabel(drilldownFilters)); + } else { + chips.push(analyticsDrilldownSortLabel(drilldownFilters)); + } + return chips; +} + +function analyticsDrilldownCurrentItemId(item) { + if (!item) { + return ''; + } + if (state.drilldown.mode === 'ai') { + return String(item.session_id || ''); + } + return String(item.interaction_id || ''); +} + +syncAnalyticsDrilldownControls = function syncAnalyticsDrilldownControls() { + if (state.drilldown.mode !== 'ai') { + syncAnalyticsDrilldownControlsBase(); + return; + } + const searchInput = $('analyticsDrilldownSearch'); + const statusSelect = $('analyticsDrilldownStatus'); + const sortSelect = $('analyticsDrilldownSort'); + const clearBtn = $('analyticsDrilldownClearBtn'); + const exportBtn = $('analyticsDrilldownExportBtn'); + const isOpen = state.drilldown.open; + const localFiltersEnabled = isOpen; + const filters = normalizeAnalyticsDrilldownFilters(state.drilldown.filters || {}); + + if (statusSelect) { + statusSelect.innerHTML = [ + '', + '', + '', + '', + '', + '', + ].join(''); + statusSelect.value = isOpen ? (filters.status || 'all') : 'all'; + statusSelect.disabled = !localFiltersEnabled || state.drilldown.loading || state.drilldown.exporting; + } + if (sortSelect) { + sortSelect.innerHTML = [ + '', + '', + '', + '', + '', + ].join(''); + sortSelect.value = analyticsDrilldownSortValue(filters); + sortSelect.disabled = !localFiltersEnabled || state.drilldown.loading || state.drilldown.exporting; + } + if (searchInput) { + searchInput.value = isOpen ? filters.q : ''; + searchInput.disabled = !localFiltersEnabled || state.drilldown.loading || state.drilldown.exporting; + searchInput.placeholder = 'ID сессии, диалога, обращения или причина'; + } + if (clearBtn) { + clearBtn.disabled = !localFiltersEnabled || state.drilldown.loading || state.drilldown.exporting; + } + if (exportBtn) { + exportBtn.disabled = !isOpen || state.drilldown.loading || state.drilldown.exporting || !state.drilldown.total; + exportBtn.textContent = state.drilldown.exporting ? 'Готовим CSV...' : 'Экспорт CSV'; + } +} + +analyticsDrilldownFiltersForCurrentSource = function analyticsDrilldownFiltersForCurrentSource() { + if (state.drilldown.mode !== 'ai') { + return analyticsDrilldownFiltersForCurrentSourceBase(); + } + const filters = analyticsDrilldownBaseFilters(); + filters.slice = 'all'; + filters.reason_key = ''; + filters.status = ''; + filters.q = ''; + filters.sort_by = 'created_at'; + filters.sort_dir = 'desc'; + if (state.drilldown.sourceType === 'ai-overview' && state.drilldown.sourceValue) { + filters.slice = state.drilldown.sourceValue; + } + if (state.drilldown.sourceType === 'ai-channel' && state.drilldown.sourceValue) { + filters.channel = state.drilldown.sourceValue; + } + if (state.drilldown.sourceType === 'ai-reason' && state.drilldown.sourceValue) { + filters.slice = 'handoff'; + filters.reason_key = state.drilldown.sourceValue; + } + return normalizeAnalyticsDrilldownFilters(filters); +} + +applyAnalyticsDrilldownLocalFilters = function applyAnalyticsDrilldownLocalFilters(nextFilters, options = {}) { + if (!state.drilldown.open || state.drilldown.mode === 'metric') { + if (state.drilldown.mode !== 'ai') { + return applyAnalyticsDrilldownLocalFiltersBase(nextFilters, options); + } + return; + } + state.drilldown.filters = normalizeAnalyticsDrilldownFilters(nextFilters); + state.drilldown.offset = 0; + state.drilldown.notice = ''; + void loadAnalyticsDrilldownPage({ preserveSelection: options.preserveSelection !== false }); +} + +resetAnalyticsDrilldownLocalFilters = function resetAnalyticsDrilldownLocalFilters() { + clearAnalyticsDrilldownSearchDebounce(); + if (!state.drilldown.open || state.drilldown.mode === 'metric') { + if (state.drilldown.mode !== 'ai') { + return resetAnalyticsDrilldownLocalFiltersBase(); + } + return; + } + state.drilldown.filters = analyticsDrilldownFiltersForCurrentSource(); + state.drilldown.offset = 0; + state.drilldown.notice = ''; + renderAnalyticsDrilldown(); + void loadAnalyticsDrilldownPage({ preserveSelection: true }); +} + +handleAnalyticsDrilldownSearchInput = function handleAnalyticsDrilldownSearchInput(event) { + if (!state.drilldown.open || state.drilldown.mode === 'metric') { + if (state.drilldown.mode !== 'ai') { + return handleAnalyticsDrilldownSearchInputBase(event); + } + return; + } + clearAnalyticsDrilldownSearchDebounce(); + const query = event.target?.value || ''; + analyticsDrilldownSearchDebounce = window.setTimeout(() => { + if (!state.drilldown.open || !state.drilldown.filters) { + return; + } + const normalizedQuery = query.trim(); + if ((state.drilldown.filters.q || '') === normalizedQuery) { + return; + } + applyAnalyticsDrilldownLocalFilters({ + ...state.drilldown.filters, + q: normalizedQuery, + }); + }, 300); +} + +handleAnalyticsDrilldownSearchKeydown = function handleAnalyticsDrilldownSearchKeydown(event) { + if (event.key !== 'Enter' || !state.drilldown.open || !state.drilldown.filters || state.drilldown.mode === 'metric') { + if (state.drilldown.mode !== 'ai') { + return handleAnalyticsDrilldownSearchKeydownBase(event); + } + return; + } + event.preventDefault(); + clearAnalyticsDrilldownSearchDebounce(); + const normalizedQuery = (event.target?.value || '').trim(); + if ((state.drilldown.filters.q || '') === normalizedQuery) { + return; + } + applyAnalyticsDrilldownLocalFilters({ + ...state.drilldown.filters, + q: normalizedQuery, + }); +} + +handleAnalyticsDrilldownStatusChange = function handleAnalyticsDrilldownStatusChange(event) { + if (!state.drilldown.open || !state.drilldown.filters || state.drilldown.mode === 'metric') { + if (state.drilldown.mode !== 'ai') { + return handleAnalyticsDrilldownStatusChangeBase(event); + } + return; + } + const nextStatus = event.target?.value === 'all' ? '' : String(event.target?.value || ''); + if ((state.drilldown.filters.status || '') === nextStatus) { + return; + } + clearAnalyticsDrilldownSearchDebounce(); + applyAnalyticsDrilldownLocalFilters({ + ...state.drilldown.filters, + status: nextStatus, + }); +} + +handleAnalyticsDrilldownSortChange = function handleAnalyticsDrilldownSortChange(event) { + if (!state.drilldown.open || !state.drilldown.filters || state.drilldown.mode === 'metric') { + if (state.drilldown.mode !== 'ai') { + return handleAnalyticsDrilldownSortChangeBase(event); + } + return; + } + const nextSort = parseAnalyticsDrilldownSort(event.target?.value || 'created_at:desc'); + if ( + (state.drilldown.filters.sort_by || 'created_at') === nextSort.sort_by + && (state.drilldown.filters.sort_dir || 'desc') === nextSort.sort_dir + ) { + return; + } + clearAnalyticsDrilldownSearchDebounce(); + applyAnalyticsDrilldownLocalFilters({ + ...state.drilldown.filters, + ...nextSort, + }); +} + +function analyticsTimeseriesIntervalForRange(rangeMeta) { + const durationMs = Math.max(0, rangeMeta.current.to.getTime() - rangeMeta.current.from.getTime()); + return durationMs <= 36 * 60 * 60 * 1000 ? 'hour' : 'day'; +} + +async function fetchAnalyticsTimeseries(range, metric, interval, overrides = {}) { + const params = new URLSearchParams(analyticsQuery(range, overrides)); + params.set('metric', metric || state.analytics.trendMetric || 'volume'); + params.set('interval', interval || 'day'); + return api('reporting', `reports/timeseries?${params.toString()}`); +} + +function trendPointEnvelope(metric, value) { + const envelope = emptyKpiEnvelope(); + if (metric === 'volume' || metric === 'total') { + envelope.volume.total = Number(value || 0); + return envelope; + } + if (metric === 'answered') { + envelope.volume.answered = Number(value || 0); + return envelope; + } + envelope.kpi[metric] = Number(value || 0); + return envelope; +} + +async function loadAnalyticsTrend(rangeMeta, requestId) { + const metric = state.analytics.trendMetric || 'volume'; + const interval = analyticsTimeseriesIntervalForRange(rangeMeta); + try { + const payload = await fetchAnalyticsTimeseries(rangeMeta.current, metric, interval); + if (requestId !== state.analytics.requestId) { + return []; + } + const resolvedInterval = payload?.interval || interval; + state.analytics.trendInterval = resolvedInterval; + const points = Array.isArray(payload?.points) ? payload.points : []; + return points.map((point) => ({ + ts: point.ts, + label: formatAnalyticsBucketLabel(point.ts, resolvedInterval), + payload: trendPointEnvelope(metric, point.value), + sampleSize: Number(point.sample_size || 0), + })); + } catch { + state.analytics.trendInterval = interval; + return []; + } +} + +function reportingExportQuery(range, extra = {}) { + const params = new URLSearchParams(analyticsQuery(range, extra)); + if (extra.metric) { + params.set('metric', extra.metric); + } + return params.toString(); +} + +async function exportAnalyticsCsv() { + const rangeMeta = state.analytics.lastRangeMeta || analyticsRangeFromControls(); + const dateStamp = new Date().toISOString().slice(0, 10); + try { + await downloadProxyFile( + 'reporting', + `reports/export?${reportingExportQuery(rangeMeta.current)}`, + `konturcc-analytics-${dateStamp}.csv`, + ); + state.analytics.statusMessage = `CSV выгружен в ${new Date().toLocaleTimeString('ru-RU', { + hour: '2-digit', + minute: '2-digit', + })}.`; + } catch (err) { + state.analytics.statusMessage = `Не удалось выгрузить CSV: ${err.message}`; + } + renderSavedAnalyticsViews(); +} + +async function loadSavedAnalyticsViews() { + try { + const items = await api('reporting', 'reports/views'); + state.analytics.savedViews = Array.isArray(items) + ? items + .filter((item) => item && typeof item === 'object') + .map((item) => normalizeSavedAnalyticsView(item)) + .sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt))) + : []; + } catch (err) { + state.analytics.savedViews = []; + state.analytics.statusMessage = `Не удалось загрузить сохранённые виды: ${err.message}`; + } + if ( + state.analytics.activeViewId + && !state.analytics.savedViews.some((item) => item.id === state.analytics.activeViewId) + ) { + state.analytics.activeViewId = ''; + } + renderSavedAnalyticsViews(); +} + +async function saveAnalyticsView() { + syncAnalyticsStateFromControls(); + const input = $('analyticsSavedViewName'); + const name = input?.value.trim() || suggestAnalyticsViewName(); + try { + const view = await api('reporting', 'reports/views', { + method: 'POST', + body: JSON.stringify({ + id: state.analytics.activeViewId || null, + name, + snapshot: analyticsCurrentSnapshot(), + }), + }); + const normalized = normalizeSavedAnalyticsView(view); + const existingIndex = state.analytics.savedViews.findIndex((item) => item.id === normalized.id); + if (existingIndex >= 0) { + state.analytics.savedViews.splice(existingIndex, 1, normalized); + state.analytics.statusMessage = `Вид «${name}» обновлён.`; + } else { + state.analytics.savedViews.unshift(normalized); + state.analytics.statusMessage = `Вид «${name}» сохранён.`; + } + state.analytics.savedViews = state.analytics.savedViews + .sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt))) + .slice(0, 12); + state.analytics.activeViewId = normalized.id; + } catch (err) { + state.analytics.statusMessage = `Не удалось сохранить вид: ${err.message}`; + } + renderSavedAnalyticsViews(); +} + +async function deleteAnalyticsView() { + const activeView = state.analytics.savedViews.find((item) => item.id === state.analytics.activeViewId) || null; + if (!activeView) { + state.analytics.statusMessage = 'Выберите сохранённый вид, чтобы удалить его.'; + renderSavedAnalyticsViews(); + return; + } + try { + await api('reporting', `reports/views/${encodeURIComponent(activeView.id)}`, { method: 'DELETE' }); + state.analytics.savedViews = state.analytics.savedViews.filter((item) => item.id !== activeView.id); + state.analytics.activeViewId = ''; + state.analytics.statusMessage = `Вид «${activeView.name}» удалён.`; + } catch (err) { + state.analytics.statusMessage = `Не удалось удалить вид: ${err.message}`; + } + renderSavedAnalyticsViews(); +} + +function wire() { + $('logoutBtn').addEventListener('click', logout); + $('refreshBtn').addEventListener('click', () => { + void loadAnalyticsDashboard(); + }); + $('analyticsRefreshBtn').addEventListener('click', () => { + void loadAnalyticsDashboard(); + }); + $('analyticsResetBtn').addEventListener('click', () => { + resetAnalyticsFilters(); + void loadAnalyticsDashboard(); + }); + $('analyticsPreset').addEventListener('change', handleAnalyticsPresetChange); + $('analyticsFrom').addEventListener('change', handleAnalyticsFilterChange); + $('analyticsTo').addEventListener('change', handleAnalyticsFilterChange); + $('analyticsQueue').addEventListener('change', handleAnalyticsFilterChange); + $('analyticsChannel').addEventListener('change', handleAnalyticsFilterChange); + $('analyticsCompareMode').addEventListener('change', handleAnalyticsFilterChange); + $('analyticsSavedViewSelect').addEventListener('change', applySelectedAnalyticsView); + $('analyticsSaveViewBtn').addEventListener('click', async () => { + await saveAnalyticsView(); + syncAnalyticsUrlState(); + }); + $('analyticsDeleteViewBtn').addEventListener('click', async () => { + await deleteAnalyticsView(); + syncAnalyticsUrlState(); + }); + $('analyticsExportBtn').addEventListener('click', exportAnalyticsCsv); + $('analyticsTrendMetric').addEventListener('change', () => { + syncAnalyticsStateFromControls(); + detachActiveAnalyticsView(); + renderSavedAnalyticsViews(); + renderAnalyticsTrendChart(); + syncAnalyticsUrlState(); + }); + $('aiAnalyticsTrendMetric').addEventListener('change', () => { + syncAnalyticsStateFromControls(); + detachActiveAnalyticsView(); + renderSavedAnalyticsViews(); + syncAnalyticsUrlState(); + void loadAnalyticsDashboard(); + }); + $('agentAnalyticsTrendMetric')?.addEventListener('change', () => { + syncAnalyticsStateFromControls(); + detachActiveAnalyticsView(); + renderSavedAnalyticsViews(); + syncAnalyticsUrlState(); + void loadAnalyticsDashboard(); + }); + $('analyticsOverview').addEventListener('click', handleAnalyticsOverviewClick); + $('analyticsChannelTable').addEventListener('click', handleAnalyticsChannelClick); + $('analyticsQueueTable').addEventListener('click', handleAnalyticsQueueClick); + $('agentAnalyticsTable')?.addEventListener('click', handleAgentAnalyticsClick); + $('analyticsDrilldownCloseBtn').addEventListener('click', closeAnalyticsDrilldown); + $('analyticsDrilldownBackdrop').addEventListener('click', closeAnalyticsDrilldown); + $('analyticsDrilldownList').addEventListener('click', handleAnalyticsDrilldownListClick); + $('analyticsDrilldownPrevBtn').addEventListener('click', handleAnalyticsDrilldownPrevPage); + $('analyticsDrilldownNextBtn').addEventListener('click', handleAnalyticsDrilldownNextPage); + $('analyticsDrilldownSearch').addEventListener('input', handleAnalyticsDrilldownSearchInput); + $('analyticsDrilldownSearch').addEventListener('keydown', handleAnalyticsDrilldownSearchKeydown); + $('analyticsDrilldownStatus').addEventListener('change', handleAnalyticsDrilldownStatusChange); + $('analyticsDrilldownSort').addEventListener('change', handleAnalyticsDrilldownSortChange); + $('analyticsDrilldownClearBtn').addEventListener('click', resetAnalyticsDrilldownLocalFilters); + $('analyticsDrilldownExportBtn').addEventListener('click', () => { + void exportAnalyticsDrilldownCsv(); + }); + window.addEventListener('keydown', handleAnalyticsDrilldownWindowKeydown); + window.addEventListener('message', handleOidcMessage); + $('aiAnalyticsOverview')?.addEventListener('click', handleAiAnalyticsOverviewClick); + $('aiAnalyticsChannelComparison')?.addEventListener('click', handleAiAnalyticsChannelClick); + $('aiAnalyticsOutcomeTable')?.addEventListener('click', handleAiAnalyticsOutcomeClick); + $('aiAnalyticsReasonTable')?.addEventListener('click', handleAiAnalyticsReasonClick); +} + +async function init() { + if (!restoreStoredSession()) { + window.location.href = '/'; + return; + } + if (!ensurePageAccess()) { + return; + } + wire(); + updateSessionInfo(); + const deepLinkState = parseAnalyticsDeepLinkState(); + analyticsUrlSyncSuspended = true; + try { + resetAnalyticsFilters(); + if (deepLinkState.hasSnapshot) { + applyAnalyticsSnapshot(deepLinkState.snapshot); + } + state.analytics.activeViewId = deepLinkState.activeViewId || ''; + syncAnalyticsControlsFromState(); + await Promise.all([ + checkGateway(), + loadOidcConfig(), + loadSavedAnalyticsViews(), + ]); + if (!deepLinkState.hasSnapshot && state.analytics.activeViewId) { + const savedView = state.analytics.savedViews.find((item) => item.id === state.analytics.activeViewId) || null; + if (savedView) { + applyAnalyticsSnapshot(savedView.snapshot); + syncAnalyticsControlsFromState(); + } + } else { + syncAnalyticsControlsFromState(); + } + await loadAnalyticsDashboard(true); + if (deepLinkState.drilldown) { + restoreAnalyticsDrilldownFromUrlState(deepLinkState.drilldown); + } + } finally { + analyticsUrlSyncSuspended = false; + } + syncAnalyticsUrlState(); +} + +init(); diff --git a/ui/analyst/index.html b/ui/analyst/index.html new file mode 100644 index 0000000..f6bc2d3 --- /dev/null +++ b/ui/analyst/index.html @@ -0,0 +1,392 @@ + + + + + + KonturCC Аналитика + + + + + + + +
            + + +
            +
            +
            + Историческая аналитика сервиса +

            Периоды, каналы и очереди в одном окне, без перехода в операционный экран.

            +
            +
            +
            Данные: проверка...
            + +
            +
            AN
            +
            +
            Центр аналитики
            +
            Историческая аналитика
            +
            +
            +
            +
            + +
            +
            +

            История сервиса

            +

            Аналитика контакт-центра

            +

            Исторические KPI, тренды и структура потока по каналам и очередям в одной витрине.

            +
            + +
            +
            +
            +

            Сводка периода

            +

            Ключевые метрики и тренды

            +

            Сравнивайте выбранный период с предыдущим окном и быстро переключайтесь между очередями и каналами.

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

            Сохранённых видов пока нет.

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

            Выберите период и при необходимости уточните канал или очередь.

            + + +
            + +
            + +
            +
            +
            +

            AI-аналитика

            +

            AI-сессии в цифровых каналах

            +

            Отдельный блок по Telegram и WhatsApp: закрытие AI, передача оператору, задержка ответа и закрытия без оператора без доступа к текстам сообщений.

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

            AI-тренд по периоду

            +

            Динамика AI по выбранной метрике строится по тем же фильтрам периода, канала и очереди, что и основная витрина.

            +
            +
            + +
            +

            Покрытие AI-данных

            +

            Проверяем, насколько AI-сессии связаны с обращениями, очередями и данными по задержке ответа для корректной интерпретации отчёта.

            +
            +
            +
            + +
            +

            AI и оператор по каналам

            +

            Сравнение полностью автоматических и затронутых оператором сессий по Telegram и WhatsApp без текстов сообщений, записей и AI-сводок.

            +
            +
            +
            +
            +

            Исходы AI-сессий

            +

            Клик по исходу открывает полноэкранную детализацию по AI-сессиям только на уровне метаданных.

            +
            +
            + +
            +

            Причины передачи оператору

            +

            Нажмите на причину, чтобы открыть срез по передачам оператору с той же группой причин.

            +
            +
            +
            +
            + +
            +
            +
            +

            Агенты

            +

            Агентская аналитика

            +

            Нагрузка, обработка и текущие состояния агентов по фактам reporting и данным supervisor без локальных заглушек.

            +
            +
            + +
            +
            +
            +
            +
            +
            +

            Тренд по агентам

            +

            Динамика по агентам помогает оценить нагрузку, стабильность обработки и изменение FCR по выбранному окну.

            +
            +
            + + +
            +
            +
            +
            + +
            +

            Команды и очереди

            +

            Сводка по командам и очередям, где сейчас сосредоточена агентская нагрузка.

            +
            +
            +
            +
            +

            Агенты по выбранному периоду

            +

            Клик по строке открывает полноэкранную детализацию по обращениям выбранного агента без изменения фильтров витрины.

            +
            +
            +
            +
            +

            Смены

            +

            Сравнение ночной, дневной и вечерней смен по обращениям, обработке и FCR.

            +
            +
            +
            +
            + +
            +
            +
            +
            +

            Тренд по периоду

            +

            Динамика по выбранной метрике помогает быстро увидеть изменение нагрузки и качества.

            +
            +
            + + +
            +
            +
            +
            + +
            +

            Каналы

            +

            Сравнение каналов по объему, обработке и доле ответа за выбранный период.

            +

            Нажмите на строку, чтобы открыть точную детализацию по обращениям этого канала.

            +
            +
            +
            + +
            +
            +

            Очереди

            +

            Очереди отсортированы по объему. Нажмите на строку, чтобы сфокусировать всю витрину на одной очереди.

            +

            Клик по строке открывает точный срез по очереди и не меняет фильтры витрины.

            +
            +
            + +
            +
            +
            +
            +
            + + + + + + + + + + diff --git a/ui/login/app.js b/ui/login/app.js new file mode 100644 index 0000000..33133b3 --- /dev/null +++ b/ui/login/app.js @@ -0,0 +1,193 @@ +const SESSION_STORAGE_KEY = 'cc_session'; + +const state = { + user: 'admin', + role: 'admin', + token: null, + authSource: 'local', + oidc: { + enabled: false, + loginPath: '/auth/oidc/start?return_mode=popup', + providerLabel: 'Keycloak', + }, +}; + +const $ = (id) => document.getElementById(id); + +function destinationForRole(role) { + if (role === 'admin') { + return '/admin'; + } + if (role === 'supervisor') { + return '/supervisor'; + } + if (role === 'analyst') { + return '/analyst'; + } + return '/operator'; +} + +function syncSessionFromInputs() { + state.user = $('sessionUser').value.trim() || 'admin'; +} + +function writeLog(message, payload) { + const suffix = payload ? ` ${JSON.stringify(payload, null, 2)}` : ''; + $('loginLog').textContent = `${message}${suffix}`; +} + +function persistSession(data) { + const payload = { + access_token: data.access_token, + user: data.username || state.user, + role: data.role, + auth_source: data.auth_source || 'local', + provider: data.provider || null, + full_name: data.full_name || null, + }; + window.localStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(payload)); +} + +function updateSessionInfo(extra = '') { + if (!state.token) { + $('sessionInfo').textContent = 'Токен: отсутствует'; + return; + } + const source = state.authSource === 'oidc' ? 'корпоративный' : 'локальный'; + $('sessionInfo').textContent = `Токен активен | роль: ${state.role} | вход: ${source}${extra ? ` | ${extra}` : ''}`; +} + +function applyAuthenticatedSession(data) { + state.token = data.access_token; + state.role = data.role; + state.user = data.username || state.user; + state.authSource = data.auth_source || 'local'; + $('sessionUser').value = state.user; + updateSessionInfo(data.provider ? `provider: ${data.provider}` : ''); + persistSession(data); +} + +async function api(service, path, options = {}) { + syncSessionFromInputs(); + const headers = { + 'Content-Type': 'application/json', + ...(options.headers || {}), + }; + if (state.token) { + headers.Authorization = `Bearer ${state.token}`; + } + const response = await fetch(`/proxy/${service}/${path}`, { + ...options, + headers, + }); + let data; + try { + data = await response.json(); + } catch { + data = { error: 'Non-JSON response' }; + } + if (!response.ok) { + throw new Error(`${response.status}: ${JSON.stringify(data)}`); + } + return data; +} + +async function loadOidcConfig() { + try { + const response = await fetch('/proxy/auth/auth/oidc/config'); + const data = await response.json(); + state.oidc.enabled = Boolean(data.enabled); + state.oidc.loginPath = data.login_path || '/auth/oidc/start?return_mode=popup'; + state.oidc.providerLabel = data.provider_label || 'Keycloak'; + $('corporateLoginBtn').disabled = !state.oidc.enabled; + $('corporateLoginBtn').textContent = state.oidc.enabled + ? `Корпоративный вход (${state.oidc.providerLabel})` + : 'Корпоративный вход недоступен'; + } catch { + $('corporateLoginBtn').disabled = true; + $('corporateLoginBtn').textContent = 'Корпоративный вход недоступен'; + } +} + +function startCorporateLogin() { + if (!state.oidc.enabled) { + writeLog('Корпоративный вход недоступен.'); + return; + } + const popup = window.open(`/proxy/auth${state.oidc.loginPath}`, 'oidc-login', 'width=620,height=760'); + if (!popup) { + writeLog('Не удалось открыть окно корпоративного входа.'); + return; + } + writeLog('Открыт корпоративный вход.', { provider: state.oidc.providerLabel }); +} + +function handleOidcMessage(event) { + if (!event?.data || typeof event.data !== 'object') { + return; + } + if (event.data.type === 'oidc-login' && event.data.access_token) { + applyAuthenticatedSession(event.data); + writeLog('Корпоративный вход выполнен.', { user: state.user, role: state.role }); + window.location.href = destinationForRole(state.role); + return; + } + if (event.data.type === 'oidc-error') { + writeLog('Корпоративный вход не выполнен.', { error: event.data.message || 'unknown' }); + } +} + +async function login() { + syncSessionFromInputs(); + try { + const data = await api('auth', 'auth/login', { + method: 'POST', + body: JSON.stringify({ + username: state.user, + password: $('sessionPassword').value, + }), + }); + applyAuthenticatedSession({ + ...data, + username: state.user, + auth_source: data.auth_source || 'local', + }); + writeLog('Локальный вход выполнен.', { user: state.user, role: data.role }); + window.location.href = destinationForRole(data.role); + } catch (err) { + $('sessionInfo').textContent = `Ошибка входа: ${err.message}`; + writeLog('Вход не выполнен.', { error: err.message }); + } +} + +function wire() { + $('loginBtn').addEventListener('click', login); + $('corporateLoginBtn').addEventListener('click', startCorporateLogin); + $('refreshBtn').addEventListener('click', loadOidcConfig); + window.addEventListener('message', handleOidcMessage); +} + +async function init() { + wire(); + updateSessionInfo(); + await loadOidcConfig(); + const raw = window.localStorage.getItem(SESSION_STORAGE_KEY); + if (raw) { + try { + const payload = JSON.parse(raw); + if (payload?.access_token) { + state.token = payload.access_token; + state.user = payload.user || state.user; + state.role = payload.role || state.role; + state.authSource = payload.auth_source || 'local'; + $('sessionUser').value = state.user; + updateSessionInfo(payload.provider ? `provider: ${payload.provider}` : ''); + writeLog('Найдена активная сессия.', { user: state.user, role: state.role }); + } + } catch { + window.localStorage.removeItem(SESSION_STORAGE_KEY); + } + } +} + +init(); diff --git a/ui/login/index.html b/ui/login/index.html new file mode 100644 index 0000000..7491f7e --- /dev/null +++ b/ui/login/index.html @@ -0,0 +1,62 @@ + + + + + + KonturCC Login + + + + + + + +
            + +
            + + + + diff --git a/ui/operator/access-guards.js b/ui/operator/access-guards.js new file mode 100644 index 0000000..5e3a9c6 --- /dev/null +++ b/ui/operator/access-guards.js @@ -0,0 +1,70 @@ +(() => { + const SESSION_STORAGE_KEY = 'cc_session'; + const SUPERVISOR_REALTIME_PATH = '/proxy/supervisor/supervisor/realtime'; + + function currentRole() { + try { + const raw = window.localStorage.getItem(SESSION_STORAGE_KEY); + if (!raw) { + return ''; + } + const payload = JSON.parse(raw); + return String(payload?.role || '').toLowerCase(); + } catch { + return ''; + } + } + + function roleFromRequest(input, init) { + try { + const headers = new Headers((init && init.headers) || (input && input.headers) || undefined); + return String(headers.get('X-Role') || '').toLowerCase(); + } catch { + return ''; + } + } + + function urlFromRequest(input) { + if (typeof input === 'string') { + return input; + } + if (input && typeof input.url === 'string') { + return input.url; + } + return ''; + } + + function isRestrictedSupervisorRealtime(input, init) { + const url = urlFromRequest(input); + if (!url.includes(SUPERVISOR_REALTIME_PATH)) { + return false; + } + const role = roleFromRequest(input, init) || currentRole(); + return role === 'operator'; + } + + function restrictedSupervisorResponse() { + return new Response( + JSON.stringify({ + agents: { total: 0, by_state: {}, items: [] }, + queues: [], + timestamp: new Date().toISOString(), + restricted: true, + }), + { + status: 200, + headers: { + 'Content-Type': 'application/json', + }, + }, + ); + } + + const originalFetch = window.fetch.bind(window); + window.fetch = function patchedFetch(input, init) { + if (isRestrictedSupervisorRealtime(input, init)) { + return Promise.resolve(restrictedSupervisorResponse()); + } + return originalFetch(input, init); + }; +})(); diff --git a/ui/operator/app.js b/ui/operator/app.js new file mode 100644 index 0000000..2ec300e --- /dev/null +++ b/ui/operator/app.js @@ -0,0 +1,5411 @@ +const state = { + user: 'admin', + role: 'admin', + token: null, + authSource: 'local', + fullName: null, + logLines: [], + interactions: [], + oidc: { + enabled: false, + loginPath: '/auth/oidc/start?return_mode=popup', + providerLabel: 'Keycloak', + }, + liveCalls: { + items: [], + recentItems: [], + selectedCallId: '', + pollTimer: null, + pendingAction: '', + aiSummaries: {}, + aiSummaryPending: {}, + }, + customers: { + items: [], + page: 1, + pageSize: 7, + selectedCustomerId: '', + leadFormOpen: false, + }, + telegram: { + threads: [], + selectedThreadId: '', + messages: [], + selectedThreadSummary: null, + searchQuery: '', + messageSearchQuery: '', + messageSearchOpen: false, + operatorTrayOpen: false, + pendingAction: '', + pollTimer: null, + }, + whatsapp: { + chats: [], + selectedChatId: '', + searchQuery: '', + activeFilter: 'all', + composerText: '', + selectedThreadSummary: null, + pendingAction: '', + pollTimer: null, + mode: 'idle', + backendError: '', + mockFallbackLogged: false, + }, + browserPhone: { + config: null, + ua: null, + registerer: null, + session: null, + status: 'Не настроен', + connected: false, + connecting: false, + incoming: false, + muted: false, + micDeviceId: '', + speakerDeviceId: '', + autoClaimCallId: '', + popupCallId: '', + endingCallId: '', + callPhase: '', + autoClaimInFlight: false, + warning: '', + settingsOpen: false, + sessionStartedAt: '', + localStream: null, + localStreamPromise: null, + ringtoneContext: null, + ringtoneTimer: null, + ringtoneActive: false, + }, +}; + +const $ = (id) => document.getElementById(id); +const SESSION_STORAGE_KEY = 'cc_session'; +const DEMO_ASSIGNEE = 'operator_a'; +const DEMO_QUEUE = 'line2'; +const ROLE_LABELS = { + admin: 'Администратор', + supervisor: 'Супервизор', + operator: 'Оператор', + analyst: 'Аналитик', +}; + +const STATUS_META = { + new: { label: 'новое', className: 'status-new' }, + in_progress: { label: 'назначено', className: 'status-in-progress' }, + escalated: { label: 'эскалировано', className: 'status-escalated' }, + closed: { label: 'закрыто', className: 'status-closed' }, + abandoned: { label: 'потеряно', className: 'status-closed' }, +}; + +const CHANNEL_LABELS = { + voice: 'Голос', + telegram: 'Telegram', + whatsapp: 'WhatsApp', + webchat: 'Webchat', + email: 'Email', +}; + +const TELEGRAM_AVATAR_PALETTES = [ + ['#5bc7ff', '#288cff'], + ['#7a8cff', '#5664f5'], + ['#52d6b5', '#0f9f8a'], + ['#ffa96e', '#f57b3d'], + ['#ff88b6', '#f34b86'], + ['#8a7dff', '#5d58ea'], +]; + +const WHATSAPP_AVATAR_PALETTES = [ + ['#2dd4bf', '#0f766e'], + ['#60a5fa', '#1d4ed8'], + ['#f59e0b', '#c2410c'], + ['#a78bfa', '#6d28d9'], + ['#f472b6', '#be185d'], + ['#34d399', '#047857'], + ['#fb7185', '#be123c'], +]; + +const WHATSAPP_MOCK_CHATS = [ + { + id: 'wa-amy-chen', + title: 'Amy Chen', + handle: '@amyc', + statusLine: 'в сети', + isGroup: false, + unreadCount: 0, + pinned: true, + muted: false, + lastMessageAt: '2026-03-10T18:43:00+05:00', + lastMessagePreview: 'Отлично, пришлите образцы и счёт.', + messages: [ + { + id: 'wa-amy-1', + direction: 'in', + text: 'Здравствуйте! В обновлённое предложение вошли матовые бутылки?', + createdAt: '2026-03-10T18:15:00+05:00', + }, + { + id: 'wa-amy-2', + direction: 'out', + text: 'Да, я добавил и матовое покрытие, и вариант с индивидуальной этикеткой.', + createdAt: '2026-03-10T18:18:00+05:00', + deliveryStatus: 'read', + }, + { + id: 'wa-amy-3', + direction: 'in', + text: 'Отлично, пришлите образцы и счёт.', + createdAt: '2026-03-10T18:43:00+05:00', + }, + ], + }, + { + id: 'wa-design-ops', + title: 'Design Ops', + handle: '6 participants', + statusLine: 'Анна, Тимур, Лейла и ещё 3 участника', + isGroup: true, + unreadCount: 12, + pinned: true, + muted: true, + lastMessageAt: '2026-03-10T17:58:00+05:00', + lastMessagePreview: 'Лейла: hero-карточки согласованы, остались только иконки.', + messages: [ + { + id: 'wa-design-1', + direction: 'system', + text: 'Today', + createdAt: '2026-03-10T09:00:00+05:00', + }, + { + id: 'wa-design-2', + direction: 'in', + author: 'Timur', + text: 'Залил обновлённые отступы сайдбара и тёмные токены.', + createdAt: '2026-03-10T17:28:00+05:00', + }, + { + id: 'wa-design-3', + direction: 'out', + text: 'Отлично. Оставьте rail компактным, а действия с чатом перенесите в header.', + createdAt: '2026-03-10T17:35:00+05:00', + deliveryStatus: 'read', + }, + { + id: 'wa-design-4', + direction: 'in', + author: 'Leila', + text: 'Hero-карточки согласованы, остались только иконки.', + createdAt: '2026-03-10T17:58:00+05:00', + }, + ], + }, + { + id: 'wa-marat-logistics', + title: 'Marat Logistics', + handle: '+7 707 555 2001', + statusLine: 'был в сети сегодня в 16:42', + isGroup: false, + unreadCount: 0, + pinned: false, + muted: false, + lastMessageAt: '2026-03-10T16:41:00+05:00', + lastMessagePreview: 'Грузовик будет у ворот склада через 20 минут.', + messages: [ + { + id: 'wa-marat-1', + direction: 'in', + text: 'Грузовик будет у ворот склада через 20 минут.', + createdAt: '2026-03-10T16:41:00+05:00', + }, + { + id: 'wa-marat-2', + direction: 'out', + text: 'Принято. На пост охраны уже передали обновлённую накладную.', + createdAt: '2026-03-10T16:44:00+05:00', + deliveryStatus: 'delivered', + }, + ], + }, + { + id: 'wa-leila', + title: 'Leila', + handle: '@leila.studio', + statusLine: 'печатает...', + isGroup: false, + unreadCount: 1, + pinned: false, + muted: false, + lastMessageAt: '2026-03-10T15:26:00+05:00', + lastMessagePreview: 'Можно перенести ревью на 18:30?', + messages: [ + { + id: 'wa-leila-1', + direction: 'out', + text: 'Могу быстро посмотреть в 18:00, если так удобнее.', + createdAt: '2026-03-10T15:20:00+05:00', + deliveryStatus: 'read', + }, + { + id: 'wa-leila-2', + direction: 'in', + text: 'Можно перенести ревью на 18:30?', + createdAt: '2026-03-10T15:26:00+05:00', + }, + ], + }, + { + id: 'wa-family-trip', + title: 'Family Trip 2026', + handle: '8 participants', + statusLine: 'Закреплённая группа', + isGroup: true, + unreadCount: 0, + pinned: false, + muted: false, + lastMessageAt: '2026-03-10T13:08:00+05:00', + lastMessagePreview: 'Арман: я забронировал отель на пятницу.', + messages: [ + { + id: 'wa-family-1', + direction: 'in', + author: 'Arman', + text: 'Я забронировал отель на пятницу.', + createdAt: '2026-03-10T13:08:00+05:00', + }, + { + id: 'wa-family-2', + direction: 'out', + text: 'Отлично. Позже отправлю сюда маршрут и детали заселения.', + createdAt: '2026-03-10T13:11:00+05:00', + deliveryStatus: 'read', + }, + ], + }, + { + id: 'wa-studio-intake', + title: 'Studio Intake', + handle: '+7 701 222 8811', + statusLine: 'был в сети вчера в 22:10', + isGroup: false, + unreadCount: 4, + pinned: false, + muted: true, + lastMessageAt: '2026-03-09T22:08:00+05:00', + lastMessagePreview: 'Можете ещё раз прислать референсы по упаковке?', + messages: [ + { + id: 'wa-studio-1', + direction: 'in', + text: 'Можете ещё раз прислать референсы по упаковке?', + createdAt: '2026-03-09T22:08:00+05:00', + }, + { + id: 'wa-studio-2', + direction: 'out', + text: 'Да, сейчас отправлю свежий PDF и подборку образцов.', + createdAt: '2026-03-09T22:12:00+05:00', + deliveryStatus: 'read', + }, + ], + }, + { + id: 'wa-oleg-voice', + title: 'Oleg Voice', + handle: '+7 705 123 4490', + statusLine: 'был в сети вчера в 20:31', + isGroup: false, + unreadCount: 0, + pinned: false, + muted: false, + lastMessageAt: '2026-03-09T19:18:00+05:00', + lastMessagePreview: 'Спасибо, заметки для handoff хватило, чтобы продолжить.', + messages: [ + { + id: 'wa-oleg-1', + direction: 'out', + text: 'Оставил handoff-сводку в карточке задачи и отправил заметки по звонку.', + createdAt: '2026-03-09T19:08:00+05:00', + deliveryStatus: 'read', + }, + { + id: 'wa-oleg-2', + direction: 'in', + text: 'Спасибо, заметки для handoff хватило, чтобы продолжить.', + createdAt: '2026-03-09T19:18:00+05:00', + }, + ], + }, +]; + +const BOARD_COLUMNS = [ + { key: 'new', title: 'Новые' }, + { key: 'in_progress', title: 'В работе' }, + { key: 'escalated', title: '2 линия' }, + { key: 'closed', title: 'Закрытые' }, +]; + +const LIVE_TELEPHONY_LABELS = { + ringing: 'Звонит', + claimed: 'Взято', + connected: 'Соединено', + ended: 'Завершено', + failed: 'Ошибка', +}; + +const VOICE_AI_STATE_META = { + greeting: { label: 'AI активен', className: 'ai-active' }, + listening: { label: 'AI активен', className: 'ai-active' }, + thinking: { label: 'AI думает', className: 'ai-thinking' }, + speaking: { label: 'AI активен', className: 'ai-active' }, + active: { label: 'AI активен', className: 'ai-active' }, + handoff_requested: { label: 'Ждёт человека', className: 'ai-handoff' }, + handoff_required: { label: 'Ждёт человека', className: 'ai-handoff' }, + human_owned: { label: 'У оператора', className: 'ai-human' }, + error: { label: 'Ошибка AI', className: 'ai-error' }, + closed: { label: 'AI завершён', className: 'ai-muted' }, +}; + +const OPERATOR_VIEW_IDS = { + workspace: 'workspaceView', + customers: 'customersView', + 'customer-profile': 'customerProfileView', + telegram: 'telegramView', + whatsapp: 'whatsappView', + 'voice-debug': 'voiceDebugView', +}; + +function syncSessionFromInputs() { + state.user = $('sessionUser').value.trim() || 'admin'; + state.role = $('sessionRole').value || 'admin'; +} + +function destinationForRole(role) { + if (role === 'admin') { + return '/admin'; + } + if (role === 'supervisor') { + return '/supervisor'; + } + if (role === 'analyst') { + return '/analyst'; + } + return '/operator'; +} + +function applyRoleNavigation() { + document.querySelectorAll('[data-roles]').forEach((link) => { + const allowed = (link.dataset.roles || '') + .split(',') + .map((item) => item.trim()) + .filter(Boolean); + link.style.display = !allowed.length || allowed.includes(state.role) ? '' : 'none'; + }); + document.querySelectorAll('.nav-group').forEach((group) => { + const shellLinks = [...group.querySelectorAll('[data-shell]')]; + if (!shellLinks.length) { + group.style.display = ''; + return; + } + const hasVisibleShellLink = shellLinks.some((link) => link.style.display !== 'none'); + group.style.display = hasVisibleShellLink ? '' : 'none'; + }); +} + +function persistSession() { + window.localStorage.setItem( + SESSION_STORAGE_KEY, + JSON.stringify({ + access_token: state.token, + user: state.user, + role: state.role, + auth_source: state.authSource, + full_name: state.fullName, + }), + ); +} + +function clearStoredSession() { + window.localStorage.removeItem(SESSION_STORAGE_KEY); +} + +function restoreStoredSession() { + const raw = window.localStorage.getItem(SESSION_STORAGE_KEY); + if (!raw) { + return false; + } + try { + const payload = JSON.parse(raw); + if (!payload?.access_token) { + clearStoredSession(); + return false; + } + state.token = payload.access_token; + state.user = payload.user || state.user; + state.role = payload.role || state.role; + state.authSource = payload.auth_source || 'local'; + state.fullName = payload.full_name || null; + $('sessionUser').value = state.user; + $('sessionRole').value = state.role; + if ($('authPanel')) { + $('authPanel').style.display = 'none'; + } + updateProfileMeta(); + applyRoleNavigation(); + return true; + } catch { + clearStoredSession(); + return false; + } +} + +function logout() { + if (state.liveCalls.pollTimer) { + window.clearInterval(state.liveCalls.pollTimer); + state.liveCalls.pollTimer = null; + } + if (state.telegram.pollTimer) { + window.clearInterval(state.telegram.pollTimer); + state.telegram.pollTimer = null; + } + clearStoredSession(); + window.location.href = '/'; +} + +function shortJson(payload) { + if (!payload || typeof payload !== 'object') { + return ''; + } + const parts = Object.entries(payload) + .filter(([, value]) => value !== null && value !== undefined && value !== '') + .slice(0, 3) + .map(([key, value]) => { + const printable = typeof value === 'object' ? JSON.stringify(value) : value; + return `${key}=${printable}`; + }); + return parts.join(', '); +} + +function log(message, payload) { + const ts = new Date().toLocaleTimeString(); + const suffix = shortJson(payload); + const line = suffix ? `[${ts}] ${message} (${suffix})` : `[${ts}] ${message}`; + state.logLines.unshift(line); + state.logLines = state.logLines.slice(0, 14); + const target = $('eventLog'); + if (target) { + target.textContent = state.logLines.join('\n'); + } +} + +function setOutput(targetId, payload) { + $(targetId).textContent = JSON.stringify(payload, null, 2); +} + +function setEmptyList(targetId, message) { + $(targetId).innerHTML = `
          • ${message}
          • `; +} + +function setEmptyBlock(targetId, message) { + $(targetId).innerHTML = `
            ${message}
            `; +} + +function escapeHtml(value) { + return String(value ?? '') + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); +} + +function escapeRegExp(value) { + return String(value ?? '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function customerHashSeed(item, fallback = 0) { + const raw = `${item?.customer_id || ''}|${item?.display_name || ''}|${(item?.phones || []).join(',') || ''}`; + return [...raw].reduce((acc, char, index) => (acc + char.charCodeAt(0) * (index + 1)) % 997, fallback); +} + +function customerInitials(name) { + const parts = String(name || '') + .trim() + .split(/\s+/) + .filter(Boolean) + .slice(0, 2); + if (!parts.length) { + return 'LD'; + } + return parts.map((part) => part[0].toUpperCase()).join(''); +} + +function customerLeadSourceMeta(item, index) { + const variants = [ + { label: 'Instagram', className: 'lead-badge source-instagram' }, + { label: 'WhatsApp', className: 'lead-badge source-whatsapp' }, + { label: 'Site', className: 'lead-badge source-site' }, + { label: 'Referral', className: 'lead-badge source-referral' }, + ]; + return variants[customerHashSeed(item, index) % variants.length]; +} + +function customerLeadStatusMeta(item, index) { + const variants = [ + { label: 'Переговоры', className: 'lead-badge status-negotiation' }, + { label: 'Квалификация', className: 'lead-badge status-qualification' }, + { label: 'Предложение', className: 'lead-badge status-proposal' }, + { label: 'Первичный контакт', className: 'lead-badge status-initial' }, + { label: 'Успех', className: 'lead-badge status-success' }, + { label: 'Новый', className: 'lead-badge status-new' }, + { label: 'Отказ', className: 'lead-badge status-lost' }, + ]; + return variants[customerHashSeed(item, index + 17) % variants.length]; +} + +function customerLeadScore(item, index) { + return 12 + (customerHashSeed(item, index + 29) % 81); +} + +function customerLeadScoreClass(score) { + if (score >= 80) { + return 'lead-score score-strong'; + } + if (score >= 50) { + return 'lead-score score-medium'; + } + return 'lead-score score-weak'; +} + +function customerLeadCompany(item) { + const tags = Array.isArray(item?.tags) ? item.tags.filter(Boolean) : []; + if (tags.length) { + return tags[0]; + } + const suffixes = ['Tech', 'Group', 'Retail', 'Logistics', 'Studio', 'Services']; + const seed = customerHashSeed(item, 41); + return `${String(item?.display_name || 'Lead').split(' ')[0]} ${suffixes[seed % suffixes.length]}`; +} + +function customerLeadCreatedAtLabel(item, index) { + if (item?.created_at) { + return formatIsoShort(item.created_at); + } + const seed = customerHashSeed(item, index + 59); + const shifted = new Date(Date.now() - ((seed % 9) * 86400000 + (seed % 18) * 3600000)); + return shifted.toLocaleDateString('ru-RU', { day: '2-digit', month: 'short', year: 'numeric' }); +} + +function customerLeadRows() { + const start = (state.customers.page - 1) * state.customers.pageSize; + return state.customers.items.slice(start, start + state.customers.pageSize); +} + +function selectedCustomer() { + return state.customers.items.find((item) => item.customer_id === state.customers.selectedCustomerId) || state.customers.items[0] || null; +} + +function customerProfileHash(customerId = state.customers.selectedCustomerId) { + return customerId ? `#customer-profile/${encodeURIComponent(customerId)}` : '#customer-profile'; +} + +function openCustomerProfile(customerId) { + selectCustomer(customerId || ''); + window.location.hash = customerProfileHash(customerId || state.customers.selectedCustomerId); +} + +function customerIndex(customer) { + if (!customer) { + return -1; + } + return state.customers.items.findIndex((item) => item.customer_id === customer.customer_id); +} + +function customerPhones(customer) { + const phones = []; + if (Array.isArray(customer?.phones)) { + phones.push(...customer.phones); + } + if (customer?.phone) { + phones.push(customer.phone); + } + if (customer?.preferred_phone) { + phones.unshift(customer.preferred_phone); + } + return [...new Set(phones.map((value) => String(value || '').trim()).filter(Boolean))]; +} + +function customerInteractions(customer) { + if (!customer) { + return []; + } + return state.interactions.filter((item) => item.customer_id === customer.customer_id); +} + +function customerInteractionIds(customer) { + return new Set(customerInteractions(customer).map((item) => item.interaction_id).filter(Boolean)); +} + +function customerTelegramThreads(customer) { + const interactionIds = customerInteractionIds(customer); + return state.telegram.threads.filter((thread) => interactionIds.has(thread.interaction_id)); +} + +function customerLiveCalls(customer) { + const interactionIds = customerInteractionIds(customer); + const phones = new Set(customerPhones(customer)); + return uniqueLiveCalls([...state.liveCalls.items, ...state.liveCalls.recentItems]).filter((item) => { + if (interactionIds.has(item.interaction_id)) { + return true; + } + return phones.has(String(item.caller_number || '').trim()); + }); +} + +function customerPreferredChannel(customer) { + const counters = new Map(); + customerInteractions(customer).forEach((item) => { + const key = item.channel || 'voice'; + counters.set(key, (counters.get(key) || 0) + 1); + }); + if (customerTelegramThreads(customer).length) { + counters.set('telegram', (counters.get('telegram') || 0) + customerTelegramThreads(customer).length); + } + if (customerLiveCalls(customer).length) { + counters.set('voice', (counters.get('voice') || 0) + customerLiveCalls(customer).length); + } + const [winner] = [...counters.entries()].sort((left, right) => right[1] - left[1]); + return winner?.[0] || 'voice'; +} + +function customerHistoryTimeValue(value) { + if (!value) { + return 0; + } + const dt = new Date(value); + if (Number.isNaN(dt.getTime())) { + return 0; + } + return dt.getTime(); +} + +function customerHistoryMeta(kind) { + if (kind === 'voice') { + return { token: 'VC', className: 'voice' }; + } + if (kind === 'telegram') { + return { token: 'TG', className: 'telegram' }; + } + if (kind === 'email') { + return { token: 'EM', className: 'email' }; + } + if (kind === 'webchat') { + return { token: 'WC', className: 'webchat' }; + } + if (kind === 'profile') { + return { token: 'ID', className: 'profile' }; + } + return { token: 'CS', className: 'case' }; +} + +function buildCustomerHistoryEvents(customer) { + if (!customer) { + return []; + } + + const company = customerLeadCompany(customer); + const phones = customerPhones(customer); + const interactions = customerInteractions(customer); + const interactionIds = new Set(interactions.map((item) => item.interaction_id).filter(Boolean)); + const threads = customerTelegramThreads(customer); + const liveCalls = customerLiveCalls(customer); + const events = []; + + events.push({ + timestamp: customer.created_at || '', + kind: 'profile', + title: 'Профиль клиента активен', + body: phones.length + ? `Контакт доступен для оператора по номеру ${phones[0]}.` + : 'Контакт добавлен в клиентскую базу и готов к работе.', + note: [customer.customer_id, company].filter(Boolean).join(' • '), + }); + + interactions.forEach((item) => { + const meta = statusMeta(item.status); + events.push({ + timestamp: item.updated_at || item.created_at || '', + kind: item.channel || 'case', + title: `${channelLabel(item.channel)} • ${meta.label}`, + body: item.subject || 'Обращение без темы', + note: [ + item.interaction_id, + item.assigned_to ? `владелец ${item.assigned_to}` : '', + item.queue_id ? `queue ${item.queue_id}` : '', + ].filter(Boolean).join(' • '), + }); + }); + + threads.forEach((thread) => { + events.push({ + timestamp: thread.last_message_at || '', + kind: 'telegram', + title: 'Telegram диалог', + body: thread.last_message_preview || 'Последнее сообщение недоступно.', + note: [ + thread.display_name || thread.username || `chat ${thread.chat_id}`, + thread.claimed_by_user ? `владелец ${thread.claimed_by_user}` : '', + thread.status ? statusMeta(thread.status).label : '', + ].filter(Boolean).join(' • '), + }); + }); + + const activeThread = selectedTelegramThread(); + if (activeThread && interactionIds.has(activeThread.interaction_id) && state.telegram.messages.length) { + state.telegram.messages.slice(-3).forEach((message) => { + const direction = message.direction === 'outbound' ? 'Исходящее сообщение' : 'Входящее сообщение'; + events.push({ + timestamp: message.created_at || '', + kind: 'telegram', + title: direction, + body: message.text || 'Сообщение без текста', + note: [ + activeThread.display_name || activeThread.username || `chat ${activeThread.chat_id}`, + message.operator_user ? `оператор ${message.operator_user}` : '', + message.delivery_status || '', + ].filter(Boolean).join(' • '), + }); + }); + } + + liveCalls.forEach((item) => { + const completed = item.telephony_status === 'ended' || item.ended_at; + events.push({ + timestamp: item.last_transition_at || item.ended_at || item.connected_at || item.started_at || '', + kind: 'voice', + title: completed ? `Звонок • ${terminalActionLabel(item)}` : `Звонок • ${telephonyLabel(item.telephony_status)}`, + body: `Номер клиента: ${item.caller_number || 'не определён'}`, + note: [ + item.call_id, + item.operator_extension ? `внутр. ${item.operator_extension}` : '', + item.claimed_by_user ? `владелец ${item.claimed_by_user}` : '', + ].filter(Boolean).join(' • '), + }); + const aiMeta = voiceAiStatusMeta(item); + if (aiMeta) { + events.push({ + timestamp: item.ai_last_model_at || item.last_transition_at || item.updated_at || '', + kind: 'voice', + title: `Голосовой AI • ${aiMeta.label}`, + body: item.ai_handoff_reason || 'AI участвовал в обработке звонка и обновил контекст для оператора.', + note: [ + item.call_id, + item.voice_session_id ? `голос ${item.voice_session_id}` : '', + item.ai_session_id ? `ai ${item.ai_session_id}` : '', + ].filter(Boolean).join(' • '), + }); + } + }); + + return events + .sort((left, right) => { + const timeDelta = customerHistoryTimeValue(right.timestamp) - customerHistoryTimeValue(left.timestamp); + if (timeDelta !== 0) { + return timeDelta; + } + return String(right.note || '').localeCompare(String(left.note || '')); + }) + .slice(0, 9); +} + +function emptyCustomerSpotlightMarkup(title, description) { + return ` +
            + ${escapeHtml(title)} +

            ${escapeHtml(description)}

            +
            + `; +} + +function customerSpotlightMarkup(customer, options = {}) { + const { pageView = false } = options; + const index = Math.max(customerIndex(customer), 0); + const phones = customerPhones(customer); + const source = customerLeadSourceMeta(customer, index); + const leadStatus = customerLeadStatusMeta(customer, index); + const score = customerLeadScore(customer, index); + const interactions = customerInteractions(customer); + const threads = customerTelegramThreads(customer); + const liveCalls = customerLiveCalls(customer); + const historyEvents = buildCustomerHistoryEvents(customer); + const latestEvent = historyEvents[0] || null; + const openCases = interactions.filter((item) => item.status !== 'closed').length; + const activeChannels = [...new Set([ + ...interactions.map((item) => item.channel).filter(Boolean), + ...(threads.length ? ['telegram'] : []), + ...(liveCalls.length ? ['voice'] : []), + ])]; + const tags = Array.isArray(customer.tags) ? customer.tags.filter(Boolean) : []; + const primaryThread = [...threads].sort((left, right) => { + return customerHistoryTimeValue(right.last_message_at) - customerHistoryTimeValue(left.last_message_at); + })[0] || null; + const summaryCards = [ + renderSummaryCard('Контакты', String(interactions.length + threads.length + liveCalls.length), 'Все точки касания клиента'), + renderSummaryCard('Открытые кейсы', String(openCases), openCases ? 'Требуют внимания оператора' : 'Новых действий нет'), + renderSummaryCard( + 'Каналы', + String(activeChannels.length || (phones.length ? 1 : 0)), + activeChannels.length ? activeChannels.map((item) => channelLabel(item)).join(' • ') : 'Пока только профиль', + ), + renderSummaryCard( + 'Последний контакт', + latestEvent ? formatIsoShort(latestEvent.timestamp) : '—', + latestEvent ? latestEvent.title : 'Активность ещё не зафиксирована', + ), + ].join(''); + + const historyMarkup = historyEvents.length + ? historyEvents.map((event) => { + const meta = customerHistoryMeta(event.kind); + return ` +
            +
            ${meta.token}
            +
            +
            +
            ${escapeHtml(event.title)}
            + +
            +
            ${escapeHtml(event.body)}
            + ${event.note ? `
            ${escapeHtml(event.note)}
            ` : ''} +
            +
            + `; + }).join('') + : '
            Пока нет событий по этому клиенту.
            '; + + return ` +
            +
            +
            +
            ${escapeHtml(customerInitials(customer.display_name))}
            +
            +
            Клиент ${escapeHtml(customer.customer_id)}
            +

            ${escapeHtml(customer.display_name || 'Без имени')}

            +

            ${escapeHtml(customerLeadCompany(customer))}

            +
            +
            + ${escapeHtml(source.label)} + ${escapeHtml(leadStatus.label)} + AI ${score} +
            +
            + +
            + ${summaryCards} +
            + +
            +
            +
            Основной номер
            +
            ${escapeHtml(phones[0] || 'Не указан')}
            +
            ${phones.length > 1 ? `+${phones.length - 1} дополнительных номера` : 'Один контактный номер'}
            +
            +
            +
            Предпочтительный канал
            +
            ${escapeHtml(channelLabel(customerPreferredChannel(customer)))}
            +
            ${escapeHtml(openCases ? 'Есть активные обращения' : 'Сейчас без открытых кейсов')}
            +
            +
            +
            Теги профиля
            +
            + ${(tags.length ? tags : [customerLeadCompany(customer)]).slice(0, 4).map((tag) => `${escapeHtml(tag)}`).join('')} +
            +
            +
            +
            Активные каналы
            +
            + ${(activeChannels.length ? activeChannels : ['profile']).map((channel) => `${escapeHtml(channel === 'profile' ? 'Профиль' : channelLabel(channel))}`).join('')} +
            +
            +
            + +
            + + +
            +
            + +
            +
            +
            +
            Единая история клиента
            +

            Все контакты в одной ленте

            +
            +
            ${escapeHtml(latestEvent ? `Последнее событие: ${latestEvent.title}` : 'События появятся после первого обращения')}
            +
            +
            + ${historyMarkup} +
            +
            +
            + `; +} + +function renderCustomerSpotlight() { + const target = $('customerSpotlight'); + const customer = selectedCustomer(); + if (target && !customer) { + target.innerHTML = emptyCustomerSpotlightMarkup( + 'Выберите клиента', + 'Здесь появятся профиль клиента и единая история контактов по всем каналам.', + ); + } + if (target && customer) { + target.innerHTML = customerSpotlightMarkup(customer); + } + renderCustomerProfilePage(); +} + +function renderCustomerProfilePage() { + const target = $('customerProfileContent'); + if (!target) { + return; + } + + const customer = selectedCustomer(); + const title = $('customerProfileTitle'); + const hint = $('customerProfileHint'); + + if (!customer) { + if (title) { + title.textContent = 'Профиль клиента'; + } + if (hint) { + hint.textContent = 'Откройте карточку из списка клиентов, чтобы увидеть профиль и историю.'; + } + target.innerHTML = emptyCustomerSpotlightMarkup( + 'Выберите клиента', + 'Откройте карточку из списка клиентов, чтобы увидеть профиль и историю.', + ); + return; + } + + if (title) { + title.textContent = customer.display_name || 'Профиль клиента'; + } + if (hint) { + const phones = customerPhones(customer); + hint.textContent = phones.length + ? `Клиент ${customer.customer_id} • основной номер ${phones[0]}` + : `Клиент ${customer.customer_id} • омниканальный профиль`; + } + + target.innerHTML = customerSpotlightMarkup(customer, { pageView: true }); +} + +function syncCustomerFormUi() { + $('customerLeadForm').classList.toggle('hidden', !state.customers.leadFormOpen); + $('toggleCustomerLeadFormBtn').textContent = state.customers.leadFormOpen ? 'Закрыть форму' : '+ Добавить лид'; +} + +function updateCustomerPagerUi() { + const total = state.customers.items.length; + const pageCount = Math.max(1, Math.ceil(total / state.customers.pageSize)); + const start = total ? (state.customers.page - 1) * state.customers.pageSize + 1 : 0; + const end = total ? Math.min(state.customers.page * state.customers.pageSize, total) : 0; + $('customerPageSummary').textContent = total ? `Показано ${start}-${end} из ${total}` : 'Пока нет лидов.'; + $('customerPrevPageBtn').disabled = state.customers.page <= 1; + $('customerNextPageBtn').disabled = state.customers.page >= pageCount; +} + +function renderCustomerList() { + const rows = customerLeadRows(); + if (!rows.length) { + $('customerList').innerHTML = ` +
            + Лиды пока не найдены. +

            Попробуйте другой запрос или добавьте новый лид вручную.

            +
            + `; + renderCustomerSpotlight(); + updateCustomerPagerUi(); + return; + } + + $('customerList').innerHTML = ` +
            +
            +
            +
            ИМЯ КОНТАКТА
            +
            ИСТОЧНИК
            +
            ДАТА СОЗДАНИЯ
            +
            СТАТУС
            +
            AI SCORE
            +
            + ${rows.map((item, index) => { + const source = customerLeadSourceMeta(item, index); + const status = customerLeadStatusMeta(item, index); + const score = customerLeadScore(item, index); + const selected = item.customer_id === state.customers.selectedCustomerId ? ' selected' : ''; + return ` +
            +
            +
            +
            ${escapeHtml(customerInitials(item.display_name))}
            +
            +
            ${escapeHtml(item.display_name)}
            +
            ${escapeHtml(customerLeadCompany(item))}
            +
            +
            +
            ${escapeHtml(source.label)}
            +
            ${escapeHtml(customerLeadCreatedAtLabel(item, index))}
            +
            ${escapeHtml(status.label)}
            +
            ${score}
            +
            + `; + }).join('')} +
            + `; + renderCustomerSpotlight(); + updateCustomerPagerUi(); +} + +function selectCustomer(customerId) { + state.customers.selectedCustomerId = customerId || ''; + if (customerId) { + $('interactionCustomerId').value = customerId; + } + renderCustomerList(); +} + +function setCustomerPage(page) { + const totalPages = Math.max(1, Math.ceil(state.customers.items.length / state.customers.pageSize)); + state.customers.page = Math.min(Math.max(page, 1), totalPages); + renderCustomerList(); +} + +function selectedTelegramThread() { + return state.telegram.threads.find((thread) => thread.thread_id === state.telegram.selectedThreadId) || null; +} + +function telegramThreadCanClaim(thread) { + if (!thread || thread.status === 'closed') { + return false; + } + return !thread.claimed_by_user; +} + +function telegramThreadCanReply(thread) { + if (!thread || thread.status === 'closed') { + return false; + } + if (state.role === 'admin' || state.role === 'supervisor') { + return true; + } + return thread.claimed_by_user === state.user; +} + +function telegramThreadCanManage(thread) { + if (!thread) { + return false; + } + if (state.role === 'admin' || state.role === 'supervisor') { + return true; + } + return thread.claimed_by_user === state.user; +} + +function telegramThreadCanReturnToAi(thread) { + if (!thread || thread.status === 'closed') { + return false; + } + if (String(thread.ai_state || '') !== 'human_owned') { + return false; + } + return telegramThreadCanManage(thread); +} + +function telegramThreadDisplayName(thread) { + return thread?.display_name || thread?.username || thread?.chat_id || 'Telegram'; +} + +function telegramThreadInitials(thread) { + return customerInitials(telegramThreadDisplayName(thread)); +} + +function telegramThreadTimeLabel(value) { + if (!value) { + return 'сейчас'; + } + const dt = new Date(value); + if (Number.isNaN(dt.getTime())) { + return String(value); + } + const now = new Date(); + const sameDay = dt.toDateString() === now.toDateString(); + if (sameDay) { + return dt.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' }); + } + return dt.toLocaleDateString('ru-RU', { day: '2-digit', month: 'short' }); +} + +function telegramHashValue(value) { + let hash = 0; + const text = String(value || 'telegram'); + for (let index = 0; index < text.length; index += 1) { + hash = ((hash << 5) - hash + text.charCodeAt(index)) >>> 0; + } + return hash; +} + +function telegramAvatarPalette(seed) { + return TELEGRAM_AVATAR_PALETTES[telegramHashValue(seed) % TELEGRAM_AVATAR_PALETTES.length]; +} + +function telegramAvatarStyle(seed) { + const [start, end] = telegramAvatarPalette(seed); + return `style="--telegram-avatar-start:${start};--telegram-avatar-end:${end};"`; +} + +function whatsappAvatarPalette(seed) { + return WHATSAPP_AVATAR_PALETTES[telegramHashValue(seed || 'whatsapp') % WHATSAPP_AVATAR_PALETTES.length]; +} + +function cloneWhatsappChats() { + return WHATSAPP_MOCK_CHATS.map((chat) => ({ + ...chat, + messages: (chat.messages || []).map((message) => ({ ...message })), + })); +} + +function sortWhatsappChats(chats) { + return [...(chats || [])].sort((left, right) => { + const pinnedDelta = Number(Boolean(right.pinned)) - Number(Boolean(left.pinned)); + if (pinnedDelta !== 0) { + return pinnedDelta; + } + return String(right.lastMessageAt || '').localeCompare(String(left.lastMessageAt || '')); + }); +} + +function seedWhatsappChats() { + if (state.whatsapp.chats.length) { + return; + } + state.whatsapp.mode = 'mock'; + state.whatsapp.chats = sortWhatsappChats(cloneWhatsappChats()); + state.whatsapp.selectedChatId = state.whatsapp.chats[0]?.id || ''; +} + +function whatsappChatThreadId(chat) { + return chat?.threadId || chat?.id || ''; +} + +function whatsappThreadCanClaim(thread) { + if (!thread || thread.status === 'closed') { + return false; + } + return !thread.claimedByUser; +} + +function whatsappThreadCanReply(thread) { + if (!thread || thread.status === 'closed') { + return false; + } + if (state.role === 'admin' || state.role === 'supervisor') { + return true; + } + return !thread.claimedByUser || thread.claimedByUser === state.user; +} + +function whatsappThreadCanManage(thread) { + if (!thread) { + return false; + } + if (state.role === 'admin' || state.role === 'supervisor') { + return true; + } + return thread.claimedByUser === state.user; +} + +function whatsappThreadCanReturnToAi(thread) { + if (!thread || thread.status === 'closed') { + return false; + } + if (String(thread.aiState || '').trim() !== 'human_owned') { + return false; + } + return whatsappThreadCanManage(thread); +} + +function whatsappThreadDisplayName(thread) { + return thread?.title || thread?.display_name || thread?.phoneNumber || thread?.phone_number || thread?.chatId || thread?.chat_id || 'WhatsApp'; +} + +function whatsappThreadAiStateMeta(thread) { + const stateKey = String(thread?.aiState || thread?.ai_state || '').trim(); + if (!stateKey) { + return null; + } + const meta = { + queued: { label: 'AI готов', tone: 'queued' }, + thinking: { label: 'AI думает', tone: 'thinking' }, + active: { label: 'AI активен', tone: 'active' }, + handoff_required: { label: 'Нужен оператор', tone: 'handoff' }, + human_owned: { label: 'Operator', tone: 'human' }, + closed: { label: 'AI завершён', tone: 'closed' }, + error: { label: 'Ошибка AI', tone: 'error' }, + }; + return meta[stateKey] || { label: stateKey, tone: 'queued' }; +} + +function whatsappThreadAiClaimable(thread) { + return Boolean(thread && !thread.claimedByUser && ['queued', 'thinking', 'active', 'handoff_required'].includes(String(thread.aiState || ''))); +} + +function whatsappThreadUnreadCount(thread) { + const raw = Number(thread?.unreadCount ?? thread?.unread_count ?? 0); + return Number.isFinite(raw) && raw > 0 ? raw : 0; +} + +function whatsappThreadHandle(thread) { + if (!thread) { + return 'Direct message'; + } + if (thread.handle) { + return thread.handle; + } + if (thread.phoneNumber || thread.phone_number) { + return thread.phoneNumber || thread.phone_number; + } + if (thread.whatsappUserId || thread.whatsapp_user_id) { + return thread.whatsappUserId || thread.whatsapp_user_id; + } + if (thread.isGroup || thread.is_group) { + return 'Групповой чат'; + } + const chatId = thread.chatId || thread.chat_id; + return chatId ? `chat ${chatId}` : 'Direct message'; +} + +function whatsappThreadPresenceText(thread) { + if (!thread) { + return 'Выберите чат'; + } + if (thread.status === 'closed') { + return 'Закрыт'; + } + const aiMeta = whatsappThreadAiStateMeta(thread); + if (aiMeta && String(thread.aiState || '').trim() !== 'human_owned') { + return aiMeta.label; + } + if (thread.claimedByUser) { + return `в работе у ${thread.claimedByUser}`; + } + return thread.statusLine || whatsappThreadHandle(thread); +} + +function whatsappThreadOwnerText(thread) { + if (whatsappThreadAiClaimable(thread)) { + return 'владелец AI'; + } + return thread?.claimedByUser ? `владелец ${thread.claimedByUser}` : 'владелец -'; +} + +function whatsappThreadSummaryAccessible(thread) { + if (!thread) { + return false; + } + if (!['handoff_required', 'human_owned'].includes(String(thread.aiState || '').trim())) { + return false; + } + if (String(state.role || '') !== 'operator') { + return true; + } + const claimedByUser = String(thread.claimedByUser || '').trim(); + return !claimedByUser || claimedByUser === String(state.user || '').trim(); +} + +function whatsappSelectedSummaryVisible(thread) { + if (!whatsappThreadSummaryAccessible(thread)) { + return false; + } + const summary = state.whatsapp.selectedThreadSummary; + return Boolean(summary && summary.thread_id === whatsappChatThreadId(thread)); +} + +function whatsappSummaryFieldMarkup(label, value) { + return ` +
            +
            ${escapeHtml(label)}
            +
            ${escapeHtml(value || '-')}
            +
            + `; +} + +function whatsappSummaryStatusMarkup(summary) { + if (!summary?.status_label) { + return ''; + } + const tone = String(summary.status_tone || 'handoff').trim() || 'handoff'; + return `${escapeHtml(summary.status_label)}`; +} + +function normalizeWhatsappDeliveryStatus(status) { + const raw = String(status || '').trim().toLowerCase(); + if (!raw) { + return ''; + } + if (raw.includes('read')) { + return 'read'; + } + if (raw.includes('deliver') || raw.includes('sent')) { + return 'delivered'; + } + return raw; +} + +function whatsappAuthorLabel(message, chat = null) { + if (message.author) { + return message.author; + } + const payload = message.payload || {}; + if (message.author_type === 'customer') { + return payload.display_name || payload.profile_name || chat?.title || ''; + } + if (message.author_type === 'human') { + return message.operator_user || payload.operator_user || 'Оператор'; + } + if (message.author_type === 'ai') { + return 'AI'; + } + return ''; +} + +function normalizeWhatsappMessage(message, chat = null) { + const direction = message.direction === 'outbound' + ? 'out' + : message.direction === 'inbound' + ? 'in' + : (message.direction || 'system'); + return { + id: message.message_id || message.id, + direction, + text: message.text || '', + createdAt: message.created_at || message.createdAt || '', + deliveryStatus: normalizeWhatsappDeliveryStatus(message.delivery_status || message.deliveryStatus), + author: whatsappAuthorLabel(message, chat), + authorType: message.author_type || message.authorType || '', + operatorUser: message.operator_user || '', + payload: message.payload || {}, + }; +} + +function whatsappThreadStatusLine(thread) { + const aiMeta = whatsappThreadAiStateMeta(thread); + if (aiMeta && String(thread.ai_state || '').trim() !== 'human_owned') { + return aiMeta.label; + } + if (thread.claimed_by_user) { + return `owned by ${thread.claimed_by_user}`; + } + return thread.phone_number || thread.whatsapp_user_id || (thread.is_group ? 'Групповой чат' : 'Чат WhatsApp'); +} + +function normalizeWhatsappThread(thread, existingChat = null) { + return { + id: thread.thread_id, + threadId: thread.thread_id, + interactionId: thread.interaction_id, + chatId: thread.chat_id, + whatsappUserId: thread.whatsapp_user_id || '', + phoneNumber: thread.phone_number || '', + title: thread.display_name || thread.phone_number || thread.chat_id || 'WhatsApp', + handle: thread.phone_number || thread.whatsapp_user_id || (thread.is_group ? 'Групповой чат' : `чат ${thread.chat_id}`), + statusLine: whatsappThreadStatusLine(thread), + isGroup: Boolean(thread.is_group), + unreadCount: whatsappThreadUnreadCount(thread), + pinned: Boolean(existingChat?.pinned), + muted: Boolean(existingChat?.muted), + lastMessageAt: thread.last_message_at || '', + lastMessagePreview: thread.last_message_preview || '', + claimedByUser: thread.claimed_by_user || '', + claimedAt: thread.claimed_at || '', + status: thread.status || '', + queueId: thread.queue_id || '', + aiSessionId: thread.ai_session_id || '', + aiState: thread.ai_state || '', + aiHandoffReason: thread.ai_handoff_reason || '', + aiLastModelAt: thread.ai_last_model_at || '', + createdAt: thread.created_at || '', + updatedAt: thread.updated_at || '', + source: 'live', + messages: Array.isArray(existingChat?.messages) ? existingChat.messages.map((message) => ({ ...message })) : [], + }; +} + +function applyWhatsappChats(chats, options = {}) { + const { preserveSelection = true } = options; + const sorted = sortWhatsappChats(chats); + const previous = preserveSelection ? state.whatsapp.selectedChatId : ''; + const stillExists = sorted.some((chat) => chat.id === previous); + const nextSelected = stillExists ? previous : (sorted[0]?.id || ''); + state.whatsapp.chats = sorted; + state.whatsapp.selectedChatId = nextSelected; + if (nextSelected !== previous) { + state.whatsapp.composerText = ''; + state.whatsapp.selectedThreadSummary = null; + } + if (!nextSelected) { + state.whatsapp.selectedThreadSummary = null; + } + renderWhatsappWorkspace(); + return nextSelected; +} + +function upsertWhatsappThreadData(thread, options = {}) { + const existing = state.whatsapp.chats.find((item) => item.id === thread.thread_id) || null; + const normalized = normalizeWhatsappThread(thread, existing); + const nextChats = existing + ? state.whatsapp.chats.map((item) => (item.id === normalized.id ? normalized : item)) + : [...state.whatsapp.chats, normalized]; + applyWhatsappChats(nextChats, options); + return normalized; +} + +function whatsappEstimateUnreadCount(messages) { + let lastOutboundIndex = -1; + (messages || []).forEach((message, index) => { + if (message.direction === 'out') { + lastOutboundIndex = index; + } + }); + return (messages || []) + .slice(lastOutboundIndex + 1) + .filter((message) => message.direction === 'in') + .length; +} + +function updateWhatsappChatMessages(threadId, messages, options = {}) { + const { preserveUnread = false } = options; + const nextChats = state.whatsapp.chats.map((chat) => { + if (chat.id !== threadId) { + return chat; + } + return { + ...chat, + messages, + unreadCount: preserveUnread ? chat.unreadCount : (threadId === state.whatsapp.selectedChatId ? 0 : whatsappEstimateUnreadCount(messages)), + }; + }); + applyWhatsappChats(nextChats); +} + +function renderWhatsappSummaryCard() { + const card = $('whatsappAiSummaryCard'); + const content = $('whatsappAiSummaryContent'); + const meta = $('whatsappAiSummaryMeta'); + const status = $('whatsappAiSummaryStatus'); + if (!card || !content) { + return; + } + const thread = selectedWhatsappChat(); + if (!whatsappSelectedSummaryVisible(thread)) { + card.classList.add('hidden'); + content.innerHTML = ''; + if (meta) { + meta.textContent = ''; + } + if (status) { + status.innerHTML = ''; + } + return; + } + const summary = state.whatsapp.selectedThreadSummary; + const generatedAt = summary?.generated_at ? `Сформировано ${escapeHtml(whatsappChatTimeLabel(summary.generated_at))}` : ''; + content.innerHTML = [ + whatsappSummaryFieldMarkup('Запрос клиента', summary.customer_request_text), + whatsappSummaryFieldMarkup('Что сделал AI', summary.ai_outcome_text), + whatsappSummaryFieldMarkup('Почему передал человеку', summary.handoff_reason), + whatsappSummaryFieldMarkup('Что делать дальше', summary.recommended_next_step), + ].join(''); + if (meta) { + meta.textContent = generatedAt; + } + if (status) { + status.innerHTML = whatsappSummaryStatusMarkup(summary); + } + card.classList.remove('hidden'); +} + +function telegramThreadSummary(thread) { + return thread.username ? `@${thread.username}` : `chat ${thread.chat_id}`; +} + +function telegramThreadStatusText(thread) { + return statusMeta(thread?.status || '').label; +} + +function telegramThreadAiStateMeta(thread) { + const stateKey = String(thread?.ai_state || '').trim(); + if (!stateKey) { + return null; + } + const meta = { + queued: { label: 'AI готов', tone: 'queued' }, + thinking: { label: 'AI думает', tone: 'thinking' }, + active: { label: 'AI активен', tone: 'active' }, + handoff_required: { label: 'Ждёт человека', tone: 'handoff' }, + human_owned: { label: 'Человек', tone: 'human' }, + closed: { label: 'AI завершён', tone: 'closed' }, + error: { label: 'Ошибка AI', tone: 'error' }, + }; + return meta[stateKey] || { label: stateKey, tone: 'queued' }; +} + +function telegramThreadAiClaimable(thread) { + return Boolean(thread && !thread.claimed_by_user && ['queued', 'thinking', 'active', 'handoff_required'].includes(String(thread.ai_state || ''))); +} + +function telegramClaimButtonLabel(thread) { + return telegramThreadAiClaimable(thread) ? 'Забрать у AI' : 'В работу'; +} + +function telegramThreadUnreadCount(thread) { + const raw = Number(thread?.unread_count ?? 0); + return Number.isFinite(raw) && raw > 0 ? raw : 0; +} + +function telegramThreadPresenceText(thread) { + if (!thread) { + return 'нет диалога'; + } + const aiMeta = telegramThreadAiStateMeta(thread); + if (aiMeta) { + return aiMeta.label; + } + return thread.claimed_by_user ? 'в работе' : 'свободен'; +} + +function telegramThreadOwnerText(thread) { + if (telegramThreadAiClaimable(thread)) { + return 'владелец AI'; + } + return thread?.claimed_by_user ? `владелец ${thread.claimed_by_user}` : 'владелец —'; +} + +function telegramThreadHeaderPresence(thread) { + if (!thread) { + return 'Выберите диалог слева'; + } + if (thread.status === 'closed') { + return 'был(а) недавно'; + } + const aiMeta = telegramThreadAiStateMeta(thread); + if (aiMeta && thread.ai_state !== 'human_owned') { + return aiMeta.label; + } + return thread.claimed_by_user ? 'в работе' : 'в сети'; +} + +function telegramThreadNeedsSummary(thread) { + if (!thread) { + return false; + } + return ['handoff_required', 'human_owned'].includes(String(thread.ai_state || '')); +} + +function telegramThreadSummaryAccessible(thread) { + if (!telegramThreadNeedsSummary(thread)) { + return false; + } + if (String(state.role || '') !== 'operator') { + return true; + } + const claimedByUser = String(thread?.claimed_by_user || '').trim(); + return !claimedByUser || claimedByUser === String(state.user || '').trim(); +} + +function telegramSelectedSummaryVisible(thread) { + if (!telegramThreadSummaryAccessible(thread)) { + return false; + } + const summary = state.telegram.selectedThreadSummary; + return Boolean(summary && summary.thread_id === thread?.thread_id); +} + +function telegramSummaryFieldMarkup(label, value) { + return ` +
            +
            ${escapeHtml(label)}
            +
            ${escapeHtml(value || '—')}
            +
            + `; +} + +function telegramSummaryStatusMarkup(summary) { + if (!summary?.status_label) { + return ''; + } + const tone = String(summary.status_tone || 'handoff').trim() || 'handoff'; + return `${escapeHtml(summary.status_label)}`; +} + +function renderTelegramSummaryCard() { + const card = $('telegramAiSummaryCard'); + const content = $('telegramAiSummaryContent'); + const meta = $('telegramAiSummaryMeta'); + const status = $('telegramAiSummaryStatus'); + if (!card || !content) { + return; + } + const thread = selectedTelegramThread(); + if (!telegramSelectedSummaryVisible(thread)) { + card.classList.add('hidden'); + content.innerHTML = ''; + if (meta) { + meta.textContent = ''; + } + if (status) { + status.innerHTML = ''; + } + return; + } + const summary = state.telegram.selectedThreadSummary; + const generatedAt = summary?.generated_at ? `Сформировано ${escapeHtml(telegramThreadTimeLabel(summary.generated_at))}` : ''; + content.innerHTML = [ + telegramSummaryFieldMarkup('Запрос клиента', summary.customer_request_text), + telegramSummaryFieldMarkup('Что сделал AI', summary.ai_outcome_text), + telegramSummaryFieldMarkup('Почему передал человеку', summary.handoff_reason), + telegramSummaryFieldMarkup('Что делать дальше', summary.recommended_next_step), + ].join(''); + if (meta) { + meta.textContent = generatedAt; + } + if (status) { + status.innerHTML = telegramSummaryStatusMarkup(summary); + } + card.classList.remove('hidden'); +} + +function telegramThreadCountLabel(count) { + const mod10 = count % 10; + const mod100 = count % 100; + if (mod10 === 1 && mod100 !== 11) { + return `${count} диалог`; + } + if ([2, 3, 4].includes(mod10) && ![12, 13, 14].includes(mod100)) { + return `${count} диалога`; + } + return `${count} диалогов`; +} + +function telegramVisibleThreads() { + const query = String(state.telegram.searchQuery || '').trim().toLowerCase(); + return state.telegram.threads.filter((thread) => { + if (!query) { + return true; + } + return [ + telegramThreadDisplayName(thread), + thread.username ? `@${thread.username}` : '', + thread.chat_id, + thread.last_message_preview, + ].some((value) => String(value || '').toLowerCase().includes(query)); + }); +} + +function resetTelegramMessageSearch() { + state.telegram.messageSearchQuery = ''; + state.telegram.messageSearchOpen = false; +} + +function telegramMessageSearchQuery() { + return String(state.telegram.messageSearchQuery || '').trim(); +} + +function telegramMessageMatchesSearch(message) { + const query = telegramMessageSearchQuery().toLowerCase(); + if (!query) { + return true; + } + return String(message?.text || '').toLowerCase().includes(query); +} + +function telegramVisibleMessages() { + return state.telegram.messages.filter((message) => telegramMessageMatchesSearch(message)); +} + +function telegramMessageSearchMeta(thread) { + if (!thread) { + return 'Выберите чат для поиска'; + } + const query = telegramMessageSearchQuery(); + if (!query) { + return 'Поиск по текущему чату'; + } + const matches = telegramVisibleMessages().length; + if (matches === 0) { + return 'Ничего не найдено'; + } + if (matches === 1) { + return '1 совпадение'; + } + return `${matches} совпадений`; +} + +function highlightTelegramMessageText(value) { + const source = String(value || 'Сообщение без текста.'); + const query = telegramMessageSearchQuery(); + if (!query) { + return escapeHtml(source); + } + const matcher = new RegExp(`(${escapeRegExp(query)})`, 'gi'); + return source + .split(matcher) + .map((part) => (part.toLowerCase() === query.toLowerCase() + ? `${escapeHtml(part)}` + : escapeHtml(part))) + .join(''); +} + +function toggleTelegramMessageSearch(force = null) { + const hasThread = Boolean(selectedTelegramThread()); + const nextOpen = force === null ? !state.telegram.messageSearchOpen : Boolean(force); + state.telegram.messageSearchOpen = hasThread && nextOpen; + if (!state.telegram.messageSearchOpen) { + state.telegram.messageSearchQuery = ''; + } + renderTelegramWorkspace(); + if (state.telegram.messageSearchOpen) { + window.requestAnimationFrame(() => { + $('telegramMessageSearch')?.focus(); + $('telegramMessageSearch')?.select(); + }); + } +} + +function telegramMessageDayKey(value) { + if (!value) { + return ''; + } + const dt = new Date(value); + if (Number.isNaN(dt.getTime())) { + return ''; + } + return `${dt.getFullYear()}-${dt.getMonth()}-${dt.getDate()}`; +} + +function telegramMessageDayLabel(value) { + if (!value) { + return 'Недавно'; + } + const dt = new Date(value); + if (Number.isNaN(dt.getTime())) { + return String(value); + } + const now = new Date(); + const sameDay = dt.toDateString() === now.toDateString(); + if (sameDay) { + return 'Сегодня'; + } + const yesterday = new Date(now); + yesterday.setDate(now.getDate() - 1); + if (dt.toDateString() === yesterday.toDateString()) { + return 'Вчера'; + } + return dt.toLocaleDateString('ru-RU', { day: 'numeric', month: 'long' }); +} + +function telegramMessageStatusMarkup(message) { + if (message.direction !== 'outbound' || !message.delivery_status) { + return ''; + } + const status = String(message.delivery_status || '').toLowerCase(); + const checks = status.includes('deliver') || status.includes('read') || status.includes('sent') ? '✓✓' : '✓'; + return `${checks}`; +} + +function syncTelegramComposerHeight() { + const input = $('telegramReplyText'); + if (!input) { + return; + } + input.style.height = '0px'; + const nextHeight = Math.min(Math.max(input.scrollHeight, 54), 164); + input.style.height = `${nextHeight}px`; +} + +function renderTelegramThreadsList() { + const target = $('telegramThreadsList'); + const threads = telegramVisibleThreads(); + if (!threads.length) { + target.innerHTML = '
            Пока нет Telegram диалогов.
            '; + return; + } + target.innerHTML = threads + .map((thread) => { + const selected = thread.thread_id === state.telegram.selectedThreadId ? ' selected' : ''; + const preview = thread.last_message_preview || 'Нет сообщений.'; + const unread = telegramThreadUnreadCount(thread); + const aiMeta = telegramThreadAiStateMeta(thread); + return ` + + `; + }) + .join(''); +} + +function renderTelegramMessagesTimeline() { + const target = $('telegramMessagesTimeline'); + const thread = selectedTelegramThread(); + if (!thread) { + target.innerHTML = '
            Выберите диалог слева, чтобы увидеть историю сообщений.
            '; + return; + } + if (!state.telegram.messages.length) { + target.innerHTML = '
            Сообщений пока нет.
            '; + return; + } + const messages = telegramVisibleMessages(); + if (!messages.length) { + target.innerHTML = '
            Ничего не найдено в этом чате.
            '; + return; + } + const fragments = messages.map((message) => { + const direction = message.direction || 'system'; + if (direction === 'system') { + return ` +
            +
            ${highlightTelegramMessageText(message.text || 'Системное сообщение')}
            +
            + `; + } + const mine = direction === 'outbound'; + const aiBadge = message.author_type === 'ai' + ? 'AI' + : ''; + return ` +
            +
            + ${aiBadge} +
            ${highlightTelegramMessageText(message.text || 'Сообщение без текста.')}
            +
            + ${escapeHtml(telegramThreadTimeLabel(message.created_at))} + ${mine ? telegramMessageStatusMarkup(message) : ''} +
            +
            +
            + `; + }); + target.innerHTML = fragments.join(''); + target.scrollTop = telegramMessageSearchQuery() ? 0 : target.scrollHeight; +} + +function syncTelegramActionButtons() { + const thread = selectedTelegramThread(); + const pending = state.telegram.pendingAction; + const replyText = $('telegramReplyText').value.trim(); + $('telegramClaimThreadBtn').textContent = telegramClaimButtonLabel(thread); + $('telegramClaimThreadBtn').disabled = pending !== '' || !telegramThreadCanClaim(thread); + $('telegramReturnToAiBtn').disabled = pending !== '' || !telegramThreadCanReturnToAi(thread); + $('telegramCloseThreadBtn').disabled = pending !== '' || !telegramThreadCanManage(thread) || thread?.status === 'closed'; + $('telegramEscalateThreadBtn').disabled = pending !== '' || !telegramThreadCanManage(thread); + $('telegramSendReplyBtn').disabled = pending !== '' || !telegramThreadCanReply(thread) || !replyText; + syncTelegramComposerHeight(); +} + +function syncTelegramOperatorTray() { + $('telegramOperatorTray').classList.toggle('hidden', !state.telegram.operatorTrayOpen); +} + +function toggleTelegramOperatorTray(force = null) { + state.telegram.operatorTrayOpen = force === null ? !state.telegram.operatorTrayOpen : Boolean(force); + syncTelegramOperatorTray(); +} + +function renderTelegramWorkspace() { + renderTelegramThreadsList(); + renderTelegramSummaryCard(); + renderTelegramMessagesTimeline(); + const thread = selectedTelegramThread(); + const avatar = $('telegramThreadAvatar'); + const presence = $('telegramThreadPresenceChip'); + const owner = $('telegramThreadOwnerChip'); + const messageSearchButton = $('telegramHeaderSearchBtn'); + const messageSearchBar = $('telegramMessageSearchBar'); + const messageSearchInput = $('telegramMessageSearch'); + const messageSearchMeta = $('telegramMessageSearchMeta'); + const messageSearchClearBtn = $('telegramMessageSearchClearBtn'); + const aiChip = $('telegramThreadAiChip'); + const aiMeta = telegramThreadAiStateMeta(thread); + $('telegramThreadDisplayName').textContent = thread + ? telegramThreadDisplayName(thread) + : 'Выберите диалог'; + $('telegramThreadMeta').textContent = thread + ? telegramThreadHeaderPresence(thread) + : 'Выберите диалог слева.'; + if (avatar) { + avatar.textContent = thread ? telegramThreadInitials(thread) : 'TG'; + const [start, end] = telegramAvatarPalette(thread ? telegramThreadDisplayName(thread) : 'Telegram'); + avatar.style.setProperty('--telegram-avatar-start', start); + avatar.style.setProperty('--telegram-avatar-end', end); + } + if (presence) { + presence.textContent = thread ? telegramThreadPresenceText(thread) : 'нет диалога'; + const presenceTone = aiMeta + ? aiMeta.tone + : (thread?.claimed_by_user ? 'claimed' : 'free'); + presence.className = `telegram-operator-chip ${presenceTone}`; + } + if (owner) { + owner.textContent = thread ? telegramThreadOwnerText(thread) : 'владелец —'; + owner.className = 'telegram-operator-chip subtle'; + } + if (aiChip) { + if (aiMeta) { + aiChip.textContent = aiMeta.label; + aiChip.className = `telegram-thread-ai-chip ${aiMeta.tone}`; + } else { + aiChip.textContent = ''; + aiChip.className = 'telegram-thread-ai-chip hidden'; + } + } + if (messageSearchButton) { + messageSearchButton.disabled = !thread; + messageSearchButton.classList.toggle('active', Boolean(thread) && state.telegram.messageSearchOpen); + } + if (messageSearchBar) { + messageSearchBar.classList.toggle('hidden', !thread || !state.telegram.messageSearchOpen); + } + if (messageSearchInput) { + messageSearchInput.value = state.telegram.messageSearchQuery || ''; + messageSearchInput.disabled = !thread; + } + if (messageSearchMeta) { + messageSearchMeta.textContent = telegramMessageSearchMeta(thread); + } + if (messageSearchClearBtn) { + messageSearchClearBtn.textContent = telegramMessageSearchQuery() ? 'Очистить' : 'Закрыть'; + } + $('telegramComposerHint').textContent = thread + ? telegramThreadCanReply(thread) + ? 'Reply уйдёт через Telegram Bot API и сохранится в thread history.' + : telegramThreadCanReturnToAi(thread) + ? 'Диалог сейчас у человека. Нажмите «Вернуть AI», чтобы снова включить автоответ.' + : telegramThreadAiClaimable(thread) + ? 'Диалог сейчас у AI. Заберите его в работу, чтобы ответить вручную.' + : 'Reply доступен после claim или для admin/supervisor.' + : 'Выберите thread, чтобы отвечать в Telegram.'; + syncTelegramOperatorTray(); + syncTelegramComposerHeight(); + syncTelegramActionButtons(); +} + +function applyTelegramCollections(threads, options = {}) { + const { preserveSelection = true } = options; + const sorted = [...(threads || [])].sort((left, right) => { + return String(right.last_message_at || '').localeCompare(String(left.last_message_at || '')); + }); + state.telegram.threads = sorted; + const previous = preserveSelection ? state.telegram.selectedThreadId : ''; + const stillExists = sorted.some((thread) => thread.thread_id === previous); + state.telegram.selectedThreadId = stillExists ? previous : (sorted[0]?.thread_id || ''); + if (state.telegram.selectedThreadId !== previous) { + resetTelegramMessageSearch(); + state.telegram.selectedThreadSummary = null; + } + if (!state.telegram.selectedThreadId) { + state.telegram.selectedThreadSummary = null; + } + renderTelegramWorkspace(); + renderCustomerSpotlight(); +} + +async function loadTelegramThreadMessages(threadId = state.telegram.selectedThreadId, logResult = false) { + if (!threadId) { + state.telegram.messages = []; + state.telegram.selectedThreadSummary = null; + resetTelegramMessageSearch(); + renderTelegramWorkspace(); + return []; + } + const data = await api('telegram', `integrations/telegram/threads/${encodeURIComponent(threadId)}/messages`); + if (threadId === state.telegram.selectedThreadId) { + state.telegram.messages = Array.isArray(data) ? data : []; + renderTelegramWorkspace(); + renderCustomerSpotlight(); + } + if (logResult) { + log('Сообщения Telegram-диалога обновлены', { thread_id: threadId, messages: Array.isArray(data) ? data.length : 0 }); + } + return Array.isArray(data) ? data : []; +} + +async function loadTelegramThreadSummary(threadId = state.telegram.selectedThreadId, options = {}) { + const { logResult = false } = options; + if (!threadId) { + state.telegram.selectedThreadSummary = null; + renderTelegramWorkspace(); + return null; + } + const thread = state.telegram.threads.find((item) => item.thread_id === threadId) || null; + if (!telegramThreadSummaryAccessible(thread)) { + if (threadId === state.telegram.selectedThreadId) { + state.telegram.selectedThreadSummary = null; + renderTelegramWorkspace(); + } + return null; + } + try { + const data = await api('telegram', `integrations/telegram/threads/${encodeURIComponent(threadId)}/ai-summary`); + if (threadId === state.telegram.selectedThreadId) { + state.telegram.selectedThreadSummary = data || null; + renderTelegramWorkspace(); + } + if (logResult && data) { + log('AI-сводка загружена', { thread_id: threadId, session_id: data.session_id }); + } + return data || null; + } catch (err) { + if (threadId === state.telegram.selectedThreadId) { + state.telegram.selectedThreadSummary = null; + renderTelegramWorkspace(); + } + if (logResult) { + log('Не удалось загрузить AI-сводку', { thread_id: threadId, error: err.message }); + } + return null; + } +} + +async function loadTelegramThreads(logResult = true, options = {}) { + const { preserveSelection = true, preserveOnError = false } = options; + try { + const data = await api('telegram', 'integrations/telegram/threads'); + applyTelegramCollections(Array.isArray(data) ? data : [], { preserveSelection }); + await loadTelegramThreadMessages(state.telegram.selectedThreadId, false); + await loadTelegramThreadSummary(state.telegram.selectedThreadId, { logResult: false }); + if (logResult) { + log('Telegram-диалоги обновлены', { threads: state.telegram.threads.length }); + } + } catch (err) { + if (!preserveOnError) { + state.telegram.threads = []; + state.telegram.selectedThreadId = ''; + state.telegram.messages = []; + state.telegram.selectedThreadSummary = null; + renderTelegramWorkspace(); + } + if (logResult) { + log('Не удалось загрузить Telegram-диалоги', { error: err.message }); + } + } +} + +async function selectTelegramThread(threadId) { + if ((threadId || '') !== state.telegram.selectedThreadId) { + resetTelegramMessageSearch(); + } + state.telegram.selectedThreadId = threadId || ''; + state.telegram.selectedThreadSummary = null; + state.telegram.operatorTrayOpen = false; + renderTelegramWorkspace(); + await loadTelegramThreadMessages(threadId, false); + await loadTelegramThreadSummary(threadId, { logResult: false }); +} + +async function claimTelegramThread() { + const thread = selectedTelegramThread(); + if (!thread) { + return; + } + state.telegram.pendingAction = 'claim'; + renderTelegramWorkspace(); + try { + const data = await api('telegram', `integrations/telegram/threads/${encodeURIComponent(thread.thread_id)}/claim`, { + method: 'POST', + }); + const nextThreads = state.telegram.threads.map((item) => (item.thread_id === data.thread_id ? data : item)); + applyTelegramCollections(nextThreads); + await loadTelegramThreadSummary(data.thread_id, { logResult: false }); + refreshLiveCallsInBackground(); + loadInteractions().catch(() => {}); + log('Telegram-диалог взят в работу', { thread_id: data.thread_id, interaction_id: data.interaction_id }); + } catch (err) { + log('Не удалось взять Telegram-диалог в работу', { error: err.message }); + } finally { + state.telegram.pendingAction = ''; + renderTelegramWorkspace(); + } +} + +async function returnTelegramThreadToAi() { + const thread = selectedTelegramThread(); + if (!thread) { + return; + } + state.telegram.pendingAction = 'return-ai'; + renderTelegramWorkspace(); + try { + const data = await api('telegram', `integrations/telegram/threads/${encodeURIComponent(thread.thread_id)}/return-to-ai`, { + method: 'POST', + }); + const nextThreads = state.telegram.threads.map((item) => (item.thread_id === data.thread_id ? data : item)); + applyTelegramCollections(nextThreads); + await loadTelegramThreadSummary(data.thread_id, { logResult: false }); + loadInteractions().catch(() => {}); + log('Telegram-диалог возвращён AI', { thread_id: data.thread_id, interaction_id: data.interaction_id }); + } catch (err) { + log('Не удалось вернуть Telegram-диалог AI', { error: err.message }); + } finally { + state.telegram.pendingAction = ''; + renderTelegramWorkspace(); + } +} + +async function sendTelegramReply() { + const thread = selectedTelegramThread(); + const text = $('telegramReplyText').value.trim(); + if (!thread || !text) { + return; + } + state.telegram.pendingAction = 'reply'; + renderTelegramWorkspace(); + try { + const data = await api('telegram', `integrations/telegram/threads/${encodeURIComponent(thread.thread_id)}/messages`, { + method: 'POST', + body: JSON.stringify({ text }), + }); + $('telegramReplyText').value = ''; + state.telegram.messages = [...state.telegram.messages, data]; + const nextThreads = state.telegram.threads.map((item) => { + if (item.thread_id !== thread.thread_id) { + return item; + } + return { + ...item, + last_message_at: data.created_at, + last_message_preview: data.text, + }; + }); + applyTelegramCollections(nextThreads); + renderTelegramWorkspace(); + log('Ответ в Telegram поставлен в очередь', { thread_id: thread.thread_id, delivery_status: data.delivery_status || 'pending' }); + window.setTimeout(() => loadTelegramThreads(false, { preserveSelection: true, preserveOnError: true }), 0); + } catch (err) { + log('Не удалось отправить ответ в Telegram', { error: err.message }); + } finally { + state.telegram.pendingAction = ''; + renderTelegramWorkspace(); + } +} + +async function closeTelegramThread() { + const thread = selectedTelegramThread(); + if (!thread) { + return; + } + state.telegram.pendingAction = 'close'; + renderTelegramWorkspace(); + try { + const data = await api('telegram', `integrations/telegram/threads/${encodeURIComponent(thread.thread_id)}/close`, { + method: 'POST', + }); + const nextThreads = state.telegram.threads.map((item) => (item.thread_id === data.thread_id ? data : item)); + applyTelegramCollections(nextThreads); + await loadTelegramThreadSummary(data.thread_id, { logResult: false }); + loadInteractions().catch(() => {}); + log('Telegram-диалог закрыт', { thread_id: data.thread_id }); + } catch (err) { + log('Не удалось закрыть Telegram-диалог', { error: err.message }); + } finally { + state.telegram.pendingAction = ''; + renderTelegramWorkspace(); + } +} + +async function escalateTelegramThread() { + const thread = selectedTelegramThread(); + const targetQueueId = $('telegramEscalationQueue').value.trim() || DEMO_QUEUE; + if (!thread) { + return; + } + state.telegram.pendingAction = 'escalate'; + renderTelegramWorkspace(); + try { + const data = await api('telegram', `integrations/telegram/threads/${encodeURIComponent(thread.thread_id)}/escalate`, { + method: 'POST', + body: JSON.stringify({ target_queue_id: targetQueueId }), + }); + const nextThreads = state.telegram.threads.map((item) => (item.thread_id === data.thread_id ? data : item)); + applyTelegramCollections(nextThreads); + await loadTelegramThreadSummary(data.thread_id, { logResult: false }); + loadInteractions().catch(() => {}); + log('Telegram-диалог эскалирован', { thread_id: data.thread_id, queue_id: data.queue_id }); + } catch (err) { + log('Не удалось эскалировать Telegram-диалог', { error: err.message }); + } finally { + state.telegram.pendingAction = ''; + renderTelegramWorkspace(); + } +} + +function startTelegramPolling() { + if (state.telegram.pollTimer) { + window.clearInterval(state.telegram.pollTimer); + state.telegram.pollTimer = null; + } + state.telegram.pollTimer = window.setInterval(() => { + loadTelegramThreads(false, { preserveSelection: true, preserveOnError: true }); + }, 5000); +} + +function selectedWhatsappChat() { + return state.whatsapp.chats.find((chat) => chat.id === state.whatsapp.selectedChatId) || null; +} + +function ensureWhatsappSelection() { + if (!state.whatsapp.chats.length) { + state.whatsapp.selectedChatId = ''; + return; + } + const exists = state.whatsapp.chats.some((chat) => chat.id === state.whatsapp.selectedChatId); + if (!exists) { + state.whatsapp.selectedChatId = state.whatsapp.chats[0].id; + } +} + +function whatsappChatInitials(chat) { + const parts = String(chat?.title || 'WA') + .trim() + .split(/\s+/) + .filter(Boolean) + .slice(0, 2); + if (!parts.length) { + return 'WA'; + } + return parts.map((part) => part[0].toUpperCase()).join(''); +} + +function whatsappChatTimeLabel(value) { + if (!value) { + return ''; + } + const dt = new Date(value); + if (Number.isNaN(dt.getTime())) { + return ''; + } + const now = new Date(); + const sameDay = dt.toDateString() === now.toDateString(); + if (sameDay) { + return dt.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + } + return dt.toLocaleDateString([], { day: '2-digit', month: 'short' }); +} + +function whatsappMessageTimeLabel(value) { + if (!value) { + return ''; + } + const dt = new Date(value); + if (Number.isNaN(dt.getTime())) { + return ''; + } + return dt.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); +} + +function whatsappMessageDayKey(value) { + if (!value) { + return ''; + } + const dt = new Date(value); + if (Number.isNaN(dt.getTime())) { + return ''; + } + return `${dt.getFullYear()}-${String(dt.getMonth() + 1).padStart(2, '0')}-${String(dt.getDate()).padStart(2, '0')}`; +} + +function whatsappMessageDayLabel(value) { + if (!value) { + return ''; + } + const dt = new Date(value); + if (Number.isNaN(dt.getTime())) { + return ''; + } + const today = new Date(); + const yesterday = new Date(); + yesterday.setDate(today.getDate() - 1); + if (dt.toDateString() === today.toDateString()) { + return 'Today'; + } + if (dt.toDateString() === yesterday.toDateString()) { + return 'Yesterday'; + } + return dt.toLocaleDateString([], { day: 'numeric', month: 'long' }); +} + +function whatsappVisibleChats() { + const query = String(state.whatsapp.searchQuery || '').trim().toLowerCase(); + return state.whatsapp.chats.filter((chat) => { + if (state.whatsapp.activeFilter === 'unread' && !(whatsappThreadUnreadCount(chat) > 0)) { + return false; + } + if (state.whatsapp.activeFilter === 'groups' && !chat.isGroup) { + return false; + } + if (!query) { + return true; + } + return [ + chat.title, + chat.handle, + chat.statusLine, + chat.lastMessagePreview, + ...(chat.messages || []).map((message) => message.text || ''), + ] + .join(' ') + .toLowerCase() + .includes(query); + }); +} + +function whatsappMessageStatusMarkup(message) { + if (message.direction !== 'out') { + return ''; + } + if (message.deliveryStatus === 'read') { + return ''; + } + if (message.deliveryStatus === 'delivered') { + return ''; + } + return ''; +} + +function whatsappComposerBlockedReason(chat) { + if (!chat) { + return 'Выберите чат'; + } + if (chat.status === 'closed') { + return 'Thread is closed'; + } + if (state.role === 'operator' && chat.claimedByUser && chat.claimedByUser !== state.user) { + return `Claimed by ${chat.claimedByUser}`; + } + return ''; +} + +function syncWhatsappComposerHeight() { + const input = $('whatsappComposerInput'); + if (!input) { + return; + } + input.style.height = 'auto'; + input.style.height = `${Math.min(Math.max(input.scrollHeight, 24), 120)}px`; +} + +function syncWhatsappComposerUi() { + const input = $('whatsappComposerInput'); + const sendBtn = $('whatsappSendBtn'); + if (!input || !sendBtn) { + return; + } + sendBtn.disabled = Boolean(whatsappComposerBlockedReason(selectedWhatsappChat())) + || !String(state.whatsapp.composerText || '').trim() + || Boolean(state.whatsapp.pendingAction); + syncWhatsappComposerHeight(); +} + +function renderWhatsappChatList() { + const target = $('whatsappChatList'); + if (!target) { + return; + } + const chats = whatsappVisibleChats(); + if (!chats.length) { + target.innerHTML = ` +
            + Чаты не найдены +

            Попробуйте другой запрос или переключите активный фильтр.

            +
            + `; + return; + } + target.innerHTML = chats.map((chat) => { + const [start, end] = whatsappAvatarPalette(chat.title); + const selected = chat.id === state.whatsapp.selectedChatId; + const aiMeta = whatsappThreadAiStateMeta(chat); + return ` + + `; + }).join(''); +} + +function renderWhatsappContextPanel() { + const panel = $('whatsappContextPanel'); + const aiChip = $('whatsappAiStateChip'); + const ownerChip = $('whatsappOwnerChip'); + const claimBtn = $('whatsappClaimBtn'); + const returnBtn = $('whatsappReturnToAiBtn'); + const chat = selectedWhatsappChat(); + if (!panel || !aiChip || !ownerChip || !claimBtn || !returnBtn) { + return; + } + const aiMeta = whatsappThreadAiStateMeta(chat); + const ownerText = whatsappThreadOwnerText(chat); + const showPanel = Boolean(chat && (aiMeta || chat.claimedByUser || whatsappSelectedSummaryVisible(chat))); + panel.classList.toggle('hidden', !showPanel); + + if (aiMeta) { + aiChip.textContent = aiMeta.label; + aiChip.className = `whatsapp-state-chip ${aiMeta.tone}`; + } else { + aiChip.textContent = ''; + aiChip.className = 'whatsapp-state-chip hidden'; + } + + if (chat) { + ownerChip.textContent = ownerText; + ownerChip.className = 'whatsapp-state-chip subtle'; + } else { + ownerChip.textContent = ''; + ownerChip.className = 'whatsapp-state-chip subtle hidden'; + } + + claimBtn.disabled = Boolean(state.whatsapp.pendingAction) || !whatsappThreadCanClaim(chat); + claimBtn.classList.toggle('hidden', !whatsappThreadCanClaim(chat)); + returnBtn.disabled = Boolean(state.whatsapp.pendingAction) || !whatsappThreadCanReturnToAi(chat); + returnBtn.classList.toggle('hidden', !whatsappThreadCanReturnToAi(chat)); + + renderWhatsappSummaryCard(); +} + +function renderWhatsappMessagesTimeline() { + const target = $('whatsappMessageTimeline'); + if (!target) { + return; + } + const chat = selectedWhatsappChat(); + if (!chat) { + target.innerHTML = ` +
            + Выберите чат +

            ${state.whatsapp.mode === 'live' ? 'Здесь появятся живые WhatsApp-диалоги.' : 'Демо-режим остаётся доступным, пока backend недоступен.'}

            +
            + `; + return; + } + if (!chat.messages?.length) { + target.innerHTML = ` +
            + Пока нет сообщений +

            ${state.whatsapp.mode === 'live' ? 'Ждём входящую активность WhatsApp в этом чате.' : 'Демо-рабочее место готово к первому сообщению.'}

            +
            + `; + return; + } + const fragments = [ + '
            Сообщения и звонки защищены сквозным шифрованием. Никто вне этого чата не может их прочитать или прослушать.
            ', + ]; + let lastDayKey = ''; + (chat.messages || []).forEach((message) => { + const dayKey = whatsappMessageDayKey(message.createdAt); + if (message.direction === 'system') { + fragments.push(`
            ${escapeHtml(message.text || whatsappMessageDayLabel(message.createdAt))}
            `); + lastDayKey = dayKey || lastDayKey; + return; + } + if (dayKey && dayKey !== lastDayKey) { + lastDayKey = dayKey; + fragments.push(`
            ${escapeHtml(whatsappMessageDayLabel(message.createdAt))}
            `); + } + const mine = message.direction === 'out'; + const aiBadge = mine && message.authorType === 'ai' + ? 'AI' + : ''; + const author = !mine && chat.isGroup && message.author + ? `
            ${escapeHtml(message.author)}
            ` + : ''; + fragments.push(` +
            +
            + ${aiBadge} + ${author} +
            ${escapeHtml(message.text || '')}
            +
            + ${escapeHtml(whatsappMessageTimeLabel(message.createdAt))} + ${whatsappMessageStatusMarkup(message)} +
            +
            +
            + `); + }); + target.innerHTML = fragments.join(''); + target.scrollTop = target.scrollHeight; +} + +function renderWhatsappWorkspace() { + ensureWhatsappSelection(); + renderWhatsappChatList(); + renderWhatsappContextPanel(); + renderWhatsappMessagesTimeline(); + const chat = selectedWhatsappChat(); + const searchInput = $('whatsappSearchInput'); + const composerInput = $('whatsappComposerInput'); + const avatar = $('whatsappChatAvatar'); + const paneCaption = $('whatsappPaneCaption'); + const blockedReason = whatsappComposerBlockedReason(chat); + const [start, end] = whatsappAvatarPalette(chat?.title || 'WhatsApp'); + if (searchInput && searchInput.value !== state.whatsapp.searchQuery) { + searchInput.value = state.whatsapp.searchQuery; + } + if (composerInput && composerInput.value !== state.whatsapp.composerText) { + composerInput.value = state.whatsapp.composerText; + } + if (composerInput) { + composerInput.disabled = !chat || Boolean(blockedReason) || Boolean(state.whatsapp.pendingAction); + composerInput.placeholder = !chat + ? 'Выберите чат' + : blockedReason || 'Написать сообщение'; + } + if (avatar) { + avatar.textContent = chat ? whatsappChatInitials(chat) : 'WA'; + avatar.style.setProperty('--whatsapp-avatar-start', start); + avatar.style.setProperty('--whatsapp-avatar-end', end); + } + if (paneCaption) { + paneCaption.textContent = state.whatsapp.mode === 'live' + ? 'Живая очередь с поддержкой AI' + : state.whatsapp.mode === 'mock' + ? 'Демо-режим, пока backend недоступен' + : 'Подключаемся к backend WhatsApp'; + } + $('whatsappChatTitle').textContent = chat ? chat.title : 'WhatsApp'; + $('whatsappChatMeta').textContent = chat + ? [whatsappThreadHandle(chat), whatsappThreadPresenceText(chat)].filter(Boolean).join(' • ') + : 'Выберите чат для начала переписки.'; + document.querySelectorAll('[data-whatsapp-filter]').forEach((button) => { + button.classList.toggle('active', button.dataset.whatsappFilter === state.whatsapp.activeFilter); + }); + syncWhatsappComposerUi(); +} + +async function loadWhatsappThreadMessages(threadId = state.whatsapp.selectedChatId, logResult = false) { + if (!threadId) { + state.whatsapp.selectedThreadSummary = null; + renderWhatsappWorkspace(); + return []; + } + if (state.whatsapp.mode !== 'live') { + return selectedWhatsappChat()?.messages || []; + } + const chat = state.whatsapp.chats.find((item) => item.id === threadId) || null; + const data = await api('whatsapp', `integrations/whatsapp/threads/${encodeURIComponent(threadId)}/messages`); + const messages = Array.isArray(data) + ? data.map((message) => normalizeWhatsappMessage(message, chat)) + : []; + updateWhatsappChatMessages(threadId, messages); + if (logResult) { + log('WhatsApp thread messages обновлены', { thread_id: threadId, messages: messages.length }); + } + return messages; +} + +async function loadWhatsappThreadSummary(threadId = state.whatsapp.selectedChatId, options = {}) { + const { logResult = false } = options; + if (!threadId || state.whatsapp.mode !== 'live') { + state.whatsapp.selectedThreadSummary = null; + renderWhatsappWorkspace(); + return null; + } + const thread = state.whatsapp.chats.find((item) => item.id === threadId) || null; + if (!whatsappThreadSummaryAccessible(thread)) { + if (threadId === state.whatsapp.selectedChatId) { + state.whatsapp.selectedThreadSummary = null; + renderWhatsappWorkspace(); + } + return null; + } + try { + const data = await api('whatsapp', `integrations/whatsapp/threads/${encodeURIComponent(threadId)}/ai-summary`); + if (threadId === state.whatsapp.selectedChatId) { + state.whatsapp.selectedThreadSummary = data || null; + renderWhatsappWorkspace(); + } + if (logResult && data) { + log('AI-сводка WhatsApp загружена', { thread_id: threadId, session_id: data.session_id }); + } + return data || null; + } catch (err) { + if (threadId === state.whatsapp.selectedChatId) { + state.whatsapp.selectedThreadSummary = null; + renderWhatsappWorkspace(); + } + if (logResult) { + log('Не удалось загрузить AI-сводку WhatsApp', { thread_id: threadId, error: err.message }); + } + return null; + } +} + +async function loadWhatsappThreads(logResult = true, options = {}) { + const { + preserveSelection = true, + preserveOnError = false, + allowMockFallback = true, + } = options; + try { + const data = await api('whatsapp', 'integrations/whatsapp/threads'); + const existingById = new Map(state.whatsapp.chats.map((chat) => [chat.id, chat])); + const chats = (Array.isArray(data) ? data : []).map((thread) => normalizeWhatsappThread(thread, existingById.get(thread.thread_id))); + state.whatsapp.mode = 'live'; + state.whatsapp.backendError = ''; + applyWhatsappChats(chats, { preserveSelection }); + await loadWhatsappThreadMessages(state.whatsapp.selectedChatId, false); + await loadWhatsappThreadSummary(state.whatsapp.selectedChatId, { logResult: false }); + if (logResult) { + log('WhatsApp threads обновлены', { + threads: state.whatsapp.chats.length, + ai_threads: state.whatsapp.chats.filter((chat) => chat.aiState).length, + }); + } + } catch (err) { + state.whatsapp.backendError = err.message; + if (!state.whatsapp.chats.length && allowMockFallback) { + seedWhatsappChats(); + renderWhatsappWorkspace(); + if (logResult || !state.whatsapp.mockFallbackLogged) { + log('WhatsApp backend недоступен, включён demo fallback', { error: err.message }); + state.whatsapp.mockFallbackLogged = true; + } + return; + } + if (!preserveOnError) { + state.whatsapp.chats = []; + state.whatsapp.selectedChatId = ''; + state.whatsapp.selectedThreadSummary = null; + renderWhatsappWorkspace(); + } + if (logResult) { + log('Не удалось загрузить WhatsApp threads', { error: err.message }); + } + } +} + +async function selectWhatsappChat(chatId) { + if (!chatId) { + return; + } + if (chatId !== state.whatsapp.selectedChatId) { + state.whatsapp.selectedThreadSummary = null; + } + state.whatsapp.selectedChatId = chatId; + state.whatsapp.composerText = ''; + state.whatsapp.chats = state.whatsapp.chats.map((chat) => ({ + ...chat, + unreadCount: chat.id === chatId ? 0 : chat.unreadCount, + })); + renderWhatsappWorkspace(); + if (state.whatsapp.mode === 'live') { + await loadWhatsappThreadMessages(chatId, false); + await loadWhatsappThreadSummary(chatId, { logResult: false }); + } + window.requestAnimationFrame(() => { + $('whatsappComposerInput')?.focus(); + }); +} + +function setWhatsappFilter(filter) { + state.whatsapp.activeFilter = filter || 'all'; + renderWhatsappWorkspace(); +} + +async function claimWhatsappThread(options = {}) { + const { silent = false } = options; + const chat = selectedWhatsappChat(); + if (!chat) { + return null; + } + const shouldManagePending = !state.whatsapp.pendingAction; + if (shouldManagePending) { + state.whatsapp.pendingAction = 'claim'; + renderWhatsappWorkspace(); + } + try { + const data = await api('whatsapp', `integrations/whatsapp/threads/${encodeURIComponent(whatsappChatThreadId(chat))}/claim`, { + method: 'POST', + }); + upsertWhatsappThreadData(data); + await loadWhatsappThreadSummary(data.thread_id, { logResult: false }); + loadInteractions().catch(() => {}); + if (!silent) { + log('WhatsApp thread взят в работу', { thread_id: data.thread_id, interaction_id: data.interaction_id }); + } + return selectedWhatsappChat(); + } catch (err) { + if (!silent) { + log('Не удалось взять WhatsApp thread в работу', { error: err.message }); + } + throw err; + } finally { + if (shouldManagePending) { + state.whatsapp.pendingAction = ''; + renderWhatsappWorkspace(); + } + } +} + +async function returnWhatsappThreadToAi() { + const chat = selectedWhatsappChat(); + if (!chat) { + return; + } + state.whatsapp.pendingAction = 'return-ai'; + renderWhatsappWorkspace(); + try { + const data = await api('whatsapp', `integrations/whatsapp/threads/${encodeURIComponent(whatsappChatThreadId(chat))}/return-to-ai`, { + method: 'POST', + }); + upsertWhatsappThreadData(data); + await loadWhatsappThreadSummary(data.thread_id, { logResult: false }); + loadInteractions().catch(() => {}); + log('WhatsApp thread возвращён AI', { thread_id: data.thread_id, interaction_id: data.interaction_id }); + } catch (err) { + log('Не удалось вернуть WhatsApp thread AI', { error: err.message }); + } finally { + state.whatsapp.pendingAction = ''; + renderWhatsappWorkspace(); + } +} + +async function sendWhatsappMessage() { + const chat = selectedWhatsappChat(); + const text = String(state.whatsapp.composerText || '').trim(); + if (!chat || !text) { + return; + } + const blockedReason = whatsappComposerBlockedReason(chat); + if (blockedReason) { + log('Нельзя отправить WhatsApp reply', { reason: blockedReason, thread_id: whatsappChatThreadId(chat) }); + return; + } + if (state.whatsapp.mode !== 'live') { + const now = new Date().toISOString(); + const outgoing = { + id: `wa-local-${Date.now()}`, + direction: 'out', + text, + createdAt: now, + deliveryStatus: 'read', + authorType: 'human', + }; + state.whatsapp.chats = sortWhatsappChats(state.whatsapp.chats.map((item) => { + if (item.id !== chat.id) { + return item; + } + return { + ...item, + unreadCount: 0, + lastMessageAt: now, + lastMessagePreview: text, + messages: [...(item.messages || []), outgoing], + }; + })); + state.whatsapp.selectedChatId = chat.id; + state.whatsapp.composerText = ''; + renderWhatsappWorkspace(); + window.requestAnimationFrame(() => { + $('whatsappComposerInput')?.focus(); + }); + log('WhatsApp local reply saved', { chat_id: chat.id }); + return; + } + state.whatsapp.pendingAction = 'reply'; + renderWhatsappWorkspace(); + try { + let activeChat = selectedWhatsappChat() || chat; + if (state.role === 'operator' && !activeChat.claimedByUser) { + const claimedChat = await claimWhatsappThread({ silent: true }); + activeChat = claimedChat || selectedWhatsappChat() || activeChat; + } + const data = await api('whatsapp', `integrations/whatsapp/threads/${encodeURIComponent(whatsappChatThreadId(activeChat))}/messages`, { + method: 'POST', + body: JSON.stringify({ text }), + }); + const outgoing = normalizeWhatsappMessage(data, activeChat); + state.whatsapp.composerText = ''; + const nextChats = state.whatsapp.chats.map((item) => { + if (item.id !== activeChat.id) { + return item; + } + return { + ...item, + claimedByUser: item.claimedByUser || (state.role === 'operator' ? state.user : ''), + aiState: 'human_owned', + unreadCount: 0, + lastMessageAt: outgoing.createdAt, + lastMessagePreview: outgoing.text, + messages: [...(item.messages || []), outgoing], + }; + }); + applyWhatsappChats(nextChats); + await loadWhatsappThreadSummary(whatsappChatThreadId(activeChat), { logResult: false }); + log('WhatsApp reply поставлен в очередь', { + thread_id: whatsappChatThreadId(activeChat), + delivery_status: data.delivery_status || 'pending', + }); + window.setTimeout(() => { + loadWhatsappThreads(false, { preserveSelection: true, preserveOnError: true, allowMockFallback: false }); + }, 0); + } catch (err) { + log('Не удалось отправить WhatsApp reply', { error: err.message }); + } finally { + state.whatsapp.pendingAction = ''; + renderWhatsappWorkspace(); + window.requestAnimationFrame(() => { + $('whatsappComposerInput')?.focus(); + }); + } +} + +function startWhatsappPolling() { + if (state.whatsapp.pollTimer) { + window.clearInterval(state.whatsapp.pollTimer); + state.whatsapp.pollTimer = null; + } + state.whatsapp.pollTimer = window.setInterval(() => { + loadWhatsappThreads(false, { + preserveSelection: true, + preserveOnError: true, + allowMockFallback: state.whatsapp.mode !== 'live', + }); + }, 5000); +} + +function handleWhatsappUiAction(event) { + const action = event.currentTarget.dataset.whatsappUiAction || 'неизвестно'; + if (action === 'chats') { + return; + } + if (action === 'search-chat') { + $('whatsappSearchInput')?.focus(); + return; + } + if (action === 'new-chat') { + $('whatsappSearchInput')?.focus(); + log('Создание нового WhatsApp чата пока не подключено'); + return; + } + log('WhatsApp UI action пока без backend-привязки', { action, mode: state.whatsapp.mode }); +} + +function toggleCustomerLeadForm(forceOpen = null) { + state.customers.leadFormOpen = forceOpen === null ? !state.customers.leadFormOpen : Boolean(forceOpen); + syncCustomerFormUi(); +} + +function exportCustomersCsv() { + if (!state.customers.items.length) { + log('Нечего экспортировать: список лидов пуст.'); + return; + } + const csv = [ + ['customer_id', 'display_name', 'phone', 'source', 'status', 'score'], + ...state.customers.items.map((item, index) => [ + item.customer_id, + item.display_name, + (item.phones || []).join(', '), + customerLeadSourceMeta(item, index).label, + customerLeadStatusMeta(item, index).label, + customerLeadScore(item, index), + ]), + ] + .map((row) => row.map((value) => `"${String(value ?? '').replaceAll('"', '""')}"`).join(',')) + .join('\n'); + const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = 'leads.csv'; + link.click(); + URL.revokeObjectURL(url); + log('Экспорт лидов подготовлен', { rows: state.customers.items.length }); +} + +function statusMeta(status) { + return STATUS_META[status] || { label: status || 'неизвестно', className: 'status-closed' }; +} + +function channelLabel(channel) { + return CHANNEL_LABELS[channel] || channel || 'Канал не указан'; +} + +function formatIsoShort(value) { + if (!value) { + return 'н/д'; + } + const dt = new Date(value); + if (Number.isNaN(dt.getTime())) { + return value; + } + return dt.toLocaleString(); +} + +function renderSummaryCard(label, value, note = '') { + return ` +
            +
            ${label}
            +
            ${value}
            + ${note ? `
            ${note}
            ` : ''} +
            + `; +} + +function updateProfileMeta() { + const profileName = $('profileName'); + const profileRole = $('profileRole'); + if (!profileName || !profileRole) { + return; + } + profileName.textContent = state.fullName || state.user || 'Пользователь KonturCC'; + profileRole.textContent = ROLE_LABELS[state.role] || state.role || 'Пользователь'; +} + +function operatorHashState(hash = window.location.hash) { + const raw = String(hash || '').replace(/^#/, ''); + if (raw.startsWith('customer-profile/')) { + return { view: 'customer-profile', anchor: '', customerId: decodeURIComponent(raw.slice('customer-profile/'.length)) }; + } + if (raw === 'customer-profile') { + return { view: 'customer-profile', anchor: '', customerId: '' }; + } + if (!raw || raw === 'workspace') { + return { view: 'workspace', anchor: '', customerId: '' }; + } + if (raw === 'customers-page' || raw === 'customers') { + return { view: 'customers', anchor: '', customerId: '' }; + } + if (raw === 'telegram-page' || raw === 'telegram') { + return { view: 'telegram', anchor: '', customerId: '' }; + } + if (raw === 'whatsapp-page' || raw === 'whatsapp') { + return { view: 'whatsapp', anchor: '', customerId: '' }; + } + if (raw === 'voice-debug' || raw === 'voice-debug-page') { + return { view: 'voice-debug', anchor: '', customerId: '' }; + } + if (raw === 'interactions' || raw === 'integrations') { + return { view: 'workspace', anchor: raw, customerId: '' }; + } + return { view: 'workspace', anchor: '', customerId: '' }; +} + +function syncOperatorViewNav(view, anchor = '') { + document.querySelectorAll('[data-operator-view-link]').forEach((link) => { + const linkView = link.dataset.operatorViewLink; + const isCustomers = view === 'customer-profile' && linkView === 'customers'; + link.classList.toggle('active', linkView === view || isCustomers); + }); + document.querySelectorAll('[data-operator-anchor-link]').forEach((link) => { + link.classList.toggle('active', view === 'workspace' && link.dataset.operatorAnchorLink === anchor); + }); +} + +function applyOperatorViewFromHash(options = {}) { + const { scroll = false } = options; + let { view, anchor, customerId } = operatorHashState(); + const viewLink = document.querySelector(`[data-operator-view-link="${view}"]`); + if (viewLink && viewLink.style.display === 'none') { + view = 'workspace'; + anchor = ''; + if (window.location.hash === '#voice-debug' || window.location.hash === '#voice-debug-page') { + window.history.replaceState(null, '', '#workspace'); + } + } + if (customerId) { + state.customers.selectedCustomerId = customerId; + } + if (view === 'customer-profile' && !selectedCustomer()) { + view = 'customers'; + window.history.replaceState(null, '', '#customers-page'); + } + Object.entries(OPERATOR_VIEW_IDS).forEach(([key, id]) => { + $(id)?.classList.toggle('hidden', key !== view); + }); + syncOperatorViewNav(view, anchor); + + const targetId = view === 'workspace' ? anchor : ''; + window.requestAnimationFrame(() => { + if (targetId && $(targetId)) { + $(targetId).scrollIntoView({ behavior: scroll ? 'smooth' : 'auto', block: 'start' }); + return; + } + window.scrollTo({ top: 0, behavior: scroll ? 'smooth' : 'auto' }); + }); +} + +function handleOperatorHashChange() { + applyOperatorViewFromHash({ scroll: true }); +} + +function renderSupervisorSummary(data) { + const agents = data?.agents || {}; + const byState = agents.by_state || {}; + const firstQueue = (data?.queues || [])[0] || {}; + const total = Number(agents.total || 0); + const ready = Number(byState.READY || 0); + const busy = Number(byState.BUSY || 0); + const queueId = firstQueue.queue_id || 'нет очереди'; + const inQueue = Number(firstQueue.in_queue || 0); + + $('supervisorSummary').innerHTML = [ + renderSummaryCard('Агентов', total, 'Текущее состояние смены'), + renderSummaryCard('Готовы', ready, 'Могут взять следующий контакт'), + renderSummaryCard('Заняты', busy, 'Сейчас в обработке'), + renderSummaryCard('Очередь', queueId, `В очереди: ${inQueue}`), + ].join(''); +} + +function renderKpiSummary(data) { + const kpi = data?.kpi || {}; + $('kpiSummary').innerHTML = [ + renderSummaryCard('SL', `${Number(kpi.SL || 0).toFixed(2)}%`, 'Доля контактов в SLA'), + renderSummaryCard('ASA', `${Number(kpi.ASA || 0).toFixed(2)} с`, 'Среднее время ожидания'), + renderSummaryCard('AHT', `${Number(kpi.AHT || 0).toFixed(2)} с`, 'Среднее время обработки'), + renderSummaryCard('Abandon', `${Number(kpi.Abandon || 0).toFixed(2)}%`, 'Потерянные обращения'), + renderSummaryCard('FCR', `${Number(kpi.FCR || 0).toFixed(2)}%`, 'Решено с первого контакта'), + ].join(''); +} + +function getDefaultAssignee() { + return $('defaultAssignee').value.trim() || DEMO_ASSIGNEE; +} + +function getDefaultEscalationQueue() { + return $('defaultEscalationQueue').value.trim() || DEMO_QUEUE; +} + +function updateSessionInfo(extra = '') { + if (!state.token) { + $('sessionInfo').textContent = 'Токен: отсутствует'; + return; + } + const source = state.authSource === 'oidc' ? 'корпоративный' : 'локальный'; + $('sessionInfo').textContent = `Токен активен | роль: ${state.role} | вход: ${source}${extra ? ` | ${extra}` : ''}`; +} + +function applyAuthenticatedSession(data) { + state.token = data.access_token; + state.role = data.role; + state.user = data.username || state.user; + state.authSource = data.auth_source || 'local'; + state.fullName = data.full_name || null; + $('sessionUser').value = state.user; + $('sessionRole').value = data.role; + updateSessionInfo(data.provider ? `провайдер: ${data.provider}` : ''); + updateProfileMeta(); + persistSession(); + applyRoleNavigation(); +} + +async function api(service, path, options = {}) { + syncSessionFromInputs(); + const headers = { + 'Content-Type': 'application/json', + 'X-User': state.user, + 'X-Role': state.role, + ...(options.headers || {}), + }; + if (state.token) { + headers.Authorization = `Bearer ${state.token}`; + } + + const response = await fetch(`/proxy/${service}/${path}`, { + ...options, + headers, + }); + + let data; + try { + data = await response.json(); + } catch { + data = { error: 'Сервис вернул ответ не в JSON-формате' }; + } + + if (!response.ok) { + if (response.status === 401 && state.token) { + state.token = null; + state.authSource = 'local'; + clearStoredSession(); + updateSessionInfo('требуется повторный вход'); + window.location.href = '/'; + } + throw new Error(`${response.status}: ${JSON.stringify(data)}`); + } + return data; +} + +async function checkGateway() { + try { + const res = await fetch('/health'); + const data = await res.json(); + $('gatewayStatus').textContent = `Шлюз: ${data.status === 'ok' ? 'готов' : data.status}`; + } catch { + $('gatewayStatus').textContent = 'Шлюз: недоступен'; + } +} + +async function loadOidcConfig() { + try { + const response = await fetch('/proxy/auth/auth/oidc/config'); + const data = await response.json(); + state.oidc.enabled = Boolean(data.enabled); + state.oidc.loginPath = data.login_path || '/auth/oidc/start?return_mode=popup'; + state.oidc.providerLabel = data.provider_label || 'Keycloak'; + $('corporateLoginBtn').disabled = !state.oidc.enabled; + $('corporateLoginBtn').textContent = state.oidc.enabled + ? `Корпоративный вход (${state.oidc.providerLabel})` + : 'Корпоративный вход недоступен'; + } catch { + $('corporateLoginBtn').disabled = true; + $('corporateLoginBtn').textContent = 'Корпоративный вход недоступен'; + } +} + +function startCorporateLogin() { + if (!state.oidc.enabled) { + log('Корпоративный вход недоступен'); + return; + } + const popup = window.open(`/proxy/auth${state.oidc.loginPath}`, 'oidc-login', 'width=620,height=760'); + if (!popup) { + log('Не удалось открыть окно корпоративного входа'); + return; + } + log('Открыт корпоративный вход', { провайдер: state.oidc.providerLabel }); +} + +function handleOidcMessage(event) { + if (!event?.data || typeof event.data !== 'object') { + return; + } + if (event.data.type === 'oidc-login' && event.data.access_token) { + applyAuthenticatedSession(event.data); + log('Корпоративный вход выполнен', { user: state.user, role: state.role }); + return; + } + if (event.data.type === 'oidc-error') { + log('Корпоративный вход не выполнен', { error: event.data.message || 'неизвестно' }); + updateSessionInfo(`ошибка SSO: ${event.data.message || 'неизвестно'}`); + } +} + +async function login() { + syncSessionFromInputs(); + const password = $('sessionPassword').value; + + try { + const data = await api('auth', 'auth/login', { + method: 'POST', + body: JSON.stringify({ username: state.user, password }), + }); + applyAuthenticatedSession({ + ...data, + username: state.user, + auth_source: data.auth_source || 'local', + }); + log('Вход выполнен', { user: state.user, role: data.role }); + } catch (err) { + $('sessionInfo').textContent = `Ошибка входа: ${err.message}`; + log('Вход не выполнен', { error: err.message }); + } +} + +async function createCustomer() { + const displayName = $('customerName').value.trim(); + const phone = $('customerPhone').value.trim(); + if (!displayName) { + return; + } + + const payload = { + display_name: displayName, + phones: phone ? [phone] : [], + preferred_phone: phone || null, + tags: [], + }; + + try { + const data = await api('customer', 'customers', { + method: 'POST', + body: JSON.stringify(payload), + }); + $('interactionCustomerId').value = data.customer_id; + $('customerName').value = ''; + $('customerPhone').value = ''; + toggleCustomerLeadForm(false); + log('Клиент создан', { customer_id: data.customer_id, name: data.display_name }); + await searchCustomers(); + } catch (err) { + log('Не удалось создать клиента', { error: err.message }); + } +} + +async function searchCustomers() { + const rawQuery = $('customerQuery').value.trim(); + const q = encodeURIComponent(rawQuery); + try { + const data = await api('customer', `customers${q ? `?query=${q}` : ''}`); + if (!data.length) { + state.customers.items = []; + state.customers.page = 1; + state.customers.selectedCustomerId = ''; + renderCustomerList(); + return; + } + if (!$('interactionCustomerId').value.trim()) { + $('interactionCustomerId').value = data[0].customer_id; + } + state.customers.items = data; + state.customers.page = 1; + const selectedExists = data.some((item) => item.customer_id === state.customers.selectedCustomerId); + state.customers.selectedCustomerId = selectedExists ? state.customers.selectedCustomerId : data[0].customer_id; + renderCustomerList(); + } catch (err) { + state.customers.items = []; + state.customers.page = 1; + state.customers.selectedCustomerId = ''; + renderCustomerList(); + log('Не удалось загрузить клиентов', { error: err.message }); + } +} + +async function createInteraction() { + const payload = { + channel: $('interactionChannel').value, + subject: $('interactionSubject').value.trim() || 'Операционный контакт', + customer_id: $('interactionCustomerId').value.trim() || null, + queue_id: 'q_main', + priority: 3, + }; + + try { + const data = await api('interaction', 'interactions', { + method: 'POST', + body: JSON.stringify(payload), + }); + log('Обращение создано', { interaction_id: data.interaction_id, channel: data.channel }); + await loadInteractions(); + } catch (err) { + log('Не удалось создать обращение', { error: err.message }); + } +} + +function renderInteractionCard(item) { + const meta = statusMeta(item.status); + const cardClass = item.status === 'escalated' + ? 'pipeline-card escalated' + : item.status === 'closed' + ? 'pipeline-card closed' + : 'pipeline-card'; + const badges = [ + `${escapeHtml(channelLabel(item.channel))}`, + item.queue_id ? `${escapeHtml(item.queue_id)}` : '', + item.assigned_to ? `${escapeHtml(item.assigned_to)}` : '', + ].filter(Boolean).join(''); + + return ` +
            +
            ${badges}
            +
            +

            ${escapeHtml(item.subject || 'Без темы')}

            + ${escapeHtml(meta.label)} +
            +

            ${escapeHtml(item.interaction_id)}

            +

            Клиент: ${escapeHtml(item.customer_id || 'не привязан')}

            +

            Обновлено: ${escapeHtml(formatIsoShort(item.updated_at))}

            +
            +
            + + + +
            +
            + `; +} + +function renderInteractionBoard(items) { + const groups = { + new: [], + in_progress: [], + escalated: [], + closed: [], + }; + + items.forEach((item) => { + const bucket = groups[item.status] ? item.status : 'closed'; + groups[bucket].push(item); + }); + + return ` +
            + ${BOARD_COLUMNS.map((column) => { + const cards = groups[column.key]; + const content = cards.length + ? cards.map((item) => renderInteractionCard(item)).join('') + : '
            Пока пусто.
            '; + return ` +
            +
            +
            ${column.title}
            +
            ${cards.length}
            +
            +
            + ${content} +
            +
            + `; + }).join('')} +
            + `; +} + +function telephonyLabel(value) { + return LIVE_TELEPHONY_LABELS[value] || value || 'неизвестно'; +} + +function selectedLiveCallId() { + const value = $('liveCallIdSelect')?.value || ''; + return value.trim(); +} + +function selectedLiveCallItem() { + const callId = selectedLiveCallId(); + return state.liveCalls.items.find((item) => item.call_id === callId) || null; +} + +function isClaimableLiveCall(item) { + if (!item) { + return false; + } + return !item.claimed_by_user && ['ringing', 'claimed', 'connected'].includes(item.telephony_status); +} + +function canControlLiveCall(item) { + if (!item) { + return false; + } + return ['claimed', 'connected'].includes(item.telephony_status); +} + +function terminalActionLabel(item) { + const value = item?.terminal_action || ''; + if (value === 'blind-transfer') { + return 'Передан'; + } + if (value === 'hangup') { + return 'Завершён оператором'; + } + return 'Завершён'; +} + +function voiceAiStatusMeta(item) { + const stateKey = String(item?.ai_state || '').trim(); + if (!stateKey) { + return null; + } + return VOICE_AI_STATE_META[stateKey] || { label: stateKey, className: 'ai-muted' }; +} + +function liveCallHasAi(item) { + return Boolean( + item?.voice_session_id + || item?.ai_session_id + || item?.ai_state + || item?.ai_handoff_reason + || item?.ai_last_model_at, + ); +} + +function shouldLoadVoiceAiSummary(item) { + if (!item || !liveCallHasAi(item)) { + return false; + } + return ['handoff_requested', 'handoff_required', 'human_owned', 'error', 'closed'].includes(item.ai_state || '') + || Boolean(item.ai_handoff_reason); +} + +function voiceAiSummaryForCall(callId) { + return state.liveCalls.aiSummaries[callId] || null; +} + +function voiceAiSummaryPending(callId) { + return Boolean(state.liveCalls.aiSummaryPending[callId]); +} + +async function loadVoiceAiSummary(callId, options = {}) { + if (!callId || voiceAiSummaryPending(callId)) { + return voiceAiSummaryForCall(callId); + } + const { force = false, silent = false } = options; + if (!force && Object.prototype.hasOwnProperty.call(state.liveCalls.aiSummaries, callId)) { + return state.liveCalls.aiSummaries[callId]; + } + state.liveCalls.aiSummaryPending[callId] = true; + try { + const data = await api('asterisk-bridge', `asterisk/live-calls/${encodeURIComponent(callId)}/ai-summary`); + state.liveCalls.aiSummaries[callId] = data || null; + return state.liveCalls.aiSummaries[callId]; + } catch (err) { + if (!silent) { + log('Не удалось загрузить сводку AI по звонку', { call_id: callId, error: err.message }); + } + state.liveCalls.aiSummaries[callId] = null; + return null; + } finally { + delete state.liveCalls.aiSummaryPending[callId]; + updateBrowserPhoneUi(); + } +} + +function ensureVoiceAiSummaryLoaded(item) { + if (!shouldLoadVoiceAiSummary(item)) { + return; + } + const callId = item.call_id || ''; + if (!callId || voiceAiSummaryPending(callId) || Object.prototype.hasOwnProperty.call(state.liveCalls.aiSummaries, callId)) { + return; + } + window.setTimeout(() => { + loadVoiceAiSummary(callId, { silent: true }); + }, 0); +} + +function renderVoiceAiSummaryField(label, value) { + if (!value) { + return ''; + } + return ` +
            +
            ${escapeHtml(label)}
            +
            ${escapeHtml(value)}
            +
            + `; +} + +function renderVoiceAiTranscript(summary) { + const segments = Array.isArray(summary?.transcript_segments) + ? summary.transcript_segments.filter((segment) => segment && String(segment.text || '').trim()) + : []; + if (!segments.length) { + return ''; + } + return ` +
            +
            +
            Транскрипт разговора
            +
            ${escapeHtml(`${segments.length} реплик`)}
            +
            +
            + ${segments + .map((segment) => { + const speaker = segment?.speaker === 'assistant' ? 'AI' : 'Клиент'; + const lineClass = segment?.speaker === 'assistant' ? 'assistant' : 'caller'; + const timeLabel = formatIsoShort(segment?.created_at || ''); + const interrupted = segment?.interrupted && segment?.speaker === 'assistant' ? ' • прерван' : ''; + return ` +
            +
            +
            ${escapeHtml(`${speaker}${interrupted}`)}
            +
            ${escapeHtml(timeLabel)}
            +
            +
            ${escapeHtml(segment?.text || '')}
            +
            + `; + }) + .join('')} +
            +
            + `; +} + +function renderVoiceAiSummary(summary, pending = false) { + if (!summary && !pending) { + return ''; + } + if (pending && !summary) { + return ` +
            +
            +
            +
            Сводка AI
            +
            Подтягиваем контекст AI по текущему звонку...
            +
            +
            +
            + `; + } + const statusClass = summary?.status_tone === 'handoff' ? 'handoff' : 'answered'; + return ` +
            +
            +
            +
            Сводка AI
            +
            Краткий контекст передачи до ручной обработки звонка.
            +
            +
            +
            ${escapeHtml(summary?.status_label || 'AI-сводка')}
            +
            ${escapeHtml(formatIsoShort(summary?.generated_at || ''))}
            +
            +
            +
            + ${renderVoiceAiSummaryField('Запрос клиента', summary?.customer_request_text)} + ${renderVoiceAiSummaryField('Что сделал AI', summary?.ai_outcome_text)} + ${renderVoiceAiSummaryField('Причина передачи', summary?.handoff_reason)} + ${renderVoiceAiSummaryField('Следующий шаг', summary?.recommended_next_step)} +
            + ${renderVoiceAiTranscript(summary)} +
            + `; +} + +function describeLiveCallError(message) { + const text = String(message || '').trim(); + const lower = text.toLowerCase(); + if (!text) { + return 'Операция по звонку не выполнена.'; + } + if (lower.includes('already ended') || lower.includes('channel_already_closed')) { + return 'Звонок уже завершён.'; + } + if (lower.includes('already claimed by another operator')) { + return 'Звонок уже закреплён другим оператором.'; + } + if (lower.includes('unable to resolve active channel')) { + return 'Asterisk не смог найти активный канал звонка.'; + } + if (lower.includes('operator_extension is required')) { + return 'Укажите внутренний номер оператора для этого действия.'; + } + if (lower.includes('target extension')) { + return 'Указанный target недоступен для передачи.'; + } + if (lower.includes('blind transfer failed')) { + return 'Передача звонка не выполнена.'; + } + if (lower.includes('hangup failed')) { + return 'Не удалось завершить звонок.'; + } + if (lower.includes('claim failed')) { + return 'Не удалось принять звонок в работу.'; + } + return text; +} + +function browserPhoneConfig() { + return state.browserPhone.config || null; +} + +function browserPhoneLibraryAvailable() { + return Boolean(window.SIP && window.SIP.UserAgent); +} + +function browserPhoneAudioElement() { + return $('browserPhoneRemoteAudio'); +} + +function browserPhoneAudioConstraints() { + if (state.browserPhone.micDeviceId) { + return { audio: { deviceId: { exact: state.browserPhone.micDeviceId } }, video: false }; + } + return { audio: true, video: false }; +} + +function browserPhoneIncomingLabel(session) { + const remote = session?.remoteIdentity; + const name = remote?.displayName || remote?.uri?.user || remote?.uri?.toString?.() || 'неизвестно'; + return `Входящий звонок: ${name}`; +} + +function browserPhoneAudioContext() { + if (state.browserPhone.ringtoneContext) { + return state.browserPhone.ringtoneContext; + } + const AudioContextCtor = window.AudioContext || window.webkitAudioContext; + if (!AudioContextCtor) { + return null; + } + state.browserPhone.ringtoneContext = new AudioContextCtor(); + return state.browserPhone.ringtoneContext; +} + +async function ensureBrowserPhoneAudioContextResumed() { + const context = browserPhoneAudioContext(); + if (!context) { + return null; + } + if (context.state === 'suspended') { + await context.resume().catch(() => {}); + } + return context; +} + +function playBrowserPhoneRingtoneBurst(context) { + if (!context) { + return; + } + const tones = [ + { offset: 0, duration: 0.18, frequency: 880 }, + { offset: 0.24, duration: 0.18, frequency: 660 }, + ]; + tones.forEach((tone) => { + const oscillator = context.createOscillator(); + const gain = context.createGain(); + const startAt = context.currentTime + tone.offset; + const stopAt = startAt + tone.duration; + oscillator.type = 'sine'; + oscillator.frequency.setValueAtTime(tone.frequency, startAt); + gain.gain.setValueAtTime(0.0001, startAt); + gain.gain.exponentialRampToValueAtTime(0.14, startAt + 0.02); + gain.gain.exponentialRampToValueAtTime(0.0001, stopAt); + oscillator.connect(gain); + gain.connect(context.destination); + oscillator.start(startAt); + oscillator.stop(stopAt + 0.02); + }); +} + +async function startBrowserPhoneRingtone() { + if (state.browserPhone.ringtoneTimer) { + return; + } + const context = await ensureBrowserPhoneAudioContextResumed(); + if (!context) { + return; + } + state.browserPhone.ringtoneActive = true; + playBrowserPhoneRingtoneBurst(context); + state.browserPhone.ringtoneTimer = window.setInterval(() => { + playBrowserPhoneRingtoneBurst(context); + }, 2200); + updateBrowserPhoneUi(); +} + +function stopBrowserPhoneRingtone() { + if (state.browserPhone.ringtoneTimer) { + window.clearInterval(state.browserPhone.ringtoneTimer); + state.browserPhone.ringtoneTimer = null; + } + if (state.browserPhone.ringtoneActive) { + state.browserPhone.ringtoneActive = false; + updateBrowserPhoneUi(); + } +} + +function syncBrowserPhoneSpeakerSink() { + const element = browserPhoneAudioElement(); + if (!element || !state.browserPhone.speakerDeviceId) { + return; + } + if (typeof element.setSinkId !== 'function') { + return; + } + element.setSinkId(state.browserPhone.speakerDeviceId).catch(() => {}); +} + +function setBrowserPhoneStatus(text) { + state.browserPhone.status = text; + updateBrowserPhoneUi(); +} + +function setBrowserPhoneWarning(text = '') { + state.browserPhone.warning = text; + updateBrowserPhoneUi(); +} + +function toggleBrowserPhoneSettings() { + state.browserPhone.settingsOpen = !state.browserPhone.settingsOpen; + updateBrowserPhoneUi(); +} + +function browserPhoneStatusState() { + const config = browserPhoneConfig(); + const hasSession = Boolean(state.browserPhone.session); + const popupPhase = browserPhonePopupPhase(); + if (!config) { + return 'unavailable'; + } + if (!config.enabled) { + return 'disabled'; + } + if (state.browserPhone.connecting && popupPhase === 'idle') { + return 'connecting'; + } + if (popupPhase === 'error') { + return 'error'; + } + if (popupPhase === 'ending') { + return 'ending'; + } + if (popupPhase === 'incoming') { + return 'incoming'; + } + if (popupPhase === 'connecting') { + return 'connecting'; + } + if (popupPhase === 'in-call' || hasSession) { + return 'active'; + } + if (state.browserPhone.connected) { + return 'registered'; + } + return 'offline'; +} + +function browserPhoneStatusLabel() { + const labels = { + unavailable: 'Недоступен', + disabled: 'Отключен', + connecting: 'Подключение', + ending: 'Завершаем', + error: 'Нужно завершить', + incoming: 'Входящий звонок', + active: 'В разговоре', + registered: 'Зарегистрирован', + offline: 'Не подключен', + }; + return labels[browserPhoneStatusState()] || state.browserPhone.status || 'Не подключен'; +} + +function browserPhonePopupPhase() { + if (state.browserPhone.callPhase === 'error') { + return 'error'; + } + if (state.browserPhone.endingCallId || state.browserPhone.callPhase === 'ending') { + return 'ending'; + } + if (state.browserPhone.callPhase === 'connecting') { + return 'connecting'; + } + if (state.browserPhone.incoming || state.browserPhone.callPhase === 'incoming') { + return 'incoming'; + } + if (state.browserPhone.callPhase === 'in-call' || state.browserPhone.session) { + return 'in-call'; + } + return 'idle'; +} + +function browserPhoneCallById(callId, options = {}) { + if (!callId) { + return null; + } + const { includeRecent = true } = options; + const live = state.liveCalls.items.find((item) => item.call_id === callId); + if (live) { + return live; + } + if (!includeRecent) { + return null; + } + return state.liveCalls.recentItems.find((item) => item.call_id === callId) || null; +} + +function browserPhoneActivePopupCall() { + const trackedIds = [ + state.browserPhone.endingCallId, + state.browserPhone.popupCallId, + state.browserPhone.autoClaimCallId, + ].filter(Boolean); + for (const callId of trackedIds) { + const found = browserPhoneCallById(callId); + if (found) { + return found; + } + } + const owned = state.liveCalls.items.find((item) => item.claimed_by_user === state.user); + if (owned) { + return owned; + } + const candidates = browserPhoneClaimCandidates(); + if (candidates.length === 1) { + return candidates[0]; + } + const selected = selectedLiveCallItem(); + if (selected) { + return selected; + } + return null; +} + +function browserPhoneCallTimerSource(item) { + return item?.connected_at || item?.started_at || state.browserPhone.sessionStartedAt || ''; +} + +function formatCallTimer(value) { + if (!value) { + return '00:00'; + } + const startedAt = new Date(value); + if (Number.isNaN(startedAt.getTime())) { + return '00:00'; + } + const totalSeconds = Math.max(0, Math.floor((Date.now() - startedAt.getTime()) / 1000)); + const minutes = String(Math.floor(totalSeconds / 60)).padStart(2, '0'); + const seconds = String(totalSeconds % 60).padStart(2, '0'); + return `${minutes}:${seconds}`; +} + +function browserPhonePopupVisible() { + return browserPhonePopupPhase() !== 'idle'; +} + +function browserPhoneCallSummary(item) { + if (!item) { + return 'Ожидаем карточку звонка из bridge...'; + } + const aiMeta = voiceAiStatusMeta(item); + const parts = [ + item.queue_code || item.queue_id || 'queue?', + item.interaction_id || 'interaction?', + item.operator_extension ? `ext ${item.operator_extension}` : '', + aiMeta ? aiMeta.label : '', + ].filter(Boolean); + return parts.join(' • '); +} + +function browserPhoneHintText(config) { + const popupPhase = browserPhonePopupPhase(); + if (!config) { + return 'Browser softphone недоступен: bridge не вернул конфиг для текущего пользователя.'; + } + if (!config.enabled) { + return 'Browser softphone отключён в окружении. Внешний SIP-клиент остаётся fallback-путём.'; + } + if (!browserPhoneLibraryAvailable()) { + return 'Browser softphone не инициализирован: SIP.js asset не загрузился.'; + } + if (popupPhase === 'incoming' && state.browserPhone.ringtoneActive) { + return 'Входящий звонок в браузере. Используйте Ответить или Отклонить. Рингтон воспроизводится локально.'; + } + if (popupPhase === 'connecting') { + return 'Подключаем медиа и быстро сопоставляем звонок с bridge. При неоднозначности можно нажать «Принять в работу» вручную.'; + } + if (popupPhase === 'ending') { + return 'Ждём финальное подтверждение завершения звонка от bridge.'; + } + if (popupPhase === 'error') { + return 'Локальная media session уже завершена, но bridge ещё не подтвердил финальный terminal state. Проверьте предупреждение в popup.'; + } + if (state.browserPhone.connected) { + return 'Телефон зарегистрирован. Входящий браузерный звонок откроет отдельное окно и попытается выполнить авто-claim.'; + } + return 'Подключите browser phone и разрешите доступ к микрофону.'; +} + +function syncBrowserPhonePopupLifecycle() { + const activeCallId = state.browserPhone.endingCallId || state.browserPhone.popupCallId || state.browserPhone.autoClaimCallId; + if (!activeCallId) { + updateBrowserPhoneUi(); + return; + } + const liveCall = state.liveCalls.items.find((item) => item.call_id === activeCallId); + const recentCall = state.liveCalls.recentItems.find((item) => item.call_id === activeCallId); + if (!liveCall && recentCall && !state.browserPhone.session && !state.browserPhone.incoming) { + state.browserPhone.endingCallId = ''; + state.browserPhone.popupCallId = ''; + state.browserPhone.autoClaimCallId = ''; + state.browserPhone.callPhase = ''; + state.browserPhone.warning = ''; + if (state.browserPhone.connected) { + state.browserPhone.status = 'Зарегистрирован'; + } + } + updateBrowserPhoneUi(); +} + +function updateBrowserPhoneUi() { + const config = browserPhoneConfig(); + const hasSession = Boolean(state.browserPhone.session); + const pendingAction = state.liveCalls.pendingAction || ''; + const popupCall = browserPhoneActivePopupCall(); + const popupPhase = browserPhonePopupPhase(); + const phoneState = browserPhoneStatusState(); + const connectBtn = $('browserPhoneConnectBtn'); + const disconnectBtn = $('browserPhoneDisconnectBtn'); + const micSelect = $('browserPhoneMicSelect'); + const speakerSelect = $('browserPhoneSpeakerSelect'); + const settingsPanel = $('browserPhoneSettingsPanel'); + const statusBtn = $('browserPhoneStatusBtn'); + const statusLabel = $('browserPhoneStatusLabel'); + const hint = $('browserPhoneHint'); + const muteBtn = $('browserPhoneMuteBtn'); + const answerBtn = $('browserPhoneAnswerBtn'); + const rejectBtn = $('browserPhoneRejectBtn'); + const claimBtn = $('browserPhoneCallClaimBtn'); + const transferBtn = $('browserPhoneCallTransferBtn'); + const hangupBtn = $('browserPhoneCallHangupBtn'); + const transferType = $('browserPhoneTransferTargetType'); + const transferValue = $('browserPhoneTransferTargetValue'); + const overlay = $('browserPhoneCallOverlay'); + const popupState = $('browserPhoneCallState'); + const popupTitle = $('browserPhoneCallTitle'); + const popupIncomingText = $('browserPhoneIncomingText'); + const popupMeta = $('browserPhoneCallMeta'); + const popupAiSummary = $('browserPhoneAiSummary'); + const popupTimer = $('browserPhoneCallTimer'); + const popupWarning = $('browserPhoneCallWarning'); + + $('browserPhoneRegistrationState').textContent = state.browserPhone.status; + $('browserPhoneOperatorExtension').textContent = config?.operator_extension || '—'; + $('browserPhoneWsUrl').textContent = config?.ws_url || '—'; + statusLabel.textContent = browserPhoneStatusLabel(); + statusBtn.dataset.state = phoneState; + statusBtn.setAttribute('aria-expanded', state.browserPhone.settingsOpen ? 'true' : 'false'); + settingsPanel.classList.toggle('hidden', !state.browserPhone.settingsOpen); + + connectBtn.disabled = !config?.enabled || state.browserPhone.connected || state.browserPhone.connecting; + disconnectBtn.disabled = !state.browserPhone.connected && !hasSession && !state.browserPhone.connecting; + micSelect.disabled = state.browserPhone.connecting; + speakerSelect.disabled = state.browserPhone.connecting; + hint.textContent = browserPhoneHintText(config); + + const incoming = popupPhase === 'incoming'; + const connecting = popupPhase === 'connecting'; + const ending = popupPhase === 'ending'; + const errorState = popupPhase === 'error'; + const active = popupPhase === 'in-call'; + const canClaim = Boolean(popupCall && isClaimableLiveCall(popupCall) && !incoming && !connecting && !ending && !state.browserPhone.autoClaimInFlight); + const canControl = Boolean(popupCall && canControlLiveCall(popupCall) && !connecting && !ending); + + overlay.classList.toggle('hidden', !browserPhonePopupVisible()); + popupState.textContent = errorState + ? 'Нужна синхронизация завершения' + : ending + ? 'Завершаем звонок' + : connecting + ? 'Подключаем media' + : incoming + ? 'Входящий звонок' + : active + ? 'Разговор в браузере' + : 'Browser call'; + popupTitle.textContent = popupCall?.caller_number || popupCall?.caller_name || browserPhoneIncomingLabel(state.browserPhone.session).replace('Входящий звонок: ', ''); + popupIncomingText.textContent = state.browserPhone.session + ? browserPhoneIncomingLabel(state.browserPhone.session) + : popupCall + ? `call_id: ${popupCall.call_id}` + : 'SIP invite ещё не поступал.'; + popupMeta.textContent = browserPhoneCallSummary(popupCall); + if (popupCall) { + ensureVoiceAiSummaryLoaded(popupCall); + } + const popupAiSummaryData = popupCall ? voiceAiSummaryForCall(popupCall.call_id) : null; + const popupAiSummaryPending = popupCall ? voiceAiSummaryPending(popupCall.call_id) : false; + popupAiSummary.innerHTML = renderVoiceAiSummary(popupAiSummaryData, popupAiSummaryPending); + popupAiSummary.classList.toggle('hidden', !popupAiSummary.innerHTML.trim()); + popupTimer.textContent = incoming + ? 'ringing' + : connecting + ? 'connecting…' + : ending + ? 'ending…' + : formatCallTimer(browserPhoneCallTimerSource(popupCall)); + popupWarning.textContent = state.browserPhone.warning; + popupWarning.classList.toggle('hidden', !state.browserPhone.warning); + + answerBtn.classList.toggle('hidden', !incoming); + rejectBtn.classList.toggle('hidden', !incoming); + claimBtn.classList.toggle('hidden', !canClaim); + muteBtn.classList.toggle('hidden', !(active || connecting)); + transferBtn.classList.toggle('hidden', !(canControl || ending || errorState)); + hangupBtn.classList.toggle('hidden', !(popupCall || hasSession || ending || errorState || connecting)); + transferType.classList.toggle('hidden', !(canControl || ending || errorState)); + transferValue.classList.toggle('hidden', !(canControl || ending || errorState)); + + answerBtn.disabled = !incoming || Boolean(pendingAction); + rejectBtn.disabled = !incoming || Boolean(pendingAction); + claimBtn.disabled = !canClaim || Boolean(pendingAction) || state.browserPhone.autoClaimInFlight; + muteBtn.disabled = !hasSession; + muteBtn.textContent = state.browserPhone.muted ? 'Включить микрофон' : 'Выключить микрофон'; + transferBtn.disabled = !canControl || Boolean(pendingAction) || ending || connecting || !transferValue.value.trim(); + hangupBtn.disabled = (!popupCall && !hasSession) || Boolean(pendingAction) || ending; + transferType.disabled = !canControl || Boolean(pendingAction) || connecting; + transferValue.disabled = !canControl || Boolean(pendingAction) || connecting; +} + +function renderBrowserDeviceOptions(selectId, devices, preferredId, placeholder) { + const select = $(selectId); + if (!select) { + return; + } + const items = devices.length ? devices : [{ deviceId: '', label: placeholder }]; + select.innerHTML = items + .map((item, index) => ``) + .join(''); + if (preferredId && items.some((item) => item.deviceId === preferredId)) { + select.value = preferredId; + } else if (!devices.length) { + select.value = ''; + } +} + +async function loadBrowserPhoneDevices() { + if (!navigator.mediaDevices?.enumerateDevices) { + renderBrowserDeviceOptions('browserPhoneMicSelect', [], '', 'Media devices not available'); + renderBrowserDeviceOptions('browserPhoneSpeakerSelect', [], '', 'Output selection not supported'); + return; + } + const devices = await navigator.mediaDevices.enumerateDevices(); + const inputs = devices.filter((item) => item.kind === 'audioinput'); + const outputs = devices.filter((item) => item.kind === 'audiooutput'); + renderBrowserDeviceOptions('browserPhoneMicSelect', inputs, state.browserPhone.micDeviceId, 'Микрофон'); + renderBrowserDeviceOptions('browserPhoneSpeakerSelect', outputs, state.browserPhone.speakerDeviceId, 'Динамик'); + if (!state.browserPhone.micDeviceId) { + state.browserPhone.micDeviceId = $('browserPhoneMicSelect').value; + } + if (!state.browserPhone.speakerDeviceId) { + state.browserPhone.speakerDeviceId = $('browserPhoneSpeakerSelect').value; + } + syncBrowserPhoneSpeakerSink(); +} + +async function ensureBrowserPhoneMediaAccess() { + if (!navigator.mediaDevices?.getUserMedia) { + throw new Error('Browser getUserMedia is not available'); + } + const stream = await navigator.mediaDevices.getUserMedia(browserPhoneAudioConstraints()); + stream.getTracks().forEach((track) => track.stop()); +} + +function stopBrowserPhoneLocalStream() { + if (state.browserPhone.localStream) { + state.browserPhone.localStream.getTracks().forEach((track) => track.stop()); + } + state.browserPhone.localStream = null; +} + +function browserPhoneLocalStreamHealthy() { + const stream = state.browserPhone.localStream; + return Boolean(stream && stream.getTracks().some((track) => track.readyState === 'live')); +} + +async function ensureBrowserPhoneLocalStream(options = {}) { + const { forceFresh = false } = options; + if (!navigator.mediaDevices?.getUserMedia) { + throw new Error('Browser getUserMedia is not available'); + } + if (forceFresh) { + stopBrowserPhoneLocalStream(); + } + if (browserPhoneLocalStreamHealthy()) { + return state.browserPhone.localStream; + } + if (state.browserPhone.localStreamPromise) { + return state.browserPhone.localStreamPromise; + } + state.browserPhone.localStreamPromise = navigator.mediaDevices + .getUserMedia(browserPhoneAudioConstraints()) + .then((stream) => { + stopBrowserPhoneLocalStream(); + state.browserPhone.localStream = stream; + return stream; + }) + .finally(() => { + state.browserPhone.localStreamPromise = null; + }); + return state.browserPhone.localStreamPromise; +} + +function prepareBrowserPhoneAnswerMedia() { + return ensureBrowserPhoneLocalStream().catch((err) => { + log('Browser softphone не смог заранее подготовить media', { error: err.message }); + return null; + }); +} + +function browserPhoneClaimCandidates() { + return browserPhoneClaimCandidatesFrom(state.liveCalls.items); +} + +function browserPhoneCallTimestampMs(item) { + const raw = item?.last_transition_at || item?.connected_at || item?.started_at || item?.updated_at || ''; + const ts = raw ? new Date(raw).getTime() : Number.NaN; + return Number.isFinite(ts) ? ts : Number.NaN; +} + +function browserPhoneSessionTimestampMs() { + const raw = state.browserPhone.sessionStartedAt || ''; + const ts = raw ? new Date(raw).getTime() : Number.NaN; + return Number.isFinite(ts) ? ts : Number.NaN; +} + +function browserPhoneClaimCandidatesFrom(items) { + const config = browserPhoneConfig(); + const extension = config?.operator_extension || ''; + return (items || []).filter((item) => { + if (!['ringing', 'claimed', 'connected'].includes(item.telephony_status)) { + return false; + } + if (extension && item.operator_extension && item.operator_extension !== extension) { + return false; + } + return true; + }); +} + +function browserPhoneBestCandidateMatch(candidates) { + if (!Array.isArray(candidates) || !candidates.length) { + return null; + } + const sessionTs = browserPhoneSessionTimestampMs(); + const ranked = [...candidates].sort((left, right) => { + const leftTs = browserPhoneCallTimestampMs(left); + const rightTs = browserPhoneCallTimestampMs(right); + if (Number.isFinite(sessionTs)) { + const leftDelta = Number.isFinite(leftTs) ? Math.abs(leftTs - sessionTs) : Number.POSITIVE_INFINITY; + const rightDelta = Number.isFinite(rightTs) ? Math.abs(rightTs - sessionTs) : Number.POSITIVE_INFINITY; + if (leftDelta !== rightDelta) { + return leftDelta - rightDelta; + } + } + if (Number.isFinite(leftTs) && Number.isFinite(rightTs) && leftTs !== rightTs) { + return rightTs - leftTs; + } + return String(right?.call_id || '').localeCompare(String(left?.call_id || '')); + }); + return ranked[0] || null; +} + +function attachBrowserRemoteAudio(session) { + const element = browserPhoneAudioElement(); + const sdh = session?.sessionDescriptionHandler; + const peerConnection = sdh?.peerConnection; + if (!element || !peerConnection) { + return; + } + + const syncRemoteStream = () => { + const stream = new MediaStream(); + peerConnection.getReceivers().forEach((receiver) => { + if (receiver.track) { + stream.addTrack(receiver.track); + } + }); + element.srcObject = stream; + syncBrowserPhoneSpeakerSink(); + element.play().catch(() => {}); + }; + + syncRemoteStream(); + peerConnection.addEventListener('track', syncRemoteStream); +} + +function applyBrowserMuteState() { + const sdh = state.browserPhone.session?.sessionDescriptionHandler; + const peerConnection = sdh?.peerConnection; + if (!peerConnection) { + return; + } + peerConnection.getSenders().forEach((sender) => { + if (sender.track && sender.track.kind === 'audio') { + sender.track.enabled = !state.browserPhone.muted; + } + }); +} + +function resetBrowserPhoneSessionState() { + stopBrowserPhoneRingtone(); + state.browserPhone.session = null; + state.browserPhone.incoming = false; + state.browserPhone.muted = false; + state.browserPhone.autoClaimCallId = ''; + state.browserPhone.autoClaimInFlight = false; + state.browserPhone.sessionStartedAt = ''; + state.browserPhone.callPhase = state.browserPhone.endingCallId ? 'ending' : ''; + stopBrowserPhoneLocalStream(); + const element = browserPhoneAudioElement(); + if (element) { + element.srcObject = null; + } + updateBrowserPhoneUi(); +} + +function browserSessionStateValue(session = state.browserPhone.session) { + const SessionState = window.SIP?.SessionState; + if (!session || !SessionState) { + return null; + } + return session.state; +} + +function browserSessionIsActive(session = state.browserPhone.session) { + const SessionState = window.SIP?.SessionState; + if (!session || !SessionState) { + return false; + } + return ![SessionState.Terminated].includes(session.state); +} + +function ensureBrowserSessionFreshForInvite() { + if (!state.browserPhone.session) { + return true; + } + if (browserSessionIsActive(state.browserPhone.session)) { + return false; + } + resetBrowserPhoneSessionState(); + return true; +} + +function bindBrowserPhoneSession(session) { + const SessionState = window.SIP?.SessionState; + state.browserPhone.session = session; + state.browserPhone.incoming = true; + state.browserPhone.muted = false; + state.browserPhone.popupCallId = ''; + state.browserPhone.callPhase = 'incoming'; + state.browserPhone.autoClaimInFlight = false; + state.browserPhone.warning = ''; + setBrowserPhoneStatus('Входящий звонок'); + startBrowserPhoneRingtone().catch(() => {}); + prepareBrowserPhoneAnswerMedia(); + updateBrowserPhoneUi(); + session.stateChange.addListener((newState) => { + if (newState === SessionState.Established) { + stopBrowserPhoneRingtone(); + state.browserPhone.incoming = false; + state.browserPhone.callPhase = 'in-call'; + state.browserPhone.sessionStartedAt = new Date().toISOString(); + setBrowserPhoneStatus('Соединено в браузере'); + attachBrowserRemoteAudio(session); + applyBrowserMuteState(); + if (!state.browserPhone.autoClaimInFlight && !state.browserPhone.popupCallId && !state.browserPhone.autoClaimCallId) { + void bestEffortBrowserAutoClaim({ refreshAttempts: 1, retryDelayMs: 150 }); + } + return; + } + if (newState === SessionState.Terminated) { + resetBrowserPhoneSessionState(); + if (state.browserPhone.connected && !state.browserPhone.endingCallId) { + setBrowserPhoneStatus('Зарегистрирован'); + } + syncBrowserPhonePopupLifecycle(); + } + }); +} + +async function terminateBrowserPhoneSessionLocally() { + const session = state.browserPhone.session; + if (!session) { + return; + } + try { + const SessionState = window.SIP?.SessionState; + if (state.browserPhone.incoming && typeof session.reject === 'function') { + await session.reject(); + return; + } + if (session.state === SessionState.Established && typeof session.bye === 'function') { + await session.bye(); + return; + } + if (typeof session.cancel === 'function') { + await session.cancel(); + return; + } + if (typeof session.dispose === 'function') { + session.dispose(); + } + } catch { + if (typeof session.dispose === 'function') { + session.dispose(); + } + } +} + +async function claimLiveCallById(callId, options = {}) { + const operatorExtension = (options.operatorExtension || browserPhoneConfig()?.operator_extension || '').trim(); + const payload = {}; + if (operatorExtension) { + payload.operator_extension = operatorExtension; + } + const data = await api('asterisk-bridge', `asterisk/live-calls/${encodeURIComponent(callId)}/claim`, { + method: 'POST', + body: JSON.stringify(payload), + }); + state.browserPhone.popupCallId = data.call_id; + state.browserPhone.endingCallId = ''; + state.browserPhone.warning = ''; + applyClaimedLiveCall(data); + refreshLiveCallsInBackground(); + return data; +} + +async function hangupLiveCallById(callId, options = {}) { + const { optimistic = true } = options; + const data = await api('asterisk-bridge', `asterisk/live-calls/${encodeURIComponent(callId)}/hangup`, { + method: 'POST', + body: JSON.stringify({}), + }); + if (optimistic) { + moveLiveCallToRecent(data); + refreshLiveCallsInBackground(); + } else { + state.browserPhone.endingCallId = callId; + updateBrowserPhoneUi(); + refreshLiveCallsInBackground(); + } + return data; +} + +async function bestEffortBrowserAutoClaim(options = {}) { + return bestEffortBrowserAutoClaimWithOptions(options); +} + +function browserPhoneAutoClaimSnapshot(items = state.liveCalls.items) { + const byId = (callId) => { + if (!callId) { + return null; + } + return (items || []).find((item) => item.call_id === callId) || null; + }; + + const trackedIds = [ + state.browserPhone.popupCallId, + state.browserPhone.autoClaimCallId, + selectedLiveCallId(), + ].filter(Boolean); + + for (const callId of trackedIds) { + const found = byId(callId); + if (!found) { + continue; + } + if (found.claimed_by_user === state.user) { + return { mode: 'owned', call: found }; + } + if (isClaimableLiveCall(found)) { + return { mode: 'claim', call: found }; + } + } + + const candidates = browserPhoneClaimCandidatesFrom(items); + const ownedCandidates = candidates.filter((item) => item.claimed_by_user === state.user); + if (ownedCandidates.length === 1) { + return { mode: 'owned', call: ownedCandidates[0] }; + } + if (ownedCandidates.length > 1) { + const bestOwned = browserPhoneBestCandidateMatch(ownedCandidates); + if (bestOwned) { + return { mode: 'owned', call: bestOwned }; + } + } + const unclaimedCandidates = candidates.filter((item) => !item.claimed_by_user); + if (unclaimedCandidates.length === 1) { + return { mode: 'claim', call: unclaimedCandidates[0] }; + } + if (unclaimedCandidates.length > 1) { + const bestUnclaimed = browserPhoneBestCandidateMatch(unclaimedCandidates); + if (bestUnclaimed) { + return { mode: 'claim', call: bestUnclaimed }; + } + } + return null; +} + +async function bestEffortBrowserAutoClaimWithOptions(options = {}) { + const { refreshAttempts = 2, retryDelayMs = 150 } = options; + if (state.browserPhone.autoClaimInFlight) { + return false; + } + state.browserPhone.autoClaimInFlight = true; + updateBrowserPhoneUi(); + const config = browserPhoneConfig(); + const operatorExtension = config?.operator_extension || ''; + const handleMatch = async (match) => { + if (!match?.call) { + return false; + } + if (match.mode === 'owned') { + state.browserPhone.autoClaimCallId = match.call.call_id; + state.browserPhone.popupCallId = match.call.call_id; + state.browserPhone.warning = ''; + return true; + } + if (match.mode === 'claim') { + try { + const claimed = await claimLiveCallById(match.call.call_id, { operatorExtension }); + state.browserPhone.autoClaimCallId = claimed.call_id; + state.browserPhone.popupCallId = claimed.call_id; + state.browserPhone.warning = ''; + log('Browser softphone выполнил auto-claim', { call_id: claimed.call_id, extension: operatorExtension || claimed.operator_extension }); + return true; + } catch (err) { + setBrowserPhoneWarning('Звонок соединён, но авто-claim не выполнился. Нажмите «Принять в работу».'); + log('Browser softphone не смог выполнить auto-claim', { error: describeLiveCallError(err.message) }); + return false; + } + } + return false; + }; + + try { + const localMatch = browserPhoneAutoClaimSnapshot(); + if (await handleMatch(localMatch)) { + return true; + } + for (let attempt = 0; attempt < refreshAttempts; attempt += 1) { + if (attempt > 0 && retryDelayMs > 0) { + await new Promise((resolve) => window.setTimeout(resolve, retryDelayMs)); + } + await loadLiveCalls(false, { preserveStateOnError: true }); + const refreshedMatch = browserPhoneAutoClaimSnapshot(); + if (await handleMatch(refreshedMatch)) { + return true; + } + } + setBrowserPhoneWarning('Не удалось однозначно сопоставить звонок. Нажмите «Принять в работу» вручную.'); + log('Browser softphone требует ручного claim', { candidates: browserPhoneClaimCandidates().length }); + return false; + } finally { + state.browserPhone.autoClaimInFlight = false; + updateBrowserPhoneUi(); + } +} + +async function fetchBrowserSoftphoneConfig() { + try { + const data = await api('asterisk-bridge', 'asterisk/browser-softphone/config'); + state.browserPhone.config = data; + state.browserPhone.status = data.enabled ? 'Готов к подключению' : 'Отключён в окружении'; + } catch (err) { + state.browserPhone.config = null; + state.browserPhone.status = 'Не настроен'; + log('Browser softphone config недоступен', { error: describeLiveCallError(err.message) }); + } finally { + updateBrowserPhoneUi(); + } +} + +async function connectBrowserSoftphone() { + if (state.browserPhone.connecting || state.browserPhone.connected) { + return; + } + if (!browserPhoneLibraryAvailable()) { + setBrowserPhoneStatus('SIP.js asset не загружен'); + return; + } + if (!browserPhoneConfig()) { + await fetchBrowserSoftphoneConfig(); + } + const config = browserPhoneConfig(); + if (!config?.enabled) { + updateBrowserPhoneUi(); + return; + } + + try { + state.browserPhone.connecting = true; + state.browserPhone.settingsOpen = false; + state.browserPhone.warning = ''; + setBrowserPhoneStatus('Подключаем browser softphone...'); + await ensureBrowserPhoneMediaAccess(); + await ensureBrowserPhoneAudioContextResumed(); + await loadBrowserPhoneDevices(); + + const uri = window.SIP.UserAgent.makeURI(config.sip_uri); + if (!uri) { + throw new Error('Invalid browser SIP URI'); + } + + const userAgent = new window.SIP.UserAgent({ + uri, + authorizationUsername: config.authorization_username, + authorizationPassword: config.password, + displayName: config.display_name, + transportOptions: { server: config.ws_url }, + sessionDescriptionHandlerFactoryOptions: { + constraints: browserPhoneAudioConstraints(), + peerConnectionConfiguration: { + iceServers: Array.isArray(config.ice_servers) ? config.ice_servers : [], + iceCandidatePoolSize: 4, + bundlePolicy: 'max-bundle', + rtcpMuxPolicy: 'require', + }, + }, + }); + userAgent.delegate = { + onInvite: (invitation) => { + if (!ensureBrowserSessionFreshForInvite()) { + invitation.reject().catch(() => {}); + log('Browser softphone отклонил входящий INVITE: предыдущая session ещё активна'); + return; + } + bindBrowserPhoneSession(invitation); + log('Browser softphone получил входящий звонок', { remote: browserPhoneIncomingLabel(invitation) }); + }, + }; + const registerer = new window.SIP.Registerer(userAgent); + await userAgent.start(); + await registerer.register(); + + state.browserPhone.ua = userAgent; + state.browserPhone.registerer = registerer; + state.browserPhone.connected = true; + setBrowserPhoneStatus('Зарегистрирован'); + } catch (err) { + state.browserPhone.connected = false; + state.browserPhone.ua = null; + state.browserPhone.registerer = null; + setBrowserPhoneStatus('Ошибка регистрации'); + log('Browser softphone не подключился', { error: err.message }); + } finally { + state.browserPhone.connecting = false; + updateBrowserPhoneUi(); + } +} + +async function disconnectBrowserSoftphone() { + try { + stopBrowserPhoneRingtone(); + if (state.browserPhone.session) { + await terminateBrowserPhoneSessionLocally(); + } + if (state.browserPhone.registerer) { + await state.browserPhone.registerer.unregister().catch(() => {}); + } + if (state.browserPhone.ua) { + await state.browserPhone.ua.stop().catch(() => {}); + } + } finally { + state.browserPhone.connected = false; + state.browserPhone.connecting = false; + state.browserPhone.settingsOpen = false; + state.browserPhone.ua = null; + state.browserPhone.registerer = null; + state.browserPhone.popupCallId = ''; + state.browserPhone.endingCallId = ''; + state.browserPhone.callPhase = ''; + state.browserPhone.warning = ''; + stopBrowserPhoneLocalStream(); + resetBrowserPhoneSessionState(); + setBrowserPhoneStatus(browserPhoneConfig()?.enabled ? 'Отключён' : 'Не настроен'); + } +} + +async function answerBrowserSoftphoneCall() { + const session = state.browserPhone.session; + if (!session || !state.browserPhone.incoming) { + return; + } + try { + stopBrowserPhoneRingtone(); + state.browserPhone.warning = ''; + state.browserPhone.incoming = false; + state.browserPhone.callPhase = 'connecting'; + setBrowserPhoneStatus('Соединяем browser media...'); + updateBrowserPhoneUi(); + const localMediaStream = await ensureBrowserPhoneLocalStream(); + await session.accept({ + sessionDescriptionHandlerOptions: { + constraints: browserPhoneAudioConstraints(), + localMediaStream, + }, + }); + const hasImmediateMatch = Boolean(browserPhoneAutoClaimSnapshot()); + void bestEffortBrowserAutoClaimWithOptions({ + refreshAttempts: hasImmediateMatch ? 0 : 2, + retryDelayMs: 150, + }); + } catch (err) { + state.browserPhone.incoming = true; + state.browserPhone.callPhase = 'incoming'; + startBrowserPhoneRingtone().catch(() => {}); + setBrowserPhoneWarning('Не удалось ответить на звонок в браузере.'); + setBrowserPhoneStatus('Входящий звонок'); + log('Ответ в браузерном телефоне не выполнен', { error: err.message }); + } +} + +async function rejectBrowserSoftphoneCall() { + if (!state.browserPhone.session || !state.browserPhone.incoming) { + return; + } + stopBrowserPhoneRingtone(); + state.browserPhone.warning = ''; + await terminateBrowserPhoneSessionLocally(); + resetBrowserPhoneSessionState(); + state.browserPhone.callPhase = ''; + if (state.browserPhone.connected) { + setBrowserPhoneStatus('Зарегистрирован'); + } +} + +function toggleBrowserSoftphoneMute() { + if (!state.browserPhone.session) { + return; + } + state.browserPhone.muted = !state.browserPhone.muted; + applyBrowserMuteState(); + updateBrowserPhoneUi(); +} + +function syncLiveCallActionButtons() { + const loadActionsBtn = $('loadLiveCallActionsBtn'); + const refreshBtn = $('loadLiveCallsBtn'); + const buttons = [loadActionsBtn, refreshBtn].filter(Boolean); + const hasCall = Boolean(selectedLiveCallId()); + const pendingAction = state.liveCalls.pendingAction || ''; + + buttons.forEach((button) => { + if (!button.dataset.defaultLabel) { + button.dataset.defaultLabel = button.textContent; + } + button.textContent = button.dataset.defaultLabel; + }); + + loadActionsBtn.disabled = !hasCall || Boolean(pendingAction); + refreshBtn.disabled = Boolean(pendingAction); + updateBrowserPhoneUi(); +} + +async function runLiveCallAction(actionName, handler) { + if (state.liveCalls.pendingAction) { + log('Дождитесь завершения предыдущего действия по звонку'); + return null; + } + state.liveCalls.pendingAction = actionName; + syncLiveCallActionButtons(); + try { + return await handler(); + } finally { + state.liveCalls.pendingAction = ''; + syncLiveCallActionButtons(); + } +} + +async function claimBrowserPopupCall() { + const popupCall = browserPhoneActivePopupCall(); + if (!popupCall) { + setBrowserPhoneWarning('Не удалось найти активный звонок для claim.'); + return; + } + await runLiveCallAction('claim', async () => { + try { + const data = await claimLiveCallById(popupCall.call_id); + state.browserPhone.popupCallId = data.call_id; + state.browserPhone.warning = ''; + log('Звонок принят в работу', { + call_id: data.call_id, + claimed_by: data.claimed_by_user, + extension: data.operator_extension, + }); + } catch (err) { + setBrowserPhoneWarning(describeLiveCallError(err.message)); + log('Не удалось принять звонок в работу', { call_id: popupCall.call_id, error: describeLiveCallError(err.message) }); + } + }); +} + +async function hangupBrowserPopupCall() { + const popupCall = browserPhoneActivePopupCall(); + if (!popupCall && !state.browserPhone.session) { + setBrowserPhoneWarning('Не удалось найти активный звонок для завершения.'); + return; + } + await runLiveCallAction('hangup', async () => { + const callId = popupCall?.call_id || state.browserPhone.popupCallId || state.browserPhone.autoClaimCallId || selectedLiveCallId(); + try { + state.browserPhone.endingCallId = callId; + state.browserPhone.callPhase = 'ending'; + state.browserPhone.warning = ''; + updateBrowserPhoneUi(); + if (state.browserPhone.session) { + await terminateBrowserPhoneSessionLocally(); + resetBrowserPhoneSessionState(); + state.browserPhone.endingCallId = callId; + state.browserPhone.popupCallId = callId; + state.browserPhone.callPhase = 'ending'; + updateBrowserPhoneUi(); + } + if (!callId) { + throw new Error('hangup failed: call_id is missing'); + } + await hangupLiveCallById(callId, { optimistic: false }); + log('Отправлена команда завершения звонка', { call_id: callId }); + } catch (err) { + state.browserPhone.callPhase = 'error'; + state.browserPhone.endingCallId = callId || state.browserPhone.endingCallId; + setBrowserPhoneWarning(describeLiveCallError(err.message)); + refreshLiveCallsInBackground(); + log('Не удалось завершить звонок', { call_id: callId || popupCall?.call_id || '', error: describeLiveCallError(err.message) }); + } + }); +} + +async function transferBrowserPopupCall() { + const popupCall = browserPhoneActivePopupCall(); + const targetType = $('browserPhoneTransferTargetType').value; + const targetValue = $('browserPhoneTransferTargetValue').value.trim(); + if (!popupCall) { + setBrowserPhoneWarning('Не удалось найти активный звонок для передачи.'); + return; + } + if (!targetValue) { + setBrowserPhoneWarning('Укажите target для передачи звонка.'); + return; + } + await runLiveCallAction('transfer', async () => { + try { + const data = await api('asterisk-bridge', `asterisk/live-calls/${encodeURIComponent(popupCall.call_id)}/blind-transfer`, { + method: 'POST', + body: JSON.stringify({ target_type: targetType, target_value: targetValue }), + }); + if (state.browserPhone.session) { + await terminateBrowserPhoneSessionLocally(); + resetBrowserPhoneSessionState(); + } + state.browserPhone.popupCallId = ''; + state.browserPhone.endingCallId = ''; + state.browserPhone.warning = ''; + moveLiveCallToRecent(data); + refreshLiveCallsInBackground(); + log('Blind transfer выполнен', { + call_id: data.call_id, + target_type: targetType, + target_value: targetValue, + }); + } catch (err) { + setBrowserPhoneWarning(describeLiveCallError(err.message)); + log('Не удалось передать звонок', { call_id: popupCall.call_id, error: describeLiveCallError(err.message) }); + } + }); +} + +function updateLiveCallSelector(items) { + const select = $('liveCallIdSelect'); + const previous = state.liveCalls.selectedCallId || select.value || ''; + const sorted = [...items]; + select.innerHTML = sorted.length + ? sorted + .map((item) => ``) + .join('') + : ''; + if (sorted.length) { + const hasPrevious = sorted.some((item) => item.call_id === previous); + const latestRinging = sorted.find((item) => item.telephony_status === 'ringing' && !item.claimed_by_user); + if (latestRinging && latestRinging.call_id !== previous) { + select.value = latestRinging.call_id; + } else { + select.value = hasPrevious ? previous : sorted[0].call_id; + } + } else { + select.value = ''; + } + state.liveCalls.selectedCallId = select.value; + syncLiveCallActionButtons(); +} + +function renderLiveCallCard(item, { recent = false } = {}) { + const isSelected = !recent && item.call_id === state.liveCalls.selectedCallId; + const caller = item.caller_number || item.caller_name || 'неизвестно'; + const aiMeta = voiceAiStatusMeta(item); + const badges = [ + `${escapeHtml(item.queue_code || item.queue_id)}`, + `${escapeHtml(telephonyLabel(item.telephony_status))}`, + ]; + if (aiMeta) { + badges.push(`${escapeHtml(aiMeta.label)}`); + } + if (recent && item.terminal_action) { + badges.push(`${escapeHtml(terminalActionLabel(item))}`); + } + if (!recent && item.claimed_by_user) { + badges.push(`${escapeHtml(item.claimed_by_user)}`); + } + const targetLine = recent && item.terminal_action === 'blind-transfer' && item.terminal_target + ? `

            цель: ${escapeHtml(item.terminal_target)}

            ` + : ''; + const hangupLine = recent && item.hangup_cause + ? `

            завершение: ${escapeHtml(item.hangup_cause)}

            ` + : ''; + const aiReasonLine = item.ai_handoff_reason + ? `

            AI: ${escapeHtml(item.ai_handoff_reason)}

            ` + : ''; + const timingLine = recent + ? `

            завершён: ${escapeHtml(formatIsoShort(item.last_transition_at || item.ended_at || item.updated_at))}

            ` + : `

            соединён: ${escapeHtml(formatIsoShort(item.connected_at || item.started_at))}

            `; + return ` +
            +
            ${badges.join('')}
            +

            ${escapeHtml(item.call_id)}

            +

            обращение: ${escapeHtml(item.interaction_id)}

            +

            абонент: ${escapeHtml(caller)}

            +

            взял в работу: ${escapeHtml(item.claimed_by_user || '-')}

            +

            внутренний номер: ${escapeHtml(item.operator_extension || '-')}

            +

            начат: ${escapeHtml(formatIsoShort(item.started_at))}

            + ${timingLine} + ${aiReasonLine} + ${targetLine} + ${hangupLine} +

            запись: ${item.has_recording ? 'да' : 'нет'}

            +
            + `; +} + +function renderLiveCallColumn(title, items, emptyMessage, options = {}) { + const { recent = false } = options; + return ` +
            +
            +
            ${escapeHtml(title)}
            +
            ${items.length}
            +
            +
            + ${items.length ? items.map((item) => renderLiveCallCard(item, { recent })).join('') : `
            ${escapeHtml(emptyMessage)}
            `} +
            +
            + `; +} + +function renderLiveCallsTable(activeItems, recentItems) { + $('liveCallsTable').innerHTML = ` +
            + ${renderLiveCallColumn('Активные звонки', activeItems, 'Активных звонков нет.')} + ${renderLiveCallColumn('Только что завершённые', recentItems, 'Недавних завершённых звонков нет.', { recent: true })} +
            + `; + + if (!activeItems.length && !recentItems.length) { + $('liveCallStatusHint').textContent = 'Активных звонков нет.'; + syncLiveCallActionButtons(); + return; + } + + if (activeItems.length) { + const first = activeItems[0]; + $('liveCallStatusHint').textContent = `Активных: ${activeItems.length}. Недавно завершённых: ${recentItems.length}. Последний активный: ${first.call_id} (${telephonyLabel(first.telephony_status)}).`; + } else { + const firstRecent = recentItems[0]; + $('liveCallStatusHint').textContent = `Активных нет. Недавно завершённых: ${recentItems.length}. Последний завершённый: ${firstRecent.call_id} (${terminalActionLabel(firstRecent)}).`; + } + syncLiveCallActionButtons(); +} + +function uniqueLiveCalls(items) { + const seen = new Set(); + return (items || []).filter((item) => { + const callId = item?.call_id; + if (!callId || seen.has(callId)) { + return false; + } + seen.add(callId); + return true; + }); +} + +function applyLiveCallCollections(activeItems, recentItems, options = {}) { + const { selectedCallId = undefined } = options; + state.liveCalls.items = uniqueLiveCalls(activeItems); + state.liveCalls.recentItems = uniqueLiveCalls(recentItems); + if (selectedCallId !== undefined) { + state.liveCalls.selectedCallId = selectedCallId; + } + updateLiveCallSelector(state.liveCalls.items); + renderLiveCallsTable(state.liveCalls.items, state.liveCalls.recentItems); + syncBrowserPhonePopupLifecycle(); + renderCustomerSpotlight(); +} + +function applyClaimedLiveCall(item) { + const activeItems = [...state.liveCalls.items]; + const existingIndex = activeItems.findIndex((entry) => entry.call_id === item.call_id); + if (existingIndex >= 0) { + activeItems[existingIndex] = item; + } else { + activeItems.unshift(item); + } + const recentItems = state.liveCalls.recentItems.filter((entry) => entry.call_id !== item.call_id); + applyLiveCallCollections(activeItems, recentItems, { selectedCallId: item.call_id }); +} + +function moveLiveCallToRecent(item) { + const activeItems = state.liveCalls.items.filter((entry) => entry.call_id !== item.call_id); + const recentItems = [item, ...state.liveCalls.recentItems.filter((entry) => entry.call_id !== item.call_id)]; + const nextSelectedCallId = state.liveCalls.selectedCallId === item.call_id + ? (activeItems[0]?.call_id || '') + : state.liveCalls.selectedCallId; + applyLiveCallCollections(activeItems, recentItems, { selectedCallId: nextSelectedCallId }); +} + +function refreshLiveCallsInBackground() { + window.setTimeout(() => { + loadLiveCalls(false, { preserveStateOnError: true }); + }, 0); +} + +async function loadLiveCalls(logResult = true, options = {}) { + const { preserveStateOnError = false } = options; + try { + const [activeData, recentData] = await Promise.all([ + api('asterisk-bridge', 'asterisk/live-calls'), + api('asterisk-bridge', 'asterisk/recent-calls'), + ]); + const items = Array.isArray(activeData) ? activeData : []; + const recentItems = Array.isArray(recentData) ? recentData : []; + applyLiveCallCollections(items, recentItems); + if (logResult) { + log('Живые звонки обновлены', { active: items.length, recent: recentItems.length }); + } + } catch (err) { + if (!preserveStateOnError) { + applyLiveCallCollections([], [], { selectedCallId: '' }); + $('liveCallStatusHint').textContent = 'Сервис live-calls недоступен.'; + } + if (logResult) { + log('Не удалось загрузить звонки', { error: describeLiveCallError(err.message) }); + } + } finally { + syncLiveCallActionButtons(); + } +} + +async function claimLiveCall() { + await claimBrowserPopupCall(); +} + +async function hangupLiveCall() { + await hangupBrowserPopupCall(); +} + +async function blindTransferLiveCall() { + await transferBrowserPopupCall(); +} + +async function loadLiveCallActions() { + const callId = selectedLiveCallId(); + if (!callId) { + $('liveCallActionsOutput').textContent = 'Выберите звонок.'; + return; + } + try { + const data = await api('asterisk-bridge', `asterisk/live-calls/${encodeURIComponent(callId)}/actions`); + setOutput('liveCallActionsOutput', data); + log('Загружен action log по звонку', { call_id: callId, actions: data.length }); + } catch (err) { + $('liveCallActionsOutput').textContent = err.message; + log('Не удалось загрузить action log', { call_id: callId, error: err.message }); + } +} + +function startLiveCallsPolling() { + if (state.liveCalls.pollTimer) { + window.clearInterval(state.liveCalls.pollTimer); + state.liveCalls.pollTimer = null; + } + state.liveCalls.pollTimer = window.setInterval(() => { + loadLiveCalls(false); + }, 3000); +} + +async function loadInteractions() { + try { + const data = await api('interaction', 'interactions'); + state.interactions = Array.isArray(data) ? data : []; + if (!data.length) { + setEmptyBlock('interactionTable', 'Обращений пока нет. После подготовки демо они появятся автоматически.'); + renderCustomerSpotlight(); + return; + } + $('interactionTable').innerHTML = renderInteractionBoard(data); + renderCustomerSpotlight(); + } catch (err) { + state.interactions = []; + setEmptyBlock('interactionTable', 'Не удалось загрузить обращения.'); + renderCustomerSpotlight(); + log('Не удалось загрузить обращения', { error: err.message }); + } +} + +window.assignInteraction = async function assignInteraction(id) { + const assignee = getDefaultAssignee(); + try { + await api('interaction', `interactions/${id}/assign`, { + method: 'PATCH', + body: JSON.stringify({ assignee }), + }); + await loadInteractions(); + log('Обращение назначено', { id, assignee }); + } catch (err) { + log('Не удалось назначить обращение', { error: err.message }); + } +}; + +window.escalateInteraction = async function escalateInteraction(id) { + const targetQueueId = getDefaultEscalationQueue(); + try { + await api('interaction', `interactions/${id}/escalate`, { + method: 'POST', + body: JSON.stringify({ target_queue_id: targetQueueId }), + }); + await loadInteractions(); + log('Обращение передано на 2 линию', { id, target_queue_id: targetQueueId }); + } catch (err) { + log('Не удалось передать обращение', { error: err.message }); + } +}; + +window.closeInteraction = async function closeInteraction(id) { + try { + await api('interaction', `interactions/${id}/status`, { + method: 'PATCH', + body: JSON.stringify({ status: 'closed' }), + }); + await loadInteractions(); + log('Обращение закрыто', { id }); + } catch (err) { + log('Не удалось закрыть обращение', { error: err.message }); + } +}; + +async function loadSupervisor() { + try { + const data = await api('supervisor', 'supervisor/realtime'); + renderSupervisorSummary(data); + setOutput('supervisorOutput', data); + log('Данные супервизора обновлены', { + agents: data?.agents?.total || 0, + queue: data?.queues?.[0]?.queue_id || 'нет', + }); + } catch (err) { + $('supervisorSummary').innerHTML = renderSummaryCard('Супервизор', 'нет данных', 'Сервис не ответил'); + $('supervisorOutput').textContent = err.message; + log('Не удалось обновить супервизора', { error: err.message }); + } +} + +async function loadKpi() { + try { + const data = await api('reporting', 'reports/kpi'); + renderKpiSummary(data); + setOutput('kpiOutput', data); + log('KPI обновлены', data?.kpi || {}); + } catch (err) { + $('kpiSummary').innerHTML = renderSummaryCard('KPI', 'нет данных', 'Сервис не ответил'); + $('kpiOutput').textContent = err.message; + log('Не удалось обновить KPI', { error: err.message }); + } +} + +async function handleCustomerActionClick(event) { + const button = event.target.closest('[data-customer-action]'); + if (!button) { + return; + } + const customer = selectedCustomer(); + if (!customer) { + return; + } + if (button.dataset.customerAction === 'workspace') { + $('interactionCustomerId').value = customer.customer_id; + window.location.hash = '#workspace'; + log('Клиент открыт в рабочем столе', { customer_id: customer.customer_id }); + return; + } + if (button.dataset.customerAction === 'telegram') { + const threadId = button.dataset.threadId || ''; + if (!threadId) { + return; + } + window.location.hash = '#telegram-page'; + await selectTelegramThread(threadId); + log('Открыт Telegram клиента', { customer_id: customer.customer_id, thread_id: threadId }); + } +} + +function wire() { + $('loginBtn').addEventListener('click', login); + $('corporateLoginBtn').addEventListener('click', startCorporateLogin); + $('refreshBtn').addEventListener('click', async () => { + await searchCustomers(); + await loadInteractions(); + await loadTelegramThreads(false, { preserveSelection: true, preserveOnError: true }); + await loadWhatsappThreads(false, { preserveSelection: true, preserveOnError: true }); + await loadLiveCalls(false); + log('Данные на экране обновлены'); + }); + $('toggleCustomerLeadFormBtn').addEventListener('click', () => toggleCustomerLeadForm()); + $('customerCancelBtn').addEventListener('click', () => toggleCustomerLeadForm(false)); + $('createCustomerBtn').addEventListener('click', createCustomer); + $('exportCustomersBtn').addEventListener('click', exportCustomersCsv); + $('customerPrevPageBtn').addEventListener('click', () => setCustomerPage(state.customers.page - 1)); + $('customerNextPageBtn').addEventListener('click', () => setCustomerPage(state.customers.page + 1)); + $('customerQuery').addEventListener('keydown', (event) => { + if (event.key === 'Enter') { + event.preventDefault(); + searchCustomers(); + } + }); + $('customerList').addEventListener('click', (event) => { + const row = event.target.closest('[data-customer-id]'); + if (!row) { + return; + } + openCustomerProfile(row.dataset.customerId || ''); + }); + $('customerList').addEventListener('keydown', (event) => { + if (event.key !== 'Enter' && event.key !== ' ') { + return; + } + const row = event.target.closest('[data-customer-id]'); + if (!row) { + return; + } + event.preventDefault(); + openCustomerProfile(row.dataset.customerId || ''); + }); + $('customerSpotlight')?.addEventListener('click', handleCustomerActionClick); + $('customerProfileContent').addEventListener('click', handleCustomerActionClick); + $('customerProfileBackBtn').addEventListener('click', () => { + window.location.hash = '#customers-page'; + }); + $('loadTelegramThreadsBtn').addEventListener('click', () => loadTelegramThreads(true, { preserveSelection: true, preserveOnError: true })); + $('telegramThreadSearch').addEventListener('input', (event) => { + state.telegram.searchQuery = event.target.value || ''; + renderTelegramWorkspace(); + }); + $('telegramHeaderSearchBtn').addEventListener('click', () => { + toggleTelegramMessageSearch(); + }); + $('telegramMessageSearch').addEventListener('input', (event) => { + state.telegram.messageSearchQuery = event.target.value || ''; + renderTelegramWorkspace(); + }); + $('telegramMessageSearch').addEventListener('keydown', (event) => { + if (event.key !== 'Escape') { + return; + } + event.preventDefault(); + if (telegramMessageSearchQuery()) { + state.telegram.messageSearchQuery = ''; + renderTelegramWorkspace(); + return; + } + toggleTelegramMessageSearch(false); + }); + $('telegramMessageSearchClearBtn').addEventListener('click', () => { + if (telegramMessageSearchQuery()) { + state.telegram.messageSearchQuery = ''; + renderTelegramWorkspace(); + $('telegramMessageSearch').focus(); + return; + } + toggleTelegramMessageSearch(false); + }); + $('telegramHeaderCallBtn').addEventListener('click', () => { + log('Voice call action для Telegram пока не назначен'); + }); + $('telegramHeaderMenuBtn').addEventListener('click', () => { + toggleTelegramOperatorTray(); + }); + $('telegramThreadsList').addEventListener('click', (event) => { + const button = event.target.closest('[data-telegram-thread-id]'); + if (!button) { + return; + } + selectTelegramThread(button.dataset.telegramThreadId || ''); + }); + $('telegramThreadsList').addEventListener('keydown', (event) => { + if (event.key !== 'Enter' && event.key !== ' ') { + return; + } + const button = event.target.closest('[data-telegram-thread-id]'); + if (!button) { + return; + } + event.preventDefault(); + selectTelegramThread(button.dataset.telegramThreadId || ''); + }); + $('telegramClaimThreadBtn').addEventListener('click', claimTelegramThread); + $('telegramReturnToAiBtn').addEventListener('click', returnTelegramThreadToAi); + $('telegramCloseThreadBtn').addEventListener('click', closeTelegramThread); + $('telegramEscalateThreadBtn').addEventListener('click', escalateTelegramThread); + $('telegramSendReplyBtn').addEventListener('click', sendTelegramReply); + $('telegramReplyText').addEventListener('input', () => { + syncTelegramComposerHeight(); + syncTelegramActionButtons(); + }); + $('telegramReplyText').addEventListener('keydown', (event) => { + if (event.key !== 'Enter') { + return; + } + if (event.shiftKey) { + return; + } + event.preventDefault(); + sendTelegramReply(); + }); + $('whatsappSearchInput').addEventListener('input', (event) => { + state.whatsapp.searchQuery = event.target.value || ''; + renderWhatsappWorkspace(); + }); + $('whatsappFilterBar').addEventListener('click', (event) => { + const button = event.target.closest('[data-whatsapp-filter]'); + if (!button) { + return; + } + setWhatsappFilter(button.dataset.whatsappFilter || 'all'); + }); + $('whatsappClaimBtn').addEventListener('click', () => { + claimWhatsappThread().catch(() => {}); + }); + $('whatsappReturnToAiBtn').addEventListener('click', returnWhatsappThreadToAi); + $('whatsappChatList').addEventListener('click', (event) => { + const button = event.target.closest('[data-whatsapp-chat-id]'); + if (!button) { + return; + } + selectWhatsappChat(button.dataset.whatsappChatId || '').catch(() => {}); + }); + $('whatsappChatList').addEventListener('keydown', (event) => { + if (event.key !== 'Enter' && event.key !== ' ') { + return; + } + const button = event.target.closest('[data-whatsapp-chat-id]'); + if (!button) { + return; + } + event.preventDefault(); + selectWhatsappChat(button.dataset.whatsappChatId || '').catch(() => {}); + }); + document.querySelectorAll('[data-whatsapp-ui-action]').forEach((button) => { + button.addEventListener('click', handleWhatsappUiAction); + }); + $('whatsappSendBtn').addEventListener('click', sendWhatsappMessage); + $('whatsappComposerInput').addEventListener('input', (event) => { + state.whatsapp.composerText = event.target.value || ''; + syncWhatsappComposerUi(); + }); + $('whatsappComposerInput').addEventListener('keydown', (event) => { + if (event.key !== 'Enter' || event.shiftKey) { + return; + } + event.preventDefault(); + sendWhatsappMessage(); + }); + $('createInteractionBtn').addEventListener('click', createInteraction); + $('loadInteractionsBtn').addEventListener('click', loadInteractions); + $('loadLiveCallsBtn').addEventListener('click', () => loadLiveCalls(true)); + $('loadLiveCallActionsBtn').addEventListener('click', loadLiveCallActions); + $('browserPhoneStatusBtn').addEventListener('click', toggleBrowserPhoneSettings); + $('browserPhoneCallSettingsBtn').addEventListener('click', toggleBrowserPhoneSettings); + $('browserPhoneConnectBtn').addEventListener('click', connectBrowserSoftphone); + $('browserPhoneDisconnectBtn').addEventListener('click', disconnectBrowserSoftphone); + $('browserPhoneMuteBtn').addEventListener('click', toggleBrowserSoftphoneMute); + $('browserPhoneAnswerBtn').addEventListener('click', answerBrowserSoftphoneCall); + $('browserPhoneRejectBtn').addEventListener('click', rejectBrowserSoftphoneCall); + $('browserPhoneCallClaimBtn').addEventListener('click', claimBrowserPopupCall); + $('browserPhoneCallTransferBtn').addEventListener('click', transferBrowserPopupCall); + $('browserPhoneCallHangupBtn').addEventListener('click', hangupBrowserPopupCall); + $('browserPhoneMicSelect').addEventListener('change', () => { + state.browserPhone.micDeviceId = $('browserPhoneMicSelect').value; + stopBrowserPhoneLocalStream(); + if (state.browserPhone.incoming) { + prepareBrowserPhoneAnswerMedia(); + } + }); + $('browserPhoneSpeakerSelect').addEventListener('change', () => { + state.browserPhone.speakerDeviceId = $('browserPhoneSpeakerSelect').value; + syncBrowserPhoneSpeakerSink(); + }); + $('browserPhoneTransferTargetValue').addEventListener('input', updateBrowserPhoneUi); + $('browserPhoneTransferTargetType').addEventListener('change', updateBrowserPhoneUi); + $('liveCallIdSelect').addEventListener('change', () => { + state.liveCalls.selectedCallId = $('liveCallIdSelect').value; + syncLiveCallActionButtons(); + }); + $('logoutBtn').addEventListener('click', logout); + window.addEventListener('message', handleOidcMessage); + window.addEventListener('hashchange', handleOperatorHashChange); + if (navigator.mediaDevices?.addEventListener) { + navigator.mediaDevices.addEventListener('devicechange', () => { + loadBrowserPhoneDevices().catch(() => {}); + }); + } +} + +async function init() { + if (!restoreStoredSession()) { + window.location.href = '/'; + return; + } + wire(); + applyOperatorViewFromHash(); + $('defaultAssignee').value = DEMO_ASSIGNEE; + $('defaultEscalationQueue').value = DEMO_QUEUE; + $('telegramEscalationQueue').value = DEMO_QUEUE; + updateSessionInfo(); + syncLiveCallActionButtons(); + renderWhatsappWorkspace(); + renderTelegramWorkspace(); + await Promise.all([checkGateway(), loadOidcConfig()]); + await fetchBrowserSoftphoneConfig(); + await loadBrowserPhoneDevices().catch(() => {}); + syncCustomerFormUi(); + await searchCustomers(); + await loadInteractions(); + await loadTelegramThreads(false, { preserveSelection: true, preserveOnError: true }); + await loadWhatsappThreads(false, { preserveSelection: true, preserveOnError: true }); + await loadLiveCalls(false); + startLiveCallsPolling(); + startTelegramPolling(); + startWhatsappPolling(); + log('Экран готов к показу', { assignee: DEMO_ASSIGNEE, queue: DEMO_QUEUE }); +} + +init(); + diff --git a/ui/operator/favicon.svg b/ui/operator/favicon.svg new file mode 100644 index 0000000..927bd5b --- /dev/null +++ b/ui/operator/favicon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/ui/operator/index.html b/ui/operator/index.html new file mode 100644 index 0000000..a2b46c4 --- /dev/null +++ b/ui/operator/index.html @@ -0,0 +1,608 @@ + + + + + + KonturCC Оператор + + + + + + + +
            + + +
            +
            + +
            +
            Шлюз: проверка...
            +
            + + +
            + +
            +
            OP
            +
            +
            Экран оператора
            +
            Основной сценарий
            +
            +
            +
            +
            + +
            +
            +
            +

            Операторский пульт

            +
            + +
            +

            Сессия

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

            Токен: отсутствует

            +
            + +
            +
            +

            Обращения

            +
            + + + + +
            +
            + + +
            +
            + +
            +
            +
            + +
            +
            + + + + + + + + + + +
            +
            +
            + + + + + + + + + + diff --git a/ui/operator/sip-0.21.2.min.js b/ui/operator/sip-0.21.2.min.js new file mode 100644 index 0000000..5b917b4 --- /dev/null +++ b/ui/operator/sip-0.21.2.min.js @@ -0,0 +1,2 @@ +/*! For license information please see sip.min.js.LICENSE.txt */ +!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.SIP=t():e.SIP=t()}(this,(()=>(()=>{"use strict";var e={d:(t,s)=>{for(var i in s)e.o(s,i)&&!e.o(t,i)&&Object.defineProperty(t,i,{enumerable:!0,get:s[i]})},o:(e,t)=>Object.prototype.hasOwnProperty.call(e,t),r:e=>{"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})}},t={};e.r(t),e.d(t,{Ack:()=>l,Bye:()=>g,Cancel:()=>u,ContentTypeUnsupportedError:()=>o,Core:()=>s,EmitterImpl:()=>p,Grammar:()=>y,Info:()=>f,Invitation:()=>Q,Inviter:()=>ee,Message:()=>W,Messager:()=>te,NameAddrHeader:()=>v,Notification:()=>K,Parameters:()=>m,Publisher:()=>le,PublisherState:()=>se,Referral:()=>Y,Registerer:()=>ge,RegistererState:()=>ie,RequestPendingError:()=>a,SIPExtension:()=>J,Session:()=>z,SessionDescriptionHandlerError:()=>c,SessionState:()=>Z,SessionTerminatedError:()=>h,StateTransitionError:()=>d,Subscriber:()=>pe,Subscription:()=>ue,SubscriptionState:()=>ne,TransportState:()=>oe,URI:()=>w,UserAgent:()=>dt,UserAgentRegisteredOptionTags:()=>X,UserAgentState:()=>ae,Web:()=>i,equivalentURI:()=>b,name:()=>At,version:()=>Ct});var s={};e.r(s),e.d(s,{ByeUserAgentClient:()=>Pe,ByeUserAgentServer:()=>_e,C:()=>G,CancelUserAgentClient:()=>lt,ClientTransaction:()=>Ie,Dialog:()=>Ae,DigestAuthentication:()=>we,Exception:()=>n,Grammar:()=>y,IncomingMessage:()=>H,IncomingRequestMessage:()=>P,IncomingResponseMessage:()=>x,InfoUserAgentClient:()=>qe,InfoUserAgentServer:()=>Me,InviteClientTransaction:()=>De,InviteServerTransaction:()=>$e,InviteUserAgentClient:()=>Ye,InviteUserAgentServer:()=>Ze,Levels:()=>ce,Logger:()=>be,LoggerFactory:()=>Te,MessageUserAgentClient:()=>Ne,MessageUserAgentServer:()=>Oe,NameAddrHeader:()=>v,NonInviteClientTransaction:()=>Ce,NonInviteServerTransaction:()=>xe,NotifyUserAgentClient:()=>Ue,NotifyUserAgentServer:()=>je,OutgoingRequestMessage:()=>k,Parameters:()=>m,Parser:()=>he,PrackUserAgentClient:()=>Fe,PrackUserAgentServer:()=>Le,PublishUserAgentClient:()=>Je,ReInviteUserAgentClient:()=>Be,ReInviteUserAgentServer:()=>Ge,ReSubscribeUserAgentClient:()=>Qe,ReSubscribeUserAgentServer:()=>gt,ReferUserAgentClient:()=>Ve,ReferUserAgentServer:()=>We,RegisterUserAgentClient:()=>ze,RegisterUserAgentServer:()=>Xe,ServerTransaction:()=>Ee,SessionDialog:()=>Ke,SessionState:()=>O,SignalingState:()=>U,SubscribeUserAgentClient:()=>tt,SubscribeUserAgentServer:()=>st,SubscriptionDialog:()=>et,SubscriptionState:()=>re,Timers:()=>L,Transaction:()=>ye,TransactionState:()=>de,TransactionStateError:()=>B,TransportError:()=>Re,URI:()=>w,UserAgentClient:()=>He,UserAgentCore:()=>rt,UserAgentServer:()=>ke,constructOutgoingResponse:()=>Se,equivalentURI:()=>b,fromBodyLegacy:()=>q,getBody:()=>N,isBody:()=>M});var i={};e.r(i),e.d(i,{SessionDescriptionHandler:()=>at,SessionManager:()=>$t,SimpleUser:()=>It,Transport:()=>ht,WebAudioSessionDescriptionHandler:()=>yt,addMidLines:()=>Tt,cleanJitsiSdpImageattr:()=>mt,defaultManagedSessionFactory:()=>Et,defaultMediaStreamFactory:()=>nt,defaultPeerConnectionConfiguration:()=>ot,defaultSessionDescriptionHandlerFactory:()=>ct,holdModifier:()=>St,startLocalConference:()=>Rt,stripG722:()=>vt,stripRtpPayload:()=>wt,stripTcpCandidates:()=>pt,stripTelephoneEvent:()=>ft,stripVideo:()=>bt});const r="0.21.2";class n extends Error{constructor(e){super(e),Object.setPrototypeOf(this,new.target.prototype)}}class o extends n{constructor(e){super(e||"Unsupported content type.")}}class a extends n{constructor(e){super(e||"Request pending.")}}class c extends n{constructor(e){super(e||"Unspecified session description handler error.")}}class h extends n{constructor(){super("The session has terminated.")}}class d extends n{constructor(e){super(e||"An error occurred during state transition.")}}class l{constructor(e){this.incomingAckRequest=e}get request(){return this.incomingAckRequest.message}}class g{constructor(e){this.incomingByeRequest=e}get request(){return this.incomingByeRequest.message}accept(e){return this.incomingByeRequest.accept(e),Promise.resolve()}reject(e){return this.incomingByeRequest.reject(e),Promise.resolve()}}class u{constructor(e){this.incomingCancelRequest=e}get request(){return this.incomingCancelRequest}}class p{constructor(){this.listeners=new Array}addListener(e,t){const s=t=>{this.removeListener(s),e(t)};!0===(null==t?void 0:t.once)?this.listeners.push(s):this.listeners.push(e)}emit(e){this.listeners.slice().forEach((t=>t(e)))}removeAllListeners(){this.listeners=[]}removeListener(e){this.listeners=this.listeners.filter((t=>t!==e))}on(e){return this.addListener(e)}off(e){return this.removeListener(e)}once(e){return this.addListener(e,{once:!0})}}class f{constructor(e){this.incomingInfoRequest=e}get request(){return this.incomingInfoRequest.message}accept(e){return this.incomingInfoRequest.accept(e),Promise.resolve()}reject(e){return this.incomingInfoRequest.reject(e),Promise.resolve()}}class m{constructor(e){this.parameters={};for(const t in e)e.hasOwnProperty(t)&&this.setParam(t,e[t])}setParam(e,t){e&&(this.parameters[e.toLowerCase()]=null==t?null:t.toString())}getParam(e){if(e)return this.parameters[e.toLowerCase()]}hasParam(e){return!(!e||void 0===this.parameters[e.toLowerCase()])}deleteParam(e){if(e=e.toLowerCase(),this.hasParam(e)){const t=this.parameters[e];return delete this.parameters[e],t}}clearParams(){this.parameters={}}}class v extends m{constructor(e,t,s){super(s),this.uri=e,this._displayName=t}get friendlyName(){return this.displayName||this.uri.aor}get displayName(){return this._displayName}set displayName(e){this._displayName=e}clone(){return new v(this.uri.clone(),this._displayName,JSON.parse(JSON.stringify(this.parameters)))}toString(){let e=this.displayName||"0"===this.displayName?'"'+this.displayName+'" ':"";e+="<"+this.uri.toString()+">";for(const t in this.parameters)this.parameters.hasOwnProperty(t)&&(e+=";"+t,null!==this.parameters[t]&&(e+="="+this.parameters[t]));return e}}class w extends m{constructor(e="sip",t,s,i,r,n){if(super(r||{}),this.headers={},!s)throw new TypeError('missing or invalid "host" parameter');for(const e in n)n.hasOwnProperty(e)&&this.setHeader(e,n[e]);this.raw={scheme:e,user:t,host:s,port:i},this.normal={scheme:e.toLowerCase(),user:t,host:s.toLowerCase(),port:i}}get scheme(){return this.normal.scheme}set scheme(e){this.raw.scheme=e,this.normal.scheme=e.toLowerCase()}get user(){return this.normal.user}set user(e){this.normal.user=this.raw.user=e}get host(){return this.normal.host}set host(e){this.raw.host=e,this.normal.host=e.toLowerCase()}get aor(){return this.normal.user+"@"+this.normal.host}get port(){return this.normal.port}set port(e){this.normal.port=this.raw.port=e}setHeader(e,t){this.headers[this.headerize(e)]=t instanceof Array?t:[t]}getHeader(e){if(e)return this.headers[this.headerize(e)]}hasHeader(e){return!!e&&!!this.headers.hasOwnProperty(this.headerize(e))}deleteHeader(e){if(e=this.headerize(e),this.headers.hasOwnProperty(e)){const t=this.headers[e];return delete this.headers[e],t}}clearHeaders(){this.headers={}}clone(){return new w(this._raw.scheme,this._raw.user||"",this._raw.host,this._raw.port,JSON.parse(JSON.stringify(this.parameters)),JSON.parse(JSON.stringify(this.headers)))}toRaw(){return this._toString(this._raw)}toString(){return this._toString(this._normal)}get _normal(){return this.normal}get _raw(){return this.raw}_toString(e){let t=e.scheme+":";e.scheme.toLowerCase().match("^sips?$")||(t+="//"),e.user&&(t+=this.escapeUser(e.user)+"@"),t+=e.host,(e.port||0===e.port)&&(t+=":"+e.port);for(const e in this.parameters)this.parameters.hasOwnProperty(e)&&(t+=";"+e,null!==this.parameters[e]&&(t+="="+this.parameters[e]));const s=[];for(const e in this.headers)if(this.headers.hasOwnProperty(e))for(const t in this.headers[e])this.headers[e].hasOwnProperty(t)&&s.push(e+"="+this.headers[e][t]);return s.length>0&&(t+="?"+s.join("&")),t}escapeUser(e){let t;try{t=decodeURIComponent(e)}catch(e){throw e}return encodeURIComponent(t).replace(/%3A/gi,":").replace(/%2B/gi,"+").replace(/%3F/gi,"?").replace(/%2F/gi,"/")}headerize(e){const t={"Call-Id":"Call-ID",Cseq:"CSeq","Min-Se":"Min-SE",Rack:"RAck",Rseq:"RSeq","Www-Authenticate":"WWW-Authenticate"},s=e.toLowerCase().replace(/_/g,"-").split("-"),i=s.length;let r="";for(let e=0;ei.includes(e))).every((s=>e.parameters[s]===t.parameters[s]))&&(!!["user","ttl","method","transport"].every((s=>e.hasParam(s)&&t.hasParam(s)||!e.hasParam(s)&&!t.hasParam(s)))&&!!["maddr"].every((s=>e.hasParam(s)&&t.hasParam(s)||!e.hasParam(s)&&!t.hasParam(s))))}(e,t))return!1;const s=Object.keys(e.headers),i=Object.keys(t.headers);if(0!==s.length||0!==i.length){if(s.length!==i.length)return!1;const r=s.filter((e=>i.includes(e)));if(r.length!==i.length)return!1;if(!r.every((s=>e.headers[s].length&&t.headers[s].length&&e.headers[s][0]===t.headers[s][0])))return!1}return!0}function T(e,t,s){return s=s||" ",e.length>t?e:(t-=e.length,e+(s+=s.repeat(t)).slice(0,t))}class S extends Error{constructor(e,t,s,i){super(),this.message=e,this.expected=t,this.found=s,this.location=i,this.name="SyntaxError","function"==typeof Object.setPrototypeOf?Object.setPrototypeOf(this,S.prototype):this.__proto__=S.prototype,"function"==typeof Error.captureStackTrace&&Error.captureStackTrace(this,S)}static buildMessage(e,t){function s(e){return e.charCodeAt(0).toString(16).toUpperCase()}function i(e){return e.replace(/\\/g,"\\\\").replace(/"/g,'\\"').replace(/\0/g,"\\0").replace(/\t/g,"\\t").replace(/\n/g,"\\n").replace(/\r/g,"\\r").replace(/[\x00-\x0F]/g,(e=>"\\x0"+s(e))).replace(/[\x10-\x1F\x7F-\x9F]/g,(e=>"\\x"+s(e)))}function r(e){return e.replace(/\\/g,"\\\\").replace(/\]/g,"\\]").replace(/\^/g,"\\^").replace(/-/g,"\\-").replace(/\0/g,"\\0").replace(/\t/g,"\\t").replace(/\n/g,"\\n").replace(/\r/g,"\\r").replace(/[\x00-\x0F]/g,(e=>"\\x0"+s(e))).replace(/[\x10-\x1F\x7F-\x9F]/g,(e=>"\\x"+s(e)))}function n(e){switch(e.type){case"literal":return'"'+i(e.text)+'"';case"class":const t=e.parts.map((e=>Array.isArray(e)?r(e[0])+"-"+r(e[1]):r(e)));return"["+(e.inverted?"^":"")+t+"]";case"any":return"any character";case"end":return"end of input";case"other":return e.description}}return"Expected "+function(e){const t=e.map(n);let s,i;if(t.sort(),t.length>0){for(s=1,i=1;s",b(">",!1),"\\",b("\\",!1),"[",b("[",!1),"]",b("]",!1),"{",b("{",!1),"}",b("}",!1),function(){return"*"},function(){return"/"},function(){return"="},function(){return"("},function(){return")"},function(){return">"},function(){return"<"},function(){return","},function(){return";"},function(){return":"},function(){return'"'},/^[!-']/,T([["!","'"]],!1,!1),/^[*-[]/,T([["*","["]],!1,!1),/^[\]-~]/,T([["]","~"]],!1,!1),function(e){return e},/^[#-[]/,T([["#","["]],!1,!1),/^[\0-\t]/,T([["\0","\t"]],!1,!1),/^[\v-\f]/,T([["\v","\f"]],!1,!1),/^[\x0E-\x7F]/,T([["\x0e","\x7f"]],!1,!1),function(){(t=t||{data:{}}).data.uri=new w(t.data.scheme,t.data.user,t.data.host,t.data.port),delete t.data.scheme,delete t.data.user,delete t.data.host,delete t.data.host_type,delete t.data.port},function(){(t=t||{data:{}}).data.uri=new w(t.data.scheme,t.data.user,t.data.host,t.data.port,t.data.uri_params,t.data.uri_headers),delete t.data.scheme,delete t.data.user,delete t.data.host,delete t.data.host_type,delete t.data.port,delete t.data.uri_params,"SIP_URI"===t.startRule&&(t.data=t.data.uri)},"sips",b("sips",!0),"sip",b("sip",!0),function(e){(t=t||{data:{}}).data.scheme=e},function(){(t=t||{data:{}}).data.user=decodeURIComponent(f().slice(0,-1))},function(){(t=t||{data:{}}).data.password=f()},function(){return(t=t||{data:{}}).data.host=f(),t.data.host},function(){return(t=t||{data:{}}).data.host_type="domain",f()},/^[a-zA-Z0-9_\-]/,T([["a","z"],["A","Z"],["0","9"],"_","-"],!1,!1),/^[a-zA-Z0-9\-]/,T([["a","z"],["A","Z"],["0","9"],"-"],!1,!1),function(){return(t=t||{data:{}}).data.host_type="IPv6",f()},"::",b("::",!1),function(){return(t=t||{data:{}}).data.host_type="IPv6",f()},function(){return(t=t||{data:{}}).data.host_type="IPv4",f()},"25",b("25",!1),/^[0-5]/,T([["0","5"]],!1,!1),"2",b("2",!1),/^[0-4]/,T([["0","4"]],!1,!1),"1",b("1",!1),/^[1-9]/,T([["1","9"]],!1,!1),function(e){return t=t||{data:{}},e=parseInt(e.join("")),t.data.port=e,e},"transport=",b("transport=",!0),"udp",b("udp",!0),"tcp",b("tcp",!0),"sctp",b("sctp",!0),"tls",b("tls",!0),function(e){(t=t||{data:{}}).data.uri_params||(t.data.uri_params={}),t.data.uri_params.transport=e.toLowerCase()},"user=",b("user=",!0),"phone",b("phone",!0),"ip",b("ip",!0),function(e){(t=t||{data:{}}).data.uri_params||(t.data.uri_params={}),t.data.uri_params.user=e.toLowerCase()},"method=",b("method=",!0),function(e){(t=t||{data:{}}).data.uri_params||(t.data.uri_params={}),t.data.uri_params.method=e},"ttl=",b("ttl=",!0),function(e){(t=t||{data:{}}).data.params||(t.data.params={}),t.data.params.ttl=e},"maddr=",b("maddr=",!0),function(e){(t=t||{data:{}}).data.uri_params||(t.data.uri_params={}),t.data.uri_params.maddr=e},"lr",b("lr",!0),function(){(t=t||{data:{}}).data.uri_params||(t.data.uri_params={}),t.data.uri_params.lr=void 0},function(e,s){(t=t||{data:{}}).data.uri_params||(t.data.uri_params={}),s=null===s?void 0:s[1],t.data.uri_params[e.toLowerCase()]=s},function(e,s){e=e.join("").toLowerCase(),s=s.join(""),(t=t||{data:{}}).data.uri_headers||(t.data.uri_headers={}),t.data.uri_headers[e]?t.data.uri_headers[e].push(s):t.data.uri_headers[e]=[s]},function(){"Refer_To"===(t=t||{data:{}}).startRule&&(t.data.uri=new w(t.data.scheme,t.data.user,t.data.host,t.data.port,t.data.uri_params,t.data.uri_headers),delete t.data.scheme,delete t.data.user,delete t.data.host,delete t.data.host_type,delete t.data.port,delete t.data.uri_params)},"//",b("//",!1),function(){(t=t||{data:{}}).data.scheme=f()},b("SIP",!0),function(){(t=t||{data:{}}).data.sip_version=f()},"INVITE",b("INVITE",!1),"ACK",b("ACK",!1),"VXACH",b("VXACH",!1),"OPTIONS",b("OPTIONS",!1),"BYE",b("BYE",!1),"CANCEL",b("CANCEL",!1),"REGISTER",b("REGISTER",!1),"SUBSCRIBE",b("SUBSCRIBE",!1),"NOTIFY",b("NOTIFY",!1),"REFER",b("REFER",!1),"PUBLISH",b("PUBLISH",!1),function(){return(t=t||{data:{}}).data.method=f(),t.data.method},function(e){(t=t||{data:{}}).data.status_code=parseInt(e.join(""))},function(){(t=t||{data:{}}).data.reason_phrase=f()},function(){(t=t||{data:{}}).data=f()},function(){var e,s;for(s=(t=t||{data:{}}).data.multi_header.length,e=0;e""6>7?.A &2@""6@7A.5 &2B""6B7C.) &2D""6D7E'),I(";).# &;,"),I('2F""6F7G.} &2H""6H7I.q &2J""6J7K.e &2L""6L7M.Y &2N""6N7O.M &2P""6P7Q.A &2R""6R7S.5 &2T""6T7U.) &2V""6V7W'),I('%%2X""6X7Y/5#;#/,$;#/#$+#)(#\'#("\'#&\'#/"!&,)'),I('%%$;$0#*;$&/,#; /#$+")("\'#&\'#." &"/=#$;$/�#*;$&&&#/\'$8":Z" )("\'#&\'#'),I(';.." &"'),I("%$;'.# &;(0)*;'.# &;(&/?#28\"\"6879/0$;//'$8#:[# )(#'#(\"'#&'#"),I('%%$;2/�#*;2&&&#/g#$%$;.0#*;.&/,#;2/#$+")("\'#&\'#0=*%$;.0#*;.&/,#;2/#$+")("\'#&\'#&/#$+")("\'#&\'#/"!&,)'),I('4\\""5!7].# &;3'),I('4^""5!7_'),I('4`""5!7a'),I(';!.) &4b""5!7c'),I('%$;).\x95 &2F""6F7G.\x89 &2J""6J7K.} &2L""6L7M.q &2X""6X7Y.e &2P""6P7Q.Y &2H""6H7I.M &2@""6@7A.A &2d""6d7e.5 &2R""6R7S.) &2N""6N7O/\x9e#0\x9b*;).\x95 &2F""6F7G.\x89 &2J""6J7K.} &2L""6L7M.q &2X""6X7Y.e &2P""6P7Q.Y &2H""6H7I.M &2@""6@7A.A &2d""6d7e.5 &2R""6R7S.) &2N""6N7O&&&#/"!&,)'),I('%$;).\x89 &2F""6F7G.} &2L""6L7M.q &2X""6X7Y.e &2P""6P7Q.Y &2H""6H7I.M &2@""6@7A.A &2d""6d7e.5 &2R""6R7S.) &2N""6N7O/\x92#0\x8f*;).\x89 &2F""6F7G.} &2L""6L7M.q &2X""6X7Y.e &2P""6P7Q.Y &2H""6H7I.M &2@""6@7A.A &2d""6d7e.5 &2R""6R7S.) &2N""6N7O&&&#/"!&,)'),I('2T""6T7U.\xe3 &2V""6V7W.\xd7 &2f""6f7g.\xcb &2h""6h7i.\xbf &2:""6:7;.\xb3 &2D""6D7E.\xa7 &22""6273.\x9b &28""6879.\x8f &2j""6j7k.\x83 &;&.} &24""6475.q &2l""6l7m.e &2n""6n7o.Y &26""6677.M &2>""6>7?.A &2p""6p7q.5 &2r""6r7s.) &;\'.# &;('),I('%$;).\u012b &2F""6F7G.\u011f &2J""6J7K.\u0113 &2L""6L7M.\u0107 &2X""6X7Y.\xfb &2P""6P7Q.\xef &2H""6H7I.\xe3 &2@""6@7A.\xd7 &2d""6d7e.\xcb &2R""6R7S.\xbf &2N""6N7O.\xb3 &2T""6T7U.\xa7 &2V""6V7W.\x9b &2f""6f7g.\x8f &2h""6h7i.\x83 &28""6879.w &2j""6j7k.k &;&.e &24""6475.Y &2l""6l7m.M &2n""6n7o.A &26""6677.5 &2p""6p7q.) &2r""6r7s/\u0134#0\u0131*;).\u012b &2F""6F7G.\u011f &2J""6J7K.\u0113 &2L""6L7M.\u0107 &2X""6X7Y.\xfb &2P""6P7Q.\xef &2H""6H7I.\xe3 &2@""6@7A.\xd7 &2d""6d7e.\xcb &2R""6R7S.\xbf &2N""6N7O.\xb3 &2T""6T7U.\xa7 &2V""6V7W.\x9b &2f""6f7g.\x8f &2h""6h7i.\x83 &28""6879.w &2j""6j7k.k &;&.e &24""6475.Y &2l""6l7m.M &2n""6n7o.A &26""6677.5 &2p""6p7q.) &2r""6r7s&&&#/"!&,)'),I("%;//?#2P\"\"6P7Q/0$;//'$8#:t# )(#'#(\"'#&'#"),I("%;//?#24\"\"6475/0$;//'$8#:u# )(#'#(\"'#&'#"),I("%;//?#2>\"\"6>7?/0$;//'$8#:v# )(#'#(\"'#&'#"),I("%;//?#2T\"\"6T7U/0$;//'$8#:w# )(#'#(\"'#&'#"),I("%;//?#2V\"\"6V7W/0$;//'$8#:x# )(#'#(\"'#&'#"),I('%2h""6h7i/0#;//\'$8":y" )("\'#&\'#'),I('%;//6#2f""6f7g/\'$8":z" )("\'#&\'#'),I("%;//?#2D\"\"6D7E/0$;//'$8#:{# )(#'#(\"'#&'#"),I("%;//?#22\"\"6273/0$;//'$8#:|# )(#'#(\"'#&'#"),I("%;//?#28\"\"6879/0$;//'$8#:}# )(#'#(\"'#&'#"),I("%;//0#;&/'$8\":~\" )(\"'#&'#"),I("%;&/0#;//'$8\":~\" )(\"'#&'#"),I("%;=/T#$;G.) &;K.# &;F0/*;G.) &;K.# &;F&/,$;>/#$+#)(#'#(\"'#&'#"),I('4\x7f""5!7\x80.A &4\x81""5!7\x82.5 &4\x83""5!7\x84.) &;3.# &;.'),I("%%;//Q#;&/H$$;J.# &;K0)*;J.# &;K&/,$;&/#$+$)($'#(#'#(\"'#&'#/\"!&,)"),I("%;//]#;&/T$%$;J.# &;K0)*;J.# &;K&/\"!&,)/1$;&/($8$:\x85$!!)($'#(#'#(\"'#&'#"),I(';..G &2L""6L7M.; &4\x86""5!7\x87./ &4\x83""5!7\x84.# &;3'),I('%2j""6j7k/J#4\x88""5!7\x89.5 &4\x8a""5!7\x8b.) &4\x8c""5!7\x8d/#$+")("\'#&\'#'),I("%;N/M#28\"\"6879/>$;O.\" &\"/0$;S/'$8$:\x8e$ )($'#(#'#(\"'#&'#"),I("%;N/d#28\"\"6879/U$;O.\" &\"/G$;S/>$;_/5$;l.\" &\"/'$8&:\x8f& )(&'#(%'#($'#(#'#(\"'#&'#"),I('%3\x90""5$7\x91.) &3\x92""5#7\x93/\' 8!:\x94!! )'),I('%;P/]#%28""6879/,#;R/#$+")("\'#&\'#." &"/6$2:""6:7;/\'$8#:\x95# )(#\'#("\'#&\'#'),I("$;+.) &;-.# &;Q/2#0/*;+.) &;-.# &;Q&&&#"),I('2<""6<7=.q &2>""6>7?.e &2@""6@7A.Y &2B""6B7C.M &2D""6D7E.A &22""6273.5 &26""6677.) &24""6475'),I('%$;+._ &;-.Y &2<""6<7=.M &2>""6>7?.A &2@""6@7A.5 &2B""6B7C.) &2D""6D7E0e*;+._ &;-.Y &2<""6<7=.M &2>""6>7?.A &2@""6@7A.5 &2B""6B7C.) &2D""6D7E&/& 8!:\x96! )'),I('%;T/J#%28""6879/,#;^/#$+")("\'#&\'#." &"/#$+")("\'#&\'#'),I("%;U.) &;\\.# &;X/& 8!:\x97! )"),I('%$%;V/2#2J""6J7K/#$+")("\'#&\'#0<*%;V/2#2J""6J7K/#$+")("\'#&\'#&/D#;W/;$2J""6J7K." &"/\'$8#:\x98# )(#\'#("\'#&\'#'),I('$4\x99""5!7\x9a/,#0)*4\x99""5!7\x9a&&&#'),I('%4$""5!7%/?#$4\x9b""5!7\x9c0)*4\x9b""5!7\x9c&/#$+")("\'#&\'#'),I('%2l""6l7m/?#;Y/6$2n""6n7o/\'$8#:\x9d# )(#\'#("\'#&\'#'),I('%%;Z/\xb3#28""6879/\xa4$;Z/\x9b$28""6879/\x8c$;Z/\x83$28""6879/t$;Z/k$28""6879/\\$;Z/S$28""6879/D$;Z/;$28""6879/,$;[/#$+-)(-\'#(,\'#(+\'#(*\'#()\'#((\'#(\'\'#(&\'#(%\'#($\'#(#\'#("\'#&\'#.\u0790 &%2\x9e""6\x9e7\x9f/\xa4#;Z/\x9b$28""6879/\x8c$;Z/\x83$28""6879/t$;Z/k$28""6879/\\$;Z/S$28""6879/D$;Z/;$28""6879/,$;[/#$+,)(,\'#(+\'#(*\'#()\'#((\'#(\'\'#(&\'#(%\'#($\'#(#\'#("\'#&\'#.\u06f9 &%2\x9e""6\x9e7\x9f/\x8c#;Z/\x83$28""6879/t$;Z/k$28""6879/\\$;Z/S$28""6879/D$;Z/;$28""6879/,$;[/#$+*)(*\'#()\'#((\'#(\'\'#(&\'#(%\'#($\'#(#\'#("\'#&\'#.\u067a &%2\x9e""6\x9e7\x9f/t#;Z/k$28""6879/\\$;Z/S$28""6879/D$;Z/;$28""6879/,$;[/#$+()((\'#(\'\'#(&\'#(%\'#($\'#(#\'#("\'#&\'#.\u0613 &%2\x9e""6\x9e7\x9f/\\#;Z/S$28""6879/D$;Z/;$28""6879/,$;[/#$+&)(&\'#(%\'#($\'#(#\'#("\'#&\'#.\u05c4 &%2\x9e""6\x9e7\x9f/D#;Z/;$28""6879/,$;[/#$+$)($\'#(#\'#("\'#&\'#.\u058d &%2\x9e""6\x9e7\x9f/,#;[/#$+")("\'#&\'#.\u056e &%2\x9e""6\x9e7\x9f/,#;Z/#$+")("\'#&\'#.\u054f &%;Z/\x9b#2\x9e""6\x9e7\x9f/\x8c$;Z/\x83$28""6879/t$;Z/k$28""6879/\\$;Z/S$28""6879/D$;Z/;$28""6879/,$;[/#$++)(+\'#(*\'#()\'#((\'#(\'\'#(&\'#(%\'#($\'#(#\'#("\'#&\'#.\u04c7 &%;Z/\xaa#%28""6879/,#;Z/#$+")("\'#&\'#." &"/\x83$2\x9e""6\x9e7\x9f/t$;Z/k$28""6879/\\$;Z/S$28""6879/D$;Z/;$28""6879/,$;[/#$+*)(*\'#()\'#((\'#(\'\'#(&\'#(%\'#($\'#(#\'#("\'#&\'#.\u0430 &%;Z/\xb9#%28""6879/,#;Z/#$+")("\'#&\'#." &"/\x92$%28""6879/,#;Z/#$+")("\'#&\'#." &"/k$2\x9e""6\x9e7\x9f/\\$;Z/S$28""6879/D$;Z/;$28""6879/,$;[/#$+))()\'#((\'#(\'\'#(&\'#(%\'#($\'#(#\'#("\'#&\'#.\u038a &%;Z/\xc8#%28""6879/,#;Z/#$+")("\'#&\'#." &"/\xa1$%28""6879/,#;Z/#$+")("\'#&\'#." &"/z$%28""6879/,#;Z/#$+")("\'#&\'#." &"/S$2\x9e""6\x9e7\x9f/D$;Z/;$28""6879/,$;[/#$+()((\'#(\'\'#(&\'#(%\'#($\'#(#\'#("\'#&\'#.\u02d5 &%;Z/\xd7#%28""6879/,#;Z/#$+")("\'#&\'#." &"/\xb0$%28""6879/,#;Z/#$+")("\'#&\'#." &"/\x89$%28""6879/,#;Z/#$+")("\'#&\'#." &"/b$%28""6879/,#;Z/#$+")("\'#&\'#." &"/;$2\x9e""6\x9e7\x9f/,$;[/#$+\')(\'\'#(&\'#(%\'#($\'#(#\'#("\'#&\'#.\u0211 &%;Z/\xfe#%28""6879/,#;Z/#$+")("\'#&\'#." &"/\xd7$%28""6879/,#;Z/#$+")("\'#&\'#." &"/\xb0$%28""6879/,#;Z/#$+")("\'#&\'#." &"/\x89$%28""6879/,#;Z/#$+")("\'#&\'#." &"/b$%28""6879/,#;Z/#$+")("\'#&\'#." &"/;$2\x9e""6\x9e7\x9f/,$;Z/#$+()((\'#(\'\'#(&\'#(%\'#($\'#(#\'#("\'#&\'#.\u0126 &%;Z/\u011c#%28""6879/,#;Z/#$+")("\'#&\'#." &"/\xf5$%28""6879/,#;Z/#$+")("\'#&\'#." &"/\xce$%28""6879/,#;Z/#$+")("\'#&\'#." &"/\xa7$%28""6879/,#;Z/#$+")("\'#&\'#." &"/\x80$%28""6879/,#;Z/#$+")("\'#&\'#." &"/Y$%28""6879/,#;Z/#$+")("\'#&\'#." &"/2$2\x9e""6\x9e7\x9f/#$+()((\'#(\'\'#(&\'#(%\'#($\'#(#\'#("\'#&\'#/& 8!:\xa0! )'),I('%;#/M#;#." &"/?$;#." &"/1$;#." &"/#$+$)($\'#(#\'#("\'#&\'#'),I("%;Z/;#28\"\"6879/,$;Z/#$+#)(#'#(\"'#&'#.# &;\\"),I("%;]/o#2J\"\"6J7K/`$;]/W$2J\"\"6J7K/H$;]/?$2J\"\"6J7K/0$;]/'$8':\xa1' )(''#(&'#(%'#($'#(#'#(\"'#&'#"),I('%2\xa2""6\xa27\xa3/2#4\xa4""5!7\xa5/#$+")("\'#&\'#.\x98 &%2\xa6""6\xa67\xa7/;#4\xa8""5!7\xa9/,$;!/#$+#)(#\'#("\'#&\'#.j &%2\xaa""6\xaa7\xab/5#;!/,$;!/#$+#)(#\'#("\'#&\'#.B &%4\xac""5!7\xad/,#;!/#$+")("\'#&\'#.# &;!'),I('%%;!." &"/[#;!." &"/M$;!." &"/?$;!." &"/1$;!." &"/#$+%)(%\'#($\'#(#\'#("\'#&\'#/\' 8!:\xae!! )'),I('$%22""6273/,#;`/#$+")("\'#&\'#0<*%22""6273/,#;`/#$+")("\'#&\'#&'),I(";a.A &;b.; &;c.5 &;d./ &;e.) &;f.# &;g"),I('%3\xaf""5*7\xb0/a#3\xb1""5#7\xb2.G &3\xb3""5#7\xb4.; &3\xb5""5$7\xb6./ &3\xb7""5#7\xb8.# &;6/($8":\xb9"! )("\'#&\'#'),I('%3\xba""5%7\xbb/I#3\xbc""5%7\xbd./ &3\xbe""5"7\xbf.# &;6/($8":\xc0"! )("\'#&\'#'),I('%3\xc1""5\'7\xc2/1#;\x90/($8":\xc3"! )("\'#&\'#'),I('%3\xc4""5$7\xc5/1#;\xf0/($8":\xc6"! )("\'#&\'#'),I('%3\xc7""5&7\xc8/1#;T/($8":\xc9"! )("\'#&\'#'),I('%3\xca""5"7\xcb/N#%2>""6>7?/,#;6/#$+")("\'#&\'#." &"/\'$8":\xcc" )("\'#&\'#'),I('%;h/P#%2>""6>7?/,#;i/#$+")("\'#&\'#." &"/)$8":\xcd""! )("\'#&\'#'),I('%$;j/�#*;j&&&#/"!&,)'),I('%$;j/�#*;j&&&#/"!&,)'),I(";k.) &;+.# &;-"),I('2l""6l7m.e &2n""6n7o.Y &24""6475.M &28""6879.A &2<""6<7=.5 &2@""6@7A.) &2B""6B7C'),I('%26""6677/n#;m/e$$%2<""6<7=/,#;m/#$+")("\'#&\'#0<*%2<""6<7=/,#;m/#$+")("\'#&\'#&/#$+#)(#\'#("\'#&\'#'),I('%;n/A#2>""6>7?/2$;o/)$8#:\xce#"" )(#\'#("\'#&\'#'),I("$;p.) &;+.# &;-/2#0/*;p.) &;+.# &;-&&&#"),I("$;p.) &;+.# &;-0/*;p.) &;+.# &;-&"),I('2l""6l7m.e &2n""6n7o.Y &24""6475.M &26""6677.A &28""6879.5 &2@""6@7A.) &2B""6B7C'),I(";\x91.# &;r"),I("%;\x90/G#;'/>$;s/5$;'/,$;\x84/#$+%)(%'#($'#(#'#(\"'#&'#"),I(";M.# &;t"),I("%;\x7f/E#28\"\"6879/6$;u.# &;x/'$8#:\xcf# )(#'#(\"'#&'#"),I('%;v.# &;w/J#%26""6677/,#;\x83/#$+")("\'#&\'#." &"/#$+")("\'#&\'#'),I('%2\xd0""6\xd07\xd1/:#;\x80/1$;w." &"/#$+#)(#\'#("\'#&\'#'),I('%24""6475/,#;{/#$+")("\'#&\'#'),I("%;z/3#$;y0#*;y&/#$+\")(\"'#&'#"),I(";*.) &;+.# &;-"),I(';+.\x8f &;-.\x89 &22""6273.} &26""6677.q &28""6879.e &2:""6:7;.Y &2<""6<7=.M &2>""6>7?.A &2@""6@7A.5 &2B""6B7C.) &2D""6D7E'),I('%;|/e#$%24""6475/,#;|/#$+")("\'#&\'#0<*%24""6475/,#;|/#$+")("\'#&\'#&/#$+")("\'#&\'#'),I('%$;~0#*;~&/e#$%22""6273/,#;}/#$+")("\'#&\'#0<*%22""6273/,#;}/#$+")("\'#&\'#&/#$+")("\'#&\'#'),I("$;~0#*;~&"),I(';+.w &;-.q &28""6879.e &2:""6:7;.Y &2<""6<7=.M &2>""6>7?.A &2@""6@7A.5 &2B""6B7C.) &2D""6D7E'),I('%%;"/\x87#$;".G &;!.A &2@""6@7A.5 &2F""6F7G.) &2J""6J7K0M*;".G &;!.A &2@""6@7A.5 &2F""6F7G.) &2J""6J7K&/#$+")("\'#&\'#/& 8!:\xd2! )'),I(";\x81.# &;\x82"),I('%%;O/2#2:""6:7;/#$+")("\'#&\'#." &"/,#;S/#$+")("\'#&\'#." &"'),I('$;+.\x83 &;-.} &2B""6B7C.q &2D""6D7E.e &22""6273.Y &28""6879.M &2:""6:7;.A &2<""6<7=.5 &2>""6>7?.) &2@""6@7A/\x8c#0\x89*;+.\x83 &;-.} &2B""6B7C.q &2D""6D7E.e &22""6273.Y &28""6879.M &2:""6:7;.A &2<""6<7=.5 &2>""6>7?.) &2@""6@7A&&&#'),I("$;y0#*;y&"),I('%3\x92""5#7\xd3/q#24""6475/b$$;!/�#*;!&&&#/L$2J""6J7K/=$$;!/�#*;!&&&#/\'$8%:\xd4% )(%\'#($\'#(#\'#("\'#&\'#'),I('2\xd5""6\xd57\xd6'),I('2\xd7""6\xd77\xd8'),I('2\xd9""6\xd97\xda'),I('2\xdb""6\xdb7\xdc'),I('2\xdd""6\xdd7\xde'),I('2\xdf""6\xdf7\xe0'),I('2\xe1""6\xe17\xe2'),I('2\xe3""6\xe37\xe4'),I('2\xe5""6\xe57\xe6'),I('2\xe7""6\xe77\xe8'),I('2\xe9""6\xe97\xea'),I("%;\x85.Y &;\x86.S &;\x88.M &;\x89.G &;\x8a.A &;\x8b.; &;\x8c.5 &;\x8f./ &;\x8d.) &;\x8e.# &;6/& 8!:\xeb! )"),I("%;\x84/G#;'/>$;\x92/5$;'/,$;\x94/#$+%)(%'#($'#(#'#(\"'#&'#"),I("%;\x93/' 8!:\xec!! )"),I("%;!/5#;!/,$;!/#$+#)(#'#(\"'#&'#"),I("%$;*.A &;+.; &;-.5 &;3./ &;4.) &;'.# &;(0G*;*.A &;+.; &;-.5 &;3./ &;4.) &;'.# &;(&/& 8!:\xed! )"),I("%;\xb6/Y#$%;A/,#;\xb6/#$+\")(\"'#&'#06*%;A/,#;\xb6/#$+\")(\"'#&'#&/#$+\")(\"'#&'#"),I('%;9/N#%2:""6:7;/,#;9/#$+")("\'#&\'#." &"/\'$8":\xee" )("\'#&\'#'),I("%;:.c &%;\x98/Y#$%;A/,#;\x98/#$+\")(\"'#&'#06*%;A/,#;\x98/#$+\")(\"'#&'#&/#$+\")(\"'#&'#/& 8!:\xef! )"),I("%;L.# &;\x99/]#$%;B/,#;\x9b/#$+\")(\"'#&'#06*%;B/,#;\x9b/#$+\")(\"'#&'#&/'$8\":\xf0\" )(\"'#&'#"),I("%;\x9a.\" &\"/>#;@/5$;M/,$;?/#$+$)($'#(#'#(\"'#&'#"),I("%%;6/Y#$%;./,#;6/#$+\")(\"'#&'#06*%;./,#;6/#$+\")(\"'#&'#&/#$+\")(\"'#&'#.# &;H/' 8!:\xf1!! )"),I(";\x9c.) &;\x9d.# &;\xa0"),I("%3\xf2\"\"5!7\xf3/:#;$;\xcf/5$;./,$;\x90/#$+%)(%'#($'#(#'#(\"'#&'#"),I("%$;!/�#*;!&&&#/' 8!:\u014b!! )"),I("%;\xd1/]#$%;A/,#;\xd1/#$+\")(\"'#&'#06*%;A/,#;\xd1/#$+\")(\"'#&'#&/'$8\":\u014c\" )(\"'#&'#"),I("%;\x99/]#$%;B/,#;\xa0/#$+\")(\"'#&'#06*%;B/,#;\xa0/#$+\")(\"'#&'#&/'$8\":\u014d\" )(\"'#&'#"),I('%;L.O &;\x99.I &%;@." &"/:#;t/1$;?." &"/#$+#)(#\'#("\'#&\'#/]#$%;B/,#;\xa0/#$+")("\'#&\'#06*%;B/,#;\xa0/#$+")("\'#&\'#&/\'$8":\u014e" )("\'#&\'#'),I("%;\xd4/]#$%;B/,#;\xd5/#$+\")(\"'#&'#06*%;B/,#;\xd5/#$+\")(\"'#&'#&/'$8\":\u014f\" )(\"'#&'#"),I("%;\x96/& 8!:\u0150! )"),I('%3\u0151""5(7\u0152/:#;$;6/5$;;/,$;\xec/#$+%)(%'#($'#(#'#(\"'#&'#"),I('%3\x92""5#7\xd3.# &;6/\' 8!:\u018b!! )'),I('%3\xb1""5#7\u018c.G &3\xb3""5#7\u018d.; &3\xb7""5#7\u018e./ &3\xb5""5$7\u018f.# &;6/\' 8!:\u0190!! )'),I('%;\xee/D#%;C/,#;\xef/#$+")("\'#&\'#." &"/#$+")("\'#&\'#'),I("%;U.) &;\\.# &;X/& 8!:\u0191! )"),I('%%;!." &"/[#;!." &"/M$;!." &"/?$;!." &"/1$;!." &"/#$+%)(%\'#($\'#(#\'#("\'#&\'#/\' 8!:\u0192!! )'),I('%%;!/?#;!." &"/1$;!." &"/#$+#)(#\'#("\'#&\'#/\' 8!:\u0193!! )'),I(";\xbe"),I('%;\x9e/^#$%;B/,#;\xf3/#$+")("\'#&\'#06*%;B/,#;\xf3/#$+")("\'#&\'#&/($8":\u0194"!!)("\'#&\'#'),I(";\xf4.# &;\xa0"),I('%2\u0195""6\u01957\u0196/L#;""6>7?'),I('%;\u0100/b#28""6879/S$;\xfb/J$%2\u01a3""6\u01a37\u01a4/,#;\xec/#$+")("\'#&\'#." &"/#$+$)($\'#(#\'#("\'#&\'#'),I('%3\u01a5""5%7\u01a6.) &3\u01a7""5$7\u01a8/\' 8!:\u01a1!! )'),I('%3\xb1""5#7\xb2.6 &3\xb3""5#7\xb4.* &$;+0#*;+&/\' 8!:\u01a9!! )'),I("%;\u0104/\x87#2F\"\"6F7G/x$;\u0103/o$2F\"\"6F7G/`$;\u0103/W$2F\"\"6F7G/H$;\u0103/?$2F\"\"6F7G/0$;\u0105/'$8):\u01aa) )()'#(('#(''#(&'#(%'#($'#(#'#(\"'#&'#"),I("%;#/>#;#/5$;#/,$;#/#$+$)($'#(#'#(\"'#&'#"),I("%;\u0103/,#;\u0103/#$+\")(\"'#&'#"),I("%;\u0103/5#;\u0103/,$;\u0103/#$+#)(#'#(\"'#&'#"),I("%;q/T#$;m0#*;m&/D$%; /,#;\xf8/#$+\")(\"'#&'#.\" &\"/#$+#)(#'#(\"'#&'#"),I('%2\u01ab""6\u01ab7\u01ac.) &2\u01ad""6\u01ad7\u01ae/w#;0/n$;\u0108/e$$%;B/2#;\u0109.# &;\xa0/#$+")("\'#&\'#0<*%;B/2#;\u0109.# &;\xa0/#$+")("\'#&\'#&/#$+$)($\'#(#\'#("\'#&\'#'),I(";\x99.# &;L"),I("%2\u01af\"\"6\u01af7\u01b0/5#;g&&(g=c,u=[]),u.push(e))}function $(e,t,s){return new S(S.buildMessage(e,t),e,t,s)}function I(e){return e.split("").map((e=>e.charCodeAt(0)-32))}if(t.data={},l=function t(i){const r=a[i];let n=0;const d=[];let l=r.length;const g=[],u=[];let f;for(;;){for(;nc?(l=n+3+r[n+1],n+=3):(l=n+3+r[n+1]+r[n+2],n+=3+r[n+1]);break;case 18:g.push(l),d.push(n+4+r[n+2]+r[n+3]),e.substr(c,o[r[n+1]].length)===o[r[n+1]]?(l=n+4+r[n+2],n+=4):(l=n+4+r[n+2]+r[n+3],n+=4+r[n+2]);break;case 19:g.push(l),d.push(n+4+r[n+2]+r[n+3]),e.substr(c,o[r[n+1]].length).toLowerCase()===o[r[n+1]]?(l=n+4+r[n+2],n+=4):(l=n+4+r[n+2]+r[n+3],n+=4+r[n+2]);break;case 20:g.push(l),d.push(n+4+r[n+2]+r[n+3]),o[r[n+1]].test(e.charAt(c))?(l=n+4+r[n+2],n+=4):(l=n+4+r[n+2]+r[n+3],n+=4+r[n+2]);break;case 21:u.push(e.substr(c,r[n+1])),c+=r[n+1],n+=2;break;case 22:u.push(o[r[n+1]]),c+=o[r[n+1]].length,n+=2;break;case 23:u.push(s),0===p&&E(o[r[n+1]]),n+=2;break;case 24:h=u[u.length-1-r[n+1]],n+=2;break;case 25:h=c,n++;break;case 26:f=r.slice(n+4,n+4+r[n+3]).map((function(e){return u[u.length-1-e]})),u.splice(u.length-r[n+2],r[n+2],o[r[n+1]].apply(null,f)),n+=4+r[n+3];break;case 27:u.push(t(r[n+1])),n+=2;break;case 28:p++,n++;break;case 29:p--,n++;break;default:throw new Error("Invalid opcode: "+r[n]+".")}if(!(g.length>0))break;l=g.pop(),n=d.pop()}return u[0]}(n),l!==s&&c===e.length)return l;throw l!==s&&c=this.headers[e].length)return;const s=this.headers[e][t],i=s.raw;if(s.parsed)return s.parsed;const r=y.parse(i,e.replace(/-/g,"_"));return-1===r?void this.headers[e].splice(t,1):(s.parsed=r,r)}s(e,t=0){return this.parseHeader(e,t)}setHeader(e,t){this.headers[A(e)]=[{raw:t}]}toString(){return this.data}}class P extends H{constructor(){super()}}class x extends H{constructor(){super()}}class k{constructor(e,t,s,i,r,n,o){this.headers={},this.extraHeaders=[],this.options=k.getDefaultOptions(),r&&(this.options=Object.assign(Object.assign({},this.options),r),this.options.optionTags&&this.options.optionTags.length&&(this.options.optionTags=this.options.optionTags.slice()),this.options.routeSet&&this.options.routeSet.length&&(this.options.routeSet=this.options.routeSet.slice())),n&&n.length&&(this.extraHeaders=n.slice()),o&&(this.body={body:o.content,contentType:o.contentType}),this.method=e,this.ruri=t.clone(),this.fromURI=s.clone(),this.fromTag=this.options.fromTag?this.options.fromTag:C(),this.from=k.makeNameAddrHeader(this.fromURI,this.options.fromDisplayName,this.fromTag),this.toURI=i.clone(),this.toTag=this.options.toTag,this.to=k.makeNameAddrHeader(this.toURI,this.options.toDisplayName,this.toTag),this.callId=this.options.callId?this.options.callId:this.options.callIdPrefix+$(15),this.cseq=this.options.cseq,this.setHeader("route",this.options.routeSet),this.setHeader("via",""),this.setHeader("to",this.to.toString()),this.setHeader("from",this.from.toString()),this.setHeader("cseq",this.cseq+" "+this.method),this.setHeader("call-id",this.callId),this.setHeader("max-forwards","70")}static getDefaultOptions(){return{callId:"",callIdPrefix:"",cseq:1,toDisplayName:"",toTag:"",fromDisplayName:"",fromTag:"",forceRport:!1,hackViaTcp:!1,optionTags:["outbound"],routeSet:[],userAgentString:"sip.js",viaHost:""}}static makeNameAddrHeader(e,t,s){const i={};return s&&(i.tag=s),new v(e,t,i)}getHeader(e){const t=this.headers[A(e)];if(t){if(t[0])return t[0]}else{const t=new RegExp("^\\s*"+e+"\\s*:","i");for(const e of this.extraHeaders)if(t.test(e))return e.substring(e.indexOf(":")+1).trim()}}getHeaders(e){const t=[],s=this.headers[A(e)];if(s)for(const e of s)t.push(e);else{const s=new RegExp("^\\s*"+e+"\\s*:","i");for(const e of this.extraHeaders)s.test(e)&&t.push(e.substring(e.indexOf(":")+1).trim())}return t}hasHeader(e){if(this.headers[A(e)])return!0;{const t=new RegExp("^\\s*"+e+"\\s*:","i");for(const e of this.extraHeaders)if(t.test(e))return!0}return!1}setHeader(e,t){this.headers[A(e)]=t instanceof Array?t:[t]}setViaHeader(e,t){this.options.hackViaTcp&&(t="TCP");let s="SIP/2.0/"+t;s+=" "+this.options.viaHost+";branch="+e,this.options.forceRport&&(s+=";rport"),this.setHeader("via",s),this.branch=e}toString(){let e="";e+=this.method+" "+this.ruri.toRaw()+" SIP/2.0\r\n";for(const t in this.headers)if(this.headers[t])for(const s of this.headers[t])e+=t+": "+s+"\r\n";for(const t of this.extraHeaders)e+=t.trim()+"\r\n";return e+="Supported: "+this.options.optionTags.join(", ")+"\r\n",e+="User-Agent: "+this.options.userAgentString+"\r\n",this.body?"string"==typeof this.body?(e+="Content-Length: "+D(this.body)+"\r\n\r\n",e+=this.body):this.body.body&&this.body.contentType?(e+="Content-Type: "+this.body.contentType+"\r\n",e+="Content-Length: "+D(this.body.body)+"\r\n\r\n",e+=this.body.body):e+="Content-Length: 0\r\n\r\n":e+="Content-Length: 0\r\n\r\n",e}}function _(e){return"application/sdp"===e?"session":"render"}function q(e){const t="string"==typeof e?e:e.body,s="string"==typeof e?"application/sdp":e.contentType;return{contentDisposition:_(s),contentType:s,content:t}}function M(e){return!(!e||"string"!=typeof e.content||"string"!=typeof e.contentType||void 0!==e.contentDisposition)||"string"==typeof e.contentDisposition}function N(e){let t,s,i;if(e instanceof P&&e.body){const r=e.parseHeader("Content-Disposition");t=r?r.type:void 0,s=e.parseHeader("Content-Type"),i=e.body}if(e instanceof x&&e.body){const r=e.parseHeader("Content-Disposition");t=r?r.type:void 0,s=e.parseHeader("Content-Type"),i=e.body}if(e instanceof k&&e.body)if(t=e.getHeader("Content-Disposition"),s=e.getHeader("Content-Type"),"string"==typeof e.body){if(!s)throw new Error("Header content type header does not equal body content type.");i=e.body}else{if(s&&s!==e.body.contentType)throw new Error("Header content type header does not equal body content type.");s=e.body.contentType,i=e.body.body}if(M(e)&&(t=e.contentDisposition,s=e.contentType,i=e.content),i){if(s&&!t&&(t=_(s)),!t)throw new Error("Content disposition undefined.");if(!s)throw new Error("Content type undefined.");return{contentDisposition:t,contentType:s,content:i}}}var O,U;!function(e){e.Initial="Initial",e.Early="Early",e.AckWait="AckWait",e.Confirmed="Confirmed",e.Terminated="Terminated"}(O=O||(O={})),function(e){e.Initial="Initial",e.HaveLocalOffer="HaveLocalOffer",e.HaveRemoteOffer="HaveRemoteOffer",e.Stable="Stable",e.Closed="Closed"}(U=U||(U={}));const j=500,F=5e3,L={T1:j,T2:4e3,T4:F,TIMER_B:32e3,TIMER_D:0,TIMER_F:32e3,TIMER_H:32e3,TIMER_I:0,TIMER_J:0,TIMER_K:0,TIMER_L:32e3,TIMER_M:32e3,TIMER_N:32e3,PROVISIONAL_RESPONSE_INTERVAL:6e4};class B extends n{constructor(e){super(e||"Transaction state error.")}}var G;!function(e){e.ACK="ACK",e.BYE="BYE",e.CANCEL="CANCEL",e.INFO="INFO",e.INVITE="INVITE",e.MESSAGE="MESSAGE",e.NOTIFY="NOTIFY",e.OPTIONS="OPTIONS",e.REGISTER="REGISTER",e.UPDATE="UPDATE",e.SUBSCRIBE="SUBSCRIBE",e.PUBLISH="PUBLISH",e.REFER="REFER",e.PRACK="PRACK"}(G=G||(G={}));const V=[G.ACK,G.BYE,G.CANCEL,G.INFO,G.INVITE,G.MESSAGE,G.NOTIFY,G.OPTIONS,G.PRACK,G.REFER,G.REGISTER,G.SUBSCRIBE];class W{constructor(e){this.incomingMessageRequest=e}get request(){return this.incomingMessageRequest.message}accept(e){return this.incomingMessageRequest.accept(e),Promise.resolve()}reject(e){return this.incomingMessageRequest.reject(e),Promise.resolve()}}class K{constructor(e){this.incomingNotifyRequest=e}get request(){return this.incomingNotifyRequest.message}accept(e){return this.incomingNotifyRequest.accept(e),Promise.resolve()}reject(e){return this.incomingNotifyRequest.reject(e),Promise.resolve()}}class Y{constructor(e,t){this.incomingReferRequest=e,this.session=t}get referTo(){const e=this.incomingReferRequest.message.parseHeader("refer-to");if(!(e instanceof v))throw new Error("Failed to parse Refer-To header.");return e}get referredBy(){return this.incomingReferRequest.message.getHeader("referred-by")}get replaces(){const e=this.referTo.uri.getHeader("replaces");return e instanceof Array?e[0]:e}get request(){return this.incomingReferRequest.message}accept(e={statusCode:202}){return this.incomingReferRequest.accept(e),Promise.resolve()}reject(e){return this.incomingReferRequest.reject(e),Promise.resolve()}makeInviter(e){if(this.inviter)return this.inviter;const t=this.referTo.uri.clone();t.clearHeaders();const s=((e=e||{}).extraHeaders||[]).slice(),i=this.replaces;i&&s.push("Replaces: "+decodeURIComponent(i));const r=this.referredBy;return r&&s.push("Referred-By: "+r),e.extraHeaders=s,this.inviter=this.session.userAgent._makeInviter(t,e),this.inviter._referred=this.session,this.session._referral=this.inviter,this.inviter}}var Z,J;!function(e){e.Initial="Initial",e.Establishing="Establishing",e.Established="Established",e.Terminating="Terminating",e.Terminated="Terminated"}(Z=Z||(Z={}));class z{constructor(e,t={}){this.pendingReinvite=!1,this.pendingReinviteAck=!1,this._state=Z.Initial,this.delegate=t.delegate,this._stateEventEmitter=new p,this._userAgent=e}dispose(){switch(this.logger.log(`Session ${this.id} in state ${this._state} is being disposed`),delete this.userAgent._sessions[this.id],this._sessionDescriptionHandler&&this._sessionDescriptionHandler.close(),this.state){case Z.Initial:case Z.Establishing:break;case Z.Established:return new Promise((e=>{this._bye({onAccept:()=>e(),onRedirect:()=>e(),onReject:()=>e()})}));case Z.Terminating:case Z.Terminated:break;default:throw new Error("Unknown state.")}return Promise.resolve()}get assertedIdentity(){return this._assertedIdentity}get dialog(){return this._dialog}get id(){return this._id}get replacee(){return this._replacee}get sessionDescriptionHandler(){return this._sessionDescriptionHandler}get sessionDescriptionHandlerFactory(){return this.userAgent.configuration.sessionDescriptionHandlerFactory}get sessionDescriptionHandlerModifiers(){return this._sessionDescriptionHandlerModifiers||[]}set sessionDescriptionHandlerModifiers(e){this._sessionDescriptionHandlerModifiers=e.slice()}get sessionDescriptionHandlerOptions(){return this._sessionDescriptionHandlerOptions||{}}set sessionDescriptionHandlerOptions(e){this._sessionDescriptionHandlerOptions=Object.assign({},e)}get sessionDescriptionHandlerModifiersReInvite(){return this._sessionDescriptionHandlerModifiersReInvite||[]}set sessionDescriptionHandlerModifiersReInvite(e){this._sessionDescriptionHandlerModifiersReInvite=e.slice()}get sessionDescriptionHandlerOptionsReInvite(){return this._sessionDescriptionHandlerOptionsReInvite||{}}set sessionDescriptionHandlerOptionsReInvite(e){this._sessionDescriptionHandlerOptionsReInvite=Object.assign({},e)}get state(){return this._state}get stateChange(){return this._stateEventEmitter}get userAgent(){return this._userAgent}bye(e={}){let t="Session.bye() may only be called if established session.";switch(this.state){case Z.Initial:"function"==typeof this.cancel?(t+=" However Inviter.invite() has not yet been called.",t+=" Perhaps you should have called Inviter.cancel()?"):"function"==typeof this.reject&&(t+=" However Invitation.accept() has not yet been called.",t+=" Perhaps you should have called Invitation.reject()?");break;case Z.Establishing:"function"==typeof this.cancel?(t+=" However a dialog does not yet exist.",t+=" Perhaps you should have called Inviter.cancel()?"):"function"==typeof this.reject&&(t+=" However Invitation.accept() has not yet been called (or not yet resolved).",t+=" Perhaps you should have called Invitation.reject()?");break;case Z.Established:{const t=e.requestDelegate,s=this.copyRequestOptions(e.requestOptions);return this._bye(t,s)}case Z.Terminating:t+=" However this session is already terminating.","function"==typeof this.cancel?t+=" Perhaps you have already called Inviter.cancel()?":"function"==typeof this.reject&&(t+=" Perhaps you have already called Session.bye()?");break;case Z.Terminated:t+=" However this session is already terminated.";break;default:throw new Error("Unknown state")}return this.logger.error(t),Promise.reject(new Error(`Invalid session state ${this.state}`))}info(e={}){if(this.state!==Z.Established){const e="Session.info() may only be called if established session.";return this.logger.error(e),Promise.reject(new Error(`Invalid session state ${this.state}`))}const t=e.requestDelegate,s=this.copyRequestOptions(e.requestOptions);return this._info(t,s)}invite(e={}){if(this.logger.log("Session.invite"),this.state!==Z.Established)return Promise.reject(new Error(`Invalid session state ${this.state}`));if(this.pendingReinvite)return Promise.reject(new a("Reinvite in progress. Please wait until complete, then try again."));this.pendingReinvite=!0,e.sessionDescriptionHandlerModifiers&&(this.sessionDescriptionHandlerModifiersReInvite=e.sessionDescriptionHandlerModifiers),e.sessionDescriptionHandlerOptions&&(this.sessionDescriptionHandlerOptionsReInvite=e.sessionDescriptionHandlerOptions);const t={onAccept:t=>{const s=N(t.message);if(!s)return this.logger.error("Received 2xx response to re-INVITE without a session description"),this.ackAndBye(t,400,"Missing session description"),this.stateTransition(Z.Terminated),void(this.pendingReinvite=!1);if(e.withoutSdp){const i={sessionDescriptionHandlerOptions:this.sessionDescriptionHandlerOptionsReInvite,sessionDescriptionHandlerModifiers:this.sessionDescriptionHandlerModifiersReInvite};this.setOfferAndGetAnswer(s,i).then((e=>{t.ack({body:e})})).catch((e=>{this.logger.error("Failed to handle offer in 2xx response to re-INVITE"),this.logger.error(e.message),this.state===Z.Terminated?t.ack():(this.ackAndBye(t,488,"Bad Media Description"),this.stateTransition(Z.Terminated))})).then((()=>{this.pendingReinvite=!1,e.requestDelegate&&e.requestDelegate.onAccept&&e.requestDelegate.onAccept(t)}))}else{const i={sessionDescriptionHandlerOptions:this.sessionDescriptionHandlerOptionsReInvite,sessionDescriptionHandlerModifiers:this.sessionDescriptionHandlerModifiersReInvite};this.setAnswer(s,i).then((()=>{t.ack()})).catch((e=>{this.logger.error("Failed to handle answer in 2xx response to re-INVITE"),this.logger.error(e.message),this.state!==Z.Terminated?(this.ackAndBye(t,488,"Bad Media Description"),this.stateTransition(Z.Terminated)):t.ack()})).then((()=>{this.pendingReinvite=!1,e.requestDelegate&&e.requestDelegate.onAccept&&e.requestDelegate.onAccept(t)}))}},onProgress:e=>{},onRedirect:e=>{},onReject:t=>{this.logger.warn("Received a non-2xx response to re-INVITE"),this.pendingReinvite=!1,e.withoutSdp?e.requestDelegate&&e.requestDelegate.onReject&&e.requestDelegate.onReject(t):this.rollbackOffer().catch((e=>{if(this.logger.error("Failed to rollback offer on non-2xx response to re-INVITE"),this.logger.error(e.message),this.state!==Z.Terminated){if(!this.dialog)throw new Error("Dialog undefined.");const e=[];e.push("Reason: "+this.getReasonHeaderValue(500,"Internal Server Error")),this.dialog.bye(void 0,{extraHeaders:e}),this.stateTransition(Z.Terminated)}})).then((()=>{e.requestDelegate&&e.requestDelegate.onReject&&e.requestDelegate.onReject(t)}))},onTrying:e=>{}},s=e.requestOptions||{};if(s.extraHeaders=(s.extraHeaders||[]).slice(),s.extraHeaders.push("Allow: "+V.toString()),s.extraHeaders.push("Contact: "+this._contact),e.withoutSdp){if(!this.dialog)throw this.pendingReinvite=!1,new Error("Dialog undefined.");return Promise.resolve(this.dialog.invite(t,s))}const i={sessionDescriptionHandlerOptions:this.sessionDescriptionHandlerOptionsReInvite,sessionDescriptionHandlerModifiers:this.sessionDescriptionHandlerModifiersReInvite};return this.getOffer(i).then((e=>{if(!this.dialog)throw this.pendingReinvite=!1,new Error("Dialog undefined.");return s.body=e,this.dialog.invite(t,s)})).catch((e=>{throw this.logger.error(e.message),this.logger.error("Failed to send re-INVITE"),this.pendingReinvite=!1,e}))}message(e={}){if(this.state!==Z.Established){const e="Session.message() may only be called if established session.";return this.logger.error(e),Promise.reject(new Error(`Invalid session state ${this.state}`))}const t=e.requestDelegate,s=this.copyRequestOptions(e.requestOptions);return this._message(t,s)}refer(e,t={}){if(this.state!==Z.Established){const e="Session.refer() may only be called if established session.";return this.logger.error(e),Promise.reject(new Error(`Invalid session state ${this.state}`))}if(e instanceof z&&!e.dialog){const e="Session.refer() may only be called with session which is established. You are perhaps attempting to attended transfer to a target for which there is not dialog yet established. Perhaps you are attempting a 'semi-attended' tansfer? Regardless, this is not supported. The recommended approached is to check to see if the target Session is in the Established state before calling refer(); if the state is not Established you may proceed by falling back using a URI as the target (blind transfer).";return this.logger.error(e),Promise.reject(new Error(`Invalid session state ${this.state}`))}const s=t.requestDelegate,i=this.copyRequestOptions(t.requestOptions);return i.extraHeaders=i.extraHeaders?i.extraHeaders.concat(this.referExtraHeaders(this.referToString(e))):this.referExtraHeaders(this.referToString(e)),this._refer(t.onNotify,s,i)}_bye(e,t){if(!this.dialog)return Promise.reject(new Error("Session dialog undefined."));const s=this.dialog;switch(s.sessionState){case O.Initial:case O.Early:throw new Error(`Invalid dialog state ${s.sessionState}`);case O.AckWait:return this.stateTransition(Z.Terminating),new Promise((i=>{s.delegate={onAck:()=>{const r=s.bye(e,t);return this.stateTransition(Z.Terminated),i(r),Promise.resolve()},onAckTimeout:()=>{const r=s.bye(e,t);this.stateTransition(Z.Terminated),i(r)}}}));case O.Confirmed:{const i=s.bye(e,t);return this.stateTransition(Z.Terminated),Promise.resolve(i)}case O.Terminated:throw new Error(`Invalid dialog state ${s.sessionState}`);default:throw new Error("Unrecognized state.")}}_info(e,t){return this.dialog?Promise.resolve(this.dialog.info(e,t)):Promise.reject(new Error("Session dialog undefined."))}_message(e,t){return this.dialog?Promise.resolve(this.dialog.message(e,t)):Promise.reject(new Error("Session dialog undefined."))}_refer(e,t,s){return this.dialog?(this.onNotify=e,Promise.resolve(this.dialog.refer(t,s))):Promise.reject(new Error("Session dialog undefined."))}ackAndBye(e,t,s){e.ack();const i=[];t&&i.push("Reason: "+this.getReasonHeaderValue(t,s)),e.session.bye(void 0,{extraHeaders:i})}onAckRequest(e){if(this.logger.log("Session.onAckRequest"),this.state!==Z.Established&&this.state!==Z.Terminating)return this.logger.error(`ACK received while in state ${this.state}, dropping request`),Promise.resolve();const t=this.dialog;if(!t)throw new Error("Dialog undefined.");const s={sessionDescriptionHandlerOptions:this.pendingReinviteAck?this.sessionDescriptionHandlerOptionsReInvite:this.sessionDescriptionHandlerOptions,sessionDescriptionHandlerModifiers:this.pendingReinviteAck?this._sessionDescriptionHandlerModifiersReInvite:this._sessionDescriptionHandlerModifiers};if(this.delegate&&this.delegate.onAck){const t=new l(e);this.delegate.onAck(t)}switch(this.pendingReinviteAck=!1,t.signalingState){case U.Initial:{this.logger.error(`Invalid signaling state ${t.signalingState}.`);const e=["Reason: "+this.getReasonHeaderValue(488,"Bad Media Description")];return t.bye(void 0,{extraHeaders:e}),this.stateTransition(Z.Terminated),Promise.resolve()}case U.Stable:{const i=N(e.message);return i?"render"===i.contentDisposition?(this._renderbody=i.content,this._rendertype=i.contentType,Promise.resolve()):"session"!==i.contentDisposition?Promise.resolve():this.setAnswer(i,s).catch((e=>{this.logger.error(e.message);const s=["Reason: "+this.getReasonHeaderValue(488,"Bad Media Description")];t.bye(void 0,{extraHeaders:s}),this.stateTransition(Z.Terminated)})):Promise.resolve()}case U.HaveLocalOffer:{this.logger.error(`Invalid signaling state ${t.signalingState}.`);const e=["Reason: "+this.getReasonHeaderValue(488,"Bad Media Description")];return t.bye(void 0,{extraHeaders:e}),this.stateTransition(Z.Terminated),Promise.resolve()}case U.HaveRemoteOffer:{this.logger.error(`Invalid signaling state ${t.signalingState}.`);const e=["Reason: "+this.getReasonHeaderValue(488,"Bad Media Description")];return t.bye(void 0,{extraHeaders:e}),this.stateTransition(Z.Terminated),Promise.resolve()}case U.Closed:default:throw new Error(`Invalid signaling state ${t.signalingState}.`)}}onByeRequest(e){if(this.logger.log("Session.onByeRequest"),this.state===Z.Established){if(this.delegate&&this.delegate.onBye){const t=new g(e);this.delegate.onBye(t)}else e.accept();this.stateTransition(Z.Terminated)}else this.logger.error(`BYE received while in state ${this.state}, dropping request`)}onInfoRequest(e){if(this.logger.log("Session.onInfoRequest"),this.state===Z.Established)if(this.delegate&&this.delegate.onInfo){const t=new f(e);this.delegate.onInfo(t)}else e.accept();else this.logger.error(`INFO received while in state ${this.state}, dropping request`)}onInviteRequest(e){if(this.logger.log("Session.onInviteRequest"),this.state!==Z.Established)return void this.logger.error(`INVITE received while in state ${this.state}, dropping request`);this.pendingReinviteAck=!0;const t=["Contact: "+this._contact];if(e.message.hasHeader("P-Asserted-Identity")){const t=e.message.getHeader("P-Asserted-Identity");if(!t)throw new Error("Header undefined.");this._assertedIdentity=y.nameAddrHeaderParse(t)}const s={sessionDescriptionHandlerOptions:this.sessionDescriptionHandlerOptionsReInvite,sessionDescriptionHandlerModifiers:this.sessionDescriptionHandlerModifiersReInvite};this.generateResponseOfferAnswerInDialog(s).then((s=>{const i=e.accept({statusCode:200,extraHeaders:t,body:s});this.delegate&&this.delegate.onInvite&&this.delegate.onInvite(e.message,i.message,200)})).catch((t=>{if(this.logger.error(t.message),this.logger.error("Failed to handle to re-INVITE request"),!this.dialog)throw new Error("Dialog undefined.");if(this.logger.error(this.dialog.signalingState),this.dialog.signalingState!==U.Stable)this.rollbackOffer().then((()=>{const t=e.reject({statusCode:488});this.delegate&&this.delegate.onInvite&&this.delegate.onInvite(e.message,t.message,488)})).catch((t=>{this.logger.error(t.message),this.logger.error("Failed to rollback offer on re-INVITE request");const s=e.reject({statusCode:488});if(this.state!==Z.Terminated){if(!this.dialog)throw new Error("Dialog undefined.");const e=[];e.push("Reason: "+this.getReasonHeaderValue(500,"Internal Server Error")),this.dialog.bye(void 0,{extraHeaders:e}),this.stateTransition(Z.Terminated)}this.delegate&&this.delegate.onInvite&&this.delegate.onInvite(e.message,s.message,488)}));else{const t=e.reject({statusCode:488});this.delegate&&this.delegate.onInvite&&this.delegate.onInvite(e.message,t.message,488)}}))}onMessageRequest(e){if(this.logger.log("Session.onMessageRequest"),this.state===Z.Established)if(this.delegate&&this.delegate.onMessage){const t=new W(e);this.delegate.onMessage(t)}else e.accept();else this.logger.error(`MESSAGE received while in state ${this.state}, dropping request`)}onNotifyRequest(e){if(this.logger.log("Session.onNotifyRequest"),this.state===Z.Established)if(this.onNotify){const t=new K(e);this.onNotify(t)}else if(this.delegate&&this.delegate.onNotify){const t=new K(e);this.delegate.onNotify(t)}else e.accept();else this.logger.error(`NOTIFY received while in state ${this.state}, dropping request`)}onPrackRequest(e){if(this.logger.log("Session.onPrackRequest"),this.state===Z.Established)throw new Error("Unimplemented.");this.logger.error(`PRACK received while in state ${this.state}, dropping request`)}onReferRequest(e){if(this.logger.log("Session.onReferRequest"),this.state!==Z.Established)return void this.logger.error(`REFER received while in state ${this.state}, dropping request`);if(!e.message.hasHeader("refer-to"))return this.logger.warn("Invalid REFER packet. A refer-to header is required. Rejecting."),void e.reject();const t=new Y(e,this);this.delegate&&this.delegate.onRefer?this.delegate.onRefer(t):(this.logger.log("No delegate available to handle REFER, automatically accepting and following."),t.accept().then((()=>t.makeInviter(this._referralInviterOptions).invite())).catch((e=>{this.logger.error(e.message)})))}generateResponseOfferAnswer(e,t){if(this.dialog)return this.generateResponseOfferAnswerInDialog(t);const s=N(e.message);return s&&"session"===s.contentDisposition?this.setOfferAndGetAnswer(s,t):this.getOffer(t)}generateResponseOfferAnswerInDialog(e){if(!this.dialog)throw new Error("Dialog undefined.");switch(this.dialog.signalingState){case U.Initial:return this.getOffer(e);case U.HaveLocalOffer:return Promise.resolve(void 0);case U.HaveRemoteOffer:if(!this.dialog.offer)throw new Error(`Session offer undefined in signaling state ${this.dialog.signalingState}.`);return this.setOfferAndGetAnswer(this.dialog.offer,e);case U.Stable:return this.state!==Z.Established?Promise.resolve(void 0):this.getOffer(e);case U.Closed:default:throw new Error(`Invalid signaling state ${this.dialog.signalingState}.`)}}getOffer(e){const t=this.setupSessionDescriptionHandler(),s=e.sessionDescriptionHandlerOptions,i=e.sessionDescriptionHandlerModifiers;try{return t.getDescription(s,i).then((e=>q(e))).catch((e=>{this.logger.error("Session.getOffer: SDH getDescription rejected...");const t=e instanceof Error?e:new Error("Session.getOffer unknown error.");throw this.logger.error(t.message),t}))}catch(e){this.logger.error("Session.getOffer: SDH getDescription threw...");const t=e instanceof Error?e:new Error(e);return this.logger.error(t.message),Promise.reject(t)}}rollbackOffer(){const e=this.setupSessionDescriptionHandler();if(void 0===e.rollbackDescription)return Promise.resolve();try{return e.rollbackDescription().catch((e=>{this.logger.error("Session.rollbackOffer: SDH rollbackDescription rejected...");const t=e instanceof Error?e:new Error("Session.rollbackOffer unknown error.");throw this.logger.error(t.message),t}))}catch(e){this.logger.error("Session.rollbackOffer: SDH rollbackDescription threw...");const t=e instanceof Error?e:new Error(e);return this.logger.error(t.message),Promise.reject(t)}}setAnswer(e,t){const s=this.setupSessionDescriptionHandler(),i=t.sessionDescriptionHandlerOptions,r=t.sessionDescriptionHandlerModifiers;try{if(!s.hasDescription(e.contentType))return Promise.reject(new o)}catch(e){this.logger.error("Session.setAnswer: SDH hasDescription threw...");const t=e instanceof Error?e:new Error(e);return this.logger.error(t.message),Promise.reject(t)}try{return s.setDescription(e.content,i,r).catch((e=>{this.logger.error("Session.setAnswer: SDH setDescription rejected...");const t=e instanceof Error?e:new Error("Session.setAnswer unknown error.");throw this.logger.error(t.message),t}))}catch(e){this.logger.error("Session.setAnswer: SDH setDescription threw...");const t=e instanceof Error?e:new Error(e);return this.logger.error(t.message),Promise.reject(t)}}setOfferAndGetAnswer(e,t){const s=this.setupSessionDescriptionHandler(),i=t.sessionDescriptionHandlerOptions,r=t.sessionDescriptionHandlerModifiers;try{if(!s.hasDescription(e.contentType))return Promise.reject(new o)}catch(e){this.logger.error("Session.setOfferAndGetAnswer: SDH hasDescription threw...");const t=e instanceof Error?e:new Error(e);return this.logger.error(t.message),Promise.reject(t)}try{return s.setDescription(e.content,i,r).then((()=>s.getDescription(i,r))).then((e=>q(e))).catch((e=>{this.logger.error("Session.setOfferAndGetAnswer: SDH setDescription or getDescription rejected...");const t=e instanceof Error?e:new Error("Session.setOfferAndGetAnswer unknown error.");throw this.logger.error(t.message),t}))}catch(e){this.logger.error("Session.setOfferAndGetAnswer: SDH setDescription or getDescription threw...");const t=e instanceof Error?e:new Error(e);return this.logger.error(t.message),Promise.reject(t)}}setSessionDescriptionHandler(e){if(this._sessionDescriptionHandler)throw new Error("Session description handler defined.");this._sessionDescriptionHandler=e}setupSessionDescriptionHandler(){var e;return this._sessionDescriptionHandler||(this._sessionDescriptionHandler=this.sessionDescriptionHandlerFactory(this,this.userAgent.configuration.sessionDescriptionHandlerFactoryOptions),(null===(e=this.delegate)||void 0===e?void 0:e.onSessionDescriptionHandler)&&this.delegate.onSessionDescriptionHandler(this._sessionDescriptionHandler,!1)),this._sessionDescriptionHandler}stateTransition(e){const t=()=>{throw new Error(`Invalid state transition from ${this._state} to ${e}`)};switch(this._state){case Z.Initial:e!==Z.Establishing&&e!==Z.Established&&e!==Z.Terminating&&e!==Z.Terminated&&t();break;case Z.Establishing:e!==Z.Established&&e!==Z.Terminating&&e!==Z.Terminated&&t();break;case Z.Established:e!==Z.Terminating&&e!==Z.Terminated&&t();break;case Z.Terminating:e!==Z.Terminated&&t();break;case Z.Terminated:t();break;default:throw new Error("Unrecognized state.")}this._state=e,this.logger.log(`Session ${this.id} transitioned to state ${this._state}`),this._stateEventEmitter.emit(this._state),e===Z.Terminated&&this.dispose()}copyRequestOptions(e={}){return{extraHeaders:e.extraHeaders?e.extraHeaders.slice():void 0,body:e.body?{contentDisposition:e.body.contentDisposition||"render",contentType:e.body.contentType||"text/plain",content:e.body.content||""}:void 0}}getReasonHeaderValue(e,t){const s=e;let i=I(e);return!i&&t&&(i=t),"SIP;cause="+s+';text="'+i+'"'}referExtraHeaders(e){const t=[];return t.push("Referred-By: <"+this.userAgent.configuration.uri+">"),t.push("Contact: "+this._contact),t.push("Allow: "+["ACK","CANCEL","INVITE","MESSAGE","BYE","OPTIONS","INFO","NOTIFY","REFER"].toString()),t.push("Refer-To: "+e),t}referToString(e){let t;if(e instanceof w)t=e.toString();else{if(!e.dialog)throw new Error("Dialog undefined.");const s=e.remoteIdentity.friendlyName,i=e.dialog.remoteTarget.toString(),r=e.dialog.callId,n=e.dialog.remoteTag,o=e.dialog.localTag;t=`"${s}" <${i}?Replaces=${encodeURIComponent(`${r};to-tag=${n};from-tag=${o}`)}>`}return t}}!function(e){e.Required="Required",e.Supported="Supported",e.Unsupported="Unsupported"}(J=J||(J={}));const X={"100rel":!0,199:!0,answermode:!0,"early-session":!0,eventlist:!0,explicitsub:!0,"from-change":!0,"geolocation-http":!0,"geolocation-sip":!0,gin:!0,gruu:!0,histinfo:!0,ice:!0,join:!0,"multiple-refer":!0,norefersub:!0,nosub:!0,outbound:!0,path:!0,policy:!0,precondition:!0,pref:!0,privacy:!0,"recipient-list-invite":!0,"recipient-list-message":!0,"recipient-list-subscribe":!0,replaces:!0,"resource-priority":!0,"sdp-anat":!0,"sec-agree":!0,tdialog:!0,timer:!0,uui:!0};class Q extends z{constructor(e,t){super(e),this.incomingInviteRequest=t,this.disposed=!1,this.expiresTimer=void 0,this.isCanceled=!1,this.rel100="none",this.rseq=Math.floor(1e4*Math.random()),this.userNoAnswerTimer=void 0,this.waitingForPrack=!1,this.logger=e.getLogger("sip.Invitation");const s=this.incomingInviteRequest.message,i=s.getHeader("require");i&&i.toLowerCase().includes("100rel")&&(this.rel100="required");const r=s.getHeader("supported");if(r&&r.toLowerCase().includes("100rel")&&(this.rel100="supported"),s.toTag=t.toTag,"string"!=typeof s.toTag)throw new TypeError("toTag should have been a string.");if(this.userNoAnswerTimer=setTimeout((()=>{t.reject({statusCode:480}),this.stateTransition(Z.Terminated)}),this.userAgent.configuration.noAnswerTimeout?1e3*this.userAgent.configuration.noAnswerTimeout:6e4),s.hasHeader("expires")){const e=1e3*Number(s.getHeader("expires")||0);this.expiresTimer=setTimeout((()=>{this.state===Z.Initial&&(t.reject({statusCode:487}),this.stateTransition(Z.Terminated))}),e)}const n=this.request.getHeader("P-Asserted-Identity");n&&(this._assertedIdentity=y.nameAddrHeaderParse(n)),this._contact=this.userAgent.contact.toString();const o=s.parseHeader("Content-Disposition");o&&"render"===o.type&&(this._renderbody=s.body,this._rendertype=s.getHeader("Content-Type")),this._id=s.callId+s.fromTag,this.userAgent._sessions[this._id]=this}dispose(){if(this.disposed)return Promise.resolve();switch(this.disposed=!0,this.expiresTimer&&(clearTimeout(this.expiresTimer),this.expiresTimer=void 0),this.userNoAnswerTimer&&(clearTimeout(this.userNoAnswerTimer),this.userNoAnswerTimer=void 0),this.prackNeverArrived(),this.state){case Z.Initial:case Z.Establishing:return this.reject().then((()=>super.dispose()));case Z.Established:case Z.Terminating:case Z.Terminated:return super.dispose();default:throw new Error("Unknown state.")}}get autoSendAnInitialProvisionalResponse(){return"required"!==this.rel100&&this.userAgent.configuration.sendInitialProvisionalResponse}get body(){return this.incomingInviteRequest.message.body}get localIdentity(){return this.request.to}get remoteIdentity(){return this.request.from}get request(){return this.incomingInviteRequest.message}accept(e={}){if(this.logger.log("Invitation.accept"),this.state!==Z.Initial){const e=new Error(`Invalid session state ${this.state}`);return this.logger.error(e.message),Promise.reject(e)}return e.sessionDescriptionHandlerModifiers&&(this.sessionDescriptionHandlerModifiers=e.sessionDescriptionHandlerModifiers),e.sessionDescriptionHandlerOptions&&(this.sessionDescriptionHandlerOptions=e.sessionDescriptionHandlerOptions),this.stateTransition(Z.Establishing),this.sendAccept(e).then((({message:e,session:t})=>{t.delegate={onAck:e=>this.onAckRequest(e),onAckTimeout:()=>this.onAckTimeout(),onBye:e=>this.onByeRequest(e),onInfo:e=>this.onInfoRequest(e),onInvite:e=>this.onInviteRequest(e),onMessage:e=>this.onMessageRequest(e),onNotify:e=>this.onNotifyRequest(e),onPrack:e=>this.onPrackRequest(e),onRefer:e=>this.onReferRequest(e)},this._dialog=t,this.stateTransition(Z.Established),this._replacee&&this._replacee._bye()})).catch((e=>this.handleResponseError(e)))}progress(e={}){if(this.logger.log("Invitation.progress"),this.state!==Z.Initial){const e=new Error(`Invalid session state ${this.state}`);return this.logger.error(e.message),Promise.reject(e)}const t=e.statusCode||180;if(t<100||t>199)throw new TypeError("Invalid statusCode: "+t);return e.sessionDescriptionHandlerModifiers&&(this.sessionDescriptionHandlerModifiers=e.sessionDescriptionHandlerModifiers),e.sessionDescriptionHandlerOptions&&(this.sessionDescriptionHandlerOptions=e.sessionDescriptionHandlerOptions),this.waitingForPrack?(this.logger.warn("Unexpected call for progress while waiting for prack, ignoring"),Promise.resolve()):100===e.statusCode?this.sendProgressTrying().then((()=>{})).catch((e=>this.handleResponseError(e))):"required"===this.rel100||"supported"===this.rel100&&e.rel100||"supported"===this.rel100&&this.userAgent.configuration.sipExtension100rel===J.Required?this.sendProgressReliableWaitForPrack(e).then((()=>{})).catch((e=>this.handleResponseError(e))):this.sendProgress(e).then((()=>{})).catch((e=>this.handleResponseError(e)))}reject(e={}){if(this.logger.log("Invitation.reject"),this.state!==Z.Initial&&this.state!==Z.Establishing){const e=new Error(`Invalid session state ${this.state}`);return this.logger.error(e.message),Promise.reject(e)}const t=e.statusCode||480,s=e.reasonPhrase?e.reasonPhrase:I(t),i=e.extraHeaders||[];if(t<300||t>699)throw new TypeError("Invalid statusCode: "+t);const r=e.body?q(e.body):void 0;return t<400?this.incomingInviteRequest.redirect([],{statusCode:t,reasonPhrase:s,extraHeaders:i,body:r}):this.incomingInviteRequest.reject({statusCode:t,reasonPhrase:s,extraHeaders:i,body:r}),this.stateTransition(Z.Terminated),Promise.resolve()}_onCancel(e){if(this.logger.log("Invitation._onCancel"),this.state===Z.Initial||this.state===Z.Establishing){if(this.delegate&&this.delegate.onCancel){const t=new u(e);this.delegate.onCancel(t)}this.isCanceled=!0,this.incomingInviteRequest.reject({statusCode:487}),this.stateTransition(Z.Terminated)}else this.logger.error(`CANCEL received while in state ${this.state}, dropping request`)}handlePrackOfferAnswer(e){if(!this.dialog)throw new Error("Dialog undefined.");const t=N(e.message);if(!t||"session"!==t.contentDisposition)return Promise.resolve(void 0);const s={sessionDescriptionHandlerOptions:this.sessionDescriptionHandlerOptions,sessionDescriptionHandlerModifiers:this.sessionDescriptionHandlerModifiers};switch(this.dialog.signalingState){case U.Initial:throw new Error(`Invalid signaling state ${this.dialog.signalingState}.`);case U.Stable:return this.setAnswer(t,s).then((()=>{}));case U.HaveLocalOffer:throw new Error(`Invalid signaling state ${this.dialog.signalingState}.`);case U.HaveRemoteOffer:return this.setOfferAndGetAnswer(t,s);case U.Closed:default:throw new Error(`Invalid signaling state ${this.dialog.signalingState}.`)}}handleResponseError(e){let t=480;if(e instanceof Error?this.logger.error(e.message):this.logger.error(e),e instanceof o?(this.logger.error("A session description handler occurred while sending response (content type unsupported"),t=415):e instanceof c?this.logger.error("A session description handler occurred while sending response"):e instanceof h?this.logger.error("Session ended before response could be formulated and sent (while waiting for PRACK)"):e instanceof B&&this.logger.error("Session changed state before response could be formulated and sent"),this.state===Z.Initial||this.state===Z.Establishing)try{this.incomingInviteRequest.reject({statusCode:t}),this.stateTransition(Z.Terminated)}catch(e){throw this.logger.error("An error occurred attempting to reject the request while handling another error"),e}if(!this.isCanceled)throw e;this.logger.warn("An error occurred while attempting to formulate and send a response to an incoming INVITE. However a CANCEL was received and processed while doing so which can (and often does) result in errors occurring as the session terminates in the meantime. Said error is being ignored.")}onAckTimeout(){if(this.logger.log("Invitation.onAckTimeout"),!this.dialog)throw new Error("Dialog undefined.");this.logger.log("No ACK received for an extended period of time, terminating session"),this.dialog.bye(),this.stateTransition(Z.Terminated)}sendAccept(e={}){const t={sessionDescriptionHandlerOptions:this.sessionDescriptionHandlerOptions,sessionDescriptionHandlerModifiers:this.sessionDescriptionHandlerModifiers},s=e.extraHeaders||[];return this.waitingForPrack?this.waitForArrivalOfPrack().then((()=>clearTimeout(this.userNoAnswerTimer))).then((()=>this.generateResponseOfferAnswer(this.incomingInviteRequest,t))).then((e=>this.incomingInviteRequest.accept({statusCode:200,body:e,extraHeaders:s}))):(clearTimeout(this.userNoAnswerTimer),this.generateResponseOfferAnswer(this.incomingInviteRequest,t).then((e=>this.incomingInviteRequest.accept({statusCode:200,body:e,extraHeaders:s}))))}sendProgress(e={}){const t=e.statusCode||180,s=e.reasonPhrase,i=(e.extraHeaders||[]).slice(),r=e.body?q(e.body):void 0;if(183===t&&!r)return this.sendProgressWithSDP(e);try{const e=this.incomingInviteRequest.progress({statusCode:t,reasonPhrase:s,extraHeaders:i,body:r});return this._dialog=e.session,Promise.resolve(e)}catch(e){return Promise.reject(e)}}sendProgressWithSDP(e={}){const t={sessionDescriptionHandlerOptions:this.sessionDescriptionHandlerOptions,sessionDescriptionHandlerModifiers:this.sessionDescriptionHandlerModifiers},s=e.statusCode||183,i=e.reasonPhrase,r=(e.extraHeaders||[]).slice();return this.generateResponseOfferAnswer(this.incomingInviteRequest,t).then((e=>this.incomingInviteRequest.progress({statusCode:s,reasonPhrase:i,extraHeaders:r,body:e}))).then((e=>(this._dialog=e.session,e)))}sendProgressReliable(e={}){return e.extraHeaders=(e.extraHeaders||[]).slice(),e.extraHeaders.push("Require: 100rel"),e.extraHeaders.push("RSeq: "+Math.floor(1e4*Math.random())),this.sendProgressWithSDP(e)}sendProgressReliableWaitForPrack(e={}){const t={sessionDescriptionHandlerOptions:this.sessionDescriptionHandlerOptions,sessionDescriptionHandlerModifiers:this.sessionDescriptionHandlerModifiers},s=e.statusCode||183,i=e.reasonPhrase,r=(e.extraHeaders||[]).slice();let n;return r.push("Require: 100rel"),r.push("RSeq: "+this.rseq++),new Promise(((e,o)=>{this.waitingForPrack=!0,this.generateResponseOfferAnswer(this.incomingInviteRequest,t).then((e=>(n=e,this.incomingInviteRequest.progress({statusCode:s,reasonPhrase:i,extraHeaders:r,body:n})))).then((t=>{let a,c;this._dialog=t.session,t.session.delegate={onPrack:s=>{a=s,clearTimeout(d),clearTimeout(u),this.waitingForPrack&&(this.waitingForPrack=!1,this.handlePrackOfferAnswer(a).then((s=>{try{c=a.accept({statusCode:200,body:s}),this.prackArrived(),e({prackRequest:a,prackResponse:c,progressResponse:t})}catch(e){o(e)}})).catch((e=>o(e))))}};const d=setTimeout((()=>{this.waitingForPrack&&(this.waitingForPrack=!1,this.logger.warn("No PRACK received, rejecting INVITE."),clearTimeout(u),this.reject({statusCode:504}).then((()=>o(new h))).catch((e=>o(e))))}),64*L.T1),l=()=>{try{this.incomingInviteRequest.progress({statusCode:s,reasonPhrase:i,extraHeaders:r,body:n})}catch(e){return this.waitingForPrack=!1,void o(e)}u=setTimeout(l,g*=2)};let g=L.T1,u=setTimeout(l,g)})).catch((e=>{this.waitingForPrack=!1,o(e)}))}))}sendProgressTrying(){try{const e=this.incomingInviteRequest.trying();return Promise.resolve(e)}catch(e){return Promise.reject(e)}}waitForArrivalOfPrack(){if(this.waitingForPrackPromise)throw new Error("Already waiting for PRACK");return this.waitingForPrackPromise=new Promise(((e,t)=>{this.waitingForPrackResolve=e,this.waitingForPrackReject=t})),this.waitingForPrackPromise}prackArrived(){this.waitingForPrackResolve&&this.waitingForPrackResolve(),this.waitingForPrackPromise=void 0,this.waitingForPrackResolve=void 0,this.waitingForPrackReject=void 0}prackNeverArrived(){this.waitingForPrackReject&&this.waitingForPrackReject(new h),this.waitingForPrackPromise=void 0,this.waitingForPrackResolve=void 0,this.waitingForPrackReject=void 0}}class ee extends z{constructor(e,t,s={}){super(e,s),this.disposed=!1,this.earlyMedia=!1,this.earlyMediaSessionDescriptionHandlers=new Map,this.isCanceled=!1,this.inviteWithoutSdp=!1,this.logger=e.getLogger("sip.Inviter"),this.earlyMedia=void 0!==s.earlyMedia?s.earlyMedia:this.earlyMedia,this.fromTag=C(),this.inviteWithoutSdp=void 0!==s.inviteWithoutSdp?s.inviteWithoutSdp:this.inviteWithoutSdp;const i=Object.assign({},s);i.params=Object.assign({},s.params);const r=s.anonymous||!1,n=e.contact.toString({anonymous:r,outbound:r?!e.contact.tempGruu:!e.contact.pubGruu});r&&e.configuration.uri&&(i.params.fromDisplayName="Anonymous",i.params.fromUri="sip:anonymous@anonymous.invalid");let o=e.userAgentCore.configuration.aor;if(i.params.fromUri&&(o="string"==typeof i.params.fromUri?y.URIParse(i.params.fromUri):i.params.fromUri),!o)throw new TypeError("Invalid from URI: "+i.params.fromUri);let a=t;if(i.params.toUri&&(a="string"==typeof i.params.toUri?y.URIParse(i.params.toUri):i.params.toUri),!a)throw new TypeError("Invalid to URI: "+i.params.toUri);const c=Object.assign({},i.params);c.fromTag=this.fromTag;const h=(i.extraHeaders||[]).slice();r&&e.configuration.uri&&(h.push("P-Preferred-Identity: "+e.configuration.uri.toString()),h.push("Privacy: id")),h.push("Contact: "+n),h.push("Allow: "+["ACK","CANCEL","INVITE","MESSAGE","BYE","OPTIONS","INFO","NOTIFY","REFER"].toString()),e.configuration.sipExtension100rel===J.Required&&h.push("Require: 100rel"),e.configuration.sipExtensionReplaces===J.Required&&h.push("Require: replaces"),i.extraHeaders=h;this.outgoingRequestMessage=e.userAgentCore.makeOutgoingRequestMessage(G.INVITE,t,o,a,c,h,undefined),this._contact=n,this._referralInviterOptions=i,this._renderbody=s.renderbody,this._rendertype=s.rendertype,s.sessionDescriptionHandlerModifiers&&(this.sessionDescriptionHandlerModifiers=s.sessionDescriptionHandlerModifiers),s.sessionDescriptionHandlerOptions&&(this.sessionDescriptionHandlerOptions=s.sessionDescriptionHandlerOptions),s.sessionDescriptionHandlerModifiersReInvite&&(this.sessionDescriptionHandlerModifiersReInvite=s.sessionDescriptionHandlerModifiersReInvite),s.sessionDescriptionHandlerOptionsReInvite&&(this.sessionDescriptionHandlerOptionsReInvite=s.sessionDescriptionHandlerOptionsReInvite),this._id=this.outgoingRequestMessage.callId+this.fromTag,this.userAgent._sessions[this._id]=this}dispose(){if(this.disposed)return Promise.resolve();switch(this.disposed=!0,this.disposeEarlyMedia(),this.state){case Z.Initial:case Z.Establishing:return this.cancel().then((()=>super.dispose()));case Z.Established:case Z.Terminating:case Z.Terminated:return super.dispose();default:throw new Error("Unknown state.")}}get body(){return this.outgoingRequestMessage.body}get localIdentity(){return this.outgoingRequestMessage.from}get remoteIdentity(){return this.outgoingRequestMessage.to}get request(){return this.outgoingRequestMessage}cancel(e={}){if(this.logger.log("Inviter.cancel"),this.state!==Z.Initial&&this.state!==Z.Establishing){const e=new Error(`Invalid session state ${this.state}`);return this.logger.error(e.message),Promise.reject(e)}if(this.isCanceled=!0,this.stateTransition(Z.Terminating),this.outgoingInviteRequest){let t;e.statusCode&&e.reasonPhrase&&(t=function(e,t){if(e&&e<200||e>699)throw new TypeError("Invalid statusCode: "+e);if(e)return"SIP;cause="+e+';text="'+(I(e)||t)+'"'}(e.statusCode,e.reasonPhrase)),this.outgoingInviteRequest.cancel(t,e)}else this.logger.warn("Canceled session before INVITE was sent"),this.stateTransition(Z.Terminated);return Promise.resolve()}invite(e={}){if(this.logger.log("Inviter.invite"),this.state!==Z.Initial)return super.invite(e);if(e.sessionDescriptionHandlerModifiers&&(this.sessionDescriptionHandlerModifiers=e.sessionDescriptionHandlerModifiers),e.sessionDescriptionHandlerOptions&&(this.sessionDescriptionHandlerOptions=e.sessionDescriptionHandlerOptions),e.withoutSdp||this.inviteWithoutSdp)return this._renderbody&&this._rendertype&&(this.outgoingRequestMessage.body={contentType:this._rendertype,body:this._renderbody}),this.stateTransition(Z.Establishing),Promise.resolve(this.sendInvite(e));const t={sessionDescriptionHandlerModifiers:this.sessionDescriptionHandlerModifiers,sessionDescriptionHandlerOptions:this.sessionDescriptionHandlerOptions};return this.getOffer(t).then((t=>(this.outgoingRequestMessage.body={body:t.content,contentType:t.contentType},this.stateTransition(Z.Establishing),this.sendInvite(e)))).catch((e=>{throw this.logger.log(e.message),this.state!==Z.Terminated&&this.stateTransition(Z.Terminated),e}))}sendInvite(e={}){return this.outgoingInviteRequest=this.userAgent.userAgentCore.invite(this.outgoingRequestMessage,{onAccept:t=>this.dialog?(this.logger.log("Additional confirmed dialog, sending ACK and BYE"),void this.ackAndBye(t)):this.isCanceled?(this.logger.log("Canceled session accepted, sending ACK and BYE"),this.ackAndBye(t),void this.stateTransition(Z.Terminated)):(this.notifyReferer(t),void this.onAccept(t).then((()=>{this.disposeEarlyMedia()})).catch((()=>{this.disposeEarlyMedia()})).then((()=>{e.requestDelegate&&e.requestDelegate.onAccept&&e.requestDelegate.onAccept(t)}))),onProgress:t=>{this.isCanceled||(this.notifyReferer(t),this.onProgress(t).catch((()=>{this.disposeEarlyMedia()})).then((()=>{e.requestDelegate&&e.requestDelegate.onProgress&&e.requestDelegate.onProgress(t)})))},onRedirect:t=>{this.notifyReferer(t),this.onRedirect(t),e.requestDelegate&&e.requestDelegate.onRedirect&&e.requestDelegate.onRedirect(t)},onReject:t=>{this.notifyReferer(t),this.onReject(t),e.requestDelegate&&e.requestDelegate.onReject&&e.requestDelegate.onReject(t)},onTrying:t=>{this.notifyReferer(t),this.onTrying(t),e.requestDelegate&&e.requestDelegate.onTrying&&e.requestDelegate.onTrying(t)}}),this.outgoingInviteRequest}disposeEarlyMedia(){this.earlyMediaSessionDescriptionHandlers.forEach((e=>{e.close()})),this.earlyMediaSessionDescriptionHandlers.clear()}notifyReferer(e){if(!this._referred)return;if(!(this._referred instanceof z))throw new Error("Referred session not instance of session");if(!this._referred.dialog)return;if(!e.message.statusCode)throw new Error("Status code undefined.");if(!e.message.reasonPhrase)throw new Error("Reason phrase undefined.");const t=`SIP/2.0 ${e.message.statusCode} ${e.message.reasonPhrase}`.trim();this._referred.dialog.notify(void 0,{extraHeaders:["Event: refer","Subscription-State: terminated"],body:{contentDisposition:"render",contentType:"message/sipfrag",content:t}}).delegate={onReject:()=>{this._referred=void 0}}}onAccept(e){if(this.logger.log("Inviter.onAccept"),this.state!==Z.Establishing)return this.logger.error(`Accept received while in state ${this.state}, dropping response`),Promise.reject(new Error(`Invalid session state ${this.state}`));const t=e.message,s=e.session;switch(t.hasHeader("P-Asserted-Identity")&&(this._assertedIdentity=y.nameAddrHeaderParse(t.getHeader("P-Asserted-Identity"))),s.delegate={onAck:e=>this.onAckRequest(e),onBye:e=>this.onByeRequest(e),onInfo:e=>this.onInfoRequest(e),onInvite:e=>this.onInviteRequest(e),onMessage:e=>this.onMessageRequest(e),onNotify:e=>this.onNotifyRequest(e),onPrack:e=>this.onPrackRequest(e),onRefer:e=>this.onReferRequest(e)},this._dialog=s,s.signalingState){case U.Initial:case U.HaveLocalOffer:return this.logger.error("Received 2xx response to INVITE without a session description"),this.ackAndBye(e,400,"Missing session description"),this.stateTransition(Z.Terminated),Promise.reject(new Error("Bad Media Description"));case U.HaveRemoteOffer:{if(!this._dialog.offer)throw new Error(`Session offer undefined in signaling state ${this._dialog.signalingState}.`);const t={sessionDescriptionHandlerModifiers:this.sessionDescriptionHandlerModifiers,sessionDescriptionHandlerOptions:this.sessionDescriptionHandlerOptions};return this.setOfferAndGetAnswer(this._dialog.offer,t).then((t=>{e.ack({body:t}),this.stateTransition(Z.Established)})).catch((t=>{throw this.ackAndBye(e,488,"Invalid session description"),this.stateTransition(Z.Terminated),t}))}case U.Stable:{if(this.earlyMediaSessionDescriptionHandlers.size>0){const t=this.earlyMediaSessionDescriptionHandlers.get(s.id);if(!t)throw new Error("Session description handler undefined.");return this.setSessionDescriptionHandler(t),this.earlyMediaSessionDescriptionHandlers.delete(s.id),e.ack(),this.stateTransition(Z.Established),Promise.resolve()}if(this.earlyMediaDialog){if(this.earlyMediaDialog!==s){if(this.earlyMedia){const e="You have set the 'earlyMedia' option to 'true' which requires that your INVITE requests do not fork and yet this INVITE request did in fact fork. Consequentially and not surprisingly the end point which accepted the INVITE (confirmed dialog) does not match the end point with which early media has been setup (early dialog) and thus this session is unable to proceed. In accordance with the SIP specifications, the SIP servers your end point is connected to determine if an INVITE forks and the forking behavior of those servers cannot be controlled by this library. If you wish to use early media with this library you must configure those servers accordingly. Alternatively you may set the 'earlyMedia' to 'false' which will allow this library to function with any INVITE requests which do fork.";this.logger.error(e)}const t=new Error("Early media dialog does not equal confirmed dialog, terminating session");return this.logger.error(t.message),this.ackAndBye(e,488,"Not Acceptable Here"),this.stateTransition(Z.Terminated),Promise.reject(t)}return e.ack(),this.stateTransition(Z.Established),Promise.resolve()}const t=s.answer;if(!t)throw new Error("Answer is undefined.");const i={sessionDescriptionHandlerModifiers:this.sessionDescriptionHandlerModifiers,sessionDescriptionHandlerOptions:this.sessionDescriptionHandlerOptions};return this.setAnswer(t,i).then((()=>{let t;this._renderbody&&this._rendertype&&(t={body:{contentDisposition:"render",contentType:this._rendertype,content:this._renderbody}}),e.ack(t),this.stateTransition(Z.Established)})).catch((t=>{throw this.logger.error(t.message),this.ackAndBye(e,488,"Not Acceptable Here"),this.stateTransition(Z.Terminated),t}))}case U.Closed:return Promise.reject(new Error("Terminated."));default:throw new Error("Unknown session signaling state.")}}onProgress(e){var t;if(this.logger.log("Inviter.onProgress"),this.state!==Z.Establishing)return this.logger.error(`Progress received while in state ${this.state}, dropping response`),Promise.reject(new Error(`Invalid session state ${this.state}`));if(!this.outgoingInviteRequest)throw new Error("Outgoing INVITE request undefined.");const s=e.message,i=e.session;s.hasHeader("P-Asserted-Identity")&&(this._assertedIdentity=y.nameAddrHeaderParse(s.getHeader("P-Asserted-Identity")));const r=s.getHeader("require"),n=s.getHeader("rseq"),o=!!(r&&r.includes("100rel")&&n?Number(n):void 0),a=[];switch(o&&a.push("RAck: "+s.getHeader("rseq")+" "+s.getHeader("cseq")),i.signalingState){case U.Initial:return o&&(this.logger.warn("First reliable provisional response received MUST contain an offer when INVITE does not contain an offer."),e.prack({extraHeaders:a})),Promise.resolve();case U.HaveLocalOffer:return o&&e.prack({extraHeaders:a}),Promise.resolve();case U.HaveRemoteOffer:if(!o)return this.logger.warn("Non-reliable provisional response MUST NOT contain an initial offer, discarding response."),Promise.resolve();{const r=this.sessionDescriptionHandlerFactory(this,this.userAgent.configuration.sessionDescriptionHandlerFactoryOptions||{});return(null===(t=this.delegate)||void 0===t?void 0:t.onSessionDescriptionHandler)&&this.delegate.onSessionDescriptionHandler(r,!0),this.earlyMediaSessionDescriptionHandlers.set(i.id,r),r.setDescription(s.body,this.sessionDescriptionHandlerOptions,this.sessionDescriptionHandlerModifiers).then((()=>r.getDescription(this.sessionDescriptionHandlerOptions,this.sessionDescriptionHandlerModifiers))).then((t=>{const s={contentDisposition:"session",contentType:t.contentType,content:t.body};e.prack({extraHeaders:a,body:s})})).catch((e=>{throw this.stateTransition(Z.Terminated),e}))}case U.Stable:if(o&&e.prack({extraHeaders:a}),this.earlyMedia&&!this.earlyMediaDialog){this.earlyMediaDialog=i;const e=i.answer;if(!e)throw new Error("Answer is undefined.");const t={sessionDescriptionHandlerModifiers:this.sessionDescriptionHandlerModifiers,sessionDescriptionHandlerOptions:this.sessionDescriptionHandlerOptions};return this.setAnswer(e,t).catch((e=>{throw this.stateTransition(Z.Terminated),e}))}return Promise.resolve();case U.Closed:return Promise.reject(new Error("Terminated."));default:throw new Error("Unknown session signaling state.")}}onRedirect(e){this.logger.log("Inviter.onRedirect"),this.state===Z.Establishing||this.state===Z.Terminating?this.stateTransition(Z.Terminated):this.logger.error(`Redirect received while in state ${this.state}, dropping response`)}onReject(e){this.logger.log("Inviter.onReject"),this.state===Z.Establishing||this.state===Z.Terminating?this.stateTransition(Z.Terminated):this.logger.error(`Reject received while in state ${this.state}, dropping response`)}onTrying(e){this.logger.log("Inviter.onTrying"),this.state===Z.Establishing||this.logger.error(`Trying received while in state ${this.state}, dropping response`)}}class te{constructor(e,t,s,i="text/plain",r={}){this.logger=e.getLogger("sip.Messager"),r.params=r.params||{};let n=e.userAgentCore.configuration.aor;if(r.params.fromUri&&(n="string"==typeof r.params.fromUri?y.URIParse(r.params.fromUri):r.params.fromUri),!n)throw new TypeError("Invalid from URI: "+r.params.fromUri);let o=t;if(r.params.toUri&&(o="string"==typeof r.params.toUri?y.URIParse(r.params.toUri):r.params.toUri),!o)throw new TypeError("Invalid to URI: "+r.params.toUri);const a=r.params?Object.assign({},r.params):{},c=(r.extraHeaders||[]).slice(),h={contentDisposition:"render",contentType:i,content:s};this.request=e.userAgentCore.makeOutgoingRequestMessage(G.MESSAGE,t,n,o,a,c,h),this.userAgent=e}message(e={}){return this.userAgent.userAgentCore.request(this.request,e.requestDelegate),Promise.resolve()}}var se,ie,re,ne,oe,ae,ce,he,de;!function(e){e.Initial="Initial",e.Published="Published",e.Unpublished="Unpublished",e.Terminated="Terminated"}(se=se||(se={}));class le{constructor(e,t,s,i={}){this.disposed=!1,this._state=se.Initial,this._stateEventEmitter=new p,this.userAgent=e,i.extraHeaders=(i.extraHeaders||[]).slice(),i.contentType=i.contentType||"text/plain","number"!=typeof i.expires||i.expires%1!=0?i.expires=3600:i.expires=Number(i.expires),"boolean"!=typeof i.unpublishOnClose&&(i.unpublishOnClose=!0),this.target=t,this.event=s,this.options=i,this.pubRequestExpires=i.expires,this.logger=e.getLogger("sip.Publisher");const r=i.params||{},n=r.fromUri?r.fromUri:e.userAgentCore.configuration.aor,o=r.toUri?r.toUri:t;let a;if(i.body&&i.contentType){a={contentDisposition:"render",contentType:i.contentType,content:i.body}}const c=(i.extraHeaders||[]).slice();this.request=e.userAgentCore.makeOutgoingRequestMessage(G.PUBLISH,t,n,o,r,c,a),this.id=this.target.toString()+":"+this.event,this.userAgent._publishers[this.id]=this}dispose(){return this.disposed?Promise.resolve():(this.disposed=!0,this.logger.log(`Publisher ${this.id} in state ${this.state} is being disposed`),delete this.userAgent._publishers[this.id],this.options.unpublishOnClose&&this.state===se.Published?this.unpublish():(this.publishRefreshTimer&&(clearTimeout(this.publishRefreshTimer),this.publishRefreshTimer=void 0),this.pubRequestBody=void 0,this.pubRequestExpires=0,this.pubRequestEtag=void 0,Promise.resolve()))}get state(){return this._state}get stateChange(){return this._stateEventEmitter}publish(e,t={}){if(this.publishRefreshTimer&&(clearTimeout(this.publishRefreshTimer),this.publishRefreshTimer=void 0),this.options.body=e,this.pubRequestBody=this.options.body,0===this.pubRequestExpires){if(void 0===this.options.expires)throw new Error("Expires undefined.");this.pubRequestExpires=this.options.expires,this.pubRequestEtag=void 0}return this.sendPublishRequest(),Promise.resolve()}unpublish(e={}){return this.publishRefreshTimer&&(clearTimeout(this.publishRefreshTimer),this.publishRefreshTimer=void 0),this.pubRequestBody=void 0,this.pubRequestExpires=0,void 0!==this.pubRequestEtag&&this.sendPublishRequest(),Promise.resolve()}receiveResponse(e){const t=e.statusCode||0;switch(!0){case/^1[0-9]{2}$/.test(t.toString()):break;case/^2[0-9]{2}$/.test(t.toString()):if(e.hasHeader("SIP-ETag")?this.pubRequestEtag=e.getHeader("SIP-ETag"):this.logger.warn("SIP-ETag header missing in a 200-class response to PUBLISH"),e.hasHeader("Expires")){const t=Number(e.getHeader("Expires"));"number"==typeof t&&t>=0&&t<=this.pubRequestExpires?this.pubRequestExpires=t:this.logger.warn("Bad Expires header in a 200-class response to PUBLISH")}else this.logger.warn("Expires header missing in a 200-class response to PUBLISH");0!==this.pubRequestExpires?(this.publishRefreshTimer=setTimeout((()=>this.refreshRequest()),900*this.pubRequestExpires),this._state!==se.Published&&this.stateTransition(se.Published)):this.stateTransition(se.Unpublished);break;case/^412$/.test(t.toString()):if(void 0!==this.pubRequestEtag&&0!==this.pubRequestExpires){if(this.logger.warn("412 response to PUBLISH, recovering"),this.pubRequestEtag=void 0,void 0===this.options.body)throw new Error("Body undefined.");this.publish(this.options.body)}else this.logger.warn("412 response to PUBLISH, recovery failed"),this.pubRequestExpires=0,this.stateTransition(se.Unpublished),this.stateTransition(se.Terminated);break;case/^423$/.test(t.toString()):if(0!==this.pubRequestExpires&&e.hasHeader("Min-Expires")){const t=Number(e.getHeader("Min-Expires"));if("number"==typeof t||t>this.pubRequestExpires){if(this.logger.warn("423 code in response to PUBLISH, adjusting the Expires value and trying to recover"),this.pubRequestExpires=t,void 0===this.options.body)throw new Error("Body undefined.");this.publish(this.options.body)}else this.logger.warn("Bad 423 response Min-Expires header received for PUBLISH"),this.pubRequestExpires=0,this.stateTransition(se.Unpublished),this.stateTransition(se.Terminated)}else this.logger.warn("423 response to PUBLISH, recovery failed"),this.pubRequestExpires=0,this.stateTransition(se.Unpublished),this.stateTransition(se.Terminated);break;default:this.pubRequestExpires=0,this.stateTransition(se.Unpublished),this.stateTransition(se.Terminated)}0===this.pubRequestExpires&&(this.publishRefreshTimer&&(clearTimeout(this.publishRefreshTimer),this.publishRefreshTimer=void 0),this.pubRequestBody=void 0,this.pubRequestEtag=void 0)}send(){return this.userAgent.userAgentCore.publish(this.request,{onAccept:e=>this.receiveResponse(e.message),onProgress:e=>this.receiveResponse(e.message),onRedirect:e=>this.receiveResponse(e.message),onReject:e=>this.receiveResponse(e.message),onTrying:e=>this.receiveResponse(e.message)})}refreshRequest(){if(this.publishRefreshTimer&&(clearTimeout(this.publishRefreshTimer),this.publishRefreshTimer=void 0),this.pubRequestBody=void 0,void 0===this.pubRequestEtag)throw new Error("Etag undefined");if(0===this.pubRequestExpires)throw new Error("Expires zero");this.sendPublishRequest()}sendPublishRequest(){const e=Object.assign({},this.options);e.extraHeaders=(this.options.extraHeaders||[]).slice(),e.extraHeaders.push("Event: "+this.event),e.extraHeaders.push("Expires: "+this.pubRequestExpires),void 0!==this.pubRequestEtag&&e.extraHeaders.push("SIP-If-Match: "+this.pubRequestEtag);const t=this.target,s=this.options.params||{};let i,r;if(void 0!==this.pubRequestBody){if(void 0===this.options.contentType)throw new Error("Content type undefined.");i={body:this.pubRequestBody,contentType:this.options.contentType}}return i&&(r=q(i)),this.request=this.userAgent.userAgentCore.makeOutgoingRequestMessage(G.PUBLISH,t,s.fromUri?s.fromUri:this.userAgent.userAgentCore.configuration.aor,s.toUri?s.toUri:this.target,s,e.extraHeaders,r),this.send()}stateTransition(e){const t=()=>{throw new Error(`Invalid state transition from ${this._state} to ${e}`)};switch(this._state){case se.Initial:e!==se.Published&&e!==se.Unpublished&&e!==se.Terminated&&t();break;case se.Published:e!==se.Unpublished&&e!==se.Terminated&&t();break;case se.Unpublished:e!==se.Published&&e!==se.Terminated&&t();break;case se.Terminated:t();break;default:throw new Error("Unrecognized state.")}this._state=e,this.logger.log(`Publication transitioned to state ${this._state}`),this._stateEventEmitter.emit(this._state),e===se.Terminated&&this.dispose()}}!function(e){e.Initial="Initial",e.Registered="Registered",e.Unregistered="Unregistered",e.Terminated="Terminated"}(ie=ie||(ie={}));class ge{constructor(e,t={}){this.disposed=!1,this._contacts=[],this._retryAfter=void 0,this._state=ie.Initial,this._waiting=!1,this._stateEventEmitter=new p,this._waitingEventEmitter=new p,this.userAgent=e;const s=e.configuration.uri.clone();if(s.user=void 0,this.options=Object.assign(Object.assign(Object.assign({},ge.defaultOptions()),{registrar:s}),ge.stripUndefinedProperties(t)),this.options.extraContactHeaderParams=(this.options.extraContactHeaderParams||[]).slice(),this.options.extraHeaders=(this.options.extraHeaders||[]).slice(),!this.options.registrar)throw new Error("Registrar undefined.");if(this.options.registrar=this.options.registrar.clone(),this.options.regId&&!this.options.instanceId?this.options.instanceId=this.userAgent.instanceId:!this.options.regId&&this.options.instanceId&&(this.options.regId=1),this.options.instanceId&&-1===y.parse(this.options.instanceId,"uuid"))throw new Error("Invalid instanceId.");if(this.options.regId&&this.options.regId<0)throw new Error("Invalid regId.");const i=this.options.registrar,r=this.options.params&&this.options.params.fromUri||e.userAgentCore.configuration.aor,n=this.options.params&&this.options.params.toUri||e.configuration.uri,o=this.options.params||{},a=(t.extraHeaders||[]).slice();if(this.request=e.userAgentCore.makeOutgoingRequestMessage(G.REGISTER,i,r,n,o,a,void 0),this.expires=this.options.expires||ge.defaultExpires,this.expires<0)throw new Error("Invalid expires.");if(this.refreshFrequency=this.options.refreshFrequency||ge.defaultRefreshFrequency,this.refreshFrequency<50||this.refreshFrequency>99)throw new Error("Invalid refresh frequency. The value represents a percentage of the expiration time and should be between 50 and 99.");this.logger=e.getLogger("sip.Registerer"),this.options.logConfiguration&&(this.logger.log("Configuration:"),Object.keys(this.options).forEach((e=>{const t=this.options[e];if("registrar"===e)this.logger.log("\xb7 "+e+": "+t);else this.logger.log("\xb7 "+e+": "+JSON.stringify(t))}))),this.id=this.request.callId+this.request.from.parameters.tag,this.userAgent._registerers[this.id]=this}static defaultOptions(){return{expires:ge.defaultExpires,extraContactHeaderParams:[],extraHeaders:[],logConfiguration:!0,instanceId:"",params:{},regId:0,registrar:new w("sip","anonymous","anonymous.invalid"),refreshFrequency:ge.defaultRefreshFrequency}}static stripUndefinedProperties(e){return Object.keys(e).reduce(((t,s)=>(void 0!==e[s]&&(t[s]=e[s]),t)),{})}get contacts(){return this._contacts.slice()}get retryAfter(){return this._retryAfter}get state(){return this._state}get stateChange(){return this._stateEventEmitter}dispose(){return this.disposed?Promise.resolve():(this.disposed=!0,this.logger.log(`Registerer ${this.id} in state ${this.state} is being disposed`),delete this.userAgent._registerers[this.id],new Promise((e=>{const t=()=>{if(!this.waiting&&this._state===ie.Registered)return this.stateChange.addListener((()=>{this.terminated(),e()}),{once:!0}),void this.unregister();this.terminated(),e()};this.waiting?this.waitingChange.addListener((()=>{t()}),{once:!0}):t()})))}register(e={}){if(this.state===ie.Terminated)throw this.stateError(),new Error("Registerer terminated. Unable to register.");if(this.disposed)throw this.stateError(),new Error("Registerer disposed. Unable to register.");if(this.waiting){this.waitingWarning();const e=new a("REGISTER request already in progress, waiting for final response");return Promise.reject(e)}e.requestOptions&&(this.options=Object.assign(Object.assign({},this.options),e.requestOptions));const t=(this.options.extraHeaders||[]).slice();t.push("Contact: "+this.generateContactHeader(this.expires)),t.push("Allow: "+["ACK","CANCEL","INVITE","MESSAGE","BYE","OPTIONS","INFO","NOTIFY","REFER"].toString()),this.request.cseq++,this.request.setHeader("cseq",this.request.cseq+" REGISTER"),this.request.extraHeaders=t,this.waitingToggle(!0);const s=this.userAgent.userAgentCore.register(this.request,{onAccept:t=>{let s;t.message.hasHeader("expires")&&(s=Number(t.message.getHeader("expires"))),this._contacts=t.message.getHeaders("contact");let i,r=this._contacts.length;if(!r)return this.logger.error("No Contact header in response to REGISTER, dropping response."),void this.unregistered();for(;r--;){if(i=t.message.parseHeader("contact",r),!i)throw new Error("Contact undefined");if(this.userAgent.contact.pubGruu&&b(i.uri,this.userAgent.contact.pubGruu)){s=Number(i.getParam("expires"));break}if(""===this.userAgent.configuration.contactName){if(i.uri.user===this.userAgent.contact.uri.user){s=Number(i.getParam("expires"));break}}else if(b(i.uri,this.userAgent.contact.uri)){s=Number(i.getParam("expires"));break}i=void 0}if(void 0===i)return this.logger.error("No Contact header pointing to us, dropping response"),this.unregistered(),void this.waitingToggle(!1);if(void 0===s)return this.logger.error("Contact pointing to us is missing expires parameter, dropping response"),this.unregistered(),void this.waitingToggle(!1);if(i.hasParam("temp-gruu")){const e=i.getParam("temp-gruu");e&&(this.userAgent.contact.tempGruu=y.URIParse(e.replace(/"/g,"")))}if(i.hasParam("pub-gruu")){const e=i.getParam("pub-gruu");e&&(this.userAgent.contact.pubGruu=y.URIParse(e.replace(/"/g,"")))}this.registered(s),e.requestDelegate&&e.requestDelegate.onAccept&&e.requestDelegate.onAccept(t),this.waitingToggle(!1)},onProgress:t=>{e.requestDelegate&&e.requestDelegate.onProgress&&e.requestDelegate.onProgress(t)},onRedirect:t=>{this.logger.error("Redirect received. Not supported."),this.unregistered(),e.requestDelegate&&e.requestDelegate.onRedirect&&e.requestDelegate.onRedirect(t),this.waitingToggle(!1)},onReject:t=>{if(423===t.message.statusCode)return t.message.hasHeader("min-expires")?(this.expires=Number(t.message.getHeader("min-expires")),this.waitingToggle(!1),void this.register()):(this.logger.error("423 response received for REGISTER without Min-Expires, dropping response"),this.unregistered(),void this.waitingToggle(!1));this.logger.warn(`Failed to register, status code ${t.message.statusCode}`);let s=NaN;if(500===t.message.statusCode||503===t.message.statusCode){const e=t.message.getHeader("retry-after");e&&(s=Number.parseInt(e,void 0))}this._retryAfter=isNaN(s)?void 0:s,this.unregistered(),e.requestDelegate&&e.requestDelegate.onReject&&e.requestDelegate.onReject(t),this._retryAfter=void 0,this.waitingToggle(!1)},onTrying:t=>{e.requestDelegate&&e.requestDelegate.onTrying&&e.requestDelegate.onTrying(t)}});return Promise.resolve(s)}unregister(e={}){if(this.state===ie.Terminated)throw this.stateError(),new Error("Registerer terminated. Unable to register.");if(this.disposed&&this.state!==ie.Registered)throw this.stateError(),new Error("Registerer disposed. Unable to register.");if(this.waiting){this.waitingWarning();const e=new a("REGISTER request already in progress, waiting for final response");return Promise.reject(e)}this._state===ie.Registered||e.all||this.logger.warn("Not currently registered, but sending an unregister anyway.");const t=(e.requestOptions&&e.requestOptions.extraHeaders||[]).slice();this.request.extraHeaders=t,e.all?(t.push("Contact: *"),t.push("Expires: 0")):t.push("Contact: "+this.generateContactHeader(0)),this.request.cseq++,this.request.setHeader("cseq",this.request.cseq+" REGISTER"),void 0!==this.registrationTimer&&(clearTimeout(this.registrationTimer),this.registrationTimer=void 0),this.waitingToggle(!0);const s=this.userAgent.userAgentCore.register(this.request,{onAccept:t=>{this._contacts=t.message.getHeaders("contact"),this.unregistered(),e.requestDelegate&&e.requestDelegate.onAccept&&e.requestDelegate.onAccept(t),this.waitingToggle(!1)},onProgress:t=>{e.requestDelegate&&e.requestDelegate.onProgress&&e.requestDelegate.onProgress(t)},onRedirect:t=>{this.logger.error("Unregister redirected. Not currently supported."),this.unregistered(),e.requestDelegate&&e.requestDelegate.onRedirect&&e.requestDelegate.onRedirect(t),this.waitingToggle(!1)},onReject:t=>{this.logger.error(`Unregister rejected with status code ${t.message.statusCode}`),this.unregistered(),e.requestDelegate&&e.requestDelegate.onReject&&e.requestDelegate.onReject(t),this.waitingToggle(!1)},onTrying:t=>{e.requestDelegate&&e.requestDelegate.onTrying&&e.requestDelegate.onTrying(t)}});return Promise.resolve(s)}clearTimers(){void 0!==this.registrationTimer&&(clearTimeout(this.registrationTimer),this.registrationTimer=void 0),void 0!==this.registrationExpiredTimer&&(clearTimeout(this.registrationExpiredTimer),this.registrationExpiredTimer=void 0)}generateContactHeader(e){let t=this.userAgent.contact.toString({register:!0});return this.options.regId&&this.options.instanceId&&(t+=";reg-id="+this.options.regId,t+=';+sip.instance=""'),this.options.extraContactHeaderParams&&this.options.extraContactHeaderParams.forEach((e=>{t+=";"+e})),t+=";expires="+e,t}registered(e){this.clearTimers(),this.registrationTimer=setTimeout((()=>{this.registrationTimer=void 0,this.register()}),this.refreshFrequency/100*e*1e3),this.registrationExpiredTimer=setTimeout((()=>{this.logger.warn("Registration expired"),this.unregistered()}),1e3*e),this._state!==ie.Registered&&this.stateTransition(ie.Registered)}unregistered(){this.clearTimers(),this._state!==ie.Unregistered&&this.stateTransition(ie.Unregistered)}terminated(){this.clearTimers(),this._state!==ie.Terminated&&this.stateTransition(ie.Terminated)}stateTransition(e){const t=()=>{throw new Error(`Invalid state transition from ${this._state} to ${e}`)};switch(this._state){case ie.Initial:e!==ie.Registered&&e!==ie.Unregistered&&e!==ie.Terminated&&t();break;case ie.Registered:e!==ie.Unregistered&&e!==ie.Terminated&&t();break;case ie.Unregistered:e!==ie.Registered&&e!==ie.Terminated&&t();break;case ie.Terminated:t();break;default:throw new Error("Unrecognized state.")}this._state=e,this.logger.log(`Registration transitioned to state ${this._state}`),this._stateEventEmitter.emit(this._state),e===ie.Terminated&&this.dispose()}get waiting(){return this._waiting}get waitingChange(){return this._waitingEventEmitter}waitingToggle(e){if(this._waiting===e)throw new Error(`Invalid waiting transition from ${this._waiting} to ${e}`);this._waiting=e,this.logger.log(`Waiting toggled to ${this._waiting}`),this._waitingEventEmitter.emit(this._waiting)}waitingWarning(){let e="An attempt was made to send a REGISTER request while a prior one was still in progress.";e+=" RFC 3261 requires UAs MUST NOT send a new registration until they have received a final response",e+=" from the registrar for the previous one or the previous REGISTER request has timed out.",e+=" Note that if the transport disconnects, you still must wait for the prior request to time out before",e+=" sending a new REGISTER request or alternatively dispose of the current Registerer and create a new Registerer.",this.logger.warn("An attempt was made to send a REGISTER request while a prior one was still in progress. RFC 3261 requires UAs MUST NOT send a new registration until they have received a final response from the registrar for the previous one or the previous REGISTER request has timed out. Note that if the transport disconnects, you still must wait for the prior request to time out before sending a new REGISTER request or alternatively dispose of the current Registerer and create a new Registerer.")}stateError(){let e=`An attempt was made to send a REGISTER request when the Registerer ${this.state===ie.Terminated?"is in 'Terminated' state":"has been disposed"}.`;e+=" The Registerer transitions to 'Terminated' when Registerer.dispose() is called.",e+=" Perhaps you called UserAgent.stop() which dipsoses of all Registerers?",this.logger.error(e)}}ge.defaultExpires=600,ge.defaultRefreshFrequency=99,function(e){e.Initial="Initial",e.NotifyWait="NotifyWait",e.Pending="Pending",e.Active="Active",e.Terminated="Terminated"}(re=re||(re={})),function(e){e.Initial="Initial",e.NotifyWait="NotifyWait",e.Subscribed="Subscribed",e.Terminated="Terminated"}(ne=ne||(ne={}));class ue{constructor(e,t={}){this._disposed=!1,this._state=ne.Initial,this._logger=e.getLogger("sip.Subscription"),this._stateEventEmitter=new p,this._userAgent=e,this.delegate=t.delegate}dispose(){return this._disposed||(this._disposed=!0,this._stateEventEmitter.removeAllListeners()),Promise.resolve()}get dialog(){return this._dialog}get disposed(){return this._disposed}get state(){return this._state}get stateChange(){return this._stateEventEmitter}stateTransition(e){const t=()=>{throw new Error(`Invalid state transition from ${this._state} to ${e}`)};switch(this._state){case ne.Initial:e!==ne.NotifyWait&&e!==ne.Terminated&&t();break;case ne.NotifyWait:e!==ne.Subscribed&&e!==ne.Terminated&&t();break;case ne.Subscribed:e!==ne.Terminated&&t();break;case ne.Terminated:t();break;default:throw new Error("Unrecognized state.")}this._state!==e&&(this._state=e,this._logger.log(`Subscription ${this._dialog?this._dialog.id:void 0} transitioned to ${this._state}`),this._stateEventEmitter.emit(this._state),e===ne.Terminated&&this.dispose())}}class pe extends ue{constructor(e,t,s,i={}){super(e,i),this.body=void 0,this.logger=e.getLogger("sip.Subscriber"),i.body&&(this.body={body:i.body,contentType:i.contentType?i.contentType:"application/sdp"}),this.targetURI=t,this.event=s,void 0===i.expires?this.expires=3600:"number"!=typeof i.expires?(this.logger.warn('Option "expires" must be a number. Using default of 3600.'),this.expires=3600):this.expires=i.expires,this.extraHeaders=(i.extraHeaders||[]).slice(),this.subscriberRequest=this.initSubscriberRequest(),this.outgoingRequestMessage=this.subscriberRequest.message,this.id=this.outgoingRequestMessage.callId+this.outgoingRequestMessage.from.parameters.tag+this.event,this._userAgent._subscriptions[this.id]=this}dispose(){return this.disposed?Promise.resolve():(this.logger.log(`Subscription ${this.id} in state ${this.state} is being disposed`),delete this._userAgent._subscriptions[this.id],this.retryAfterTimer&&(clearTimeout(this.retryAfterTimer),this.retryAfterTimer=void 0),this.subscriberRequest.dispose(),super.dispose().then((()=>{if(this.state===ne.Subscribed){if(!this._dialog)throw new Error("Dialog undefined.");if(this._dialog.subscriptionState===re.Pending||this._dialog.subscriptionState===re.Active){const e=this._dialog;return new Promise(((t,s)=>{e.delegate={onTerminated:()=>t()},e.unsubscribe()}))}}})))}subscribe(e={}){switch(this.subscriberRequest.state){case re.Initial:this.state===ne.Initial&&this.stateTransition(ne.NotifyWait),this.subscriberRequest.subscribe().then((e=>{e.success?(e.success.subscription&&(this._dialog=e.success.subscription,this._dialog.delegate={onNotify:e=>this.onNotify(e),onRefresh:e=>this.onRefresh(e),onTerminated:()=>{this.state!==ne.Terminated&&this.stateTransition(ne.Terminated)}}),this.onNotify(e.success.request)):e.failure&&this.unsubscribe()}));break;case re.NotifyWait:case re.Pending:break;case re.Active:if(this._dialog){this._dialog.refresh().delegate={onAccept:e=>this.onAccepted(e),onRedirect:e=>this.unsubscribe(),onReject:e=>this.unsubscribe()}}case re.Terminated:}return Promise.resolve()}unsubscribe(e={}){if(this.disposed)return Promise.resolve();switch(this.subscriberRequest.state){case re.Initial:case re.NotifyWait:break;case re.Pending:case re.Active:this._dialog&&this._dialog.unsubscribe();break;case re.Terminated:break;default:throw new Error("Unknown state.")}return this.stateTransition(ne.Terminated),Promise.resolve()}_refresh(){return this.subscriberRequest.state===re.Active?this.subscribe():Promise.resolve()}onAccepted(e){}onNotify(e){if(this.disposed)return void e.accept();if(this.state!==ne.Subscribed&&this.stateTransition(ne.Subscribed),this.delegate&&this.delegate.onNotify){const t=new K(e);this.delegate.onNotify(t)}else e.accept();const t=e.message.parseHeader("Subscription-State");if(t&&t.state&&"terminated"===t.state){if(t.reason)switch(this.logger.log(`Terminated subscription with reason ${t.reason}`),t.reason){case"deactivated":case"timeout":return this.initSubscriberRequest(),void this.subscribe();case"probation":case"giveup":return this.initSubscriberRequest(),void(t.params&&t.params["retry-after"]?this.retryAfterTimer=setTimeout((()=>{this.subscribe()}),t.params["retry-after"]):this.subscribe())}this.unsubscribe()}}onRefresh(e){e.delegate={onAccept:e=>this.onAccepted(e)}}initSubscriberRequest(){const e={extraHeaders:this.extraHeaders,body:this.body?q(this.body):void 0};return this.subscriberRequest=new fe(this._userAgent.userAgentCore,this.targetURI,this.event,this.expires,e),this.subscriberRequest.delegate={onAccept:e=>this.onAccepted(e)},this.subscriberRequest}}class fe{constructor(e,t,s,i,r,n){this.core=e,this.target=t,this.event=s,this.expires=i,this.subscribed=!1,this.logger=e.loggerFactory.getLogger("sip.Subscriber"),this.delegate=n;const o="Allow: "+V.toString(),a=(r&&r.extraHeaders||[]).slice();a.push(o),a.push("Event: "+this.event),a.push("Expires: "+this.expires),a.push("Contact: "+this.core.configuration.contact.toString());const c=r&&r.body;this.message=e.makeOutgoingRequestMessage(G.SUBSCRIBE,this.target,this.core.configuration.aor,this.target,{},a,c)}dispose(){this.request&&(this.request.waitNotifyStop(),this.request.dispose(),this.request=void 0)}get state(){return this.subscription?this.subscription.subscriptionState:this.subscribed?re.NotifyWait:re.Initial}subscribe(){return this.subscribed?Promise.reject(new Error("Not in initial state. Did you call subscribe more than once?")):(this.subscribed=!0,new Promise((e=>{if(!this.message)throw new Error("Message undefined.");this.request=this.core.subscribe(this.message,{onAccept:e=>{this.delegate&&this.delegate.onAccept&&this.delegate.onAccept(e)},onNotify:t=>{this.subscription=t.subscription,this.subscription&&(this.subscription.autoRefresh=!0),e({success:t})},onNotifyTimeout:()=>{e({failure:{}})},onRedirect:t=>{e({failure:{response:t}})},onReject:t=>{e({failure:{response:t}})}})})))}}!function(e){e.Connecting="Connecting",e.Connected="Connected",e.Disconnecting="Disconnecting",e.Disconnected="Disconnected"}(oe=oe||(oe={})),function(e){e.Started="Started",e.Stopped="Stopped"}(ae=ae||(ae={}));class me{constructor(){this._dataLength=0,this._bufferLength=0,this._state=new Int32Array(4),this._buffer=new ArrayBuffer(68),this._buffer8=new Uint8Array(this._buffer,0,68),this._buffer32=new Uint32Array(this._buffer,0,17),this.start()}static hashStr(e,t=!1){return this.onePassHasher.start().appendStr(e).end(t)}static hashAsciiStr(e,t=!1){return this.onePassHasher.start().appendAsciiStr(e).end(t)}static _hex(e){const t=me.hexChars,s=me.hexOut;let i,r,n,o;for(o=0;o<4;o+=1)for(r=8*o,i=e[o],n=0;n<8;n+=2)s[r+1+n]=t.charAt(15&i),i>>>=4,s[r+0+n]=t.charAt(15&i),i>>>=4;return s.join("")}static _md5cycle(e,t){let s=e[0],i=e[1],r=e[2],n=e[3];s+=(i&r|~i&n)+t[0]-680876936|0,s=(s<<7|s>>>25)+i|0,n+=(s&i|~s&r)+t[1]-389564586|0,n=(n<<12|n>>>20)+s|0,r+=(n&s|~n&i)+t[2]+606105819|0,r=(r<<17|r>>>15)+n|0,i+=(r&n|~r&s)+t[3]-1044525330|0,i=(i<<22|i>>>10)+r|0,s+=(i&r|~i&n)+t[4]-176418897|0,s=(s<<7|s>>>25)+i|0,n+=(s&i|~s&r)+t[5]+1200080426|0,n=(n<<12|n>>>20)+s|0,r+=(n&s|~n&i)+t[6]-1473231341|0,r=(r<<17|r>>>15)+n|0,i+=(r&n|~r&s)+t[7]-45705983|0,i=(i<<22|i>>>10)+r|0,s+=(i&r|~i&n)+t[8]+1770035416|0,s=(s<<7|s>>>25)+i|0,n+=(s&i|~s&r)+t[9]-1958414417|0,n=(n<<12|n>>>20)+s|0,r+=(n&s|~n&i)+t[10]-42063|0,r=(r<<17|r>>>15)+n|0,i+=(r&n|~r&s)+t[11]-1990404162|0,i=(i<<22|i>>>10)+r|0,s+=(i&r|~i&n)+t[12]+1804603682|0,s=(s<<7|s>>>25)+i|0,n+=(s&i|~s&r)+t[13]-40341101|0,n=(n<<12|n>>>20)+s|0,r+=(n&s|~n&i)+t[14]-1502002290|0,r=(r<<17|r>>>15)+n|0,i+=(r&n|~r&s)+t[15]+1236535329|0,i=(i<<22|i>>>10)+r|0,s+=(i&n|r&~n)+t[1]-165796510|0,s=(s<<5|s>>>27)+i|0,n+=(s&r|i&~r)+t[6]-1069501632|0,n=(n<<9|n>>>23)+s|0,r+=(n&i|s&~i)+t[11]+643717713|0,r=(r<<14|r>>>18)+n|0,i+=(r&s|n&~s)+t[0]-373897302|0,i=(i<<20|i>>>12)+r|0,s+=(i&n|r&~n)+t[5]-701558691|0,s=(s<<5|s>>>27)+i|0,n+=(s&r|i&~r)+t[10]+38016083|0,n=(n<<9|n>>>23)+s|0,r+=(n&i|s&~i)+t[15]-660478335|0,r=(r<<14|r>>>18)+n|0,i+=(r&s|n&~s)+t[4]-405537848|0,i=(i<<20|i>>>12)+r|0,s+=(i&n|r&~n)+t[9]+568446438|0,s=(s<<5|s>>>27)+i|0,n+=(s&r|i&~r)+t[14]-1019803690|0,n=(n<<9|n>>>23)+s|0,r+=(n&i|s&~i)+t[3]-187363961|0,r=(r<<14|r>>>18)+n|0,i+=(r&s|n&~s)+t[8]+1163531501|0,i=(i<<20|i>>>12)+r|0,s+=(i&n|r&~n)+t[13]-1444681467|0,s=(s<<5|s>>>27)+i|0,n+=(s&r|i&~r)+t[2]-51403784|0,n=(n<<9|n>>>23)+s|0,r+=(n&i|s&~i)+t[7]+1735328473|0,r=(r<<14|r>>>18)+n|0,i+=(r&s|n&~s)+t[12]-1926607734|0,i=(i<<20|i>>>12)+r|0,s+=(i^r^n)+t[5]-378558|0,s=(s<<4|s>>>28)+i|0,n+=(s^i^r)+t[8]-2022574463|0,n=(n<<11|n>>>21)+s|0,r+=(n^s^i)+t[11]+1839030562|0,r=(r<<16|r>>>16)+n|0,i+=(r^n^s)+t[14]-35309556|0,i=(i<<23|i>>>9)+r|0,s+=(i^r^n)+t[1]-1530992060|0,s=(s<<4|s>>>28)+i|0,n+=(s^i^r)+t[4]+1272893353|0,n=(n<<11|n>>>21)+s|0,r+=(n^s^i)+t[7]-155497632|0,r=(r<<16|r>>>16)+n|0,i+=(r^n^s)+t[10]-1094730640|0,i=(i<<23|i>>>9)+r|0,s+=(i^r^n)+t[13]+681279174|0,s=(s<<4|s>>>28)+i|0,n+=(s^i^r)+t[0]-358537222|0,n=(n<<11|n>>>21)+s|0,r+=(n^s^i)+t[3]-722521979|0,r=(r<<16|r>>>16)+n|0,i+=(r^n^s)+t[6]+76029189|0,i=(i<<23|i>>>9)+r|0,s+=(i^r^n)+t[9]-640364487|0,s=(s<<4|s>>>28)+i|0,n+=(s^i^r)+t[12]-421815835|0,n=(n<<11|n>>>21)+s|0,r+=(n^s^i)+t[15]+530742520|0,r=(r<<16|r>>>16)+n|0,i+=(r^n^s)+t[2]-995338651|0,i=(i<<23|i>>>9)+r|0,s+=(r^(i|~n))+t[0]-198630844|0,s=(s<<6|s>>>26)+i|0,n+=(i^(s|~r))+t[7]+1126891415|0,n=(n<<10|n>>>22)+s|0,r+=(s^(n|~i))+t[14]-1416354905|0,r=(r<<15|r>>>17)+n|0,i+=(n^(r|~s))+t[5]-57434055|0,i=(i<<21|i>>>11)+r|0,s+=(r^(i|~n))+t[12]+1700485571|0,s=(s<<6|s>>>26)+i|0,n+=(i^(s|~r))+t[3]-1894986606|0,n=(n<<10|n>>>22)+s|0,r+=(s^(n|~i))+t[10]-1051523|0,r=(r<<15|r>>>17)+n|0,i+=(n^(r|~s))+t[1]-2054922799|0,i=(i<<21|i>>>11)+r|0,s+=(r^(i|~n))+t[8]+1873313359|0,s=(s<<6|s>>>26)+i|0,n+=(i^(s|~r))+t[15]-30611744|0,n=(n<<10|n>>>22)+s|0,r+=(s^(n|~i))+t[6]-1560198380|0,r=(r<<15|r>>>17)+n|0,i+=(n^(r|~s))+t[13]+1309151649|0,i=(i<<21|i>>>11)+r|0,s+=(r^(i|~n))+t[4]-145523070|0,s=(s<<6|s>>>26)+i|0,n+=(i^(s|~r))+t[11]-1120210379|0,n=(n<<10|n>>>22)+s|0,r+=(s^(n|~i))+t[2]+718787259|0,r=(r<<15|r>>>17)+n|0,i+=(n^(r|~s))+t[9]-343485551|0,i=(i<<21|i>>>11)+r|0,e[0]=s+e[0]|0,e[1]=i+e[1]|0,e[2]=r+e[2]|0,e[3]=n+e[3]|0}start(){return this._dataLength=0,this._bufferLength=0,this._state.set(me.stateIdentity),this}appendStr(e){const t=this._buffer8,s=this._buffer32;let i,r,n=this._bufferLength;for(r=0;r>>6),t[n++]=63&i|128;else if(i<55296||i>56319)t[n++]=224+(i>>>12),t[n++]=i>>>6&63|128,t[n++]=63&i|128;else{if(i=1024*(i-55296)+(e.charCodeAt(++r)-56320)+65536,i>1114111)throw new Error("Unicode standard supports code points up to U+10FFFF");t[n++]=240+(i>>>18),t[n++]=i>>>12&63|128,t[n++]=i>>>6&63|128,t[n++]=63&i|128}n>=64&&(this._dataLength+=64,me._md5cycle(this._state,s),n-=64,s[0]=s[16])}return this._bufferLength=n,this}appendAsciiStr(e){const t=this._buffer8,s=this._buffer32;let i,r=this._bufferLength,n=0;for(;;){for(i=Math.min(e.length-n,64-r);i--;)t[r++]=e.charCodeAt(n++);if(r<64)break;this._dataLength+=64,me._md5cycle(this._state,s),r=0}return this._bufferLength=r,this}appendByteArray(e){const t=this._buffer8,s=this._buffer32;let i,r=this._bufferLength,n=0;for(;;){for(i=Math.min(e.length-n,64-r);i--;)t[r++]=e[n++];if(r<64)break;this._dataLength+=64,me._md5cycle(this._state,s),r=0}return this._bufferLength=r,this}getState(){const e=this,t=e._state;return{buffer:String.fromCharCode.apply(null,e._buffer8),buflen:e._bufferLength,length:e._dataLength,state:[t[0],t[1],t[2],t[3]]}}setState(e){const t=e.buffer,s=e.state,i=this._state;let r;for(this._dataLength=e.length,this._bufferLength=e.buflen,i[0]=s[0],i[1]=s[1],i[2]=s[2],i[3]=s[3],r=0;r>2);let n;if(this._dataLength+=t,s[t]=128,s[t+1]=s[t+2]=s[t+3]=0,i.set(me.buffer32Identity.subarray(r),r),t>55&&(me._md5cycle(this._state,i),i.set(me.buffer32Identity)),n=8*this._dataLength,n<=4294967295)i[14]=n;else{const e=n.toString(16).match(/(.*?)(.{0,8})$/);if(null===e)return;const t=parseInt(e[2],16),s=parseInt(e[1],16)||0;i[14]=t,i[15]=s}return me._md5cycle(this._state,i),e?this._state:me._hex(this._state)}}function ve(e){return me.hashStr(e)}me.stateIdentity=new Int32Array([1732584193,-271733879,-1732584194,271733878]),me.buffer32Identity=new Int32Array([0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]),me.hexChars="0123456789abcdef",me.hexOut=[],me.onePassHasher=new me,"5d41402abc4b2a76b9719d911017c592"!==me.hashStr("hello")&&console.error("Md5 self test failed.");class we{constructor(e,t,s,i){this.logger=e.getLogger("sipjs.digestauthentication"),this.username=s,this.password=i,this.ha1=t,this.nc=0,this.ncHex="00000000"}authenticate(e,t,s){if(this.algorithm=t.algorithm,this.realm=t.realm,this.nonce=t.nonce,this.opaque=t.opaque,this.stale=t.stale,this.algorithm){if("MD5"!==this.algorithm)return this.logger.warn("challenge with Digest algorithm different than 'MD5', authentication aborted"),!1}else this.algorithm="MD5";if(!this.realm)return this.logger.warn("challenge without Digest realm, authentication aborted"),!1;if(!this.nonce)return this.logger.warn("challenge without Digest nonce, authentication aborted"),!1;if(t.qop)if(t.qop.indexOf("auth")>-1)this.qop="auth";else{if(!(t.qop.indexOf("auth-int")>-1))return this.logger.warn("challenge without Digest qop different than 'auth' or 'auth-int', authentication aborted"),!1;this.qop="auth-int"}else this.qop=void 0;return this.method=e.method,this.uri=e.ruri,this.cnonce=$(12),this.nc+=1,this.updateNcHex(),4294967296===this.nc&&(this.nc=1,this.ncHex="00000001"),this.calculateResponse(s),!0}toString(){const e=[];if(!this.response)throw new Error("response field does not exist, cannot generate Authorization header");return e.push("algorithm="+this.algorithm),e.push('username="'+this.username+'"'),e.push('realm="'+this.realm+'"'),e.push('nonce="'+this.nonce+'"'),e.push('uri="'+this.uri+'"'),e.push('response="'+this.response+'"'),this.opaque&&e.push('opaque="'+this.opaque+'"'),this.qop&&(e.push("qop="+this.qop),e.push('cnonce="'+this.cnonce+'"'),e.push("nc="+this.ncHex)),"Digest "+e.join(", ")}updateNcHex(){const e=Number(this.nc).toString(16);this.ncHex="00000000".substr(0,8-e.length)+e}calculateResponse(e){let t,s;t=this.ha1,""!==t&&void 0!==t||(t=ve(this.username+":"+this.realm+":"+this.password)),"auth"===this.qop?(s=ve(this.method+":"+this.uri),this.response=ve(t+":"+this.nonce+":"+this.ncHex+":"+this.cnonce+":auth:"+s)):"auth-int"===this.qop?(s=ve(this.method+":"+this.uri+":"+ve(e||"")),this.response=ve(t+":"+this.nonce+":"+this.ncHex+":"+this.cnonce+":auth-int:"+s)):void 0===this.qop&&(s=ve(this.method+":"+this.uri),this.response=ve(t+":"+this.nonce+":"+s))}}!function(e){e[e.error=0]="error",e[e.warn=1]="warn",e[e.log=2]="log",e[e.debug=3]="debug"}(ce=ce||(ce={}));class be{constructor(e,t,s){this.logger=e,this.category=t,this.label=s}error(e){this.genericLog(ce.error,e)}warn(e){this.genericLog(ce.warn,e)}log(e){this.genericLog(ce.log,e)}debug(e){this.genericLog(ce.debug,e)}genericLog(e,t){this.logger.genericLog(e,this.category,this.label,t)}get level(){return this.logger.level}set level(e){this.logger.level=e}}class Te{constructor(){this.builtinEnabled=!0,this._level=ce.log,this.loggers={},this.logger=this.getLogger("sip:loggerfactory")}get level(){return this._level}set level(e){e>=0&&e<=3?this._level=e:e>3?this._level=3:ce.hasOwnProperty(e)?this._level=e:this.logger.error("invalid 'level' parameter value: "+JSON.stringify(e))}get connector(){return this._connector}set connector(e){e?"function"==typeof e?this._connector=e:this.logger.error("invalid 'connector' parameter value: "+JSON.stringify(e)):this._connector=void 0}getLogger(e,t){if(t&&3===this.level)return new be(this,e,t);if(this.loggers[e])return this.loggers[e];{const t=new be(this,e);return this.loggers[e]=t,t}}genericLog(e,t,s,i){this.level>=e&&this.builtinEnabled&&this.print(e,t,s,i),this.connector&&this.connector(ce[e],t,s,i)}print(e,t,s,i){if("string"==typeof i){const e=[new Date,t];s&&e.push(s),i=e.concat(i).join(" | ")}switch(e){case ce.error:console.error(i);break;case ce.warn:console.warn(i);break;case ce.log:console.log(i);break;case ce.debug:console.debug(i)}}}function Se(e,t){const s="\r\n";if(t.statusCode<100||t.statusCode>699)throw new TypeError("Invalid statusCode: "+t.statusCode);const i=t.reasonPhrase?t.reasonPhrase:I(t.statusCode);let r="SIP/2.0 "+t.statusCode+" "+i+s;t.statusCode>=100&&t.statusCode,t.statusCode;const n="From: "+e.getHeader("From")+s,o="Call-ID: "+e.callId+s,a="CSeq: "+e.cseq+" "+e.method+s,c=e.getHeaders("via").reduce(((e,t)=>e+"Via: "+t+s),"");let h="To: "+e.getHeader("to");if(t.statusCode>100&&!e.parseHeader("to").hasParam("tag")){let e=t.toTag;e||(e=C()),h+=";tag="+e}h+=s;let d="";t.supported&&(d="Supported: "+t.supported.join(", ")+s);let l="";t.userAgent&&(l="User-Agent: "+t.userAgent+s);let g="";return t.extraHeaders&&(g=t.extraHeaders.reduce(((e,t)=>e+t.trim()+s),"")),r+=c,r+=n,r+=h,r+=a,r+=o,r+=d,r+=l,r+=g,t.body?(r+="Content-Type: "+t.body.contentType+s,r+="Content-Length: "+D(t.body.content)+s+s,r+=t.body.content):r+="Content-Length: 0\r\n\r\n",{message:r}}!function(e){function t(e,t){let s=t,i=0,r=0;if(e.substring(s,s+2).match(/(^\r\n)/))return-2;for(;0===i;){if(r=e.indexOf("\r\n",s),-1===r)return r;!e.substring(r+2,r+4).match(/(^\r\n)/)&&e.charAt(r+2).match(/(^\s+)/)?s=r+2:i=r}return i}function s(e,t,s,i){const r=t.indexOf(":",s),n=t.substring(s,r).trim(),o=t.substring(r+1,i).trim();let a;switch(n.toLowerCase()){case"via":case"v":e.addHeader("via",o),1===e.getHeaders("via").length?(a=e.parseHeader("Via"),a&&(e.via=a,e.viaBranch=a.branch)):a=0;break;case"from":case"f":e.setHeader("from",o),a=e.parseHeader("from"),a&&(e.from=a,e.fromTag=a.getParam("tag"));break;case"to":case"t":e.setHeader("to",o),a=e.parseHeader("to"),a&&(e.to=a,e.toTag=a.getParam("tag"));break;case"record-route":if(a=y.parse(o,"Record_Route"),-1===a){a=void 0;break}if(!(a instanceof Array)){a=void 0;break}a.forEach((t=>{e.addHeader("record-route",o.substring(t.position,t.offset)),e.headers["Record-Route"][e.getHeaders("record-route").length-1].parsed=t.parsed}));break;case"call-id":case"i":e.setHeader("call-id",o),a=e.parseHeader("call-id"),a&&(e.callId=o);break;case"contact":case"m":if(a=y.parse(o,"Contact"),-1===a){a=void 0;break}if(!(a instanceof Array)){a=void 0;break}a.forEach((t=>{e.addHeader("contact",o.substring(t.position,t.offset)),e.headers.Contact[e.getHeaders("contact").length-1].parsed=t.parsed}));break;case"content-length":case"l":e.setHeader("content-length",o),a=e.parseHeader("content-length");break;case"content-type":case"c":e.setHeader("content-type",o),a=e.parseHeader("content-type");break;case"cseq":e.setHeader("cseq",o),a=e.parseHeader("cseq"),a&&(e.cseq=a.value),e instanceof x&&(e.method=a.method);break;case"max-forwards":e.setHeader("max-forwards",o),a=e.parseHeader("max-forwards");break;case"www-authenticate":e.setHeader("www-authenticate",o),a=e.parseHeader("www-authenticate");break;case"proxy-authenticate":e.setHeader("proxy-authenticate",o),a=e.parseHeader("proxy-authenticate");break;case"refer-to":case"r":e.setHeader("refer-to",o),a=e.parseHeader("refer-to"),a&&(e.referTo=a);break;default:e.addHeader(n.toLowerCase(),o),a=0}return void 0!==a||{error:"error parsing header '"+n+"'"}}e.getHeader=t,e.parseHeader=s,e.parseMessage=function(e,i){let r=0,n=e.indexOf("\r\n");if(-1===n)return void i.warn("no CRLF found, not a SIP message, discarded");const o=e.substring(0,n),a=y.parse(o,"Request_Response");let c,h;if(-1!==a){for(a.status_code?(c=new x,c.statusCode=a.status_code,c.reasonPhrase=a.reason_phrase):(c=new P,c.method=a.method,c.ruri=a.uri),c.data=e,r=n+2;;){if(n=t(e,r),-2===n){h=r+2;break}if(-1===n)return void i.error("malformed message");const o=s(c,e,r,n);if(o&&!0!==o)return void i.error(o.error);r=n+2}return c.hasHeader("content-length")?c.body=e.substr(h,Number(c.getHeader("content-length"))):c.body=e.substring(h),c}i.warn('error parsing first line of SIP message: "'+o+'"')}}(he=he||(he={}));class Re extends n{constructor(e){super(e||"Unspecified transport error.")}}class ye{constructor(e,t,s,i,r){this._transport=e,this._user=t,this._id=s,this._state=i,this.listeners=new Array,this.logger=t.loggerFactory.getLogger(r,s),this.logger.debug(`Constructing ${this.typeToString()} with id ${this.id}.`)}dispose(){this.logger.debug(`Destroyed ${this.typeToString()} with id ${this.id}.`)}get id(){return this._id}get kind(){throw new Error("Invalid kind.")}get state(){return this._state}get transport(){return this._transport}addStateChangeListener(e,t){const s=()=>{this.removeStateChangeListener(s),e()};!0===(null==t?void 0:t.once)?this.listeners.push(s):this.listeners.push(e)}notifyStateChangeListeners(){this.listeners.slice().forEach((e=>e()))}removeStateChangeListener(e){this.listeners=this.listeners.filter((t=>t!==e))}logTransportError(e,t){this.logger.error(e.message),this.logger.error(`Transport error occurred in ${this.typeToString()} with id ${this.id}.`),this.logger.error(t)}send(e){return this.transport.send(e).catch((e=>{if(e instanceof Re)throw this.onTransportError(e),e;let t;throw t=e&&"string"==typeof e.message?new Re(e.message):new Re,this.onTransportError(t),t}))}setState(e){this.logger.debug(`State change to "${e}" on ${this.typeToString()} with id ${this.id}.`),this._state=e,this._user.onStateChange&&this._user.onStateChange(e),this.notifyStateChangeListeners()}typeToString(){return"UnknownType"}}class Ee extends ye{constructor(e,t,s,i,r){super(t,s,e.viaBranch,i,r),this._request=e,this.user=s}get request(){return this._request}}!function(e){e.Accepted="Accepted",e.Calling="Calling",e.Completed="Completed",e.Confirmed="Confirmed",e.Proceeding="Proceeding",e.Terminated="Terminated",e.Trying="Trying"}(de=de||(de={}));class $e extends Ee{constructor(e,t,s){super(e,t,s,de.Proceeding,"sip.transaction.ist")}dispose(){this.stopProgressExtensionTimer(),this.H&&(clearTimeout(this.H),this.H=void 0),this.I&&(clearTimeout(this.I),this.I=void 0),this.L&&(clearTimeout(this.L),this.L=void 0),super.dispose()}get kind(){return"ist"}receiveRequest(e){switch(this.state){case de.Proceeding:if(e.method===G.INVITE)return void(this.lastProvisionalResponse&&this.send(this.lastProvisionalResponse).catch((e=>{this.logTransportError(e,"Failed to send retransmission of provisional response.")})));break;case de.Accepted:if(e.method===G.INVITE)return;break;case de.Completed:if(e.method===G.INVITE){if(!this.lastFinalResponse)throw new Error("Last final response undefined.");return void this.send(this.lastFinalResponse).catch((e=>{this.logTransportError(e,"Failed to send retransmission of final response.")}))}if(e.method===G.ACK)return void this.stateTransition(de.Confirmed);break;case de.Confirmed:case de.Terminated:if(e.method===G.INVITE||e.method===G.ACK)return;break;default:throw new Error(`Invalid state ${this.state}`)}const t=`INVITE server transaction received unexpected ${e.method} request while in state ${this.state}.`;this.logger.warn(t)}receiveResponse(e,t){if(e<100||e>699)throw new Error(`Invalid status code ${e}`);switch(this.state){case de.Proceeding:if(e>=100&&e<=199)return this.lastProvisionalResponse=t,e>100&&this.startProgressExtensionTimer(),void this.send(t).catch((e=>{this.logTransportError(e,"Failed to send 1xx response.")}));if(e>=200&&e<=299)return this.lastFinalResponse=t,this.stateTransition(de.Accepted),void this.send(t).catch((e=>{this.logTransportError(e,"Failed to send 2xx response.")}));if(e>=300&&e<=699)return this.lastFinalResponse=t,this.stateTransition(de.Completed),void this.send(t).catch((e=>{this.logTransportError(e,"Failed to send non-2xx final response.")}));break;case de.Accepted:if(e>=200&&e<=299)return void this.send(t).catch((e=>{this.logTransportError(e,"Failed to send 2xx response.")}));break;case de.Completed:case de.Confirmed:case de.Terminated:break;default:throw new Error(`Invalid state ${this.state}`)}const s=`INVITE server transaction received unexpected ${e} response from TU while in state ${this.state}.`;throw this.logger.error(s),new Error(s)}retransmitAcceptedResponse(){this.state===de.Accepted&&this.lastFinalResponse&&this.send(this.lastFinalResponse).catch((e=>{this.logTransportError(e,"Failed to send 2xx response.")}))}onTransportError(e){this.user.onTransportError&&this.user.onTransportError(e)}typeToString(){return"INVITE server transaction"}stateTransition(e){const t=()=>{throw new Error(`Invalid state transition from ${this.state} to ${e}`)};switch(e){case de.Proceeding:t();break;case de.Accepted:case de.Completed:this.state!==de.Proceeding&&t();break;case de.Confirmed:this.state!==de.Completed&&t();break;case de.Terminated:this.state!==de.Accepted&&this.state!==de.Completed&&this.state!==de.Confirmed&&t();break;default:t()}this.stopProgressExtensionTimer(),e===de.Accepted&&(this.L=setTimeout((()=>this.timerL()),L.TIMER_L)),e===de.Completed&&(this.H=setTimeout((()=>this.timerH()),L.TIMER_H)),e===de.Confirmed&&(this.I=setTimeout((()=>this.timerI()),L.TIMER_I)),e===de.Terminated&&this.dispose(),this.setState(e)}startProgressExtensionTimer(){void 0===this.progressExtensionTimer&&(this.progressExtensionTimer=setInterval((()=>{if(this.logger.debug(`Progress extension timer expired for INVITE server transaction ${this.id}.`),!this.lastProvisionalResponse)throw new Error("Last provisional response undefined.");this.send(this.lastProvisionalResponse).catch((e=>{this.logTransportError(e,"Failed to send retransmission of provisional response.")}))}),L.PROVISIONAL_RESPONSE_INTERVAL))}stopProgressExtensionTimer(){void 0!==this.progressExtensionTimer&&(clearInterval(this.progressExtensionTimer),this.progressExtensionTimer=void 0)}timerG(){}timerH(){this.logger.debug(`Timer H expired for INVITE server transaction ${this.id}.`),this.state===de.Completed&&(this.logger.warn("ACK to negative final response was never received, terminating transaction."),this.stateTransition(de.Terminated))}timerI(){this.logger.debug(`Timer I expired for INVITE server transaction ${this.id}.`),this.stateTransition(de.Terminated)}timerL(){this.logger.debug(`Timer L expired for INVITE server transaction ${this.id}.`),this.state===de.Accepted&&this.stateTransition(de.Terminated)}}class Ie extends ye{constructor(e,t,s,i,r){super(t,s,Ie.makeId(e),i,r),this._request=e,this.user=s,e.setViaHeader(this.id,t.protocol)}static makeId(e){if("CANCEL"===e.method){if(!e.branch)throw new Error("Outgoing CANCEL request without a branch.");return e.branch}return"z9hG4bK"+Math.floor(1e7*Math.random())}get request(){return this._request}onRequestTimeout(){this.user.onRequestTimeout&&this.user.onRequestTimeout()}}class Ce extends Ie{constructor(e,t,s){super(e,t,s,de.Trying,"sip.transaction.nict"),this.F=setTimeout((()=>this.timerF()),L.TIMER_F),this.send(e.toString()).catch((e=>{this.logTransportError(e,"Failed to send initial outgoing request.")}))}dispose(){this.F&&(clearTimeout(this.F),this.F=void 0),this.K&&(clearTimeout(this.K),this.K=void 0),super.dispose()}get kind(){return"nict"}receiveResponse(e){const t=e.statusCode;if(!t||t<100||t>699)throw new Error(`Invalid status code ${t}`);switch(this.state){case de.Trying:if(t>=100&&t<=199)return this.stateTransition(de.Proceeding),void(this.user.receiveResponse&&this.user.receiveResponse(e));if(t>=200&&t<=699)return this.stateTransition(de.Completed),408===t?void this.onRequestTimeout():void(this.user.receiveResponse&&this.user.receiveResponse(e));break;case de.Proceeding:if(t>=100&&t<=199&&this.user.receiveResponse)return this.user.receiveResponse(e);if(t>=200&&t<=699)return this.stateTransition(de.Completed),408===t?void this.onRequestTimeout():void(this.user.receiveResponse&&this.user.receiveResponse(e));break;case de.Completed:case de.Terminated:return;default:throw new Error(`Invalid state ${this.state}`)}const s=`Non-INVITE client transaction received unexpected ${t} response while in state ${this.state}.`;this.logger.warn(s)}onTransportError(e){this.user.onTransportError&&this.user.onTransportError(e),this.stateTransition(de.Terminated,!0)}typeToString(){return"non-INVITE client transaction"}stateTransition(e,t=!1){const s=()=>{throw new Error(`Invalid state transition from ${this.state} to ${e}`)};switch(e){case de.Trying:s();break;case de.Proceeding:this.state!==de.Trying&&s();break;case de.Completed:this.state!==de.Trying&&this.state!==de.Proceeding&&s();break;case de.Terminated:this.state!==de.Trying&&this.state!==de.Proceeding&&this.state!==de.Completed&&(t||s());break;default:s()}e===de.Completed&&(this.F&&(clearTimeout(this.F),this.F=void 0),this.K=setTimeout((()=>this.timerK()),L.TIMER_K)),e===de.Terminated&&this.dispose(),this.setState(e)}timerF(){this.logger.debug(`Timer F expired for non-INVITE client transaction ${this.id}.`),this.state!==de.Trying&&this.state!==de.Proceeding||(this.onRequestTimeout(),this.stateTransition(de.Terminated))}timerK(){this.state===de.Completed&&this.stateTransition(de.Terminated)}}class Ae{constructor(e,t){this.core=e,this.dialogState=t,this.core.dialogs.set(this.id,this)}static initialDialogStateForUserAgentClient(e,t){const s=t.getHeaders("record-route").reverse(),i=t.parseHeader("contact");if(!i)throw new Error("Contact undefined.");if(!(i instanceof v))throw new Error("Contact not instance of NameAddrHeader.");const r=i.uri,n=e.cseq,o=e.callId,a=e.fromTag,c=t.toTag;if(!o)throw new Error("Call id undefined.");if(!a)throw new Error("From tag undefined.");if(!c)throw new Error("To tag undefined.");if(!e.from)throw new Error("From undefined.");if(!e.to)throw new Error("To undefined.");const h=e.from.uri,d=e.to.uri;if(!t.statusCode)throw new Error("Incoming response status code undefined.");return{id:o+a+c,early:t.statusCode<200,callId:o,localTag:a,remoteTag:c,localSequenceNumber:n,remoteSequenceNumber:undefined,localURI:h,remoteURI:d,remoteTarget:r,routeSet:s,secure:!1}}static initialDialogStateForUserAgentServer(e,t,s=!1){const i=e.getHeaders("record-route"),r=e.parseHeader("contact");if(!r)throw new Error("Contact undefined.");if(!(r instanceof v))throw new Error("Contact not instance of NameAddrHeader.");const n=r.uri,o=e.cseq,a=e.callId,c=t,h=e.fromTag,d=e.from.uri;return{id:a+c+h,early:s,callId:a,localTag:c,remoteTag:h,localSequenceNumber:undefined,remoteSequenceNumber:o,localURI:e.to.uri,remoteURI:d,remoteTarget:n,routeSet:i,secure:!1}}dispose(){this.core.dialogs.delete(this.id)}get id(){return this.dialogState.id}get early(){return this.dialogState.early}get callId(){return this.dialogState.callId}get localTag(){return this.dialogState.localTag}get remoteTag(){return this.dialogState.remoteTag}get localSequenceNumber(){return this.dialogState.localSequenceNumber}get remoteSequenceNumber(){return this.dialogState.remoteSequenceNumber}get localURI(){return this.dialogState.localURI}get remoteURI(){return this.dialogState.remoteURI}get remoteTarget(){return this.dialogState.remoteTarget}get routeSet(){return this.dialogState.routeSet}get secure(){return this.dialogState.secure}get userAgentCore(){return this.core}confirm(){this.dialogState.early=!1}receiveRequest(e){if(e.method!==G.ACK){if(this.remoteSequenceNumber){if(e.cseq<=this.remoteSequenceNumber)throw new Error("Out of sequence in dialog request. Did you forget to call sequenceGuard()?");this.dialogState.remoteSequenceNumber=e.cseq}this.remoteSequenceNumber||(this.dialogState.remoteSequenceNumber=e.cseq)}}recomputeRouteSet(e){this.dialogState.routeSet=e.getHeaders("record-route").reverse()}createOutgoingRequestMessage(e,t){const s=this.remoteURI,i=this.remoteTag,r=this.localURI,n=this.localTag,o=this.callId;let a;a=t&&t.cseq?t.cseq:this.dialogState.localSequenceNumber?this.dialogState.localSequenceNumber+=1:this.dialogState.localSequenceNumber=1;const c=this.remoteTarget,h=this.routeSet,d=t&&t.extraHeaders,l=t&&t.body;return this.userAgentCore.makeOutgoingRequestMessage(e,c,r,s,{callId:o,cseq:a,fromTag:n,toTag:i,routeSet:h},d,l)}incrementLocalSequenceNumber(){if(!this.dialogState.localSequenceNumber)throw new Error("Local sequence number undefined.");this.dialogState.localSequenceNumber+=1}sequenceGuard(e){return e.method===G.ACK||(!(this.remoteSequenceNumber&&e.cseq<=this.remoteSequenceNumber)||(this.core.replyStateless(e,{statusCode:500}),!1))}}class De extends Ie{constructor(e,t,s){super(e,t,s,de.Calling,"sip.transaction.ict"),this.ackRetransmissionCache=new Map,this.B=setTimeout((()=>this.timerB()),L.TIMER_B),this.send(e.toString()).catch((e=>{this.logTransportError(e,"Failed to send initial outgoing request.")}))}dispose(){this.B&&(clearTimeout(this.B),this.B=void 0),this.D&&(clearTimeout(this.D),this.D=void 0),this.M&&(clearTimeout(this.M),this.M=void 0),super.dispose()}get kind(){return"ict"}ackResponse(e){const t=e.toTag;if(!t)throw new Error("To tag undefined.");const s="z9hG4bK"+Math.floor(1e7*Math.random());e.setViaHeader(s,this.transport.protocol),this.ackRetransmissionCache.set(t,e),this.send(e.toString()).catch((e=>{this.logTransportError(e,"Failed to send ACK to 2xx response.")}))}receiveResponse(e){const t=e.statusCode;if(!t||t<100||t>699)throw new Error(`Invalid status code ${t}`);switch(this.state){case de.Calling:if(t>=100&&t<=199)return this.stateTransition(de.Proceeding),void(this.user.receiveResponse&&this.user.receiveResponse(e));if(t>=200&&t<=299)return this.ackRetransmissionCache.set(e.toTag,void 0),this.stateTransition(de.Accepted),void(this.user.receiveResponse&&this.user.receiveResponse(e));if(t>=300&&t<=699)return this.stateTransition(de.Completed),this.ack(e),void(this.user.receiveResponse&&this.user.receiveResponse(e));break;case de.Proceeding:if(t>=100&&t<=199)return void(this.user.receiveResponse&&this.user.receiveResponse(e));if(t>=200&&t<=299)return this.ackRetransmissionCache.set(e.toTag,void 0),this.stateTransition(de.Accepted),void(this.user.receiveResponse&&this.user.receiveResponse(e));if(t>=300&&t<=699)return this.stateTransition(de.Completed),this.ack(e),void(this.user.receiveResponse&&this.user.receiveResponse(e));break;case de.Accepted:if(t>=200&&t<=299){if(!this.ackRetransmissionCache.has(e.toTag))return this.ackRetransmissionCache.set(e.toTag,void 0),void(this.user.receiveResponse&&this.user.receiveResponse(e));const t=this.ackRetransmissionCache.get(e.toTag);return t?void this.send(t.toString()).catch((e=>{this.logTransportError(e,"Failed to send retransmission of ACK to 2xx response.")})):void 0}break;case de.Completed:if(t>=300&&t<=699)return void this.ack(e);break;case de.Terminated:break;default:throw new Error(`Invalid state ${this.state}`)}const s=`Received unexpected ${t} response while in state ${this.state}.`;this.logger.warn(s)}onTransportError(e){this.user.onTransportError&&this.user.onTransportError(e),this.stateTransition(de.Terminated,!0)}typeToString(){return"INVITE client transaction"}ack(e){const t=this.request.ruri,s=this.request.callId,i=this.request.cseq,r=this.request.getHeader("from"),n=e.getHeader("to"),o=this.request.getHeader("via"),a=this.request.getHeader("route");if(!r)throw new Error("From undefined.");if(!n)throw new Error("To undefined.");if(!o)throw new Error("Via undefined.");let c=`ACK ${t} SIP/2.0\r\n`;a&&(c+=`Route: ${a}\r\n`),c+=`Via: ${o}\r\n`,c+=`To: ${n}\r\n`,c+=`From: ${r}\r\n`,c+=`Call-ID: ${s}\r\n`,c+=`CSeq: ${i} ACK\r\n`,c+="Max-Forwards: 70\r\n",c+="Content-Length: 0\r\n\r\n",this.send(c).catch((e=>{this.logTransportError(e,"Failed to send ACK to non-2xx response.")}))}stateTransition(e,t=!1){const s=()=>{throw new Error(`Invalid state transition from ${this.state} to ${e}`)};switch(e){case de.Calling:s();break;case de.Proceeding:this.state!==de.Calling&&s();break;case de.Accepted:case de.Completed:this.state!==de.Calling&&this.state!==de.Proceeding&&s();break;case de.Terminated:this.state!==de.Calling&&this.state!==de.Accepted&&this.state!==de.Completed&&(t||s());break;default:s()}this.B&&(clearTimeout(this.B),this.B=void 0),de.Proceeding,e===de.Completed&&(this.D=setTimeout((()=>this.timerD()),L.TIMER_D)),e===de.Accepted&&(this.M=setTimeout((()=>this.timerM()),L.TIMER_M)),e===de.Terminated&&this.dispose(),this.setState(e)}timerA(){}timerB(){this.logger.debug(`Timer B expired for INVITE client transaction ${this.id}.`),this.state===de.Calling&&(this.onRequestTimeout(),this.stateTransition(de.Terminated))}timerD(){this.logger.debug(`Timer D expired for INVITE client transaction ${this.id}.`),this.state===de.Completed&&this.stateTransition(de.Terminated)}timerM(){this.logger.debug(`Timer M expired for INVITE client transaction ${this.id}.`),this.state===de.Accepted&&this.stateTransition(de.Terminated)}}class He{constructor(e,t,s,i){this.transactionConstructor=e,this.core=t,this.message=s,this.delegate=i,this.challenged=!1,this.stale=!1,this.logger=this.loggerFactory.getLogger("sip.user-agent-client"),this.init()}dispose(){this.transaction.dispose()}get loggerFactory(){return this.core.loggerFactory}get transaction(){if(!this._transaction)throw new Error("Transaction undefined.");return this._transaction}cancel(e,t={}){if(!this.transaction)throw new Error("Transaction undefined.");if(!this.message.to)throw new Error("To undefined.");if(!this.message.from)throw new Error("From undefined.");const s=this.core.makeOutgoingRequestMessage(G.CANCEL,this.message.ruri,this.message.from.uri,this.message.to.uri,{toTag:this.message.toTag,fromTag:this.message.fromTag,callId:this.message.callId,cseq:this.message.cseq},t.extraHeaders);return s.branch=this.message.branch,this.message.headers.Route&&(s.headers.Route=this.message.headers.Route),e&&s.setHeader("Reason",e),this.transaction.state===de.Proceeding?new He(Ce,this.core,s):this.transaction.addStateChangeListener((()=>{this.transaction&&this.transaction.state===de.Proceeding&&new He(Ce,this.core,s)}),{once:!0}),s}authenticationGuard(e,t){const s=e.statusCode;if(!s)throw new Error("Response status code undefined.");if(401!==s&&407!==s)return!0;let i,r;if(401===s?(i=e.parseHeader("www-authenticate"),r="authorization"):(i=e.parseHeader("proxy-authenticate"),r="proxy-authorization"),!i)return this.logger.warn(s+" with wrong or missing challenge, cannot authenticate"),!0;if(this.challenged&&(this.stale||!0!==i.stale))return this.logger.warn(s+" apparently in authentication loop, cannot authenticate"),!0;if(!this.credentials&&(this.credentials=this.core.configuration.authenticationFactory(),!this.credentials))return this.logger.warn("Unable to obtain credentials, cannot authenticate"),!0;if(!this.credentials.authenticate(this.message,i))return!0;this.challenged=!0,i.stale&&(this.stale=!0);let n=this.message.cseq+=1;return t&&t.localSequenceNumber&&(t.incrementLocalSequenceNumber(),n=this.message.cseq=t.localSequenceNumber),this.message.setHeader("cseq",n+" "+this.message.method),this.message.setHeader(r,this.credentials.toString()),this.init(),!1}onRequestTimeout(){this.logger.warn("User agent client request timed out. Generating internal 408 Request Timeout.");const e=new x;e.statusCode=408,e.reasonPhrase="Request Timeout",this.receiveResponse(e)}onTransportError(e){this.logger.error(e.message),this.logger.error("User agent client request transport error. Generating internal 503 Service Unavailable.");const t=new x;t.statusCode=503,t.reasonPhrase="Service Unavailable",this.receiveResponse(t)}receiveResponse(e){if(!this.authenticationGuard(e))return;const t=e.statusCode?e.statusCode.toString():"";if(!t)throw new Error("Response status code undefined.");switch(!0){case/^100$/.test(t):this.delegate&&this.delegate.onTrying&&this.delegate.onTrying({message:e});break;case/^1[0-9]{2}$/.test(t):this.delegate&&this.delegate.onProgress&&this.delegate.onProgress({message:e});break;case/^2[0-9]{2}$/.test(t):this.delegate&&this.delegate.onAccept&&this.delegate.onAccept({message:e});break;case/^3[0-9]{2}$/.test(t):this.delegate&&this.delegate.onRedirect&&this.delegate.onRedirect({message:e});break;case/^[4-6][0-9]{2}$/.test(t):this.delegate&&this.delegate.onReject&&this.delegate.onReject({message:e});break;default:throw new Error(`Invalid status code ${t}`)}}init(){const e={loggerFactory:this.loggerFactory,onRequestTimeout:()=>this.onRequestTimeout(),onStateChange:e=>{e===de.Terminated&&(this.core.userAgentClients.delete(s),t===this._transaction&&this.dispose())},onTransportError:e=>this.onTransportError(e),receiveResponse:e=>this.receiveResponse(e)},t=new this.transactionConstructor(this.message,this.core.transport,e);this._transaction=t;const s=t.id+t.request.method;this.core.userAgentClients.set(s,this)}}class Pe extends He{constructor(e,t,s){const i=e.createOutgoingRequestMessage(G.BYE,s);super(Ce,e.userAgentCore,i,t),e.dispose()}}class xe extends Ee{constructor(e,t,s){super(e,t,s,de.Trying,"sip.transaction.nist")}dispose(){this.J&&(clearTimeout(this.J),this.J=void 0),super.dispose()}get kind(){return"nist"}receiveRequest(e){switch(this.state){case de.Trying:break;case de.Proceeding:if(!this.lastResponse)throw new Error("Last response undefined.");this.send(this.lastResponse).catch((e=>{this.logTransportError(e,"Failed to send retransmission of provisional response.")}));break;case de.Completed:if(!this.lastResponse)throw new Error("Last response undefined.");this.send(this.lastResponse).catch((e=>{this.logTransportError(e,"Failed to send retransmission of final response.")}));break;case de.Terminated:break;default:throw new Error(`Invalid state ${this.state}`)}}receiveResponse(e,t){if(e<100||e>699)throw new Error(`Invalid status code ${e}`);if(e>100&&e<=199)throw new Error("Provisional response other than 100 not allowed.");switch(this.state){case de.Trying:if(this.lastResponse=t,e>=100&&e<200)return this.stateTransition(de.Proceeding),void this.send(t).catch((e=>{this.logTransportError(e,"Failed to send provisional response.")}));if(e>=200&&e<=699)return this.stateTransition(de.Completed),void this.send(t).catch((e=>{this.logTransportError(e,"Failed to send final response.")}));break;case de.Proceeding:if(this.lastResponse=t,e>=200&&e<=699)return this.stateTransition(de.Completed),void this.send(t).catch((e=>{this.logTransportError(e,"Failed to send final response.")}));break;case de.Completed:return;case de.Terminated:break;default:throw new Error(`Invalid state ${this.state}`)}const s=`Non-INVITE server transaction received unexpected ${e} response from TU while in state ${this.state}.`;throw this.logger.error(s),new Error(s)}onTransportError(e){this.user.onTransportError&&this.user.onTransportError(e),this.stateTransition(de.Terminated,!0)}typeToString(){return"non-INVITE server transaction"}stateTransition(e,t=!1){const s=()=>{throw new Error(`Invalid state transition from ${this.state} to ${e}`)};switch(e){case de.Trying:s();break;case de.Proceeding:this.state!==de.Trying&&s();break;case de.Completed:this.state!==de.Trying&&this.state!==de.Proceeding&&s();break;case de.Terminated:this.state!==de.Proceeding&&this.state!==de.Completed&&(t||s());break;default:s()}e===de.Completed&&(this.J=setTimeout((()=>this.timerJ()),L.TIMER_J)),e===de.Terminated&&this.dispose(),this.setState(e)}timerJ(){this.logger.debug(`Timer J expired for NON-INVITE server transaction ${this.id}.`),this.state===de.Completed&&this.stateTransition(de.Terminated)}}class ke{constructor(e,t,s,i){this.transactionConstructor=e,this.core=t,this.message=s,this.delegate=i,this.logger=this.loggerFactory.getLogger("sip.user-agent-server"),this.toTag=s.toTag?s.toTag:C(),this.init()}dispose(){this.transaction.dispose()}get loggerFactory(){return this.core.loggerFactory}get transaction(){if(!this._transaction)throw new Error("Transaction undefined.");return this._transaction}accept(e={statusCode:200}){if(!this.acceptable)throw new B(`${this.message.method} not acceptable in state ${this.transaction.state}.`);const t=e.statusCode;if(t<200||t>299)throw new TypeError(`Invalid statusCode: ${t}`);return this.reply(e)}progress(e={statusCode:180}){if(!this.progressable)throw new B(`${this.message.method} not progressable in state ${this.transaction.state}.`);const t=e.statusCode;if(t<101||t>199)throw new TypeError(`Invalid statusCode: ${t}`);return this.reply(e)}redirect(e,t={statusCode:302}){if(!this.redirectable)throw new B(`${this.message.method} not redirectable in state ${this.transaction.state}.`);const s=t.statusCode;if(s<300||s>399)throw new TypeError(`Invalid statusCode: ${s}`);const i=new Array;e.forEach((e=>i.push(`Contact: ${e.toString()}`))),t.extraHeaders=(t.extraHeaders||[]).concat(i);return this.reply(t)}reject(e={statusCode:480}){if(!this.rejectable)throw new B(`${this.message.method} not rejectable in state ${this.transaction.state}.`);const t=e.statusCode;if(t<400||t>699)throw new TypeError(`Invalid statusCode: ${t}`);return this.reply(e)}trying(e){if(!this.tryingable)throw new B(`${this.message.method} not tryingable in state ${this.transaction.state}.`);return this.reply({statusCode:100})}receiveCancel(e){this.delegate&&this.delegate.onCancel&&this.delegate.onCancel(e)}get acceptable(){if(this.transaction instanceof $e)return this.transaction.state===de.Proceeding||this.transaction.state===de.Accepted;if(this.transaction instanceof xe)return this.transaction.state===de.Trying||this.transaction.state===de.Proceeding;throw new Error("Unknown transaction type.")}get progressable(){if(this.transaction instanceof $e)return this.transaction.state===de.Proceeding;if(this.transaction instanceof xe)return!1;throw new Error("Unknown transaction type.")}get redirectable(){if(this.transaction instanceof $e)return this.transaction.state===de.Proceeding;if(this.transaction instanceof xe)return this.transaction.state===de.Trying||this.transaction.state===de.Proceeding;throw new Error("Unknown transaction type.")}get rejectable(){if(this.transaction instanceof $e)return this.transaction.state===de.Proceeding;if(this.transaction instanceof xe)return this.transaction.state===de.Trying||this.transaction.state===de.Proceeding;throw new Error("Unknown transaction type.")}get tryingable(){if(this.transaction instanceof $e)return this.transaction.state===de.Proceeding;if(this.transaction instanceof xe)return this.transaction.state===de.Trying;throw new Error("Unknown transaction type.")}reply(e){e.toTag||100===e.statusCode||(e.toTag=this.toTag),e.userAgent=e.userAgent||this.core.configuration.userAgentHeaderFieldValue,e.supported=e.supported||this.core.configuration.supportedOptionTagsResponse;const t=Se(this.message,e);return this.transaction.receiveResponse(e.statusCode,t.message),t}init(){const e={loggerFactory:this.loggerFactory,onStateChange:e=>{e===de.Terminated&&(this.core.userAgentServers.delete(s),this.dispose())},onTransportError:e=>{this.logger.error(e.message),this.delegate&&this.delegate.onTransportError?this.delegate.onTransportError(e):this.logger.error("User agent server response transport error.")}},t=new this.transactionConstructor(this.message,this.core.transport,e);this._transaction=t;const s=t.id;this.core.userAgentServers.set(t.id,this)}}class _e extends ke{constructor(e,t,s){super(xe,e.userAgentCore,t,s)}}class qe extends He{constructor(e,t,s){const i=e.createOutgoingRequestMessage(G.INFO,s);super(Ce,e.userAgentCore,i,t)}}class Me extends ke{constructor(e,t,s){super(xe,e.userAgentCore,t,s)}}class Ne extends He{constructor(e,t,s){super(Ce,e,t,s)}}class Oe extends ke{constructor(e,t,s){super(xe,e,t,s)}}class Ue extends He{constructor(e,t,s){const i=e.createOutgoingRequestMessage(G.NOTIFY,s);super(Ce,e.userAgentCore,i,t)}}class je extends ke{constructor(e,t,s){const i=void 0!==e.userAgentCore?e.userAgentCore:e;super(xe,i,t,s)}}class Fe extends He{constructor(e,t,s){const i=e.createOutgoingRequestMessage(G.PRACK,s);super(Ce,e.userAgentCore,i,t),e.signalingStateTransition(i)}}class Le extends ke{constructor(e,t,s){super(xe,e.userAgentCore,t,s),e.signalingStateTransition(t),this.dialog=e}accept(e={statusCode:200}){return e.body&&this.dialog.signalingStateTransition(e.body),super.accept(e)}}class Be extends He{constructor(e,t,s){const i=e.createOutgoingRequestMessage(G.INVITE,s);super(De,e.userAgentCore,i,t),this.delegate=t,e.signalingStateTransition(i),e.reinviteUserAgentClient=this,this.dialog=e}receiveResponse(e){if(!this.authenticationGuard(e,this.dialog))return;const t=e.statusCode?e.statusCode.toString():"";if(!t)throw new Error("Response status code undefined.");switch(!0){case/^100$/.test(t):this.delegate&&this.delegate.onTrying&&this.delegate.onTrying({message:e});break;case/^1[0-9]{2}$/.test(t):this.delegate&&this.delegate.onProgress&&this.delegate.onProgress({message:e,session:this.dialog,prack:e=>{throw new Error("Unimplemented.")}});break;case/^2[0-9]{2}$/.test(t):this.dialog.signalingStateTransition(e),this.delegate&&this.delegate.onAccept&&this.delegate.onAccept({message:e,session:this.dialog,ack:e=>this.dialog.ack(e)});break;case/^3[0-9]{2}$/.test(t):this.dialog.signalingStateRollback(),this.dialog.reinviteUserAgentClient=void 0,this.delegate&&this.delegate.onRedirect&&this.delegate.onRedirect({message:e});break;case/^[4-6][0-9]{2}$/.test(t):this.dialog.signalingStateRollback(),this.dialog.reinviteUserAgentClient=void 0,this.delegate&&this.delegate.onReject&&this.delegate.onReject({message:e});break;default:throw new Error(`Invalid status code ${t}`)}}}class Ge extends ke{constructor(e,t,s){super($e,e.userAgentCore,t,s),e.reinviteUserAgentServer=this,this.dialog=e}accept(e={statusCode:200}){e.extraHeaders=e.extraHeaders||[],e.extraHeaders=e.extraHeaders.concat(this.dialog.routeSet.map((e=>`Record-Route: ${e}`)));const t=super.accept(e),s=this.dialog,i=Object.assign(Object.assign({},t),{session:s});return e.body&&this.dialog.signalingStateTransition(e.body),this.dialog.reConfirm(),i}progress(e={statusCode:180}){const t=super.progress(e),s=this.dialog,i=Object.assign(Object.assign({},t),{session:s});return e.body&&this.dialog.signalingStateTransition(e.body),i}redirect(e,t={statusCode:302}){throw this.dialog.signalingStateRollback(),this.dialog.reinviteUserAgentServer=void 0,new Error("Unimplemented.")}reject(e={statusCode:488}){return this.dialog.signalingStateRollback(),this.dialog.reinviteUserAgentServer=void 0,super.reject(e)}}class Ve extends He{constructor(e,t,s){const i=e.createOutgoingRequestMessage(G.REFER,s);super(Ce,e.userAgentCore,i,t)}}class We extends ke{constructor(e,t,s){const i=void 0!==e.userAgentCore?e.userAgentCore:e;super(xe,i,t,s)}}class Ke extends Ae{constructor(e,t,s,i){super(t,s),this.initialTransaction=e,this._signalingState=U.Initial,this.ackWait=!1,this.ackProcessing=!1,this.delegate=i,e instanceof $e&&(this.ackWait=!0),this.early||this.start2xxRetransmissionTimer(),this.signalingStateTransition(e.request),this.logger=t.loggerFactory.getLogger("sip.invite-dialog"),this.logger.log(`INVITE dialog ${this.id} constructed`)}dispose(){super.dispose(),this._signalingState=U.Closed,this._offer=void 0,this._answer=void 0,this.invite2xxTimer&&(clearTimeout(this.invite2xxTimer),this.invite2xxTimer=void 0),this.logger.log(`INVITE dialog ${this.id} destroyed`)}get sessionState(){return this.early?O.Early:this.ackWait?O.AckWait:this._signalingState===U.Closed?O.Terminated:O.Confirmed}get signalingState(){return this._signalingState}get offer(){return this._offer}get answer(){return this._answer}confirm(){this.early&&this.start2xxRetransmissionTimer(),super.confirm()}reConfirm(){this.reinviteUserAgentServer&&this.startReInvite2xxRetransmissionTimer()}ack(e={}){let t;if(this.logger.log(`INVITE dialog ${this.id} sending ACK request`),this.reinviteUserAgentClient){if(!(this.reinviteUserAgentClient.transaction instanceof De))throw new Error("Transaction not instance of InviteClientTransaction.");t=this.reinviteUserAgentClient.transaction,this.reinviteUserAgentClient=void 0}else{if(!(this.initialTransaction instanceof De))throw new Error("Initial transaction not instance of InviteClientTransaction.");t=this.initialTransaction}const s=this.createOutgoingRequestMessage(G.ACK,{cseq:t.request.cseq,extraHeaders:e.extraHeaders,body:e.body});return t.ackResponse(s),this.signalingStateTransition(s),{message:s}}bye(e,t){if(this.logger.log(`INVITE dialog ${this.id} sending BYE request`),this.initialTransaction instanceof $e){if(this.early)throw new Error("UAS MUST NOT send a BYE on early dialogs.");if(this.ackWait&&this.initialTransaction.state!==de.Terminated)throw new Error("UAS MUST NOT send a BYE on a confirmed dialog until it has received an ACK for its 2xx response or until the server transaction times out.")}return new Pe(this,e,t)}info(e,t){if(this.logger.log(`INVITE dialog ${this.id} sending INFO request`),this.early)throw new Error("Dialog not confirmed.");return new qe(this,e,t)}invite(e,t){if(this.logger.log(`INVITE dialog ${this.id} sending INVITE request`),this.early)throw new Error("Dialog not confirmed.");if(this.reinviteUserAgentClient)throw new Error("There is an ongoing re-INVITE client transaction.");if(this.reinviteUserAgentServer)throw new Error("There is an ongoing re-INVITE server transaction.");return new Be(this,e,t)}message(e,t){if(this.logger.log(`INVITE dialog ${this.id} sending MESSAGE request`),this.early)throw new Error("Dialog not confirmed.");const s=this.createOutgoingRequestMessage(G.MESSAGE,t);return new Ne(this.core,s,e)}notify(e,t){if(this.logger.log(`INVITE dialog ${this.id} sending NOTIFY request`),this.early)throw new Error("Dialog not confirmed.");return new Ue(this,e,t)}prack(e,t){return this.logger.log(`INVITE dialog ${this.id} sending PRACK request`),new Fe(this,e,t)}refer(e,t){if(this.logger.log(`INVITE dialog ${this.id} sending REFER request`),this.early)throw new Error("Dialog not confirmed.");return new Ve(this,e,t)}receiveRequest(e){if(this.logger.log(`INVITE dialog ${this.id} received ${e.method} request`),e.method!==G.ACK)if(this.sequenceGuard(e)){if(super.receiveRequest(e),e.method===G.INVITE){const t=()=>{const e=this.ackWait?"waiting for initial ACK":"processing initial ACK";this.logger.warn(`INVITE dialog ${this.id} received re-INVITE while ${e}`);let t="RFC 5407 suggests the following to avoid this race condition... ";t+=" Note: Implementation issues are outside the scope of this document,",t+=" but the following tip is provided for avoiding race conditions of",t+=" this type. The caller can delay sending re-INVITE F6 for some period",t+=" of time (2 seconds, perhaps), after which the caller can reasonably",t+=" assume that its ACK has been received. Implementors can decouple the",t+=" actions of the user (e.g., pressing the hold button) from the actions",t+=" of the protocol (the sending of re-INVITE F6), so that the UA can",t+=" behave like this. In this case, it is the implementor's choice as to",t+=" how long to wait. In most cases, such an implementation may be",t+=" useful to prevent the type of race condition shown in this section.",t+=" This document expresses no preference about whether or not they",t+=" should wait for an ACK to be delivered. After considering the impact",t+=" on user experience, implementors should decide whether or not to wait",t+=" for a while, because the user experience depends on the",t+=" implementation and has no direct bearing on protocol behavior.",this.logger.warn("RFC 5407 suggests the following to avoid this race condition... Note: Implementation issues are outside the scope of this document, but the following tip is provided for avoiding race conditions of this type. The caller can delay sending re-INVITE F6 for some period of time (2 seconds, perhaps), after which the caller can reasonably assume that its ACK has been received. Implementors can decouple the actions of the user (e.g., pressing the hold button) from the actions of the protocol (the sending of re-INVITE F6), so that the UA can behave like this. In this case, it is the implementor's choice as to how long to wait. In most cases, such an implementation may be useful to prevent the type of race condition shown in this section. This document expresses no preference about whether or not they should wait for an ACK to be delivered. After considering the impact on user experience, implementors should decide whether or not to wait for a while, because the user experience depends on the implementation and has no direct bearing on protocol behavior.")},s=[`Retry-After: ${Math.floor(10*Math.random())+1}`];if(this.ackProcessing)return this.core.replyStateless(e,{statusCode:500,extraHeaders:s}),void t();if(this.ackWait&&this.signalingState!==U.Stable)return this.core.replyStateless(e,{statusCode:500,extraHeaders:s}),void t();if(this.reinviteUserAgentServer)return void this.core.replyStateless(e,{statusCode:500,extraHeaders:s});if(this.reinviteUserAgentClient)return void this.core.replyStateless(e,{statusCode:491})}if(e.method===G.INVITE){const t=e.parseHeader("contact");if(!t)throw new Error("Contact undefined.");if(!(t instanceof v))throw new Error("Contact not instance of NameAddrHeader.");this.dialogState.remoteTarget=t.uri}switch(e.method){case G.BYE:{const t=new _e(this,e);this.delegate&&this.delegate.onBye?this.delegate.onBye(t):t.accept(),this.dispose()}break;case G.INFO:{const t=new Me(this,e);this.delegate&&this.delegate.onInfo?this.delegate.onInfo(t):t.reject({statusCode:469,extraHeaders:["Recv-Info:"]})}break;case G.INVITE:{const t=new Ge(this,e);this.signalingStateTransition(e),this.delegate&&this.delegate.onInvite?this.delegate.onInvite(t):t.reject({statusCode:488})}break;case G.MESSAGE:{const t=new Oe(this.core,e);this.delegate&&this.delegate.onMessage?this.delegate.onMessage(t):t.accept()}break;case G.NOTIFY:{const t=new je(this,e);this.delegate&&this.delegate.onNotify?this.delegate.onNotify(t):t.accept()}break;case G.PRACK:{const t=new Le(this,e);this.delegate&&this.delegate.onPrack?this.delegate.onPrack(t):t.accept()}break;case G.REFER:{const t=new We(this,e);this.delegate&&this.delegate.onRefer?this.delegate.onRefer(t):t.reject()}break;default:this.logger.log(`INVITE dialog ${this.id} received unimplemented ${e.method} request`),this.core.replyStateless(e,{statusCode:501})}}else this.logger.log(`INVITE dialog ${this.id} rejected out of order ${e.method} request.`);else{if(this.ackWait){if(this.initialTransaction instanceof De)return void this.logger.warn(`INVITE dialog ${this.id} received unexpected ${e.method} request, dropping.`);if(this.initialTransaction.request.cseq!==e.cseq)return void this.logger.warn(`INVITE dialog ${this.id} received unexpected ${e.method} request, dropping.`);this.ackWait=!1}else{if(!this.reinviteUserAgentServer)return void this.logger.warn(`INVITE dialog ${this.id} received unexpected ${e.method} request, dropping.`);if(this.reinviteUserAgentServer.transaction.request.cseq!==e.cseq)return void this.logger.warn(`INVITE dialog ${this.id} received unexpected ${e.method} request, dropping.`);this.reinviteUserAgentServer=void 0}if(this.signalingStateTransition(e),this.delegate&&this.delegate.onAck){const t=this.delegate.onAck({message:e});t instanceof Promise&&(this.ackProcessing=!0,t.then((()=>this.ackProcessing=!1)).catch((()=>this.ackProcessing=!1)))}}}reliableSequenceGuard(e){const t=e.statusCode;if(!t)throw new Error("Status code undefined");if(t>100&&t<200){const t=e.getHeader("require"),s=e.getHeader("rseq"),i=t&&t.includes("100rel")&&s?Number(s):void 0;if(i){if(this.rseq&&this.rseq+1!==i)return!1;this.rseq=this.rseq?this.rseq+1:i}}return!0}signalingStateRollback(){this._signalingState!==U.HaveLocalOffer&&this.signalingState!==U.HaveRemoteOffer||this._rollbackOffer&&this._rollbackAnswer&&(this._signalingState=U.Stable,this._offer=this._rollbackOffer,this._answer=this._rollbackAnswer)}signalingStateTransition(e){const t=N(e);if(t&&"session"===t.contentDisposition){if(this._signalingState===U.Stable&&(this._rollbackOffer=this._offer,this._rollbackAnswer=this._answer),e instanceof P)switch(this._signalingState){case U.Initial:case U.Stable:this._signalingState=U.HaveRemoteOffer,this._offer=t,this._answer=void 0;break;case U.HaveLocalOffer:this._signalingState=U.Stable,this._answer=t;break;case U.HaveRemoteOffer:case U.Closed:break;default:throw new Error("Unexpected signaling state.")}if(e instanceof x)switch(this._signalingState){case U.Initial:case U.Stable:this._signalingState=U.HaveRemoteOffer,this._offer=t,this._answer=void 0;break;case U.HaveLocalOffer:this._signalingState=U.Stable,this._answer=t;break;case U.HaveRemoteOffer:case U.Closed:break;default:throw new Error("Unexpected signaling state.")}if(e instanceof k)switch(this._signalingState){case U.Initial:case U.Stable:this._signalingState=U.HaveLocalOffer,this._offer=t,this._answer=void 0;break;case U.HaveLocalOffer:break;case U.HaveRemoteOffer:this._signalingState=U.Stable,this._answer=t;break;case U.Closed:break;default:throw new Error("Unexpected signaling state.")}if(M(e))switch(this._signalingState){case U.Initial:case U.Stable:this._signalingState=U.HaveLocalOffer,this._offer=t,this._answer=void 0;break;case U.HaveLocalOffer:break;case U.HaveRemoteOffer:this._signalingState=U.Stable,this._answer=t;break;case U.Closed:break;default:throw new Error("Unexpected signaling state.")}}}start2xxRetransmissionTimer(){if(this.initialTransaction instanceof $e){const e=this.initialTransaction;let t=L.T1;const s=()=>{this.ackWait?(this.logger.log("No ACK for 2xx response received, attempting retransmission"),e.retransmitAcceptedResponse(),t=Math.min(2*t,L.T2),this.invite2xxTimer=setTimeout(s,t)):this.invite2xxTimer=void 0};this.invite2xxTimer=setTimeout(s,t);const i=()=>{e.state===de.Terminated&&(e.removeStateChangeListener(i),this.invite2xxTimer&&(clearTimeout(this.invite2xxTimer),this.invite2xxTimer=void 0),this.ackWait&&(this.delegate&&this.delegate.onAckTimeout?this.delegate.onAckTimeout():this.bye()))};e.addStateChangeListener(i)}}startReInvite2xxRetransmissionTimer(){if(this.reinviteUserAgentServer&&this.reinviteUserAgentServer.transaction instanceof $e){const e=this.reinviteUserAgentServer.transaction;let t=L.T1;const s=()=>{this.reinviteUserAgentServer?(this.logger.log("No ACK for 2xx response received, attempting retransmission"),e.retransmitAcceptedResponse(),t=Math.min(2*t,L.T2),this.invite2xxTimer=setTimeout(s,t)):this.invite2xxTimer=void 0};this.invite2xxTimer=setTimeout(s,t);const i=()=>{e.state===de.Terminated&&(e.removeStateChangeListener(i),this.invite2xxTimer&&(clearTimeout(this.invite2xxTimer),this.invite2xxTimer=void 0),this.reinviteUserAgentServer)};e.addStateChangeListener(i)}}}class Ye extends He{constructor(e,t,s){super(De,e,t,s),this.confirmedDialogAcks=new Map,this.confirmedDialogs=new Map,this.earlyDialogs=new Map,this.delegate=s}dispose(){this.earlyDialogs.forEach((e=>e.dispose())),this.earlyDialogs.clear(),super.dispose()}onTransportError(e){if(this.transaction.state===de.Calling)return super.onTransportError(e);this.logger.error(e.message),this.logger.error("User agent client request transport error while sending ACK.")}receiveResponse(e){if(!this.authenticationGuard(e))return;const t=e.statusCode?e.statusCode.toString():"";if(!t)throw new Error("Response status code undefined.");switch(!0){case/^100$/.test(t):return void(this.delegate&&this.delegate.onTrying&&this.delegate.onTrying({message:e}));case/^1[0-9]{2}$/.test(t):{if(!e.toTag)return void this.logger.warn("Non-100 1xx INVITE response received without a to tag, dropping.");if(!e.parseHeader("contact"))return void this.logger.error("Non-100 1xx INVITE response received without a Contact header field, dropping.");const t=Ae.initialDialogStateForUserAgentClient(this.message,e);let s=this.earlyDialogs.get(t.id);if(!s){const e=this.transaction;if(!(e instanceof De))throw new Error("Transaction not instance of InviteClientTransaction.");s=new Ke(e,this.core,t),this.earlyDialogs.set(s.id,s)}if(!s.reliableSequenceGuard(e))return void this.logger.warn("1xx INVITE reliable response received out of order or is a retransmission, dropping.");s.signalingState!==U.Initial&&s.signalingState!==U.HaveLocalOffer||s.signalingStateTransition(e);const i=s;this.delegate&&this.delegate.onProgress&&this.delegate.onProgress({message:e,session:i,prack:e=>i.prack(void 0,e)})}return;case/^2[0-9]{2}$/.test(t):{if(!e.toTag)return void this.logger.error("2xx INVITE response received without a to tag, dropping.");if(!e.parseHeader("contact"))return void this.logger.error("2xx INVITE response received without a Contact header field, dropping.");const t=Ae.initialDialogStateForUserAgentClient(this.message,e);let s=this.confirmedDialogs.get(t.id);if(s){const e=this.confirmedDialogAcks.get(t.id);if(e){const t=this.transaction;if(!(t instanceof De))throw new Error("Client transaction not instance of InviteClientTransaction.");t.ackResponse(e.message)}return}if(s=this.earlyDialogs.get(t.id),s)s.confirm(),s.recomputeRouteSet(e),this.earlyDialogs.delete(s.id),this.confirmedDialogs.set(s.id,s);else{const e=this.transaction;if(!(e instanceof De))throw new Error("Transaction not instance of InviteClientTransaction.");s=new Ke(e,this.core,t),this.confirmedDialogs.set(s.id,s)}s.signalingState!==U.Initial&&s.signalingState!==U.HaveLocalOffer||s.signalingStateTransition(e);const i=s;if(this.delegate&&this.delegate.onAccept)this.delegate.onAccept({message:e,session:i,ack:e=>{const t=i.ack(e);return this.confirmedDialogAcks.set(i.id,t),t}});else{const e=i.ack();this.confirmedDialogAcks.set(i.id,e)}}return;case/^3[0-9]{2}$/.test(t):return this.earlyDialogs.forEach((e=>e.dispose())),this.earlyDialogs.clear(),void(this.delegate&&this.delegate.onRedirect&&this.delegate.onRedirect({message:e}));case/^[4-6][0-9]{2}$/.test(t):return this.earlyDialogs.forEach((e=>e.dispose())),this.earlyDialogs.clear(),void(this.delegate&&this.delegate.onReject&&this.delegate.onReject({message:e}));default:throw new Error(`Invalid status code ${t}`)}throw new Error(`Executing what should be an unreachable code path receiving ${t} response.`)}}class Ze extends ke{constructor(e,t,s){super($e,e,t,s),this.core=e}dispose(){this.earlyDialog&&this.earlyDialog.dispose(),super.dispose()}accept(e={statusCode:200}){if(!this.acceptable)throw new B(`${this.message.method} not acceptable in state ${this.transaction.state}.`);if(!this.confirmedDialog)if(this.earlyDialog)this.earlyDialog.confirm(),this.confirmedDialog=this.earlyDialog,this.earlyDialog=void 0;else{const e=this.transaction;if(!(e instanceof $e))throw new Error("Transaction not instance of InviteClientTransaction.");const t=Ae.initialDialogStateForUserAgentServer(this.message,this.toTag);this.confirmedDialog=new Ke(e,this.core,t)}const t=this.message.getHeaders("record-route").map((e=>`Record-Route: ${e}`)),s=`Contact: ${this.core.configuration.contact.toString()}`,i="Allow: "+V.toString();if(!e.body)if(this.confirmedDialog.signalingState===U.Stable)e.body=this.confirmedDialog.answer;else if(this.confirmedDialog.signalingState===U.Initial||this.confirmedDialog.signalingState===U.HaveRemoteOffer)throw new Error("Response must have a body.");e.statusCode=e.statusCode||200,e.extraHeaders=e.extraHeaders||[],e.extraHeaders=e.extraHeaders.concat(t),e.extraHeaders.push(i),e.extraHeaders.push(s);const r=super.accept(e),n=this.confirmedDialog,o=Object.assign(Object.assign({},r),{session:n});return e.body&&this.confirmedDialog.signalingState!==U.Stable&&this.confirmedDialog.signalingStateTransition(e.body),o}progress(e={statusCode:180}){if(!this.progressable)throw new B(`${this.message.method} not progressable in state ${this.transaction.state}.`);if(!this.earlyDialog){const e=this.transaction;if(!(e instanceof $e))throw new Error("Transaction not instance of InviteClientTransaction.");const t=Ae.initialDialogStateForUserAgentServer(this.message,this.toTag,!0);this.earlyDialog=new Ke(e,this.core,t)}const t=this.message.getHeaders("record-route").map((e=>`Record-Route: ${e}`)),s=`Contact: ${this.core.configuration.contact}`;e.extraHeaders=e.extraHeaders||[],e.extraHeaders=e.extraHeaders.concat(t),e.extraHeaders.push(s);const i=super.progress(e),r=this.earlyDialog,n=Object.assign(Object.assign({},i),{session:r});return e.body&&this.earlyDialog.signalingState!==U.Stable&&this.earlyDialog.signalingStateTransition(e.body),n}redirect(e,t={statusCode:302}){return super.redirect(e,t)}reject(e={statusCode:486}){return super.reject(e)}}class Je extends He{constructor(e,t,s){super(Ce,e,t,s)}}class ze extends He{constructor(e,t,s){super(Ce,e,t,s)}}class Xe extends ke{constructor(e,t,s){super(xe,e,t,s),this.core=e}}class Qe extends He{constructor(e,t,s){const i=e.createOutgoingRequestMessage(G.SUBSCRIBE,s);super(Ce,e.userAgentCore,i,t),this.dialog=e}waitNotifyStop(){}receiveResponse(e){if(e.statusCode&&e.statusCode>=200&&e.statusCode<300){const t=e.getHeader("Expires");if(t){const e=Number(t);this.dialog.subscriptionExpires>e&&(this.dialog.subscriptionExpires=e)}else this.logger.warn("Expires header missing in a 200-class response to SUBSCRIBE")}if(e.statusCode&&e.statusCode>=400&&e.statusCode<700){[404,405,410,416,480,481,482,483,484,485,489,501,604].includes(e.statusCode)&&this.dialog.terminate()}super.receiveResponse(e)}}class et extends Ae{constructor(e,t,s,i,r,n){super(i,r),this.delegate=n,this._autoRefresh=!1,this._subscriptionEvent=e,this._subscriptionExpires=t,this._subscriptionExpiresInitial=t,this._subscriptionExpiresLastSet=Math.floor(Date.now()/1e3),this._subscriptionRefresh=void 0,this._subscriptionRefreshLastSet=void 0,this._subscriptionState=s,this.logger=i.loggerFactory.getLogger("sip.subscribe-dialog"),this.logger.log(`SUBSCRIBE dialog ${this.id} constructed`)}static initialDialogStateForSubscription(e,t){const s=t.getHeaders("record-route"),i=t.parseHeader("contact");if(!i)throw new Error("Contact undefined.");if(!(i instanceof v))throw new Error("Contact not instance of NameAddrHeader.");const r=i.uri,n=e.cseq,o=e.callId,a=e.fromTag,c=t.fromTag;if(!o)throw new Error("Call id undefined.");if(!a)throw new Error("From tag undefined.");if(!c)throw new Error("To tag undefined.");if(!e.from)throw new Error("From undefined.");if(!e.to)throw new Error("To undefined.");return{id:o+a+c,early:!1,callId:o,localTag:a,remoteTag:c,localSequenceNumber:n,remoteSequenceNumber:undefined,localURI:e.from.uri,remoteURI:e.to.uri,remoteTarget:r,routeSet:s,secure:!1}}dispose(){super.dispose(),this.N&&(clearTimeout(this.N),this.N=void 0),this.refreshTimerClear(),this.logger.log(`SUBSCRIBE dialog ${this.id} destroyed`)}get autoRefresh(){return this._autoRefresh}set autoRefresh(e){this._autoRefresh=!0,this.refreshTimerSet()}get subscriptionEvent(){return this._subscriptionEvent}get subscriptionExpires(){const e=Math.floor(Date.now()/1e3)-this._subscriptionExpiresLastSet,t=this._subscriptionExpires-e;return Math.max(t,0)}set subscriptionExpires(e){if(e<0)throw new Error("Expires must be greater than or equal to zero.");if(this._subscriptionExpires=e,this._subscriptionExpiresLastSet=Math.floor(Date.now()/1e3),this.autoRefresh){const t=this.subscriptionRefresh;(void 0===t||t>=e)&&this.refreshTimerSet()}}get subscriptionExpiresInitial(){return this._subscriptionExpiresInitial}get subscriptionRefresh(){if(void 0===this._subscriptionRefresh||void 0===this._subscriptionRefreshLastSet)return;const e=Math.floor(Date.now()/1e3)-this._subscriptionRefreshLastSet,t=this._subscriptionRefresh-e;return Math.max(t,0)}get subscriptionState(){return this._subscriptionState}receiveRequest(e){if(this.logger.log(`SUBSCRIBE dialog ${this.id} received ${e.method} request`),this.sequenceGuard(e))if(super.receiveRequest(e),e.method===G.NOTIFY)this.onNotify(e);else this.logger.log(`SUBSCRIBE dialog ${this.id} received unimplemented ${e.method} request`),this.core.replyStateless(e,{statusCode:501});else this.logger.log(`SUBSCRIBE dialog ${this.id} rejected out of order ${e.method} request.`)}refresh(){const e="Allow: "+V.toString(),t={};return t.extraHeaders=(t.extraHeaders||[]).slice(),t.extraHeaders.push(e),t.extraHeaders.push("Event: "+this.subscriptionEvent),t.extraHeaders.push("Expires: "+this.subscriptionExpiresInitial),t.extraHeaders.push("Contact: "+this.core.configuration.contact.toString()),this.subscribe(void 0,t)}subscribe(e,t={}){var s;if(this.subscriptionState!==re.Pending&&this.subscriptionState!==re.Active)throw new Error(`Invalid state ${this.subscriptionState}. May only re-subscribe while in state "pending" or "active".`);this.logger.log(`SUBSCRIBE dialog ${this.id} sending SUBSCRIBE request`);const i=new Qe(this,e,t);return this.N&&(clearTimeout(this.N),this.N=void 0),(null===(s=t.extraHeaders)||void 0===s?void 0:s.includes("Expires: 0"))||(this.N=setTimeout((()=>this.timerN()),L.TIMER_N)),i}terminate(){this.stateTransition(re.Terminated),this.onTerminated()}unsubscribe(){const e="Allow: "+V.toString(),t={};return t.extraHeaders=(t.extraHeaders||[]).slice(),t.extraHeaders.push(e),t.extraHeaders.push("Event: "+this.subscriptionEvent),t.extraHeaders.push("Expires: 0"),t.extraHeaders.push("Contact: "+this.core.configuration.contact.toString()),this.subscribe(void 0,t)}onNotify(e){const t=e.parseHeader("Event").event;if(!t||t!==this.subscriptionEvent)return void this.core.replyStateless(e,{statusCode:489});this.N&&(clearTimeout(this.N),this.N=void 0);const s=e.parseHeader("Subscription-State");if(!s||!s.state)return void this.core.replyStateless(e,{statusCode:489});const i=s.state,r=s.expires?Math.max(s.expires,0):void 0;switch(i){case"pending":this.stateTransition(re.Pending,r);break;case"active":this.stateTransition(re.Active,r);break;case"terminated":this.stateTransition(re.Terminated,r);break;default:this.logger.warn("Unrecognized subscription state.")}const n=new je(this,e);this.delegate&&this.delegate.onNotify?this.delegate.onNotify(n):n.accept()}onRefresh(e){this.delegate&&this.delegate.onRefresh&&this.delegate.onRefresh(e)}onTerminated(){this.delegate&&this.delegate.onTerminated&&this.delegate.onTerminated()}refreshTimerClear(){this.refreshTimer&&(clearTimeout(this.refreshTimer),this.refreshTimer=void 0)}refreshTimerSet(){if(this.refreshTimerClear(),this.autoRefresh&&this.subscriptionExpires>0){const e=900*this.subscriptionExpires;this._subscriptionRefresh=Math.floor(e/1e3),this._subscriptionRefreshLastSet=Math.floor(Date.now()/1e3),this.refreshTimer=setTimeout((()=>{this.refreshTimer=void 0,this._subscriptionRefresh=void 0,this._subscriptionRefreshLastSet=void 0,this.onRefresh(this.refresh())}),e)}}stateTransition(e,t){const s=()=>{this.logger.warn(`Invalid subscription state transition from ${this.subscriptionState} to ${e}`)};switch(e){case re.Initial:case re.NotifyWait:return void s();case re.Pending:if(this.subscriptionState!==re.NotifyWait&&this.subscriptionState!==re.Pending)return void s();break;case re.Active:case re.Terminated:if(this.subscriptionState!==re.NotifyWait&&this.subscriptionState!==re.Pending&&this.subscriptionState!==re.Active)return void s();break;default:return void s()}e===re.Pending&&t&&(this.subscriptionExpires=t),e===re.Active&&t&&(this.subscriptionExpires=t),e===re.Terminated&&this.dispose(),this._subscriptionState=e}timerN(){this.logger.warn("Timer N expired for SUBSCRIBE dialog. Timed out waiting for NOTIFY."),this.subscriptionState!==re.Terminated&&(this.stateTransition(re.Terminated),this.onTerminated())}}class tt extends He{constructor(e,t,s){const i=t.getHeader("Event");if(!i)throw new Error("Event undefined");const r=t.getHeader("Expires");if(!r)throw new Error("Expires undefined");super(Ce,e,t,s),this.delegate=s,this.subscriberId=t.callId+t.fromTag+i,this.subscriptionExpiresRequested=this.subscriptionExpires=Number(r),this.subscriptionEvent=i,this.subscriptionState=re.NotifyWait,this.waitNotifyStart()}dispose(){super.dispose()}onNotify(e){const t=e.message.parseHeader("Event").event;if(!t||t!==this.subscriptionEvent)return this.logger.warn("Failed to parse event."),void e.reject({statusCode:489});const s=e.message.parseHeader("Subscription-State");if(!s||!s.state)return this.logger.warn("Failed to parse subscription state."),void e.reject({statusCode:489});const i=s.state;switch(i){case"pending":case"active":case"terminated":break;default:return this.logger.warn(`Invalid subscription state ${i}`),void e.reject({statusCode:489})}if("terminated"!==i){if(!e.message.parseHeader("contact"))return this.logger.warn("Failed to parse contact."),void e.reject({statusCode:489})}if(this.dialog)throw new Error("Dialog already created. This implementation only supports install of single subscriptions.");switch(this.waitNotifyStop(),this.subscriptionExpires=s.expires?Math.min(this.subscriptionExpires,Math.max(s.expires,0)):this.subscriptionExpires,i){case"pending":this.subscriptionState=re.Pending;break;case"active":this.subscriptionState=re.Active;break;case"terminated":this.subscriptionState=re.Terminated;break;default:throw new Error(`Unrecognized state ${i}.`)}if(this.subscriptionState!==re.Terminated){const t=et.initialDialogStateForSubscription(this.message,e.message);this.dialog=new et(this.subscriptionEvent,this.subscriptionExpires,this.subscriptionState,this.core,t)}if(this.delegate&&this.delegate.onNotify){const t=e,s=this.dialog;this.delegate.onNotify({request:t,subscription:s})}else e.accept()}waitNotifyStart(){this.N||(this.core.subscribers.set(this.subscriberId,this),this.N=setTimeout((()=>this.timerN()),L.TIMER_N))}waitNotifyStop(){this.N&&(this.core.subscribers.delete(this.subscriberId),clearTimeout(this.N),this.N=void 0)}receiveResponse(e){if(this.authenticationGuard(e)){if(e.statusCode&&e.statusCode>=200&&e.statusCode<300){const t=e.getHeader("Expires");if(t){const e=Number(t);e>this.subscriptionExpiresRequested&&this.logger.warn("Expires header in a 200-class response to SUBSCRIBE with a higher value than the one in the request"),ethis.subscriptionExpires&&(this.dialog.subscriptionExpires=this.subscriptionExpires)}e.statusCode&&e.statusCode>=300&&e.statusCode<700&&this.waitNotifyStop(),super.receiveResponse(e)}}timerN(){this.logger.warn("Timer N expired for SUBSCRIBE user agent client. Timed out waiting for NOTIFY."),this.waitNotifyStop(),this.delegate&&this.delegate.onNotifyTimeout&&this.delegate.onNotifyTimeout()}}class st extends ke{constructor(e,t,s){super(xe,e,t,s),this.core=e}}const it=["application/sdp","application/dtmf-relay"];class rt{constructor(e,t={}){this.userAgentClients=new Map,this.userAgentServers=new Map,this.configuration=e,this.delegate=t,this.dialogs=new Map,this.subscribers=new Map,this.logger=e.loggerFactory.getLogger("sip.user-agent-core")}dispose(){this.reset()}reset(){this.dialogs.forEach((e=>e.dispose())),this.dialogs.clear(),this.subscribers.forEach((e=>e.dispose())),this.subscribers.clear(),this.userAgentClients.forEach((e=>e.dispose())),this.userAgentClients.clear(),this.userAgentServers.forEach((e=>e.dispose())),this.userAgentServers.clear()}get loggerFactory(){return this.configuration.loggerFactory}get transport(){const e=this.configuration.transportAccessor();if(!e)throw new Error("Transport undefined.");return e}invite(e,t){return new Ye(this,e,t)}message(e,t){return new Ne(this,e,t)}publish(e,t){return new Je(this,e,t)}register(e,t){return new ze(this,e,t)}subscribe(e,t){return new tt(this,e,t)}request(e,t){return new He(Ce,this,e,t)}makeOutgoingRequestMessage(e,t,s,i,r,n,o){const a=this.configuration.sipjsId,c=this.configuration.displayName,h=this.configuration.viaForceRport,d=this.configuration.hackViaTcp,l=this.configuration.supportedOptionTags.slice();e===G.REGISTER&&l.push("path","gruu"),e===G.INVITE&&(this.configuration.contact.pubGruu||this.configuration.contact.tempGruu)&&l.push("gruu");const g={callIdPrefix:a,forceRport:h,fromDisplayName:c,hackViaTcp:d,optionTags:l,routeSet:this.configuration.routeSet,userAgentString:this.configuration.userAgentHeaderFieldValue,viaHost:this.configuration.viaHost},u=Object.assign(Object.assign({},g),r);return new k(e,t,s,i,u,n,o)}receiveIncomingRequestFromTransport(e){this.receiveRequestFromTransport(e)}receiveIncomingResponseFromTransport(e){this.receiveResponseFromTransport(e)}replyStateless(e,t){const s=this.configuration.userAgentHeaderFieldValue,i=this.configuration.supportedOptionTagsResponse;t=Object.assign(Object.assign({},t),{userAgent:s,supported:i});const r=Se(e,t);return this.transport.send(r.message).catch((t=>{t instanceof Error&&this.logger.error(t.message),this.logger.error(`Transport error occurred sending stateless reply to ${e.method} request.`)})),r}receiveRequestFromTransport(e){const t=e.viaBranch,s=this.userAgentServers.get(t);e.method===G.ACK&&s&&s.transaction.state===de.Accepted&&s instanceof Ze?this.logger.warn(`Discarding out of dialog ACK after 2xx response sent on transaction ${t}.`):e.method!==G.CANCEL?s?s.transaction.receiveRequest(e):this.receiveRequest(e):s?(this.replyStateless(e,{statusCode:200}),s.transaction instanceof $e&&s.transaction.state===de.Proceeding&&s instanceof Ze&&s.receiveCancel(e)):this.replyStateless(e,{statusCode:481})}receiveRequest(e){if(!V.includes(e.method)){const t="Allow: "+V.toString();return void this.replyStateless(e,{statusCode:405,extraHeaders:[t]})}if(!e.ruri)throw new Error("Request-URI undefined.");if("sip"!==e.ruri.scheme)return void this.replyStateless(e,{statusCode:416});const t=e.ruri,s=e=>!!e&&e.user===t.user;if(!s(this.configuration.aor)&&!(s(this.configuration.contact.uri)||s(this.configuration.contact.pubGruu)||s(this.configuration.contact.tempGruu)))return this.logger.warn("Request-URI does not point to us."),void(e.method!==G.ACK&&this.replyStateless(e,{statusCode:404}));if(e.method!==G.INVITE||e.hasHeader("Contact")){if(!e.toTag){const t=e.viaBranch;if(!this.userAgentServers.has(t)){if(Array.from(this.userAgentServers.values()).some((t=>t.transaction.request.fromTag===e.fromTag&&t.transaction.request.callId===e.callId&&t.transaction.request.cseq===e.cseq)))return void this.replyStateless(e,{statusCode:482})}}e.toTag?this.receiveInsideDialogRequest(e):this.receiveOutsideDialogRequest(e)}else this.replyStateless(e,{statusCode:400,reasonPhrase:"Missing Contact Header"})}receiveInsideDialogRequest(e){if(e.method===G.NOTIFY){const t=e.parseHeader("Event");if(!t||!t.event)return void this.replyStateless(e,{statusCode:489});const s=e.callId+e.toTag+t.event,i=this.subscribers.get(s);if(i){const t=new je(this,e);return void i.onNotify(t)}}const t=e.callId+e.toTag+e.fromTag,s=this.dialogs.get(t);if(s){if(e.method===G.OPTIONS){const t="Allow: "+V.toString(),s="Accept: "+it.toString();return void this.replyStateless(e,{statusCode:200,extraHeaders:[t,s]})}s.receiveRequest(e)}else e.method!==G.ACK&&this.replyStateless(e,{statusCode:481})}receiveOutsideDialogRequest(e){switch(e.method){case G.ACK:break;case G.BYE:this.replyStateless(e,{statusCode:481});break;case G.CANCEL:throw new Error(`Unexpected out of dialog request method ${e.method}.`);case G.INFO:this.replyStateless(e,{statusCode:405});break;case G.INVITE:{const t=new Ze(this,e);this.delegate.onInvite?this.delegate.onInvite(t):t.reject()}break;case G.MESSAGE:{const t=new Oe(this,e);this.delegate.onMessage?this.delegate.onMessage(t):t.accept()}break;case G.NOTIFY:{const t=new je(this,e);this.delegate.onNotify?this.delegate.onNotify(t):t.reject({statusCode:405})}break;case G.OPTIONS:{const t="Allow: "+V.toString(),s="Accept: "+it.toString();this.replyStateless(e,{statusCode:200,extraHeaders:[t,s]})}break;case G.REFER:{const t=new We(this,e);this.delegate.onRefer?this.delegate.onRefer(t):t.reject({statusCode:405})}break;case G.REGISTER:{const t=new Xe(this,e);this.delegate.onRegister?this.delegate.onRegister(t):t.reject({statusCode:405})}break;case G.SUBSCRIBE:{const t=new st(this,e);this.delegate.onSubscribe?this.delegate.onSubscribe(t):t.reject({statusCode:480})}break;default:throw new Error(`Unexpected out of dialog request method ${e.method}.`)}}receiveResponseFromTransport(e){if(e.getHeaders("via").length>1)return void this.logger.warn("More than one Via header field present in the response, dropping");const t=e.viaBranch+e.method,s=this.userAgentClients.get(t);s?s.transaction.receiveResponse(e):this.logger.warn(`Discarding unmatched ${e.statusCode} response to ${e.method} ${t}.`)}}function nt(){return e=>e.audio||e.video?void 0===navigator.mediaDevices?Promise.reject(new Error("Media devices not available in insecure contexts.")):navigator.mediaDevices.getUserMedia.call(navigator.mediaDevices,e):Promise.resolve(new MediaStream)}function ot(){return{bundlePolicy:"balanced",certificates:void 0,iceCandidatePoolSize:0,iceServers:[{urls:"stun:stun.l.google.com:19302"}],iceTransportPolicy:"all",rtcpMuxPolicy:"require"}}class at{constructor(e,t,s){e.debug("SessionDescriptionHandler.constructor"),this.logger=e,this.mediaStreamFactory=t,this.sessionDescriptionHandlerConfiguration=s,this._localMediaStream=new MediaStream,this._remoteMediaStream=new MediaStream,this._peerConnection=new RTCPeerConnection(null==s?void 0:s.peerConnectionConfiguration),this.initPeerConnectionEventHandlers()}get localMediaStream(){return this._localMediaStream}get remoteMediaStream(){return this._remoteMediaStream}get dataChannel(){return this._dataChannel}get peerConnection(){return this._peerConnection}get peerConnectionDelegate(){return this._peerConnectionDelegate}set peerConnectionDelegate(e){this._peerConnectionDelegate=e}static dispatchAddTrackEvent(e,t){e.dispatchEvent(new MediaStreamTrackEvent("addtrack",{track:t}))}static dispatchRemoveTrackEvent(e,t){e.dispatchEvent(new MediaStreamTrackEvent("removetrack",{track:t}))}close(){this.logger.debug("SessionDescriptionHandler.close"),void 0!==this._peerConnection&&(this._peerConnection.getReceivers().forEach((e=>{e.track&&e.track.stop()})),this._peerConnection.getSenders().forEach((e=>{e.track&&e.track.stop()})),this._dataChannel&&this._dataChannel.close(),this._peerConnection.close(),this._peerConnection=void 0)}enableReceiverTracks(e){const t=this.peerConnection;if(!t)throw new Error("Peer connection closed.");t.getReceivers().forEach((t=>{t.track&&(t.track.enabled=e)}))}enableSenderTracks(e){const t=this.peerConnection;if(!t)throw new Error("Peer connection closed.");t.getSenders().forEach((t=>{t.track&&(t.track.enabled=e)}))}getDescription(e,t){var s,i;if(this.logger.debug("SessionDescriptionHandler.getDescription"),void 0===this._peerConnection)return Promise.reject(new Error("Peer connection closed."));this.onDataChannel=null==e?void 0:e.onDataChannel;const r=null===(s=null==e?void 0:e.offerOptions)||void 0===s?void 0:s.iceRestart,n=void 0===(null==e?void 0:e.iceGatheringTimeout)?null===(i=this.sessionDescriptionHandlerConfiguration)||void 0===i?void 0:i.iceGatheringTimeout:null==e?void 0:e.iceGatheringTimeout;return this.getLocalMediaStream(e).then((()=>this.updateDirection(e))).then((()=>this.createDataChannel(e))).then((()=>this.createLocalOfferOrAnswer(e))).then((e=>this.applyModifiers(e,t))).then((e=>this.setLocalSessionDescription(e))).then((()=>this.waitForIceGatheringComplete(r,n))).then((()=>this.getLocalSessionDescription())).then((e=>({body:e.sdp,contentType:"application/sdp"}))).catch((e=>{throw this.logger.error("SessionDescriptionHandler.getDescription failed - "+e),e}))}hasDescription(e){return this.logger.debug("SessionDescriptionHandler.hasDescription"),"application/sdp"===e}iceGatheringComplete(){this.logger.debug("SessionDescriptionHandler.iceGatheringComplete"),void 0!==this.iceGatheringCompleteTimeoutId&&(this.logger.debug("SessionDescriptionHandler.iceGatheringComplete - clearing timeout"),clearTimeout(this.iceGatheringCompleteTimeoutId),this.iceGatheringCompleteTimeoutId=void 0),void 0!==this.iceGatheringCompletePromise&&(this.logger.debug("SessionDescriptionHandler.iceGatheringComplete - resolving promise"),this.iceGatheringCompleteResolve&&this.iceGatheringCompleteResolve(),this.iceGatheringCompletePromise=void 0,this.iceGatheringCompleteResolve=void 0,this.iceGatheringCompleteReject=void 0)}sendDtmf(e,t){if(this.logger.debug("SessionDescriptionHandler.sendDtmf"),void 0===this._peerConnection)return this.logger.error("SessionDescriptionHandler.sendDtmf failed - peer connection closed"),!1;const s=this._peerConnection.getSenders();if(0===s.length)return this.logger.error("SessionDescriptionHandler.sendDtmf failed - no senders"),!1;const i=s[0].dtmf;if(!i)return this.logger.error("SessionDescriptionHandler.sendDtmf failed - no DTMF sender"),!1;const r=null==t?void 0:t.duration,n=null==t?void 0:t.interToneGap;try{i.insertDTMF(e,r,n)}catch(e){return this.logger.error(e.toString()),!1}return this.logger.log("SessionDescriptionHandler.sendDtmf sent via RTP: "+e.toString()),!0}setDescription(e,t,s){if(this.logger.debug("SessionDescriptionHandler.setDescription"),void 0===this._peerConnection)return Promise.reject(new Error("Peer connection closed."));this.onDataChannel=null==t?void 0:t.onDataChannel;const i="have-local-offer"===this._peerConnection.signalingState?"answer":"offer";return this.getLocalMediaStream(t).then((()=>this.applyModifiers({sdp:e,type:i},s))).then((e=>this.setRemoteSessionDescription(e))).catch((e=>{throw this.logger.error("SessionDescriptionHandler.setDescription failed - "+e),e}))}applyModifiers(e,t){return this.logger.debug("SessionDescriptionHandler.applyModifiers"),t&&0!==t.length?t.reduce(((e,t)=>e.then(t)),Promise.resolve(e)).then((e=>{if(this.logger.debug("SessionDescriptionHandler.applyModifiers - modified sdp"),!e.sdp||!e.type)throw new Error("Invalid SDP.");return{sdp:e.sdp,type:e.type}})):Promise.resolve(e)}createDataChannel(e){if(void 0===this._peerConnection)return Promise.reject(new Error("Peer connection closed."));if(!0!==(null==e?void 0:e.dataChannel))return Promise.resolve();if(this._dataChannel)return Promise.resolve();switch(this._peerConnection.signalingState){case"stable":this.logger.debug("SessionDescriptionHandler.createDataChannel - creating data channel");try{return this._dataChannel=this._peerConnection.createDataChannel((null==e?void 0:e.dataChannelLabel)||"",null==e?void 0:e.dataChannelOptions),this.onDataChannel&&this.onDataChannel(this._dataChannel),Promise.resolve()}catch(e){return Promise.reject(e)}case"have-remote-offer":return Promise.resolve();default:return Promise.reject(new Error("Invalid signaling state "+this._peerConnection.signalingState))}}createLocalOfferOrAnswer(e){if(void 0===this._peerConnection)return Promise.reject(new Error("Peer connection closed."));switch(this._peerConnection.signalingState){case"stable":return this.logger.debug("SessionDescriptionHandler.createLocalOfferOrAnswer - creating SDP offer"),this._peerConnection.createOffer(null==e?void 0:e.offerOptions);case"have-remote-offer":return this.logger.debug("SessionDescriptionHandler.createLocalOfferOrAnswer - creating SDP answer"),this._peerConnection.createAnswer(null==e?void 0:e.answerOptions);default:return Promise.reject(new Error("Invalid signaling state "+this._peerConnection.signalingState))}}getLocalMediaStream(e){if(this.logger.debug("SessionDescriptionHandler.getLocalMediaStream"),void 0===this._peerConnection)return Promise.reject(new Error("Peer connection closed."));let t=Object.assign({},null==e?void 0:e.constraints);if(this.localMediaStreamConstraints){if(t.audio=t.audio||this.localMediaStreamConstraints.audio,t.video=t.video||this.localMediaStreamConstraints.video,JSON.stringify(this.localMediaStreamConstraints.audio)===JSON.stringify(t.audio)&&JSON.stringify(this.localMediaStreamConstraints.video)===JSON.stringify(t.video))return Promise.resolve()}else void 0===t.audio&&void 0===t.video&&(t={audio:!0});return this.localMediaStreamConstraints=t,this.mediaStreamFactory(t,this,e).then((e=>this.setLocalMediaStream(e)))}setLocalMediaStream(e){if(this.logger.debug("SessionDescriptionHandler.setLocalMediaStream"),!this._peerConnection)throw new Error("Peer connection undefined.");const t=this._peerConnection,s=this._localMediaStream,i=[],r=e=>{const r=e.kind;if("audio"!==r&&"video"!==r)throw new Error(`Unknown new track kind ${r}.`);const n=t.getSenders().find((e=>e.track&&e.track.kind===r));n?i.push(new Promise((e=>{this.logger.debug(`SessionDescriptionHandler.setLocalMediaStream - replacing sender ${r} track`),e()})).then((()=>n.replaceTrack(e).then((()=>{const t=s.getTracks().find((e=>e.kind===r));t&&(t.stop(),s.removeTrack(t),at.dispatchRemoveTrackEvent(s,t)),s.addTrack(e),at.dispatchAddTrackEvent(s,e)})).catch((e=>{throw this.logger.error(`SessionDescriptionHandler.setLocalMediaStream - failed to replace sender ${r} track`),e}))))):i.push(new Promise((e=>{this.logger.debug(`SessionDescriptionHandler.setLocalMediaStream - adding sender ${r} track`),e()})).then((()=>{try{t.addTrack(e,s)}catch(e){throw this.logger.error(`SessionDescriptionHandler.setLocalMediaStream - failed to add sender ${r} track`),e}s.addTrack(e),at.dispatchAddTrackEvent(s,e)})))},n=e.getAudioTracks();n.length&&r(n[0]);const o=e.getVideoTracks();return o.length&&r(o[0]),i.reduce(((e,t)=>e.then((()=>t))),Promise.resolve())}getLocalSessionDescription(){if(this.logger.debug("SessionDescriptionHandler.getLocalSessionDescription"),void 0===this._peerConnection)return Promise.reject(new Error("Peer connection closed."));const e=this._peerConnection.localDescription;return e?Promise.resolve(e):Promise.reject(new Error("Failed to get local session description"))}setLocalSessionDescription(e){return this.logger.debug("SessionDescriptionHandler.setLocalSessionDescription"),void 0===this._peerConnection?Promise.reject(new Error("Peer connection closed.")):this._peerConnection.setLocalDescription(e)}setRemoteSessionDescription(e){if(this.logger.debug("SessionDescriptionHandler.setRemoteSessionDescription"),void 0===this._peerConnection)return Promise.reject(new Error("Peer connection closed."));const t=e.sdp;let s;switch(this._peerConnection.signalingState){case"stable":s="offer";break;case"have-local-offer":s="answer";break;default:return Promise.reject(new Error("Invalid signaling state "+this._peerConnection.signalingState))}return t?this._peerConnection.setRemoteDescription({sdp:t,type:s}):(this.logger.error("SessionDescriptionHandler.setRemoteSessionDescription failed - cannot set null sdp"),Promise.reject(new Error("SDP is undefined")))}setRemoteTrack(e){this.logger.debug("SessionDescriptionHandler.setRemoteTrack");const t=this._remoteMediaStream;t.getTrackById(e.id)?this.logger.debug(`SessionDescriptionHandler.setRemoteTrack - have remote ${e.kind} track`):"audio"===e.kind?(this.logger.debug(`SessionDescriptionHandler.setRemoteTrack - adding remote ${e.kind} track`),t.getAudioTracks().forEach((e=>{e.stop(),t.removeTrack(e),at.dispatchRemoveTrackEvent(t,e)})),t.addTrack(e),at.dispatchAddTrackEvent(t,e)):"video"===e.kind&&(this.logger.debug(`SessionDescriptionHandler.setRemoteTrack - adding remote ${e.kind} track`),t.getVideoTracks().forEach((e=>{e.stop(),t.removeTrack(e),at.dispatchRemoveTrackEvent(t,e)})),t.addTrack(e),at.dispatchAddTrackEvent(t,e))}updateDirection(e){if(void 0===this._peerConnection)return Promise.reject(new Error("Peer connection closed."));switch(this._peerConnection.signalingState){case"stable":this.logger.debug("SessionDescriptionHandler.updateDirection - setting offer direction");{const t=t=>{switch(t){case"inactive":case"recvonly":return(null==e?void 0:e.hold)?"inactive":"recvonly";case"sendonly":case"sendrecv":return(null==e?void 0:e.hold)?"sendonly":"sendrecv";case"stopped":return"stopped";default:throw new Error("Should never happen")}};this._peerConnection.getTransceivers().forEach((e=>{if(e.direction){const s=t(e.direction);e.direction!==s&&(e.direction=s)}}))}break;case"have-remote-offer":this.logger.debug("SessionDescriptionHandler.updateDirection - setting answer direction");{const t=(()=>{const e=this._peerConnection.remoteDescription;if(!e)throw new Error("Failed to read remote offer");const t=/a=sendrecv\r\n|a=sendonly\r\n|a=recvonly\r\n|a=inactive\r\n/.exec(e.sdp);if(t)switch(t[0]){case"a=inactive\r\n":return"inactive";case"a=recvonly\r\n":return"recvonly";case"a=sendonly\r\n":return"sendonly";case"a=sendrecv\r\n":return"sendrecv";default:throw new Error("Should never happen")}return"sendrecv"})(),s=(()=>{switch(t){case"inactive":return"inactive";case"recvonly":return"sendonly";case"sendonly":return(null==e?void 0:e.hold)?"inactive":"recvonly";case"sendrecv":return(null==e?void 0:e.hold)?"sendonly":"sendrecv";default:throw new Error("Should never happen")}})();this._peerConnection.getTransceivers().forEach((e=>{e.direction&&"stopped"!==e.direction&&e.direction!==s&&(e.direction=s)}))}break;default:return Promise.reject(new Error("Invalid signaling state "+this._peerConnection.signalingState))}return Promise.resolve()}waitForIceGatheringComplete(e=!1,t=0){return this.logger.debug("SessionDescriptionHandler.waitForIceGatheringToComplete"),void 0===this._peerConnection?Promise.reject("Peer connection closed."):e||"complete"!==this._peerConnection.iceGatheringState?(void 0!==this.iceGatheringCompletePromise&&(this.logger.debug("SessionDescriptionHandler.waitForIceGatheringToComplete - rejecting prior waiting promise"),this.iceGatheringCompleteReject&&this.iceGatheringCompleteReject(new Error("Promise superseded.")),this.iceGatheringCompletePromise=void 0,this.iceGatheringCompleteResolve=void 0,this.iceGatheringCompleteReject=void 0),this.iceGatheringCompletePromise=new Promise(((e,s)=>{this.iceGatheringCompleteResolve=e,this.iceGatheringCompleteReject=s,t>0&&(this.logger.debug("SessionDescriptionHandler.waitForIceGatheringToComplete - timeout in "+t),this.iceGatheringCompleteTimeoutId=setTimeout((()=>{this.logger.debug("SessionDescriptionHandler.waitForIceGatheringToComplete - timeout"),this.iceGatheringComplete()}),t))})),this.iceGatheringCompletePromise):(this.logger.debug("SessionDescriptionHandler.waitForIceGatheringToComplete - already complete"),Promise.resolve())}initPeerConnectionEventHandlers(){if(this.logger.debug("SessionDescriptionHandler.initPeerConnectionEventHandlers"),!this._peerConnection)throw new Error("Peer connection undefined.");const e=this._peerConnection;e.onconnectionstatechange=t=>{var s;const i=e.connectionState;this.logger.debug(`SessionDescriptionHandler.onconnectionstatechange ${i}`),(null===(s=this._peerConnectionDelegate)||void 0===s?void 0:s.onconnectionstatechange)&&this._peerConnectionDelegate.onconnectionstatechange(t)},e.ondatachannel=e=>{var t;this.logger.debug("SessionDescriptionHandler.ondatachannel"),this._dataChannel=e.channel,this.onDataChannel&&this.onDataChannel(this._dataChannel),(null===(t=this._peerConnectionDelegate)||void 0===t?void 0:t.ondatachannel)&&this._peerConnectionDelegate.ondatachannel(e)},e.onicecandidate=e=>{var t;this.logger.debug("SessionDescriptionHandler.onicecandidate"),(null===(t=this._peerConnectionDelegate)||void 0===t?void 0:t.onicecandidate)&&this._peerConnectionDelegate.onicecandidate(e)},e.onicecandidateerror=e=>{var t;this.logger.debug("SessionDescriptionHandler.onicecandidateerror"),(null===(t=this._peerConnectionDelegate)||void 0===t?void 0:t.onicecandidateerror)&&this._peerConnectionDelegate.onicecandidateerror(e)},e.oniceconnectionstatechange=t=>{var s;const i=e.iceConnectionState;this.logger.debug(`SessionDescriptionHandler.oniceconnectionstatechange ${i}`),(null===(s=this._peerConnectionDelegate)||void 0===s?void 0:s.oniceconnectionstatechange)&&this._peerConnectionDelegate.oniceconnectionstatechange(t)},e.onicegatheringstatechange=t=>{var s;const i=e.iceGatheringState;this.logger.debug(`SessionDescriptionHandler.onicegatheringstatechange ${i}`),"complete"===i&&this.iceGatheringComplete(),(null===(s=this._peerConnectionDelegate)||void 0===s?void 0:s.onicegatheringstatechange)&&this._peerConnectionDelegate.onicegatheringstatechange(t)},e.onnegotiationneeded=e=>{var t;this.logger.debug("SessionDescriptionHandler.onnegotiationneeded"),(null===(t=this._peerConnectionDelegate)||void 0===t?void 0:t.onnegotiationneeded)&&this._peerConnectionDelegate.onnegotiationneeded(e)},e.onsignalingstatechange=t=>{var s;const i=e.signalingState;this.logger.debug(`SessionDescriptionHandler.onsignalingstatechange ${i}`),(null===(s=this._peerConnectionDelegate)||void 0===s?void 0:s.onsignalingstatechange)&&this._peerConnectionDelegate.onsignalingstatechange(t)},e.ontrack=e=>{var t;const s=e.track.kind,i=e.track.enabled?"enabled":"disabled";this.logger.debug(`SessionDescriptionHandler.ontrack ${s} ${i}`),this.setRemoteTrack(e.track),(null===(t=this._peerConnectionDelegate)||void 0===t?void 0:t.ontrack)&&this._peerConnectionDelegate.ontrack(e)}}}function ct(e){return(t,s)=>{void 0===e&&(e=nt());const i={iceGatheringTimeout:void 0!==(null==s?void 0:s.iceGatheringTimeout)?null==s?void 0:s.iceGatheringTimeout:5e3,peerConnectionConfiguration:Object.assign(Object.assign({},{bundlePolicy:"balanced",certificates:void 0,iceCandidatePoolSize:0,iceServers:[{urls:"stun:stun.l.google.com:19302"}],iceTransportPolicy:"all",rtcpMuxPolicy:"require"}),null==s?void 0:s.peerConnectionConfiguration)},r=t.userAgent.getLogger("sip.SessionDescriptionHandler");return new at(r,e,i)}}class ht{constructor(e,t){if(this._state=oe.Disconnected,this.transitioningState=!1,this._stateEventEmitter=new p,this.logger=e,t){const e=t,s=null==e?void 0:e.wsServers,i=null==e?void 0:e.maxReconnectionAttempts;if(void 0!==s){const e='The transport option "wsServers" as has apparently been specified and has been deprecated. It will no longer be available starting with SIP.js release 0.16.0. Please update accordingly.';this.logger.warn(e)}if(void 0!==i){const e='The transport option "maxReconnectionAttempts" as has apparently been specified and has been deprecated. It will no longer be available starting with SIP.js release 0.16.0. Please update accordingly.';this.logger.warn(e)}s&&!t.server&&("string"==typeof s&&(t.server=s),s instanceof Array&&(t.server=s[0]))}this.configuration=Object.assign(Object.assign({},ht.defaultOptions),t);const s=this.configuration.server,i=y.parse(s,"absoluteURI");if(-1===i)throw this.logger.error(`Invalid WebSocket Server URL "${s}"`),new Error("Invalid WebSocket Server URL");if(!["wss","ws","udp"].includes(i.scheme))throw this.logger.error(`Invalid scheme in WebSocket Server URL "${s}"`),new Error("Invalid scheme in WebSocket Server URL");this._protocol=i.scheme.toUpperCase()}dispose(){return this.disconnect()}get protocol(){return this._protocol}get server(){return this.configuration.server}get state(){return this._state}get stateChange(){return this._stateEventEmitter}get ws(){return this._ws}connect(){return this._connect()}disconnect(){return this._disconnect()}isConnected(){return this.state===oe.Connected}send(e){return this._send(e)}_connect(){switch(this.logger.log(`Connecting ${this.server}`),this.state){case oe.Connecting:if(this.transitioningState)return Promise.reject(this.transitionLoopDetectedError(oe.Connecting));if(!this.connectPromise)throw new Error("Connect promise must be defined.");return this.connectPromise;case oe.Connected:if(this.transitioningState)return Promise.reject(this.transitionLoopDetectedError(oe.Connecting));if(this.connectPromise)throw new Error("Connect promise must not be defined.");return Promise.resolve();case oe.Disconnecting:if(this.connectPromise)throw new Error("Connect promise must not be defined.");try{this.transitionState(oe.Connecting)}catch(e){if(e instanceof d)return Promise.reject(e);throw e}break;case oe.Disconnected:if(this.connectPromise)throw new Error("Connect promise must not be defined.");try{this.transitionState(oe.Connecting)}catch(e){if(e instanceof d)return Promise.reject(e);throw e}break;default:throw new Error("Unknown state")}let e;try{e=new WebSocket(this.server,"sip"),e.binaryType="arraybuffer",e.addEventListener("close",(t=>this.onWebSocketClose(t,e))),e.addEventListener("error",(t=>this.onWebSocketError(t,e))),e.addEventListener("open",(t=>this.onWebSocketOpen(t,e))),e.addEventListener("message",(t=>this.onWebSocketMessage(t,e))),this._ws=e}catch(e){return this._ws=void 0,this.logger.error("WebSocket construction failed."),this.logger.error(e.toString()),new Promise(((t,s)=>{this.connectResolve=t,this.connectReject=s,this.transitionState(oe.Disconnected,e)}))}return this.connectPromise=new Promise(((t,s)=>{this.connectResolve=t,this.connectReject=s,this.connectTimeout=setTimeout((()=>{this.logger.warn("Connect timed out. Exceeded time set in configuration.connectionTimeout: "+this.configuration.connectionTimeout+"s."),e.close(1e3)}),1e3*this.configuration.connectionTimeout)})),this.connectPromise}_disconnect(){switch(this.logger.log(`Disconnecting ${this.server}`),this.state){case oe.Connecting:if(this.disconnectPromise)throw new Error("Disconnect promise must not be defined.");try{this.transitionState(oe.Disconnecting)}catch(e){if(e instanceof d)return Promise.reject(e);throw e}break;case oe.Connected:if(this.disconnectPromise)throw new Error("Disconnect promise must not be defined.");try{this.transitionState(oe.Disconnecting)}catch(e){if(e instanceof d)return Promise.reject(e);throw e}break;case oe.Disconnecting:if(this.transitioningState)return Promise.reject(this.transitionLoopDetectedError(oe.Disconnecting));if(!this.disconnectPromise)throw new Error("Disconnect promise must be defined.");return this.disconnectPromise;case oe.Disconnected:if(this.transitioningState)return Promise.reject(this.transitionLoopDetectedError(oe.Disconnecting));if(this.disconnectPromise)throw new Error("Disconnect promise must not be defined.");return Promise.resolve();default:throw new Error("Unknown state")}if(!this._ws)throw new Error("WebSocket must be defined.");const e=this._ws;return this.disconnectPromise=new Promise(((t,s)=>{this.disconnectResolve=t,this.disconnectReject=s;try{e.close(1e3)}catch(e){throw this.logger.error("WebSocket close failed."),this.logger.error(e.toString()),e}})),this.disconnectPromise}_send(e){if(!0===this.configuration.traceSip&&this.logger.log("Sending WebSocket message:\n\n"+e+"\n"),this._state!==oe.Connected)return Promise.reject(new Error("Not connected."));if(!this._ws)throw new Error("WebSocket undefined.");try{this._ws.send(e)}catch(e){return e instanceof Error?Promise.reject(e):Promise.reject(new Error("WebSocket send failed."))}return Promise.resolve()}onWebSocketClose(e,t){if(t!==this._ws)return;const s=`WebSocket closed ${this.server} (code: ${e.code})`,i=this.disconnectPromise?void 0:new Error(s);i&&this.logger.warn("WebSocket closed unexpectedly"),this.logger.log(s),this._ws=void 0,this.transitionState(oe.Disconnected,i)}onWebSocketError(e,t){t===this._ws&&this.logger.error("WebSocket error occurred.")}onWebSocketMessage(e,t){if(t!==this._ws)return;const s=e.data;let i;if(/^(\r\n)+$/.test(s))return this.clearKeepAliveTimeout(),void(!0===this.configuration.traceSip&&this.logger.log("Received WebSocket message with CRLF Keep Alive response"));if(s){if("string"!=typeof s){try{i=(new TextDecoder).decode(new Uint8Array(s))}catch(e){return this.logger.error(e.toString()),void this.logger.error("Received WebSocket binary message failed to be converted into string, message discarded")}!0===this.configuration.traceSip&&this.logger.log("Received WebSocket binary message:\n\n"+i+"\n")}else i=s,!0===this.configuration.traceSip&&this.logger.log("Received WebSocket text message:\n\n"+i+"\n");if(this.state===oe.Connected){if(this.onMessage)try{this.onMessage(i)}catch(e){throw this.logger.error(e.toString()),this.logger.error("Exception thrown by onMessage callback"),e}}else this.logger.warn("Received message while not connected, discarding...")}else this.logger.warn("Received empty message, discarding...")}onWebSocketOpen(e,t){t===this._ws&&this._state===oe.Connecting&&(this.logger.log(`WebSocket opened ${this.server}`),this.transitionState(oe.Connected))}transitionLoopDetectedError(e){let t="A state transition loop has been detected.";return t+=` An attempt to transition from ${this._state} to ${e} before the prior transition completed.`,t+=" Perhaps you are synchronously calling connect() or disconnect() from a callback or state change handler?",this.logger.error(t),new d("Loop detected.")}transitionState(e,t){const s=()=>{throw new Error(`Invalid state transition from ${this._state} to ${e}`)};if(this.transitioningState)throw this.transitionLoopDetectedError(e);switch(this.transitioningState=!0,this._state){case oe.Connecting:e!==oe.Connected&&e!==oe.Disconnecting&&e!==oe.Disconnected&&s();break;case oe.Connected:e!==oe.Disconnecting&&e!==oe.Disconnected&&s();break;case oe.Disconnecting:e!==oe.Connecting&&e!==oe.Disconnected&&s();break;case oe.Disconnected:e!==oe.Connecting&&s();break;default:throw new Error("Unknown state.")}const i=this._state;this._state=e;const r=this.connectResolve,n=this.connectReject;i===oe.Connecting&&(this.connectPromise=void 0,this.connectResolve=void 0,this.connectReject=void 0);const o=this.disconnectResolve,a=this.disconnectReject;if(i===oe.Disconnecting&&(this.disconnectPromise=void 0,this.disconnectResolve=void 0,this.disconnectReject=void 0),this.connectTimeout&&(clearTimeout(this.connectTimeout),this.connectTimeout=void 0),this.logger.log(`Transitioned from ${i} to ${this._state}`),this._stateEventEmitter.emit(this._state),e===oe.Connected&&(this.startSendingKeepAlives(),this.onConnect))try{this.onConnect()}catch(e){throw this.logger.error(e.toString()),this.logger.error("Exception thrown by onConnect callback"),e}if(i===oe.Connected&&(this.stopSendingKeepAlives(),this.onDisconnect))try{t?this.onDisconnect(t):this.onDisconnect()}catch(e){throw this.logger.error(e.toString()),this.logger.error("Exception thrown by onDisconnect callback"),e}if(i===oe.Connecting){if(!r)throw new Error("Connect resolve undefined.");if(!n)throw new Error("Connect reject undefined.");e===oe.Connected?r():n(t||new Error("Connect aborted."))}if(i===oe.Disconnecting){if(!o)throw new Error("Disconnect resolve undefined.");if(!a)throw new Error("Disconnect reject undefined.");e===oe.Disconnected?o():a(t||new Error("Disconnect aborted."))}this.transitioningState=!1}clearKeepAliveTimeout(){this.keepAliveDebounceTimeout&&clearTimeout(this.keepAliveDebounceTimeout),this.keepAliveDebounceTimeout=void 0}sendKeepAlive(){return this.keepAliveDebounceTimeout?Promise.resolve():(this.keepAliveDebounceTimeout=setTimeout((()=>{this.clearKeepAliveTimeout()}),1e3*this.configuration.keepAliveDebounce),this.send("\r\n\r\n"))}startSendingKeepAlives(){this.configuration.keepAliveInterval&&!this.keepAliveInterval&&(this.keepAliveInterval=setInterval((()=>{this.sendKeepAlive(),this.startSendingKeepAlives()}),(e=>{const t=.8*e;return 1e3*(Math.random()*(e-t)+t)})(this.configuration.keepAliveInterval)))}stopSendingKeepAlives(){this.keepAliveInterval&&clearInterval(this.keepAliveInterval),this.keepAliveDebounceTimeout&&clearTimeout(this.keepAliveDebounceTimeout),this.keepAliveInterval=void 0,this.keepAliveDebounceTimeout=void 0}}ht.defaultOptions={server:"",connectionTimeout:5,keepAliveInterval:0,keepAliveDebounce:10,traceSip:!0};class dt{constructor(e={}){if(this._publishers={},this._registerers={},this._sessions={},this._subscriptions={},this._state=ae.Stopped,this._stateEventEmitter=new p,this.delegate=e.delegate,this.options=Object.assign(Object.assign(Object.assign(Object.assign(Object.assign({},dt.defaultOptions()),{sipjsId:$(5)}),{uri:new w("sip","anonymous."+$(6),"anonymous.invalid")}),{viaHost:$(12)+".invalid"}),dt.stripUndefinedProperties(e)),this.options.hackIpInContact)if("boolean"==typeof this.options.hackIpInContact&&this.options.hackIpInContact){const e=1,t=254,s=Math.floor(Math.random()*(t-e+1)+e);this.options.viaHost="192.0.2."+s}else this.options.hackIpInContact&&(this.options.viaHost=this.options.hackIpInContact);switch(this.loggerFactory=new Te,this.logger=this.loggerFactory.getLogger("sip.UserAgent"),this.loggerFactory.builtinEnabled=this.options.logBuiltinEnabled,this.loggerFactory.connector=this.options.logConnector,this.options.logLevel){case"error":this.loggerFactory.level=ce.error;break;case"warn":this.loggerFactory.level=ce.warn;break;case"log":this.loggerFactory.level=ce.log;break;case"debug":this.loggerFactory.level=ce.debug}if(this.options.logConfiguration&&(this.logger.log("Configuration:"),Object.keys(this.options).forEach((e=>{const t=this.options[e];switch(e){case"uri":case"sessionDescriptionHandlerFactory":this.logger.log("\xb7 "+e+": "+t);break;case"authorizationPassword":this.logger.log("\xb7 "+e+": NOT SHOWN");break;case"transportConstructor":this.logger.log("\xb7 "+e+": "+t.name);break;default:this.logger.log("\xb7 "+e+": "+JSON.stringify(t))}}))),this.options.transportOptions){const t=this.options.transportOptions,s=t.maxReconnectionAttempts,i=t.reconnectionTimeout;if(void 0!==s){const e='The transport option "maxReconnectionAttempts" as has apparently been specified and has been deprecated. It will no longer be available starting with SIP.js release 0.16.0. Please update accordingly.';this.logger.warn(e)}if(void 0!==i){const e='The transport option "reconnectionTimeout" as has apparently been specified and has been deprecated. It will no longer be available starting with SIP.js release 0.16.0. Please update accordingly.';this.logger.warn(e)}void 0===e.reconnectionDelay&&void 0!==i&&(this.options.reconnectionDelay=i),void 0===e.reconnectionAttempts&&void 0!==s&&(this.options.reconnectionAttempts=s)}if(void 0!==e.reconnectionDelay){const e='The user agent option "reconnectionDelay" as has apparently been specified and has been deprecated. It will no longer be available starting with SIP.js release 0.16.0. Please update accordingly.';this.logger.warn(e)}if(void 0!==e.reconnectionAttempts){const e='The user agent option "reconnectionAttempts" as has apparently been specified and has been deprecated. It will no longer be available starting with SIP.js release 0.16.0. Please update accordingly.';this.logger.warn(e)}if(this._transport=new this.options.transportConstructor(this.getLogger("sip.Transport"),this.options.transportOptions),this.initTransportCallbacks(),this._contact=this.initContact(),this._instanceId=this.options.instanceId?this.options.instanceId:dt.newUUID(),-1===y.parse(this._instanceId,"uuid"))throw new Error("Invalid instanceId.");this._userAgentCore=this.initCore()}static makeURI(e){return y.URIParse(e)}static defaultOptions(){return{allowLegacyNotifications:!1,authorizationHa1:"",authorizationPassword:"",authorizationUsername:"",delegate:{},contactName:"",contactParams:{transport:"ws"},displayName:"",forceRport:!1,gracefulShutdown:!0,hackAllowUnregisteredOptionTags:!1,hackIpInContact:!1,hackViaTcp:!1,instanceId:"",instanceIdAlwaysAdded:!1,logBuiltinEnabled:!0,logConfiguration:!0,logConnector:()=>{},logLevel:"log",noAnswerTimeout:60,preloadedRouteSet:[],reconnectionAttempts:0,reconnectionDelay:4,sendInitialProvisionalResponse:!0,sessionDescriptionHandlerFactory:ct(),sessionDescriptionHandlerFactoryOptions:{},sipExtension100rel:J.Unsupported,sipExtensionReplaces:J.Unsupported,sipExtensionExtraSupported:[],sipjsId:"",transportConstructor:ht,transportOptions:{},uri:new w("sip","anonymous","anonymous.invalid"),userAgentString:"SIP.js/0.21.2",viaHost:""}}static newUUID(){return"xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g,(e=>{const t=Math.floor(16*Math.random());return("x"===e?t:t%4+8).toString(16)}))}static stripUndefinedProperties(e){return Object.keys(e).reduce(((t,s)=>(void 0!==e[s]&&(t[s]=e[s]),t)),{})}get configuration(){return this.options}get contact(){return this._contact}get instanceId(){return this._instanceId}get state(){return this._state}get stateChange(){return this._stateEventEmitter}get transport(){return this._transport}get userAgentCore(){return this._userAgentCore}getLogger(e,t){return this.loggerFactory.getLogger(e,t)}getLoggerFactory(){return this.loggerFactory}isConnected(){return this.transport.isConnected()}reconnect(){return this.state===ae.Stopped?Promise.reject(new Error("User agent stopped.")):Promise.resolve().then((()=>this.transport.connect()))}start(){return this.state===ae.Started?(this.logger.warn("User agent already started"),Promise.resolve()):(this.logger.log(`Starting ${this.configuration.uri}`),this.transitionState(ae.Started),this.transport.connect())}async stop(){if(this.state===ae.Stopped)return this.logger.warn("User agent already stopped"),Promise.resolve();if(this.logger.log(`Stopping ${this.configuration.uri}`),!this.options.gracefulShutdown)return this.logger.log("Dispose of transport"),this.transport.dispose().catch((e=>{throw this.logger.error(e.message),e})),this.logger.log("Dispose of core"),this.userAgentCore.dispose(),this._publishers={},this._registerers={},this._sessions={},this._subscriptions={},this.transitionState(ae.Stopped),Promise.resolve();const e=Object.assign({},this._publishers),t=Object.assign({},this._registerers),s=Object.assign({},this._sessions),i=Object.assign({},this._subscriptions),r=this.transport,n=this.userAgentCore;this.logger.log("Dispose of registerers");for(const e in t)t[e]&&await t[e].dispose().catch((t=>{throw this.logger.error(t.message),delete this._registerers[e],t}));this.logger.log("Dispose of sessions");for(const e in s)s[e]&&await s[e].dispose().catch((t=>{throw this.logger.error(t.message),delete this._sessions[e],t}));this.logger.log("Dispose of subscriptions");for(const e in i)i[e]&&await i[e].dispose().catch((t=>{throw this.logger.error(t.message),delete this._subscriptions[e],t}));this.logger.log("Dispose of publishers");for(const t in e)e[t]&&await e[t].dispose().catch((e=>{throw this.logger.error(e.message),delete this._publishers[t],e}));this.logger.log("Dispose of transport"),await r.dispose().catch((e=>{throw this.logger.error(e.message),e})),this.logger.log("Dispose of core"),n.dispose(),this.transitionState(ae.Stopped)}_makeInviter(e,t){return new ee(this,e,t)}attemptReconnection(e=1){const t=this.options.reconnectionAttempts,s=this.options.reconnectionDelay;e>t?this.logger.log("Maximum reconnection attempts reached"):(this.logger.log(`Reconnection attempt ${e} of ${t} - trying`),setTimeout((()=>{this.reconnect().then((()=>{this.logger.log(`Reconnection attempt ${e} of ${t} - succeeded`)})).catch((s=>{this.logger.error(s.message),this.logger.log(`Reconnection attempt ${e} of ${t} - failed`),this.attemptReconnection(++e)}))}),1===e?0:1e3*s))}initContact(){const e=""!==this.options.contactName?this.options.contactName:$(8),t=this.options.contactParams;return{pubGruu:void 0,tempGruu:void 0,uri:new w("sip",e,this.options.viaHost,void 0,t),toString:(e={})=>{const s=e.anonymous||!1,i=e.outbound||!1,r=e.register||!1;let n="<";return n+=s?this.contact.tempGruu||`sip:anonymous@anonymous.invalid;transport=${t.transport?t.transport:"ws"}`:r?this.contact.uri:this.contact.pubGruu||this.contact.uri,i&&(n+=";ob"),n+=">",this.options.instanceIdAlwaysAdded&&(n+=';+sip.instance=""'),n}}}initCore(){let e=[];e.push("outbound"),this.options.sipExtension100rel===J.Supported&&e.push("100rel"),this.options.sipExtensionReplaces===J.Supported&&e.push("replaces"),this.options.sipExtensionExtraSupported&&e.push(...this.options.sipExtensionExtraSupported),this.options.hackAllowUnregisteredOptionTags||(e=e.filter((e=>X[e]))),e=Array.from(new Set(e));const t=e.slice();(this.contact.pubGruu||this.contact.tempGruu)&&t.push("gruu");const s={aor:this.options.uri,contact:this.contact,displayName:this.options.displayName,loggerFactory:this.loggerFactory,hackViaTcp:this.options.hackViaTcp,routeSet:this.options.preloadedRouteSet,supportedOptionTags:e,supportedOptionTagsResponse:t,sipjsId:this.options.sipjsId,userAgentHeaderFieldValue:this.options.userAgentString,viaForceRport:this.options.forceRport,viaHost:this.options.viaHost,authenticationFactory:()=>{const e=this.options.authorizationUsername?this.options.authorizationUsername:this.options.uri.user,t=this.options.authorizationPassword?this.options.authorizationPassword:void 0,s=this.options.authorizationHa1?this.options.authorizationHa1:void 0;return new we(this.getLoggerFactory(),s,e,t)},transportAccessor:()=>this.transport};return new rt(s,{onInvite:e=>{var t;const s=new Q(this,e);if(e.delegate={onCancel:e=>{s._onCancel(e)},onTransportError:e=>{this.logger.error("A transport error has occurred while handling an incoming INVITE request.")}},e.trying(),this.options.sipExtensionReplaces!==J.Unsupported){const t=e.message.parseHeader("replaces");if(t){const e=t.call_id;if("string"!=typeof e)throw new Error("Type of call id is not string");const i=t.replaces_to_tag;if("string"!=typeof i)throw new Error("Type of to tag is not string");const r=t.replaces_from_tag;if("string"!=typeof r)throw new Error("type of from tag is not string");const n=e+i+r,o=this.userAgentCore.dialogs.get(n);if(!o)return void s.reject({statusCode:481});if(!o.early&&!0===t.early_only)return void s.reject({statusCode:486});const a=this._sessions[e+r]||this._sessions[e+i]||void 0;if(!a)throw new Error("Session does not exist.");s._replacee=a}}if(null===(t=this.delegate)||void 0===t?void 0:t.onInvite)return s.autoSendAnInitialProvisionalResponse?void s.progress().then((()=>{var e;if(void 0===(null===(e=this.delegate)||void 0===e?void 0:e.onInvite))throw new Error("onInvite undefined.");this.delegate.onInvite(s)})):void this.delegate.onInvite(s);s.reject({statusCode:486})},onMessage:e=>{if(this.delegate&&this.delegate.onMessage){const t=new W(e);this.delegate.onMessage(t)}else e.accept()},onNotify:e=>{if(this.delegate&&this.delegate.onNotify){const t=new K(e);this.delegate.onNotify(t)}else this.options.allowLegacyNotifications?e.accept():e.reject({statusCode:481})},onRefer:e=>{this.logger.warn("Received an out of dialog REFER request"),this.delegate&&this.delegate.onReferRequest?this.delegate.onReferRequest(e):e.reject({statusCode:405})},onRegister:e=>{this.logger.warn("Received an out of dialog REGISTER request"),this.delegate&&this.delegate.onRegisterRequest?this.delegate.onRegisterRequest(e):e.reject({statusCode:405})},onSubscribe:e=>{this.logger.warn("Received an out of dialog SUBSCRIBE request"),this.delegate&&this.delegate.onSubscribeRequest?this.delegate.onSubscribeRequest(e):e.reject({statusCode:405})}})}initTransportCallbacks(){this.transport.onConnect=()=>this.onTransportConnect(),this.transport.onDisconnect=e=>this.onTransportDisconnect(e),this.transport.onMessage=e=>this.onTransportMessage(e)}onTransportConnect(){this.state!==ae.Stopped&&this.delegate&&this.delegate.onConnect&&this.delegate.onConnect()}onTransportDisconnect(e){this.state!==ae.Stopped&&(this.delegate&&this.delegate.onDisconnect&&this.delegate.onDisconnect(e),e&&this.options.reconnectionAttempts>0&&this.attemptReconnection())}onTransportMessage(e){const t=he.parseMessage(e,this.getLogger("sip.Parser"));if(!t)return void this.logger.warn("Failed to parse incoming message. Dropping.");if(this.state===ae.Stopped&&t instanceof P)return void this.logger.warn(`Received ${t.method} request while stopped. Dropping.`);const s=()=>{const e=["from","to","call_id","cseq","via"];for(const s of e)if(!t.hasHeader(s))return this.logger.warn(`Missing mandatory header field : ${s}.`),!1;return!0};if(t instanceof P){if(!s())return void this.logger.warn("Request missing mandatory header field. Dropping.");if(!t.toTag&&t.callId.substr(0,5)===this.options.sipjsId)return void this.userAgentCore.replyStateless(t,{statusCode:482});const e=D(t.body),i=t.getHeader("content-length");if(i&&e1)return void this.logger.warn("More than one Via header field present in the response. Dropping.");if(t.via.host!==this.options.viaHost||void 0!==t.via.port)return void this.logger.warn("Via sent-by in the response does not match UA Via host value. Dropping.");const e=D(t.body),i=t.getHeader("content-length");if(i&&e{throw new Error(`Invalid state transition from ${this._state} to ${e}`)};switch(this._state){case ae.Started:e!==ae.Stopped&&s();break;case ae.Stopped:e!==ae.Started&&s();break;default:throw new Error("Unknown state.")}this.logger.log(`Transitioned from ${this._state} to ${e}`),this._state=e,this._stateEventEmitter.emit(this._state)}}class lt extends He{constructor(e,t,s){super(Ce,e,t,s)}}class gt extends ke{constructor(e,t,s){super(xe,e.userAgentCore,t,s)}}const ut=(e,t)=>{const s=[],i=e.split(/\r\n/);let r;for(let e=0;e(t.sdp=ut(t.sdp||"",e),Promise.resolve(t))}function bt(e){return e.sdp=((e,t)=>{const s=new RegExp("m="+t+".*$","gm"),i=new RegExp("^a=group:.*$","gm");if(s.test(e)){let s;const r=(e=e.split(/^m=/gm).filter((e=>{if(e.substr(0,t.length)===t){if(s=e.match(/^a=mid:.*$/gm),s){const e=s[0].match(/:.+$/g);e&&(s=e[0].substr(1))}return!1}return!0})).join("m=")).match(i);if(r&&1===r.length){let t=r[0];const n=new RegExp(" *"+s+"[^ ]*","g");t=t.replace(n,""),e=e.split(i).join(t)}}return e})(e.sdp||"","video"),Promise.resolve(e)}function Tt(e){let t=e.sdp||"";if(-1===t.search(/^a=mid.*$/gm)){const s=t.match(/^m=.*$/gm),i=t.split(/^m=.*$/gm);s&&s.forEach(((e,t)=>{s[t]=e+"\na=mid:"+t})),i.forEach(((e,t)=>{s&&s[t]&&(i[t]=e+s[t])})),t=i.join(""),e.sdp=t}return Promise.resolve(e)}function St(e){if(!e.sdp||!e.type)throw new Error("Invalid SDP");let t=e.sdp;const s=e.type;return t&&(/a=(sendrecv|sendonly|recvonly|inactive)/.test(t)?(t=t.replace(/a=sendrecv\r\n/g,"a=sendonly\r\n"),t=t.replace(/a=recvonly\r\n/g,"a=inactive\r\n")):t=t.replace(/(m=[^\r]*\r\n)/g,"$1a=sendonly\r\n")),Promise.resolve({sdp:t,type:s})}function Rt(e){if(e.length<2)throw new Error("Start local conference requires at leaast 2 sessions.");var t;(t=e.map((e=>e.sessionDescriptionHandler)),t.map(((e,s)=>t.slice(s+1).map((t=>[e,t])))).reduce(((e,t)=>e.concat(t)),[])).forEach((([e,t])=>{if(!(e instanceof yt&&t instanceof yt))throw new Error("Session description handler not instance of SessionManagerSessionDescriptionHandler");e.joinWith(t)}))}class yt extends at{constructor(e,t,s){super(e,t,s),yt.audioContext||(yt.audioContext=new AudioContext)}enableSenderTracks(e){const t=this.localMediaStreamReal;if(void 0===t)throw new Error("Stream undefined.");t.getAudioTracks().forEach((t=>{t.enabled=e}))}initLocalMediaStream(e){if(!yt.audioContext)throw new Error("SessionManagerSessionDescriptionHandler.audioContext undefined.");return this.localMediaStreamReal=e,this.localMediaStreamSourceNode=yt.audioContext.createMediaStreamSource(e),this.localMediaStreamDestinationNode=yt.audioContext.createMediaStreamDestination(),this.localMediaStreamSourceNode.connect(this.localMediaStreamDestinationNode),this.localMediaStreamDestinationNode.stream}joinWith(e){if(!yt.audioContext)throw new Error("SessionManagerSessionDescriptionHandler.audioContext undefined.");const t=yt.audioContext.createMediaStreamSource(this.remoteMediaStream),s=e.localMediaStreamDestinationNode;if(void 0===s)throw new Error("Peer outbound (local) stream local media stream destination is undefined.");t.connect(s);const i=yt.audioContext.createMediaStreamSource(e.remoteMediaStream),r=this.localMediaStreamDestinationNode;if(void 0===r)throw new Error("Our outbound (local) stream local media stream destination is undefined.");i.connect(r)}setRealLocalMediaStream(e){if(!yt.audioContext)throw new Error("SessionManagerSessionDescriptionHandler.audioContext undefined.");if(this.localMediaStreamReal){if(!this.localMediaStreamDestinationNode||!this.localMediaStreamSourceNode||!this.localMediaStreamReal)throw new Error("Local media stream undefined.");this.localMediaStreamReal=e,this.localMediaStreamSourceNode.disconnect(this.localMediaStreamDestinationNode),this.localMediaStreamSourceNode=yt.audioContext.createMediaStreamSource(e),this.localMediaStreamSourceNode.connect(this.localMediaStreamDestinationNode)}else this.initLocalMediaStream(e)}}function Et(){return(e,t)=>({session:t,held:!1,muted:!1})}class $t{constructor(e,t={}){this.managedSessions=[],this.attemptingReconnection=!1,this.optionsPingFailure=!1,this.optionsPingRunning=!1,this.shouldBeConnected=!1,this.shouldBeRegistered=!1,this.delegate=t.delegate,this.options=Object.assign({aor:"",autoStop:!0,delegate:{},iceStopWaitingOnServerReflexive:!1,managedSessionFactory:(e,t)=>({session:t,held:!1,muted:!1}),maxSimultaneousSessions:2,media:{},optionsPingInterval:-1,optionsPingRequestURI:"",reconnectionAttempts:3,reconnectionDelay:4,registrationRetry:!1,registrationRetryInterval:3,registerGuard:null,registererOptions:{},registererRegisterOptions:{},sendDTMFUsingSessionDescriptionHandler:!1,userAgentOptions:{}},$t.stripUndefinedProperties(t));const s=Object.assign({},t.userAgentOptions);if(s.transportConstructor||(s.transportConstructor=ht),s.transportOptions||(s.transportOptions={server:e}),!s.uri&&t.aor){const e=dt.makeURI(t.aor);if(!e)throw new Error(`Failed to create valid URI from ${t.aor}`);s.uri=e}if(this.userAgent=new dt(s),this.userAgent.delegate={onConnect:()=>{this.logger.log("Connected"),this.delegate&&this.delegate.onServerConnect&&this.delegate.onServerConnect(),this.shouldBeRegistered&&this.register(),this.options.optionsPingInterval>0&&this.optionsPingStart()},onDisconnect:async e=>{this.logger.log("Disconnected");let t=!1;this.options.optionsPingInterval>0&&(t=this.optionsPingFailure,this.optionsPingFailure=!1,this.optionsPingStop()),this.delegate&&this.delegate.onServerDisconnect&&this.delegate.onServerDisconnect(e),(e||t)&&(this.registerer&&(this.logger.log("Disposing of registerer..."),this.registerer.dispose().catch((e=>{this.logger.debug("Error occurred disposing of registerer after connection with server was lost."),this.logger.debug(e.toString())})),this.registerer=void 0),this.managedSessions.slice().map((e=>e.session)).forEach((async e=>{this.logger.log("Disposing of session..."),e.dispose().catch((e=>{this.logger.debug("Error occurred disposing of a session after connection with server was lost."),this.logger.debug(e.toString())}))})),this.shouldBeConnected&&this.attemptReconnection())},onInvite:e=>{this.logger.log(`[${e.id}] Received INVITE`);const t=this.options.maxSimultaneousSessions;if(0!==t&&this.managedSessions.length>t)return this.logger.warn(`[${e.id}] Session already in progress, rejecting INVITE...`),void e.reject().then((()=>{this.logger.log(`[${e.id}] Rejected INVITE`)})).catch((t=>{this.logger.error(`[${e.id}] Failed to reject INVITE`),this.logger.error(t.toString())}));const s={sessionDescriptionHandlerOptions:{constraints:this.constraints}};this.initSession(e,s),this.delegate&&this.delegate.onCallReceived?this.delegate.onCallReceived(e):(this.logger.warn(`[${e.id}] No handler available, rejecting INVITE...`),e.reject().then((()=>{this.logger.log(`[${e.id}] Rejected INVITE`)})).catch((t=>{this.logger.error(`[${e.id}] Failed to reject INVITE`),this.logger.error(t.toString())})))},onMessage:e=>{e.accept().then((()=>{this.delegate&&this.delegate.onMessageReceived&&this.delegate.onMessageReceived(e)}))},onNotify:e=>{e.accept().then((()=>{this.delegate&&this.delegate.onNotificationReceived&&this.delegate.onNotificationReceived(e)}))}},this.registererOptions=Object.assign({},t.registererOptions),this.registererRegisterOptions=Object.assign({},t.registererRegisterOptions),this.options.registrationRetry){this.registererRegisterOptions.requestDelegate=this.registererRegisterOptions.requestDelegate||{};const e=this.registererRegisterOptions.requestDelegate.onReject;this.registererRegisterOptions.requestDelegate.onReject=t=>{e&&e(t),this.attemptRegistration()}}this.logger=this.userAgent.getLogger("sip.SessionManager"),window.addEventListener("online",(()=>{this.logger.log("Online"),this.shouldBeConnected&&this.connect()})),this.options.autoStop&&window.addEventListener("beforeunload",(async()=>{this.shouldBeConnected=!1,this.shouldBeRegistered=!1,this.userAgent.state!==ae.Stopped&&await this.userAgent.stop()}))}static stripUndefinedProperties(e){return Object.keys(e).reduce(((t,s)=>(void 0!==e[s]&&(t[s]=e[s]),t)),{})}getLocalMediaStream(e){const t=e.sessionDescriptionHandler;if(t){if(!(t instanceof at))throw new Error("Session description handler not instance of web SessionDescriptionHandler");return t.localMediaStream}}getRemoteMediaStream(e){const t=e.sessionDescriptionHandler;if(t){if(!(t instanceof at))throw new Error("Session description handler not instance of web SessionDescriptionHandler");return t.remoteMediaStream}}getLocalAudioTrack(e){var t;return null===(t=this.getLocalMediaStream(e))||void 0===t?void 0:t.getTracks().find((e=>"audio"===e.kind))}getLocalVideoTrack(e){var t;return null===(t=this.getLocalMediaStream(e))||void 0===t?void 0:t.getTracks().find((e=>"video"===e.kind))}getRemoteAudioTrack(e){var t;return null===(t=this.getRemoteMediaStream(e))||void 0===t?void 0:t.getTracks().find((e=>"audio"===e.kind))}getRemoteVideoTrack(e){var t;return null===(t=this.getRemoteMediaStream(e))||void 0===t?void 0:t.getTracks().find((e=>"video"===e.kind))}async connect(){return this.logger.log("Connecting UserAgent..."),this.shouldBeConnected=!0,this.userAgent.state!==ae.Started?this.userAgent.start():this.userAgent.reconnect()}async disconnect(){return this.logger.log("Disconnecting UserAgent..."),this.userAgent.state===ae.Stopped?Promise.resolve():(this.shouldBeConnected=!1,this.shouldBeRegistered=!1,this.registerer=void 0,this.userAgent.stop())}isConnected(){return this.userAgent.isConnected()}async register(e){return this.logger.log("Registering UserAgent..."),this.shouldBeRegistered=!0,void 0!==e&&(this.registererRegisterOptions=Object.assign({},e)),this.registerer||(this.registerer=new ge(this.userAgent,this.registererOptions),this.registerer.stateChange.addListener((e=>{switch(e){case ie.Initial:break;case ie.Registered:this.delegate&&this.delegate.onRegistered&&this.delegate.onRegistered();break;case ie.Unregistered:this.delegate&&this.delegate.onUnregistered&&this.delegate.onUnregistered(),this.shouldBeRegistered&&this.attemptRegistration();break;case ie.Terminated:break;default:throw new Error("Unknown registerer state.")}}))),this.attemptRegistration(!0)}async unregister(e){return this.logger.log("Unregistering UserAgent..."),this.shouldBeRegistered=!1,this.registerer?this.registerer.unregister(e).then((()=>{})):(this.logger.warn("No registerer to unregister."),Promise.resolve())}async call(e,t,s){this.logger.log("Beginning Session...");const i=this.options.maxSimultaneousSessions;if(0!==i&&this.managedSessions.length>i)return Promise.reject(new Error("Maximum number of sessions already exists."));const r=dt.makeURI(e);if(!r)return Promise.reject(new Error(`Failed to create a valid URI from "${e}"`));if(t||(t={}),t.sessionDescriptionHandlerOptions||(t.sessionDescriptionHandlerOptions={}),t.sessionDescriptionHandlerOptions.constraints||(t.sessionDescriptionHandlerOptions.constraints=this.constraints),t.earlyMedia){(s=s||{}).requestDelegate=s.requestDelegate||{};const e=s.requestDelegate.onProgress;s.requestDelegate.onProgress=t=>{183===t.message.statusCode&&this.setupRemoteMedia(n),e&&e(t)}}this.options.iceStopWaitingOnServerReflexive&&(t.delegate=t.delegate||{},t.delegate.onSessionDescriptionHandler=e=>{if(!(e instanceof at))throw new Error("Session description handler not instance of SessionDescriptionHandler");e.peerConnectionDelegate={onicecandidate:t=>{var s;if("srflx"===(null===(s=t.candidate)||void 0===s?void 0:s.type)){this.logger.log(`[${n.id}] Found srflx ICE candidate, stop waiting...`);e.iceGatheringComplete()}}}});const n=new ee(this.userAgent,r,t);return this.sendInvite(n,t,s).then((()=>n))}async hangup(e){return this.logger.log(`[${e.id}] Hangup...`),this.sessionExists(e)?this.terminate(e):Promise.reject(new Error("Session does not exist."))}async answer(e,t){return this.logger.log(`[${e.id}] Accepting Invitation...`),this.sessionExists(e)?e instanceof Q?(t||(t={}),t.sessionDescriptionHandlerOptions||(t.sessionDescriptionHandlerOptions={}),t.sessionDescriptionHandlerOptions.constraints||(t.sessionDescriptionHandlerOptions.constraints=this.constraints),e.accept(t)):Promise.reject(new Error("Session not instance of Invitation.")):Promise.reject(new Error("Session does not exist."))}async decline(e){return this.logger.log(`[${e.id}] Rejecting Invitation...`),this.sessionExists(e)?e instanceof Q?e.reject():Promise.reject(new Error("Session not instance of Invitation.")):Promise.reject(new Error("Session does not exist."))}async hold(e){return this.logger.log(`[${e.id}] Holding session...`),this.setHold(e,!0)}async unhold(e){return this.logger.log(`[${e.id}] Unholding session...`),this.setHold(e,!1)}isHeld(e){const t=this.sessionManaged(e);return!!t&&t.held}mute(e){this.logger.log(`[${e.id}] Disabling media tracks...`),this.setMute(e,!0)}unmute(e){this.logger.log(`[${e.id}] Enabling media tracks...`),this.setMute(e,!1)}isMuted(e){const t=this.sessionManaged(e);return!!t&&t.muted}async sendDTMF(e,t){if(this.logger.log(`[${e.id}] Sending DTMF...`),!/^[0-9A-D#*,]$/.exec(t))return Promise.reject(new Error("Invalid DTMF tone."));if(!this.sessionExists(e))return Promise.reject(new Error("Session does not exist."));if(this.logger.log(`[${e.id}] Sending DTMF tone: ${t}`),this.options.sendDTMFUsingSessionDescriptionHandler)return e.sessionDescriptionHandler?e.sessionDescriptionHandler.sendDtmf(t)?Promise.resolve():Promise.reject(new Error("Failed to send DTMF")):Promise.reject(new Error("Session desciption handler undefined."));{const s={body:{contentDisposition:"render",contentType:"application/dtmf-relay",content:"Signal="+t+"\r\nDuration="+2e3}};return e.info({requestOptions:s}).then((()=>{}))}}async transfer(e,t,s){if(this.logger.log(`[${e.id}] Referring session...`),t instanceof z)return e.refer(t,s).then((()=>{}));const i=dt.makeURI(t);return i?e.refer(i,s).then((()=>{})):Promise.reject(new Error(`Failed to create a valid URI from "${t}"`))}async message(e,t){this.logger.log("Sending message...");const s=dt.makeURI(e);return s?new te(this.userAgent,s,t).message():Promise.reject(new Error(`Failed to create a valid URI from "${e}"`))}get constraints(){let e={audio:!0,video:!1};return this.options.media.constraints&&(e=Object.assign({},this.options.media.constraints)),e}attemptReconnection(e=1){const t=this.options.reconnectionAttempts,s=this.options.reconnectionDelay;this.shouldBeConnected?(this.attemptingReconnection&&this.logger.log("Reconnection attempt already in progress"),e>t?this.logger.log("Reconnection maximum attempts reached"):(1===e?this.logger.log(`Reconnection attempt ${e} of ${t} - trying`):this.logger.log(`Reconnection attempt ${e} of ${t} - trying in ${s} seconds`),this.attemptingReconnection=!0,setTimeout((()=>{if(!this.shouldBeConnected)return this.logger.log(`Reconnection attempt ${e} of ${t} - aborted`),void(this.attemptingReconnection=!1);this.userAgent.reconnect().then((()=>{this.logger.log(`Reconnection attempt ${e} of ${t} - succeeded`),this.attemptingReconnection=!1})).catch((s=>{this.logger.log(`Reconnection attempt ${e} of ${t} - failed`),this.logger.error(s.message),this.attemptingReconnection=!1,this.attemptReconnection(++e)}))}),1===e?0:1e3*s))):this.logger.log("Should not be connected currently")}attemptRegistration(e=!1){if(this.logger.log("Registration attempt "+(e?"without delay":"")),!this.shouldBeRegistered)return this.logger.log("Should not be registered currently"),Promise.resolve();if(void 0!==this.registrationAttemptTimeout)return this.logger.log("Registration attempt already in progress"),Promise.resolve();const t=()=>this.registerer?this.isConnected()?this.userAgent.state===ae.Stopped?(this.logger.log("User agent stopped"),Promise.resolve()):this.options.registerGuard?this.options.registerGuard().catch((e=>{throw this.logger.log("Register guard rejected will making registration attempt"),e})).then((e=>e||!this.registerer?Promise.resolve():this.registerer.register(this.registererRegisterOptions).then((()=>{})))):this.registerer.register(this.registererRegisterOptions).then((()=>{})):(this.logger.log("User agent not connected"),Promise.resolve()):(this.logger.log("Registerer undefined"),Promise.resolve());return new Promise(((s,i)=>{this.registrationAttemptTimeout=setTimeout((()=>{t().then((()=>{this.registrationAttemptTimeout=void 0,s()})).catch((e=>{this.registrationAttemptTimeout=void 0,e instanceof a?s():i(e)}))}),e?0:(e=>{const t=2*e;return 1e3*(Math.random()*(t-e)+e)})(this.options.registrationRetryInterval))}))}cleanupMedia(e){const t=this.sessionManaged(e);if(!t)throw new Error("Managed session does not exist.");t.mediaLocal&&t.mediaLocal.video&&(t.mediaLocal.video.srcObject=null,t.mediaLocal.video.pause()),t.mediaRemote&&(t.mediaRemote.audio&&(t.mediaRemote.audio.srcObject=null,t.mediaRemote.audio.pause()),t.mediaRemote.video&&(t.mediaRemote.video.srcObject=null,t.mediaRemote.video.pause()))}enableReceiverTracks(e,t){if(!this.sessionExists(e))throw new Error("Session does not exist.");const s=e.sessionDescriptionHandler;if(!(s instanceof at))throw new Error("Session's session description handler not instance of SessionDescriptionHandler.");s.enableReceiverTracks(t)}enableSenderTracks(e,t){if(!this.sessionExists(e))throw new Error("Session does not exist.");const s=e.sessionDescriptionHandler;if(!(s instanceof at))throw new Error("Session's session description handler not instance of SessionDescriptionHandler.");s.enableSenderTracks(t)}initSession(e,t){this.sessionAdd(e),this.delegate&&this.delegate.onCallCreated&&this.delegate.onCallCreated(e),e.stateChange.addListener((t=>{switch(this.logger.log(`[${e.id}] Session state changed to ${t}`),t){case Z.Initial:case Z.Establishing:break;case Z.Established:this.setupLocalMedia(e),this.setupRemoteMedia(e),this.delegate&&this.delegate.onCallAnswered&&this.delegate.onCallAnswered(e);break;case Z.Terminating:case Z.Terminated:this.sessionExists(e)&&(this.cleanupMedia(e),this.sessionRemove(e),this.delegate&&this.delegate.onCallHangup&&this.delegate.onCallHangup(e));break;default:throw new Error("Unknown session state.")}})),e.delegate=e.delegate||{},e.delegate.onInfo=t=>{var s;if(void 0===(null===(s=this.delegate)||void 0===s?void 0:s.onCallDTMFReceived))return void t.reject();const i=t.request.getHeader("content-type");if(!i||!/^application\/dtmf-relay/i.exec(i))return void t.reject();const r=t.request.body.split("\r\n",2);if(2!==r.length)return void t.reject();let n;const o=/^(Signal\s*?=\s*?)([0-9A-D#*]{1})(\s)?.*/;if(void 0!==r[0]&&o.test(r[0])&&(n=r[0].replace(o,"$2")),!n)return void t.reject();let a;const c=/^(Duration\s?=\s?)([0-9]{1,4})(\s)?.*/;void 0!==r[1]&&c.test(r[1])&&(a=parseInt(r[1].replace(c,"$2"),10)),a?t.accept().then((()=>{if(this.delegate&&this.delegate.onCallDTMFReceived){if(!n||!a)throw new Error("Tone or duration undefined.");this.delegate.onCallDTMFReceived(e,n,a)}})).catch((e=>{this.logger.error(e.message)})):t.reject()},e.delegate.onRefer=e=>{e.accept().then((()=>this.sendInvite(e.makeInviter(t),t))).catch((e=>{this.logger.error(e.message)}))}}optionsPingRun(e,t,s){if(this.options.optionsPingInterval<1)throw new Error("Invalid options ping interval.");this.optionsPingRunning||(this.optionsPingRunning=!0,this.optionsPingTimeout=setTimeout((()=>{this.optionsPingTimeout=void 0;const i=()=>{this.optionsPingFailure=!1,this.optionsPingRunning&&(this.optionsPingRunning=!1,this.optionsPingRun(e,t,s))},r=()=>{this.logger.error("OPTIONS ping failed"),this.optionsPingFailure=!0,this.optionsPingRunning=!1,this.userAgent.transport.disconnect().catch((e=>this.logger.error(e)))},n=this.userAgent.userAgentCore,o=n.makeOutgoingRequestMessage("OPTIONS",e,t,s,{});this.optionsPingRequest=n.request(o,{onAccept:()=>{this.optionsPingRequest=void 0,i()},onReject:e=>{this.optionsPingRequest=void 0,408===e.message.statusCode||503===e.message.statusCode?r():i()}})}),1e3*this.options.optionsPingInterval))}optionsPingStart(){let e,t,s;if(this.logger.log("OPTIONS pings started"),this.options.optionsPingRequestURI){if(e=dt.makeURI(this.options.optionsPingRequestURI),!e)throw new Error("Failed to create Request URI.");t=this.userAgent.contact.uri.clone(),s=this.userAgent.contact.uri.clone()}else{if(!this.options.aor)return void this.logger.error("You have enabled sending OPTIONS pings and as such you must provide either a) an AOR to register, or b) an RURI to use for the target of the OPTIONS ping requests. ");{const i=dt.makeURI(this.options.aor);if(!i)throw new Error("Failed to create URI.");e=i.clone(),e.user=void 0,t=i.clone(),s=i.clone()}}this.optionsPingRun(e,t,s)}optionsPingStop(){this.logger.log("OPTIONS pings stopped"),this.optionsPingRunning=!1,this.optionsPingFailure=!1,this.optionsPingRequest&&(this.optionsPingRequest.dispose(),this.optionsPingRequest=void 0),this.optionsPingTimeout&&(clearTimeout(this.optionsPingTimeout),this.optionsPingTimeout=void 0)}async sendInvite(e,t,s){return this.initSession(e,t),e.invite(s).then((()=>{this.logger.log(`[${e.id}] Sent INVITE`)}))}sessionAdd(e){const t=this.options.managedSessionFactory(this,e);this.managedSessions.push(t)}sessionExists(e){return void 0!==this.sessionManaged(e)}sessionManaged(e){return this.managedSessions.find((t=>t.session.id===e.id))}sessionRemove(e){this.managedSessions=this.managedSessions.filter((t=>t.session.id!==e.id))}async setHold(e,t){if(!this.sessionExists(e))return Promise.reject(new Error("Session does not exist."));if(this.isHeld(e)===t)return Promise.resolve();if(!(e.sessionDescriptionHandler instanceof at))throw new Error("Session's session description handler not instance of SessionDescriptionHandler.");const s={requestDelegate:{onAccept:()=>{const s=this.sessionManaged(e);void 0!==s&&(s.held=t,this.enableReceiverTracks(e,!s.held),this.enableSenderTracks(e,!s.held&&!s.muted),this.delegate&&this.delegate.onCallHold&&this.delegate.onCallHold(e,s.held))},onReject:()=>{this.logger.warn(`[${e.id}] Re-invite request was rejected`);const s=this.sessionManaged(e);void 0!==s&&(s.held=!t,this.enableReceiverTracks(e,!s.held),this.enableSenderTracks(e,!s.held&&!s.muted),this.delegate&&this.delegate.onCallHold&&this.delegate.onCallHold(e,s.held))}}},i=e.sessionDescriptionHandlerOptionsReInvite;i.hold=t,e.sessionDescriptionHandlerOptionsReInvite=i;const r=this.sessionManaged(e);if(!r)throw new Error("Managed session is undefiend.");return r.held=t,e.invite(s).then((()=>{const t=this.sessionManaged(e);void 0!==t&&(this.enableReceiverTracks(e,!t.held),this.enableSenderTracks(e,!t.held&&!t.muted))})).catch((s=>{throw r.held=!t,s instanceof a&&this.logger.error(`[${e.id}] A hold request is already in progress.`),s}))}setMute(e,t){if(!this.sessionExists(e))return void this.logger.warn(`[${e.id}] A session is required to enabled/disable media tracks`);if(e.state!==Z.Established)return void this.logger.warn(`[${e.id}] An established session is required to enable/disable media tracks`);const s=this.sessionManaged(e);void 0!==s&&(s.muted=t,this.enableSenderTracks(e,!s.held&&!s.muted))}setupLocalMedia(e){const t=this.sessionManaged(e);if(!t)throw new Error("Managed session does not exist.");const s="function"==typeof this.options.media.local?this.options.media.local(e):this.options.media.local;t.mediaLocal=s;const i=null==s?void 0:s.video;if(i){const t=this.getLocalMediaStream(e);if(!t)throw new Error("Local media stream undefiend.");i.srcObject=t,i.volume=0,i.play().catch((t=>{this.logger.error(`[${e.id}] Failed to play local media`),this.logger.error(t.message)}))}}setupRemoteMedia(e){const t=this.sessionManaged(e);if(!t)throw new Error("Managed session does not exist.");const s="function"==typeof this.options.media.remote?this.options.media.remote(e):this.options.media.remote;t.mediaRemote=s;const i=(null==s?void 0:s.video)||(null==s?void 0:s.audio);if(i){const t=this.getRemoteMediaStream(e);if(!t)throw new Error("Remote media stream undefiend.");i.autoplay=!0,i.srcObject=t,i.play().catch((t=>{this.logger.error(`[${e.id}] Failed to play remote media`),this.logger.error(t.message)})),t.onaddtrack=()=>{this.logger.log("Remote media onaddtrack"),i.load(),i.play().catch((t=>{this.logger.error(`[${e.id}] Failed to play remote media`),this.logger.error(t.message)}))}}}async terminate(e){switch(this.logger.log(`[${e.id}] Terminating...`),e.state){case Z.Initial:if(e instanceof ee)return e.cancel().then((()=>{this.logger.log(`[${e.id}] Inviter never sent INVITE (canceled)`)}));if(e instanceof Q)return e.reject().then((()=>{this.logger.log(`[${e.id}] Invitation rejected (sent 480)`)}));throw new Error("Unknown session type.");case Z.Establishing:if(e instanceof ee)return e.cancel().then((()=>{this.logger.log(`[${e.id}] Inviter canceled (sent CANCEL)`)}));if(e instanceof Q)return e.reject().then((()=>{this.logger.log(`[${e.id}] Invitation rejected (sent 480)`)}));throw new Error("Unknown session type.");case Z.Established:return e.bye().then((()=>{this.logger.log(`[${e.id}] Session ended (sent BYE)`)}));case Z.Terminating:case Z.Terminated:break;default:throw new Error("Unknown state")}return this.logger.log(`[${e.id}] Terminating in state ${e.state}, no action taken`),Promise.resolve()}}class It{constructor(e,t={}){this.session=void 0,this.delegate=t.delegate,this.options=Object.assign({},t);const s={aor:this.options.aor,delegate:{onCallAnswered:()=>{var e,t;return null===(t=null===(e=this.delegate)||void 0===e?void 0:e.onCallAnswered)||void 0===t?void 0:t.call(e)},onCallCreated:e=>{var t,s;this.session=e,null===(s=null===(t=this.delegate)||void 0===t?void 0:t.onCallCreated)||void 0===s||s.call(t)},onCallReceived:()=>{var e,t;return null===(t=null===(e=this.delegate)||void 0===e?void 0:e.onCallReceived)||void 0===t?void 0:t.call(e)},onCallHangup:()=>{var e,t;this.session=void 0,(null===(e=this.delegate)||void 0===e?void 0:e.onCallHangup)&&(null===(t=this.delegate)||void 0===t||t.onCallHangup())},onCallHold:(e,t)=>{var s,i;return null===(i=null===(s=this.delegate)||void 0===s?void 0:s.onCallHold)||void 0===i?void 0:i.call(s,t)},onCallDTMFReceived:(e,t,s)=>{var i,r;return null===(r=null===(i=this.delegate)||void 0===i?void 0:i.onCallDTMFReceived)||void 0===r?void 0:r.call(i,t,s)},onMessageReceived:e=>{var t,s;return null===(s=null===(t=this.delegate)||void 0===t?void 0:t.onMessageReceived)||void 0===s?void 0:s.call(t,e.request.body)},onRegistered:()=>{var e,t;return null===(t=null===(e=this.delegate)||void 0===e?void 0:e.onRegistered)||void 0===t?void 0:t.call(e)},onUnregistered:()=>{var e,t;return null===(t=null===(e=this.delegate)||void 0===e?void 0:e.onUnregistered)||void 0===t?void 0:t.call(e)},onServerConnect:()=>{var e,t;return null===(t=null===(e=this.delegate)||void 0===e?void 0:e.onServerConnect)||void 0===t?void 0:t.call(e)},onServerDisconnect:()=>{var e,t;return null===(t=null===(e=this.delegate)||void 0===e?void 0:e.onServerDisconnect)||void 0===t?void 0:t.call(e)}},maxSimultaneousSessions:1,media:this.options.media,reconnectionAttempts:this.options.reconnectionAttempts,reconnectionDelay:this.options.reconnectionDelay,registererOptions:this.options.registererOptions,sendDTMFUsingSessionDescriptionHandler:this.options.sendDTMFUsingSessionDescriptionHandler,userAgentOptions:this.options.userAgentOptions};this.sessionManager=new $t(e,s),this.logger=this.sessionManager.userAgent.getLogger("sip.SimpleUser")}get id(){return this.options.userAgentOptions&&this.options.userAgentOptions.displayName||"Anonymous"}get localMediaStream(){return this.session&&this.sessionManager.getLocalMediaStream(this.session)}get remoteMediaStream(){return this.session&&this.sessionManager.getRemoteMediaStream(this.session)}get localAudioTrack(){return this.session&&this.sessionManager.getLocalAudioTrack(this.session)}get localVideoTrack(){return this.session&&this.sessionManager.getLocalVideoTrack(this.session)}get remoteAudioTrack(){return this.session&&this.sessionManager.getRemoteAudioTrack(this.session)}get remoteVideoTrack(){return this.session&&this.sessionManager.getRemoteVideoTrack(this.session)}connect(){return this.logger.log(`[${this.id}] Connecting UserAgent...`),this.sessionManager.connect()}disconnect(){return this.logger.log(`[${this.id}] Disconnecting UserAgent...`),this.sessionManager.disconnect()}isConnected(){return this.sessionManager.isConnected()}register(e){return this.logger.log(`[${this.id}] Registering UserAgent...`),this.sessionManager.register(e)}unregister(e){return this.logger.log(`[${this.id}] Unregistering UserAgent...`),this.sessionManager.unregister(e)}call(e,t,s){return this.logger.log(`[${this.id}] Beginning Session...`),this.session?Promise.reject(new Error("Session already exists.")):this.sessionManager.call(e,t,s).then((()=>{}))}hangup(){return this.logger.log(`[${this.id}] Hangup...`),this.session?this.sessionManager.hangup(this.session).then((()=>{this.session=void 0})):Promise.reject(new Error("Session does not exist."))}answer(e){return this.logger.log(`[${this.id}] Accepting Invitation...`),this.session?this.sessionManager.answer(this.session,e):Promise.reject(new Error("Session does not exist."))}decline(){return this.logger.log(`[${this.id}] rejecting Invitation...`),this.session?this.sessionManager.decline(this.session):Promise.reject(new Error("Session does not exist."))}hold(){return this.logger.log(`[${this.id}] holding session...`),this.session?this.sessionManager.hold(this.session):Promise.reject(new Error("Session does not exist."))}unhold(){return this.logger.log(`[${this.id}] unholding session...`),this.session?this.sessionManager.unhold(this.session):Promise.reject(new Error("Session does not exist."))}isHeld(){return!!this.session&&this.sessionManager.isHeld(this.session)}mute(){return this.logger.log(`[${this.id}] disabling media tracks...`),this.session&&this.sessionManager.mute(this.session)}unmute(){return this.logger.log(`[${this.id}] enabling media tracks...`),this.session&&this.sessionManager.unmute(this.session)}isMuted(){return!!this.session&&this.sessionManager.isMuted(this.session)}sendDTMF(e){return this.logger.log(`[${this.id}] sending DTMF...`),this.session?this.sessionManager.sendDTMF(this.session,e):Promise.reject(new Error("Session does not exist."))}message(e,t){return this.logger.log(`[${this.id}] sending message...`),this.sessionManager.message(e,t)}}const Ct=r,At="sip.js";return t})())); \ No newline at end of file diff --git a/ui/operator/styles.css b/ui/operator/styles.css new file mode 100644 index 0000000..bbf9208 --- /dev/null +++ b/ui/operator/styles.css @@ -0,0 +1,5331 @@ +:root { + --bg-app: #f5f7fb; + --bg-soft: #eef2f8; + --bg-panel: #ffffff; + --bg-panel-2: #fbfcff; + --line: #d9e0ea; + --line-strong: #cad4e2; + --text: #10213d; + --text-soft: #617089; + --text-faint: #8c99ad; + --primary: #3366e8; + --primary-soft: #edf2ff; + --primary-line: #d4def9; + --success: #0f9f6f; + --success-soft: #e8f9f1; + --warning: #f59e0b; + --warning-soft: #fff5dd; + --danger: #ef4444; + --danger-soft: #fff0f0; + --shadow: 0 18px 40px rgba(16, 33, 61, 0.06); + --radius-xl: 22px; + --radius-lg: 16px; + --radius-md: 12px; +} + +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + min-height: 100vh; +} + +body { + background: + radial-gradient(circle at top right, rgba(51, 102, 232, 0.06), transparent 28%), + linear-gradient(180deg, #f8faff 0%, var(--bg-app) 32%, #f2f5fa 100%); + color: var(--text); + font-family: "IBM Plex Sans", "Segoe UI", sans-serif; +} + +h1, +h2, +.eyebrow, +.btn, +.brand-title, +.nav-link, +.summary-value, +.status-pill { + font-family: "Manrope", "IBM Plex Sans", sans-serif; +} + +a { + color: inherit; + text-decoration: none; +} + +.bg-orb { + display: none; +} + +.app-shell { + min-height: 100vh; + display: block; +} + +.sidebar { + width: 300px; + background: rgba(255, 255, 255, 0.9); + border-right: 1px solid var(--line); + padding: 24px 20px; + display: flex; + flex-direction: column; + gap: 24px; + position: fixed; + top: 0; + left: 0; + bottom: 0; + min-height: 100vh; + overflow-y: auto; + z-index: 20; +} + +.brand { + display: flex; + align-items: center; + gap: 14px; + padding: 4px 8px; +} + +.brand-mark { + width: 40px; + height: 40px; + border-radius: 12px; + display: grid; + place-items: center; + background: linear-gradient(135deg, #2d63ea, #4b79f5); + color: #fff; + font-size: 22px; + font-weight: 800; +} + +.brand-title { + font-size: 28px; + font-weight: 800; + letter-spacing: -0.03em; +} + +.brand-sub { + margin-top: 4px; + color: var(--text-soft); + font-size: 13px; +} + +.nav-menu { + display: grid; + gap: 8px; +} + +.nav-group { + display: grid; + gap: 6px; +} + +.nav-label { + padding: 0 12px; + font-size: 11px; + font-weight: 700; + color: var(--text-faint); + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.nav-link { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 14px; + border-radius: 14px; + color: var(--text-soft); + font-size: 15px; + font-weight: 700; + transition: 0.18s ease; +} + +.nav-link:hover { + background: #f4f7fd; + color: var(--text); +} + +.nav-link.active { + background: var(--primary-soft); + color: var(--primary); + border: 1px solid var(--primary-line); +} + +.nav-bullet { + width: 10px; + height: 10px; + border-radius: 50%; + background: currentColor; + opacity: 0.25; +} + +.nav-link.active .nav-bullet { + opacity: 1; +} + +.sidebar-footer { + margin-top: auto; + padding: 16px 14px; + border-radius: 16px; + background: linear-gradient(180deg, #f7f9fe, #eef3fb); + border: 1px solid var(--line); +} + +.sidebar-footer strong { + display: block; + margin-bottom: 6px; +} + +.sidebar-footer p { + margin: 0; + font-size: 13px; + color: var(--text-soft); + line-height: 1.45; +} + +.sidebar-footer .btn { + width: 100%; + margin-top: 12px; +} + +.login-shell { + min-height: 100vh; + display: grid; + place-items: center; + padding: 32px; +} + +.login-layout { + width: min(1120px, 100%); +} + +.login-card { + padding: 26px; +} + +.login-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 14px; + align-items: end; + margin-top: 18px; +} + +.login-layout .login-grid > .row:nth-child(2) { + display: none; +} + +.recording-detail { + display: grid; + gap: 12px; + margin-top: 16px; +} + +.recording-detail audio { + width: 100%; +} + +.login-grid .actions { + flex-direction: column; +} + +.login-brand { + display: flex; + align-items: center; + gap: 14px; + margin-bottom: 18px; +} + +.login-brand-copy strong { + display: block; + font-size: 26px; + font-weight: 800; + letter-spacing: -0.03em; +} + +.login-brand-copy span { + color: var(--text-soft); + font-size: 14px; +} + +.workspace { + min-width: 0; + margin-left: 300px; +} + +.workspace-topbar { + height: 84px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 18px; + padding: 16px 32px; + background: rgba(255, 255, 255, 0.88); + border-bottom: 1px solid var(--line); + backdrop-filter: blur(12px); + position: sticky; + top: 0; + z-index: 10; +} + +.search-box { + flex: 1; + max-width: 720px; + position: relative; +} + +.search-box::before { + content: "⌕"; + position: absolute; + left: 16px; + top: 50%; + transform: translateY(-50%); + color: var(--text-faint); + font-size: 18px; + pointer-events: none; +} + +.app-search { + width: 100%; + height: 48px; + padding: 0 16px 0 48px; + border-radius: 14px; + border: 1px solid var(--line); + background: #f8faff; + color: var(--text); + font-size: 16px; +} + +.topbar-actions { + display: flex; + align-items: center; + gap: 14px; +} + +.phone-entrypoint { + position: relative; +} + +.phone-chip { + min-height: 42px; + padding: 0 14px; + border-radius: 999px; + border: 1px solid var(--line); + background: #fff; + color: var(--text); + display: inline-flex; + align-items: center; + gap: 10px; + cursor: pointer; +} + +.phone-chip-dot { + width: 10px; + height: 10px; + border-radius: 50%; + background: #a9b5c7; + flex: 0 0 auto; +} + +.phone-chip-copy { + display: grid; + gap: 1px; + text-align: left; +} + +.phone-chip-title { + font-size: 11px; + font-weight: 800; + color: var(--text-faint); + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.phone-chip-status { + font-size: 13px; + font-weight: 800; +} + +.phone-chip[data-state="registered"] .phone-chip-dot, +.phone-chip[data-state="active"] .phone-chip-dot { + background: var(--success); +} + +.phone-chip[data-state="incoming"] .phone-chip-dot { + background: var(--warning); +} + +.phone-chip[data-state="connecting"] .phone-chip-dot, +.phone-chip[data-state="ending"] .phone-chip-dot { + background: var(--primary); +} + +.phone-chip[data-state="disabled"] .phone-chip-dot, +.phone-chip[data-state="unavailable"] .phone-chip-dot, +.phone-chip[data-state="offline"] .phone-chip-dot { + background: var(--text-faint); +} + +.phone-settings { + position: absolute; + top: calc(100% + 10px); + right: 0; + width: min(420px, 92vw); + padding: 16px; + border-radius: 18px; + border: 1px solid var(--line); + background: rgba(255, 255, 255, 0.98); + box-shadow: 0 20px 40px rgba(16, 33, 61, 0.14); + z-index: 30; +} + +.icon-button { + width: 42px; + height: 42px; + border-radius: 50%; + border: 1px solid var(--line); + background: #fff; + color: var(--text-soft); + display: grid; + place-items: center; + font-size: 18px; + cursor: pointer; +} + +.status-pill { + display: inline-flex; + align-items: center; + gap: 8px; + height: 42px; + padding: 0 14px; + border-radius: 999px; + border: 1px solid #cfe8d6; + background: #f2fbf5; + color: #157347; + font-size: 14px; + font-weight: 800; +} + +.status-pill::before { + content: ""; + width: 8px; + height: 8px; + border-radius: 50%; + background: currentColor; +} + +.profile-chip { + display: flex; + align-items: center; + gap: 12px; + padding-left: 10px; + border-left: 1px solid var(--line); +} + +.avatar-badge { + width: 42px; + height: 42px; + border-radius: 50%; + display: grid; + place-items: center; + background: linear-gradient(180deg, #eef3ff, #e2ebff); + color: var(--primary); + font-weight: 800; +} + +.profile-meta { + min-width: 0; +} + +.profile-name { + font-weight: 700; +} + +.profile-role { + color: var(--text-soft); + font-size: 12px; +} + +.layout { + padding: 28px 32px 40px; +} + +.operator-view { + display: grid; + gap: 18px; +} + +.page-hero { + margin-bottom: 18px; +} + +.customers-page-hero { + margin-bottom: 4px; +} + +.customers-page-hero .eyebrow { + display: none; +} + +.customers-page-hero h1 { + margin: 0 0 8px; + font-size: clamp(32px, 3.5vw, 44px); + line-height: 1.05; +} + +.customers-page-hero .hint { + font-size: 16px; + color: #6c7890; +} + +.eyebrow { + margin: 0; + color: var(--primary); + font-size: 12px; + font-weight: 800; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +h1 { + margin: 8px 0 6px; + font-size: clamp(34px, 4vw, 52px); + line-height: 1.04; + letter-spacing: -0.04em; +} + +.page-hero .hint { + font-size: 18px; + color: var(--text-soft); +} + +.panel { + background: var(--bg-panel); + border: 1px solid var(--line); + border-radius: var(--radius-xl); + box-shadow: var(--shadow); + padding: 20px; +} + +.panel h2 { + margin: 0 0 14px; + font-size: 22px; + line-height: 1.15; + letter-spacing: -0.02em; +} + +.auth { + margin-bottom: 18px; + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; + align-items: end; +} + +.auth h2, +.auth .hint { + grid-column: 1 / -1; +} + +.row { + display: grid; + gap: 6px; +} + +label { + font-size: 13px; + font-weight: 700; + color: var(--text-soft); +} + +input, +select, +textarea { + width: 100%; + min-height: 46px; + border-radius: 14px; + border: 1px solid var(--line); + background: var(--bg-panel-2); + color: var(--text); + font: inherit; + padding: 0 14px; + outline: none; + transition: border-color 0.15s ease, box-shadow 0.15s ease, background 0.15s ease; +} + +input:focus, +select:focus, +textarea:focus { + border-color: #b8c8ef; + background: #fff; + box-shadow: 0 0 0 4px rgba(51, 102, 232, 0.08); +} + +input::placeholder, +textarea::placeholder { + color: var(--text-faint); +} + +.actions { + display: flex; + gap: 10px; + flex-wrap: wrap; +} + +.btn, +.row-actions button { + min-height: 44px; + padding: 0 16px; + border: 1px solid transparent; + border-radius: 14px; + background: linear-gradient(180deg, #3f71ef, #2f61e0); + color: #fff; + font-size: 14px; + font-weight: 800; + cursor: pointer; + transition: transform 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease; + box-shadow: 0 10px 18px rgba(51, 102, 232, 0.18); +} + +.btn:hover, +.row-actions button:hover { + transform: translateY(-1px); +} + +.btn.ghost, +.row-actions button { + background: #fff; + color: var(--primary); + border-color: var(--primary-line); + box-shadow: none; +} + +.btn.danger { + background: linear-gradient(180deg, #fa5d5d, #e33d3d); + color: #fff; + border-color: transparent; +} + +.hint { + margin: 0; + color: var(--text-soft); + font-size: 13px; + line-height: 1.45; +} + +.hint strong { + color: var(--text); +} + +.demo-guide { + margin-bottom: 18px; + background: linear-gradient(180deg, #ffffff, #f9fbff); +} + +.guide-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.guide-badge { + padding: 9px 12px; + border-radius: 999px; + border: 1px solid var(--primary-line); + background: var(--primary-soft); + color: var(--primary); + font-size: 12px; + font-weight: 800; +} + +.guide-list { + margin: 14px 0 0; + padding-left: 20px; + display: grid; + gap: 8px; + color: var(--text-soft); + font-size: 14px; +} + +.grid { + display: grid; + grid-template-columns: repeat(2, minmax(320px, 1fr)); + gap: 18px; +} + +.panel-span-full { + grid-column: 1 / -1; +} + +.inline-form { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 10px; + margin-bottom: 10px; +} + +.inline-form.compact { + grid-template-columns: 1fr auto; +} + +.inline-form.split-two { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.checkbox-label { + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 0.92rem; + color: var(--text-muted); +} + +.checkbox-label input { + width: auto; + min-height: auto; + margin: 0; +} + +.json-textarea { + min-height: 220px; + font-family: "IBM Plex Mono", "IBM Plex Sans", monospace; + font-size: 0.9rem; +} + +.list { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: 10px; + max-height: 280px; + overflow: auto; +} + +.list li { + padding: 14px; + border-radius: var(--radius-lg); + border: 1px solid var(--line); + background: linear-gradient(180deg, #fff, #fbfcff); + box-shadow: 0 8px 20px rgba(16, 33, 61, 0.04); + font-size: 14px; + line-height: 1.45; +} + +.customers-panel { + display: grid; + gap: 18px; + padding: 0; + overflow: hidden; +} + +.customers-toolbar, +.customers-toolbar-left, +.customers-toolbar-right, +.customers-footer, +.customers-pager { + display: flex; + align-items: center; + gap: 12px; +} + +.customers-toolbar, +.customers-footer { + justify-content: space-between; + flex-wrap: wrap; + padding: 18px 18px 0; +} + +.customers-footer { + padding: 16px 18px 18px; +} + +.customers-toolbar-left, +.customers-toolbar-right { + flex-wrap: wrap; +} + +.customers-search { + position: relative; + min-width: min(100%, 420px); + max-width: 460px; + flex: 1; +} + +.customers-search input { + width: 100%; + height: 46px; + padding: 0 16px 0 44px; + border-radius: 14px; + border: 1px solid var(--line); + background: #f9fbfd; + color: var(--text); + font-size: 15px; +} + +.customers-search-icon { + position: absolute; + left: 16px; + top: 50%; + transform: translateY(-50%); + color: var(--text-faint); + pointer-events: none; +} + +.customers-filter-btn { + border-radius: 14px; +} + +.customer-lead-form { + margin: 0 18px; + padding: 16px; + border-radius: 18px; + border: 1px solid var(--line); + background: linear-gradient(180deg, #f8fbff, #f3f7fd); +} + +.lead-table-shell { + border-top: 1px solid var(--line); + border-bottom: 1px solid var(--line); + background: #fff; + max-height: 580px; + overflow-y: auto; + overflow-x: hidden; +} + +.lead-table { + display: grid; +} + +.lead-table-head, +.lead-table-row { + display: grid; + grid-template-columns: 52px minmax(260px, 2fr) minmax(140px, 1fr) minmax(150px, 1fr) minmax(170px, 1fr) 110px; + align-items: center; + gap: 12px; +} + +.lead-table-head { + min-height: 50px; + padding: 0 18px; + border-bottom: 1px solid var(--line); + background: #fff; + color: #66748d; + font-size: 12px; + font-weight: 800; + letter-spacing: 0.06em; + text-transform: uppercase; + position: sticky; + top: 0; + z-index: 2; +} + +.lead-table-row { + width: 100%; + min-height: 86px; + padding: 0 18px; + border: 0; + border-bottom: 1px solid var(--line); + background: #fff; + text-align: left; + cursor: pointer; +} + +.lead-table-row:last-child { + border-bottom: 0; +} + +.lead-table-row:hover { + background: #f8fbff; +} + +.lead-table-row.selected { + background: #f4f8ff; +} + +.lead-cell { + min-width: 0; + color: #5f6d86; + font-size: 14px; +} + +.checkbox-cell { + display: inline-flex; + justify-content: center; +} + +.checkbox-cell input { + width: 16px; + height: 16px; + min-height: 16px; + margin: 0; + padding: 0; + border-radius: 4px; + accent-color: var(--primary); + box-shadow: none; +} + +.contact-cell { + display: flex; + align-items: center; + gap: 14px; +} + +.lead-avatar { + width: 36px; + height: 36px; + border-radius: 50%; + display: inline-flex; + align-items: center; + justify-content: center; + background: linear-gradient(180deg, #dfe8ff, #d4dcfa); + color: #3658c9; + font-weight: 800; + font-size: 13px; + letter-spacing: 0.02em; + flex: 0 0 auto; +} + +.lead-contact-copy { + min-width: 0; +} + +.lead-contact-name { + color: var(--text); + font-size: 16px; + font-weight: 800; +} + +.lead-contact-company { + margin-top: 2px; + color: #6f7c93; + font-size: 13px; +} + +.lead-badge { + display: inline-flex; + align-items: center; + min-height: 26px; + padding: 0 12px; + border-radius: 999px; + font-size: 13px; + font-weight: 600; +} + +.source-instagram { + color: #9b27d7; + background: #f4e9ff; +} + +.source-whatsapp { + color: #13984a; + background: #e5f8ea; +} + +.source-site { + color: #3465f3; + background: #e8f0ff; +} + +.source-referral { + color: #53627a; + background: #eff2f6; +} + +.status-negotiation { + color: #8a2be2; + background: #f2e8ff; +} + +.status-qualification { + color: #2f63f6; + background: #e8f0ff; +} + +.status-proposal { + color: #b56d00; + background: #fff0c7; +} + +.status-initial { + color: #55657a; + background: #eff2f6; +} + +.status-success { + color: #0f8b4c; + background: #dff7e7; +} + +.status-new { + color: #4d57e8; + background: #e8ebff; +} + +.status-lost { + color: #d33c32; + background: #fde8e7; +} + +.score-cell { + text-align: center; +} + +.lead-score { + font-size: 16px; + font-weight: 800; + letter-spacing: 0; +} + +.score-strong { + color: #0d8b57; +} + +.score-medium { + color: #e08a00; +} + +.score-weak { + color: #db4a44; +} + +.lead-empty-state { + padding: 28px; + color: var(--text-soft); + display: grid; + gap: 6px; +} + +.customer-spotlight-shell { + padding: 18px; + border-top: 1px solid var(--line); + background: + radial-gradient(circle at top right, rgba(51, 102, 232, 0.08), transparent 32%), + linear-gradient(180deg, #fcfdff, #f6f9ff); +} + +.customer-spotlight-empty { + padding: 24px; + border-radius: 22px; + border: 1px dashed var(--line-strong); + background: rgba(255, 255, 255, 0.78); + color: var(--text-soft); + display: grid; + gap: 6px; +} + +.customer-spotlight-empty strong { + color: var(--text); + font-size: 18px; +} + +.customer-spotlight-empty p { + margin: 0; + line-height: 1.55; +} + +.customer-spotlight-grid { + display: grid; + grid-template-columns: minmax(0, 1.08fr) minmax(360px, 0.92fr); + gap: 18px; + align-items: start; +} + +.customer-spotlight-grid.page { + grid-template-columns: minmax(0, 1.02fr) minmax(420px, 0.98fr); +} + +.page-hero-actions { + margin-bottom: 12px; +} + +.customer-profile-page-panel { + padding: 0; + overflow: hidden; +} + +.customer-spotlight-page { + border-top: 0; + padding: 22px; +} + +.customer-profile-card, +.customer-history-card { + padding: 20px; + border-radius: 24px; + border: 1px solid var(--line); + background: rgba(255, 255, 255, 0.9); + box-shadow: 0 18px 36px rgba(16, 33, 61, 0.06); + display: grid; + gap: 18px; +} + +.customer-profile-hero, +.customer-history-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 18px; +} + +.customer-profile-avatar { + width: 64px; + height: 64px; + border-radius: 20px; + flex: 0 0 auto; + display: grid; + place-items: center; + background: linear-gradient(135deg, #3569ec, #7aa2ff); + color: #fff; + font-family: "Manrope", "IBM Plex Sans", sans-serif; + font-size: 22px; + font-weight: 800; + letter-spacing: 0.04em; + box-shadow: 0 16px 28px rgba(51, 102, 232, 0.2); +} + +.customer-profile-copy { + flex: 1; + min-width: 0; +} + +.customer-profile-kicker { + color: var(--text-faint); + font-size: 11px; + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.customer-profile-copy h3, +.customer-history-header h3 { + margin: 8px 0 0; + font-family: "Manrope", "IBM Plex Sans", sans-serif; + font-size: 30px; + font-weight: 800; + letter-spacing: -0.04em; +} + +.customer-profile-copy p, +.customer-history-caption { + margin: 6px 0 0; + color: var(--text-soft); + line-height: 1.5; +} + +.customer-profile-badges { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 8px; + max-width: 280px; +} + +.customer-score-pill { + min-height: 26px; + padding: 0 12px; + border-radius: 999px; + display: inline-flex; + align-items: center; + background: #f6f8fc; + border: 1px solid var(--line); + font-size: 13px; + font-weight: 800; +} + +.customer-profile-summary-grid, +.customer-profile-meta-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.customer-meta-card { + padding: 16px; + border-radius: 18px; + border: 1px solid var(--line); + background: linear-gradient(180deg, #ffffff, #f8fbff); + display: grid; + gap: 8px; +} + +.customer-meta-label { + color: var(--text-faint); + font-size: 11px; + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.customer-meta-value { + color: var(--text); + font-family: "Manrope", "IBM Plex Sans", sans-serif; + font-size: 20px; + font-weight: 800; + letter-spacing: -0.03em; +} + +.customer-meta-note { + color: var(--text-soft); + font-size: 13px; + line-height: 1.45; +} + +.customer-chip-row { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.customer-chip, +.customer-channel-pill { + min-height: 30px; + padding: 0 12px; + border-radius: 999px; + display: inline-flex; + align-items: center; + font-size: 13px; + font-weight: 700; +} + +.customer-chip { + background: #edf2ff; + color: #325ed8; +} + +.customer-channel-pill { + background: #eef4f1; + color: #11704e; +} + +.customer-profile-actions { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.customer-history-header { + padding-bottom: 2px; + border-bottom: 1px solid rgba(217, 224, 234, 0.72); +} + +.customer-history-caption { + max-width: 260px; + text-align: right; +} + +.customer-history-list { + display: grid; + gap: 12px; + max-height: 720px; + overflow: auto; + padding-right: 4px; +} + +.customer-history-event { + display: grid; + grid-template-columns: 46px minmax(0, 1fr); + gap: 12px; + align-items: start; +} + +.customer-history-mark { + width: 46px; + height: 46px; + border-radius: 16px; + display: grid; + place-items: center; + font-family: "Manrope", "IBM Plex Sans", sans-serif; + font-size: 12px; + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; + border: 1px solid var(--line); + background: #f5f8fd; + color: var(--text-soft); +} + +.customer-history-body { + padding: 14px 16px; + border-radius: 18px; + border: 1px solid var(--line); + background: #fff; + box-shadow: 0 10px 24px rgba(16, 33, 61, 0.04); + display: grid; + gap: 6px; +} + +.customer-history-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.customer-history-title { + color: var(--text); + font-size: 15px; + font-weight: 800; +} + +.customer-history-time { + color: var(--text-faint); + font-size: 12px; + white-space: nowrap; +} + +.customer-history-copy { + color: var(--text-soft); + font-size: 14px; + line-height: 1.55; +} + +.customer-history-note { + color: var(--text-faint); + font-size: 12px; +} + +.customer-history-event.voice .customer-history-mark { + background: #e8f0ff; + border-color: #cfdcff; + color: #2e63e9; +} + +.customer-history-event.telegram .customer-history-mark { + background: #eaf8ff; + border-color: #c8ebff; + color: #1187c9; +} + +.customer-history-event.email .customer-history-mark { + background: #fff4e5; + border-color: #ffe0b3; + color: #b56d00; +} + +.customer-history-event.webchat .customer-history-mark { + background: #ecfff6; + border-color: #c9f0dd; + color: #0f8b57; +} + +.customer-history-event.profile .customer-history-mark { + background: #f2efff; + border-color: #ddd4ff; + color: #6f4de3; +} + +.customer-history-event.case .customer-history-mark { + background: #f4f7fb; + border-color: #dbe4f1; + color: #4e617f; +} + +.telegram-page-hero { + display: none; +} + +.telegram-panel { + padding: 0; + overflow: hidden; + background: #e7ebf0; + height: calc(100dvh - 152px); + min-height: 620px; + display: flex; +} + +.telegram-layout { + width: 100%; + height: 100%; + display: grid; + grid-template-columns: 400px minmax(0, 1fr); + min-height: 0; +} + +.telegram-threads-pane { + background: #fff; + border-right: 1px solid #e5e7eb; + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; +} + +.telegram-pane-head { + padding: 12px; + display: flex; + align-items: center; + gap: 12px; +} + +.telegram-pane-icon-btn, +.telegram-chat-icon-btn, +.telegram-composer-icon-btn, +.telegram-send-btn { + border: 0; + background: transparent; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + color: #707579; +} + +.telegram-pane-icon-btn, +.telegram-chat-icon-btn, +.telegram-composer-icon-btn { + width: 40px; + height: 40px; + border-radius: 999px; +} + +.telegram-pane-icon-btn:hover, +.telegram-chat-icon-btn:hover, +.telegram-composer-icon-btn:hover { + background: #f3f4f6; + color: #0088cc; +} + +.telegram-chat-icon-btn.active { + background: #e6f4fb; + color: #0088cc; +} + +.telegram-pane-icon-btn:disabled { + cursor: default; + opacity: 1; +} + +.telegram-pane-icon-btn svg, +.telegram-chat-icon-btn svg, +.telegram-thread-search-icon svg, +.telegram-composer-icon-btn svg, +.telegram-send-btn svg { + width: 20px; + height: 20px; + fill: none; + stroke: currentColor; + stroke-width: 1.9; + stroke-linecap: round; + stroke-linejoin: round; +} + +.telegram-thread-search { + position: relative; + flex: 1; +} + +.telegram-thread-search input { + width: 100%; + height: 44px; + padding: 0 16px 0 42px; + border: 0; + border-radius: 999px; + background: #f4f4f5; + color: #000; + font-size: 14px; + outline: none; +} + +.telegram-thread-search input:focus { + box-shadow: 0 0 0 2px rgba(0, 136, 204, 0.25); +} + +.telegram-thread-search-icon { + position: absolute; + left: 14px; + top: 50%; + transform: translateY(-50%); + color: #9ca3af; + pointer-events: none; + display: inline-flex; +} + +.telegram-thread-list { + flex: 1; + overflow: auto; + padding-bottom: 10px; +} + +.telegram-empty-state { + min-height: 120px; + display: grid; + place-items: center; + text-align: center; + color: #707579; +} + +.telegram-thread-card { + width: 100%; + padding: 12px 16px; + border: 0; + background: transparent; + color: #000; + text-align: left; + cursor: pointer; + display: flex; + align-items: center; + gap: 12px; + transition: background 0.16s ease, color 0.16s ease; +} + +.telegram-thread-card:hover { + background: #f9fafb; +} + +.telegram-thread-card.selected { + background: #0088cc; + color: #fff; +} + +.telegram-thread-avatar { + width: 48px; + height: 48px; + border-radius: 50%; + display: grid; + place-items: center; + background: linear-gradient(135deg, var(--telegram-avatar-start, #4daeff), var(--telegram-avatar-end, #228fff)); + color: #fff; + font-family: "Manrope", "IBM Plex Sans", sans-serif; + font-size: 17px; + font-weight: 800; + flex: 0 0 auto; +} + +.telegram-thread-card.selected .telegram-thread-avatar { + background: rgba(255, 255, 255, 0.2); +} + +.telegram-thread-copy, +.telegram-thread-body { + min-width: 0; + flex: 1; +} + +.telegram-thread-card-head, +.telegram-thread-preview-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.telegram-thread-status-row, +.telegram-thread-meta-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + flex-wrap: wrap; +} + +.telegram-thread-name { + font-size: 15px; + font-weight: 800; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.telegram-thread-time { + color: #9ca3af; + font-size: 12px; + white-space: nowrap; +} + +.telegram-thread-preview { + min-width: 0; + color: #707579; + font-size: 14px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.telegram-thread-summary { + min-width: 0; + color: #8a94a6; + font-size: 12px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.telegram-thread-card.selected .telegram-thread-time { + color: rgba(255, 255, 255, 0.72); +} + +.telegram-thread-card.selected .telegram-thread-preview { + color: rgba(255, 255, 255, 0.82); +} + +.telegram-thread-card.selected .telegram-thread-summary { + color: rgba(255, 255, 255, 0.72); +} + +.telegram-thread-card.selected .telegram-thread-ai-chip { + background: rgba(255, 255, 255, 0.18); + color: #fff; +} + +.telegram-thread-unread { + min-width: 20px; + height: 20px; + padding: 0 6px; + border-radius: 999px; + display: inline-flex; + align-items: center; + justify-content: center; + background: #0088cc; + color: #fff; + font-size: 11px; + font-weight: 800; + flex: 0 0 auto; +} + +.telegram-chat-pane { + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; + overflow: hidden; + background: #e7ebf0; +} + +.telegram-chat-header { + height: 56px; + padding: 0 16px; + background: #fff; + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + box-shadow: 0 1px 4px rgba(15, 23, 42, 0.06); + position: sticky; + top: 0; + z-index: 2; + flex: 0 0 auto; +} + +.telegram-thread-identity { + display: flex; + align-items: center; + gap: 12px; + min-width: 0; +} + +.telegram-thread-title { + font-family: "Manrope", "IBM Plex Sans", sans-serif; + font-size: 16px; + font-weight: 800; + line-height: 1.15; +} + +.telegram-thread-meta { + margin-top: 2px; + color: #0088cc; + font-size: 13px; + line-height: 1.2; +} + +.telegram-thread-ai-chip { + min-height: 24px; + padding: 0 10px; + border-radius: 999px; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 11px; + font-weight: 800; + letter-spacing: 0.02em; + background: #edf2f7; + color: #64748b; +} + +.telegram-thread-ai-chip.active, +.telegram-thread-ai-chip.queued, +.telegram-thread-ai-chip.thinking { + background: #e8f1ff; + color: #1f6feb; +} + +.telegram-thread-ai-chip.handoff { + background: #fff3d6; + color: #a15c00; +} + +.telegram-thread-ai-chip.human { + background: #e7f7ef; + color: #0f9f6f; +} + +.telegram-thread-ai-chip.closed { + background: #eef2f7; + color: #667085; +} + +.telegram-thread-ai-chip.error { + background: #ffe8e8; + color: #c62828; +} + +.telegram-chat-actions { + display: flex; + align-items: center; + gap: 2px; + flex: 0 0 auto; +} + +.telegram-message-search { + padding: 10px 16px; + background: rgba(255, 255, 255, 0.92); + border-bottom: 1px solid rgba(209, 213, 219, 0.7); + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; + flex: 0 0 auto; +} + +.telegram-message-search-field { + position: relative; + flex: 1; +} + +.telegram-message-search-field input { + width: 100%; + height: 40px; + padding: 0 14px 0 42px; + border: 0; + border-radius: 14px; + background: #f4f4f5; + color: #000; + font-size: 14px; + outline: none; +} + +.telegram-message-search-field input:focus { + box-shadow: 0 0 0 2px rgba(0, 136, 204, 0.25); +} + +.telegram-message-search-meta { + color: #707579; + font-size: 12px; + font-weight: 700; + white-space: nowrap; +} + +.telegram-message-search-clear { + min-height: 34px; + padding: 0 14px; + border: 0; + border-radius: 999px; + background: #eef6fb; + color: #0088cc; + font-family: "Manrope", "IBM Plex Sans", sans-serif; + font-size: 13px; + font-weight: 700; + cursor: pointer; +} + +.telegram-message-search-clear:hover { + background: #dff0fa; +} + +.telegram-operator-tray { + padding: 12px 16px; + background: rgba(255, 255, 255, 0.88); + border-bottom: 1px solid rgba(209, 213, 219, 0.7); + display: grid; + gap: 10px; + flex: 0 0 auto; +} + +.telegram-operator-meta, +.telegram-operator-controls { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.telegram-operator-chip { + min-height: 28px; + padding: 0 12px; + border-radius: 999px; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 12px; + font-weight: 700; +} + +.telegram-operator-chip.claimed { + background: #e7f7ef; + color: #0f9f6f; +} + +.telegram-operator-chip.free { + background: #eef2f7; + color: #667085; +} + +.telegram-operator-chip.active, +.telegram-operator-chip.queued, +.telegram-operator-chip.thinking { + background: #e8f1ff; + color: #1f6feb; +} + +.telegram-operator-chip.handoff { + background: #fff3d6; + color: #a15c00; +} + +.telegram-operator-chip.human { + background: #e7f7ef; + color: #0f9f6f; +} + +.telegram-operator-chip.closed { + background: #eef2f7; + color: #667085; +} + +.telegram-operator-chip.error { + background: #ffe8e8; + color: #c62828; +} + +.telegram-operator-chip.subtle { + background: #f4f4f5; + color: #707579; +} + +.telegram-operator-action { + min-height: 34px; + padding: 0 14px; + border: 0; + border-radius: 999px; + background: #0088cc; + color: #fff; + font-family: "Manrope", "IBM Plex Sans", sans-serif; + font-size: 13px; + font-weight: 700; + cursor: pointer; +} + +.telegram-operator-action.subtle { + background: #f4f4f5; + color: #374151; +} + +.telegram-operator-action:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.telegram-operator-input { + min-width: 120px; + height: 34px; + padding: 0 12px; + border: 1px solid #d1d5db; + border-radius: 999px; + background: #fff; +} + +.telegram-ai-summary { + padding: 14px 16px 10px; + background: rgba(255, 255, 255, 0.94); + border-bottom: 1px solid rgba(209, 213, 219, 0.72); + display: grid; + gap: 12px; + flex: 0 0 auto; +} + +.telegram-ai-summary-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.telegram-ai-summary-title { + color: #10213d; + font-family: "Manrope", "IBM Plex Sans", sans-serif; + font-size: 13px; + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.telegram-ai-summary-caption, +.telegram-ai-summary-meta { + color: #707579; + font-size: 12px; + line-height: 1.45; +} + +.telegram-ai-summary-meta { + white-space: nowrap; + text-align: right; +} + +.telegram-ai-summary-side { + display: grid; + justify-items: end; + gap: 8px; +} + +.telegram-ai-summary-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.telegram-summary-field { + padding: 12px 14px; + border-radius: 16px; + border: 1px solid rgba(219, 228, 241, 0.92); + background: #fff; + box-shadow: 0 8px 20px rgba(16, 33, 61, 0.04); + display: grid; + gap: 6px; +} + +.telegram-summary-label { + color: #6b7280; + font-size: 11px; + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.telegram-summary-value { + color: #10213d; + font-size: 14px; + line-height: 1.55; + white-space: pre-wrap; +} + +.telegram-summary-status { + min-height: 28px; + padding: 0 12px; + border-radius: 999px; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 11px; + font-weight: 800; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.telegram-summary-status.answered { + background: #e7f7ef; + color: #0f9f6f; +} + +.telegram-summary-status.handoff { + background: #fff3d6; + color: #a15c00; +} + +.telegram-chat-surface { + position: relative; + flex: 1; + min-height: 0; + overflow: hidden; + background: #e2e5ea; +} + +.telegram-message-timeline { + position: relative; + z-index: 1; + height: 100%; + overflow: auto; + padding: 16px; + display: grid; + gap: 8px; +} + +.telegram-message { + display: flex; + width: 100%; + align-items: flex-start; +} + +.telegram-message.me { + justify-content: flex-end; +} + +.telegram-message.other { + justify-content: flex-start; +} + +.telegram-message.system { + justify-content: center; +} + +.telegram-message-bubble { + position: relative; + width: fit-content; + min-width: 92px; + max-width: min(85%, 720px); + padding: 10px 12px 20px; + border-radius: 18px; + box-shadow: 0 2px 6px rgba(15, 23, 42, 0.06); +} + +.telegram-message-bubble.me { + background: #effdde; + color: #000; + border-top-right-radius: 6px; +} + +.telegram-message-bubble.ai { + box-shadow: 0 3px 10px rgba(0, 136, 204, 0.12); +} + +.telegram-message-bubble.other { + background: #fff; + color: #000; + border-top-left-radius: 6px; +} + +.telegram-message-system-pill { + padding: 8px 14px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.8); + color: #707579; + font-size: 12px; + font-weight: 700; +} + +.telegram-message-body { + padding-right: 52px; + white-space: pre-wrap; + line-height: 1.5; + font-size: 15px; + color: inherit; + overflow-wrap: anywhere; +} + +.telegram-message-badge { + display: inline-flex; + align-items: center; + min-height: 20px; + padding: 0 8px; + border-radius: 999px; + margin-bottom: 8px; + font-size: 10px; + font-weight: 800; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.telegram-message-badge.ai { + background: rgba(0, 136, 204, 0.14); + color: #0088cc; +} + +.telegram-message-meta { + position: absolute; + right: 10px; + bottom: 6px; + display: inline-flex; + align-items: center; + gap: 4px; +} + +.telegram-message-time { + font-size: 10px; + color: #9ca3af; +} + +.telegram-message-bubble.me .telegram-message-time { + color: rgba(21, 128, 61, 0.72); +} + +.telegram-message-checks { + font-size: 13px; + line-height: 1; + color: #22c55e; +} + +.telegram-message-highlight { + padding: 0 2px; + border-radius: 5px; + background: #fff1a8; + color: inherit; +} + +.telegram-composer-wrap { + padding: 10px 16px 16px; + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + position: sticky; + bottom: 0; + z-index: 2; + background: #e7ebf0; + flex: 0 0 auto; +} + +.telegram-composer-shell { + width: 100%; + max-width: 860px; + display: grid; + grid-template-columns: 44px minmax(0, 1fr) 44px 48px; + align-items: end; + gap: 4px; + padding: 6px; + background: #fff; + border-radius: 18px; + box-shadow: 0 2px 6px rgba(15, 23, 42, 0.08); +} + +.telegram-composer-icon-btn { + color: #9ca3af; +} + +.telegram-composer-icon-btn:disabled { + cursor: default; + opacity: 1; +} + +.telegram-composer textarea { + min-height: 48px; + max-height: 128px; + height: 48px; + padding: 10px 4px; + border: 0; + background: transparent; + resize: none; + outline: none; + font-size: 16px; + line-height: 1.45; +} + +.telegram-send-btn { + width: 44px; + height: 44px; + border-radius: 999px; + color: #0088cc; + transform: scale(0.92); + transition: transform 0.16s ease, background 0.16s ease, color 0.16s ease; +} + +.telegram-send-btn:not(:disabled) { + background: #0088cc; + color: #fff; + transform: scale(1); +} + +.telegram-send-btn:disabled { + cursor: not-allowed; +} + +.telegram-composer-hint { + color: #707579; + font-size: 12px; + text-align: center; +} + +.whatsapp-panel { + padding: 0; + overflow: hidden; + background: #0b141a; + height: calc(100dvh - 152px); + min-height: 620px; + display: flex; +} + +.whatsapp-panel, +.whatsapp-panel button, +.whatsapp-panel input, +.whatsapp-panel textarea { + font-family: "Segoe UI", system-ui, -apple-system, BlinkMacSystemFont, sans-serif; +} + +.whatsapp-shell { + width: 100%; + height: 100%; + display: grid; + grid-template-columns: 72px 420px minmax(0, 1fr); + min-height: 0; + background: #0b141a; + color: #e9edef; +} + +.whatsapp-panel svg { + width: 20px; + height: 20px; + fill: none; + stroke: currentColor; + stroke-width: 1.8; + stroke-linecap: round; + stroke-linejoin: round; +} + +.whatsapp-nav-rail { + padding: 14px 0; + background: #202c33; + border-right: 1px solid rgba(233, 237, 239, 0.08); + display: flex; + flex-direction: column; + justify-content: space-between; + align-items: center; + gap: 16px; +} + +.whatsapp-rail-group, +.whatsapp-rail-footer { + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; +} + +.whatsapp-rail-btn, +.whatsapp-icon-btn, +.whatsapp-composer-btn, +.whatsapp-send-btn { + border: 0; + background: transparent; + color: #aebac1; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: background 0.16s ease, color 0.16s ease, transform 0.16s ease; +} + +.whatsapp-rail-btn, +.whatsapp-icon-btn, +.whatsapp-composer-btn, +.whatsapp-send-btn { + width: 42px; + height: 42px; + border-radius: 14px; +} + +.whatsapp-rail-btn:hover, +.whatsapp-icon-btn:hover, +.whatsapp-composer-btn:hover { + background: rgba(255, 255, 255, 0.08); + color: #f7f9fa; +} + +.whatsapp-rail-btn.active { + background: rgba(37, 211, 102, 0.18); + color: #25d366; +} + +.whatsapp-rail-avatar { + width: 40px; + height: 40px; + border-radius: 50%; + display: grid; + place-items: center; + background: linear-gradient(135deg, #25d366, #128c7e); + color: #08171d; + font-size: 13px; + font-weight: 800; +} + +.whatsapp-list-pane { + background: #111b21; + border-right: 1px solid rgba(233, 237, 239, 0.08); + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; +} + +.whatsapp-list-head { + padding: 18px 16px 10px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.whatsapp-pane-title-wrap { + min-width: 0; +} + +.whatsapp-pane-title { + font-size: 19px; + font-weight: 700; + letter-spacing: -0.01em; + color: #e9edef; +} + +.whatsapp-pane-caption { + margin-top: 4px; + color: #8696a0; + font-size: 12px; +} + +.whatsapp-list-actions, +.whatsapp-chat-actions { + display: flex; + align-items: center; + gap: 4px; +} + +.whatsapp-icon-btn { + color: #8696a0; +} + +.whatsapp-search-wrap { + padding: 0 16px 12px; + display: grid; + gap: 10px; +} + +.whatsapp-search-field { + position: relative; +} + +.whatsapp-search-icon { + position: absolute; + left: 14px; + top: 50%; + transform: translateY(-50%); + color: #8696a0; + display: inline-flex; + pointer-events: none; +} + +.whatsapp-search-field input { + width: 100%; + height: 42px; + padding: 0 16px 0 42px; + border: 1px solid transparent; + border-radius: 12px; + background: #202c33; + color: #e9edef; + font-size: 14px; + outline: none; +} + +.whatsapp-search-field input::placeholder, +.whatsapp-composer-shell textarea::placeholder { + color: #8696a0; +} + +.whatsapp-search-field input:focus { + border-color: rgba(37, 211, 102, 0.45); +} + +.whatsapp-filter-bar { + display: flex; + align-items: center; + gap: 8px; + overflow-x: auto; + padding-bottom: 2px; + scrollbar-width: none; +} + +.whatsapp-filter-bar::-webkit-scrollbar { + display: none; +} + +.whatsapp-filter-chip { + min-height: 32px; + padding: 0 12px; + border: 1px solid rgba(233, 237, 239, 0.08); + border-radius: 999px; + background: #202c33; + color: #aebac1; + font-size: 13px; + font-weight: 600; + cursor: pointer; + flex: 0 0 auto; +} + +.whatsapp-filter-chip.active { + background: rgba(37, 211, 102, 0.18); + border-color: rgba(37, 211, 102, 0.18); + color: #d9fdd3; +} + +.whatsapp-chat-list { + flex: 1; + min-height: 0; + overflow-y: auto; + overflow-x: hidden; + padding-bottom: 12px; +} + +.whatsapp-chat-list-empty, +.whatsapp-chat-empty { + min-height: 180px; + display: grid; + place-items: center; + text-align: center; + color: #aebac1; + padding: 24px; +} + +.whatsapp-chat-list-empty strong, +.whatsapp-chat-empty strong { + display: block; + color: #e9edef; + margin-bottom: 8px; +} + +.whatsapp-chat-card { + width: 100%; + padding: 14px 16px; + border: 0; + background: transparent; + color: inherit; + text-align: left; + display: flex; + align-items: flex-start; + gap: 12px; + cursor: pointer; + transition: background 0.16s ease; + border-bottom: 1px solid rgba(255, 255, 255, 0.04); +} + +.whatsapp-chat-card:hover { + background: rgba(255, 255, 255, 0.04); +} + +.whatsapp-chat-card.selected { + background: #202c33; +} + +.whatsapp-chat-card-avatar, +.whatsapp-chat-avatar { + border-radius: 50%; + display: grid; + place-items: center; + background: linear-gradient(135deg, var(--whatsapp-avatar-start, #25d366), var(--whatsapp-avatar-end, #128c7e)); + color: #fff; + font-size: 15px; + font-weight: 700; + flex: 0 0 auto; +} + +.whatsapp-chat-card-avatar { + width: 49px; + height: 49px; +} + +.whatsapp-chat-card-copy { + min-width: 0; + flex: 1; + display: grid; + gap: 4px; +} + +.whatsapp-chat-card-head, +.whatsapp-chat-card-preview-row, +.whatsapp-chat-card-meta { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.whatsapp-chat-card-title { + min-width: 0; + font-size: 15px; + font-weight: 600; + color: #e9edef; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.whatsapp-chat-card-time { + color: #8696a0; + font-size: 12px; + white-space: nowrap; +} + +.whatsapp-chat-card-preview { + min-width: 0; + color: #aebac1; + font-size: 13px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.whatsapp-chat-card-meta { + justify-content: flex-start; + flex-wrap: wrap; + color: #8696a0; + font-size: 11px; +} + +.whatsapp-chat-tag { + min-height: 20px; + padding: 0 8px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.06); + color: #cfd8dc; + display: inline-flex; + align-items: center; +} + +.whatsapp-chat-tag.ai.active, +.whatsapp-chat-tag.ai.queued, +.whatsapp-chat-tag.ai.thinking { + background: rgba(83, 189, 235, 0.18); + color: #d2f4ff; +} + +.whatsapp-chat-tag.ai.handoff { + background: rgba(255, 205, 96, 0.18); + color: #ffe5a6; +} + +.whatsapp-chat-tag.ai.human { + background: rgba(37, 211, 102, 0.18); + color: #d9fdd3; +} + +.whatsapp-chat-tag.ai.closed { + background: rgba(255, 255, 255, 0.08); + color: #d5dde0; +} + +.whatsapp-chat-tag.ai.error { + background: rgba(255, 118, 117, 0.18); + color: #ffd3d1; +} + +.whatsapp-chat-tag.owner { + background: rgba(255, 255, 255, 0.08); + color: #f7f9fa; +} + +.whatsapp-chat-unread { + min-width: 20px; + height: 20px; + padding: 0 6px; + border-radius: 999px; + display: inline-flex; + align-items: center; + justify-content: center; + background: #25d366; + color: #08171d; + font-size: 11px; + font-weight: 800; +} + +.whatsapp-chat-pane { + min-width: 0; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; + background: #efeae2; +} + +.whatsapp-chat-head { + min-height: 72px; + padding: 12px 18px; + background: #f0f2f5; + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + border-bottom: 1px solid rgba(17, 27, 33, 0.08); + flex: 0 0 auto; +} + +.whatsapp-context-panel { + padding: 10px 16px 0; + background: #f0f2f5; + border-bottom: 1px solid rgba(17, 27, 33, 0.08); + display: grid; + gap: 10px; +} + +.whatsapp-context-bar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; +} + +.whatsapp-context-pills, +.whatsapp-context-actions { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.whatsapp-state-chip, +.whatsapp-summary-status { + min-height: 28px; + padding: 0 12px; + border-radius: 999px; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 12px; + font-weight: 700; +} + +.whatsapp-state-chip.active, +.whatsapp-state-chip.queued, +.whatsapp-state-chip.thinking { + background: #dff2fc; + color: #126c89; +} + +.whatsapp-state-chip.handoff, +.whatsapp-summary-status.handoff { + background: #fff0cf; + color: #8d5c00; +} + +.whatsapp-state-chip.human, +.whatsapp-summary-status.answered { + background: #d9fdd3; + color: #0f7a45; +} + +.whatsapp-state-chip.closed { + background: #e5eaed; + color: #54656f; +} + +.whatsapp-state-chip.error { + background: #ffe3e1; + color: #c03b37; +} + +.whatsapp-state-chip.subtle { + background: rgba(17, 27, 33, 0.06); + color: #54656f; +} + +.whatsapp-context-btn { + min-height: 34px; + padding: 0 14px; + border: 0; + border-radius: 999px; + background: #25d366; + color: #08171d; + font-size: 13px; + font-weight: 700; + cursor: pointer; +} + +.whatsapp-context-btn.secondary { + background: rgba(17, 27, 33, 0.08); + color: #111b21; +} + +.whatsapp-context-btn:disabled { + background: #dfe5e7; + color: #94a3a8; + cursor: not-allowed; +} + +.whatsapp-ai-summary { + padding: 14px; + border-radius: 18px; + border: 1px solid rgba(17, 27, 33, 0.08); + background: rgba(255, 255, 255, 0.84); + box-shadow: 0 10px 30px rgba(17, 27, 33, 0.06); +} + +.whatsapp-ai-summary-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; +} + +.whatsapp-ai-summary-kicker { + color: #667781; + font-size: 11px; + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.whatsapp-ai-summary-head h3 { + margin: 6px 0 0; + color: #111b21; + font-size: 18px; + font-weight: 700; +} + +.whatsapp-ai-summary-side { + display: grid; + justify-items: end; + gap: 8px; +} + +.whatsapp-ai-summary-meta { + color: #667781; + font-size: 12px; + white-space: nowrap; +} + +.whatsapp-ai-summary-grid { + margin-top: 14px; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.whatsapp-summary-field { + padding: 12px 14px; + border-radius: 16px; + border: 1px solid rgba(17, 27, 33, 0.08); + background: rgba(240, 242, 245, 0.78); +} + +.whatsapp-summary-label { + color: #667781; + font-size: 11px; + font-weight: 800; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.whatsapp-summary-value { + margin-top: 8px; + color: #111b21; + font-size: 14px; + line-height: 1.55; +} + +.whatsapp-chat-identity { + display: flex; + align-items: center; + gap: 12px; + min-width: 0; +} + +.whatsapp-chat-avatar { + width: 40px; + height: 40px; + font-size: 14px; +} + +.whatsapp-chat-copy { + min-width: 0; +} + +.whatsapp-chat-title { + font-size: 16px; + font-weight: 600; + color: #111b21; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.whatsapp-chat-meta { + margin-top: 4px; + color: #667781; + font-size: 13px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.whatsapp-chat-surface { + position: relative; + flex: 1; + min-height: 0; + overflow: hidden; + background: + radial-gradient(circle at top left, rgba(18, 140, 126, 0.06), transparent 22%), + radial-gradient(circle at bottom right, rgba(37, 211, 102, 0.08), transparent 18%), + #efeae2; +} + +.whatsapp-chat-surface::before { + content: ""; + position: absolute; + inset: 0; + background: + linear-gradient(135deg, rgba(255, 255, 255, 0.18) 12%, transparent 12%) 0 0 / 28px 28px, + linear-gradient(45deg, rgba(255, 255, 255, 0.12) 12%, transparent 12%) 0 0 / 28px 28px; + opacity: 0.28; + pointer-events: none; +} + +.whatsapp-message-timeline { + position: relative; + z-index: 1; + height: 100%; + overflow: auto; + padding: 20px min(5vw, 32px) 16px; + display: flex; + flex-direction: column; + gap: 8px; +} + +.whatsapp-encryption-pill, +.whatsapp-day-pill { + align-self: center; + display: inline-flex; + align-items: center; + justify-content: center; + width: fit-content; + max-width: min(92%, 640px); + padding: 7px 12px; + border-radius: 10px; + background: rgba(255, 255, 255, 0.72); + color: #54656f; + font-size: 12px; + line-height: 1.45; + text-align: center; + box-shadow: 0 1px 2px rgba(17, 27, 33, 0.08); +} + +.whatsapp-day-pill { + background: rgba(223, 242, 252, 0.92); + color: #54656f; + font-weight: 600; +} + +.whatsapp-message-row { + display: flex; + width: 100%; + min-width: 0; +} + +.whatsapp-message-row.me { + justify-content: flex-end; +} + +.whatsapp-message-row.other { + justify-content: flex-start; +} + +.whatsapp-message-bubble { + position: relative; + width: fit-content; + max-width: min(72%, 720px); + min-width: 120px; + padding: 8px 12px 22px; + border-radius: 12px; + box-shadow: 0 1px 2px rgba(17, 27, 33, 0.12); +} + +.whatsapp-message-bubble.me { + background: #d9fdd3; + color: #111b21; + border-top-right-radius: 4px; +} + +.whatsapp-message-bubble.other { + background: #fff; + color: #111b21; + border-top-left-radius: 4px; +} + +.whatsapp-message-author { + margin-bottom: 4px; + color: #0b7285; + font-size: 12px; + font-weight: 700; +} + +.whatsapp-message-badge { + margin-bottom: 6px; + display: inline-flex; + align-items: center; + min-height: 20px; + padding: 0 8px; + border-radius: 999px; + font-size: 11px; + font-weight: 800; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.whatsapp-message-badge.ai { + background: rgba(18, 108, 137, 0.12); + color: #126c89; +} + +.whatsapp-message-body { + padding-right: 56px; + white-space: pre-wrap; + overflow-wrap: anywhere; + font-size: 14px; + line-height: 1.45; +} + +.whatsapp-message-meta { + position: absolute; + right: 10px; + bottom: 6px; + display: inline-flex; + align-items: center; + gap: 4px; +} + +.whatsapp-message-time { + font-size: 11px; + color: #667781; +} + +.whatsapp-message-checks { + font-size: 12px; + line-height: 1; + color: #667781; +} + +.whatsapp-message-checks.read { + color: #53bdeb; +} + +.whatsapp-message-checks.delivered { + color: #667781; +} + +.whatsapp-composer-wrap { + padding: 10px 16px 16px; + background: #f0f2f5; + flex: 0 0 auto; +} + +.whatsapp-composer-shell { + display: grid; + grid-template-columns: 40px minmax(0, 1fr) 40px 44px; + align-items: end; + gap: 8px; + padding: 8px 10px; + border-radius: 16px; + background: #fff; + box-shadow: 0 1px 2px rgba(17, 27, 33, 0.08); +} + +.whatsapp-composer-btn { + color: #54656f; +} + +.whatsapp-composer-shell textarea { + min-height: 24px; + max-height: 120px; + height: 24px; + padding: 8px 2px; + border: 0; + background: transparent; + resize: none; + outline: none; + color: #111b21; + font-size: 15px; + line-height: 1.45; +} + +.whatsapp-send-btn { + border-radius: 50%; + background: #25d366; + color: #08171d; +} + +.whatsapp-send-btn:disabled { + background: #dfe5e7; + color: #94a3a8; + cursor: not-allowed; +} + +.customers-summary { + color: var(--text-soft); + font-size: 14px; +} + +.table { + display: grid; + gap: 12px; + max-height: 420px; + overflow: auto; +} + +.pipeline-board { + display: grid; + grid-template-columns: repeat(4, minmax(240px, 1fr)); + gap: 14px; + align-items: start; +} + +.live-calls-board { + grid-template-columns: repeat(2, minmax(280px, 1fr)); +} + +.pipeline-column { + min-width: 0; + padding: 12px; + border-radius: 20px; + border: 1px solid var(--line); + background: linear-gradient(180deg, #f8faff, #f2f6fc); +} + +.pipeline-column-recent { + background: linear-gradient(180deg, #fbfcfe, #f5f7fb); +} + +.pipeline-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 12px; +} + +.pipeline-title { + font-size: 18px; + font-weight: 800; + letter-spacing: -0.02em; +} + +.pipeline-count { + min-width: 30px; + height: 30px; + padding: 0 10px; + border-radius: 999px; + display: inline-flex; + align-items: center; + justify-content: center; + background: #e1e8f4; + color: var(--text-soft); + font-size: 13px; + font-weight: 800; +} + +.pipeline-stack { + display: grid; + gap: 10px; +} + +.pipeline-card { + padding: 14px; + border-radius: 18px; + border: 1px solid var(--line); + background: #fff; + box-shadow: 0 10px 24px rgba(16, 33, 61, 0.05); +} + +.pipeline-card.escalated { + border-left: 4px solid var(--danger); +} + +.pipeline-card.closed { + border-left: 4px solid #d9e0ea; +} + +.live-call-card.selected { + border-color: #b9c9ef; + box-shadow: 0 14px 26px rgba(51, 102, 232, 0.12); +} + +.card-badges { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-bottom: 10px; +} + +.micro-badge { + min-height: 24px; + padding: 0 8px; + border-radius: 8px; + display: inline-flex; + align-items: center; + border: 1px solid transparent; + font-size: 10px; + font-weight: 800; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.micro-badge.channel { + background: var(--primary-soft); + border-color: var(--primary-line); + color: var(--primary); +} + +.micro-badge.queue { + background: #f4f6fa; + border-color: #dde4ef; + color: var(--text-soft); +} + +.micro-badge.assignee { + background: var(--success-soft); + border-color: #bee8d3; + color: var(--success); +} + +.micro-badge.owner { + background: #fff4df; + border-color: #f1d8a3; + color: #9a6a00; +} + +.micro-badge.terminal { + background: #edf2fb; + border-color: #d9e1f1; + color: #51627e; +} + +.micro-badge.ai-active { + background: rgba(67, 200, 152, 0.14); + border-color: rgba(67, 200, 152, 0.34); + color: #118a63; +} + +.micro-badge.ai-thinking { + background: rgba(64, 132, 255, 0.12); + border-color: rgba(64, 132, 255, 0.28); + color: #3466d6; +} + +.micro-badge.ai-handoff { + background: rgba(245, 158, 11, 0.14); + border-color: rgba(245, 158, 11, 0.34); + color: #b66b00; +} + +.micro-badge.ai-human { + background: rgba(148, 163, 184, 0.16); + border-color: rgba(148, 163, 184, 0.32); + color: #55657a; +} + +.micro-badge.ai-error { + background: rgba(239, 68, 68, 0.14); + border-color: rgba(239, 68, 68, 0.32); + color: #cc4343; +} + +.micro-badge.ai-muted { + background: rgba(226, 232, 240, 0.4); + border-color: rgba(203, 213, 225, 0.62); + color: #6b7280; +} + +.card-title { + margin: 0; + font-size: 18px; + line-height: 1.25; + letter-spacing: -0.02em; +} + +.card-subtitle { + margin: 4px 0 10px; + color: var(--text-soft); + font-size: 13px; +} + +.card-meta-line { + margin: 0 0 10px; + color: var(--text-faint); + font-size: 12px; +} + +.card-divider { + height: 1px; + margin: 10px 0 12px; + background: #edf1f7; +} + +.row-item { + padding: 16px; + border-radius: 18px; + border: 1px solid var(--line); + background: linear-gradient(180deg, #fff, #fbfcff); + box-shadow: 0 10px 22px rgba(16, 33, 61, 0.04); +} + +.row-item-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + margin-bottom: 8px; +} + +.row-item-meta { + margin-bottom: 10px; + color: var(--text-soft); + font-size: 13px; + line-height: 1.5; +} + +.row-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.row-actions button { + min-height: 38px; + padding: 0 12px; + border-radius: 12px; +} + +.status-chip { + display: inline-flex; + align-items: center; + min-height: 28px; + padding: 0 10px; + border-radius: 999px; + font-size: 11px; + font-weight: 800; + letter-spacing: 0.06em; + text-transform: uppercase; + border: 1px solid transparent; +} + +.status-new { + background: var(--warning-soft); + border-color: #f7ddb0; + color: #9a6a00; +} + +.status-in-progress { + background: var(--success-soft); + border-color: #bee8d3; + color: var(--success); +} + +.status-escalated { + background: var(--primary-soft); + border-color: var(--primary-line); + color: var(--primary); +} + +.status-closed { + background: #eef2f8; + border-color: #dde4ef; + color: var(--text-soft); +} + +.summary-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; + margin-bottom: 12px; +} + +.summary-card { + padding: 16px; + border-radius: 18px; + border: 1px solid var(--line); + background: linear-gradient(180deg, #ffffff, #f8fbff); +} + +.summary-label { + font-size: 11px; + font-weight: 800; + color: var(--text-faint); + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.summary-value { + margin-top: 8px; + font-size: 28px; + font-weight: 800; + letter-spacing: -0.03em; +} + +.summary-note { + margin-top: 5px; + color: var(--text-soft); + font-size: 12px; +} + +.analytics-dashboard-panel { + display: grid; + gap: 18px; + margin-bottom: 18px; +} + +.analytics-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; +} + +.analytics-head h2 { + margin-bottom: 8px; +} + +.analytics-topbar-intro { + display: grid; + gap: 4px; +} + +.analytics-topbar-intro strong { + font-size: 15px; + font-weight: 800; + color: var(--text); +} + +.analytics-topbar-intro p { + margin: 0; + color: var(--text-soft); + font-size: 13px; +} + +.analytics-filter-bar { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 12px; + align-items: end; +} + +.analytics-toolkit { + display: grid; + grid-template-columns: minmax(220px, 0.8fr) minmax(0, 1.2fr); + gap: 12px; +} + +.analytics-toolkit-group { + display: grid; + gap: 12px; + padding: 14px; + border-radius: 18px; + border: 1px solid var(--line); + background: linear-gradient(180deg, #ffffff, #f8fbff); +} + +.analytics-toolkit-group-wide { + grid-template-columns: minmax(180px, 0.9fr) minmax(220px, 1fr) auto; + align-items: end; +} + +.analytics-toolkit-group-wide { + display: grid; + gap: 12px; +} + +.analytics-toolkit-actions { + justify-content: flex-end; +} + +.analytics-inline-meta { + min-height: 20px; +} + +.analytics-filter-note { + margin: -6px 0 0; +} + +.analytics-empty-state { + padding: 14px 16px; + border-radius: 16px; + border: 1px dashed var(--primary-line); + background: linear-gradient(180deg, #f7f9ff, #eef3fb); + color: var(--text-soft); +} + +.analytics-narrative { + display: grid; + gap: 14px; + padding: 18px 20px; + border-radius: 20px; + border: 1px solid var(--primary-line); + background: + radial-gradient(circle at top right, rgba(35, 92, 212, 0.08), transparent 34%), + linear-gradient(180deg, #f8fbff, #eef4ff); +} + +.analytics-narrative-main { + display: grid; + gap: 6px; +} + +.analytics-narrative-main strong { + font-size: 30px; + line-height: 1; + letter-spacing: -0.04em; +} + +.analytics-narrative-main p { + margin: 0; + max-width: 72ch; + color: var(--text-soft); + line-height: 1.5; +} + +.analytics-narrative-meta { + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; + flex-wrap: wrap; +} + +.analytics-filter-chips { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.analytics-filter-chip { + display: inline-flex; + align-items: center; + padding: 7px 10px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.72); + border: 1px solid var(--line); + color: var(--text-soft); + font-size: 12px; + font-weight: 700; +} + +.analytics-filter-chip-quiet { + background: rgba(255, 255, 255, 0.48); + border-color: rgba(16, 33, 61, 0.08); +} + +.analytics-compare-panel { + display: grid; + gap: 14px; + padding: 18px; + border-radius: 20px; + border: 1px solid var(--line); + background: linear-gradient(180deg, #ffffff, #f6f9ff); +} + +.analytics-compare-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 14px; +} + +.analytics-compare-head h3 { + margin: 0 0 6px; + font-family: "Manrope", "IBM Plex Sans", sans-serif; + font-size: 22px; + line-height: 1.08; +} + +.analytics-compare-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; +} + +.analytics-compare-card { + display: grid; + gap: 12px; + padding: 16px; + border-radius: 18px; + border: 1px solid var(--line); + background: linear-gradient(180deg, #fbfcff, #f4f7fd); +} + +.analytics-compare-card-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 10px; +} + +.analytics-compare-label { + color: var(--text); + font-size: 13px; + font-weight: 800; +} + +.analytics-compare-values { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.analytics-compare-value { + display: grid; + gap: 6px; +} + +.analytics-compare-value small { + color: var(--text-faint); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.analytics-compare-value strong { + font-family: "Manrope", "IBM Plex Sans", sans-serif; + font-size: 20px; + line-height: 1.05; + letter-spacing: -0.03em; +} + +.analytics-compare-note { + margin: 0; + color: var(--text-soft); + font-size: 13px; + line-height: 1.45; +} + +.analytics-overview-grid { + grid-template-columns: repeat(4, minmax(0, 1fr)); + margin-bottom: 0; +} + +.analytics-card { + min-height: 148px; +} + +.analytics-card-top { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 10px; +} + +.analytics-card-top-meta { + display: inline-flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + flex-wrap: wrap; +} + +.analytics-coverage-badge { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 4px 8px; + border-radius: 999px; + font-size: 11px; + font-weight: 800; + letter-spacing: 0.04em; + white-space: nowrap; + border: 1px solid transparent; +} + +.analytics-coverage-badge-exact { + color: #0d6b42; + background: rgba(24, 160, 99, 0.12); + border-color: rgba(24, 160, 99, 0.18); +} + +.analytics-coverage-badge-partial { + color: #8b5b00; + background: rgba(240, 176, 67, 0.16); + border-color: rgba(240, 176, 67, 0.2); +} + +.analytics-coverage-badge-unavailable { + color: var(--text-soft); + background: rgba(16, 33, 61, 0.08); + border-color: rgba(16, 33, 61, 0.1); +} + +.analytics-delta { + display: inline-flex; + align-items: center; + padding: 4px 8px; + border-radius: 999px; + font-size: 11px; + font-weight: 800; + letter-spacing: 0.04em; + white-space: nowrap; +} + +.analytics-delta-positive { + background: var(--success-soft); + color: var(--success); +} + +.analytics-delta-negative { + background: var(--danger-soft); + color: var(--danger); +} + +.analytics-delta-neutral { + background: #f1f4f9; + color: var(--text-soft); +} + +.analytics-sections { + display: grid; + grid-template-columns: minmax(0, 1.55fr) minmax(0, 1fr); + gap: 14px; +} + +.analytics-subpanel { + min-width: 0; + padding: 18px; + border-radius: 20px; + border: 1px solid var(--line); + background: linear-gradient(180deg, #fbfcff, #f5f8fd); +} + +.analytics-subpanel h3 { + margin: 0 0 8px; + font-family: "Manrope", "IBM Plex Sans", sans-serif; + font-size: 20px; + line-height: 1.1; +} + +.analytics-subpanel .hint { + margin: 0 0 12px; + font-size: 13px; + color: var(--text-soft); +} + +.analytics-subhead { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 14px; + margin-bottom: 12px; +} + +.analytics-trend-selector { + min-width: 160px; +} + +.analytics-chart-shell { + display: grid; + gap: 10px; +} + +.analytics-chart-plot { + position: relative; +} + +.analytics-chart-meta { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + color: var(--text-soft); + font-size: 13px; +} + +.analytics-chart-meta-values { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 10px; + flex-wrap: wrap; +} + +.analytics-chart-summary.is-hovered { + color: var(--text); + font-weight: 600; +} + +.analytics-chart-tooltip { + position: absolute; + top: 8px; + left: 0; + transform: translateX(-50%); + display: grid; + gap: 2px; + min-width: 104px; + padding: 8px 10px; + border-radius: 12px; + border: 1px solid rgba(53, 99, 233, 0.18); + background: rgba(255, 255, 255, 0.96); + box-shadow: 0 16px 34px rgba(14, 26, 49, 0.12); + pointer-events: none; + z-index: 2; +} + +.analytics-chart-tooltip strong { + color: var(--text); + font-size: 12px; + line-height: 1.2; +} + +.analytics-chart-tooltip span { + color: var(--primary); + font-size: 13px; + font-weight: 700; + line-height: 1.2; +} + +.analytics-chart-svg { + width: 100%; + height: auto; + display: block; +} + +.analytics-grid-line { + stroke: #e2e8f3; + stroke-width: 1; +} + +.analytics-line { + fill: none; + stroke: var(--primary); + stroke-width: 3; + stroke-linecap: round; + stroke-linejoin: round; +} + +.analytics-point { + fill: #ffffff; + stroke: var(--primary); + stroke-width: 2; + transition: fill 120ms ease, stroke-width 120ms ease, transform 120ms ease; + transform-origin: center; +} + +.analytics-point.is-active { + fill: #dbe8ff; + stroke-width: 3; +} + +.analytics-point-hit { + fill: transparent; + stroke: transparent; + cursor: pointer; +} + +.analytics-axis-label { + fill: var(--text-faint); + font-size: 11px; + font-family: "IBM Plex Sans", "Segoe UI", sans-serif; +} + +.analytics-table-shell { + display: grid; + gap: 0; + border-radius: 16px; + border: 1px solid var(--line); + background: #ffffff; + overflow: hidden; +} + +.analytics-table-head, +.analytics-table-row { + display: grid; + gap: 12px; + align-items: center; + padding: 12px 14px; +} + +.analytics-channel-grid { + grid-template-columns: minmax(120px, 1.4fr) repeat(4, minmax(72px, 1fr)); +} + +.analytics-queue-grid { + grid-template-columns: minmax(180px, 2fr) repeat(4, minmax(72px, 1fr)); +} + +.agent-analytics-grid { + grid-template-columns: minmax(170px, 1.6fr) minmax(110px, 0.9fr) minmax(130px, 1.1fr) repeat(4, minmax(78px, 0.75fr)); +} + +.analytics-table-head { + background: var(--bg-soft); + color: var(--text-faint); + font-size: 11px; + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.analytics-table-row { + border-top: 1px solid var(--line); + background: #ffffff; + text-align: left; +} + +.analytics-table-row small { + display: block; + margin-top: 4px; + color: var(--text-faint); +} + +.analytics-table-button { + width: 100%; + border: 0; + font: inherit; + cursor: pointer; +} + +.analytics-table-button:hover { + background: #f8fbff; +} + +.analytics-table-button.selected { + background: var(--primary-soft); +} + +.analytics-card-button { + width: 100%; + border: 1px solid var(--primary-line); + text-align: left; + cursor: pointer; + background: linear-gradient(180deg, #ffffff, #f6f9ff); + transition: transform 0.18s ease, box-shadow 0.18s ease, border-color 0.18s ease; +} + +.analytics-card-button:hover { + transform: translateY(-1px); + box-shadow: 0 12px 24px rgba(31, 83, 181, 0.08); + border-color: rgba(31, 83, 181, 0.22); +} + +.analytics-card-readonly { + border: 1px dashed var(--line-strong); + background: linear-gradient(180deg, #fbfcff, #f5f7fb); +} + +.analytics-card-coverage-exact { + border-color: rgba(24, 160, 99, 0.18); +} + +.analytics-card-coverage-partial { + border-color: rgba(240, 176, 67, 0.22); + background: linear-gradient(180deg, #fffdf7, #fbf6ea); +} + +.analytics-card-coverage-unavailable { + border-color: rgba(16, 33, 61, 0.12); +} + +.analytics-card-hint { + margin-top: 12px; + color: var(--primary); + font-size: 12px; + line-height: 1.45; + font-weight: 700; +} + +.analytics-card-readonly .analytics-card-hint { + color: var(--text-faint); + font-weight: 600; +} + +.analytics-ai-panel { + display: grid; + gap: 14px; + padding: 20px; + border-radius: 24px; + border: 1px solid rgba(31, 83, 181, 0.12); + background: + radial-gradient(circle at top right, rgba(31, 83, 181, 0.08), transparent 34%), + linear-gradient(180deg, #f9fbff, #f1f6ff); +} + +.analytics-agent-panel { + display: grid; + gap: 14px; + padding: 20px; + border-radius: 24px; + border: 1px solid rgba(16, 33, 61, 0.08); + background: + radial-gradient(circle at top right, rgba(16, 33, 61, 0.05), transparent 34%), + linear-gradient(180deg, #fbfcff, #f4f7fd); +} + +.agent-analytics-overview { + margin-bottom: 0; +} + +.agent-analytics-card { + border-style: solid; +} + +.agent-analytics-state-strip { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.agent-team-grid { + grid-template-columns: minmax(160px, 1.5fr) repeat(5, minmax(72px, 0.9fr)); +} + +.agent-shift-grid { + grid-template-columns: minmax(120px, 1.1fr) repeat(5, minmax(72px, 0.9fr)); +} + +.agent-state-chip { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 8px 12px; + border-radius: 999px; + font-size: 12px; + font-weight: 800; + border: 1px solid rgba(16, 33, 61, 0.08); + background: rgba(255, 255, 255, 0.94); + color: var(--text-soft); +} + +.agent-state-chip-ready { + background: rgba(24, 166, 109, 0.14); + color: #16694a; +} + +.agent-state-chip-busy { + background: rgba(255, 177, 46, 0.18); + color: #9c5b00; +} + +.agent-state-chip-break { + background: rgba(123, 66, 200, 0.14); + color: #6a34ad; +} + +.agent-state-chip-offline { + background: rgba(16, 33, 61, 0.08); + color: var(--text-soft); +} + +.ai-analytics-overview { + margin-bottom: 0; +} + +.ai-analytics-card { + border-style: solid; + border-color: rgba(31, 83, 181, 0.12); + background: linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(240, 246, 255, 0.95)); +} + +.ai-analytics-card-hint { + color: var(--text-soft); +} + +.ai-analytics-channel-grid { + grid-template-columns: minmax(120px, 1.3fr) repeat(5, minmax(72px, 1fr)); +} + +.ai-analytics-outcome-grid { + grid-template-columns: minmax(160px, 1.4fr) repeat(2, minmax(72px, 0.8fr)); +} + +.ai-analytics-reason-grid { + grid-template-columns: minmax(220px, 1.6fr) repeat(2, minmax(72px, 0.8fr)); +} + +.ai-analytics-coverage-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.ai-analytics-row-button { + width: 100%; + font: inherit; + text-align: left; + cursor: pointer; +} + +.ai-analytics-row-button:hover { + border-color: rgba(31, 83, 181, 0.2); + background: linear-gradient(180deg, #ffffff, #f1f6ff); +} + +.ai-analytics-row-button:focus-visible { + outline: 2px solid rgba(31, 83, 181, 0.28); + outline-offset: 2px; +} + +.analytics-drilldown-note { + margin-top: -4px; +} + +body.analytics-drilldown-open { + overflow: hidden; +} + +.analytics-drawer-backdrop { + display: none; + position: fixed; + inset: 0; + z-index: 79; + background: rgba(7, 13, 24, 0.18); +} + +.analytics-drawer { + display: none; + position: fixed; + inset: 0; + z-index: 80; + width: 100%; + height: 100dvh; + padding: 28px 32px; + border: 0; + background: + radial-gradient(circle at top right, rgba(35, 92, 212, 0.08), transparent 34%), + linear-gradient(180deg, #fbfdff, #f1f5fc); + box-shadow: none; + grid-template-rows: auto auto auto minmax(0, 1fr); + gap: 18px; + overflow: hidden; +} + +.analytics-drawer-backdrop.is-open { + display: block; +} + +.analytics-drawer.is-open { + display: grid; +} + +.analytics-drawer-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + padding: 0 2px; +} + +.analytics-drawer-close { + white-space: nowrap; +} + +.analytics-drawer-head h2 { + margin: 6px 0 0; + font-family: "Manrope", "IBM Plex Sans", sans-serif; + font-size: 34px; + line-height: 1.05; + letter-spacing: -0.03em; +} + +.analytics-drawer-filters { + gap: 8px; + padding: 0 2px; +} + +.analytics-drawer-toolbar { + display: grid; + grid-template-columns: minmax(260px, 1.25fr) repeat(2, minmax(180px, 0.85fr)) auto; + gap: 14px; + padding: 18px 20px; + border-radius: 24px; + border: 1px solid var(--line); + background: rgba(255, 255, 255, 0.94); + box-shadow: 0 18px 36px rgba(31, 83, 181, 0.08); +} + +.analytics-drawer-toolbar .row { + min-width: 0; +} + +.analytics-drawer-toolbar-search input[type="search"] { + width: 100%; +} + +.analytics-drawer-toolbar-actions { + align-self: end; + justify-content: flex-end; +} + +.analytics-drawer-body { + min-height: 0; + display: grid; + grid-template-columns: minmax(360px, 0.86fr) minmax(0, 1.5fr); + gap: 18px; + overflow: hidden; +} + +.analytics-drawer-list-shell, +.analytics-drawer-detail-shell { + min-height: 0; + display: grid; + gap: 12px; + padding: 20px; + border-radius: 24px; + border: 1px solid var(--line); + background: rgba(255, 255, 255, 0.96); + box-shadow: 0 18px 36px rgba(16, 33, 61, 0.06); +} + +.analytics-drawer-list-shell { + grid-template-rows: auto minmax(0, 1fr) auto; +} + +.analytics-drawer-detail-shell { + grid-template-rows: auto minmax(0, 1fr); +} + +.analytics-drawer-list-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + position: sticky; + top: 0; + z-index: 2; + margin: -20px -20px 0; + padding: 20px 20px 12px; + background: linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(255, 255, 255, 0.92)); + backdrop-filter: blur(10px); +} + +.analytics-drawer-list-head strong { + font-family: "Manrope", "IBM Plex Sans", sans-serif; + font-size: 20px; + line-height: 1.1; +} + +.analytics-drawer-list { + min-height: 0; + overflow: auto; + display: grid; + gap: 10px; + scrollbar-gutter: stable; +} + +.analytics-drilldown-row { + width: 100%; + padding: 14px; + border-radius: 18px; + border: 1px solid var(--line); + background: linear-gradient(180deg, #fbfcff, #f7f9fc); + text-align: left; + cursor: pointer; + font: inherit; + display: grid; + gap: 8px; + transition: border-color 0.18s ease, background 0.18s ease, transform 0.18s ease; +} + +.analytics-drilldown-row:hover { + transform: translateY(-1px); + border-color: rgba(31, 83, 181, 0.2); + background: linear-gradient(180deg, #ffffff, #f1f6ff); +} + +.analytics-drilldown-row.selected { + border-color: var(--primary); + background: linear-gradient(180deg, #eef4ff, #e5efff); +} + +.analytics-drilldown-row-top, +.analytics-drilldown-row-meta, +.analytics-timeline-meta { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.analytics-drilldown-row-top strong, +.analytics-drilldown-detail-head strong { + font-family: "Manrope", "IBM Plex Sans", sans-serif; + font-size: 16px; + line-height: 1.2; +} + +.analytics-drilldown-row-meta { + color: var(--text-soft); + font-size: 12px; + flex-wrap: wrap; +} + +.analytics-drilldown-facts { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.analytics-fact-badge { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 5px 9px; + border-radius: 999px; + font-size: 11px; + font-weight: 800; + border: 1px solid rgba(16, 33, 61, 0.08); + background: rgba(255, 255, 255, 0.94); + color: var(--text-soft); +} + +.analytics-fact-badge-wait, +.analytics-fact-badge-handle { + background: rgba(31, 83, 181, 0.08); + color: var(--primary-strong); +} + +.analytics-fact-badge-sla, +.analytics-fact-badge-fcr { + background: rgba(24, 160, 99, 0.12); + color: #0d6b42; +} + +.analytics-fact-badge-sla-miss, +.analytics-fact-badge-abandoned { + background: rgba(214, 73, 80, 0.1); + color: #9d313c; +} + +.analytics-drilldown-status, +.analytics-drilldown-pill { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 6px 10px; + border-radius: 999px; + font-size: 11px; + font-weight: 800; + letter-spacing: 0.02em; + border: 1px solid rgba(16, 33, 61, 0.08); +} + +.analytics-drilldown-pill { + background: rgba(244, 247, 252, 0.9); + color: var(--text-soft); +} + +.analytics-drilldown-status { + background: rgba(245, 248, 253, 0.92); + color: var(--text-soft); +} + +.analytics-drilldown-status-new { + background: rgba(39, 124, 255, 0.12); + color: #1b5fc8; +} + +.analytics-drilldown-status-in-progress { + background: rgba(255, 177, 46, 0.18); + color: #9c5b00; +} + +.analytics-drilldown-status-escalated { + background: rgba(223, 80, 68, 0.14); + color: #9b2e25; +} + +.analytics-drilldown-status-closed { + background: rgba(24, 166, 109, 0.16); + color: #16694a; +} + +.analytics-drilldown-status-abandoned { + background: rgba(123, 66, 200, 0.14); + color: #6a34ad; +} + +.analytics-drilldown-status-ready { + background: rgba(24, 166, 109, 0.16); + color: #16694a; +} + +.analytics-drilldown-status-busy { + background: rgba(255, 177, 46, 0.18); + color: #9c5b00; +} + +.analytics-drilldown-status-break { + background: rgba(123, 66, 200, 0.14); + color: #6a34ad; +} + +.analytics-drilldown-status-offline { + background: rgba(16, 33, 61, 0.08); + color: var(--text-soft); +} + +.analytics-drilldown-status-active { + background: rgba(39, 124, 255, 0.12); + color: #1b5fc8; +} + +.analytics-drilldown-status-handoff-required { + background: rgba(223, 80, 68, 0.14); + color: #9b2e25; +} + +.analytics-drilldown-status-human-owned { + background: rgba(255, 177, 46, 0.18); + color: #9c5b00; +} + +.analytics-drilldown-status-error { + background: rgba(123, 66, 200, 0.14); + color: #6a34ad; +} + +.analytics-drawer-pager { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + position: sticky; + bottom: 0; + margin: 0 -20px -20px; + padding: 12px 20px 0; + background: linear-gradient(180deg, rgba(255, 255, 255, 0), rgba(255, 255, 255, 0.96) 34%); +} + +.analytics-drawer-detail { + min-height: 0; + overflow: auto; + display: grid; + gap: 12px; + scrollbar-gutter: stable; +} + +.analytics-drilldown-detail-card { + padding: 18px; + border-radius: 20px; + border: 1px solid var(--line); + background: linear-gradient(180deg, #fbfcff, #f7f9fd); + display: grid; + gap: 14px; + position: sticky; + top: 0; + z-index: 1; +} + +.analytics-drilldown-metric-note { + display: grid; + gap: 8px; + padding: 12px 14px; + border-radius: 16px; + border: 1px solid rgba(31, 83, 181, 0.12); + background: rgba(241, 246, 255, 0.7); +} + +.analytics-drilldown-detail-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 14px; +} + +.analytics-drilldown-detail-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.analytics-drilldown-detail-grid div { + padding: 12px; + border-radius: 16px; + background: rgba(255, 255, 255, 0.8); + border: 1px solid rgba(21, 38, 70, 0.08); + display: grid; + gap: 6px; +} + +.analytics-drilldown-detail-grid small { + color: var(--text-faint); + font-size: 11px; + font-weight: 800; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.analytics-drilldown-detail-grid strong { + font-size: 14px; + line-height: 1.45; + word-break: break-word; +} + +.analytics-drilldown-timeline { + display: grid; + gap: 10px; + padding-bottom: 8px; +} + +.analytics-timeline-item { + padding: 14px; + border-radius: 18px; + border: 1px solid var(--line); + background: #ffffff; + display: grid; + gap: 8px; +} + +.analytics-timeline-meta span { + color: var(--text-soft); + font-size: 12px; +} + +.analytics-timeline-item pre { + margin: 0; + padding: 12px; + border-radius: 14px; + background: #f4f7fb; + color: var(--text-soft); + font-size: 12px; + line-height: 1.5; + overflow: auto; +} + +.analytics-coverage { + display: grid; + gap: 14px; +} + +.analytics-coverage-list { + display: grid; + gap: 12px; +} + +.analytics-coverage-block h4 { + margin: 0 0 8px; + color: var(--text-faint); + font-size: 11px; + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.analytics-coverage-chips { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.analytics-coverage-chip { + display: inline-flex; + align-items: center; + padding: 8px 10px; + border-radius: 999px; + background: var(--primary-soft); + border: 1px solid var(--primary-line); + color: var(--primary); + font-size: 12px; + font-weight: 700; +} + +.analytics-coverage-chip.soft { + background: #f5f7fb; + border-color: var(--line); + color: var(--text-soft); +} + +.analytics-placeholder-grid { + display: grid; + gap: 10px; +} + +.analytics-placeholder-card { + padding: 14px; + border-radius: 16px; + border: 1px dashed var(--line-strong); + background: linear-gradient(180deg, #fbfcff, #f7f9fd); +} + +.analytics-placeholder-card p { + margin: 6px 0 0; + color: var(--text-soft); + font-size: 13px; + line-height: 1.5; +} + +.agent-analytics-table-foot { + padding: 12px 14px; + border-top: 1px solid var(--line); + color: var(--text-faint); + font-size: 12px; +} + +.softphone-summary { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; + margin-bottom: 12px; +} + +.softphone-summary.compact { + margin-bottom: 14px; +} + +.softphone-status-block { + padding: 16px; + border-radius: 18px; + border: 1px solid var(--line); + background: linear-gradient(180deg, #ffffff, #f8fbff); +} + +.softphone-status-label { + font-size: 11px; + font-weight: 800; + color: var(--text-faint); + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.softphone-status-value { + margin-top: 8px; + font-size: 16px; + font-weight: 700; + color: var(--text); + word-break: break-word; +} + +.row.stacked { + display: grid; + gap: 6px; +} + +.softphone-banner { + margin-bottom: 12px; + padding: 16px; + border-radius: 18px; + border: 1px solid var(--primary-line); + background: linear-gradient(180deg, #f7f9ff, #edf2ff); + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; +} + +.softphone-banner p { + margin: 6px 0 0; + color: var(--text-soft); +} + +.softphone-banner-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.live-call-inspector { + grid-template-columns: 1fr; +} + +.call-window-overlay { + position: fixed; + inset: 0; + z-index: 40; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + background: rgba(12, 21, 35, 0.45); +} + +.call-window { + width: min(620px, 100%); + padding: 22px; + border-radius: 28px; + border: 1px solid rgba(255, 255, 255, 0.14); + background: linear-gradient(180deg, rgba(17, 25, 41, 0.97), rgba(8, 13, 22, 0.97)); + color: #f5f7fb; + box-shadow: 0 28px 60px rgba(8, 13, 22, 0.4); +} + +.call-window-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; +} + +.call-window-status { + font-size: 12px; + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; + color: #95b7ff; +} + +.call-window-title { + margin: 8px 0 4px; + font-size: 34px; + line-height: 1.05; + letter-spacing: -0.04em; +} + +.call-window-meta { + margin: 0; + color: rgba(245, 247, 251, 0.9); + font-size: 15px; +} + +.call-window-meta.subtle { + margin-top: 6px; + color: rgba(200, 210, 224, 0.78); + font-size: 13px; +} + +.call-window-body { + display: grid; + justify-items: center; + gap: 16px; + padding: 28px 0 22px; +} + +.call-window-avatar { + width: 112px; + height: 112px; + border-radius: 50%; + display: grid; + place-items: center; + background: radial-gradient(circle at top, rgba(95, 133, 255, 0.75), rgba(40, 63, 112, 0.95)); + border: 1px solid rgba(255, 255, 255, 0.12); + font-size: 42px; +} + +.call-window-timer { + font-family: "Manrope", "IBM Plex Sans", sans-serif; + font-size: 24px; + font-weight: 800; + letter-spacing: -0.03em; +} + +.call-window-warning { + margin: 0; + width: min(420px, 100%); + padding: 10px 12px; + border-radius: 14px; + background: rgba(239, 68, 68, 0.16); + border: 1px solid rgba(239, 68, 68, 0.32); + color: #ffd0d0; + text-align: center; + font-size: 13px; + line-height: 1.45; +} + +.call-window-ai-summary { + margin-bottom: 14px; +} + +.voice-ai-summary { + padding: 14px 16px; + border-radius: 18px; + border: 1px solid rgba(255, 255, 255, 0.12); + background: rgba(255, 255, 255, 0.06); + display: grid; + gap: 12px; +} + +.voice-ai-summary-loading { + opacity: 0.92; +} + +.voice-ai-summary-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.voice-ai-summary-title { + color: rgba(229, 236, 248, 0.92); + font-size: 12px; + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.voice-ai-summary-caption, +.voice-ai-summary-meta { + color: rgba(209, 220, 236, 0.72); + font-size: 12px; + line-height: 1.45; +} + +.voice-ai-summary-side { + display: grid; + justify-items: end; + gap: 8px; +} + +.voice-ai-summary-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.voice-summary-field { + padding: 12px 14px; + border-radius: 16px; + background: rgba(9, 16, 28, 0.46); + border: 1px solid rgba(255, 255, 255, 0.08); + display: grid; + gap: 6px; +} + +.voice-summary-label { + color: rgba(175, 189, 210, 0.76); + font-size: 11px; + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.voice-summary-value { + color: #f5f7fb; + font-size: 14px; + line-height: 1.5; + white-space: pre-wrap; +} + +.voice-summary-status { + min-height: 28px; + padding: 0 12px; + border-radius: 999px; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 11px; + font-weight: 800; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.voice-summary-status.answered { + background: rgba(16, 185, 129, 0.16); + color: #70e0bb; +} + +.voice-summary-status.handoff { + background: rgba(245, 158, 11, 0.18); + color: #f3bf6a; +} + +.voice-summary-transcript { + display: grid; + gap: 10px; +} + +.voice-summary-transcript-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.voice-summary-transcript-meta { + color: rgba(175, 189, 210, 0.72); + font-size: 11px; +} + +.voice-summary-transcript-list { + display: grid; + gap: 8px; + max-height: 240px; + overflow-y: auto; + padding-right: 4px; +} + +.voice-transcript-line { + padding: 10px 12px; + border-radius: 14px; + border: 1px solid rgba(255, 255, 255, 0.08); + display: grid; + gap: 6px; +} + +.voice-transcript-line.caller { + background: rgba(9, 16, 28, 0.42); +} + +.voice-transcript-line.assistant { + background: rgba(56, 96, 201, 0.18); +} + +.voice-transcript-speaker-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.voice-transcript-speaker { + color: #d7e2f3; + font-size: 11px; + font-weight: 800; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.voice-transcript-time { + color: rgba(175, 189, 210, 0.68); + font-size: 11px; +} + +.voice-transcript-text { + color: #f5f7fb; + font-size: 14px; + line-height: 1.5; + white-space: pre-wrap; +} + +.call-window-transfer { + display: grid; + grid-template-columns: 160px 1fr; + gap: 10px; + margin-bottom: 14px; +} + +.call-window-transfer select, +.call-window-transfer input { + background: rgba(255, 255, 255, 0.08); + border-color: rgba(255, 255, 255, 0.15); + color: #fff; +} + +.call-window-transfer input::placeholder { + color: rgba(255, 255, 255, 0.45); +} + +.call-window-actions { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.call-window .icon-button { + border-color: rgba(255, 255, 255, 0.15); + background: rgba(255, 255, 255, 0.06); + color: #fff; +} + +.hidden { + display: none !important; +} + +#browserPhoneRemoteAudio { + width: 100%; + margin-top: 10px; +} + +.output { + margin: 0; + padding: 14px; + border-radius: var(--radius-lg); + border: 1px solid var(--line); + background: #f8fbff; + color: var(--text-soft); + white-space: pre-wrap; + max-height: 220px; + overflow: auto; + font-size: 12px; + line-height: 1.55; +} + +.output.tall { + max-height: 340px; +} + +.compact-actions { + margin-bottom: 12px; +} + +.technical-panel { + background: linear-gradient(180deg, #ffffff, #fbfcff); +} + +.technical-details { + margin-top: 8px; + padding: 14px 16px 10px; + border-radius: 18px; + border: 1px solid var(--line); + background: #f9fbff; +} + +.technical-details > summary { + cursor: pointer; + color: var(--text); + font-size: 15px; + font-weight: 800; + list-style: none; +} + +.technical-details > summary::-webkit-details-marker { + display: none; +} + +.technical-details[open] { + background: #fff; +} + +.details-block { + margin-bottom: 12px; +} + +.details-block summary { + cursor: pointer; + color: var(--text-soft); + font-size: 13px; + font-weight: 700; + margin-bottom: 8px; +} + +.empty-state { + color: var(--text-faint); + font-style: italic; +} + +.reveal { + animation: rise 0.45s ease both; +} + +.reveal:nth-child(2) { animation-delay: 0.04s; } +.reveal:nth-child(3) { animation-delay: 0.08s; } +.reveal:nth-child(4) { animation-delay: 0.12s; } + +@keyframes rise { + from { + opacity: 0; + transform: translateY(12px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@media (max-width: 1200px) { + .app-shell { + display: block; + } + + .sidebar { + width: auto; + position: static; + min-height: auto; + border-right: 0; + border-bottom: 1px solid var(--line); + overflow-y: visible; + } + + .workspace { + margin-left: 0; + } +} + +@media (max-width: 980px) { + .workspace-topbar, + .layout { + padding-left: 18px; + padding-right: 18px; + } + + .workspace-topbar { + height: auto; + align-items: stretch; + flex-direction: column; + } + + .login-grid { + grid-template-columns: 1fr; + } + + .search-box { + max-width: none; + } + + .topbar-actions, + .guide-head { + flex-direction: column; + align-items: stretch; + } + + .auth, + .grid, + .inline-form, + .inline-form.compact, + .inline-form.split-two, + .summary-grid, + .softphone-summary, + .analytics-filter-bar, + .analytics-toolkit, + .analytics-toolkit-group-wide, + .analytics-compare-grid, + .analytics-overview-grid, + .analytics-sections { + grid-template-columns: 1fr; + } + + .ai-analytics-coverage-grid, + .ai-analytics-channel-grid, + .ai-analytics-outcome-grid, + .ai-analytics-reason-grid { + grid-template-columns: 1fr; + } + + .analytics-head, + .analytics-compare-head, + .analytics-subhead, + .analytics-chart-meta, + .analytics-narrative-meta { + flex-direction: column; + align-items: stretch; + } + + .analytics-table-shell { + overflow: auto; + } + + .analytics-table-head, + .analytics-table-row { + min-width: 620px; + } + + .pipeline-board { + grid-template-columns: 1fr; + } + + .customer-spotlight-grid, + .customer-spotlight-grid.page, + .customer-profile-summary-grid, + .customer-profile-meta-grid { + grid-template-columns: 1fr; + } + + .customer-profile-hero, + .customer-history-header { + flex-direction: column; + } + + .customer-profile-badges { + justify-content: flex-start; + max-width: none; + } + + .customer-history-caption { + max-width: none; + text-align: left; + } + + .customers-toolbar, + .customers-footer { + align-items: stretch; + } + + .customers-toolbar-left, + .customers-toolbar-right, + .customers-search { + width: 100%; + max-width: none; + } + + .lead-table-head, + .lead-table-row { + grid-template-columns: 44px minmax(200px, 2fr) minmax(120px, 1fr) minmax(120px, 1fr) minmax(140px, 1fr) 90px; + } + + .call-window { + padding: 18px; + } + + .call-window-transfer { + grid-template-columns: 1fr; + } + + .call-window-actions { + flex-direction: column; + } + + .voice-ai-summary-head { + flex-direction: column; + } + + .voice-ai-summary-side { + justify-items: start; + } + + .voice-ai-summary-grid { + grid-template-columns: 1fr; + } + + .profile-chip { + border-left: 0; + padding-left: 0; + } + + .telegram-layout { + grid-template-columns: 1fr; + } + + .telegram-threads-pane { + border-right: 0; + border-bottom: 1px solid var(--line); + min-height: 320px; + } + + .telegram-chat-header, + .telegram-operator-controls { + flex-wrap: wrap; + } + + .telegram-ai-summary-head { + flex-direction: column; + } + + .telegram-ai-summary-meta { + white-space: normal; + text-align: left; + } + + .telegram-ai-summary-side { + justify-items: start; + } + + .telegram-ai-summary-grid { + grid-template-columns: 1fr; + } + + .telegram-chat-surface { + min-height: 420px; + } + + .telegram-composer-shell { + grid-template-columns: 40px minmax(0, 1fr) 40px 44px; + } + + .whatsapp-shell { + grid-template-columns: 1fr; + grid-template-rows: auto auto minmax(520px, 1fr); + } + + .whatsapp-nav-rail { + padding: 10px 14px; + border-right: 0; + border-bottom: 1px solid rgba(233, 237, 239, 0.08); + flex-direction: row; + } + + .whatsapp-rail-group, + .whatsapp-rail-footer { + flex-direction: row; + } + + .whatsapp-list-pane { + border-right: 0; + border-bottom: 1px solid rgba(233, 237, 239, 0.08); + min-height: 320px; + } + + .whatsapp-chat-head { + flex-wrap: wrap; + } + + .whatsapp-context-panel { + padding-left: 12px; + padding-right: 12px; + } + + .whatsapp-ai-summary-head { + flex-direction: column; + } + + .whatsapp-ai-summary-side { + justify-items: start; + } + + .whatsapp-ai-summary-grid { + grid-template-columns: 1fr; + } + + .whatsapp-chat-surface { + min-height: 420px; + } +} + +@media (max-width: 880px) { + .lead-table-head { + display: none; + } + + .lead-table-row { + grid-template-columns: 1fr; + gap: 10px; + padding: 16px; + } + + .customer-spotlight-shell { + padding: 14px; + } + + .customer-spotlight-page { + padding: 16px; + } + + .customer-profile-card, + .customer-history-card { + padding: 16px; + border-radius: 20px; + } + + .customer-profile-copy h3, + .customer-history-header h3 { + font-size: 24px; + } + + .customer-history-event { + grid-template-columns: 1fr; + } + + .customer-history-mark { + width: 40px; + height: 40px; + border-radius: 14px; + } + + .telegram-thread-card { + padding: 10px; + } + + .telegram-thread-avatar { + width: 38px; + height: 38px; + font-size: 11px; + } + + .telegram-thread-title { + font-size: 18px; + } + + .telegram-message-bubble { + max-width: 92%; + } + + .telegram-composer-wrap { + padding-left: 10px; + padding-right: 10px; + } + + .telegram-composer-shell { + grid-template-columns: 38px minmax(0, 1fr) 38px 42px; + } + + .whatsapp-list-head, + .whatsapp-chat-head, + .whatsapp-context-panel, + .whatsapp-search-wrap, + .whatsapp-composer-wrap { + padding-left: 12px; + padding-right: 12px; + } + + .whatsapp-chat-card { + padding: 12px; + } + + .whatsapp-chat-card-avatar { + width: 42px; + height: 42px; + font-size: 13px; + } + + .whatsapp-chat-card-title { + font-size: 14px; + } + + .whatsapp-message-timeline { + padding-left: 10px; + padding-right: 10px; + } + + .whatsapp-message-bubble { + max-width: 94%; + } + + .whatsapp-composer-shell { + grid-template-columns: 38px minmax(0, 1fr) 38px 42px; + gap: 6px; + } + + .checkbox-cell { + display: none; + } + + .score-cell { + text-align: left; + } +} + +@media (max-width: 960px) { + .analytics-drawer { + padding: 22px; + } + + .analytics-drawer-body { + grid-template-columns: 1fr; + grid-template-rows: minmax(240px, 36dvh) minmax(0, 1fr); + } + + .analytics-drawer-toolbar { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .analytics-drawer-toolbar-actions { + grid-column: 1 / -1; + justify-content: stretch; + } + + .analytics-drawer-list-shell, + .analytics-drawer-detail-shell { + min-height: 0; + } +} + +@media (max-width: 720px) { + .analytics-drawer { + inset: 0; + height: 100dvh; + border-radius: 0; + padding: 14px; + gap: 14px; + } + + .analytics-drawer-head { + flex-direction: column; + align-items: stretch; + } + + .analytics-drawer-head h2 { + font-size: 28px; + } + + .analytics-drawer-toolbar { + grid-template-columns: 1fr; + padding: 14px; + gap: 12px; + } + + .analytics-drawer-toolbar-actions { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + justify-content: stretch; + } + + .analytics-drawer-body { + grid-template-rows: minmax(220px, 34dvh) minmax(0, 1fr); + gap: 12px; + } + + .analytics-drilldown-detail-grid { + grid-template-columns: 1fr; + } + + .analytics-drawer-list-head { + margin: -16px -16px 0; + padding: 16px 16px 10px; + } + + .analytics-drawer-pager { + margin: 0 -16px -16px; + padding: 10px 16px 0; + } +} diff --git a/ui/operator/vendor/sip-0.21.2.min.js b/ui/operator/vendor/sip-0.21.2.min.js new file mode 100644 index 0000000..5b917b4 --- /dev/null +++ b/ui/operator/vendor/sip-0.21.2.min.js @@ -0,0 +1,2 @@ +/*! For license information please see sip.min.js.LICENSE.txt */ +!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.SIP=t():e.SIP=t()}(this,(()=>(()=>{"use strict";var e={d:(t,s)=>{for(var i in s)e.o(s,i)&&!e.o(t,i)&&Object.defineProperty(t,i,{enumerable:!0,get:s[i]})},o:(e,t)=>Object.prototype.hasOwnProperty.call(e,t),r:e=>{"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})}},t={};e.r(t),e.d(t,{Ack:()=>l,Bye:()=>g,Cancel:()=>u,ContentTypeUnsupportedError:()=>o,Core:()=>s,EmitterImpl:()=>p,Grammar:()=>y,Info:()=>f,Invitation:()=>Q,Inviter:()=>ee,Message:()=>W,Messager:()=>te,NameAddrHeader:()=>v,Notification:()=>K,Parameters:()=>m,Publisher:()=>le,PublisherState:()=>se,Referral:()=>Y,Registerer:()=>ge,RegistererState:()=>ie,RequestPendingError:()=>a,SIPExtension:()=>J,Session:()=>z,SessionDescriptionHandlerError:()=>c,SessionState:()=>Z,SessionTerminatedError:()=>h,StateTransitionError:()=>d,Subscriber:()=>pe,Subscription:()=>ue,SubscriptionState:()=>ne,TransportState:()=>oe,URI:()=>w,UserAgent:()=>dt,UserAgentRegisteredOptionTags:()=>X,UserAgentState:()=>ae,Web:()=>i,equivalentURI:()=>b,name:()=>At,version:()=>Ct});var s={};e.r(s),e.d(s,{ByeUserAgentClient:()=>Pe,ByeUserAgentServer:()=>_e,C:()=>G,CancelUserAgentClient:()=>lt,ClientTransaction:()=>Ie,Dialog:()=>Ae,DigestAuthentication:()=>we,Exception:()=>n,Grammar:()=>y,IncomingMessage:()=>H,IncomingRequestMessage:()=>P,IncomingResponseMessage:()=>x,InfoUserAgentClient:()=>qe,InfoUserAgentServer:()=>Me,InviteClientTransaction:()=>De,InviteServerTransaction:()=>$e,InviteUserAgentClient:()=>Ye,InviteUserAgentServer:()=>Ze,Levels:()=>ce,Logger:()=>be,LoggerFactory:()=>Te,MessageUserAgentClient:()=>Ne,MessageUserAgentServer:()=>Oe,NameAddrHeader:()=>v,NonInviteClientTransaction:()=>Ce,NonInviteServerTransaction:()=>xe,NotifyUserAgentClient:()=>Ue,NotifyUserAgentServer:()=>je,OutgoingRequestMessage:()=>k,Parameters:()=>m,Parser:()=>he,PrackUserAgentClient:()=>Fe,PrackUserAgentServer:()=>Le,PublishUserAgentClient:()=>Je,ReInviteUserAgentClient:()=>Be,ReInviteUserAgentServer:()=>Ge,ReSubscribeUserAgentClient:()=>Qe,ReSubscribeUserAgentServer:()=>gt,ReferUserAgentClient:()=>Ve,ReferUserAgentServer:()=>We,RegisterUserAgentClient:()=>ze,RegisterUserAgentServer:()=>Xe,ServerTransaction:()=>Ee,SessionDialog:()=>Ke,SessionState:()=>O,SignalingState:()=>U,SubscribeUserAgentClient:()=>tt,SubscribeUserAgentServer:()=>st,SubscriptionDialog:()=>et,SubscriptionState:()=>re,Timers:()=>L,Transaction:()=>ye,TransactionState:()=>de,TransactionStateError:()=>B,TransportError:()=>Re,URI:()=>w,UserAgentClient:()=>He,UserAgentCore:()=>rt,UserAgentServer:()=>ke,constructOutgoingResponse:()=>Se,equivalentURI:()=>b,fromBodyLegacy:()=>q,getBody:()=>N,isBody:()=>M});var i={};e.r(i),e.d(i,{SessionDescriptionHandler:()=>at,SessionManager:()=>$t,SimpleUser:()=>It,Transport:()=>ht,WebAudioSessionDescriptionHandler:()=>yt,addMidLines:()=>Tt,cleanJitsiSdpImageattr:()=>mt,defaultManagedSessionFactory:()=>Et,defaultMediaStreamFactory:()=>nt,defaultPeerConnectionConfiguration:()=>ot,defaultSessionDescriptionHandlerFactory:()=>ct,holdModifier:()=>St,startLocalConference:()=>Rt,stripG722:()=>vt,stripRtpPayload:()=>wt,stripTcpCandidates:()=>pt,stripTelephoneEvent:()=>ft,stripVideo:()=>bt});const r="0.21.2";class n extends Error{constructor(e){super(e),Object.setPrototypeOf(this,new.target.prototype)}}class o extends n{constructor(e){super(e||"Unsupported content type.")}}class a extends n{constructor(e){super(e||"Request pending.")}}class c extends n{constructor(e){super(e||"Unspecified session description handler error.")}}class h extends n{constructor(){super("The session has terminated.")}}class d extends n{constructor(e){super(e||"An error occurred during state transition.")}}class l{constructor(e){this.incomingAckRequest=e}get request(){return this.incomingAckRequest.message}}class g{constructor(e){this.incomingByeRequest=e}get request(){return this.incomingByeRequest.message}accept(e){return this.incomingByeRequest.accept(e),Promise.resolve()}reject(e){return this.incomingByeRequest.reject(e),Promise.resolve()}}class u{constructor(e){this.incomingCancelRequest=e}get request(){return this.incomingCancelRequest}}class p{constructor(){this.listeners=new Array}addListener(e,t){const s=t=>{this.removeListener(s),e(t)};!0===(null==t?void 0:t.once)?this.listeners.push(s):this.listeners.push(e)}emit(e){this.listeners.slice().forEach((t=>t(e)))}removeAllListeners(){this.listeners=[]}removeListener(e){this.listeners=this.listeners.filter((t=>t!==e))}on(e){return this.addListener(e)}off(e){return this.removeListener(e)}once(e){return this.addListener(e,{once:!0})}}class f{constructor(e){this.incomingInfoRequest=e}get request(){return this.incomingInfoRequest.message}accept(e){return this.incomingInfoRequest.accept(e),Promise.resolve()}reject(e){return this.incomingInfoRequest.reject(e),Promise.resolve()}}class m{constructor(e){this.parameters={};for(const t in e)e.hasOwnProperty(t)&&this.setParam(t,e[t])}setParam(e,t){e&&(this.parameters[e.toLowerCase()]=null==t?null:t.toString())}getParam(e){if(e)return this.parameters[e.toLowerCase()]}hasParam(e){return!(!e||void 0===this.parameters[e.toLowerCase()])}deleteParam(e){if(e=e.toLowerCase(),this.hasParam(e)){const t=this.parameters[e];return delete this.parameters[e],t}}clearParams(){this.parameters={}}}class v extends m{constructor(e,t,s){super(s),this.uri=e,this._displayName=t}get friendlyName(){return this.displayName||this.uri.aor}get displayName(){return this._displayName}set displayName(e){this._displayName=e}clone(){return new v(this.uri.clone(),this._displayName,JSON.parse(JSON.stringify(this.parameters)))}toString(){let e=this.displayName||"0"===this.displayName?'"'+this.displayName+'" ':"";e+="<"+this.uri.toString()+">";for(const t in this.parameters)this.parameters.hasOwnProperty(t)&&(e+=";"+t,null!==this.parameters[t]&&(e+="="+this.parameters[t]));return e}}class w extends m{constructor(e="sip",t,s,i,r,n){if(super(r||{}),this.headers={},!s)throw new TypeError('missing or invalid "host" parameter');for(const e in n)n.hasOwnProperty(e)&&this.setHeader(e,n[e]);this.raw={scheme:e,user:t,host:s,port:i},this.normal={scheme:e.toLowerCase(),user:t,host:s.toLowerCase(),port:i}}get scheme(){return this.normal.scheme}set scheme(e){this.raw.scheme=e,this.normal.scheme=e.toLowerCase()}get user(){return this.normal.user}set user(e){this.normal.user=this.raw.user=e}get host(){return this.normal.host}set host(e){this.raw.host=e,this.normal.host=e.toLowerCase()}get aor(){return this.normal.user+"@"+this.normal.host}get port(){return this.normal.port}set port(e){this.normal.port=this.raw.port=e}setHeader(e,t){this.headers[this.headerize(e)]=t instanceof Array?t:[t]}getHeader(e){if(e)return this.headers[this.headerize(e)]}hasHeader(e){return!!e&&!!this.headers.hasOwnProperty(this.headerize(e))}deleteHeader(e){if(e=this.headerize(e),this.headers.hasOwnProperty(e)){const t=this.headers[e];return delete this.headers[e],t}}clearHeaders(){this.headers={}}clone(){return new w(this._raw.scheme,this._raw.user||"",this._raw.host,this._raw.port,JSON.parse(JSON.stringify(this.parameters)),JSON.parse(JSON.stringify(this.headers)))}toRaw(){return this._toString(this._raw)}toString(){return this._toString(this._normal)}get _normal(){return this.normal}get _raw(){return this.raw}_toString(e){let t=e.scheme+":";e.scheme.toLowerCase().match("^sips?$")||(t+="//"),e.user&&(t+=this.escapeUser(e.user)+"@"),t+=e.host,(e.port||0===e.port)&&(t+=":"+e.port);for(const e in this.parameters)this.parameters.hasOwnProperty(e)&&(t+=";"+e,null!==this.parameters[e]&&(t+="="+this.parameters[e]));const s=[];for(const e in this.headers)if(this.headers.hasOwnProperty(e))for(const t in this.headers[e])this.headers[e].hasOwnProperty(t)&&s.push(e+"="+this.headers[e][t]);return s.length>0&&(t+="?"+s.join("&")),t}escapeUser(e){let t;try{t=decodeURIComponent(e)}catch(e){throw e}return encodeURIComponent(t).replace(/%3A/gi,":").replace(/%2B/gi,"+").replace(/%3F/gi,"?").replace(/%2F/gi,"/")}headerize(e){const t={"Call-Id":"Call-ID",Cseq:"CSeq","Min-Se":"Min-SE",Rack:"RAck",Rseq:"RSeq","Www-Authenticate":"WWW-Authenticate"},s=e.toLowerCase().replace(/_/g,"-").split("-"),i=s.length;let r="";for(let e=0;ei.includes(e))).every((s=>e.parameters[s]===t.parameters[s]))&&(!!["user","ttl","method","transport"].every((s=>e.hasParam(s)&&t.hasParam(s)||!e.hasParam(s)&&!t.hasParam(s)))&&!!["maddr"].every((s=>e.hasParam(s)&&t.hasParam(s)||!e.hasParam(s)&&!t.hasParam(s))))}(e,t))return!1;const s=Object.keys(e.headers),i=Object.keys(t.headers);if(0!==s.length||0!==i.length){if(s.length!==i.length)return!1;const r=s.filter((e=>i.includes(e)));if(r.length!==i.length)return!1;if(!r.every((s=>e.headers[s].length&&t.headers[s].length&&e.headers[s][0]===t.headers[s][0])))return!1}return!0}function T(e,t,s){return s=s||" ",e.length>t?e:(t-=e.length,e+(s+=s.repeat(t)).slice(0,t))}class S extends Error{constructor(e,t,s,i){super(),this.message=e,this.expected=t,this.found=s,this.location=i,this.name="SyntaxError","function"==typeof Object.setPrototypeOf?Object.setPrototypeOf(this,S.prototype):this.__proto__=S.prototype,"function"==typeof Error.captureStackTrace&&Error.captureStackTrace(this,S)}static buildMessage(e,t){function s(e){return e.charCodeAt(0).toString(16).toUpperCase()}function i(e){return e.replace(/\\/g,"\\\\").replace(/"/g,'\\"').replace(/\0/g,"\\0").replace(/\t/g,"\\t").replace(/\n/g,"\\n").replace(/\r/g,"\\r").replace(/[\x00-\x0F]/g,(e=>"\\x0"+s(e))).replace(/[\x10-\x1F\x7F-\x9F]/g,(e=>"\\x"+s(e)))}function r(e){return e.replace(/\\/g,"\\\\").replace(/\]/g,"\\]").replace(/\^/g,"\\^").replace(/-/g,"\\-").replace(/\0/g,"\\0").replace(/\t/g,"\\t").replace(/\n/g,"\\n").replace(/\r/g,"\\r").replace(/[\x00-\x0F]/g,(e=>"\\x0"+s(e))).replace(/[\x10-\x1F\x7F-\x9F]/g,(e=>"\\x"+s(e)))}function n(e){switch(e.type){case"literal":return'"'+i(e.text)+'"';case"class":const t=e.parts.map((e=>Array.isArray(e)?r(e[0])+"-"+r(e[1]):r(e)));return"["+(e.inverted?"^":"")+t+"]";case"any":return"any character";case"end":return"end of input";case"other":return e.description}}return"Expected "+function(e){const t=e.map(n);let s,i;if(t.sort(),t.length>0){for(s=1,i=1;s",b(">",!1),"\\",b("\\",!1),"[",b("[",!1),"]",b("]",!1),"{",b("{",!1),"}",b("}",!1),function(){return"*"},function(){return"/"},function(){return"="},function(){return"("},function(){return")"},function(){return">"},function(){return"<"},function(){return","},function(){return";"},function(){return":"},function(){return'"'},/^[!-']/,T([["!","'"]],!1,!1),/^[*-[]/,T([["*","["]],!1,!1),/^[\]-~]/,T([["]","~"]],!1,!1),function(e){return e},/^[#-[]/,T([["#","["]],!1,!1),/^[\0-\t]/,T([["\0","\t"]],!1,!1),/^[\v-\f]/,T([["\v","\f"]],!1,!1),/^[\x0E-\x7F]/,T([["\x0e","\x7f"]],!1,!1),function(){(t=t||{data:{}}).data.uri=new w(t.data.scheme,t.data.user,t.data.host,t.data.port),delete t.data.scheme,delete t.data.user,delete t.data.host,delete t.data.host_type,delete t.data.port},function(){(t=t||{data:{}}).data.uri=new w(t.data.scheme,t.data.user,t.data.host,t.data.port,t.data.uri_params,t.data.uri_headers),delete t.data.scheme,delete t.data.user,delete t.data.host,delete t.data.host_type,delete t.data.port,delete t.data.uri_params,"SIP_URI"===t.startRule&&(t.data=t.data.uri)},"sips",b("sips",!0),"sip",b("sip",!0),function(e){(t=t||{data:{}}).data.scheme=e},function(){(t=t||{data:{}}).data.user=decodeURIComponent(f().slice(0,-1))},function(){(t=t||{data:{}}).data.password=f()},function(){return(t=t||{data:{}}).data.host=f(),t.data.host},function(){return(t=t||{data:{}}).data.host_type="domain",f()},/^[a-zA-Z0-9_\-]/,T([["a","z"],["A","Z"],["0","9"],"_","-"],!1,!1),/^[a-zA-Z0-9\-]/,T([["a","z"],["A","Z"],["0","9"],"-"],!1,!1),function(){return(t=t||{data:{}}).data.host_type="IPv6",f()},"::",b("::",!1),function(){return(t=t||{data:{}}).data.host_type="IPv6",f()},function(){return(t=t||{data:{}}).data.host_type="IPv4",f()},"25",b("25",!1),/^[0-5]/,T([["0","5"]],!1,!1),"2",b("2",!1),/^[0-4]/,T([["0","4"]],!1,!1),"1",b("1",!1),/^[1-9]/,T([["1","9"]],!1,!1),function(e){return t=t||{data:{}},e=parseInt(e.join("")),t.data.port=e,e},"transport=",b("transport=",!0),"udp",b("udp",!0),"tcp",b("tcp",!0),"sctp",b("sctp",!0),"tls",b("tls",!0),function(e){(t=t||{data:{}}).data.uri_params||(t.data.uri_params={}),t.data.uri_params.transport=e.toLowerCase()},"user=",b("user=",!0),"phone",b("phone",!0),"ip",b("ip",!0),function(e){(t=t||{data:{}}).data.uri_params||(t.data.uri_params={}),t.data.uri_params.user=e.toLowerCase()},"method=",b("method=",!0),function(e){(t=t||{data:{}}).data.uri_params||(t.data.uri_params={}),t.data.uri_params.method=e},"ttl=",b("ttl=",!0),function(e){(t=t||{data:{}}).data.params||(t.data.params={}),t.data.params.ttl=e},"maddr=",b("maddr=",!0),function(e){(t=t||{data:{}}).data.uri_params||(t.data.uri_params={}),t.data.uri_params.maddr=e},"lr",b("lr",!0),function(){(t=t||{data:{}}).data.uri_params||(t.data.uri_params={}),t.data.uri_params.lr=void 0},function(e,s){(t=t||{data:{}}).data.uri_params||(t.data.uri_params={}),s=null===s?void 0:s[1],t.data.uri_params[e.toLowerCase()]=s},function(e,s){e=e.join("").toLowerCase(),s=s.join(""),(t=t||{data:{}}).data.uri_headers||(t.data.uri_headers={}),t.data.uri_headers[e]?t.data.uri_headers[e].push(s):t.data.uri_headers[e]=[s]},function(){"Refer_To"===(t=t||{data:{}}).startRule&&(t.data.uri=new w(t.data.scheme,t.data.user,t.data.host,t.data.port,t.data.uri_params,t.data.uri_headers),delete t.data.scheme,delete t.data.user,delete t.data.host,delete t.data.host_type,delete t.data.port,delete t.data.uri_params)},"//",b("//",!1),function(){(t=t||{data:{}}).data.scheme=f()},b("SIP",!0),function(){(t=t||{data:{}}).data.sip_version=f()},"INVITE",b("INVITE",!1),"ACK",b("ACK",!1),"VXACH",b("VXACH",!1),"OPTIONS",b("OPTIONS",!1),"BYE",b("BYE",!1),"CANCEL",b("CANCEL",!1),"REGISTER",b("REGISTER",!1),"SUBSCRIBE",b("SUBSCRIBE",!1),"NOTIFY",b("NOTIFY",!1),"REFER",b("REFER",!1),"PUBLISH",b("PUBLISH",!1),function(){return(t=t||{data:{}}).data.method=f(),t.data.method},function(e){(t=t||{data:{}}).data.status_code=parseInt(e.join(""))},function(){(t=t||{data:{}}).data.reason_phrase=f()},function(){(t=t||{data:{}}).data=f()},function(){var e,s;for(s=(t=t||{data:{}}).data.multi_header.length,e=0;e""6>7?.A &2@""6@7A.5 &2B""6B7C.) &2D""6D7E'),I(";).# &;,"),I('2F""6F7G.} &2H""6H7I.q &2J""6J7K.e &2L""6L7M.Y &2N""6N7O.M &2P""6P7Q.A &2R""6R7S.5 &2T""6T7U.) &2V""6V7W'),I('%%2X""6X7Y/5#;#/,$;#/#$+#)(#\'#("\'#&\'#/"!&,)'),I('%%$;$0#*;$&/,#; /#$+")("\'#&\'#." &"/=#$;$/�#*;$&&&#/\'$8":Z" )("\'#&\'#'),I(';.." &"'),I("%$;'.# &;(0)*;'.# &;(&/?#28\"\"6879/0$;//'$8#:[# )(#'#(\"'#&'#"),I('%%$;2/�#*;2&&&#/g#$%$;.0#*;.&/,#;2/#$+")("\'#&\'#0=*%$;.0#*;.&/,#;2/#$+")("\'#&\'#&/#$+")("\'#&\'#/"!&,)'),I('4\\""5!7].# &;3'),I('4^""5!7_'),I('4`""5!7a'),I(';!.) &4b""5!7c'),I('%$;).\x95 &2F""6F7G.\x89 &2J""6J7K.} &2L""6L7M.q &2X""6X7Y.e &2P""6P7Q.Y &2H""6H7I.M &2@""6@7A.A &2d""6d7e.5 &2R""6R7S.) &2N""6N7O/\x9e#0\x9b*;).\x95 &2F""6F7G.\x89 &2J""6J7K.} &2L""6L7M.q &2X""6X7Y.e &2P""6P7Q.Y &2H""6H7I.M &2@""6@7A.A &2d""6d7e.5 &2R""6R7S.) &2N""6N7O&&&#/"!&,)'),I('%$;).\x89 &2F""6F7G.} &2L""6L7M.q &2X""6X7Y.e &2P""6P7Q.Y &2H""6H7I.M &2@""6@7A.A &2d""6d7e.5 &2R""6R7S.) &2N""6N7O/\x92#0\x8f*;).\x89 &2F""6F7G.} &2L""6L7M.q &2X""6X7Y.e &2P""6P7Q.Y &2H""6H7I.M &2@""6@7A.A &2d""6d7e.5 &2R""6R7S.) &2N""6N7O&&&#/"!&,)'),I('2T""6T7U.\xe3 &2V""6V7W.\xd7 &2f""6f7g.\xcb &2h""6h7i.\xbf &2:""6:7;.\xb3 &2D""6D7E.\xa7 &22""6273.\x9b &28""6879.\x8f &2j""6j7k.\x83 &;&.} &24""6475.q &2l""6l7m.e &2n""6n7o.Y &26""6677.M &2>""6>7?.A &2p""6p7q.5 &2r""6r7s.) &;\'.# &;('),I('%$;).\u012b &2F""6F7G.\u011f &2J""6J7K.\u0113 &2L""6L7M.\u0107 &2X""6X7Y.\xfb &2P""6P7Q.\xef &2H""6H7I.\xe3 &2@""6@7A.\xd7 &2d""6d7e.\xcb &2R""6R7S.\xbf &2N""6N7O.\xb3 &2T""6T7U.\xa7 &2V""6V7W.\x9b &2f""6f7g.\x8f &2h""6h7i.\x83 &28""6879.w &2j""6j7k.k &;&.e &24""6475.Y &2l""6l7m.M &2n""6n7o.A &26""6677.5 &2p""6p7q.) &2r""6r7s/\u0134#0\u0131*;).\u012b &2F""6F7G.\u011f &2J""6J7K.\u0113 &2L""6L7M.\u0107 &2X""6X7Y.\xfb &2P""6P7Q.\xef &2H""6H7I.\xe3 &2@""6@7A.\xd7 &2d""6d7e.\xcb &2R""6R7S.\xbf &2N""6N7O.\xb3 &2T""6T7U.\xa7 &2V""6V7W.\x9b &2f""6f7g.\x8f &2h""6h7i.\x83 &28""6879.w &2j""6j7k.k &;&.e &24""6475.Y &2l""6l7m.M &2n""6n7o.A &26""6677.5 &2p""6p7q.) &2r""6r7s&&&#/"!&,)'),I("%;//?#2P\"\"6P7Q/0$;//'$8#:t# )(#'#(\"'#&'#"),I("%;//?#24\"\"6475/0$;//'$8#:u# )(#'#(\"'#&'#"),I("%;//?#2>\"\"6>7?/0$;//'$8#:v# )(#'#(\"'#&'#"),I("%;//?#2T\"\"6T7U/0$;//'$8#:w# )(#'#(\"'#&'#"),I("%;//?#2V\"\"6V7W/0$;//'$8#:x# )(#'#(\"'#&'#"),I('%2h""6h7i/0#;//\'$8":y" )("\'#&\'#'),I('%;//6#2f""6f7g/\'$8":z" )("\'#&\'#'),I("%;//?#2D\"\"6D7E/0$;//'$8#:{# )(#'#(\"'#&'#"),I("%;//?#22\"\"6273/0$;//'$8#:|# )(#'#(\"'#&'#"),I("%;//?#28\"\"6879/0$;//'$8#:}# )(#'#(\"'#&'#"),I("%;//0#;&/'$8\":~\" )(\"'#&'#"),I("%;&/0#;//'$8\":~\" )(\"'#&'#"),I("%;=/T#$;G.) &;K.# &;F0/*;G.) &;K.# &;F&/,$;>/#$+#)(#'#(\"'#&'#"),I('4\x7f""5!7\x80.A &4\x81""5!7\x82.5 &4\x83""5!7\x84.) &;3.# &;.'),I("%%;//Q#;&/H$$;J.# &;K0)*;J.# &;K&/,$;&/#$+$)($'#(#'#(\"'#&'#/\"!&,)"),I("%;//]#;&/T$%$;J.# &;K0)*;J.# &;K&/\"!&,)/1$;&/($8$:\x85$!!)($'#(#'#(\"'#&'#"),I(';..G &2L""6L7M.; &4\x86""5!7\x87./ &4\x83""5!7\x84.# &;3'),I('%2j""6j7k/J#4\x88""5!7\x89.5 &4\x8a""5!7\x8b.) &4\x8c""5!7\x8d/#$+")("\'#&\'#'),I("%;N/M#28\"\"6879/>$;O.\" &\"/0$;S/'$8$:\x8e$ )($'#(#'#(\"'#&'#"),I("%;N/d#28\"\"6879/U$;O.\" &\"/G$;S/>$;_/5$;l.\" &\"/'$8&:\x8f& )(&'#(%'#($'#(#'#(\"'#&'#"),I('%3\x90""5$7\x91.) &3\x92""5#7\x93/\' 8!:\x94!! )'),I('%;P/]#%28""6879/,#;R/#$+")("\'#&\'#." &"/6$2:""6:7;/\'$8#:\x95# )(#\'#("\'#&\'#'),I("$;+.) &;-.# &;Q/2#0/*;+.) &;-.# &;Q&&&#"),I('2<""6<7=.q &2>""6>7?.e &2@""6@7A.Y &2B""6B7C.M &2D""6D7E.A &22""6273.5 &26""6677.) &24""6475'),I('%$;+._ &;-.Y &2<""6<7=.M &2>""6>7?.A &2@""6@7A.5 &2B""6B7C.) &2D""6D7E0e*;+._ &;-.Y &2<""6<7=.M &2>""6>7?.A &2@""6@7A.5 &2B""6B7C.) &2D""6D7E&/& 8!:\x96! )'),I('%;T/J#%28""6879/,#;^/#$+")("\'#&\'#." &"/#$+")("\'#&\'#'),I("%;U.) &;\\.# &;X/& 8!:\x97! )"),I('%$%;V/2#2J""6J7K/#$+")("\'#&\'#0<*%;V/2#2J""6J7K/#$+")("\'#&\'#&/D#;W/;$2J""6J7K." &"/\'$8#:\x98# )(#\'#("\'#&\'#'),I('$4\x99""5!7\x9a/,#0)*4\x99""5!7\x9a&&&#'),I('%4$""5!7%/?#$4\x9b""5!7\x9c0)*4\x9b""5!7\x9c&/#$+")("\'#&\'#'),I('%2l""6l7m/?#;Y/6$2n""6n7o/\'$8#:\x9d# )(#\'#("\'#&\'#'),I('%%;Z/\xb3#28""6879/\xa4$;Z/\x9b$28""6879/\x8c$;Z/\x83$28""6879/t$;Z/k$28""6879/\\$;Z/S$28""6879/D$;Z/;$28""6879/,$;[/#$+-)(-\'#(,\'#(+\'#(*\'#()\'#((\'#(\'\'#(&\'#(%\'#($\'#(#\'#("\'#&\'#.\u0790 &%2\x9e""6\x9e7\x9f/\xa4#;Z/\x9b$28""6879/\x8c$;Z/\x83$28""6879/t$;Z/k$28""6879/\\$;Z/S$28""6879/D$;Z/;$28""6879/,$;[/#$+,)(,\'#(+\'#(*\'#()\'#((\'#(\'\'#(&\'#(%\'#($\'#(#\'#("\'#&\'#.\u06f9 &%2\x9e""6\x9e7\x9f/\x8c#;Z/\x83$28""6879/t$;Z/k$28""6879/\\$;Z/S$28""6879/D$;Z/;$28""6879/,$;[/#$+*)(*\'#()\'#((\'#(\'\'#(&\'#(%\'#($\'#(#\'#("\'#&\'#.\u067a &%2\x9e""6\x9e7\x9f/t#;Z/k$28""6879/\\$;Z/S$28""6879/D$;Z/;$28""6879/,$;[/#$+()((\'#(\'\'#(&\'#(%\'#($\'#(#\'#("\'#&\'#.\u0613 &%2\x9e""6\x9e7\x9f/\\#;Z/S$28""6879/D$;Z/;$28""6879/,$;[/#$+&)(&\'#(%\'#($\'#(#\'#("\'#&\'#.\u05c4 &%2\x9e""6\x9e7\x9f/D#;Z/;$28""6879/,$;[/#$+$)($\'#(#\'#("\'#&\'#.\u058d &%2\x9e""6\x9e7\x9f/,#;[/#$+")("\'#&\'#.\u056e &%2\x9e""6\x9e7\x9f/,#;Z/#$+")("\'#&\'#.\u054f &%;Z/\x9b#2\x9e""6\x9e7\x9f/\x8c$;Z/\x83$28""6879/t$;Z/k$28""6879/\\$;Z/S$28""6879/D$;Z/;$28""6879/,$;[/#$++)(+\'#(*\'#()\'#((\'#(\'\'#(&\'#(%\'#($\'#(#\'#("\'#&\'#.\u04c7 &%;Z/\xaa#%28""6879/,#;Z/#$+")("\'#&\'#." &"/\x83$2\x9e""6\x9e7\x9f/t$;Z/k$28""6879/\\$;Z/S$28""6879/D$;Z/;$28""6879/,$;[/#$+*)(*\'#()\'#((\'#(\'\'#(&\'#(%\'#($\'#(#\'#("\'#&\'#.\u0430 &%;Z/\xb9#%28""6879/,#;Z/#$+")("\'#&\'#." &"/\x92$%28""6879/,#;Z/#$+")("\'#&\'#." &"/k$2\x9e""6\x9e7\x9f/\\$;Z/S$28""6879/D$;Z/;$28""6879/,$;[/#$+))()\'#((\'#(\'\'#(&\'#(%\'#($\'#(#\'#("\'#&\'#.\u038a &%;Z/\xc8#%28""6879/,#;Z/#$+")("\'#&\'#." &"/\xa1$%28""6879/,#;Z/#$+")("\'#&\'#." &"/z$%28""6879/,#;Z/#$+")("\'#&\'#." &"/S$2\x9e""6\x9e7\x9f/D$;Z/;$28""6879/,$;[/#$+()((\'#(\'\'#(&\'#(%\'#($\'#(#\'#("\'#&\'#.\u02d5 &%;Z/\xd7#%28""6879/,#;Z/#$+")("\'#&\'#." &"/\xb0$%28""6879/,#;Z/#$+")("\'#&\'#." &"/\x89$%28""6879/,#;Z/#$+")("\'#&\'#." &"/b$%28""6879/,#;Z/#$+")("\'#&\'#." &"/;$2\x9e""6\x9e7\x9f/,$;[/#$+\')(\'\'#(&\'#(%\'#($\'#(#\'#("\'#&\'#.\u0211 &%;Z/\xfe#%28""6879/,#;Z/#$+")("\'#&\'#." &"/\xd7$%28""6879/,#;Z/#$+")("\'#&\'#." &"/\xb0$%28""6879/,#;Z/#$+")("\'#&\'#." &"/\x89$%28""6879/,#;Z/#$+")("\'#&\'#." &"/b$%28""6879/,#;Z/#$+")("\'#&\'#." &"/;$2\x9e""6\x9e7\x9f/,$;Z/#$+()((\'#(\'\'#(&\'#(%\'#($\'#(#\'#("\'#&\'#.\u0126 &%;Z/\u011c#%28""6879/,#;Z/#$+")("\'#&\'#." &"/\xf5$%28""6879/,#;Z/#$+")("\'#&\'#." &"/\xce$%28""6879/,#;Z/#$+")("\'#&\'#." &"/\xa7$%28""6879/,#;Z/#$+")("\'#&\'#." &"/\x80$%28""6879/,#;Z/#$+")("\'#&\'#." &"/Y$%28""6879/,#;Z/#$+")("\'#&\'#." &"/2$2\x9e""6\x9e7\x9f/#$+()((\'#(\'\'#(&\'#(%\'#($\'#(#\'#("\'#&\'#/& 8!:\xa0! )'),I('%;#/M#;#." &"/?$;#." &"/1$;#." &"/#$+$)($\'#(#\'#("\'#&\'#'),I("%;Z/;#28\"\"6879/,$;Z/#$+#)(#'#(\"'#&'#.# &;\\"),I("%;]/o#2J\"\"6J7K/`$;]/W$2J\"\"6J7K/H$;]/?$2J\"\"6J7K/0$;]/'$8':\xa1' )(''#(&'#(%'#($'#(#'#(\"'#&'#"),I('%2\xa2""6\xa27\xa3/2#4\xa4""5!7\xa5/#$+")("\'#&\'#.\x98 &%2\xa6""6\xa67\xa7/;#4\xa8""5!7\xa9/,$;!/#$+#)(#\'#("\'#&\'#.j &%2\xaa""6\xaa7\xab/5#;!/,$;!/#$+#)(#\'#("\'#&\'#.B &%4\xac""5!7\xad/,#;!/#$+")("\'#&\'#.# &;!'),I('%%;!." &"/[#;!." &"/M$;!." &"/?$;!." &"/1$;!." &"/#$+%)(%\'#($\'#(#\'#("\'#&\'#/\' 8!:\xae!! )'),I('$%22""6273/,#;`/#$+")("\'#&\'#0<*%22""6273/,#;`/#$+")("\'#&\'#&'),I(";a.A &;b.; &;c.5 &;d./ &;e.) &;f.# &;g"),I('%3\xaf""5*7\xb0/a#3\xb1""5#7\xb2.G &3\xb3""5#7\xb4.; &3\xb5""5$7\xb6./ &3\xb7""5#7\xb8.# &;6/($8":\xb9"! )("\'#&\'#'),I('%3\xba""5%7\xbb/I#3\xbc""5%7\xbd./ &3\xbe""5"7\xbf.# &;6/($8":\xc0"! )("\'#&\'#'),I('%3\xc1""5\'7\xc2/1#;\x90/($8":\xc3"! )("\'#&\'#'),I('%3\xc4""5$7\xc5/1#;\xf0/($8":\xc6"! )("\'#&\'#'),I('%3\xc7""5&7\xc8/1#;T/($8":\xc9"! )("\'#&\'#'),I('%3\xca""5"7\xcb/N#%2>""6>7?/,#;6/#$+")("\'#&\'#." &"/\'$8":\xcc" )("\'#&\'#'),I('%;h/P#%2>""6>7?/,#;i/#$+")("\'#&\'#." &"/)$8":\xcd""! )("\'#&\'#'),I('%$;j/�#*;j&&&#/"!&,)'),I('%$;j/�#*;j&&&#/"!&,)'),I(";k.) &;+.# &;-"),I('2l""6l7m.e &2n""6n7o.Y &24""6475.M &28""6879.A &2<""6<7=.5 &2@""6@7A.) &2B""6B7C'),I('%26""6677/n#;m/e$$%2<""6<7=/,#;m/#$+")("\'#&\'#0<*%2<""6<7=/,#;m/#$+")("\'#&\'#&/#$+#)(#\'#("\'#&\'#'),I('%;n/A#2>""6>7?/2$;o/)$8#:\xce#"" )(#\'#("\'#&\'#'),I("$;p.) &;+.# &;-/2#0/*;p.) &;+.# &;-&&&#"),I("$;p.) &;+.# &;-0/*;p.) &;+.# &;-&"),I('2l""6l7m.e &2n""6n7o.Y &24""6475.M &26""6677.A &28""6879.5 &2@""6@7A.) &2B""6B7C'),I(";\x91.# &;r"),I("%;\x90/G#;'/>$;s/5$;'/,$;\x84/#$+%)(%'#($'#(#'#(\"'#&'#"),I(";M.# &;t"),I("%;\x7f/E#28\"\"6879/6$;u.# &;x/'$8#:\xcf# )(#'#(\"'#&'#"),I('%;v.# &;w/J#%26""6677/,#;\x83/#$+")("\'#&\'#." &"/#$+")("\'#&\'#'),I('%2\xd0""6\xd07\xd1/:#;\x80/1$;w." &"/#$+#)(#\'#("\'#&\'#'),I('%24""6475/,#;{/#$+")("\'#&\'#'),I("%;z/3#$;y0#*;y&/#$+\")(\"'#&'#"),I(";*.) &;+.# &;-"),I(';+.\x8f &;-.\x89 &22""6273.} &26""6677.q &28""6879.e &2:""6:7;.Y &2<""6<7=.M &2>""6>7?.A &2@""6@7A.5 &2B""6B7C.) &2D""6D7E'),I('%;|/e#$%24""6475/,#;|/#$+")("\'#&\'#0<*%24""6475/,#;|/#$+")("\'#&\'#&/#$+")("\'#&\'#'),I('%$;~0#*;~&/e#$%22""6273/,#;}/#$+")("\'#&\'#0<*%22""6273/,#;}/#$+")("\'#&\'#&/#$+")("\'#&\'#'),I("$;~0#*;~&"),I(';+.w &;-.q &28""6879.e &2:""6:7;.Y &2<""6<7=.M &2>""6>7?.A &2@""6@7A.5 &2B""6B7C.) &2D""6D7E'),I('%%;"/\x87#$;".G &;!.A &2@""6@7A.5 &2F""6F7G.) &2J""6J7K0M*;".G &;!.A &2@""6@7A.5 &2F""6F7G.) &2J""6J7K&/#$+")("\'#&\'#/& 8!:\xd2! )'),I(";\x81.# &;\x82"),I('%%;O/2#2:""6:7;/#$+")("\'#&\'#." &"/,#;S/#$+")("\'#&\'#." &"'),I('$;+.\x83 &;-.} &2B""6B7C.q &2D""6D7E.e &22""6273.Y &28""6879.M &2:""6:7;.A &2<""6<7=.5 &2>""6>7?.) &2@""6@7A/\x8c#0\x89*;+.\x83 &;-.} &2B""6B7C.q &2D""6D7E.e &22""6273.Y &28""6879.M &2:""6:7;.A &2<""6<7=.5 &2>""6>7?.) &2@""6@7A&&&#'),I("$;y0#*;y&"),I('%3\x92""5#7\xd3/q#24""6475/b$$;!/�#*;!&&&#/L$2J""6J7K/=$$;!/�#*;!&&&#/\'$8%:\xd4% )(%\'#($\'#(#\'#("\'#&\'#'),I('2\xd5""6\xd57\xd6'),I('2\xd7""6\xd77\xd8'),I('2\xd9""6\xd97\xda'),I('2\xdb""6\xdb7\xdc'),I('2\xdd""6\xdd7\xde'),I('2\xdf""6\xdf7\xe0'),I('2\xe1""6\xe17\xe2'),I('2\xe3""6\xe37\xe4'),I('2\xe5""6\xe57\xe6'),I('2\xe7""6\xe77\xe8'),I('2\xe9""6\xe97\xea'),I("%;\x85.Y &;\x86.S &;\x88.M &;\x89.G &;\x8a.A &;\x8b.; &;\x8c.5 &;\x8f./ &;\x8d.) &;\x8e.# &;6/& 8!:\xeb! )"),I("%;\x84/G#;'/>$;\x92/5$;'/,$;\x94/#$+%)(%'#($'#(#'#(\"'#&'#"),I("%;\x93/' 8!:\xec!! )"),I("%;!/5#;!/,$;!/#$+#)(#'#(\"'#&'#"),I("%$;*.A &;+.; &;-.5 &;3./ &;4.) &;'.# &;(0G*;*.A &;+.; &;-.5 &;3./ &;4.) &;'.# &;(&/& 8!:\xed! )"),I("%;\xb6/Y#$%;A/,#;\xb6/#$+\")(\"'#&'#06*%;A/,#;\xb6/#$+\")(\"'#&'#&/#$+\")(\"'#&'#"),I('%;9/N#%2:""6:7;/,#;9/#$+")("\'#&\'#." &"/\'$8":\xee" )("\'#&\'#'),I("%;:.c &%;\x98/Y#$%;A/,#;\x98/#$+\")(\"'#&'#06*%;A/,#;\x98/#$+\")(\"'#&'#&/#$+\")(\"'#&'#/& 8!:\xef! )"),I("%;L.# &;\x99/]#$%;B/,#;\x9b/#$+\")(\"'#&'#06*%;B/,#;\x9b/#$+\")(\"'#&'#&/'$8\":\xf0\" )(\"'#&'#"),I("%;\x9a.\" &\"/>#;@/5$;M/,$;?/#$+$)($'#(#'#(\"'#&'#"),I("%%;6/Y#$%;./,#;6/#$+\")(\"'#&'#06*%;./,#;6/#$+\")(\"'#&'#&/#$+\")(\"'#&'#.# &;H/' 8!:\xf1!! )"),I(";\x9c.) &;\x9d.# &;\xa0"),I("%3\xf2\"\"5!7\xf3/:#;$;\xcf/5$;./,$;\x90/#$+%)(%'#($'#(#'#(\"'#&'#"),I("%$;!/�#*;!&&&#/' 8!:\u014b!! )"),I("%;\xd1/]#$%;A/,#;\xd1/#$+\")(\"'#&'#06*%;A/,#;\xd1/#$+\")(\"'#&'#&/'$8\":\u014c\" )(\"'#&'#"),I("%;\x99/]#$%;B/,#;\xa0/#$+\")(\"'#&'#06*%;B/,#;\xa0/#$+\")(\"'#&'#&/'$8\":\u014d\" )(\"'#&'#"),I('%;L.O &;\x99.I &%;@." &"/:#;t/1$;?." &"/#$+#)(#\'#("\'#&\'#/]#$%;B/,#;\xa0/#$+")("\'#&\'#06*%;B/,#;\xa0/#$+")("\'#&\'#&/\'$8":\u014e" )("\'#&\'#'),I("%;\xd4/]#$%;B/,#;\xd5/#$+\")(\"'#&'#06*%;B/,#;\xd5/#$+\")(\"'#&'#&/'$8\":\u014f\" )(\"'#&'#"),I("%;\x96/& 8!:\u0150! )"),I('%3\u0151""5(7\u0152/:#;$;6/5$;;/,$;\xec/#$+%)(%'#($'#(#'#(\"'#&'#"),I('%3\x92""5#7\xd3.# &;6/\' 8!:\u018b!! )'),I('%3\xb1""5#7\u018c.G &3\xb3""5#7\u018d.; &3\xb7""5#7\u018e./ &3\xb5""5$7\u018f.# &;6/\' 8!:\u0190!! )'),I('%;\xee/D#%;C/,#;\xef/#$+")("\'#&\'#." &"/#$+")("\'#&\'#'),I("%;U.) &;\\.# &;X/& 8!:\u0191! )"),I('%%;!." &"/[#;!." &"/M$;!." &"/?$;!." &"/1$;!." &"/#$+%)(%\'#($\'#(#\'#("\'#&\'#/\' 8!:\u0192!! )'),I('%%;!/?#;!." &"/1$;!." &"/#$+#)(#\'#("\'#&\'#/\' 8!:\u0193!! )'),I(";\xbe"),I('%;\x9e/^#$%;B/,#;\xf3/#$+")("\'#&\'#06*%;B/,#;\xf3/#$+")("\'#&\'#&/($8":\u0194"!!)("\'#&\'#'),I(";\xf4.# &;\xa0"),I('%2\u0195""6\u01957\u0196/L#;""6>7?'),I('%;\u0100/b#28""6879/S$;\xfb/J$%2\u01a3""6\u01a37\u01a4/,#;\xec/#$+")("\'#&\'#." &"/#$+$)($\'#(#\'#("\'#&\'#'),I('%3\u01a5""5%7\u01a6.) &3\u01a7""5$7\u01a8/\' 8!:\u01a1!! )'),I('%3\xb1""5#7\xb2.6 &3\xb3""5#7\xb4.* &$;+0#*;+&/\' 8!:\u01a9!! )'),I("%;\u0104/\x87#2F\"\"6F7G/x$;\u0103/o$2F\"\"6F7G/`$;\u0103/W$2F\"\"6F7G/H$;\u0103/?$2F\"\"6F7G/0$;\u0105/'$8):\u01aa) )()'#(('#(''#(&'#(%'#($'#(#'#(\"'#&'#"),I("%;#/>#;#/5$;#/,$;#/#$+$)($'#(#'#(\"'#&'#"),I("%;\u0103/,#;\u0103/#$+\")(\"'#&'#"),I("%;\u0103/5#;\u0103/,$;\u0103/#$+#)(#'#(\"'#&'#"),I("%;q/T#$;m0#*;m&/D$%; /,#;\xf8/#$+\")(\"'#&'#.\" &\"/#$+#)(#'#(\"'#&'#"),I('%2\u01ab""6\u01ab7\u01ac.) &2\u01ad""6\u01ad7\u01ae/w#;0/n$;\u0108/e$$%;B/2#;\u0109.# &;\xa0/#$+")("\'#&\'#0<*%;B/2#;\u0109.# &;\xa0/#$+")("\'#&\'#&/#$+$)($\'#(#\'#("\'#&\'#'),I(";\x99.# &;L"),I("%2\u01af\"\"6\u01af7\u01b0/5#;g&&(g=c,u=[]),u.push(e))}function $(e,t,s){return new S(S.buildMessage(e,t),e,t,s)}function I(e){return e.split("").map((e=>e.charCodeAt(0)-32))}if(t.data={},l=function t(i){const r=a[i];let n=0;const d=[];let l=r.length;const g=[],u=[];let f;for(;;){for(;nc?(l=n+3+r[n+1],n+=3):(l=n+3+r[n+1]+r[n+2],n+=3+r[n+1]);break;case 18:g.push(l),d.push(n+4+r[n+2]+r[n+3]),e.substr(c,o[r[n+1]].length)===o[r[n+1]]?(l=n+4+r[n+2],n+=4):(l=n+4+r[n+2]+r[n+3],n+=4+r[n+2]);break;case 19:g.push(l),d.push(n+4+r[n+2]+r[n+3]),e.substr(c,o[r[n+1]].length).toLowerCase()===o[r[n+1]]?(l=n+4+r[n+2],n+=4):(l=n+4+r[n+2]+r[n+3],n+=4+r[n+2]);break;case 20:g.push(l),d.push(n+4+r[n+2]+r[n+3]),o[r[n+1]].test(e.charAt(c))?(l=n+4+r[n+2],n+=4):(l=n+4+r[n+2]+r[n+3],n+=4+r[n+2]);break;case 21:u.push(e.substr(c,r[n+1])),c+=r[n+1],n+=2;break;case 22:u.push(o[r[n+1]]),c+=o[r[n+1]].length,n+=2;break;case 23:u.push(s),0===p&&E(o[r[n+1]]),n+=2;break;case 24:h=u[u.length-1-r[n+1]],n+=2;break;case 25:h=c,n++;break;case 26:f=r.slice(n+4,n+4+r[n+3]).map((function(e){return u[u.length-1-e]})),u.splice(u.length-r[n+2],r[n+2],o[r[n+1]].apply(null,f)),n+=4+r[n+3];break;case 27:u.push(t(r[n+1])),n+=2;break;case 28:p++,n++;break;case 29:p--,n++;break;default:throw new Error("Invalid opcode: "+r[n]+".")}if(!(g.length>0))break;l=g.pop(),n=d.pop()}return u[0]}(n),l!==s&&c===e.length)return l;throw l!==s&&c=this.headers[e].length)return;const s=this.headers[e][t],i=s.raw;if(s.parsed)return s.parsed;const r=y.parse(i,e.replace(/-/g,"_"));return-1===r?void this.headers[e].splice(t,1):(s.parsed=r,r)}s(e,t=0){return this.parseHeader(e,t)}setHeader(e,t){this.headers[A(e)]=[{raw:t}]}toString(){return this.data}}class P extends H{constructor(){super()}}class x extends H{constructor(){super()}}class k{constructor(e,t,s,i,r,n,o){this.headers={},this.extraHeaders=[],this.options=k.getDefaultOptions(),r&&(this.options=Object.assign(Object.assign({},this.options),r),this.options.optionTags&&this.options.optionTags.length&&(this.options.optionTags=this.options.optionTags.slice()),this.options.routeSet&&this.options.routeSet.length&&(this.options.routeSet=this.options.routeSet.slice())),n&&n.length&&(this.extraHeaders=n.slice()),o&&(this.body={body:o.content,contentType:o.contentType}),this.method=e,this.ruri=t.clone(),this.fromURI=s.clone(),this.fromTag=this.options.fromTag?this.options.fromTag:C(),this.from=k.makeNameAddrHeader(this.fromURI,this.options.fromDisplayName,this.fromTag),this.toURI=i.clone(),this.toTag=this.options.toTag,this.to=k.makeNameAddrHeader(this.toURI,this.options.toDisplayName,this.toTag),this.callId=this.options.callId?this.options.callId:this.options.callIdPrefix+$(15),this.cseq=this.options.cseq,this.setHeader("route",this.options.routeSet),this.setHeader("via",""),this.setHeader("to",this.to.toString()),this.setHeader("from",this.from.toString()),this.setHeader("cseq",this.cseq+" "+this.method),this.setHeader("call-id",this.callId),this.setHeader("max-forwards","70")}static getDefaultOptions(){return{callId:"",callIdPrefix:"",cseq:1,toDisplayName:"",toTag:"",fromDisplayName:"",fromTag:"",forceRport:!1,hackViaTcp:!1,optionTags:["outbound"],routeSet:[],userAgentString:"sip.js",viaHost:""}}static makeNameAddrHeader(e,t,s){const i={};return s&&(i.tag=s),new v(e,t,i)}getHeader(e){const t=this.headers[A(e)];if(t){if(t[0])return t[0]}else{const t=new RegExp("^\\s*"+e+"\\s*:","i");for(const e of this.extraHeaders)if(t.test(e))return e.substring(e.indexOf(":")+1).trim()}}getHeaders(e){const t=[],s=this.headers[A(e)];if(s)for(const e of s)t.push(e);else{const s=new RegExp("^\\s*"+e+"\\s*:","i");for(const e of this.extraHeaders)s.test(e)&&t.push(e.substring(e.indexOf(":")+1).trim())}return t}hasHeader(e){if(this.headers[A(e)])return!0;{const t=new RegExp("^\\s*"+e+"\\s*:","i");for(const e of this.extraHeaders)if(t.test(e))return!0}return!1}setHeader(e,t){this.headers[A(e)]=t instanceof Array?t:[t]}setViaHeader(e,t){this.options.hackViaTcp&&(t="TCP");let s="SIP/2.0/"+t;s+=" "+this.options.viaHost+";branch="+e,this.options.forceRport&&(s+=";rport"),this.setHeader("via",s),this.branch=e}toString(){let e="";e+=this.method+" "+this.ruri.toRaw()+" SIP/2.0\r\n";for(const t in this.headers)if(this.headers[t])for(const s of this.headers[t])e+=t+": "+s+"\r\n";for(const t of this.extraHeaders)e+=t.trim()+"\r\n";return e+="Supported: "+this.options.optionTags.join(", ")+"\r\n",e+="User-Agent: "+this.options.userAgentString+"\r\n",this.body?"string"==typeof this.body?(e+="Content-Length: "+D(this.body)+"\r\n\r\n",e+=this.body):this.body.body&&this.body.contentType?(e+="Content-Type: "+this.body.contentType+"\r\n",e+="Content-Length: "+D(this.body.body)+"\r\n\r\n",e+=this.body.body):e+="Content-Length: 0\r\n\r\n":e+="Content-Length: 0\r\n\r\n",e}}function _(e){return"application/sdp"===e?"session":"render"}function q(e){const t="string"==typeof e?e:e.body,s="string"==typeof e?"application/sdp":e.contentType;return{contentDisposition:_(s),contentType:s,content:t}}function M(e){return!(!e||"string"!=typeof e.content||"string"!=typeof e.contentType||void 0!==e.contentDisposition)||"string"==typeof e.contentDisposition}function N(e){let t,s,i;if(e instanceof P&&e.body){const r=e.parseHeader("Content-Disposition");t=r?r.type:void 0,s=e.parseHeader("Content-Type"),i=e.body}if(e instanceof x&&e.body){const r=e.parseHeader("Content-Disposition");t=r?r.type:void 0,s=e.parseHeader("Content-Type"),i=e.body}if(e instanceof k&&e.body)if(t=e.getHeader("Content-Disposition"),s=e.getHeader("Content-Type"),"string"==typeof e.body){if(!s)throw new Error("Header content type header does not equal body content type.");i=e.body}else{if(s&&s!==e.body.contentType)throw new Error("Header content type header does not equal body content type.");s=e.body.contentType,i=e.body.body}if(M(e)&&(t=e.contentDisposition,s=e.contentType,i=e.content),i){if(s&&!t&&(t=_(s)),!t)throw new Error("Content disposition undefined.");if(!s)throw new Error("Content type undefined.");return{contentDisposition:t,contentType:s,content:i}}}var O,U;!function(e){e.Initial="Initial",e.Early="Early",e.AckWait="AckWait",e.Confirmed="Confirmed",e.Terminated="Terminated"}(O=O||(O={})),function(e){e.Initial="Initial",e.HaveLocalOffer="HaveLocalOffer",e.HaveRemoteOffer="HaveRemoteOffer",e.Stable="Stable",e.Closed="Closed"}(U=U||(U={}));const j=500,F=5e3,L={T1:j,T2:4e3,T4:F,TIMER_B:32e3,TIMER_D:0,TIMER_F:32e3,TIMER_H:32e3,TIMER_I:0,TIMER_J:0,TIMER_K:0,TIMER_L:32e3,TIMER_M:32e3,TIMER_N:32e3,PROVISIONAL_RESPONSE_INTERVAL:6e4};class B extends n{constructor(e){super(e||"Transaction state error.")}}var G;!function(e){e.ACK="ACK",e.BYE="BYE",e.CANCEL="CANCEL",e.INFO="INFO",e.INVITE="INVITE",e.MESSAGE="MESSAGE",e.NOTIFY="NOTIFY",e.OPTIONS="OPTIONS",e.REGISTER="REGISTER",e.UPDATE="UPDATE",e.SUBSCRIBE="SUBSCRIBE",e.PUBLISH="PUBLISH",e.REFER="REFER",e.PRACK="PRACK"}(G=G||(G={}));const V=[G.ACK,G.BYE,G.CANCEL,G.INFO,G.INVITE,G.MESSAGE,G.NOTIFY,G.OPTIONS,G.PRACK,G.REFER,G.REGISTER,G.SUBSCRIBE];class W{constructor(e){this.incomingMessageRequest=e}get request(){return this.incomingMessageRequest.message}accept(e){return this.incomingMessageRequest.accept(e),Promise.resolve()}reject(e){return this.incomingMessageRequest.reject(e),Promise.resolve()}}class K{constructor(e){this.incomingNotifyRequest=e}get request(){return this.incomingNotifyRequest.message}accept(e){return this.incomingNotifyRequest.accept(e),Promise.resolve()}reject(e){return this.incomingNotifyRequest.reject(e),Promise.resolve()}}class Y{constructor(e,t){this.incomingReferRequest=e,this.session=t}get referTo(){const e=this.incomingReferRequest.message.parseHeader("refer-to");if(!(e instanceof v))throw new Error("Failed to parse Refer-To header.");return e}get referredBy(){return this.incomingReferRequest.message.getHeader("referred-by")}get replaces(){const e=this.referTo.uri.getHeader("replaces");return e instanceof Array?e[0]:e}get request(){return this.incomingReferRequest.message}accept(e={statusCode:202}){return this.incomingReferRequest.accept(e),Promise.resolve()}reject(e){return this.incomingReferRequest.reject(e),Promise.resolve()}makeInviter(e){if(this.inviter)return this.inviter;const t=this.referTo.uri.clone();t.clearHeaders();const s=((e=e||{}).extraHeaders||[]).slice(),i=this.replaces;i&&s.push("Replaces: "+decodeURIComponent(i));const r=this.referredBy;return r&&s.push("Referred-By: "+r),e.extraHeaders=s,this.inviter=this.session.userAgent._makeInviter(t,e),this.inviter._referred=this.session,this.session._referral=this.inviter,this.inviter}}var Z,J;!function(e){e.Initial="Initial",e.Establishing="Establishing",e.Established="Established",e.Terminating="Terminating",e.Terminated="Terminated"}(Z=Z||(Z={}));class z{constructor(e,t={}){this.pendingReinvite=!1,this.pendingReinviteAck=!1,this._state=Z.Initial,this.delegate=t.delegate,this._stateEventEmitter=new p,this._userAgent=e}dispose(){switch(this.logger.log(`Session ${this.id} in state ${this._state} is being disposed`),delete this.userAgent._sessions[this.id],this._sessionDescriptionHandler&&this._sessionDescriptionHandler.close(),this.state){case Z.Initial:case Z.Establishing:break;case Z.Established:return new Promise((e=>{this._bye({onAccept:()=>e(),onRedirect:()=>e(),onReject:()=>e()})}));case Z.Terminating:case Z.Terminated:break;default:throw new Error("Unknown state.")}return Promise.resolve()}get assertedIdentity(){return this._assertedIdentity}get dialog(){return this._dialog}get id(){return this._id}get replacee(){return this._replacee}get sessionDescriptionHandler(){return this._sessionDescriptionHandler}get sessionDescriptionHandlerFactory(){return this.userAgent.configuration.sessionDescriptionHandlerFactory}get sessionDescriptionHandlerModifiers(){return this._sessionDescriptionHandlerModifiers||[]}set sessionDescriptionHandlerModifiers(e){this._sessionDescriptionHandlerModifiers=e.slice()}get sessionDescriptionHandlerOptions(){return this._sessionDescriptionHandlerOptions||{}}set sessionDescriptionHandlerOptions(e){this._sessionDescriptionHandlerOptions=Object.assign({},e)}get sessionDescriptionHandlerModifiersReInvite(){return this._sessionDescriptionHandlerModifiersReInvite||[]}set sessionDescriptionHandlerModifiersReInvite(e){this._sessionDescriptionHandlerModifiersReInvite=e.slice()}get sessionDescriptionHandlerOptionsReInvite(){return this._sessionDescriptionHandlerOptionsReInvite||{}}set sessionDescriptionHandlerOptionsReInvite(e){this._sessionDescriptionHandlerOptionsReInvite=Object.assign({},e)}get state(){return this._state}get stateChange(){return this._stateEventEmitter}get userAgent(){return this._userAgent}bye(e={}){let t="Session.bye() may only be called if established session.";switch(this.state){case Z.Initial:"function"==typeof this.cancel?(t+=" However Inviter.invite() has not yet been called.",t+=" Perhaps you should have called Inviter.cancel()?"):"function"==typeof this.reject&&(t+=" However Invitation.accept() has not yet been called.",t+=" Perhaps you should have called Invitation.reject()?");break;case Z.Establishing:"function"==typeof this.cancel?(t+=" However a dialog does not yet exist.",t+=" Perhaps you should have called Inviter.cancel()?"):"function"==typeof this.reject&&(t+=" However Invitation.accept() has not yet been called (or not yet resolved).",t+=" Perhaps you should have called Invitation.reject()?");break;case Z.Established:{const t=e.requestDelegate,s=this.copyRequestOptions(e.requestOptions);return this._bye(t,s)}case Z.Terminating:t+=" However this session is already terminating.","function"==typeof this.cancel?t+=" Perhaps you have already called Inviter.cancel()?":"function"==typeof this.reject&&(t+=" Perhaps you have already called Session.bye()?");break;case Z.Terminated:t+=" However this session is already terminated.";break;default:throw new Error("Unknown state")}return this.logger.error(t),Promise.reject(new Error(`Invalid session state ${this.state}`))}info(e={}){if(this.state!==Z.Established){const e="Session.info() may only be called if established session.";return this.logger.error(e),Promise.reject(new Error(`Invalid session state ${this.state}`))}const t=e.requestDelegate,s=this.copyRequestOptions(e.requestOptions);return this._info(t,s)}invite(e={}){if(this.logger.log("Session.invite"),this.state!==Z.Established)return Promise.reject(new Error(`Invalid session state ${this.state}`));if(this.pendingReinvite)return Promise.reject(new a("Reinvite in progress. Please wait until complete, then try again."));this.pendingReinvite=!0,e.sessionDescriptionHandlerModifiers&&(this.sessionDescriptionHandlerModifiersReInvite=e.sessionDescriptionHandlerModifiers),e.sessionDescriptionHandlerOptions&&(this.sessionDescriptionHandlerOptionsReInvite=e.sessionDescriptionHandlerOptions);const t={onAccept:t=>{const s=N(t.message);if(!s)return this.logger.error("Received 2xx response to re-INVITE without a session description"),this.ackAndBye(t,400,"Missing session description"),this.stateTransition(Z.Terminated),void(this.pendingReinvite=!1);if(e.withoutSdp){const i={sessionDescriptionHandlerOptions:this.sessionDescriptionHandlerOptionsReInvite,sessionDescriptionHandlerModifiers:this.sessionDescriptionHandlerModifiersReInvite};this.setOfferAndGetAnswer(s,i).then((e=>{t.ack({body:e})})).catch((e=>{this.logger.error("Failed to handle offer in 2xx response to re-INVITE"),this.logger.error(e.message),this.state===Z.Terminated?t.ack():(this.ackAndBye(t,488,"Bad Media Description"),this.stateTransition(Z.Terminated))})).then((()=>{this.pendingReinvite=!1,e.requestDelegate&&e.requestDelegate.onAccept&&e.requestDelegate.onAccept(t)}))}else{const i={sessionDescriptionHandlerOptions:this.sessionDescriptionHandlerOptionsReInvite,sessionDescriptionHandlerModifiers:this.sessionDescriptionHandlerModifiersReInvite};this.setAnswer(s,i).then((()=>{t.ack()})).catch((e=>{this.logger.error("Failed to handle answer in 2xx response to re-INVITE"),this.logger.error(e.message),this.state!==Z.Terminated?(this.ackAndBye(t,488,"Bad Media Description"),this.stateTransition(Z.Terminated)):t.ack()})).then((()=>{this.pendingReinvite=!1,e.requestDelegate&&e.requestDelegate.onAccept&&e.requestDelegate.onAccept(t)}))}},onProgress:e=>{},onRedirect:e=>{},onReject:t=>{this.logger.warn("Received a non-2xx response to re-INVITE"),this.pendingReinvite=!1,e.withoutSdp?e.requestDelegate&&e.requestDelegate.onReject&&e.requestDelegate.onReject(t):this.rollbackOffer().catch((e=>{if(this.logger.error("Failed to rollback offer on non-2xx response to re-INVITE"),this.logger.error(e.message),this.state!==Z.Terminated){if(!this.dialog)throw new Error("Dialog undefined.");const e=[];e.push("Reason: "+this.getReasonHeaderValue(500,"Internal Server Error")),this.dialog.bye(void 0,{extraHeaders:e}),this.stateTransition(Z.Terminated)}})).then((()=>{e.requestDelegate&&e.requestDelegate.onReject&&e.requestDelegate.onReject(t)}))},onTrying:e=>{}},s=e.requestOptions||{};if(s.extraHeaders=(s.extraHeaders||[]).slice(),s.extraHeaders.push("Allow: "+V.toString()),s.extraHeaders.push("Contact: "+this._contact),e.withoutSdp){if(!this.dialog)throw this.pendingReinvite=!1,new Error("Dialog undefined.");return Promise.resolve(this.dialog.invite(t,s))}const i={sessionDescriptionHandlerOptions:this.sessionDescriptionHandlerOptionsReInvite,sessionDescriptionHandlerModifiers:this.sessionDescriptionHandlerModifiersReInvite};return this.getOffer(i).then((e=>{if(!this.dialog)throw this.pendingReinvite=!1,new Error("Dialog undefined.");return s.body=e,this.dialog.invite(t,s)})).catch((e=>{throw this.logger.error(e.message),this.logger.error("Failed to send re-INVITE"),this.pendingReinvite=!1,e}))}message(e={}){if(this.state!==Z.Established){const e="Session.message() may only be called if established session.";return this.logger.error(e),Promise.reject(new Error(`Invalid session state ${this.state}`))}const t=e.requestDelegate,s=this.copyRequestOptions(e.requestOptions);return this._message(t,s)}refer(e,t={}){if(this.state!==Z.Established){const e="Session.refer() may only be called if established session.";return this.logger.error(e),Promise.reject(new Error(`Invalid session state ${this.state}`))}if(e instanceof z&&!e.dialog){const e="Session.refer() may only be called with session which is established. You are perhaps attempting to attended transfer to a target for which there is not dialog yet established. Perhaps you are attempting a 'semi-attended' tansfer? Regardless, this is not supported. The recommended approached is to check to see if the target Session is in the Established state before calling refer(); if the state is not Established you may proceed by falling back using a URI as the target (blind transfer).";return this.logger.error(e),Promise.reject(new Error(`Invalid session state ${this.state}`))}const s=t.requestDelegate,i=this.copyRequestOptions(t.requestOptions);return i.extraHeaders=i.extraHeaders?i.extraHeaders.concat(this.referExtraHeaders(this.referToString(e))):this.referExtraHeaders(this.referToString(e)),this._refer(t.onNotify,s,i)}_bye(e,t){if(!this.dialog)return Promise.reject(new Error("Session dialog undefined."));const s=this.dialog;switch(s.sessionState){case O.Initial:case O.Early:throw new Error(`Invalid dialog state ${s.sessionState}`);case O.AckWait:return this.stateTransition(Z.Terminating),new Promise((i=>{s.delegate={onAck:()=>{const r=s.bye(e,t);return this.stateTransition(Z.Terminated),i(r),Promise.resolve()},onAckTimeout:()=>{const r=s.bye(e,t);this.stateTransition(Z.Terminated),i(r)}}}));case O.Confirmed:{const i=s.bye(e,t);return this.stateTransition(Z.Terminated),Promise.resolve(i)}case O.Terminated:throw new Error(`Invalid dialog state ${s.sessionState}`);default:throw new Error("Unrecognized state.")}}_info(e,t){return this.dialog?Promise.resolve(this.dialog.info(e,t)):Promise.reject(new Error("Session dialog undefined."))}_message(e,t){return this.dialog?Promise.resolve(this.dialog.message(e,t)):Promise.reject(new Error("Session dialog undefined."))}_refer(e,t,s){return this.dialog?(this.onNotify=e,Promise.resolve(this.dialog.refer(t,s))):Promise.reject(new Error("Session dialog undefined."))}ackAndBye(e,t,s){e.ack();const i=[];t&&i.push("Reason: "+this.getReasonHeaderValue(t,s)),e.session.bye(void 0,{extraHeaders:i})}onAckRequest(e){if(this.logger.log("Session.onAckRequest"),this.state!==Z.Established&&this.state!==Z.Terminating)return this.logger.error(`ACK received while in state ${this.state}, dropping request`),Promise.resolve();const t=this.dialog;if(!t)throw new Error("Dialog undefined.");const s={sessionDescriptionHandlerOptions:this.pendingReinviteAck?this.sessionDescriptionHandlerOptionsReInvite:this.sessionDescriptionHandlerOptions,sessionDescriptionHandlerModifiers:this.pendingReinviteAck?this._sessionDescriptionHandlerModifiersReInvite:this._sessionDescriptionHandlerModifiers};if(this.delegate&&this.delegate.onAck){const t=new l(e);this.delegate.onAck(t)}switch(this.pendingReinviteAck=!1,t.signalingState){case U.Initial:{this.logger.error(`Invalid signaling state ${t.signalingState}.`);const e=["Reason: "+this.getReasonHeaderValue(488,"Bad Media Description")];return t.bye(void 0,{extraHeaders:e}),this.stateTransition(Z.Terminated),Promise.resolve()}case U.Stable:{const i=N(e.message);return i?"render"===i.contentDisposition?(this._renderbody=i.content,this._rendertype=i.contentType,Promise.resolve()):"session"!==i.contentDisposition?Promise.resolve():this.setAnswer(i,s).catch((e=>{this.logger.error(e.message);const s=["Reason: "+this.getReasonHeaderValue(488,"Bad Media Description")];t.bye(void 0,{extraHeaders:s}),this.stateTransition(Z.Terminated)})):Promise.resolve()}case U.HaveLocalOffer:{this.logger.error(`Invalid signaling state ${t.signalingState}.`);const e=["Reason: "+this.getReasonHeaderValue(488,"Bad Media Description")];return t.bye(void 0,{extraHeaders:e}),this.stateTransition(Z.Terminated),Promise.resolve()}case U.HaveRemoteOffer:{this.logger.error(`Invalid signaling state ${t.signalingState}.`);const e=["Reason: "+this.getReasonHeaderValue(488,"Bad Media Description")];return t.bye(void 0,{extraHeaders:e}),this.stateTransition(Z.Terminated),Promise.resolve()}case U.Closed:default:throw new Error(`Invalid signaling state ${t.signalingState}.`)}}onByeRequest(e){if(this.logger.log("Session.onByeRequest"),this.state===Z.Established){if(this.delegate&&this.delegate.onBye){const t=new g(e);this.delegate.onBye(t)}else e.accept();this.stateTransition(Z.Terminated)}else this.logger.error(`BYE received while in state ${this.state}, dropping request`)}onInfoRequest(e){if(this.logger.log("Session.onInfoRequest"),this.state===Z.Established)if(this.delegate&&this.delegate.onInfo){const t=new f(e);this.delegate.onInfo(t)}else e.accept();else this.logger.error(`INFO received while in state ${this.state}, dropping request`)}onInviteRequest(e){if(this.logger.log("Session.onInviteRequest"),this.state!==Z.Established)return void this.logger.error(`INVITE received while in state ${this.state}, dropping request`);this.pendingReinviteAck=!0;const t=["Contact: "+this._contact];if(e.message.hasHeader("P-Asserted-Identity")){const t=e.message.getHeader("P-Asserted-Identity");if(!t)throw new Error("Header undefined.");this._assertedIdentity=y.nameAddrHeaderParse(t)}const s={sessionDescriptionHandlerOptions:this.sessionDescriptionHandlerOptionsReInvite,sessionDescriptionHandlerModifiers:this.sessionDescriptionHandlerModifiersReInvite};this.generateResponseOfferAnswerInDialog(s).then((s=>{const i=e.accept({statusCode:200,extraHeaders:t,body:s});this.delegate&&this.delegate.onInvite&&this.delegate.onInvite(e.message,i.message,200)})).catch((t=>{if(this.logger.error(t.message),this.logger.error("Failed to handle to re-INVITE request"),!this.dialog)throw new Error("Dialog undefined.");if(this.logger.error(this.dialog.signalingState),this.dialog.signalingState!==U.Stable)this.rollbackOffer().then((()=>{const t=e.reject({statusCode:488});this.delegate&&this.delegate.onInvite&&this.delegate.onInvite(e.message,t.message,488)})).catch((t=>{this.logger.error(t.message),this.logger.error("Failed to rollback offer on re-INVITE request");const s=e.reject({statusCode:488});if(this.state!==Z.Terminated){if(!this.dialog)throw new Error("Dialog undefined.");const e=[];e.push("Reason: "+this.getReasonHeaderValue(500,"Internal Server Error")),this.dialog.bye(void 0,{extraHeaders:e}),this.stateTransition(Z.Terminated)}this.delegate&&this.delegate.onInvite&&this.delegate.onInvite(e.message,s.message,488)}));else{const t=e.reject({statusCode:488});this.delegate&&this.delegate.onInvite&&this.delegate.onInvite(e.message,t.message,488)}}))}onMessageRequest(e){if(this.logger.log("Session.onMessageRequest"),this.state===Z.Established)if(this.delegate&&this.delegate.onMessage){const t=new W(e);this.delegate.onMessage(t)}else e.accept();else this.logger.error(`MESSAGE received while in state ${this.state}, dropping request`)}onNotifyRequest(e){if(this.logger.log("Session.onNotifyRequest"),this.state===Z.Established)if(this.onNotify){const t=new K(e);this.onNotify(t)}else if(this.delegate&&this.delegate.onNotify){const t=new K(e);this.delegate.onNotify(t)}else e.accept();else this.logger.error(`NOTIFY received while in state ${this.state}, dropping request`)}onPrackRequest(e){if(this.logger.log("Session.onPrackRequest"),this.state===Z.Established)throw new Error("Unimplemented.");this.logger.error(`PRACK received while in state ${this.state}, dropping request`)}onReferRequest(e){if(this.logger.log("Session.onReferRequest"),this.state!==Z.Established)return void this.logger.error(`REFER received while in state ${this.state}, dropping request`);if(!e.message.hasHeader("refer-to"))return this.logger.warn("Invalid REFER packet. A refer-to header is required. Rejecting."),void e.reject();const t=new Y(e,this);this.delegate&&this.delegate.onRefer?this.delegate.onRefer(t):(this.logger.log("No delegate available to handle REFER, automatically accepting and following."),t.accept().then((()=>t.makeInviter(this._referralInviterOptions).invite())).catch((e=>{this.logger.error(e.message)})))}generateResponseOfferAnswer(e,t){if(this.dialog)return this.generateResponseOfferAnswerInDialog(t);const s=N(e.message);return s&&"session"===s.contentDisposition?this.setOfferAndGetAnswer(s,t):this.getOffer(t)}generateResponseOfferAnswerInDialog(e){if(!this.dialog)throw new Error("Dialog undefined.");switch(this.dialog.signalingState){case U.Initial:return this.getOffer(e);case U.HaveLocalOffer:return Promise.resolve(void 0);case U.HaveRemoteOffer:if(!this.dialog.offer)throw new Error(`Session offer undefined in signaling state ${this.dialog.signalingState}.`);return this.setOfferAndGetAnswer(this.dialog.offer,e);case U.Stable:return this.state!==Z.Established?Promise.resolve(void 0):this.getOffer(e);case U.Closed:default:throw new Error(`Invalid signaling state ${this.dialog.signalingState}.`)}}getOffer(e){const t=this.setupSessionDescriptionHandler(),s=e.sessionDescriptionHandlerOptions,i=e.sessionDescriptionHandlerModifiers;try{return t.getDescription(s,i).then((e=>q(e))).catch((e=>{this.logger.error("Session.getOffer: SDH getDescription rejected...");const t=e instanceof Error?e:new Error("Session.getOffer unknown error.");throw this.logger.error(t.message),t}))}catch(e){this.logger.error("Session.getOffer: SDH getDescription threw...");const t=e instanceof Error?e:new Error(e);return this.logger.error(t.message),Promise.reject(t)}}rollbackOffer(){const e=this.setupSessionDescriptionHandler();if(void 0===e.rollbackDescription)return Promise.resolve();try{return e.rollbackDescription().catch((e=>{this.logger.error("Session.rollbackOffer: SDH rollbackDescription rejected...");const t=e instanceof Error?e:new Error("Session.rollbackOffer unknown error.");throw this.logger.error(t.message),t}))}catch(e){this.logger.error("Session.rollbackOffer: SDH rollbackDescription threw...");const t=e instanceof Error?e:new Error(e);return this.logger.error(t.message),Promise.reject(t)}}setAnswer(e,t){const s=this.setupSessionDescriptionHandler(),i=t.sessionDescriptionHandlerOptions,r=t.sessionDescriptionHandlerModifiers;try{if(!s.hasDescription(e.contentType))return Promise.reject(new o)}catch(e){this.logger.error("Session.setAnswer: SDH hasDescription threw...");const t=e instanceof Error?e:new Error(e);return this.logger.error(t.message),Promise.reject(t)}try{return s.setDescription(e.content,i,r).catch((e=>{this.logger.error("Session.setAnswer: SDH setDescription rejected...");const t=e instanceof Error?e:new Error("Session.setAnswer unknown error.");throw this.logger.error(t.message),t}))}catch(e){this.logger.error("Session.setAnswer: SDH setDescription threw...");const t=e instanceof Error?e:new Error(e);return this.logger.error(t.message),Promise.reject(t)}}setOfferAndGetAnswer(e,t){const s=this.setupSessionDescriptionHandler(),i=t.sessionDescriptionHandlerOptions,r=t.sessionDescriptionHandlerModifiers;try{if(!s.hasDescription(e.contentType))return Promise.reject(new o)}catch(e){this.logger.error("Session.setOfferAndGetAnswer: SDH hasDescription threw...");const t=e instanceof Error?e:new Error(e);return this.logger.error(t.message),Promise.reject(t)}try{return s.setDescription(e.content,i,r).then((()=>s.getDescription(i,r))).then((e=>q(e))).catch((e=>{this.logger.error("Session.setOfferAndGetAnswer: SDH setDescription or getDescription rejected...");const t=e instanceof Error?e:new Error("Session.setOfferAndGetAnswer unknown error.");throw this.logger.error(t.message),t}))}catch(e){this.logger.error("Session.setOfferAndGetAnswer: SDH setDescription or getDescription threw...");const t=e instanceof Error?e:new Error(e);return this.logger.error(t.message),Promise.reject(t)}}setSessionDescriptionHandler(e){if(this._sessionDescriptionHandler)throw new Error("Session description handler defined.");this._sessionDescriptionHandler=e}setupSessionDescriptionHandler(){var e;return this._sessionDescriptionHandler||(this._sessionDescriptionHandler=this.sessionDescriptionHandlerFactory(this,this.userAgent.configuration.sessionDescriptionHandlerFactoryOptions),(null===(e=this.delegate)||void 0===e?void 0:e.onSessionDescriptionHandler)&&this.delegate.onSessionDescriptionHandler(this._sessionDescriptionHandler,!1)),this._sessionDescriptionHandler}stateTransition(e){const t=()=>{throw new Error(`Invalid state transition from ${this._state} to ${e}`)};switch(this._state){case Z.Initial:e!==Z.Establishing&&e!==Z.Established&&e!==Z.Terminating&&e!==Z.Terminated&&t();break;case Z.Establishing:e!==Z.Established&&e!==Z.Terminating&&e!==Z.Terminated&&t();break;case Z.Established:e!==Z.Terminating&&e!==Z.Terminated&&t();break;case Z.Terminating:e!==Z.Terminated&&t();break;case Z.Terminated:t();break;default:throw new Error("Unrecognized state.")}this._state=e,this.logger.log(`Session ${this.id} transitioned to state ${this._state}`),this._stateEventEmitter.emit(this._state),e===Z.Terminated&&this.dispose()}copyRequestOptions(e={}){return{extraHeaders:e.extraHeaders?e.extraHeaders.slice():void 0,body:e.body?{contentDisposition:e.body.contentDisposition||"render",contentType:e.body.contentType||"text/plain",content:e.body.content||""}:void 0}}getReasonHeaderValue(e,t){const s=e;let i=I(e);return!i&&t&&(i=t),"SIP;cause="+s+';text="'+i+'"'}referExtraHeaders(e){const t=[];return t.push("Referred-By: <"+this.userAgent.configuration.uri+">"),t.push("Contact: "+this._contact),t.push("Allow: "+["ACK","CANCEL","INVITE","MESSAGE","BYE","OPTIONS","INFO","NOTIFY","REFER"].toString()),t.push("Refer-To: "+e),t}referToString(e){let t;if(e instanceof w)t=e.toString();else{if(!e.dialog)throw new Error("Dialog undefined.");const s=e.remoteIdentity.friendlyName,i=e.dialog.remoteTarget.toString(),r=e.dialog.callId,n=e.dialog.remoteTag,o=e.dialog.localTag;t=`"${s}" <${i}?Replaces=${encodeURIComponent(`${r};to-tag=${n};from-tag=${o}`)}>`}return t}}!function(e){e.Required="Required",e.Supported="Supported",e.Unsupported="Unsupported"}(J=J||(J={}));const X={"100rel":!0,199:!0,answermode:!0,"early-session":!0,eventlist:!0,explicitsub:!0,"from-change":!0,"geolocation-http":!0,"geolocation-sip":!0,gin:!0,gruu:!0,histinfo:!0,ice:!0,join:!0,"multiple-refer":!0,norefersub:!0,nosub:!0,outbound:!0,path:!0,policy:!0,precondition:!0,pref:!0,privacy:!0,"recipient-list-invite":!0,"recipient-list-message":!0,"recipient-list-subscribe":!0,replaces:!0,"resource-priority":!0,"sdp-anat":!0,"sec-agree":!0,tdialog:!0,timer:!0,uui:!0};class Q extends z{constructor(e,t){super(e),this.incomingInviteRequest=t,this.disposed=!1,this.expiresTimer=void 0,this.isCanceled=!1,this.rel100="none",this.rseq=Math.floor(1e4*Math.random()),this.userNoAnswerTimer=void 0,this.waitingForPrack=!1,this.logger=e.getLogger("sip.Invitation");const s=this.incomingInviteRequest.message,i=s.getHeader("require");i&&i.toLowerCase().includes("100rel")&&(this.rel100="required");const r=s.getHeader("supported");if(r&&r.toLowerCase().includes("100rel")&&(this.rel100="supported"),s.toTag=t.toTag,"string"!=typeof s.toTag)throw new TypeError("toTag should have been a string.");if(this.userNoAnswerTimer=setTimeout((()=>{t.reject({statusCode:480}),this.stateTransition(Z.Terminated)}),this.userAgent.configuration.noAnswerTimeout?1e3*this.userAgent.configuration.noAnswerTimeout:6e4),s.hasHeader("expires")){const e=1e3*Number(s.getHeader("expires")||0);this.expiresTimer=setTimeout((()=>{this.state===Z.Initial&&(t.reject({statusCode:487}),this.stateTransition(Z.Terminated))}),e)}const n=this.request.getHeader("P-Asserted-Identity");n&&(this._assertedIdentity=y.nameAddrHeaderParse(n)),this._contact=this.userAgent.contact.toString();const o=s.parseHeader("Content-Disposition");o&&"render"===o.type&&(this._renderbody=s.body,this._rendertype=s.getHeader("Content-Type")),this._id=s.callId+s.fromTag,this.userAgent._sessions[this._id]=this}dispose(){if(this.disposed)return Promise.resolve();switch(this.disposed=!0,this.expiresTimer&&(clearTimeout(this.expiresTimer),this.expiresTimer=void 0),this.userNoAnswerTimer&&(clearTimeout(this.userNoAnswerTimer),this.userNoAnswerTimer=void 0),this.prackNeverArrived(),this.state){case Z.Initial:case Z.Establishing:return this.reject().then((()=>super.dispose()));case Z.Established:case Z.Terminating:case Z.Terminated:return super.dispose();default:throw new Error("Unknown state.")}}get autoSendAnInitialProvisionalResponse(){return"required"!==this.rel100&&this.userAgent.configuration.sendInitialProvisionalResponse}get body(){return this.incomingInviteRequest.message.body}get localIdentity(){return this.request.to}get remoteIdentity(){return this.request.from}get request(){return this.incomingInviteRequest.message}accept(e={}){if(this.logger.log("Invitation.accept"),this.state!==Z.Initial){const e=new Error(`Invalid session state ${this.state}`);return this.logger.error(e.message),Promise.reject(e)}return e.sessionDescriptionHandlerModifiers&&(this.sessionDescriptionHandlerModifiers=e.sessionDescriptionHandlerModifiers),e.sessionDescriptionHandlerOptions&&(this.sessionDescriptionHandlerOptions=e.sessionDescriptionHandlerOptions),this.stateTransition(Z.Establishing),this.sendAccept(e).then((({message:e,session:t})=>{t.delegate={onAck:e=>this.onAckRequest(e),onAckTimeout:()=>this.onAckTimeout(),onBye:e=>this.onByeRequest(e),onInfo:e=>this.onInfoRequest(e),onInvite:e=>this.onInviteRequest(e),onMessage:e=>this.onMessageRequest(e),onNotify:e=>this.onNotifyRequest(e),onPrack:e=>this.onPrackRequest(e),onRefer:e=>this.onReferRequest(e)},this._dialog=t,this.stateTransition(Z.Established),this._replacee&&this._replacee._bye()})).catch((e=>this.handleResponseError(e)))}progress(e={}){if(this.logger.log("Invitation.progress"),this.state!==Z.Initial){const e=new Error(`Invalid session state ${this.state}`);return this.logger.error(e.message),Promise.reject(e)}const t=e.statusCode||180;if(t<100||t>199)throw new TypeError("Invalid statusCode: "+t);return e.sessionDescriptionHandlerModifiers&&(this.sessionDescriptionHandlerModifiers=e.sessionDescriptionHandlerModifiers),e.sessionDescriptionHandlerOptions&&(this.sessionDescriptionHandlerOptions=e.sessionDescriptionHandlerOptions),this.waitingForPrack?(this.logger.warn("Unexpected call for progress while waiting for prack, ignoring"),Promise.resolve()):100===e.statusCode?this.sendProgressTrying().then((()=>{})).catch((e=>this.handleResponseError(e))):"required"===this.rel100||"supported"===this.rel100&&e.rel100||"supported"===this.rel100&&this.userAgent.configuration.sipExtension100rel===J.Required?this.sendProgressReliableWaitForPrack(e).then((()=>{})).catch((e=>this.handleResponseError(e))):this.sendProgress(e).then((()=>{})).catch((e=>this.handleResponseError(e)))}reject(e={}){if(this.logger.log("Invitation.reject"),this.state!==Z.Initial&&this.state!==Z.Establishing){const e=new Error(`Invalid session state ${this.state}`);return this.logger.error(e.message),Promise.reject(e)}const t=e.statusCode||480,s=e.reasonPhrase?e.reasonPhrase:I(t),i=e.extraHeaders||[];if(t<300||t>699)throw new TypeError("Invalid statusCode: "+t);const r=e.body?q(e.body):void 0;return t<400?this.incomingInviteRequest.redirect([],{statusCode:t,reasonPhrase:s,extraHeaders:i,body:r}):this.incomingInviteRequest.reject({statusCode:t,reasonPhrase:s,extraHeaders:i,body:r}),this.stateTransition(Z.Terminated),Promise.resolve()}_onCancel(e){if(this.logger.log("Invitation._onCancel"),this.state===Z.Initial||this.state===Z.Establishing){if(this.delegate&&this.delegate.onCancel){const t=new u(e);this.delegate.onCancel(t)}this.isCanceled=!0,this.incomingInviteRequest.reject({statusCode:487}),this.stateTransition(Z.Terminated)}else this.logger.error(`CANCEL received while in state ${this.state}, dropping request`)}handlePrackOfferAnswer(e){if(!this.dialog)throw new Error("Dialog undefined.");const t=N(e.message);if(!t||"session"!==t.contentDisposition)return Promise.resolve(void 0);const s={sessionDescriptionHandlerOptions:this.sessionDescriptionHandlerOptions,sessionDescriptionHandlerModifiers:this.sessionDescriptionHandlerModifiers};switch(this.dialog.signalingState){case U.Initial:throw new Error(`Invalid signaling state ${this.dialog.signalingState}.`);case U.Stable:return this.setAnswer(t,s).then((()=>{}));case U.HaveLocalOffer:throw new Error(`Invalid signaling state ${this.dialog.signalingState}.`);case U.HaveRemoteOffer:return this.setOfferAndGetAnswer(t,s);case U.Closed:default:throw new Error(`Invalid signaling state ${this.dialog.signalingState}.`)}}handleResponseError(e){let t=480;if(e instanceof Error?this.logger.error(e.message):this.logger.error(e),e instanceof o?(this.logger.error("A session description handler occurred while sending response (content type unsupported"),t=415):e instanceof c?this.logger.error("A session description handler occurred while sending response"):e instanceof h?this.logger.error("Session ended before response could be formulated and sent (while waiting for PRACK)"):e instanceof B&&this.logger.error("Session changed state before response could be formulated and sent"),this.state===Z.Initial||this.state===Z.Establishing)try{this.incomingInviteRequest.reject({statusCode:t}),this.stateTransition(Z.Terminated)}catch(e){throw this.logger.error("An error occurred attempting to reject the request while handling another error"),e}if(!this.isCanceled)throw e;this.logger.warn("An error occurred while attempting to formulate and send a response to an incoming INVITE. However a CANCEL was received and processed while doing so which can (and often does) result in errors occurring as the session terminates in the meantime. Said error is being ignored.")}onAckTimeout(){if(this.logger.log("Invitation.onAckTimeout"),!this.dialog)throw new Error("Dialog undefined.");this.logger.log("No ACK received for an extended period of time, terminating session"),this.dialog.bye(),this.stateTransition(Z.Terminated)}sendAccept(e={}){const t={sessionDescriptionHandlerOptions:this.sessionDescriptionHandlerOptions,sessionDescriptionHandlerModifiers:this.sessionDescriptionHandlerModifiers},s=e.extraHeaders||[];return this.waitingForPrack?this.waitForArrivalOfPrack().then((()=>clearTimeout(this.userNoAnswerTimer))).then((()=>this.generateResponseOfferAnswer(this.incomingInviteRequest,t))).then((e=>this.incomingInviteRequest.accept({statusCode:200,body:e,extraHeaders:s}))):(clearTimeout(this.userNoAnswerTimer),this.generateResponseOfferAnswer(this.incomingInviteRequest,t).then((e=>this.incomingInviteRequest.accept({statusCode:200,body:e,extraHeaders:s}))))}sendProgress(e={}){const t=e.statusCode||180,s=e.reasonPhrase,i=(e.extraHeaders||[]).slice(),r=e.body?q(e.body):void 0;if(183===t&&!r)return this.sendProgressWithSDP(e);try{const e=this.incomingInviteRequest.progress({statusCode:t,reasonPhrase:s,extraHeaders:i,body:r});return this._dialog=e.session,Promise.resolve(e)}catch(e){return Promise.reject(e)}}sendProgressWithSDP(e={}){const t={sessionDescriptionHandlerOptions:this.sessionDescriptionHandlerOptions,sessionDescriptionHandlerModifiers:this.sessionDescriptionHandlerModifiers},s=e.statusCode||183,i=e.reasonPhrase,r=(e.extraHeaders||[]).slice();return this.generateResponseOfferAnswer(this.incomingInviteRequest,t).then((e=>this.incomingInviteRequest.progress({statusCode:s,reasonPhrase:i,extraHeaders:r,body:e}))).then((e=>(this._dialog=e.session,e)))}sendProgressReliable(e={}){return e.extraHeaders=(e.extraHeaders||[]).slice(),e.extraHeaders.push("Require: 100rel"),e.extraHeaders.push("RSeq: "+Math.floor(1e4*Math.random())),this.sendProgressWithSDP(e)}sendProgressReliableWaitForPrack(e={}){const t={sessionDescriptionHandlerOptions:this.sessionDescriptionHandlerOptions,sessionDescriptionHandlerModifiers:this.sessionDescriptionHandlerModifiers},s=e.statusCode||183,i=e.reasonPhrase,r=(e.extraHeaders||[]).slice();let n;return r.push("Require: 100rel"),r.push("RSeq: "+this.rseq++),new Promise(((e,o)=>{this.waitingForPrack=!0,this.generateResponseOfferAnswer(this.incomingInviteRequest,t).then((e=>(n=e,this.incomingInviteRequest.progress({statusCode:s,reasonPhrase:i,extraHeaders:r,body:n})))).then((t=>{let a,c;this._dialog=t.session,t.session.delegate={onPrack:s=>{a=s,clearTimeout(d),clearTimeout(u),this.waitingForPrack&&(this.waitingForPrack=!1,this.handlePrackOfferAnswer(a).then((s=>{try{c=a.accept({statusCode:200,body:s}),this.prackArrived(),e({prackRequest:a,prackResponse:c,progressResponse:t})}catch(e){o(e)}})).catch((e=>o(e))))}};const d=setTimeout((()=>{this.waitingForPrack&&(this.waitingForPrack=!1,this.logger.warn("No PRACK received, rejecting INVITE."),clearTimeout(u),this.reject({statusCode:504}).then((()=>o(new h))).catch((e=>o(e))))}),64*L.T1),l=()=>{try{this.incomingInviteRequest.progress({statusCode:s,reasonPhrase:i,extraHeaders:r,body:n})}catch(e){return this.waitingForPrack=!1,void o(e)}u=setTimeout(l,g*=2)};let g=L.T1,u=setTimeout(l,g)})).catch((e=>{this.waitingForPrack=!1,o(e)}))}))}sendProgressTrying(){try{const e=this.incomingInviteRequest.trying();return Promise.resolve(e)}catch(e){return Promise.reject(e)}}waitForArrivalOfPrack(){if(this.waitingForPrackPromise)throw new Error("Already waiting for PRACK");return this.waitingForPrackPromise=new Promise(((e,t)=>{this.waitingForPrackResolve=e,this.waitingForPrackReject=t})),this.waitingForPrackPromise}prackArrived(){this.waitingForPrackResolve&&this.waitingForPrackResolve(),this.waitingForPrackPromise=void 0,this.waitingForPrackResolve=void 0,this.waitingForPrackReject=void 0}prackNeverArrived(){this.waitingForPrackReject&&this.waitingForPrackReject(new h),this.waitingForPrackPromise=void 0,this.waitingForPrackResolve=void 0,this.waitingForPrackReject=void 0}}class ee extends z{constructor(e,t,s={}){super(e,s),this.disposed=!1,this.earlyMedia=!1,this.earlyMediaSessionDescriptionHandlers=new Map,this.isCanceled=!1,this.inviteWithoutSdp=!1,this.logger=e.getLogger("sip.Inviter"),this.earlyMedia=void 0!==s.earlyMedia?s.earlyMedia:this.earlyMedia,this.fromTag=C(),this.inviteWithoutSdp=void 0!==s.inviteWithoutSdp?s.inviteWithoutSdp:this.inviteWithoutSdp;const i=Object.assign({},s);i.params=Object.assign({},s.params);const r=s.anonymous||!1,n=e.contact.toString({anonymous:r,outbound:r?!e.contact.tempGruu:!e.contact.pubGruu});r&&e.configuration.uri&&(i.params.fromDisplayName="Anonymous",i.params.fromUri="sip:anonymous@anonymous.invalid");let o=e.userAgentCore.configuration.aor;if(i.params.fromUri&&(o="string"==typeof i.params.fromUri?y.URIParse(i.params.fromUri):i.params.fromUri),!o)throw new TypeError("Invalid from URI: "+i.params.fromUri);let a=t;if(i.params.toUri&&(a="string"==typeof i.params.toUri?y.URIParse(i.params.toUri):i.params.toUri),!a)throw new TypeError("Invalid to URI: "+i.params.toUri);const c=Object.assign({},i.params);c.fromTag=this.fromTag;const h=(i.extraHeaders||[]).slice();r&&e.configuration.uri&&(h.push("P-Preferred-Identity: "+e.configuration.uri.toString()),h.push("Privacy: id")),h.push("Contact: "+n),h.push("Allow: "+["ACK","CANCEL","INVITE","MESSAGE","BYE","OPTIONS","INFO","NOTIFY","REFER"].toString()),e.configuration.sipExtension100rel===J.Required&&h.push("Require: 100rel"),e.configuration.sipExtensionReplaces===J.Required&&h.push("Require: replaces"),i.extraHeaders=h;this.outgoingRequestMessage=e.userAgentCore.makeOutgoingRequestMessage(G.INVITE,t,o,a,c,h,undefined),this._contact=n,this._referralInviterOptions=i,this._renderbody=s.renderbody,this._rendertype=s.rendertype,s.sessionDescriptionHandlerModifiers&&(this.sessionDescriptionHandlerModifiers=s.sessionDescriptionHandlerModifiers),s.sessionDescriptionHandlerOptions&&(this.sessionDescriptionHandlerOptions=s.sessionDescriptionHandlerOptions),s.sessionDescriptionHandlerModifiersReInvite&&(this.sessionDescriptionHandlerModifiersReInvite=s.sessionDescriptionHandlerModifiersReInvite),s.sessionDescriptionHandlerOptionsReInvite&&(this.sessionDescriptionHandlerOptionsReInvite=s.sessionDescriptionHandlerOptionsReInvite),this._id=this.outgoingRequestMessage.callId+this.fromTag,this.userAgent._sessions[this._id]=this}dispose(){if(this.disposed)return Promise.resolve();switch(this.disposed=!0,this.disposeEarlyMedia(),this.state){case Z.Initial:case Z.Establishing:return this.cancel().then((()=>super.dispose()));case Z.Established:case Z.Terminating:case Z.Terminated:return super.dispose();default:throw new Error("Unknown state.")}}get body(){return this.outgoingRequestMessage.body}get localIdentity(){return this.outgoingRequestMessage.from}get remoteIdentity(){return this.outgoingRequestMessage.to}get request(){return this.outgoingRequestMessage}cancel(e={}){if(this.logger.log("Inviter.cancel"),this.state!==Z.Initial&&this.state!==Z.Establishing){const e=new Error(`Invalid session state ${this.state}`);return this.logger.error(e.message),Promise.reject(e)}if(this.isCanceled=!0,this.stateTransition(Z.Terminating),this.outgoingInviteRequest){let t;e.statusCode&&e.reasonPhrase&&(t=function(e,t){if(e&&e<200||e>699)throw new TypeError("Invalid statusCode: "+e);if(e)return"SIP;cause="+e+';text="'+(I(e)||t)+'"'}(e.statusCode,e.reasonPhrase)),this.outgoingInviteRequest.cancel(t,e)}else this.logger.warn("Canceled session before INVITE was sent"),this.stateTransition(Z.Terminated);return Promise.resolve()}invite(e={}){if(this.logger.log("Inviter.invite"),this.state!==Z.Initial)return super.invite(e);if(e.sessionDescriptionHandlerModifiers&&(this.sessionDescriptionHandlerModifiers=e.sessionDescriptionHandlerModifiers),e.sessionDescriptionHandlerOptions&&(this.sessionDescriptionHandlerOptions=e.sessionDescriptionHandlerOptions),e.withoutSdp||this.inviteWithoutSdp)return this._renderbody&&this._rendertype&&(this.outgoingRequestMessage.body={contentType:this._rendertype,body:this._renderbody}),this.stateTransition(Z.Establishing),Promise.resolve(this.sendInvite(e));const t={sessionDescriptionHandlerModifiers:this.sessionDescriptionHandlerModifiers,sessionDescriptionHandlerOptions:this.sessionDescriptionHandlerOptions};return this.getOffer(t).then((t=>(this.outgoingRequestMessage.body={body:t.content,contentType:t.contentType},this.stateTransition(Z.Establishing),this.sendInvite(e)))).catch((e=>{throw this.logger.log(e.message),this.state!==Z.Terminated&&this.stateTransition(Z.Terminated),e}))}sendInvite(e={}){return this.outgoingInviteRequest=this.userAgent.userAgentCore.invite(this.outgoingRequestMessage,{onAccept:t=>this.dialog?(this.logger.log("Additional confirmed dialog, sending ACK and BYE"),void this.ackAndBye(t)):this.isCanceled?(this.logger.log("Canceled session accepted, sending ACK and BYE"),this.ackAndBye(t),void this.stateTransition(Z.Terminated)):(this.notifyReferer(t),void this.onAccept(t).then((()=>{this.disposeEarlyMedia()})).catch((()=>{this.disposeEarlyMedia()})).then((()=>{e.requestDelegate&&e.requestDelegate.onAccept&&e.requestDelegate.onAccept(t)}))),onProgress:t=>{this.isCanceled||(this.notifyReferer(t),this.onProgress(t).catch((()=>{this.disposeEarlyMedia()})).then((()=>{e.requestDelegate&&e.requestDelegate.onProgress&&e.requestDelegate.onProgress(t)})))},onRedirect:t=>{this.notifyReferer(t),this.onRedirect(t),e.requestDelegate&&e.requestDelegate.onRedirect&&e.requestDelegate.onRedirect(t)},onReject:t=>{this.notifyReferer(t),this.onReject(t),e.requestDelegate&&e.requestDelegate.onReject&&e.requestDelegate.onReject(t)},onTrying:t=>{this.notifyReferer(t),this.onTrying(t),e.requestDelegate&&e.requestDelegate.onTrying&&e.requestDelegate.onTrying(t)}}),this.outgoingInviteRequest}disposeEarlyMedia(){this.earlyMediaSessionDescriptionHandlers.forEach((e=>{e.close()})),this.earlyMediaSessionDescriptionHandlers.clear()}notifyReferer(e){if(!this._referred)return;if(!(this._referred instanceof z))throw new Error("Referred session not instance of session");if(!this._referred.dialog)return;if(!e.message.statusCode)throw new Error("Status code undefined.");if(!e.message.reasonPhrase)throw new Error("Reason phrase undefined.");const t=`SIP/2.0 ${e.message.statusCode} ${e.message.reasonPhrase}`.trim();this._referred.dialog.notify(void 0,{extraHeaders:["Event: refer","Subscription-State: terminated"],body:{contentDisposition:"render",contentType:"message/sipfrag",content:t}}).delegate={onReject:()=>{this._referred=void 0}}}onAccept(e){if(this.logger.log("Inviter.onAccept"),this.state!==Z.Establishing)return this.logger.error(`Accept received while in state ${this.state}, dropping response`),Promise.reject(new Error(`Invalid session state ${this.state}`));const t=e.message,s=e.session;switch(t.hasHeader("P-Asserted-Identity")&&(this._assertedIdentity=y.nameAddrHeaderParse(t.getHeader("P-Asserted-Identity"))),s.delegate={onAck:e=>this.onAckRequest(e),onBye:e=>this.onByeRequest(e),onInfo:e=>this.onInfoRequest(e),onInvite:e=>this.onInviteRequest(e),onMessage:e=>this.onMessageRequest(e),onNotify:e=>this.onNotifyRequest(e),onPrack:e=>this.onPrackRequest(e),onRefer:e=>this.onReferRequest(e)},this._dialog=s,s.signalingState){case U.Initial:case U.HaveLocalOffer:return this.logger.error("Received 2xx response to INVITE without a session description"),this.ackAndBye(e,400,"Missing session description"),this.stateTransition(Z.Terminated),Promise.reject(new Error("Bad Media Description"));case U.HaveRemoteOffer:{if(!this._dialog.offer)throw new Error(`Session offer undefined in signaling state ${this._dialog.signalingState}.`);const t={sessionDescriptionHandlerModifiers:this.sessionDescriptionHandlerModifiers,sessionDescriptionHandlerOptions:this.sessionDescriptionHandlerOptions};return this.setOfferAndGetAnswer(this._dialog.offer,t).then((t=>{e.ack({body:t}),this.stateTransition(Z.Established)})).catch((t=>{throw this.ackAndBye(e,488,"Invalid session description"),this.stateTransition(Z.Terminated),t}))}case U.Stable:{if(this.earlyMediaSessionDescriptionHandlers.size>0){const t=this.earlyMediaSessionDescriptionHandlers.get(s.id);if(!t)throw new Error("Session description handler undefined.");return this.setSessionDescriptionHandler(t),this.earlyMediaSessionDescriptionHandlers.delete(s.id),e.ack(),this.stateTransition(Z.Established),Promise.resolve()}if(this.earlyMediaDialog){if(this.earlyMediaDialog!==s){if(this.earlyMedia){const e="You have set the 'earlyMedia' option to 'true' which requires that your INVITE requests do not fork and yet this INVITE request did in fact fork. Consequentially and not surprisingly the end point which accepted the INVITE (confirmed dialog) does not match the end point with which early media has been setup (early dialog) and thus this session is unable to proceed. In accordance with the SIP specifications, the SIP servers your end point is connected to determine if an INVITE forks and the forking behavior of those servers cannot be controlled by this library. If you wish to use early media with this library you must configure those servers accordingly. Alternatively you may set the 'earlyMedia' to 'false' which will allow this library to function with any INVITE requests which do fork.";this.logger.error(e)}const t=new Error("Early media dialog does not equal confirmed dialog, terminating session");return this.logger.error(t.message),this.ackAndBye(e,488,"Not Acceptable Here"),this.stateTransition(Z.Terminated),Promise.reject(t)}return e.ack(),this.stateTransition(Z.Established),Promise.resolve()}const t=s.answer;if(!t)throw new Error("Answer is undefined.");const i={sessionDescriptionHandlerModifiers:this.sessionDescriptionHandlerModifiers,sessionDescriptionHandlerOptions:this.sessionDescriptionHandlerOptions};return this.setAnswer(t,i).then((()=>{let t;this._renderbody&&this._rendertype&&(t={body:{contentDisposition:"render",contentType:this._rendertype,content:this._renderbody}}),e.ack(t),this.stateTransition(Z.Established)})).catch((t=>{throw this.logger.error(t.message),this.ackAndBye(e,488,"Not Acceptable Here"),this.stateTransition(Z.Terminated),t}))}case U.Closed:return Promise.reject(new Error("Terminated."));default:throw new Error("Unknown session signaling state.")}}onProgress(e){var t;if(this.logger.log("Inviter.onProgress"),this.state!==Z.Establishing)return this.logger.error(`Progress received while in state ${this.state}, dropping response`),Promise.reject(new Error(`Invalid session state ${this.state}`));if(!this.outgoingInviteRequest)throw new Error("Outgoing INVITE request undefined.");const s=e.message,i=e.session;s.hasHeader("P-Asserted-Identity")&&(this._assertedIdentity=y.nameAddrHeaderParse(s.getHeader("P-Asserted-Identity")));const r=s.getHeader("require"),n=s.getHeader("rseq"),o=!!(r&&r.includes("100rel")&&n?Number(n):void 0),a=[];switch(o&&a.push("RAck: "+s.getHeader("rseq")+" "+s.getHeader("cseq")),i.signalingState){case U.Initial:return o&&(this.logger.warn("First reliable provisional response received MUST contain an offer when INVITE does not contain an offer."),e.prack({extraHeaders:a})),Promise.resolve();case U.HaveLocalOffer:return o&&e.prack({extraHeaders:a}),Promise.resolve();case U.HaveRemoteOffer:if(!o)return this.logger.warn("Non-reliable provisional response MUST NOT contain an initial offer, discarding response."),Promise.resolve();{const r=this.sessionDescriptionHandlerFactory(this,this.userAgent.configuration.sessionDescriptionHandlerFactoryOptions||{});return(null===(t=this.delegate)||void 0===t?void 0:t.onSessionDescriptionHandler)&&this.delegate.onSessionDescriptionHandler(r,!0),this.earlyMediaSessionDescriptionHandlers.set(i.id,r),r.setDescription(s.body,this.sessionDescriptionHandlerOptions,this.sessionDescriptionHandlerModifiers).then((()=>r.getDescription(this.sessionDescriptionHandlerOptions,this.sessionDescriptionHandlerModifiers))).then((t=>{const s={contentDisposition:"session",contentType:t.contentType,content:t.body};e.prack({extraHeaders:a,body:s})})).catch((e=>{throw this.stateTransition(Z.Terminated),e}))}case U.Stable:if(o&&e.prack({extraHeaders:a}),this.earlyMedia&&!this.earlyMediaDialog){this.earlyMediaDialog=i;const e=i.answer;if(!e)throw new Error("Answer is undefined.");const t={sessionDescriptionHandlerModifiers:this.sessionDescriptionHandlerModifiers,sessionDescriptionHandlerOptions:this.sessionDescriptionHandlerOptions};return this.setAnswer(e,t).catch((e=>{throw this.stateTransition(Z.Terminated),e}))}return Promise.resolve();case U.Closed:return Promise.reject(new Error("Terminated."));default:throw new Error("Unknown session signaling state.")}}onRedirect(e){this.logger.log("Inviter.onRedirect"),this.state===Z.Establishing||this.state===Z.Terminating?this.stateTransition(Z.Terminated):this.logger.error(`Redirect received while in state ${this.state}, dropping response`)}onReject(e){this.logger.log("Inviter.onReject"),this.state===Z.Establishing||this.state===Z.Terminating?this.stateTransition(Z.Terminated):this.logger.error(`Reject received while in state ${this.state}, dropping response`)}onTrying(e){this.logger.log("Inviter.onTrying"),this.state===Z.Establishing||this.logger.error(`Trying received while in state ${this.state}, dropping response`)}}class te{constructor(e,t,s,i="text/plain",r={}){this.logger=e.getLogger("sip.Messager"),r.params=r.params||{};let n=e.userAgentCore.configuration.aor;if(r.params.fromUri&&(n="string"==typeof r.params.fromUri?y.URIParse(r.params.fromUri):r.params.fromUri),!n)throw new TypeError("Invalid from URI: "+r.params.fromUri);let o=t;if(r.params.toUri&&(o="string"==typeof r.params.toUri?y.URIParse(r.params.toUri):r.params.toUri),!o)throw new TypeError("Invalid to URI: "+r.params.toUri);const a=r.params?Object.assign({},r.params):{},c=(r.extraHeaders||[]).slice(),h={contentDisposition:"render",contentType:i,content:s};this.request=e.userAgentCore.makeOutgoingRequestMessage(G.MESSAGE,t,n,o,a,c,h),this.userAgent=e}message(e={}){return this.userAgent.userAgentCore.request(this.request,e.requestDelegate),Promise.resolve()}}var se,ie,re,ne,oe,ae,ce,he,de;!function(e){e.Initial="Initial",e.Published="Published",e.Unpublished="Unpublished",e.Terminated="Terminated"}(se=se||(se={}));class le{constructor(e,t,s,i={}){this.disposed=!1,this._state=se.Initial,this._stateEventEmitter=new p,this.userAgent=e,i.extraHeaders=(i.extraHeaders||[]).slice(),i.contentType=i.contentType||"text/plain","number"!=typeof i.expires||i.expires%1!=0?i.expires=3600:i.expires=Number(i.expires),"boolean"!=typeof i.unpublishOnClose&&(i.unpublishOnClose=!0),this.target=t,this.event=s,this.options=i,this.pubRequestExpires=i.expires,this.logger=e.getLogger("sip.Publisher");const r=i.params||{},n=r.fromUri?r.fromUri:e.userAgentCore.configuration.aor,o=r.toUri?r.toUri:t;let a;if(i.body&&i.contentType){a={contentDisposition:"render",contentType:i.contentType,content:i.body}}const c=(i.extraHeaders||[]).slice();this.request=e.userAgentCore.makeOutgoingRequestMessage(G.PUBLISH,t,n,o,r,c,a),this.id=this.target.toString()+":"+this.event,this.userAgent._publishers[this.id]=this}dispose(){return this.disposed?Promise.resolve():(this.disposed=!0,this.logger.log(`Publisher ${this.id} in state ${this.state} is being disposed`),delete this.userAgent._publishers[this.id],this.options.unpublishOnClose&&this.state===se.Published?this.unpublish():(this.publishRefreshTimer&&(clearTimeout(this.publishRefreshTimer),this.publishRefreshTimer=void 0),this.pubRequestBody=void 0,this.pubRequestExpires=0,this.pubRequestEtag=void 0,Promise.resolve()))}get state(){return this._state}get stateChange(){return this._stateEventEmitter}publish(e,t={}){if(this.publishRefreshTimer&&(clearTimeout(this.publishRefreshTimer),this.publishRefreshTimer=void 0),this.options.body=e,this.pubRequestBody=this.options.body,0===this.pubRequestExpires){if(void 0===this.options.expires)throw new Error("Expires undefined.");this.pubRequestExpires=this.options.expires,this.pubRequestEtag=void 0}return this.sendPublishRequest(),Promise.resolve()}unpublish(e={}){return this.publishRefreshTimer&&(clearTimeout(this.publishRefreshTimer),this.publishRefreshTimer=void 0),this.pubRequestBody=void 0,this.pubRequestExpires=0,void 0!==this.pubRequestEtag&&this.sendPublishRequest(),Promise.resolve()}receiveResponse(e){const t=e.statusCode||0;switch(!0){case/^1[0-9]{2}$/.test(t.toString()):break;case/^2[0-9]{2}$/.test(t.toString()):if(e.hasHeader("SIP-ETag")?this.pubRequestEtag=e.getHeader("SIP-ETag"):this.logger.warn("SIP-ETag header missing in a 200-class response to PUBLISH"),e.hasHeader("Expires")){const t=Number(e.getHeader("Expires"));"number"==typeof t&&t>=0&&t<=this.pubRequestExpires?this.pubRequestExpires=t:this.logger.warn("Bad Expires header in a 200-class response to PUBLISH")}else this.logger.warn("Expires header missing in a 200-class response to PUBLISH");0!==this.pubRequestExpires?(this.publishRefreshTimer=setTimeout((()=>this.refreshRequest()),900*this.pubRequestExpires),this._state!==se.Published&&this.stateTransition(se.Published)):this.stateTransition(se.Unpublished);break;case/^412$/.test(t.toString()):if(void 0!==this.pubRequestEtag&&0!==this.pubRequestExpires){if(this.logger.warn("412 response to PUBLISH, recovering"),this.pubRequestEtag=void 0,void 0===this.options.body)throw new Error("Body undefined.");this.publish(this.options.body)}else this.logger.warn("412 response to PUBLISH, recovery failed"),this.pubRequestExpires=0,this.stateTransition(se.Unpublished),this.stateTransition(se.Terminated);break;case/^423$/.test(t.toString()):if(0!==this.pubRequestExpires&&e.hasHeader("Min-Expires")){const t=Number(e.getHeader("Min-Expires"));if("number"==typeof t||t>this.pubRequestExpires){if(this.logger.warn("423 code in response to PUBLISH, adjusting the Expires value and trying to recover"),this.pubRequestExpires=t,void 0===this.options.body)throw new Error("Body undefined.");this.publish(this.options.body)}else this.logger.warn("Bad 423 response Min-Expires header received for PUBLISH"),this.pubRequestExpires=0,this.stateTransition(se.Unpublished),this.stateTransition(se.Terminated)}else this.logger.warn("423 response to PUBLISH, recovery failed"),this.pubRequestExpires=0,this.stateTransition(se.Unpublished),this.stateTransition(se.Terminated);break;default:this.pubRequestExpires=0,this.stateTransition(se.Unpublished),this.stateTransition(se.Terminated)}0===this.pubRequestExpires&&(this.publishRefreshTimer&&(clearTimeout(this.publishRefreshTimer),this.publishRefreshTimer=void 0),this.pubRequestBody=void 0,this.pubRequestEtag=void 0)}send(){return this.userAgent.userAgentCore.publish(this.request,{onAccept:e=>this.receiveResponse(e.message),onProgress:e=>this.receiveResponse(e.message),onRedirect:e=>this.receiveResponse(e.message),onReject:e=>this.receiveResponse(e.message),onTrying:e=>this.receiveResponse(e.message)})}refreshRequest(){if(this.publishRefreshTimer&&(clearTimeout(this.publishRefreshTimer),this.publishRefreshTimer=void 0),this.pubRequestBody=void 0,void 0===this.pubRequestEtag)throw new Error("Etag undefined");if(0===this.pubRequestExpires)throw new Error("Expires zero");this.sendPublishRequest()}sendPublishRequest(){const e=Object.assign({},this.options);e.extraHeaders=(this.options.extraHeaders||[]).slice(),e.extraHeaders.push("Event: "+this.event),e.extraHeaders.push("Expires: "+this.pubRequestExpires),void 0!==this.pubRequestEtag&&e.extraHeaders.push("SIP-If-Match: "+this.pubRequestEtag);const t=this.target,s=this.options.params||{};let i,r;if(void 0!==this.pubRequestBody){if(void 0===this.options.contentType)throw new Error("Content type undefined.");i={body:this.pubRequestBody,contentType:this.options.contentType}}return i&&(r=q(i)),this.request=this.userAgent.userAgentCore.makeOutgoingRequestMessage(G.PUBLISH,t,s.fromUri?s.fromUri:this.userAgent.userAgentCore.configuration.aor,s.toUri?s.toUri:this.target,s,e.extraHeaders,r),this.send()}stateTransition(e){const t=()=>{throw new Error(`Invalid state transition from ${this._state} to ${e}`)};switch(this._state){case se.Initial:e!==se.Published&&e!==se.Unpublished&&e!==se.Terminated&&t();break;case se.Published:e!==se.Unpublished&&e!==se.Terminated&&t();break;case se.Unpublished:e!==se.Published&&e!==se.Terminated&&t();break;case se.Terminated:t();break;default:throw new Error("Unrecognized state.")}this._state=e,this.logger.log(`Publication transitioned to state ${this._state}`),this._stateEventEmitter.emit(this._state),e===se.Terminated&&this.dispose()}}!function(e){e.Initial="Initial",e.Registered="Registered",e.Unregistered="Unregistered",e.Terminated="Terminated"}(ie=ie||(ie={}));class ge{constructor(e,t={}){this.disposed=!1,this._contacts=[],this._retryAfter=void 0,this._state=ie.Initial,this._waiting=!1,this._stateEventEmitter=new p,this._waitingEventEmitter=new p,this.userAgent=e;const s=e.configuration.uri.clone();if(s.user=void 0,this.options=Object.assign(Object.assign(Object.assign({},ge.defaultOptions()),{registrar:s}),ge.stripUndefinedProperties(t)),this.options.extraContactHeaderParams=(this.options.extraContactHeaderParams||[]).slice(),this.options.extraHeaders=(this.options.extraHeaders||[]).slice(),!this.options.registrar)throw new Error("Registrar undefined.");if(this.options.registrar=this.options.registrar.clone(),this.options.regId&&!this.options.instanceId?this.options.instanceId=this.userAgent.instanceId:!this.options.regId&&this.options.instanceId&&(this.options.regId=1),this.options.instanceId&&-1===y.parse(this.options.instanceId,"uuid"))throw new Error("Invalid instanceId.");if(this.options.regId&&this.options.regId<0)throw new Error("Invalid regId.");const i=this.options.registrar,r=this.options.params&&this.options.params.fromUri||e.userAgentCore.configuration.aor,n=this.options.params&&this.options.params.toUri||e.configuration.uri,o=this.options.params||{},a=(t.extraHeaders||[]).slice();if(this.request=e.userAgentCore.makeOutgoingRequestMessage(G.REGISTER,i,r,n,o,a,void 0),this.expires=this.options.expires||ge.defaultExpires,this.expires<0)throw new Error("Invalid expires.");if(this.refreshFrequency=this.options.refreshFrequency||ge.defaultRefreshFrequency,this.refreshFrequency<50||this.refreshFrequency>99)throw new Error("Invalid refresh frequency. The value represents a percentage of the expiration time and should be between 50 and 99.");this.logger=e.getLogger("sip.Registerer"),this.options.logConfiguration&&(this.logger.log("Configuration:"),Object.keys(this.options).forEach((e=>{const t=this.options[e];if("registrar"===e)this.logger.log("\xb7 "+e+": "+t);else this.logger.log("\xb7 "+e+": "+JSON.stringify(t))}))),this.id=this.request.callId+this.request.from.parameters.tag,this.userAgent._registerers[this.id]=this}static defaultOptions(){return{expires:ge.defaultExpires,extraContactHeaderParams:[],extraHeaders:[],logConfiguration:!0,instanceId:"",params:{},regId:0,registrar:new w("sip","anonymous","anonymous.invalid"),refreshFrequency:ge.defaultRefreshFrequency}}static stripUndefinedProperties(e){return Object.keys(e).reduce(((t,s)=>(void 0!==e[s]&&(t[s]=e[s]),t)),{})}get contacts(){return this._contacts.slice()}get retryAfter(){return this._retryAfter}get state(){return this._state}get stateChange(){return this._stateEventEmitter}dispose(){return this.disposed?Promise.resolve():(this.disposed=!0,this.logger.log(`Registerer ${this.id} in state ${this.state} is being disposed`),delete this.userAgent._registerers[this.id],new Promise((e=>{const t=()=>{if(!this.waiting&&this._state===ie.Registered)return this.stateChange.addListener((()=>{this.terminated(),e()}),{once:!0}),void this.unregister();this.terminated(),e()};this.waiting?this.waitingChange.addListener((()=>{t()}),{once:!0}):t()})))}register(e={}){if(this.state===ie.Terminated)throw this.stateError(),new Error("Registerer terminated. Unable to register.");if(this.disposed)throw this.stateError(),new Error("Registerer disposed. Unable to register.");if(this.waiting){this.waitingWarning();const e=new a("REGISTER request already in progress, waiting for final response");return Promise.reject(e)}e.requestOptions&&(this.options=Object.assign(Object.assign({},this.options),e.requestOptions));const t=(this.options.extraHeaders||[]).slice();t.push("Contact: "+this.generateContactHeader(this.expires)),t.push("Allow: "+["ACK","CANCEL","INVITE","MESSAGE","BYE","OPTIONS","INFO","NOTIFY","REFER"].toString()),this.request.cseq++,this.request.setHeader("cseq",this.request.cseq+" REGISTER"),this.request.extraHeaders=t,this.waitingToggle(!0);const s=this.userAgent.userAgentCore.register(this.request,{onAccept:t=>{let s;t.message.hasHeader("expires")&&(s=Number(t.message.getHeader("expires"))),this._contacts=t.message.getHeaders("contact");let i,r=this._contacts.length;if(!r)return this.logger.error("No Contact header in response to REGISTER, dropping response."),void this.unregistered();for(;r--;){if(i=t.message.parseHeader("contact",r),!i)throw new Error("Contact undefined");if(this.userAgent.contact.pubGruu&&b(i.uri,this.userAgent.contact.pubGruu)){s=Number(i.getParam("expires"));break}if(""===this.userAgent.configuration.contactName){if(i.uri.user===this.userAgent.contact.uri.user){s=Number(i.getParam("expires"));break}}else if(b(i.uri,this.userAgent.contact.uri)){s=Number(i.getParam("expires"));break}i=void 0}if(void 0===i)return this.logger.error("No Contact header pointing to us, dropping response"),this.unregistered(),void this.waitingToggle(!1);if(void 0===s)return this.logger.error("Contact pointing to us is missing expires parameter, dropping response"),this.unregistered(),void this.waitingToggle(!1);if(i.hasParam("temp-gruu")){const e=i.getParam("temp-gruu");e&&(this.userAgent.contact.tempGruu=y.URIParse(e.replace(/"/g,"")))}if(i.hasParam("pub-gruu")){const e=i.getParam("pub-gruu");e&&(this.userAgent.contact.pubGruu=y.URIParse(e.replace(/"/g,"")))}this.registered(s),e.requestDelegate&&e.requestDelegate.onAccept&&e.requestDelegate.onAccept(t),this.waitingToggle(!1)},onProgress:t=>{e.requestDelegate&&e.requestDelegate.onProgress&&e.requestDelegate.onProgress(t)},onRedirect:t=>{this.logger.error("Redirect received. Not supported."),this.unregistered(),e.requestDelegate&&e.requestDelegate.onRedirect&&e.requestDelegate.onRedirect(t),this.waitingToggle(!1)},onReject:t=>{if(423===t.message.statusCode)return t.message.hasHeader("min-expires")?(this.expires=Number(t.message.getHeader("min-expires")),this.waitingToggle(!1),void this.register()):(this.logger.error("423 response received for REGISTER without Min-Expires, dropping response"),this.unregistered(),void this.waitingToggle(!1));this.logger.warn(`Failed to register, status code ${t.message.statusCode}`);let s=NaN;if(500===t.message.statusCode||503===t.message.statusCode){const e=t.message.getHeader("retry-after");e&&(s=Number.parseInt(e,void 0))}this._retryAfter=isNaN(s)?void 0:s,this.unregistered(),e.requestDelegate&&e.requestDelegate.onReject&&e.requestDelegate.onReject(t),this._retryAfter=void 0,this.waitingToggle(!1)},onTrying:t=>{e.requestDelegate&&e.requestDelegate.onTrying&&e.requestDelegate.onTrying(t)}});return Promise.resolve(s)}unregister(e={}){if(this.state===ie.Terminated)throw this.stateError(),new Error("Registerer terminated. Unable to register.");if(this.disposed&&this.state!==ie.Registered)throw this.stateError(),new Error("Registerer disposed. Unable to register.");if(this.waiting){this.waitingWarning();const e=new a("REGISTER request already in progress, waiting for final response");return Promise.reject(e)}this._state===ie.Registered||e.all||this.logger.warn("Not currently registered, but sending an unregister anyway.");const t=(e.requestOptions&&e.requestOptions.extraHeaders||[]).slice();this.request.extraHeaders=t,e.all?(t.push("Contact: *"),t.push("Expires: 0")):t.push("Contact: "+this.generateContactHeader(0)),this.request.cseq++,this.request.setHeader("cseq",this.request.cseq+" REGISTER"),void 0!==this.registrationTimer&&(clearTimeout(this.registrationTimer),this.registrationTimer=void 0),this.waitingToggle(!0);const s=this.userAgent.userAgentCore.register(this.request,{onAccept:t=>{this._contacts=t.message.getHeaders("contact"),this.unregistered(),e.requestDelegate&&e.requestDelegate.onAccept&&e.requestDelegate.onAccept(t),this.waitingToggle(!1)},onProgress:t=>{e.requestDelegate&&e.requestDelegate.onProgress&&e.requestDelegate.onProgress(t)},onRedirect:t=>{this.logger.error("Unregister redirected. Not currently supported."),this.unregistered(),e.requestDelegate&&e.requestDelegate.onRedirect&&e.requestDelegate.onRedirect(t),this.waitingToggle(!1)},onReject:t=>{this.logger.error(`Unregister rejected with status code ${t.message.statusCode}`),this.unregistered(),e.requestDelegate&&e.requestDelegate.onReject&&e.requestDelegate.onReject(t),this.waitingToggle(!1)},onTrying:t=>{e.requestDelegate&&e.requestDelegate.onTrying&&e.requestDelegate.onTrying(t)}});return Promise.resolve(s)}clearTimers(){void 0!==this.registrationTimer&&(clearTimeout(this.registrationTimer),this.registrationTimer=void 0),void 0!==this.registrationExpiredTimer&&(clearTimeout(this.registrationExpiredTimer),this.registrationExpiredTimer=void 0)}generateContactHeader(e){let t=this.userAgent.contact.toString({register:!0});return this.options.regId&&this.options.instanceId&&(t+=";reg-id="+this.options.regId,t+=';+sip.instance=""'),this.options.extraContactHeaderParams&&this.options.extraContactHeaderParams.forEach((e=>{t+=";"+e})),t+=";expires="+e,t}registered(e){this.clearTimers(),this.registrationTimer=setTimeout((()=>{this.registrationTimer=void 0,this.register()}),this.refreshFrequency/100*e*1e3),this.registrationExpiredTimer=setTimeout((()=>{this.logger.warn("Registration expired"),this.unregistered()}),1e3*e),this._state!==ie.Registered&&this.stateTransition(ie.Registered)}unregistered(){this.clearTimers(),this._state!==ie.Unregistered&&this.stateTransition(ie.Unregistered)}terminated(){this.clearTimers(),this._state!==ie.Terminated&&this.stateTransition(ie.Terminated)}stateTransition(e){const t=()=>{throw new Error(`Invalid state transition from ${this._state} to ${e}`)};switch(this._state){case ie.Initial:e!==ie.Registered&&e!==ie.Unregistered&&e!==ie.Terminated&&t();break;case ie.Registered:e!==ie.Unregistered&&e!==ie.Terminated&&t();break;case ie.Unregistered:e!==ie.Registered&&e!==ie.Terminated&&t();break;case ie.Terminated:t();break;default:throw new Error("Unrecognized state.")}this._state=e,this.logger.log(`Registration transitioned to state ${this._state}`),this._stateEventEmitter.emit(this._state),e===ie.Terminated&&this.dispose()}get waiting(){return this._waiting}get waitingChange(){return this._waitingEventEmitter}waitingToggle(e){if(this._waiting===e)throw new Error(`Invalid waiting transition from ${this._waiting} to ${e}`);this._waiting=e,this.logger.log(`Waiting toggled to ${this._waiting}`),this._waitingEventEmitter.emit(this._waiting)}waitingWarning(){let e="An attempt was made to send a REGISTER request while a prior one was still in progress.";e+=" RFC 3261 requires UAs MUST NOT send a new registration until they have received a final response",e+=" from the registrar for the previous one or the previous REGISTER request has timed out.",e+=" Note that if the transport disconnects, you still must wait for the prior request to time out before",e+=" sending a new REGISTER request or alternatively dispose of the current Registerer and create a new Registerer.",this.logger.warn("An attempt was made to send a REGISTER request while a prior one was still in progress. RFC 3261 requires UAs MUST NOT send a new registration until they have received a final response from the registrar for the previous one or the previous REGISTER request has timed out. Note that if the transport disconnects, you still must wait for the prior request to time out before sending a new REGISTER request or alternatively dispose of the current Registerer and create a new Registerer.")}stateError(){let e=`An attempt was made to send a REGISTER request when the Registerer ${this.state===ie.Terminated?"is in 'Terminated' state":"has been disposed"}.`;e+=" The Registerer transitions to 'Terminated' when Registerer.dispose() is called.",e+=" Perhaps you called UserAgent.stop() which dipsoses of all Registerers?",this.logger.error(e)}}ge.defaultExpires=600,ge.defaultRefreshFrequency=99,function(e){e.Initial="Initial",e.NotifyWait="NotifyWait",e.Pending="Pending",e.Active="Active",e.Terminated="Terminated"}(re=re||(re={})),function(e){e.Initial="Initial",e.NotifyWait="NotifyWait",e.Subscribed="Subscribed",e.Terminated="Terminated"}(ne=ne||(ne={}));class ue{constructor(e,t={}){this._disposed=!1,this._state=ne.Initial,this._logger=e.getLogger("sip.Subscription"),this._stateEventEmitter=new p,this._userAgent=e,this.delegate=t.delegate}dispose(){return this._disposed||(this._disposed=!0,this._stateEventEmitter.removeAllListeners()),Promise.resolve()}get dialog(){return this._dialog}get disposed(){return this._disposed}get state(){return this._state}get stateChange(){return this._stateEventEmitter}stateTransition(e){const t=()=>{throw new Error(`Invalid state transition from ${this._state} to ${e}`)};switch(this._state){case ne.Initial:e!==ne.NotifyWait&&e!==ne.Terminated&&t();break;case ne.NotifyWait:e!==ne.Subscribed&&e!==ne.Terminated&&t();break;case ne.Subscribed:e!==ne.Terminated&&t();break;case ne.Terminated:t();break;default:throw new Error("Unrecognized state.")}this._state!==e&&(this._state=e,this._logger.log(`Subscription ${this._dialog?this._dialog.id:void 0} transitioned to ${this._state}`),this._stateEventEmitter.emit(this._state),e===ne.Terminated&&this.dispose())}}class pe extends ue{constructor(e,t,s,i={}){super(e,i),this.body=void 0,this.logger=e.getLogger("sip.Subscriber"),i.body&&(this.body={body:i.body,contentType:i.contentType?i.contentType:"application/sdp"}),this.targetURI=t,this.event=s,void 0===i.expires?this.expires=3600:"number"!=typeof i.expires?(this.logger.warn('Option "expires" must be a number. Using default of 3600.'),this.expires=3600):this.expires=i.expires,this.extraHeaders=(i.extraHeaders||[]).slice(),this.subscriberRequest=this.initSubscriberRequest(),this.outgoingRequestMessage=this.subscriberRequest.message,this.id=this.outgoingRequestMessage.callId+this.outgoingRequestMessage.from.parameters.tag+this.event,this._userAgent._subscriptions[this.id]=this}dispose(){return this.disposed?Promise.resolve():(this.logger.log(`Subscription ${this.id} in state ${this.state} is being disposed`),delete this._userAgent._subscriptions[this.id],this.retryAfterTimer&&(clearTimeout(this.retryAfterTimer),this.retryAfterTimer=void 0),this.subscriberRequest.dispose(),super.dispose().then((()=>{if(this.state===ne.Subscribed){if(!this._dialog)throw new Error("Dialog undefined.");if(this._dialog.subscriptionState===re.Pending||this._dialog.subscriptionState===re.Active){const e=this._dialog;return new Promise(((t,s)=>{e.delegate={onTerminated:()=>t()},e.unsubscribe()}))}}})))}subscribe(e={}){switch(this.subscriberRequest.state){case re.Initial:this.state===ne.Initial&&this.stateTransition(ne.NotifyWait),this.subscriberRequest.subscribe().then((e=>{e.success?(e.success.subscription&&(this._dialog=e.success.subscription,this._dialog.delegate={onNotify:e=>this.onNotify(e),onRefresh:e=>this.onRefresh(e),onTerminated:()=>{this.state!==ne.Terminated&&this.stateTransition(ne.Terminated)}}),this.onNotify(e.success.request)):e.failure&&this.unsubscribe()}));break;case re.NotifyWait:case re.Pending:break;case re.Active:if(this._dialog){this._dialog.refresh().delegate={onAccept:e=>this.onAccepted(e),onRedirect:e=>this.unsubscribe(),onReject:e=>this.unsubscribe()}}case re.Terminated:}return Promise.resolve()}unsubscribe(e={}){if(this.disposed)return Promise.resolve();switch(this.subscriberRequest.state){case re.Initial:case re.NotifyWait:break;case re.Pending:case re.Active:this._dialog&&this._dialog.unsubscribe();break;case re.Terminated:break;default:throw new Error("Unknown state.")}return this.stateTransition(ne.Terminated),Promise.resolve()}_refresh(){return this.subscriberRequest.state===re.Active?this.subscribe():Promise.resolve()}onAccepted(e){}onNotify(e){if(this.disposed)return void e.accept();if(this.state!==ne.Subscribed&&this.stateTransition(ne.Subscribed),this.delegate&&this.delegate.onNotify){const t=new K(e);this.delegate.onNotify(t)}else e.accept();const t=e.message.parseHeader("Subscription-State");if(t&&t.state&&"terminated"===t.state){if(t.reason)switch(this.logger.log(`Terminated subscription with reason ${t.reason}`),t.reason){case"deactivated":case"timeout":return this.initSubscriberRequest(),void this.subscribe();case"probation":case"giveup":return this.initSubscriberRequest(),void(t.params&&t.params["retry-after"]?this.retryAfterTimer=setTimeout((()=>{this.subscribe()}),t.params["retry-after"]):this.subscribe())}this.unsubscribe()}}onRefresh(e){e.delegate={onAccept:e=>this.onAccepted(e)}}initSubscriberRequest(){const e={extraHeaders:this.extraHeaders,body:this.body?q(this.body):void 0};return this.subscriberRequest=new fe(this._userAgent.userAgentCore,this.targetURI,this.event,this.expires,e),this.subscriberRequest.delegate={onAccept:e=>this.onAccepted(e)},this.subscriberRequest}}class fe{constructor(e,t,s,i,r,n){this.core=e,this.target=t,this.event=s,this.expires=i,this.subscribed=!1,this.logger=e.loggerFactory.getLogger("sip.Subscriber"),this.delegate=n;const o="Allow: "+V.toString(),a=(r&&r.extraHeaders||[]).slice();a.push(o),a.push("Event: "+this.event),a.push("Expires: "+this.expires),a.push("Contact: "+this.core.configuration.contact.toString());const c=r&&r.body;this.message=e.makeOutgoingRequestMessage(G.SUBSCRIBE,this.target,this.core.configuration.aor,this.target,{},a,c)}dispose(){this.request&&(this.request.waitNotifyStop(),this.request.dispose(),this.request=void 0)}get state(){return this.subscription?this.subscription.subscriptionState:this.subscribed?re.NotifyWait:re.Initial}subscribe(){return this.subscribed?Promise.reject(new Error("Not in initial state. Did you call subscribe more than once?")):(this.subscribed=!0,new Promise((e=>{if(!this.message)throw new Error("Message undefined.");this.request=this.core.subscribe(this.message,{onAccept:e=>{this.delegate&&this.delegate.onAccept&&this.delegate.onAccept(e)},onNotify:t=>{this.subscription=t.subscription,this.subscription&&(this.subscription.autoRefresh=!0),e({success:t})},onNotifyTimeout:()=>{e({failure:{}})},onRedirect:t=>{e({failure:{response:t}})},onReject:t=>{e({failure:{response:t}})}})})))}}!function(e){e.Connecting="Connecting",e.Connected="Connected",e.Disconnecting="Disconnecting",e.Disconnected="Disconnected"}(oe=oe||(oe={})),function(e){e.Started="Started",e.Stopped="Stopped"}(ae=ae||(ae={}));class me{constructor(){this._dataLength=0,this._bufferLength=0,this._state=new Int32Array(4),this._buffer=new ArrayBuffer(68),this._buffer8=new Uint8Array(this._buffer,0,68),this._buffer32=new Uint32Array(this._buffer,0,17),this.start()}static hashStr(e,t=!1){return this.onePassHasher.start().appendStr(e).end(t)}static hashAsciiStr(e,t=!1){return this.onePassHasher.start().appendAsciiStr(e).end(t)}static _hex(e){const t=me.hexChars,s=me.hexOut;let i,r,n,o;for(o=0;o<4;o+=1)for(r=8*o,i=e[o],n=0;n<8;n+=2)s[r+1+n]=t.charAt(15&i),i>>>=4,s[r+0+n]=t.charAt(15&i),i>>>=4;return s.join("")}static _md5cycle(e,t){let s=e[0],i=e[1],r=e[2],n=e[3];s+=(i&r|~i&n)+t[0]-680876936|0,s=(s<<7|s>>>25)+i|0,n+=(s&i|~s&r)+t[1]-389564586|0,n=(n<<12|n>>>20)+s|0,r+=(n&s|~n&i)+t[2]+606105819|0,r=(r<<17|r>>>15)+n|0,i+=(r&n|~r&s)+t[3]-1044525330|0,i=(i<<22|i>>>10)+r|0,s+=(i&r|~i&n)+t[4]-176418897|0,s=(s<<7|s>>>25)+i|0,n+=(s&i|~s&r)+t[5]+1200080426|0,n=(n<<12|n>>>20)+s|0,r+=(n&s|~n&i)+t[6]-1473231341|0,r=(r<<17|r>>>15)+n|0,i+=(r&n|~r&s)+t[7]-45705983|0,i=(i<<22|i>>>10)+r|0,s+=(i&r|~i&n)+t[8]+1770035416|0,s=(s<<7|s>>>25)+i|0,n+=(s&i|~s&r)+t[9]-1958414417|0,n=(n<<12|n>>>20)+s|0,r+=(n&s|~n&i)+t[10]-42063|0,r=(r<<17|r>>>15)+n|0,i+=(r&n|~r&s)+t[11]-1990404162|0,i=(i<<22|i>>>10)+r|0,s+=(i&r|~i&n)+t[12]+1804603682|0,s=(s<<7|s>>>25)+i|0,n+=(s&i|~s&r)+t[13]-40341101|0,n=(n<<12|n>>>20)+s|0,r+=(n&s|~n&i)+t[14]-1502002290|0,r=(r<<17|r>>>15)+n|0,i+=(r&n|~r&s)+t[15]+1236535329|0,i=(i<<22|i>>>10)+r|0,s+=(i&n|r&~n)+t[1]-165796510|0,s=(s<<5|s>>>27)+i|0,n+=(s&r|i&~r)+t[6]-1069501632|0,n=(n<<9|n>>>23)+s|0,r+=(n&i|s&~i)+t[11]+643717713|0,r=(r<<14|r>>>18)+n|0,i+=(r&s|n&~s)+t[0]-373897302|0,i=(i<<20|i>>>12)+r|0,s+=(i&n|r&~n)+t[5]-701558691|0,s=(s<<5|s>>>27)+i|0,n+=(s&r|i&~r)+t[10]+38016083|0,n=(n<<9|n>>>23)+s|0,r+=(n&i|s&~i)+t[15]-660478335|0,r=(r<<14|r>>>18)+n|0,i+=(r&s|n&~s)+t[4]-405537848|0,i=(i<<20|i>>>12)+r|0,s+=(i&n|r&~n)+t[9]+568446438|0,s=(s<<5|s>>>27)+i|0,n+=(s&r|i&~r)+t[14]-1019803690|0,n=(n<<9|n>>>23)+s|0,r+=(n&i|s&~i)+t[3]-187363961|0,r=(r<<14|r>>>18)+n|0,i+=(r&s|n&~s)+t[8]+1163531501|0,i=(i<<20|i>>>12)+r|0,s+=(i&n|r&~n)+t[13]-1444681467|0,s=(s<<5|s>>>27)+i|0,n+=(s&r|i&~r)+t[2]-51403784|0,n=(n<<9|n>>>23)+s|0,r+=(n&i|s&~i)+t[7]+1735328473|0,r=(r<<14|r>>>18)+n|0,i+=(r&s|n&~s)+t[12]-1926607734|0,i=(i<<20|i>>>12)+r|0,s+=(i^r^n)+t[5]-378558|0,s=(s<<4|s>>>28)+i|0,n+=(s^i^r)+t[8]-2022574463|0,n=(n<<11|n>>>21)+s|0,r+=(n^s^i)+t[11]+1839030562|0,r=(r<<16|r>>>16)+n|0,i+=(r^n^s)+t[14]-35309556|0,i=(i<<23|i>>>9)+r|0,s+=(i^r^n)+t[1]-1530992060|0,s=(s<<4|s>>>28)+i|0,n+=(s^i^r)+t[4]+1272893353|0,n=(n<<11|n>>>21)+s|0,r+=(n^s^i)+t[7]-155497632|0,r=(r<<16|r>>>16)+n|0,i+=(r^n^s)+t[10]-1094730640|0,i=(i<<23|i>>>9)+r|0,s+=(i^r^n)+t[13]+681279174|0,s=(s<<4|s>>>28)+i|0,n+=(s^i^r)+t[0]-358537222|0,n=(n<<11|n>>>21)+s|0,r+=(n^s^i)+t[3]-722521979|0,r=(r<<16|r>>>16)+n|0,i+=(r^n^s)+t[6]+76029189|0,i=(i<<23|i>>>9)+r|0,s+=(i^r^n)+t[9]-640364487|0,s=(s<<4|s>>>28)+i|0,n+=(s^i^r)+t[12]-421815835|0,n=(n<<11|n>>>21)+s|0,r+=(n^s^i)+t[15]+530742520|0,r=(r<<16|r>>>16)+n|0,i+=(r^n^s)+t[2]-995338651|0,i=(i<<23|i>>>9)+r|0,s+=(r^(i|~n))+t[0]-198630844|0,s=(s<<6|s>>>26)+i|0,n+=(i^(s|~r))+t[7]+1126891415|0,n=(n<<10|n>>>22)+s|0,r+=(s^(n|~i))+t[14]-1416354905|0,r=(r<<15|r>>>17)+n|0,i+=(n^(r|~s))+t[5]-57434055|0,i=(i<<21|i>>>11)+r|0,s+=(r^(i|~n))+t[12]+1700485571|0,s=(s<<6|s>>>26)+i|0,n+=(i^(s|~r))+t[3]-1894986606|0,n=(n<<10|n>>>22)+s|0,r+=(s^(n|~i))+t[10]-1051523|0,r=(r<<15|r>>>17)+n|0,i+=(n^(r|~s))+t[1]-2054922799|0,i=(i<<21|i>>>11)+r|0,s+=(r^(i|~n))+t[8]+1873313359|0,s=(s<<6|s>>>26)+i|0,n+=(i^(s|~r))+t[15]-30611744|0,n=(n<<10|n>>>22)+s|0,r+=(s^(n|~i))+t[6]-1560198380|0,r=(r<<15|r>>>17)+n|0,i+=(n^(r|~s))+t[13]+1309151649|0,i=(i<<21|i>>>11)+r|0,s+=(r^(i|~n))+t[4]-145523070|0,s=(s<<6|s>>>26)+i|0,n+=(i^(s|~r))+t[11]-1120210379|0,n=(n<<10|n>>>22)+s|0,r+=(s^(n|~i))+t[2]+718787259|0,r=(r<<15|r>>>17)+n|0,i+=(n^(r|~s))+t[9]-343485551|0,i=(i<<21|i>>>11)+r|0,e[0]=s+e[0]|0,e[1]=i+e[1]|0,e[2]=r+e[2]|0,e[3]=n+e[3]|0}start(){return this._dataLength=0,this._bufferLength=0,this._state.set(me.stateIdentity),this}appendStr(e){const t=this._buffer8,s=this._buffer32;let i,r,n=this._bufferLength;for(r=0;r>>6),t[n++]=63&i|128;else if(i<55296||i>56319)t[n++]=224+(i>>>12),t[n++]=i>>>6&63|128,t[n++]=63&i|128;else{if(i=1024*(i-55296)+(e.charCodeAt(++r)-56320)+65536,i>1114111)throw new Error("Unicode standard supports code points up to U+10FFFF");t[n++]=240+(i>>>18),t[n++]=i>>>12&63|128,t[n++]=i>>>6&63|128,t[n++]=63&i|128}n>=64&&(this._dataLength+=64,me._md5cycle(this._state,s),n-=64,s[0]=s[16])}return this._bufferLength=n,this}appendAsciiStr(e){const t=this._buffer8,s=this._buffer32;let i,r=this._bufferLength,n=0;for(;;){for(i=Math.min(e.length-n,64-r);i--;)t[r++]=e.charCodeAt(n++);if(r<64)break;this._dataLength+=64,me._md5cycle(this._state,s),r=0}return this._bufferLength=r,this}appendByteArray(e){const t=this._buffer8,s=this._buffer32;let i,r=this._bufferLength,n=0;for(;;){for(i=Math.min(e.length-n,64-r);i--;)t[r++]=e[n++];if(r<64)break;this._dataLength+=64,me._md5cycle(this._state,s),r=0}return this._bufferLength=r,this}getState(){const e=this,t=e._state;return{buffer:String.fromCharCode.apply(null,e._buffer8),buflen:e._bufferLength,length:e._dataLength,state:[t[0],t[1],t[2],t[3]]}}setState(e){const t=e.buffer,s=e.state,i=this._state;let r;for(this._dataLength=e.length,this._bufferLength=e.buflen,i[0]=s[0],i[1]=s[1],i[2]=s[2],i[3]=s[3],r=0;r>2);let n;if(this._dataLength+=t,s[t]=128,s[t+1]=s[t+2]=s[t+3]=0,i.set(me.buffer32Identity.subarray(r),r),t>55&&(me._md5cycle(this._state,i),i.set(me.buffer32Identity)),n=8*this._dataLength,n<=4294967295)i[14]=n;else{const e=n.toString(16).match(/(.*?)(.{0,8})$/);if(null===e)return;const t=parseInt(e[2],16),s=parseInt(e[1],16)||0;i[14]=t,i[15]=s}return me._md5cycle(this._state,i),e?this._state:me._hex(this._state)}}function ve(e){return me.hashStr(e)}me.stateIdentity=new Int32Array([1732584193,-271733879,-1732584194,271733878]),me.buffer32Identity=new Int32Array([0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]),me.hexChars="0123456789abcdef",me.hexOut=[],me.onePassHasher=new me,"5d41402abc4b2a76b9719d911017c592"!==me.hashStr("hello")&&console.error("Md5 self test failed.");class we{constructor(e,t,s,i){this.logger=e.getLogger("sipjs.digestauthentication"),this.username=s,this.password=i,this.ha1=t,this.nc=0,this.ncHex="00000000"}authenticate(e,t,s){if(this.algorithm=t.algorithm,this.realm=t.realm,this.nonce=t.nonce,this.opaque=t.opaque,this.stale=t.stale,this.algorithm){if("MD5"!==this.algorithm)return this.logger.warn("challenge with Digest algorithm different than 'MD5', authentication aborted"),!1}else this.algorithm="MD5";if(!this.realm)return this.logger.warn("challenge without Digest realm, authentication aborted"),!1;if(!this.nonce)return this.logger.warn("challenge without Digest nonce, authentication aborted"),!1;if(t.qop)if(t.qop.indexOf("auth")>-1)this.qop="auth";else{if(!(t.qop.indexOf("auth-int")>-1))return this.logger.warn("challenge without Digest qop different than 'auth' or 'auth-int', authentication aborted"),!1;this.qop="auth-int"}else this.qop=void 0;return this.method=e.method,this.uri=e.ruri,this.cnonce=$(12),this.nc+=1,this.updateNcHex(),4294967296===this.nc&&(this.nc=1,this.ncHex="00000001"),this.calculateResponse(s),!0}toString(){const e=[];if(!this.response)throw new Error("response field does not exist, cannot generate Authorization header");return e.push("algorithm="+this.algorithm),e.push('username="'+this.username+'"'),e.push('realm="'+this.realm+'"'),e.push('nonce="'+this.nonce+'"'),e.push('uri="'+this.uri+'"'),e.push('response="'+this.response+'"'),this.opaque&&e.push('opaque="'+this.opaque+'"'),this.qop&&(e.push("qop="+this.qop),e.push('cnonce="'+this.cnonce+'"'),e.push("nc="+this.ncHex)),"Digest "+e.join(", ")}updateNcHex(){const e=Number(this.nc).toString(16);this.ncHex="00000000".substr(0,8-e.length)+e}calculateResponse(e){let t,s;t=this.ha1,""!==t&&void 0!==t||(t=ve(this.username+":"+this.realm+":"+this.password)),"auth"===this.qop?(s=ve(this.method+":"+this.uri),this.response=ve(t+":"+this.nonce+":"+this.ncHex+":"+this.cnonce+":auth:"+s)):"auth-int"===this.qop?(s=ve(this.method+":"+this.uri+":"+ve(e||"")),this.response=ve(t+":"+this.nonce+":"+this.ncHex+":"+this.cnonce+":auth-int:"+s)):void 0===this.qop&&(s=ve(this.method+":"+this.uri),this.response=ve(t+":"+this.nonce+":"+s))}}!function(e){e[e.error=0]="error",e[e.warn=1]="warn",e[e.log=2]="log",e[e.debug=3]="debug"}(ce=ce||(ce={}));class be{constructor(e,t,s){this.logger=e,this.category=t,this.label=s}error(e){this.genericLog(ce.error,e)}warn(e){this.genericLog(ce.warn,e)}log(e){this.genericLog(ce.log,e)}debug(e){this.genericLog(ce.debug,e)}genericLog(e,t){this.logger.genericLog(e,this.category,this.label,t)}get level(){return this.logger.level}set level(e){this.logger.level=e}}class Te{constructor(){this.builtinEnabled=!0,this._level=ce.log,this.loggers={},this.logger=this.getLogger("sip:loggerfactory")}get level(){return this._level}set level(e){e>=0&&e<=3?this._level=e:e>3?this._level=3:ce.hasOwnProperty(e)?this._level=e:this.logger.error("invalid 'level' parameter value: "+JSON.stringify(e))}get connector(){return this._connector}set connector(e){e?"function"==typeof e?this._connector=e:this.logger.error("invalid 'connector' parameter value: "+JSON.stringify(e)):this._connector=void 0}getLogger(e,t){if(t&&3===this.level)return new be(this,e,t);if(this.loggers[e])return this.loggers[e];{const t=new be(this,e);return this.loggers[e]=t,t}}genericLog(e,t,s,i){this.level>=e&&this.builtinEnabled&&this.print(e,t,s,i),this.connector&&this.connector(ce[e],t,s,i)}print(e,t,s,i){if("string"==typeof i){const e=[new Date,t];s&&e.push(s),i=e.concat(i).join(" | ")}switch(e){case ce.error:console.error(i);break;case ce.warn:console.warn(i);break;case ce.log:console.log(i);break;case ce.debug:console.debug(i)}}}function Se(e,t){const s="\r\n";if(t.statusCode<100||t.statusCode>699)throw new TypeError("Invalid statusCode: "+t.statusCode);const i=t.reasonPhrase?t.reasonPhrase:I(t.statusCode);let r="SIP/2.0 "+t.statusCode+" "+i+s;t.statusCode>=100&&t.statusCode,t.statusCode;const n="From: "+e.getHeader("From")+s,o="Call-ID: "+e.callId+s,a="CSeq: "+e.cseq+" "+e.method+s,c=e.getHeaders("via").reduce(((e,t)=>e+"Via: "+t+s),"");let h="To: "+e.getHeader("to");if(t.statusCode>100&&!e.parseHeader("to").hasParam("tag")){let e=t.toTag;e||(e=C()),h+=";tag="+e}h+=s;let d="";t.supported&&(d="Supported: "+t.supported.join(", ")+s);let l="";t.userAgent&&(l="User-Agent: "+t.userAgent+s);let g="";return t.extraHeaders&&(g=t.extraHeaders.reduce(((e,t)=>e+t.trim()+s),"")),r+=c,r+=n,r+=h,r+=a,r+=o,r+=d,r+=l,r+=g,t.body?(r+="Content-Type: "+t.body.contentType+s,r+="Content-Length: "+D(t.body.content)+s+s,r+=t.body.content):r+="Content-Length: 0\r\n\r\n",{message:r}}!function(e){function t(e,t){let s=t,i=0,r=0;if(e.substring(s,s+2).match(/(^\r\n)/))return-2;for(;0===i;){if(r=e.indexOf("\r\n",s),-1===r)return r;!e.substring(r+2,r+4).match(/(^\r\n)/)&&e.charAt(r+2).match(/(^\s+)/)?s=r+2:i=r}return i}function s(e,t,s,i){const r=t.indexOf(":",s),n=t.substring(s,r).trim(),o=t.substring(r+1,i).trim();let a;switch(n.toLowerCase()){case"via":case"v":e.addHeader("via",o),1===e.getHeaders("via").length?(a=e.parseHeader("Via"),a&&(e.via=a,e.viaBranch=a.branch)):a=0;break;case"from":case"f":e.setHeader("from",o),a=e.parseHeader("from"),a&&(e.from=a,e.fromTag=a.getParam("tag"));break;case"to":case"t":e.setHeader("to",o),a=e.parseHeader("to"),a&&(e.to=a,e.toTag=a.getParam("tag"));break;case"record-route":if(a=y.parse(o,"Record_Route"),-1===a){a=void 0;break}if(!(a instanceof Array)){a=void 0;break}a.forEach((t=>{e.addHeader("record-route",o.substring(t.position,t.offset)),e.headers["Record-Route"][e.getHeaders("record-route").length-1].parsed=t.parsed}));break;case"call-id":case"i":e.setHeader("call-id",o),a=e.parseHeader("call-id"),a&&(e.callId=o);break;case"contact":case"m":if(a=y.parse(o,"Contact"),-1===a){a=void 0;break}if(!(a instanceof Array)){a=void 0;break}a.forEach((t=>{e.addHeader("contact",o.substring(t.position,t.offset)),e.headers.Contact[e.getHeaders("contact").length-1].parsed=t.parsed}));break;case"content-length":case"l":e.setHeader("content-length",o),a=e.parseHeader("content-length");break;case"content-type":case"c":e.setHeader("content-type",o),a=e.parseHeader("content-type");break;case"cseq":e.setHeader("cseq",o),a=e.parseHeader("cseq"),a&&(e.cseq=a.value),e instanceof x&&(e.method=a.method);break;case"max-forwards":e.setHeader("max-forwards",o),a=e.parseHeader("max-forwards");break;case"www-authenticate":e.setHeader("www-authenticate",o),a=e.parseHeader("www-authenticate");break;case"proxy-authenticate":e.setHeader("proxy-authenticate",o),a=e.parseHeader("proxy-authenticate");break;case"refer-to":case"r":e.setHeader("refer-to",o),a=e.parseHeader("refer-to"),a&&(e.referTo=a);break;default:e.addHeader(n.toLowerCase(),o),a=0}return void 0!==a||{error:"error parsing header '"+n+"'"}}e.getHeader=t,e.parseHeader=s,e.parseMessage=function(e,i){let r=0,n=e.indexOf("\r\n");if(-1===n)return void i.warn("no CRLF found, not a SIP message, discarded");const o=e.substring(0,n),a=y.parse(o,"Request_Response");let c,h;if(-1!==a){for(a.status_code?(c=new x,c.statusCode=a.status_code,c.reasonPhrase=a.reason_phrase):(c=new P,c.method=a.method,c.ruri=a.uri),c.data=e,r=n+2;;){if(n=t(e,r),-2===n){h=r+2;break}if(-1===n)return void i.error("malformed message");const o=s(c,e,r,n);if(o&&!0!==o)return void i.error(o.error);r=n+2}return c.hasHeader("content-length")?c.body=e.substr(h,Number(c.getHeader("content-length"))):c.body=e.substring(h),c}i.warn('error parsing first line of SIP message: "'+o+'"')}}(he=he||(he={}));class Re extends n{constructor(e){super(e||"Unspecified transport error.")}}class ye{constructor(e,t,s,i,r){this._transport=e,this._user=t,this._id=s,this._state=i,this.listeners=new Array,this.logger=t.loggerFactory.getLogger(r,s),this.logger.debug(`Constructing ${this.typeToString()} with id ${this.id}.`)}dispose(){this.logger.debug(`Destroyed ${this.typeToString()} with id ${this.id}.`)}get id(){return this._id}get kind(){throw new Error("Invalid kind.")}get state(){return this._state}get transport(){return this._transport}addStateChangeListener(e,t){const s=()=>{this.removeStateChangeListener(s),e()};!0===(null==t?void 0:t.once)?this.listeners.push(s):this.listeners.push(e)}notifyStateChangeListeners(){this.listeners.slice().forEach((e=>e()))}removeStateChangeListener(e){this.listeners=this.listeners.filter((t=>t!==e))}logTransportError(e,t){this.logger.error(e.message),this.logger.error(`Transport error occurred in ${this.typeToString()} with id ${this.id}.`),this.logger.error(t)}send(e){return this.transport.send(e).catch((e=>{if(e instanceof Re)throw this.onTransportError(e),e;let t;throw t=e&&"string"==typeof e.message?new Re(e.message):new Re,this.onTransportError(t),t}))}setState(e){this.logger.debug(`State change to "${e}" on ${this.typeToString()} with id ${this.id}.`),this._state=e,this._user.onStateChange&&this._user.onStateChange(e),this.notifyStateChangeListeners()}typeToString(){return"UnknownType"}}class Ee extends ye{constructor(e,t,s,i,r){super(t,s,e.viaBranch,i,r),this._request=e,this.user=s}get request(){return this._request}}!function(e){e.Accepted="Accepted",e.Calling="Calling",e.Completed="Completed",e.Confirmed="Confirmed",e.Proceeding="Proceeding",e.Terminated="Terminated",e.Trying="Trying"}(de=de||(de={}));class $e extends Ee{constructor(e,t,s){super(e,t,s,de.Proceeding,"sip.transaction.ist")}dispose(){this.stopProgressExtensionTimer(),this.H&&(clearTimeout(this.H),this.H=void 0),this.I&&(clearTimeout(this.I),this.I=void 0),this.L&&(clearTimeout(this.L),this.L=void 0),super.dispose()}get kind(){return"ist"}receiveRequest(e){switch(this.state){case de.Proceeding:if(e.method===G.INVITE)return void(this.lastProvisionalResponse&&this.send(this.lastProvisionalResponse).catch((e=>{this.logTransportError(e,"Failed to send retransmission of provisional response.")})));break;case de.Accepted:if(e.method===G.INVITE)return;break;case de.Completed:if(e.method===G.INVITE){if(!this.lastFinalResponse)throw new Error("Last final response undefined.");return void this.send(this.lastFinalResponse).catch((e=>{this.logTransportError(e,"Failed to send retransmission of final response.")}))}if(e.method===G.ACK)return void this.stateTransition(de.Confirmed);break;case de.Confirmed:case de.Terminated:if(e.method===G.INVITE||e.method===G.ACK)return;break;default:throw new Error(`Invalid state ${this.state}`)}const t=`INVITE server transaction received unexpected ${e.method} request while in state ${this.state}.`;this.logger.warn(t)}receiveResponse(e,t){if(e<100||e>699)throw new Error(`Invalid status code ${e}`);switch(this.state){case de.Proceeding:if(e>=100&&e<=199)return this.lastProvisionalResponse=t,e>100&&this.startProgressExtensionTimer(),void this.send(t).catch((e=>{this.logTransportError(e,"Failed to send 1xx response.")}));if(e>=200&&e<=299)return this.lastFinalResponse=t,this.stateTransition(de.Accepted),void this.send(t).catch((e=>{this.logTransportError(e,"Failed to send 2xx response.")}));if(e>=300&&e<=699)return this.lastFinalResponse=t,this.stateTransition(de.Completed),void this.send(t).catch((e=>{this.logTransportError(e,"Failed to send non-2xx final response.")}));break;case de.Accepted:if(e>=200&&e<=299)return void this.send(t).catch((e=>{this.logTransportError(e,"Failed to send 2xx response.")}));break;case de.Completed:case de.Confirmed:case de.Terminated:break;default:throw new Error(`Invalid state ${this.state}`)}const s=`INVITE server transaction received unexpected ${e} response from TU while in state ${this.state}.`;throw this.logger.error(s),new Error(s)}retransmitAcceptedResponse(){this.state===de.Accepted&&this.lastFinalResponse&&this.send(this.lastFinalResponse).catch((e=>{this.logTransportError(e,"Failed to send 2xx response.")}))}onTransportError(e){this.user.onTransportError&&this.user.onTransportError(e)}typeToString(){return"INVITE server transaction"}stateTransition(e){const t=()=>{throw new Error(`Invalid state transition from ${this.state} to ${e}`)};switch(e){case de.Proceeding:t();break;case de.Accepted:case de.Completed:this.state!==de.Proceeding&&t();break;case de.Confirmed:this.state!==de.Completed&&t();break;case de.Terminated:this.state!==de.Accepted&&this.state!==de.Completed&&this.state!==de.Confirmed&&t();break;default:t()}this.stopProgressExtensionTimer(),e===de.Accepted&&(this.L=setTimeout((()=>this.timerL()),L.TIMER_L)),e===de.Completed&&(this.H=setTimeout((()=>this.timerH()),L.TIMER_H)),e===de.Confirmed&&(this.I=setTimeout((()=>this.timerI()),L.TIMER_I)),e===de.Terminated&&this.dispose(),this.setState(e)}startProgressExtensionTimer(){void 0===this.progressExtensionTimer&&(this.progressExtensionTimer=setInterval((()=>{if(this.logger.debug(`Progress extension timer expired for INVITE server transaction ${this.id}.`),!this.lastProvisionalResponse)throw new Error("Last provisional response undefined.");this.send(this.lastProvisionalResponse).catch((e=>{this.logTransportError(e,"Failed to send retransmission of provisional response.")}))}),L.PROVISIONAL_RESPONSE_INTERVAL))}stopProgressExtensionTimer(){void 0!==this.progressExtensionTimer&&(clearInterval(this.progressExtensionTimer),this.progressExtensionTimer=void 0)}timerG(){}timerH(){this.logger.debug(`Timer H expired for INVITE server transaction ${this.id}.`),this.state===de.Completed&&(this.logger.warn("ACK to negative final response was never received, terminating transaction."),this.stateTransition(de.Terminated))}timerI(){this.logger.debug(`Timer I expired for INVITE server transaction ${this.id}.`),this.stateTransition(de.Terminated)}timerL(){this.logger.debug(`Timer L expired for INVITE server transaction ${this.id}.`),this.state===de.Accepted&&this.stateTransition(de.Terminated)}}class Ie extends ye{constructor(e,t,s,i,r){super(t,s,Ie.makeId(e),i,r),this._request=e,this.user=s,e.setViaHeader(this.id,t.protocol)}static makeId(e){if("CANCEL"===e.method){if(!e.branch)throw new Error("Outgoing CANCEL request without a branch.");return e.branch}return"z9hG4bK"+Math.floor(1e7*Math.random())}get request(){return this._request}onRequestTimeout(){this.user.onRequestTimeout&&this.user.onRequestTimeout()}}class Ce extends Ie{constructor(e,t,s){super(e,t,s,de.Trying,"sip.transaction.nict"),this.F=setTimeout((()=>this.timerF()),L.TIMER_F),this.send(e.toString()).catch((e=>{this.logTransportError(e,"Failed to send initial outgoing request.")}))}dispose(){this.F&&(clearTimeout(this.F),this.F=void 0),this.K&&(clearTimeout(this.K),this.K=void 0),super.dispose()}get kind(){return"nict"}receiveResponse(e){const t=e.statusCode;if(!t||t<100||t>699)throw new Error(`Invalid status code ${t}`);switch(this.state){case de.Trying:if(t>=100&&t<=199)return this.stateTransition(de.Proceeding),void(this.user.receiveResponse&&this.user.receiveResponse(e));if(t>=200&&t<=699)return this.stateTransition(de.Completed),408===t?void this.onRequestTimeout():void(this.user.receiveResponse&&this.user.receiveResponse(e));break;case de.Proceeding:if(t>=100&&t<=199&&this.user.receiveResponse)return this.user.receiveResponse(e);if(t>=200&&t<=699)return this.stateTransition(de.Completed),408===t?void this.onRequestTimeout():void(this.user.receiveResponse&&this.user.receiveResponse(e));break;case de.Completed:case de.Terminated:return;default:throw new Error(`Invalid state ${this.state}`)}const s=`Non-INVITE client transaction received unexpected ${t} response while in state ${this.state}.`;this.logger.warn(s)}onTransportError(e){this.user.onTransportError&&this.user.onTransportError(e),this.stateTransition(de.Terminated,!0)}typeToString(){return"non-INVITE client transaction"}stateTransition(e,t=!1){const s=()=>{throw new Error(`Invalid state transition from ${this.state} to ${e}`)};switch(e){case de.Trying:s();break;case de.Proceeding:this.state!==de.Trying&&s();break;case de.Completed:this.state!==de.Trying&&this.state!==de.Proceeding&&s();break;case de.Terminated:this.state!==de.Trying&&this.state!==de.Proceeding&&this.state!==de.Completed&&(t||s());break;default:s()}e===de.Completed&&(this.F&&(clearTimeout(this.F),this.F=void 0),this.K=setTimeout((()=>this.timerK()),L.TIMER_K)),e===de.Terminated&&this.dispose(),this.setState(e)}timerF(){this.logger.debug(`Timer F expired for non-INVITE client transaction ${this.id}.`),this.state!==de.Trying&&this.state!==de.Proceeding||(this.onRequestTimeout(),this.stateTransition(de.Terminated))}timerK(){this.state===de.Completed&&this.stateTransition(de.Terminated)}}class Ae{constructor(e,t){this.core=e,this.dialogState=t,this.core.dialogs.set(this.id,this)}static initialDialogStateForUserAgentClient(e,t){const s=t.getHeaders("record-route").reverse(),i=t.parseHeader("contact");if(!i)throw new Error("Contact undefined.");if(!(i instanceof v))throw new Error("Contact not instance of NameAddrHeader.");const r=i.uri,n=e.cseq,o=e.callId,a=e.fromTag,c=t.toTag;if(!o)throw new Error("Call id undefined.");if(!a)throw new Error("From tag undefined.");if(!c)throw new Error("To tag undefined.");if(!e.from)throw new Error("From undefined.");if(!e.to)throw new Error("To undefined.");const h=e.from.uri,d=e.to.uri;if(!t.statusCode)throw new Error("Incoming response status code undefined.");return{id:o+a+c,early:t.statusCode<200,callId:o,localTag:a,remoteTag:c,localSequenceNumber:n,remoteSequenceNumber:undefined,localURI:h,remoteURI:d,remoteTarget:r,routeSet:s,secure:!1}}static initialDialogStateForUserAgentServer(e,t,s=!1){const i=e.getHeaders("record-route"),r=e.parseHeader("contact");if(!r)throw new Error("Contact undefined.");if(!(r instanceof v))throw new Error("Contact not instance of NameAddrHeader.");const n=r.uri,o=e.cseq,a=e.callId,c=t,h=e.fromTag,d=e.from.uri;return{id:a+c+h,early:s,callId:a,localTag:c,remoteTag:h,localSequenceNumber:undefined,remoteSequenceNumber:o,localURI:e.to.uri,remoteURI:d,remoteTarget:n,routeSet:i,secure:!1}}dispose(){this.core.dialogs.delete(this.id)}get id(){return this.dialogState.id}get early(){return this.dialogState.early}get callId(){return this.dialogState.callId}get localTag(){return this.dialogState.localTag}get remoteTag(){return this.dialogState.remoteTag}get localSequenceNumber(){return this.dialogState.localSequenceNumber}get remoteSequenceNumber(){return this.dialogState.remoteSequenceNumber}get localURI(){return this.dialogState.localURI}get remoteURI(){return this.dialogState.remoteURI}get remoteTarget(){return this.dialogState.remoteTarget}get routeSet(){return this.dialogState.routeSet}get secure(){return this.dialogState.secure}get userAgentCore(){return this.core}confirm(){this.dialogState.early=!1}receiveRequest(e){if(e.method!==G.ACK){if(this.remoteSequenceNumber){if(e.cseq<=this.remoteSequenceNumber)throw new Error("Out of sequence in dialog request. Did you forget to call sequenceGuard()?");this.dialogState.remoteSequenceNumber=e.cseq}this.remoteSequenceNumber||(this.dialogState.remoteSequenceNumber=e.cseq)}}recomputeRouteSet(e){this.dialogState.routeSet=e.getHeaders("record-route").reverse()}createOutgoingRequestMessage(e,t){const s=this.remoteURI,i=this.remoteTag,r=this.localURI,n=this.localTag,o=this.callId;let a;a=t&&t.cseq?t.cseq:this.dialogState.localSequenceNumber?this.dialogState.localSequenceNumber+=1:this.dialogState.localSequenceNumber=1;const c=this.remoteTarget,h=this.routeSet,d=t&&t.extraHeaders,l=t&&t.body;return this.userAgentCore.makeOutgoingRequestMessage(e,c,r,s,{callId:o,cseq:a,fromTag:n,toTag:i,routeSet:h},d,l)}incrementLocalSequenceNumber(){if(!this.dialogState.localSequenceNumber)throw new Error("Local sequence number undefined.");this.dialogState.localSequenceNumber+=1}sequenceGuard(e){return e.method===G.ACK||(!(this.remoteSequenceNumber&&e.cseq<=this.remoteSequenceNumber)||(this.core.replyStateless(e,{statusCode:500}),!1))}}class De extends Ie{constructor(e,t,s){super(e,t,s,de.Calling,"sip.transaction.ict"),this.ackRetransmissionCache=new Map,this.B=setTimeout((()=>this.timerB()),L.TIMER_B),this.send(e.toString()).catch((e=>{this.logTransportError(e,"Failed to send initial outgoing request.")}))}dispose(){this.B&&(clearTimeout(this.B),this.B=void 0),this.D&&(clearTimeout(this.D),this.D=void 0),this.M&&(clearTimeout(this.M),this.M=void 0),super.dispose()}get kind(){return"ict"}ackResponse(e){const t=e.toTag;if(!t)throw new Error("To tag undefined.");const s="z9hG4bK"+Math.floor(1e7*Math.random());e.setViaHeader(s,this.transport.protocol),this.ackRetransmissionCache.set(t,e),this.send(e.toString()).catch((e=>{this.logTransportError(e,"Failed to send ACK to 2xx response.")}))}receiveResponse(e){const t=e.statusCode;if(!t||t<100||t>699)throw new Error(`Invalid status code ${t}`);switch(this.state){case de.Calling:if(t>=100&&t<=199)return this.stateTransition(de.Proceeding),void(this.user.receiveResponse&&this.user.receiveResponse(e));if(t>=200&&t<=299)return this.ackRetransmissionCache.set(e.toTag,void 0),this.stateTransition(de.Accepted),void(this.user.receiveResponse&&this.user.receiveResponse(e));if(t>=300&&t<=699)return this.stateTransition(de.Completed),this.ack(e),void(this.user.receiveResponse&&this.user.receiveResponse(e));break;case de.Proceeding:if(t>=100&&t<=199)return void(this.user.receiveResponse&&this.user.receiveResponse(e));if(t>=200&&t<=299)return this.ackRetransmissionCache.set(e.toTag,void 0),this.stateTransition(de.Accepted),void(this.user.receiveResponse&&this.user.receiveResponse(e));if(t>=300&&t<=699)return this.stateTransition(de.Completed),this.ack(e),void(this.user.receiveResponse&&this.user.receiveResponse(e));break;case de.Accepted:if(t>=200&&t<=299){if(!this.ackRetransmissionCache.has(e.toTag))return this.ackRetransmissionCache.set(e.toTag,void 0),void(this.user.receiveResponse&&this.user.receiveResponse(e));const t=this.ackRetransmissionCache.get(e.toTag);return t?void this.send(t.toString()).catch((e=>{this.logTransportError(e,"Failed to send retransmission of ACK to 2xx response.")})):void 0}break;case de.Completed:if(t>=300&&t<=699)return void this.ack(e);break;case de.Terminated:break;default:throw new Error(`Invalid state ${this.state}`)}const s=`Received unexpected ${t} response while in state ${this.state}.`;this.logger.warn(s)}onTransportError(e){this.user.onTransportError&&this.user.onTransportError(e),this.stateTransition(de.Terminated,!0)}typeToString(){return"INVITE client transaction"}ack(e){const t=this.request.ruri,s=this.request.callId,i=this.request.cseq,r=this.request.getHeader("from"),n=e.getHeader("to"),o=this.request.getHeader("via"),a=this.request.getHeader("route");if(!r)throw new Error("From undefined.");if(!n)throw new Error("To undefined.");if(!o)throw new Error("Via undefined.");let c=`ACK ${t} SIP/2.0\r\n`;a&&(c+=`Route: ${a}\r\n`),c+=`Via: ${o}\r\n`,c+=`To: ${n}\r\n`,c+=`From: ${r}\r\n`,c+=`Call-ID: ${s}\r\n`,c+=`CSeq: ${i} ACK\r\n`,c+="Max-Forwards: 70\r\n",c+="Content-Length: 0\r\n\r\n",this.send(c).catch((e=>{this.logTransportError(e,"Failed to send ACK to non-2xx response.")}))}stateTransition(e,t=!1){const s=()=>{throw new Error(`Invalid state transition from ${this.state} to ${e}`)};switch(e){case de.Calling:s();break;case de.Proceeding:this.state!==de.Calling&&s();break;case de.Accepted:case de.Completed:this.state!==de.Calling&&this.state!==de.Proceeding&&s();break;case de.Terminated:this.state!==de.Calling&&this.state!==de.Accepted&&this.state!==de.Completed&&(t||s());break;default:s()}this.B&&(clearTimeout(this.B),this.B=void 0),de.Proceeding,e===de.Completed&&(this.D=setTimeout((()=>this.timerD()),L.TIMER_D)),e===de.Accepted&&(this.M=setTimeout((()=>this.timerM()),L.TIMER_M)),e===de.Terminated&&this.dispose(),this.setState(e)}timerA(){}timerB(){this.logger.debug(`Timer B expired for INVITE client transaction ${this.id}.`),this.state===de.Calling&&(this.onRequestTimeout(),this.stateTransition(de.Terminated))}timerD(){this.logger.debug(`Timer D expired for INVITE client transaction ${this.id}.`),this.state===de.Completed&&this.stateTransition(de.Terminated)}timerM(){this.logger.debug(`Timer M expired for INVITE client transaction ${this.id}.`),this.state===de.Accepted&&this.stateTransition(de.Terminated)}}class He{constructor(e,t,s,i){this.transactionConstructor=e,this.core=t,this.message=s,this.delegate=i,this.challenged=!1,this.stale=!1,this.logger=this.loggerFactory.getLogger("sip.user-agent-client"),this.init()}dispose(){this.transaction.dispose()}get loggerFactory(){return this.core.loggerFactory}get transaction(){if(!this._transaction)throw new Error("Transaction undefined.");return this._transaction}cancel(e,t={}){if(!this.transaction)throw new Error("Transaction undefined.");if(!this.message.to)throw new Error("To undefined.");if(!this.message.from)throw new Error("From undefined.");const s=this.core.makeOutgoingRequestMessage(G.CANCEL,this.message.ruri,this.message.from.uri,this.message.to.uri,{toTag:this.message.toTag,fromTag:this.message.fromTag,callId:this.message.callId,cseq:this.message.cseq},t.extraHeaders);return s.branch=this.message.branch,this.message.headers.Route&&(s.headers.Route=this.message.headers.Route),e&&s.setHeader("Reason",e),this.transaction.state===de.Proceeding?new He(Ce,this.core,s):this.transaction.addStateChangeListener((()=>{this.transaction&&this.transaction.state===de.Proceeding&&new He(Ce,this.core,s)}),{once:!0}),s}authenticationGuard(e,t){const s=e.statusCode;if(!s)throw new Error("Response status code undefined.");if(401!==s&&407!==s)return!0;let i,r;if(401===s?(i=e.parseHeader("www-authenticate"),r="authorization"):(i=e.parseHeader("proxy-authenticate"),r="proxy-authorization"),!i)return this.logger.warn(s+" with wrong or missing challenge, cannot authenticate"),!0;if(this.challenged&&(this.stale||!0!==i.stale))return this.logger.warn(s+" apparently in authentication loop, cannot authenticate"),!0;if(!this.credentials&&(this.credentials=this.core.configuration.authenticationFactory(),!this.credentials))return this.logger.warn("Unable to obtain credentials, cannot authenticate"),!0;if(!this.credentials.authenticate(this.message,i))return!0;this.challenged=!0,i.stale&&(this.stale=!0);let n=this.message.cseq+=1;return t&&t.localSequenceNumber&&(t.incrementLocalSequenceNumber(),n=this.message.cseq=t.localSequenceNumber),this.message.setHeader("cseq",n+" "+this.message.method),this.message.setHeader(r,this.credentials.toString()),this.init(),!1}onRequestTimeout(){this.logger.warn("User agent client request timed out. Generating internal 408 Request Timeout.");const e=new x;e.statusCode=408,e.reasonPhrase="Request Timeout",this.receiveResponse(e)}onTransportError(e){this.logger.error(e.message),this.logger.error("User agent client request transport error. Generating internal 503 Service Unavailable.");const t=new x;t.statusCode=503,t.reasonPhrase="Service Unavailable",this.receiveResponse(t)}receiveResponse(e){if(!this.authenticationGuard(e))return;const t=e.statusCode?e.statusCode.toString():"";if(!t)throw new Error("Response status code undefined.");switch(!0){case/^100$/.test(t):this.delegate&&this.delegate.onTrying&&this.delegate.onTrying({message:e});break;case/^1[0-9]{2}$/.test(t):this.delegate&&this.delegate.onProgress&&this.delegate.onProgress({message:e});break;case/^2[0-9]{2}$/.test(t):this.delegate&&this.delegate.onAccept&&this.delegate.onAccept({message:e});break;case/^3[0-9]{2}$/.test(t):this.delegate&&this.delegate.onRedirect&&this.delegate.onRedirect({message:e});break;case/^[4-6][0-9]{2}$/.test(t):this.delegate&&this.delegate.onReject&&this.delegate.onReject({message:e});break;default:throw new Error(`Invalid status code ${t}`)}}init(){const e={loggerFactory:this.loggerFactory,onRequestTimeout:()=>this.onRequestTimeout(),onStateChange:e=>{e===de.Terminated&&(this.core.userAgentClients.delete(s),t===this._transaction&&this.dispose())},onTransportError:e=>this.onTransportError(e),receiveResponse:e=>this.receiveResponse(e)},t=new this.transactionConstructor(this.message,this.core.transport,e);this._transaction=t;const s=t.id+t.request.method;this.core.userAgentClients.set(s,this)}}class Pe extends He{constructor(e,t,s){const i=e.createOutgoingRequestMessage(G.BYE,s);super(Ce,e.userAgentCore,i,t),e.dispose()}}class xe extends Ee{constructor(e,t,s){super(e,t,s,de.Trying,"sip.transaction.nist")}dispose(){this.J&&(clearTimeout(this.J),this.J=void 0),super.dispose()}get kind(){return"nist"}receiveRequest(e){switch(this.state){case de.Trying:break;case de.Proceeding:if(!this.lastResponse)throw new Error("Last response undefined.");this.send(this.lastResponse).catch((e=>{this.logTransportError(e,"Failed to send retransmission of provisional response.")}));break;case de.Completed:if(!this.lastResponse)throw new Error("Last response undefined.");this.send(this.lastResponse).catch((e=>{this.logTransportError(e,"Failed to send retransmission of final response.")}));break;case de.Terminated:break;default:throw new Error(`Invalid state ${this.state}`)}}receiveResponse(e,t){if(e<100||e>699)throw new Error(`Invalid status code ${e}`);if(e>100&&e<=199)throw new Error("Provisional response other than 100 not allowed.");switch(this.state){case de.Trying:if(this.lastResponse=t,e>=100&&e<200)return this.stateTransition(de.Proceeding),void this.send(t).catch((e=>{this.logTransportError(e,"Failed to send provisional response.")}));if(e>=200&&e<=699)return this.stateTransition(de.Completed),void this.send(t).catch((e=>{this.logTransportError(e,"Failed to send final response.")}));break;case de.Proceeding:if(this.lastResponse=t,e>=200&&e<=699)return this.stateTransition(de.Completed),void this.send(t).catch((e=>{this.logTransportError(e,"Failed to send final response.")}));break;case de.Completed:return;case de.Terminated:break;default:throw new Error(`Invalid state ${this.state}`)}const s=`Non-INVITE server transaction received unexpected ${e} response from TU while in state ${this.state}.`;throw this.logger.error(s),new Error(s)}onTransportError(e){this.user.onTransportError&&this.user.onTransportError(e),this.stateTransition(de.Terminated,!0)}typeToString(){return"non-INVITE server transaction"}stateTransition(e,t=!1){const s=()=>{throw new Error(`Invalid state transition from ${this.state} to ${e}`)};switch(e){case de.Trying:s();break;case de.Proceeding:this.state!==de.Trying&&s();break;case de.Completed:this.state!==de.Trying&&this.state!==de.Proceeding&&s();break;case de.Terminated:this.state!==de.Proceeding&&this.state!==de.Completed&&(t||s());break;default:s()}e===de.Completed&&(this.J=setTimeout((()=>this.timerJ()),L.TIMER_J)),e===de.Terminated&&this.dispose(),this.setState(e)}timerJ(){this.logger.debug(`Timer J expired for NON-INVITE server transaction ${this.id}.`),this.state===de.Completed&&this.stateTransition(de.Terminated)}}class ke{constructor(e,t,s,i){this.transactionConstructor=e,this.core=t,this.message=s,this.delegate=i,this.logger=this.loggerFactory.getLogger("sip.user-agent-server"),this.toTag=s.toTag?s.toTag:C(),this.init()}dispose(){this.transaction.dispose()}get loggerFactory(){return this.core.loggerFactory}get transaction(){if(!this._transaction)throw new Error("Transaction undefined.");return this._transaction}accept(e={statusCode:200}){if(!this.acceptable)throw new B(`${this.message.method} not acceptable in state ${this.transaction.state}.`);const t=e.statusCode;if(t<200||t>299)throw new TypeError(`Invalid statusCode: ${t}`);return this.reply(e)}progress(e={statusCode:180}){if(!this.progressable)throw new B(`${this.message.method} not progressable in state ${this.transaction.state}.`);const t=e.statusCode;if(t<101||t>199)throw new TypeError(`Invalid statusCode: ${t}`);return this.reply(e)}redirect(e,t={statusCode:302}){if(!this.redirectable)throw new B(`${this.message.method} not redirectable in state ${this.transaction.state}.`);const s=t.statusCode;if(s<300||s>399)throw new TypeError(`Invalid statusCode: ${s}`);const i=new Array;e.forEach((e=>i.push(`Contact: ${e.toString()}`))),t.extraHeaders=(t.extraHeaders||[]).concat(i);return this.reply(t)}reject(e={statusCode:480}){if(!this.rejectable)throw new B(`${this.message.method} not rejectable in state ${this.transaction.state}.`);const t=e.statusCode;if(t<400||t>699)throw new TypeError(`Invalid statusCode: ${t}`);return this.reply(e)}trying(e){if(!this.tryingable)throw new B(`${this.message.method} not tryingable in state ${this.transaction.state}.`);return this.reply({statusCode:100})}receiveCancel(e){this.delegate&&this.delegate.onCancel&&this.delegate.onCancel(e)}get acceptable(){if(this.transaction instanceof $e)return this.transaction.state===de.Proceeding||this.transaction.state===de.Accepted;if(this.transaction instanceof xe)return this.transaction.state===de.Trying||this.transaction.state===de.Proceeding;throw new Error("Unknown transaction type.")}get progressable(){if(this.transaction instanceof $e)return this.transaction.state===de.Proceeding;if(this.transaction instanceof xe)return!1;throw new Error("Unknown transaction type.")}get redirectable(){if(this.transaction instanceof $e)return this.transaction.state===de.Proceeding;if(this.transaction instanceof xe)return this.transaction.state===de.Trying||this.transaction.state===de.Proceeding;throw new Error("Unknown transaction type.")}get rejectable(){if(this.transaction instanceof $e)return this.transaction.state===de.Proceeding;if(this.transaction instanceof xe)return this.transaction.state===de.Trying||this.transaction.state===de.Proceeding;throw new Error("Unknown transaction type.")}get tryingable(){if(this.transaction instanceof $e)return this.transaction.state===de.Proceeding;if(this.transaction instanceof xe)return this.transaction.state===de.Trying;throw new Error("Unknown transaction type.")}reply(e){e.toTag||100===e.statusCode||(e.toTag=this.toTag),e.userAgent=e.userAgent||this.core.configuration.userAgentHeaderFieldValue,e.supported=e.supported||this.core.configuration.supportedOptionTagsResponse;const t=Se(this.message,e);return this.transaction.receiveResponse(e.statusCode,t.message),t}init(){const e={loggerFactory:this.loggerFactory,onStateChange:e=>{e===de.Terminated&&(this.core.userAgentServers.delete(s),this.dispose())},onTransportError:e=>{this.logger.error(e.message),this.delegate&&this.delegate.onTransportError?this.delegate.onTransportError(e):this.logger.error("User agent server response transport error.")}},t=new this.transactionConstructor(this.message,this.core.transport,e);this._transaction=t;const s=t.id;this.core.userAgentServers.set(t.id,this)}}class _e extends ke{constructor(e,t,s){super(xe,e.userAgentCore,t,s)}}class qe extends He{constructor(e,t,s){const i=e.createOutgoingRequestMessage(G.INFO,s);super(Ce,e.userAgentCore,i,t)}}class Me extends ke{constructor(e,t,s){super(xe,e.userAgentCore,t,s)}}class Ne extends He{constructor(e,t,s){super(Ce,e,t,s)}}class Oe extends ke{constructor(e,t,s){super(xe,e,t,s)}}class Ue extends He{constructor(e,t,s){const i=e.createOutgoingRequestMessage(G.NOTIFY,s);super(Ce,e.userAgentCore,i,t)}}class je extends ke{constructor(e,t,s){const i=void 0!==e.userAgentCore?e.userAgentCore:e;super(xe,i,t,s)}}class Fe extends He{constructor(e,t,s){const i=e.createOutgoingRequestMessage(G.PRACK,s);super(Ce,e.userAgentCore,i,t),e.signalingStateTransition(i)}}class Le extends ke{constructor(e,t,s){super(xe,e.userAgentCore,t,s),e.signalingStateTransition(t),this.dialog=e}accept(e={statusCode:200}){return e.body&&this.dialog.signalingStateTransition(e.body),super.accept(e)}}class Be extends He{constructor(e,t,s){const i=e.createOutgoingRequestMessage(G.INVITE,s);super(De,e.userAgentCore,i,t),this.delegate=t,e.signalingStateTransition(i),e.reinviteUserAgentClient=this,this.dialog=e}receiveResponse(e){if(!this.authenticationGuard(e,this.dialog))return;const t=e.statusCode?e.statusCode.toString():"";if(!t)throw new Error("Response status code undefined.");switch(!0){case/^100$/.test(t):this.delegate&&this.delegate.onTrying&&this.delegate.onTrying({message:e});break;case/^1[0-9]{2}$/.test(t):this.delegate&&this.delegate.onProgress&&this.delegate.onProgress({message:e,session:this.dialog,prack:e=>{throw new Error("Unimplemented.")}});break;case/^2[0-9]{2}$/.test(t):this.dialog.signalingStateTransition(e),this.delegate&&this.delegate.onAccept&&this.delegate.onAccept({message:e,session:this.dialog,ack:e=>this.dialog.ack(e)});break;case/^3[0-9]{2}$/.test(t):this.dialog.signalingStateRollback(),this.dialog.reinviteUserAgentClient=void 0,this.delegate&&this.delegate.onRedirect&&this.delegate.onRedirect({message:e});break;case/^[4-6][0-9]{2}$/.test(t):this.dialog.signalingStateRollback(),this.dialog.reinviteUserAgentClient=void 0,this.delegate&&this.delegate.onReject&&this.delegate.onReject({message:e});break;default:throw new Error(`Invalid status code ${t}`)}}}class Ge extends ke{constructor(e,t,s){super($e,e.userAgentCore,t,s),e.reinviteUserAgentServer=this,this.dialog=e}accept(e={statusCode:200}){e.extraHeaders=e.extraHeaders||[],e.extraHeaders=e.extraHeaders.concat(this.dialog.routeSet.map((e=>`Record-Route: ${e}`)));const t=super.accept(e),s=this.dialog,i=Object.assign(Object.assign({},t),{session:s});return e.body&&this.dialog.signalingStateTransition(e.body),this.dialog.reConfirm(),i}progress(e={statusCode:180}){const t=super.progress(e),s=this.dialog,i=Object.assign(Object.assign({},t),{session:s});return e.body&&this.dialog.signalingStateTransition(e.body),i}redirect(e,t={statusCode:302}){throw this.dialog.signalingStateRollback(),this.dialog.reinviteUserAgentServer=void 0,new Error("Unimplemented.")}reject(e={statusCode:488}){return this.dialog.signalingStateRollback(),this.dialog.reinviteUserAgentServer=void 0,super.reject(e)}}class Ve extends He{constructor(e,t,s){const i=e.createOutgoingRequestMessage(G.REFER,s);super(Ce,e.userAgentCore,i,t)}}class We extends ke{constructor(e,t,s){const i=void 0!==e.userAgentCore?e.userAgentCore:e;super(xe,i,t,s)}}class Ke extends Ae{constructor(e,t,s,i){super(t,s),this.initialTransaction=e,this._signalingState=U.Initial,this.ackWait=!1,this.ackProcessing=!1,this.delegate=i,e instanceof $e&&(this.ackWait=!0),this.early||this.start2xxRetransmissionTimer(),this.signalingStateTransition(e.request),this.logger=t.loggerFactory.getLogger("sip.invite-dialog"),this.logger.log(`INVITE dialog ${this.id} constructed`)}dispose(){super.dispose(),this._signalingState=U.Closed,this._offer=void 0,this._answer=void 0,this.invite2xxTimer&&(clearTimeout(this.invite2xxTimer),this.invite2xxTimer=void 0),this.logger.log(`INVITE dialog ${this.id} destroyed`)}get sessionState(){return this.early?O.Early:this.ackWait?O.AckWait:this._signalingState===U.Closed?O.Terminated:O.Confirmed}get signalingState(){return this._signalingState}get offer(){return this._offer}get answer(){return this._answer}confirm(){this.early&&this.start2xxRetransmissionTimer(),super.confirm()}reConfirm(){this.reinviteUserAgentServer&&this.startReInvite2xxRetransmissionTimer()}ack(e={}){let t;if(this.logger.log(`INVITE dialog ${this.id} sending ACK request`),this.reinviteUserAgentClient){if(!(this.reinviteUserAgentClient.transaction instanceof De))throw new Error("Transaction not instance of InviteClientTransaction.");t=this.reinviteUserAgentClient.transaction,this.reinviteUserAgentClient=void 0}else{if(!(this.initialTransaction instanceof De))throw new Error("Initial transaction not instance of InviteClientTransaction.");t=this.initialTransaction}const s=this.createOutgoingRequestMessage(G.ACK,{cseq:t.request.cseq,extraHeaders:e.extraHeaders,body:e.body});return t.ackResponse(s),this.signalingStateTransition(s),{message:s}}bye(e,t){if(this.logger.log(`INVITE dialog ${this.id} sending BYE request`),this.initialTransaction instanceof $e){if(this.early)throw new Error("UAS MUST NOT send a BYE on early dialogs.");if(this.ackWait&&this.initialTransaction.state!==de.Terminated)throw new Error("UAS MUST NOT send a BYE on a confirmed dialog until it has received an ACK for its 2xx response or until the server transaction times out.")}return new Pe(this,e,t)}info(e,t){if(this.logger.log(`INVITE dialog ${this.id} sending INFO request`),this.early)throw new Error("Dialog not confirmed.");return new qe(this,e,t)}invite(e,t){if(this.logger.log(`INVITE dialog ${this.id} sending INVITE request`),this.early)throw new Error("Dialog not confirmed.");if(this.reinviteUserAgentClient)throw new Error("There is an ongoing re-INVITE client transaction.");if(this.reinviteUserAgentServer)throw new Error("There is an ongoing re-INVITE server transaction.");return new Be(this,e,t)}message(e,t){if(this.logger.log(`INVITE dialog ${this.id} sending MESSAGE request`),this.early)throw new Error("Dialog not confirmed.");const s=this.createOutgoingRequestMessage(G.MESSAGE,t);return new Ne(this.core,s,e)}notify(e,t){if(this.logger.log(`INVITE dialog ${this.id} sending NOTIFY request`),this.early)throw new Error("Dialog not confirmed.");return new Ue(this,e,t)}prack(e,t){return this.logger.log(`INVITE dialog ${this.id} sending PRACK request`),new Fe(this,e,t)}refer(e,t){if(this.logger.log(`INVITE dialog ${this.id} sending REFER request`),this.early)throw new Error("Dialog not confirmed.");return new Ve(this,e,t)}receiveRequest(e){if(this.logger.log(`INVITE dialog ${this.id} received ${e.method} request`),e.method!==G.ACK)if(this.sequenceGuard(e)){if(super.receiveRequest(e),e.method===G.INVITE){const t=()=>{const e=this.ackWait?"waiting for initial ACK":"processing initial ACK";this.logger.warn(`INVITE dialog ${this.id} received re-INVITE while ${e}`);let t="RFC 5407 suggests the following to avoid this race condition... ";t+=" Note: Implementation issues are outside the scope of this document,",t+=" but the following tip is provided for avoiding race conditions of",t+=" this type. The caller can delay sending re-INVITE F6 for some period",t+=" of time (2 seconds, perhaps), after which the caller can reasonably",t+=" assume that its ACK has been received. Implementors can decouple the",t+=" actions of the user (e.g., pressing the hold button) from the actions",t+=" of the protocol (the sending of re-INVITE F6), so that the UA can",t+=" behave like this. In this case, it is the implementor's choice as to",t+=" how long to wait. In most cases, such an implementation may be",t+=" useful to prevent the type of race condition shown in this section.",t+=" This document expresses no preference about whether or not they",t+=" should wait for an ACK to be delivered. After considering the impact",t+=" on user experience, implementors should decide whether or not to wait",t+=" for a while, because the user experience depends on the",t+=" implementation and has no direct bearing on protocol behavior.",this.logger.warn("RFC 5407 suggests the following to avoid this race condition... Note: Implementation issues are outside the scope of this document, but the following tip is provided for avoiding race conditions of this type. The caller can delay sending re-INVITE F6 for some period of time (2 seconds, perhaps), after which the caller can reasonably assume that its ACK has been received. Implementors can decouple the actions of the user (e.g., pressing the hold button) from the actions of the protocol (the sending of re-INVITE F6), so that the UA can behave like this. In this case, it is the implementor's choice as to how long to wait. In most cases, such an implementation may be useful to prevent the type of race condition shown in this section. This document expresses no preference about whether or not they should wait for an ACK to be delivered. After considering the impact on user experience, implementors should decide whether or not to wait for a while, because the user experience depends on the implementation and has no direct bearing on protocol behavior.")},s=[`Retry-After: ${Math.floor(10*Math.random())+1}`];if(this.ackProcessing)return this.core.replyStateless(e,{statusCode:500,extraHeaders:s}),void t();if(this.ackWait&&this.signalingState!==U.Stable)return this.core.replyStateless(e,{statusCode:500,extraHeaders:s}),void t();if(this.reinviteUserAgentServer)return void this.core.replyStateless(e,{statusCode:500,extraHeaders:s});if(this.reinviteUserAgentClient)return void this.core.replyStateless(e,{statusCode:491})}if(e.method===G.INVITE){const t=e.parseHeader("contact");if(!t)throw new Error("Contact undefined.");if(!(t instanceof v))throw new Error("Contact not instance of NameAddrHeader.");this.dialogState.remoteTarget=t.uri}switch(e.method){case G.BYE:{const t=new _e(this,e);this.delegate&&this.delegate.onBye?this.delegate.onBye(t):t.accept(),this.dispose()}break;case G.INFO:{const t=new Me(this,e);this.delegate&&this.delegate.onInfo?this.delegate.onInfo(t):t.reject({statusCode:469,extraHeaders:["Recv-Info:"]})}break;case G.INVITE:{const t=new Ge(this,e);this.signalingStateTransition(e),this.delegate&&this.delegate.onInvite?this.delegate.onInvite(t):t.reject({statusCode:488})}break;case G.MESSAGE:{const t=new Oe(this.core,e);this.delegate&&this.delegate.onMessage?this.delegate.onMessage(t):t.accept()}break;case G.NOTIFY:{const t=new je(this,e);this.delegate&&this.delegate.onNotify?this.delegate.onNotify(t):t.accept()}break;case G.PRACK:{const t=new Le(this,e);this.delegate&&this.delegate.onPrack?this.delegate.onPrack(t):t.accept()}break;case G.REFER:{const t=new We(this,e);this.delegate&&this.delegate.onRefer?this.delegate.onRefer(t):t.reject()}break;default:this.logger.log(`INVITE dialog ${this.id} received unimplemented ${e.method} request`),this.core.replyStateless(e,{statusCode:501})}}else this.logger.log(`INVITE dialog ${this.id} rejected out of order ${e.method} request.`);else{if(this.ackWait){if(this.initialTransaction instanceof De)return void this.logger.warn(`INVITE dialog ${this.id} received unexpected ${e.method} request, dropping.`);if(this.initialTransaction.request.cseq!==e.cseq)return void this.logger.warn(`INVITE dialog ${this.id} received unexpected ${e.method} request, dropping.`);this.ackWait=!1}else{if(!this.reinviteUserAgentServer)return void this.logger.warn(`INVITE dialog ${this.id} received unexpected ${e.method} request, dropping.`);if(this.reinviteUserAgentServer.transaction.request.cseq!==e.cseq)return void this.logger.warn(`INVITE dialog ${this.id} received unexpected ${e.method} request, dropping.`);this.reinviteUserAgentServer=void 0}if(this.signalingStateTransition(e),this.delegate&&this.delegate.onAck){const t=this.delegate.onAck({message:e});t instanceof Promise&&(this.ackProcessing=!0,t.then((()=>this.ackProcessing=!1)).catch((()=>this.ackProcessing=!1)))}}}reliableSequenceGuard(e){const t=e.statusCode;if(!t)throw new Error("Status code undefined");if(t>100&&t<200){const t=e.getHeader("require"),s=e.getHeader("rseq"),i=t&&t.includes("100rel")&&s?Number(s):void 0;if(i){if(this.rseq&&this.rseq+1!==i)return!1;this.rseq=this.rseq?this.rseq+1:i}}return!0}signalingStateRollback(){this._signalingState!==U.HaveLocalOffer&&this.signalingState!==U.HaveRemoteOffer||this._rollbackOffer&&this._rollbackAnswer&&(this._signalingState=U.Stable,this._offer=this._rollbackOffer,this._answer=this._rollbackAnswer)}signalingStateTransition(e){const t=N(e);if(t&&"session"===t.contentDisposition){if(this._signalingState===U.Stable&&(this._rollbackOffer=this._offer,this._rollbackAnswer=this._answer),e instanceof P)switch(this._signalingState){case U.Initial:case U.Stable:this._signalingState=U.HaveRemoteOffer,this._offer=t,this._answer=void 0;break;case U.HaveLocalOffer:this._signalingState=U.Stable,this._answer=t;break;case U.HaveRemoteOffer:case U.Closed:break;default:throw new Error("Unexpected signaling state.")}if(e instanceof x)switch(this._signalingState){case U.Initial:case U.Stable:this._signalingState=U.HaveRemoteOffer,this._offer=t,this._answer=void 0;break;case U.HaveLocalOffer:this._signalingState=U.Stable,this._answer=t;break;case U.HaveRemoteOffer:case U.Closed:break;default:throw new Error("Unexpected signaling state.")}if(e instanceof k)switch(this._signalingState){case U.Initial:case U.Stable:this._signalingState=U.HaveLocalOffer,this._offer=t,this._answer=void 0;break;case U.HaveLocalOffer:break;case U.HaveRemoteOffer:this._signalingState=U.Stable,this._answer=t;break;case U.Closed:break;default:throw new Error("Unexpected signaling state.")}if(M(e))switch(this._signalingState){case U.Initial:case U.Stable:this._signalingState=U.HaveLocalOffer,this._offer=t,this._answer=void 0;break;case U.HaveLocalOffer:break;case U.HaveRemoteOffer:this._signalingState=U.Stable,this._answer=t;break;case U.Closed:break;default:throw new Error("Unexpected signaling state.")}}}start2xxRetransmissionTimer(){if(this.initialTransaction instanceof $e){const e=this.initialTransaction;let t=L.T1;const s=()=>{this.ackWait?(this.logger.log("No ACK for 2xx response received, attempting retransmission"),e.retransmitAcceptedResponse(),t=Math.min(2*t,L.T2),this.invite2xxTimer=setTimeout(s,t)):this.invite2xxTimer=void 0};this.invite2xxTimer=setTimeout(s,t);const i=()=>{e.state===de.Terminated&&(e.removeStateChangeListener(i),this.invite2xxTimer&&(clearTimeout(this.invite2xxTimer),this.invite2xxTimer=void 0),this.ackWait&&(this.delegate&&this.delegate.onAckTimeout?this.delegate.onAckTimeout():this.bye()))};e.addStateChangeListener(i)}}startReInvite2xxRetransmissionTimer(){if(this.reinviteUserAgentServer&&this.reinviteUserAgentServer.transaction instanceof $e){const e=this.reinviteUserAgentServer.transaction;let t=L.T1;const s=()=>{this.reinviteUserAgentServer?(this.logger.log("No ACK for 2xx response received, attempting retransmission"),e.retransmitAcceptedResponse(),t=Math.min(2*t,L.T2),this.invite2xxTimer=setTimeout(s,t)):this.invite2xxTimer=void 0};this.invite2xxTimer=setTimeout(s,t);const i=()=>{e.state===de.Terminated&&(e.removeStateChangeListener(i),this.invite2xxTimer&&(clearTimeout(this.invite2xxTimer),this.invite2xxTimer=void 0),this.reinviteUserAgentServer)};e.addStateChangeListener(i)}}}class Ye extends He{constructor(e,t,s){super(De,e,t,s),this.confirmedDialogAcks=new Map,this.confirmedDialogs=new Map,this.earlyDialogs=new Map,this.delegate=s}dispose(){this.earlyDialogs.forEach((e=>e.dispose())),this.earlyDialogs.clear(),super.dispose()}onTransportError(e){if(this.transaction.state===de.Calling)return super.onTransportError(e);this.logger.error(e.message),this.logger.error("User agent client request transport error while sending ACK.")}receiveResponse(e){if(!this.authenticationGuard(e))return;const t=e.statusCode?e.statusCode.toString():"";if(!t)throw new Error("Response status code undefined.");switch(!0){case/^100$/.test(t):return void(this.delegate&&this.delegate.onTrying&&this.delegate.onTrying({message:e}));case/^1[0-9]{2}$/.test(t):{if(!e.toTag)return void this.logger.warn("Non-100 1xx INVITE response received without a to tag, dropping.");if(!e.parseHeader("contact"))return void this.logger.error("Non-100 1xx INVITE response received without a Contact header field, dropping.");const t=Ae.initialDialogStateForUserAgentClient(this.message,e);let s=this.earlyDialogs.get(t.id);if(!s){const e=this.transaction;if(!(e instanceof De))throw new Error("Transaction not instance of InviteClientTransaction.");s=new Ke(e,this.core,t),this.earlyDialogs.set(s.id,s)}if(!s.reliableSequenceGuard(e))return void this.logger.warn("1xx INVITE reliable response received out of order or is a retransmission, dropping.");s.signalingState!==U.Initial&&s.signalingState!==U.HaveLocalOffer||s.signalingStateTransition(e);const i=s;this.delegate&&this.delegate.onProgress&&this.delegate.onProgress({message:e,session:i,prack:e=>i.prack(void 0,e)})}return;case/^2[0-9]{2}$/.test(t):{if(!e.toTag)return void this.logger.error("2xx INVITE response received without a to tag, dropping.");if(!e.parseHeader("contact"))return void this.logger.error("2xx INVITE response received without a Contact header field, dropping.");const t=Ae.initialDialogStateForUserAgentClient(this.message,e);let s=this.confirmedDialogs.get(t.id);if(s){const e=this.confirmedDialogAcks.get(t.id);if(e){const t=this.transaction;if(!(t instanceof De))throw new Error("Client transaction not instance of InviteClientTransaction.");t.ackResponse(e.message)}return}if(s=this.earlyDialogs.get(t.id),s)s.confirm(),s.recomputeRouteSet(e),this.earlyDialogs.delete(s.id),this.confirmedDialogs.set(s.id,s);else{const e=this.transaction;if(!(e instanceof De))throw new Error("Transaction not instance of InviteClientTransaction.");s=new Ke(e,this.core,t),this.confirmedDialogs.set(s.id,s)}s.signalingState!==U.Initial&&s.signalingState!==U.HaveLocalOffer||s.signalingStateTransition(e);const i=s;if(this.delegate&&this.delegate.onAccept)this.delegate.onAccept({message:e,session:i,ack:e=>{const t=i.ack(e);return this.confirmedDialogAcks.set(i.id,t),t}});else{const e=i.ack();this.confirmedDialogAcks.set(i.id,e)}}return;case/^3[0-9]{2}$/.test(t):return this.earlyDialogs.forEach((e=>e.dispose())),this.earlyDialogs.clear(),void(this.delegate&&this.delegate.onRedirect&&this.delegate.onRedirect({message:e}));case/^[4-6][0-9]{2}$/.test(t):return this.earlyDialogs.forEach((e=>e.dispose())),this.earlyDialogs.clear(),void(this.delegate&&this.delegate.onReject&&this.delegate.onReject({message:e}));default:throw new Error(`Invalid status code ${t}`)}throw new Error(`Executing what should be an unreachable code path receiving ${t} response.`)}}class Ze extends ke{constructor(e,t,s){super($e,e,t,s),this.core=e}dispose(){this.earlyDialog&&this.earlyDialog.dispose(),super.dispose()}accept(e={statusCode:200}){if(!this.acceptable)throw new B(`${this.message.method} not acceptable in state ${this.transaction.state}.`);if(!this.confirmedDialog)if(this.earlyDialog)this.earlyDialog.confirm(),this.confirmedDialog=this.earlyDialog,this.earlyDialog=void 0;else{const e=this.transaction;if(!(e instanceof $e))throw new Error("Transaction not instance of InviteClientTransaction.");const t=Ae.initialDialogStateForUserAgentServer(this.message,this.toTag);this.confirmedDialog=new Ke(e,this.core,t)}const t=this.message.getHeaders("record-route").map((e=>`Record-Route: ${e}`)),s=`Contact: ${this.core.configuration.contact.toString()}`,i="Allow: "+V.toString();if(!e.body)if(this.confirmedDialog.signalingState===U.Stable)e.body=this.confirmedDialog.answer;else if(this.confirmedDialog.signalingState===U.Initial||this.confirmedDialog.signalingState===U.HaveRemoteOffer)throw new Error("Response must have a body.");e.statusCode=e.statusCode||200,e.extraHeaders=e.extraHeaders||[],e.extraHeaders=e.extraHeaders.concat(t),e.extraHeaders.push(i),e.extraHeaders.push(s);const r=super.accept(e),n=this.confirmedDialog,o=Object.assign(Object.assign({},r),{session:n});return e.body&&this.confirmedDialog.signalingState!==U.Stable&&this.confirmedDialog.signalingStateTransition(e.body),o}progress(e={statusCode:180}){if(!this.progressable)throw new B(`${this.message.method} not progressable in state ${this.transaction.state}.`);if(!this.earlyDialog){const e=this.transaction;if(!(e instanceof $e))throw new Error("Transaction not instance of InviteClientTransaction.");const t=Ae.initialDialogStateForUserAgentServer(this.message,this.toTag,!0);this.earlyDialog=new Ke(e,this.core,t)}const t=this.message.getHeaders("record-route").map((e=>`Record-Route: ${e}`)),s=`Contact: ${this.core.configuration.contact}`;e.extraHeaders=e.extraHeaders||[],e.extraHeaders=e.extraHeaders.concat(t),e.extraHeaders.push(s);const i=super.progress(e),r=this.earlyDialog,n=Object.assign(Object.assign({},i),{session:r});return e.body&&this.earlyDialog.signalingState!==U.Stable&&this.earlyDialog.signalingStateTransition(e.body),n}redirect(e,t={statusCode:302}){return super.redirect(e,t)}reject(e={statusCode:486}){return super.reject(e)}}class Je extends He{constructor(e,t,s){super(Ce,e,t,s)}}class ze extends He{constructor(e,t,s){super(Ce,e,t,s)}}class Xe extends ke{constructor(e,t,s){super(xe,e,t,s),this.core=e}}class Qe extends He{constructor(e,t,s){const i=e.createOutgoingRequestMessage(G.SUBSCRIBE,s);super(Ce,e.userAgentCore,i,t),this.dialog=e}waitNotifyStop(){}receiveResponse(e){if(e.statusCode&&e.statusCode>=200&&e.statusCode<300){const t=e.getHeader("Expires");if(t){const e=Number(t);this.dialog.subscriptionExpires>e&&(this.dialog.subscriptionExpires=e)}else this.logger.warn("Expires header missing in a 200-class response to SUBSCRIBE")}if(e.statusCode&&e.statusCode>=400&&e.statusCode<700){[404,405,410,416,480,481,482,483,484,485,489,501,604].includes(e.statusCode)&&this.dialog.terminate()}super.receiveResponse(e)}}class et extends Ae{constructor(e,t,s,i,r,n){super(i,r),this.delegate=n,this._autoRefresh=!1,this._subscriptionEvent=e,this._subscriptionExpires=t,this._subscriptionExpiresInitial=t,this._subscriptionExpiresLastSet=Math.floor(Date.now()/1e3),this._subscriptionRefresh=void 0,this._subscriptionRefreshLastSet=void 0,this._subscriptionState=s,this.logger=i.loggerFactory.getLogger("sip.subscribe-dialog"),this.logger.log(`SUBSCRIBE dialog ${this.id} constructed`)}static initialDialogStateForSubscription(e,t){const s=t.getHeaders("record-route"),i=t.parseHeader("contact");if(!i)throw new Error("Contact undefined.");if(!(i instanceof v))throw new Error("Contact not instance of NameAddrHeader.");const r=i.uri,n=e.cseq,o=e.callId,a=e.fromTag,c=t.fromTag;if(!o)throw new Error("Call id undefined.");if(!a)throw new Error("From tag undefined.");if(!c)throw new Error("To tag undefined.");if(!e.from)throw new Error("From undefined.");if(!e.to)throw new Error("To undefined.");return{id:o+a+c,early:!1,callId:o,localTag:a,remoteTag:c,localSequenceNumber:n,remoteSequenceNumber:undefined,localURI:e.from.uri,remoteURI:e.to.uri,remoteTarget:r,routeSet:s,secure:!1}}dispose(){super.dispose(),this.N&&(clearTimeout(this.N),this.N=void 0),this.refreshTimerClear(),this.logger.log(`SUBSCRIBE dialog ${this.id} destroyed`)}get autoRefresh(){return this._autoRefresh}set autoRefresh(e){this._autoRefresh=!0,this.refreshTimerSet()}get subscriptionEvent(){return this._subscriptionEvent}get subscriptionExpires(){const e=Math.floor(Date.now()/1e3)-this._subscriptionExpiresLastSet,t=this._subscriptionExpires-e;return Math.max(t,0)}set subscriptionExpires(e){if(e<0)throw new Error("Expires must be greater than or equal to zero.");if(this._subscriptionExpires=e,this._subscriptionExpiresLastSet=Math.floor(Date.now()/1e3),this.autoRefresh){const t=this.subscriptionRefresh;(void 0===t||t>=e)&&this.refreshTimerSet()}}get subscriptionExpiresInitial(){return this._subscriptionExpiresInitial}get subscriptionRefresh(){if(void 0===this._subscriptionRefresh||void 0===this._subscriptionRefreshLastSet)return;const e=Math.floor(Date.now()/1e3)-this._subscriptionRefreshLastSet,t=this._subscriptionRefresh-e;return Math.max(t,0)}get subscriptionState(){return this._subscriptionState}receiveRequest(e){if(this.logger.log(`SUBSCRIBE dialog ${this.id} received ${e.method} request`),this.sequenceGuard(e))if(super.receiveRequest(e),e.method===G.NOTIFY)this.onNotify(e);else this.logger.log(`SUBSCRIBE dialog ${this.id} received unimplemented ${e.method} request`),this.core.replyStateless(e,{statusCode:501});else this.logger.log(`SUBSCRIBE dialog ${this.id} rejected out of order ${e.method} request.`)}refresh(){const e="Allow: "+V.toString(),t={};return t.extraHeaders=(t.extraHeaders||[]).slice(),t.extraHeaders.push(e),t.extraHeaders.push("Event: "+this.subscriptionEvent),t.extraHeaders.push("Expires: "+this.subscriptionExpiresInitial),t.extraHeaders.push("Contact: "+this.core.configuration.contact.toString()),this.subscribe(void 0,t)}subscribe(e,t={}){var s;if(this.subscriptionState!==re.Pending&&this.subscriptionState!==re.Active)throw new Error(`Invalid state ${this.subscriptionState}. May only re-subscribe while in state "pending" or "active".`);this.logger.log(`SUBSCRIBE dialog ${this.id} sending SUBSCRIBE request`);const i=new Qe(this,e,t);return this.N&&(clearTimeout(this.N),this.N=void 0),(null===(s=t.extraHeaders)||void 0===s?void 0:s.includes("Expires: 0"))||(this.N=setTimeout((()=>this.timerN()),L.TIMER_N)),i}terminate(){this.stateTransition(re.Terminated),this.onTerminated()}unsubscribe(){const e="Allow: "+V.toString(),t={};return t.extraHeaders=(t.extraHeaders||[]).slice(),t.extraHeaders.push(e),t.extraHeaders.push("Event: "+this.subscriptionEvent),t.extraHeaders.push("Expires: 0"),t.extraHeaders.push("Contact: "+this.core.configuration.contact.toString()),this.subscribe(void 0,t)}onNotify(e){const t=e.parseHeader("Event").event;if(!t||t!==this.subscriptionEvent)return void this.core.replyStateless(e,{statusCode:489});this.N&&(clearTimeout(this.N),this.N=void 0);const s=e.parseHeader("Subscription-State");if(!s||!s.state)return void this.core.replyStateless(e,{statusCode:489});const i=s.state,r=s.expires?Math.max(s.expires,0):void 0;switch(i){case"pending":this.stateTransition(re.Pending,r);break;case"active":this.stateTransition(re.Active,r);break;case"terminated":this.stateTransition(re.Terminated,r);break;default:this.logger.warn("Unrecognized subscription state.")}const n=new je(this,e);this.delegate&&this.delegate.onNotify?this.delegate.onNotify(n):n.accept()}onRefresh(e){this.delegate&&this.delegate.onRefresh&&this.delegate.onRefresh(e)}onTerminated(){this.delegate&&this.delegate.onTerminated&&this.delegate.onTerminated()}refreshTimerClear(){this.refreshTimer&&(clearTimeout(this.refreshTimer),this.refreshTimer=void 0)}refreshTimerSet(){if(this.refreshTimerClear(),this.autoRefresh&&this.subscriptionExpires>0){const e=900*this.subscriptionExpires;this._subscriptionRefresh=Math.floor(e/1e3),this._subscriptionRefreshLastSet=Math.floor(Date.now()/1e3),this.refreshTimer=setTimeout((()=>{this.refreshTimer=void 0,this._subscriptionRefresh=void 0,this._subscriptionRefreshLastSet=void 0,this.onRefresh(this.refresh())}),e)}}stateTransition(e,t){const s=()=>{this.logger.warn(`Invalid subscription state transition from ${this.subscriptionState} to ${e}`)};switch(e){case re.Initial:case re.NotifyWait:return void s();case re.Pending:if(this.subscriptionState!==re.NotifyWait&&this.subscriptionState!==re.Pending)return void s();break;case re.Active:case re.Terminated:if(this.subscriptionState!==re.NotifyWait&&this.subscriptionState!==re.Pending&&this.subscriptionState!==re.Active)return void s();break;default:return void s()}e===re.Pending&&t&&(this.subscriptionExpires=t),e===re.Active&&t&&(this.subscriptionExpires=t),e===re.Terminated&&this.dispose(),this._subscriptionState=e}timerN(){this.logger.warn("Timer N expired for SUBSCRIBE dialog. Timed out waiting for NOTIFY."),this.subscriptionState!==re.Terminated&&(this.stateTransition(re.Terminated),this.onTerminated())}}class tt extends He{constructor(e,t,s){const i=t.getHeader("Event");if(!i)throw new Error("Event undefined");const r=t.getHeader("Expires");if(!r)throw new Error("Expires undefined");super(Ce,e,t,s),this.delegate=s,this.subscriberId=t.callId+t.fromTag+i,this.subscriptionExpiresRequested=this.subscriptionExpires=Number(r),this.subscriptionEvent=i,this.subscriptionState=re.NotifyWait,this.waitNotifyStart()}dispose(){super.dispose()}onNotify(e){const t=e.message.parseHeader("Event").event;if(!t||t!==this.subscriptionEvent)return this.logger.warn("Failed to parse event."),void e.reject({statusCode:489});const s=e.message.parseHeader("Subscription-State");if(!s||!s.state)return this.logger.warn("Failed to parse subscription state."),void e.reject({statusCode:489});const i=s.state;switch(i){case"pending":case"active":case"terminated":break;default:return this.logger.warn(`Invalid subscription state ${i}`),void e.reject({statusCode:489})}if("terminated"!==i){if(!e.message.parseHeader("contact"))return this.logger.warn("Failed to parse contact."),void e.reject({statusCode:489})}if(this.dialog)throw new Error("Dialog already created. This implementation only supports install of single subscriptions.");switch(this.waitNotifyStop(),this.subscriptionExpires=s.expires?Math.min(this.subscriptionExpires,Math.max(s.expires,0)):this.subscriptionExpires,i){case"pending":this.subscriptionState=re.Pending;break;case"active":this.subscriptionState=re.Active;break;case"terminated":this.subscriptionState=re.Terminated;break;default:throw new Error(`Unrecognized state ${i}.`)}if(this.subscriptionState!==re.Terminated){const t=et.initialDialogStateForSubscription(this.message,e.message);this.dialog=new et(this.subscriptionEvent,this.subscriptionExpires,this.subscriptionState,this.core,t)}if(this.delegate&&this.delegate.onNotify){const t=e,s=this.dialog;this.delegate.onNotify({request:t,subscription:s})}else e.accept()}waitNotifyStart(){this.N||(this.core.subscribers.set(this.subscriberId,this),this.N=setTimeout((()=>this.timerN()),L.TIMER_N))}waitNotifyStop(){this.N&&(this.core.subscribers.delete(this.subscriberId),clearTimeout(this.N),this.N=void 0)}receiveResponse(e){if(this.authenticationGuard(e)){if(e.statusCode&&e.statusCode>=200&&e.statusCode<300){const t=e.getHeader("Expires");if(t){const e=Number(t);e>this.subscriptionExpiresRequested&&this.logger.warn("Expires header in a 200-class response to SUBSCRIBE with a higher value than the one in the request"),ethis.subscriptionExpires&&(this.dialog.subscriptionExpires=this.subscriptionExpires)}e.statusCode&&e.statusCode>=300&&e.statusCode<700&&this.waitNotifyStop(),super.receiveResponse(e)}}timerN(){this.logger.warn("Timer N expired for SUBSCRIBE user agent client. Timed out waiting for NOTIFY."),this.waitNotifyStop(),this.delegate&&this.delegate.onNotifyTimeout&&this.delegate.onNotifyTimeout()}}class st extends ke{constructor(e,t,s){super(xe,e,t,s),this.core=e}}const it=["application/sdp","application/dtmf-relay"];class rt{constructor(e,t={}){this.userAgentClients=new Map,this.userAgentServers=new Map,this.configuration=e,this.delegate=t,this.dialogs=new Map,this.subscribers=new Map,this.logger=e.loggerFactory.getLogger("sip.user-agent-core")}dispose(){this.reset()}reset(){this.dialogs.forEach((e=>e.dispose())),this.dialogs.clear(),this.subscribers.forEach((e=>e.dispose())),this.subscribers.clear(),this.userAgentClients.forEach((e=>e.dispose())),this.userAgentClients.clear(),this.userAgentServers.forEach((e=>e.dispose())),this.userAgentServers.clear()}get loggerFactory(){return this.configuration.loggerFactory}get transport(){const e=this.configuration.transportAccessor();if(!e)throw new Error("Transport undefined.");return e}invite(e,t){return new Ye(this,e,t)}message(e,t){return new Ne(this,e,t)}publish(e,t){return new Je(this,e,t)}register(e,t){return new ze(this,e,t)}subscribe(e,t){return new tt(this,e,t)}request(e,t){return new He(Ce,this,e,t)}makeOutgoingRequestMessage(e,t,s,i,r,n,o){const a=this.configuration.sipjsId,c=this.configuration.displayName,h=this.configuration.viaForceRport,d=this.configuration.hackViaTcp,l=this.configuration.supportedOptionTags.slice();e===G.REGISTER&&l.push("path","gruu"),e===G.INVITE&&(this.configuration.contact.pubGruu||this.configuration.contact.tempGruu)&&l.push("gruu");const g={callIdPrefix:a,forceRport:h,fromDisplayName:c,hackViaTcp:d,optionTags:l,routeSet:this.configuration.routeSet,userAgentString:this.configuration.userAgentHeaderFieldValue,viaHost:this.configuration.viaHost},u=Object.assign(Object.assign({},g),r);return new k(e,t,s,i,u,n,o)}receiveIncomingRequestFromTransport(e){this.receiveRequestFromTransport(e)}receiveIncomingResponseFromTransport(e){this.receiveResponseFromTransport(e)}replyStateless(e,t){const s=this.configuration.userAgentHeaderFieldValue,i=this.configuration.supportedOptionTagsResponse;t=Object.assign(Object.assign({},t),{userAgent:s,supported:i});const r=Se(e,t);return this.transport.send(r.message).catch((t=>{t instanceof Error&&this.logger.error(t.message),this.logger.error(`Transport error occurred sending stateless reply to ${e.method} request.`)})),r}receiveRequestFromTransport(e){const t=e.viaBranch,s=this.userAgentServers.get(t);e.method===G.ACK&&s&&s.transaction.state===de.Accepted&&s instanceof Ze?this.logger.warn(`Discarding out of dialog ACK after 2xx response sent on transaction ${t}.`):e.method!==G.CANCEL?s?s.transaction.receiveRequest(e):this.receiveRequest(e):s?(this.replyStateless(e,{statusCode:200}),s.transaction instanceof $e&&s.transaction.state===de.Proceeding&&s instanceof Ze&&s.receiveCancel(e)):this.replyStateless(e,{statusCode:481})}receiveRequest(e){if(!V.includes(e.method)){const t="Allow: "+V.toString();return void this.replyStateless(e,{statusCode:405,extraHeaders:[t]})}if(!e.ruri)throw new Error("Request-URI undefined.");if("sip"!==e.ruri.scheme)return void this.replyStateless(e,{statusCode:416});const t=e.ruri,s=e=>!!e&&e.user===t.user;if(!s(this.configuration.aor)&&!(s(this.configuration.contact.uri)||s(this.configuration.contact.pubGruu)||s(this.configuration.contact.tempGruu)))return this.logger.warn("Request-URI does not point to us."),void(e.method!==G.ACK&&this.replyStateless(e,{statusCode:404}));if(e.method!==G.INVITE||e.hasHeader("Contact")){if(!e.toTag){const t=e.viaBranch;if(!this.userAgentServers.has(t)){if(Array.from(this.userAgentServers.values()).some((t=>t.transaction.request.fromTag===e.fromTag&&t.transaction.request.callId===e.callId&&t.transaction.request.cseq===e.cseq)))return void this.replyStateless(e,{statusCode:482})}}e.toTag?this.receiveInsideDialogRequest(e):this.receiveOutsideDialogRequest(e)}else this.replyStateless(e,{statusCode:400,reasonPhrase:"Missing Contact Header"})}receiveInsideDialogRequest(e){if(e.method===G.NOTIFY){const t=e.parseHeader("Event");if(!t||!t.event)return void this.replyStateless(e,{statusCode:489});const s=e.callId+e.toTag+t.event,i=this.subscribers.get(s);if(i){const t=new je(this,e);return void i.onNotify(t)}}const t=e.callId+e.toTag+e.fromTag,s=this.dialogs.get(t);if(s){if(e.method===G.OPTIONS){const t="Allow: "+V.toString(),s="Accept: "+it.toString();return void this.replyStateless(e,{statusCode:200,extraHeaders:[t,s]})}s.receiveRequest(e)}else e.method!==G.ACK&&this.replyStateless(e,{statusCode:481})}receiveOutsideDialogRequest(e){switch(e.method){case G.ACK:break;case G.BYE:this.replyStateless(e,{statusCode:481});break;case G.CANCEL:throw new Error(`Unexpected out of dialog request method ${e.method}.`);case G.INFO:this.replyStateless(e,{statusCode:405});break;case G.INVITE:{const t=new Ze(this,e);this.delegate.onInvite?this.delegate.onInvite(t):t.reject()}break;case G.MESSAGE:{const t=new Oe(this,e);this.delegate.onMessage?this.delegate.onMessage(t):t.accept()}break;case G.NOTIFY:{const t=new je(this,e);this.delegate.onNotify?this.delegate.onNotify(t):t.reject({statusCode:405})}break;case G.OPTIONS:{const t="Allow: "+V.toString(),s="Accept: "+it.toString();this.replyStateless(e,{statusCode:200,extraHeaders:[t,s]})}break;case G.REFER:{const t=new We(this,e);this.delegate.onRefer?this.delegate.onRefer(t):t.reject({statusCode:405})}break;case G.REGISTER:{const t=new Xe(this,e);this.delegate.onRegister?this.delegate.onRegister(t):t.reject({statusCode:405})}break;case G.SUBSCRIBE:{const t=new st(this,e);this.delegate.onSubscribe?this.delegate.onSubscribe(t):t.reject({statusCode:480})}break;default:throw new Error(`Unexpected out of dialog request method ${e.method}.`)}}receiveResponseFromTransport(e){if(e.getHeaders("via").length>1)return void this.logger.warn("More than one Via header field present in the response, dropping");const t=e.viaBranch+e.method,s=this.userAgentClients.get(t);s?s.transaction.receiveResponse(e):this.logger.warn(`Discarding unmatched ${e.statusCode} response to ${e.method} ${t}.`)}}function nt(){return e=>e.audio||e.video?void 0===navigator.mediaDevices?Promise.reject(new Error("Media devices not available in insecure contexts.")):navigator.mediaDevices.getUserMedia.call(navigator.mediaDevices,e):Promise.resolve(new MediaStream)}function ot(){return{bundlePolicy:"balanced",certificates:void 0,iceCandidatePoolSize:0,iceServers:[{urls:"stun:stun.l.google.com:19302"}],iceTransportPolicy:"all",rtcpMuxPolicy:"require"}}class at{constructor(e,t,s){e.debug("SessionDescriptionHandler.constructor"),this.logger=e,this.mediaStreamFactory=t,this.sessionDescriptionHandlerConfiguration=s,this._localMediaStream=new MediaStream,this._remoteMediaStream=new MediaStream,this._peerConnection=new RTCPeerConnection(null==s?void 0:s.peerConnectionConfiguration),this.initPeerConnectionEventHandlers()}get localMediaStream(){return this._localMediaStream}get remoteMediaStream(){return this._remoteMediaStream}get dataChannel(){return this._dataChannel}get peerConnection(){return this._peerConnection}get peerConnectionDelegate(){return this._peerConnectionDelegate}set peerConnectionDelegate(e){this._peerConnectionDelegate=e}static dispatchAddTrackEvent(e,t){e.dispatchEvent(new MediaStreamTrackEvent("addtrack",{track:t}))}static dispatchRemoveTrackEvent(e,t){e.dispatchEvent(new MediaStreamTrackEvent("removetrack",{track:t}))}close(){this.logger.debug("SessionDescriptionHandler.close"),void 0!==this._peerConnection&&(this._peerConnection.getReceivers().forEach((e=>{e.track&&e.track.stop()})),this._peerConnection.getSenders().forEach((e=>{e.track&&e.track.stop()})),this._dataChannel&&this._dataChannel.close(),this._peerConnection.close(),this._peerConnection=void 0)}enableReceiverTracks(e){const t=this.peerConnection;if(!t)throw new Error("Peer connection closed.");t.getReceivers().forEach((t=>{t.track&&(t.track.enabled=e)}))}enableSenderTracks(e){const t=this.peerConnection;if(!t)throw new Error("Peer connection closed.");t.getSenders().forEach((t=>{t.track&&(t.track.enabled=e)}))}getDescription(e,t){var s,i;if(this.logger.debug("SessionDescriptionHandler.getDescription"),void 0===this._peerConnection)return Promise.reject(new Error("Peer connection closed."));this.onDataChannel=null==e?void 0:e.onDataChannel;const r=null===(s=null==e?void 0:e.offerOptions)||void 0===s?void 0:s.iceRestart,n=void 0===(null==e?void 0:e.iceGatheringTimeout)?null===(i=this.sessionDescriptionHandlerConfiguration)||void 0===i?void 0:i.iceGatheringTimeout:null==e?void 0:e.iceGatheringTimeout;return this.getLocalMediaStream(e).then((()=>this.updateDirection(e))).then((()=>this.createDataChannel(e))).then((()=>this.createLocalOfferOrAnswer(e))).then((e=>this.applyModifiers(e,t))).then((e=>this.setLocalSessionDescription(e))).then((()=>this.waitForIceGatheringComplete(r,n))).then((()=>this.getLocalSessionDescription())).then((e=>({body:e.sdp,contentType:"application/sdp"}))).catch((e=>{throw this.logger.error("SessionDescriptionHandler.getDescription failed - "+e),e}))}hasDescription(e){return this.logger.debug("SessionDescriptionHandler.hasDescription"),"application/sdp"===e}iceGatheringComplete(){this.logger.debug("SessionDescriptionHandler.iceGatheringComplete"),void 0!==this.iceGatheringCompleteTimeoutId&&(this.logger.debug("SessionDescriptionHandler.iceGatheringComplete - clearing timeout"),clearTimeout(this.iceGatheringCompleteTimeoutId),this.iceGatheringCompleteTimeoutId=void 0),void 0!==this.iceGatheringCompletePromise&&(this.logger.debug("SessionDescriptionHandler.iceGatheringComplete - resolving promise"),this.iceGatheringCompleteResolve&&this.iceGatheringCompleteResolve(),this.iceGatheringCompletePromise=void 0,this.iceGatheringCompleteResolve=void 0,this.iceGatheringCompleteReject=void 0)}sendDtmf(e,t){if(this.logger.debug("SessionDescriptionHandler.sendDtmf"),void 0===this._peerConnection)return this.logger.error("SessionDescriptionHandler.sendDtmf failed - peer connection closed"),!1;const s=this._peerConnection.getSenders();if(0===s.length)return this.logger.error("SessionDescriptionHandler.sendDtmf failed - no senders"),!1;const i=s[0].dtmf;if(!i)return this.logger.error("SessionDescriptionHandler.sendDtmf failed - no DTMF sender"),!1;const r=null==t?void 0:t.duration,n=null==t?void 0:t.interToneGap;try{i.insertDTMF(e,r,n)}catch(e){return this.logger.error(e.toString()),!1}return this.logger.log("SessionDescriptionHandler.sendDtmf sent via RTP: "+e.toString()),!0}setDescription(e,t,s){if(this.logger.debug("SessionDescriptionHandler.setDescription"),void 0===this._peerConnection)return Promise.reject(new Error("Peer connection closed."));this.onDataChannel=null==t?void 0:t.onDataChannel;const i="have-local-offer"===this._peerConnection.signalingState?"answer":"offer";return this.getLocalMediaStream(t).then((()=>this.applyModifiers({sdp:e,type:i},s))).then((e=>this.setRemoteSessionDescription(e))).catch((e=>{throw this.logger.error("SessionDescriptionHandler.setDescription failed - "+e),e}))}applyModifiers(e,t){return this.logger.debug("SessionDescriptionHandler.applyModifiers"),t&&0!==t.length?t.reduce(((e,t)=>e.then(t)),Promise.resolve(e)).then((e=>{if(this.logger.debug("SessionDescriptionHandler.applyModifiers - modified sdp"),!e.sdp||!e.type)throw new Error("Invalid SDP.");return{sdp:e.sdp,type:e.type}})):Promise.resolve(e)}createDataChannel(e){if(void 0===this._peerConnection)return Promise.reject(new Error("Peer connection closed."));if(!0!==(null==e?void 0:e.dataChannel))return Promise.resolve();if(this._dataChannel)return Promise.resolve();switch(this._peerConnection.signalingState){case"stable":this.logger.debug("SessionDescriptionHandler.createDataChannel - creating data channel");try{return this._dataChannel=this._peerConnection.createDataChannel((null==e?void 0:e.dataChannelLabel)||"",null==e?void 0:e.dataChannelOptions),this.onDataChannel&&this.onDataChannel(this._dataChannel),Promise.resolve()}catch(e){return Promise.reject(e)}case"have-remote-offer":return Promise.resolve();default:return Promise.reject(new Error("Invalid signaling state "+this._peerConnection.signalingState))}}createLocalOfferOrAnswer(e){if(void 0===this._peerConnection)return Promise.reject(new Error("Peer connection closed."));switch(this._peerConnection.signalingState){case"stable":return this.logger.debug("SessionDescriptionHandler.createLocalOfferOrAnswer - creating SDP offer"),this._peerConnection.createOffer(null==e?void 0:e.offerOptions);case"have-remote-offer":return this.logger.debug("SessionDescriptionHandler.createLocalOfferOrAnswer - creating SDP answer"),this._peerConnection.createAnswer(null==e?void 0:e.answerOptions);default:return Promise.reject(new Error("Invalid signaling state "+this._peerConnection.signalingState))}}getLocalMediaStream(e){if(this.logger.debug("SessionDescriptionHandler.getLocalMediaStream"),void 0===this._peerConnection)return Promise.reject(new Error("Peer connection closed."));let t=Object.assign({},null==e?void 0:e.constraints);if(this.localMediaStreamConstraints){if(t.audio=t.audio||this.localMediaStreamConstraints.audio,t.video=t.video||this.localMediaStreamConstraints.video,JSON.stringify(this.localMediaStreamConstraints.audio)===JSON.stringify(t.audio)&&JSON.stringify(this.localMediaStreamConstraints.video)===JSON.stringify(t.video))return Promise.resolve()}else void 0===t.audio&&void 0===t.video&&(t={audio:!0});return this.localMediaStreamConstraints=t,this.mediaStreamFactory(t,this,e).then((e=>this.setLocalMediaStream(e)))}setLocalMediaStream(e){if(this.logger.debug("SessionDescriptionHandler.setLocalMediaStream"),!this._peerConnection)throw new Error("Peer connection undefined.");const t=this._peerConnection,s=this._localMediaStream,i=[],r=e=>{const r=e.kind;if("audio"!==r&&"video"!==r)throw new Error(`Unknown new track kind ${r}.`);const n=t.getSenders().find((e=>e.track&&e.track.kind===r));n?i.push(new Promise((e=>{this.logger.debug(`SessionDescriptionHandler.setLocalMediaStream - replacing sender ${r} track`),e()})).then((()=>n.replaceTrack(e).then((()=>{const t=s.getTracks().find((e=>e.kind===r));t&&(t.stop(),s.removeTrack(t),at.dispatchRemoveTrackEvent(s,t)),s.addTrack(e),at.dispatchAddTrackEvent(s,e)})).catch((e=>{throw this.logger.error(`SessionDescriptionHandler.setLocalMediaStream - failed to replace sender ${r} track`),e}))))):i.push(new Promise((e=>{this.logger.debug(`SessionDescriptionHandler.setLocalMediaStream - adding sender ${r} track`),e()})).then((()=>{try{t.addTrack(e,s)}catch(e){throw this.logger.error(`SessionDescriptionHandler.setLocalMediaStream - failed to add sender ${r} track`),e}s.addTrack(e),at.dispatchAddTrackEvent(s,e)})))},n=e.getAudioTracks();n.length&&r(n[0]);const o=e.getVideoTracks();return o.length&&r(o[0]),i.reduce(((e,t)=>e.then((()=>t))),Promise.resolve())}getLocalSessionDescription(){if(this.logger.debug("SessionDescriptionHandler.getLocalSessionDescription"),void 0===this._peerConnection)return Promise.reject(new Error("Peer connection closed."));const e=this._peerConnection.localDescription;return e?Promise.resolve(e):Promise.reject(new Error("Failed to get local session description"))}setLocalSessionDescription(e){return this.logger.debug("SessionDescriptionHandler.setLocalSessionDescription"),void 0===this._peerConnection?Promise.reject(new Error("Peer connection closed.")):this._peerConnection.setLocalDescription(e)}setRemoteSessionDescription(e){if(this.logger.debug("SessionDescriptionHandler.setRemoteSessionDescription"),void 0===this._peerConnection)return Promise.reject(new Error("Peer connection closed."));const t=e.sdp;let s;switch(this._peerConnection.signalingState){case"stable":s="offer";break;case"have-local-offer":s="answer";break;default:return Promise.reject(new Error("Invalid signaling state "+this._peerConnection.signalingState))}return t?this._peerConnection.setRemoteDescription({sdp:t,type:s}):(this.logger.error("SessionDescriptionHandler.setRemoteSessionDescription failed - cannot set null sdp"),Promise.reject(new Error("SDP is undefined")))}setRemoteTrack(e){this.logger.debug("SessionDescriptionHandler.setRemoteTrack");const t=this._remoteMediaStream;t.getTrackById(e.id)?this.logger.debug(`SessionDescriptionHandler.setRemoteTrack - have remote ${e.kind} track`):"audio"===e.kind?(this.logger.debug(`SessionDescriptionHandler.setRemoteTrack - adding remote ${e.kind} track`),t.getAudioTracks().forEach((e=>{e.stop(),t.removeTrack(e),at.dispatchRemoveTrackEvent(t,e)})),t.addTrack(e),at.dispatchAddTrackEvent(t,e)):"video"===e.kind&&(this.logger.debug(`SessionDescriptionHandler.setRemoteTrack - adding remote ${e.kind} track`),t.getVideoTracks().forEach((e=>{e.stop(),t.removeTrack(e),at.dispatchRemoveTrackEvent(t,e)})),t.addTrack(e),at.dispatchAddTrackEvent(t,e))}updateDirection(e){if(void 0===this._peerConnection)return Promise.reject(new Error("Peer connection closed."));switch(this._peerConnection.signalingState){case"stable":this.logger.debug("SessionDescriptionHandler.updateDirection - setting offer direction");{const t=t=>{switch(t){case"inactive":case"recvonly":return(null==e?void 0:e.hold)?"inactive":"recvonly";case"sendonly":case"sendrecv":return(null==e?void 0:e.hold)?"sendonly":"sendrecv";case"stopped":return"stopped";default:throw new Error("Should never happen")}};this._peerConnection.getTransceivers().forEach((e=>{if(e.direction){const s=t(e.direction);e.direction!==s&&(e.direction=s)}}))}break;case"have-remote-offer":this.logger.debug("SessionDescriptionHandler.updateDirection - setting answer direction");{const t=(()=>{const e=this._peerConnection.remoteDescription;if(!e)throw new Error("Failed to read remote offer");const t=/a=sendrecv\r\n|a=sendonly\r\n|a=recvonly\r\n|a=inactive\r\n/.exec(e.sdp);if(t)switch(t[0]){case"a=inactive\r\n":return"inactive";case"a=recvonly\r\n":return"recvonly";case"a=sendonly\r\n":return"sendonly";case"a=sendrecv\r\n":return"sendrecv";default:throw new Error("Should never happen")}return"sendrecv"})(),s=(()=>{switch(t){case"inactive":return"inactive";case"recvonly":return"sendonly";case"sendonly":return(null==e?void 0:e.hold)?"inactive":"recvonly";case"sendrecv":return(null==e?void 0:e.hold)?"sendonly":"sendrecv";default:throw new Error("Should never happen")}})();this._peerConnection.getTransceivers().forEach((e=>{e.direction&&"stopped"!==e.direction&&e.direction!==s&&(e.direction=s)}))}break;default:return Promise.reject(new Error("Invalid signaling state "+this._peerConnection.signalingState))}return Promise.resolve()}waitForIceGatheringComplete(e=!1,t=0){return this.logger.debug("SessionDescriptionHandler.waitForIceGatheringToComplete"),void 0===this._peerConnection?Promise.reject("Peer connection closed."):e||"complete"!==this._peerConnection.iceGatheringState?(void 0!==this.iceGatheringCompletePromise&&(this.logger.debug("SessionDescriptionHandler.waitForIceGatheringToComplete - rejecting prior waiting promise"),this.iceGatheringCompleteReject&&this.iceGatheringCompleteReject(new Error("Promise superseded.")),this.iceGatheringCompletePromise=void 0,this.iceGatheringCompleteResolve=void 0,this.iceGatheringCompleteReject=void 0),this.iceGatheringCompletePromise=new Promise(((e,s)=>{this.iceGatheringCompleteResolve=e,this.iceGatheringCompleteReject=s,t>0&&(this.logger.debug("SessionDescriptionHandler.waitForIceGatheringToComplete - timeout in "+t),this.iceGatheringCompleteTimeoutId=setTimeout((()=>{this.logger.debug("SessionDescriptionHandler.waitForIceGatheringToComplete - timeout"),this.iceGatheringComplete()}),t))})),this.iceGatheringCompletePromise):(this.logger.debug("SessionDescriptionHandler.waitForIceGatheringToComplete - already complete"),Promise.resolve())}initPeerConnectionEventHandlers(){if(this.logger.debug("SessionDescriptionHandler.initPeerConnectionEventHandlers"),!this._peerConnection)throw new Error("Peer connection undefined.");const e=this._peerConnection;e.onconnectionstatechange=t=>{var s;const i=e.connectionState;this.logger.debug(`SessionDescriptionHandler.onconnectionstatechange ${i}`),(null===(s=this._peerConnectionDelegate)||void 0===s?void 0:s.onconnectionstatechange)&&this._peerConnectionDelegate.onconnectionstatechange(t)},e.ondatachannel=e=>{var t;this.logger.debug("SessionDescriptionHandler.ondatachannel"),this._dataChannel=e.channel,this.onDataChannel&&this.onDataChannel(this._dataChannel),(null===(t=this._peerConnectionDelegate)||void 0===t?void 0:t.ondatachannel)&&this._peerConnectionDelegate.ondatachannel(e)},e.onicecandidate=e=>{var t;this.logger.debug("SessionDescriptionHandler.onicecandidate"),(null===(t=this._peerConnectionDelegate)||void 0===t?void 0:t.onicecandidate)&&this._peerConnectionDelegate.onicecandidate(e)},e.onicecandidateerror=e=>{var t;this.logger.debug("SessionDescriptionHandler.onicecandidateerror"),(null===(t=this._peerConnectionDelegate)||void 0===t?void 0:t.onicecandidateerror)&&this._peerConnectionDelegate.onicecandidateerror(e)},e.oniceconnectionstatechange=t=>{var s;const i=e.iceConnectionState;this.logger.debug(`SessionDescriptionHandler.oniceconnectionstatechange ${i}`),(null===(s=this._peerConnectionDelegate)||void 0===s?void 0:s.oniceconnectionstatechange)&&this._peerConnectionDelegate.oniceconnectionstatechange(t)},e.onicegatheringstatechange=t=>{var s;const i=e.iceGatheringState;this.logger.debug(`SessionDescriptionHandler.onicegatheringstatechange ${i}`),"complete"===i&&this.iceGatheringComplete(),(null===(s=this._peerConnectionDelegate)||void 0===s?void 0:s.onicegatheringstatechange)&&this._peerConnectionDelegate.onicegatheringstatechange(t)},e.onnegotiationneeded=e=>{var t;this.logger.debug("SessionDescriptionHandler.onnegotiationneeded"),(null===(t=this._peerConnectionDelegate)||void 0===t?void 0:t.onnegotiationneeded)&&this._peerConnectionDelegate.onnegotiationneeded(e)},e.onsignalingstatechange=t=>{var s;const i=e.signalingState;this.logger.debug(`SessionDescriptionHandler.onsignalingstatechange ${i}`),(null===(s=this._peerConnectionDelegate)||void 0===s?void 0:s.onsignalingstatechange)&&this._peerConnectionDelegate.onsignalingstatechange(t)},e.ontrack=e=>{var t;const s=e.track.kind,i=e.track.enabled?"enabled":"disabled";this.logger.debug(`SessionDescriptionHandler.ontrack ${s} ${i}`),this.setRemoteTrack(e.track),(null===(t=this._peerConnectionDelegate)||void 0===t?void 0:t.ontrack)&&this._peerConnectionDelegate.ontrack(e)}}}function ct(e){return(t,s)=>{void 0===e&&(e=nt());const i={iceGatheringTimeout:void 0!==(null==s?void 0:s.iceGatheringTimeout)?null==s?void 0:s.iceGatheringTimeout:5e3,peerConnectionConfiguration:Object.assign(Object.assign({},{bundlePolicy:"balanced",certificates:void 0,iceCandidatePoolSize:0,iceServers:[{urls:"stun:stun.l.google.com:19302"}],iceTransportPolicy:"all",rtcpMuxPolicy:"require"}),null==s?void 0:s.peerConnectionConfiguration)},r=t.userAgent.getLogger("sip.SessionDescriptionHandler");return new at(r,e,i)}}class ht{constructor(e,t){if(this._state=oe.Disconnected,this.transitioningState=!1,this._stateEventEmitter=new p,this.logger=e,t){const e=t,s=null==e?void 0:e.wsServers,i=null==e?void 0:e.maxReconnectionAttempts;if(void 0!==s){const e='The transport option "wsServers" as has apparently been specified and has been deprecated. It will no longer be available starting with SIP.js release 0.16.0. Please update accordingly.';this.logger.warn(e)}if(void 0!==i){const e='The transport option "maxReconnectionAttempts" as has apparently been specified and has been deprecated. It will no longer be available starting with SIP.js release 0.16.0. Please update accordingly.';this.logger.warn(e)}s&&!t.server&&("string"==typeof s&&(t.server=s),s instanceof Array&&(t.server=s[0]))}this.configuration=Object.assign(Object.assign({},ht.defaultOptions),t);const s=this.configuration.server,i=y.parse(s,"absoluteURI");if(-1===i)throw this.logger.error(`Invalid WebSocket Server URL "${s}"`),new Error("Invalid WebSocket Server URL");if(!["wss","ws","udp"].includes(i.scheme))throw this.logger.error(`Invalid scheme in WebSocket Server URL "${s}"`),new Error("Invalid scheme in WebSocket Server URL");this._protocol=i.scheme.toUpperCase()}dispose(){return this.disconnect()}get protocol(){return this._protocol}get server(){return this.configuration.server}get state(){return this._state}get stateChange(){return this._stateEventEmitter}get ws(){return this._ws}connect(){return this._connect()}disconnect(){return this._disconnect()}isConnected(){return this.state===oe.Connected}send(e){return this._send(e)}_connect(){switch(this.logger.log(`Connecting ${this.server}`),this.state){case oe.Connecting:if(this.transitioningState)return Promise.reject(this.transitionLoopDetectedError(oe.Connecting));if(!this.connectPromise)throw new Error("Connect promise must be defined.");return this.connectPromise;case oe.Connected:if(this.transitioningState)return Promise.reject(this.transitionLoopDetectedError(oe.Connecting));if(this.connectPromise)throw new Error("Connect promise must not be defined.");return Promise.resolve();case oe.Disconnecting:if(this.connectPromise)throw new Error("Connect promise must not be defined.");try{this.transitionState(oe.Connecting)}catch(e){if(e instanceof d)return Promise.reject(e);throw e}break;case oe.Disconnected:if(this.connectPromise)throw new Error("Connect promise must not be defined.");try{this.transitionState(oe.Connecting)}catch(e){if(e instanceof d)return Promise.reject(e);throw e}break;default:throw new Error("Unknown state")}let e;try{e=new WebSocket(this.server,"sip"),e.binaryType="arraybuffer",e.addEventListener("close",(t=>this.onWebSocketClose(t,e))),e.addEventListener("error",(t=>this.onWebSocketError(t,e))),e.addEventListener("open",(t=>this.onWebSocketOpen(t,e))),e.addEventListener("message",(t=>this.onWebSocketMessage(t,e))),this._ws=e}catch(e){return this._ws=void 0,this.logger.error("WebSocket construction failed."),this.logger.error(e.toString()),new Promise(((t,s)=>{this.connectResolve=t,this.connectReject=s,this.transitionState(oe.Disconnected,e)}))}return this.connectPromise=new Promise(((t,s)=>{this.connectResolve=t,this.connectReject=s,this.connectTimeout=setTimeout((()=>{this.logger.warn("Connect timed out. Exceeded time set in configuration.connectionTimeout: "+this.configuration.connectionTimeout+"s."),e.close(1e3)}),1e3*this.configuration.connectionTimeout)})),this.connectPromise}_disconnect(){switch(this.logger.log(`Disconnecting ${this.server}`),this.state){case oe.Connecting:if(this.disconnectPromise)throw new Error("Disconnect promise must not be defined.");try{this.transitionState(oe.Disconnecting)}catch(e){if(e instanceof d)return Promise.reject(e);throw e}break;case oe.Connected:if(this.disconnectPromise)throw new Error("Disconnect promise must not be defined.");try{this.transitionState(oe.Disconnecting)}catch(e){if(e instanceof d)return Promise.reject(e);throw e}break;case oe.Disconnecting:if(this.transitioningState)return Promise.reject(this.transitionLoopDetectedError(oe.Disconnecting));if(!this.disconnectPromise)throw new Error("Disconnect promise must be defined.");return this.disconnectPromise;case oe.Disconnected:if(this.transitioningState)return Promise.reject(this.transitionLoopDetectedError(oe.Disconnecting));if(this.disconnectPromise)throw new Error("Disconnect promise must not be defined.");return Promise.resolve();default:throw new Error("Unknown state")}if(!this._ws)throw new Error("WebSocket must be defined.");const e=this._ws;return this.disconnectPromise=new Promise(((t,s)=>{this.disconnectResolve=t,this.disconnectReject=s;try{e.close(1e3)}catch(e){throw this.logger.error("WebSocket close failed."),this.logger.error(e.toString()),e}})),this.disconnectPromise}_send(e){if(!0===this.configuration.traceSip&&this.logger.log("Sending WebSocket message:\n\n"+e+"\n"),this._state!==oe.Connected)return Promise.reject(new Error("Not connected."));if(!this._ws)throw new Error("WebSocket undefined.");try{this._ws.send(e)}catch(e){return e instanceof Error?Promise.reject(e):Promise.reject(new Error("WebSocket send failed."))}return Promise.resolve()}onWebSocketClose(e,t){if(t!==this._ws)return;const s=`WebSocket closed ${this.server} (code: ${e.code})`,i=this.disconnectPromise?void 0:new Error(s);i&&this.logger.warn("WebSocket closed unexpectedly"),this.logger.log(s),this._ws=void 0,this.transitionState(oe.Disconnected,i)}onWebSocketError(e,t){t===this._ws&&this.logger.error("WebSocket error occurred.")}onWebSocketMessage(e,t){if(t!==this._ws)return;const s=e.data;let i;if(/^(\r\n)+$/.test(s))return this.clearKeepAliveTimeout(),void(!0===this.configuration.traceSip&&this.logger.log("Received WebSocket message with CRLF Keep Alive response"));if(s){if("string"!=typeof s){try{i=(new TextDecoder).decode(new Uint8Array(s))}catch(e){return this.logger.error(e.toString()),void this.logger.error("Received WebSocket binary message failed to be converted into string, message discarded")}!0===this.configuration.traceSip&&this.logger.log("Received WebSocket binary message:\n\n"+i+"\n")}else i=s,!0===this.configuration.traceSip&&this.logger.log("Received WebSocket text message:\n\n"+i+"\n");if(this.state===oe.Connected){if(this.onMessage)try{this.onMessage(i)}catch(e){throw this.logger.error(e.toString()),this.logger.error("Exception thrown by onMessage callback"),e}}else this.logger.warn("Received message while not connected, discarding...")}else this.logger.warn("Received empty message, discarding...")}onWebSocketOpen(e,t){t===this._ws&&this._state===oe.Connecting&&(this.logger.log(`WebSocket opened ${this.server}`),this.transitionState(oe.Connected))}transitionLoopDetectedError(e){let t="A state transition loop has been detected.";return t+=` An attempt to transition from ${this._state} to ${e} before the prior transition completed.`,t+=" Perhaps you are synchronously calling connect() or disconnect() from a callback or state change handler?",this.logger.error(t),new d("Loop detected.")}transitionState(e,t){const s=()=>{throw new Error(`Invalid state transition from ${this._state} to ${e}`)};if(this.transitioningState)throw this.transitionLoopDetectedError(e);switch(this.transitioningState=!0,this._state){case oe.Connecting:e!==oe.Connected&&e!==oe.Disconnecting&&e!==oe.Disconnected&&s();break;case oe.Connected:e!==oe.Disconnecting&&e!==oe.Disconnected&&s();break;case oe.Disconnecting:e!==oe.Connecting&&e!==oe.Disconnected&&s();break;case oe.Disconnected:e!==oe.Connecting&&s();break;default:throw new Error("Unknown state.")}const i=this._state;this._state=e;const r=this.connectResolve,n=this.connectReject;i===oe.Connecting&&(this.connectPromise=void 0,this.connectResolve=void 0,this.connectReject=void 0);const o=this.disconnectResolve,a=this.disconnectReject;if(i===oe.Disconnecting&&(this.disconnectPromise=void 0,this.disconnectResolve=void 0,this.disconnectReject=void 0),this.connectTimeout&&(clearTimeout(this.connectTimeout),this.connectTimeout=void 0),this.logger.log(`Transitioned from ${i} to ${this._state}`),this._stateEventEmitter.emit(this._state),e===oe.Connected&&(this.startSendingKeepAlives(),this.onConnect))try{this.onConnect()}catch(e){throw this.logger.error(e.toString()),this.logger.error("Exception thrown by onConnect callback"),e}if(i===oe.Connected&&(this.stopSendingKeepAlives(),this.onDisconnect))try{t?this.onDisconnect(t):this.onDisconnect()}catch(e){throw this.logger.error(e.toString()),this.logger.error("Exception thrown by onDisconnect callback"),e}if(i===oe.Connecting){if(!r)throw new Error("Connect resolve undefined.");if(!n)throw new Error("Connect reject undefined.");e===oe.Connected?r():n(t||new Error("Connect aborted."))}if(i===oe.Disconnecting){if(!o)throw new Error("Disconnect resolve undefined.");if(!a)throw new Error("Disconnect reject undefined.");e===oe.Disconnected?o():a(t||new Error("Disconnect aborted."))}this.transitioningState=!1}clearKeepAliveTimeout(){this.keepAliveDebounceTimeout&&clearTimeout(this.keepAliveDebounceTimeout),this.keepAliveDebounceTimeout=void 0}sendKeepAlive(){return this.keepAliveDebounceTimeout?Promise.resolve():(this.keepAliveDebounceTimeout=setTimeout((()=>{this.clearKeepAliveTimeout()}),1e3*this.configuration.keepAliveDebounce),this.send("\r\n\r\n"))}startSendingKeepAlives(){this.configuration.keepAliveInterval&&!this.keepAliveInterval&&(this.keepAliveInterval=setInterval((()=>{this.sendKeepAlive(),this.startSendingKeepAlives()}),(e=>{const t=.8*e;return 1e3*(Math.random()*(e-t)+t)})(this.configuration.keepAliveInterval)))}stopSendingKeepAlives(){this.keepAliveInterval&&clearInterval(this.keepAliveInterval),this.keepAliveDebounceTimeout&&clearTimeout(this.keepAliveDebounceTimeout),this.keepAliveInterval=void 0,this.keepAliveDebounceTimeout=void 0}}ht.defaultOptions={server:"",connectionTimeout:5,keepAliveInterval:0,keepAliveDebounce:10,traceSip:!0};class dt{constructor(e={}){if(this._publishers={},this._registerers={},this._sessions={},this._subscriptions={},this._state=ae.Stopped,this._stateEventEmitter=new p,this.delegate=e.delegate,this.options=Object.assign(Object.assign(Object.assign(Object.assign(Object.assign({},dt.defaultOptions()),{sipjsId:$(5)}),{uri:new w("sip","anonymous."+$(6),"anonymous.invalid")}),{viaHost:$(12)+".invalid"}),dt.stripUndefinedProperties(e)),this.options.hackIpInContact)if("boolean"==typeof this.options.hackIpInContact&&this.options.hackIpInContact){const e=1,t=254,s=Math.floor(Math.random()*(t-e+1)+e);this.options.viaHost="192.0.2."+s}else this.options.hackIpInContact&&(this.options.viaHost=this.options.hackIpInContact);switch(this.loggerFactory=new Te,this.logger=this.loggerFactory.getLogger("sip.UserAgent"),this.loggerFactory.builtinEnabled=this.options.logBuiltinEnabled,this.loggerFactory.connector=this.options.logConnector,this.options.logLevel){case"error":this.loggerFactory.level=ce.error;break;case"warn":this.loggerFactory.level=ce.warn;break;case"log":this.loggerFactory.level=ce.log;break;case"debug":this.loggerFactory.level=ce.debug}if(this.options.logConfiguration&&(this.logger.log("Configuration:"),Object.keys(this.options).forEach((e=>{const t=this.options[e];switch(e){case"uri":case"sessionDescriptionHandlerFactory":this.logger.log("\xb7 "+e+": "+t);break;case"authorizationPassword":this.logger.log("\xb7 "+e+": NOT SHOWN");break;case"transportConstructor":this.logger.log("\xb7 "+e+": "+t.name);break;default:this.logger.log("\xb7 "+e+": "+JSON.stringify(t))}}))),this.options.transportOptions){const t=this.options.transportOptions,s=t.maxReconnectionAttempts,i=t.reconnectionTimeout;if(void 0!==s){const e='The transport option "maxReconnectionAttempts" as has apparently been specified and has been deprecated. It will no longer be available starting with SIP.js release 0.16.0. Please update accordingly.';this.logger.warn(e)}if(void 0!==i){const e='The transport option "reconnectionTimeout" as has apparently been specified and has been deprecated. It will no longer be available starting with SIP.js release 0.16.0. Please update accordingly.';this.logger.warn(e)}void 0===e.reconnectionDelay&&void 0!==i&&(this.options.reconnectionDelay=i),void 0===e.reconnectionAttempts&&void 0!==s&&(this.options.reconnectionAttempts=s)}if(void 0!==e.reconnectionDelay){const e='The user agent option "reconnectionDelay" as has apparently been specified and has been deprecated. It will no longer be available starting with SIP.js release 0.16.0. Please update accordingly.';this.logger.warn(e)}if(void 0!==e.reconnectionAttempts){const e='The user agent option "reconnectionAttempts" as has apparently been specified and has been deprecated. It will no longer be available starting with SIP.js release 0.16.0. Please update accordingly.';this.logger.warn(e)}if(this._transport=new this.options.transportConstructor(this.getLogger("sip.Transport"),this.options.transportOptions),this.initTransportCallbacks(),this._contact=this.initContact(),this._instanceId=this.options.instanceId?this.options.instanceId:dt.newUUID(),-1===y.parse(this._instanceId,"uuid"))throw new Error("Invalid instanceId.");this._userAgentCore=this.initCore()}static makeURI(e){return y.URIParse(e)}static defaultOptions(){return{allowLegacyNotifications:!1,authorizationHa1:"",authorizationPassword:"",authorizationUsername:"",delegate:{},contactName:"",contactParams:{transport:"ws"},displayName:"",forceRport:!1,gracefulShutdown:!0,hackAllowUnregisteredOptionTags:!1,hackIpInContact:!1,hackViaTcp:!1,instanceId:"",instanceIdAlwaysAdded:!1,logBuiltinEnabled:!0,logConfiguration:!0,logConnector:()=>{},logLevel:"log",noAnswerTimeout:60,preloadedRouteSet:[],reconnectionAttempts:0,reconnectionDelay:4,sendInitialProvisionalResponse:!0,sessionDescriptionHandlerFactory:ct(),sessionDescriptionHandlerFactoryOptions:{},sipExtension100rel:J.Unsupported,sipExtensionReplaces:J.Unsupported,sipExtensionExtraSupported:[],sipjsId:"",transportConstructor:ht,transportOptions:{},uri:new w("sip","anonymous","anonymous.invalid"),userAgentString:"SIP.js/0.21.2",viaHost:""}}static newUUID(){return"xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g,(e=>{const t=Math.floor(16*Math.random());return("x"===e?t:t%4+8).toString(16)}))}static stripUndefinedProperties(e){return Object.keys(e).reduce(((t,s)=>(void 0!==e[s]&&(t[s]=e[s]),t)),{})}get configuration(){return this.options}get contact(){return this._contact}get instanceId(){return this._instanceId}get state(){return this._state}get stateChange(){return this._stateEventEmitter}get transport(){return this._transport}get userAgentCore(){return this._userAgentCore}getLogger(e,t){return this.loggerFactory.getLogger(e,t)}getLoggerFactory(){return this.loggerFactory}isConnected(){return this.transport.isConnected()}reconnect(){return this.state===ae.Stopped?Promise.reject(new Error("User agent stopped.")):Promise.resolve().then((()=>this.transport.connect()))}start(){return this.state===ae.Started?(this.logger.warn("User agent already started"),Promise.resolve()):(this.logger.log(`Starting ${this.configuration.uri}`),this.transitionState(ae.Started),this.transport.connect())}async stop(){if(this.state===ae.Stopped)return this.logger.warn("User agent already stopped"),Promise.resolve();if(this.logger.log(`Stopping ${this.configuration.uri}`),!this.options.gracefulShutdown)return this.logger.log("Dispose of transport"),this.transport.dispose().catch((e=>{throw this.logger.error(e.message),e})),this.logger.log("Dispose of core"),this.userAgentCore.dispose(),this._publishers={},this._registerers={},this._sessions={},this._subscriptions={},this.transitionState(ae.Stopped),Promise.resolve();const e=Object.assign({},this._publishers),t=Object.assign({},this._registerers),s=Object.assign({},this._sessions),i=Object.assign({},this._subscriptions),r=this.transport,n=this.userAgentCore;this.logger.log("Dispose of registerers");for(const e in t)t[e]&&await t[e].dispose().catch((t=>{throw this.logger.error(t.message),delete this._registerers[e],t}));this.logger.log("Dispose of sessions");for(const e in s)s[e]&&await s[e].dispose().catch((t=>{throw this.logger.error(t.message),delete this._sessions[e],t}));this.logger.log("Dispose of subscriptions");for(const e in i)i[e]&&await i[e].dispose().catch((t=>{throw this.logger.error(t.message),delete this._subscriptions[e],t}));this.logger.log("Dispose of publishers");for(const t in e)e[t]&&await e[t].dispose().catch((e=>{throw this.logger.error(e.message),delete this._publishers[t],e}));this.logger.log("Dispose of transport"),await r.dispose().catch((e=>{throw this.logger.error(e.message),e})),this.logger.log("Dispose of core"),n.dispose(),this.transitionState(ae.Stopped)}_makeInviter(e,t){return new ee(this,e,t)}attemptReconnection(e=1){const t=this.options.reconnectionAttempts,s=this.options.reconnectionDelay;e>t?this.logger.log("Maximum reconnection attempts reached"):(this.logger.log(`Reconnection attempt ${e} of ${t} - trying`),setTimeout((()=>{this.reconnect().then((()=>{this.logger.log(`Reconnection attempt ${e} of ${t} - succeeded`)})).catch((s=>{this.logger.error(s.message),this.logger.log(`Reconnection attempt ${e} of ${t} - failed`),this.attemptReconnection(++e)}))}),1===e?0:1e3*s))}initContact(){const e=""!==this.options.contactName?this.options.contactName:$(8),t=this.options.contactParams;return{pubGruu:void 0,tempGruu:void 0,uri:new w("sip",e,this.options.viaHost,void 0,t),toString:(e={})=>{const s=e.anonymous||!1,i=e.outbound||!1,r=e.register||!1;let n="<";return n+=s?this.contact.tempGruu||`sip:anonymous@anonymous.invalid;transport=${t.transport?t.transport:"ws"}`:r?this.contact.uri:this.contact.pubGruu||this.contact.uri,i&&(n+=";ob"),n+=">",this.options.instanceIdAlwaysAdded&&(n+=';+sip.instance=""'),n}}}initCore(){let e=[];e.push("outbound"),this.options.sipExtension100rel===J.Supported&&e.push("100rel"),this.options.sipExtensionReplaces===J.Supported&&e.push("replaces"),this.options.sipExtensionExtraSupported&&e.push(...this.options.sipExtensionExtraSupported),this.options.hackAllowUnregisteredOptionTags||(e=e.filter((e=>X[e]))),e=Array.from(new Set(e));const t=e.slice();(this.contact.pubGruu||this.contact.tempGruu)&&t.push("gruu");const s={aor:this.options.uri,contact:this.contact,displayName:this.options.displayName,loggerFactory:this.loggerFactory,hackViaTcp:this.options.hackViaTcp,routeSet:this.options.preloadedRouteSet,supportedOptionTags:e,supportedOptionTagsResponse:t,sipjsId:this.options.sipjsId,userAgentHeaderFieldValue:this.options.userAgentString,viaForceRport:this.options.forceRport,viaHost:this.options.viaHost,authenticationFactory:()=>{const e=this.options.authorizationUsername?this.options.authorizationUsername:this.options.uri.user,t=this.options.authorizationPassword?this.options.authorizationPassword:void 0,s=this.options.authorizationHa1?this.options.authorizationHa1:void 0;return new we(this.getLoggerFactory(),s,e,t)},transportAccessor:()=>this.transport};return new rt(s,{onInvite:e=>{var t;const s=new Q(this,e);if(e.delegate={onCancel:e=>{s._onCancel(e)},onTransportError:e=>{this.logger.error("A transport error has occurred while handling an incoming INVITE request.")}},e.trying(),this.options.sipExtensionReplaces!==J.Unsupported){const t=e.message.parseHeader("replaces");if(t){const e=t.call_id;if("string"!=typeof e)throw new Error("Type of call id is not string");const i=t.replaces_to_tag;if("string"!=typeof i)throw new Error("Type of to tag is not string");const r=t.replaces_from_tag;if("string"!=typeof r)throw new Error("type of from tag is not string");const n=e+i+r,o=this.userAgentCore.dialogs.get(n);if(!o)return void s.reject({statusCode:481});if(!o.early&&!0===t.early_only)return void s.reject({statusCode:486});const a=this._sessions[e+r]||this._sessions[e+i]||void 0;if(!a)throw new Error("Session does not exist.");s._replacee=a}}if(null===(t=this.delegate)||void 0===t?void 0:t.onInvite)return s.autoSendAnInitialProvisionalResponse?void s.progress().then((()=>{var e;if(void 0===(null===(e=this.delegate)||void 0===e?void 0:e.onInvite))throw new Error("onInvite undefined.");this.delegate.onInvite(s)})):void this.delegate.onInvite(s);s.reject({statusCode:486})},onMessage:e=>{if(this.delegate&&this.delegate.onMessage){const t=new W(e);this.delegate.onMessage(t)}else e.accept()},onNotify:e=>{if(this.delegate&&this.delegate.onNotify){const t=new K(e);this.delegate.onNotify(t)}else this.options.allowLegacyNotifications?e.accept():e.reject({statusCode:481})},onRefer:e=>{this.logger.warn("Received an out of dialog REFER request"),this.delegate&&this.delegate.onReferRequest?this.delegate.onReferRequest(e):e.reject({statusCode:405})},onRegister:e=>{this.logger.warn("Received an out of dialog REGISTER request"),this.delegate&&this.delegate.onRegisterRequest?this.delegate.onRegisterRequest(e):e.reject({statusCode:405})},onSubscribe:e=>{this.logger.warn("Received an out of dialog SUBSCRIBE request"),this.delegate&&this.delegate.onSubscribeRequest?this.delegate.onSubscribeRequest(e):e.reject({statusCode:405})}})}initTransportCallbacks(){this.transport.onConnect=()=>this.onTransportConnect(),this.transport.onDisconnect=e=>this.onTransportDisconnect(e),this.transport.onMessage=e=>this.onTransportMessage(e)}onTransportConnect(){this.state!==ae.Stopped&&this.delegate&&this.delegate.onConnect&&this.delegate.onConnect()}onTransportDisconnect(e){this.state!==ae.Stopped&&(this.delegate&&this.delegate.onDisconnect&&this.delegate.onDisconnect(e),e&&this.options.reconnectionAttempts>0&&this.attemptReconnection())}onTransportMessage(e){const t=he.parseMessage(e,this.getLogger("sip.Parser"));if(!t)return void this.logger.warn("Failed to parse incoming message. Dropping.");if(this.state===ae.Stopped&&t instanceof P)return void this.logger.warn(`Received ${t.method} request while stopped. Dropping.`);const s=()=>{const e=["from","to","call_id","cseq","via"];for(const s of e)if(!t.hasHeader(s))return this.logger.warn(`Missing mandatory header field : ${s}.`),!1;return!0};if(t instanceof P){if(!s())return void this.logger.warn("Request missing mandatory header field. Dropping.");if(!t.toTag&&t.callId.substr(0,5)===this.options.sipjsId)return void this.userAgentCore.replyStateless(t,{statusCode:482});const e=D(t.body),i=t.getHeader("content-length");if(i&&e1)return void this.logger.warn("More than one Via header field present in the response. Dropping.");if(t.via.host!==this.options.viaHost||void 0!==t.via.port)return void this.logger.warn("Via sent-by in the response does not match UA Via host value. Dropping.");const e=D(t.body),i=t.getHeader("content-length");if(i&&e{throw new Error(`Invalid state transition from ${this._state} to ${e}`)};switch(this._state){case ae.Started:e!==ae.Stopped&&s();break;case ae.Stopped:e!==ae.Started&&s();break;default:throw new Error("Unknown state.")}this.logger.log(`Transitioned from ${this._state} to ${e}`),this._state=e,this._stateEventEmitter.emit(this._state)}}class lt extends He{constructor(e,t,s){super(Ce,e,t,s)}}class gt extends ke{constructor(e,t,s){super(xe,e.userAgentCore,t,s)}}const ut=(e,t)=>{const s=[],i=e.split(/\r\n/);let r;for(let e=0;e(t.sdp=ut(t.sdp||"",e),Promise.resolve(t))}function bt(e){return e.sdp=((e,t)=>{const s=new RegExp("m="+t+".*$","gm"),i=new RegExp("^a=group:.*$","gm");if(s.test(e)){let s;const r=(e=e.split(/^m=/gm).filter((e=>{if(e.substr(0,t.length)===t){if(s=e.match(/^a=mid:.*$/gm),s){const e=s[0].match(/:.+$/g);e&&(s=e[0].substr(1))}return!1}return!0})).join("m=")).match(i);if(r&&1===r.length){let t=r[0];const n=new RegExp(" *"+s+"[^ ]*","g");t=t.replace(n,""),e=e.split(i).join(t)}}return e})(e.sdp||"","video"),Promise.resolve(e)}function Tt(e){let t=e.sdp||"";if(-1===t.search(/^a=mid.*$/gm)){const s=t.match(/^m=.*$/gm),i=t.split(/^m=.*$/gm);s&&s.forEach(((e,t)=>{s[t]=e+"\na=mid:"+t})),i.forEach(((e,t)=>{s&&s[t]&&(i[t]=e+s[t])})),t=i.join(""),e.sdp=t}return Promise.resolve(e)}function St(e){if(!e.sdp||!e.type)throw new Error("Invalid SDP");let t=e.sdp;const s=e.type;return t&&(/a=(sendrecv|sendonly|recvonly|inactive)/.test(t)?(t=t.replace(/a=sendrecv\r\n/g,"a=sendonly\r\n"),t=t.replace(/a=recvonly\r\n/g,"a=inactive\r\n")):t=t.replace(/(m=[^\r]*\r\n)/g,"$1a=sendonly\r\n")),Promise.resolve({sdp:t,type:s})}function Rt(e){if(e.length<2)throw new Error("Start local conference requires at leaast 2 sessions.");var t;(t=e.map((e=>e.sessionDescriptionHandler)),t.map(((e,s)=>t.slice(s+1).map((t=>[e,t])))).reduce(((e,t)=>e.concat(t)),[])).forEach((([e,t])=>{if(!(e instanceof yt&&t instanceof yt))throw new Error("Session description handler not instance of SessionManagerSessionDescriptionHandler");e.joinWith(t)}))}class yt extends at{constructor(e,t,s){super(e,t,s),yt.audioContext||(yt.audioContext=new AudioContext)}enableSenderTracks(e){const t=this.localMediaStreamReal;if(void 0===t)throw new Error("Stream undefined.");t.getAudioTracks().forEach((t=>{t.enabled=e}))}initLocalMediaStream(e){if(!yt.audioContext)throw new Error("SessionManagerSessionDescriptionHandler.audioContext undefined.");return this.localMediaStreamReal=e,this.localMediaStreamSourceNode=yt.audioContext.createMediaStreamSource(e),this.localMediaStreamDestinationNode=yt.audioContext.createMediaStreamDestination(),this.localMediaStreamSourceNode.connect(this.localMediaStreamDestinationNode),this.localMediaStreamDestinationNode.stream}joinWith(e){if(!yt.audioContext)throw new Error("SessionManagerSessionDescriptionHandler.audioContext undefined.");const t=yt.audioContext.createMediaStreamSource(this.remoteMediaStream),s=e.localMediaStreamDestinationNode;if(void 0===s)throw new Error("Peer outbound (local) stream local media stream destination is undefined.");t.connect(s);const i=yt.audioContext.createMediaStreamSource(e.remoteMediaStream),r=this.localMediaStreamDestinationNode;if(void 0===r)throw new Error("Our outbound (local) stream local media stream destination is undefined.");i.connect(r)}setRealLocalMediaStream(e){if(!yt.audioContext)throw new Error("SessionManagerSessionDescriptionHandler.audioContext undefined.");if(this.localMediaStreamReal){if(!this.localMediaStreamDestinationNode||!this.localMediaStreamSourceNode||!this.localMediaStreamReal)throw new Error("Local media stream undefined.");this.localMediaStreamReal=e,this.localMediaStreamSourceNode.disconnect(this.localMediaStreamDestinationNode),this.localMediaStreamSourceNode=yt.audioContext.createMediaStreamSource(e),this.localMediaStreamSourceNode.connect(this.localMediaStreamDestinationNode)}else this.initLocalMediaStream(e)}}function Et(){return(e,t)=>({session:t,held:!1,muted:!1})}class $t{constructor(e,t={}){this.managedSessions=[],this.attemptingReconnection=!1,this.optionsPingFailure=!1,this.optionsPingRunning=!1,this.shouldBeConnected=!1,this.shouldBeRegistered=!1,this.delegate=t.delegate,this.options=Object.assign({aor:"",autoStop:!0,delegate:{},iceStopWaitingOnServerReflexive:!1,managedSessionFactory:(e,t)=>({session:t,held:!1,muted:!1}),maxSimultaneousSessions:2,media:{},optionsPingInterval:-1,optionsPingRequestURI:"",reconnectionAttempts:3,reconnectionDelay:4,registrationRetry:!1,registrationRetryInterval:3,registerGuard:null,registererOptions:{},registererRegisterOptions:{},sendDTMFUsingSessionDescriptionHandler:!1,userAgentOptions:{}},$t.stripUndefinedProperties(t));const s=Object.assign({},t.userAgentOptions);if(s.transportConstructor||(s.transportConstructor=ht),s.transportOptions||(s.transportOptions={server:e}),!s.uri&&t.aor){const e=dt.makeURI(t.aor);if(!e)throw new Error(`Failed to create valid URI from ${t.aor}`);s.uri=e}if(this.userAgent=new dt(s),this.userAgent.delegate={onConnect:()=>{this.logger.log("Connected"),this.delegate&&this.delegate.onServerConnect&&this.delegate.onServerConnect(),this.shouldBeRegistered&&this.register(),this.options.optionsPingInterval>0&&this.optionsPingStart()},onDisconnect:async e=>{this.logger.log("Disconnected");let t=!1;this.options.optionsPingInterval>0&&(t=this.optionsPingFailure,this.optionsPingFailure=!1,this.optionsPingStop()),this.delegate&&this.delegate.onServerDisconnect&&this.delegate.onServerDisconnect(e),(e||t)&&(this.registerer&&(this.logger.log("Disposing of registerer..."),this.registerer.dispose().catch((e=>{this.logger.debug("Error occurred disposing of registerer after connection with server was lost."),this.logger.debug(e.toString())})),this.registerer=void 0),this.managedSessions.slice().map((e=>e.session)).forEach((async e=>{this.logger.log("Disposing of session..."),e.dispose().catch((e=>{this.logger.debug("Error occurred disposing of a session after connection with server was lost."),this.logger.debug(e.toString())}))})),this.shouldBeConnected&&this.attemptReconnection())},onInvite:e=>{this.logger.log(`[${e.id}] Received INVITE`);const t=this.options.maxSimultaneousSessions;if(0!==t&&this.managedSessions.length>t)return this.logger.warn(`[${e.id}] Session already in progress, rejecting INVITE...`),void e.reject().then((()=>{this.logger.log(`[${e.id}] Rejected INVITE`)})).catch((t=>{this.logger.error(`[${e.id}] Failed to reject INVITE`),this.logger.error(t.toString())}));const s={sessionDescriptionHandlerOptions:{constraints:this.constraints}};this.initSession(e,s),this.delegate&&this.delegate.onCallReceived?this.delegate.onCallReceived(e):(this.logger.warn(`[${e.id}] No handler available, rejecting INVITE...`),e.reject().then((()=>{this.logger.log(`[${e.id}] Rejected INVITE`)})).catch((t=>{this.logger.error(`[${e.id}] Failed to reject INVITE`),this.logger.error(t.toString())})))},onMessage:e=>{e.accept().then((()=>{this.delegate&&this.delegate.onMessageReceived&&this.delegate.onMessageReceived(e)}))},onNotify:e=>{e.accept().then((()=>{this.delegate&&this.delegate.onNotificationReceived&&this.delegate.onNotificationReceived(e)}))}},this.registererOptions=Object.assign({},t.registererOptions),this.registererRegisterOptions=Object.assign({},t.registererRegisterOptions),this.options.registrationRetry){this.registererRegisterOptions.requestDelegate=this.registererRegisterOptions.requestDelegate||{};const e=this.registererRegisterOptions.requestDelegate.onReject;this.registererRegisterOptions.requestDelegate.onReject=t=>{e&&e(t),this.attemptRegistration()}}this.logger=this.userAgent.getLogger("sip.SessionManager"),window.addEventListener("online",(()=>{this.logger.log("Online"),this.shouldBeConnected&&this.connect()})),this.options.autoStop&&window.addEventListener("beforeunload",(async()=>{this.shouldBeConnected=!1,this.shouldBeRegistered=!1,this.userAgent.state!==ae.Stopped&&await this.userAgent.stop()}))}static stripUndefinedProperties(e){return Object.keys(e).reduce(((t,s)=>(void 0!==e[s]&&(t[s]=e[s]),t)),{})}getLocalMediaStream(e){const t=e.sessionDescriptionHandler;if(t){if(!(t instanceof at))throw new Error("Session description handler not instance of web SessionDescriptionHandler");return t.localMediaStream}}getRemoteMediaStream(e){const t=e.sessionDescriptionHandler;if(t){if(!(t instanceof at))throw new Error("Session description handler not instance of web SessionDescriptionHandler");return t.remoteMediaStream}}getLocalAudioTrack(e){var t;return null===(t=this.getLocalMediaStream(e))||void 0===t?void 0:t.getTracks().find((e=>"audio"===e.kind))}getLocalVideoTrack(e){var t;return null===(t=this.getLocalMediaStream(e))||void 0===t?void 0:t.getTracks().find((e=>"video"===e.kind))}getRemoteAudioTrack(e){var t;return null===(t=this.getRemoteMediaStream(e))||void 0===t?void 0:t.getTracks().find((e=>"audio"===e.kind))}getRemoteVideoTrack(e){var t;return null===(t=this.getRemoteMediaStream(e))||void 0===t?void 0:t.getTracks().find((e=>"video"===e.kind))}async connect(){return this.logger.log("Connecting UserAgent..."),this.shouldBeConnected=!0,this.userAgent.state!==ae.Started?this.userAgent.start():this.userAgent.reconnect()}async disconnect(){return this.logger.log("Disconnecting UserAgent..."),this.userAgent.state===ae.Stopped?Promise.resolve():(this.shouldBeConnected=!1,this.shouldBeRegistered=!1,this.registerer=void 0,this.userAgent.stop())}isConnected(){return this.userAgent.isConnected()}async register(e){return this.logger.log("Registering UserAgent..."),this.shouldBeRegistered=!0,void 0!==e&&(this.registererRegisterOptions=Object.assign({},e)),this.registerer||(this.registerer=new ge(this.userAgent,this.registererOptions),this.registerer.stateChange.addListener((e=>{switch(e){case ie.Initial:break;case ie.Registered:this.delegate&&this.delegate.onRegistered&&this.delegate.onRegistered();break;case ie.Unregistered:this.delegate&&this.delegate.onUnregistered&&this.delegate.onUnregistered(),this.shouldBeRegistered&&this.attemptRegistration();break;case ie.Terminated:break;default:throw new Error("Unknown registerer state.")}}))),this.attemptRegistration(!0)}async unregister(e){return this.logger.log("Unregistering UserAgent..."),this.shouldBeRegistered=!1,this.registerer?this.registerer.unregister(e).then((()=>{})):(this.logger.warn("No registerer to unregister."),Promise.resolve())}async call(e,t,s){this.logger.log("Beginning Session...");const i=this.options.maxSimultaneousSessions;if(0!==i&&this.managedSessions.length>i)return Promise.reject(new Error("Maximum number of sessions already exists."));const r=dt.makeURI(e);if(!r)return Promise.reject(new Error(`Failed to create a valid URI from "${e}"`));if(t||(t={}),t.sessionDescriptionHandlerOptions||(t.sessionDescriptionHandlerOptions={}),t.sessionDescriptionHandlerOptions.constraints||(t.sessionDescriptionHandlerOptions.constraints=this.constraints),t.earlyMedia){(s=s||{}).requestDelegate=s.requestDelegate||{};const e=s.requestDelegate.onProgress;s.requestDelegate.onProgress=t=>{183===t.message.statusCode&&this.setupRemoteMedia(n),e&&e(t)}}this.options.iceStopWaitingOnServerReflexive&&(t.delegate=t.delegate||{},t.delegate.onSessionDescriptionHandler=e=>{if(!(e instanceof at))throw new Error("Session description handler not instance of SessionDescriptionHandler");e.peerConnectionDelegate={onicecandidate:t=>{var s;if("srflx"===(null===(s=t.candidate)||void 0===s?void 0:s.type)){this.logger.log(`[${n.id}] Found srflx ICE candidate, stop waiting...`);e.iceGatheringComplete()}}}});const n=new ee(this.userAgent,r,t);return this.sendInvite(n,t,s).then((()=>n))}async hangup(e){return this.logger.log(`[${e.id}] Hangup...`),this.sessionExists(e)?this.terminate(e):Promise.reject(new Error("Session does not exist."))}async answer(e,t){return this.logger.log(`[${e.id}] Accepting Invitation...`),this.sessionExists(e)?e instanceof Q?(t||(t={}),t.sessionDescriptionHandlerOptions||(t.sessionDescriptionHandlerOptions={}),t.sessionDescriptionHandlerOptions.constraints||(t.sessionDescriptionHandlerOptions.constraints=this.constraints),e.accept(t)):Promise.reject(new Error("Session not instance of Invitation.")):Promise.reject(new Error("Session does not exist."))}async decline(e){return this.logger.log(`[${e.id}] Rejecting Invitation...`),this.sessionExists(e)?e instanceof Q?e.reject():Promise.reject(new Error("Session not instance of Invitation.")):Promise.reject(new Error("Session does not exist."))}async hold(e){return this.logger.log(`[${e.id}] Holding session...`),this.setHold(e,!0)}async unhold(e){return this.logger.log(`[${e.id}] Unholding session...`),this.setHold(e,!1)}isHeld(e){const t=this.sessionManaged(e);return!!t&&t.held}mute(e){this.logger.log(`[${e.id}] Disabling media tracks...`),this.setMute(e,!0)}unmute(e){this.logger.log(`[${e.id}] Enabling media tracks...`),this.setMute(e,!1)}isMuted(e){const t=this.sessionManaged(e);return!!t&&t.muted}async sendDTMF(e,t){if(this.logger.log(`[${e.id}] Sending DTMF...`),!/^[0-9A-D#*,]$/.exec(t))return Promise.reject(new Error("Invalid DTMF tone."));if(!this.sessionExists(e))return Promise.reject(new Error("Session does not exist."));if(this.logger.log(`[${e.id}] Sending DTMF tone: ${t}`),this.options.sendDTMFUsingSessionDescriptionHandler)return e.sessionDescriptionHandler?e.sessionDescriptionHandler.sendDtmf(t)?Promise.resolve():Promise.reject(new Error("Failed to send DTMF")):Promise.reject(new Error("Session desciption handler undefined."));{const s={body:{contentDisposition:"render",contentType:"application/dtmf-relay",content:"Signal="+t+"\r\nDuration="+2e3}};return e.info({requestOptions:s}).then((()=>{}))}}async transfer(e,t,s){if(this.logger.log(`[${e.id}] Referring session...`),t instanceof z)return e.refer(t,s).then((()=>{}));const i=dt.makeURI(t);return i?e.refer(i,s).then((()=>{})):Promise.reject(new Error(`Failed to create a valid URI from "${t}"`))}async message(e,t){this.logger.log("Sending message...");const s=dt.makeURI(e);return s?new te(this.userAgent,s,t).message():Promise.reject(new Error(`Failed to create a valid URI from "${e}"`))}get constraints(){let e={audio:!0,video:!1};return this.options.media.constraints&&(e=Object.assign({},this.options.media.constraints)),e}attemptReconnection(e=1){const t=this.options.reconnectionAttempts,s=this.options.reconnectionDelay;this.shouldBeConnected?(this.attemptingReconnection&&this.logger.log("Reconnection attempt already in progress"),e>t?this.logger.log("Reconnection maximum attempts reached"):(1===e?this.logger.log(`Reconnection attempt ${e} of ${t} - trying`):this.logger.log(`Reconnection attempt ${e} of ${t} - trying in ${s} seconds`),this.attemptingReconnection=!0,setTimeout((()=>{if(!this.shouldBeConnected)return this.logger.log(`Reconnection attempt ${e} of ${t} - aborted`),void(this.attemptingReconnection=!1);this.userAgent.reconnect().then((()=>{this.logger.log(`Reconnection attempt ${e} of ${t} - succeeded`),this.attemptingReconnection=!1})).catch((s=>{this.logger.log(`Reconnection attempt ${e} of ${t} - failed`),this.logger.error(s.message),this.attemptingReconnection=!1,this.attemptReconnection(++e)}))}),1===e?0:1e3*s))):this.logger.log("Should not be connected currently")}attemptRegistration(e=!1){if(this.logger.log("Registration attempt "+(e?"without delay":"")),!this.shouldBeRegistered)return this.logger.log("Should not be registered currently"),Promise.resolve();if(void 0!==this.registrationAttemptTimeout)return this.logger.log("Registration attempt already in progress"),Promise.resolve();const t=()=>this.registerer?this.isConnected()?this.userAgent.state===ae.Stopped?(this.logger.log("User agent stopped"),Promise.resolve()):this.options.registerGuard?this.options.registerGuard().catch((e=>{throw this.logger.log("Register guard rejected will making registration attempt"),e})).then((e=>e||!this.registerer?Promise.resolve():this.registerer.register(this.registererRegisterOptions).then((()=>{})))):this.registerer.register(this.registererRegisterOptions).then((()=>{})):(this.logger.log("User agent not connected"),Promise.resolve()):(this.logger.log("Registerer undefined"),Promise.resolve());return new Promise(((s,i)=>{this.registrationAttemptTimeout=setTimeout((()=>{t().then((()=>{this.registrationAttemptTimeout=void 0,s()})).catch((e=>{this.registrationAttemptTimeout=void 0,e instanceof a?s():i(e)}))}),e?0:(e=>{const t=2*e;return 1e3*(Math.random()*(t-e)+e)})(this.options.registrationRetryInterval))}))}cleanupMedia(e){const t=this.sessionManaged(e);if(!t)throw new Error("Managed session does not exist.");t.mediaLocal&&t.mediaLocal.video&&(t.mediaLocal.video.srcObject=null,t.mediaLocal.video.pause()),t.mediaRemote&&(t.mediaRemote.audio&&(t.mediaRemote.audio.srcObject=null,t.mediaRemote.audio.pause()),t.mediaRemote.video&&(t.mediaRemote.video.srcObject=null,t.mediaRemote.video.pause()))}enableReceiverTracks(e,t){if(!this.sessionExists(e))throw new Error("Session does not exist.");const s=e.sessionDescriptionHandler;if(!(s instanceof at))throw new Error("Session's session description handler not instance of SessionDescriptionHandler.");s.enableReceiverTracks(t)}enableSenderTracks(e,t){if(!this.sessionExists(e))throw new Error("Session does not exist.");const s=e.sessionDescriptionHandler;if(!(s instanceof at))throw new Error("Session's session description handler not instance of SessionDescriptionHandler.");s.enableSenderTracks(t)}initSession(e,t){this.sessionAdd(e),this.delegate&&this.delegate.onCallCreated&&this.delegate.onCallCreated(e),e.stateChange.addListener((t=>{switch(this.logger.log(`[${e.id}] Session state changed to ${t}`),t){case Z.Initial:case Z.Establishing:break;case Z.Established:this.setupLocalMedia(e),this.setupRemoteMedia(e),this.delegate&&this.delegate.onCallAnswered&&this.delegate.onCallAnswered(e);break;case Z.Terminating:case Z.Terminated:this.sessionExists(e)&&(this.cleanupMedia(e),this.sessionRemove(e),this.delegate&&this.delegate.onCallHangup&&this.delegate.onCallHangup(e));break;default:throw new Error("Unknown session state.")}})),e.delegate=e.delegate||{},e.delegate.onInfo=t=>{var s;if(void 0===(null===(s=this.delegate)||void 0===s?void 0:s.onCallDTMFReceived))return void t.reject();const i=t.request.getHeader("content-type");if(!i||!/^application\/dtmf-relay/i.exec(i))return void t.reject();const r=t.request.body.split("\r\n",2);if(2!==r.length)return void t.reject();let n;const o=/^(Signal\s*?=\s*?)([0-9A-D#*]{1})(\s)?.*/;if(void 0!==r[0]&&o.test(r[0])&&(n=r[0].replace(o,"$2")),!n)return void t.reject();let a;const c=/^(Duration\s?=\s?)([0-9]{1,4})(\s)?.*/;void 0!==r[1]&&c.test(r[1])&&(a=parseInt(r[1].replace(c,"$2"),10)),a?t.accept().then((()=>{if(this.delegate&&this.delegate.onCallDTMFReceived){if(!n||!a)throw new Error("Tone or duration undefined.");this.delegate.onCallDTMFReceived(e,n,a)}})).catch((e=>{this.logger.error(e.message)})):t.reject()},e.delegate.onRefer=e=>{e.accept().then((()=>this.sendInvite(e.makeInviter(t),t))).catch((e=>{this.logger.error(e.message)}))}}optionsPingRun(e,t,s){if(this.options.optionsPingInterval<1)throw new Error("Invalid options ping interval.");this.optionsPingRunning||(this.optionsPingRunning=!0,this.optionsPingTimeout=setTimeout((()=>{this.optionsPingTimeout=void 0;const i=()=>{this.optionsPingFailure=!1,this.optionsPingRunning&&(this.optionsPingRunning=!1,this.optionsPingRun(e,t,s))},r=()=>{this.logger.error("OPTIONS ping failed"),this.optionsPingFailure=!0,this.optionsPingRunning=!1,this.userAgent.transport.disconnect().catch((e=>this.logger.error(e)))},n=this.userAgent.userAgentCore,o=n.makeOutgoingRequestMessage("OPTIONS",e,t,s,{});this.optionsPingRequest=n.request(o,{onAccept:()=>{this.optionsPingRequest=void 0,i()},onReject:e=>{this.optionsPingRequest=void 0,408===e.message.statusCode||503===e.message.statusCode?r():i()}})}),1e3*this.options.optionsPingInterval))}optionsPingStart(){let e,t,s;if(this.logger.log("OPTIONS pings started"),this.options.optionsPingRequestURI){if(e=dt.makeURI(this.options.optionsPingRequestURI),!e)throw new Error("Failed to create Request URI.");t=this.userAgent.contact.uri.clone(),s=this.userAgent.contact.uri.clone()}else{if(!this.options.aor)return void this.logger.error("You have enabled sending OPTIONS pings and as such you must provide either a) an AOR to register, or b) an RURI to use for the target of the OPTIONS ping requests. ");{const i=dt.makeURI(this.options.aor);if(!i)throw new Error("Failed to create URI.");e=i.clone(),e.user=void 0,t=i.clone(),s=i.clone()}}this.optionsPingRun(e,t,s)}optionsPingStop(){this.logger.log("OPTIONS pings stopped"),this.optionsPingRunning=!1,this.optionsPingFailure=!1,this.optionsPingRequest&&(this.optionsPingRequest.dispose(),this.optionsPingRequest=void 0),this.optionsPingTimeout&&(clearTimeout(this.optionsPingTimeout),this.optionsPingTimeout=void 0)}async sendInvite(e,t,s){return this.initSession(e,t),e.invite(s).then((()=>{this.logger.log(`[${e.id}] Sent INVITE`)}))}sessionAdd(e){const t=this.options.managedSessionFactory(this,e);this.managedSessions.push(t)}sessionExists(e){return void 0!==this.sessionManaged(e)}sessionManaged(e){return this.managedSessions.find((t=>t.session.id===e.id))}sessionRemove(e){this.managedSessions=this.managedSessions.filter((t=>t.session.id!==e.id))}async setHold(e,t){if(!this.sessionExists(e))return Promise.reject(new Error("Session does not exist."));if(this.isHeld(e)===t)return Promise.resolve();if(!(e.sessionDescriptionHandler instanceof at))throw new Error("Session's session description handler not instance of SessionDescriptionHandler.");const s={requestDelegate:{onAccept:()=>{const s=this.sessionManaged(e);void 0!==s&&(s.held=t,this.enableReceiverTracks(e,!s.held),this.enableSenderTracks(e,!s.held&&!s.muted),this.delegate&&this.delegate.onCallHold&&this.delegate.onCallHold(e,s.held))},onReject:()=>{this.logger.warn(`[${e.id}] Re-invite request was rejected`);const s=this.sessionManaged(e);void 0!==s&&(s.held=!t,this.enableReceiverTracks(e,!s.held),this.enableSenderTracks(e,!s.held&&!s.muted),this.delegate&&this.delegate.onCallHold&&this.delegate.onCallHold(e,s.held))}}},i=e.sessionDescriptionHandlerOptionsReInvite;i.hold=t,e.sessionDescriptionHandlerOptionsReInvite=i;const r=this.sessionManaged(e);if(!r)throw new Error("Managed session is undefiend.");return r.held=t,e.invite(s).then((()=>{const t=this.sessionManaged(e);void 0!==t&&(this.enableReceiverTracks(e,!t.held),this.enableSenderTracks(e,!t.held&&!t.muted))})).catch((s=>{throw r.held=!t,s instanceof a&&this.logger.error(`[${e.id}] A hold request is already in progress.`),s}))}setMute(e,t){if(!this.sessionExists(e))return void this.logger.warn(`[${e.id}] A session is required to enabled/disable media tracks`);if(e.state!==Z.Established)return void this.logger.warn(`[${e.id}] An established session is required to enable/disable media tracks`);const s=this.sessionManaged(e);void 0!==s&&(s.muted=t,this.enableSenderTracks(e,!s.held&&!s.muted))}setupLocalMedia(e){const t=this.sessionManaged(e);if(!t)throw new Error("Managed session does not exist.");const s="function"==typeof this.options.media.local?this.options.media.local(e):this.options.media.local;t.mediaLocal=s;const i=null==s?void 0:s.video;if(i){const t=this.getLocalMediaStream(e);if(!t)throw new Error("Local media stream undefiend.");i.srcObject=t,i.volume=0,i.play().catch((t=>{this.logger.error(`[${e.id}] Failed to play local media`),this.logger.error(t.message)}))}}setupRemoteMedia(e){const t=this.sessionManaged(e);if(!t)throw new Error("Managed session does not exist.");const s="function"==typeof this.options.media.remote?this.options.media.remote(e):this.options.media.remote;t.mediaRemote=s;const i=(null==s?void 0:s.video)||(null==s?void 0:s.audio);if(i){const t=this.getRemoteMediaStream(e);if(!t)throw new Error("Remote media stream undefiend.");i.autoplay=!0,i.srcObject=t,i.play().catch((t=>{this.logger.error(`[${e.id}] Failed to play remote media`),this.logger.error(t.message)})),t.onaddtrack=()=>{this.logger.log("Remote media onaddtrack"),i.load(),i.play().catch((t=>{this.logger.error(`[${e.id}] Failed to play remote media`),this.logger.error(t.message)}))}}}async terminate(e){switch(this.logger.log(`[${e.id}] Terminating...`),e.state){case Z.Initial:if(e instanceof ee)return e.cancel().then((()=>{this.logger.log(`[${e.id}] Inviter never sent INVITE (canceled)`)}));if(e instanceof Q)return e.reject().then((()=>{this.logger.log(`[${e.id}] Invitation rejected (sent 480)`)}));throw new Error("Unknown session type.");case Z.Establishing:if(e instanceof ee)return e.cancel().then((()=>{this.logger.log(`[${e.id}] Inviter canceled (sent CANCEL)`)}));if(e instanceof Q)return e.reject().then((()=>{this.logger.log(`[${e.id}] Invitation rejected (sent 480)`)}));throw new Error("Unknown session type.");case Z.Established:return e.bye().then((()=>{this.logger.log(`[${e.id}] Session ended (sent BYE)`)}));case Z.Terminating:case Z.Terminated:break;default:throw new Error("Unknown state")}return this.logger.log(`[${e.id}] Terminating in state ${e.state}, no action taken`),Promise.resolve()}}class It{constructor(e,t={}){this.session=void 0,this.delegate=t.delegate,this.options=Object.assign({},t);const s={aor:this.options.aor,delegate:{onCallAnswered:()=>{var e,t;return null===(t=null===(e=this.delegate)||void 0===e?void 0:e.onCallAnswered)||void 0===t?void 0:t.call(e)},onCallCreated:e=>{var t,s;this.session=e,null===(s=null===(t=this.delegate)||void 0===t?void 0:t.onCallCreated)||void 0===s||s.call(t)},onCallReceived:()=>{var e,t;return null===(t=null===(e=this.delegate)||void 0===e?void 0:e.onCallReceived)||void 0===t?void 0:t.call(e)},onCallHangup:()=>{var e,t;this.session=void 0,(null===(e=this.delegate)||void 0===e?void 0:e.onCallHangup)&&(null===(t=this.delegate)||void 0===t||t.onCallHangup())},onCallHold:(e,t)=>{var s,i;return null===(i=null===(s=this.delegate)||void 0===s?void 0:s.onCallHold)||void 0===i?void 0:i.call(s,t)},onCallDTMFReceived:(e,t,s)=>{var i,r;return null===(r=null===(i=this.delegate)||void 0===i?void 0:i.onCallDTMFReceived)||void 0===r?void 0:r.call(i,t,s)},onMessageReceived:e=>{var t,s;return null===(s=null===(t=this.delegate)||void 0===t?void 0:t.onMessageReceived)||void 0===s?void 0:s.call(t,e.request.body)},onRegistered:()=>{var e,t;return null===(t=null===(e=this.delegate)||void 0===e?void 0:e.onRegistered)||void 0===t?void 0:t.call(e)},onUnregistered:()=>{var e,t;return null===(t=null===(e=this.delegate)||void 0===e?void 0:e.onUnregistered)||void 0===t?void 0:t.call(e)},onServerConnect:()=>{var e,t;return null===(t=null===(e=this.delegate)||void 0===e?void 0:e.onServerConnect)||void 0===t?void 0:t.call(e)},onServerDisconnect:()=>{var e,t;return null===(t=null===(e=this.delegate)||void 0===e?void 0:e.onServerDisconnect)||void 0===t?void 0:t.call(e)}},maxSimultaneousSessions:1,media:this.options.media,reconnectionAttempts:this.options.reconnectionAttempts,reconnectionDelay:this.options.reconnectionDelay,registererOptions:this.options.registererOptions,sendDTMFUsingSessionDescriptionHandler:this.options.sendDTMFUsingSessionDescriptionHandler,userAgentOptions:this.options.userAgentOptions};this.sessionManager=new $t(e,s),this.logger=this.sessionManager.userAgent.getLogger("sip.SimpleUser")}get id(){return this.options.userAgentOptions&&this.options.userAgentOptions.displayName||"Anonymous"}get localMediaStream(){return this.session&&this.sessionManager.getLocalMediaStream(this.session)}get remoteMediaStream(){return this.session&&this.sessionManager.getRemoteMediaStream(this.session)}get localAudioTrack(){return this.session&&this.sessionManager.getLocalAudioTrack(this.session)}get localVideoTrack(){return this.session&&this.sessionManager.getLocalVideoTrack(this.session)}get remoteAudioTrack(){return this.session&&this.sessionManager.getRemoteAudioTrack(this.session)}get remoteVideoTrack(){return this.session&&this.sessionManager.getRemoteVideoTrack(this.session)}connect(){return this.logger.log(`[${this.id}] Connecting UserAgent...`),this.sessionManager.connect()}disconnect(){return this.logger.log(`[${this.id}] Disconnecting UserAgent...`),this.sessionManager.disconnect()}isConnected(){return this.sessionManager.isConnected()}register(e){return this.logger.log(`[${this.id}] Registering UserAgent...`),this.sessionManager.register(e)}unregister(e){return this.logger.log(`[${this.id}] Unregistering UserAgent...`),this.sessionManager.unregister(e)}call(e,t,s){return this.logger.log(`[${this.id}] Beginning Session...`),this.session?Promise.reject(new Error("Session already exists.")):this.sessionManager.call(e,t,s).then((()=>{}))}hangup(){return this.logger.log(`[${this.id}] Hangup...`),this.session?this.sessionManager.hangup(this.session).then((()=>{this.session=void 0})):Promise.reject(new Error("Session does not exist."))}answer(e){return this.logger.log(`[${this.id}] Accepting Invitation...`),this.session?this.sessionManager.answer(this.session,e):Promise.reject(new Error("Session does not exist."))}decline(){return this.logger.log(`[${this.id}] rejecting Invitation...`),this.session?this.sessionManager.decline(this.session):Promise.reject(new Error("Session does not exist."))}hold(){return this.logger.log(`[${this.id}] holding session...`),this.session?this.sessionManager.hold(this.session):Promise.reject(new Error("Session does not exist."))}unhold(){return this.logger.log(`[${this.id}] unholding session...`),this.session?this.sessionManager.unhold(this.session):Promise.reject(new Error("Session does not exist."))}isHeld(){return!!this.session&&this.sessionManager.isHeld(this.session)}mute(){return this.logger.log(`[${this.id}] disabling media tracks...`),this.session&&this.sessionManager.mute(this.session)}unmute(){return this.logger.log(`[${this.id}] enabling media tracks...`),this.session&&this.sessionManager.unmute(this.session)}isMuted(){return!!this.session&&this.sessionManager.isMuted(this.session)}sendDTMF(e){return this.logger.log(`[${this.id}] sending DTMF...`),this.session?this.sessionManager.sendDTMF(this.session,e):Promise.reject(new Error("Session does not exist."))}message(e,t){return this.logger.log(`[${this.id}] sending message...`),this.sessionManager.message(e,t)}}const Ct=r,At="sip.js";return t})())); \ No newline at end of file diff --git a/ui/supervisor/app.js b/ui/supervisor/app.js new file mode 100644 index 0000000..aa05b7d --- /dev/null +++ b/ui/supervisor/app.js @@ -0,0 +1,1808 @@ +const state = { + user: 'supervisor', + role: 'supervisor', + token: null, + authSource: 'local', + fullName: null, + selectedRecordingId: null, + recordingPreviewUrl: null, + logLines: [], + knowledge: { + categories: [], + articles: [], + selectedArticleId: '', + lastQuery: '', + }, + analytics: { + preset: '7d', + fromTs: '', + toTs: '', + queueId: 'all', + channel: 'all', + trendMetric: 'volume', + overview: null, + compare: null, + trend: [], + channelRows: [], + queueRows: [], + coverage: null, + queueOptions: [], + realtime: null, + liveCallsCount: 0, + loading: false, + error: '', + queueAccessError: '', + requestId: 0, + }, + oidc: { + enabled: false, + loginPath: '/auth/oidc/start?return_mode=popup', + providerLabel: 'Keycloak', + }, +}; + +const $ = (id) => document.getElementById(id); +const SESSION_STORAGE_KEY = 'cc_session'; +const ROLE_LABELS = { + admin: 'Администратор', + supervisor: 'Супервизор', + operator: 'Оператор', + analyst: 'Аналитик', +}; + +const ANALYTICS_CHANNEL_OPTIONS = [ + { value: 'all', label: 'Все каналы' }, + { value: 'voice', label: 'Голос' }, + { value: 'telegram', label: 'Telegram' }, + { value: 'whatsapp', label: 'WhatsApp' }, + { value: 'webchat', label: 'Веб-чат' }, + { value: 'email', label: 'Электронная почта' }, +]; + +const ANALYTICS_METRIC_META = { + total: { unit: 'count', better: 'up', note: 'Всего обращений в окне' }, + answered: { unit: 'count', better: 'up', note: 'Обработано командой' }, + SL: { unit: 'pct', better: 'up', note: 'Доля в SLA' }, + ASA: { unit: 'seconds', better: 'down', note: 'Среднее ожидание' }, + AHT: { unit: 'seconds', better: 'down', note: 'Среднее время обработки' }, + Abandon: { unit: 'pct', better: 'down', note: 'Потерянные обращения' }, + FCR: { unit: 'pct', better: 'up', note: 'Решено с первого контакта' }, + DigitalShare: { unit: 'pct', better: 'neutral', note: 'Доля цифровых каналов' }, + volume: { unit: 'count', better: 'up', note: 'Объём по временной точке' }, +}; + +function destinationForRole(role) { + if (role === 'admin') { + return '/admin'; + } + if (role === 'supervisor') { + return '/supervisor'; + } + if (role === 'analyst') { + return '/analyst'; + } + return '/operator'; +} + +function applyRoleNavigation() { + document.querySelectorAll('[data-shell]').forEach((link) => { + const allowed = (link.dataset.roles || '') + .split(',') + .map((item) => item.trim()) + .filter(Boolean); + link.style.display = !allowed.length || allowed.includes(state.role) ? '' : 'none'; + }); +} + +function ensurePageAccess() { + const allowed = ['admin', 'supervisor']; + if (!allowed.includes(state.role)) { + window.location.href = destinationForRole(state.role); + return false; + } + return true; +} + +function persistSession() { + window.localStorage.setItem( + SESSION_STORAGE_KEY, + JSON.stringify({ + access_token: state.token, + user: state.user, + role: state.role, + auth_source: state.authSource, + full_name: state.fullName, + }), + ); +} + +function clearStoredSession() { + window.localStorage.removeItem(SESSION_STORAGE_KEY); +} + +function restoreStoredSession() { + const raw = window.localStorage.getItem(SESSION_STORAGE_KEY); + if (!raw) { + return false; + } + try { + const payload = JSON.parse(raw); + if (!payload?.access_token) { + clearStoredSession(); + return false; + } + state.token = payload.access_token; + state.user = payload.user || state.user; + state.role = payload.role || state.role; + state.authSource = payload.auth_source || 'local'; + state.fullName = payload.full_name || null; + $('sessionUser').value = state.user; + $('sessionRole').value = state.role; + if ($('authPanel')) { + $('authPanel').style.display = 'none'; + } + updateProfileMeta(); + applyRoleNavigation(); + return true; + } catch { + clearStoredSession(); + return false; + } +} + +function logout() { + if (state.recordingPreviewUrl) { + URL.revokeObjectURL(state.recordingPreviewUrl); + state.recordingPreviewUrl = null; + } + clearStoredSession(); + window.location.href = '/'; +} + +function syncSessionFromInputs() { + state.user = $('sessionUser').value.trim() || 'supervisor'; + state.role = $('sessionRole').value || 'supervisor'; +} + +function log(message, payload) { + const ts = new Date().toLocaleTimeString(); + const suffix = payload ? ` ${JSON.stringify(payload)}` : ''; + state.logLines.unshift(`[${ts}] ${message}${suffix}`); + state.logLines = state.logLines.slice(0, 14); + $('eventLog').textContent = state.logLines.join('\n'); +} + +function escapeHtml(value) { + return String(value ?? '') + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); +} + +function renderSummaryCard(label, value, note = '') { + return ` +
            +
            ${label}
            +
            ${value}
            + ${note ? `
            ${note}
            ` : ''} +
            + `; +} + +function updateProfileMeta() { + const profileName = $('profileName'); + const profileRole = $('profileRole'); + if (!profileName || !profileRole) { + return; + } + profileName.textContent = state.fullName || state.user || 'Пользователь KonturCC'; + profileRole.textContent = ROLE_LABELS[state.role] || state.role || 'Пользователь'; +} + +function updateSessionInfo(extra = '') { + if (!state.token) { + $('sessionInfo').textContent = 'Токен: отсутствует'; + return; + } + const source = state.authSource === 'oidc' ? 'корпоративный' : 'локальный'; + $('sessionInfo').textContent = `Токен активен | роль: ${state.role} | вход: ${source}${extra ? ` | ${extra}` : ''}`; +} + +function applyAuthenticatedSession(data) { + state.token = data.access_token; + state.role = data.role; + state.user = data.username || state.user; + state.authSource = data.auth_source || 'local'; + state.fullName = data.full_name || null; + $('sessionUser').value = state.user; + $('sessionRole').value = data.role; + updateSessionInfo(data.provider ? `провайдер: ${data.provider}` : ''); + updateProfileMeta(); + persistSession(); + applyRoleNavigation(); +} + +async function api(service, path, options = {}) { + syncSessionFromInputs(); + const headers = { + 'Content-Type': 'application/json', + 'X-User': state.user, + 'X-Role': state.role, + ...(options.headers || {}), + }; + if (state.token) { + headers.Authorization = `Bearer ${state.token}`; + } + + const response = await fetch(`/proxy/${service}/${path}`, { ...options, headers }); + let data; + try { + data = await response.json(); + } catch { + data = { error: 'Сервис вернул ответ не в JSON-формате' }; + } + if (!response.ok) { + if (response.status === 401 && state.token) { + state.token = null; + state.authSource = 'local'; + clearStoredSession(); + updateSessionInfo('требуется повторный вход'); + window.location.href = '/'; + } + throw new Error(`${response.status}: ${JSON.stringify(data)}`); + } + return data; +} + +async function fetchBinary(service, path) { + syncSessionFromInputs(); + const headers = { + 'X-User': state.user, + 'X-Role': state.role, + }; + if (state.token) { + headers.Authorization = `Bearer ${state.token}`; + } + const response = await fetch(`/proxy/${service}/${path}`, { + method: 'GET', + headers, + }); + if (!response.ok) { + const detail = await response.text(); + if (response.status === 401 && state.token) { + state.token = null; + state.authSource = 'local'; + clearStoredSession(); + updateSessionInfo('требуется повторный вход'); + window.location.href = '/'; + } + throw new Error(`${response.status}: ${detail}`); + } + return response; +} + +async function checkGateway() { + try { + const response = await fetch('/health'); + const data = await response.json(); + $('gatewayStatus').textContent = `Шлюз: ${data.status === 'ok' ? 'готов' : data.status}`; + } catch { + $('gatewayStatus').textContent = 'Шлюз: недоступен'; + } +} + +async function loadOidcConfig() { + try { + const response = await fetch('/proxy/auth/auth/oidc/config'); + const data = await response.json(); + state.oidc.enabled = Boolean(data.enabled); + state.oidc.loginPath = data.login_path || '/auth/oidc/start?return_mode=popup'; + state.oidc.providerLabel = data.provider_label || 'Keycloak'; + $('corporateLoginBtn').disabled = !state.oidc.enabled; + $('corporateLoginBtn').textContent = state.oidc.enabled + ? `Корпоративный вход (${state.oidc.providerLabel})` + : 'Корпоративный вход недоступен'; + } catch { + $('corporateLoginBtn').disabled = true; + $('corporateLoginBtn').textContent = 'Корпоративный вход недоступен'; + } +} + +function startCorporateLogin() { + if (!state.oidc.enabled) { + log('Корпоративный вход недоступен'); + return; + } + const popup = window.open(`/proxy/auth${state.oidc.loginPath}`, 'oidc-login', 'width=620,height=760'); + if (!popup) { + log('Не удалось открыть окно корпоративного входа'); + return; + } + log('Открыт корпоративный вход', { provider: state.oidc.providerLabel }); +} + +function handleOidcMessage(event) { + if (!event?.data || typeof event.data !== 'object') { + return; + } + if (event.data.type === 'oidc-login' && event.data.access_token) { + applyAuthenticatedSession(event.data); + log('Корпоративный вход выполнен', { user: state.user, role: state.role }); + return; + } + if (event.data.type === 'oidc-error') { + log('Корпоративный вход не выполнен', { error: event.data.message || 'неизвестно' }); + updateSessionInfo(`ошибка SSO: ${event.data.message || 'неизвестно'}`); + } +} + +async function login() { + syncSessionFromInputs(); + try { + const data = await api('auth', 'auth/login', { + method: 'POST', + body: JSON.stringify({ + username: state.user, + password: $('sessionPassword').value, + }), + }); + applyAuthenticatedSession({ ...data, username: state.user, auth_source: data.auth_source || 'local' }); + log('Локальный вход выполнен', { user: state.user, role: data.role }); + } catch (err) { + $('sessionInfo').textContent = `Ошибка входа: ${err.message}`; + log('Вход не выполнен', { error: err.message }); + } +} + +function renderRealtime(data) { + const agents = data?.agents || {}; + const byState = agents.by_state || {}; + const firstQueue = (data?.queues || [])[0] || {}; + $('realtimeSummary').innerHTML = [ + renderSummaryCard('Агентов', Number(agents.total || 0), 'Текущее состояние команды'), + renderSummaryCard('Готовы', Number(byState.READY || 0), 'Свободны'), + renderSummaryCard('Заняты', Number(byState.BUSY || 0), 'В обработке'), + renderSummaryCard('Очередь', firstQueue.queue_id || 'нет', `В очереди: ${Number(firstQueue.in_queue || 0)}`), + ].join(''); + $('realtimeOutput').textContent = JSON.stringify(data, null, 2); +} + +function renderKpi(data) { + const kpi = data?.kpi || {}; + $('kpiSummary').innerHTML = [ + renderSummaryCard('SL', `${Number(kpi.SL || 0).toFixed(2)}%`), + renderSummaryCard('ASA', `${Number(kpi.ASA || 0).toFixed(2)} с`), + renderSummaryCard('AHT', `${Number(kpi.AHT || 0).toFixed(2)} с`), + renderSummaryCard('Потери', `${Number(kpi.Abandon || 0).toFixed(2)}%`), + renderSummaryCard('FCR', `${Number(kpi.FCR || 0).toFixed(2)}%`), + ].join(''); + $('kpiOutput').textContent = JSON.stringify(data, null, 2); +} + +function emptyKpiEnvelope(fromTs = null, toTs = null) { + return { + window: { from: fromTs, to: toTs }, + volume: { + total: 0, + answered: 0, + abandoned: 0, + }, + kpi: { + SL: 0, + ASA: 0, + AHT: 0, + Abandon: 0, + FCR: 0, + AnswerRate: 0, + WaitP95: 0, + HandleP95: 0, + Occupancy: 0, + DigitalShare: 0, + }, + breakdowns: { + by_channel: {}, + }, + filters: {}, + }; +} + +function pad2(value) { + return String(value).padStart(2, '0'); +} + +function startOfDay(date) { + const next = new Date(date); + next.setHours(0, 0, 0, 0); + return next; +} + +function addDays(date, days) { + const next = new Date(date); + next.setDate(next.getDate() + days); + return next; +} + +function formatDateTimeLocalValue(date) { + return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}T${pad2(date.getHours())}:${pad2(date.getMinutes())}`; +} + +function normalizeAnalyticsRange(from, to) { + const safeFrom = new Date(from); + const safeTo = new Date(to); + if (Number.isNaN(safeFrom.getTime()) || Number.isNaN(safeTo.getTime())) { + return { from: new Date(), to: new Date() }; + } + if (safeTo <= safeFrom) { + return { from: safeFrom, to: new Date(safeFrom.getTime() + 60 * 60 * 1000) }; + } + return { from: safeFrom, to: safeTo }; +} + +function analyticsRangeFromControls() { + const preset = $('analyticsPreset').value || state.analytics.preset || '7d'; + const customFrom = $('analyticsFrom').value.trim(); + const customTo = $('analyticsTo').value.trim(); + const now = new Date(); + let from; + let to; + let custom = false; + + if (customFrom && customTo) { + custom = true; + from = new Date(customFrom); + to = new Date(customTo); + } else if (preset === 'today') { + from = startOfDay(now); + to = now; + } else if (preset === '30d') { + from = startOfDay(addDays(now, -29)); + to = now; + } else { + from = startOfDay(addDays(now, -6)); + to = now; + } + + const current = normalizeAnalyticsRange(from, to); + const durationMs = Math.max(current.to.getTime() - current.from.getTime(), 60 * 60 * 1000); + const previous = { + from: new Date(current.from.getTime() - durationMs), + to: new Date(current.from.getTime()), + }; + + return { + preset, + custom, + current, + previous, + }; +} + +function syncAnalyticsStateFromControls() { + state.analytics.preset = $('analyticsPreset').value || '7d'; + state.analytics.fromTs = $('analyticsFrom').value.trim(); + state.analytics.toTs = $('analyticsTo').value.trim(); + state.analytics.queueId = $('analyticsQueue').value || 'all'; + state.analytics.channel = $('analyticsChannel').value || 'all'; + state.analytics.trendMetric = $('analyticsTrendMetric').value || 'volume'; +} + +function syncAnalyticsControlsFromState() { + $('analyticsPreset').value = state.analytics.preset || '7d'; + $('analyticsFrom').value = state.analytics.fromTs || ''; + $('analyticsTo').value = state.analytics.toTs || ''; + $('analyticsChannel').value = state.analytics.channel || 'all'; + $('analyticsTrendMetric').value = state.analytics.trendMetric || 'volume'; + renderAnalyticsQueueOptions(); +} + +function resetAnalyticsFilters() { + state.analytics.preset = '7d'; + state.analytics.fromTs = ''; + state.analytics.toTs = ''; + state.analytics.queueId = 'all'; + state.analytics.channel = 'all'; + state.analytics.trendMetric = 'volume'; + syncAnalyticsControlsFromState(); +} + +function analyticsQuery(range, overrides = {}) { + const params = new URLSearchParams(); + params.set('from_ts', range.from.toISOString()); + params.set('to_ts', range.to.toISOString()); + + const queueId = overrides.queueId ?? state.analytics.queueId; + const channel = overrides.channel ?? state.analytics.channel; + + if (queueId && queueId !== 'all') { + params.set('queue_id', queueId); + } + if (channel && channel !== 'all') { + params.set('channel', channel); + } + + return params.toString(); +} + +async function fetchAnalyticsKpi(range, overrides = {}) { + return api('reporting', `reports/kpi?${analyticsQuery(range, overrides)}`); +} + +function renderAnalyticsQueueOptions() { + const select = $('analyticsQueue'); + if (!select) { + return; + } + const selected = state.analytics.queueId || 'all'; + const options = ['']; + if (selected !== 'all' && !state.analytics.queueOptions.some((item) => item.queue_id === selected)) { + options.push(``); + } + options.push( + ...state.analytics.queueOptions.map( + (item) => ``, + ), + ); + select.innerHTML = options.join(''); + select.value = options.some((item) => item.includes(`value="${selected}"`)) ? selected : 'all'; +} + +async function loadAnalyticsQueueOptions() { + try { + const data = await api('routing', 'queues'); + state.analytics.queueOptions = Array.isArray(data) ? data : []; + state.analytics.queueAccessError = ''; + } catch (err) { + state.analytics.queueOptions = []; + state.analytics.queueAccessError = err.message; + } + renderAnalyticsQueueOptions(); + return state.analytics.queueOptions; +} + +function formatAnalyticsNumber(value, digits = 0) { + return new Intl.NumberFormat('ru-RU', { + minimumFractionDigits: digits, + maximumFractionDigits: digits, + }).format(Number(value || 0)); +} + +function analyticsChannelLabel(channel) { + const meta = ANALYTICS_CHANNEL_OPTIONS.find((item) => item.value === channel); + return meta ? meta.label : String(channel || 'неизвестно'); +} + +function metricValueFromPayload(metric, payload) { + if (metric === 'volume' || metric === 'total') { + return Number(payload?.volume?.total || 0); + } + if (metric === 'answered') { + return Number(payload?.volume?.answered || 0); + } + return Number(payload?.kpi?.[metric] || 0); +} + +function formatAnalyticsMetric(metric, value) { + const meta = ANALYTICS_METRIC_META[metric] || { unit: 'count' }; + if (meta.unit === 'pct') { + return `${formatAnalyticsNumber(value, 2)}%`; + } + if (meta.unit === 'seconds') { + return `${formatAnalyticsNumber(value, 2)} с`; + } + return formatAnalyticsNumber(value, 0); +} + +function signedDelta(value, digits = 1) { + if (!value) { + return '0'; + } + const sign = value > 0 ? '+' : '-'; + return `${sign}${formatAnalyticsNumber(Math.abs(value), digits)}`; +} + +function analyticsDelta(metric, currentPayload, previousPayload) { + const current = metricValueFromPayload(metric, currentPayload); + const previous = metricValueFromPayload(metric, previousPayload); + const diff = current - previous; + const meta = ANALYTICS_METRIC_META[metric] || { unit: 'count', better: 'neutral' }; + + let text = signedDelta(diff, meta.unit === 'count' ? 0 : 1); + if (meta.unit === 'pct') { + text = `${text} п.п.`; + } else if (meta.unit === 'seconds') { + text = `${text} с`; + } + + let tone = 'neutral'; + if (meta.better === 'up') { + tone = diff > 0 ? 'positive' : diff < 0 ? 'negative' : 'neutral'; + } else if (meta.better === 'down') { + tone = diff < 0 ? 'positive' : diff > 0 ? 'negative' : 'neutral'; + } + + return { text, tone }; +} + +function renderAnalyticsCard(label, metric, payload, previousPayload) { + const value = metricValueFromPayload(metric, payload); + const delta = analyticsDelta(metric, payload, previousPayload); + const note = ANALYTICS_METRIC_META[metric]?.note || ''; + return ` +
            +
            +
            ${label}
            + ${delta.text} +
            +
            ${formatAnalyticsMetric(metric, value)}
            + ${note ? `
            ${note}
            ` : ''} +
            + `; +} + +function updateAnalyticsRangeHint(range) { + const filters = []; + if (state.analytics.queueId !== 'all') { + filters.push(`очередь: ${state.analytics.queueId}`); + } + if (state.analytics.channel !== 'all') { + filters.push(`канал: ${state.analytics.channel}`); + } + const suffix = filters.length ? ` | ${filters.join(' | ')}` : ''; + $('analyticsRangeHint').textContent = range.custom + ? `Кастомный диапазон: ${formatTime(range.current.from.toISOString())} - ${formatTime(range.current.to.toISOString())}${suffix}` + : `Период: ${range.preset}${suffix}`; +} + +function renderAnalyticsOperationsStrip() { + const realtime = state.analytics.realtime || {}; + const agents = realtime?.agents || {}; + const byState = agents.by_state || {}; + const firstQueue = Array.isArray(realtime?.queues) ? realtime.queues[0] || {} : {}; + + $('analyticsOperations').innerHTML = [ + renderSummaryCard('Агенты', Number(agents.total || 0), 'Активный состав в оперативном срезе'), + renderSummaryCard('Готовы', Number(byState.READY || 0), 'Свободные агенты'), + renderSummaryCard('Заняты', Number(byState.BUSY || 0), 'В обработке'), + renderSummaryCard('Живые звонки', Number(state.analytics.liveCallsCount || 0), 'Последние звонки от Asterisk'), + renderSummaryCard('Очередь', firstQueue.queue_id || 'нет', `В очереди: ${Number(firstQueue.in_queue || 0)}`), + ].join(''); +} + +function renderAnalyticsOverview() { + const current = state.analytics.overview || emptyKpiEnvelope(); + const previous = state.analytics.compare || emptyKpiEnvelope(); + $('analyticsOverview').innerHTML = [ + renderAnalyticsCard('Поток', 'total', current, previous), + renderAnalyticsCard('Обработано', 'answered', current, previous), + renderAnalyticsCard('SL', 'SL', current, previous), + renderAnalyticsCard('ASA', 'ASA', current, previous), + renderAnalyticsCard('AHT', 'AHT', current, previous), + renderAnalyticsCard('Потери', 'Abandon', current, previous), + renderAnalyticsCard('FCR', 'FCR', current, previous), + renderAnalyticsCard('Цифровая доля', 'DigitalShare', current, previous), + ].join(''); +} + +function renderAnalyticsChannelTable() { + const container = $('analyticsChannelTable'); + const rows = state.analytics.channelRows || []; + if (!rows.length) { + container.innerHTML = '
            Нет данных по каналам за выбранный период.
            '; + return; + } + + container.innerHTML = ` +
            + Канал + Всего + Обработано + Потеряно + Доля ответа +
            + ${rows + .map((item) => { + const answerRate = item.total ? (item.answered / item.total) * 100 : 0; + return ` +
            + ${escapeHtml(analyticsChannelLabel(item.channel))} + ${formatAnalyticsNumber(item.total, 0)} + ${formatAnalyticsNumber(item.answered, 0)} + ${formatAnalyticsNumber(item.abandoned, 0)} + ${formatAnalyticsNumber(answerRate, 2)}% +
            + `; + }) + .join('')} + `; +} + +function renderAnalyticsQueueTable() { + const container = $('analyticsQueueTable'); + const rows = state.analytics.queueRows || []; + if (!rows.length) { + const reason = state.analytics.queueAccessError + ? 'Таблица очередей недоступна для текущей роли: backend не разрешил получить каталог очередей.' + : 'Очереди пока не вернули исторические данные за выбранное окно.'; + container.innerHTML = `
            ${escapeHtml(reason)}
            `; + return; + } + + container.innerHTML = ` +
            + Очередь + Всего + Обработано + SL + Потери +
            + ${rows + .map((item) => ` + + `) + .join('')} + `; +} + +function renderAnalyticsCoverage() { + const implemented = Array.isArray(state.analytics.coverage?.implemented_metrics) + ? state.analytics.coverage.implemented_metrics + : []; + const dimensions = Array.isArray(state.analytics.coverage?.dimensions) + ? state.analytics.coverage.dimensions + : []; + const filters = Array.isArray(state.analytics.coverage?.supported_filters) + ? state.analytics.coverage.supported_filters + : []; + + $('analyticsCoverage').innerHTML = ` +
            +
            +

            Что уже есть

            +
            + ${implemented.length + ? implemented + .map((item) => `${escapeHtml(item.code)} · ${escapeHtml(item.label)}`) + .join('') + : 'Сервис coverage пока не вернул каталог метрик.'} +
            +
            +
            +

            Разрезы

            +
            + ${dimensions.length + ? dimensions.map((item) => `${escapeHtml(item)}`).join('') + : 'Нет данных о разрезах.'} +
            +
            +
            +

            Фильтры

            +
            + ${filters.length + ? filters.map((item) => `${escapeHtml(item)}`).join('') + : 'Нет данных о фильтрах.'} +
            +
            +
            +
            +
            + Аналитика по агентам +

            Появится, когда reporting начнёт отдавать детализацию по agent_id и персональные ряды производительности.

            +
            +
            + AI-аналитика +

            Ждём отдельные AI-метрики: долю закрытий, передачу оператору, задержку ответа и покрытие по AI-сводкам.

            +
            +
            + Качество речи +

            Блок будет строиться поверх скоринга транскриптов, QA-правил и расширенных метаданных записей.

            +
            + ${ + state.analytics.queueAccessError + ? `
            + Доступ к каталогу очередей +

            ${escapeHtml(state.analytics.queueAccessError)}

            +
            ` + : '' + } +
            + `; +} + +function buildTrendBuckets(range, preset, custom) { + const buckets = []; + const hourMs = 60 * 60 * 1000; + const dayMs = 24 * hourMs; + + if (custom) { + const fromDay = startOfDay(range.from); + const endDay = startOfDay(addDays(range.to, 1)); + const totalDays = Math.max(1, Math.ceil((endDay.getTime() - fromDay.getTime()) / dayMs)); + const bucketDays = Math.max(1, Math.ceil(totalDays / 31)); + for (let cursor = new Date(fromDay); cursor < endDay; cursor = new Date(cursor.getTime() + bucketDays * dayMs)) { + const bucketFrom = new Date(cursor); + const bucketTo = new Date(Math.min(bucketFrom.getTime() + bucketDays * dayMs, endDay.getTime())); + const lastDay = new Date(bucketTo.getTime() - dayMs); + const label = bucketDays === 1 + ? bucketFrom.toLocaleDateString('ru-RU', { day: '2-digit', month: 'short' }) + : `${bucketFrom.toLocaleDateString('ru-RU', { day: '2-digit', month: 'short' })} - ${lastDay.toLocaleDateString('ru-RU', { day: '2-digit', month: 'short' })}`; + buckets.push({ label, range: { from: bucketFrom, to: bucketTo } }); + } + return buckets.slice(0, 31); + } + + if (preset === 'today') { + const dayStart = startOfDay(range.from); + for (let hour = 0; hour < 24; hour += 1) { + const bucketFrom = new Date(dayStart.getTime() + hour * hourMs); + const bucketTo = new Date(bucketFrom.getTime() + hourMs); + buckets.push({ + label: `${pad2(hour)}:00`, + range: { from: bucketFrom, to: bucketTo }, + }); + } + return buckets; + } + + const days = preset === '30d' ? 30 : 7; + const firstDay = startOfDay(range.from); + for (let day = 0; day < days; day += 1) { + const bucketFrom = new Date(firstDay.getTime() + day * dayMs); + const bucketTo = new Date(bucketFrom.getTime() + dayMs); + buckets.push({ + label: bucketFrom.toLocaleDateString('ru-RU', { day: '2-digit', month: 'short' }), + range: { from: bucketFrom, to: bucketTo }, + }); + } + return buckets; +} + +async function loadAnalyticsTrend(rangeMeta, requestId) { + const buckets = buildTrendBuckets(rangeMeta.current, rangeMeta.preset, rangeMeta.custom); + const items = await Promise.all( + buckets.map(async (bucket) => { + try { + const payload = await fetchAnalyticsKpi(bucket.range); + return { ...bucket, payload }; + } catch { + return { ...bucket, payload: emptyKpiEnvelope(bucket.range.from.toISOString(), bucket.range.to.toISOString()) }; + } + }), + ); + if (requestId !== state.analytics.requestId) { + return []; + } + return items; +} + +async function loadAnalyticsQueueRows(rangeMeta, queues, requestId) { + if (!queues.length) { + return []; + } + const rows = await Promise.all( + queues.map(async (queue) => { + try { + const payload = await fetchAnalyticsKpi(rangeMeta.current, { + queueId: queue.queue_id, + channel: state.analytics.channel, + }); + return { + queue_id: queue.queue_id, + name: queue.name || queue.queue_id, + total: Number(payload?.volume?.total || 0), + answered: Number(payload?.volume?.answered || 0), + SL: Number(payload?.kpi?.SL || 0), + Abandon: Number(payload?.kpi?.Abandon || 0), + }; + } catch { + return { + queue_id: queue.queue_id, + name: queue.name || queue.queue_id, + total: 0, + answered: 0, + SL: 0, + Abandon: 0, + }; + } + }), + ); + if (requestId !== state.analytics.requestId) { + return []; + } + return rows.sort((a, b) => b.total - a.total || a.name.localeCompare(b.name)); +} + +function renderAnalyticsTrendChart() { + const metric = state.analytics.trendMetric || 'volume'; + const items = state.analytics.trend || []; + const container = $('analyticsTrendChart'); + if (!items.length) { + container.innerHTML = '
            Нет данных тренда за выбранный период.
            '; + return; + } + + const values = items.map((item) => metricValueFromPayload(metric, item.payload)); + const maxValue = Math.max(...values, 1); + const width = 720; + const height = 220; + const paddingLeft = 38; + const paddingRight = 16; + const paddingTop = 16; + const paddingBottom = 36; + const chartWidth = width - paddingLeft - paddingRight; + const chartHeight = height - paddingTop - paddingBottom; + const step = items.length > 1 ? chartWidth / (items.length - 1) : chartWidth; + + const pointFor = (value, index) => { + const x = paddingLeft + index * step; + const y = paddingTop + chartHeight - (value / maxValue) * chartHeight; + return { x, y }; + }; + + const polyline = values + .map((value, index) => { + const point = pointFor(value, index); + return `${point.x},${point.y}`; + }) + .join(' '); + + const grid = [0, 0.25, 0.5, 0.75, 1] + .map((ratio) => { + const y = paddingTop + chartHeight - ratio * chartHeight; + const labelValue = maxValue * ratio; + return ` + + ${escapeHtml(formatAnalyticsNumber(labelValue, metric === 'volume' ? 0 : 1))} + `; + }) + .join(''); + + const markers = values + .map((value, index) => { + const point = pointFor(value, index); + return ``; + }) + .join(''); + + const labelStep = items.length > 12 ? Math.ceil(items.length / 6) : 1; + const labels = items + .map((item, index) => { + if (index % labelStep !== 0 && index !== items.length - 1) { + return ''; + } + const point = pointFor(values[index], index); + return `${escapeHtml(item.label)}`; + }) + .join(''); + + container.innerHTML = ` +
            + ${escapeHtml(metric === 'volume' ? 'Volume' : metric)} + ${escapeHtml(formatAnalyticsMetric(metric, values[values.length - 1] || 0))} в последней точке +
            + + ${grid} + + ${markers} + ${labels} + + `; +} + +function renderAnalyticsEmptyState() { + const box = $('analyticsEmptyState'); + if (state.analytics.error) { + box.hidden = false; + box.textContent = `Не удалось загрузить аналитику: ${state.analytics.error}`; + return; + } + const total = Number(state.analytics.overview?.volume?.total || 0); + if (!state.analytics.loading && total === 0) { + box.hidden = false; + box.textContent = 'За выбранный период данных пока нет. Фильтры сохранены, а заглушки остаются видимыми.'; + return; + } + box.hidden = true; + box.textContent = ''; +} + +function renderAnalyticsDashboard(rangeMeta = analyticsRangeFromControls()) { + $('analyticsRefreshBtn').disabled = state.analytics.loading; + renderAnalyticsQueueOptions(); + updateAnalyticsRangeHint(rangeMeta); + renderAnalyticsOperationsStrip(); + renderAnalyticsOverview(); + renderAnalyticsTrendChart(); + renderAnalyticsChannelTable(); + renderAnalyticsQueueTable(); + renderAnalyticsCoverage(); + renderAnalyticsEmptyState(); +} + +async function loadAnalyticsDashboard(silent = false) { + syncAnalyticsStateFromControls(); + const rangeMeta = analyticsRangeFromControls(); + const requestId = state.analytics.requestId + 1; + state.analytics.requestId = requestId; + state.analytics.loading = true; + state.analytics.error = ''; + renderAnalyticsDashboard(rangeMeta); + + try { + const queuePromise = loadAnalyticsQueueOptions(); + const [current, previous, coverage, realtime, liveCalls, queues] = await Promise.all([ + fetchAnalyticsKpi(rangeMeta.current), + fetchAnalyticsKpi(rangeMeta.previous).catch(() => emptyKpiEnvelope()), + api('reporting', 'reports/coverage').catch(() => ({ implemented_metrics: [], dimensions: [], supported_filters: [] })), + api('supervisor', 'supervisor/realtime').catch(() => ({ agents: { total: 0, by_state: {} }, queues: [] })), + api('supervisor', 'supervisor/live-calls?limit=20').catch(() => ({ items: [] })), + queuePromise, + ]); + + if (requestId !== state.analytics.requestId) { + return; + } + + state.analytics.overview = current || emptyKpiEnvelope(rangeMeta.current.from.toISOString(), rangeMeta.current.to.toISOString()); + state.analytics.compare = previous || emptyKpiEnvelope(rangeMeta.previous.from.toISOString(), rangeMeta.previous.to.toISOString()); + state.analytics.coverage = coverage || { implemented_metrics: [], dimensions: [], supported_filters: [] }; + state.analytics.realtime = realtime || { agents: { total: 0, by_state: {} }, queues: [] }; + state.analytics.liveCallsCount = Array.isArray(liveCalls?.items) ? liveCalls.items.length : 0; + + const byChannel = current?.breakdowns?.by_channel || {}; + state.analytics.channelRows = Object.entries(byChannel) + .map(([channel, payload]) => ({ + channel, + total: Number(payload?.total || 0), + answered: Number(payload?.answered || 0), + abandoned: Number(payload?.abandoned || 0), + })) + .sort((a, b) => b.total - a.total || a.channel.localeCompare(b.channel)); + + const [trend, queueRows] = await Promise.all([ + loadAnalyticsTrend(rangeMeta, requestId), + loadAnalyticsQueueRows(rangeMeta, queues, requestId), + ]); + + if (requestId !== state.analytics.requestId) { + return; + } + + state.analytics.trend = trend; + state.analytics.queueRows = queueRows; + state.analytics.loading = false; + renderAnalyticsDashboard(rangeMeta); + + if (!silent) { + log('Analytics dashboard updated', { + total: current?.volume?.total || 0, + queues: queueRows.length, + trend_points: trend.length, + }); + } + } catch (err) { + if (requestId !== state.analytics.requestId) { + return; + } + state.analytics.loading = false; + state.analytics.error = err.message; + state.analytics.overview = emptyKpiEnvelope(); + state.analytics.compare = emptyKpiEnvelope(); + state.analytics.trend = []; + state.analytics.channelRows = []; + state.analytics.queueRows = []; + renderAnalyticsDashboard(rangeMeta); + if (!silent) { + log('Не удалось обновить аналитическую витрину', { error: err.message }); + } + } +} + +function handleAnalyticsQueueClick(event) { + const button = event.target.closest('[data-analytics-queue-id]'); + if (!button) { + return; + } + const queueId = button.dataset.analyticsQueueId || 'all'; + state.analytics.queueId = queueId; + $('analyticsQueue').value = queueId; + $('queueMetricId').value = queueId; + void loadAnalyticsDashboard(); +} + +function formatTime(ts) { + if (!ts) { + return '-'; + } + const date = new Date(ts); + if (Number.isNaN(date.getTime())) { + return ts; + } + return date.toLocaleString(); +} + +function renderLiveCalls(items) { + if (!items.length) { + $('liveCallsList').innerHTML = '
          • Новых звонков от Asterisk пока нет.
          • '; + return; + } + + $('liveCallsList').innerHTML = items + .map( + (item) => ` +
          • + ${item.call_id}
            + обращение: ${item.interaction_id || '-'}
            + статус: ${item.status}
            + очередь: ${item.queue_id || '-'}
            + начат: ${formatTime(item.started_at)}
            + завершён: ${formatTime(item.ended_at)}
            + запись: ${item.has_recording ? 'да' : 'нет'} +
          • + `, + ) + .join(''); +} + +function kbCategoryName(categoryId) { + const category = state.knowledge.categories.find((item) => item.category_id === categoryId); + return category?.name || categoryId || 'Без категории'; +} + +function kbLanguageLabel(language) { + return String(language || 'ru').toUpperCase(); +} + +function renderKnowledgeSummary() { + const ruCount = state.knowledge.articles.filter((item) => item.language === 'ru').length; + const kzCount = state.knowledge.articles.filter((item) => item.language === 'kz').length; + $('kbSummary').innerHTML = [ + renderSummaryCard('Категорий', state.knowledge.categories.length, 'Структура базы знаний'), + renderSummaryCard('Статей', state.knowledge.articles.length, 'Текущий результат поиска'), + renderSummaryCard('RU', ruCount, 'Русские статьи в текущем срезе'), + renderSummaryCard('KZ', kzCount, 'Казахские статьи в текущем срезе'), + ].join(''); +} + +function syncKnowledgeCategoryOptions(preferredCategoryId = '') { + const select = $('kbArticleCategory'); + const currentValue = preferredCategoryId || select.value; + if (!state.knowledge.categories.length) { + select.innerHTML = ''; + select.value = ''; + return; + } + select.innerHTML = state.knowledge.categories + .map((item) => ``) + .join(''); + const nextValue = state.knowledge.categories.some((item) => item.category_id === currentValue) + ? currentValue + : state.knowledge.categories[0].category_id; + select.value = nextValue; +} + +function renderKnowledgeCategories() { + if (!state.knowledge.categories.length) { + $('kbCategoriesList').innerHTML = '
          • Категории базы знаний пока не созданы.
          • '; + return; + } + $('kbCategoriesList').innerHTML = state.knowledge.categories + .map( + (item) => ` +
          • + ${escapeHtml(item.name)}
            + ID: ${escapeHtml(item.category_id)}
            + описание: ${escapeHtml(item.description || '—')} +
          • + `, + ) + .join(''); +} + +function renderKnowledgeArticlePreview(article = null) { + $('kbArticlePreview').textContent = article ? JSON.stringify(article, null, 2) : 'Выберите статью из списка ниже.'; +} + +function resetKnowledgeArticleForm() { + state.knowledge.selectedArticleId = ''; + $('kbArticleGroupId').value = ''; + $('kbArticleLanguage').value = 'ru'; + $('kbArticleTitle').value = ''; + $('kbArticleTags').value = ''; + $('kbArticleBody').value = ''; + $('saveKbArticleBtn').textContent = 'Сохранить статью'; + $('kbFormHint').textContent = 'Создайте новую статью или выберите существующую для редактирования.'; + syncKnowledgeCategoryOptions(); + renderKnowledgeArticlePreview(null); +} + +function fillKnowledgeArticleForm(article) { + if (!article) { + resetKnowledgeArticleForm(); + return; + } + state.knowledge.selectedArticleId = article.article_id; + $('kbArticleGroupId').value = article.article_group_id || ''; + $('kbArticleLanguage').value = article.language || 'ru'; + $('kbArticleTitle').value = article.title || ''; + $('kbArticleTags').value = Array.isArray(article.tags) ? article.tags.join(', ') : ''; + $('kbArticleBody').value = article.body || ''; + syncKnowledgeCategoryOptions(article.category_id || ''); + $('saveKbArticleBtn').textContent = 'Обновить статью'; + $('kbFormHint').textContent = `Редактирование статьи: ${article.title || article.article_id}`; + renderKnowledgeArticlePreview(article); +} + +function renderKnowledgeArticles() { + renderKnowledgeSummary(); + if (!state.knowledge.articles.length) { + $('kbArticlesList').innerHTML = '
          • Статьи базы знаний не найдены.
          • '; + return; + } + $('kbArticlesList').innerHTML = state.knowledge.articles + .map((item) => { + const tags = Array.isArray(item.tags) && item.tags.length ? item.tags.join(', ') : '—'; + return ` +
          • + ${escapeHtml(item.title)}
            + категория: ${escapeHtml(kbCategoryName(item.category_id))}
            + язык: ${escapeHtml(kbLanguageLabel(item.language))}
            + ID группы: ${escapeHtml(item.article_group_id || item.article_id)}
            + теги: ${escapeHtml(tags)}
            + +
          • + `; + }) + .join(''); +} + +function knowledgeArticlePayloadFromForm() { + return { + category_id: $('kbArticleCategory').value.trim(), + article_group_id: $('kbArticleGroupId').value.trim() || null, + language: $('kbArticleLanguage').value.trim() || 'ru', + title: $('kbArticleTitle').value.trim(), + body: $('kbArticleBody').value.trim(), + tags: $('kbArticleTags').value + .split(',') + .map((item) => item.trim()) + .filter(Boolean), + }; +} + +async function loadKnowledgeCategories(silent = false) { + const data = await api('kb', 'knowledge/categories'); + state.knowledge.categories = Array.isArray(data) ? data : []; + syncKnowledgeCategoryOptions(); + renderKnowledgeCategories(); + renderKnowledgeSummary(); + if (!silent) { + log('Категории базы знаний обновлены', { count: state.knowledge.categories.length }); + } +} + +async function loadKnowledgeArticles(silent = false) { + const query = $('kbSearchQuery').value.trim(); + const language = $('kbSearchLanguage') ? $('kbSearchLanguage').value.trim() : ''; + const params = new URLSearchParams({ q: query, limit: '100' }); + if (language) { + params.set('language', language); + } + const data = await api('kb', `knowledge/search?${params.toString()}`); + state.knowledge.articles = Array.isArray(data) ? data : []; + state.knowledge.lastQuery = query; + renderKnowledgeArticles(); + if (state.knowledge.selectedArticleId) { + const selected = state.knowledge.articles.find((item) => item.article_id === state.knowledge.selectedArticleId); + if (selected) { + renderKnowledgeArticlePreview(selected); + } + } + if (!silent) { + log('Статьи базы знаний обновлены', { count: state.knowledge.articles.length, query }); + } +} + +async function refreshKnowledgeBase(silent = false) { + try { + await loadKnowledgeCategories(true); + await loadKnowledgeArticles(true); + if (!silent) { + log('База знаний обновлена', { + categories: state.knowledge.categories.length, + articles: state.knowledge.articles.length, + }); + } + } catch (err) { + $('kbCategoriesList').innerHTML = '
          • Не удалось загрузить категории базы знаний.
          • '; + $('kbArticlesList').innerHTML = '
          • Не удалось загрузить статьи базы знаний.
          • '; + $('kbArticlePreview').textContent = `Ошибка базы знаний: ${err.message}`; + if (!silent) { + log('Не удалось обновить базу знаний', { error: err.message }); + } + } +} + +async function createKnowledgeCategory() { + const name = $('kbCategoryName').value.trim(); + const description = $('kbCategoryDescription').value.trim(); + if (!name) { + $('kbFormHint').textContent = 'Укажите название категории перед сохранением.'; + return; + } + try { + const created = await api('kb', 'knowledge/categories', { + method: 'POST', + body: JSON.stringify({ name, description }), + }); + $('kbCategoryName').value = ''; + $('kbCategoryDescription').value = ''; + await loadKnowledgeCategories(true); + syncKnowledgeCategoryOptions(created.category_id); + renderKnowledgeCategories(); + renderKnowledgeSummary(); + $('kbFormHint').textContent = `Категория создана: ${created.name}`; + log('Категория базы знаний создана', { category_id: created.category_id, name: created.name }); + } catch (err) { + $('kbFormHint').textContent = `Не удалось создать категорию: ${err.message}`; + log('Не удалось создать категорию базы знаний', { error: err.message }); + } +} + +async function saveKnowledgeArticle() { + const payload = knowledgeArticlePayloadFromForm(); + if (!payload.category_id) { + $('kbFormHint').textContent = 'Сначала создайте или выберите категорию.'; + return; + } + if (!payload.title || !payload.body) { + $('kbFormHint').textContent = 'Заполните заголовок и текст статьи.'; + return; + } + try { + const articleId = state.knowledge.selectedArticleId; + const data = articleId + ? await api('kb', `knowledge/articles/${encodeURIComponent(articleId)}`, { + method: 'PATCH', + body: JSON.stringify({ + article_group_id: payload.article_group_id, + language: payload.language, + title: payload.title, + body: payload.body, + tags: payload.tags, + }), + }) + : await api('kb', 'knowledge/articles', { + method: 'POST', + body: JSON.stringify(payload), + }); + await loadKnowledgeArticles(true); + fillKnowledgeArticleForm(data); + $('kbFormHint').textContent = articleId ? `Статья обновлена: ${data.title}` : `Статья создана: ${data.title}`; + log(articleId ? 'Статья базы знаний обновлена' : 'Статья базы знаний создана', { article_id: data.article_id, title: data.title }); + } catch (err) { + $('kbFormHint').textContent = `Не удалось сохранить статью: ${err.message}`; + log('Не удалось сохранить статью базы знаний', { error: err.message }); + } +} + +function openKnowledgeArticleFromList(event) { + const button = event.target.closest('[data-kb-article-id]'); + if (!button) { + return; + } + const articleId = button.dataset.kbArticleId || ''; + const article = state.knowledge.articles.find((item) => item.article_id === articleId); + if (!article) { + return; + } + fillKnowledgeArticleForm(article); +} + +async function loadLiveCalls(silent = false) { + try { + const data = await api('supervisor', 'supervisor/live-calls?limit=20'); + const items = Array.isArray(data?.items) ? data.items : []; + renderLiveCalls(items); + if (!silent) { + log('Лента звонков обновлена', { count: items.length }); + } + } catch (err) { + $('liveCallsList').innerHTML = '
          • Не удалось загрузить звонки.
          • '; + if (!silent) { + log('Не удалось обновить ленту звонков', { error: err.message }); + } + } +} + +async function loadRealtime() { + try { + const data = await api('supervisor', 'supervisor/realtime'); + renderRealtime(data); + log('Оперативный срез обновлён', { agents: data?.agents?.total || 0 }); + } catch (err) { + $('realtimeOutput').textContent = err.message; + log('Не удалось обновить оперативный срез', { error: err.message }); + } +} + +async function loadKpi() { + try { + const queueId = encodeURIComponent($('queueMetricId').value.trim() || 'line2'); + const data = await api('reporting', `reports/kpi?queue_id=${queueId}`); + renderKpi(data); + log('KPI обновлены', data?.kpi || {}); + } catch (err) { + $('kpiOutput').textContent = err.message; + log('Не удалось обновить KPI', { error: err.message }); + } +} + +async function loadAgents() { + try { + const data = await api('supervisor', 'supervisor/agents'); + if (!data.length) { + $('agentsList').innerHTML = '
          • Агенты пока не зафиксированы.
          • '; + return; + } + $('agentsList').innerHTML = data + .map((item) => `
          • ${item.agent_id}
            статус: ${item.state}
            очередь: ${item.queue_id || 'не указана'}
          • `) + .join(''); + log('Список агентов обновлён', { count: data.length }); + } catch (err) { + $('agentsList').innerHTML = '
          • Не удалось загрузить агентов.
          • '; + log('Не удалось загрузить агентов', { error: err.message }); + } +} + +async function setAgentState() { + try { + const data = await api('supervisor', 'supervisor/agent-states', { + method: 'POST', + body: JSON.stringify({ + agent_id: $('agentId').value.trim() || 'supervisor_demo_agent', + state: $('agentState').value, + queue_id: $('agentQueueId').value.trim() || null, + }), + }); + log('Статус агента обновлён', { agent_id: data.agent_id, state: data.state }); + await Promise.all([loadAgents(), loadRealtime()]); + } catch (err) { + log('Не удалось обновить статус агента', { error: err.message }); + } +} + +async function saveQueueMetrics() { + try { + const queueId = encodeURIComponent($('queueMetricId').value.trim() || 'line2'); + const inQueue = Number($('queueInQueue').value || 0); + const avgWait = Number($('queueAvgWait').value || 0); + const data = await api( + 'supervisor', + `supervisor/queue-metrics?queue_id=${queueId}&in_queue=${inQueue}&avg_wait_seconds=${avgWait}`, + { method: 'POST' }, + ); + $('queueMetricsOutput').textContent = JSON.stringify(data, null, 2); + log('Показатели очереди обновлены', { queue_id: data.queue_id, in_queue: data.in_queue }); + await Promise.all([loadRealtime(), loadKpi()]); + } catch (err) { + $('queueMetricsOutput').textContent = err.message; + log('Не удалось обновить очередь', { error: err.message }); + } +} + +function setRecordingPreviewEmpty(message) { + $('recordingMetaOutput').textContent = message || ''; + $('recordingSelectionHint').textContent = message || 'Выберите запись для предпросмотра.'; + $('recordingPlayer').removeAttribute('src'); + $('recordingPlayer').load(); + $('downloadRecordingBtn').disabled = true; + state.selectedRecordingId = null; + if (state.recordingPreviewUrl) { + URL.revokeObjectURL(state.recordingPreviewUrl); + state.recordingPreviewUrl = null; + } +} + +function renderRecordingsList(data) { + if (!data.length) { + $('recordingsList').innerHTML = '
          • Записи не найдены.
          • '; + setRecordingPreviewEmpty('По текущим фильтрам записи не найдены.'); + return; + } + + $('recordingsList').innerHTML = data + .map( + (item) => ` +
          • + ${item.recording_id}
            + звонок: ${item.call_id}
            + файл: ${item.file_name}
            + статус: ${item.status} +
            + +
            +
          • + `, + ) + .join(''); + + document.querySelectorAll('.recording-open-btn').forEach((button) => { + button.addEventListener('click', () => { + void openRecording(button.dataset.recordingId || ''); + }); + }); +} + +function renderRecordingMeta(data) { + $('recordingMetaOutput').textContent = JSON.stringify(data, null, 2); + $('recordingSelectionHint').textContent = `Выбрано: ${data.recording_id} | статус: ${data.status}`; + $('downloadRecordingBtn').disabled = false; +} + +async function loadRecordings() { + try { + const params = new URLSearchParams(); + const callId = $('recordingCallId').value.trim(); + const interactionId = $('recordingInteractionId').value.trim(); + const status = $('recordingStatus').value; + if (callId) { + params.set('call_id', callId); + } + if (interactionId) { + params.set('interaction_id', interactionId); + } + if (status) { + params.set('status', status); + } + params.set('limit', '50'); + + const suffix = params.toString() ? `?${params.toString()}` : ''; + const data = await api('recording', `recordings${suffix}`); + renderRecordingsList(data); + log('Список записей обновлён', { count: data.length }); + } catch (err) { + $('recordingsList').innerHTML = '
          • Не удалось загрузить записи.
          • '; + setRecordingPreviewEmpty(`Ошибка: ${err.message}`); + log('Не удалось загрузить записи', { error: err.message }); + } +} + +async function loadRecordingPreview(recordingId) { + const response = await fetchBinary('recording', `recordings/${encodeURIComponent(recordingId)}/content?disposition=inline`); + const blob = await response.blob(); + if (state.recordingPreviewUrl) { + URL.revokeObjectURL(state.recordingPreviewUrl); + } + state.recordingPreviewUrl = URL.createObjectURL(blob); + $('recordingPlayer').src = state.recordingPreviewUrl; + $('recordingPlayer').load(); +} + +async function openRecording(recordingId) { + if (!recordingId) { + return; + } + try { + const data = await api('recording', `recordings/${encodeURIComponent(recordingId)}`); + state.selectedRecordingId = recordingId; + renderRecordingMeta(data); + await loadRecordingPreview(recordingId); + log('Запись открыта', { recording_id: recordingId }); + } catch (err) { + setRecordingPreviewEmpty(`Не удалось открыть запись: ${err.message}`); + log('Не удалось открыть запись', { error: err.message }); + } +} + +async function resolveRecordingEvent(rawValue) { + const value = (rawValue || '').trim(); + if (!value) { + return null; + } + + const events = await api('voice', 'integrations/voice/events?limit=300'); + if (value.startsWith('vev_')) { + const direct = events.find((item) => item.event_id === value) || null; + return direct ? { eventId: value, event: direct } : { eventId: value, event: null }; + } + + const recordingEvents = events.filter((item) => item.event_type === 'recording.ready'); + for (let i = recordingEvents.length - 1; i >= 0; i -= 1) { + const item = recordingEvents[i]; + if (item.interaction_id === value || item.call_id === value) { + return { eventId: item.event_id, event: item }; + } + } + return null; +} + +async function resolveVoiceContext(rawValue) { + const value = (rawValue || '').trim(); + if (!value) { + return null; + } + const events = await api('voice', 'integrations/voice/events?limit=300'); + const matches = events.filter( + (item) => item.event_id === value || item.interaction_id === value || item.call_id === value, + ); + if (!matches.length) { + return null; + } + return matches[matches.length - 1]; +} + +async function importRecording() { + const rawInput = $('recordingEventId').value.trim(); + if (!rawInput) { + setRecordingPreviewEmpty('Укажите ID voice_event, обращения или звонка перед импортом.'); + return; + } + try { + const resolved = await resolveRecordingEvent(rawInput); + if (!resolved?.eventId) { + const ctx = await resolveVoiceContext(rawInput); + const callId = ctx?.call_id || ''; + const interactionId = ctx?.interaction_id || ''; + if (callId) { + $('recordingCallId').value = callId; + } + if (interactionId) { + $('recordingInteractionId').value = interactionId; + } + + if (callId) { + const existing = await api('recording', `recordings?call_id=${encodeURIComponent(callId)}&limit=10`); + if (Array.isArray(existing) && existing.length) { + await loadRecordings(); + await openRecording(existing[0].recording_id); + log('Запись уже импортирована автоматически', { call_id: callId, recording_id: existing[0].recording_id }); + return; + } + } + + const reason = ctx?.event_type === 'call.started' + ? 'Звонок ещё активен или только что завершился. Подождите 5-15 секунд до события recording.ready.' + : `Событие recording.ready для "${rawInput}" не найдено.`; + setRecordingPreviewEmpty(reason); + log('Импорт записи не выполнен', { error: reason, input: rawInput }); + return; + } + const { eventId, event } = resolved; + if (eventId !== rawInput) { + $('recordingEventId').value = eventId; + log('Событие записи найдено автоматически', { input: rawInput, event_id: eventId }); + } + + if (event?.payload?.source === 'asterisk' && !event?.payload?.source_path) { + $('recordingCallId').value = event.call_id || ''; + $('recordingInteractionId').value = event.interaction_id || ''; + await loadRecordings(); + setRecordingPreviewEmpty( + 'Записи Asterisk импортируются автоматически. Используйте список ниже и откройте последнюю запись.', + ); + log('Ручной импорт пропущен для события Asterisk', { event_id: eventId, call_id: event.call_id }); + return; + } + + const data = await api('recording', `recordings/import-from-voice-event/${encodeURIComponent(eventId)}`, { + method: 'POST', + }); + $('recordingCallId').value = data.call_id; + $('recordingInteractionId').value = data.interaction_id || ''; + await loadRecordings(); + await openRecording(data.recording_id); + log('Запись импортирована', { recording_id: data.recording_id, event_id: eventId }); + } catch (err) { + setRecordingPreviewEmpty(`Импорт не выполнен: ${err.message}`); + log('Импорт записи не выполнен', { error: err.message }); + } +} + +async function archiveRecording() { + if (!state.selectedRecordingId) { + setRecordingPreviewEmpty('Сначала откройте запись.'); + return; + } + try { + const data = await api('recording', `recordings/${encodeURIComponent(state.selectedRecordingId)}/archive`, { + method: 'POST', + }); + renderRecordingMeta(data); + await loadRecordings(); + log('Запись отправлена в архив', { recording_id: data.recording_id }); + } catch (err) { + log('Не удалось отправить запись в архив', { error: err.message }); + } +} + +async function downloadRecording() { + if (!state.selectedRecordingId) { + setRecordingPreviewEmpty('Сначала откройте запись.'); + return; + } + try { + const response = await fetchBinary( + 'recording', + `recordings/${encodeURIComponent(state.selectedRecordingId)}/content?disposition=attachment`, + ); + const blob = await response.blob(); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + const contentDisposition = response.headers.get('Content-Disposition') || ''; + const fileNameMatch = /filename=\"?([^\";]+)\"?/i.exec(contentDisposition); + link.href = url; + link.download = fileNameMatch?.[1] || `${state.selectedRecordingId}.wav`; + document.body.appendChild(link); + link.click(); + link.remove(); + URL.revokeObjectURL(url); + log('Запись скачана', { recording_id: state.selectedRecordingId }); + } catch (err) { + log('Не удалось скачать запись', { error: err.message }); + } +} + +function wire() { + $('loginBtn').addEventListener('click', login); + $('corporateLoginBtn').addEventListener('click', startCorporateLogin); + $('refreshBtn').addEventListener('click', async () => { + await Promise.all([loadRealtime(), loadKpi(), loadLiveCalls(), loadAgents(), loadRecordings(), refreshKnowledgeBase(true)]); + log('Данные супервизора обновлены'); + }); + $('loadRealtimeBtn').addEventListener('click', loadRealtime); + $('loadKpiBtn').addEventListener('click', loadKpi); + $('loadLiveCallsBtn').addEventListener('click', () => loadLiveCalls()); + $('loadAgentsBtn').addEventListener('click', loadAgents); + $('setAgentBtn').addEventListener('click', setAgentState); + $('saveQueueMetricsBtn').addEventListener('click', saveQueueMetrics); + $('createKbCategoryBtn').addEventListener('click', createKnowledgeCategory); + $('loadKbBtn').addEventListener('click', () => refreshKnowledgeBase()); + $('searchKbBtn').addEventListener('click', () => loadKnowledgeArticles()); + $('saveKbArticleBtn').addEventListener('click', saveKnowledgeArticle); + $('resetKbArticleBtn').addEventListener('click', resetKnowledgeArticleForm); + $('kbArticlesList').addEventListener('click', openKnowledgeArticleFromList); + $('importRecordingBtn').addEventListener('click', importRecording); + $('loadRecordingsBtn').addEventListener('click', loadRecordings); + $('archiveRecordingBtn').addEventListener('click', archiveRecording); + $('downloadRecordingBtn').addEventListener('click', downloadRecording); + $('logoutBtn').addEventListener('click', logout); + window.addEventListener('message', handleOidcMessage); +} + +async function init() { + if (!restoreStoredSession()) { + window.location.href = '/'; + return; + } + if (!ensurePageAccess()) { + return; + } + wire(); + updateSessionInfo(); + setRecordingPreviewEmpty(''); + resetKnowledgeArticleForm(); + await Promise.all([ + checkGateway(), + loadOidcConfig(), + loadRealtime(), + loadKpi(), + loadLiveCalls(true), + loadAgents(), + loadRecordings(), + refreshKnowledgeBase(true), + ]); + window.setInterval(() => { + void loadLiveCalls(true); + }, 5000); + log('Пульт супервизора готов'); +} + +init(); diff --git a/ui/supervisor/index.html b/ui/supervisor/index.html new file mode 100644 index 0000000..a02b198 --- /dev/null +++ b/ui/supervisor/index.html @@ -0,0 +1,272 @@ + + + + + + KonturCC Супервизор + + + + + + + +
            + + +
            +
            + +
            +
            Шлюз: проверка...
            + +
            +
            SV
            +
            +
            Экран супервизора
            +
            Операционный контроль
            +
            +
            +
            +
            + +
            +
            +

            Операционный контур

            +

            Пульт супервизора

            +

            Операционный обзор: состояние команды, очередей и ключевых KPI.

            +
            + +
            +

            Сессия

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

            Токен: отсутствует

            +
            + +
            +
            +

            Оперативный срез

            +
            + + +
            +

            Этот блок нужен для оперативной проверки и технической диагностики на экране супервизора.

            +
            +
            + Технический ответ оперативного среза +
            
            +            
            +
            +
            + Технический ответ KPI +
            
            +            
            +
            + +
            +

            Живые звонки Asterisk

            +
            + +
            +

            Показываются последние звонки, пришедшие от Asterisk.

            +
              +
              + +
              +

              Агенты

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

                Очередь

                +
                + + +
                +
                + + +
                +

                Используйте это как быстрый операционный контроль для демонстрационного среза.

                +
                
                +          
                + +
                +

                База знаний

                +

                Супервизор может вручную создать категорию, добавить статью и сразу обновить базу знаний для Voice AI.

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

                Создайте новую статью или выберите существующую для редактирования.

                + +
                + + +
                + +
                +
                +

                Категории

                +
                  +
                  +
                  +

                  Статьи

                  +
                    +
                    +
                    +
                    + Предпросмотр выбранной статьи +
                    
                    +            
                    +
                    + +
                    +

                    Лог действий

                    +

                    Сверху всегда последнее действие супервизора.

                    +
                    
                    +          
                    + +
                    +

                    Записи

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

                    Для Asterisk запись обычно импортируется автоматически после завершения звонка.

                    +
                      +
                      +
                      Выберите запись для предпросмотра.
                      +
                      
                      +              
                      +              
                      +            
                      +
                      +
                      +
                      +
                      +
                      + + + +