diff --git a/.env.example b/.env.example index 10cf1b6..a527a32 100644 --- a/.env.example +++ b/.env.example @@ -110,6 +110,14 @@ LLM_MAX_TOOL_ROUNDS=5 # Wall-clock cap on one whole agentic turn (langgraph engine only). LLM_AGENT_TIMEOUT_S=30.0 +# Per-request context ceiling for the vLLM server (not a memory reservation -- +# KV cache is paged in on demand). At --gpu-memory-utilization 0.50 the default +# Qwen3-30B-A3B-FP8 gets ~293k tokens of KV cache, so 49152 leaves worst-case +# room for ~6 concurrent full-length sequences: three sit at 50% of the cache, +# four at 67%. Raise toward the model's native 262144 only if you also drop +# --max-num-seqs, or long requests will preempt each other mid-call. +MAX_MODEL_LEN=49152 + # Extra args appended to the vLLM server command. The default enables native # tool-call parsing with the "hermes" parser (correct for the default Qwen3 # model). Set to match your model — e.g. for openai/gpt-oss-*: @@ -169,9 +177,20 @@ ENABLE_PLUGIN_AUTODISCOVERY=true WEATHER_LATITUDE= WEATHER_LONGITUDE= -# WEB_SEARCH tool: URL of a SearxNG instance. The compose files ship one -# behind a profile: `docker compose --profile search up -d` then set -# SEARXNG_URL=http://searxng:8080 +# Home/base address, injected into the system prompt so the agent knows where +# "here"/"home" is. The MAP tool routes from the WEATHER_LATITUDE/LONGITUDE +# coordinates above; this is the human-readable label. Empty omits it. +# Example: 851 Chalcedony St, San Diego +AGENT_LOCATION= + +# MAP tool: driving distance/time + directions via OpenStreetMap (keyless). +# Needs WEATHER_LATITUDE/LONGITUDE for the "from home" origin. +ENABLE_MAP_TOOL=true + +# WEB_SEARCH tool: URL of a SearxNG instance. The base compose starts one by +# default (open-webui depends on it); docker-compose.dgx.yml keeps it behind a +# profile (`docker compose -f docker-compose.dgx.yml --profile search up -d`). +# Then set SEARXNG_URL=http://searxng:8080 SEARXNG_URL= WEB_SEARCH_MAX_RESULTS=3 @@ -192,6 +211,51 @@ CONTAINER_CTL_ALLOWLIST= # Targets follow the same OUTBOUND_* dial policy below. ENABLE_TRANSFER_TOOL=true +# =================== +# Caller identity verification (optional) +# =================== +# Prove a caller is who they claim before sensitive actions, via a static PIN +# and/or a rolling TOTP code entered on the keypad (the VERIFY tool). The +# feature stays dormant until you set a global factor below OR enroll a caller +# via POST /verify/credentials — otherwise nothing changes. +ENABLE_VERIFY_TOOL=true +# Global fallback factors, shared across all callers (per-caller enrollments in +# data/verify_credentials.json take precedence). Leave empty for no global factor. +VERIFY_PIN= +VERIFY_TOTP_SECRET= +# TOTP algorithm parameters (RFC 6238) — must match the caller's authenticator / +# issuing system. Digits per code, seconds per step, and the HMAC hash +# (SHA1|SHA256|SHA512). SHA1 / 6 digits / 30s are the near-universal defaults. +VERIFY_TOTP_DIGITS=6 +VERIFY_TOTP_PERIOD=30 +VERIFY_TOTP_ALGORITHM=SHA1 +# Accept TOTP codes within +/- this many steps (clock-skew tolerance). +VERIFY_TOTP_WINDOW=1 +# Comma-separated tool names that REQUIRE a verified caller before they run +# (fail closed). Example: VERIFY_REQUIRED_TOOLS=TRANSFER,CONTAINER_CTL +VERIFY_REQUIRED_TOOLS= +# Wrong-code attempts allowed per call, and how long to wait for the caller to +# START keying in a code (seconds). +VERIFY_MAX_ATTEMPTS=3 +VERIFY_DTMF_TIMEOUT_S=20.0 +# Once digits are being entered, auto-submit after this gap with no new key, so +# the caller need not press '#' and a time-based one-time code doesn't expire +# while waiting out the full window. +VERIFY_DTMF_INTERDIGIT_S=3.0 +# Issuer label embedded in authenticator provisioning URIs (enrollment/QR). +VERIFY_ISSUER=General Disarray +# Spoken lines for the outbound "call and verify" flow (POST /verify/call): the +# agent dials the caller and asks them to key in their PIN/OTP, then verifies it. +VERIFY_CALL_PROMPT=Please enter your PIN or one-time code, then press pound. +VERIFY_CALL_RETRY_PHRASE=That code wasn't right. Please try again. +VERIFY_CALL_SUCCESS_PHRASE=Thank you — your identity is verified. Goodbye. +VERIFY_CALL_FAIL_PHRASE=I could not verify your identity. Goodbye. +# Where per-caller enrollments live (default: /verify_credentials.json, +# written 0600 — it holds plaintext TOTP secrets, so it is git-ignored). +#VERIFY_CREDENTIALS_FILE= +# Open WebUI host port (base compose only; sip-agent already publishes API_PORT). +#OPEN_WEBUI_PORT=3000 + # =================== # REST API security & limits # =================== @@ -318,8 +382,11 @@ TURN_ACK_MODE=chime # Virtual numbers: ephemeral single-use inbound extensions created via # POST /virtual-numbers. A call dialed to one is answered with the number's # purpose as context; the outcome is webhooked and the number cleared. -# Unused numbers expire after the TTL. Requires the PBX/registrar (if any) -# to route the range to the agent; direct SIP dialing works out of the box. +# Unused numbers expire after the TTL. With "persistent": true a number +# becomes a long-lived trigger number (n8n "SIP Agent Trigger" node) whose +# every call fires the selected events (answered/first_speech/speech/ +# completed). Requires the PBX/registrar (if any) to route the range to the +# agent; direct SIP dialing works out of the box. # VIRTUAL_NUMBERS_ENABLED=false # VIRTUAL_NUMBER_DEFAULT_TTL_S=900 # VIRTUAL_NUMBER_MAX_TTL_S=86400 diff --git a/.gitignore b/.gitignore index 5b1ce7a..e620ef6 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,9 @@ data/workflows.json data/virtual_numbers.json data/mcp_servers.json data/personas.json +# Per-caller PIN hashes + plaintext TOTP secrets — never commit. +data/verify_credentials.json +data/verify_credentials.tmp # Ship one example knowledge doc (indexed by RAG, exercised by the e2e # KNOWLEDGE test); keep any other user-dropped documents ignored. diff --git a/.gitmodules b/.gitmodules index 71697e5..582d577 100644 --- a/.gitmodules +++ b/.gitmodules @@ -7,3 +7,6 @@ [submodule "speaches"] path = speaches url = https://github.com/speaches-ai/speaches.git +[submodule "examples/n8n-nodes"] + path = examples/n8n-nodes + url = https://github.com/CHA0S-CORP/n8n-nodes.git diff --git a/CLAUDE.md b/CLAUDE.md index 0676c18..164cbca 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -104,4 +104,4 @@ cd speaches # ruff format/check, pyright, pytest - TTS for common phrases (greetings, acknowledgments, etc.) is pre-cached at startup from `config.phrases` (see `PhrasesConfig.get_all_phrases_for_cache`); new fixed phrases should flow through that path for instant playback. Phrases can be overridden via `PHRASES_*` env vars (JSON array or comma-separated) or a `data/phrases.json` file. - Commit style uses emoji-prefixed conventional commits (e.g. `✨ feat:`, `fix:`). - `docker-compose.dgx.yml` is a separate, self-contained compose for the DGX Spark / GB10 target (recently split out) — keep it in sync with the base compose when changing service wiring. -- `examples/n8n-nodes-general-disarray/` is a custom n8n node package for the agent's REST API. Its `dist/` is bind-mounted into the n8n container (`N8N_CUSTOM_EXTENSIONS=/custom-nodes`) — run `examples/n8n-nodes-general-disarray/build.sh` before recreating the n8n container, and keep the n8n service blocks in both compose files in lockstep. +- `examples/n8n-nodes/` is a git submodule of [CHA0S-CORP/n8n-nodes](https://github.com/CHA0S-CORP/n8n-nodes); the agent's custom n8n node package lives there at `packages/n8n-nodes-general-disarray/`. Its `dist/` is bind-mounted into the n8n container (`N8N_CUSTOM_EXTENSIONS=/custom-nodes`) — `git submodule update --init examples/n8n-nodes`, run `examples/n8n-nodes/packages/n8n-nodes-general-disarray/build.sh` before recreating the n8n container, and keep the n8n service blocks in both compose files in lockstep. Node code changes go to the n8n-nodes repo (PR there, then bump the submodule pointer here). diff --git a/README.md b/README.md index 7adfbc7..0f6f058 100644 --- a/README.md +++ b/README.md @@ -490,7 +490,7 @@ TTS_SPEED=1.1 | ☎️ `TRANSFER` | Blind-transfer the caller (SIP REFER) | *"Transfer me to extension 2001"* | Several tools stay hidden until configured: `WEB_SEARCH` needs `SEARXNG_URL` -(`docker compose --profile search up -d`), `FORECAST` needs `WEATHER_LATITUDE`/`WEATHER_LONGITUDE`, +(started by default in the base compose; `--profile search` on `docker-compose.dgx.yml`), `FORECAST` needs `WEATHER_LATITUDE`/`WEATHER_LONGITUDE`, `GPU_STATUS`/`ALERTS` need the observability stack, and `CONTAINER_CTL` needs **both** a `CONTAINER_CTL_ALLOWLIST` and the (commented-out) docker-socket mount — the socket is root-equivalent on the host, so enable it deliberately. diff --git a/docker-compose.dgx.yml b/docker-compose.dgx.yml index 9e43aba..f0f22b8 100644 --- a/docker-compose.dgx.yml +++ b/docker-compose.dgx.yml @@ -35,6 +35,9 @@ services: environment: - NVIDIA_VISIBLE_DEVICES=all - HUGGING_FACE_HUB_TOKEN=${HF_TOKEN:-} + # Blackwell (GB10/sm_121): DeepGemm FP8 MoE asserts ("Unknown SF transformation") + # on the 30B-A3B-FP8. Keep this unless you've confirmed the 25.11 image fixed it. + - VLLM_USE_DEEP_GEMM=0 # <-- re-added (was missing) ipc: host ulimits: memlock: -1 @@ -43,17 +46,16 @@ services: vllm serve ${LLM_MODEL:-Qwen/Qwen3-30B-A3B-Instruct-2507-FP8} --port 8000 - --gpu-memory-utilization 0.60 - --max-model-len 8192 + --gpu-memory-utilization 0.75 + --max-model-len 65536 --trust-remote-code --enable-prefix-caching --enable-chunked-prefill - --max-num-batched-tokens 2048 - --max-num-seqs 4 + --kv-cache-dtype fp8 + --max-num-batched-tokens 8192 + --max-num-seqs 16 ${VLLM_TOOL_ARGS---enable-auto-tool-choice --tool-call-parser hermes} ports: - # Host-side publish is parameterized so the stack can coexist with other - # services on the box; in-cluster traffic uses http://vllm:8000 regardless. - "${VLLM_PORT:-8000}:8000" volumes: - ~/.cache/huggingface:/root/.cache/huggingface @@ -71,7 +73,6 @@ services: count: all capabilities: [gpu] restart: unless-stopped - # ============================================================================ # Speaches Server - Unified STT (Whisper) + TTS (Piper/Kokoro) # OpenAI-compatible API for both transcription and speech synthesis @@ -176,6 +177,10 @@ services: # Location for WEATHER / FORECAST / QUAKES-near (empty disables those tools) - WEATHER_LATITUDE=${WEATHER_LATITUDE:-} - WEATHER_LONGITUDE=${WEATHER_LONGITUDE:-} + # Home address for the system prompt + MAP tool routing origin + - AGENT_LOCATION=${AGENT_LOCATION:-} + # MAP tool: driving distance/time via OpenStreetMap (needs coordinates above) + - ENABLE_MAP_TOOL=${ENABLE_MAP_TOOL:-true} # WEB_SEARCH via SearxNG (start it with: docker compose --profile search up -d) - SEARXNG_URL=${SEARXNG_URL:-} - WEB_SEARCH_MAX_RESULTS=${WEB_SEARCH_MAX_RESULTS:-3} @@ -188,6 +193,28 @@ services: - ENABLE_TRANSFER_TOOL=${ENABLE_TRANSFER_TOOL:-true} # DRINK_RECIPE tool (TheCocktailDB lookups) - ENABLE_DRINK_TOOL=${ENABLE_DRINK_TOOL:-true} + # Optional caller identity verification (VERIFY tool, DTMF PIN/OTP entry). + # Off until a global PIN/secret is set or a caller is enrolled via + # /verify/credentials. VERIFY_REQUIRED_TOOLS hard-gates the listed tools. + - ENABLE_VERIFY_TOOL=${ENABLE_VERIFY_TOOL:-true} + - VERIFY_PIN=${VERIFY_PIN:-} + - VERIFY_TOTP_SECRET=${VERIFY_TOTP_SECRET:-} + # TOTP algorithm params (RFC 6238) — must match the caller's authenticator. + - VERIFY_TOTP_DIGITS=${VERIFY_TOTP_DIGITS:-6} + - VERIFY_TOTP_PERIOD=${VERIFY_TOTP_PERIOD:-30} + - VERIFY_TOTP_ALGORITHM=${VERIFY_TOTP_ALGORITHM:-SHA1} + - VERIFY_TOTP_WINDOW=${VERIFY_TOTP_WINDOW:-1} + - VERIFY_REQUIRED_TOOLS=${VERIFY_REQUIRED_TOOLS:-} + - VERIFY_MAX_ATTEMPTS=${VERIFY_MAX_ATTEMPTS:-3} + - VERIFY_DTMF_TIMEOUT_S=${VERIFY_DTMF_TIMEOUT_S:-20.0} + - VERIFY_DTMF_INTERDIGIT_S=${VERIFY_DTMF_INTERDIGIT_S:-3.0} + - VERIFY_ISSUER=${VERIFY_ISSUER:-General Disarray} + # Spoken lines for the outbound "call and verify" flow (POST /verify/call). + - VERIFY_CALL_PROMPT=${VERIFY_CALL_PROMPT:-} + - VERIFY_CALL_RETRY_PHRASE=${VERIFY_CALL_RETRY_PHRASE:-} + - VERIFY_CALL_SUCCESS_PHRASE=${VERIFY_CALL_SUCCESS_PHRASE:-} + - VERIFY_CALL_FAIL_PHRASE=${VERIFY_CALL_FAIL_PHRASE:-} + - VERIFY_CREDENTIALS_FILE=${VERIFY_CREDENTIALS_FILE:-} # Single-line prompt override; multi-line prompts go in data/system_prompt.txt - SYSTEM_PROMPT=${SYSTEM_PROMPT:-} # Per-turn acknowledgment: chime (earcon, default) | phrase | none @@ -280,7 +307,7 @@ services: - CALL_EVENT_WEBHOOK_URL=${CALL_EVENT_WEBHOOK_URL:-} - CALL_EVENTS=${CALL_EVENTS:-call.started,call.ended} - CALL_EVENT_INCLUDE_TRANSCRIPT=${CALL_EVENT_INCLUDE_TRANSCRIPT:-true} - # Virtual numbers: ephemeral single-use inbound extensions (POST /virtual-numbers) + # Virtual numbers: ephemeral single-use inbound extensions + persistent trigger numbers (POST /virtual-numbers) - VIRTUAL_NUMBERS_ENABLED=${VIRTUAL_NUMBERS_ENABLED:-false} - VIRTUAL_NUMBER_DEFAULT_TTL_S=${VIRTUAL_NUMBER_DEFAULT_TTL_S:-900} - VIRTUAL_NUMBER_MAX_TTL_S=${VIRTUAL_NUMBER_MAX_TTL_S:-86400} @@ -328,7 +355,7 @@ services: environment: - SEARXNG_BASE_URL=http://searxng:8080/ ports: - - "127.0.0.1:8081:8080" + - "127.0.0.1:8082:8080" restart: unless-stopped n8n: @@ -348,8 +375,8 @@ services: - N8N_CUSTOM_EXTENSIONS=/custom-nodes volumes: - n8n_data:/home/node/.n8n - # Custom SIP Agent nodes — built by examples/n8n-nodes-general-disarray/build.sh - - ./examples/n8n-nodes-general-disarray/dist:/custom-nodes/n8n-nodes-general-disarray:ro + # Custom SIP Agent nodes — from the examples/n8n-nodes submodule (CHA0S-CORP/n8n-nodes); build with examples/n8n-nodes/packages/n8n-nodes-general-disarray/build.sh + - ./examples/n8n-nodes/packages/n8n-nodes-general-disarray/dist:/custom-nodes/n8n-nodes-general-disarray:ro volumes: n8n_data: diff --git a/docker-compose.observability.yml b/docker-compose.observability.yml index cba136d..8484863 100644 --- a/docker-compose.observability.yml +++ b/docker-compose.observability.yml @@ -218,7 +218,7 @@ services: - --port=8000 - --tensor-parallel-size=${TENSOR_PARALLEL_SIZE:-1} - --gpu-memory-utilization=${GPU_MEMORY_UTILIZATION:-0.90} - - --max-model-len=${MAX_MODEL_LEN:-8192} + - --max-model-len=${MAX_MODEL_LEN:-49152} - --otlp-traces-endpoint=http://otel-collector:4317 # depends_on: # otel-collector: diff --git a/docker-compose.yml b/docker-compose.yml index 29d4c1f..013e831 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -26,46 +26,312 @@ services: restart: unless-stopped # ============================================================================ - # vLLM Server - Serves the LLM + # LLM Server - Ling-3.0-flash-int4 on SGLang (replaced vLLM 2026-08-07) + # + # Was: vllm/vllm-openai:latest serving Qwen/Qwen3-30B-A3B-Instruct-2507-FP8 + # at --gpu-memory-utilization 0.50 (~60 GiB). + # Now: 124B total / 5.1B active, ~90 GiB. The service is still named `vllm` + # and still answers on http://vllm:8000 so sip-agent's LLM_BASE_URL and + # `depends_on: vllm` keep working unchanged. Only the engine changed. + # + # Runtime is SGLang, NOT vLLM: this is a BailingMoeV3 / bailing_hybrid model + # that released vLLM builds do not register. + # + # THE IMAGE IS BUILT LOCALLY, from ~/code-llm/ling-fp4: + # docker build -t sglang-ling-fp4:local ~/code-llm/ling-fp4/ + # (The "fp4" in the tag is historical - the image is a patched SGLang, not a + # checkpoint. See ~/code-llm/ling-fp4/README.md for the full story.) The int4 + # checkpoint here does not need that image's FP4 loader patch, but it does + # benefit from its ling3 structural-tag patch, which is what makes + # tool_choice="required" grammar-constrained instead of a free-for-all. + # + # THE THREE FLAGS BELOW ARE LOAD-BEARING - do not drop them: + # --disable-shared-experts-fusion correctness + # --moe-runner-backend=triton correctness + # --default-chat-template-kwargs latency + # Each is explained at its line. Verified working 2026-08-07. # ============================================================================ vllm: - image: vllm/vllm-openai:latest + image: sglang-ling-fp4:local # built from ~/code-llm/ling-fp4 - see above container_name: sip-ai-vllm runtime: nvidia environment: - NVIDIA_VISIBLE_DEVICES=all - HUGGING_FACE_HUB_TOKEN=${HF_TOKEN:-} - # DeepGEMM has no SM 12.1 (GB10) support — vLLM >= 0.24 crashes loading - # FP8 weights through it ("Unknown SF transformation"); force the - # Triton/CUTLASS fallback instead. - - VLLM_USE_DEEP_GEMM=0 ipc: host + shm_size: "32gb" # SGLang wants a large /dev/shm ulimits: memlock: -1 stack: 67108864 - # Upstream :latest switched its ENTRYPOINT to ["vllm", "serve"]; blank it - # so the full command below works on any image vintage. + # This image's ENTRYPOINT is not a server, so spell out the full command. entrypoint: [] - command: > - vllm serve - ${LLM_MODEL:-Qwen/Qwen3-30B-A3B-Instruct-2507-FP8} - --port 8000 - --gpu-memory-utilization 0.50 - --max-model-len 8192 - --trust-remote-code - ${VLLM_TOOL_ARGS---enable-auto-tool-choice --tool-call-parser hermes} + # LIST form, not the folded "command: >" string form. Compose splits the + # string form with shell-like rules and strips the inner double quotes from + # the JSON below, turning {"enable_thinking":false} into + # {enable_thinking:false} - which json.loads rejects, so the server refuses + # to start. Keep this a list. + command: + - python3 + - -m + - sglang.launch_server + - --model-path=${LLM_MODEL_PATH:-inclusionAI/Ling-3.0-flash-int4} + - --served-model-name=${LLM_MODEL:-ling-3.0-flash} + - --host=0.0.0.0 + - --port=8000 + - --tp=1 + - --mem-fraction-static=${LLM_MEM_FRACTION:-0.74} + - --context-length=${MAX_MODEL_LEN:-65536} + # ---- quantisation-specific block: currently int4 -------------------- + # FP4 was put on this URL on 2026-08-08 and reverted the same day. It + # passed every synthetic test (tool calls 8/8 on bare requests, code echo + # ~3%, 40% faster decode) but produced degenerate output under real + # OpenCode traffic. int4 has never done that in any test here. + # + # TO TRY FP4 AGAIN: set LLM_MODEL_PATH=inclusionAI/Ling-3.0-flash-fp4 in + # .env and swap this block for: + # - --moe-runner-backend=flashinfer_mxfp4 + # - --flashinfer-mxfp4-moe-precision=default + # - --disable-shared-experts-fusion + # - --enable-fp32-lm-head + # - --fp8-gemm-backend=cutlass + # (all five required; the last three are from the vendor's model card). + # FP4 also relies on a hand-added generation_config.json in the HF cache + # setting temperature 0.2 - see ling-fp4/README.md in ~/code-llm. + # Soak it against real traffic before trusting it, not just probes. + # MARLIN TESTED AND REJECTED 2026-08-09. The original token soup was + # caused by shared-experts fusion, not by Marlin, so Marlin was retested + # with --disable-shared-experts-fusion: output was fine and acceptance was + # healthy (2.37-2.82), but decode measured 9.0 tok/s vs triton's 26.3. + # Marlin is simply not optimised for sm_121 here - same story as its 172 s + # CUDA graph capture on FP4 (vs 5 s for flashinfer). Do not retry. + - --moe-runner-backend=triton + - --disable-shared-experts-fusion + # FlashKDA prefill TESTED 2026-08-09: no gain (8.4/14.4/25.5/78.1s vs + # triton's 7.3/14.8/25.3/77.9s at 15K/23K/32K/59K). The KDA layers are + # not the prefill bottleneck. + # Prefill scaling measures O(n^1.64) -> O(n^1.83) as context grows, + # i.e. the 7 quadratic MLA layers dominate at long context. But no + # better MLA kernel is available here: trtllm_mla dies with + # "TllmGenFmhaRunner ... Unsupported architecture" on sm_121, and + # flashinfer attention ties triton. + # + # KEEPER: bigger prefill chunks. Same total attention FLOPs, but fewer + # passes and better GEMM shapes. Measured cold-prefill wait, 8K chunks + # -> 32K chunks, confirmed over two runs: + # 15K ctx 7.3s -> 5.8s (-21%) + # 24K ctx 14.8s -> 11.9s (-20%) + # 32K ctx 25.3s -> 15.8s (-38%) + # 59K ctx 77.9s -> 74.5s (-4%, quadratic MLA dominates there) + # Costs 0.4% of the KV pool (881,891 -> 878,702 tokens) and zero + # request slots. Decode unchanged at 20.7 tok/s, correctness clean. + # NOTE an earlier note in this file says 16384/32768 was 'tried and + # reverted' as neutral - that test ran with NEXTN active, which + # distorted it. Retested cleanly without NEXTN, it is a real win. + - --chunked-prefill-size=32768 + - --max-prefill-tokens=65536 + # KDA kernel sweep 2026-08-09: 35 of 42 layers are KDA linear-attention, + # so this is where the decode compute actually is. Baseline (triton): + # median 20.7 tok/s. + # NOTE: --linear-attn-backend accepts {triton,cutedsl,flashinfer,flashkda, + # nvidia_kda,ptx_kda} but KDA *decode* only supports triton/cutedsl/ + # flashinfer - ptx_kda crashes the scheduler at startup with + # "Unsupported KDA decode backend". flashkda is prefill-only. + # KDA BACKEND SWEEP DONE 2026-08-09 - triton (the default) wins, so no + # --linear-attn-backend flag is set. Measured medians, server metric: + # triton (default) 20.7 <- keep + # cutedsl 19.8 (but 49 req slots vs 32) + # flashinfer decode + triton 20.7 (tie, extra flag for nothing) + # ptx_kda / nvidia_kda crash: decode supports only + # triton/cutedsl/flashinfer + # flashkda prefill-only + # Correctness was clean (0/6 junk, 4/4 tools) on every variant. + # torch.compile TESTED AND REJECTED 2026-08-09: compiled for 10+ min + # emitting 152 'ttir analysis hit an op we do not know how to analyze: + # tt.elementwise_inline_asm' failures (so it cannot even trace the + # triton kernels this model relies on), and drove host MemAvailable to + # 3 GiB - memwatch.sh killed it to protect the box. Do not enable. + # KERNEL SWEEP COMPLETE 2026-08-09 - every alternative ties or loses to + # SGLang's auto-selected defaults, so NO backend flags are set beyond + # --moe-runner-backend=triton (which is a correctness requirement, not + # a speed choice). Medians from SGLang's own gen-throughput metric: + # + # defaults (triton everywhere) 20.7-20.8 <- ceiling + # flashinfer MLA attention 20.7-20.8 tie (n=93) + # flashinfer mamba backend 20.7-20.8 tie + # KDA flashinfer decode 20.7 tie + # KDA cutedsl 19.8 worse (49 slots though) + # marlin MoE runner 9.0 much worse + # NEXTN speculative decoding 11.0 SUPERSEDED - see below + # NEXTN + longer chain 7.1 SUPERSEDED - see below + # KDA ptx_kda / nvidia_kda crash (decode unsupported) + # torch.compile OOM, watchdog killed it + # + # The two NEXTN rows above were remeasured 2026-08-17 and do not hold; the + # rest of the sweep still stands. "~20.8 tok/s is this box's ceiling for + # int4" was wrong: with NEXTN configured properly it is 33.8. See the + # NEXTN block below. + # SPECULATIVE TUNING, revised 2026-08-17. The 2026-08-09 note here said + # SGLang's auto-chosen chain (steps=3/topk=1/draft=4) was already best and + # to "leave the speculative parameters on auto". Half right: longer chains + # ARE worse (a full steps/draft sweep confirms steps=5/draft=6 is the worst + # arm tested), but auto is not the peak and the parameters must not be left + # unset. Measured, SGLang's own gen-throughput metric, mem-fraction 0.88: + # steps1/draft2 32.66 tok/s 6 slots accept len 1.79 rate 0.80 + # steps2/draft3 33.77 tok/s 5 slots accept len 2.24 rate 0.62 <- + # steps3/draft4 32.54 tok/s 2 slots accept len 2.50 rate 0.50 + # steps4/draft5 29.86 tok/s 3 slots accept len 2.55 rate 0.39 + # steps5/draft6 25.94 tok/s 4 slots accept len 2.50 rate 0.30 + # Past draft=3 accept LENGTH still rises while accept RATE collapses: the + # extra draft tokens get computed and thrown away. That is the mechanism + # behind the old steps5/draft6 observation, and it is real. + # The draft budget is the ONLY lever that pays. Also swept 2026-08-17 on + # top of steps2/draft3, one knob per arm, all within ~3% BELOW baseline + # and all three identical to each other on accept len/rate (i.e. they do + # not move the decode path at all): + # baseline 33.60 tok/s ttft 0.194s + # --speculative-attention-mode=decode 32.73 ttft 0.196s + # --num-continuous-decode-steps=2 32.59 ttft 0.190s + # --scheduler-recv-interval=4 32.60 ttft 0.195s + # Do not bother with these. (--num-continuous-decode-steps did NOT hurt + # TTFT despite its docs - it just buys nothing.) + # Speculative decoding via the checkpoint's built-in MTP layer. config + # has num_nextn_predict_layers=1 and the weights carry 43 layers (0-42) + # against num_hidden_layers=42, so layer 42 IS the draft layer. This is + # the vendor model card's "recommended low-latency recipe". Decode is the + # bottleneck for agent use here (int4 on the triton MoE runner is + # ~14-18 tok/s), and MTP is the one lever that attacks it without + # changing quantisation. + # NEXTN / MTP SPECULATIVE DECODING: REJECTED 2026-08-09, OVERTURNED + # 2026-08-17. It is worth 1.63x on single-stream decode. + # + # no spec 20.67 tok/s 13 slots + # steps2/draft3 33.77 tok/s 5 slots accept len 2.24 + # + # both by SGLang's own gen-throughput line, same image, same box. + # + # WHY THE 2026-08-09 VERDICT WAS WRONG. Its reasoning was "this box is + # compute-bound on the MoE, not memory-bound; speculation wins on + # memory-bound decode". Neither half survives measurement: + # - Not compute-bound on the MoE. Summing the safetensors and scaling + # routed experts by num_experts_per_tok/num_experts = 8/512, int4 + # touches 7.49 GB per decoded token, of which ATTENTION is ~60% and + # the 8 live experts only ~13%. At 273 GB/s that allows 36.4 tok/s + # and we measure ~20 - about 50% MBU. + # - The missing half is per-step overhead, and that is the regime + # speculation wins hardest in. Driving the live server: + # bs=1 20.2 | bs=2 46.4 (23.2/stream) | bs=4 90.2 (22.5/stream) + # A step carrying 4 tokens costs about what a step carrying 1 costs, + # so verifying k draft tokens is nearly free. Accept len 2.4-2.9 + # should have yielded ~2.5x, and now does. + # The old run almost certainly passed a bare --speculative-algorithm=NEXTN + # and let the other three parameters default. Unset + # --speculative-num-draft-tokens reaches allocation_sizing.py:26 as None + # (max(spec_steps*spec_topk, spec_tokens)); a sibling bench script doing + # exactly that died there with TypeError. PIN ALL FOUR PARAMETERS. + # + # STILL TRUE from the old note: the "26.3 tok/s" claim that predated it + # was a client-side artifact. Speculative decoding delivers tokens in + # bursts, so tokens/(total - ttft) over-states the rate. Judge only by + # SGLang's own "gen throughput" log line. + # + # COSTS, and they are real: + # - Request slots 13 -> 5. KDA linear-attention state is 37.46 MB/req + # and the hybrid cache is sized against the draft budget. Fine for + # one phone call plus OpenCode; not fine for many concurrent callers. + # (Slot counts move +/-1 with load-time memory state.) + # - Needs --mem-fraction-static 0.85 -> 0.88. At 0.85 the server dies + # at startup with "Not enough GPU memory for hybrid (mamba/linear- + # attention) state cache. Computed max_mamba_cache_size=-6". 0.88 is + # still under the ~0.92 ceiling docker-compose.ablate.yml warns about. + # The no-spec baseline at 0.88 is 20.67, so mem-fraction alone does + # not move bs=1 decode. + # - NOT validated on tool-calling or long agent turns; accept len 2.24 + # comes from a prose prompt. And LING_ABLATE_LAYERS=28:42 is half-open + # so the draft layer 42 is OUTSIDE it - an un-ablated draft proposing + # against an ablated target. Acceptance in refusal-adjacent territory + # is unmeasured. That interaction did not exist in 2026-08-09. + # + # Full analysis and sweep table: ling-fp4/README.md, "Speculative decoding + # (NEXTN) on int4". To enable, uncomment ALL FOUR and raise mem-fraction: + # - --speculative-algorithm=NEXTN + # - --speculative-num-steps=2 + # - --speculative-eagle-topk=1 + # - --speculative-num-draft-tokens=3 + # (superseded: see the chunked-prefill KEEPER note below) + # Longest-prefix-match beats fcfs when several agent sessions share the + # box: it schedules to maximise radix cache reuse, and cache hits are the + # difference between a 0.5 s turn and a 33 s one. + - --schedule-policy=lpm + # Let one session's prefill share a batch with another's decode instead + # of alternating, so a long prefill does not stall other sessions. + - --enable-mixed-chunk + # Concurrency: max_running_requests was capped at 16 by the mamba state + # cache (max_mamba_cache_size=80, 5 slots per request), not by KV. 35 of + # this model's 42 layers are KDA linear-attention, so that recurrent + # state is large - 5.54 GB ssm_state + 0.19 GB conv_state. bfloat16 + # halves it, which roughly doubles the request cap and hands the freed + # memory back to the KV pool. + - --mamba-ssm-dtype=bfloat16 + # Single-quoted so YAML treats the JSON as a scalar, not a flow mapping. + - '--default-chat-template-kwargs={"enable_thinking": false}' + - --reasoning-parser=ling3 + - --tool-call-parser=ling3 + - --chat-template=/opt/ling/chat_template.jinja + - --trust-remote-code + # ^ Why each of the non-obvious flags: + # + # --moe-runner-backend triton + # Default 'auto' picks CompressedTensorsWNA16MarlinMoEMethod + # (GPTQ-Marlin), which loads without complaint on GB10 (sm_121) and + # then emits pure token soup - greedy "what is 2+2" returns + # " practically practically practically...". + # + # --disable-shared-experts-fusion + # Same token-soup symptom from a different cause. This checkpoint + # leaves shared experts UNQUANTIZED (config.json `ignore` list) while + # routed experts are int4. SGLang fuses the shared expert into the + # routed-expert tensor by default and the int4 path has no handler + # for that mixed case, so it silently corrupts the weights. + # + # --default-chat-template-kwargs {"enable_thinking":false} + # THIS IS THE ONE THAT MAKES IT USABLE ON A PHONE CALL. Ling is a + # thinking model, on by default. sip-agent is a generic OpenAI client + # and never sends chat_template_kwargs, so without this every turn + # generates reasoning tokens first - which are never spoken. Measured + # time to first *spoken* token, 728-token system prompt + 3 tools: + # thinking on : 1441 ms chit-chat, 3.66 s through a tool round + # thinking off: 173 ms chit-chat, 1.56 s through a tool round + # A per-request chat_template_kwargs still overrides this if some + # caller genuinely wants the reasoning path. + # + # NOTE: $VLLM_TOOL_ARGS, $VLLM_MAX_NUM_SEQS, $VLLM_MAX_NUM_BATCHED_TOKENS + # and $VLLM_KV_CACHE_DTYPE are vLLM-only and are deliberately NOT passed + # here; SGLang would reject them. They are commented out in .env. ports: # Host-side publish is parameterized so the stack can coexist with other # services on the box; in-cluster traffic uses http://vllm:8000 regardless. - "${VLLM_PORT:-8000}:8000" volumes: - ~/.cache/huggingface:/root/.cache/huggingface + # Chat template with the tool-call placeholders replaced by a concrete + # example; the stock one teaches the model to emit literal + # "{arg-value-1}" and "...". See ~/code-llm/ling-fp4/README.md. + - ./docker/ling/chat_template.jinja:/opt/ling/chat_template.jinja:ro healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + # This image has no curl; use python3 like the rest of the SGLang stack. + test: ["CMD-SHELL", "python3 -c \"import urllib.request; urllib.request.urlopen('http://localhost:8000/health')\""] interval: 30s timeout: 10s retries: 5 - start_period: 300s + # Was 300s for the 30B. Ling needs ~7 min (weights ~200s + Marlin/graph + # capture). sip-agent gates on `condition: service_healthy`, so a + # start_period shorter than the real load time would fail the stack. + start_period: 2400s + # If this ever runs away, make the OOM killer pick it before the desktop. + # On GB10 the GPU allocates from the same 119 GiB as the OS, and driver + # allocations are not charged to the process RSS, so the global OOM killer + # cannot otherwise see the offender. + oom_score_adj: 1000 deploy: resources: reservations: @@ -75,6 +341,489 @@ services: capabilities: [gpu] restart: unless-stopped + # ============================================================================ + # LLM warmup - one-shot, exits immediately. + # + # The first request to a freshly started SGLang pays JIT/autotune cost for the + # long-prompt-with-tools kernel shapes. Measured with the real ~3.3K-token, + # 29-tool sip-agent turn: 35.6 s cold vs ~1-2 s warm. sip-agent's + # LLM_AGENT_TIMEOUT_S is 30, so WITHOUT this the first caller after a restart + # or reboot times out and gets nothing. + # + # Uses the SGLang image only because it is already on the box and has python3 + # - it runs a ~2 KB script, not a model. Failures are ignored by design; a + # warmup must never keep the stack down. + # ============================================================================ + llm-warmup: + image: sglang-ling-fp4:local + container_name: sip-ai-llm-warmup + restart: "no" + depends_on: + vllm: + condition: service_healthy + environment: + - LLM_WARMUP_URL=http://vllm:8000/v1 + - LLM_MODEL=${LLM_MODEL:-ling-3.0-flash} + entrypoint: ["python3", "/opt/ling/warmup.py"] + volumes: + - ./docker/ling/warmup.py:/opt/ling/warmup.py:ro + + # ============================================================================ + # Open WebUI - browser chat front-end for the same Ling the phone bot uses. + # + # Cloned from ~/llm-sc-1 on 2026-08-08. Its data volume was copied across, so + # existing chats/users/settings carry over (llm-sc-1_open-webui -> + # general-disarray_open-webui). Stop the llm-sc-1 one before starting this; + # they both want host port 8080 and both would write their own copy of the DB. + # + # Rewired for this stack rather than copied verbatim - in llm-sc-1 the STT, + # TTS and image endpoints point at services that do not exist here. Here: + # LLM -> vllm (Ling-3.0-flash, same network, no host gateway needed) + # search -> searxng (already in this stack) + # STT/TTS-> speaches (already in this stack: Whisper + Kokoro) + # Image generation is intentionally absent - there is no imagegen service in + # this stack, and starting one would want GPU memory Ling is holding. + # + # !! SHARES THE LLM WITH LIVE PHONE CALLS !! A long prompt pasted here blocks + # the call path: measured, a 244K-token request kept a trivial request queued + # for 679 s while sip-agent's LLM_AGENT_TIMEOUT_S is 30. Keep documents small + # or expect dropped calls. See ~/code-llm/ling-fp4/README.md. + # ============================================================================ + # Apache Tika - document text extraction (PDF, DOCX, PPTX...) for RAG. + # The cloned config has CONTENT_EXTRACTION_ENGINE=tika, so without this + # service every document upload fails to extract. CPU-only, no GPU. + tika: + image: apache/tika:3.1.0.0 + container_name: tika + restart: unless-stopped + ports: + - "127.0.0.1:9998:9998" + deploy: + resources: + limits: + memory: 2G + healthcheck: + test: ["CMD", "bash", "-c", "(echo > /dev/tcp/localhost/9998) 2>/dev/null"] + interval: 30s + timeout: 10s + retries: 10 + logging: + driver: json-file + options: + max-size: "20m" + max-file: "3" + + open-webui: + image: ghcr.io/open-webui/open-webui:main + container_name: open-webui + restart: unless-stopped + depends_on: + vllm: + condition: service_healthy + searxng: + condition: service_started + tika: + condition: service_started + ports: + # Default 3000: sip-agent already publishes host :${API_PORT:-8080}. + - "${OPEN_WEBUI_PORT:-3000}:8080" + # The embedding server (code-llm-embed, :8002) lives in the SEPARATE + # `code-llm` compose project, so its container name does not resolve on + # this project's network. Reach it through the host's published port. + extra_hosts: + - "host.docker.internal:host-gateway" + volumes: + - open-webui:/app/backend/data + # Reranker/embedding models are already in the host cache; without this + # the container re-downloads them on first RAG use. + - ~/.cache/huggingface:/hf-cache + environment: + # ===================================================================== + # CONFIG PARITY WITH ~/llm-sc-1 (restored 2026-08-09) + # + # This service was cloned from llm-sc-1 on 2026-08-08 but only the LLM / + # search / voice wiring came across - the auth, hardening, RAG and UI + # settings did not. They are restored here. Anything that pointed at a + # service llm-sc-1 has and this stack does not (imagegen/FLUX, chatterbox + # TTS, open-terminal) is deliberately still absent; everything else now + # matches, with this stack's endpoints substituted. + # + # ENABLE_PERSISTENT_CONFIG=False is what makes the rest of this block + # actually apply. With it True (the default) Open WebUI persists config in + # webui.db on first boot and the stored value wins forever after, so edits + # here would be silently ignored on an existing volume - which is exactly + # why this instance drifted. The trade: Admin Settings changes made in the + # UI no longer survive a restart. Change settings HERE, not in the UI. + # ===================================================================== + + # ---- General ----------------------------------------------------------- + - WEBUI_NAME=Cha0s AI + - WEBUI_DESCRIPTION=An AI assistant for hackers, built with open-source models and tools. + - WEBUI_THEME=dark + - "RESPONSE_WATERMARK=— Cha0s AI" + - WEBUI_URL=${WEBUI_URL:-https://cha0s.ai} + - WEBUI_PORT=8080 + - WEBUI_SECRET_KEY=${WEBUI_SECRET_KEY} + # WEBHOOK_URL is deliberately NOT set, though llm-sc-1 sets it to + # https://cha0s.ai/webhook. It is meant to point at an external notifier + # (Slack/Discord/n8n); Open WebUI has no /webhook route of its own, so + # that value made it POST to itself and log a 405 traceback on every + # notification event. Verified here 2026-08-09 before removing it. Point + # it at http://n8n:5678/webhook/ if outbound notifications are wanted. + - ENABLE_PERSISTENT_CONFIG=False + - ENABLE_REALTIME_CHAT_SAVE=True + - ENABLE_TELEMETRY=False + - LOG_LEVEL=info + - GLOBAL_LOG_LEVEL=info + - ENABLE_AUDIT_STDOUT=True + - AUDIT_LOG_LEVEL=REQUEST_RESPONSE + + # ---- Auth: Entra ID SSO only ------------------------------------------- + # cha0s.ai is internet-reachable (Cloudflare -> this box), so the previous + # state - password login on, open signup - let anyone who found the + # hostname create an account. All five existing users in webui.db are + # already linked to the `microsoft` OAuth provider, including the admin, + # so turning the password form off does not lock anyone out. + # IF SSO EVER BREAKS: set ENABLE_LOGIN_FORM/ENABLE_PASSWORD_AUTH back to + # true here and `docker compose up -d open-webui` to get the form back. + - ENABLE_LOGIN_FORM=false + - ENABLE_PASSWORD_AUTH=false + - ENABLE_SIGNUP=False + - DEFAULT_USER_ROLE=user + - MICROSOFT_CLIENT_ID=${MICROSOFT_CLIENT_ID} + - MICROSOFT_CLIENT_SECRET=${MICROSOFT_CLIENT_SECRET} + - MICROSOFT_CLIENT_TENANT_ID=${MICROSOFT_CLIENT_TENANT_ID} + - OPENID_PROVIDER_URL=https://login.microsoftonline.com/${MICROSOFT_CLIENT_TENANT_ID}/v2.0/.well-known/openid-configuration + - MICROSOFT_OAUTH_SCOPE=openid email profile offline_access + - ENABLE_OAUTH_SIGNUP=true + # Entra app roles -> Open WebUI roles + - ENABLE_OAUTH_ROLE_MANAGEMENT=true + - OAUTH_ROLES_CLAIM=roles + - OAUTH_ADMIN_ROLES=admin + - OAUTH_ALLOWED_ROLES=user,admin + # Entra groups -> Open WebUI groups + - ENABLE_OAUTH_GROUP_MANAGEMENT=true + - OAUTH_GROUP_CLAIM=groups + - ENABLE_OAUTH_GROUP_CREATION=true + - OAUTH_GROUP_DEFAULT_SHARE=members + + # ---- Security: cookies, CORS, tokens ----------------------------------- + # Secure=True is correct now that the entry point is HTTPS at cha0s.ai. It + # does mean a browser hitting http://10.42.252.10:8080 directly will not + # store the session cookie, so LAN logins must go through the public name. + - WEBUI_SESSION_COOKIE_SECURE=True + - WEBUI_AUTH_COOKIE_SECURE=True + - WEBUI_SESSION_COOKIE_SAME_SITE=lax + - JWT_EXPIRES_IN=1h + # llm-sc-1 listed the LAN origin without a port, which can never match a + # browser request to :8080; the port is added here. + - CORS_ALLOW_ORIGIN=https://cha0s.ai;http://10.42.252.10:8080 + - BYPASS_MODEL_ACCESS_CONTROL=False + - ENABLE_API_KEYS=True + + # ---- LLM: the same Ling container the phone bot uses ------------------- + # llm-sc-1 pointed at its own gemma4 vLLM on :8888; here it is this + # stack's SGLang/Ling on the compose network. + - ENABLE_OPENAI_API=True + - ENABLE_OLLAMA_API=False + - OPENAI_API_BASE_URL=http://vllm:8000/v1 + - OPENAI_API_KEY=not-needed + - AIOHTTP_CLIENT_TIMEOUT=600 + # llm-sc-1's three model knobs all named gemma-4-26b, which does not exist + # in this stack. ling-3.0-flash is the only served model here. + - DEFAULT_MODELS=${LLM_MODEL:-ling-3.0-flash} + - DEFAULT_PINNED_MODELS=${LLM_MODEL:-ling-3.0-flash} + - TASK_MODEL_EXTERNAL=${LLM_MODEL:-ling-3.0-flash} + - ENABLE_CHANNELS=True + - ENABLE_EVALUATION_ARENA_MODELS=False + - ENABLE_EVALUATION_ARENA_USER_CHALLENGES=False + - ENABLE_CHAT_RESPONSE_BASE64_IMAGE_URL_CONVERSION=True + - ENABLE_CHAT_RESPONSE_BASE64_AUDIO_URL_CONVERSION=True + + # ---- Workspace sharing permissions ------------------------------------- + - USER_PERMISSIONS_WORKSPACE_MODELS_ALLOW_SHARING=True + - USER_PERMISSIONS_WORKSPACE_KNOWLEDGE_ALLOW_SHARING=True + - USER_PERMISSIONS_WORKSPACE_PROMPTS_ALLOW_SHARING=True + - USER_PERMISSIONS_WORKSPACE_SKILLS_ALLOW_SHARING=True + - USER_PERMISSIONS_WORKSPACE_TOOLS_ALLOW_SHARING=True + + # ---- Web search: searxng in this stack --------------------------------- + # Result count / concurrency stay at this stack's tuned values (llm-sc-1 + # used 15 results and no concurrency settings); 0 concurrency serialised + # the search step. + - ENABLE_WEB_SEARCH=True + - WEB_SEARCH_ENGINE=searxng + - SEARXNG_QUERY_URL=http://searxng:8080/search?q=&format=json + - SEARXNG_LANGUAGE=en + - WEB_SEARCH_RESULT_COUNT=30 + - WEB_SEARCH_CONCURRENT_REQUESTS=10 + - BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL=False + - WEB_FETCH_MAX_CONTENT_LENGTH=50000 + # Page fetching: 30 results is only useful if the pages are fetched in + # parallel and slow sites time out instead of blocking the batch. + - WEB_LOADER_CONCURRENT_REQUESTS=15 + - WEB_LOADER_TIMEOUT=20 + - ENABLE_WEB_LOADER_SSL_VERIFICATION=True + - ENABLE_RAG_LOCAL_WEB_FETCH=True + + # ---- RAG / retrieval --------------------------------------------------- + # Chunking and RAG_SYSTEM_CONTEXT come from llm-sc-1. top_k stays at this + # stack's wider 20/10 rather than llm-sc-1's 5 - with hybrid search and a + # reranker in front, a narrow top_k was the real limiter on how much of a + # 30-site web search ever reached the model. + - RAG_SYSTEM_CONTEXT=True + - CHUNK_SIZE=1500 + - CHUNK_OVERLAP=200 + - ENABLE_RAG_HYBRID_SEARCH=True + - RAG_TOP_K=20 + - RAG_TOP_K_RERANKER=10 + - RAG_RELEVANCE_THRESHOLD=0.0 + - RAG_RERANKING_MODEL=cross-encoder/ms-marco-MiniLM-L-6-v2 + + # ---- Embeddings: Qwen3-Embedding-4B @ 1024-d (added 2026-08-11) -------- + # Was the built-in sentence-transformers engine running + # sentence-transformers/all-MiniLM-L6-v2 (384-d) on the Grace cores + # inside this container. Now offloaded to the GPU-backed vLLM pooling + # server in the `code-llm` project (container code-llm-embed, host + # :8002, served as `qwen3-embed`). That server pins output width to 1024 + # via --pooler-config, which matters here because Open WebUI's + # generate_openai_batch_embeddings posts only {input, model} and never + # sends a `dimensions` field - the width has to be a server-side default. + # + # !! DIMENSION CHANGE: 384 -> 1024 !! Every collection already in + # /app/backend/data/vector_db was written by MiniLM and is now both the + # wrong width AND the wrong vector space. Old documents must be + # re-uploaded/re-indexed; new ones are fine. Nothing re-embeds itself. + # + # NOT set deliberately: RAG_EMBEDDING_QUERY_PREFIX / + # RAG_EMBEDDING_CONTENT_PREFIX. Qwen3-Embedding does support instruction + # prefixes, but Open WebUI ships them as an extra JSON field named by + # RAG_EMBEDDING_PREFIX_FIELD_NAME, which vLLM's embeddings endpoint does + # not accept. Leaving them unset costs a little retrieval quality and + # keeps the requests valid. + - RAG_EMBEDDING_ENGINE=openai + - RAG_EMBEDDING_MODEL=qwen3-embed + - RAG_OPENAI_API_BASE_URL=http://host.docker.internal:8002/v1 + - RAG_OPENAI_API_KEY=not-needed + # Default is 1 - one HTTP round trip per chunk, which makes indexing a + # large document painfully serial. 16 matches the server's --max-num-seqs. + - RAG_EMBEDDING_BATCH_SIZE=16 + + # ---- Document extraction ---------------------------------------------- + - CONTENT_EXTRACTION_ENGINE=tika + - TIKA_SERVER_URL=http://tika:9998 + - PDF_EXTRACT_IMAGES=True + + # ---- Voice: speaches in this stack ------------------------------------- + # llm-sc-1 used a separate faster-whisper-server and chatterbox TTS; both + # roles are filled by `speaches` here. + - AUDIO_STT_ENGINE=openai + - AUDIO_STT_OPENAI_API_BASE_URL=http://speaches:8001/v1 + - AUDIO_STT_OPENAI_API_KEY=not-needed + - AUDIO_STT_MODEL=${WHISPER_MODEL:-Systran/faster-distil-whisper-small.en} + - AUDIO_TTS_ENGINE=openai + - AUDIO_TTS_OPENAI_API_BASE_URL=http://speaches:8001/v1 + - AUDIO_TTS_OPENAI_API_KEY=not-needed + - AUDIO_TTS_MODEL=${TTS_MODEL:-speaches-ai/Kokoro-82M-v1.0-ONNX} + - AUDIO_TTS_VOICE=${TTS_VOICE:-af_heart} + + # ---- RAG model cache --------------------------------------------------- + - HF_HOME=/hf-cache + - SENTENCE_TRANSFORMERS_HOME=/hf-cache + + # ---- Prompt suggestions (verbatim from llm-sc-1) ------------------------ + # NOTE: the three "Generate an image of ..." entries near the end came from + # llm-sc-1, which had a FLUX imagegen service. This stack has none, so + # those suggestions will not produce an image. + - | + DEFAULT_PROMPT_SUGGESTIONS=[ + {"title": ["Explain how", "buffer overflows work"], "content": "Explain how buffer overflow vulnerabilities work, including stack-based and heap-based variants, and how modern mitigations like ASLR and stack canaries defend against them."}, + {"title": ["Write a Python", "port scanner"], "content": "Write a Python script that performs a TCP port scan on a given target IP address, supporting configurable port ranges and timeouts."}, + {"title": ["Explain the difference between", "symmetric and asymmetric encryption"], "content": "Explain the difference between symmetric and asymmetric encryption, with examples of common algorithms and when to use each."}, + {"title": ["Generate a", "reverse shell cheat sheet"], "content": "Generate a reverse shell cheat sheet covering bash, Python, PHP, Netcat, and PowerShell payloads."}, + {"title": ["Explain how", "SQL injection works"], "content": "Explain how SQL injection attacks work, including union-based, blind, and time-based techniques, with examples and prevention strategies."}, + {"title": ["Write a Bash script to", "enumerate a Linux system"], "content": "Write a Bash script that performs basic Linux enumeration: users, network config, running services, SUID binaries, cron jobs, and writable directories."}, + {"title": ["Explain how", "JWT tokens can be exploited"], "content": "Explain common JWT vulnerabilities including none algorithm attacks, secret brute-forcing, and key confusion attacks."}, + {"title": ["Help me write", "a custom Nmap NSE script"], "content": "Help me write a custom Nmap NSE script that checks for a specific vulnerability or service misconfiguration."}, + {"title": ["Explain how", "Docker container escapes work"], "content": "Explain common Docker container escape techniques and how to harden containers against them."}, + {"title": ["Explain the", "MITRE ATT&CK framework"], "content": "Explain the MITRE ATT&CK framework, its tactics and techniques, and how red teams and blue teams use it in practice."}, + {"title": ["Help me set up", "a home lab for pentesting"], "content": "Help me plan a home lab setup for practicing penetration testing, including recommended VMs, vulnerable applications, and network architecture."}, + {"title": ["Write a", "XSS payload collection"], "content": "Create a collection of XSS payloads for different contexts: HTML, JavaScript, attribute injection, and DOM-based scenarios, with explanations."}, + {"title": ["Explain how", "Kerberos authentication works"], "content": "Explain how Kerberos authentication works in Active Directory, including TGT, TGS, and common attacks like Kerberoasting and Golden Ticket."}, + {"title": ["Write a Python script to", "crack password hashes"], "content": "Write a Python script that performs dictionary attacks against common hash types (MD5, SHA-256, bcrypt) for authorized security testing."}, + {"title": ["Explain how", "SSRF vulnerabilities work"], "content": "Explain Server-Side Request Forgery (SSRF) vulnerabilities, common exploitation techniques, and how to access cloud metadata endpoints."}, + {"title": ["Help me write", "a Burp Suite extension"], "content": "Help me write a Burp Suite extension in Python that automates detection of a specific vulnerability class."}, + {"title": ["Explain the", "OAuth 2.0 attack surface"], "content": "Explain common OAuth 2.0 vulnerabilities including redirect URI manipulation, token leakage, and CSRF attacks on the authorization flow."}, + {"title": ["Write a", "Wireshark display filter cheat sheet"], "content": "Create a comprehensive Wireshark display filter cheat sheet for analyzing network traffic during incident response."}, + {"title": ["Explain how", "privilege escalation works on Linux"], "content": "Explain common Linux privilege escalation techniques: SUID abuse, kernel exploits, cron jobs, PATH hijacking, and capability misconfigurations."}, + {"title": ["Write a Python", "web scraper with stealth features"], "content": "Write a Python web scraper that rotates user agents, handles rate limiting, and uses session management to avoid detection."}, + {"title": ["Explain how", "ARP spoofing works"], "content": "Explain ARP spoofing attacks, how they enable man-in-the-middle positioning on a LAN, and how to detect and prevent them."}, + {"title": ["Help me write", "YARA rules for malware detection"], "content": "Help me write YARA rules to detect common malware patterns, including string matching, byte patterns, and condition logic."}, + {"title": ["Explain how", "ransomware encryption works"], "content": "Explain the technical mechanisms behind ransomware encryption, including hybrid encryption schemes and key management used by threat actors."}, + {"title": ["Write a", "Dockerfile for a CTF environment"], "content": "Write a Dockerfile that sets up a CTF challenge environment with common tools like pwntools, GDB with pwndbg, and radare2."}, + {"title": ["Explain how", "DNS tunneling works"], "content": "Explain DNS tunneling techniques for data exfiltration, how tools like dnscat2 and iodine work, and detection strategies."}, + {"title": ["Write a Python", "network packet sniffer"], "content": "Write a Python packet sniffer using raw sockets or scapy that captures and analyzes network traffic with protocol parsing."}, + {"title": ["Explain how", "race condition vulnerabilities work"], "content": "Explain race condition vulnerabilities in web applications, including TOCTOU bugs and how to exploit them with concurrent requests."}, + {"title": ["Help me analyze", "a suspicious binary"], "content": "Walk me through the process of statically and dynamically analyzing a suspicious binary, including tools, techniques, and indicators to look for."}, + {"title": ["Explain how", "WebSocket security issues arise"], "content": "Explain common WebSocket security vulnerabilities including cross-site WebSocket hijacking, injection attacks, and lack of authentication."}, + {"title": ["Write a", "Terraform config for a red team lab"], "content": "Write Terraform configuration to deploy a cloud-based red team infrastructure with redirectors, C2, and phishing servers."}, + {"title": ["Explain how", "deserialization attacks work"], "content": "Explain insecure deserialization vulnerabilities in Java, Python, and PHP, with examples of exploitation and prevention."}, + {"title": ["Write a Python script for", "subdomain enumeration"], "content": "Write a Python script that enumerates subdomains using DNS brute-forcing, certificate transparency logs, and web scraping techniques."}, + {"title": ["Explain how", "Bluetooth hacking works"], "content": "Explain Bluetooth security vulnerabilities including BlueBorne, KNOB attacks, and techniques for Bluetooth reconnaissance and exploitation."}, + {"title": ["Help me write", "a fuzzer for a network protocol"], "content": "Help me write a protocol fuzzer in Python that generates malformed inputs to test a network service for crashes and vulnerabilities."}, + {"title": ["Explain how", "supply chain attacks work"], "content": "Explain software supply chain attack vectors including dependency confusion, typosquatting, and build pipeline compromise with real-world examples."}, + {"title": ["Write a", "regex collection for log analysis"], "content": "Create a collection of regex patterns for parsing and analyzing common log formats: Apache, Nginx, syslog, Windows Event Logs, and auth logs."}, + {"title": ["Explain how", "API security testing works"], "content": "Explain how to test REST and GraphQL APIs for security vulnerabilities including BOLA, broken authentication, mass assignment, and injection."}, + {"title": ["Write a Python", "exploit for a format string bug"], "content": "Write a Python exploit using pwntools that demonstrates format string vulnerability exploitation for educational CTF purposes."}, + {"title": ["Explain how", "Windows privilege escalation works"], "content": "Explain common Windows privilege escalation techniques: token impersonation, unquoted service paths, DLL hijacking, and registry abuse."}, + {"title": ["Help me set up", "an ELK stack for threat hunting"], "content": "Help me set up an ELK (Elasticsearch, Logstash, Kibana) stack for security log aggregation and threat hunting with example queries."}, + {"title": ["Explain how", "phishing infrastructure works"], "content": "Explain the technical components of a phishing campaign from a defensive perspective: infrastructure, delivery, payloads, and detection opportunities."}, + {"title": ["Write a", "GDB cheat sheet for binary exploitation"], "content": "Create a GDB cheat sheet focused on binary exploitation, including pwndbg/GEF commands, breakpoints, memory inspection, and ROP gadget finding."}, + {"title": ["Explain how", "cloud metadata attacks work"], "content": "Explain cloud metadata service attacks on AWS, GCP, and Azure, including SSRF-to-RCE chains and IAM credential theft."}, + {"title": ["Write a Python", "C2 beacon for a CTF"], "content": "Write a simple Python C2 beacon and listener for CTF/lab use that demonstrates command-and-control communication patterns."}, + {"title": ["Explain how", "memory forensics works"], "content": "Explain memory forensics techniques using Volatility, including how to extract processes, network connections, and injected code from memory dumps."}, + {"title": ["Help me write", "Sigma rules for detection"], "content": "Help me write Sigma detection rules for common attack techniques that can be converted to SIEM-specific queries."}, + {"title": ["Explain how", "LLM prompt injection works"], "content": "Explain prompt injection attacks against LLM-powered applications, including direct injection, indirect injection, and jailbreaking techniques."}, + {"title": ["Write a", "Metasploit resource script"], "content": "Write a Metasploit resource script that automates common penetration testing workflows including scanning, exploitation, and post-exploitation."}, + {"title": ["Explain how", "TLS pinning bypass works"], "content": "Explain TLS certificate pinning, how it protects mobile apps, and techniques to bypass it using Frida and objection for authorized testing."}, + {"title": ["Write a Python script to", "analyze malware traffic"], "content": "Write a Python script using scapy to analyze a PCAP file for indicators of compromise: beaconing patterns, DNS anomalies, and data exfiltration."}, + {"title": ["Explain how", "AD attacks chain together"], "content": "Explain a typical Active Directory attack chain from initial access to domain admin, covering enumeration, lateral movement, and persistence."}, + {"title": ["Help me build", "a custom wordlist generator"], "content": "Help me build a Python tool that generates custom wordlists based on target-specific information for password attacks."}, + {"title": ["Explain how", "kernel exploits work"], "content": "Explain the fundamentals of kernel exploitation including common vulnerability classes, exploitation primitives, and modern kernel defenses."}, + {"title": ["Write a", "pwntools exploit template"], "content": "Write a comprehensive pwntools exploit template for CTF binary exploitation challenges covering local and remote targets."}, + {"title": ["Explain how", "WAF bypass techniques work"], "content": "Explain common Web Application Firewall bypass techniques including encoding tricks, HTTP parameter pollution, and chunked transfer abuse."}, + {"title": ["Write a Python", "honeypot service"], "content": "Write a Python honeypot service that emulates a vulnerable service and logs all attacker interactions for analysis."}, + {"title": ["Explain how", "Cobalt Strike detection works"], "content": "Explain Cobalt Strike's architecture and capabilities from a defensive perspective, including detection opportunities for beacon traffic."}, + {"title": ["Help me write", "an incident response playbook"], "content": "Help me write an incident response playbook for a ransomware attack covering containment, eradication, recovery, and lessons learned."}, + {"title": ["Explain how", "side-channel attacks work"], "content": "Explain side-channel attacks including timing attacks, cache attacks, and power analysis, with examples like Spectre and Meltdown."}, + {"title": ["Write a", "Ghidra script for binary analysis"], "content": "Write a Ghidra Python script that automates common reverse engineering tasks like finding crypto constants or suspicious API calls."}, + {"title": ["Explain how", "smart contract exploits work"], "content": "Explain common smart contract vulnerabilities including reentrancy, integer overflow, and flash loan attacks with Solidity examples."}, + {"title": ["Write a Python", "directory brute-forcer"], "content": "Write a Python script for web directory brute-forcing with support for custom wordlists, status code filtering, and recursive scanning."}, + {"title": ["Explain how", "zero-days are discovered"], "content": "Explain the vulnerability research process: attack surface analysis, fuzzing, code auditing, and responsible disclosure workflows."}, + {"title": ["Help me write", "a secure password manager"], "content": "Help me write a command-line password manager in Python using proper cryptographic primitives (Argon2, AES-GCM) with a master password."}, + {"title": ["Explain how", "network pivoting works"], "content": "Explain network pivoting techniques including SSH tunneling, SOCKS proxies, and tools like Chisel and Ligolo for accessing internal networks."}, + {"title": ["Write a", "Linux forensics cheat sheet"], "content": "Create a Linux forensics cheat sheet covering artifact locations, timeline analysis, log analysis, and evidence preservation commands."}, + {"title": ["Explain how", "Android reverse engineering works"], "content": "Explain Android APK reverse engineering using tools like jadx, apktool, and Frida for dynamic analysis and security testing."}, + {"title": ["Write a Python script for", "Wi-Fi deauth detection"], "content": "Write a Python script using Scapy that monitors for Wi-Fi deauthentication frames to detect potential wireless attacks."}, + {"title": ["Explain how", "LDAP injection attacks work"], "content": "Explain LDAP injection vulnerabilities, how they differ from SQL injection, and demonstrate exploitation and prevention techniques."}, + {"title": ["Help me create", "a CTF challenge"], "content": "Help me design and create a capture-the-flag challenge covering web exploitation, with a vulnerable application and solution walkthrough."}, + {"title": ["Explain how", "SSH tunneling works"], "content": "Explain SSH local, remote, and dynamic port forwarding with practical examples for penetration testing and secure access."}, + {"title": ["Write a", "Nmap scanning cheat sheet"], "content": "Create a comprehensive Nmap cheat sheet covering scan types, script scanning, output formats, timing options, and firewall evasion."}, + {"title": ["Explain how", "Kubernetes security works"], "content": "Explain Kubernetes security concerns including RBAC misconfigurations, pod escapes, secrets management, and network policy enforcement."}, + {"title": ["Write a Python", "SSL/TLS analyzer"], "content": "Write a Python script that connects to a host and analyzes its SSL/TLS configuration for weak ciphers, expired certs, and misconfigurations."}, + {"title": ["Explain how", "steganography works"], "content": "Explain digital steganography techniques for hiding data in images, audio, and network traffic, plus detection methods."}, + {"title": ["Help me write", "a shellcode encoder"], "content": "Help me write a custom shellcode encoder in Python for CTF challenges that avoids null bytes and common bad characters."}, + {"title": ["Explain how", "macOS security internals work"], "content": "Explain macOS security internals including Gatekeeper, SIP, TCC, and common bypasses used in red team operations."}, + {"title": ["Write a", "PowerShell hardening script"], "content": "Write a PowerShell script that audits and hardens Windows security settings: firewall rules, audit policies, service configs, and registry keys."}, + {"title": ["Explain how", "email security protocols work"], "content": "Explain SPF, DKIM, and DMARC email authentication, how to audit them, and common misconfigurations that enable spoofing."}, + {"title": ["Write a Python", "OSINT domain recon tool"], "content": "Write a Python OSINT tool that gathers information about a domain: WHOIS, DNS records, subdomains, certificate transparency, and web technologies."}, + {"title": ["Explain how", "heap exploitation works"], "content": "Explain heap exploitation techniques including use-after-free, double-free, and tcache poisoning for glibc-based systems."}, + {"title": ["Help me set up", "Suricata for network monitoring"], "content": "Help me set up Suricata IDS/IPS with custom rules for detecting common attack patterns on my network."}, + {"title": ["Explain how", "CI/CD pipeline attacks work"], "content": "Explain CI/CD security risks including poisoned pipelines, secret extraction, and supply chain compromise through build systems."}, + {"title": ["Write a", "tmux config for hacking"], "content": "Write an optimized tmux configuration for penetration testing workflows with custom panes, status bar, and useful keybindings."}, + {"title": ["Explain how", "BGP hijacking works"], "content": "Explain BGP hijacking attacks, how they redirect internet traffic, real-world incidents, and detection/prevention mechanisms."}, + {"title": ["Write a Python", "SSH brute force detector"], "content": "Write a Python script that analyzes auth.log to detect SSH brute force attempts, extract attacker IPs, and generate fail2ban-compatible rules."}, + {"title": ["Explain how", "ROP chains work"], "content": "Explain ROP (Return-Oriented Programming) exploitation technique, how to find gadgets, and build ROP chains to bypass DEP/NX."}, + {"title": ["Help me write", "a threat model"], "content": "Help me create a threat model for a web application using STRIDE methodology, identifying threats, attack vectors, and mitigations."}, + {"title": ["Explain how", "timing attacks on web apps work"], "content": "Explain timing-based side-channel attacks on web applications, including username enumeration and token comparison vulnerabilities."}, + {"title": ["Write a", "shell config for security work"], "content": "Write an optimized shell configuration with aliases, functions, and tools for penetration testing and security research workflows."}, + {"title": ["Explain how", "firmware reverse engineering works"], "content": "Explain firmware extraction and reverse engineering techniques using binwalk, Ghidra, and emulation with QEMU for IoT security research."}, + {"title": ["Write a Python", "web vulnerability scanner"], "content": "Write a Python vulnerability scanner that checks web applications for common issues: open redirects, CORS misconfig, security headers, and info disclosure."}, + {"title": ["Explain how", "HTTP request smuggling works"], "content": "Explain HTTP request smuggling attacks including CL.TE, TE.CL, and TE.TE variants, with exploitation scenarios and detection methods."}, + {"title": ["Write a", "hash identification tool"], "content": "Write a Python tool that identifies hash types by format and length, and attempts to crack them using common wordlists and rules."}, + {"title": ["Explain how", "EDR evasion works defensively"], "content": "Explain Endpoint Detection and Response evasion techniques from a defensive perspective: what attackers do and how to improve detection."}, + {"title": ["Write a Python", "DNS enumeration tool"], "content": "Write a Python DNS enumeration tool that performs zone transfers, brute-forcing, reverse lookups, and DNS record analysis."}, + {"title": ["Explain how", "prototype pollution works"], "content": "Explain prototype pollution vulnerabilities in JavaScript, how they lead to RCE in Node.js applications, and prevention strategies."}, + {"title": ["Help me create", "Git hooks for secret scanning"], "content": "Help me create Git pre-commit hooks that scan for secrets, credentials, API keys, and other sensitive data before allowing commits."}, + {"title": ["Generate an image of", "a cyberpunk hacker workspace"], "content": "Generate an image of a cyberpunk hacker workspace with multiple monitors showing code, network maps, and terminal windows in a neon-lit room."}, + {"title": ["Generate an image of", "a futuristic AI data center"], "content": "Generate an image of a massive futuristic data center with glowing server racks, holographic displays, and cool blue lighting."}, + {"title": ["Help me plan", "a weekly meal prep schedule"], "content": "Help me plan a weekly meal prep schedule with healthy, budget-friendly recipes that can be batch-cooked on Sunday."}, + {"title": ["Write a", "cover letter for a job application"], "content": "Help me write a professional cover letter. Ask me about the role and my experience, then draft a compelling letter."}, + {"title": ["Explain", "how mortgage rates work"], "content": "Explain how mortgage interest rates work, the difference between fixed and variable rates, and what factors affect the rate I qualify for."}, + {"title": ["Help me write", "a best man speech"], "content": "Help me write a best man speech that's funny, heartfelt, and appropriate. Ask me about the couple and our relationship."}, + {"title": ["Create a", "workout plan for beginners"], "content": "Create a 4-week beginner workout plan that I can do at home with minimal equipment, progressing in difficulty each week."}, + {"title": ["Explain quantum computing", "in simple terms"], "content": "Explain quantum computing in simple terms that a non-physicist could understand, including qubits, superposition, and entanglement."}, + {"title": ["Help me write", "a resignation letter"], "content": "Help me write a professional and gracious resignation letter. Ask me about the circumstances before drafting."}, + {"title": ["Suggest", "date night ideas"], "content": "Suggest 20 creative date night ideas that go beyond dinner and a movie, ranging from free to splurge-worthy."}, + {"title": ["Teach me", "basic car maintenance"], "content": "Teach me the essential car maintenance tasks every owner should know: oil changes, tire pressure, brake checks, fluid levels, and warning signs."}, + {"title": ["Write a Python script to", "organize my files"], "content": "Write a Python script that organizes files in a directory by sorting them into subfolders based on file type (images, documents, videos, etc.)."}, + {"title": ["Explain the basics of", "investing in index funds"], "content": "Explain the basics of investing in index funds for a complete beginner, including how they work, risks, and how to get started."}, + {"title": ["Help me draft", "a rental lease agreement"], "content": "Help me draft a basic residential rental lease agreement covering key terms like rent, security deposit, maintenance, and termination."}, + {"title": ["Create a", "study schedule for an exam"], "content": "Help me create an effective study schedule. Ask me about the exam, subjects, and timeline, then build a structured plan with active recall techniques."}, + {"title": ["Write a short story", "about time travel"], "content": "Write a creative short story about someone who discovers they can travel through time, but each trip has an unexpected consequence."}, + {"title": ["Explain how", "the stock market works"], "content": "Explain how the stock market works from the ground up: exchanges, market orders, IPOs, bulls vs bears, and how regular people can participate."}, + {"title": ["Help me plan", "a road trip itinerary"], "content": "Help me plan a road trip. Ask me about my starting point, destination, budget, and interests, then create a day-by-day itinerary with stops."}, + {"title": ["Teach me", "how to brew better coffee"], "content": "Teach me how to brew better coffee at home, covering grind size, water temperature, ratios, and different brewing methods like pour-over, French press, and AeroPress."}, + {"title": ["Write a", "personal budget template"], "content": "Help me create a personal monthly budget using the 50/30/20 rule. Ask about my income and expenses, then build a detailed breakdown."}, + {"title": ["Explain how", "credit scores work"], "content": "Explain how credit scores are calculated, what factors affect them most, and practical steps to improve a low score."}, + {"title": ["Help me write", "a compelling bio for social media"], "content": "Help me write a professional yet personable bio for LinkedIn, Twitter, or a personal website. Ask me about my background and goals."}, + {"title": ["Recommend", "books based on my interests"], "content": "Recommend 10 books for me. Ask me about genres I like, books I've enjoyed, and what I'm in the mood for."}, + {"title": ["Explain", "how to start a small business"], "content": "Walk me through the key steps to start a small business: business plan, legal structure, registration, funding, and first customers."}, + {"title": ["Create a", "home cleaning schedule"], "content": "Create a realistic weekly and monthly home cleaning schedule that breaks tasks into manageable daily chunks."}, + {"title": ["Teach me", "basic photography composition"], "content": "Teach me the fundamentals of photography composition: rule of thirds, leading lines, framing, symmetry, and how to use light effectively."}, + {"title": ["Help me negotiate", "a higher salary"], "content": "Help me prepare for a salary negotiation. Give me strategies, scripts, and tips for negotiating a raise or a better starting offer."}, + {"title": ["Write a", "D&D character backstory"], "content": "Help me write an engaging D&D character backstory. Ask me about the race, class, and personality traits, then craft a compelling narrative."}, + {"title": ["Explain how", "machine learning works"], "content": "Explain machine learning in plain English: supervised vs unsupervised learning, neural networks, training data, and real-world applications."}, + {"title": ["Help me write", "a thank-you note"], "content": "Help me write a thoughtful thank-you note. Tell me who it's for and the occasion, and I'll draft something sincere and specific."}, + {"title": ["Create a", "morning routine for productivity"], "content": "Help me design a morning routine optimized for energy and productivity, based on sleep science and habit-building research."}, + {"title": ["Explain", "how to read a nutrition label"], "content": "Explain how to read and understand nutrition labels, including serving sizes, daily values, hidden sugars, and what to watch out for."}, + {"title": ["Help me plan", "a vegetable garden"], "content": "Help me plan a beginner-friendly vegetable garden. Ask about my climate zone, space, and preferences, then suggest what to plant and when."}, + {"title": ["Write a", "professional email template"], "content": "Help me write a professional email for a specific situation. Ask me the context, then draft a clear, polite, and effective message."}, + {"title": ["Teach me", "basic home electrical safety"], "content": "Teach me the basics of home electrical safety: how circuits work, when to call an electrician, and common DIY mistakes to avoid."}, + {"title": ["Explain", "how blockchain technology works"], "content": "Explain blockchain technology in simple terms: distributed ledgers, consensus mechanisms, mining, and why it matters beyond cryptocurrency."}, + {"title": ["Help me choose", "a laptop for my needs"], "content": "Help me choose the right laptop. Ask me about my budget, primary use cases, and preferences, then recommend specific models."}, + {"title": ["Create a", "packing list for travel"], "content": "Create a comprehensive packing list for a trip. Ask me about the destination, duration, climate, and activities planned."}, + {"title": ["Explain the difference between", "renting and buying a home"], "content": "Explain the financial pros and cons of renting vs buying a home, including hidden costs, opportunity cost, and when each makes more sense."}, + {"title": ["Write a", "bedtime story for kids"], "content": "Write a fun, age-appropriate bedtime story for a child. Ask me about their favorite animals, themes, or characters to personalize it."}, + {"title": ["Help me prepare for", "a job interview"], "content": "Help me prepare for a job interview. Ask about the role and company, then give me likely questions, strong answers, and tips for making a great impression."}, + {"title": ["Teach me", "how to tie common knots"], "content": "Teach me how to tie the most useful knots: bowline, clove hitch, figure-eight, sheet bend, and trucker's hitch, with when to use each."}, + {"title": ["Explain how", "solar panels work"], "content": "Explain how solar panels work, the economics of home solar installation, and how to evaluate if it makes sense for my situation."}, + {"title": ["Help me write", "a letter of recommendation"], "content": "Help me write a strong letter of recommendation. Ask me about the person, their accomplishments, and what the letter is for."}, + {"title": ["Create a", "30-day learning challenge"], "content": "Create a structured 30-day learning challenge for a topic of my choice, with daily goals, resources, and milestones."}, + {"title": ["Explain", "how to improve my sleep quality"], "content": "Explain evidence-based strategies to improve sleep quality: sleep hygiene, circadian rhythm, temperature, light exposure, and habits to avoid."}, + {"title": ["Help me write", "a product review"], "content": "Help me write a detailed, helpful product review. Ask me about the product and my experience, then structure a balanced review."}, + {"title": ["Teach me", "the basics of home plumbing"], "content": "Teach me basic home plumbing: how to fix a running toilet, unclog a drain, replace a faucet, and when to call a professional."}, + {"title": ["Explain how", "electric vehicles compare to gas cars"], "content": "Compare electric vehicles to gas cars: total cost of ownership, environmental impact, range anxiety, charging infrastructure, and maintenance."}, + {"title": ["Help me create", "a family emergency plan"], "content": "Help me create a comprehensive family emergency preparedness plan covering natural disasters, power outages, evacuation routes, and supply kits."}, + {"title": ["Write a", "haiku collection about nature"], "content": "Write a collection of 10 original haikus about different aspects of nature: seasons, weather, animals, landscapes, and the ocean."}, + {"title": ["Explain", "how to start journaling effectively"], "content": "Explain different journaling methods (gratitude, bullet, reflective, morning pages) and help me pick one that fits my goals."}, + {"title": ["Help me plan", "a birthday party on a budget"], "content": "Help me plan a fun birthday party on a budget. Ask about the age group, number of guests, and preferences, then suggest themes, food, and activities."}, + {"title": ["Teach me", "basic first aid skills"], "content": "Teach me essential first aid skills: CPR basics, treating burns, stopping bleeding, recognizing stroke symptoms, and when to call 911."}, + {"title": ["Explain how", "compound interest works"], "content": "Explain compound interest with clear examples, how it applies to both savings and debt, and why Einstein allegedly called it the eighth wonder of the world."}, + {"title": ["Help me organize", "my digital photos"], "content": "Help me develop a system for organizing thousands of digital photos: folder structure, naming conventions, backup strategy, and tools to use."}, + {"title": ["Write a", "weekly newsletter template"], "content": "Help me create a template for a weekly newsletter. Ask about the topic and audience, then draft a structure with engaging sections."}, + {"title": ["Explain", "how to train a puppy"], "content": "Explain the fundamentals of puppy training: house training, basic commands, socialization, crate training, and positive reinforcement techniques."}, + {"title": ["Help me set up", "a home network"], "content": "Help me set up a reliable home network: router placement, Wi-Fi optimization, mesh vs extenders, guest networks, and basic security settings."}, + {"title": ["Create a", "reading list for self-improvement"], "content": "Create a curated reading list of 15 books across productivity, psychology, finance, health, and communication for personal growth."}, + {"title": ["Explain the basics of", "home insurance"], "content": "Explain home insurance basics: what's covered, what's not, how deductibles work, and tips for getting the best rate without being underinsured."}, + {"title": ["Help me write", "a toast for a special occasion"], "content": "Help me write a toast for a special occasion. Ask me about the event, the person being honored, and our relationship."}, + {"title": ["Teach me", "how to cook 5 basic dishes"], "content": "Teach me how to cook 5 essential dishes every adult should know, with step-by-step instructions and tips for each."}, + {"title": ["Explain how", "therapy and counseling work"], "content": "Explain the different types of therapy (CBT, DBT, psychodynamic, EMDR), how to find a therapist, and what to expect in a first session."}, + {"title": ["Help me create", "a capsule wardrobe"], "content": "Help me create a capsule wardrobe. Ask about my style, climate, and lifestyle, then suggest versatile pieces that mix and match."}, + {"title": ["Write a", "travel packing guide by climate"], "content": "Write a practical packing guide organized by climate type: tropical, cold weather, desert, and rainy season destinations."}, + {"title": ["Explain", "how taxes work for freelancers"], "content": "Explain how taxes work for freelancers and independent contractors: quarterly estimates, deductions, record-keeping, and common mistakes."}, + {"title": ["Help me design", "a productive home office"], "content": "Help me design an ergonomic and productive home office setup: desk, chair, monitor placement, lighting, and cable management."}, + {"title": ["Teach me", "basic sewing repairs"], "content": "Teach me essential sewing repairs: replacing a button, hemming pants, fixing a seam, and patching a hole, with tools I need."}, + {"title": ["Explain how", "meditation benefits the brain"], "content": "Explain the science behind meditation: how it changes the brain, reduces stress, and improves focus, with simple techniques to start."}, + {"title": ["Help me write", "a personal mission statement"], "content": "Guide me through creating a personal mission statement that reflects my values, goals, and how I want to show up in the world."}, + {"title": ["Create a", "home maintenance calendar"], "content": "Create a month-by-month home maintenance calendar covering HVAC, gutters, plumbing, appliances, and seasonal tasks."}, + {"title": ["Explain", "how to read a financial statement"], "content": "Explain how to read the three main financial statements (income statement, balance sheet, cash flow) in plain English with examples."}, + {"title": ["Generate an image of", "a cozy mountain cabin at sunset"], "content": "Generate an image of a cozy log cabin nestled in the mountains during a golden sunset, with warm light glowing from the windows and snow-capped peaks in the background."}, + {"title": ["Generate an image of", "a serene Japanese garden"], "content": "Generate an image of a peaceful Japanese zen garden with a stone path, koi pond, cherry blossom trees, and a traditional wooden bridge."}, + {"title": ["Generate an image of", "a whimsical underwater city"], "content": "Generate an image of a fantastical underwater city with bioluminescent buildings, coral architecture, schools of colorful fish, and shafts of sunlight filtering through the ocean."} + ] + healthcheck: + test: ["CMD-SHELL", "python3 -c \"import urllib.request; urllib.request.urlopen('http://localhost:8080/health')\""] + interval: 30s + timeout: 10s + retries: 5 + start_period: 180s + logging: + driver: json-file + options: + max-size: "100m" + max-file: "5" + # ============================================================================ # Speaches Server - Unified STT (Whisper) + TTS (Piper/Kokoro) # OpenAI-compatible API for both transcription and speech synthesis @@ -152,7 +901,8 @@ services: # LLM settings (LLM_BACKEND=langgraph enables the agentic engine) - LLM_BACKEND=${LLM_BACKEND:-vllm} - LLM_BASE_URL=http://vllm:8000/v1 - - LLM_MODEL=${LLM_MODEL:-Qwen/Qwen3-30B-A3B-Instruct-2507-FP8} + # Must match --served-model-name on the vllm service above (same default). + - LLM_MODEL=${LLM_MODEL:-ling-3.0-flash} - LLM_MAX_TOKENS=${LLM_MAX_TOKENS:-512} - LLM_TEMPERATURE=${LLM_TEMPERATURE:-0.6} - LLM_TOP_P=${LLM_TOP_P:-0.85} @@ -182,6 +932,10 @@ services: # Location for WEATHER / FORECAST / QUAKES-near (empty disables those tools) - WEATHER_LATITUDE=${WEATHER_LATITUDE:-} - WEATHER_LONGITUDE=${WEATHER_LONGITUDE:-} + # Home address for the system prompt + MAP tool routing origin + - AGENT_LOCATION=${AGENT_LOCATION:-} + # MAP tool: driving distance/time via OpenStreetMap (needs coordinates above) + - ENABLE_MAP_TOOL=${ENABLE_MAP_TOOL:-true} # WEB_SEARCH via SearxNG (start it with: docker compose --profile search up -d) - SEARXNG_URL=${SEARXNG_URL:-} - WEB_SEARCH_MAX_RESULTS=${WEB_SEARCH_MAX_RESULTS:-3} @@ -194,6 +948,28 @@ services: - ENABLE_TRANSFER_TOOL=${ENABLE_TRANSFER_TOOL:-true} # DRINK_RECIPE tool (TheCocktailDB lookups) - ENABLE_DRINK_TOOL=${ENABLE_DRINK_TOOL:-true} + # Optional caller identity verification (VERIFY tool, DTMF PIN/OTP entry). + # Off until a global PIN/secret is set or a caller is enrolled via + # /verify/credentials. VERIFY_REQUIRED_TOOLS hard-gates the listed tools. + - ENABLE_VERIFY_TOOL=${ENABLE_VERIFY_TOOL:-true} + - VERIFY_PIN=${VERIFY_PIN:-} + - VERIFY_TOTP_SECRET=${VERIFY_TOTP_SECRET:-} + # TOTP algorithm params (RFC 6238) — must match the caller's authenticator. + - VERIFY_TOTP_DIGITS=${VERIFY_TOTP_DIGITS:-6} + - VERIFY_TOTP_PERIOD=${VERIFY_TOTP_PERIOD:-30} + - VERIFY_TOTP_ALGORITHM=${VERIFY_TOTP_ALGORITHM:-SHA1} + - VERIFY_TOTP_WINDOW=${VERIFY_TOTP_WINDOW:-1} + - VERIFY_REQUIRED_TOOLS=${VERIFY_REQUIRED_TOOLS:-} + - VERIFY_MAX_ATTEMPTS=${VERIFY_MAX_ATTEMPTS:-3} + - VERIFY_DTMF_TIMEOUT_S=${VERIFY_DTMF_TIMEOUT_S:-20.0} + - VERIFY_DTMF_INTERDIGIT_S=${VERIFY_DTMF_INTERDIGIT_S:-3.0} + - VERIFY_ISSUER=${VERIFY_ISSUER:-General Disarray} + # Spoken lines for the outbound "call and verify" flow (POST /verify/call). + - VERIFY_CALL_PROMPT=${VERIFY_CALL_PROMPT:-} + - VERIFY_CALL_RETRY_PHRASE=${VERIFY_CALL_RETRY_PHRASE:-} + - VERIFY_CALL_SUCCESS_PHRASE=${VERIFY_CALL_SUCCESS_PHRASE:-} + - VERIFY_CALL_FAIL_PHRASE=${VERIFY_CALL_FAIL_PHRASE:-} + - VERIFY_CREDENTIALS_FILE=${VERIFY_CREDENTIALS_FILE:-} # Single-line prompt override; multi-line prompts go in data/system_prompt.txt - SYSTEM_PROMPT=${SYSTEM_PROMPT:-} # Per-turn acknowledgment: chime (earcon, default) | phrase | none @@ -286,7 +1062,7 @@ services: - CALL_EVENT_WEBHOOK_URL=${CALL_EVENT_WEBHOOK_URL:-} - CALL_EVENTS=${CALL_EVENTS:-call.started,call.ended} - CALL_EVENT_INCLUDE_TRANSCRIPT=${CALL_EVENT_INCLUDE_TRANSCRIPT:-true} - # Virtual numbers: ephemeral single-use inbound extensions (POST /virtual-numbers) + # Virtual numbers: ephemeral single-use inbound extensions + persistent trigger numbers (POST /virtual-numbers) - VIRTUAL_NUMBERS_ENABLED=${VIRTUAL_NUMBERS_ENABLED:-false} - VIRTUAL_NUMBER_DEFAULT_TTL_S=${VIRTUAL_NUMBER_DEFAULT_TTL_S:-900} - VIRTUAL_NUMBER_MAX_TTL_S=${VIRTUAL_NUMBER_MAX_TTL_S:-86400} @@ -328,13 +1104,16 @@ services: searxng: image: searxng/searxng:latest container_name: searxng - profiles: ["search"] + # profiles: ["search"] removed 2026-08-08 - open-webui depends on this for + # web search, and a profile-gated dependency does not reliably come up with + # `docker compose up`. settings.yml already lists `json` under + # search.formats, which Open WebUI requires. volumes: - ./searxng:/etc/searxng environment: - SEARXNG_BASE_URL=http://searxng:8080/ ports: - - "127.0.0.1:8081:8080" + - "127.0.0.1:8082:8080" restart: unless-stopped n8n: @@ -354,11 +1133,14 @@ services: - N8N_CUSTOM_EXTENSIONS=/custom-nodes volumes: - n8n_data:/home/node/.n8n - # Custom SIP Agent nodes — built by examples/n8n-nodes-general-disarray/build.sh - - ./examples/n8n-nodes-general-disarray/dist:/custom-nodes/n8n-nodes-general-disarray:ro + # Custom SIP Agent nodes — from the examples/n8n-nodes submodule (CHA0S-CORP/n8n-nodes); build with examples/n8n-nodes/packages/n8n-nodes-general-disarray/build.sh + - ./examples/n8n-nodes/packages/n8n-nodes-general-disarray/dist:/custom-nodes/n8n-nodes-general-disarray:ro volumes: n8n_data: + # Cloned from llm-sc-1_open-webui on 2026-08-08 (chats, users, vector_db, + # uploads). Resolves to general-disarray_open-webui. + open-webui: # ============================================================================ diff --git a/docker/ling/chat_template.jinja b/docker/ling/chat_template.jinja new file mode 100644 index 0000000..321051d --- /dev/null +++ b/docker/ling/chat_template.jinja @@ -0,0 +1,130 @@ +{#- Bailing V3 chat template -#} +{#- Supports: thinking option, tool calling -#} + +{#- ==================== thinking option normalization ==================== -#} +{%- if enable_thinking is defined %} + {%- if enable_thinking %} + {%- set thinking_option = 'on' %} + {%- else %} + {%- set thinking_option = 'off' %} + {%- endif %} +{%- elif thinking_option is not defined %} + {%- set thinking_option = 'on' %} +{%- endif %} + +{#- ==================== preserved thinking ==================== -#} +{% set preserved_thinking = true %} + +{#- ==================== system message ==================== -#} +{{- 'SYSTEM' }} +{%- if tools %} + {%- if messages[0].role == 'system' %} + {{- messages[0].content + '\n' }} + {%- endif %} + {{- "# Tools\n\nYou may call one or more functions to assist with the user query.\n\nYou are provided with function signatures within XML tags:\n" }} + {%- for tool in tools %} + {{- "\n" }} + {{- tool | tojson }} + {%- endfor %} + {{- "\n\n\nIf none of the functions can be used, point it out. If the given question lacks the parameters required by the function, also point it out.\nIf you need to use a function, for each function call, output the function name and arguments within the following XML format:\nget_current_time\ntimezone\nAsia/Shanghai\n\n" }} + {%- if messages[0].role == 'system' and messages[0].content is string and ('detailed thinking on' in messages[0].content or 'detailed thinking off' in messages[0].content) %} + {{- '<|role_end|>' }} + {%- else %} + {{- 'detailed thinking ' + thinking_option + '<|role_end|>' }} + {%- endif %} +{%- else %} + {%- if messages[0].role == 'system' %} + {%- if 'detailed thinking on' in messages[0].content or 'detailed thinking off' in messages[0].content %} + {{- messages[0].content + '<|role_end|>' }} + {%- else %} + {{- messages[0].content + '\n' }} + {{- 'detailed thinking ' + thinking_option + '<|role_end|>' }} + {%- endif %} + {% else %} + {{- 'detailed thinking ' + thinking_option + '<|role_end|>' }} + {%- endif %} +{%- endif %} +{%- set ns = namespace(multi_step_tool=true, last_query_index=messages|length - 1) %} +{%- for message in messages[::-1] %} + {%- set index = (messages|length - 1) - loop.index0 %} + {%- if ns.multi_step_tool and message.role == "user" and message.content is string and not(message.content.startswith('') and message.content.endswith('')) %} + {%- set ns.multi_step_tool = false %} + {%- set ns.last_query_index = index %} + {%- endif %} +{%- endfor %} +{%- for message in messages %} + {%- if message.content is string %} + {%- set content = message.content %} + {%- else %} + {%- set content = '' %} + {%- endif %} + {%- if message.role == "user" %} + {{- 'HUMAN' + message.content + '<|role_end|>' }} + {%- elif message.role == "system" and not loop.first %} + {{- 'SYSTEM' + message.content + '<|role_end|>' }} + {%- elif message.role == "assistant" %} + {%- set reasoning_content = '' %} + {%- if message.reasoning_content is string and message.reasoning_content != '' %} + {%- set reasoning_content = message.reasoning_content %} + {%- else %} + {%- if '' in content %} + {%- set reasoning_content = content.split('')[0].rstrip('\n').split('')[-1].lstrip('\n') %} + {%- set content = content.split('')[-1].lstrip('\n') %} + {%- endif %} + {%- endif %} + {%- if preserved_thinking or loop.index0 > ns.last_query_index %} + {%- if reasoning_content != '' %} + {{- 'ASSISTANT' + '\n' + reasoning_content.strip('\n') + '' + content.lstrip('\n') }} + {%- else %} + {{- 'ASSISTANT\n' + content }} + {%- endif %} + {%- else %} + {{- 'ASSISTANT\n' + content }} + {%- endif %} + {%- if message.tool_calls %} + {%- for tool_call in message.tool_calls %} + {%- if (loop.first and content) or (not loop.first) %} + {{- '\n' }} + {%- endif %} + {%- set tc = tool_call %} + {%- if tool_call.function %} + {%- set tc = tool_call.function %} + {%- endif %} + {{- '' + tc.name }} + {% set _args = tc.arguments %} + {%- for k, v in _args.items() %} + {{- '' + k + '' }} + {{- '\n' }} + {%- if v is string %} + {{- v }} + {%- else %} + {{- v | tojson(ensure_ascii=False) }} + {%- endif %} + {{- '' }} + {%- endfor %} + {{- '\n' }} + {%- endfor %} + {%- endif %} + {{- '<|role_end|>' }} + {%- elif message.role == "tool" %} + {%- if loop.first or (messages[loop.index0 - 1].role != "tool") %} + {{- 'OBSERVATION' }} + {%- endif %} + {{- '\n\n' }} + {{- content }} + {{- '\n' }} + {%- if loop.last or (messages[loop.index0 + 1].role != "tool") %} + {{- '<|role_end|>' }} + {%- endif %} + {%- endif %} +{%- endfor %} + +{#- ==================== generation prompt ==================== -#} +{%- if add_generation_prompt %} + {{- 'ASSISTANT' }} + {%- if thinking_option == 'on' %} + {{- '\n' }} + {%- elif thinking_option == 'off' %} + {{- '\n' }} + {%- endif %} +{%- endif %} \ No newline at end of file diff --git a/docker/ling/warmup.py b/docker/ling/warmup.py new file mode 100644 index 0000000..f32d136 --- /dev/null +++ b/docker/ling/warmup.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python3 +"""Fire one representative request at the LLM so the first real phone call +does not pay JIT/autotune cost. + +Measured on GB10 with Ling-3.0-flash-int4, ~3.3K-token prompt + 29 tools: + + cold (first request ever): 35.6 s <-- exceeds sip-agent's + warm (second): 2.1 s LLM_AGENT_TIMEOUT_S=30 + warm (third): 1.0 s + +So without this, the first caller after a restart or reboot hits the agent +timeout and gets nothing. SGLang's own --skip-server-warmup=False warmup uses a +trivial prompt and does not cover the long-prompt-with-tools kernel shapes, and +--warmups only runs named functions built into warmup.py, so neither helps. + +This deliberately mimics the real sip-agent turn shape (long system prompt + +large tool array) rather than sending "hello", because the cost is per kernel +shape. Failures are logged and ignored - a warmup that cannot run must never +block the stack from coming up. +""" + +import json +import os +import sys +import time +import urllib.request + +BASE = os.environ.get("LLM_WARMUP_URL", "http://vllm:8000/v1") +MODEL = os.environ.get("LLM_MODEL", "ling-3.0-flash") +TIMEOUT = float(os.environ.get("LLM_WARMUP_TIMEOUT_S", "180")) + +# Roughly the sip-agent tool registry, in count and schema shape. +NAMES = [ + "alerts", "calc", "callback", "cancel", "coin", "datetime", "dice", + "drink_recipe", "forecast", "forget", "gpu_status", "hangup", "joke", + "knowledge", "memory", "news", "note", "number_fact", "quote", "recall", + "remind", "schedule", "search", "spell", "timer", "transfer", "trivia", + "weather", "wiki", +] +TOOLS = [ + { + "type": "function", + "function": { + "name": n, + "description": ( + f"{n.replace('_', ' ').title()} tool for the voice assistant. " + f"Use when the caller asks about {n.replace('_', ' ')}." + ), + "parameters": { + "type": "object", + "properties": { + "query": {"type": "string", "description": "what the caller asked"}, + "detail": {"type": "string", "enum": ["short", "full"]}, + }, + "required": ["query"], + }, + }, + } + for n in NAMES +] +SYS = ( + "You are a helpful voice assistant answering a phone call. Keep replies short " + "and conversational - one or two sentences, no markdown, no lists, since your " + "text is read aloud. Call a tool for live information rather than guessing. " + "Never invent facts. " +) * 3 + + +def fire(label, user, tools): + body = { + "model": MODEL, + "messages": [{"role": "system", "content": SYS}, {"role": "user", "content": user}], + "max_tokens": 64, + "temperature": 0.6, + "top_p": 0.85, + } + if tools: + body["tools"] = tools + body["tool_choice"] = "auto" + req = urllib.request.Request( + BASE.rstrip("/") + "/chat/completions", + data=json.dumps(body).encode(), + headers={"Content-Type": "application/json"}, + ) + t0 = time.perf_counter() + with urllib.request.urlopen(req, timeout=TIMEOUT) as r: + d = json.loads(r.read()) + el = time.perf_counter() - t0 + ptok = (d.get("usage") or {}).get("prompt_tokens", "?") + print(f"warmup: {label:<12} {el:6.2f}s prompt={ptok} tok", flush=True) + + +def main(): + # Both shapes: a plain reply and a tool-call reply. + for label, user, tools in ( + ("chit-chat", "Hi there, how are you doing today?", TOOLS), + ("tool-call", "What's the weather like in Oslo right now?", TOOLS), + ("no-tools", "Say OK.", None), + ): + try: + fire(label, user, tools) + except Exception as e: # noqa: BLE001 - never block startup + print(f"warmup: {label} FAILED (ignored): {e!r}", file=sys.stderr, flush=True) + print("warmup: done", flush=True) + + +if __name__ == "__main__": + main() diff --git a/examples/n8n-nodes b/examples/n8n-nodes new file mode 160000 index 0000000..7ab1938 --- /dev/null +++ b/examples/n8n-nodes @@ -0,0 +1 @@ +Subproject commit 7ab193873f53bbb77e334688c46fd3fdd681764a diff --git a/examples/n8n-nodes-general-disarray/.gitignore b/examples/n8n-nodes-general-disarray/.gitignore deleted file mode 100644 index 5ff8782..0000000 --- a/examples/n8n-nodes-general-disarray/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -node_modules/ -dist/ -.npm/ diff --git a/examples/n8n-nodes-general-disarray/README.md b/examples/n8n-nodes-general-disarray/README.md deleted file mode 100644 index 231f378..0000000 --- a/examples/n8n-nodes-general-disarray/README.md +++ /dev/null @@ -1,167 +0,0 @@ -# n8n-nodes-general-disarray - -Custom [n8n](https://n8n.io) nodes for the **General Disarray** SIP AI phone assistant. - -The package ships two nodes plus one credential type: - -- **SIP Agent** (action node) — drives the agent's REST API on `:8080`: make outbound calls, check call status, fetch transcripts, speak into the active call, run tools, schedule calls, and probe system health/queue state. -- **SIP Agent Trigger** — a webhook trigger that receives the agent's **call-lifecycle events** (`call.started` / `call.ended`, see below) and **choice-callback** POSTs (the JSON the agent sends to your `callback_url` when a call with a choice prompt completes), with an event filter and optional HMAC-SHA256 signature verification. -- **SIP Agent API** credential — base URL, optional API token, and the webhook signing secret. - -## Build - -No local Node.js toolchain required — the build runs inside a `node:20-alpine` container and works on arm64 (DGX Spark / GB10): - -```bash -cd examples/n8n-nodes-general-disarray -./build.sh -``` - -Requires only Docker. Compiled output lands in `dist/` (owned by your host user), which is what gets mounted into n8n. Re-run `./build.sh` after any source change. - -## Install into this stack - -The compose files are **already wired**: both `docker-compose.yml` and `docker-compose.dgx.yml` set `N8N_CUSTOM_EXTENSIONS=/custom-nodes` on the n8n service and mount `./examples/n8n-nodes-general-disarray/dist` read-only at `/custom-nodes/n8n-nodes-general-disarray`. (Only `dist/` is mounted on purpose — mounting the package root would let n8n's `**/*.node.js` glob sweep `node_modules`.) - -So installation is just: build, then (re)create the n8n container so it picks the nodes up. - -```bash -cd examples/n8n-nodes-general-disarray && ./build.sh && cd ../.. - -# DGX Spark stack: -docker compose -f docker-compose.dgx.yml up -d n8n - -# Base stack — identical wiring, same command shape: -docker compose up -d n8n -``` - -The two nodes then appear in the n8n editor as **SIP Agent** and **SIP Agent Trigger**. - -## Credential setup - -In n8n, create a **SIP Agent API** credential: - -| Field | Value | Notes | -|---|---|---| -| Base URL | `http://sip-agent:8080` | Container-internal hostname; correct for n8n running in the same compose network. | -| API Token | value of `API_AUTH_TOKEN` from `.env` | Leave empty if the agent runs without auth. | -| Auth Header Style | `X-API-Key` (default) or `Authorization: Bearer` | The agent accepts both. | -| Webhook Signing Secret | value of `WEBHOOK_SIGNING_SECRET` from `.env` | Used **only** by the SIP Agent Trigger to verify incoming callback signatures. | - -> **Note:** the credential's built-in test calls `GET /health`, which is unauthenticated. A passing test confirms connectivity and the base URL, **not** that the API token is correct — token errors only surface on the first mutating request (e.g. `POST /call` returning 401). - -## Resources & Operations - -| Resource | Operation | Endpoint | Notes | -|---|---|---|---| -| Call | Make | `POST /call` | Message + extension; optional choice prompt, callback URL, ring timeout, custom call ID. | -| Call | Get Status | `GET /call/{id}` | API always returns 200; `status` may be `"not_found"`. Optional *Error on Not Found* toggle turns that into a node error. | -| Call | Get Transcript | `GET /call/{id}/transcript` | HTTP 404 if the transcript doesn't exist. | -| Speak | Say | `POST /speak` | Speaks into the currently active call (query params: `message`, optional `call_id` guard). | -| Speak | Play Audio | `POST /play` | Plays an audio file from the item's binary data (default field: `data`) into the active call. WAV/FLAC/OGG (MP3 when the agent's libsndfile supports it); the agent resamples to the call rate. Size-capped by `PLAY_AUDIO_MAX_BYTES`. | -| Tool | Get Many | `GET /tools` | One output item per tool. | -| Tool | Get | `GET /tools/{name}` | Tool names are uppercase (e.g. `WEATHER`); the server uppercases anyway. | -| Tool | Execute | `POST /tools/{name}/execute` | JSON params; optional *Speak Result* into the active call. | -| Tool | Execute and Call | `POST /tools/{name}/call` | Runs the tool, then calls out speaking prefix + result + suffix; supports choice prompts. A `"tool_failed"` status is passed through, not thrown. | -| Schedule | Create | `POST /schedule` | Message **or** tool content; delay **or** at-time; optional timezone, recurrence (daily/weekdays/weekends/cron), callback URL. | -| Schedule | Get Many | `GET /schedule` | One output item per scheduled call. | -| Schedule | Get | `GET /schedule/{id}` | 404 if unknown. | -| Schedule | Delete | `DELETE /schedule/{id}` | 404 if unknown. | -| System | Health | `GET /health` | Optional *Deep* toggle adds `?deep=true` (probes vLLM/Speaches/Redis). | -| System | Get Queue | `GET /queue` | Outbound call queue status. | -| Virtual Number | Create | `POST /virtual-numbers` | Ephemeral inbound extension: the agent answers a call dialed to it with the given `purpose` as context (optional custom greeting), webhooks the outcome + transcript to `callback_url`, then clears the number. Single-use; unused numbers expire after the TTL. Requires `VIRTUAL_NUMBERS_ENABLED=true`. | -| Virtual Number | Get Many / Get / Delete | `GET`/`DELETE /virtual-numbers[/{id}]` | Registry inspection and early removal; Get returns 404 once the number is used or expired. | - -## Reformat for Speech - -Call:Make, Tool:Execute and Call, Schedule:Create, and Speak:Say expose a **Reformat for Speech** toggle (`reformat_for_speech` in the API). When on, the agent's own LLM rewrites the message into natural spoken form before it is voiced — `ALERT: svc-api p99=340ms @ 2026-07-08T17:03Z` becomes something like "Alert: the API service's ninety-ninth percentile latency is three hundred forty milliseconds, as of July eighth at five oh three PM" — preserving every fact (numbers, IDs, dates, statuses) rather than stripping them. Adds roughly one to two seconds of latency (one local LLM round-trip) before dialing; for schedules the rewrite happens at call time so tool-generated content is covered. Fail-open: on any LLM failure or timeout the original text is spoken unchanged. - -## Choice prompt + callback walkthrough - -This is the flagship flow: call someone, ask them a question, branch a workflow on their answer. - -> **⚠️ You MUST set `WEBHOOK_ALLOW_PRIVATE=true` in `.env` and restart sip-agent** (`docker compose up -d sip-agent`). The callback URL `http://n8n:5678/...` resolves to a private docker bridge-network address, and the agent's SSRF guard rejects private destinations by default — your `POST /call` will fail with **HTTP 400** ("callback_url resolves to a private address") until you enable it. - -1. **Add a SIP Agent Trigger** node to a workflow and copy its **production** webhook URL. Because n8n runs behind `WEBHOOK_URL=http://n8n:5678/` in this stack, the URL looks like `http://n8n:5678/webhook//webhook`. That hostname is **container-internal**: it is exactly what the sip-agent container needs to deliver the callback across the compose network, but it is **not browsable from your workstation** — don't be surprised when it doesn't open in a browser. (For manual "Listen for test event" runs, the test URL follows the same host; deliveries still work because both containers share the network.) -2. **Activate the workflow** (production webhooks are only registered while the workflow is active). -3. **Make the call** with a SIP Agent node — resource *Call*, operation *Make*: - - **Message**: `Hi, this is the assistant confirming your appointment tomorrow at 3 PM.` - - **Extension**: the number/extension to dial. - - **Callback URL**: the trigger's production URL from step 1. - - **Choice** → prompt: `Do you confirm? Say yes or no, or press 1 for yes, 2 for no.` with options `yes` (synonyms: `yeah, confirm, sure`, DTMF 1) and `no` (synonyms: `nope, cancel`, DTMF 2). -4. **When the call completes**, the agent POSTs JSON to the trigger: - - ```json - { - "call_id": "…", - "status": "completed", - "extension": "1001", - "duration_seconds": 24.7, - "message_played": true, - "choice_response": "yes", - "choice_raw_text": "yeah sure", - "machine_answered": false - } - ``` - - `choice_response` is the matched option value (absent if nothing matched); branch on it with an IF node. - -An importable example workflow (Trigger → IF on `{{$json.choice_response}}` → Confirmed / Declined) is in [`examples/choice-callback-workflow.json`](examples/choice-callback-workflow.json) — in n8n use *Import from File*. - -## Call lifecycle events (trigger on any call) - -The agent can push signed `call.started` / `call.ended` events for **every** call — inbound or outbound, no per-call `callback_url` needed — so a workflow can trigger whenever the assistant is on a call. - -> **⚠️ Same SSRF caveat as above:** the n8n trigger URL is a private address, so `WEBHOOK_ALLOW_PRIVATE=true` must be set in `.env`. - -1. **Add a SIP Agent Trigger** node, pick the events you want under **Events** (*Call Started*, *Call Ended*, and/or *Choice Result / Call Outcome*), activate the workflow, and copy its production URL. -2. In `.env`, set: - - ```bash - CALL_EVENT_WEBHOOK_URL=http://n8n:5678/webhook//webhook - CALL_EVENTS=call.started,call.ended # or a subset - CALL_EVENT_INCLUDE_TRANSCRIPT=true # embed the transcript in call.ended - WEBHOOK_ALLOW_PRIVATE=true - ``` - - then recreate the agent: `docker compose -f docker-compose.dgx.yml up -d sip-agent`. -3. The agent now POSTs to the trigger on every call: - - ```json - { - "event": "call.started", - "call_id": "in-1751970000-3", - "sip_call_id": "4", - "direction": "inbound", - "remote_uri": "sip:1001@pbx", - "started_at": "2026-07-08T17:00:00+00:00", - "timestamp": "2026-07-08T17:00:00+00:00" - } - ``` - - `call.ended` adds `duration_seconds` and (unless `CALL_EVENT_INCLUDE_TRANSCRIPT=false`) the full `transcript` record — `{call_id, direction, remote_uri, started_at, ended_at, turns: [{role, content, ts}]}` — so an "after every call" workflow can summarize, archive, or alert on the conversation without an extra API round-trip. - -Notes: - -- The event feed is signed with `WEBHOOK_SIGNING_SECRET` exactly like the choice callbacks, so HMAC verification (next section) applies unchanged. -- Legacy choice/outcome callbacks carry **no `event` field**; the trigger classifies them as *Choice Result / Call Outcome*. Existing workflows keep working (the Events default selects everything). -- Deliveries for deselected events are acknowledged with 200 (no retries on the agent side) but start no execution. -- One agent URL feeds one trigger; to handle multiple event types differently in a single workflow, select several events and branch on `{{$json.event}}` with a Switch node. - -## Verifying webhook signatures (HMAC) - -To authenticate callbacks end-to-end: - -1. Set `WEBHOOK_SIGNING_SECRET=` in `.env` and restart sip-agent. The agent then signs every outgoing webhook with `X-Timestamp` and `X-Signature: sha256=` headers, where the digest is HMAC-SHA256 over `"." + raw body`. -2. Put the **same secret** in the *Webhook Signing Secret* field of your SIP Agent API credential, and attach that credential to the **SIP Agent Trigger** node. -3. Enable **Require Signature** on the trigger. Unsigned, stale (older than *Tolerance (Seconds)*, default 300), or tampered requests are rejected with 401 before your workflow runs. - -If *Require Signature* is off but a secret is configured and a request carries an `X-Signature` header, the trigger still verifies it opportunistically — bad signatures are rejected either way. - -## Note on node type names (custom dir vs. npm package) - -Nodes loaded from `N8N_CUSTOM_EXTENSIONS` get the **`CUSTOM.`** package prefix, so saved workflows reference them as `CUSTOM.sipAgent` / `CUSTOM.sipAgentTrigger`. If you later publish this package to npm and install it as a community package instead, the type strings change (to `n8n-nodes-general-disarray.sipAgent` etc.) and existing workflows will show the nodes as unrecognized until you re-add them or edit the workflow JSON. Keep that in mind before building a large library of workflows on the custom-directory install. - -## License - -AGPL-3.0, same as the rest of General Disarray. diff --git a/examples/n8n-nodes-general-disarray/build.sh b/examples/n8n-nodes-general-disarray/build.sh deleted file mode 100755 index 4a67db4..0000000 --- a/examples/n8n-nodes-general-disarray/build.sh +++ /dev/null @@ -1,13 +0,0 @@ -#!/usr/bin/env sh -# Dockerized build for n8n-nodes-general-disarray — no host node/npm needed. -# Works on arm64 (DGX Spark / GB10); output lands in ./dist owned by the host user. -set -eu -cd "$(dirname "$0")" -docker run --rm \ - -u "$(id -u):$(id -g)" \ - -e HOME=/tmp -e npm_config_cache=/tmp/.npm \ - -v "$PWD":/app -w /app \ - node:20-alpine \ - sh -c "npm install --no-audit --no-fund --ignore-scripts && npm run build" -echo "Built:" -ls dist/nodes/*/*.node.js dist/credentials/*.credentials.js diff --git a/examples/n8n-nodes-general-disarray/credentials/SipAgentApi.credentials.ts b/examples/n8n-nodes-general-disarray/credentials/SipAgentApi.credentials.ts deleted file mode 100644 index 48b45ea..0000000 --- a/examples/n8n-nodes-general-disarray/credentials/SipAgentApi.credentials.ts +++ /dev/null @@ -1,81 +0,0 @@ -import type { - IAuthenticateGeneric, - ICredentialTestRequest, - ICredentialType, - INodeProperties, -} from 'n8n-workflow'; - -export class SipAgentApi implements ICredentialType { - name = 'sipAgentApi'; - - displayName = 'SIP Agent API'; - - documentationUrl = 'https://github.com/cha0s-corp/general-disarray'; - - properties: INodeProperties[] = [ - { - displayName: 'Base URL', - name: 'baseUrl', - type: 'string', - default: 'http://sip-agent:8080', - description: - 'Base URL of the SIP agent REST API. From inside the docker network use http://sip-agent:8080.', - }, - { - displayName: 'API Token', - name: 'apiToken', - type: 'string', - typeOptions: { password: true }, - default: '', - description: - 'Value of API_AUTH_TOKEN on the agent. Leave empty if the agent runs without authentication.', - }, - { - displayName: 'Auth Header Style', - name: 'headerStyle', - type: 'options', - options: [ - { name: 'X-API-Key', value: 'xApiKey' }, - { name: 'Authorization: Bearer', value: 'bearer' }, - ], - default: 'xApiKey', - description: 'How the API token is sent. The agent accepts both.', - }, - { - displayName: 'Webhook Signing Secret', - name: 'signingSecret', - type: 'string', - typeOptions: { password: true }, - default: '', - description: - 'Value of WEBHOOK_SIGNING_SECRET on the agent. Used only by the SIP Agent Trigger node to verify HMAC signatures on incoming callbacks. Leave empty if signing is disabled.', - }, - ]; - - // Applied to the credential test below. The node's own requests build these - // headers by hand in GenericFunctions.ts (it needs the same logic for the raw - // binary /play upload), so the two must stay in step. An unset token yields - // empty headers, which the agent treats as absent — the supported tokenless mode. - authenticate: IAuthenticateGeneric = { - type: 'generic', - properties: { - headers: { - 'X-API-Key': - '={{ $credentials.apiToken && $credentials.headerStyle !== "bearer" ? $credentials.apiToken : "" }}', - Authorization: - '={{ $credentials.apiToken && $credentials.headerStyle === "bearer" ? "Bearer " + $credentials.apiToken : "" }}', - }, - }, - }; - - // GET /schedule is token-protected but read-only, so it validates the base URL - // *and* the token in one shot: 401 on a missing/wrong token, 200 on a correct - // one — and 200 on a tokenless agent, which is a supported config. (/health is - // unauthenticated, so testing against it goes green even with a blank token.) - test: ICredentialTestRequest = { - request: { - baseURL: '={{$credentials.baseUrl}}', - url: '/schedule', - }, - }; -} diff --git a/examples/n8n-nodes-general-disarray/examples/choice-callback-workflow.json b/examples/n8n-nodes-general-disarray/examples/choice-callback-workflow.json deleted file mode 100644 index 2e42488..0000000 --- a/examples/n8n-nodes-general-disarray/examples/choice-callback-workflow.json +++ /dev/null @@ -1,120 +0,0 @@ -{ - "name": "SIP Agent choice callback", - "nodes": [ - { - "parameters": { - "requireSignature": false, - "tolerance": 300 - }, - "id": "a1f6d0c2-3b7e-4c8a-9f21-5d0e8b4c7a10", - "name": "SIP Agent Trigger", - "type": "CUSTOM.sipAgentTrigger", - "typeVersion": 1, - "position": [ - -200, - 0 - ], - "webhookId": "6f2c9b8e-1d4a-4e5f-8a3b-0c7d2e9f6a41", - "credentials": { - "sipAgentApi": { - "id": "1", - "name": "SIP Agent API" - } - } - }, - { - "parameters": { - "conditions": { - "options": { - "caseSensitive": false, - "leftValue": "", - "typeValidation": "loose" - }, - "conditions": [ - { - "id": "c3e8f1a2-7b6d-4a9c-b5e0-2f4d8c1a9e73", - "leftValue": "={{ $json.choice_response }}", - "rightValue": "yes", - "operator": { - "type": "string", - "operation": "equals" - } - } - ], - "combinator": "and" - }, - "options": {} - }, - "id": "b2d7e9f4-8c1a-4b6d-a3e5-9f0c2d7b8e64", - "name": "Choice Is Yes?", - "type": "n8n-nodes-base.if", - "typeVersion": 2, - "position": [ - 60, - 0 - ] - }, - { - "parameters": {}, - "id": "d4f9a2b6-0e3c-4d7f-b8a1-6c5e9f2d0b37", - "name": "Confirmed", - "type": "n8n-nodes-base.noOp", - "typeVersion": 1, - "position": [ - 320, - -100 - ] - }, - { - "parameters": {}, - "id": "e5a0b3c7-1f4d-4e8a-c9b2-7d6f0a3e1c48", - "name": "Declined", - "type": "n8n-nodes-base.noOp", - "typeVersion": 1, - "position": [ - 320, - 100 - ] - } - ], - "connections": { - "SIP Agent Trigger": { - "main": [ - [ - { - "node": "Choice Is Yes?", - "type": "main", - "index": 0 - } - ] - ] - }, - "Choice Is Yes?": { - "main": [ - [ - { - "node": "Confirmed", - "type": "main", - "index": 0 - } - ], - [ - { - "node": "Declined", - "type": "main", - "index": 0 - } - ] - ] - } - }, - "pinData": {}, - "settings": { - "executionOrder": "v1" - }, - "active": false, - "meta": { - "instanceId": "example" - }, - "tags": [] -} diff --git a/examples/n8n-nodes-general-disarray/nodes/SipAgent/GenericFunctions.ts b/examples/n8n-nodes-general-disarray/nodes/SipAgent/GenericFunctions.ts deleted file mode 100644 index 20b445c..0000000 --- a/examples/n8n-nodes-general-disarray/nodes/SipAgent/GenericFunctions.ts +++ /dev/null @@ -1,111 +0,0 @@ -import type { - IDataObject, - IExecuteFunctions, - IHookFunctions, - IHttpRequestMethods, - ILoadOptionsFunctions, - JsonObject, -} from 'n8n-workflow'; -import { NodeApiError } from 'n8n-workflow'; - -type SipAgentContext = IExecuteFunctions | ILoadOptionsFunctions | IHookFunctions; - -/** Auth headers + base URL from the sipAgentApi credential. */ -async function resolveCredentials( - ctx: SipAgentContext, -): Promise<{ baseUrl: string; headers: IDataObject }> { - const credentials = await ctx.getCredentials('sipAgentApi'); - const headers: IDataObject = {}; - const token = (credentials.apiToken as string) || ''; - if (token) { - if (credentials.headerStyle === 'bearer') { - headers.Authorization = `Bearer ${token}`; - } else { - headers['X-API-Key'] = token; - } - } - return { baseUrl: (credentials.baseUrl as string).replace(/\/+$/, ''), headers }; -} - -/** - * Surface the agent's FastAPI error detail as the headline message instead - * of n8n's generic HTTP-status text (e.g. "No active call to speak to" - * rather than "The resource you are requesting could not be found"). - */ -function toNodeApiError(ctx: SipAgentContext, error: unknown): NodeApiError { - const detail = (error as { response?: { data?: { detail?: unknown } } }).response?.data - ?.detail; - let message: string | undefined; - if (typeof detail === 'string' && detail) { - message = detail; - } else if (Array.isArray(detail)) { - // FastAPI 422 validation errors: [{loc, msg, type}, ...] - message = detail - .map((d) => (d && typeof d === 'object' && 'msg' in d ? String(d.msg) : JSON.stringify(d))) - .join('; '); - } - return new NodeApiError(ctx.getNode(), error as JsonObject, message ? { message } : undefined); -} - -/** - * Make a request to the SIP agent REST API using the sipAgentApi credential. - * Injects the API token as X-API-Key or Authorization: Bearer per the credential's - * headerStyle, only when a token is set (auth is optional on the agent). - */ -export async function sipAgentApiRequest( - this: SipAgentContext, - method: IHttpRequestMethods, - endpoint: string, - body?: IDataObject, - qs?: IDataObject, -): Promise { - const { baseUrl, headers } = await resolveCredentials(this); - try { - return (await this.helpers.httpRequest({ - method, - url: `${baseUrl}${endpoint}`, - headers, - body, - qs, - json: true, - })) as IDataObject; - } catch (error) { - throw toNodeApiError(this, error); - } -} - -/** - * POST a raw binary body (e.g. an audio file for /play) to the SIP agent API. - * Same credential/auth handling as sipAgentApiRequest, but no JSON encoding. - */ -export async function sipAgentApiUpload( - this: IExecuteFunctions, - endpoint: string, - data: Buffer, - contentType: string, - qs?: IDataObject, -): Promise { - const { baseUrl, headers } = await resolveCredentials(this); - try { - // json:false so the Buffer body goes over the wire untouched; the - // agent still answers JSON, so parse the response text ourselves. - const response = await this.helpers.httpRequest({ - method: 'POST', - url: `${baseUrl}${endpoint}`, - headers: { ...headers, 'Content-Type': contentType || 'application/octet-stream' }, - body: data, - qs, - json: false, - }); - if (typeof response === 'string') { - try { - return JSON.parse(response) as IDataObject; - } catch { - return { response }; - } - } - return response as IDataObject; - } catch (error) { - throw toNodeApiError(this, error); - } -} diff --git a/examples/n8n-nodes-general-disarray/nodes/SipAgent/SipAgent.node.ts b/examples/n8n-nodes-general-disarray/nodes/SipAgent/SipAgent.node.ts deleted file mode 100644 index 4117f40..0000000 --- a/examples/n8n-nodes-general-disarray/nodes/SipAgent/SipAgent.node.ts +++ /dev/null @@ -1,119 +0,0 @@ -import type { - IDataObject, - IExecuteFunctions, - INodeExecutionData, - INodeType, - INodeTypeDescription, -} from 'n8n-workflow'; -import { NodeConnectionTypes, NodeOperationError } from 'n8n-workflow'; - -import { callProperties, executeCall } from './resources/call'; -import { scheduleProperties, executeSchedule } from './resources/schedule'; -import { speakProperties, executeSpeak } from './resources/speak'; -import { systemProperties, executeSystem } from './resources/system'; -import { toolProperties, executeTool } from './resources/tool'; -import { virtualNumberProperties, executeVirtualNumber } from './resources/virtualNumber'; - -export class SipAgent implements INodeType { - description: INodeTypeDescription = { - displayName: 'SIP Agent', - name: 'sipAgent', - icon: 'file:sipAgent.svg', - group: ['output'], - version: 1, - subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', - description: 'Interact with the General Disarray SIP AI phone assistant', - defaults: { - name: 'SIP Agent', - }, - usableAsTool: true, - inputs: [NodeConnectionTypes.Main], - outputs: [NodeConnectionTypes.Main], - credentials: [ - { - name: 'sipAgentApi', - required: true, - }, - ], - properties: [ - { - displayName: 'Resource', - name: 'resource', - type: 'options', - noDataExpression: true, - options: [ - { name: 'Call', value: 'call' }, - { name: 'Schedule', value: 'schedule' }, - { name: 'Speak', value: 'speak' }, - { name: 'System', value: 'system' }, - { name: 'Tool', value: 'tool' }, - { name: 'Virtual Number', value: 'virtualNumber' }, - ], - default: 'call', - }, - ...callProperties, - ...scheduleProperties, - ...speakProperties, - ...systemProperties, - ...toolProperties, - ...virtualNumberProperties, - ], - }; - - async execute(this: IExecuteFunctions): Promise { - const items = this.getInputData(); - const returnData: INodeExecutionData[] = []; - const resource = this.getNodeParameter('resource', 0) as string; - const operation = this.getNodeParameter('operation', 0) as string; - - for (let i = 0; i < items.length; i++) { - try { - let result: IDataObject | IDataObject[]; - switch (resource) { - case 'call': - result = await executeCall(this, i, operation); - break; - case 'schedule': - result = await executeSchedule(this, i, operation); - break; - case 'speak': - result = await executeSpeak(this, i, operation); - break; - case 'system': - result = await executeSystem(this, i, operation); - break; - case 'tool': - result = await executeTool(this, i, operation); - break; - case 'virtualNumber': - result = await executeVirtualNumber(this, i, operation); - break; - default: - throw new NodeOperationError( - this.getNode(), - `Unknown resource "${resource}"`, - { itemIndex: i }, - ); - } - if (Array.isArray(result)) { - for (const entry of result) { - returnData.push({ json: entry, pairedItem: { item: i } }); - } - } else { - returnData.push({ json: result, pairedItem: { item: i } }); - } - } catch (error) { - if (this.continueOnFail()) { - returnData.push({ - json: { error: (error as Error).message }, - pairedItem: { item: i }, - }); - continue; - } - throw error; - } - } - - return [returnData]; - } -} diff --git a/examples/n8n-nodes-general-disarray/nodes/SipAgent/resources/call.ts b/examples/n8n-nodes-general-disarray/nodes/SipAgent/resources/call.ts deleted file mode 100644 index 8106a48..0000000 --- a/examples/n8n-nodes-general-disarray/nodes/SipAgent/resources/call.ts +++ /dev/null @@ -1,215 +0,0 @@ -import type { IDataObject, IExecuteFunctions, INodeProperties } from 'n8n-workflow'; -import { NodeOperationError } from 'n8n-workflow'; - -import { sipAgentApiRequest } from '../GenericFunctions'; -import { assertCallbackWithChoice, buildChoice, choiceFixedCollection } from '../shared'; - -export const callProperties: INodeProperties[] = [ - { - displayName: 'Operation', - name: 'operation', - type: 'options', - noDataExpression: true, - displayOptions: { show: { resource: ['call'] } }, - options: [ - { - name: 'Make', - value: 'make', - description: 'Make an outbound call that speaks a message', - action: 'Make a call', - }, - { - name: 'Get Status', - value: 'getStatus', - description: 'Get the status of a call', - action: 'Get call status', - }, - { - name: 'Get Transcript', - value: 'getTranscript', - description: 'Get the conversation transcript of a call', - action: 'Get call transcript', - }, - ], - default: 'make', - }, - - // ---------------------------------- - // call:make - // ---------------------------------- - { - displayName: 'Message', - name: 'message', - type: 'string', - required: true, - default: '', - description: 'The message to speak when the callee answers', - displayOptions: { show: { resource: ['call'], operation: ['make'] } }, - }, - { - displayName: 'Extension', - name: 'extension', - type: 'string', - required: true, - default: '', - description: 'SIP extension or phone number to call', - displayOptions: { show: { resource: ['call'], operation: ['make'] } }, - }, - { - displayName: 'Callback URL', - name: 'callbackUrl', - type: 'string', - default: '', - description: - 'URL the agent POSTs the call result to (required when a Choice Prompt is set; point it at a SIP Agent Trigger node)', - displayOptions: { show: { resource: ['call'], operation: ['make'] } }, - }, - choiceFixedCollection(['call'], ['make']), - { - displayName: 'Additional Fields', - name: 'additionalFields', - type: 'collection', - placeholder: 'Add Field', - default: {}, - displayOptions: { show: { resource: ['call'], operation: ['make'] } }, - options: [ - { - displayName: 'Reformat for Speech', - name: 'reformatForSpeech', - type: 'boolean', - default: false, - description: - 'Whether the agent\'s LLM rewrites the message into natural spoken form (dates, numbers, URLs, IDs said aloud) without dropping any information. Adds a moment of latency; falls back to the original text on failure.', - }, - { - displayName: 'Ring Timeout (Seconds)', - name: 'ringTimeout', - type: 'number', - typeOptions: { minValue: 1, maxValue: 600 }, - default: 30, - description: 'How long to let the phone ring before giving up', - }, - { - displayName: 'Call ID', - name: 'callId', - type: 'string', - default: '', - description: - 'Custom call ID (letters, digits, dots, underscores, dashes; max 64 chars). Auto-generated when empty.', - }, - { - displayName: 'Caller Name', - name: 'callerName', - type: 'string', - default: '', - placeholder: 'Weather Alert', - description: - 'Name shown on the recipient\'s phone instead of the agent\'s extension, e.g. "Weather Alert". Applies to this call only. An internal PBX passes it through to the handset; a PSTN carrier will usually replace it with its own CNAM lookup.', - }, - ], - }, - - // ---------------------------------- - // call:getStatus - // ---------------------------------- - { - displayName: 'Call ID', - name: 'callId', - type: 'string', - required: true, - default: '', - description: 'The ID of the call', - displayOptions: { show: { resource: ['call'], operation: ['getStatus'] } }, - }, - { - displayName: 'Error on Not Found', - name: 'errorOnNotFound', - type: 'boolean', - default: false, - description: - 'Whether to throw an error when the call is unknown. The API returns 200 with status "not_found" instead of an HTTP 404.', - displayOptions: { show: { resource: ['call'], operation: ['getStatus'] } }, - }, - - // ---------------------------------- - // call:getTranscript - // ---------------------------------- - { - displayName: 'Call ID', - name: 'callId', - type: 'string', - required: true, - default: '', - description: 'The ID of the call', - displayOptions: { show: { resource: ['call'], operation: ['getTranscript'] } }, - }, -]; - -export async function executeCall( - ctx: IExecuteFunctions, - i: number, - operation: string, -): Promise { - if (operation === 'make') { - const message = ctx.getNodeParameter('message', i) as string; - const extension = ctx.getNodeParameter('extension', i) as string; - const callbackUrl = ctx.getNodeParameter('callbackUrl', i, '') as string; - const additionalFields = ctx.getNodeParameter('additionalFields', i, {}) as IDataObject; - - const choice = buildChoice(ctx, i); - assertCallbackWithChoice(ctx, i, choice, callbackUrl); - - const body: IDataObject = { - message, - extension, - ring_timeout: (additionalFields.ringTimeout as number) ?? 30, - }; - if (additionalFields.callId) { - body.call_id = additionalFields.callId; - } - if (additionalFields.callerName) { - body.caller_name = additionalFields.callerName; - } - if (additionalFields.reformatForSpeech) { - body.reformat_for_speech = true; - } - if (callbackUrl) { - body.callback_url = callbackUrl; - } - if (choice) { - body.choice = choice; - } - - return sipAgentApiRequest.call(ctx, 'POST', '/call', body); - } - - if (operation === 'getStatus') { - const callId = ctx.getNodeParameter('callId', i) as string; - const errorOnNotFound = ctx.getNodeParameter('errorOnNotFound', i, false) as boolean; - - const response = await sipAgentApiRequest.call( - ctx, - 'GET', - `/call/${encodeURIComponent(callId)}`, - ); - if (errorOnNotFound && response.status === 'not_found') { - throw new NodeOperationError(ctx.getNode(), `Call "${callId}" was not found`, { - itemIndex: i, - }); - } - return response; - } - - if (operation === 'getTranscript') { - const callId = ctx.getNodeParameter('callId', i) as string; - return sipAgentApiRequest.call( - ctx, - 'GET', - `/call/${encodeURIComponent(callId)}/transcript`, - ); - } - - throw new NodeOperationError(ctx.getNode(), `Unknown operation "${operation}"`, { - itemIndex: i, - }); -} diff --git a/examples/n8n-nodes-general-disarray/nodes/SipAgent/resources/schedule.ts b/examples/n8n-nodes-general-disarray/nodes/SipAgent/resources/schedule.ts deleted file mode 100644 index ee342c4..0000000 --- a/examples/n8n-nodes-general-disarray/nodes/SipAgent/resources/schedule.ts +++ /dev/null @@ -1,327 +0,0 @@ -import type { IDataObject, IExecuteFunctions, INodeProperties } from 'n8n-workflow'; -import { NodeOperationError } from 'n8n-workflow'; - -import { sipAgentApiRequest } from '../GenericFunctions'; -import { parseJsonParameter } from '../shared'; - -export const scheduleProperties: INodeProperties[] = [ - { - displayName: 'Operation', - name: 'operation', - type: 'options', - noDataExpression: true, - displayOptions: { show: { resource: ['schedule'] } }, - options: [ - { - name: 'Create', - value: 'create', - description: 'Schedule an outbound call for later', - action: 'Create a scheduled call', - }, - { - name: 'Get Many', - value: 'getMany', - description: 'List all scheduled calls', - action: 'Get many scheduled calls', - }, - { - name: 'Get', - value: 'get', - description: 'Get a scheduled call by ID', - action: 'Get a scheduled call', - }, - { - name: 'Delete', - value: 'delete', - description: 'Cancel a scheduled call', - action: 'Delete a scheduled call', - }, - ], - default: 'create', - }, - - // ---------------------------------- - // schedule:create - // ---------------------------------- - { - displayName: 'Extension', - name: 'extension', - type: 'string', - required: true, - default: '', - description: 'SIP extension or phone number to call', - displayOptions: { show: { resource: ['schedule'], operation: ['create'] } }, - }, - { - displayName: 'Content', - name: 'contentMode', - type: 'options', - noDataExpression: true, - options: [ - { - name: 'Message', - value: 'message', - description: 'Speak a fixed message', - }, - { - name: 'Tool', - value: 'tool', - description: 'Run a tool at call time and speak its result', - }, - ], - default: 'message', - displayOptions: { show: { resource: ['schedule'], operation: ['create'] } }, - }, - { - displayName: 'Message', - name: 'message', - type: 'string', - required: true, - default: '', - description: 'Message to speak when the call is answered', - displayOptions: { - show: { resource: ['schedule'], operation: ['create'], contentMode: ['message'] }, - }, - }, - { - displayName: 'Tool Name', - name: 'toolName', - type: 'string', - required: true, - default: '', - placeholder: 'WEATHER', - description: - 'Tool to execute when the schedule fires; its result is spoken on the call. Tool names are uppercase, e.g. WEATHER, TIMER (the server uppercases anyway).', - displayOptions: { - show: { resource: ['schedule'], operation: ['create'], contentMode: ['tool'] }, - }, - }, - { - displayName: 'Tool Parameters', - name: 'toolParams', - type: 'json', - default: '{}', - description: 'Parameters passed to the tool, as a JSON object', - displayOptions: { - show: { resource: ['schedule'], operation: ['create'], contentMode: ['tool'] }, - }, - }, - { - displayName: 'Schedule Type', - name: 'scheduleType', - type: 'options', - noDataExpression: true, - options: [ - { - name: 'Delay', - value: 'delay', - description: 'Call after a number of seconds', - }, - { - name: 'At Time', - value: 'atTime', - description: 'Call at a specific time', - }, - ], - default: 'delay', - displayOptions: { show: { resource: ['schedule'], operation: ['create'] } }, - }, - { - displayName: 'Delay (Seconds)', - name: 'delaySeconds', - type: 'number', - typeOptions: { minValue: 1 }, - default: 60, - description: 'Seconds from now until the call is made', - displayOptions: { - show: { resource: ['schedule'], operation: ['create'], scheduleType: ['delay'] }, - }, - }, - { - displayName: 'At Time', - name: 'atTime', - type: 'string', - required: true, - default: '', - placeholder: '15:30', - description: 'Time of day (e.g. "15:30") or an ISO timestamp', - displayOptions: { - show: { resource: ['schedule'], operation: ['create'], scheduleType: ['atTime'] }, - }, - }, - { - displayName: 'Additional Fields', - name: 'additionalFields', - type: 'collection', - placeholder: 'Add Field', - default: {}, - displayOptions: { show: { resource: ['schedule'], operation: ['create'] } }, - options: [ - { - displayName: 'Timezone', - name: 'timezone', - type: 'string', - default: 'America/Los_Angeles', - description: 'IANA timezone used to interpret At Time', - }, - { - displayName: 'Prefix', - name: 'prefix', - type: 'string', - default: '', - description: 'Text spoken before the message or tool result', - }, - { - displayName: 'Suffix', - name: 'suffix', - type: 'string', - default: '', - description: 'Text spoken after the message or tool result', - }, - { - displayName: 'Callback URL', - name: 'callbackUrl', - type: 'string', - default: '', - description: 'URL the agent POSTs the call result to', - }, - { - displayName: 'Reformat for Speech', - name: 'reformatForSpeech', - type: 'boolean', - default: false, - description: - 'Whether the agent\'s LLM rewrites the composed message into natural spoken form at call time without dropping any information', - }, - { - displayName: 'Recurring', - name: 'recurring', - type: 'options', - options: [ - { name: 'None', value: '' }, - { name: 'Daily', value: 'daily' }, - { name: 'Weekdays', value: 'weekdays' }, - { name: 'Weekends', value: 'weekends' }, - { name: 'Cron Expression', value: 'cron' }, - ], - default: '', - description: 'Repeat the call on a schedule instead of firing once', - }, - { - displayName: 'Cron Expression', - name: 'cronExpression', - type: 'string', - default: '', - placeholder: '30 8 * * 1-5', - description: 'Standard 5-field cron expression (minute hour day month weekday)', - displayOptions: { show: { recurring: ['cron'] } }, - }, - ], - }, - - // ---------------------------------- - // schedule:get / delete - // ---------------------------------- - { - displayName: 'Schedule ID', - name: 'scheduleId', - type: 'string', - required: true, - default: '', - description: 'ID returned when the schedule was created', - displayOptions: { show: { resource: ['schedule'], operation: ['get', 'delete'] } }, - }, -]; - -export async function executeSchedule( - ctx: IExecuteFunctions, - i: number, - operation: string, -): Promise { - if (operation === 'create') { - const extension = ctx.getNodeParameter('extension', i) as string; - const body: IDataObject = { extension }; - - const contentMode = ctx.getNodeParameter('contentMode', i) as string; - if (contentMode === 'tool') { - const toolName = ctx.getNodeParameter('toolName', i) as string; - if (!toolName) { - throw new NodeOperationError(ctx.getNode(), 'Tool Name is required', { itemIndex: i }); - } - body.tool = toolName; - const toolParams = parseJsonParameter(ctx, i, 'toolParams', 'Tool Parameters'); - if (Object.keys(toolParams).length > 0) { - body.tool_params = toolParams; - } - } else { - const message = ctx.getNodeParameter('message', i) as string; - if (!message) { - throw new NodeOperationError(ctx.getNode(), 'Message is required', { itemIndex: i }); - } - body.message = message; - } - - const scheduleType = ctx.getNodeParameter('scheduleType', i) as string; - if (scheduleType === 'atTime') { - const atTime = ctx.getNodeParameter('atTime', i) as string; - if (!atTime) { - throw new NodeOperationError(ctx.getNode(), 'At Time is required', { itemIndex: i }); - } - body.at_time = atTime; - } else { - body.delay_seconds = ctx.getNodeParameter('delaySeconds', i) as number; - } - - const additionalFields = ctx.getNodeParameter('additionalFields', i, {}) as IDataObject; - if (additionalFields.timezone) { - body.timezone = additionalFields.timezone; - } - if (additionalFields.prefix) { - body.prefix = additionalFields.prefix; - } - if (additionalFields.suffix) { - body.suffix = additionalFields.suffix; - } - if (additionalFields.callbackUrl) { - body.callback_url = additionalFields.callbackUrl; - } - if (additionalFields.reformatForSpeech) { - body.reformat_for_speech = true; - } - const recurring = (additionalFields.recurring as string) || ''; - if (recurring === 'cron') { - const cronExpression = ((additionalFields.cronExpression as string) || '').trim(); - if (!cronExpression) { - throw new NodeOperationError( - ctx.getNode(), - 'Cron Expression is required when Recurring is set to Cron Expression', - { itemIndex: i }, - ); - } - body.recurring = cronExpression; - } else if (recurring) { - body.recurring = recurring; - } - - return sipAgentApiRequest.call(ctx, 'POST', '/schedule', body); - } - - if (operation === 'getMany') { - const response = await sipAgentApiRequest.call(ctx, 'GET', '/schedule'); - return response as unknown as IDataObject[]; - } - - if (operation === 'get') { - const scheduleId = ctx.getNodeParameter('scheduleId', i) as string; - return sipAgentApiRequest.call(ctx, 'GET', `/schedule/${encodeURIComponent(scheduleId)}`); - } - - if (operation === 'delete') { - const scheduleId = ctx.getNodeParameter('scheduleId', i) as string; - return sipAgentApiRequest.call(ctx, 'DELETE', `/schedule/${encodeURIComponent(scheduleId)}`); - } - - throw new NodeOperationError(ctx.getNode(), `Unknown operation "${operation}"`, { - itemIndex: i, - }); -} diff --git a/examples/n8n-nodes-general-disarray/nodes/SipAgent/resources/speak.ts b/examples/n8n-nodes-general-disarray/nodes/SipAgent/resources/speak.ts deleted file mode 100644 index c8cd31c..0000000 --- a/examples/n8n-nodes-general-disarray/nodes/SipAgent/resources/speak.ts +++ /dev/null @@ -1,106 +0,0 @@ -import type { IDataObject, IExecuteFunctions, INodeProperties } from 'n8n-workflow'; -import { NodeOperationError } from 'n8n-workflow'; - -import { sipAgentApiRequest, sipAgentApiUpload } from '../GenericFunctions'; - -export const speakProperties: INodeProperties[] = [ - { - displayName: 'Operation', - name: 'operation', - type: 'options', - noDataExpression: true, - displayOptions: { show: { resource: ['speak'] } }, - options: [ - { - name: 'Say', - value: 'say', - description: 'Speak a message into the active call', - action: 'Say a message', - }, - { - name: 'Play Audio', - value: 'play', - description: 'Play an audio file from the item\'s binary data into the active call', - action: 'Play an audio file', - }, - ], - default: 'say', - }, - { - displayName: 'Message', - name: 'message', - type: 'string', - required: true, - default: '', - description: 'The message to speak into the active call', - displayOptions: { show: { resource: ['speak'], operation: ['say'] } }, - }, - { - displayName: 'Input Binary Field', - name: 'binaryPropertyName', - type: 'string', - required: true, - default: 'data', - description: - 'Name of the input item\'s binary field holding the audio file (WAV, FLAC, or OGG; MP3 when the agent\'s libsndfile supports it)', - displayOptions: { show: { resource: ['speak'], operation: ['play'] } }, - }, - { - displayName: 'Call ID', - name: 'callId', - type: 'string', - default: '', - description: - 'Optional call ID; when set it must match the currently active call, otherwise the request is rejected', - displayOptions: { show: { resource: ['speak'], operation: ['say', 'play'] } }, - }, - { - displayName: 'Reformat for Speech', - name: 'reformatForSpeech', - type: 'boolean', - default: false, - description: - 'Whether the agent\'s LLM rewrites the message into natural spoken form without dropping any information', - displayOptions: { show: { resource: ['speak'], operation: ['say'] } }, - }, -]; - -export async function executeSpeak( - ctx: IExecuteFunctions, - i: number, - operation: string, -): Promise { - if (operation === 'say') { - const message = ctx.getNodeParameter('message', i) as string; - const callId = ctx.getNodeParameter('callId', i, '') as string; - const reformat = ctx.getNodeParameter('reformatForSpeech', i, false) as boolean; - - // POST /speak takes query parameters only — no JSON body. - return sipAgentApiRequest.call(ctx, 'POST', '/speak', undefined, { - message, - ...(callId && { call_id: callId }), - ...(reformat && { reformat_for_speech: 'true' }), - }); - } - - if (operation === 'play') { - const binaryPropertyName = ctx.getNodeParameter('binaryPropertyName', i) as string; - const callId = ctx.getNodeParameter('callId', i, '') as string; - - const binary = ctx.helpers.assertBinaryData(i, binaryPropertyName); - const buffer = await ctx.helpers.getBinaryDataBuffer(i, binaryPropertyName); - - // POST /play takes the audio file bytes as the raw request body. - return sipAgentApiUpload.call( - ctx, - '/play', - buffer, - binary.mimeType || 'application/octet-stream', - callId ? { call_id: callId } : undefined, - ); - } - - throw new NodeOperationError(ctx.getNode(), `Unknown operation "${operation}"`, { - itemIndex: i, - }); -} diff --git a/examples/n8n-nodes-general-disarray/nodes/SipAgent/resources/system.ts b/examples/n8n-nodes-general-disarray/nodes/SipAgent/resources/system.ts deleted file mode 100644 index 9e6a3d0..0000000 --- a/examples/n8n-nodes-general-disarray/nodes/SipAgent/resources/system.ts +++ /dev/null @@ -1,62 +0,0 @@ -import type { IDataObject, IExecuteFunctions, INodeProperties } from 'n8n-workflow'; -import { NodeOperationError } from 'n8n-workflow'; - -import { sipAgentApiRequest } from '../GenericFunctions'; - -export const systemProperties: INodeProperties[] = [ - { - displayName: 'Operation', - name: 'operation', - type: 'options', - noDataExpression: true, - displayOptions: { show: { resource: ['system'] } }, - options: [ - { - name: 'Health', - value: 'health', - description: 'Get the agent health status', - action: 'Get health', - }, - { - name: 'Get Queue', - value: 'getQueue', - description: 'Get the outbound call queue status', - action: 'Get queue', - }, - ], - default: 'health', - }, - { - displayName: 'Deep', - name: 'deep', - type: 'boolean', - default: false, - description: 'Whether to also probe the vLLM, Speaches and Redis dependencies', - displayOptions: { show: { resource: ['system'], operation: ['health'] } }, - }, -]; - -export async function executeSystem( - ctx: IExecuteFunctions, - i: number, - operation: string, -): Promise { - if (operation === 'health') { - const deep = ctx.getNodeParameter('deep', i, false) as boolean; - return await sipAgentApiRequest.call( - ctx, - 'GET', - '/health', - undefined, - deep ? { deep: true } : undefined, - ); - } - - if (operation === 'getQueue') { - return await sipAgentApiRequest.call(ctx, 'GET', '/queue'); - } - - throw new NodeOperationError(ctx.getNode(), `Unknown operation "${operation}"`, { - itemIndex: i, - }); -} diff --git a/examples/n8n-nodes-general-disarray/nodes/SipAgent/resources/tool.ts b/examples/n8n-nodes-general-disarray/nodes/SipAgent/resources/tool.ts deleted file mode 100644 index 077e322..0000000 --- a/examples/n8n-nodes-general-disarray/nodes/SipAgent/resources/tool.ts +++ /dev/null @@ -1,224 +0,0 @@ -import type { IDataObject, IExecuteFunctions, INodeProperties } from 'n8n-workflow'; -import { NodeOperationError } from 'n8n-workflow'; - -import { sipAgentApiRequest } from '../GenericFunctions'; -import { - assertCallbackWithChoice, - buildChoice, - choiceFixedCollection, - parseJsonParameter, -} from '../shared'; - -export const toolProperties: INodeProperties[] = [ - { - displayName: 'Operation', - name: 'operation', - type: 'options', - noDataExpression: true, - displayOptions: { show: { resource: ['tool'] } }, - options: [ - { - name: 'Get Many', - value: 'getMany', - description: 'List all registered tools', - action: 'Get many tools', - }, - { - name: 'Get', - value: 'get', - description: 'Get info about a single tool', - action: 'Get a tool', - }, - { - name: 'Execute', - value: 'execute', - description: 'Execute a tool directly', - action: 'Execute a tool', - }, - { - name: 'Execute and Call', - value: 'executeAndCall', - description: 'Execute a tool, then place an outbound call speaking the result', - action: 'Execute a tool and call', - }, - ], - default: 'getMany', - }, - { - displayName: 'Tool Name', - name: 'toolName', - type: 'string', - required: true, - default: '', - placeholder: 'WEATHER', - description: - 'Name of the tool. Tool names are uppercase, e.g. WEATHER, TIMER (the server uppercases anyway).', - displayOptions: { - show: { resource: ['tool'], operation: ['get', 'execute', 'executeAndCall'] }, - }, - }, - { - displayName: 'Tool Parameters', - name: 'toolParams', - type: 'json', - default: '{}', - description: 'Parameters to pass to the tool, as a JSON object', - displayOptions: { - show: { resource: ['tool'], operation: ['execute', 'executeAndCall'] }, - }, - }, - { - displayName: 'Speak Result', - name: 'speakResult', - type: 'boolean', - default: false, - description: 'Whether to speak the tool result into the active call', - displayOptions: { show: { resource: ['tool'], operation: ['execute'] } }, - }, - { - displayName: 'Call ID', - name: 'callId', - type: 'string', - default: '', - description: 'Optional call ID to associate the execution with', - displayOptions: { show: { resource: ['tool'], operation: ['execute'] } }, - }, - { - displayName: 'Extension', - name: 'extension', - type: 'string', - required: true, - default: '', - placeholder: '1001', - description: 'SIP extension or number to call with the tool result', - displayOptions: { show: { resource: ['tool'], operation: ['executeAndCall'] } }, - }, - { - displayName: 'Callback URL', - name: 'callbackUrl', - type: 'string', - default: '', - description: - 'URL the agent POSTs the call result to. Required when a Choice Prompt is set.', - displayOptions: { show: { resource: ['tool'], operation: ['executeAndCall'] } }, - }, - choiceFixedCollection(['tool'], ['executeAndCall']), - { - displayName: 'Additional Fields', - name: 'additionalFields', - type: 'collection', - placeholder: 'Add Field', - default: {}, - displayOptions: { show: { resource: ['tool'], operation: ['executeAndCall'] } }, - options: [ - { - displayName: 'Prefix', - name: 'prefix', - type: 'string', - default: '', - description: 'Text spoken before the tool result', - }, - { - displayName: 'Suffix', - name: 'suffix', - type: 'string', - default: '', - description: 'Text spoken after the tool result', - }, - { - displayName: 'Ring Timeout', - name: 'ringTimeout', - type: 'number', - typeOptions: { minValue: 1, maxValue: 600 }, - default: 30, - description: 'Seconds to let the phone ring before giving up', - }, - { - displayName: 'Call ID', - name: 'callId', - type: 'string', - default: '', - description: 'Custom call ID (letters, digits, ".", "_", "-"; max 64 chars)', - }, - { - displayName: 'Reformat for Speech', - name: 'reformatForSpeech', - type: 'boolean', - default: false, - description: - 'Whether the agent\'s LLM rewrites the composed message into natural spoken form without dropping any information', - }, - ], - }, -]; - -export async function executeTool( - ctx: IExecuteFunctions, - i: number, - operation: string, -): Promise { - if (operation === 'getMany') { - const response = await sipAgentApiRequest.call(ctx, 'GET', '/tools'); - return response as unknown as IDataObject[]; - } - - if (operation === 'get') { - const toolName = ctx.getNodeParameter('toolName', i) as string; - return await sipAgentApiRequest.call( - ctx, - 'GET', - `/tools/${encodeURIComponent(toolName)}`, - ); - } - - if (operation === 'execute') { - const toolName = ctx.getNodeParameter('toolName', i) as string; - const params = parseJsonParameter(ctx, i, 'toolParams', 'Tool Parameters'); - const speakResult = ctx.getNodeParameter('speakResult', i, false) as boolean; - const callId = ctx.getNodeParameter('callId', i, '') as string; - const body: IDataObject = { - params, - speak_result: speakResult, - ...(callId ? { call_id: callId } : {}), - }; - return await sipAgentApiRequest.call( - ctx, - 'POST', - `/tools/${encodeURIComponent(toolName)}/execute`, - body, - ); - } - - if (operation === 'executeAndCall') { - const toolName = ctx.getNodeParameter('toolName', i) as string; - const params = parseJsonParameter(ctx, i, 'toolParams', 'Tool Parameters'); - const extension = ctx.getNodeParameter('extension', i) as string; - const callbackUrl = ctx.getNodeParameter('callbackUrl', i, '') as string; - const choice = buildChoice(ctx, i); - assertCallbackWithChoice(ctx, i, choice, callbackUrl); - const additionalFields = ctx.getNodeParameter('additionalFields', i, {}) as IDataObject; - const body: IDataObject = { - params, - extension, - ring_timeout: (additionalFields.ringTimeout as number) ?? 30, - ...(additionalFields.prefix ? { prefix: additionalFields.prefix } : {}), - ...(additionalFields.suffix ? { suffix: additionalFields.suffix } : {}), - ...(additionalFields.callId ? { call_id: additionalFields.callId } : {}), - ...(additionalFields.reformatForSpeech ? { reformat_for_speech: true } : {}), - ...(callbackUrl ? { callback_url: callbackUrl } : {}), - ...(choice ? { choice } : {}), - }; - // The API responds 200 with status "tool_failed" when the tool errors; - // pass that through as-is so workflows can branch on it. - return await sipAgentApiRequest.call( - ctx, - 'POST', - `/tools/${encodeURIComponent(toolName)}/call`, - body, - ); - } - - throw new NodeOperationError(ctx.getNode(), `Unknown operation "${operation}"`, { - itemIndex: i, - }); -} diff --git a/examples/n8n-nodes-general-disarray/nodes/SipAgent/resources/virtualNumber.ts b/examples/n8n-nodes-general-disarray/nodes/SipAgent/resources/virtualNumber.ts deleted file mode 100644 index 355d419..0000000 --- a/examples/n8n-nodes-general-disarray/nodes/SipAgent/resources/virtualNumber.ts +++ /dev/null @@ -1,177 +0,0 @@ -import type { IDataObject, IExecuteFunctions, INodeProperties } from 'n8n-workflow'; -import { NodeOperationError } from 'n8n-workflow'; - -import { sipAgentApiRequest } from '../GenericFunctions'; - -export const virtualNumberProperties: INodeProperties[] = [ - { - displayName: 'Operation', - name: 'operation', - type: 'options', - noDataExpression: true, - displayOptions: { show: { resource: ['virtualNumber'] } }, - options: [ - { - name: 'Create', - value: 'create', - description: - 'Create an ephemeral inbound extension the agent listens for; single-use, expires on TTL', - action: 'Create a virtual number', - }, - { - name: 'Get Many', - value: 'getMany', - description: 'List active virtual numbers', - action: 'Get many virtual numbers', - }, - { - name: 'Get', - value: 'get', - description: 'Get a virtual number by ID (404 once used or expired)', - action: 'Get a virtual number', - }, - { - name: 'Delete', - value: 'delete', - description: 'Remove a virtual number before it is used', - action: 'Delete a virtual number', - }, - ], - default: 'create', - }, - - // ---------------------------------- - // virtualNumber:create - // ---------------------------------- - { - displayName: 'Purpose', - name: 'purpose', - type: 'string', - required: true, - default: '', - description: - 'What this number is for — injected into the agent\'s system prompt for the call that arrives on it', - displayOptions: { show: { resource: ['virtualNumber'], operation: ['create'] } }, - }, - { - displayName: 'Additional Fields', - name: 'additionalFields', - type: 'collection', - placeholder: 'Add Field', - default: {}, - displayOptions: { show: { resource: ['virtualNumber'], operation: ['create'] } }, - options: [ - { - displayName: 'Number', - name: 'number', - type: 'string', - default: '', - description: - 'Explicit extension (digits/*/#); leave empty to auto-allocate from the agent\'s configured range', - }, - { - displayName: 'TTL (Seconds)', - name: 'ttlS', - type: 'number', - typeOptions: { minValue: 1 }, - default: 900, - description: 'Seconds until the unused number expires', - }, - { - displayName: 'Greeting', - name: 'greeting', - type: 'string', - default: '', - description: 'Custom greeting spoken instead of the default one', - }, - { - displayName: 'Callback URL', - name: 'callbackUrl', - type: 'string', - default: '', - description: - 'URL the agent POSTs the call outcome (status completed/expired, transcript) to', - }, - { - displayName: 'Include Transcript', - name: 'includeTranscript', - type: 'boolean', - default: true, - description: 'Whether the completion webhook includes the call transcript', - }, - ], - }, - - // ---------------------------------- - // virtualNumber:get / delete - // ---------------------------------- - { - displayName: 'Virtual Number ID', - name: 'virtualNumberId', - type: 'string', - required: true, - default: '', - description: 'ID returned when the virtual number was created', - displayOptions: { show: { resource: ['virtualNumber'], operation: ['get', 'delete'] } }, - }, -]; - -export async function executeVirtualNumber( - ctx: IExecuteFunctions, - i: number, - operation: string, -): Promise { - if (operation === 'create') { - const purpose = ctx.getNodeParameter('purpose', i) as string; - if (!purpose) { - throw new NodeOperationError(ctx.getNode(), 'Purpose is required', { itemIndex: i }); - } - const body: IDataObject = { purpose }; - - const additionalFields = ctx.getNodeParameter('additionalFields', i, {}) as IDataObject; - if (additionalFields.number) { - body.number = additionalFields.number; - } - if (additionalFields.ttlS) { - body.ttl_s = additionalFields.ttlS; - } - if (additionalFields.greeting) { - body.greeting = additionalFields.greeting; - } - if (additionalFields.callbackUrl) { - body.callback_url = additionalFields.callbackUrl; - } - if (additionalFields.includeTranscript === false) { - body.include_transcript = false; - } - - return sipAgentApiRequest.call(ctx, 'POST', '/virtual-numbers', body); - } - - if (operation === 'getMany') { - const response = await sipAgentApiRequest.call(ctx, 'GET', '/virtual-numbers'); - return response as unknown as IDataObject[]; - } - - if (operation === 'get') { - const virtualNumberId = ctx.getNodeParameter('virtualNumberId', i) as string; - return sipAgentApiRequest.call( - ctx, - 'GET', - `/virtual-numbers/${encodeURIComponent(virtualNumberId)}`, - ); - } - - if (operation === 'delete') { - const virtualNumberId = ctx.getNodeParameter('virtualNumberId', i) as string; - return sipAgentApiRequest.call( - ctx, - 'DELETE', - `/virtual-numbers/${encodeURIComponent(virtualNumberId)}`, - ); - } - - throw new NodeOperationError(ctx.getNode(), `Unknown operation "${operation}"`, { - itemIndex: i, - }); -} diff --git a/examples/n8n-nodes-general-disarray/nodes/SipAgent/shared.ts b/examples/n8n-nodes-general-disarray/nodes/SipAgent/shared.ts deleted file mode 100644 index 4712a02..0000000 --- a/examples/n8n-nodes-general-disarray/nodes/SipAgent/shared.ts +++ /dev/null @@ -1,166 +0,0 @@ -import type { IDataObject, IExecuteFunctions, INodeProperties } from 'n8n-workflow'; -import { NodeOperationError } from 'n8n-workflow'; - -/** - * The "Choice Prompt" UI, reused by Call:Make and Tool:Execute and Call. - * Maps to the agent's ChoicePrompt schema (POST /call, POST /tools/{name}/call). - */ -export function choiceFixedCollection(resources: string[], operations: string[]): INodeProperties { - return { - displayName: 'Choice Prompt', - name: 'choiceUi', - type: 'fixedCollection', - default: {}, - placeholder: 'Add Choice Prompt', - description: - 'Ask the callee a question and collect a spoken answer or DTMF keypress. Requires a Callback URL — the agent POSTs the result there (use a SIP Agent Trigger node).', - displayOptions: { show: { resource: resources, operation: operations } }, - options: [ - { - displayName: 'Choice', - name: 'choiceValues', - values: [ - { - displayName: 'Prompt', - name: 'prompt', - type: 'string', - default: '', - required: true, - description: 'Question spoken to the callee, e.g. "Should I confirm the appointment?"', - }, - { - displayName: 'Options', - name: 'options', - type: 'fixedCollection', - typeOptions: { multipleValues: true }, - default: {}, - placeholder: 'Add Option', - options: [ - { - displayName: 'Option', - name: 'optionValues', - values: [ - { - displayName: 'Value', - name: 'value', - type: 'string', - default: '', - required: true, - description: 'Canonical answer value, e.g. "yes"', - }, - { - displayName: 'Synonyms', - name: 'synonyms', - type: 'string', - default: '', - description: 'Comma-separated alternatives, e.g. "yeah, sure, ok"', - }, - { - displayName: 'DTMF Key', - name: 'dtmf', - type: 'string', - default: '', - description: - 'Single phone key 0-9, * or # that selects this option (defaults to the option\'s 1-based position)', - }, - ], - }, - ], - }, - { - displayName: 'Timeout (Seconds)', - name: 'timeoutSeconds', - type: 'number', - typeOptions: { minValue: 1, maxValue: 300 }, - default: 30, - }, - { - displayName: 'Repeat Count', - name: 'repeatCount', - type: 'number', - typeOptions: { minValue: 1, maxValue: 10 }, - default: 2, - }, - ], - }, - ], - }; -} - -/** - * Build the ChoicePrompt request body from the choiceUi fixedCollection. - * Returns undefined when no choice prompt was configured. - */ -export function buildChoice(ctx: IExecuteFunctions, i: number): IDataObject | undefined { - const ui = ctx.getNodeParameter('choiceUi', i, {}) as IDataObject; - const cv = ui.choiceValues as IDataObject | undefined; - if (!cv || !cv.prompt) return undefined; - const optionValues = ((cv.options as IDataObject | undefined)?.optionValues ?? - []) as IDataObject[]; - if (optionValues.length === 0) { - throw new NodeOperationError( - ctx.getNode(), - 'Choice Prompt needs at least one option', - { itemIndex: i }, - ); - } - return { - prompt: cv.prompt, - options: optionValues.map((o) => ({ - value: o.value, - synonyms: ((o.synonyms as string) || '') - .split(',') - .map((s) => s.trim()) - .filter(Boolean), - ...(o.dtmf ? { dtmf: o.dtmf } : {}), - })), - timeout_seconds: (cv.timeoutSeconds as number) ?? 30, - repeat_count: (cv.repeatCount as number) ?? 2, - }; -} - -/** - * The agent rejects choice without callback_url with a 422; fail fast with a - * clearer message instead. - */ -export function assertCallbackWithChoice( - ctx: IExecuteFunctions, - i: number, - choice: IDataObject | undefined, - callbackUrl: string, -): void { - if (choice && !callbackUrl) { - throw new NodeOperationError( - ctx.getNode(), - 'Callback URL is required when a Choice Prompt is set', - { itemIndex: i }, - ); - } -} - -/** - * Parse a `json`-type node parameter that may arrive as a string or an object. - */ -export function parseJsonParameter( - ctx: IExecuteFunctions, - i: number, - name: string, - displayName: string, -): IDataObject { - const raw = ctx.getNodeParameter(name, i, {}) as unknown; - if (raw === null || raw === undefined || raw === '') return {}; - if (typeof raw === 'object') return raw as IDataObject; - try { - const parsed = JSON.parse(raw as string); - if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { - throw new Error('not a JSON object'); - } - return parsed as IDataObject; - } catch { - throw new NodeOperationError( - ctx.getNode(), - `${displayName} must be a valid JSON object`, - { itemIndex: i }, - ); - } -} diff --git a/examples/n8n-nodes-general-disarray/nodes/SipAgent/sipAgent.svg b/examples/n8n-nodes-general-disarray/nodes/SipAgent/sipAgent.svg deleted file mode 100644 index ec6e289..0000000 --- a/examples/n8n-nodes-general-disarray/nodes/SipAgent/sipAgent.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/examples/n8n-nodes-general-disarray/nodes/SipAgentTrigger/SipAgentTrigger.node.ts b/examples/n8n-nodes-general-disarray/nodes/SipAgentTrigger/SipAgentTrigger.node.ts deleted file mode 100644 index 40e41a8..0000000 --- a/examples/n8n-nodes-general-disarray/nodes/SipAgentTrigger/SipAgentTrigger.node.ts +++ /dev/null @@ -1,158 +0,0 @@ -import { createHmac, timingSafeEqual } from 'crypto'; - -import type { - IDataObject, - INodeType, - INodeTypeDescription, - IWebhookFunctions, - IWebhookResponseData, -} from 'n8n-workflow'; -import { NodeConnectionTypes } from 'n8n-workflow'; - -export class SipAgentTrigger implements INodeType { - description: INodeTypeDescription = { - displayName: 'SIP Agent Trigger', - name: 'sipAgentTrigger', - icon: 'file:sipAgentTrigger.svg', - group: ['trigger'], - version: 1, - description: - 'Receives call-lifecycle and choice-callback webhooks from the General Disarray SIP AI phone assistant', - defaults: { - name: 'SIP Agent Trigger', - }, - inputs: [], - outputs: [NodeConnectionTypes.Main], - credentials: [ - { - name: 'sipAgentApi', - required: false, - }, - ], - webhooks: [ - { - name: 'default', - httpMethod: 'POST', - responseMode: 'onReceived', - path: 'webhook', - }, - ], - properties: [ - { - displayName: 'Events', - name: 'events', - type: 'multiOptions', - options: [ - { - name: 'Call Ended', - value: 'call.ended', - description: 'A call on the agent ended (inbound or outbound)', - }, - { - name: 'Call Started', - value: 'call.started', - description: 'A call on the agent started (inbound or outbound)', - }, - { - name: 'Choice Result / Call Outcome', - value: 'choice.result', - description: - 'The callback_url payload sent when an outbound call (with an optional choice prompt) completes — these payloads carry no "event" field', - }, - ], - default: ['call.ended', 'call.started', 'choice.result'], - description: - 'Which agent events start the workflow. Non-matching deliveries are acknowledged with 200 but do not start an execution.', - }, - { - displayName: 'Require Signature', - name: 'requireSignature', - type: 'boolean', - default: false, - description: - 'Whether to reject requests that lack a valid HMAC-SHA256 signature (X-Timestamp / X-Signature headers). Needs the Signing Secret set on the SIP Agent API credential, matching the agent\'s WEBHOOK_SIGNING_SECRET.', - }, - { - displayName: 'Tolerance (Seconds)', - name: 'tolerance', - type: 'number', - typeOptions: { minValue: 1 }, - default: 300, - description: - 'Maximum allowed age of the X-Timestamp header before a signed request is rejected as stale', - }, - ], - }; - - async webhook(this: IWebhookFunctions): Promise { - const requireSignature = this.getNodeParameter('requireSignature', false) as boolean; - const tolerance = this.getNodeParameter('tolerance', 300) as number; - - const credentials = await this.getCredentials('sipAgentApi').catch(() => undefined); - const secret = ((credentials?.signingSecret as string) || '').trim(); - - const headers = this.getHeaderData() as IDataObject; - const signatureHeader = (headers['x-signature'] as string) || ''; - - const reject = (message: string): IWebhookResponseData => { - const res = this.getResponseObject(); - res.status(401).json({ error: message }); - return { noWebhookResponse: true }; - }; - - if (requireSignature || (secret && signatureHeader)) { - if (!secret) { - return reject('Signature required but no signing secret is configured'); - } - const timestamp = (headers['x-timestamp'] as string) || ''; - if (!timestamp || !signatureHeader) { - return reject('Missing X-Timestamp or X-Signature header'); - } - if (Math.abs(Date.now() / 1000 - Number(timestamp)) > tolerance) { - return reject('Stale timestamp'); - } - const provided = signatureHeader.replace(/^sha256=/, ''); - const req = this.getRequestObject(); - const raw: Buffer = - (req as unknown as { rawBody?: Buffer }).rawBody ?? - Buffer.from(JSON.stringify(req.body)); - const expected = createHmac('sha256', secret) - .update(`${timestamp}.`) - .update(raw) - .digest(); - let providedBuffer: Buffer; - try { - providedBuffer = Buffer.from(provided, 'hex'); - } catch { - return reject('Invalid signature'); - } - if ( - providedBuffer.length !== expected.length || - !timingSafeEqual(providedBuffer, expected) - ) { - return reject('Invalid signature'); - } - } - - const events = this.getNodeParameter('events', [ - 'call.ended', - 'call.started', - 'choice.result', - ]) as string[]; - const body = this.getBodyData() as IDataObject; - // Legacy choice/outcome callbacks carry no `event` field. - const eventType = - typeof body.event === 'string' && body.event !== '' ? (body.event as string) : 'choice.result'; - if (!events.includes(eventType)) { - // Acknowledge with 200 so the agent's deliver_webhook doesn't retry, - // but start no execution. - const res = this.getResponseObject(); - res.status(200).json({ ok: true, ignored: eventType }); - return { noWebhookResponse: true }; - } - - return { - workflowData: [this.helpers.returnJsonArray(body)], - }; - } -} diff --git a/examples/n8n-nodes-general-disarray/nodes/SipAgentTrigger/sipAgentTrigger.svg b/examples/n8n-nodes-general-disarray/nodes/SipAgentTrigger/sipAgentTrigger.svg deleted file mode 100644 index 8565c82..0000000 --- a/examples/n8n-nodes-general-disarray/nodes/SipAgentTrigger/sipAgentTrigger.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/examples/n8n-nodes-general-disarray/package-lock.json b/examples/n8n-nodes-general-disarray/package-lock.json deleted file mode 100644 index 9a8c2f0..0000000 --- a/examples/n8n-nodes-general-disarray/package-lock.json +++ /dev/null @@ -1,1266 +0,0 @@ -{ - "name": "n8n-nodes-general-disarray", - "version": "0.1.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "n8n-nodes-general-disarray", - "version": "0.1.0", - "license": "AGPL-3.0", - "devDependencies": { - "@types/node": "^20.14.0", - "n8n-workflow": "^1.82.0", - "typescript": "^5.6.0" - }, - "peerDependencies": { - "n8n-workflow": "*" - } - }, - "node_modules/@n8n_io/riot-tmpl": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@n8n_io/riot-tmpl/-/riot-tmpl-4.0.1.tgz", - "integrity": "sha512-/zdRbEfTFjsm1NqnpPQHgZTkTdbp5v3VUxGeMA9098sps8jRCTraQkc3AQstJgHUm7ylBXJcIVhnVeLUMWAfwQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "eslint-config-riot": "^1.0.0" - } - }, - "node_modules/@n8n/errors": { - "version": "0.5.1", - "resolved": "https://registry.npmjs.org/@n8n/errors/-/errors-0.5.1.tgz", - "integrity": "sha512-1FkdDcuOHKNE2U6irsljl3+RYXif9wZxqOaDjwZpTvTMxEKLMHt8oneN5/mIaLpAhPgk2eSyUcCkHEW+UjILCQ==", - "dev": true, - "license": "SEE LICENSE IN LICENSE.md", - "dependencies": { - "callsites": "3.1.0" - } - }, - "node_modules/@n8n/expression-runtime": { - "version": "0.14.4", - "resolved": "https://registry.npmjs.org/@n8n/expression-runtime/-/expression-runtime-0.14.4.tgz", - "integrity": "sha512-VQ8P1SKz9PNtw+ewXjrdnEwgK+io+lD+5cZP9UFs41K8KY9Dz13SpOD9e/ffhmZPkZPiyX5QPDyXkOjt4gkZdQ==", - "dev": true, - "license": "SEE LICENSE IN LICENSE.md", - "dependencies": { - "@n8n/errors": "0.5.1", - "@n8n/tournament": "1.2.0", - "isolated-vm": "^6.1.2", - "jmespath": "0.16.0", - "js-base64": "3.7.2", - "jssha": "3.3.1", - "lodash": "4.18.1", - "luxon": "3.4.4", - "md5": "2.3.0", - "title-case": "3.0.3", - "transliteration": "2.3.5", - "zod": "3.25.67" - } - }, - "node_modules/@n8n/expression-runtime/node_modules/@n8n/tournament": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@n8n/tournament/-/tournament-1.2.0.tgz", - "integrity": "sha512-EY/vqijjYi2LhnKeShNXNtVBJW7lSRGFe5D2ikv5AfDZiLOqneRnNdfwZbv1bTLByRYyzY708KiXF8P66Gr1dg==", - "dev": true, - "license": "SEE LICENSE IN LICENSE.md", - "dependencies": { - "ast-types": "^0.16.1", - "esprima-next": "^5.8.4", - "recast": "^0.22.0" - }, - "engines": { - "node": ">=20.15", - "pnpm": ">=9.5" - } - }, - "node_modules/@n8n/tournament": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/@n8n/tournament/-/tournament-1.0.6.tgz", - "integrity": "sha512-UGSxYXXVuOX0yL6HTLBStKYwLIa0+JmRKiSZSCMcM2s2Wax984KWT6XIA1TR/27i7yYpDk1MY14KsTPnuEp27A==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@n8n_io/riot-tmpl": "^4.0.1", - "ast-types": "^0.16.1", - "esprima-next": "^5.8.4", - "recast": "^0.22.0" - }, - "engines": { - "node": ">=20.15", - "pnpm": ">=9.5" - } - }, - "node_modules/@types/node": { - "version": "20.19.43", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", - "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/assert": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/assert/-/assert-2.1.0.tgz", - "integrity": "sha512-eLHpSK/Y4nhMJ07gDaAzoX/XAKS8PSaojml3M0DM4JpV1LAi5JOJ/p6H/XWrl8L+DzVEvVCW1z3vWAaB9oTsQw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.2", - "is-nan": "^1.3.2", - "object-is": "^1.1.5", - "object.assign": "^4.1.4", - "util": "^0.12.5" - } - }, - "node_modules/ast-types": { - "version": "0.16.1", - "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.16.1.tgz", - "integrity": "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==", - "dev": true, - "license": "MIT", - "dependencies": { - "tslib": "^2.0.1" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/available-typed-arrays": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", - "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "possible-typed-array-names": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/call-bind": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", - "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "get-intrinsic": "^1.3.0", - "set-function-length": "^1.2.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/call-bound": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/charenc": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz", - "integrity": "sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": "*" - } - }, - "node_modules/cliui": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", - "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "license": "MIT" - }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, - "license": "MIT", - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/crypt": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz", - "integrity": "sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": "*" - } - }, - "node_modules/define-data-property": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", - "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-define-property": "^1.0.0", - "es-errors": "^1.3.0", - "gopd": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/define-properties": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", - "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", - "dev": true, - "license": "MIT", - "dependencies": { - "define-data-property": "^1.0.1", - "has-property-descriptors": "^1.0.0", - "object-keys": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-object-atoms": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", - "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/eslint-config-riot": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/eslint-config-riot/-/eslint-config-riot-1.0.0.tgz", - "integrity": "sha512-NB/L/1Y30qyJcG5xZxCJKW/+bqyj+llbcCwo9DEz8bESIP0SLTOQ8T1DWCCFc+wJ61AMEstj4511PSScqMMfCw==", - "dev": true, - "license": "MIT" - }, - "node_modules/esprima": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "dev": true, - "license": "BSD-2-Clause", - "bin": { - "esparse": "bin/esparse.js", - "esvalidate": "bin/esvalidate.js" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/esprima-next": { - "version": "5.8.4", - "resolved": "https://registry.npmjs.org/esprima-next/-/esprima-next-5.8.4.tgz", - "integrity": "sha512-8nYVZ4ioIH4Msjb/XmhnBdz5WRRBaYqevKa1cv9nGJdCehMbzZCPNEEnqfLCZVetUVrUPEcb5IYyu1GG4hFqgg==", - "dev": true, - "license": "BSD-2-Clause", - "bin": { - "esparse": "bin/esparse.js", - "esvalidate": "bin/esvalidate.js" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/for-each": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", - "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-callable": "^1.2.7" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/form-data": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", - "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", - "dev": true, - "license": "MIT", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "mime-types": "^2.1.12" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/generator-function": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", - "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/get-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, - "license": "ISC", - "engines": { - "node": "6.* || 8.* || >= 10.*" - } - }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dev": true, - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-property-descriptors": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", - "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-define-property": "^1.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-symbols": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", - "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", - "dev": true, - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/is-arguments": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz", - "integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-buffer": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", - "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", - "dev": true, - "license": "MIT" - }, - "node_modules/is-callable": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", - "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/is-generator-function": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", - "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.4", - "generator-function": "^2.0.0", - "get-proto": "^1.0.1", - "has-tostringtag": "^1.0.2", - "safe-regex-test": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-nan": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/is-nan/-/is-nan-1.3.2.tgz", - "integrity": "sha512-E+zBKpQ2t6MEo1VsonYmluk9NxGrbzpeeLC2xIViuO2EjU2xsXsBPwTr3Ykv9l08UYEVEdWeRZNouaZqF6RN0w==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.0", - "define-properties": "^1.1.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-regex": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", - "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "gopd": "^1.2.0", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-typed-array": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", - "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "which-typed-array": "^1.1.16" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/isolated-vm": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/isolated-vm/-/isolated-vm-6.1.2.tgz", - "integrity": "sha512-GGfsHqtlZiiurZaxB/3kY7LLAXR3sgzDul0fom4cSyBjx6ZbjpTrFWiH3z/nUfLJGJ8PIq9LQmQFiAxu24+I7A==", - "dev": true, - "hasInstallScript": true, - "license": "ISC", - "dependencies": { - "node-gyp-build": "^4.8.4" - }, - "engines": { - "node": ">=22.0.0" - } - }, - "node_modules/jmespath": { - "version": "0.16.0", - "resolved": "https://registry.npmjs.org/jmespath/-/jmespath-0.16.0.tgz", - "integrity": "sha512-9FzQjJ7MATs1tSpnco1K6ayiYE3figslrXA72G2HQ/n76RzvYlofyi5QM+iX4YRs/pu3yzxlVQSST23+dMDknw==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">= 0.6.0" - } - }, - "node_modules/js-base64": { - "version": "3.7.2", - "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.7.2.tgz", - "integrity": "sha512-NnRs6dsyqUXejqk/yv2aiXlAvOs56sLkX6nUdeaNezI5LFFLlsZjOThmwnrcwh5ZZRwZlCMnVAY3CvhIhoVEKQ==", - "dev": true, - "license": "BSD-3-Clause" - }, - "node_modules/jsonrepair": { - "version": "3.13.2", - "resolved": "https://registry.npmjs.org/jsonrepair/-/jsonrepair-3.13.2.tgz", - "integrity": "sha512-Leuly0nbM4R+S5SVJk3VHfw1oxnlEK9KygdZvfUtEtTawNDyzB4qa1xWTmFt1aeoA7sXZkVTRuIixJ8bAvqVUg==", - "dev": true, - "license": "ISC", - "bin": { - "jsonrepair": "bin/cli.js" - } - }, - "node_modules/jssha": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/jssha/-/jssha-3.3.1.tgz", - "integrity": "sha512-VCMZj12FCFMQYcFLPRm/0lOBbLi8uM2BhXPTqw3U4YAfs4AZfiApOoBLoN8cQE60Z50m1MYMTQVCfgF/KaCVhQ==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": "*" - } - }, - "node_modules/lodash": { - "version": "4.18.1", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", - "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/luxon": { - "version": "3.4.4", - "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.4.4.tgz", - "integrity": "sha512-zobTr7akeGHnv7eBOXcRgMeCP6+uyYsczwmeRCauvpvaAltgNyTbLH/+VaEAPUeWBT+1GuNmz4wC/6jtQzbbVA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - } - }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/md5": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/md5/-/md5-2.3.0.tgz", - "integrity": "sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "charenc": "0.0.2", - "crypt": "0.0.2", - "is-buffer": "~1.1.6" - } - }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/n8n-workflow": { - "version": "1.120.21", - "resolved": "https://registry.npmjs.org/n8n-workflow/-/n8n-workflow-1.120.21.tgz", - "integrity": "sha512-hEOdl8ZEL+ZFbjfxQPE8T78lU5qP1J7SwSH/yNDehJmD5I4vmjQ0XL5xAok3HMl6zM3ynRpZwuiep9uzihGmGQ==", - "dev": true, - "license": "SEE LICENSE IN LICENSE.md", - "dependencies": { - "@n8n/errors": "0.5.1", - "@n8n/expression-runtime": "0.14.4", - "@n8n/tournament": "1.0.6", - "ast-types": "0.16.1", - "callsites": "3.1.0", - "esprima-next": "5.8.4", - "form-data": "4.0.0", - "jmespath": "0.16.0", - "js-base64": "3.7.2", - "jsonrepair": "3.13.2", - "jssha": "3.3.1", - "lodash": "4.18.1", - "luxon": "3.4.4", - "md5": "2.3.0", - "recast": "0.22.0", - "title-case": "3.0.3", - "transliteration": "2.3.5", - "xml2js": "0.6.2", - "zod": "3.25.67" - } - }, - "node_modules/node-gyp-build": { - "version": "4.8.4", - "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", - "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", - "dev": true, - "license": "MIT", - "bin": { - "node-gyp-build": "bin.js", - "node-gyp-build-optional": "optional.js", - "node-gyp-build-test": "build-test.js" - } - }, - "node_modules/object-is": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz", - "integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/object-keys": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", - "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/object.assign": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", - "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0", - "has-symbols": "^1.1.0", - "object-keys": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/possible-typed-array-names": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", - "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/recast": { - "version": "0.22.0", - "resolved": "https://registry.npmjs.org/recast/-/recast-0.22.0.tgz", - "integrity": "sha512-5AAx+mujtXijsEavc5lWXBPQqrM4+Dl5qNH96N2aNeuJFUzpiiToKPsxQD/zAIJHspz7zz0maX0PCtCTFVlixQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "assert": "^2.0.0", - "ast-types": "0.15.2", - "esprima": "~4.0.0", - "source-map": "~0.6.1", - "tslib": "^2.0.1" - }, - "engines": { - "node": ">= 4" - } - }, - "node_modules/recast/node_modules/ast-types": { - "version": "0.15.2", - "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.15.2.tgz", - "integrity": "sha512-c27loCv9QkZinsa5ProX751khO9DJl/AcB5c2KNtA6NRvHKS0PgLfcftz72KVq504vB0Gku5s2kUZzDBvQWvHg==", - "dev": true, - "license": "MIT", - "dependencies": { - "tslib": "^2.0.1" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/require-directory": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/safe-regex-test": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", - "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "is-regex": "^1.2.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/sax": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", - "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=11.0.0" - } - }, - "node_modules/set-function-length": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", - "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", - "dev": true, - "license": "MIT", - "dependencies": { - "define-data-property": "^1.1.4", - "es-errors": "^1.3.0", - "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.4", - "gopd": "^1.0.1", - "has-property-descriptors": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/title-case": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/title-case/-/title-case-3.0.3.tgz", - "integrity": "sha512-e1zGYRvbffpcHIrnuqT0Dh+gEJtDaxDSoG4JAIpq4oDFyooziLBIiYQv0GBT4FUAnUop5uZ1hiIAj7oAF6sOCA==", - "dev": true, - "license": "MIT", - "dependencies": { - "tslib": "^2.0.3" - } - }, - "node_modules/transliteration": { - "version": "2.3.5", - "resolved": "https://registry.npmjs.org/transliteration/-/transliteration-2.3.5.tgz", - "integrity": "sha512-HAGI4Lq4Q9dZ3Utu2phaWgtm3vB6PkLUFqWAScg/UW+1eZ/Tg6Exo4oC0/3VUol/w4BlefLhUUSVBr/9/ZGQOw==", - "dev": true, - "license": "MIT", - "dependencies": { - "yargs": "^17.5.1" - }, - "bin": { - "slugify": "dist/bin/slugify", - "transliterate": "dist/bin/transliterate" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, - "license": "0BSD" - }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/util": { - "version": "0.12.5", - "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz", - "integrity": "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==", - "dev": true, - "license": "MIT", - "dependencies": { - "inherits": "^2.0.3", - "is-arguments": "^1.0.4", - "is-generator-function": "^1.0.7", - "is-typed-array": "^1.1.3", - "which-typed-array": "^1.1.2" - } - }, - "node_modules/which-typed-array": { - "version": "1.1.22", - "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.22.tgz", - "integrity": "sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==", - "dev": true, - "license": "MIT", - "dependencies": { - "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.9", - "call-bound": "^1.0.4", - "for-each": "^0.3.5", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/xml2js": { - "version": "0.6.2", - "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz", - "integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==", - "dev": true, - "license": "MIT", - "dependencies": { - "sax": ">=0.6.0", - "xmlbuilder": "~11.0.0" - }, - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/xmlbuilder": { - "version": "11.0.1", - "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", - "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4.0" - } - }, - "node_modules/y18n": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", - "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=10" - } - }, - "node_modules/yargs": { - "version": "17.7.3", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", - "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", - "dev": true, - "license": "MIT", - "dependencies": { - "cliui": "^8.0.1", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.3", - "y18n": "^5.0.5", - "yargs-parser": "^21.1.1" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/yargs-parser": { - "version": "21.1.1", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", - "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/zod": { - "version": "3.25.67", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.67.tgz", - "integrity": "sha512-idA2YXwpCdqUSKRCACDE6ItZD9TZzy3OZMtpfLoh6oPR47lipysRrJfjzMqFxQ3uJuUPyUeWe1r9vLH33xO/Qw==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - } - } -} diff --git a/examples/n8n-nodes-general-disarray/package.json b/examples/n8n-nodes-general-disarray/package.json deleted file mode 100644 index ce929f7..0000000 --- a/examples/n8n-nodes-general-disarray/package.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "name": "n8n-nodes-general-disarray", - "version": "0.1.0", - "description": "n8n nodes for the General Disarray SIP AI phone assistant", - "license": "AGPL-3.0", - "keywords": [ - "n8n-community-node-package" - ], - "files": [ - "dist" - ], - "scripts": { - "build": "tsc && cp nodes/SipAgent/*.svg dist/nodes/SipAgent/ && cp nodes/SipAgentTrigger/*.svg dist/nodes/SipAgentTrigger/" - }, - "n8n": { - "n8nNodesApiVersion": 1, - "credentials": [ - "dist/credentials/SipAgentApi.credentials.js" - ], - "nodes": [ - "dist/nodes/SipAgent/SipAgent.node.js", - "dist/nodes/SipAgentTrigger/SipAgentTrigger.node.js" - ] - }, - "peerDependencies": { - "n8n-workflow": "*" - }, - "devDependencies": { - "@types/node": "^20.14.0", - "n8n-workflow": "^1.82.0", - "typescript": "^5.6.0" - } -} diff --git a/examples/n8n-nodes-general-disarray/tsconfig.json b/examples/n8n-nodes-general-disarray/tsconfig.json deleted file mode 100644 index d5e2aac..0000000 --- a/examples/n8n-nodes-general-disarray/tsconfig.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "compilerOptions": { - "strict": true, - "module": "commonjs", - "moduleResolution": "node", - "target": "es2021", - "lib": ["es2021"], - "outDir": "dist", - "rootDir": ".", - "declaration": false, - "sourceMap": false, - "esModuleInterop": true, - "skipLibCheck": true, - "useUnknownInCatchVariables": false - }, - "include": ["credentials/**/*.ts", "nodes/**/*.ts"] -} diff --git a/searxng/settings.yml b/searxng/settings.yml index 7ee5c36..7befe09 100644 --- a/searxng/settings.yml +++ b/searxng/settings.yml @@ -1,6 +1,6 @@ -# SearxNG settings for the General Disarray WEB_SEARCH tool. -# Minimal delta over defaults: enable the JSON API the agent queries and -# disable the bot limiter (the only client is the agent on the compose net). +# SearxNG settings for the General Disarray WEB_SEARCH tool + Open WebUI RAG. +# Minimal delta over defaults: enable the JSON API the clients query and +# disable the bot limiter (the only clients are on the compose network). use_default_settings: true server: @@ -12,3 +12,69 @@ search: formats: - html - json + +outgoing: + # Defaults are tight enough that slower engines get dropped, which is part of + # why results came back thin. Give them room without hanging the request. + request_timeout: 10.0 + max_request_timeout: 15.0 + pool_connections: 100 + pool_maxsize: 20 + +# -------------------------------------------------------------------------- +# Engine breadth (tuned 2026-08-08). +# +# Before: only duckduckgo answered. One query gave 10 results / 9 domains and +# another gave ZERO, because DDG had started serving CAPTCHAs and 429s. Open +# WebUI asking for 30 results cannot get them from one source capped at ~10. +# +# TESTING GOTCHA - read before "verifying" an engine: +# ?engines= with an UNKNOWN name silently falls back to the default +# engines and still returns results. So a name that does not exist in this +# build looks like it "works". Always confirm via the per-result `engine` +# field, e.g.: +# curl -s 'http://127.0.0.1:8082/search?q=test&format=json&engines=foo' \ +# | python3 -c 'import json,sys;print({r["engine"] for r in json.load(sys.stdin)["results"]})' +# marginalia / stract / "right dao" are NOT in this build's 246-engine +# registry; earlier notes claiming they worked were this fallback artifact. +# +# Measured from this box, attributed by `engine` field: +# RELIABLE : bing, wiby +# INTERMITTENT : yahoo, brave, duckduckgo (work, then 429/CAPTCHA under load +# and recover on their own - SearXNG auto-suspends them) +# BLOCKED HERE : google, mojeek, wikipedia, startpage, qwant, presearch +# (403 / CAPTCHA / access denied - upstream anti-bot against a +# datacenter IP. Not fixable in config; only an egress proxy +# or paid API keys would change it.) +# +# Keep several enabled precisely so one CAPTCHA does not zero out a search. +# -------------------------------------------------------------------------- +engines: + # --- reliable here --- + - name: bing + disabled: false + - name: wiby + disabled: false + categories: [general, web] + # --- intermittent but genuinely productive; keep on --- + - name: yahoo + disabled: false + - name: brave + disabled: false + - name: duckduckgo + disabled: false + # --- blocked right now, but blocks are IP/rate based and do lift --- + - name: google + disabled: false + - name: mojeek + disabled: false + categories: [general, web] + # --- consistently failing here; off to cut error noise and latency --- + - name: startpage + disabled: true + - name: qwant + disabled: true + - name: presearch + disabled: true + - name: wikidata + disabled: true diff --git a/sip-agent/requirements.txt b/sip-agent/requirements.txt index 85fd226..8cfbccd 100644 --- a/sip-agent/requirements.txt +++ b/sip-agent/requirements.txt @@ -37,6 +37,9 @@ APScheduler>=3.10.0 # Timezone support (for plugins) pytz>=2023.3 +# TOTP (rolling one-time codes) for optional caller identity verification +pyotp>=2.9.0 + # Configuration python-dotenv>=1.0.0 pydantic>=2.0.0 @@ -61,7 +64,7 @@ langchain-text-splitters>=1.0,<2.0 fastembed>=0.7,<1.0 # MCP client (MCP_ENABLED=true): consume tools from external MCP servers. # Optional at runtime — mcp_tools.py degrades to a warning if it is missing. -mcp>=1.0 +mcp>=1.0,<2 # 2.x renamed McpError/streamablehttp_client/FastMCP (breaking) # =================== # OpenTelemetry (optional - for observability) diff --git a/sip-agent/src/api.py b/sip-agent/src/api.py index 8a674c9..da5d768 100644 --- a/sip-agent/src/api.py +++ b/sip-agent/src/api.py @@ -28,11 +28,16 @@ from admin_events import EventBus from call_session import set_current_session +from identity_verification import is_safe_caller_id +from dtmf_collect import collect_dtmf_code from telemetry import create_span, Metrics from logging_utils import log_event from retry_utils import retry_async, RetryError +# Reserved caller id under which GET /verify/otp serves the GLOBAL TOTP secret. +GLOBAL_OTP_ID = "global" + if TYPE_CHECKING: from main import SIPAIAssistant from call_queue import CallQueue @@ -44,6 +49,12 @@ # Request validation / security helpers # ============================================================================ +class _VerifyCallDone(Exception): + """Internal signal inside run_verify_call: a terminal non-answer outcome + (no answer / initiate failure) with status already set — jump to the shared + teardown + webhook exit rather than running the prompt/verify loop.""" + + class RequestRejected(HTTPException): """Raised when a request is rejected at the handler boundary. @@ -449,6 +460,8 @@ class CallStatus(str, Enum): NO_ANSWER = "no_answer" FAILED = "failed" BUSY = "busy" + # Verify calls only: the caller hung up before any code was checked. + HANGUP = "hangup" class OutboundCallResponse(BaseModel): @@ -615,6 +628,17 @@ class VirtualNumberRequest(BaseModel): include_transcript: bool = Field( default=True, description="Include the transcript in the completion webhook") + persistent: bool = Field( + default=False, + description="Trigger number: never expires and is not consumed by " + "its calls — every call to it fires the webhook until " + "the number is deleted (ttl_s is ignored)") + events: Optional[List[str]] = Field( + default=None, + description="Call-time webhooks to fire: answered (call matched, " + "before the greeting), first_speech (caller's first " + "utterance), speech (every utterance), completed (call " + "ended, + transcript). Default [\"completed\"]") class VirtualNumberResponse(BaseModel): @@ -626,6 +650,124 @@ class VirtualNumberResponse(BaseModel): purpose: str expires_at: float created_at: float + persistent: bool = False + events: List[str] = [] + callback_url: str = "" + + +class VerifyRequest(BaseModel): + """Out-of-band identity check for a caller (no live call needed).""" + caller_id: str = Field(..., min_length=1, max_length=64, + description="Caller id (SIP URI user part)") + pin: Optional[str] = Field(default=None, description="Static PIN to check") + otp: Optional[str] = Field(default=None, description="One-time (TOTP) code to check") + + @model_validator(mode="after") + def _at_least_one_factor(self): + if not (self.pin or self.otp): + raise ValueError("Provide a pin and/or otp to check") + return self + + +class VerifyResponse(BaseModel): + """Result of an identity check.""" + caller_id: str + verified: bool + method: Optional[str] = None # "pin" | "otp" | None + + +class VerifyCallRequest(BaseModel): + """Place an outbound call that verifies a caller's identity by keypad. + + The agent dials ``extension`` (defaulting to ``caller_id``), asks the person + to key in their PIN or one-time code, and checks it against the credentials + stored for ``caller_id``. Digits are entered by DTMF, never spoken, so the + code never lands in the transcript. + """ + caller_id: Optional[str] = Field( + default=None, max_length=64, + description="Caller id whose stored credentials are checked (SIP URI user " + "part). Defaults to the extension when omitted.") + extension: Optional[str] = Field( + default=None, + description="SIP extension or number to dial (defaults to caller_id)") + method: str = Field( + default="auto", pattern=r"^(pin|otp|auto)$", + description="Which factor to require: 'pin', 'otp', or 'auto' (either)") + pin: Optional[str] = Field( + default=None, + description="Check the entered code against this PIN for this call " + "(instead of the caller's stored/global PIN)") + totp_secret: Optional[str] = Field( + default=None, + description="Check the entered code against this base32 TOTP secret for " + "this call (instead of the stored/global secret)") + totp_digits: Optional[int] = Field( + default=None, ge=4, le=10, + description="Digits in the TOTP code (defaults to VERIFY_TOTP_DIGITS)") + totp_period: Optional[int] = Field( + default=None, ge=5, le=300, + description="TOTP step in seconds (defaults to VERIFY_TOTP_PERIOD)") + totp_algorithm: Optional[str] = Field( + default=None, pattern=r"^(?i:sha1|sha256|sha512)$", + description="TOTP hash: SHA1|SHA256|SHA512 (defaults to VERIFY_TOTP_ALGORITHM)") + totp_window: Optional[int] = Field( + default=None, ge=0, le=10, + description="Clock-skew steps to accept (defaults to VERIFY_TOTP_WINDOW)") + prompt: Optional[str] = Field( + default=None, + description="Custom spoken prompt (defaults to VERIFY_CALL_PROMPT)") + retry_phrase: Optional[str] = Field( + default=None, + description="Spoken line after a wrong code (defaults to VERIFY_CALL_RETRY_PHRASE)") + success_phrase: Optional[str] = Field( + default=None, + description="Spoken line on success (defaults to VERIFY_CALL_SUCCESS_PHRASE)") + fail_phrase: Optional[str] = Field( + default=None, + description="Spoken line on failure (defaults to VERIFY_CALL_FAIL_PHRASE)") + ring_timeout: int = Field(default=30, ge=1, le=600, + description="Seconds to wait for the call to be answered") + callback_url: Optional[str] = Field( + default=None, description="Optional webhook URL to POST the result to") + call_id: Optional[str] = Field(default=None, description="Optional caller-provided ID for tracking") + + +class VerifyCallResponse(BaseModel): + """Result of an outbound identity-verification call.""" + call_id: str + status: CallStatus + verified: bool + method: Optional[str] = None # "pin" | "otp" | None + attempts: int = 0 + error: Optional[str] = None + + +class VerifyCredentialsRequest(BaseModel): + """Enroll or update a caller's verification factors.""" + caller_id: str = Field(..., min_length=1, max_length=64, + description="Caller id (SIP URI user part)") + pin: Optional[str] = Field(default=None, description="Static PIN to set/rotate") + totp_secret: Optional[str] = Field( + default=None, description="Base32 TOTP secret to store (ignored if generate_totp)") + generate_totp: bool = Field( + default=False, description="Mint a fresh random TOTP secret for this caller") + + +class VerifyCredentialsResponse(BaseModel): + """Public view of a caller's enrollment (never the secret or PIN hash).""" + caller_id: str + has_pin: bool + has_totp: bool + provisioning_uri: Optional[str] = None + updated_at: Optional[str] = None + + +class OtpResponse(BaseModel): + """Current TOTP code for a caller (for delivery/testing).""" + caller_id: str + otp: str + expires_in_s: int class ToolExecuteResponse(BaseModel): @@ -1162,6 +1304,219 @@ async def _send_webhook(self, url: str, payload: WebhookPayload): log_event(logger, logging.INFO, "Webhook sent successfully", event="outbound_call_webhook_success", url=url) + # --- Outbound identity verification --------------------------------- + async def _collect_code(self, call_info, timeout: float, + prompt_audio: Optional[bytes] = None) -> Optional[str]: + """Speak the prompt and collect keypad digits for a PIN/OTP. + + Thin wrapper over the shared ``dtmf_collect.collect_dtmf_code`` loop + (also used by the in-call VERIFY tool): first keypress mutes the prompt, + '*' restarts entry, '#' or an inter-digit pause submits. NEVER log the + returned code. + """ + interdigit = float(getattr(self.assistant.config, "verify_dtmf_interdigit_s", 3.0)) + return await collect_dtmf_code( + self.assistant.sip_handler, call_info, timeout=timeout, + interdigit=interdigit, prompt_audio=prompt_audio) + + async def _say(self, call_info, text: str) -> None: + """Speak a line into the live call and wait for it to finish (best-effort).""" + try: + audio = await self.assistant.audio_pipeline.synthesize(text) + if audio and getattr(call_info, "is_active", False): + await self.assistant.sip_handler.send_audio(call_info, audio) + duration = len(audio) / (self.assistant.config.sample_rate * 2) + await asyncio.sleep(duration + 0.3) + except Exception as e: + logger.debug(f"verify-call prompt playback failed: {e}") + + async def run_verify_call(self, request: 'VerifyCallRequest') -> 'VerifyCallResponse': + """Dial the caller, collect a PIN/OTP by keypad, and verify it. + + Runs synchronously (the HTTP request awaits the verdict). Bounded by + ring_timeout, VERIFY_DTMF_TIMEOUT_S and VERIFY_MAX_ATTEMPTS so the call + can't run unbounded. Fails closed on the security decision (any error + leaves verified=False) but never raises on an expected call outcome. + """ + config = self.assistant.config + caller_id = (request.caller_id or "").strip() + extension = (request.extension or caller_id).strip() + call_id = request.call_id or self.generate_call_id() + + # caller_id is optional: it names whose stored credentials to check (and + # is the default dial target). Omitted, it defaults to the extension so + # the extension's own enrollment is consulted. At least one is required. + if not extension: + raise RequestRejected(400, "Provide a caller_id or an extension to dial") + if not caller_id and is_safe_caller_id(extension): + caller_id = extension + if caller_id and not is_safe_caller_id(caller_id): + raise RequestRejected(400, "Invalid caller_id") + validate_extension(extension, config) + await validate_callback_url(request.callback_url, config) + if request.call_id and not re.fullmatch(r"[A-Za-z0-9._-]{1,64}", request.call_id): + raise RequestRejected( + 400, "call_id may only contain letters, digits, '.', '_', '-' (max 64 chars)") + + verifier = self.assistant.verifier + + # Per-request ("ad-hoc") credentials: when the caller supplies a PIN + # and/or TOTP secret in the request, the entered code is checked against + # exactly those, with no store/global lookup (an n8n workflow that holds + # the factors itself, without enrolling the caller first). + adhoc_pin = request.pin or None + adhoc_secret = (request.totp_secret or "").strip().replace(" ", "").upper() or None + if adhoc_secret and not re.fullmatch(r"[A-Z2-7]+=*", adhoc_secret): + raise RequestRejected(400, "totp_secret must be base32") + adhoc = bool(adhoc_pin or adhoc_secret) + + # No ad-hoc factor and no stored/global factor: nothing to check. + if not adhoc and not verifier.can_verify(caller_id): + raise RequestRejected( + 400, "No verification credentials configured: supply a pin/totp_secret " + "or a caller_id (or global VERIFY_*) with enrolled credentials") + + # Concurrency guard (verify calls are not queued — they're interactive). + if call_id in self.pending_calls: + raise RequestRejected(409, f"call_id '{call_id}' already in progress") + if len(self.pending_calls) >= config.max_direct_concurrent_calls: + raise RequestRejected(429, "Too many concurrent calls in progress; try again later") + + request.ring_timeout = min(request.ring_timeout, config.max_ring_timeout_s) + # Park a marker so the concurrency guard and /call/{id} see this call. + self.pending_calls[call_id] = request # type: ignore[assignment] + + set_current_session(None) + status = CallStatus.FAILED + verified = False + method: Optional[str] = None + attempts = 0 + error: Optional[str] = None + call_info = None + hung_up = False + + with create_span("api.verify_call", { + "call.id": call_id, "call.extension": extension, "verify.method": request.method, + }) as span: + try: + uri = extension + if not uri.startswith("sip:"): + uri = f"sip:{uri}@{config.sip_domain}" if "@" not in uri else f"sip:{uri}" + + call_info = await self.assistant.sip_handler.make_call(uri) + if not call_info: + error = "Failed to initiate call" + Metrics.record_call_failed("verify", "initiate_failed") + raise _VerifyCallDone() + + status = CallStatus.RINGING + ring_start = asyncio.get_event_loop().time() + while asyncio.get_event_loop().time() - ring_start < request.ring_timeout: + if getattr(call_info, "is_active", False): + status = CallStatus.ANSWERED + break + await asyncio.sleep(0.5) + else: + status = CallStatus.NO_ANSWER + Metrics.record_call_failed("verify", "no_answer") + await self.assistant.sip_handler.hangup_call(call_info) + hung_up = True + raise _VerifyCallDone() + + await asyncio.sleep(1) # let media settle + + # Spoken lines: per-request override, else the configured default. + prompt = request.prompt or config.verify_call_prompt + retry_phrase = request.retry_phrase or config.verify_call_retry_phrase + success_phrase = request.success_phrase or config.verify_call_success_phrase + fail_phrase = request.fail_phrase or config.verify_call_fail_phrase + dtmf_timeout = float(getattr(config, "verify_dtmf_timeout_s", 20.0)) + max_attempts = max(1, int(getattr(config, "verify_max_attempts", 3))) + # Pre-synthesize the prompt once; it's replayed each attempt and + # the caller's first keypress mutes it (barge-in) inside collect. + prompt_audio = await self.assistant.audio_pipeline.synthesize(prompt) + + empty_entries = 0 + while attempts < max_attempts and getattr(call_info, "is_active", False): + code = await self._collect_code(call_info, dtmf_timeout, prompt_audio) + if not code: + if not getattr(call_info, "is_active", False): + break # hung up mid-prompt: not a completed attempt + # Timeout with nothing keyed — not a wrong code (the in-call + # VERIFY tool doesn't burn an attempt either), but bound + # the re-prompts so the call can't run unbounded. + empty_entries += 1 + if empty_entries >= max_attempts: + break + continue + if adhoc: + ok, used = await verifier.averify_explicit( + code, pin=adhoc_pin, totp_secret=adhoc_secret, + method=request.method, totp_digits=request.totp_digits, + totp_period=request.totp_period, + totp_algorithm=request.totp_algorithm, + totp_window=request.totp_window) + else: + ok, used = await verifier.averify(caller_id, code, method=request.method) + attempts += 1 + # Diagnostic only — length, never the code itself. + log_event(logger, logging.DEBUG, "Verify attempt", + event="verify_call_attempt", call_id=call_id, + code_len=len(code), method=request.method, + adhoc=adhoc, matched=used, ok=ok) + if ok: + verified, method = True, used + break + if attempts < max_attempts: + await self._say(call_info, retry_phrase) + + # A caller who hung up before any code was checked is + # distinguishable from a wrong code on the webhook. + status = (CallStatus.COMPLETED + if getattr(call_info, "is_active", False) or attempts + else CallStatus.HANGUP) + if status is CallStatus.HANGUP and error is None: + error = "Caller hung up before entering a code" + # NEVER log the entered code — only the outcome. + log_event(logger, logging.INFO, + f"Verify call {'succeeded' if verified else 'failed'}", + event="verify_call", outcome="ok" if verified else "failed", + caller=caller_id or extension, call_id=call_id, + method=method, attempts=attempts) + span.set_attribute("verify.verified", verified) + + if getattr(call_info, "is_active", False): + await self._say(call_info, success_phrase if verified else fail_phrase) + if getattr(call_info, "is_active", False): + await self.assistant.sip_handler.hangup_call(call_info) + hung_up = True + + except _VerifyCallDone: + # Terminal non-answer outcome — status/error already set; fall + # through to the shared teardown + webhook exit below. + pass + except Exception as e: + error = str(e) + logger.error(f"Verify call error: {e}", exc_info=True) + span.record_exception(e) + finally: + if call_info is not None and not hung_up and getattr(call_info, "is_active", False): + try: + await self.assistant.sip_handler.hangup_call(call_info) + except Exception as cleanup_err: + logger.warning(f"Failed to hang up verify call: {cleanup_err}") + self.pending_calls.pop(call_id, None) + + response = VerifyCallResponse(call_id=call_id, status=status, verified=verified, + method=method, attempts=attempts, error=error) + if request.callback_url: + try: + await deliver_webhook(request.callback_url, response.model_dump(mode="json"), + config, api_name="verify_call_webhook") + except Exception as e: + logger.warning(f"verify-call webhook delivery failed: {e}") + return response + # ============================================================================ # FastAPI Application @@ -1282,6 +1637,31 @@ async def probe_redis(): _deps_cache["deps"] = deps return deps + def _require_verified_for(tool_name: str, call_id: Optional[str]) -> None: + """Apply the VERIFY_REQUIRED_TOOLS gate to REST tool execution. + + The same check tool_manager.execute_tool applies to LLM-driven calls: + a gated tool runs only for a call whose caller has passed VERIFY this + call. Over REST the relevant session is the one named by ``call_id`` + (else the single active call); with no live verified session the tool + is refused (fail closed) with 403. + """ + session = None + try: + sessions = _active_call_sessions(assistant) + if call_id: + session = next((sess for sess in sessions + if _session_matches(sess, call_id)), None) + elif len(sessions) == 1: + session = sessions[0] + except Exception: + session = None + blocked = assistant.tool_manager.verification_block(tool_name, session) + if blocked is not None: + log_event(logger, logging.INFO, f"REST tool {tool_name} blocked: caller not verified", + event="verify_gate", tool=tool_name, outcome="blocked", source="api") + raise HTTPException(status_code=403, detail=blocked.message) + @app.get("/health") async def health_check(deep: bool = False): """Health check endpoint. @@ -1638,6 +2018,7 @@ async def tool_call(tool_name: str, request: ToolCallRequest): log_event(logger, logging.INFO, f"Tool call request: {actual_tool_name} -> {request.extension}", event="api_tool_call", tool=actual_tool_name, extension=request.extension) + _require_verified_for(actual_tool_name, None) try: # Execute the tool first @@ -1727,7 +2108,8 @@ async def execute_tool(tool_name: str, request: ToolExecuteRequest = None): log_event(logger, logging.INFO, f"API executing tool: {actual_tool_name}", event="api_tool_execute", tool=actual_tool_name, params=request.params) - + _require_verified_for(actual_tool_name, request.call_id) + try: # Execute the tool result = await tool.execute(request.params) @@ -2175,6 +2557,9 @@ def _virtual_number_response(entry) -> VirtualNumberResponse: purpose=entry.purpose, expires_at=entry.expires_at, created_at=entry.created_at, + persistent=entry.persistent, + events=list(entry.events), + callback_url=entry.callback_url, ) @app.post("/virtual-numbers", response_model=VirtualNumberResponse, @@ -2190,6 +2575,13 @@ async def create_virtual_number(request: VirtualNumberRequest): to `callback_url` and the number is cleared. Unused numbers expire after `ttl_s` (an "expired" webhook fires instead). + With `persistent: true` the number becomes a **trigger number**: it + never expires, survives its calls, and every call to it fires the + webhooks selected in `events` — e.g. `["answered", "first_speech"]` + kicks a workflow off as soon as the call lands and again with what + the caller first said. Payloads carry `event: virtual_number.`, + `caller`, `call_id`, and for speech events `text`. + Example: ```json { @@ -2212,6 +2604,8 @@ async def create_virtual_number(request: VirtualNumberRequest): greeting=request.greeting or "", callback_url=request.callback_url or "", include_transcript=request.include_transcript, + persistent=request.persistent, + events=request.events, ) except VirtualNumberError as e: raise HTTPException(status_code=e.status_code, detail=e.detail) @@ -2244,6 +2638,108 @@ async def delete_virtual_number(number_id: str): raise HTTPException(status_code=404, detail="Virtual number not found") return {"success": True, "message": f"Virtual number {number_id} deleted"} + # --- Identity verification ------------------------------------------- + def _verify_credentials_view(caller_id: str) -> VerifyCredentialsResponse: + view = assistant.verify_store.public_view(caller_id) + uri = assistant.verify_store.provisioning_uri( + caller_id, assistant.config.verify_issuer) + return VerifyCredentialsResponse( + caller_id=caller_id, has_pin=view["has_pin"], has_totp=view["has_totp"], + provisioning_uri=uri, updated_at=view.get("updated_at")) + + @app.post("/verify", response_model=VerifyResponse, dependencies=protected) + async def verify_caller(request: VerifyRequest): + """Check a caller's PIN and/or OTP out-of-band (no live call needed). + + OTP is tried first when supplied, then the PIN; `verified` is true if + either matches. Credentials resolve per-caller, then global fallback. + """ + caller_id = (request.caller_id or "").strip() + verifier = assistant.verifier + ok = False + method: Optional[str] = None + if request.otp and await verifier.averify_totp(caller_id, request.otp): + ok, method = True, "otp" + elif request.pin and await verifier.averify_pin(caller_id, request.pin): + ok, method = True, "pin" + return VerifyResponse(caller_id=caller_id, verified=ok, method=method) + + @app.post("/verify/call", response_model=VerifyCallResponse, dependencies=protected) + async def verify_call(request: VerifyCallRequest): + """Place an outbound call that verifies a caller by keypad. + + Dials the caller (``extension``, defaulting to ``caller_id``), asks them + to key in their PIN or one-time code, checks it, and returns the verdict + synchronously (also POSTed to ``callback_url`` when set). Returns 400 + when the caller has no verification credentials configured. + """ + try: + return await handler.run_verify_call(request) + except RequestRejected as e: + raise HTTPException(status_code=e.status_code, detail=e.detail) + + @app.post("/verify/credentials", response_model=VerifyCredentialsResponse, + dependencies=protected) + async def set_verify_credentials(request: VerifyCredentialsRequest): + """Enroll/update a caller's PIN and/or TOTP secret. + + Returns the enrollment metadata plus an otpauth:// provisioning URI when + a per-caller TOTP secret exists (import into an authenticator app). The + raw secret and PIN are never returned. + """ + caller_id = (request.caller_id or "").strip() + if not is_safe_caller_id(caller_id): + raise HTTPException(status_code=400, detail="Invalid caller_id") + if not (request.pin or request.totp_secret or request.generate_totp): + raise HTTPException( + status_code=400, + detail="Provide a pin, totp_secret, or generate_totp=true") + # PBKDF2 hashing is CPU-bound; keep it off the call-serving event loop. + result = await asyncio.to_thread( + assistant.verify_store.set_credentials, + caller_id, pin=request.pin, totp_secret=request.totp_secret, + generate_totp=request.generate_totp) + if result is None: + raise HTTPException(status_code=400, detail="Could not store credentials") + return _verify_credentials_view(caller_id) + + @app.get("/verify/credentials/{caller_id}", + response_model=VerifyCredentialsResponse, dependencies=protected) + async def get_verify_credentials(caller_id: str): + """Enrollment metadata for a caller (404 when none). Never the secret/PIN.""" + if not assistant.verify_store.get(caller_id): + raise HTTPException(status_code=404, detail="No credentials for this caller") + return _verify_credentials_view(caller_id) + + @app.delete("/verify/credentials/{caller_id}", dependencies=protected) + async def delete_verify_credentials(caller_id: str): + """Remove a caller's enrolled credentials.""" + if not assistant.verify_store.delete(caller_id): + raise HTTPException(status_code=404, detail="No credentials for this caller") + return {"success": True, "message": f"Credentials for {caller_id} deleted"} + + @app.get("/verify/otp/{caller_id}", response_model=OtpResponse, + dependencies=protected) + async def get_current_otp(caller_id: str): + """Current TOTP code for an ENROLLED caller's own secret. + + The global VERIFY_TOTP_SECRET is served only under the reserved id + ``global`` — never as a silent fallback for an unknown/typo'd caller, + which would deliver the shared code to the wrong recipient. + """ + if caller_id == GLOBAL_OTP_ID: + if not getattr(assistant.config, "verify_totp_secret", ""): + raise HTTPException(status_code=404, detail="No global TOTP secret configured") + elif not is_safe_caller_id(caller_id): + raise HTTPException(status_code=400, detail="Invalid caller_id") + elif not assistant.verifier.has_own_totp_secret(caller_id): + raise HTTPException(status_code=404, detail="No TOTP secret for this caller") + result = assistant.verifier.current_otp(caller_id if caller_id != GLOBAL_OTP_ID else "") + if result is None: + raise HTTPException(status_code=404, detail="No TOTP secret for this caller") + code, remaining = result + return OtpResponse(caller_id=caller_id, otp=code, expires_in_s=remaining) + return app diff --git a/sip-agent/src/call_session.py b/sip-agent/src/call_session.py index be9c206..271ed66 100644 --- a/sip-agent/src/call_session.py +++ b/sip-agent/src/call_session.py @@ -112,6 +112,18 @@ class CallSession: # default demeanor. Set/cleared live by the PERSONA tool; never persisted # with the session — saved profiles live in the PersonaStore. persona: str = "" + # Identity verification for THIS call: flips to True once the caller passes + # a PIN/OTP check via the VERIFY tool. Read directly by tool-gating in + # tool_manager (which is why it's a first-class field, not tool_state). + # verify_attempts counts wrong entries this call, capped by config. + verified: bool = False + verify_attempts: int = 0 + # True while a tool is collecting a keypad code from the caller. The + # audio loop suppresses barge-in for the duration (in-band DTMF tones and + # "okay" would otherwise cancel the turn mid-entry) and the agentic engine + # pauses its wall-clock budget so the wait for digits is not charged to + # the LLM turn. + dtmf_collecting: bool = False # True once the post-call memory update has been dispatched (teardown and # the audio-loop tail can both reach the update site). memory_update_started: bool = False @@ -124,6 +136,9 @@ class CallSession: # single-use consumption (teardown and the audio-loop tail both reach it). virtual_number: Optional[Any] = None virtual_number_finalized: bool = False + # User utterances seen so far on a virtual-number call (drives the + # first_speech / speech trigger webhooks). + virtual_number_speech_count: int = 0 # This call's audio-pipeline state (VAD + utterance buffer + latency # metrics; a SessionAudioState from audio_pipeline.new_session_state()). # Typed loosely so this module stays dependency-light. diff --git a/sip-agent/src/config.py b/sip-agent/src/config.py index 19ca246..8be939c 100644 --- a/sip-agent/src/config.py +++ b/sip-agent/src/config.py @@ -404,6 +404,11 @@ def use_realtime_stt(self) -> bool: enable_weather_tool: bool = True enable_drink_tool: bool = field( default_factory=lambda: os.getenv("ENABLE_DRINK_TOOL", "true").lower() == "true") + # MAP tool: driving distance/time + directions via OpenStreetMap (Nominatim + # geocoding + OSRM routing, both keyless). Needs WEATHER_LATITUDE/LONGITUDE + # for the "from home" origin; self-disables without them. + enable_map_tool: bool = field( + default_factory=lambda: os.getenv("ENABLE_MAP_TOOL", "true").lower() == "true") enable_search_tool: bool = False enable_calendar_tool: bool = False max_timer_duration_hours: int = 24 @@ -416,6 +421,11 @@ def use_realtime_stt(self) -> bool: weather_latitude: str = field(default_factory=lambda: os.getenv("WEATHER_LATITUDE", "")) weather_longitude: str = field(default_factory=lambda: os.getenv("WEATHER_LONGITUDE", "")) + # Human-readable home/base address, injected into the system prompt so the + # agent knows where "here"/"home" is (e.g. for directions). Empty omits it. + # The MAP tool uses the WEATHER_LATITUDE/LONGITUDE coordinates as its origin. + agent_location: str = field(default_factory=lambda: os.getenv("AGENT_LOCATION", "")) + # SearxNG instance for the WEB_SEARCH tool (empty disables the tool). # The compose files ship an optional service: docker compose --profile search up -d searxng_url: str = field(default_factory=lambda: os.getenv("SEARXNG_URL", "")) @@ -454,7 +464,60 @@ def use_realtime_stt(self) -> bool: # TRANSFER tool (SIP REFER to another extension; same outbound dial policy). enable_transfer_tool: bool = field( default_factory=lambda: os.getenv("ENABLE_TRANSFER_TOOL", "true").lower() == "true") - + + # =================== + # Caller identity verification (optional) + # =================== + # Prove a caller is who they claim before sensitive actions, via a static PIN + # and/or a rolling TOTP code entered over DTMF (the VERIFY tool). Credentials + # resolve per-caller first (data/verify_credentials.json), then fall back to + # the global values below. Empty PIN + empty secret + no enrolled caller = + # feature off (nothing changes). Fail-open, except tool-gating (fails closed). + enable_verify_tool: bool = field( + default_factory=lambda: os.getenv("ENABLE_VERIFY_TOOL", "true").lower() == "true") + # Global fallback factors (shared across all callers). Empty = no global factor. + verify_pin: str = field(default_factory=lambda: os.getenv("VERIFY_PIN", "")) + verify_totp_secret: str = field(default_factory=lambda: os.getenv("VERIFY_TOTP_SECRET", "")) + # TOTP algorithm parameters (RFC 6238). Must match the caller's authenticator + # app / issuing system. Number of digits in a code, seconds per step, and the + # HMAC hash (SHA1|SHA256|SHA512). SHA1/6/30 are the near-universal defaults. + verify_totp_digits: int = field(default_factory=lambda: int(os.getenv("VERIFY_TOTP_DIGITS", "6"))) + verify_totp_period: int = field(default_factory=lambda: int(os.getenv("VERIFY_TOTP_PERIOD", "30"))) + verify_totp_algorithm: str = field( + default_factory=lambda: os.getenv("VERIFY_TOTP_ALGORITHM", "SHA1")) + # Accept TOTP codes within +/- this many steps (clock skew tolerance). + verify_totp_window: int = field(default_factory=lambda: int(os.getenv("VERIFY_TOTP_WINDOW", "1"))) + # Comma-separated tool names that require a verified caller before they run + # (e.g. "TRANSFER,CONTAINER_CTL"). Parsed to verify_required_tools_set below. + verify_required_tools: str = field( + default_factory=lambda: os.getenv("VERIFY_REQUIRED_TOOLS", "")) + # Wrong-code attempts allowed per call before VERIFY refuses further tries. + verify_max_attempts: int = field(default_factory=lambda: int(os.getenv("VERIFY_MAX_ATTEMPTS", "3"))) + # How long to wait for the caller to START keying in a code (seconds). + verify_dtmf_timeout_s: float = field( + default_factory=lambda: float(os.getenv("VERIFY_DTMF_TIMEOUT_S", "20.0"))) + # Once digits are being entered, submit after this gap with no new key + # (an inter-digit timeout, so the caller need not press '#'). Keeps a + # time-based one-time code from expiring while we wait out the full window. + verify_dtmf_interdigit_s: float = field( + default_factory=lambda: float(os.getenv("VERIFY_DTMF_INTERDIGIT_S", "3.0"))) + # Issuer label embedded in authenticator provisioning URIs (enrollment). + verify_issuer: str = field(default_factory=lambda: os.getenv("VERIFY_ISSUER", "General Disarray")) + # Spoken lines for the outbound "call and verify" flow (POST /verify/call). + # `or` (not getenv default) so a set-but-empty env var still uses the default + # rather than speaking nothing — an empty prompt is never wanted. + verify_call_prompt: str = field(default_factory=lambda: os.getenv("VERIFY_CALL_PROMPT") + or "Please enter your PIN or one-time code, then press pound.") + verify_call_retry_phrase: str = field(default_factory=lambda: os.getenv("VERIFY_CALL_RETRY_PHRASE") + or "That code wasn't right. Please try again.") + verify_call_success_phrase: str = field(default_factory=lambda: os.getenv("VERIFY_CALL_SUCCESS_PHRASE") + or "Thank you — your identity is verified. Goodbye.") + verify_call_fail_phrase: str = field(default_factory=lambda: os.getenv("VERIFY_CALL_FAIL_PHRASE") + or "I could not verify your identity. Goodbye.") + # Resolved in __post_init__ to /verify_credentials.json. + verify_credentials_file: Optional[Path] = field( + default_factory=lambda: Path(os.getenv("VERIFY_CREDENTIALS_FILE")) if os.getenv("VERIFY_CREDENTIALS_FILE") else None) + # =================== # REST API / Webhook security & limits # =================== @@ -636,6 +699,15 @@ def __post_init__(self): # Persona profiles file defaults relative to data_dir too. if self.persona_file is None: self.persona_file = self.data_dir / "personas.json" + + # Per-caller verification credentials file defaults relative to data_dir. + if self.verify_credentials_file is None: + self.verify_credentials_file = self.data_dir / "verify_credentials.json" + # Parse the gated-tool allowlist once into an uppercased set for O(1) + # lookups on the (hot) tool-execution path. Tool names are uppercase. + self.verify_required_tools_set = { + t.strip().upper() for t in (self.verify_required_tools or "").split(",") if t.strip() + } # Load phrases from JSON file if it exists phrases_file = self.data_dir / "phrases.json" diff --git a/sip-agent/src/dtmf_collect.py b/sip-agent/src/dtmf_collect.py new file mode 100644 index 0000000..f1abc8b --- /dev/null +++ b/sip-agent/src/dtmf_collect.py @@ -0,0 +1,102 @@ +""" +DTMF code collection +==================== +One keypad-entry loop shared by the two identity-verification paths — the +in-call VERIFY tool (plugins/verify_tool.py) and the outbound +``POST /verify/call`` flow (api.OutboundCallHandler) — so their semantics +can't drift. + +Behaviour: +- The prompt (``prompt_audio``) is queued non-blocking; the caller's FIRST + keypress mutes it (barge-in via the playlist player's clear()) and is kept as + the first digit, so nobody has to wait out the prompt. +- Digits accumulate; ``#`` submits early; ``*`` clears the entry so far AND + restarts the first-digit timer (a restart is a fresh attempt at entry, not + an abort); a length cap bounds the entry. +- Two timers: ``timeout`` bounds the wait for the FIRST digit; once digits are + being keyed an ``interdigit`` gap auto-submits, so a time-based code isn't + left to expire while we wait for ``#``. +- Returns the digit string, or None on timeout/hangup with nothing entered. + +The code is never spoken, so it never reaches STT. NEVER log it. +""" + +import asyncio +import logging +from typing import List, Optional + +logger = logging.getLogger(__name__) + +# Longest code accepted before auto-submitting (TOTP is 6, PINs are short); +# entry is normally ended by '#'. A safety cap, not a real limit. +MAX_CODE_LEN = 12 + + +def mute_playback(sip, call_info) -> None: + """Barge-in: flush the prompt currently playing into the call (best-effort). + + Uses the playlist player's clear() (transient flush, NOT stop_all which + latches the player stopped). No-ops for handlers/test doubles without one. + """ + try: + get_player = getattr(sip, "get_playlist_player", None) + player = get_player(call_info) if get_player else None + if player is not None: + player.clear() + except Exception as e: + logger.debug(f"dtmf barge-in mute failed: {e}") + + +async def collect_dtmf_code(sip, call_info, *, timeout: float, interdigit: float, + prompt_audio: Optional[bytes] = None, + max_len: int = MAX_CODE_LEN) -> Optional[str]: + """Play ``prompt_audio`` (if any) and collect a keypad code. See module doc.""" + get_dtmf = getattr(sip, "get_dtmf_digit", None) + clear_dtmf = getattr(sip, "clear_dtmf", None) + if not get_dtmf: + return None + # Drop any keys buffered before the prompt so stale digits don't + # pre-answer it; keys pressed once the prompt starts are kept below. + if clear_dtmf: + clear_dtmf(call_info) + if prompt_audio: + await sip.send_audio(call_info, prompt_audio) + + digits: List[str] = [] + muted = False + loop = asyncio.get_event_loop() + start = loop.time() + last_key = start + while True: + if not getattr(call_info, "is_active", False): + break + now = loop.time() + # Before the first digit: wait up to `timeout`. After: submit once + # the caller pauses for `interdigit` seconds. + if not digits and now - start >= timeout: + break + if digits and now - last_key >= interdigit: + break + digit = get_dtmf(call_info) + if digit is None: + await asyncio.sleep(0.05) + continue + # First keypress silences the still-playing prompt (barge-in). + if not muted: + mute_playback(sip, call_info) + muted = True + if digit == "#": + break + if digit == "*": + # Restart entry: clear digits and give the caller a fresh + # first-digit window rather than aborting on the old clock. + digits = [] + start = now + last_key = now + continue + if digit.isdigit(): + digits.append(digit) + last_key = now + if len(digits) >= max_len: + break + return "".join(digits) if digits else None diff --git a/sip-agent/src/identity_verification.py b/sip-agent/src/identity_verification.py new file mode 100644 index 0000000..f953b8c --- /dev/null +++ b/sip-agent/src/identity_verification.py @@ -0,0 +1,426 @@ +""" +Identity Verification +===================== +Optional caller-identity verification with two factors: + +- a **static PIN** (something the caller knows), and +- a **rolling TOTP code** (RFC 6238, from the caller's authenticator app). + +Credentials resolve **per-caller first, then a global fallback**: a caller +enrolled in data/verify_credentials.json uses their own PIN/secret; otherwise +the agent falls back to the global ``VERIFY_PIN`` / ``VERIFY_TOTP_SECRET`` from +config. Empty global factors and no enrolled caller means the feature is simply +off — verification can't be attempted and nothing changes. + +Two objects: +- ``VerificationStore`` — persistence of per-caller credentials, modelled on + ``PersonaStore`` (single JSON object, one lock, atomic write, fail-open). PINs + are stored only as a salted PBKDF2-SHA256 hash; TOTP secrets are stored as the + base32 shared secret (needed to recompute codes). +- ``IdentityVerifier`` — the pure check logic (constant-time PIN compare, TOTP + verify with a skew window), used by both the VERIFY tool and the REST API. + +Fail-open throughout (a broken store never breaks a call); the caller is simply +treated as unverified. Callers that gate on the result must fail *closed*. +""" + +import asyncio +import hashlib +import hmac +import json +import logging +import os +import re +import secrets +import threading +import time +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Dict, Optional, Tuple + +logger = logging.getLogger(__name__) + +try: + import pyotp +except Exception: # pragma: no cover - dependency guard (mirrors house style) + pyotp = None + +# Caller ids become keys in a shared JSON file — constrain them exactly like +# caller_memory does (no path separators, bounded length). +_SAFE_CALLER_ID = re.compile(r"[A-Za-z0-9._+-]{1,64}") + +# PBKDF2 work factor. Generous for a short numeric PIN; verification is off the +# hot path (once per call, or per REST request). +_PBKDF2_ITERATIONS = 200_000 +_PBKDF2_DIGEST = "sha256" + +# Supported TOTP hash algorithms (RFC 6238). Keyed by canonical upper-case name. +_TOTP_ALGORITHMS = { + "SHA1": hashlib.sha1, + "SHA256": hashlib.sha256, + "SHA512": hashlib.sha512, +} +_TOTP_DEFAULTS = {"digits": 6, "period": 30, "algorithm": "SHA1"} + + +def resolve_totp_digest(algorithm: Optional[str]): + """Map an algorithm name (SHA1/SHA256/SHA512, dashes/case-insensitive) to its + hashlib constructor, defaulting to SHA1 for anything unrecognised.""" + key = (algorithm or "SHA1").upper().replace("-", "") + return _TOTP_ALGORITHMS.get(key, hashlib.sha1) + + +def totp_params(config, digits: Optional[int] = None, period: Optional[int] = None, + algorithm: Optional[str] = None) -> Dict[str, Any]: + """Resolve the TOTP construction parameters: explicit override, else the + configured default, else the RFC baseline (6 digits / 30s / SHA1).""" + return { + "digits": int(digits or getattr(config, "verify_totp_digits", 0) + or _TOTP_DEFAULTS["digits"]), + "period": int(period or getattr(config, "verify_totp_period", 0) + or _TOTP_DEFAULTS["period"]), + "algorithm": str(algorithm or getattr(config, "verify_totp_algorithm", "") + or _TOTP_DEFAULTS["algorithm"]), + } + + +def build_totp(secret: str, params: Dict[str, Any]): + """Construct a ``pyotp.TOTP`` from a resolved params dict (see totp_params).""" + return pyotp.TOTP(secret, digits=int(params["digits"]), + digest=resolve_totp_digest(params["algorithm"]), + interval=int(params["period"])) + + +def is_safe_caller_id(caller_id: str) -> bool: + return bool(caller_id) and _SAFE_CALLER_ID.fullmatch(caller_id) is not None + + +def _hash_pin(pin: str, salt: bytes) -> str: + return hashlib.pbkdf2_hmac( + _PBKDF2_DIGEST, pin.encode("utf-8"), salt, _PBKDF2_ITERATIONS + ).hex() + + +def _const_eq(a: str, b: str) -> bool: + """Constant-time string equality that tolerates non-ASCII input. + + ``hmac.compare_digest`` raises TypeError for ``str`` operands containing + non-ASCII characters (e.g. a full-width digit pasted into a PIN field); + comparing the UTF-8 bytes keeps a bad candidate a plain mismatch. + """ + return hmac.compare_digest((a or "").encode("utf-8"), (b or "").encode("utf-8")) + + +class VerificationStore: + """Per-caller PIN/TOTP credentials in data/verify_credentials.json. + + Single JSON object ``{caller_id: {pin_hash, pin_salt, totp_secret, ...}}``. + Thread-safe and fail-open, mirroring PersonaStore. The raw PIN is never + stored — only its salted PBKDF2 hash. + """ + + def __init__(self, config): + self.config = config + self.path: Path = getattr(config, "verify_credentials_file", None) or ( + Path(getattr(config, "data_dir", Path("./data"))) / "verify_credentials.json") + self._lock = threading.Lock() + + # -- disk ------------------------------------------------------------ + def _load_raw(self) -> Dict[str, Dict[str, Any]]: + try: + with open(self.path, "r", encoding="utf-8") as f: + data = json.load(f) + if isinstance(data, dict): + return {k: v for k, v in data.items() if isinstance(v, dict)} + except FileNotFoundError: + pass + except Exception as e: + logger.warning(f"Could not read verify store {self.path}: {e}") + return {} + + def _write_raw(self, data: Dict[str, Dict[str, Any]]) -> bool: + try: + self.path.parent.mkdir(parents=True, exist_ok=True) + tmp = self.path.with_suffix(".tmp") + # Plaintext TOTP secrets live here: owner-only from the first byte + # (0600 at creation, so there is no world-readable window). + fd = os.open(str(tmp), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(fd, "w", encoding="utf-8") as f: + json.dump(data, f, indent=2) + try: + os.chmod(tmp, 0o600) # in case the tmp file pre-existed with wider bits + except OSError: + pass + tmp.replace(self.path) + return True + except Exception as e: + logger.warning(f"Could not write verify store {self.path}: {e}") + return False + + # -- API ------------------------------------------------------------- + def get(self, caller_id: str) -> Optional[Dict[str, Any]]: + if not is_safe_caller_id(caller_id): + return None + with self._lock: + return self._load_raw().get(caller_id) + + def set_credentials(self, caller_id: str, pin: Optional[str] = None, + totp_secret: Optional[str] = None, + generate_totp: bool = False) -> Optional[Dict[str, Any]]: + """Enroll/update a caller's PIN and/or TOTP secret. Returns the public + view of the stored record, or None on invalid input / write failure. + + A non-empty ``pin`` sets/rotates the PIN. ``generate_totp`` mints a fresh + base32 secret; otherwise a non-empty ``totp_secret`` is stored verbatim. + Existing factors are preserved when their argument is omitted. + """ + if not is_safe_caller_id(caller_id): + return None + with self._lock: + data = self._load_raw() + record = dict(data.get(caller_id) or {}) + + if pin: + salt = secrets.token_bytes(16) + record["pin_salt"] = salt.hex() + record["pin_hash"] = _hash_pin(pin, salt) + + if generate_totp: + if pyotp is None: + logger.warning("generate_totp requested but pyotp is unavailable") + return None + record["totp_secret"] = pyotp.random_base32() + elif totp_secret: + cleaned = totp_secret.strip().replace(" ", "").upper() + if not re.fullmatch(r"[A-Z2-7]+=*", cleaned): + logger.warning("Rejected non-base32 TOTP secret for %s", caller_id) + return None + record["totp_secret"] = cleaned + + if not record.get("pin_hash") and not record.get("totp_secret"): + # Nothing to store — don't create an empty enrollment. + return None + + record["updated_at"] = datetime.now(timezone.utc).isoformat() + data[caller_id] = record + if not self._write_raw(data): + return None + return self.public_view(caller_id, record) + + def delete(self, caller_id: str) -> bool: + if not is_safe_caller_id(caller_id): + return False + with self._lock: + data = self._load_raw() + if caller_id not in data: + return False + del data[caller_id] + return self._write_raw(data) + + def public_view(self, caller_id: str, + record: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + """Metadata safe to return over the API — never the secret or PIN hash.""" + if record is None: + record = self.get(caller_id) or {} + return { + "caller_id": caller_id, + "has_pin": bool(record.get("pin_hash")), + "has_totp": bool(record.get("totp_secret")), + "updated_at": record.get("updated_at"), + } + + def provisioning_uri(self, caller_id: str, issuer: str) -> Optional[str]: + """otpauth:// URI for the caller's per-caller secret (for QR/enrollment), + or None when there's no per-caller secret or pyotp is missing.""" + if pyotp is None: + return None + record = self.get(caller_id) + secret = (record or {}).get("totp_secret") + if not secret: + return None + try: + totp = build_totp(secret, totp_params(self.config)) + return totp.provisioning_uri(name=caller_id, issuer_name=issuer) + except Exception as e: + logger.warning(f"provisioning_uri failed for {caller_id}: {e}") + return None + + +class IdentityVerifier: + """Pure verification logic over the store + global config fallback. + + Per-caller credentials take precedence: if a caller has enrolled a PIN, only + that PIN is accepted (no silent fall-through to the global PIN); the global + factors apply only to callers with no per-caller factor of that kind. + """ + + def __init__(self, config, store: VerificationStore): + self.config = config + self.store = store + + # -- resolution helpers --------------------------------------------- + def _resolve_totp_secret(self, caller_id: str) -> Optional[str]: + record = self.store.get(caller_id) + secret = (record or {}).get("totp_secret") + if secret: + return secret + return getattr(self.config, "verify_totp_secret", "") or None + + def is_configured(self) -> bool: + """True when a *global* factor is set (applies to every caller).""" + return bool(getattr(self.config, "verify_pin", "") + or getattr(self.config, "verify_totp_secret", "")) + + def has_any_credentials(self, caller_id: str) -> bool: + record = self.store.get(caller_id) or {} + return bool(record.get("pin_hash") or record.get("totp_secret")) + + def can_verify(self, caller_id: str) -> bool: + """Whether verification is even possible for this caller.""" + return self.is_configured() or self.has_any_credentials(caller_id) + + # -- checks ---------------------------------------------------------- + def verify_pin(self, caller_id: str, candidate: str) -> bool: + candidate = (candidate or "").strip() + if not candidate: + return False + record = self.store.get(caller_id) or {} + pin_hash = record.get("pin_hash") + salt = record.get("pin_salt") + if pin_hash and salt: + try: + computed = _hash_pin(candidate, bytes.fromhex(salt)) + except Exception: + return False + return _const_eq(computed, pin_hash) + # No per-caller PIN — fall back to the global PIN if configured. + global_pin = getattr(self.config, "verify_pin", "") or "" + if not global_pin: + return False + return _const_eq(candidate, global_pin) + + def verify_totp(self, caller_id: str, candidate: str) -> bool: + candidate = (candidate or "").strip() + if not candidate or pyotp is None: + return False + secret = self._resolve_totp_secret(caller_id) + if not secret: + return False + try: + window = int(getattr(self.config, "verify_totp_window", 1)) + totp = build_totp(secret, totp_params(self.config)) + return bool(totp.verify(candidate, valid_window=window)) + except Exception as e: + logger.warning(f"verify_totp failed for {caller_id}: {e}") + return False + + def verify(self, caller_id: str, candidate: str, + method: str = "auto") -> Tuple[bool, Optional[str]]: + """Check a single entered code. Returns (ok, method_used). + + ``method``: "pin" or "otp" forces one factor; "auto" (default) accepts + either — it tries OTP first when a TOTP secret is available, then the PIN. + """ + method = (method or "auto").lower() + if method == "pin": + ok = self.verify_pin(caller_id, candidate) + return ok, ("pin" if ok else None) + if method == "otp": + ok = self.verify_totp(caller_id, candidate) + return ok, ("otp" if ok else None) + # auto: prefer OTP when a secret exists, then PIN. + if self._resolve_totp_secret(caller_id) and self.verify_totp(caller_id, candidate): + return True, "otp" + if self.verify_pin(caller_id, candidate): + return True, "pin" + return False, None + + def verify_explicit(self, candidate: str, pin: Optional[str] = None, + totp_secret: Optional[str] = None, + method: str = "auto", + totp_digits: Optional[int] = None, + totp_period: Optional[int] = None, + totp_algorithm: Optional[str] = None, + totp_window: Optional[int] = None) -> Tuple[bool, Optional[str]]: + """Check a candidate code against explicitly-supplied factors. + + No store or global-config lookup: the PIN/secret come from the caller of + this method (e.g. an n8n workflow that holds them itself). Same method + semantics as ``verify`` — "pin"/"otp" force a factor, "auto" tries OTP + first (when a secret is given) then the PIN. The TOTP digits/period/ + algorithm/window default to the configured values when not overridden. + """ + candidate = (candidate or "").strip() + if not candidate: + return False, None + + def _pin_ok() -> bool: + return bool(pin) and _const_eq(candidate, pin) + + def _otp_ok() -> bool: + if not totp_secret or pyotp is None: + return False + try: + window = (totp_window if totp_window is not None + else int(getattr(self.config, "verify_totp_window", 1))) + params = totp_params(self.config, digits=totp_digits, + period=totp_period, algorithm=totp_algorithm) + return bool(build_totp(totp_secret, params).verify( + candidate, valid_window=window)) + except Exception as e: + logger.warning(f"verify_explicit TOTP check failed: {e}") + return False + + method = (method or "auto").lower() + if method == "pin": + return (True, "pin") if _pin_ok() else (False, None) + if method == "otp": + return (True, "otp") if _otp_ok() else (False, None) + if totp_secret and _otp_ok(): + return True, "otp" + if _pin_ok(): + return True, "pin" + return False, None + + # -- async facades --------------------------------------------------- + # PBKDF2 at 200k iterations takes tens of milliseconds; the checks run on + # the same event loop that drives RTP/VAD/TTS for the live call, so the + # call-side paths use these thread-offloaded variants. + async def averify(self, caller_id: str, candidate: str, + method: str = "auto") -> Tuple[bool, Optional[str]]: + return await asyncio.to_thread(self.verify, caller_id, candidate, method) + + async def averify_pin(self, caller_id: str, candidate: str) -> bool: + return await asyncio.to_thread(self.verify_pin, caller_id, candidate) + + async def averify_totp(self, caller_id: str, candidate: str) -> bool: + return await asyncio.to_thread(self.verify_totp, caller_id, candidate) + + async def averify_explicit(self, candidate: str, **kwargs) -> Tuple[bool, Optional[str]]: + return await asyncio.to_thread(lambda: self.verify_explicit(candidate, **kwargs)) + + def has_own_totp_secret(self, caller_id: str) -> bool: + """True only when the caller has a per-caller (enrolled) TOTP secret.""" + record = self.store.get(caller_id) or {} + return bool(record.get("totp_secret")) + + def current_otp(self, caller_id: str) -> Optional[Tuple[str, int]]: + """(current_code, seconds_until_expiry) for the caller's secret, or None.""" + if pyotp is None: + return None + secret = self._resolve_totp_secret(caller_id) + if not secret: + return None + try: + params = totp_params(self.config) + totp = build_totp(secret, params) + # Code and remaining seconds come from ONE timestamp so they can't + # straddle a step boundary and describe different codes. + now = int(time.time()) + code = totp.at(now) + period = int(params["period"]) + remaining = period - now % period + return code, remaining + except Exception as e: + logger.warning(f"current_otp failed for {caller_id}: {e}") + return None diff --git a/sip-agent/src/langchain_engine.py b/sip-agent/src/langchain_engine.py index 1e15119..a0e6c51 100644 --- a/sip-agent/src/langchain_engine.py +++ b/sip-agent/src/langchain_engine.py @@ -76,6 +76,44 @@ def __init__(self, config: Config, tool_manager): # cached so a vLLM that rejects it costs a single failed request. self._tool_choice_supported = True + def _clock_paused(self) -> bool: + """True while a tool is waiting on the caller (keypad entry): that wait + is the caller's time, not the LLM's, so it isn't charged to the budget.""" + try: + session = getattr(self.tool_manager.assistant, "session", None) + return bool(getattr(session, "dtmf_collecting", False)) + except Exception: + return False + + async def _invoke_with_budget(self, coro, timeout: float): + """``asyncio.wait_for`` whose clock pauses while ``_clock_paused()``. + + Otherwise the VERIFY tool's DTMF wait (prompt + up to + VERIFY_DTMF_TIMEOUT_S) alone could exhaust LLM_AGENT_TIMEOUT_S and a + correct code would still end in the spoken error phrase. + """ + task = asyncio.ensure_future(coro) + loop = asyncio.get_event_loop() + remaining = float(timeout) + try: + while True: + tick = loop.time() + done, _ = await asyncio.wait({task}, timeout=min(remaining, 0.25)) + if done: + return task.result() + if not self._clock_paused(): + remaining -= loop.time() - tick + if remaining <= 0: + task.cancel() + try: + await task + except (asyncio.CancelledError, Exception): + pass + raise asyncio.TimeoutError() + except asyncio.CancelledError: + task.cancel() + raise + async def start(self): # Keeps self.client (AsyncOpenAI) alive for the shared utility paths # (reformat_for_speech, summarize_text) and connectivity logging. @@ -229,12 +267,12 @@ async def _agent_generate( }) as span: start_time = time.time() try: - result = await asyncio.wait_for( + result = await self._invoke_with_budget( self._agent.ainvoke( {"messages": messages}, config={"recursion_limit": recursion_limit}, ), - timeout=self.config.llm_agent_timeout_s, + self.config.llm_agent_timeout_s, ) except asyncio.TimeoutError: logger.warning( diff --git a/sip-agent/src/llm_engine.py b/sip-agent/src/llm_engine.py index c289226..32c04f1 100644 --- a/sip-agent/src/llm_engine.py +++ b/sip-agent/src/llm_engine.py @@ -813,7 +813,15 @@ def _build_system_prompt(self, call_context: Optional[Dict[str, Any]] = None) -> except Exception: now = datetime.now() prompt += f"\n\nCurrent time: {now.strftime('%I:%M %p %Z on %A, %B %d, %Y')}" - + + # Static home/base address so "here", "home", and directions questions + # have a fixed reference point. The MAP tool routes from this location. + location = getattr(self.config, "agent_location", "") or "" + if location.strip(): + prompt += ( + f"\n\nYour location (where \"here\" and \"home\" are): " + f"{location.strip()}") + # Caller-chosen demeanor for this call, layered over the base prompt. # Placed right after the base persona and before the call facts so it # colors the whole reply, but it can only shape TONE — the base prompt's @@ -859,6 +867,21 @@ def _build_system_prompt(self, call_context: Optional[Dict[str, Any]] = None) -> " specific purpose. Handle the call with that purpose in" " mind:\n" + virtual_purpose) + # Identity verification: some actions require a verified caller. Tell + # the model where this caller stands so it routes through the VERIFY + # tool before a gated action rather than refusing or guessing. + if call_context.get("verification_required"): + if call_context.get("verified"): + prompt += ( + "\n\nThe caller has verified their identity on this call;" + " you may proceed with sensitive actions.") + else: + prompt += ( + "\n\nThe caller has NOT verified their identity. Before any" + " sensitive or restricted action, verify them using the" + " VERIFY tool (they enter a PIN or one-time code on the" + " keypad — do not ask them to say it aloud).") + # Add dynamic tools section from ToolManager. In native mode the tool # schemas travel in the request's `tools` param instead — including the diff --git a/sip-agent/src/main.py b/sip-agent/src/main.py index 40a84fb..ace9a6b 100644 --- a/sip-agent/src/main.py +++ b/sip-agent/src/main.py @@ -17,7 +17,7 @@ import asyncio import logging import ipaddress -from typing import Dict, List, Optional, Tuple +from typing import Any, Dict, List, Optional, Tuple import call_events import context_manager @@ -27,6 +27,7 @@ set_current_session, get_current_session) from caller_memory import CallerMemoryStore, caller_id_from_uri from persona_store import PersonaStore +from identity_verification import IdentityVerifier, VerificationStore from virtual_numbers import VirtualNumberRegistry, extension_from_uri from earcons import generate_chime, generate_thinking_tick from knowledge_base import KnowledgeBase @@ -170,6 +171,12 @@ def __init__(self, config: Config): # save/load. The active persona lives on the CallSession; this is just # persistence. Fail-open. self.persona_store = PersonaStore(config) + # Optional caller identity verification (static PIN + rolling TOTP over + # DTMF). The store persists per-caller credentials; the verifier holds + # the check logic (with a global config fallback). Shared by the VERIFY + # tool and the /verify REST endpoints. Fail-open. + self.verify_store = VerificationStore(config) + self.verifier = IdentityVerifier(config, self.verify_store) # MCP client (external tool servers). Connected inside # ToolManager.start() so MCP tools register through the same # wrapper path as plugins. No-op unless MCP_ENABLED. @@ -213,6 +220,11 @@ def __init__(self, config: Config): # Combined list for pre-caching self._phrases_to_cache = self.config.phrases.get_all_phrases_for_cache() + # The VERIFY tool's keypad prompt is a fixed phrase too: pre-cache it + # so it plays instantly instead of being synthesized mid-turn. + if getattr(self.config, "enable_verify_tool", False): + self._phrases_to_cache = list(dict.fromkeys( + self._phrases_to_cache + [self.config.verify_call_prompt])) # Confirmation earcon (in-memory PCM), generated once; played via the # same send_audio path as TTS so barge-in/flush semantics are identical. @@ -422,19 +434,47 @@ def _finalize_virtual_number(self, session: CallSession) -> None: log_event(logger, logging.INFO, f"Virtual number completed: {entry.number}", event="virtual_number_completed", number=entry.number, - virtual_number_id=entry.id, consumed=consumed is not None) + virtual_number_id=entry.id, consumed=consumed is not None, + persistent=entry.persistent) - extra = { - "caller": getattr(session.call_info, "remote_uri", "") or "", - "call_id": session.transcript_id, - "duration_seconds": round(time.time() - session.start_time, 1), - } + if not entry.wants("completed"): + return + extra = self._virtual_number_call_fields(session) + extra["duration_seconds"] = round(time.time() - session.start_time, 1) if entry.include_transcript: transcript = self.transcripts.get(session.transcript_id) if transcript is not None: extra["transcript"] = transcript self.virtual_numbers.fire_webhook(entry, status="completed", extra=extra) + @staticmethod + def _virtual_number_call_fields(session: CallSession) -> Dict[str, Any]: + """Per-call fields shared by every virtual-number webhook.""" + return { + "caller": getattr(session.call_info, "remote_uri", "") or "", + "call_id": session.transcript_id, + } + + def _emit_virtual_number_speech(self, session: CallSession, text: str) -> None: + """Trigger-number speech hooks: `first_speech` fires once per call, + `speech` on every utterance. Fire-and-forget; never on the speaking + path's critical section.""" + entry = session.virtual_number + if entry is None: + return + session.virtual_number_speech_count += 1 + first = session.virtual_number_speech_count == 1 + if not ((first and entry.wants("first_speech")) or entry.wants("speech")): + return + extra = self._virtual_number_call_fields(session) + extra.update({ + "text": text, + "utterance_index": session.virtual_number_speech_count, + "first": first, + }) + self.virtual_numbers.fire_webhook( + entry, status="first_speech" if first else "speech", extra=extra) + async def start(self): """Start all components and run main loop.""" await self.start_components() @@ -680,6 +720,12 @@ async def _on_call_received(self, call_info): session = self._begin_session(call_info, "inbound", remote_uri, virtual_number=virtual_number) + if virtual_number and virtual_number.wants("answered"): + # Trigger-number hook: kick the workflow off the moment + # the call is matched, before the greeting plays. + self.virtual_numbers.fire_webhook( + virtual_number, status="answered", + extra=self._virtual_number_call_fields(session)) # Everything below acts on behalf of the new call: bind it so # greeting playback (and anything else reaching self.session / @@ -823,7 +869,13 @@ async def _audio_processing_loop(self, session: CallSession): speech = self.audio_pipeline.has_speech( session.audio_state, audio_chunk) - if self._playback_active(session): + if session.dtmf_collecting: + # A tool is collecting a keypad code: the caller's + # own DTMF tones / an "okay" must not cancel the + # turn. (Digits mute the prompt themselves.) + barge_in_run.reset() + cancel_merge_run.reset() + elif self._playback_active(session): cancel_merge_run.reset() if barge_in_run.update(chunk_ms, speech): barge_in_run.reset() @@ -1069,6 +1121,7 @@ async def _handle_transcription(self, session: CallSession, text: str): }) self.transcripts.add_turn(session.transcript_id, "user", text) self._publish_admin_event("user_turn", {"text": text}, session) + self._emit_virtual_number_speech(session, text) # Deterministic farewell: when the whole utterance is just a goodbye, # don't gamble on the model invoking HANGUP — answer with a goodbye @@ -1293,6 +1346,12 @@ async def _build_llm_turn_inputs( call_context["caller_memory"] = session.caller_memory_prompt if session.virtual_number and session.virtual_number.purpose: call_context["virtual_number_context"] = session.virtual_number.purpose + # Identity verification state: tell the model whether the caller has + # verified, and whether any tool is gated behind verification (so it + # knows to route through the VERIFY tool before a sensitive action). + if getattr(self.config, "verify_required_tools_set", None): + call_context["verification_required"] = True + call_context["verified"] = bool(session.verified) if rolling_summary: call_context["conversation_summary"] = rolling_summary if self.config.knowledge_auto_inject: diff --git a/sip-agent/src/plugins/map_tool.py b/sip-agent/src/plugins/map_tool.py new file mode 100644 index 0000000..97e9ad4 --- /dev/null +++ b/sip-agent/src/plugins/map_tool.py @@ -0,0 +1,222 @@ +""" +Map / Directions Tool Plugin +============================ +Driving distance and travel time to a place, spoken for the phone. Geocodes +place names/addresses with OpenStreetMap Nominatim and routes with the public +OSRM server - both keyless, matching the no-API-key pattern of the other +information tools. + +Origin defaults to the agent's home (WEATHER_LATITUDE / WEATHER_LONGITUDE, the +same coordinates the weather tools use); the caller can also ask for the route +between two named places. + +Usage in conversation: +User: "How far is Balboa Park?" +LLM: [TOOL:MAP:destination=Balboa Park, San Diego] + +User: "How long to drive from the airport to downtown?" +LLM: [TOOL:MAP:origin=San Diego airport,destination=downtown San Diego] +""" + +import logging +from typing import Any, Dict, Optional, Tuple + +from plugins.helpers import fetch_json, home_coordinates + +from tool_plugins import BaseTool, ToolResult, ToolStatus +from logging_utils import log_event + +logger = logging.getLogger(__name__) + +# Nominatim's usage policy requires a descriptive, identifying User-Agent; +# requests with a generic client string get blocked. +_HEADERS = {"User-Agent": "general-disarray (self-hosted voice assistant)"} + +_NOMINATIM_URL = "https://nominatim.openstreetmap.org/search" +# Public OSRM demo server: driving profile, coordinates as lon,lat pairs. +_OSRM_URL = "https://router.project-osrm.org/route/v1/driving" + +_METERS_PER_MILE = 1609.344 + +_UNAVAILABLE = "I can't look up directions right now." + + +def _spoken_miles(meters: float) -> str: + """Distance in miles, spoken: one decimal under ten, whole miles above.""" + miles = meters / _METERS_PER_MILE + if miles < 0.1: + return "less than a tenth of a mile" + if miles < 10: + value = round(miles, 1) + # Drop a trailing ".0" so TTS says "four miles", not "four point zero". + text = str(int(value)) if value == int(value) else str(value) + unit = "mile" if value == 1 else "miles" + return f"{text} {unit}" + value = round(miles) + return f"{value} miles" + + +def _spoken_minutes(seconds: float) -> str: + """Travel time, spoken: minutes, rolling into hours past sixty.""" + minutes = max(1, round(seconds / 60)) + if minutes < 60: + unit = "minute" if minutes == 1 else "minutes" + return f"about {minutes} {unit}" + hours, mins = divmod(minutes, 60) + hour_unit = "hour" if hours == 1 else "hours" + if mins == 0: + return f"about {hours} {hour_unit}" + min_unit = "minute" if mins == 1 else "minutes" + return f"about {hours} {hour_unit} and {mins} {min_unit}" + + +def _short_place(display_name: str, fallback: str) -> str: + """A short, speakable label from a Nominatim display_name. + + Nominatim returns the full civic hierarchy ("Balboa Park, 6th Avenue, San + Diego, San Diego County, California, 92101, United States"); the leading + one or two segments name the place without the postal boilerplate. Bare + numeric segments (house/postal numbers, e.g. "San Diego Zoo, 2920, ...") + are skipped so the spoken label reads as a name, not a street address. + """ + parts = [p.strip() for p in (display_name or "").split(",") + if p.strip() and not p.strip().isdigit()] + if not parts: + return fallback + return ", ".join(parts[:2]) + + +class MapTool(BaseTool): + """Driving distance and time to a place, via OpenStreetMap.""" + + name = "MAP" + description = ("Get driving distance and travel time to a place or address " + "(from home by default, or between two places)") + enabled = True + speak_result = True # informational: message is spoken in marker mode + + parameters = { + "destination": { + "type": "string", + "description": "Where to go - a place name or address", + "required": True, + }, + "origin": { + "type": "string", + "description": ("Starting point - a place name or address. " + "Omit to start from home."), + "required": False, + }, + } + + def __init__(self, assistant): + super().__init__(assistant) + # The origin defaults to the home coordinates; without them the tool + # can only run when the caller names both endpoints, which is rare on a + # phone call - self-disable to keep it off the tool list. + if self.config and home_coordinates(self.config) is None: + self.enabled = False + logger.info("MAP tool disabled - WEATHER_LATITUDE/LONGITUDE not set") + + async def _geocode(self, place: str) -> Optional[Tuple[float, float, str]]: + """Resolve a place string to (lat, lon, short label), or None.""" + try: + results = await fetch_json( + _NOMINATIM_URL, + params={"q": place, "format": "json", "limit": 1}, + headers=_HEADERS, + ) + except Exception as e: + logger.error(f"Geocode error for '{place}': {e}") + return None + if not results: + return None + top = results[0] + try: + lat = float(top["lat"]) + lon = float(top["lon"]) + except (KeyError, TypeError, ValueError): + return None + return lat, lon, _short_place(str(top.get("display_name") or ""), place) + + async def _route(self, origin: Tuple[float, float], + dest: Tuple[float, float]) -> Optional[Tuple[float, float]]: + """Driving (distance_m, duration_s) for origin->dest, or None.""" + # OSRM wants lon,lat order. + coords = (f"{origin[1]},{origin[0]};{dest[1]},{dest[0]}") + try: + data = await fetch_json( + f"{_OSRM_URL}/{coords}", + params={"overview": "false"}, + ) + except Exception as e: + logger.error(f"Routing error: {e}") + return None + if (data or {}).get("code") != "Ok": + return None + routes = data.get("routes") or [] + if not routes: + return None + route = routes[0] + try: + return float(route["distance"]), float(route["duration"]) + except (KeyError, TypeError, ValueError): + return None + + async def execute(self, params: Dict[str, Any]) -> ToolResult: + destination = str(params.get("destination") or "").strip() + if not destination: + return ToolResult(status=ToolStatus.FAILED, + message="Where would you like directions to?") + + origin_text = str(params.get("origin") or "").strip() + + # Resolve the origin: a named place is geocoded; otherwise home. + if origin_text: + origin = await self._geocode(origin_text) + if origin is None: + return ToolResult( + status=ToolStatus.SUCCESS, + message=f"I couldn't find a place called {origin_text}.") + origin_coords = (origin[0], origin[1]) + origin_label = origin[2] + else: + home = home_coordinates(self.config) if self.config else None + if home is None: + return ToolResult(status=ToolStatus.FAILED, message=_UNAVAILABLE) + origin_coords = home + origin_label = "home" + + dest = await self._geocode(destination) + if dest is None: + return ToolResult( + status=ToolStatus.SUCCESS, + message=f"I couldn't find a place called {destination}.") + dest_coords = (dest[0], dest[1]) + dest_label = dest[2] + + routed = await self._route(origin_coords, dest_coords) + if routed is None: + return ToolResult(status=ToolStatus.FAILED, message=_UNAVAILABLE) + + distance_m, duration_s = routed + miles = _spoken_miles(distance_m) + minutes = _spoken_minutes(duration_s) + + message = (f"{dest_label} is {miles} from {origin_label}, " + f"{minutes} by car.") + + log_event(logging.getLogger(__name__), logging.INFO, + f"Map route {origin_label} -> {dest_label}: {message}", + event="map_route", origin=origin_label, destination=dest_label) + + return ToolResult( + status=ToolStatus.SUCCESS, + message=message, + data={ + "origin": origin_label, + "destination": dest_label, + "distance_miles": round(distance_m / _METERS_PER_MILE, 1), + "duration_minutes": round(duration_s / 60), + }, + ) diff --git a/sip-agent/src/plugins/verify_tool.py b/sip-agent/src/plugins/verify_tool.py new file mode 100644 index 0000000..120ffc7 --- /dev/null +++ b/sip-agent/src/plugins/verify_tool.py @@ -0,0 +1,162 @@ +""" +Verify Tool Plugin +================== +Prove the caller's identity mid-call with a static PIN and/or a rolling TOTP +code, entered over the phone keypad (DTMF) — never spoken, so the code never +lands in the STT transcript. + +The LLM invokes this when a caller needs to authenticate before a sensitive +action (see config.verify_required_tools, enforced in tool_manager). On success +the per-call flag session.verified flips to True, which is injected into the +system prompt and read by tool-gating. + + User: "I need to transfer money." (a gated action) + LLM: [TOOL:VERIFY] + -> tool prompts "Please enter your code, then press pound", collects DTMF, + checks it against the caller's PIN/TOTP, and reports success/failure. + +Credential resolution (per-caller then global) and the actual checks live in +identity_verification.IdentityVerifier; this tool only drives the call-side +interaction. Fail-open on infrastructure errors, but a failed check leaves the +caller unverified (fail closed on the security decision). +""" + +import logging +from typing import Any, Dict, Optional + +from tool_plugins import BaseTool, ToolResult, ToolStatus +from caller_memory import caller_id_from_uri +from dtmf_collect import collect_dtmf_code +from logging_utils import log_event + +logger = logging.getLogger(__name__) + + +class VerifyTool(BaseTool): + """Collect a PIN/OTP over DTMF and verify the caller's identity.""" + + name = "VERIFY" + description = ( + "Verify the caller's identity when they need to authenticate before a " + "sensitive action. Prompts them to key in their PIN or one-time code on " + "the phone keypad and checks it. Call this with no arguments to accept " + "either factor; the digits are entered by keypad, so do not ask the " + "caller to say their code aloud.") + enabled = True + speak_result = True # the success/failure result is spoken to the caller + + parameters = { + "method": { + "type": "string", + "description": ("Which factor to require: 'pin', 'otp', or 'auto' " + "(default — accepts either)."), + "enum": ["pin", "otp", "auto"], + "required": False, + "default": "auto", + }, + } + + def __init__(self, assistant): + super().__init__(assistant) + # Self-gate: honored by tool_manager alongside the explicit config check. + if self.config and not getattr(self.config, "enable_verify_tool", True): + self.enabled = False + + def _session(self): + return getattr(self.assistant, "session", None) if self.assistant else None + + def _verifier(self): + return getattr(self.assistant, "verifier", None) if self.assistant else None + + async def execute(self, params: Dict[str, Any]) -> ToolResult: + session = self._session() + call_info = getattr(session, "call_info", None) if session else None + if session is None or call_info is None: + return ToolResult(status=ToolStatus.FAILED, + message="I can only verify your identity during a call.") + + verifier = self._verifier() + if verifier is None: + return ToolResult(status=ToolStatus.FAILED, + message="Identity verification isn't available right now.") + + if session.verified: + return ToolResult(status=ToolStatus.SUCCESS, + message="You're already verified — go ahead.") + + caller_id = caller_id_from_uri(getattr(call_info, "remote_uri", "") or "") + if not caller_id or not verifier.can_verify(caller_id): + # No PIN/secret for this caller and no global factor: the feature is + # effectively off. Say so benignly rather than implying a failure. + return ToolResult( + status=ToolStatus.FAILED, + message="I don't have identity verification set up for this number.") + + max_attempts = int(getattr(self.config, "verify_max_attempts", 3)) + if session.verify_attempts >= max_attempts: + return ToolResult( + status=ToolStatus.FAILED, + message="That's too many attempts. I can't verify you on this call.") + + method = str(params.get("method") or "auto").lower() + if method not in ("pin", "otp", "auto"): + method = "auto" + + code = await self._collect_digits(session, call_info) + if not code: + # A timeout / empty entry isn't a wrong code — don't burn an attempt. + return ToolResult( + status=ToolStatus.FAILED, + message="I didn't get a code. Let me know when you'd like to try again.") + + ok, used = await verifier.averify(caller_id, code, method=method) + # NEVER log or return the entered code — keep it out of transcripts/logs. + if ok: + session.verified = True + log_event(logger, logging.INFO, "Caller verified", + event="verify", outcome="ok", caller=caller_id, method=used) + return ToolResult(status=ToolStatus.SUCCESS, + message="Thank you — your identity is verified.", + data={"verified": True, "method": used}) + + session.verify_attempts += 1 + remaining = max(0, max_attempts - session.verify_attempts) + log_event(logger, logging.INFO, "Caller verification failed", + event="verify", outcome="failed", caller=caller_id, + attempts=session.verify_attempts) + if remaining: + return ToolResult( + status=ToolStatus.FAILED, + message="That code wasn't right. You can try again when you're ready.", + data={"verified": False}) + return ToolResult( + status=ToolStatus.FAILED, + message="That code wasn't right, and that was the last attempt.", + data={"verified": False}) + + async def _collect_digits(self, session, call_info) -> Optional[str]: + """Prompt and collect a keypad code (shared loop in dtmf_collect). + + Uses the configured VERIFY_CALL_PROMPT (pre-cached at startup, so the + prompt plays instantly and isn't re-synthesized per attempt). While + collecting, ``session.dtmf_collecting`` suppresses barge-in in the audio + loop and pauses the agentic engine's wall clock, so the caller's own + keypad tones (or an "okay") can't cancel the turn mid-entry. + """ + sip = self.assistant.sip_handler + prompt_audio = None + try: + prompt_audio = await self.assistant.audio_pipeline.synthesize( + self.config.verify_call_prompt) + except Exception as e: + logger.debug(f"verify prompt synthesis failed: {e}") + + timeout = float(getattr(self.config, "verify_dtmf_timeout_s", 20.0)) + interdigit = float(getattr(self.config, "verify_dtmf_interdigit_s", 3.0)) + session.dtmf_collecting = True + try: + return await collect_dtmf_code( + sip, call_info, timeout=timeout, interdigit=interdigit, + prompt_audio=prompt_audio) + finally: + session.dtmf_collecting = False diff --git a/sip-agent/src/tool_manager.py b/sip-agent/src/tool_manager.py index 2ac91f0..b59022d 100644 --- a/sip-agent/src/tool_manager.py +++ b/sip-agent/src/tool_manager.py @@ -160,6 +160,8 @@ def _load_tools(self): from plugins.container_tool import ContainerControlTool from plugins.transfer_tool import TransferTool from plugins.drink_tool import DrinkRecipeTool + from plugins.map_tool import MapTool + from plugins.verify_tool import VerifyTool # All available tool classes tool_classes = [ @@ -185,6 +187,7 @@ def _load_tools(self): NWSForecastTool, KpIndexTool, EarthquakeTool, + MapTool, # Memory + automation RememberTool, ForgetTool, @@ -196,6 +199,8 @@ def _load_tools(self): ContainerControlTool, # Telephony TransferTool, + # Identity verification + VerifyTool, ] for tool_class in tool_classes: @@ -274,6 +279,10 @@ def _should_enable_tool(self, name: str, wrapper) -> bool: return False if name == "DRINK_RECIPE" and not self.config.enable_drink_tool: return False + if name == "MAP" and not self.config.enable_map_tool: + return False + if name == "VERIFY" and not self.config.enable_verify_tool: + return False # WEB_SEARCH, FORECAST and CONTAINER_CTL self-disable in __init__ when # their required config (SearxNG URL / coordinates / allowlist+socket) # is missing; the generic `enabled` check below catches them. @@ -654,6 +663,24 @@ async def execute_tool(self, tool_call) -> ToolResult: logger.debug(f"Admin tool_call event publish failed: {e}") return result + def verification_block(self, tool_name: str, session) -> Optional[ToolResult]: + """The VERIFY_REQUIRED_TOOLS gate, shared by LLM-driven and REST execution. + + Returns a FAILED ToolResult when ``tool_name`` is gated and ``session`` + is not a verified call session (fail closed, including no session at + all); None when the tool may run. VERIFY itself is never gated. + """ + tool_name = (tool_name or "").upper() + gated_tools = getattr(self.config, "verify_required_tools_set", None) or set() + if tool_name not in gated_tools or tool_name == "VERIFY": + return None + if getattr(session, "verified", False): + return None + return ToolResult( + status=ToolStatus.FAILED, + message=("You'll need to verify your identity first — " + "say 'verify me' to begin.")) + async def _execute_tool_inner(self, tool_call) -> ToolResult: tool_name = tool_call.name.upper() start_time = time.time() @@ -683,7 +710,20 @@ async def _execute_tool_inner(self, tool_call) -> ToolResult: if error: Metrics.record_tool_error(tool_name, "validation_error") return ToolResult(status=ToolStatus.FAILED, message=error) - + + # --- IDENTITY VERIFICATION GATE --- + # Tools named in config.verify_required_tools require a caller who has + # passed the VERIFY flow this call. Fail CLOSED: if verification can't be + # confirmed, refuse. VERIFY itself is never gated (that would deadlock). + blocked = self.verification_block( + tool_name, getattr(self.assistant, "session", None)) + if blocked is not None: + Metrics.record_tool_error(tool_name, "verification_required") + log_event(logger, logging.INFO, + f"Tool {tool_name} blocked: caller not verified", + event="verify_gate", tool=tool_name, outcome="blocked") + return blocked + with create_span(f"tool.{tool_name.lower()}", { "tool.name": tool_name, "tool.params": str(params_dict) diff --git a/sip-agent/src/virtual_numbers.py b/sip-agent/src/virtual_numbers.py index 411c95c..0077972 100644 --- a/sip-agent/src/virtual_numbers.py +++ b/sip-agent/src/virtual_numbers.py @@ -9,6 +9,14 @@ arrives, and the registry persists across restarts (data/virtual_numbers.json, same atomic-write pattern as the scheduler). +Persistent "trigger numbers" (persistent=True) are the long-lived variant: +they never expire and survive their calls, so every call dialed to one +fires the number's webhook — the hook an n8n workflow registers on +activation to be kicked off by a phone call. The `events` list selects +which call-time webhooks fire: "answered" (call matched, before the +greeting), "first_speech" (the caller's first transcribed utterance), +"speech" (every utterance) and "completed" (call ended, + transcript). + Threading model: all registry state is touched only from the asyncio event loop (API handlers, the call path, and the sweep task), so no locks are needed. The PJSIP thread never calls in here — it only captures the dialed @@ -51,6 +59,11 @@ def extension_from_uri(uri: str) -> Optional[str]: return ext return None +# Call-time webhook events a number can subscribe to (the "expired" lifecycle +# webhook is not optional — it tells the creator the number is gone). +VALID_EVENTS = ("answered", "first_speech", "speech", "completed") +DEFAULT_EVENTS = ["completed"] + # Sweep cadence. Expiry precision of ~1s is plenty for minutes-scale TTLs. _SWEEP_INTERVAL_S = 1.0 @@ -78,6 +91,14 @@ class VirtualNumber: # Set when a call to this number is live; a claimed entry is exempt from # the TTL sweep so it can't vanish mid-call. claimed: bool = False + # Trigger number: never expires (expires_at is 0) and is not consumed by + # its calls — every call to it fires the webhook until it is DELETEd. + persistent: bool = False + # Which call-time webhooks fire (subset of VALID_EVENTS). + events: List[str] = field(default_factory=lambda: list(DEFAULT_EVENTS)) + + def wants(self, event: str) -> bool: + return event in self.events def to_dict(self) -> Dict[str, Any]: return { @@ -90,11 +111,15 @@ def to_dict(self) -> Dict[str, Any]: "created_at": self.created_at, "expires_at": self.expires_at, "claimed": self.claimed, + "persistent": self.persistent, + "events": list(self.events), } @classmethod def from_dict(cls, data: Dict[str, Any]) -> 'VirtualNumber': - return cls(**data) + # Tolerate records written before persistent/events existed. + known = {k: v for k, v in data.items() if k in cls.__dataclass_fields__} + return cls(**known) class VirtualNumberRegistry: @@ -137,14 +162,27 @@ async def stop(self): def create(self, number: Optional[str] = None, ttl_s: Optional[int] = None, purpose: str = "", greeting: str = "", callback_url: str = "", - include_transcript: bool = True) -> VirtualNumber: + include_transcript: bool = True, persistent: bool = False, + events: Optional[List[str]] = None) -> VirtualNumber: """Register a new virtual number; raises VirtualNumberError on policy - violations (400 bad number, 409 collision, 503 exhausted/at cap).""" + violations (400 bad number/events, 409 collision, 503 exhausted/at cap). + + persistent=True makes a trigger number: no TTL, not consumed by its + calls. `events` selects the call-time webhooks (VALID_EVENTS).""" if len(self._entries) >= self.config.virtual_number_max_active: raise VirtualNumberError( 503, f"Too many active virtual numbers " f"(VIRTUAL_NUMBER_MAX_ACTIVE={self.config.virtual_number_max_active})") + events = list(DEFAULT_EVENTS) if events is None else list(dict.fromkeys(events)) + bad = [e for e in events if e not in VALID_EVENTS] + if bad: + raise VirtualNumberError( + 400, f"Unknown events {bad}; valid: {', '.join(VALID_EVENTS)}") + if events and events != list(DEFAULT_EVENTS) and not callback_url: + raise VirtualNumberError( + 400, "events require a callback_url to deliver them to") + ttl = int(ttl_s) if ttl_s else self.config.virtual_number_default_ttl_s ttl = max(1, min(ttl, self.config.virtual_number_max_ttl_s)) @@ -168,14 +206,19 @@ def create(self, number: Optional[str] = None, ttl_s: Optional[int] = None, greeting=greeting, callback_url=callback_url, include_transcript=include_transcript, - expires_at=time.time() + ttl, + expires_at=0.0 if persistent else time.time() + ttl, + persistent=persistent, + events=events, ) self._entries[entry.id] = entry self._persist() log_event(logger, logging.INFO, - f"Virtual number created: {number} (ttl {ttl}s)", + f"Virtual number created: {number} " + f"({'persistent' if persistent else f'ttl {ttl}s'}, " + f"events={','.join(events) or '-'})", event="virtual_number_created", number=number, - virtual_number_id=entry.id, ttl_s=ttl) + virtual_number_id=entry.id, ttl_s=0 if persistent else ttl, + persistent=persistent, events=events) return entry def get(self, entry_id: str) -> Optional[VirtualNumber]: @@ -204,18 +247,28 @@ def claim(self, number: str) -> Optional[VirtualNumber]: if not self.config.virtual_numbers_enabled or not number: return None entry = self._by_number(number) - if entry is None or entry.claimed: + if entry is None: + return None + # A trigger number answers every call (concurrent ones included); + # a single-use number is busy while its one call is live. + if entry.claimed and not entry.persistent: return None entry.claimed = True self._persist() return entry def consume(self, entry_id: str) -> Optional[VirtualNumber]: - """Pop an entry after its call finished (single-use). Idempotent: + """Finish an entry after its call: pop a single-use number, un-claim + a persistent one (it stays registered for the next call). Idempotent: returns None when already gone.""" - entry = self._entries.pop(entry_id, None) - if entry is not None: - self._persist() + entry = self._entries.get(entry_id) + if entry is None: + return None + if entry.persistent: + entry.claimed = False + else: + self._entries.pop(entry_id, None) + self._persist() return entry def release(self, entry_id: str): @@ -261,7 +314,7 @@ async def _sweep_loop(self): def _sweep(self): now = time.time() expired = [e for e in self._entries.values() - if not e.claimed and e.expires_at <= now] + if not e.persistent and not e.claimed and e.expires_at <= now] if not expired: return for entry in expired: @@ -284,11 +337,17 @@ def fire_webhook(self, entry: VirtualNumber, status: str, "number": entry.number, "status": status, "purpose": entry.purpose, + "persistent": entry.persistent, "created_at": entry.created_at, "timestamp": time.time(), } if extra: payload.update(extra) + log_event(logger, logging.INFO, + f"Virtual number webhook {status}: {entry.number}", + event="virtual_number_webhook", status=status, + number=entry.number, virtual_number_id=entry.id, + call_id=payload.get("call_id")) # Imported lazily to avoid an import cycle with api.py. from api import deliver_webhook task = asyncio.create_task(deliver_webhook( @@ -332,7 +391,7 @@ def _load(self): # A claimed entry from before a crash reloads as active (its call # outcome is lost); expiry then applies normally. entry.claimed = False - if entry.expires_at <= now: + if not entry.persistent and entry.expires_at <= now: self._expired_on_load.append(entry) continue self._entries[entry.id] = entry diff --git a/sip-agent/tests/component/conftest.py b/sip-agent/tests/component/conftest.py index 7d04971..524c5eb 100644 --- a/sip-agent/tests/component/conftest.py +++ b/sip-agent/tests/component/conftest.py @@ -111,9 +111,13 @@ def __init__(self, config): from tool_manager import ToolManager from transcript_store import TranscriptStore from virtual_numbers import VirtualNumberRegistry + from identity_verification import IdentityVerifier, VerificationStore self.tool_manager = ToolManager(self) self.transcripts = TranscriptStore(config) self.virtual_numbers = VirtualNumberRegistry(config) + # Optional identity verification, wired exactly as main.py does. + self.verify_store = VerificationStore(config) + self.verifier = IdentityVerifier(config, self.verify_store) # Like production (main.py): always constructed, disabled by default — # so ToolManager.start() exercises _start_mcp_tools' no-op path in # every baseline test, exactly as a default deployment does. diff --git a/sip-agent/tests/component/test_api_endpoints.py b/sip-agent/tests/component/test_api_endpoints.py index e9d80e6..1d0dd84 100644 --- a/sip-agent/tests/component/test_api_endpoints.py +++ b/sip-agent/tests/component/test_api_endpoints.py @@ -314,6 +314,29 @@ def test_virtual_number_crud(vn_client): assert client.delete(f"/virtual-numbers/{vn_id}").status_code == 404 +def test_trigger_number_create_and_events(make_client, config_factory, tmp_path): + """persistent + events round-trip through the API; bad events are 400.""" + client, _assistant = make_client(config_factory( + VIRTUAL_NUMBERS_ENABLED="true", WEBHOOK_ALLOW_PRIVATE="true", + data_dir=str(tmp_path))) + r = client.post("/virtual-numbers", json={ + "purpose": "hotline", "number": "7360", "persistent": True, + "callback_url": "http://127.0.0.1/hook", + "events": ["answered", "first_speech"]}) + assert r.status_code == 200, r.text + body = r.json() + assert body["persistent"] is True + assert body["events"] == ["answered", "first_speech"] + assert body["expires_at"] == 0 + assert client.get(f"/virtual-numbers/{body['id']}").json()["persistent"] is True + + r = client.post("/virtual-numbers", json={ + "purpose": "x", "callback_url": "http://127.0.0.1/hook", + "events": ["nope"]}) + assert r.status_code == 400 + assert "nope" in r.json()["detail"] + + def test_virtual_number_disabled_is_403(client): # Default comp_config has VIRTUAL_NUMBERS_ENABLED unset (false). assert client.post("/virtual-numbers", json={"purpose": "x"}).status_code == 403 diff --git a/sip-agent/tests/component/test_langchain_engine.py b/sip-agent/tests/component/test_langchain_engine.py index d942d78..0cb626b 100644 --- a/sip-agent/tests/component/test_langchain_engine.py +++ b/sip-agent/tests/component/test_langchain_engine.py @@ -249,3 +249,31 @@ async def test_grounding_retry_nudge_fallback_on_400(native_engine): assert nudged, "nudge fallback request never sent" import re assert re.search(r"\d{1,2}:\d{2} (AM|PM)", reply), reply + + +# --- agent wall clock pauses during keypad entry --------------------------------- + +def test_invoke_with_budget_pauses_while_dtmf_collecting(): + """The VERIFY tool's DTMF wait is the caller's time: with the clock paused a + turn longer than LLM_AGENT_TIMEOUT_S still completes; unpaused it times out.""" + import asyncio + from langchain_engine import LangChainEngine + + class _Self: + paused = True + + def _clock_paused(self): + return self.paused + + async def slow(): + await asyncio.sleep(0.6) + return "done" + + async def go(paused): + fake = _Self() + fake.paused = paused + return await LangChainEngine._invoke_with_budget(fake, slow(), 0.3) + + assert asyncio.run(go(True)) == "done" + with pytest.raises(asyncio.TimeoutError): + asyncio.run(go(False)) diff --git a/sip-agent/tests/component/test_tool_manager.py b/sip-agent/tests/component/test_tool_manager.py index 19246a0..1463612 100644 --- a/sip-agent/tests/component/test_tool_manager.py +++ b/sip-agent/tests/component/test_tool_manager.py @@ -151,3 +151,19 @@ async def test_cancel_task_removes_from_persistence(assistant, comp_config): assert tm.cancel_task(task_id) is False persisted = _json.loads((comp_config.data_dir / "scheduled_tasks.json").read_text()) assert all(entry["id"] != task_id for entry in persisted) + + +async def test_verify_required_tool_is_gated(make_client, config_factory, tmp_path): + """A tool in VERIFY_REQUIRED_TOOLS refuses until session.verified is True.""" + cfg = config_factory(data_dir=str(tmp_path), verify_required_tools="CALC") + _, a = make_client(cfg) + a.session = SimpleNamespace(verified=False) + + blocked = await a.tool_manager.execute_tool(_call("CALC", expression="2+2")) + assert blocked.status == ToolStatus.FAILED + assert "verify" in blocked.message.lower() + + a.session.verified = True + ok = await a.tool_manager.execute_tool(_call("CALC", expression="2+2")) + assert ok.status == ToolStatus.SUCCESS + assert "4" in ok.message diff --git a/sip-agent/tests/component/test_verify_api.py b/sip-agent/tests/component/test_verify_api.py new file mode 100644 index 0000000..522f034 --- /dev/null +++ b/sip-agent/tests/component/test_verify_api.py @@ -0,0 +1,470 @@ +"""Component tests for the identity-verification REST endpoints.""" +import asyncio + +import pyotp +import pytest + +pytestmark = pytest.mark.component + + +# --- enrollment CRUD ----------------------------------------------------------- + +def test_enroll_get_delete_round_trip(client): + r = client.post("/verify/credentials", + json={"caller_id": "1001", "pin": "1234", "generate_totp": True}) + assert r.status_code == 200 + body = r.json() + assert body["caller_id"] == "1001" + assert body["has_pin"] and body["has_totp"] + assert body["provisioning_uri"].startswith("otpauth://totp/") + + g = client.get("/verify/credentials/1001") + assert g.status_code == 200 + assert g.json()["has_totp"] is True + + d = client.delete("/verify/credentials/1001") + assert d.status_code == 200 and d.json()["success"] is True + assert client.get("/verify/credentials/1001").status_code == 404 + assert client.delete("/verify/credentials/1001").status_code == 404 + + +def test_enroll_requires_a_factor(client): + r = client.post("/verify/credentials", json={"caller_id": "1001"}) + assert r.status_code == 400 + + +def test_enroll_rejects_bad_caller_id(client): + r = client.post("/verify/credentials", + json={"caller_id": "../etc/passwd", "pin": "1234"}) + assert r.status_code == 400 + + +# --- verification -------------------------------------------------------------- + +def test_verify_pin_and_otp(client, assistant): + secret = pyotp.random_base32() + client.post("/verify/credentials", + json={"caller_id": "1001", "pin": "1234", "totp_secret": secret}) + + ok = client.post("/verify", json={"caller_id": "1001", "pin": "1234"}) + assert ok.json() == {"caller_id": "1001", "verified": True, "method": "pin"} + + code = pyotp.TOTP(secret).now() + ok = client.post("/verify", json={"caller_id": "1001", "otp": code}) + assert ok.json()["verified"] is True and ok.json()["method"] == "otp" + + bad = client.post("/verify", json={"caller_id": "1001", "pin": "0000"}) + assert bad.json()["verified"] is False and bad.json()["method"] is None + + +def test_verify_requires_a_factor(client): + r = client.post("/verify", json={"caller_id": "1001"}) + assert r.status_code == 422 # pydantic model validator + + +def test_verify_global_pin_fallback(make_client, config_factory, tmp_path): + cfg = config_factory(data_dir=str(tmp_path), verify_pin="4321") + c, _ = make_client(cfg) + r = c.post("/verify", json={"caller_id": "9999", "pin": "4321"}) + assert r.json()["verified"] is True + + +# --- current OTP --------------------------------------------------------------- + +def test_get_current_otp(client): + secret = pyotp.random_base32() + client.post("/verify/credentials", + json={"caller_id": "1001", "totp_secret": secret}) + r = client.get("/verify/otp/1001") + assert r.status_code == 200 + body = r.json() + assert body["otp"] == pyotp.TOTP(secret).now() + assert 0 < body["expires_in_s"] <= 30 + + +def test_get_current_otp_missing(client): + assert client.get("/verify/otp/nobody").status_code == 404 + + +# --- call and verify (outbound) ------------------------------------------------ + +def test_call_verify_rejects_uncredentialed_caller(make_client, config_factory, tmp_path): + """No PIN/TOTP for the caller and no global factor -> nothing to check. + + Isolated data dir so no other test's enrollment leaks in. + """ + c, _ = make_client(config_factory(data_dir=str(tmp_path))) + r = c.post("/verify/call", json={"caller_id": "1001"}) + assert r.status_code == 400 + + +def test_call_verify_rejects_bad_caller_id(client): + r = client.post("/verify/call", json={"caller_id": "../etc/passwd"}) + assert r.status_code == 400 + + +class _FakeCall: + def __init__(self): + self.is_active = True + self.media_ready = True + + +class _FakeSip: + """Minimal SIP double that answers immediately and replays queued DTMF.""" + + def __init__(self, digits): + self._digits = list(digits) + self.call = _FakeCall() + + async def make_call(self, uri, caller_name=None): + return self.call + + async def hangup_call(self, call_info): + call_info.is_active = False + + async def send_audio(self, call_info, audio, tag=None): + pass + + def clear_dtmf(self, call_info): + pass + + def get_dtmf_digit(self, call_info): + return self._digits.pop(0) if self._digits else None + + +class _FakeAudio: + def __init__(self): + self.said = [] + + async def synthesize(self, text): + self.said.append(text) + return b"\x00\x00" + + def new_session_state(self): + return {} + + +def _verify_handler(assistant, digits): + from api import OutboundCallHandler + assistant.sip_handler = _FakeSip(digits) + assistant.audio_pipeline = _FakeAudio() + return OutboundCallHandler(assistant, call_queue=None) + + +def test_call_verify_success_with_correct_pin(client, assistant): + from api import VerifyCallRequest + client.post("/verify/credentials", json={"caller_id": "1001", "pin": "2468"}) + handler = _verify_handler(assistant, ["2", "4", "6", "8", "#"]) + + resp = asyncio.run(handler.run_verify_call( + VerifyCallRequest(caller_id="1001", extension="1001"))) + + assert resp.verified is True + assert resp.method == "pin" + assert resp.status.value == "completed" + # The call was torn down. + assert handler.assistant.sip_handler.call.is_active is False + + +def test_call_verify_fails_with_wrong_code(client, assistant, comp_config): + from api import VerifyCallRequest + client.post("/verify/credentials", json={"caller_id": "1001", "pin": "2468"}) + # One wrong entry per allowed attempt, each terminated by '#'. + digits = ["9"] * comp_config.verify_max_attempts + queued = [] + for d in digits: + queued += [d, "#"] + handler = _verify_handler(assistant, queued) + + resp = asyncio.run(handler.run_verify_call( + VerifyCallRequest(caller_id="1001", extension="1001"))) + + assert resp.verified is False + assert resp.method is None + assert resp.attempts == comp_config.verify_max_attempts + + +def test_call_verify_uses_spoken_message_overrides(client, assistant): + from api import VerifyCallRequest + client.post("/verify/credentials", json={"caller_id": "1001", "pin": "2468"}) + handler = _verify_handler(assistant, ["2", "4", "6", "8", "#"]) + + resp = asyncio.run(handler.run_verify_call(VerifyCallRequest( + caller_id="1001", extension="1001", + prompt="Key in your secret code now.", + success_phrase="You are in. Bye."))) + + assert resp.verified is True + said = handler.assistant.audio_pipeline.said + assert "Key in your secret code now." in said + assert "You are in. Bye." in said + # The configured defaults were overridden, not spoken. + assert assistant.config.verify_call_prompt not in said + + +def test_call_verify_with_inline_pin_no_enrollment(make_client, config_factory, tmp_path): + """A per-request PIN verifies a caller with no stored/global credentials.""" + from api import VerifyCallRequest, OutboundCallHandler + c, a = make_client(config_factory(data_dir=str(tmp_path))) + a.sip_handler = _FakeSip(["2", "4", "6", "8", "#"]) + a.audio_pipeline = _FakeAudio() + handler = OutboundCallHandler(a, call_queue=None) + + resp = asyncio.run(handler.run_verify_call( + VerifyCallRequest(caller_id="1001", extension="1001", pin="2468"))) + + assert resp.verified is True + assert resp.method == "pin" + + +def test_call_verify_with_inline_totp_secret(make_client, config_factory, tmp_path): + from api import VerifyCallRequest, OutboundCallHandler + secret = pyotp.random_base32() + code = pyotp.TOTP(secret).now() + c, a = make_client(config_factory(data_dir=str(tmp_path))) + a.sip_handler = _FakeSip(list(code) + ["#"]) + a.audio_pipeline = _FakeAudio() + handler = OutboundCallHandler(a, call_queue=None) + + resp = asyncio.run(handler.run_verify_call( + VerifyCallRequest(caller_id="1001", extension="1001", totp_secret=secret))) + + assert resp.verified is True + assert resp.method == "otp" + + +def test_call_verify_endpoint_accepts_inline_pin(make_client, config_factory, tmp_path, monkeypatch): + """The endpoint takes pin/totp_secret and reaches the call path (no 400).""" + from api import OutboundCallHandler + c, a = make_client(config_factory(data_dir=str(tmp_path))) + + captured = {} + + async def fake_run(self, request): + captured["pin"] = request.pin + captured["totp_secret"] = request.totp_secret + from api import VerifyCallResponse, CallStatus + return VerifyCallResponse(call_id="x", status=CallStatus.COMPLETED, + verified=True, method="pin") + + monkeypatch.setattr(OutboundCallHandler, "run_verify_call", fake_run) + r = c.post("/verify/call", + json={"caller_id": "1001", "pin": "2468", "totp_secret": "JBSWY3DPEHPK3PXP"}) + assert r.status_code == 200 + assert r.json()["verified"] is True + assert captured == {"pin": "2468", "totp_secret": "JBSWY3DPEHPK3PXP"} + + +def test_call_verify_blank_caller_id_with_inline_pin(make_client, config_factory, tmp_path): + """No caller_id needed when an inline PIN and an extension are supplied.""" + from api import VerifyCallRequest, OutboundCallHandler + c, a = make_client(config_factory(data_dir=str(tmp_path))) + a.sip_handler = _FakeSip(["2", "4", "6", "8", "#"]) + a.audio_pipeline = _FakeAudio() + handler = OutboundCallHandler(a, call_queue=None) + + resp = asyncio.run(handler.run_verify_call( + VerifyCallRequest(extension="1001", pin="2468"))) + + assert resp.verified is True + + +def test_call_verify_requires_caller_id_or_extension(client, assistant): + from api import VerifyCallRequest, RequestRejected, OutboundCallHandler + handler = OutboundCallHandler(assistant, call_queue=None) + with pytest.raises(RequestRejected): + asyncio.run(handler.run_verify_call(VerifyCallRequest(pin="2468"))) + + +def test_call_verify_rejects_bad_inline_totp_secret(client, assistant): + from api import VerifyCallRequest, RequestRejected, OutboundCallHandler + handler = OutboundCallHandler(assistant, call_queue=None) + with pytest.raises(RequestRejected): + asyncio.run(handler.run_verify_call( + VerifyCallRequest(caller_id="1001", totp_secret="not base32!"))) + + +def test_call_verify_keypress_mutes_prompt(make_client, config_factory, tmp_path): + """The caller's first keypress barges in — the still-playing prompt is + flushed via the playlist player's clear().""" + from api import VerifyCallRequest, OutboundCallHandler + + class _Player: + def __init__(self): + self.cleared = 0 + + def clear(self): + self.cleared += 1 + + c, a = make_client(config_factory(data_dir=str(tmp_path))) + a.sip_handler = _FakeSip(["2", "4", "6", "8", "#"]) + a.audio_pipeline = _FakeAudio() + player = _Player() + a.sip_handler.get_playlist_player = lambda call_info: player + handler = OutboundCallHandler(a, call_queue=None) + + resp = asyncio.run(handler.run_verify_call( + VerifyCallRequest(caller_id="1001", extension="1001", pin="2468"))) + + assert resp.verified is True + assert player.cleared >= 1 # prompt was muted on the first keypress + + +def test_call_verify_auto_submits_without_pound(make_client, config_factory, tmp_path): + """The caller need not press '#': entry auto-submits after the inter-digit gap + (so a time-based OTP isn't left to expire waiting out the full window).""" + from api import VerifyCallRequest, OutboundCallHandler + c, a = make_client(config_factory(data_dir=str(tmp_path), + verify_dtmf_interdigit_s="0.2")) + a.sip_handler = _FakeSip(["2", "4", "6", "8"]) # NB: no trailing '#' + a.audio_pipeline = _FakeAudio() + handler = OutboundCallHandler(a, call_queue=None) + + resp = asyncio.run(handler.run_verify_call( + VerifyCallRequest(caller_id="1001", extension="1001", pin="2468"))) + + assert resp.verified is True + assert resp.attempts == 1 + + +def test_call_verify_no_answer(client, assistant): + from api import VerifyCallRequest + client.post("/verify/credentials", json={"caller_id": "1001", "pin": "2468"}) + handler = _verify_handler(assistant, []) + handler.assistant.sip_handler.call.is_active = False # never answers + + resp = asyncio.run(handler.run_verify_call( + VerifyCallRequest(caller_id="1001", extension="1001", ring_timeout=1))) + + assert resp.verified is False + assert resp.status.value == "no_answer" + + +def test_call_verify_endpoint_requires_auth(make_client, config_factory, tmp_path): + cfg = config_factory(data_dir=str(tmp_path), api_auth_token="s3cret") + c, _ = make_client(cfg) + assert c.post("/verify/call", json={"caller_id": "1001"}).status_code == 401 + + +# --- auth ---------------------------------------------------------------------- + +def test_verify_endpoints_require_auth_when_token_set(make_client, config_factory, tmp_path): + cfg = config_factory(data_dir=str(tmp_path), api_auth_token="s3cret") + c, _ = make_client(cfg) + + # No credentials -> 401 on the mutating endpoints. + assert c.post("/verify", json={"caller_id": "1001", "pin": "1"}).status_code == 401 + assert c.post("/verify/credentials", + json={"caller_id": "1001", "pin": "1234"}).status_code == 401 + + # With the key -> succeeds. + ok = c.post("/verify/credentials", + json={"caller_id": "1001", "pin": "1234"}, + headers={"X-API-Key": "s3cret"}) + assert ok.status_code == 200 + + +# --- review regressions -------------------------------------------------------- + +def test_get_current_otp_never_falls_back_to_global_for_unknown_caller( + make_client, config_factory, tmp_path): + secret = pyotp.random_base32() + c, _ = make_client(config_factory(data_dir=str(tmp_path), verify_totp_secret=secret)) + assert c.get("/verify/otp/does-not-exist").status_code == 404 + assert c.get("/verify/otp/bad%20id").status_code == 400 + r = c.get("/verify/otp/global") + assert r.status_code == 200 + assert r.json()["otp"] == pyotp.TOTP(secret).now() + + +def test_get_current_otp_global_missing(make_client, config_factory, tmp_path): + c, _ = make_client(config_factory(data_dir=str(tmp_path))) + assert c.get("/verify/otp/global").status_code == 404 + + +def test_verify_endpoint_non_ascii_pin_is_false_not_500(make_client, config_factory, tmp_path): + c, _ = make_client(config_factory(data_dir=str(tmp_path), verify_pin="1234")) + r = c.post("/verify", json={"caller_id": "1", "pin": "1234"}) + assert r.status_code == 200 and r.json()["verified"] is False + + +def test_call_verify_caller_id_defaults_to_extension(make_client, config_factory, tmp_path): + """Enrolled extension, no global PIN, caller_id omitted: the extension's own + credentials are consulted (docs promise caller_id defaults to extension).""" + from api import VerifyCallRequest + c, a = make_client(config_factory(data_dir=str(tmp_path))) + a.verify_store.set_credentials("1001", pin="2468") + handler = _verify_handler(a, ["2", "4", "6", "8", "#"]) + resp = asyncio.run(handler.run_verify_call(VerifyCallRequest(extension="1001"))) + assert resp.verified is True and resp.method == "pin" + + +def test_call_verify_hangup_before_code_is_not_a_completed_attempt( + make_client, config_factory, tmp_path): + from api import VerifyCallRequest, CallStatus + + class _HangupSip(_FakeSip): + def get_dtmf_digit(self, call_info): + call_info.is_active = False # caller hangs up during the prompt + return None + + c, a = make_client(config_factory(data_dir=str(tmp_path))) + a.sip_handler = _HangupSip([]) + a.audio_pipeline = _FakeAudio() + from api import OutboundCallHandler + handler = OutboundCallHandler(a, call_queue=None) + resp = asyncio.run(handler.run_verify_call( + VerifyCallRequest(caller_id="1001", extension="1001", pin="2468"))) + assert resp.verified is False + assert resp.attempts == 0 + assert resp.status == CallStatus.HANGUP + + +def test_call_verify_empty_entry_does_not_burn_an_attempt(make_client, config_factory, tmp_path): + """First prompt times out with nothing keyed, second gets the code: one attempt.""" + from api import VerifyCallRequest, OutboundCallHandler + c, a = make_client(config_factory(data_dir=str(tmp_path), verify_dtmf_timeout_s="0.2", + verify_dtmf_interdigit_s="0.2")) + + class _LateSip(_FakeSip): + def __init__(self, digits): + super().__init__(digits) + self.calls = 0 + + def get_dtmf_digit(self, call_info): + self.calls += 1 + if self.calls < 8: # ~0.35s of silence: first prompt window expires + return None + return super().get_dtmf_digit(call_info) + + a.sip_handler = _LateSip(["2", "4", "6", "8", "#"]) + a.audio_pipeline = _FakeAudio() + handler = OutboundCallHandler(a, call_queue=None) + resp = asyncio.run(handler.run_verify_call( + VerifyCallRequest(caller_id="1001", extension="1001", pin="2468"))) + assert resp.verified is True + assert resp.attempts == 1 + + +# --- REST tool endpoints honour VERIFY_REQUIRED_TOOLS --------------------------- + +def test_rest_tool_execute_is_gated(make_client, config_factory, tmp_path): + c, a = make_client(config_factory(data_dir=str(tmp_path), verify_required_tools="CALC")) + r = c.post("/tools/CALC/execute", json={"params": {"expression": "2+2"}}) + assert r.status_code == 403 + # a verified live session unlocks it + from call_session import CallSession + sess = CallSession(call_info=object(), direction="inbound", transcript_id="c1") + sess.verified = True + a.sessions = {"c1": sess} + r = c.post("/tools/CALC/execute", json={"params": {"expression": "2+2"}, "call_id": "c1"}) + assert r.status_code == 200, r.text + # ungated tools are unaffected + r = c.post("/tools/JOKE/execute", json={"params": {}}) + assert r.status_code != 403 + + +def test_rest_tool_call_is_gated(make_client, config_factory, tmp_path): + c, a = make_client(config_factory(data_dir=str(tmp_path), verify_required_tools="CALC")) + r = c.post("/tools/CALC/call", json={"extension": "1001", "params": {"expression": "2+2"}}) + assert r.status_code == 403 diff --git a/sip-agent/tests/e2e/conftest.py b/sip-agent/tests/e2e/conftest.py index a67875f..b5d04fe 100644 --- a/sip-agent/tests/e2e/conftest.py +++ b/sip-agent/tests/e2e/conftest.py @@ -164,6 +164,15 @@ def _transcribe(path: Path) -> str: f"{SPEACHES_URL.rstrip('/')}/v1/audio/transcriptions", files=files, data=data, timeout=120.0, ) + if resp.status_code == 500 and "clip timestamps" in resp.text.lower(): + # faster-whisper's VAD found no speech at all in the capture — that + # is a real test outcome (the agent never spoke), not an STT outage. + # Surface it as such instead of an opaque HTTPStatusError. + rms, duration = _wav_rms_and_duration(path) + raise AssertionError( + f"captured audio has no transcribable speech ({duration:.1f}s, " + f"rms={rms:.0f}) — the agent likely never answered within the " + f"softphone's window; check LLM/TTS latency in the agent log") resp.raise_for_status() return (resp.json().get("text") or "").strip() diff --git a/sip-agent/tests/e2e/test_virtual_number.py b/sip-agent/tests/e2e/test_virtual_number.py index 931a07f..ef8eba0 100644 --- a/sip-agent/tests/e2e/test_virtual_number.py +++ b/sip-agent/tests/e2e/test_virtual_number.py @@ -123,3 +123,72 @@ def test_virtual_number_ttl_expiry(feature_enabled): assert _api("GET", f"/virtual-numbers/{vn_id}").status_code == 200 time.sleep(6) assert _api("GET", f"/virtual-numbers/{vn_id}").status_code == 404 + + +def test_trigger_number_survives_call_and_fires_speech( + feature_enabled, softphone_image, question_wav, assert_spoke, + agent_events, event_names): + """A persistent trigger number answers a call, emits the answered + + first_speech webhooks (asserted via the structured webhook log events), + and is still registered afterwards — until DELETE.""" + import gen_audio + gen_audio.FIXTURES.setdefault( + "vn_confirm_order.wav", "Yes, I'm calling to confirm my pizza order.") + question_wav("vn_confirm_order.wav") + + r = _api("POST", "/virtual-numbers", json={ + "purpose": "Calls to this number start a workflow.", + "persistent": True, + "events": ["answered", "first_speech"], + # Unroutable but syntactically fine; delivery failure is logged, not fatal. + "callback_url": "http://sip-agent:8080/health", + }) + if r.status_code == 400 and "private" in r.text: + pytest.skip("WEBHOOK_ALLOW_PRIVATE is not set on this stack") + assert r.status_code == 200, r.text + vn = r.json() + assert vn["persistent"] is True and vn["expires_at"] == 0 + number = vn["number"] + + from conftest import _AUDIO_DIR, NETWORK + from datetime import datetime, timezone + cname = f"e2e-tn-{uuid.uuid4().hex[:6]}" + started_at = datetime.now(timezone.utc) + try: + proc = subprocess.Popen( + ["docker", "run", "--rm", "-i", "--name", cname, + "--network", NETWORK, "-v", f"{_AUDIO_DIR}:/audio", + softphone_image, + "--rtp-port", "4000", + "--auto-play", "--play-file", "/audio/vn_confirm_order.wav", + "--duration", "25", "--stdout-no-buf", + f"sip:{number}@sip-agent:5060"], + stdin=subprocess.PIPE, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + time.sleep(29) + try: + if proc.stdin: + proc.stdin.close() + proc.wait(timeout=20) + except Exception: + proc.kill() + subprocess.run(["docker", "rm", "-f", cname], check=False, capture_output=True) + + events = agent_events(started_at) + names = event_names(events) + assert "virtual_number_matched" in names, f"saw {sorted(set(names))}" + assert "virtual_number_completed" in names + # fire_webhook logs a virtual_number_webhook event per delivery + # (status = answered / first_speech / ...); both subscribed events + # must have fired, and the unsubscribed "completed" must not. + fired = {(e.get("data") or {}).get("status") + for e in events if e.get("event") == "virtual_number_webhook"} + assert "answered" in fired, f"answered webhook never fired; fired={fired}" + assert "first_speech" in fired, f"first_speech webhook never fired; fired={fired}" + assert "completed" not in fired + + # Persistent: still registered after the call, un-claimed. + r = _api("GET", f"/virtual-numbers/{vn['id']}") + assert r.status_code == 200 and r.json()["status"] == "active" + finally: + _api("DELETE", f"/virtual-numbers/{vn['id']}") + assert _api("GET", f"/virtual-numbers/{vn['id']}").status_code == 404 diff --git a/sip-agent/tests/unit/test_dtmf_collect.py b/sip-agent/tests/unit/test_dtmf_collect.py new file mode 100644 index 0000000..5a76e80 --- /dev/null +++ b/sip-agent/tests/unit/test_dtmf_collect.py @@ -0,0 +1,81 @@ +"""Unit tests for the shared DTMF code collector.""" +import asyncio + +import pytest + +from dtmf_collect import collect_dtmf_code + +pytestmark = pytest.mark.unit + + +class _Call: + is_active = True + + +class _Sip: + def __init__(self, script): + # script: list of digits or floats (sleep seconds before next digit) + self._script = list(script) + self.cleared = 0 + self.sent = [] + self.player_clears = 0 + self._loop = asyncio.get_event_loop() + self._ready_at = self._loop.time() + + def clear_dtmf(self, call_info): + self.cleared += 1 + + async def send_audio(self, call_info, audio, tag=None): + self.sent.append(audio) + + def get_playlist_player(self, call_info): + sip = self + + class _P: + def clear(self_inner): + sip.player_clears += 1 + return _P() + + def get_dtmf_digit(self, call_info): + while self._script and isinstance(self._script[0], float): + self._ready_at = max(self._ready_at, self._loop.time()) + self._script.pop(0) + if self._loop.time() < self._ready_at or not self._script: + return None + return self._script.pop(0) + + +def _run(script, **kw): + async def go(): + sip = _Sip(script) + code = await collect_dtmf_code(sip, _Call(), prompt_audio=b"\x00\x00", **kw) + return sip, code + return asyncio.run(go()) + + +def test_pound_submits_and_first_key_mutes_prompt(): + sip, code = _run(["1", "2", "3", "#"], timeout=1.0, interdigit=1.0) + assert code == "123" + assert sip.cleared == 1 and sip.sent == [b"\x00\x00"] + assert sip.player_clears == 1 + + +def test_interdigit_gap_auto_submits(): + sip, code = _run(["4", "5"], timeout=1.0, interdigit=0.1) + assert code == "45" + + +def test_star_restarts_entry_and_the_first_digit_timer(): + # timeout 0.3s: caller keys '1' late, hits '*' after the original window + # would have expired, then keys the real code. '*' must NOT abort. + sip, code = _run([0.2, "1", 0.2, "*", 0.2, "9", "8", "#"], timeout=0.3, interdigit=1.0) + assert code == "98" + + +def test_timeout_with_nothing_entered_is_none(): + sip, code = _run([], timeout=0.1, interdigit=1.0) + assert code is None + + +def test_length_cap(): + sip, code = _run(list("1234567890123"), timeout=1.0, interdigit=1.0) + assert code == "123456789012" diff --git a/sip-agent/tests/unit/test_identity_verification.py b/sip-agent/tests/unit/test_identity_verification.py new file mode 100644 index 0000000..647b897 --- /dev/null +++ b/sip-agent/tests/unit/test_identity_verification.py @@ -0,0 +1,321 @@ +"""Unit tests for optional caller identity verification (PIN + TOTP).""" +import pyotp +import pytest + +from identity_verification import ( + IdentityVerifier, + VerificationStore, + is_safe_caller_id, +) + +pytestmark = pytest.mark.unit + + +@pytest.fixture +def store(config_factory, tmp_path): + cfg = config_factory(data_dir=str(tmp_path)) + return VerificationStore(cfg), cfg + + +# --- caller id validation ------------------------------------------------------ + +def test_is_safe_caller_id(): + assert is_safe_caller_id("1001") + assert is_safe_caller_id("alice.smith+1") + assert not is_safe_caller_id("") + assert not is_safe_caller_id("../etc/passwd") + assert not is_safe_caller_id("a" * 65) + + +# --- store round-trip ---------------------------------------------------------- + +def test_set_get_delete_round_trip(store): + st, _ = store + view = st.set_credentials("1001", pin="1234", generate_totp=True) + assert view["has_pin"] and view["has_totp"] + + record = st.get("1001") + assert record["pin_hash"] and record["pin_salt"] and record["totp_secret"] + # The raw PIN is never persisted. + assert "1234" not in str(record) + + assert st.delete("1001") is True + assert st.get("1001") is None + assert st.delete("1001") is False # already gone + + +def test_set_credentials_rejects_empty_enrollment(store): + st, _ = store + assert st.set_credentials("1001") is None + assert st.get("1001") is None + + +def test_set_credentials_rejects_bad_base32(store): + st, _ = store + assert st.set_credentials("1001", totp_secret="not base32!") is None + + +def test_set_credentials_preserves_other_factor(store): + st, _ = store + st.set_credentials("1001", pin="1234") + st.set_credentials("1001", generate_totp=True) # add TOTP, keep PIN + record = st.get("1001") + assert record["pin_hash"] and record["totp_secret"] + + +def test_public_view_hides_secrets(store): + st, _ = store + st.set_credentials("1001", pin="1234", generate_totp=True) + view = st.public_view("1001") + assert set(view) == {"caller_id", "has_pin", "has_totp", "updated_at"} + + +def test_provisioning_uri(store): + st, cfg = store + assert st.provisioning_uri("1001", cfg.verify_issuer) is None # no secret yet + st.set_credentials("1001", generate_totp=True) + uri = st.provisioning_uri("1001", cfg.verify_issuer) + assert uri.startswith("otpauth://totp/") + assert "1001" in uri + + +# --- PIN verification ---------------------------------------------------------- + +def test_global_pin(config_factory, tmp_path): + cfg = config_factory(data_dir=str(tmp_path), verify_pin="4321") + ver = IdentityVerifier(cfg, VerificationStore(cfg)) + assert ver.verify_pin("1001", "4321") is True + assert ver.verify_pin("1001", "0000") is False + assert ver.verify_pin("1001", "") is False + + +def test_per_caller_pin_overrides_global(config_factory, tmp_path): + cfg = config_factory(data_dir=str(tmp_path), verify_pin="4321") + store = VerificationStore(cfg) + store.set_credentials("1001", pin="1234") + ver = IdentityVerifier(cfg, store) + # Enrolled caller uses their own PIN; the global PIN no longer works for them. + assert ver.verify_pin("1001", "1234") is True + assert ver.verify_pin("1001", "4321") is False + # A different, un-enrolled caller still falls back to the global PIN. + assert ver.verify_pin("2002", "4321") is True + + +def test_no_pin_configured_rejects(config_factory, tmp_path): + cfg = config_factory(data_dir=str(tmp_path)) + ver = IdentityVerifier(cfg, VerificationStore(cfg)) + assert ver.verify_pin("1001", "1234") is False + + +# --- TOTP verification --------------------------------------------------------- + +def test_global_totp(config_factory, tmp_path): + secret = pyotp.random_base32() + cfg = config_factory(data_dir=str(tmp_path), verify_totp_secret=secret) + ver = IdentityVerifier(cfg, VerificationStore(cfg)) + assert ver.verify_totp("1001", pyotp.TOTP(secret).now()) is True + assert ver.verify_totp("1001", "000000") is False + + +def test_per_caller_totp(config_factory, tmp_path): + cfg = config_factory(data_dir=str(tmp_path)) + store = VerificationStore(cfg) + store.set_credentials("1001", generate_totp=True) + ver = IdentityVerifier(cfg, store) + secret = store.get("1001")["totp_secret"] + assert ver.verify_totp("1001", pyotp.TOTP(secret).now()) is True + + +def test_totp_window_rejects_far_code(config_factory, tmp_path): + secret = pyotp.random_base32() + cfg = config_factory(data_dir=str(tmp_path), verify_totp_secret=secret, + verify_totp_window=1) + ver = IdentityVerifier(cfg, VerificationStore(cfg)) + # A code generated for a timestamp 5 minutes ago is far outside +/-1 step. + import time + stale = pyotp.TOTP(secret).at(int(time.time()) - 300) + assert ver.verify_totp("1001", stale) is False + + +def test_current_otp(config_factory, tmp_path): + secret = pyotp.random_base32() + cfg = config_factory(data_dir=str(tmp_path), verify_totp_secret=secret) + ver = IdentityVerifier(cfg, VerificationStore(cfg)) + result = ver.current_otp("1001") + assert result is not None + code, remaining = result + assert ver.verify_totp("1001", code) is True + assert 0 < remaining <= 30 + + +# --- combined / configured ----------------------------------------------------- + +def test_verify_auto_accepts_either_factor(config_factory, tmp_path): + secret = pyotp.random_base32() + cfg = config_factory(data_dir=str(tmp_path)) + store = VerificationStore(cfg) + store.set_credentials("1001", pin="1234", totp_secret=secret) + ver = IdentityVerifier(cfg, store) + + ok, method = ver.verify("1001", "1234", method="auto") + assert ok and method == "pin" + ok, method = ver.verify("1001", pyotp.TOTP(secret).now(), method="auto") + assert ok and method == "otp" + ok, method = ver.verify("1001", "9999", method="auto") + assert not ok and method is None + + +def test_can_verify_and_is_configured(config_factory, tmp_path): + cfg = config_factory(data_dir=str(tmp_path)) + store = VerificationStore(cfg) + ver = IdentityVerifier(cfg, store) + assert ver.is_configured() is False + assert ver.can_verify("1001") is False + store.set_credentials("1001", pin="1234") + assert ver.has_any_credentials("1001") is True + assert ver.can_verify("1001") is True + + +# --- explicit (per-request) credentials ---------------------------------------- + +def test_verify_explicit_pin_and_otp(config_factory, tmp_path): + """Ad-hoc factors are checked directly, with no store/global lookup.""" + secret = pyotp.random_base32() + cfg = config_factory(data_dir=str(tmp_path)) + ver = IdentityVerifier(cfg, VerificationStore(cfg)) # empty store + + ok, method = ver.verify_explicit("1234", pin="1234") + assert ok and method == "pin" + + ok, method = ver.verify_explicit(pyotp.TOTP(secret).now(), totp_secret=secret) + assert ok and method == "otp" + + ok, method = ver.verify_explicit("0000", pin="1234", totp_secret=secret) + assert not ok and method is None + + +def test_verify_explicit_respects_forced_method(config_factory, tmp_path): + secret = pyotp.random_base32() + cfg = config_factory(data_dir=str(tmp_path)) + ver = IdentityVerifier(cfg, VerificationStore(cfg)) + + # method='pin' ignores the (correct) OTP; method='otp' ignores the PIN. + ok, _ = ver.verify_explicit(pyotp.TOTP(secret).now(), pin="1234", + totp_secret=secret, method="pin") + assert not ok + ok, _ = ver.verify_explicit("1234", pin="1234", totp_secret=secret, method="otp") + assert not ok + + +def test_verify_explicit_empty_candidate(config_factory, tmp_path): + cfg = config_factory(data_dir=str(tmp_path)) + ver = IdentityVerifier(cfg, VerificationStore(cfg)) + assert ver.verify_explicit("", pin="1234") == (False, None) + + +# --- configurable TOTP parameters ---------------------------------------------- + +def test_configurable_totp_digits_period_algorithm(config_factory, tmp_path): + """Stored-secret verification honours the configured digits/period/algorithm.""" + import hashlib + secret = pyotp.random_base32() + cfg = config_factory(data_dir=str(tmp_path), verify_totp_digits=8, + verify_totp_period=60, verify_totp_algorithm="SHA256") + store = VerificationStore(cfg) + store.set_credentials("1001", totp_secret=secret) + ver = IdentityVerifier(cfg, store) + + matching = pyotp.TOTP(secret, digits=8, digest=hashlib.sha256, interval=60).now() + assert ver.verify_totp("1001", matching) is True + # A default 6-digit/SHA1/30s code must NOT verify under the custom config. + assert ver.verify_totp("1001", pyotp.TOTP(secret).now()) is False + + +def test_current_otp_uses_configured_params(config_factory, tmp_path): + import hashlib + secret = pyotp.random_base32() + cfg = config_factory(data_dir=str(tmp_path), verify_totp_digits=8, + verify_totp_period=60, verify_totp_algorithm="SHA512") + store = VerificationStore(cfg) + store.set_credentials("1001", totp_secret=secret) + ver = IdentityVerifier(cfg, store) + + code, remaining = ver.current_otp("1001") + assert code == pyotp.TOTP(secret, digits=8, digest=hashlib.sha512, interval=60).now() + assert len(code) == 8 + assert 0 < remaining <= 60 + + +def test_provisioning_uri_embeds_custom_params(config_factory, tmp_path): + cfg = config_factory(data_dir=str(tmp_path), verify_totp_digits=8, + verify_totp_period=60, verify_totp_algorithm="SHA256") + store = VerificationStore(cfg) + store.set_credentials("1001", generate_totp=True) + uri = store.provisioning_uri("1001", cfg.verify_issuer) + assert "digits=8" in uri and "period=60" in uri and "algorithm=SHA256" in uri + + +def test_verify_explicit_totp_param_overrides(config_factory, tmp_path): + import hashlib + secret = pyotp.random_base32() + cfg = config_factory(data_dir=str(tmp_path)) # defaults 6/30/SHA1 + ver = IdentityVerifier(cfg, VerificationStore(cfg)) + + code = pyotp.TOTP(secret, digits=8, digest=hashlib.sha256, interval=60).now() + ok, method = ver.verify_explicit(code, totp_secret=secret, totp_digits=8, + totp_period=60, totp_algorithm="SHA256") + assert ok and method == "otp" + # Without the overrides, the same code fails under the SHA1/6/30 defaults. + ok, _ = ver.verify_explicit(code, totp_secret=secret) + assert not ok + + +# --- review regressions -------------------------------------------------------- + +def test_non_ascii_pin_is_a_mismatch_not_an_error(config_factory, tmp_path): + """hmac.compare_digest(str, str) raises on non-ASCII; must be a plain False.""" + cfg = config_factory(data_dir=str(tmp_path), verify_pin="1234") + v = IdentityVerifier(cfg, VerificationStore(cfg)) + assert v.verify_pin("9999", "1234") is False + assert v.verify_explicit("12", pin="12") == (False, None) + # per-caller hash path too + v.store.set_credentials("1001", pin="2468") + assert v.verify_pin("1001", "24") is False + + +def test_store_file_is_owner_only(store): + import os + import stat + s, _ = store + s.set_credentials("1001", pin="1234") + mode = stat.S_IMODE(os.stat(s.path).st_mode) + assert mode == 0o600 + + +def test_current_otp_code_and_expiry_agree(config_factory, tmp_path): + import time + from identity_verification import build_totp, totp_params + secret = pyotp.random_base32() + cfg = config_factory(data_dir=str(tmp_path), verify_totp_secret=secret) + v = IdentityVerifier(cfg, VerificationStore(cfg)) + code, remaining = v.current_otp("anyone") + now = int(time.time()) + assert code == build_totp(secret, totp_params(cfg)).at(now) + assert remaining == 30 - now % 30 + + +def test_has_own_totp_secret_ignores_global(config_factory, tmp_path): + cfg = config_factory(data_dir=str(tmp_path), verify_totp_secret=pyotp.random_base32()) + v = IdentityVerifier(cfg, VerificationStore(cfg)) + assert v.has_own_totp_secret("1001") is False + v.store.set_credentials("1001", generate_totp=True) + assert v.has_own_totp_secret("1001") is True + + +def test_async_facades(config_factory, tmp_path): + import asyncio + cfg = config_factory(data_dir=str(tmp_path), verify_pin="1234") + v = IdentityVerifier(cfg, VerificationStore(cfg)) + assert asyncio.run(v.averify("x", "1234")) == (True, "pin") + assert asyncio.run(v.averify_pin("x", "0000")) is False + assert asyncio.run(v.averify_explicit("77", pin="77")) == (True, "pin") diff --git a/sip-agent/tests/unit/test_map_tool.py b/sip-agent/tests/unit/test_map_tool.py new file mode 100644 index 0000000..03cb6a2 --- /dev/null +++ b/sip-agent/tests/unit/test_map_tool.py @@ -0,0 +1,119 @@ +"""Unit tests for the MAP tool (plugins/map_tool.py): pure spoken-formatting +helpers plus execute() with a monkeypatched Nominatim/OSRM fetch.""" +from types import SimpleNamespace + +import pytest + +from plugins import map_tool +from plugins.map_tool import ( + MapTool, + _short_place, + _spoken_miles, + _spoken_minutes, + _METERS_PER_MILE, +) +from tool_plugins import ToolStatus + +pytestmark = pytest.mark.unit + + +# --- Pure helpers ------------------------------------------------------------ + +@pytest.mark.parametrize( + "meters,expected", + [ + (0.0, "less than a tenth of a mile"), + (_METERS_PER_MILE * 1, "1 mile"), + (_METERS_PER_MILE * 4, "4 miles"), + (_METERS_PER_MILE * 4.3, "4.3 miles"), + (_METERS_PER_MILE * 25.4, "25 miles"), + ], +) +def test_spoken_miles(meters, expected): + assert _spoken_miles(meters) == expected + + +@pytest.mark.parametrize( + "seconds,expected", + [ + (30, "about 1 minute"), + (600, "about 10 minutes"), + (3600, "about 1 hour"), + (3660, "about 1 hour and 1 minute"), + (5400, "about 1 hour and 30 minutes"), + (7200, "about 2 hours"), + ], +) +def test_spoken_minutes(seconds, expected): + assert _spoken_minutes(seconds) == expected + + +def test_short_place_trims_civic_hierarchy(): + full = ("Balboa Park, 6th Avenue, San Diego, San Diego County, " + "California, 92101, United States") + assert _short_place(full, "fallback") == "Balboa Park, 6th Avenue" + assert _short_place("", "fallback") == "fallback" + # Bare house/postal numbers are skipped, not spoken as part of the name. + assert _short_place("San Diego Zoo, 2920, San Diego", "x") == \ + "San Diego Zoo, San Diego" + + +# --- execute() --------------------------------------------------------------- + +def _assistant(**cfg): + cfg.setdefault("weather_latitude", "32.7157") + cfg.setdefault("weather_longitude", "-117.1611") + return SimpleNamespace(config=SimpleNamespace(**cfg), session=None) + + +def _patch(monkeypatch, *, geocode=None, route=None, fail_geocode=False): + """Monkeypatch map_tool.fetch_json to serve Nominatim then OSRM.""" + async def fake(url, params=None, headers=None, **kwargs): + if "nominatim" in url: + if fail_geocode: + return [] + g = geocode or {"lat": "32.7", "lon": "-117.1", + "display_name": "Balboa Park, San Diego"} + return [g] + # OSRM routing + r = route or {"distance": _METERS_PER_MILE * 12, "duration": 1200} + return {"code": "Ok", "routes": [r]} + + monkeypatch.setattr(map_tool, "fetch_json", fake) + + +@pytest.mark.asyncio +async def test_route_from_home(monkeypatch): + _patch(monkeypatch) + tool = MapTool(_assistant()) + result = await tool.execute({"destination": "Balboa Park"}) + assert result.status == ToolStatus.SUCCESS + assert "from home" in result.message + assert "12 miles" in result.message + assert "about 20 minutes" in result.message + assert result.data["distance_miles"] == 12.0 + assert result.data["duration_minutes"] == 20 + + +@pytest.mark.asyncio +async def test_missing_destination_is_rejected(monkeypatch): + _patch(monkeypatch) + tool = MapTool(_assistant()) + result = await tool.execute({"destination": " "}) + assert result.status == ToolStatus.FAILED + + +@pytest.mark.asyncio +async def test_unresolvable_place_reports_gracefully(monkeypatch): + _patch(monkeypatch, fail_geocode=True) + tool = MapTool(_assistant()) + result = await tool.execute({"destination": "Nowhereville"}) + # Not a hard failure - the model gets a speakable "couldn't find" message. + assert result.status == ToolStatus.SUCCESS + assert "couldn't find" in result.message.lower() + + +@pytest.mark.asyncio +async def test_disabled_without_home_coordinates(): + tool = MapTool(_assistant(weather_latitude="", weather_longitude="")) + assert tool.enabled is False diff --git a/sip-agent/tests/unit/test_virtual_numbers.py b/sip-agent/tests/unit/test_virtual_numbers.py index 1a84092..f925be1 100644 --- a/sip-agent/tests/unit/test_virtual_numbers.py +++ b/sip-agent/tests/unit/test_virtual_numbers.py @@ -214,3 +214,67 @@ def test_expired_entries_dropped_on_load(config_factory, tmp_path): reloaded = VirtualNumberRegistry(cfg) assert reloaded.get(entry.id) is None assert [e.id for e in reloaded._expired_on_load] == [entry.id] + + +# --- persistent trigger numbers --------------------------------------------- + +def test_persistent_never_expires_and_survives_calls(config_factory, tmp_path): + reg = make_registry(config_factory, tmp_path) + entry = reg.create(number="7350", purpose="hotline", persistent=True, + callback_url="http://n8n/hook", events=["answered"]) + assert entry.persistent and entry.expires_at == 0.0 + # Sweep leaves it alone regardless of "age". + reg._sweep() + assert reg.get(entry.id) is entry + # Claim -> consume keeps it registered (un-claimed) for the next call. + assert reg.claim("7350") is entry and entry.claimed + assert reg.consume(entry.id) is entry + assert reg.get(entry.id) is entry and not entry.claimed + # A concurrent call while one is live still matches a trigger number. + reg.claim("7350") + assert reg.claim("7350") is entry + # DELETE is the only way out. + assert reg.delete(entry.id) and reg.get(entry.id) is None + + +def test_single_use_still_consumed(config_factory, tmp_path): + reg = make_registry(config_factory, tmp_path) + entry = reg.create(number="7351", purpose="one-shot") + reg.claim("7351") + assert reg.claim("7351") is None + assert reg.consume(entry.id) is entry + assert reg.get(entry.id) is None + + +def test_events_validated(config_factory, tmp_path): + reg = make_registry(config_factory, tmp_path) + with pytest.raises(VirtualNumberError) as e: + reg.create(purpose="x", callback_url="http://h", events=["bogus"]) + assert e.value.status_code == 400 + with pytest.raises(VirtualNumberError) as e: + reg.create(purpose="x", events=["answered"]) # no callback_url + assert e.value.status_code == 400 + entry = reg.create(purpose="x", callback_url="http://h", + events=["speech", "speech", "completed"]) + assert entry.events == ["speech", "completed"] + assert entry.wants("speech") and not entry.wants("answered") + assert reg.create(purpose="default").events == ["completed"] + + +def test_persistent_survives_reload_and_legacy_records(config_factory, tmp_path): + reg = make_registry(config_factory, tmp_path) + trig = reg.create(number="7352", purpose="p", persistent=True, + callback_url="http://h", events=["first_speech"]) + # Hand-write a pre-persistent-era record alongside it. + import json + raw = json.loads(reg._store_file.read_text()) + raw.append({"id": "old1", "number": "7353", "purpose": "legacy", + "expires_at": time.time() + 600, "created_at": time.time()}) + reg._store_file.write_text(json.dumps(raw)) + + reg2 = VirtualNumberRegistry(reg.config) + loaded = reg2.get(trig.id) + assert loaded.persistent and loaded.events == ["first_speech"] + legacy = reg2.get("old1") + assert legacy is not None and not legacy.persistent + assert legacy.events == ["completed"]