#!/usr/bin/env bash
# =====================================================================
#  aiVoice PBX for Linux - one file. Run it and the phone system is up.
#
#     sudo bash aivoice-pbx-linux.sh              install / update and start
#     sudo bash aivoice-pbx-linux.sh uninstall    remove it (keeps your data unless you add --purge)
#     sudo bash aivoice-pbx-linux.sh --local-only install, chat page only on this machine
#
#  What it does: installs Node.js if missing (or too old), installs espeak-ng for
#  spoken prompts, copies aiVoice PBX to /opt/aivoice-pbx, opens the firewall ports,
#  and runs it as a service that starts again after every reboot.
#  Works on Debian, Ubuntu, Raspberry Pi OS, Fedora, RHEL/Rocky/Alma, Arch,
#  openSUSE and Alpine (x64, arm64, armv7).
# =====================================================================
set -u
APP=/opt/aivoice-pbx
DATA=$APP/pbx-data
SVC=aivoice-pbx
SIP_PORT=5060
WEB_PORT=8080
RTP_PORTS=10000:10999
MIN_NODE=14
LOCAL_ONLY=0
ACTION=install
for a in "$@"; do
  case "$a" in
    uninstall|remove) ACTION=uninstall ;;
    --purge) PURGE=1 ;;
    --local-only) LOCAL_ONLY=1 ;;
    -h|--help) sed -n '2,14p' "$0"; exit 0 ;;
  esac
done

say()  { printf '\033[1;36m==>\033[0m %s\n' "$*"; }
ok()   { printf '\033[1;32m  ✓\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33m  !\033[0m %s\n' "$*"; }
die()  { printf '\033[1;31mError:\033[0m %s\n' "$*" >&2; exit 1; }
have() { command -v "$1" >/dev/null 2>&1; }

# ---------- must be root (re-run with sudo if possible)
if [ "$(id -u)" -ne 0 ]; then
  if have sudo; then exec sudo bash "$0" "$@"; fi
  die "please run as root:  sudo bash $0"
fi

HAS_SYSTEMD=0
[ -d /run/systemd/system ] && have systemctl && HAS_SYSTEMD=1

# ---------- uninstall
if [ "$ACTION" = uninstall ]; then
  say "Removing aiVoice PBX"
  if [ $HAS_SYSTEMD = 1 ]; then systemctl disable --now $SVC >/dev/null 2>&1; rm -f /etc/systemd/system/$SVC.service; systemctl daemon-reload; fi
  [ -f $DATA/running.json ] && kill "$(sed -n 's/.*"pid":\([0-9]*\).*/\1/p' $DATA/running.json)" 2>/dev/null
  pkill -f "$APP/aivoice-pbx.js" 2>/dev/null
  if [ "${PURGE:-0}" = 1 ]; then rm -rf "$APP"; ok "Removed $APP including voicemail, recordings and settings"
  else rm -f "$APP/aivoice-pbx.js" "$APP/aivoice-pbx.new.js" "$APP/run.sh"; ok "Program removed. Your settings, voicemail and recordings are still in $DATA (add --purge to delete them)"; fi
  exit 0
fi

# ---------- package manager
PM=""
for p in apt-get dnf yum pacman zypper apk; do have $p && { PM=$p; break; }; done
pkg_install() {
  case "$PM" in
    apt-get) DEBIAN_FRONTEND=noninteractive apt-get install -y -q "$@" ;;
    dnf|yum) $PM install -y -q "$@" ;;
    pacman)  pacman -S --noconfirm --needed "$@" ;;
    zypper)  zypper -n -q install "$@" ;;
    apk)     apk add -q "$@" ;;
    *) return 1 ;;
  esac
}
APT_UPDATED=0
pkg_refresh() { [ "$PM" = apt-get ] && [ $APT_UPDATED = 0 ] && { apt-get update -q >/dev/null 2>&1; APT_UPDATED=1; }; [ "$PM" = apk ] && apk update -q >/dev/null 2>&1; return 0; }

node_ok() { "$1" -e "process.exit(+process.versions.node.split('.')[0] >= $MIN_NODE ? 0 : 1)" >/dev/null 2>&1; }
fetch() { if have curl; then curl -fsSL "$1" -o "$2"; elif have wget; then wget -q "$1" -O "$2"; else return 1; fi; }

# ---------- 1. Node.js
say "Checking Node.js"
NODE=""
for n in "$APP/node/bin/node" "$(command -v node 2>/dev/null)" "$(command -v nodejs 2>/dev/null)"; do
  [ -n "$n" ] && [ -x "$n" ] && node_ok "$n" && { NODE="$n"; break; }
done
if [ -z "$NODE" ] && [ -n "$PM" ]; then
  say "Installing Node.js from your Linux packages"
  pkg_refresh; pkg_install nodejs >/dev/null 2>&1
  for n in "$(command -v node 2>/dev/null)" "$(command -v nodejs 2>/dev/null)"; do
    [ -n "$n" ] && node_ok "$n" && { NODE="$n"; break; }
  done
  [ -z "$NODE" ] && warn "your Linux packages don't have Node.js $MIN_NODE or newer; downloading the official Node.js instead"
fi
if [ -z "$NODE" ]; then
  case "$(uname -m)" in
    x86_64|amd64) ARCH=x64 ;; aarch64|arm64) ARCH=arm64 ;; armv7l|armv7*) ARCH=armv7l ;;
    *) die "no Node.js build for this processor ($(uname -m)). Install Node.js $MIN_NODE+ yourself, then run this again." ;;
  esac
  if [ -f /etc/alpine-release ]; then die "on Alpine please run: apk add nodejs   then run this again"; fi
  have curl || have wget || { pkg_refresh; pkg_install curl >/dev/null 2>&1; }
  have tar || { pkg_refresh; pkg_install tar >/dev/null 2>&1; }
  have gzip || { pkg_refresh; pkg_install gzip >/dev/null 2>&1; }
  BASE=${AIVOICEPBX_NODE_MIRROR:-https://nodejs.org/dist/latest-v22.x}
  TMP=$(mktemp -d)
  fetch "$BASE/SHASUMS256.txt" "$TMP/sums" || die "could not reach nodejs.org to download Node.js. Check the internet connection."
  FILE=$(grep -o "node-v[0-9.]*-linux-$ARCH.tar.gz" "$TMP/sums" | head -1)
  [ -n "$FILE" ] || die "nodejs.org has no Node.js 22 for linux-$ARCH"
  say "Downloading $FILE"
  fetch "$BASE/$FILE" "$TMP/$FILE" || die "download of Node.js failed"
  if have sha256sum; then
    (cd "$TMP" && grep " $FILE\$" sums | sha256sum -c - >/dev/null 2>&1) || die "the Node.js download is damaged (checksum mismatch). Run this again."
  fi
  rm -rf "$APP/node"; mkdir -p "$APP/node"
  tar -xzf "$TMP/$FILE" -C "$APP/node" --strip-components=1 || die "could not unpack Node.js"
  rm -rf "$TMP"
  NODE="$APP/node/bin/node"
  node_ok "$NODE" || die "the downloaded Node.js does not run on this machine"
fi
ok "Node.js $("$NODE" -v) ($NODE)"

# ---------- 2. voice for prompts (optional)
if have espeak-ng || have espeak; then ok "speech engine found (spoken prompts on)"
elif [ -n "$PM" ]; then
  pkg_refresh
  if pkg_install espeak-ng >/dev/null 2>&1 || pkg_install espeak >/dev/null 2>&1; then ok "installed espeak-ng (spoken prompts on)"
  else warn "could not install espeak-ng: voicemail and menus will use beeps instead of a voice"; fi
fi

# ---------- 3. the program
say "Installing aiVoice PBX to $APP"
mkdir -p "$APP" "$DATA"
cat > "$APP/aivoice-pbx.new.js" <<'AIVOICEPBX_SOURCE_END'
#!/usr/bin/env node
/*
 * aiVoice PBX: a complete SIP phone system in one file, with no dependencies.
 * Configure it by chatting with it in your browser.
 *
 *   node aivoice-pbx.js            start (opens the chat page)
 *   node aivoice-pbx.js --no-browser
 *   node aivoice-pbx.js --data <folder>   keep config, voicemail and recordings somewhere else
 *
 * Phones: any SIP softphone or desk phone (MicroSIP, Zoiper, Linphone, Yealink...).
 * Codecs: G.711 u-law and A-law, DTMF by RFC 2833 or SIP INFO. Transport: UDP.
 */
'use strict';
const dgram = require('dgram'), http = require('http'), https = require('https'), crypto = require('crypto');
const fs = require('fs'), path = require('path'), os = require('os'), dns = require('dns');
const { execFile } = require('child_process');

const VERSION = '1.0';
const ARGS = process.argv.slice(2);
const arg = (name, def) => { const i = ARGS.indexOf('--' + name); return i >= 0 && ARGS[i + 1] ? ARGS[i + 1] : def; };
const flag = name => ARGS.includes('--' + name);
const DATA = path.resolve(arg('data', path.join(__dirname, 'pbx-data')));
const DIRS = { prompts: path.join(DATA, 'prompts'), vm: path.join(DATA, 'voicemail'), rec: path.join(DATA, 'recordings') };
for (const d of [DATA, ...Object.values(DIRS)]) fs.mkdirSync(d, { recursive: true });
const CONFIG_FILE = path.join(DATA, 'config.json');
const CDR_FILE = path.join(DATA, 'calllog.json');

// ---------------------------------------------------------------- small helpers
const md5 = s => crypto.createHash('md5').update(s).digest('hex');
const randHex = n => crypto.randomBytes(Math.ceil(n / 2)).toString('hex').slice(0, n);
const randPass = () => { const a = 'abcdefghjkmnpqrstuvwxyzABCDEFGHJKMNPQRSTUVWXYZ23456789'; let s = ''; const b = crypto.randomBytes(10); for (let i = 0; i < 10; i++) s += a[b[i] % a.length]; return s; };
const sleep = ms => new Promise(r => setTimeout(r, ms));
const now = () => Date.now();
const isPrivateIp = ip => /^(10\.|127\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.|169\.254\.)/.test(ip || '');
// every IPv4 address on this computer, best guess first
function localIps() {
  const cands = [];
  for (const [name, list] of Object.entries(os.networkInterfaces())) for (const a of list || []) {
    if (a.family !== 'IPv4' && a.family !== 4) continue;
    if (a.internal) continue;
    const virtual = /vEthernet|VirtualBox|VMware|docker|br-|veth|WSL|Hyper-V|tailscale|zt/i.test(name);
    cands.push({ ip: a.address, adapter: name, virtual, score: (isPrivateIp(a.address) ? 2 : 1) + (virtual ? -2 : 0) + (/^192\.168\./.test(a.address) ? 1 : 0) - (/^169\.254\./.test(a.address) ? 3 : 0) });
  }
  return cands.sort((x, y) => y.score - x.score);
}
function lanIp() { const c = localIps(); return c.length ? c[0].ip : '127.0.0.1'; }
const IP_FROM_ARGS = arg('ip', '');
let LAN_IP = IP_FROM_ARGS || lanIp(); // the address phones are told to use (may be replaced by the saved choice in main())
const isIpv4 = s => /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.test(s || '') && s.split('.').every(x => +x <= 255);

// ---------------------------------------------------------------- logging
const LOG = [], SIPLOG = [];
const DEBUG = flag('debug');
function log(...a) {
  const line = `${new Date().toLocaleTimeString()}  ${a.join(' ')}`;
  LOG.push(line); if (LOG.length > 400) LOG.shift();
  if (!flag('quiet')) console.log(line);
}
function sipTrace(dir, addr, text) {
  const first = text.slice(0, text.indexOf('\r\n'));
  SIPLOG.push({ t: now(), dir, addr: `${addr.address}:${addr.port}`, first, text: text.length > 6000 ? text.slice(0, 6000) : text });
  if (SIPLOG.length > 300) SIPLOG.shift();
  if (DEBUG) console.log(`${dir === 'out' ? '>>' : '<<'} ${addr.address}:${addr.port}\n${text}`);
}

// ---------------------------------------------------------------- configuration
function newExtension(name) {
  return { name: name || '', password: randPass(), voicemail: true, vmPin: String(1000 + Math.floor(Math.random() * 9000)), vmGreeting: '', dnd: false,
    forward: { always: '', busy: '', noAnswer: '', unavailable: '' }, ringTime: 0, record: false };
}
function defaultConfig() {
  return {
    sipPort: 5060, httpPort: 8080, rtpStart: 10000, rtpEnd: 10999, publicIp: '', lanIp: '', realm: 'aivoice-pbx',
    ringTime: 25, callWaiting: true, recordAll: false, adminRemote: false, adminPassword: '',
    extensions: { '101': newExtension('Alice'), '102': newExtension('Bob') },
    groups: {}, queues: {}, ivrs: {}, conferences: {},
    trunk: { enabled: false, host: '', port: 5060, username: '', authUser: '', password: '', domain: '', register: true, outPrefix: '', outMinDigits: 6, callerId: '', inbound: '' },
    ai: { enabled: false, apiKey: '', model: 'claude-sonnet-5-5' },
  };
}
let CFG;
function loadConfig() {
  const def = defaultConfig();
  try {
    CFG = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
    const missing = Object.keys(def).filter(k => CFG[k] === undefined);
    for (const k of missing) CFG[k] = def[k];
    if (missing.includes('extensions')) saveConfig(); // a hand-made starter config: keep the new passwords
    for (const k of Object.keys(def.trunk)) if (CFG.trunk[k] === undefined) CFG.trunk[k] = def.trunk[k];
    for (const k of Object.keys(def.ai)) if (CFG.ai[k] === undefined) CFG.ai[k] = def.ai[k];
    for (const e of Object.values(CFG.extensions)) { const d = newExtension(); for (const k of Object.keys(d)) if (e[k] === undefined) e[k] = d[k]; for (const k of Object.keys(d.forward)) if (e.forward[k] === undefined) e.forward[k] = ''; }
  } catch (e) {
    CFG = def;
    saveConfig();
  }
}
function saveConfig() {
  fs.writeFileSync(CONFIG_FILE + '.tmp', JSON.stringify(CFG, null, 2));
  fs.renameSync(CONFIG_FILE + '.tmp', CONFIG_FILE);
}
const HISTORY = [];
function snapshot() { HISTORY.push(JSON.stringify(CFG)); if (HISTORY.length > 40) HISTORY.shift(); }
const pbxIpFor = addr => (addr && !isPrivateIp(addr.address) && CFG.publicIp) ? CFG.publicIp : LAN_IP;

// ---------------------------------------------------------------- call log
let CDR = [];
try { CDR = JSON.parse(fs.readFileSync(CDR_FILE, 'utf8')); } catch (e) { CDR = []; }
let cdrTimer = null;
function addCdr(r) {
  CDR.unshift(r); if (CDR.length > 500) CDR.length = 500;
  clearTimeout(cdrTimer); cdrTimer = setTimeout(() => { try { fs.writeFileSync(CDR_FILE, JSON.stringify(CDR)); } catch (e) {} }, 1000);
}

// ---------------------------------------------------------------- G.711 codecs
const ULAW_DEC = new Int16Array(256), ALAW_DEC = new Int16Array(256);
const ULAW_ENC = new Uint8Array(65536), ALAW_ENC = new Uint8Array(65536);
(function buildG711() {
  const SEG_U = [0x3F, 0x7F, 0xFF, 0x1FF, 0x3FF, 0x7FF, 0xFFF, 0x1FFF], SEG_A = [0x1F, 0x3F, 0x7F, 0xFF, 0x1FF, 0x3FF, 0x7FF, 0xFFF];
  const seg = (v, t) => { for (let i = 0; i < 8; i++) if (v <= t[i]) return i; return 8; };
  for (let i = 0; i < 256; i++) {
    let u = ~i & 0xff, t = ((u & 0x0f) << 3) + 0x84; t <<= (u & 0x70) >> 4; ULAW_DEC[i] = (u & 0x80) ? 0x84 - t : t - 0x84;
    let a = i ^ 0x55, ta = (a & 0x0f) << 4; const sg = (a & 0x70) >> 4;
    if (sg === 0) ta += 8; else if (sg === 1) ta += 0x108; else { ta += 0x108; ta <<= sg - 1; }
    ALAW_DEC[i] = (a & 0x80) ? ta : -ta;
  }
  for (let k = 0; k < 65536; k++) {
    const s = k - 32768;
    let p = s >> 2, mask; if (p < 0) { p = -p; mask = 0x7F; } else mask = 0xFF;
    if (p > 8159) p = 8159; p += 0x84 >> 2;
    let sg = seg(p, SEG_U); ULAW_ENC[k] = sg >= 8 ? (0x7F ^ mask) : (((sg << 4) | ((p >> (sg + 1)) & 0xF)) ^ mask);
    let q = s >> 3; if (q >= 0) mask = 0xD5; else { mask = 0x55; q = -q - 1; }
    sg = seg(q, SEG_A);
    if (sg >= 8) ALAW_ENC[k] = 0x7F ^ mask; else { let av = sg << 4; av |= sg < 2 ? (q >> 1) & 0x0F : (q >> sg) & 0x0F; ALAW_ENC[k] = av ^ mask; }
  }
})();
function g711Decode(buf, pt) { const t = pt === 8 ? ALAW_DEC : ULAW_DEC, o = new Int16Array(buf.length); for (let i = 0; i < buf.length; i++) o[i] = t[buf[i]]; return o; }
function g711Encode(pcm, pt) { const t = pt === 8 ? ALAW_ENC : ULAW_ENC, o = Buffer.allocUnsafe(pcm.length); for (let i = 0; i < pcm.length; i++) o[i] = t[pcm[i] + 32768]; return o; }

// ---------------------------------------------------------------- audio helpers (8 kHz, 16-bit mono)
const RATE = 8000, FRAME = 160;
function concatPcm(list) { const n = list.reduce((a, b) => a + b.length, 0), o = new Int16Array(n); let p = 0; for (const x of list) { o.set(x, p); p += x.length; } return o; }
const silence = ms => new Int16Array(Math.round(RATE * ms / 1000));
function tone(freqs, ms, amp = 7000) {
  const n = Math.round(RATE * ms / 1000), o = new Int16Array(n), fade = Math.min(80, n / 4);
  for (let i = 0; i < n; i++) {
    let v = 0; for (const f of freqs) v += Math.sin(2 * Math.PI * f * i / RATE);
    const e = Math.min(1, i / fade, (n - i) / fade);
    o[i] = Math.round(v / freqs.length * amp * e);
  }
  return o;
}
const BEEP = tone([1000], 300, 9000);
const JOIN_BEEP = concatPcm([tone([660], 120), tone([990], 160)]);
const LEAVE_BEEP = concatPcm([tone([990], 120), tone([660], 160)]);
function readWav(buf) {
  if (buf.toString('ascii', 0, 4) !== 'RIFF' || buf.toString('ascii', 8, 12) !== 'WAVE') throw new Error('not a wav file');
  let p = 12, fmt = null, data = null;
  while (p + 8 <= buf.length) {
    const id = buf.toString('ascii', p, p + 4), size = buf.readUInt32LE(p + 4);
    if (id === 'fmt ') fmt = { tag: buf.readUInt16LE(p + 8), ch: buf.readUInt16LE(p + 10), rate: buf.readUInt32LE(p + 12), bits: buf.readUInt16LE(p + 22) };
    if (id === 'data') data = buf.subarray(p + 8, Math.min(buf.length, p + 8 + size));
    p += 8 + size + (size & 1);
  }
  if (!fmt || !data) throw new Error('bad wav');
  const frames = fmt.tag === 1 ? Math.floor(data.length / (fmt.bits / 8) / fmt.ch) : Math.floor(data.length / fmt.ch);
  const mono = new Int16Array(frames);
  for (let i = 0; i < frames; i++) {
    let sum = 0;
    for (let c = 0; c < fmt.ch; c++) {
      const k = i * fmt.ch + c;
      if (fmt.tag === 1 && fmt.bits === 16) sum += data.readInt16LE(k * 2);
      else if (fmt.tag === 1 && fmt.bits === 8) sum += (data[k] - 128) << 8;
      else if (fmt.tag === 7) sum += ULAW_DEC[data[k]];
      else if (fmt.tag === 6) sum += ALAW_DEC[data[k]];
    }
    mono[i] = sum / fmt.ch;
  }
  return resample(mono, fmt.rate, RATE);
}
function resample(pcm, from, to) {
  if (from === to) return pcm;
  const n = Math.floor(pcm.length * to / from), o = new Int16Array(n), r = from / to;
  // simple low-pass before downsampling to avoid harsh aliasing
  let src = pcm;
  if (from > to) { src = new Int16Array(pcm.length); const k = Math.max(1, Math.round(r)); let acc = 0; for (let i = 0; i < pcm.length; i++) { acc += pcm[i]; if (i >= k) acc -= pcm[i - k]; src[i] = acc / Math.min(k, i + 1); } }
  for (let i = 0; i < n; i++) { const x = i * r, a = Math.floor(x), f = x - a; o[i] = (src[a] || 0) * (1 - f) + (src[a + 1] || src[a] || 0) * f; }
  return o;
}
function writeWav(file, channels) {
  const ch = channels.length, n = Math.max(...channels.map(c => c.length)), data = Buffer.alloc(n * ch * 2);
  for (let i = 0; i < n; i++) for (let c = 0; c < ch; c++) data.writeInt16LE(channels[c][i] || 0, (i * ch + c) * 2);
  const h = Buffer.alloc(44);
  h.write('RIFF', 0); h.writeUInt32LE(36 + data.length, 4); h.write('WAVE', 8); h.write('fmt ', 12); h.writeUInt32LE(16, 16);
  h.writeUInt16LE(1, 20); h.writeUInt16LE(ch, 22); h.writeUInt32LE(RATE, 24); h.writeUInt32LE(RATE * ch * 2, 28); h.writeUInt16LE(ch * 2, 32); h.writeUInt16LE(16, 34);
  h.write('data', 36); h.writeUInt32LE(data.length, 40);
  fs.writeFileSync(file, Buffer.concat([h, data]));
}
// Music on hold: a calm, original pentatonic loop generated at start-up (no files needed).
const MOH = (function makeMoh() {
  const notes = [220, 261.63, 293.66, 329.63, 392, 440, 523.25, 587.33];
  const melody = [0, 2, 4, 5, 4, 2, 3, 1, 0, 2, 4, 6, 5, 4, 2, 3, 4, 2, 0, 1, 2, 4, 5, 7, 6, 5, 4, 2, 3, 2, 1, 0];
  const chords = [[110, 164.81], [87.31, 130.81], [130.81, 196], [98, 146.83]];
  const step = 0.375, n = Math.round(melody.length * step * RATE), o = new Int16Array(n);
  for (let i = 0; i < n; i++) {
    const t = i / RATE, k = Math.floor(t / step), tn = t - k * step, f = notes[melody[k % melody.length]];
    const env = Math.min(1, tn / 0.01) * Math.exp(-tn * 3.2);
    let v = (Math.sin(2 * Math.PI * f * t) + 0.25 * Math.sin(4 * Math.PI * f * t)) * env * 0.5;
    const ch = chords[Math.floor(t / (step * 8)) % 4];
    v += (Math.sin(2 * Math.PI * ch[0] * t) + Math.sin(2 * Math.PI * ch[1] * t)) * 0.07;
    o[i] = Math.max(-32000, Math.min(32000, v * 9000));
  }
  return o;
})();

// ---------------------------------------------------------------- spoken prompts
// Windows: built-in speech (SAPI). macOS: say. Linux: espeak-ng / espeak. Otherwise: a short chime.
const ttsCache = new Map();
let ttsWorks = null;
function synthToFile(text, file) {
  return new Promise((resolve, reject) => {
    const done = e => e ? reject(e) : resolve();
    if (process.platform === 'win32') {
      const q = s => s.replace(/'/g, "''");
      const ps = `Add-Type -AssemblyName System.Speech; $s = New-Object System.Speech.Synthesis.SpeechSynthesizer; ` +
        `$f = New-Object System.Speech.AudioFormat.SpeechAudioFormatInfo(8000, [System.Speech.AudioFormat.AudioBitsPerSample]::Sixteen, [System.Speech.AudioFormat.AudioChannel]::Mono); ` +
        `$s.SetOutputToWaveFile('${q(file)}', $f); $s.Speak('${q(text)}'); $s.Dispose()`;
      execFile('powershell.exe', ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', ps], { timeout: 30000, windowsHide: true }, done);
    } else if (process.platform === 'darwin') {
      execFile('say', ['-o', file, '--data-format=LEI16@8000', text], { timeout: 30000 }, done);
    } else {
      execFile('espeak-ng', ['-s', '150', '-w', file, text], { timeout: 30000 }, e => { if (!e) return resolve(); execFile('espeak', ['-s', '150', '-w', file, text], { timeout: 30000 }, done); });
    }
  });
}
async function say(text) {
  text = String(text || '').trim();
  if (!text) return new Int16Array(0);
  if (ttsCache.has(text)) return ttsCache.get(text);
  const file = path.join(DIRS.prompts, md5(text).slice(0, 20) + '.wav');
  let pcm = null;
  try {
    if (!fs.existsSync(file) && ttsWorks !== false) await synthToFile(text, file);
    pcm = readWav(fs.readFileSync(file));
    ttsWorks = true;
  } catch (e) {
    if (ttsWorks === null) { ttsWorks = false; log('Speech: no text-to-speech engine found, prompts will be chimes.'); }
    pcm = concatPcm([tone([523.25], 140, 5000), tone([659.25], 140, 5000), tone([783.99], 220, 5000), silence(150)]);
  }
  ttsCache.set(text, pcm);
  return pcm;
}
const spell = s => String(s).split('').join(' ');
const PROMPTS = {
  vmDefault: name => `${name || 'The person you are calling'} is not available. Please leave a message after the tone. Press pound when you are finished.`,
  goodbye: 'Goodbye.',
  invalid: 'Sorry, that is not a valid option.',
  notInService: 'Sorry, the number you dialed is not in service.',
  confPin: 'Please enter the conference PIN, followed by pound.',
  confWrong: 'That PIN is not correct.',
  confAlone: 'You are the first person in the conference.',
  queueWelcome: 'Thank you for calling. Please hold, and the next available person will be with you.',
  queueHold: 'Thank you for holding. Please stay on the line.',
  queueNobody: 'Sorry, nobody is available to take your call. Goodbye.',
  echo: 'Echo test. Speak, and you will hear yourself. Hang up when you are finished.',
  vmMenu: 'Press 7 to delete, 9 to save, 1 to replay, or pound for the next message.',
  vmDeleted: 'Message deleted.', vmSaved: 'Message saved.', vmNoMore: 'You have no more messages. Goodbye.',
  dndOn: 'Do not disturb is on.', dndOff: 'Do not disturb is off.', fwdOn: n => `Calls will be forwarded to ${spell(n)}.`, fwdOff: 'Call forwarding is off.',
};

// ================================================================ SIP messages
const COMPACT = { v: 'via', f: 'from', t: 'to', i: 'call-id', m: 'contact', l: 'content-length', c: 'content-type', k: 'supported', o: 'event', r: 'refer-to', b: 'referred-by', u: 'allow-events' };
function parseSip(buf) {
  const s = buf.toString('utf8');
  const sep = s.indexOf('\r\n\r\n');
  const head = sep < 0 ? s : s.slice(0, sep);
  let body = sep < 0 ? '' : s.slice(sep + 4);
  const raw = head.split(/\r?\n/);
  const lines = [];
  for (const l of raw) { if (/^[ \t]/.test(l) && lines.length) lines[lines.length - 1] += ' ' + l.trim(); else lines.push(l); }
  const first = lines.shift() || '';
  const m = { headers: [], body: '' };
  if (first.startsWith('SIP/2.0 ')) { const p = first.split(' '); m.status = +p[1]; m.reason = p.slice(2).join(' '); if (!m.status) return null; }
  else { const p = first.split(' '); if (p.length < 3 || p[2] !== 'SIP/2.0') return null; m.method = p[0].toUpperCase(); m.uri = p[1]; }
  for (const l of lines) {
    const i = l.indexOf(':'); if (i < 1) continue;
    let name = l.slice(0, i).trim().toLowerCase(); name = COMPACT[name] || name;
    const value = l.slice(i + 1).trim();
    if (name === 'via') for (const v of value.split(/,(?=\s*SIP\/)/i)) m.headers.push([name, v.trim()]);
    else m.headers.push([name, value]);
  }
  const cl = hdr(m, 'content-length');
  if (cl !== null && !isNaN(+cl)) body = Buffer.from(body, 'utf8').subarray(0, +cl).toString('utf8');
  m.body = body;
  return m;
}
const hdr = (m, n) => { const x = m.headers.find(h => h[0] === n); return x ? x[1] : null; };
const hdrs = (m, n) => m.headers.filter(h => h[0] === n).map(h => h[1]);
const tagOf = v => (/;\s*tag=([^;>\s,]+)/i.exec(v || '') || [])[1] || null;
const uriOf = v => { if (!v) return ''; const a = /<([^>]*)>/.exec(v); return a ? a[1] : v.split(';')[0].trim(); };
const userOf = v => { const u = uriOf(v); const a = /^sips?:([^@;>?]+)@/i.exec(u); return a ? decodeURIComponent(a[1]) : ''; };
const displayOf = v => { const a = /^\s*"([^"]*)"/.exec(v || ''); if (a) return a[1]; const b = /^\s*([^<"]+?)\s*</.exec(v || ''); return b ? b[1] : ''; };
const paramOf = (v, name) => { const a = new RegExp(`;\\s*${name}=(?:"([^"]*)"|([^;>\\s,]+))`, 'i').exec(v || ''); return a ? (a[1] !== undefined ? a[1] : a[2]) : null; };
const branchOf = via => (/;\s*branch=([^;\s,]+)/i.exec(via || '') || [])[1] || '';
function hostPortOf(uri) {
  const a = /^sips?:(?:[^@]+@)?([^;:>?]+)(?::(\d+))?/i.exec(uriOf(uri)) || [];
  return { address: a[1] || '', port: +(a[2] || 5060) };
}
function buildMsg(first, headers, body = '') {
  const b = Buffer.from(body || '', 'utf8');
  return first + '\r\n' + headers.filter(h => h && h[1] !== null && h[1] !== undefined).map(([k, v]) => `${k}: ${v}`).join('\r\n') + `\r\nContent-Length: ${b.length}\r\n\r\n` + (body || '');
}
function parseAuth(v) { const o = {}; const re = /(\w+)\s*=\s*("([^"]*)"|[^,\s]+)/g; let x; while ((x = re.exec(v || ''))) o[x[1].toLowerCase()] = x[3] !== undefined ? x[3] : x[2]; return o; }
const ALLOW = 'INVITE, ACK, CANCEL, BYE, OPTIONS, REFER, NOTIFY, SUBSCRIBE, INFO, MESSAGE, UPDATE';
const UA_NAME = `aiVoicePBX/${VERSION}`;

// ================================================================ SDP
function buildSdp(rtp, ip, dir = 'sendrecv', pts = [0, 8]) {
  const sess = rtp.sdpSess || (rtp.sdpSess = String(Math.floor(Math.random() * 1e9)));
  rtp.sdpVer = (rtp.sdpVer || 0) + 1;
  const lines = ['v=0', `o=aiVoicePBX ${sess} ${rtp.sdpVer} IN IP4 ${ip}`, 's=aiVoicePBX', `c=IN IP4 ${ip}`, 't=0 0',
    `m=audio ${rtp.port} RTP/AVP ${pts.join(' ')} 101`];
  for (const p of pts) lines.push(p === 0 ? 'a=rtpmap:0 PCMU/8000' : 'a=rtpmap:8 PCMA/8000');
  lines.push('a=rtpmap:101 telephone-event/8000', 'a=fmtp:101 0-16', 'a=ptime:20', `a=${dir}`);
  return lines.join('\r\n') + '\r\n';
}
function parseSdp(body) {
  if (!body) return null;
  const r = { ip: '', port: 0, pts: [], dtmfPt: 101, dir: 'sendrecv' };
  let inAudio = false, sessIp = '';
  for (const l of body.split(/\r?\n/)) {
    if (l.startsWith('c=')) { const ip = l.split(' ')[2]; if (inAudio) r.ip = ip; else sessIp = ip; }
    else if (l.startsWith('m=')) { const p = l.slice(2).split(' '); inAudio = p[0] === 'audio'; if (inAudio) { r.port = +p[1]; r.pts = p.slice(3).map(Number); } }
    else if (inAudio && l.startsWith('a=rtpmap:') && /telephone-event/i.test(l)) r.dtmfPt = +l.slice(9).split(' ')[0];
    else if (/^a=(sendrecv|sendonly|recvonly|inactive)/.test(l)) r.dir = l.slice(2).trim();
  }
  if (!r.ip) r.ip = sessIp;
  r.hold = r.dir === 'sendonly' || r.dir === 'inactive' || r.ip === '0.0.0.0';
  return r.port ? r : null;
}
const choosePt = offer => offer && offer.pts.find(p => p === 0 || p === 8);

// ================================================================ transport and transactions
let sipSock = null;
function sipSend(text, addr) {
  if (!addr || !addr.address) return;
  sipTrace('out', addr, text);
  const b = Buffer.from(text, 'utf8');
  sipSock.send(b, 0, b.length, addr.port, addr.address);
}
const srvTx = new Map();   // incoming requests: branch:method -> { resp, addr, at }
const cliTx = new Map();   // outgoing requests: branch -> { ... }
setInterval(() => {
  const t = now();
  for (const [k, v] of srvTx) if (t - v.at > 40000) srvTx.delete(k);
  for (const [k, v] of cliTx) if (v.done && t - v.doneAt > 40000) cliTx.delete(k);
}, 10000).unref();

function respond(req, code, reason, { headers = [], body = '', toTag = null, ctype = null } = {}) {
  const vias = hdrs(req, 'via');
  let to = hdr(req, 'to') || '';
  if (code > 100 && !tagOf(to)) to += `;tag=${toTag || randHex(10)}`;
  const h = vias.map(v => ['Via', v]);
  h.push(['From', hdr(req, 'from')], ['To', to], ['Call-ID', hdr(req, 'call-id')], ['CSeq', hdr(req, 'cseq')], ['Server', UA_NAME]);
  for (const x of headers) h.push(x);
  if (body) h.push(['Content-Type', ctype || 'application/sdp']);
  const text = buildMsg(`SIP/2.0 ${code} ${reason}`, h, body);
  const key = branchOf(vias[0]) + ':' + req.method;
  srvTx.set(key, { resp: text, addr: req.src, at: now(), final: code >= 200 });
  sipSend(text, req.src);
  return text;
}
// Send a request and retransmit it until answered (RFC 3261 timers A/E).
function sendRequest(text, addr, branch, method, onResponse) {
  const tx = { text, addr, method, onResponse, interval: 500, tries: 0, started: now(), done: false, provisional: false };
  cliTx.set(branch, tx);
  const fire = () => {
    if (tx.done || (method === 'INVITE' && tx.provisional)) return;
    if (now() - tx.started > 32000) { tx.done = true; tx.doneAt = now(); onResponse && onResponse(null); return; }
    sipSend(tx.text, tx.addr);
    tx.tries++;
    if (method === 'INVITE' && tx.provisional) return;
    tx.interval = method === 'INVITE' ? tx.interval * 2 : Math.min(tx.interval * 2, 4000);
    tx.timer = setTimeout(fire, tx.interval);
  };
  fire();
  return tx;
}
function onSipResponse(m) {
  const branch = branchOf(hdr(m, 'via'));
  const tx = cliTx.get(branch);
  if (!tx) return;
  if (m.status < 200) { tx.provisional = true; if (tx.method !== 'INVITE') { clearTimeout(tx.timer); tx.interval = 4000; tx.timer = setTimeout(() => { if (!tx.done) { sipSend(tx.text, tx.addr); } }, 4000); } }
  else { clearTimeout(tx.timer); if (tx.done && tx.method !== 'INVITE') return; tx.done = true; tx.doneAt = now(); }
  tx.onResponse && tx.onResponse(m);
}
function viaFor(addr, branch) { return `SIP/2.0/UDP ${pbxIpFor(addr)}:${CFG.sipPort};branch=${branch};rport`; }
const newBranch = () => 'z9hG4bK' + randHex(16);
const contactFor = addr => `<sip:pbx@${pbxIpFor(addr)}:${CFG.sipPort}>`;

// ================================================================ authentication
const NONCES = new Map();
setInterval(() => { const t = now(); for (const [k, v] of NONCES) if (t - v > 300000) NONCES.delete(k); }, 60000).unref();
function challenge(req, code) {
  const nonce = randHex(24); NONCES.set(nonce, now());
  const h = code === 401 ? 'WWW-Authenticate' : 'Proxy-Authenticate';
  respond(req, code, code === 401 ? 'Unauthorized' : 'Proxy Authentication Required', { headers: [[h, `Digest realm="${CFG.realm}", nonce="${nonce}", algorithm=MD5, qop="auth"`]] });
}
// Returns true when the request carries valid credentials; otherwise it has already answered the request.
function checkAuth(req, user, pass, code) {
  const a = hdr(req, code === 401 ? 'authorization' : 'proxy-authorization');
  if (!a) { challenge(req, code); return false; }
  const p = parseAuth(a);
  if (!p.nonce || !NONCES.has(p.nonce) || (p.username && p.username !== user)) { challenge(req, code); return false; }
  const ha1 = md5(`${p.username}:${p.realm}:${pass}`), ha2 = md5(`${req.method}:${p.uri}`);
  const want = p.qop ? md5(`${ha1}:${p.nonce}:${p.nc}:${p.cnonce}:${p.qop}:${ha2}`) : md5(`${ha1}:${p.nonce}:${ha2}`);
  if (want !== p.response) { log(`Wrong password from ${req.src.address} for ${user}`); respond(req, 403, 'Forbidden'); return false; }
  return true;
}
// Answer a 401/407 challenge from someone else (used for the SIP trunk).
function digestHeader(challengeValue, method, uri, user, pass) {
  const c = parseAuth(challengeValue);
  const ha1 = md5(`${user}:${c.realm}:${pass}`), ha2 = md5(`${method}:${uri}`);
  let v = `Digest username="${user}", realm="${c.realm}", nonce="${c.nonce}", uri="${uri}", algorithm=MD5`;
  if (c.qop && /auth/.test(c.qop)) {
    const cnonce = randHex(16), nc = '00000001';
    v += `, response="${md5(`${ha1}:${c.nonce}:${nc}:${cnonce}:auth:${ha2}`)}", qop=auth, nc=${nc}, cnonce="${cnonce}"`;
  } else v += `, response="${md5(`${ha1}:${c.nonce}:${ha2}`)}"`;
  if (c.opaque) v += `, opaque="${c.opaque}"`;
  return v;
}

// ================================================================ registrar
const REGS = new Map();   // extension -> { contact, addr, expires, ua }
setInterval(() => { const t = now(); for (const [k, v] of REGS) if (v.expires < t) { REGS.delete(k); log(`Phone ${k} went offline (registration expired)`); } }, 5000).unref();
function onRegister(m) {
  const user = userOf(hdr(m, 'to'));
  const ext = CFG.extensions[user];
  if (!ext) return respond(m, 404, 'Not Found');
  if (!checkAuth(m, user, ext.password, 401)) return;
  const contact = hdr(m, 'contact') || '';
  let exp = paramOf(contact, 'expires'); if (exp === null) exp = hdr(m, 'expires'); exp = exp === null ? 3600 : +exp;
  if (contact.trim() === '*' || exp === 0) {
    if (REGS.has(user)) log(`Phone ${user} signed off`);
    REGS.delete(user);
    return respond(m, 200, 'OK', { headers: [['Expires', '0']] });
  }
  exp = Math.max(60, Math.min(exp, 900));
  const was = REGS.get(user);
  REGS.set(user, { contact: uriOf(contact), addr: m.src, expires: now() + exp * 1000, ua: hdr(m, 'user-agent') || '' });
  if (!was) log(`Phone ${user} (${ext.name || 'no name'}) is online from ${m.src.address}:${m.src.port}`);
  respond(m, 200, 'OK', { headers: [['Contact', `<${uriOf(contact)}>;expires=${exp}`], ['Expires', String(exp)], ['Date', new Date().toUTCString()]] });
  if (!was) setTimeout(() => notifyMwi(user), 500);
}

// ================================================================ message-waiting lights (MWI)
const MWI_SUBS = new Map(); // callId -> { ext, addr, contact, from, to, cseq, expires }
function vmCounts(box) { const list = vmList(box); return { neu: list.filter(x => x.isNew).length, old: list.filter(x => !x.isNew).length }; }
function mwiBody(box) {
  const c = vmCounts(box);
  return `Messages-Waiting: ${c.neu ? 'yes' : 'no'}\r\nMessage-Account: sip:${box}@${LAN_IP}\r\nVoice-Message: ${c.neu}/${c.old} (0/0)\r\n`;
}
function onSubscribe(m) {
  const ev = (hdr(m, 'event') || '').toLowerCase();
  const user = userOf(hdr(m, 'from'));
  const box = userOf(hdr(m, 'to')) || user;
  if (!ev.startsWith('message-summary')) return respond(m, 489, 'Bad Event');
  const ext = CFG.extensions[user];
  if (!ext) return respond(m, 404, 'Not Found');
  if (!checkAuth(m, user, ext.password, 407)) return;
  const exp = Math.min(+(hdr(m, 'expires') ?? 3600), 3600);
  const toTag = tagOf(hdr(m, 'to')) || randHex(10);
  respond(m, 200, 'OK', { toTag, headers: [['Expires', String(exp)], ['Contact', contactFor(m.src)]] });
  const callId = hdr(m, 'call-id');
  if (exp === 0) { MWI_SUBS.delete(callId); return; }
  const prev = MWI_SUBS.get(callId);
  const sub = { ext: CFG.extensions[box] ? box : user, addr: m.src, contact: uriOf(hdr(m, 'contact')) || uriOf(hdr(m, 'from')), from: hdr(m, 'from'), toTag, to: hdr(m, 'to'), cseq: prev ? prev.cseq : 1, expires: now() + exp * 1000 };
  MWI_SUBS.set(callId, sub);
  setTimeout(() => sendMwiNotify(callId, sub), 200);
}
function sendMwiNotify(callId, sub) {
  const branch = newBranch();
  let to = sub.to; if (!tagOf(to)) to += `;tag=${sub.toTag}`;
  const text = buildMsg(`NOTIFY ${sub.contact} SIP/2.0`, [['Via', viaFor(sub.addr, branch)], ['Max-Forwards', '70'],
    ['From', to], ['To', sub.from], ['Call-ID', callId], ['CSeq', `${++sub.cseq} NOTIFY`], ['Contact', contactFor(sub.addr)],
    ['Event', 'message-summary'], ['Subscription-State', `active;expires=${Math.max(1, Math.round((sub.expires - now()) / 1000))}`],
    ['Content-Type', 'application/simple-message-summary'], ['User-Agent', UA_NAME]], mwiBody(sub.ext));
  sendRequest(text, sub.addr, branch, 'NOTIFY', null);
}
function notifyMwi(box) {
  let found = false;
  for (const [id, s] of MWI_SUBS) { if (s.expires < now()) { MWI_SUBS.delete(id); continue; } if (s.ext === box) { found = true; sendMwiNotify(id, s); } }
  const reg = REGS.get(box);
  if (!found && reg) { // unsolicited NOTIFY, understood by most phones
    const branch = newBranch();
    const text = buildMsg(`NOTIFY ${reg.contact} SIP/2.0`, [['Via', viaFor(reg.addr, branch)], ['Max-Forwards', '70'],
      ['From', `<sip:${box}@${LAN_IP}>;tag=${randHex(8)}`], ['To', `<sip:${box}@${LAN_IP}>`], ['Call-ID', randHex(20) + '@aivoice-pbx'], ['CSeq', '1 NOTIFY'],
      ['Contact', contactFor(reg.addr)], ['Event', 'message-summary'], ['Subscription-State', 'terminated'], ['Content-Type', 'application/simple-message-summary'], ['User-Agent', UA_NAME]], mwiBody(box));
    sendRequest(text, reg.addr, branch, 'NOTIFY', null);
  }
}

// ================================================================ text messages between phones (SIP MESSAGE)
function onMessage(m) {
  const from = userOf(hdr(m, 'from')), to = userOf(m.uri) || userOf(hdr(m, 'to'));
  const ext = CFG.extensions[from];
  if (!ext) return respond(m, 403, 'Forbidden');
  if (!checkAuth(m, from, ext.password, 407)) return;
  const reg = REGS.get(to);
  if (!reg) return respond(m, 480, 'Temporarily Unavailable');
  respond(m, 202, 'Accepted');
  const branch = newBranch();
  const text = buildMsg(`MESSAGE ${reg.contact} SIP/2.0`, [['Via', viaFor(reg.addr, branch)], ['Max-Forwards', '70'],
    ['From', `"${ext.name || from}" <sip:${from}@${LAN_IP}>;tag=${randHex(8)}`], ['To', `<sip:${to}@${LAN_IP}>`], ['Call-ID', randHex(20) + '@aivoice-pbx'],
    ['CSeq', '1 MESSAGE'], ['Content-Type', hdr(m, 'content-type') || 'text/plain'], ['User-Agent', UA_NAME]], m.body);
  sendRequest(text, reg.addr, branch, 'MESSAGE', null);
  log(`Text message ${from} -> ${to}`);
}

// ================================================================ RTP (voice packets)
const usedPorts = new Set();
const DTMF_CHARS = '0123456789*#ABCD';
class Rtp {
  constructor() {
    this.sock = null; this.port = 0; this.remote = null; this.latched = false; this.pt = 0; this.dtmfPt = 101;
    this.seq = Math.floor(Math.random() * 65535); this.ts = Math.floor(Math.random() * 2 ** 31); this.ssrc = crypto.randomBytes(4).readUInt32BE(0);
    this.onPcm = null; this.onDtmf = null; this.lastDtmf = -1; this.closed = false; this.rx = 0; this.tx = 0; this.lastRx = now();
  }
  async open() {
    for (let tries = 0; tries < 200; tries++) {
      let p = CFG.rtpStart + 2 * Math.floor(Math.random() * ((CFG.rtpEnd - CFG.rtpStart) / 2));
      if (usedPorts.has(p)) continue;
      usedPorts.add(p);
      const s = dgram.createSocket('udp4');
      const ok = await new Promise(res => { s.once('error', () => res(false)); s.bind(p, () => res(true)); });
      if (!ok) { try { s.close(); } catch (e) {} usedPorts.delete(p); continue; }
      this.sock = s; this.port = p;
      s.on('message', (b, r) => this.receive(b, r));
      s.on('error', () => {});
      return this;
    }
    throw new Error('no free RTP port');
  }
  useOffer(sdp) {
    if (!sdp) return;
    const pt = choosePt(sdp); if (pt !== undefined) this.pt = pt;
    this.dtmfPt = sdp.dtmfPt || 101;
    if (sdp.ip && sdp.ip !== '0.0.0.0' && sdp.port && !this.latched) this.remote = { address: sdp.ip, port: sdp.port };
  }
  receive(b, r) {
    if (this.closed || b.length < 12 || (b[0] >> 6) !== 2) return;
    const pt = b[1] & 0x7f;
    if (pt >= 72 && pt <= 76) return; // RTCP on the same port
    // symmetric RTP: answer to wherever the packets really come from (gets through home routers)
    if (!this.latched || this.remote.address !== r.address || this.remote.port !== r.port) { this.remote = { address: r.address, port: r.port }; this.latched = true; }
    const cc = b[0] & 0x0f; let off = 12 + cc * 4;
    if (b[0] & 0x10) { if (b.length < off + 4) return; off += 4 + b.readUInt16BE(off + 2) * 4; }
    let end = b.length; if (b[0] & 0x20) end -= b[b.length - 1];
    if (off >= end) return;
    const payload = b.subarray(off, end);
    this.rx++; this.lastRx = now();
    if (pt === this.dtmfPt || (pt >= 96 && pt !== 0 && pt !== 8 && payload.length === 4)) {
      const ts = b.readUInt32BE(4);
      if (ts !== this.lastDtmf && payload[0] < 16) { this.lastDtmf = ts; this.onDtmf && this.onDtmf(DTMF_CHARS[payload[0]]); }
      return;
    }
    if (pt === 0 || pt === 8) this.onPcm && this.onPcm(g711Decode(payload, pt));
  }
  packet(payload, pt, marker, ts) {
    const h = Buffer.alloc(12);
    h[0] = 0x80; h[1] = (marker ? 0x80 : 0) | pt;
    h.writeUInt16BE(this.seq, 2); h.writeUInt32BE(ts >>> 0, 4); h.writeUInt32BE(this.ssrc, 8);
    this.seq = (this.seq + 1) & 0xffff;
    return Buffer.concat([h, payload]);
  }
  sendPcm(pcm) {
    if (this.closed || !this.remote) return;
    const p = this.packet(g711Encode(pcm, this.pt), this.pt, false, this.ts);
    this.ts = (this.ts + pcm.length) >>> 0; this.tx++;
    this.sock.send(p, this.remote.port, this.remote.address);
  }
  sendDtmf(ch) {
    const ev = DTMF_CHARS.indexOf(ch); if (ev < 0 || this.closed || !this.remote) return;
    const ts = this.ts, sends = [];
    for (let i = 1; i <= 5; i++) { const pl = Buffer.from([ev, 10, 0, 0]); pl.writeUInt16BE(i * 160, 2); sends.push([pl, i === 1]); }
    for (let i = 0; i < 3; i++) { const pl = Buffer.from([ev, 0x80 | 10, 0, 0]); pl.writeUInt16BE(960, 2); sends.push([pl, false]); }
    sends.forEach(([pl, mk], i) => setTimeout(() => { if (!this.closed && this.remote) this.sock.send(this.packet(pl, this.dtmfPt, mk, ts), this.remote.port, this.remote.address); }, i * 20));
    this.ts = (this.ts + 960) >>> 0;
  }
  close() { if (this.closed) return; this.closed = true; try { this.sock.close(); } catch (e) {} usedPorts.delete(this.port); }
}

// ================================================================ 20 ms clock for anything that generates audio
const TICKERS = new Set();
let clockNext = 0;
setInterval(() => {
  const t = Number(process.hrtime.bigint() / 1000000n);
  if (!clockNext) clockNext = t;
  let n = 0;
  while (t >= clockNext && n < 6) { clockNext += 20; n++; for (const f of TICKERS) { try { f(); } catch (e) { log('clock error', e.message); } } }
  if (t - clockNext > 200) clockNext = t;
}, 5).unref();

// Audio sources a leg can play: they hand out 160-sample frames.
class BufferSource {
  constructor(pcm, loop = false) { this.pcm = pcm; this.pos = 0; this.loop = loop; this.done = null; }
  next() {
    if (this.pos >= this.pcm.length) { if (!this.loop || !this.pcm.length) return null; this.pos = 0; }
    const f = new Int16Array(FRAME); f.set(this.pcm.subarray(this.pos, this.pos + FRAME)); this.pos += FRAME; return f;
  }
}
class MohSource { constructor() { this.pos = Math.floor(Math.random() * MOH.length / FRAME) * FRAME; } next() { const f = new Int16Array(FRAME); for (let i = 0; i < FRAME; i++) f[i] = MOH[(this.pos + i) % MOH.length] * 0.6; this.pos = (this.pos + FRAME) % MOH.length; return f; } }
class RingbackSource {
  constructor() { this.n = 0; }
  next() { const f = new Int16Array(FRAME), cycle = 6 * RATE; for (let i = 0; i < FRAME; i++, this.n++) { const p = this.n % cycle; f[i] = p < 2 * RATE ? Math.round((Math.sin(2 * Math.PI * 440 * this.n / RATE) + Math.sin(2 * Math.PI * 480 * this.n / RATE)) * 2500) : 0; } return f; }
}

// ================================================================ call recording (stereo: caller left, callee right)
class Recorder {
  constructor(a, b) {
    this.a = a; this.b = b; this.ch = [[], []]; this.started = now();
    const d = new Date(), pad = n => String(n).padStart(2, '0');
    const day = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
    this.file = path.join(DIRS.rec, `${day}_${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}_${a.number || 'unknown'}_to_${b.number || 'unknown'}.wav`.replace(/[^\w.\-]/g, '_'));
    this.samples = [0, 0];
  }
  push(side, pcm) {
    // keep both channels in step: pad the quieter side with silence
    this.ch[side].push(pcm); this.samples[side] += pcm.length;
    const other = 1 - side, lag = this.samples[side] - this.samples[other];
    if (lag > RATE / 2) { const s = new Int16Array(lag - RATE / 4); this.ch[other].push(s); this.samples[other] += s.length; }
  }
  save() {
    if (this.saved) return; this.saved = true;
    if (this.samples[0] + this.samples[1] < RATE) return;
    try { writeWav(this.file, [concatPcm(this.ch[0]), concatPcm(this.ch[1])]); log(`Recording saved: ${path.basename(this.file)}`); } catch (e) { log('Recording failed:', e.message); }
  }
}

// ================================================================ call legs (one SIP dialog between the PBX and one phone)
const LEGS = new Map(); // Call-ID -> Leg
let legSeq = 0;
class Leg {
  constructor(dir) {
    this.id = ++legSeq; this.dir = dir; this.state = 'new'; this.created = now(); this.answeredAt = 0;
    this.callId = ''; this.localTag = randHex(10); this.remoteTag = null; this.localHdr = ''; this.remoteHdr = '';
    this.remoteTarget = ''; this.addr = null; this.cseq = 1 + Math.floor(Math.random() * 900);
    this.rtp = null; this.peer = null; this.source = null; this.digits = ''; this.waiter = null; this.playing = null;
    this.ext = null; this.number = ''; this.name = ''; this.trunk = false; this.held = false; this.activity = '';
    this.endFns = []; this.timers = []; this.recorder = null; this.transferring = false;
    this.tick = () => {
      if (!this.source || this.state === 'ended' || !this.rtp) return;
      const f = this.source.next();
      if (f) this.rtp.sendPcm(f); else { const s = this.source; this.source = null; s.done && s.done(); }
    };
  }
  label() { return this.name && this.name !== this.number ? `${this.name} (${this.number})` : (this.number || `leg ${this.id}`); }
  onEnd(f) { this.endFns.push(f); }
  offEnd(f) { this.endFns = this.endFns.filter(x => x !== f); }
  async setupMedia(offer) {
    this.rtp = await new Rtp().open();
    this.rtp.useOffer(offer);
    this.rtp.onDtmf = d => this.gotDigit(d);
    TICKERS.add(this.tick);
  }
  static fromInvite(m) {
    const l = new Leg('in');
    l.callId = hdr(m, 'call-id'); l.invite = m; l.addr = m.src;
    l.remoteHdr = hdr(m, 'from'); l.remoteTag = tagOf(l.remoteHdr);
    l.localHdr = hdr(m, 'to') + `;tag=${l.localTag}`;
    l.remoteTarget = uriOf(hdr(m, 'contact')) || uriOf(l.remoteHdr);
    l.offer = parseSdp(m.body);
    LEGS.set(l.callId, l);
    return l;
  }
  // ---------- incoming leg (the phone called us)
  provisional(code = 180, reason = 'Ringing') {
    if (this.dir !== 'in' || this.state === 'answered' || this.state === 'ended') return;
    this.state = 'ringing';
    respond(this.invite, code, reason, { toTag: this.localTag, headers: [['Contact', contactFor(this.addr)]] });
  }
  answer() {
    if (this.dir !== 'in' || this.state === 'answered' || this.state === 'ended') return Promise.resolve();
    this.state = 'answered'; this.answeredAt = now();
    const sdp = buildSdp(this.rtp, pbxIpFor(this.addr), 'sendrecv', [this.rtp.pt]);
    const text = respond(this.invite, 200, 'OK', { toTag: this.localTag, body: sdp,
      headers: [['Contact', contactFor(this.addr)], ['Allow', ALLOW], ['Supported', 'replaces']] });
    let iv = 500;
    const resend = () => { if (this.acked || this.state === 'ended') return; if (now() - this.answeredAt > 32000) { this.hangup(); return; } sipSend(text, this.addr); iv = Math.min(iv * 2, 4000); this.t200 = setTimeout(resend, iv); };
    this.t200 = setTimeout(resend, iv);
    return sleep(120);
  }
  reject(code = 480, reason = 'Temporarily Unavailable') {
    if (this.dir !== 'in' || this.state === 'answered' || this.state === 'ended') return;
    respond(this.invite, code, reason, { toTag: this.localTag });
    this.end(`rejected ${code}`);
  }
  onAck() { this.acked = true; clearTimeout(this.t200); }
  onCancel(m) {
    respond(m, 200, 'OK');
    if (this.state !== 'answered' && this.state !== 'ended') { respond(this.invite, 487, 'Request Terminated', { toTag: this.localTag }); this.end('caller hung up'); }
  }
  // ---------- outgoing leg (we call a phone or the trunk)
  static async dial({ uri, addr, toUser, fromUser, fromName, ext = null, trunk = false, domain = LAN_IP }) {
    const l = new Leg('out');
    l.callId = randHex(24) + '@aivoice-pbx'; l.addr = addr; l.remoteTarget = uri; l.ext = ext; l.trunk = trunk;
    l.number = toUser; l.name = ext && CFG.extensions[ext] ? CFG.extensions[ext].name : toUser;
    l.localHdr = `"${String(fromName || fromUser).replace(/"/g, '')}" <sip:${fromUser}@${domain}>;tag=${l.localTag}`;
    l.remoteHdr = `<sip:${toUser}@${domain}>`;
    LEGS.set(l.callId, l);
    l.result = new Promise(r => { l.resolveResult = r; });
    await l.setupMedia(null);
    l.state = 'calling';
    l.sendInvite();
    return l;
  }
  sendInvite(authHeader) {
    const branch = newBranch();
    this.inviteCseq = this.cseq++;
    this.inviteBranch = branch;
    const h = [['Via', viaFor(this.addr, branch)], ['Max-Forwards', '70'], ['From', this.localHdr], ['To', this.remoteHdr],
      ['Call-ID', this.callId], ['CSeq', `${this.inviteCseq} INVITE`], ['Contact', contactFor(this.addr)], ['Allow', ALLOW], ['User-Agent', UA_NAME]];
    if (authHeader) h.push(authHeader);
    h.push(['Content-Type', 'application/sdp']);
    const ruri = this.remoteTarget;
    this.inviteText = buildMsg(`INVITE ${ruri} SIP/2.0`, h, buildSdp(this.rtp, pbxIpFor(this.addr), 'sendrecv', [0, 8]));
    this.inviteRuri = ruri;
    sendRequest(this.inviteText, this.addr, branch, 'INVITE', m => this.onInviteResponse(m));
  }
  onInviteResponse(m) {
    if (!m) { if (this.state !== 'answered') { this.finishResult({ ok: false, code: 408 }); this.end('no response'); } return; }
    if (((hdr(m, 'cseq') || '').split(' ')[1] || '').toUpperCase() !== 'INVITE') return; // e.g. the 200 OK to our CANCEL
    const code = m.status;
    if (code < 200) { if (this.state === 'calling') this.state = 'ringing'; this.gotProvisional = true; if (this.pendingCancel) this.sendCancel(); return; }
    if (code < 300) {
      this.remoteTag = tagOf(hdr(m, 'to')); this.remoteHdr = hdr(m, 'to');
      this.remoteTarget = uriOf(hdr(m, 'contact')) || this.remoteTarget;
      const ackBranch = newBranch();
      this.ackText = this.ackText || buildMsg(`ACK ${this.remoteTarget} SIP/2.0`, [['Via', viaFor(this.addr, ackBranch)], ['Max-Forwards', '70'], ['From', this.localHdr], ['To', this.remoteHdr],
        ['Call-ID', this.callId], ['CSeq', `${this.inviteCseq} ACK`], ['User-Agent', UA_NAME]]);
      sipSend(this.ackText, this.addr);
      if (this.state === 'answered') return; // a repeated 200 OK: we just acknowledged it again
      if (this.cancelled || this.state === 'ended') { this.state = 'answered'; this.hangup(); return; }
      this.rtp.useOffer(parseSdp(m.body));
      this.state = 'answered'; this.answeredAt = now();
      this.finishResult({ ok: true });
      return;
    }
    // failure: acknowledge on the same branch
    sipSend(buildMsg(`ACK ${this.inviteRuri} SIP/2.0`, [['Via', viaFor(this.addr, this.inviteBranch)], ['Max-Forwards', '70'], ['From', this.localHdr], ['To', hdr(m, 'to')],
      ['Call-ID', this.callId], ['CSeq', `${this.inviteCseq} ACK`]]), this.addr);
    if ((code === 401 || code === 407) && this.trunk && !this.authTried && !this.cancelled) {
      this.authTried = true;
      const ch = hdr(m, code === 401 ? 'www-authenticate' : 'proxy-authenticate');
      const t = CFG.trunk;
      this.sendInvite([code === 401 ? 'Authorization' : 'Proxy-Authorization', digestHeader(ch, 'INVITE', this.remoteTarget, t.authUser || t.username, t.password)]);
      return;
    }
    this.finishResult({ ok: false, code });
    this.end(`failed ${code}`);
  }
  finishResult(r) { if (this.resolveResult) { const f = this.resolveResult; this.resolveResult = null; f(r); } }
  cancel() {
    if (this.dir !== 'out' || this.state === 'answered' || this.state === 'ended') return;
    this.cancelled = true;
    if (this.gotProvisional) this.sendCancel(); else { this.pendingCancel = true; this.timers.push(setTimeout(() => { if (this.state !== 'answered') { this.finishResult({ ok: false, code: 487 }); this.end('cancelled'); } }, 4000)); }
  }
  sendCancel() {
    if (this.cancelSent) return; this.cancelSent = true;
    const text = buildMsg(`CANCEL ${this.inviteRuri} SIP/2.0`, [['Via', viaFor(this.addr, this.inviteBranch)], ['Max-Forwards', '70'], ['From', this.localHdr], ['To', this.remoteHdr],
      ['Call-ID', this.callId], ['CSeq', `${this.inviteCseq} CANCEL`], ['User-Agent', UA_NAME]]);
    // CANCEL shares the INVITE's branch, so it is resent by hand instead of through the transaction map
    [0, 500, 1500].forEach(d => this.timers.push(setTimeout(() => { if (this.state !== 'answered' && this.state !== 'ended') sipSend(text, this.addr); }, d)));
    this.timers.push(setTimeout(() => { if (this.state !== 'answered') { this.finishResult({ ok: false, code: 487 }); this.end('cancelled'); } }, 3000));
  }
  // ---------- inside the dialog
  request(method, headers = [], body = '', ctype = '') {
    const branch = newBranch();
    const h = [['Via', viaFor(this.addr, branch)], ['Max-Forwards', '70'], ['From', this.localHdr], ['To', this.remoteHdr],
      ['Call-ID', this.callId], ['CSeq', `${this.cseq++} ${method}`], ['Contact', contactFor(this.addr)], ['User-Agent', UA_NAME], ...headers];
    if (body) h.push(['Content-Type', ctype]);
    sendRequest(buildMsg(`${method} ${this.remoteTarget} SIP/2.0`, h, body), this.addr, branch, method, null);
  }
  notifyRefer(frag, final) {
    this.request('NOTIFY', [['Event', 'refer'], ['Subscription-State', final ? 'terminated;reason=noresource' : 'active;expires=60']], frag + '\r\n', 'message/sipfrag;version=2.0');
  }
  onReInvite(m) {
    const offer = parseSdp(m.body);
    if (offer) {
      if (offer.ip && offer.ip !== '0.0.0.0' && this.rtp.remote && (this.rtp.remote.address !== offer.ip)) this.rtp.latched = false;
      this.rtp.useOffer(offer);
    }
    const dir = !offer ? 'sendrecv' : offer.dir === 'sendonly' ? 'recvonly' : offer.dir === 'inactive' ? 'inactive' : offer.dir === 'recvonly' ? 'sendonly' : 'sendrecv';
    respond(m, 200, 'OK', { body: buildSdp(this.rtp, pbxIpFor(this.addr), dir, [this.rtp.pt]), headers: [['Contact', contactFor(this.addr)], ['Allow', ALLOW]] });
    if (offer && offer.hold !== this.held) this.setHold(offer.hold);
  }
  setHold(h) {
    this.held = h;
    log(`${this.label()} ${h ? 'put the call on hold' : 'took the call off hold'}`);
    if (this.peer) this.peer.source = h ? new MohSource() : null;
  }
  onInfo(m) {
    const b = m.body || '';
    const s = /signal\s*=\s*([0-9*#A-D])/i.exec(b) || (/dtmf/i.test(hdr(m, 'content-type') || '') && /^\s*([0-9*#A-D])\s*$/i.exec(b));
    if (s) this.gotDigit(s[1].toUpperCase());
  }
  // ---------- digits, prompts and audio
  gotDigit(d) {
    if (this.peer && this.peer.state === 'answered') { this.peer.rtp.sendDtmf(d); return; }
    this.digits += d;
    if (this.playing && this.playing.interrupt) { const p = this.playing; this.playing = null; this.source = null; p.resolve(); }
    if (this.waiter) this.waiter.check();
  }
  play(pcm, { interrupt = true } = {}) {
    if (this.state === 'ended') return Promise.resolve();
    if (interrupt && this.digits) return Promise.resolve();
    return new Promise(resolve => {
      const src = new BufferSource(pcm);
      const p = { resolve, interrupt };
      src.done = () => { if (this.playing === p) this.playing = null; resolve(); };
      this.playing = p; this.source = src;
      this.onEnd(() => resolve());
    });
  }
  async say(text, opts) { return this.play(await say(text), opts); }
  getDigits({ max = 1, timeout = 6000, inter = 3000, term = '#' } = {}) {
    return new Promise(resolve => {
      let timer = null;
      const finish = v => { clearTimeout(timer); this.waiter = null; resolve(v); };
      const check = () => {
        if (this.state === 'ended') return finish(null);
        const ti = term ? this.digits.indexOf(term) : -1;
        if (ti >= 0) { const v = this.digits.slice(0, ti); this.digits = this.digits.slice(ti + 1); return finish(v); }
        if (this.digits.length >= max) { const v = this.digits.slice(0, max); this.digits = this.digits.slice(max); return finish(v); }
        clearTimeout(timer);
        timer = setTimeout(() => { const v = this.digits; this.digits = ''; finish(v); }, this.digits.length ? inter : timeout);
      };
      this.waiter = { check };
      this.onEnd(() => finish(null));
      check();
    });
  }
  // ---------- ending
  hangup(why = 'hung up') {
    if (this.state === 'ended') return;
    if (this.dir === 'in') {
      if (this.state === 'answered') this.request('BYE');
      else { respond(this.invite, why === 'busy' ? 486 : 480, why === 'busy' ? 'Busy Here' : 'Temporarily Unavailable', { toTag: this.localTag }); }
    } else {
      if (this.state === 'answered') this.request('BYE');
      else { this.cancel(); return; }
    }
    this.end(why);
  }
  end(why) {
    if (this.state === 'ended') return;
    const wasAnswered = this.state === 'answered';
    this.state = 'ended'; this.endedAt = now(); this.endReason = why;
    clearTimeout(this.t200); this.timers.forEach(clearTimeout);
    TICKERS.delete(this.tick);
    if (this.rtp) this.rtp.close();
    LEGS.delete(this.callId);
    if (this.recorder) { this.recorder.save(); this.recorder = null; }
    if (this.inviteBranch) { const tx = cliTx.get(this.inviteBranch); if (tx && !tx.done) { tx.done = true; tx.doneAt = now(); clearTimeout(tx.timer); } }
    const fns = this.endFns; this.endFns = [];
    for (const f of fns) { try { f(why); } catch (e) { log('end handler', e.message); } }
    const p = this.peer; this.peer = null;
    if (p && !p.transferring && p.state !== 'ended') { p.peer = null; p.hangup('other side hung up'); }
    this.finishResult && this.finishResult({ ok: false, code: 487 });
    if (this.dir === 'in' && this.cdr) {
      this.cdr.end = now(); this.cdr.duration = this.cdr.answer ? Math.round((this.cdr.end - this.cdr.answer) / 1000) : 0;
      if (!this.cdr.result) this.cdr.result = wasAnswered ? 'answered' : (why || 'missed');
    }
  }
}

// ================================================================ connecting two legs
function bridge(a, b) {
  a.peer = b; b.peer = a; a.source = null; b.source = null; a.held = b.held = false; a.transferring = b.transferring = false;
  a.activity = b.activity = 'talking';
  const wantRec = CFG.recordAll || (a.ext && CFG.extensions[a.ext] && CFG.extensions[a.ext].record) || (b.ext && CFG.extensions[b.ext] && CFG.extensions[b.ext].record);
  const rec = wantRec ? new Recorder(a, b) : null;
  a.recorder = rec; b.recorder = null;
  a.rtp.onPcm = pcm => { if (rec) rec.push(0, pcm); if (a.peer === b && !a.held && !b.held) b.rtp.sendPcm(pcm); };
  b.rtp.onPcm = pcm => { if (rec) rec.push(1, pcm); if (b.peer === a && !a.held && !b.held) a.rtp.sendPcm(pcm); };
  const root = a.dir === 'in' ? a : b.dir === 'in' ? b : a;
  if (root.cdr && !root.cdr.answer) { root.cdr.answer = now(); root.cdr.answeredBy = (root === a ? b : a).label(); }
  log(`Connected: ${a.label()} <-> ${b.label()}${rec ? ' (recording)' : ''}`);
}
function unbridge(a) {
  const b = a.peer;
  if (a.recorder) { a.recorder.save(); a.recorder = null; }
  if (b) { if (b.recorder) { b.recorder.save(); b.recorder = null; } b.peer = null; if (b.rtp) b.rtp.onPcm = null; }
  a.peer = null; if (a.rtp) a.rtp.onPcm = null;
  return b;
}
const legsOfExt = ext => [...LEGS.values()].filter(l => l.ext === ext && l.state !== 'ended');
const isBusy = ext => legsOfExt(ext).some(l => l.state === 'answered' || l.dir === 'in');

// Ring one or more phones; first to answer gets connected. Resolves 'answered' | 'busy' | 'noanswer' | 'unavailable' | 'cancelled'.
async function dialDevices(aLeg, exts, { timeout = CFG.ringTime, keepAudio = false } = {}) {
  const targets = exts.filter(x => REGS.has(x) && CFG.extensions[x] && !CFG.extensions[x].dnd && x !== aLeg.ext);
  if (!targets.length) return 'unavailable';
  if (aLeg.state === 'ended') return 'cancelled';
  if (aLeg.state === 'answered') { if (!keepAudio) aLeg.source = new RingbackSource(); }
  else aLeg.provisional(180, 'Ringing');
  const outs = await Promise.all(targets.map(ext => {
    const reg = REGS.get(ext);
    return Leg.dial({ uri: reg.contact, addr: reg.addr, toUser: ext, fromUser: aLeg.number || 'anonymous', fromName: aLeg.name, ext });
  }));
  return new Promise(resolve => {
    let settled = false, busy = 0;
    const finish = (res, winner) => {
      if (settled) return; settled = true; clearTimeout(timer); aLeg.offEnd(onAEnd);
      for (const o of outs) if (o !== winner) o.hangup('cancelled');
      if (!winner && !keepAudio && aLeg.source instanceof RingbackSource) aLeg.source = null;
      resolve(res);
    };
    const onAEnd = () => finish('cancelled');
    aLeg.onEnd(onAEnd);
    const timer = setTimeout(() => finish('noanswer'), (timeout || CFG.ringTime) * 1000);
    for (const o of outs) {
      o.result.then(async r => {
        if (settled) { if (r.ok) o.hangup(); return; }
        if (r.ok) {
          if (aLeg.state === 'ended') { o.hangup(); return finish('cancelled'); }
          finish('answered', o);
          if (aLeg.state !== 'answered') await aLeg.answer();
          bridge(aLeg, o);
          return;
        }
        if ([486, 600, 603].includes(r.code)) busy++;
        if (outs.every(x => x.state === 'ended')) finish(busy ? 'busy' : 'unavailable');
      });
    }
  });
}

// ================================================================ incoming requests
function onSipRequest(m) {
  if (m.method !== 'ACK') {
    const c = srvTx.get(branchOf(hdr(m, 'via')) + ':' + m.method);
    if (c) { sipSend(c.resp, m.src); return; }
  }
  const leg = LEGS.get(hdr(m, 'call-id'));
  switch (m.method) {
    case 'REGISTER': return onRegister(m);
    case 'OPTIONS': return respond(m, 200, 'OK', { headers: [['Allow', ALLOW], ['Accept', 'application/sdp']] });
    case 'INVITE': return onInvite(m).catch(e => { log('INVITE error:', e.stack || e.message); try { respond(m, 500, 'Server Error'); } catch (x) {} });
    case 'ACK': if (leg) leg.onAck(m); return;
    case 'CANCEL': if (!leg) return respond(m, 481, 'Call/Transaction Does Not Exist'); return leg.onCancel(m);
    case 'BYE': respond(m, 200, 'OK'); if (leg) leg.end('hung up'); return;
    case 'REFER': if (!leg) return respond(m, 481, 'Call/Transaction Does Not Exist'); return handleRefer(leg, m);
    case 'INFO': respond(m, 200, 'OK'); if (leg) leg.onInfo(m); return;
    case 'UPDATE': if (!leg) return respond(m, 481, 'Call/Transaction Does Not Exist'); return leg.onReInvite(m);
    case 'NOTIFY': case 'PUBLISH': case 'PRACK': return respond(m, 200, 'OK');
    case 'SUBSCRIBE': return onSubscribe(m);
    case 'MESSAGE': return onMessage(m);
    default: return respond(m, 501, 'Not Implemented');
  }
}
async function onInvite(m) {
  const callId = hdr(m, 'call-id');
  if (tagOf(hdr(m, 'to'))) { const leg = LEGS.get(callId); if (!leg) return respond(m, 481, 'Call/Transaction Does Not Exist'); return leg.onReInvite(m); }
  if (LEGS.has(callId)) return;
  const fromUser = userOf(hdr(m, 'from')), toUser = userOf(m.uri) || userOf(hdr(m, 'to'));
  const fromTrunk = isFromTrunk(m.src);
  let ext = null;
  if (!fromTrunk) {
    const e = CFG.extensions[fromUser];
    if (!e) { log(`Rejected call from unknown ${fromUser || '?'} at ${m.src.address}`); return respond(m, 403, 'Forbidden'); }
    if (!checkAuth(m, fromUser, e.password, 407)) return;
    ext = fromUser;
  }
  const offer = parseSdp(m.body);
  if (!offer || choosePt(offer) === undefined) return respond(m, 488, 'Not Acceptable Here', { headers: [['Warning', '399 aivoice-pbx "Use G.711 (PCMU or PCMA)"']] });
  respond(m, 100, 'Trying');
  const leg = Leg.fromInvite(m);
  leg.ext = ext; leg.trunk = fromTrunk; leg.number = fromUser || 'anonymous';
  leg.name = ext ? (CFG.extensions[ext].name || ext) : (displayOf(hdr(m, 'from')) || fromUser);
  await leg.setupMedia(offer);
  const dest = fromTrunk ? trunkInboundDest(toUser) : toUser;
  leg.cdr = { id: leg.id, start: now(), from: leg.number, fromName: leg.name, to: dest, answer: 0, end: 0, duration: 0, result: '' };
  addCdr(leg.cdr);
  log(`Call from ${leg.label()}${fromTrunk ? ' (outside line)' : ''} to ${dest}`);
  try { await route(leg, dest, { depth: 0 }); }
  catch (e) { log('Routing error:', e.stack || e.message); leg.hangup(); }
}

// ================================================================ call transfer (REFER: blind and attended)
async function handleRefer(L, m) {
  const referTo = hdr(m, 'refer-to');
  if (!referTo) return respond(m, 400, 'Missing Refer-To');
  respond(m, 202, 'Accepted', { headers: [['Contact', contactFor(L.addr)]] });
  L.notifyRefer('SIP/2.0 100 Trying', false);
  const uri = uriOf(referTo);
  const target = userOf(uri);
  const q = uri.indexOf('?') >= 0 ? uri.slice(uri.indexOf('?') + 1) : '';
  const repl = (/(?:^|&)replaces=([^&]+)/i.exec(q) || [])[1];
  const P = L.peer;
  if (!P) { L.notifyRefer('SIP/2.0 503 Service Unavailable', true); return; }
  if (repl) {
    const callId = decodeURIComponent(repl).split(';')[0];
    const L2 = LEGS.get(callId);
    const Q = L2 && L2.peer;
    if (!Q) { L.notifyRefer('SIP/2.0 481 Call Does Not Exist', true); return; }
    P.transferring = Q.transferring = true;
    unbridge(L); unbridge(L2);
    bridge(P, Q);
    log(`Attended transfer by ${L.label()}: ${P.label()} now talking to ${Q.label()}`);
    L.notifyRefer('SIP/2.0 200 OK', true);
    setTimeout(() => { L.hangup('transferred'); L2.hangup('transferred'); }, 400);
    return;
  }
  P.transferring = true;
  unbridge(L);
  log(`Blind transfer by ${L.label()}: ${P.label()} -> ${target}`);
  L.notifyRefer('SIP/2.0 200 OK', true);
  setTimeout(() => L.hangup('transferred'), 1500);
  P.transferring = false;
  P.source = new RingbackSource();
  route(P, target, { depth: 0, transferredBy: L.ext }).catch(e => { log('Transfer error', e.message); P.hangup(); });
}

// ================================================================ dial plan
function isOutsideNumber(d) {
  const t = CFG.trunk, n = String(d).replace(/[\s\-().]/g, '');
  if (!/^\+?\d+$/.test(n)) return false;
  if (t.outPrefix && n.startsWith(t.outPrefix) && n.length > t.outPrefix.length + 2) return true;
  return n.replace('+', '').length >= (t.outMinDigits || 6);
}
function outsideDial(d) { const t = CFG.trunk; let n = String(d).replace(/[\s\-().]/g, ''); if (t.outPrefix && n.startsWith(t.outPrefix) && n.length > t.outPrefix.length + 2) n = n.slice(t.outPrefix.length); return n; }
function destType(d) {
  d = String(d || '').trim();
  if (!d) return { type: 'none' };
  if (d === 'hangup') return { type: 'hangup' };
  if (d.startsWith('vm:')) return { type: 'vm', box: d.slice(3) };
  if (d.startsWith('*')) return { type: 'feature', code: d };
  if (CFG.extensions[d]) return { type: 'ext', num: d };
  if (CFG.groups[d]) return { type: 'group', num: d };
  if (CFG.queues[d]) return { type: 'queue', num: d };
  if (CFG.ivrs[d]) return { type: 'ivr', num: d };
  if (CFG.conferences[d]) return { type: 'conf', num: d };
  if (isOutsideNumber(d)) return { type: 'outside', num: d };
  return { type: 'unknown', num: d };
}
function destName(d, forSpeech = false) {
  const t = destType(d);
  const sp = n => forSpeech ? spell(n) : n;
  switch (t.type) {
    case 'hangup': return 'hang up';
    case 'vm': return `voicemail of ${CFG.extensions[t.box] ? (CFG.extensions[t.box].name || sp(t.box)) : sp(t.box)}`;
    case 'ext': return forSpeech ? (CFG.extensions[t.num].name || `extension ${sp(t.num)}`) : `${CFG.extensions[t.num].name || 'extension'} (${t.num})`;
    case 'group': return forSpeech ? (CFG.groups[t.num].name || 'our team') : `ring group ${t.num}${CFG.groups[t.num].name ? ` "${CFG.groups[t.num].name}"` : ''}`;
    case 'queue': return forSpeech ? (CFG.queues[t.num].name || 'our team') : `queue ${t.num}${CFG.queues[t.num].name ? ` "${CFG.queues[t.num].name}"` : ''}`;
    case 'ivr': return forSpeech ? (CFG.ivrs[t.num].name || 'more options') : `menu ${t.num}${CFG.ivrs[t.num].name ? ` "${CFG.ivrs[t.num].name}"` : ''}`;
    case 'conf': return forSpeech ? 'the conference room' : `conference ${t.num}`;
    case 'outside': return forSpeech ? 'an outside line' : `outside number ${t.num}`;
    case 'feature': return `feature code ${t.code}`;
    default: return forSpeech ? sp(d) : `${d} (not set up yet)`;
  }
}
async function route(leg, dest, ctx = {}) {
  if (leg.state === 'ended') return;
  const c = { ...ctx, depth: (ctx.depth || 0) + 1 };
  if (c.depth > 12) { log('Routing loop stopped at', dest); return leg.hangup(); }
  const d = destType(dest);
  switch (d.type) {
    case 'hangup': if (leg.state === 'answered') await leg.say(PROMPTS.goodbye, { interrupt: false }); return leg.hangup();
    case 'vm': return CFG.extensions[d.box] ? voicemailApp(leg, d.box, 'direct') : route(leg, 'none', c);
    case 'feature': return featureApp(leg, d.code, c);
    case 'ext': return dialExtension(leg, d.num, c);
    case 'group': return ringGroupApp(leg, d.num, c);
    case 'queue': return queueApp(leg, d.num, c);
    case 'ivr': return ivrApp(leg, d.num, c);
    case 'conf': return conferenceApp(leg, d.num, c);
    case 'outside': return dialOutside(leg, outsideDial(dest), c);
    default:
      log(`${leg.label()} dialed ${dest || '(nothing)'}: no such number`);
      if (leg.cdr) leg.cdr.result = 'no such number';
      if (leg.state === 'answered') { leg.source = null; await leg.say(PROMPTS.notInService, { interrupt: false }); return leg.hangup(); }
      return leg.reject(404, 'Not Found');
  }
}

// ================================================================ extensions, forwarding, busy/no-answer handling
async function dialExtension(leg, num, ctx) {
  const e = CFG.extensions[num];
  leg.activity = `calling ${num}`;
  if (e.dnd) return extFallback(leg, num, 'dnd', ctx);
  if (e.forward.always && (ctx.fwd || 0) < 4) { log(`${num} forwards all calls to ${e.forward.always}`); return route(leg, e.forward.always, { ...ctx, fwd: (ctx.fwd || 0) + 1 }); }
  if (!REGS.has(num)) return extFallback(leg, num, 'unavailable', ctx);
  if (!CFG.callWaiting && isBusy(num)) return extFallback(leg, num, 'busy', ctx);
  const r = await dialDevices(leg, [num], { timeout: e.ringTime || CFG.ringTime });
  if (r === 'answered' || r === 'cancelled') return;
  return extFallback(leg, num, r, ctx);
}
async function extFallback(leg, num, why, ctx) {
  const e = CFG.extensions[num];
  if (leg.state === 'ended') return;
  const fw = { busy: e.forward.busy, noanswer: e.forward.noAnswer, unavailable: e.forward.unavailable || e.forward.noAnswer, dnd: e.forward.busy }[why];
  if (fw && (ctx.fwd || 0) < 4) { log(`${num} is ${why === 'noanswer' ? 'not answering' : why}; forwarding to ${fw}`); return route(leg, fw, { ...ctx, fwd: (ctx.fwd || 0) + 1 }); }
  if (e.voicemail) return voicemailApp(leg, num, why);
  if (leg.cdr) leg.cdr.result = why;
  if (leg.state === 'answered') { leg.source = null; await leg.say(why === 'busy' || why === 'dnd' ? 'The person you are calling is busy. Goodbye.' : 'The person you are calling is not available. Goodbye.', { interrupt: false }); return leg.hangup(); }
  return leg.reject(why === 'busy' || why === 'dnd' ? 486 : 480, why === 'busy' || why === 'dnd' ? 'Busy Here' : 'Temporarily Unavailable');
}

// ================================================================ ring groups
async function ringGroupApp(leg, num, ctx) {
  const g = CFG.groups[num];
  leg.activity = `ringing group ${num}`;
  const members = (g.members || []).filter(m => CFG.extensions[m]);
  const t = g.ringTime || CFG.ringTime;
  if (g.strategy === 'sequential') {
    for (const m of members) { if (leg.state === 'ended') return; const r = await dialDevices(leg, [m], { timeout: Math.min(t, 20) }); if (r === 'answered' || r === 'cancelled') return; }
  } else {
    const r = await dialDevices(leg, members, { timeout: t });
    if (r === 'answered' || r === 'cancelled') return;
  }
  if (leg.state === 'ended') return;
  if (g.fallback) return route(leg, g.fallback, ctx);
  if (leg.cdr) leg.cdr.result = 'nobody answered';
  if (leg.state === 'answered') { leg.source = null; await leg.say('Sorry, nobody is available right now. Goodbye.', { interrupt: false }); return leg.hangup(); }
  return leg.reject(480, 'Temporarily Unavailable');
}

// ================================================================ call queues
const QWAIT = new Map(); // queue number -> [legs waiting, oldest first]
async function queueApp(leg, num, ctx) {
  const q = CFG.queues[num];
  leg.activity = `waiting in queue ${num}`;
  await leg.answer();
  await leg.say(q.greeting || PROMPTS.queueWelcome, { interrupt: false });
  if (leg.state === 'ended') return;
  const list = QWAIT.get(num) || []; list.push(leg); QWAIT.set(num, list);
  const leave = () => { const l = QWAIT.get(num) || []; QWAIT.set(num, l.filter(x => x !== leg)); };
  leg.onEnd(leave);
  const start = now(), maxWait = (q.maxWait || 300) * 1000;
  let lastAnnounce = now();
  leg.source = new MohSource();
  while (leg.state !== 'ended' && now() - start < maxWait) {
    const head = (QWAIT.get(num) || []).find(x => x.state !== 'ended');
    const agents = (q.agents || []).filter(a => REGS.has(a) && CFG.extensions[a] && !CFG.extensions[a].dnd && !isBusy(a));
    if (head === leg && agents.length) {
      const r = await dialDevices(leg, agents, { timeout: q.ringTime || 20, keepAudio: true });
      if (r === 'answered' || r === 'cancelled') { leave(); return; }
    } else await sleep(1500);
    if (leg.state !== 'ended' && now() - lastAnnounce > 45000) { lastAnnounce = now(); leg.source = null; await leg.say(PROMPTS.queueHold, { interrupt: false }); if (leg.state !== 'ended' && !leg.peer) leg.source = new MohSource(); }
  }
  leave();
  if (leg.state === 'ended') return;
  leg.source = null;
  if (q.fallback) return route(leg, q.fallback, ctx);
  if (leg.cdr) leg.cdr.result = 'queue timeout';
  await leg.say(PROMPTS.queueNobody, { interrupt: false });
  leg.hangup();
}

// ================================================================ IVR / auto attendant
function autoGreeting(v) {
  const parts = [v.name ? `Welcome to ${v.name}.` : 'Welcome.'];
  const opts = Object.entries(v.options || {}).sort();
  for (const [k, d] of opts) parts.push(`For ${destName(d, true)}, press ${k === '*' ? 'star' : k === '#' ? 'pound' : k}.`);
  if (v.directDial !== false) parts.push('If you know the extension number, you can dial it at any time.');
  return parts.join(' ');
}
async function ivrApp(leg, num, ctx) {
  const v = CFG.ivrs[num];
  leg.activity = `in menu ${num}`;
  await leg.answer();
  const greeting = v.greeting || autoGreeting(v);
  for (let attempt = 0; attempt < 3 && leg.state !== 'ended'; attempt++) {
    await leg.say(greeting);
    const d = await leg.getDigits({ max: 1, timeout: (v.timeout || 6) * 1000, term: '' });
    if (d === null) return;
    if (d === '') continue;
    const opt = (v.options || {})[d];
    if (opt) { log(`${leg.label()} pressed ${d} in menu ${num} -> ${opt}`); leg.source = null; return route(leg, opt, ctx); }
    if (v.directDial !== false && /\d/.test(d) && [...Object.keys(CFG.extensions), ...Object.keys(CFG.groups), ...Object.keys(CFG.queues)].some(x => x.startsWith(d))) {
      const more = await leg.getDigits({ max: 6, timeout: 3000, inter: 2500, term: '#' });
      if (more === null) return;
      const full = d + more;
      if (CFG.extensions[full] || CFG.groups[full] || CFG.queues[full] || CFG.conferences[full]) { log(`${leg.label()} dialed ${full} from menu ${num}`); return route(leg, full, ctx); }
    }
    await leg.say(PROMPTS.invalid, { interrupt: false });
    leg.digits = '';
  }
  if (leg.state === 'ended') return;
  if (v.timeoutDest) return route(leg, v.timeoutDest, ctx);
  await leg.say(PROMPTS.goodbye, { interrupt: false });
  leg.hangup();
}

// ================================================================ voicemail
const vmDir = box => { const d = path.join(DIRS.vm, box); fs.mkdirSync(d, { recursive: true }); return d; };
function vmList(box) { try { return JSON.parse(fs.readFileSync(path.join(DIRS.vm, box, 'index.json'), 'utf8')); } catch (e) { return []; } }
function vmWrite(box, list) { fs.writeFileSync(path.join(vmDir(box), 'index.json'), JSON.stringify(list, null, 1)); }
function vmDelete(box, id) {
  const list = vmList(box), m = list.find(x => String(x.id) === String(id));
  if (!m) return false;
  try { fs.unlinkSync(path.join(DIRS.vm, box, m.file)); } catch (e) {}
  vmWrite(box, list.filter(x => x !== m)); notifyMwi(box); return true;
}
function vmMarkOld(box, id) { const list = vmList(box); const m = list.find(x => String(x.id) === String(id)); if (m && m.isNew) { m.isNew = false; vmWrite(box, list); } }
async function voicemailApp(leg, box, why) {
  const e = CFG.extensions[box];
  leg.activity = `leaving a voicemail for ${box}`;
  if (leg.cdr) leg.cdr.result = 'voicemail';
  await leg.answer();
  leg.source = null;
  await leg.say(e && e.vmGreeting ? e.vmGreeting : PROMPTS.vmDefault(e && e.name));
  if (leg.state === 'ended') return;
  leg.digits = '';
  await leg.play(BEEP, { interrupt: false });
  const chunks = []; let samples = 0; const max = RATE * 180;
  leg.rtp.onPcm = pcm => { if (samples < max) { chunks.push(pcm); samples += pcm.length; } };
  await Promise.race([leg.getDigits({ max: 1, timeout: 181000, term: '' }), sleep(181000)]);
  if (leg.rtp) leg.rtp.onPcm = null;
  if (samples >= RATE) {
    const id = now(), file = `${id}.wav`;
    writeWav(path.join(vmDir(box), file), [concatPcm(chunks)]);
    const list = vmList(box);
    list.unshift({ id, file, from: leg.number, fromName: leg.name, date: new Date().toISOString(), duration: Math.round(samples / RATE), isNew: true });
    vmWrite(box, list);
    log(`New voicemail for ${box} from ${leg.label()} (${Math.round(samples / RATE)} s)`);
    notifyMwi(box);
  }
  if (leg.state !== 'ended') { await leg.say('Thank you. Your message has been saved. Goodbye.', { interrupt: false }); leg.hangup(); }
}
async function voicemailMenu(leg, box) {
  await leg.answer();
  leg.activity = `listening to voicemail ${box}`;
  const list = vmList(box);
  const neu = list.filter(x => x.isNew).length, old = list.length - neu;
  await leg.say(`You have ${neu} new ${neu === 1 ? 'message' : 'messages'}, and ${old} saved ${old === 1 ? 'message' : 'messages'}.`, { interrupt: false });
  const order = [...list.filter(x => x.isNew), ...list.filter(x => !x.isNew)];
  for (let i = 0; i < order.length && leg.state !== 'ended';) {
    const msg = order[i];
    let pcm; try { pcm = readWav(fs.readFileSync(path.join(DIRS.vm, box, msg.file))); } catch (e) { i++; continue; }
    leg.digits = '';
    const when = new Date(msg.date);
    await leg.say(`Message ${i + 1}, from ${msg.fromName && msg.fromName !== msg.from ? msg.fromName + ', ' : ''}${spell(msg.from)}, received ${when.toLocaleDateString('en-US', { weekday: 'long' })} at ${when.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })}.`);
    await leg.play(pcm);
    vmMarkOld(box, msg.id);
    await leg.say(PROMPTS.vmMenu);
    const k = await leg.getDigits({ max: 1, timeout: 8000, term: '' });
    if (k === null) break;
    if (k === '7') { vmDelete(box, msg.id); await leg.say(PROMPTS.vmDeleted, { interrupt: false }); i++; }
    else if (k === '1') continue;
    else if (k === '9') { await leg.say(PROMPTS.vmSaved, { interrupt: false }); i++; }
    else i++;
  }
  notifyMwi(box);
  if (leg.state === 'ended') return;
  await leg.say(PROMPTS.vmNoMore, { interrupt: false });
  leg.hangup();
}

// ================================================================ conference rooms (the PBX mixes everyone's audio)
const ROOMS = new Map();
function takeSamples(m, n) {
  if (m.len < n) return null;
  const out = new Int16Array(n); let p = 0;
  while (p < n) { const h = m.fifo[0], k = Math.min(n - p, h.length); out.set(h.subarray(0, k), p); p += k; if (k === h.length) m.fifo.shift(); else m.fifo[0] = h.subarray(k); }
  m.len -= n; return out;
}
function mixRoom(room) {
  const total = new Int32Array(FRAME), frames = new Map();
  for (const [leg, m] of room.members) {
    while (m.len > FRAME * 8) takeSamples(m, FRAME); // drop audio if a phone sends faster than real time
    const f = takeSamples(m, FRAME); frames.set(leg, f);
    if (f && !leg.held) for (let i = 0; i < FRAME; i++) total[i] += f[i];
  }
  for (const [leg, m] of room.members) {
    if (leg.state !== 'answered' || leg.source) continue;
    const own = frames.get(leg), ex = m.extra ? m.extra.next() : null; if (m.extra && !ex) m.extra = null;
    const out = new Int16Array(FRAME);
    for (let i = 0; i < FRAME; i++) { const v = total[i] - (own && !leg.held ? own[i] : 0) + (ex ? ex[i] : 0); out[i] = v > 32767 ? 32767 : v < -32768 ? -32768 : v; }
    leg.rtp.sendPcm(out);
  }
}
async function conferenceApp(leg, num, ctx) {
  const c = CFG.conferences[num];
  await leg.answer();
  leg.activity = `in conference ${num}`;
  if (c.pin) {
    let ok = false;
    for (let i = 0; i < 3 && leg.state !== 'ended'; i++) {
      leg.digits = '';
      await leg.say(PROMPTS.confPin);
      const d = await leg.getDigits({ max: 12, timeout: 10000, inter: 5000, term: '#' });
      if (d === null) return;
      if (d === String(c.pin)) { ok = true; break; }
      await leg.say(PROMPTS.confWrong, { interrupt: false });
    }
    if (!ok) { if (leg.state !== 'ended') { await leg.say(PROMPTS.goodbye, { interrupt: false }); leg.hangup(); } return; }
  }
  let room = ROOMS.get(num);
  if (!room) { room = { num, members: new Map() }; room.tick = () => mixRoom(room); ROOMS.set(num, room); TICKERS.add(room.tick); }
  if (room.members.size >= (c.max || 20)) { await leg.say('Sorry, this conference is full. Goodbye.', { interrupt: false }); return leg.hangup(); }
  if (room.members.size === 0) await leg.say(PROMPTS.confAlone, { interrupt: false });
  else await leg.play(JOIN_BEEP, { interrupt: false });
  if (leg.state === 'ended') return;
  const mem = { fifo: [], len: 0, extra: null };
  for (const [, m] of room.members) m.extra = new BufferSource(JOIN_BEEP);
  room.members.set(leg, mem);
  leg.rtp.onPcm = pcm => { mem.fifo.push(pcm); mem.len += pcm.length; };
  if (leg.cdr) { leg.cdr.result = `conference ${num}`; if (!leg.cdr.answer) leg.cdr.answer = now(); }
  log(`${leg.label()} joined conference ${num} (${room.members.size} in the room)`);
  leg.onEnd(() => {
    room.members.delete(leg);
    for (const [, m] of room.members) m.extra = new BufferSource(LEAVE_BEEP);
    log(`${leg.label()} left conference ${num}`);
    if (!room.members.size) { TICKERS.delete(room.tick); ROOMS.delete(num); }
  });
}

// ================================================================ echo test and feature codes
async function echoApp(leg) {
  await leg.answer();
  leg.activity = 'echo test';
  if (leg.cdr) { leg.cdr.result = 'echo test'; leg.cdr.answer = now(); }
  await leg.say(PROMPTS.echo, { interrupt: false });
  if (leg.state === 'ended') return;
  leg.rtp.onPcm = pcm => leg.rtp.sendPcm(pcm);
}
const FEATURE_CODES = [
  ['*43', 'echo test: hear yourself, to check your audio'],
  ['*97', 'listen to your own voicemail'],
  ['*98', 'listen to any voicemail box (asks for box number and PIN)'],
  ['*78', 'turn Do Not Disturb on'], ['*79', 'turn Do Not Disturb off'],
  ['*72 + number', 'forward all your calls (for example *72102)'], ['*73', 'stop forwarding'],
  ['*65', 'hear your own extension number'],
];
async function featureApp(leg, code, ctx) {
  const ext = leg.ext, e = ext && CFG.extensions[ext];
  const m = /^\*(\d\d)(.*)$/.exec(code) || [];
  const fc = m[1], extra = (m[2] || '').replace(/[^\d+]/g, '');
  leg.activity = `feature ${code}`;
  if (leg.cdr) leg.cdr.result = `feature ${code}`;
  const done = async text => { await leg.answer(); await leg.say(text, { interrupt: false }); leg.hangup(); };
  const change = f => { snapshot(); f(); saveConfig(); };
  switch (fc) {
    case '43': return echoApp(leg);
    case '97': if (e) return voicemailMenu(leg, ext); break;
    case '98': {
      await leg.answer();
      await leg.say('Please enter the mailbox number, followed by pound.');
      const box = await leg.getDigits({ max: 8, timeout: 8000, term: '#' });
      if (box === null) return;
      await leg.say('Please enter the PIN, followed by pound.');
      const pin = await leg.getDigits({ max: 12, timeout: 8000, term: '#' });
      if (pin === null) return;
      if (CFG.extensions[box] && pin === String(CFG.extensions[box].vmPin)) return voicemailMenu(leg, box);
      return done('Sorry, that mailbox or PIN is not correct. Goodbye.');
    }
    case '78': if (e) { change(() => { e.dnd = true; }); log(`${ext} turned Do Not Disturb on`); return done(PROMPTS.dndOn); } break;
    case '79': if (e) { change(() => { e.dnd = false; }); log(`${ext} turned Do Not Disturb off`); return done(PROMPTS.dndOff); } break;
    case '72': if (e && extra) { change(() => { e.forward.always = extra; }); log(`${ext} forwards all calls to ${extra}`); return done(PROMPTS.fwdOn(extra)); } break;
    case '73': if (e) { change(() => { e.forward.always = ''; }); log(`${ext} stopped forwarding`); return done(PROMPTS.fwdOff); } break;
    case '65': if (e) return done(`Your extension number is ${spell(ext)}.`); break;
  }
  if (leg.state === 'answered') { await leg.say(PROMPTS.notInService, { interrupt: false }); return leg.hangup(); }
  return leg.reject(404, 'Not Found');
}

// ================================================================ outside line (SIP trunk)
const TRUNK = { addrs: new Set(), registered: false, lastError: '', timer: null, regCallId: randHex(20) + '@aivoice-pbx', regCseq: 1, tag: randHex(10) };
async function trunkAddr() {
  const t = CFG.trunk; if (!t.host) return null;
  try { const r = /^\d+\.\d+\.\d+\.\d+$/.test(t.host) ? { address: t.host } : await dns.promises.lookup(t.host, { family: 4 }); TRUNK.addrs.add(r.address); return { address: r.address, port: +t.port || 5060 }; }
  catch (e) { TRUNK.lastError = `cannot find ${t.host}`; return null; }
}
function isFromTrunk(addr) {
  if (!CFG.trunk.enabled || !TRUNK.addrs.has(addr.address)) return false;
  for (const r of REGS.values()) if (r.addr.address === addr.address && r.addr.port === addr.port) return false;
  return true;
}
function trunkInboundDest() {
  const t = CFG.trunk;
  return t.inbound || Object.keys(CFG.ivrs)[0] || Object.keys(CFG.groups)[0] || Object.keys(CFG.extensions)[0] || 'hangup';
}
async function trunkRegister(authHeader) {
  const t = CFG.trunk;
  clearTimeout(TRUNK.timer);
  if (!t.enabled || !t.host || !t.register || !t.username) { TRUNK.registered = false; return; }
  const addr = await trunkAddr();
  if (!addr) { TRUNK.registered = false; TRUNK.timer = setTimeout(() => trunkRegister(), 60000); return; }
  const dom = t.domain || t.host, branch = newBranch(), uri = `sip:${dom}`;
  const h = [['Via', viaFor(addr, branch)], ['Max-Forwards', '70'], ['From', `<sip:${t.username}@${dom}>;tag=${TRUNK.tag}`], ['To', `<sip:${t.username}@${dom}>`],
    ['Call-ID', TRUNK.regCallId], ['CSeq', `${TRUNK.regCseq++} REGISTER`], ['Contact', `<sip:${t.username}@${pbxIpFor(addr)}:${CFG.sipPort}>`], ['Expires', '300'], ['User-Agent', UA_NAME]];
  if (authHeader) h.push(authHeader);
  sendRequest(buildMsg(`REGISTER ${uri} SIP/2.0`, h), addr, branch, 'REGISTER', m => {
    if (!m) { TRUNK.registered = false; TRUNK.lastError = 'the provider did not answer'; TRUNK.timer = setTimeout(() => trunkRegister(), 30000); return; }
    if (m.status < 200) return;
    if ((m.status === 401 || m.status === 407) && !authHeader) {
      const ch = hdr(m, m.status === 401 ? 'www-authenticate' : 'proxy-authenticate');
      return trunkRegister([m.status === 401 ? 'Authorization' : 'Proxy-Authorization', digestHeader(ch, 'REGISTER', uri, t.authUser || t.username, t.password)]);
    }
    if (m.status < 300) {
      const exp = +(paramOf(hdr(m, 'contact'), 'expires') || hdr(m, 'expires') || 300);
      if (!TRUNK.registered) log(`Outside line registered with ${t.host}`);
      TRUNK.registered = true; TRUNK.lastError = '';
      TRUNK.timer = setTimeout(() => trunkRegister(), Math.max(30, exp * 0.8) * 1000);
      return;
    }
    TRUNK.registered = false; TRUNK.lastError = `${m.status} ${m.reason}`;
    log(`Outside line registration failed: ${TRUNK.lastError}`);
    TRUNK.timer = setTimeout(() => trunkRegister(), 60000);
  });
}
async function dialOutside(leg, number, ctx) {
  const t = CFG.trunk;
  const fail = async (text, code) => { if (leg.cdr) leg.cdr.result = 'outside call failed'; if (leg.state === 'answered') { leg.source = null; await leg.say(text, { interrupt: false }); return leg.hangup(); } return leg.reject(code, 'Unavailable'); };
  if (!t.enabled || !t.host) { log(`Outside call to ${number} refused: no outside line set up`); return fail('Sorry, outside calls are not set up on this phone system.', 403); }
  const addr = await trunkAddr();
  if (!addr) return fail('Sorry, the outside line is not reachable right now.', 503);
  leg.activity = `calling ${number}`;
  if (leg.state === 'answered') leg.source = new RingbackSource(); else leg.provisional(180, 'Ringing');
  const dom = t.domain || t.host;
  const o = await Leg.dial({ uri: `sip:${number}@${t.host}${+t.port && +t.port !== 5060 ? ':' + t.port : ''}`, addr, toUser: number,
    fromUser: t.username, fromName: leg.trunk ? leg.name : (t.callerId || leg.name), trunk: true, domain: dom });
  const onA = () => o.hangup(); leg.onEnd(onA);
  const timer = setTimeout(() => o.hangup('no answer'), 90000);
  const r = await o.result;
  clearTimeout(timer); leg.offEnd(onA);
  if (leg.state === 'ended') { if (r.ok) o.hangup(); return; }
  if (r.ok) { if (leg.source instanceof RingbackSource) leg.source = null; if (leg.state !== 'answered') await leg.answer(); bridge(leg, o); return; }
  log(`Outside call to ${number} failed (${r.code})`);
  return fail(r.code === 486 ? 'The number you called is busy. Goodbye.' : 'Sorry, the call could not be completed.', r.code === 486 ? 486 : 480);
}

// ================================================================ chat: the action executor (shared by the English parser and Claude)
const KINDS = { extensions: 'extension', groups: 'ring group', queues: 'queue', ivrs: 'menu', conferences: 'conference room' };
function numberInUse(n) { for (const k of Object.keys(KINDS)) if (CFG[k][n]) return KINDS[k]; return null; }
function nextFree(start) { let n = start; while (numberInUse(String(n)) && n < start + 5000) n++; return String(n); }
const extName = n => (CFG.extensions[n] && CFG.extensions[n].name) ? `${CFG.extensions[n].name} (${n})` : n;
const yes = v => v === true || /^(on|yes|true|enable|enabled|1)$/i.test(String(v));
function findByName(name, kinds = ['extensions', 'groups', 'queues', 'ivrs', 'conferences']) {
  const n = String(name || '').toLowerCase().trim(); if (!n || n.length < 2) return null;
  for (const k of kinds) for (const [num, o] of Object.entries(CFG[k])) if (o.name && o.name.toLowerCase() === n) return num;
  for (const k of kinds) for (const [num, o] of Object.entries(CFG[k])) if (o.name && o.name.toLowerCase().split(/\s+/).includes(n)) return num;
  return null;
}
function resolveDest(d) {
  if (d === null || d === undefined) return null;
  let s = String(d).trim(); if (!s) return '';
  if (/^(hangup|hang up|none|nothing|nobody|disconnect)$/i.test(s)) return 'hangup';
  const vm = /^(?:vm:|voicemail[: ]+(?:of |for )?)(.+)$/i.exec(s);
  if (vm) { const b = /^\d+$/.test(vm[1].trim()) ? vm[1].trim() : findByName(vm[1], ['extensions']); return b ? 'vm:' + b : null; }
  if (/^\*\d/.test(s)) return s;
  const num = s.replace(/[\s\-().]/g, '');
  if (/^\+?\d+$/.test(num)) return num;
  return findByName(s);
}
function setupHint(n) {
  const e = CFG.extensions[n];
  return `To connect a phone as ${extName(n)}: server/domain \`${LAN_IP}\`${CFG.sipPort !== 5060 ? ` port \`${CFG.sipPort}\`` : ''}, username \`${n}\`, password \`${e.password}\`.`;
}
function runActions(actions) {
  const msgs = []; let changed = false, snapped = false;
  const change = () => { if (!snapped) { snapshot(); snapped = true; } changed = true; };
  for (const a of actions || []) {
    try {
      const r = execAction(a, change);
      if (r) msgs.push(r);
    } catch (e) { msgs.push(`Couldn't do that: ${e.message}`); }
  }
  if (changed) { saveConfig(); afterConfigChange(); }
  return { msgs, changed };
}
function needNum(n, what) { n = String(n ?? '').replace(/\D/g, ''); if (!/^\d{2,7}$/.test(n)) throw new Error(`${what} needs a number of 2 to 7 digits`); return n; }
function execAction(a, change) {
  const t = a.type;
  switch (t) {
    case 'say': return a.text;
    case 'ext.add': {
      const n = needNum(a.number, 'An extension');
      const used = numberInUse(n);
      if (used === 'extension') {
        const e = CFG.extensions[n]; let upd = [];
        if (a.name && a.name !== e.name) { change(); e.name = a.name; upd.push(`name is now **${a.name}**`); }
        if (a.password) { change(); e.password = String(a.password); upd.push('password changed'); }
        return upd.length ? `Extension ${n} already existed, so I updated it: ${upd.join(', ')}.` : `Extension ${extName(n)} already exists. ${setupHint(n)}`;
      }
      if (used) throw new Error(`${n} is already used by a ${used}`);
      change();
      const e = newExtension(a.name || `Extension ${n}`);
      if (a.password) e.password = String(a.password);
      CFG.extensions[n] = e;
      return `Added extension **${n}**${a.name ? ` for **${a.name}**` : ''}. ${setupHint(n)}`;
    }
    case 'ext.update': {
      const n = needNum(a.number, 'Extension'); const e = CFG.extensions[n];
      if (!e) throw new Error(`there is no extension ${n}`);
      const done = [];
      if (a.name !== undefined && a.name !== null) { change(); e.name = String(a.name); done.push(`name **${e.name}**`); }
      if (a.password !== undefined && a.password !== null) { change(); e.password = a.password === 'random' || a.password === '' ? randPass() : String(a.password); done.push(`password \`${e.password}\``); }
      if (a.voicemail !== undefined) { change(); e.voicemail = yes(a.voicemail); done.push(`voicemail ${e.voicemail ? 'on' : 'off'}`); }
      if (a.vmPin !== undefined) { change(); e.vmPin = String(a.vmPin).replace(/\D/g, '') || e.vmPin; done.push(`voicemail PIN \`${e.vmPin}\``); }
      if (a.vmGreeting !== undefined) { change(); e.vmGreeting = String(a.vmGreeting || ''); done.push(e.vmGreeting ? `voicemail greeting "${e.vmGreeting}"` : 'standard voicemail greeting'); }
      if (a.dnd !== undefined) { change(); e.dnd = yes(a.dnd); done.push(`Do Not Disturb ${e.dnd ? 'on' : 'off'}`); }
      if (a.record !== undefined) { change(); e.record = yes(a.record); done.push(`call recording ${e.record ? 'on' : 'off'}`); }
      if (a.ringTime !== undefined) { change(); e.ringTime = Math.max(5, Math.min(300, +a.ringTime || 0)); done.push(`rings for ${e.ringTime} seconds`); }
      return done.length ? `${extName(n)}: ${done.join(', ')}.` : `Nothing to change for ${extName(n)}.`;
    }
    case 'ext.remove': {
      const n = needNum(a.number, 'Extension');
      if (!CFG.extensions[n]) throw new Error(`there is no extension ${n}`);
      change(); const name = extName(n); delete CFG.extensions[n]; REGS.delete(n);
      for (const g of Object.values(CFG.groups)) g.members = (g.members || []).filter(m => m !== n);
      for (const q of Object.values(CFG.queues)) q.agents = (q.agents || []).filter(m => m !== n);
      return `Deleted extension ${name}.`;
    }
    case 'ext.forward': {
      const n = needNum(a.number, 'Extension'); const e = CFG.extensions[n];
      if (!e) throw new Error(`there is no extension ${n}`);
      const when = a.when || 'always';
      const to = a.to ? resolveDest(a.to) : '';
      if (a.to && !to) throw new Error(`I don't know where "${a.to}" is`);
      change();
      const keys = when === 'all' ? ['always', 'busy', 'noAnswer', 'unavailable'] : [when];
      for (const k of keys) e.forward[k] = to || '';
      if (a.ringTime) e.ringTime = Math.max(5, Math.min(300, +a.ringTime));
      const label = { always: 'all calls', busy: 'when busy', noAnswer: `when not answered${e.ringTime ? ` within ${e.ringTime} s` : ''}`, unavailable: 'when the phone is offline', all: 'in every case' }[when];
      const warn = to && destType(to).type === 'unknown' ? ` (note: ${to} isn't set up yet)` : '';
      return to ? `${extName(n)} now forwards ${label} to **${destName(to)}**${warn}.` : `Forwarding ${label} is now off for ${extName(n)}.`;
    }
    case 'group.set': case 'queue.set': {
      const isG = t === 'group.set', store = isG ? CFG.groups : CFG.queues, key = isG ? 'members' : 'agents';
      const n = needNum(a.number, isG ? 'A ring group' : 'A queue');
      const used = numberInUse(n);
      if (used && used !== (isG ? 'ring group' : 'queue')) throw new Error(`${n} is already used by a ${used}`);
      const isNew = !store[n];
      change();
      const o = store[n] || (store[n] = isG ? { name: '', members: [], strategy: 'ringall', ringTime: 0, fallback: '' } : { name: '', agents: [], maxWait: 300, ringTime: 20, fallback: '', greeting: '' });
      const unknown = [];
      const clean = list => (list || []).map(x => /^\d+$/.test(String(x)) ? String(x) : findByName(x, ['extensions']) || (unknown.push(x), null)).filter(Boolean);
      if (a.name) o.name = a.name;
      if (a[key] || a.members || a.agents) o[key] = [...new Set(clean(a[key] || a.members || a.agents))];
      const add = clean(a.add || a.addMembers || a.addAgents), rem = clean(a.remove || a.removeMembers || a.removeAgents);
      for (const x of add) if (!o[key].includes(x)) o[key].push(x);
      o[key] = o[key].filter(x => !rem.includes(x));
      if (a.strategy) o.strategy = /seq|order|one|hunt|turn|round/i.test(a.strategy) ? 'sequential' : 'ringall';
      if (a.ringTime) o.ringTime = Math.max(5, Math.min(300, +a.ringTime));
      if (a.maxWait) o.maxWait = Math.max(10, Math.min(3600, +a.maxWait));
      if (a.fallback !== undefined) { const f = resolveDest(a.fallback); if (a.fallback && !f) throw new Error(`I don't know where "${a.fallback}" is`); o.fallback = f || ''; }
      if (a.greeting !== undefined) o.greeting = a.greeting || '';
      const missing = o[key].filter(x => !CFG.extensions[x]);
      const who = o[key].length ? o[key].map(extName).join(', ') : 'nobody yet';
      let msg = isG
        ? `${isNew ? 'Created' : 'Updated'} ring group **${n}**${o.name ? ` "${o.name}"` : ''}: rings ${who} ${o.strategy === 'sequential' ? 'one after another' : 'all at once'}${o.fallback ? `, then goes to ${destName(o.fallback)}` : ''}.`
        : `${isNew ? 'Created' : 'Updated'} queue **${n}**${o.name ? ` "${o.name}"` : ''}: agents ${who}; callers hear music and wait up to ${Math.round(o.maxWait / 60 * 10) / 10} min${o.fallback ? `, then go to ${destName(o.fallback)}` : ''}.`;
      if (unknown.length) msg += ` I couldn't find: ${unknown.join(', ')}.`;
      if (missing.length) msg += ` Note: ${missing.join(', ')} ${missing.length > 1 ? "aren't extensions" : "isn't an extension"} yet.`;
      return msg;
    }
    case 'group.remove': case 'queue.remove': case 'ivr.remove': case 'conf.remove': {
      const store = { 'group.remove': CFG.groups, 'queue.remove': CFG.queues, 'ivr.remove': CFG.ivrs, 'conf.remove': CFG.conferences }[t];
      const label = { 'group.remove': 'ring group', 'queue.remove': 'queue', 'ivr.remove': 'menu', 'conf.remove': 'conference room' }[t];
      const n = needNum(a.number, label);
      if (!store[n]) throw new Error(`there is no ${label} ${n}`);
      change(); delete store[n];
      return `Deleted ${label} ${n}.`;
    }
    case 'ivr.set': {
      const n = needNum(a.number, 'A menu');
      const used = numberInUse(n);
      if (used && used !== 'menu') throw new Error(`${n} is already used by a ${used}`);
      const isNew = !CFG.ivrs[n];
      change();
      const v = CFG.ivrs[n] || (CFG.ivrs[n] = { name: '', greeting: '', options: {}, timeoutDest: '', directDial: true, timeout: 6 });
      const bad = [];
      if (a.name) v.name = a.name;
      if (a.greeting !== undefined) v.greeting = a.greeting || '';
      for (const [k, d] of Object.entries(a.options || {})) {
        const key = String(k).replace(/^(star)$/i, '*').replace(/^(pound|hash)$/i, '#');
        if (!/^[0-9*#]$/.test(key)) continue;
        if (d === null || d === '' || d === 'remove') { delete v.options[key]; continue; }
        const r = resolveDest(d); if (!r) { bad.push(String(d)); continue; }
        v.options[key] = r;
      }
      if (a.timeoutDest !== undefined) { const r = resolveDest(a.timeoutDest); if (a.timeoutDest && !r) bad.push(String(a.timeoutDest)); else v.timeoutDest = r || ''; }
      if (a.directDial !== undefined) v.directDial = yes(a.directDial);
      if (a.timeout) v.timeout = Math.max(2, Math.min(30, +a.timeout));
      say(v.greeting || autoGreeting(v)).catch(() => {});
      const opts = Object.entries(v.options).sort().map(([k, d]) => `${k} → ${destName(d)}`).join(', ') || 'no options yet';
      let msg = `${isNew ? 'Created' : 'Updated'} menu **${n}**${v.name ? ` "${v.name}"` : ''}: ${opts}${v.timeoutDest ? `; no answer → ${destName(v.timeoutDest)}` : ''}.`;
      msg += v.greeting ? ` Greeting: "${v.greeting}"` : ` It will say: "${autoGreeting(v)}"`;
      if (bad.length) msg += ` I couldn't work out: ${bad.join(', ')}.`;
      return msg;
    }
    case 'conf.set': {
      const n = needNum(a.number, 'A conference room');
      const used = numberInUse(n);
      if (used && used !== 'conference room') throw new Error(`${n} is already used by a ${used}`);
      const isNew = !CFG.conferences[n];
      change();
      const c = CFG.conferences[n] || (CFG.conferences[n] = { name: '', pin: '', max: 20 });
      if (a.name) c.name = a.name;
      if (a.pin !== undefined) c.pin = String(a.pin || '').replace(/\D/g, '');
      return `${isNew ? 'Created' : 'Updated'} conference room **${n}**${c.name ? ` "${c.name}"` : ''}${c.pin ? ` with PIN \`${c.pin}\`` : ' (no PIN)'}. Anyone can dial ${n} to join.`;
    }
    case 'record': {
      change();
      if (!a.target || a.target === 'all') { CFG.recordAll = yes(a.on); return CFG.recordAll ? 'All calls will be recorded from now on.' : 'Stopped recording all calls (extensions set to record still are).'; }
      const n = needNum(a.target, 'Extension'); if (!CFG.extensions[n]) throw new Error(`there is no extension ${n}`);
      CFG.extensions[n].record = yes(a.on);
      return `Calls of ${extName(n)} will ${CFG.extensions[n].record ? '' : 'no longer '}be recorded.`;
    }
    case 'trunk.set': {
      change(); const tr = CFG.trunk; const done = [];
      const map = { host: 'host', server: 'host', port: 'port', username: 'username', user: 'username', authUser: 'authUser', password: 'password', domain: 'domain', register: 'register', outPrefix: 'outPrefix', outMinDigits: 'outMinDigits', callerId: 'callerId', inbound: 'inbound', enabled: 'enabled' };
      for (const [k, f] of Object.entries(map)) {
        if (a[k] === undefined) continue;
        let v = a[k];
        if (f === 'inbound') { v = resolveDest(v); if (a[k] && !v) throw new Error(`I don't know where "${a[k]}" is`); }
        if (f === 'enabled' || f === 'register') v = yes(v);
        if (f === 'port' || f === 'outMinDigits') v = +v || tr[f];
        tr[f] = v;
        done.push(f === 'password' ? 'password set' : f === 'inbound' ? `incoming calls go to ${destName(v)}` : f === 'enabled' ? (v ? 'enabled' : 'disabled') : f === 'register' ? (v ? 'registration on' : 'registration off') : `${f} = ${v}`);
      }
      if (a.enabled === undefined && tr.host && tr.username) tr.enabled = true;
      setTimeout(() => trunkRegister(), 200);
      return `Outside line: ${done.join(', ') || 'no changes'}.${tr.enabled ? ' I’m connecting to the provider now; say "show trunk" in a few seconds to check.' : ''}`;
    }
    case 'settings.set': {
      change(); const done = [];
      if (a.ringTime !== undefined) { CFG.ringTime = Math.max(5, Math.min(300, +a.ringTime)); done.push(`phones ring for ${CFG.ringTime} s before voicemail`); }
      if (a.publicIp !== undefined) { CFG.publicIp = String(a.publicIp || ''); done.push(CFG.publicIp ? `public IP ${CFG.publicIp}` : 'public IP cleared'); }
      if (a.lanIp !== undefined) {
        const want = String(a.lanIp || '').trim().toLowerCase(), list = localIps(), old = LAN_IP;
        if (!want || want === 'auto' || want === 'automatic') {
          CFG.lanIp = ''; if (!IP_FROM_ARGS) LAN_IP = lanIp();
          done.push(`server IP is automatic again (${LAN_IP})`);
        } else if (!isIpv4(want)) {
          done.push(`"${a.lanIp}" isn't an IP address, so the server IP stays ${LAN_IP}`);
        } else if (!list.some(c => c.ip === want)) {
          done.push(`${want} isn't one of this computer's addresses (${list.map(c => c.ip).join(', ') || 'none found'}), so the server IP stays ${LAN_IP}. For an address on your router or the internet, say "set public IP to ${want}" instead`);
        } else {
          CFG.lanIp = want; if (!IP_FROM_ARGS) LAN_IP = want;
          done.push(LAN_IP === old ? `server IP stays ${LAN_IP} (now fixed, not automatic)` : `server IP changed from ${old} to **${LAN_IP}**. Phones must now use \`${LAN_IP}\` as their SIP server: change it in each softphone's account settings`);
        }
        if (IP_FROM_ARGS) done.push(`note: start.bat forces ${IP_FROM_ARGS} with --ip, which wins until you remove it`);
      }
      if (a.callWaiting !== undefined) { CFG.callWaiting = yes(a.callWaiting); done.push(`call waiting ${CFG.callWaiting ? 'on' : 'off'}`); }
      if (a.sipPort !== undefined) { CFG.sipPort = +a.sipPort || 5060; done.push(`SIP port ${CFG.sipPort} (restart aiVoice PBX to apply)`); }
      if (a.httpPort !== undefined) { CFG.httpPort = +a.httpPort || 8080; done.push(`web page port ${CFG.httpPort} (restart to apply)`); }
      if (a.adminRemote !== undefined) { CFG.adminRemote = yes(a.adminRemote); done.push(CFG.adminRemote ? 'this page can now be opened from other computers' : 'this page only opens on this computer'); }
      if (a.adminPassword !== undefined) { CFG.adminPassword = String(a.adminPassword || ''); done.push(CFG.adminPassword ? 'page password set (user name: admin)' : 'page password removed'); }
      if (CFG.adminRemote && !CFG.adminPassword) { CFG.adminPassword = randPass(); done.push(`I set a page password for safety: user \`admin\`, password \`${CFG.adminPassword}\``); }
      return done.length ? `Settings: ${done.join('; ')}.` : 'No settings changed.';
    }
    case 'ai.set': {
      change(); const done = [];
      if (a.apiKey !== undefined) { CFG.ai.apiKey = String(a.apiKey || '').trim(); done.push(CFG.ai.apiKey ? 'API key saved' : 'API key removed'); if (CFG.ai.apiKey && a.enabled === undefined) CFG.ai.enabled = true; }
      if (a.model) { CFG.ai.model = String(a.model); done.push(`model ${CFG.ai.model}`); }
      if (a.enabled !== undefined) CFG.ai.enabled = yes(a.enabled);
      if (CFG.ai.enabled && !CFG.ai.apiKey) { CFG.ai.enabled = false; return 'To turn on Claude mode, paste your Anthropic API key, for example: `use AI with key sk-ant-...`'; }
      return `Claude mode is **${CFG.ai.enabled ? 'on' : 'off'}**${done.length ? ` (${done.join(', ')})` : ''}. ${CFG.ai.enabled ? 'I’ll ask Claude to understand your messages; the built-in parser is the backup.' : 'I’m using the built-in parser.'}`;
    }
    case 'vm.clear': {
      const boxes = a.number && a.number !== 'all' ? [needNum(a.number, 'Mailbox')] : Object.keys(CFG.extensions);
      let n = 0;
      for (const b of boxes) for (const m of vmList(b)) { if (vmDelete(b, m.id)) n++; }
      return `Deleted ${n} voicemail ${n === 1 ? 'message' : 'messages'}.`;
    }
    case 'vm.delete': return vmDelete(String(a.number), a.id) ? 'Voicemail deleted.' : 'That voicemail was already gone.';
    case 'rec.clear': {
      let n = 0;
      for (const f of fs.readdirSync(DIRS.rec)) if (f.endsWith('.wav') && (!a.file || f === a.file)) { try { fs.unlinkSync(path.join(DIRS.rec, f)); n++; } catch (e) {} }
      return `Deleted ${n} ${n === 1 ? 'recording' : 'recordings'}.`;
    }
    case 'call.hangup': {
      const list = [...LEGS.values()].filter(l => l.state !== 'ended' && (a.id === 'all' || a.id === undefined || String(l.id) === String(a.id) || (l.peer && String(l.peer.id) === String(a.id))));
      if (!list.length) return a.id && a.id !== 'all' ? `There is no active call ${a.id}.` : 'There are no active calls.';
      for (const l of list) l.hangup('hung up from the chat');
      return `Hung up ${list.length} call ${list.length === 1 ? 'leg' : 'legs'}.`;
    }
    case 'undo': {
      if (!HISTORY.length) return 'There is nothing to undo.';
      CFG = JSON.parse(HISTORY.pop()); saveConfig(); afterConfigChange();
      if (!IP_FROM_ARGS) LAN_IP = CFG.lanIp && localIps().some(c => c.ip === CFG.lanIp) ? CFG.lanIp : lanIp();
      return 'Undone: I put the previous setup back.';
    }
    case 'show': return showInfo(a.what, a.number);
    default: throw new Error(`unknown action "${t}"`);
  }
}
function afterConfigChange() {
  for (const v of Object.values(CFG.ivrs)) say(v.greeting || autoGreeting(v)).catch(() => {});
}

// ================================================================ chat: answering questions
function showInfo(what = 'status', number) {
  const exts = Object.entries(CFG.extensions);
  const st = n => CFG.extensions[n].dnd ? 'Do Not Disturb' : REGS.has(n) ? (isBusy(n) ? 'on a call' : 'online') : 'offline';
  const calls = activeCalls();
  switch (what) {
    case 'ips': {
      const list = localIps();
      const mode = IP_FROM_ARGS ? 'set by --ip in the start command' : CFG.lanIp && CFG.lanIp === LAN_IP ? 'chosen by you' : 'picked automatically';
      return `**Server IP: \`${LAN_IP}\`** (${mode}). Phones use this as their SIP server.\n\nAddresses on this computer:\n` +
        (list.length ? list.map(c => `- \`${c.ip}\` · ${c.adapter}${c.virtual ? ' (virtual adapter)' : ''}${c.ip === LAN_IP ? ' ← in use' : ''}`).join('\n') : '- none found (no network?)') +
        `\n\nTo change it say "use IP ${(list.find(c => c.ip !== LAN_IP) || list[0] || { ip: '192.168.1.20' }).ip}", or "use automatic IP".${CFG.publicIp ? ` Public IP for outside callers: \`${CFG.publicIp}\`.` : ''}`;
    }
    case 'ext': {
      const n = number, e = CFG.extensions[n]; if (!e) return `There is no extension ${n}.`;
      const f = Object.entries(e.forward).filter(([, v]) => v).map(([k, v]) => `${{ always: 'always', busy: 'when busy', noAnswer: 'no answer', unavailable: 'when offline' }[k]} → ${destName(v)}`);
      const reg = REGS.get(n);
      return [`**${extName(n)}**: ${st(n)}${reg ? ` (${reg.ua || 'phone'} at ${reg.addr.address})` : ''}`, `- password \`${e.password}\`, voicemail ${e.voicemail ? `on (PIN \`${e.vmPin}\`, ${vmCounts(n).neu} new)` : 'off'}`,
        `- forwarding: ${f.length ? f.join('; ') : 'none'}`, `- recording ${e.record || CFG.recordAll ? 'on' : 'off'}, rings ${e.ringTime || CFG.ringTime} s`, setupHint(n)].join('\n');
    }
    case 'extensions': case 'phones':
      return exts.length ? `**Extensions** (${[...REGS.keys()].filter(k => CFG.extensions[k]).length} of ${exts.length} online):\n` + exts.map(([n, e]) => `- **${n}** ${e.name || ''}: ${st(n)}${e.forward.always ? `, forwards to ${e.forward.always}` : ''}`).join('\n') : 'There are no extensions yet. Try "add extension 101 for Alice".';
    case 'setup': {
      const list = number && CFG.extensions[number] ? [number] : Object.keys(CFG.extensions);
      return `**Connecting a phone.** Install a softphone (for example MicroSIP on Windows, Zoiper or Linphone on a mobile) on the same network, and add an account:\n- SIP server / domain: \`${LAN_IP}\`${CFG.sipPort !== 5060 ? `, port \`${CFG.sipPort}\`` : ''}\n- Transport: UDP\n` + list.map(n => `- ${extName(n)}: username \`${n}\`, password \`${CFG.extensions[n].password}\``).join('\n') + `\n\nThen dial \`*43\` for an echo test, or call another extension.`;
    }
    case 'calls':
      return calls.length ? `**Active calls (${calls.length}):**\n` + calls.map(c => `- #${c.id} ${c.from} → ${c.to}: ${c.state}, ${c.secs} s`).join('\n') : 'No calls right now.';
    case 'log': {
      const l = CDR.slice(0, 10);
      return l.length ? '**Last calls:**\n' + l.map(c => `- ${new Date(c.start).toLocaleString()}: ${c.fromName || c.from} → ${c.answeredBy || c.to} (${c.result || 'in progress'}${c.duration ? `, ${c.duration} s` : ''})`).join('\n') : 'No calls yet.';
    }
    case 'voicemail': {
      const boxes = number ? [number] : Object.keys(CFG.extensions);
      const lines = boxes.map(b => { const l = vmList(b); return l.length ? `- **${extName(b)}**: ${l.filter(x => x.isNew).length} new, ${l.length} total` + l.slice(0, 5).map(m => `\n  - from ${m.fromName || m.from}, ${new Date(m.date).toLocaleString()}, ${m.duration} s${m.isNew ? ' (new)' : ''}`).join('') : null; }).filter(Boolean);
      return lines.length ? '**Voicemail:**\n' + lines.join('\n') + '\n\nListen in the Voicemail tab, or dial `*97` from your phone.' : 'No voicemail messages.';
    }
    case 'recordings': { const r = listRecordings(); return r.length ? `**${r.length} recordings** (newest first):\n` + r.slice(0, 8).map(x => `- ${x.name}`).join('\n') + '\n\nPlay them in the Recordings tab.' : `No recordings yet. ${CFG.recordAll ? 'All calls are being recorded.' : 'Say "record all calls" to start.'}`; }
    case 'groups': { const g = Object.entries(CFG.groups); return g.length ? '**Ring groups:**\n' + g.map(([n, o]) => `- **${n}**${o.name ? ` ${o.name}` : ''}: ${o.members.map(extName).join(', ') || 'no members'} (${o.strategy === 'sequential' ? 'one by one' : 'all at once'})${o.fallback ? ` → ${destName(o.fallback)}` : ''}`).join('\n') : 'No ring groups yet. Try "create ring group 600 called Sales with 101 and 102".'; }
    case 'queues': { const q = Object.entries(CFG.queues); return q.length ? '**Queues:**\n' + q.map(([n, o]) => `- **${n}**${o.name ? ` ${o.name}` : ''}: agents ${o.agents.map(extName).join(', ') || 'none'}, max wait ${o.maxWait} s${o.fallback ? ` → ${destName(o.fallback)}` : ''}; ${(QWAIT.get(n) || []).filter(l => l.state !== 'ended').length} waiting`).join('\n') : 'No queues yet. Try "create queue 700 Support with agents 101 and 102".'; }
    case 'ivrs': { const v = Object.entries(CFG.ivrs); return v.length ? '**Menus (IVR):**\n' + v.map(([n, o]) => `- **${n}**${o.name ? ` ${o.name}` : ''}: ${Object.entries(o.options).sort().map(([k, d]) => `${k} → ${destName(d)}`).join(', ') || 'no options'}${o.timeoutDest ? `; no input → ${destName(o.timeoutDest)}` : ''}\n  says: "${o.greeting || autoGreeting(o)}"`).join('\n') : 'No menus yet. Try "create IVR 500: press 1 for 101, 2 for 102".'; }
    case 'conferences': { const c = Object.entries(CFG.conferences); return c.length ? '**Conference rooms:**\n' + c.map(([n, o]) => `- **${n}**${o.name ? ` ${o.name}` : ''}${o.pin ? ` (PIN ${o.pin})` : ''}: ${ROOMS.get(n) ? ROOMS.get(n).members.size : 0} people in it now`).join('\n') : 'No conference rooms yet. Try "create conference room 800 with PIN 1234".'; }
    case 'trunk': {
      const t = CFG.trunk;
      if (!t.host) return 'No outside line yet. Example: `add trunk host sip.myprovider.com user 12345 password secret`, then `send incoming calls to 101`.';
      return `**Outside line** ${t.enabled ? '' : '(disabled) '}via \`${t.host}${+t.port !== 5060 ? ':' + t.port : ''}\` as \`${t.username}\`: ${!t.enabled ? 'off' : !t.register ? 'no registration (IP-based)' : TRUNK.registered ? 'registered ✓' : `not registered${TRUNK.lastError ? ` (${TRUNK.lastError})` : ''}`}.\n- Dial out: numbers with ${t.outMinDigits}+ digits${t.outPrefix ? `, or ${t.outPrefix} + number` : ''}\n- Incoming calls go to ${destName(trunkInboundDest())}${t.callerId ? `\n- Caller ID ${t.callerId}` : ''}`;
    }
    case 'codes': return '**Feature codes** (dial them from any phone):\n' + FEATURE_CODES.map(([c, d]) => `- \`${c}\` ${d}`).join('\n');
    case 'events': return '**Recent events:**\n' + LOG.slice(-12).map(l => `- ${l}`).join('\n');
    case 'config': return [showInfo('extensions'), showInfo('groups'), showInfo('queues'), showInfo('ivrs'), showInfo('conferences'), showInfo('trunk'),
      `**Settings:** ring time ${CFG.ringTime} s, call waiting ${CFG.callWaiting ? 'on' : 'off'}, record all ${CFG.recordAll ? 'on' : 'off'}, Claude mode ${CFG.ai.enabled ? 'on' : 'off'}.`].join('\n\n');
    default: {
      const online = [...REGS.keys()].filter(k => CFG.extensions[k]);
      const vmNew = Object.keys(CFG.extensions).reduce((a, b) => a + vmCounts(b).neu, 0);
      return `**aiVoice PBX at \`${LAN_IP}\`** · ${online.length} of ${exts.length} phones online${online.length ? ` (${online.map(extName).join(', ')})` : ''} · ${calls.length} active ${calls.length === 1 ? 'call' : 'calls'} · ${vmNew} new voicemail · outside line ${!CFG.trunk.host ? 'not set up' : TRUNK.registered ? 'registered' : CFG.trunk.enabled ? 'not registered' : 'off'}.`;
    }
  }
}
function activeCalls() {
  const out = [], seen = new Set();
  for (const l of LEGS.values()) {
    if (l.state === 'ended' || seen.has(l)) continue;
    const p = l.peer;
    seen.add(l); if (p) seen.add(p);
    if (l.dir === 'out' && !p) continue;
    const a = l.dir === 'in' ? l : (p && p.dir === 'in' ? p : l), b = a === l ? p : l;
    out.push({ id: a.id, from: a.label(), to: b ? b.label() : (a.cdr ? destName(a.cdr.to) : ''), state: b ? (a.held || b.held ? 'on hold' : 'talking') : (a.activity || a.state), secs: Math.round((now() - (a.answeredAt || a.created)) / 1000), recording: !!(a.recorder || (b && b.recorder)) });
  }
  return out;
}
function listRecordings() {
  try { return fs.readdirSync(DIRS.rec).filter(f => f.endsWith('.wav')).sort().reverse().map(f => { const s = fs.statSync(path.join(DIRS.rec, f)); return { name: f, size: s.size, secs: Math.round((s.size - 44) / 4 / RATE) }; }); } catch (e) { return []; }
}

// ================================================================ chat: understanding plain English
const SESS = { pending: null, aiHistory: [] };
const lev = (a, b) => { if (Math.abs(a.length - b.length) > 2) return 9; const d = Array.from({ length: a.length + 1 }, (_, i) => [i]); for (let j = 1; j <= b.length; j++) d[0][j] = j; for (let i = 1; i <= a.length; i++) for (let j = 1; j <= b.length; j++) d[i][j] = Math.min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1)); return d[a.length][b.length]; };
const NUMWORDS = { zero: '0', oh: '0', one: '1', two: '2', three: '3', four: '4', five: '5', six: '6', seven: '7', eight: '8', nine: '9' };
const STOPNAMES = new Set('extension extensions ext user users phone phones line number the a an all everyone everybody me him her them it this that group groups queue menu ivr voicemail conference room trunk calls call to for from with and or on off yes no please new my his their sales support'.split(' '));
function prep(raw) {
  let s = String(raw).replace(/[“”]/g, '"').replace(/[‘’]/g, "'").replace(/\s+/g, ' ').trim();
  // "one oh one" -> 101 (two or more digit words in a row)
  s = s.replace(/\b((?:zero|oh|one|two|three|four|five|six|seven|eight|nine)(?:[\s-]+(?:zero|oh|one|two|three|four|five|six|seven|eight|nine))+)\b/gi, m => m.toLowerCase().split(/[\s-]+/).map(w => NUMWORDS[w]).join(''));
  // phone numbers written with spaces or dashes: 050 123 4567
  s = s.replace(/(?<![\d+])(\+\d{1,4}|0\d{1,3})[ -](\d{2,4})[ -](\d{3,5})\b/g, '$1$2$3').replace(/(?<![\d+])(\d{3})[ -](\d{3})[ -](\d{4})\b/g, '$1$2$3');
  const quotes = [];
  s = s.replace(/"([^"]+)"/g, (m, q) => { quotes.push(q.trim()); return ` QUOTE${quotes.length - 1} `; });
  s = s.replace(/(^|[\s:=(])'([^']{2,})'(?=$|[\s.,!?;)])/g, (m, pre, q) => { quotes.push(q.trim()); return `${pre} QUOTE${quotes.length - 1} `; });
  const low = s.toLowerCase();
  const toks = low.match(/quote\d+|[a-z]+(?:'[a-z]+)?|\+?\d+|\*\d+|#/g) || [];
  return { raw: String(raw), s, low, toks, quotes };
}
// Words we know exactly. Typo tolerance only applies to words that are NOT on this list,
// so "remote" never turns into "remove" and "called" never into "callerid".
const VOCAB = new Set(('add create new make setup build register give need want open start include put delete remove drop erase destroy kill scrap trash deregister unregister ' +
  'show list display view see what which who whose many status tell print check any overview summary set change rename forward forwarding forwards turn enable disable record ' +
  'recording recordings recorded divert send route remote called caller named name names calls call callers extension extensions user users phone phones line account person ' +
  'employee staff colleague trunk provider carrier inbound outbound incoming outgoing outside external voicemail voicemails mailbox messages message queue queues agents agent ' +
  'conference conferences meeting meetings bridge room rooms group groups team teams department menu menus attendant receptionist disturb silent quiet mute block password ' +
  'secret ring rings ringing time public port waiting admin page remove removed reminder carol called called recorder recover remote report request redirect follow ' +
  'busy answer answers answered offline unavailable away available online always every never then otherwise press option options digit key pin code room greeting ' +
  'saying says say welcome sales support help undo hang end stop cancel clear reset off on all everyone everybody nobody').split(/\s+/));
function kw(Q, ...words) {
  for (const w of words) {
    if (w.includes(' ')) { if (Q.low.includes(w)) return true; continue; }
    for (const t of Q.toks) {
      if (t === w) return true;
      if (/^\d/.test(t) || w.length < 5 || t.length < 4 || VOCAB.has(t)) continue;
      if (lev(t, w) <= (w.length >= 9 ? 2 : 1)) return true;
    }
  }
  return false;
}
const V = {
  create: ['add', 'create', 'new', 'make', 'setup', 'set up', 'build', 'register', 'give', 'need', 'want', 'open', 'start', 'include', 'put'],
  remove: ['delete', 'remove', 'drop', 'erase', 'destroy', 'get rid', 'kill', 'scrap', 'trash', 'deregister', 'unregister'],
  show: ['show', 'list', 'display', 'view', 'see', 'what', 'which', 'who', 'whose', 'how many', 'status', 'tell me', 'print', 'check', 'any', 'is there', 'are there', 'overview', 'summary'],
  off: ['off', 'disable', 'disabled', 'deactivate', 'stop', 'cancel', 'remove', 'clear', 'turn off', 'switch off', 'no more', 'dont', "don't", 'do not', 'without', 'end', 'unset', 'reset', 'never'],
};
const onOff = Q => { if (/\b(turn|switch|set|put)\s+(it\s+)?on\b|\benable|\bactivate|\bstart\b|\b(on)\b/.test(Q.low) && !/\b(turn|switch)\s+(it\s+)?off\b|\bdisable|\bdeactivate|\bstop\b|\bno more\b|\b(off)\b/.test(Q.low)) return true; return kw(Q, ...V.off) ? false : true; };
function numbersIn(Q, opts = {}) {
  // all numbers in order, minus the ones that are durations, PINs, passwords or ports
  const skip = new Set();
  const d = Q.low.replace(/quote\d+/g, ' ');
  for (const m of d.matchAll(/(\d+)\s*(?:seconds?|secs?|s\b|minutes?|mins?|m\b|hours?|hrs?|rings?)/g)) skip.add(m[1]);
  for (const m of d.matchAll(/\b(?:pin|password|pass|pwd|secret|code|port|prefix|caller ?id|key)\s*(?:is|to|=|:|as|should be)?\s*(\+?\d+)/g)) skip.add(m[1]);
  for (const m of d.matchAll(/\b(?:press|key|option|digit|button)\s+(\d)\b/g)) skip.add(m[1]);
  const out = [];
  for (const m of d.matchAll(/\+?\d+/g)) { const n = m[0]; if (skip.has(n) && !opts.all) { skip.delete(n); continue; } out.push(n); }
  return out;
}
const isExtLike = n => /^\d{2,6}$/.test(n);
function duration(Q) {
  const m = /(\d+(?:\.\d+)?)\s*(seconds?|secs?|s\b|minutes?|mins?|m\b|hours?|hrs?)/.exec(Q.low);
  if (m) return Math.round(+m[1] * (/^h/.test(m[2]) ? 3600 : /^m/.test(m[2]) ? 60 : 1));
  const r = /(\d+)\s*rings?/.exec(Q.low); if (r) return +r[1] * 5;
  return null;
}
function quoteText(Q, i) { return Q.quotes[i]; }
function namesIn(Q) {
  const raw = Q.s, out = [];
  const add = n => { n = n.replace(/'s$/i, '').replace(/^(?:called|named|is|as|the|a|an)\s+/i, '').trim(); if (n && !STOPNAMES.has(n.toLowerCase()) && !/^quote\d+$/i.test(n) && !/^\d/.test(n)) out.push(n.charAt(0).toUpperCase() + n.slice(1)); };
  for (const m of raw.matchAll(/\b(?:for|named|called|name(?:d)?\s+(?:is|it|to|as)?|names?|owner|belongs to|to be|user)\s+([A-Za-z][\w'-]*(?:\s+[A-Z][\w'-]*)?)/gi)) add(m[1]);
  for (const m of raw.matchAll(/\b([A-Z][a-z]+)'s\b/g)) add(m[1]);
  for (const m of raw.matchAll(/\b\d{2,6}\s*(?:is|=|:|-|as)\s*([A-Za-z][a-z]+)/g)) add(m[1]);
  for (const m of raw.matchAll(/\b([A-Z][a-z]+)\s+(?:at|on|with|gets|has|=|:|-)\s*(?:ext(?:ension)?\s*|number\s*)?\d{2,6}\b/g)) add(m[1]);
  return [...new Set(out)];
}
function quotedName(Q) { const m = /(?:named|called|name(?:d)?(?: is| it)?)\s+quote(\d+)/.exec(Q.low); return m ? Q.quotes[+m[1]] : null; }
function quotedGreeting(Q) {
  const m = /(?:say|says|saying|greeting|greet|message|announce|announcement|prompt|tells?|play)\s*(?:is|of|to|:|=|that)?\s*quote(\d+)/.exec(Q.low);
  if (m) return Q.quotes[+m[1]];
  const named = quotedName(Q);
  const long = Q.quotes.find(q => q !== named && q.length > 18);
  return long || null;
}
// Which extensions does the sentence talk about? Numbers that are extensions, plus names of people.
function extRefs(Q, exclude = []) {
  const out = [];
  for (const n of numbersIn(Q)) if (CFG.extensions[n] && !exclude.includes(n)) out.push(n);
  for (const t of Q.raw.match(/[A-Za-z][a-z]+/g) || []) { const n = findByName(t, ['extensions']); if (n && !exclude.includes(n) && !STOPNAMES.has(t.toLowerCase())) out.push(n); }
  if (/\b(everyone|everybody|all (?:the )?(?:extensions|phones|users))\b/.test(Q.low)) out.push(...Object.keys(CFG.extensions));
  return [...new Set(out)];
}
function destIn(fragment) {
  if (!fragment) return null;
  const Q = prep(fragment), f = Q.low;
  if (/\b(hang ?up|disconnect|end the call|drop the call|nowhere|nobody|nothing)\b/.test(f)) return 'hangup';
  const nums = numbersIn(Q, { all: true });
  if (/\b(voice ?mail|vm|mailbox|message|messages)\b/.test(f)) {
    const n = nums.find(isExtLike) || (Q.raw.match(/[A-Za-z][a-z]+/g) || []).map(w => findByName(w, ['extensions'])).find(Boolean);
    return n ? 'vm:' + n : 'vm:';
  }
  if (nums.length) return nums[0];
  for (const w of Q.raw.match(/[A-Za-z][\w-]+/g) || []) { if (STOPNAMES.has(w.toLowerCase()) && !['sales', 'support'].includes(w.toLowerCase())) continue; const n = findByName(w); if (n) return n; }
  if (/\b(operator|reception|front desk)\b/.test(f)) { const n = findByName('operator') || findByName('reception'); if (n) return n; }
  return null;
}
function splitCommands(text) {
  return text.split(/\s*(?:;|\n|\.\s+(?=[A-Z])|\s+then\s+(?=(?:add|create|make|delete|remove|set|show|forward|turn|enable|disable)\b)|\s+and then\s+|\s+also\s+(?=(?:add|create|make|delete|remove|set|forward|turn)\b))\s*/i).filter(x => x && x.trim().length > 1);
}
const ask = (q, need) => ({ ask: q, need });
const act = (...actions) => ({ actions });

function understand(text) {
  const Q = prep(text);
  const L = Q.low;
  // ---- small talk
  if (/^\s*(hi|hello|hey|yo|good (morning|afternoon|evening)|salam|hola)\b[\s!.]*$/.test(L)) return { reply: `Hello! I run your phone system. Try "show status", "add extension 103 for Carol", or "help".` };
  if (/^\s*(thanks|thank you|thx|cool|great|nice|ok|okay|perfect|awesome)\b[\s!.]*$/.test(L)) return { reply: 'You’re welcome! Anything else?' };
  // ---- Claude mode
  const key = /(sk-ant-[\w-]{20,})/.exec(Q.raw);
  if (key) return act({ type: 'ai.set', apiKey: key[1], enabled: true });
  if (kw(Q, 'ai', 'claude', 'anthropic', 'llm', 'smart mode', 'artificial intelligence')) {
    const model = /\bmodel\s+(?:to\s+)?([\w.-]+)/.exec(Q.raw);
    if (model) return act({ type: 'ai.set', model: model[1] });
    if (/\b(remove|delete|forget|clear)\b.*\bkey\b/.test(L)) return act({ type: 'ai.set', apiKey: '', enabled: false });
    return act({ type: 'ai.set', enabled: onOff(Q) });
  }
  // ---- undo / help
  if (/\b(undo|revert|roll ?back|go back|oops|put (it|that) back)\b/.test(L)) return act({ type: 'undo' });
  if (/(feature|star|short) ?codes?|\*\d\d codes?|what can (i|we) dial/.test(L)) return act({ type: 'show', what: 'codes' });
  if (/\b(connect|set ?up|configure|register|add|use|install)\b.*\b(soft ?phone|zoiper|microsip|linphone|app|desk ?phone|device|my phone|a phone|phones)\b|\b(soft ?phone|zoiper|microsip|linphone)\b|how do i (call|use|connect)/.test(L) && !/\bextension\s+\d/.test(L.replace(/(?:for|as)\s+extension/, ''))) {
    return act({ type: 'show', what: 'setup', number: numbersIn(Q).find(n => CFG.extensions[n]) });
  }
  if (/^\s*(help|\?|commands|examples|what can you do|how does this work|menu)\b/.test(L) || /\bwhat can (you|i) (do|say|ask)\b/.test(L)) return { reply: HELP_TEXT };
  // ---- hang up calls
  if (/\b(hang ?up|end|drop|kill|disconnect|terminate)\b.*\b(calls?|everyone|everything)\b/.test(L) && !kw(Q, 'forward', 'when', 'if')) {
    const id = (/(?:call|#)\s*(\d+)/.exec(L) || [])[1];
    return act({ type: 'call.hangup', id: /\ball\b|every/.test(L) || !id ? 'all' : id });
  }
  // ---- outside line / trunk
  if (kw(Q, 'trunk', 'provider', 'itsp', 'carrier', 'sip account', 'voip provider', 'outside line', 'outside calls', 'external line', 'outbound', 'inbound', 'incoming calls', 'incoming', 'outgoing calls', 'caller id', 'phone company', 'outside number') || /\bcaller ?id\b|\bdids?\b/.test(Q.low)) return trunkIntent(Q);
  // ---- the server's own IP address (not the public one)
  if ((/\b(ip|ips|ip address(es)?|ipv4|network adapters?|network cards?|interfaces?)\b/.test(L) || /\b(use|switch|change|move)\b.*\b(wi-?fi|wireless|ethernet|wired|lan cable|cable)\b/.test(L)) && !/\b(public|external|wan|outside|internet|router)\b/.test(L) && !kw(Q, 'trunk', 'provider')) return ipIntent(Q);
  // ---- show / questions
  const showVerb = /^\s*(show|list|display|view|see|what|which|who|whose|how|is|are|any|tell|print|check|get|status|give me|can you show|do i|do we|where)\b/.test(L) || /\?\s*$/.test(L) || /^\s*(status|overview|summary|dashboard|config(uration)?|settings|log|history)\s*$/.test(L);
  const actionVerb = kw(Q, ...V.create, ...V.remove, 'set', 'change', 'rename', 'forward', 'turn', 'enable', 'disable', 'record', 'divert', 'send', 'route') && !/^\s*(show|list|what|which|who|how|is|are|tell)\b/.test(L);
  if (showVerb && !actionVerb) return showIntent(Q);
  // ---- features, most specific first
  if (kw(Q, 'ivr', 'menu', 'auto attendant', 'attendant', 'autoattendant', 'receptionist', 'voice menu', 'phone menu') || (/\bpress\s+([0-9]|one|two)\b/.test(L) && !kw(Q, 'queue', 'group'))) return ivrIntent(Q);
  if (kw(Q, 'queue', 'queues', 'call center', 'callcenter', 'waiting line', 'agents', 'agent')) return groupIntent(Q, 'queue');
  if (kw(Q, 'conference', 'conf', 'meeting', 'meeting room', 'bridge', 'meetme', 'conference room') || (/\broom\b/.test(L) && !kw(Q, 'group'))) return confIntent(Q);
  if (kw(Q, 'ring group', 'hunt group', 'group', 'groups', 'team', 'department', 'ring all', 'ringall')) return groupIntent(Q, 'group');
  if (kw(Q, 'forward', 'forwarding', 'divert', 'redirect', 'follow me', 'followme') || /\b(send|route|transfer)\b.*\bcalls?\b.*\bto\b|\bcalls?\b.*\b(go|goes|should go|ring)\b.*\bto\b|\bif\b.*\b(busy|not answer|doesn'?t answer|no answer|offline|unavailable|away)\b/.test(L)) return forwardIntent(Q);
  if (kw(Q, 'dnd', 'do not disturb', "don't disturb", 'dont disturb', 'disturb', 'silent mode', 'quiet mode', 'mute calls', 'block calls')) return dndIntent(Q);
  if (kw(Q, 'record', 'recording', 'recordings', 'recorded')) return recordIntent(Q);
  if (kw(Q, 'voicemail', 'voice mail', 'mailbox', 'vm', 'voicemails', 'messages')) return vmIntent(Q);
  if (/\bring ?time\b|\b(public|external|wan) ip\b|\b(sip|web|http) port\b|\bcall waiting\b|\bremote (access|admin)|\b(admin|page|web) password\b|\brings? for \d|\bring timeout\b|access from other computers/.test(Q.low)) return settingsIntent(Q);
  const ext = extIntent(Q);
  if (ext) return ext;
  // ---- last resort: a bare number or name we know
  const n = numbersIn(Q)[0] || findByName(Q.raw.trim());
  if (n && numberInUse(n)) return act({ type: 'show', what: { extension: 'ext', 'ring group': 'groups', queue: 'queues', menu: 'ivrs', 'conference room': 'conferences' }[numberInUse(n)], number: n });
  return { reply: `Sorry, I didn't get that. Here are some things you can say:\n- "add extension 103 for Carol"\n- "forward 101 to 102 when busy"\n- "create ring group 600 Sales with 101 and 102"\n- "make an IVR at 500: press 1 for sales, 2 for 102"\n- "show status" or "help"` };
}
function showIntent(Q) {
  const L = Q.low, n = numbersIn(Q).find(x => numberInUse(x));
  if (/password|login|credential|setup|set up|connect/.test(L) && n && CFG.extensions[n]) return act({ type: 'show', what: 'ext', number: n });
  if (/online|registered|connected|offline|who('s| is) (on|in|there)/.test(L)) return act({ type: 'show', what: 'phones' });
  if (/call ?log|history|cdr|missed|recent calls|last calls|who called/.test(L)) return act({ type: 'show', what: 'log' });
  if (/active|current|right now|on the phone|talking|calls? (now|going)|in progress|ongoing/.test(L)) return act({ type: 'show', what: 'calls' });
  if (kw(Q, 'voicemail', 'voice mail', 'messages', 'mailbox')) return act({ type: 'show', what: 'voicemail', number: n && CFG.extensions[n] ? n : undefined });
  if (kw(Q, 'recording', 'recordings', 'recorded')) return act({ type: 'show', what: 'recordings' });
  if (kw(Q, 'trunk', 'provider', 'outside line')) return act({ type: 'show', what: 'trunk' });
  if (kw(Q, 'ivr', 'menu', 'menus', 'attendant')) return act({ type: 'show', what: 'ivrs' });
  if (kw(Q, 'queue', 'queues')) return act({ type: 'show', what: 'queues' });
  if (kw(Q, 'conference', 'conferences', 'rooms', 'room')) return act({ type: 'show', what: 'conferences' });
  if (kw(Q, 'group', 'groups')) return act({ type: 'show', what: 'groups' });
  if (/feature|codes?/.test(L)) return act({ type: 'show', what: 'codes' });
  if (/\b(log|events|what happened|errors?)\b/.test(L)) return act({ type: 'show', what: 'events' });
  if (n && CFG.extensions[n]) return act({ type: 'show', what: 'ext', number: n });
  const nm = (Q.raw.match(/[A-Z][a-z]+/g) || []).map(w => findByName(w, ['extensions'])).find(Boolean);
  if (nm) return act({ type: 'show', what: 'ext', number: nm });
  if (kw(Q, 'extension', 'extensions', 'users', 'phones', 'people')) return act({ type: 'show', what: 'extensions' });
  if (/config|settings|everything|setup|all/.test(L)) return act({ type: 'show', what: 'config' });
  return act({ type: 'show', what: 'status' });
}
function extIntent(Q) {
  const L = Q.low, nums = numbersIn(Q).filter(isExtLike), names = namesIn(Q);
  const isExtWord = kw(Q, 'extension', 'extensions', 'ext', 'extn', 'user', 'users', 'phone', 'line', 'account', 'person', 'employee', 'staff', 'colleague');
  // delete
  if (kw(Q, ...V.remove)) {
    const refs = extRefs(Q);
    if (refs.length) return act(...refs.map(n => ({ type: 'ext.remove', number: n })));
    if (isExtWord && nums.length) return { reply: `There is no extension ${nums.join(', ')}.` };
    if (isExtWord) return ask('Which extension should I delete?', 'ext');
  }
  // rename
  const ren = /\b(?:rename|re-name)\s+(?:extension\s+|ext\s+)?(\S+)\s+(?:to|as|into)\s+([A-Za-z][\w' -]*)/i.exec(Q.s) || /\bchange\s+(?:the\s+)?name\s+(?:of|for)\s+(?:extension\s+|ext\s+)?(\S+)\s+to\s+([A-Za-z][\w' -]*)/i.exec(Q.s) || /\b(\d{2,6})(?:'s)?\s+name\s+(?:is|to|should be)\s+([A-Za-z][\w' -]*)/i.exec(Q.s);
  if (ren) { const n = CFG.extensions[ren[1]] ? ren[1] : findByName(ren[1], ['extensions']); if (n) return act({ type: 'ext.update', number: n, name: ren[2].trim().replace(/[.!]$/, '') }); }
  // password
  if (/\b(password|pass|pwd|secret)\b/.test(L) && !kw(Q, 'admin', 'page', 'web')) {
    const refs = extRefs(Q);
    if (!refs.length) { if (kw(Q, ...V.create) && isExtWord) { /* falls through to create with password */ } else return ask('Which extension’s password should I change?', 'ext'); }
    else {
      const v = /\b(?:password|pass|pwd|secret)\s*(?:of|for)?\s*(?:extension\s*)?(?:\d+\s*)?(?:is|to|=|:|as|should be|becomes?)?\s*(?:quote(\d+)|([^\s,]+))/.exec(L.replace(/(?:of|for)\s+(?:extension\s+)?[a-z]+\s+(?=to\b)/, ''));
      let pw = v ? (v[1] !== undefined ? Q.quotes[+v[1]] : Q.s.split(/\s+/).find(w => w.toLowerCase() === v[2]) || v[2]) : null;
      if (pw && (CFG.extensions[pw] || /^(of|for|to|is|random|new|a|the|extension)$/i.test(pw))) pw = null;
      if (!kw(Q, ...V.create) || CFG.extensions[refs[0]]) return act(...refs.map(n => ({ type: 'ext.update', number: n, password: pw || 'random' })));
    }
  }
  // ring time for an extension
  const dur = duration(Q);
  if (dur && /\bring/.test(L)) { const refs = extRefs(Q); if (refs.length) return act(...refs.map(n => ({ type: 'ext.update', number: n, ringTime: dur }))); }
  // create
  const create = kw(Q, ...V.create) || /^\s*\d{2,6}\s*(?:for|is|=|:|-)\s*[a-z]/i.test(Q.s);
  if (create && (isExtWord || nums.length || names.length)) {
    const range = /\b(\d{2,6})\s*(?:-|to|through|thru|until)\s*(\d{2,6})\b/.exec(L);
    if (range && +range[2] > +range[1] && +range[2] - +range[1] <= 200) {
      const list = []; for (let i = +range[1]; i <= +range[2]; i++) list.push({ type: 'ext.add', number: String(i) });
      return { actions: list };
    }
    const pwm = /\b(?:password|pass|pwd|secret)\s*(?:is|=|:|as)?\s*(?:quote(\d+)|(\S+))/.exec(L);
    const pw = pwm ? (pwm[1] !== undefined ? Q.quotes[+pwm[1]] : Q.s.split(/\s+/).find(w => w.toLowerCase() === pwm[2]) || pwm[2]) : undefined;
    // pairs like "103 Carol, 104 Dave" or "Carol at 103"
    const pairs = [];
    for (const m of Q.s.matchAll(/\b(\d{2,6})\s*(?:for|is|=|:|-|named|called|as)?\s*([A-Z][a-z]+(?:\s+[A-Z][a-z]+)?)/g)) if (!STOPNAMES.has(m[2].toLowerCase()) && !/^Quote/.test(m[2])) pairs.push([m[1], m[2]]);
    for (const m of Q.s.matchAll(/\b([A-Z][a-z]+)\s*(?:at|on|with|gets|=|:|-)\s*(?:ext(?:ension)?\s*|number\s*)?(\d{2,6})\b/g)) if (!STOPNAMES.has(m[1].toLowerCase()) && !pairs.some(p => p[0] === m[2])) pairs.push([m[2], m[1]]);
    if (pairs.length > 1) return { actions: pairs.map(([n, nm]) => ({ type: 'ext.add', number: n, name: nm })) };
    const fresh = nums.filter(n => !CFG.extensions[n]);
    if (nums.length > 1 && !names.length) return { actions: nums.map(n => ({ type: 'ext.add', number: n })) };
    const number = fresh[0] || nums[0] || (pairs[0] && pairs[0][0]) || nextFree(101);
    const name = (pairs[0] && pairs[0][1]) || quotedName(Q) || names[0];
    if (!isExtWord && !nums.length && !name) return null;
    return act({ type: 'ext.add', number, name, password: pw });
  }
  // "Carol's extension should be 103" / simple update phrases
  if (isExtWord && nums.length && kw(Q, 'set', 'change', 'update', 'modify', 'edit')) return act({ type: 'show', what: 'ext', number: nums[0] });
  return null;
}
function forwardIntent(Q) {
  const L = Q.low;
  const when = /\b(busy|engaged|on (?:a|another) call|on the phone)\b/.test(L) ? 'busy'
    : /\b(no answer|not answer|doesn'?t answer|don'?t answer|does not answer|unanswered|not answering|no one answers|nobody answers|doesn'?t pick|don'?t pick|not pick|rings out|missed|after \d+)/.test(L) ? 'noAnswer'
    : /\b(offline|unavailable|not registered|not connected|unreachable|away|switched off|phone is off|not online|disconnected)\b/.test(L) ? 'unavailable'
    : /\b(all cases|every case|everything|all conditions)\b/.test(L) && kw(Q, ...V.off) ? 'all' : 'always';
  const off = /\b(stop|cancel|remove|disable|turn off|switch off|no more|clear|delete|unset|don'?t|do not|end)\b/.test(L) && !/\bto\s+(?!be\b)\S/.test(L.replace(/\b(?:turn|switch)\s+off\b/, ''));
  // the target comes after the last " to "
  const ti = L.lastIndexOf(' to ');
  const before = ti >= 0 ? Q.s.slice(0, ti) : Q.s, after = ti >= 0 ? Q.s.slice(ti + 4) : '';
  const srcRefs = extRefs(prep(before));
  if (!srcRefs.length) return ask(`Which extension should ${off ? 'stop forwarding' : 'be forwarded'}?`, 'ext');
  if (off) return act(...srcRefs.map(n => ({ type: 'ext.forward', number: n, when: when === 'always' && !/\balways\b/.test(L) ? 'all' : when, to: '' })));
  let to = destIn(after);
  if (to === 'vm:') to = 'vm:' + srcRefs[0];
  if (!to) return ask(`Where should calls for ${extName(srcRefs[0])} go? (an extension, a group, voicemail, or an outside number)`, 'dest');
  const ring = when === 'noAnswer' ? duration(Q) : null;
  return act(...srcRefs.map(n => ({ type: 'ext.forward', number: n, when, to, ringTime: ring || undefined })));
}
function dndIntent(Q) {
  const refs = extRefs(Q);
  if (!refs.length) return ask('Which extension? (for example "DND on for 102")', 'ext');
  const on = !/\b(off|disable|deactivate|stop|cancel|remove|no more|end|available|unmute)\b/.test(Q.low) && !/\b(turn|switch)\s+(it\s+)?off\b/.test(Q.low);
  return act(...refs.map(n => ({ type: 'ext.update', number: n, dnd: on })));
}
function recordIntent(Q) {
  const L = Q.low;
  if (kw(Q, 'delete', 'remove', 'erase', 'clear', 'wipe') && /recordings/.test(L)) return { confirm: [{ type: 'rec.clear' }], ask: `Delete all ${listRecordings().length} recordings? This can't be undone. Say **yes** to confirm.` };
  if (/\b(show|list|play|where)\b/.test(L)) return act({ type: 'show', what: 'recordings' });
  const on = !/\b(stop|don'?t|do not|no longer|disable|off|turn off|cancel|without|never|end)\b/.test(L);
  const refs = extRefs(Q);
  if (!refs.length || /\b(all|every|everything|everyone|all calls)\b/.test(L) && !refs.length) return act({ type: 'record', target: 'all', on });
  return act(...refs.map(n => ({ type: 'record', target: n, on })));
}
function vmIntent(Q) {
  const L = Q.low, refs = extRefs(Q);
  if (kw(Q, 'delete', 'remove', 'erase', 'clear', 'wipe', 'empty') && /voice ?mails|messages|mailbox|all/.test(L)) {
    const total = (refs.length ? refs : Object.keys(CFG.extensions)).reduce((a, b) => a + vmList(b).length, 0);
    return { confirm: refs.length ? refs.map(n => ({ type: 'vm.clear', number: n })) : [{ type: 'vm.clear', number: 'all' }], ask: `Delete ${total} voicemail ${total === 1 ? 'message' : 'messages'}${refs.length ? ` of ${refs.map(extName).join(', ')}` : ''}? Say **yes** to confirm.` };
  }
  if (!refs.length) return /\b(show|list|any|new|check)\b/.test(L) ? act({ type: 'show', what: 'voicemail' }) : ask('Which extension’s voicemail?', 'ext');
  if (/\bpin\b/.test(L)) {
    const tail = L.slice(L.indexOf('pin'));
    const cand = (tail.match(/\d{3,10}/g) || []).filter(x => !refs.includes(x));
    if (cand.length) return act(...refs.map(n => ({ type: 'ext.update', number: n, vmPin: cand[cand.length - 1] })));
    return ask(`What should the new voicemail PIN be for ${extName(refs[0])}?`, 'pin');
  }
  if (/greeting|message|say/.test(L) && Q.quotes.length) return act(...refs.map(n => ({ type: 'ext.update', number: n, vmGreeting: Q.quotes[0] })));
  if (/(default|standard|normal) greeting|remove (the )?greeting/.test(L)) return act(...refs.map(n => ({ type: 'ext.update', number: n, vmGreeting: '' })));
  if (/\b(show|list|any|new|check|how many)\b/.test(L)) return act({ type: 'show', what: 'voicemail', number: refs[0] });
  return act(...refs.map(n => ({ type: 'ext.update', number: n, voicemail: onOff(Q) })));
}
function ipIntent(Q) {
  const L = Q.low, list = localIps();
  const ip = /(\d{1,3}(?:\.\d{1,3}){3})/.exec(L);
  if (ip) return act({ type: 'settings.set', lanIp: ip[1] });
  if (/\b(auto|automatic|automatically|default|reset|detect)\b/.test(L)) return act({ type: 'settings.set', lanIp: 'auto' });
  // "use the wifi one", "switch to ethernet"
  const wantsChange = /\b(use|change|set|switch|select|pick|choose|move|make)\b/.test(L);
  const norm = s => s.toLowerCase().replace(/[^a-z0-9]/g, '');
  const hit = list.find(c => { const a = norm(c.adapter); return (/wi-?fi|wireless|wlan/.test(L) && /wifi|wlan|wireless/.test(a)) || (/ethernet|cable|wired|lan cable/.test(L) && /ethernet|eth|enp|eno/.test(a) && !c.virtual) || (a.length > 2 && norm(L).includes(a)); });
  if (wantsChange && hit) return act({ type: 'settings.set', lanIp: hit.ip });
  if (wantsChange && /\b(change|set|switch|select|pick|choose)\b/.test(L)) return { reply: showInfo('ips') + '\n\nWhich one should phones use? Say for example "use IP ' + ((list[0] || {}).ip || '192.168.1.20') + '".' };
  return act({ type: 'show', what: 'ips' });
}
function settingsIntent(Q) {
  const L = Q.low, a = { type: 'settings.set' };
  const d = duration(Q);
  if (/ring/.test(L) && d) { const refs = extRefs(Q); if (refs.length) return act(...refs.map(n => ({ type: 'ext.update', number: n, ringTime: d }))); a.ringTime = d; }
  const ip = /(\d{1,3}(?:\.\d{1,3}){3})/.exec(L); if (/public|external|wan/.test(L)) a.publicIp = ip ? ip[1] : '';
  if (/call waiting/.test(L)) a.callWaiting = onOff(Q);
  const port = /port\s*(?:to|is|=|:)?\s*(\d{2,5})/.exec(L);
  if (port && /sip/.test(L)) a.sipPort = +port[1]; else if (port && /web|http|page|admin/.test(L)) a.httpPort = +port[1];
  if (/remote|other computers|from anywhere|network access|lan access/.test(L)) a.adminRemote = onOff(Q);
  const pw = /password\s*(?:is|to|=|:|as)?\s*(?:quote(\d+)|(\S+))/.exec(L);
  if (pw && /admin|page|web|remote|dashboard/.test(L)) a.adminPassword = pw[1] !== undefined ? Q.quotes[+pw[1]] : Q.s.split(/\s+/).find(w => w.toLowerCase() === pw[2]) || pw[2];
  return Object.keys(a).length > 1 ? act(a) : { reply: 'Which setting? For example: "set ring time to 30 seconds", "turn off call waiting", "set public IP to 203.0.113.5", or "allow remote access with password secret".' };
}
function objNumber(Q, store, words, base) {
  const L = Q.low.replace(/quote\d+/g, ' ');
  const re = new RegExp(`\\b(?:${words.join('|')})\\s*(?:number|no\\.?|#|at|on)?\\s*(\\d{2,6})\\b`);
  const m = re.exec(L) || /\b(?:number|at|on|extension|ext|dial|dialing)\s*(\d{2,6})\b/.exec(L);
  if (m) return m[1];
  const nums = numbersIn(Q).filter(isExtLike);
  const known = nums.find(n => store[n]); if (known) return known;
  for (const w of Q.raw.match(/[A-Za-z][\w-]+/g) || []) { const n = Object.entries(store).find(([, o]) => o.name && o.name.toLowerCase() === w.toLowerCase()); if (n) return n[0]; }
  return null;
}
function groupIntent(Q, kind) {
  const isQ = kind === 'queue', store = isQ ? CFG.queues : CFG.groups, L = Q.low;
  const words = isQ ? ['queue', 'queues', 'call center', 'callcenter'] : ['ring group', 'hunt group', 'group', 'team', 'department'];
  let num = objNumber(Q, store, words.map(w => w.replace(' ', '\\s+')));
  const typeName = isQ ? 'queue' : 'ring group';
  const del = kw(Q, ...V.remove);
  const members = extRefs(Q, num ? [num] : []);
  // add/remove members of an existing group: "add 104 to group 600", "remove Bob from sales"
  if (num && store[num] && members.length && (kw(Q, 'add', 'include', 'put', 'join', 'plus') || del)) {
    return act({ type: isQ ? 'queue.set' : 'group.set', number: num, [del ? 'remove' : 'add']: members });
  }
  if (del) { if (!num || !store[num]) return ask(`Which ${typeName} should I delete?`, 'num'); return act({ type: isQ ? 'queue.remove' : 'group.remove', number: num }); }
  if (/\b(show|list)\b/.test(L) && !kw(Q, ...V.create)) return act({ type: 'show', what: isQ ? 'queues' : 'groups' });
  const isNew = !num || !store[num];
  if (!num) num = nextFree(isQ ? 700 : 600);
  const a = { type: isQ ? 'queue.set' : 'group.set', number: num };
  const nm = quotedName(Q) || (/\b(?:[Cc]alled|[Nn]amed|[Nn]ame it|[Nn]ame is)\s+([A-Za-z][\w-]*(?:\s+[A-Z][\w-]*)?)/.exec(Q.s) || [])[1] || (/\b(?:group|queue|team)\s+(?:\d+\s+)?(?!with|for|of|to|that|and|which|at|on|called|named)([A-Z][a-z]+)/.exec(Q.s) || [])[1];
  if (nm && !CFG.extensions[findByName(nm, ['extensions']) || '-']) a.name = nm;
  // fallback: "then voicemail 101", "otherwise 102", "if nobody answers go to ..."
  const fb = /\b(?:then|otherwise|else|after that|if (?:nobody|no one|none)[^,;]*?(?:answers?|picks? up|available)?|fallback|fall back|overflow|or else|failover)\b[\s,:]*(?:go(?:es)?\s+to|send(?:\s+(?:it|them|callers?))?\s+to|to|ring|forward to)?\s*(.+)$/i.exec(Q.s);
  let memberText = Q.s;
  if (fb) { const d = destIn(fb[1]); if (d) { a.fallback = d === 'vm:' ? `vm:${members[0] || ''}` : d; memberText = Q.s.slice(0, fb.index); } }
  const mem = extRefs(prep(memberText), [num]);
  if (mem.length) a[isQ ? 'agents' : 'members'] = mem;
  if (/one (by|after) one|one at a time|in order|sequential|in turn|hunt|round robin|in sequence|first .* then/.test(L) && !isQ) a.strategy = 'sequential';
  else if (/all at once|at the same time|simultaneous|together|ring all|ringall|everyone at once/.test(L)) a.strategy = 'ringall';
  const d = duration(Q);
  if (d) { if (isQ && /wait|hold|max|up to|longest/.test(L)) a.maxWait = d; else a.ringTime = d; }
  if (isQ && Q.quotes.length) { const g = quotedGreeting(Q); if (g) a.greeting = g; }
  if (isNew && !mem.length && !kw(Q, ...V.create)) return ask(`Who should be in ${typeName} ${num}? (list extensions or names)`, 'members');
  return act(a);
}
function ivrIntent(Q) {
  const L = Q.low;
  let num = objNumber(Q, CFG.ivrs, ['ivr', 'menu', 'auto\\s*attendant', 'attendant', 'receptionist']);
  if (kw(Q, ...V.remove) && !/\boption|press|key|digit\b/.test(L)) {
    if (!num || !CFG.ivrs[num]) return ask('Which menu should I delete?', 'num');
    return act({ type: 'ivr.remove', number: num });
  }
  if (/\b(show|list)\b/.test(L) && !kw(Q, ...V.create)) return act({ type: 'show', what: 'ivrs' });
  if (!num) num = Object.keys(CFG.ivrs).length === 1 && !kw(Q, 'new', 'another', 'second') ? Object.keys(CFG.ivrs)[0] : nextFree(500);
  const a = { type: 'ivr.set', number: num, options: {} };
  const nm = quotedName(Q) || (/\b(?:called|named)\s+([A-Za-z][\w-]*(?:\s+[A-Z][\w-]*)?)/.exec(Q.s) || [])[1];
  if (nm) a.name = nm;
  const g = quotedGreeting(Q); if (g) a.greeting = g;
  // options: "press 1 for sales", "1 -> 101", "option 2 goes to queue 700", "2 for Bob"
  const body = Q.s.replace(/QUOTE\d+/g, ' ');
  const clauses = body.split(/\s*(?:,|;|\.\s|\band\b(?=\s*(?:press|option|key|digit|\d\s*(?:for|to|->|=|:|goes|rings|sends)))|(?=\bpress\b))\s*/i);
  for (const c of clauses) {
    const m = /(?:^|\b)(?:press(?:ing)?|option|key|digit|dial|button)?\s*(\d|star|pound|hash|\*|#)\s*(?:is|for|to|goes to|go to|->|=>|=|:|→|sends? (?:you |them |callers )?to|rings?|connects? (?:to)?|transfers? to|routes? to|reaches|gets)\s+(.+)$/i.exec(c.trim());
    if (!m) continue;
    const key = m[1].toLowerCase() === 'star' ? '*' : /pound|hash/i.test(m[1]) ? '#' : m[1];
    if (/^(remove|delete|nothing|none)$/i.test(m[2].trim())) { a.options[key] = null; continue; }
    const d = destIn(m[2]);
    if (d) a.options[key] = d === 'vm:' ? null : d; else a.options[key] = m[2].trim();
  }
  const rm = /\b(?:remove|delete|drop)\s+(?:option|key|digit)?\s*(\d)\b/.exec(L); if (rm) a.options[rm[1]] = null;
  const to = /\b(?:if (?:they|callers?|nobody|no one)?\s*(?:don'?t|do not|doesn'?t)\s+press(?: anything)?|timeout|no input|no key|nothing (?:is )?pressed|otherwise|by default|default to|if nothing)\b[\s,:]*(?:go(?:es)?\s+to|send(?:\s+(?:them|callers?))?\s+to|to|ring|then)?\s*([^,;.]+)/i.exec(body);
  if (to) { const d = destIn(to[1]); if (d) a.timeoutDest = d; }
  if (/no direct dial|disable direct|don'?t allow (?:direct|extension) dial|no extension dial/.test(L)) a.directDial = false;
  else if (/allow (?:direct|extension) dial|direct dial(?:ing)? on/.test(L)) a.directDial = true;
  if (!Object.keys(a.options).length) delete a.options;
  if (!a.options && !a.greeting && !a.name && a.timeoutDest === undefined && a.directDial === undefined && CFG.ivrs[num]) return act({ type: 'show', what: 'ivrs' });
  return act(a);
}
function confIntent(Q) {
  const L = Q.low;
  let num = objNumber(Q, CFG.conferences, ['conference', 'conf', 'meeting\\s+room', 'meeting', 'room', 'bridge']);
  if (kw(Q, ...V.remove) && !/\bpin\b/.test(L)) { if (!num || !CFG.conferences[num]) return ask('Which conference room should I delete?', 'num'); return act({ type: 'conf.remove', number: num }); }
  if (/\b(show|list|who)\b/.test(L) && !kw(Q, ...V.create)) return act({ type: 'show', what: 'conferences' });
  if (!num) num = nextFree(800);
  const a = { type: 'conf.set', number: num };
  const pin = /\b(?:pin|password|code|passcode)\s*(?:is|to|=|:|of|as)?\s*(\d{3,10})/.exec(L);
  if (pin) a.pin = pin[1];
  if (/\b(no|without|remove(?: the)?|delete(?: the)?|clear(?: the)?)\s+(pin|password|code)/.test(L)) a.pin = '';
  const nm = quotedName(Q) || (/\b(?:called|named)\s+([A-Za-z][\w-]*(?:\s+[A-Z][\w-]*)?)/.exec(Q.s) || [])[1];
  if (nm) a.name = nm;
  return act(a);
}
function trunkIntent(Q) {
  const L = Q.low, a = { type: 'trunk.set' };
  const val = re => { const m = re.exec(Q.s.replace(/QUOTE(\d+)/g, (x, i) => Q.quotes[+i])); return m ? m[1].replace(/[,;]$/, '') : undefined; };
  if (/\b(incoming|inbound)\b/.test(L) || /\bcalls? from (outside|the outside)\b/.test(L)) {
    const m = /\b(?:to|go to|goes to|ring|reach|route to|send to|forward to|land on|answer(?:ed)? by)\s+(.+)$/i.exec(Q.s);
    const d = m && destIn(m[1]);
    if (d) return act({ type: 'trunk.set', inbound: d === 'vm:' ? null : d });
    return ask('Where should incoming outside calls go? (for example "to IVR 500", "to 101", or "to the sales group")', 'dest');
  }
  if (/\b(dial|press|use)\s+(\d)\s+(?:for|to get|to reach|for an?)\s+(?:an?\s+)?(?:outside|external|outbound)/.test(L)) a.outPrefix = /\b(?:dial|press|use)\s+(\d)/.exec(L)[1];
  const pre = /prefix\s*(?:is|to|=|:|of)?\s*(\d+)/.exec(L); if (pre) a.outPrefix = pre[1];
  if (/no prefix|remove (the )?prefix|without (a )?prefix/.test(L)) a.outPrefix = '';
  const cid = /caller ?id\s*(?:is|to|=|:|as)?\s*(\+?\d+)/.exec(L); if (cid) a.callerId = cid[1];
  const host = val(/\b(?:host|server|proxy|registrar|address|ip)\s*(?:is|=|:|to|of)?\s*([a-z0-9][\w.-]*\.[a-z0-9.-]+|\d+\.\d+\.\d+\.\d+)/i); if (host) a.host = host;
  const dom = val(/\bdomain\s*(?:is|=|:|to)?\s*([\w.-]+)/i); if (dom) a.domain = dom;
  const au = val(/\bauth(?:orization|entication)?\s*(?:user(?:name)?|id|name)\s*(?:is|=|:|to)?\s*([^\s,;]+)/i); if (au) a.authUser = au;
  const user = val(/\b(?:user(?:name)?|account|login|number|sip user)\s*(?:is|=|:|to)?\s*([^\s,;]+)/i); if (user && user !== au && !/^(is|to|name)$/i.test(user)) a.username = user;
  const pass = val(/\b(?:pass(?:word)?|secret|pwd)\s*(?:is|=|:|to)?\s*([^\s,;]+)/i); if (pass) a.password = pass;
  const port = /\bport\s*(?:is|=|:|to)?\s*(\d{2,5})/.exec(L); if (port) a.port = +port[1];
  if (/\b(don'?t|do not|no|without|disable|turn off|stop)\s+(registration|register|registering)\b|registration\s+off/.test(L)) a.register = false;
  else if (/\b(enable|turn on|use)\s+(registration|register)\b|registration\s+on/.test(L)) a.register = true;
  if (kw(Q, ...V.remove) && !Object.keys(a).filter(k => k !== 'type').length) return act({ type: 'trunk.set', enabled: false, host: '', username: '', password: '', authUser: '', domain: '' });
  if (/\b(disable|turn off|switch off|stop|deactivate)\b/.test(L) && Object.keys(a).length === 1) a.enabled = false;
  else if (/\b(enable|turn on|switch on|activate|start)\b/.test(L) && Object.keys(a).length === 1) a.enabled = true;
  if (Object.keys(a).length === 1) return /\b(show|status|check|is|how)\b/.test(L) || /\?/.test(L) ? act({ type: 'show', what: 'trunk' }) : { reply: 'To set up the outside line, tell me your provider’s details, for example:\n`add trunk host sip.provider.com user 12345 password secret`\nThen: `send incoming calls to IVR 500`, and optionally `dial 9 for outside line` or `caller id 97150123456`.' };
  return act(a);
}
const HELP_TEXT = `I run your phone system. Ask in your own words, for example:
**Phones** · "add extension 103 for Carol" · "add 104 Dave, 105 Eve" · "rename 101 to Alicia" · "new password for 102" · "delete Carol"
**Calls** · "forward 101 to 102 when busy" · "if Bob doesn't answer in 20 seconds send it to voicemail" · "stop forwarding 101" · "DND on for 102"
**Groups & queues** · "create ring group 600 Sales with 101 and 102, then voicemail 101" · "add 104 to sales" · "create queue 700 Support with agents 101 102, max wait 3 minutes"
**Menus** · "make an IVR at 500 saying 'Welcome to Acme', press 1 for sales, 2 for support queue, 0 for 101"
**Conference** · "create conference room 800 with PIN 4321"
**Recording & voicemail** · "record all calls" · "record calls of 102" · "show voicemail" · "set voicemail PIN for 101 to 2468"
**Outside line** · "add trunk host sip.provider.com user 12345 password secret" · "send incoming calls to IVR 500"
**Network** · "show IP addresses" · "change the server IP to 192.168.1.20" · "use the Wi-Fi address" · "use automatic IP" · "set public IP to 203.0.113.5"
**Info** · "status" · "who is online" · "show calls" · "call log" · "how do I connect a phone" · "feature codes"
**Other** · "undo" · "hang up all calls" · "use AI with key sk-ant-..." to let Claude understand you`;

// ================================================================ chat entry point
async function chat(text) {
  text = String(text || '').trim();
  if (!text) return { reply: 'Say something like "add extension 103 for Carol", or "help".' };
  const p = SESS.pending; SESS.pending = null;
  if (p && p.confirm) {
    if (/^\s*(yes|y|yeah|yep|sure|ok|okay|confirm|do it|go ahead|please do)\b/i.test(text)) return { reply: runActions(p.confirm).msgs.join('\n\n'), changed: true };
    if (/^\s*(no|n|nope|cancel|stop|don'?t|never ?mind)\b/i.test(text)) return { reply: 'OK, I didn’t delete anything.' };
  }
  if (p && p.text && text.split(/\s+/).length <= 6 && !/^\s*(show|list|add|create|delete|remove|help|status)\b/i.test(text)) {
    const merged = p.need === 'ext' ? `${text} ${p.text}` : p.need === 'dest' ? `${p.text} to ${text}` : p.need === 'members' ? `${p.text} with ${text}` : p.need === 'pin' ? `${p.text} ${text}` : `${p.text} ${text}`;
    const r = parseAndRun(merged);
    if (!/didn't get that/.test(r.reply)) return r;
  }
  if (CFG.ai.enabled && CFG.ai.apiKey && !/\b(ai|claude)\b.*\b(off|disable|stop)\b|\b(off|disable|stop)\b.*\b(ai|claude)\b/i.test(text)) {
    try { return await aiChat(text); }
    catch (e) { const r = parseAndRun(text); r.reply = `_Claude didn’t answer (${e.message}), so I used the built-in parser._\n\n${r.reply}`; return r; }
  }
  return parseAndRun(text);
}
function parseAndRun(text) {
  const out = []; let changed = false;
  for (const part of splitCommands(text)) {
    const r = understand(part);
    if (!r) { out.push(`I didn't get "${part}".`); continue; }
    if (r.reply) { out.push(r.reply); continue; }
    if (r.confirm) { SESS.pending = { confirm: r.confirm }; out.push(r.ask); break; }
    if (r.ask) { SESS.pending = { text: part, need: r.need }; out.push(r.ask); break; }
    const res = runActions(r.actions);
    out.push(...res.msgs); changed = changed || res.changed;
  }
  return { reply: out.join('\n\n') || 'Done.', changed };
}

// ================================================================ Claude mode (optional; needs an Anthropic API key)
const AI_ACTIONS = {
  'ext.add': 'number, name?, password?', 'ext.update': 'number, name?, password? ("random" for a new one), voicemail? bool, vmPin?, vmGreeting?, dnd? bool, record? bool, ringTime? seconds',
  'ext.remove': 'number', 'ext.forward': 'number, when: always|busy|noAnswer|unavailable|all, to: destination ("" turns it off), ringTime?',
  'group.set': 'number, name?, members? [ext numbers], add? [], remove? [], strategy? ringall|sequential, ringTime?, fallback? destination',
  'group.remove': 'number', 'queue.set': 'number, name?, agents? [], add? [], remove? [], maxWait? seconds, ringTime?, fallback?, greeting?', 'queue.remove': 'number',
  'ivr.set': 'number, name?, greeting? (spoken text; omit to auto-generate), options? {"1": destination, ...} (null removes), timeoutDest?, directDial? bool', 'ivr.remove': 'number',
  'conf.set': 'number, name?, pin? ("" = none)', 'conf.remove': 'number', 'record': 'target: "all" or extension, on: bool',
  'trunk.set': 'host?, port?, username?, authUser?, password?, domain?, register? bool, outPrefix?, outMinDigits?, callerId?, inbound? destination, enabled? bool',
  'settings.set': 'ringTime?, lanIp? (this PC\'s address phones use as SIP server; "auto" = automatic), publicIp?, callWaiting?, adminRemote?, adminPassword?', 'vm.clear': 'number or "all"', 'rec.clear': '', 'call.hangup': 'id or "all"',
  'show': 'what: ips|status|extensions|ext|calls|log|voicemail|recordings|groups|queues|ivrs|conferences|trunk|codes|config|setup|events, number?', 'undo': '', 'ai.set': 'enabled? bool',
};
function aiSystemPrompt() {
  const safe = JSON.parse(JSON.stringify(CFG));
  for (const e of Object.values(safe.extensions)) { e.password = '(set)'; e.vmPin = e.vmPin ? '(set)' : ''; }
  for (const c of Object.values(safe.conferences || {})) if (c.pin) c.pin = '(set)';
  safe.trunk.password = safe.trunk.password ? '(set)' : ''; safe.ai = { enabled: true }; safe.adminPassword = safe.adminPassword ? '(set)' : '';
  return `You are the chat assistant built into aiVoice PBX, a small SIP phone system. The user is its administrator and may be a beginner; reply briefly and plainly.
To change anything, or to fetch details, call the tool pbx_actions with a list of actions. Never claim a change without calling the tool. If something essential is missing or ambiguous, ask one short question instead of guessing. Confirm before deleting voicemail or recordings.
Action types and their fields:
${Object.entries(AI_ACTIONS).map(([k, v]) => `- ${k}: ${v || '(no fields)'}`).join('\n')}
A destination is an extension/group/queue/menu/conference number, "vm:<extension>" for voicemail, an outside phone number, or "hangup". Numbers are shared: one number can only be one thing. Pick free numbers in these ranges when the user doesn't give one: extensions 101+, ring groups 600+, queues 700+, menus 500+, conferences 800+.
Server IP for phone setup: ${LAN_IP} (this PC's addresses: ${localIps().map(c => `${c.ip} ${c.adapter}`).join('; ')}), SIP port ${CFG.sipPort}. Feature codes: ${FEATURE_CODES.map(c => c[0]).join(', ')}.
Phones online now: ${[...REGS.keys()].join(', ') || 'none'}. Active calls: ${activeCalls().length}.
Current configuration (JSON):
${JSON.stringify(safe)}`;
}
function aiRequest(body) {
  return new Promise((resolve, reject) => {
    const data = JSON.stringify(body);
    const req = https.request({ hostname: 'api.anthropic.com', path: '/v1/messages', method: 'POST', timeout: 45000,
      headers: { 'content-type': 'application/json', 'x-api-key': CFG.ai.apiKey, 'anthropic-version': '2023-06-01', 'content-length': Buffer.byteLength(data) } }, res => {
      let s = ''; res.on('data', c => s += c); res.on('end', () => {
        let j; try { j = JSON.parse(s); } catch (e) { return reject(new Error(`bad reply (${res.statusCode})`)); }
        if (res.statusCode >= 300) return reject(new Error(j.error && j.error.message ? j.error.message : `HTTP ${res.statusCode}`));
        resolve(j);
      });
    });
    req.on('timeout', () => req.destroy(new Error('timed out')));
    req.on('error', reject);
    req.end(data);
  });
}
async function aiChat(text) {
  const tools = [{ name: 'pbx_actions', description: 'Apply changes to the PBX or fetch information. Runs the actions in order and returns their results.',
    input_schema: { type: 'object', properties: { actions: { type: 'array', items: { type: 'object', properties: { type: { type: 'string', enum: Object.keys(AI_ACTIONS) } }, required: ['type'], additionalProperties: true } } }, required: ['actions'] } }];
  const messages = [...SESS.aiHistory, { role: 'user', content: text }];
  let out = [], changed = false, held = [];
  for (let round = 0; round < 3; round++) {
    const r = await aiRequest({ model: CFG.ai.model || 'claude-sonnet-5-5', max_tokens: 1200, system: aiSystemPrompt(), tools, messages });
    const texts = r.content.filter(c => c.type === 'text').map(c => c.text.trim()).filter(Boolean);
    const uses = r.content.filter(c => c.type === 'tool_use');
    messages.push({ role: 'assistant', content: r.content });
    if (!uses.length) { out.push(...texts); break; }
    const results = [];
    for (const u of uses) {
      const all = (u.input && u.input.actions) || [];
      // deleting voicemail or recordings can't be undone, so those wait for the person's own "yes"
      const risky = all.filter(a => a && AI_NEEDS_YES.includes(a.type));
      held.push(...risky);
      const res = runActions(all.filter(a => !risky.includes(a)));
      changed = changed || res.changed;
      const note = risky.length ? `Not done yet: ${risky.map(describeRisky).join(', ')}. Deleting can't be undone, so the PBX has asked the user to confirm with "yes".` : '';
      results.push({ type: 'tool_result', tool_use_id: u.id, content: [...res.msgs, note].filter(Boolean).join('\n') || 'done' });
      out.push(...res.msgs);
    }
    messages.push({ role: 'user', content: results });
    if (r.stop_reason !== 'tool_use') { out.push(...texts); break; }
  }
  // keep a short plain-text memory of the conversation
  SESS.aiHistory.push({ role: 'user', content: text }, { role: 'assistant', content: out.join('\n').slice(0, 2000) || 'OK.' });
  if (SESS.aiHistory.length > 12) SESS.aiHistory.splice(0, SESS.aiHistory.length - 12);
  if (held.length) {
    SESS.pending = { confirm: held };
    out.push(`**Please confirm:** Claude wants to delete ${held.map(describeRisky).join(' and ')}. This can't be undone. Say **yes** to delete, or **no** to keep ${held.length === 1 ? 'it' : 'them'}.`);
  }
  return { reply: (out.join('\n\n') || 'Done.') + '\n\n_via Claude_', changed };
}
const AI_NEEDS_YES = ['vm.clear', 'vm.delete', 'rec.clear'];
function describeRisky(a) {
  if (a.type === 'rec.clear') return `all ${listRecordings().length} call recordings`;
  if (a.type === 'vm.delete') return `a voicemail message in box ${a.number}`;
  if (a.number === 'all' || !a.number) return 'all voicemail messages';
  return `the voicemail of ${CFG.extensions[a.number] ? extName(a.number) : a.number}`;
}

// ================================================================ the web page (chat + switchboard)
const UI_HTML = "<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<title>aiVoice PBX</title>\n<style>\n/* Layout: an operator's desk. Chat on the left (you talk to the PBX), live switchboard on the right (phones, calls, features, voicemail, logs). Teal is the PBX's voice; green/amber/red only mean status. */\n:root {\n  --bg: #eef1f4; --panel: #ffffff; --sunk: #f5f7f9; --ink: #131a22; --muted: #5d6876; --line: #dde2e8;\n  --accent: #0b7a75; --accent-ink: #ffffff; --ok: #1d8a4b; --warn: #b8770f; --bad: #c43b35; --me: #0b7a75; --bubble: #f1f4f6;\n  --sans: \"Segoe UI Variable Text\", \"Segoe UI\", system-ui, -apple-system, Roboto, sans-serif;\n  --display: \"Segoe UI Variable Display\", \"Segoe UI\", system-ui, sans-serif;\n  --mono: \"Cascadia Mono\", \"Cascadia Code\", Consolas, \"SF Mono\", Menlo, monospace;\n}\n@media (prefers-color-scheme: dark) {\n  :root { --bg: #0e1317; --panel: #151c22; --sunk: #10161b; --ink: #e3e9ee; --muted: #8e9aa6; --line: #26313a; --accent: #3cc2b8; --accent-ink: #061413; --ok: #4cc47e; --warn: #e2a83f; --bad: #ff7468; --me: #1c6f6a; --bubble: #1b242b; color-scheme: dark; }\n}\n* { box-sizing: border-box; }\nhtml, body { height: 100%; margin: 0; }\nbody { background: var(--bg); color: var(--ink); font: 14px/1.5 var(--sans); }\nbutton, input, textarea { font: inherit; color: inherit; }\nbutton:focus-visible, textarea:focus-visible, input:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }\n.app { height: 100%; display: grid; grid-template-rows: auto minmax(0, 1fr); }\nheader { display: flex; align-items: center; gap: 10px 18px; flex-wrap: wrap; padding: 10px 18px; background: var(--panel); border-bottom: 1px solid var(--line); }\n.brand { display: flex; align-items: center; gap: 10px; font: 700 19px/1 var(--display); letter-spacing: -0.01em; }\n.mark { display: grid; grid-template-columns: repeat(3, 6px); gap: 2px; }\n.mark i { width: 6px; height: 6px; border-radius: 2px; background: var(--accent); opacity: 0.35; }\n.mark i:nth-child(5) { opacity: 1; }\n.chips { display: flex; gap: 8px; flex-wrap: wrap; flex: 1; }\n.chip { display: inline-flex; align-items: center; gap: 6px; border: 1px solid var(--line); border-radius: 999px; padding: 3px 10px; font-size: 12.5px; color: var(--muted); background: var(--sunk); white-space: nowrap; }\n.chip b { color: var(--ink); font-weight: 600; font-variant-numeric: tabular-nums; }\n.dot { width: 8px; height: 8px; border-radius: 50%; background: var(--muted); display: inline-block; flex: none; }\n.dot.ok { background: var(--ok); } .dot.warn { background: var(--warn); } .dot.bad { background: var(--bad); }\n.chip.ai { color: var(--accent); border-color: color-mix(in srgb, var(--accent) 50%, var(--line)); }\nmain { display: grid; grid-template-columns: minmax(360px, 42%) minmax(0, 1fr); min-height: 0; }\n.chat { display: flex; flex-direction: column; min-height: 0; border-right: 1px solid var(--line); background: var(--panel); }\n.msgs { flex: 1; overflow-y: auto; padding: 16px; display: flex; flex-direction: column; gap: 10px; }\n.msg { max-width: 92%; padding: 9px 13px; border-radius: 14px; background: var(--bubble); white-space: normal; overflow-wrap: anywhere; }\n.msg.me { align-self: flex-end; background: var(--me); color: #fff; border-bottom-right-radius: 4px; }\n.msg.pbx { align-self: flex-start; border-bottom-left-radius: 4px; }\n.msg p { margin: 0 0 6px; } .msg p:last-child { margin-bottom: 0; }\n.msg ul { margin: 4px 0 6px; padding-left: 18px; } .msg li { margin: 1px 0; }\n.msg code { font: 12.5px var(--mono); background: color-mix(in srgb, var(--accent) 12%, transparent); padding: 1px 5px; border-radius: 4px; }\n.msg.me code { background: rgba(255,255,255,0.2); }\n.msg em { color: var(--muted); }\n.typing { align-self: flex-start; color: var(--muted); font-size: 13px; padding: 4px 6px; }\n.sugg { display: flex; gap: 6px; padding: 8px 12px 0; flex-wrap: wrap; }\n.sugg button { border: 1px solid var(--line); background: var(--sunk); border-radius: 999px; padding: 4px 11px; font-size: 12.5px; cursor: pointer; color: var(--muted); }\n.sugg button:hover { color: var(--ink); border-color: var(--muted); }\nform { display: flex; gap: 8px; padding: 10px 12px 14px; align-items: flex-end; }\ntextarea { flex: 1; resize: none; border: 1px solid var(--line); background: var(--sunk); border-radius: 12px; padding: 10px 12px; min-height: 44px; max-height: 160px; }\n.send { border: 0; background: var(--accent); color: var(--accent-ink); border-radius: 12px; padding: 0 16px; height: 44px; font-weight: 700; cursor: pointer; }\n.board { display: flex; flex-direction: column; min-height: 0; }\n.tabs { display: flex; gap: 2px; padding: 8px 12px 0; border-bottom: 1px solid var(--line); overflow-x: auto; background: var(--bg); }\n.tabs button { border: 0; background: none; padding: 8px 12px; border-bottom: 2px solid transparent; cursor: pointer; color: var(--muted); font-weight: 600; white-space: nowrap; }\n.tabs button[aria-selected=\"true\"] { color: var(--ink); border-bottom-color: var(--accent); }\n.tabs .n { font: 600 11px var(--mono); background: var(--accent); color: var(--accent-ink); border-radius: 999px; padding: 0 6px; margin-left: 5px; }\n.pane { flex: 1; overflow-y: auto; padding: 16px; display: flex; flex-direction: column; gap: 14px; }\n.card { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 14px 16px; }\n.card h3 { margin: 0 0 10px; font: 700 15px var(--display); display: flex; justify-content: space-between; align-items: center; gap: 8px; }\n.card h3 small { font: 500 12px var(--sans); color: var(--muted); }\n.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: 12px; }\n.kv { display: grid; grid-template-columns: auto 1fr auto; gap: 4px 10px; align-items: center; font-size: 13.5px; }\n.kv span { color: var(--muted); }\n.kv code, td code { font: 13px var(--mono); }\n.copy { border: 1px solid var(--line); background: var(--sunk); border-radius: 6px; padding: 1px 7px; font-size: 11.5px; cursor: pointer; color: var(--muted); }\ntable { width: 100%; border-collapse: collapse; font-size: 13.5px; }\nth { text-align: left; font: 600 11px var(--mono); letter-spacing: 0.06em; text-transform: uppercase; color: var(--muted); padding: 6px 8px; border-bottom: 1px solid var(--line); }\ntd { padding: 8px; border-bottom: 1px solid var(--line); vertical-align: top; }\ntr:last-child td { border-bottom: 0; }\n.tw { overflow-x: auto; }\n.pill { display: inline-flex; align-items: center; gap: 5px; font-size: 12px; padding: 1px 8px; border-radius: 999px; background: var(--sunk); border: 1px solid var(--line); white-space: nowrap; }\n.mini { border: 1px solid var(--line); background: var(--panel); border-radius: 7px; padding: 2px 9px; font-size: 12px; cursor: pointer; }\n.mini:hover { border-color: var(--muted); }\n.mini.danger { color: var(--bad); }\n.muted { color: var(--muted); }\n.empty { color: var(--muted); padding: 6px 0; }\n.pw { font: 13px var(--mono); cursor: pointer; border-bottom: 1px dashed var(--line); }\naudio { height: 34px; width: 100%; max-width: 360px; }\n.vm { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 6px 12px; align-items: center; padding: 8px 0; border-bottom: 1px solid var(--line); }\n.vm:last-child { border-bottom: 0; }\n.vm b.new { color: var(--accent); }\n.log { font: 12.5px/1.55 var(--mono); white-space: pre-wrap; overflow-wrap: anywhere; color: var(--muted); max-height: 260px; overflow-y: auto; }\n.sip { font: 12.5px var(--mono); }\n.sip details { border-bottom: 1px solid var(--line); padding: 4px 0; }\n.sip summary { cursor: pointer; list-style: none; display: grid; grid-template-columns: 70px 22px 150px minmax(0, 1fr); gap: 8px; white-space: nowrap; overflow: hidden; }\n.sip summary span:last-child { overflow: hidden; text-overflow: ellipsis; }\n.sip pre { margin: 6px 0; white-space: pre-wrap; overflow-wrap: anywhere; background: var(--sunk); padding: 8px; border-radius: 6px; }\n.in { color: var(--accent); } .out { color: var(--warn); }\n.feat { display: flex; flex-direction: column; gap: 4px; padding: 10px 0; border-bottom: 1px solid var(--line); }\n.feat:last-child { border-bottom: 0; }\n.feat .t { display: flex; justify-content: space-between; gap: 8px; }\n.feat q { color: var(--muted); font-size: 13px; }\n.call { display: flex; justify-content: space-between; align-items: center; gap: 10px; padding: 8px 0; border-bottom: 1px solid var(--line); }\n.call:last-child { border-bottom: 0; }\n@media (max-width: 900px) {\n  html, body { height: auto; }\n  .app { height: auto; }\n  main { grid-template-columns: minmax(0, 1fr); }\n  .chat, .board { min-width: 0; }\n  .chat { height: 70vh; border-right: 0; border-bottom: 1px solid var(--line); }\n  .pane { overflow: visible; }\n}\n</style>\n</head>\n<body>\n<div class=\"app\">\n  <header>\n    <div class=\"brand\"><span class=\"mark\" aria-hidden=\"true\"><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i></span>aiVoice PBX</div>\n    <div class=\"chips\" id=\"chips\"></div>\n  </header>\n  <main>\n    <section class=\"chat\" aria-label=\"Chat with your PBX\">\n      <div class=\"msgs\" id=\"msgs\" aria-live=\"polite\"></div>\n      <div class=\"sugg\" id=\"sugg\"></div>\n      <form id=\"form\" autocomplete=\"off\">\n        <textarea id=\"input\" rows=\"1\" placeholder=\"Tell your PBX what to do… e.g. add extension 103 for Carol\" aria-label=\"Message\"></textarea>\n        <button class=\"send\" type=\"submit\">Send</button>\n      </form>\n    </section>\n    <section class=\"board\" aria-label=\"Switchboard\">\n      <nav class=\"tabs\" id=\"tabs\" role=\"tablist\"></nav>\n      <div class=\"pane\" id=\"pane\"></div>\n    </section>\n  </main>\n</div>\n<script>\n(function () {\n  var $ = function (id) { return document.getElementById(id); };\n  var S = null, lastJson = '', tab = 'overview', reveal = {};\n  try { tab = localStorage.getItem('aivoice-pbx-tab') || 'overview'; } catch (e) {}\n  function esc(s) { return String(s == null ? '' : s).replace(/[&<>\"]/g, function (c) { return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '\"': '&quot;' }[c]; }); }\n  function md(t) {\n    var lines = esc(t).split('\\n'), out = [], list = 0;\n    function inline(s) { return s.replace(/\\*\\*([^*]+)\\*\\*/g, '<b>$1</b>').replace(/`([^`]+)`/g, '<code>$1</code>').replace(/(^|\\s)_([^_]+)_(?=\\s|$|[.,!?])/g, '$1<em>$2</em>'); }\n    lines.forEach(function (l) {\n      var m = /^(\\s*)- (.*)$/.exec(l);\n      if (m) { var depth = m[1].length >= 2 ? 2 : 1; while (list < depth) { out.push('<ul>'); list++; } while (list > depth) { out.push('</ul>'); list--; } out.push('<li>' + inline(m[2]) + '</li>'); return; }\n      while (list > 0) { out.push('</ul>'); list--; }\n      if (l.trim()) out.push('<p>' + inline(l) + '</p>');\n    });\n    while (list > 0) { out.push('</ul>'); list--; }\n    return out.join('');\n  }\n  // ---------------- chat\n  var history = [];\n  try { history = JSON.parse(localStorage.getItem('aivoice-pbx-chat') || '[]'); } catch (e) {}\n  function saveHist() { try { localStorage.setItem('aivoice-pbx-chat', JSON.stringify(history.slice(-60))); } catch (e) {} }\n  function addMsg(who, text, save) {\n    var d = document.createElement('div'); d.className = 'msg ' + who;\n    d.innerHTML = who === 'me' ? '<p>' + esc(text) + '</p>' : md(text);\n    $('msgs').appendChild(d); $('msgs').scrollTop = $('msgs').scrollHeight;\n    if (save !== false) { history.push([who, text]); saveHist(); }\n  }\n  if (history.length) history.forEach(function (h) { addMsg(h[0], h[1], false); });\n  else addMsg('pbx', 'Hi! I’m your phone system. Tell me what you need in plain English, for example **\"add extension 103 for Carol\"** or **\"how do I connect a phone\"**. Type **help** to see more.', true);\n  var busy = false;\n  function send(text) {\n    text = text.trim(); if (!text || busy) return;\n    addMsg('me', text); busy = true;\n    var t = document.createElement('div'); t.className = 'typing'; t.textContent = 'aiVoice PBX is thinking…'; $('msgs').appendChild(t); $('msgs').scrollTop = $('msgs').scrollHeight;\n    fetch('/api/chat', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ text: text }) })\n      .then(function (r) { return r.json(); })\n      .then(function (j) { t.remove(); addMsg('pbx', j.reply || 'Done.'); refresh(true); })\n      .catch(function () { t.remove(); addMsg('pbx', 'I can’t reach aiVoice PBX. Is it still running?'); })\n      .then(function () { busy = false; });\n  }\n  $('form').addEventListener('submit', function (e) { e.preventDefault(); var v = $('input').value; $('input').value = ''; grow(); send(v); });\n  $('input').addEventListener('keydown', function (e) { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); $('form').requestSubmit(); } });\n  function grow() { var i = $('input'); i.style.height = 'auto'; i.style.height = Math.min(160, i.scrollHeight) + 'px'; }\n  $('input').addEventListener('input', grow);\n  function suggestions() {\n    var s = ['status', 'how do I connect a phone', 'add extension 103 for Carol'];\n    if (S) {\n      if (!Object.keys(S.ivrs).length) s.push('make an IVR at 500: press 1 for 101, 2 for 102');\n      if (!Object.keys(S.groups).length) s.push('create ring group 600 Sales with 101 and 102');\n      if (!Object.keys(S.conferences).length) s.push('create conference room 800 with PIN 1234');\n      if (!S.settings.recordAll) s.push('record all calls');\n      if (!S.trunk.host) s.push('set up an outside line');\n    }\n    s.push('help');\n    $('sugg').innerHTML = s.slice(0, 6).map(function (x) { return '<button type=\"button\">' + esc(x) + '</button>'; }).join('');\n  }\n  $('sugg').addEventListener('click', function (e) { var b = e.target.closest('button'); if (b) send(b.textContent); });\n  document.addEventListener('click', function (e) {\n    var c = e.target.closest('[data-say]'); if (c) { send(c.getAttribute('data-say')); return; }\n    var cp = e.target.closest('[data-copy]');\n    if (cp) { var v = cp.getAttribute('data-copy'); try { navigator.clipboard.writeText(v).then(function () { cp.textContent = 'copied'; setTimeout(function () { cp.textContent = 'copy'; }, 1200); }); } catch (x) {} return; }\n    var pw = e.target.closest('[data-pw]'); if (pw) { reveal[pw.getAttribute('data-pw')] = !reveal[pw.getAttribute('data-pw')]; render(); return; }\n    var a = e.target.closest('[data-act]');\n    if (a) { fetch('/api/action', { method: 'POST', headers: { 'content-type': 'application/json' }, body: a.getAttribute('data-act') }).then(function (r) { return r.json(); }).then(function (j) { if (j.reply) addMsg('pbx', j.reply); refresh(true); }); }\n  });\n  // ---------------- switchboard\n  var TABS = [['overview', 'Overview'], ['phones', 'Extensions'], ['features', 'Groups & menus'], ['voicemail', 'Voicemail'], ['recordings', 'Recordings'], ['log', 'Call log'], ['sip', 'SIP messages']];\n  function tabs() {\n    var vmNew = S ? S.voicemail.reduce(function (a, b) { return a + b.messages.filter(function (m) { return m.isNew; }).length; }, 0) : 0;\n    $('tabs').innerHTML = TABS.map(function (t) { var n = t[0] === 'voicemail' && vmNew ? '<span class=\"n\">' + vmNew + '</span>' : t[0] === 'overview' && S && S.calls.length ? '<span class=\"n\">' + S.calls.length + '</span>' : ''; return '<button role=\"tab\" data-tab=\"' + t[0] + '\" aria-selected=\"' + (tab === t[0]) + '\">' + t[1] + n + '</button>'; }).join('');\n  }\n  $('tabs').addEventListener('click', function (e) { var b = e.target.closest('[data-tab]'); if (!b) return; tab = b.getAttribute('data-tab'); try { localStorage.setItem('aivoice-pbx-tab', tab); } catch (x) {} render(); });\n  function chips() {\n    var on = S.extensions.filter(function (x) { return x.online; }).length;\n    var t = S.trunk;\n    $('chips').innerHTML = '<span class=\"chip\">Server <b>' + esc(S.ip) + '</b>' + (S.sipPort !== 5060 ? ':' + S.sipPort : '') + '</span>' +\n      '<span class=\"chip\"><span class=\"dot ' + (on ? 'ok' : '') + '\"></span><b>' + on + '/' + S.extensions.length + '</b> phones online</span>' +\n      '<span class=\"chip\"><span class=\"dot ' + (S.calls.length ? 'ok' : '') + '\"></span><b>' + S.calls.length + '</b> active calls</span>' +\n      '<span class=\"chip\"><span class=\"dot ' + (!t.host ? '' : t.registered ? 'ok' : t.enabled ? 'bad' : 'warn') + '\"></span>Outside line: <b>' + (!t.host ? 'not set up' : !t.enabled ? 'off' : t.registered ? 'ready' : 'not connected') + '</b></span>' +\n      (S.ai.enabled ? '<span class=\"chip ai\">Claude mode</span>' : '') + (S.settings.recordAll ? '<span class=\"chip\"><span class=\"dot bad\"></span>Recording all calls</span>' : '');\n  }\n  function fwdText(f) {\n    var p = []; if (f.always) p.push('always → ' + f.always); if (f.busy) p.push('busy → ' + f.busy); if (f.noAnswer) p.push('no answer → ' + f.noAnswer); if (f.unavailable) p.push('offline → ' + f.unavailable);\n    return p.length ? esc(p.join(', ')) : '<span class=\"muted\">none</span>';\n  }\n  function statusPill(x) { return x.dnd ? '<span class=\"pill\"><span class=\"dot warn\"></span>Do Not Disturb</span>' : x.busy ? '<span class=\"pill\"><span class=\"dot ok\"></span>on a call</span>' : x.online ? '<span class=\"pill\"><span class=\"dot ok\"></span>online</span>' : '<span class=\"pill\"><span class=\"dot\"></span>offline</span>'; }\n  function overview() {\n    var h = '';\n    h += '<div class=\"card\"><h3>Active calls <small>' + S.calls.length + '</small></h3>' + (S.calls.length ? S.calls.map(function (c) {\n      return '<div class=\"call\"><div><b>' + esc(c.from) + '</b> → ' + esc(c.to) + '<div class=\"muted\">' + esc(c.state) + ' · ' + c.secs + ' s' + (c.recording ? ' · recording' : '') + '</div></div><button class=\"mini danger\" data-say=\"hang up call ' + c.id + '\">Hang up</button></div>';\n    }).join('') : '<div class=\"empty\">No calls right now. Dial <code>*43</code> from a phone for an echo test.</div>') + '</div>';\n    h += '<div class=\"card\"><h3>Connect a phone <small>any SIP softphone: MicroSIP, Zoiper, Linphone…</small></h3><div class=\"kv\" style=\"margin-bottom:10px\"><span>SIP server / domain</span><code>' + esc(S.ip) + (S.sipPort !== 5060 ? ':' + S.sipPort : '') + '</code><button class=\"copy\" data-copy=\"' + esc(S.ip) + '\">copy</button><span>IP chosen</span><code>' + esc(S.ipMode || 'automatic') + '</code><button class=\"copy\" data-say=\"show IP addresses\">change</button><span>Transport</span><code>UDP</code><span></span></div><div class=\"grid\">' +\n      S.extensions.map(function (x) { return '<div class=\"card\" style=\"padding:10px 12px\"><div style=\"display:flex;justify-content:space-between;gap:6px;margin-bottom:6px\"><b>' + esc(x.number) + ' ' + esc(x.name) + '</b>' + statusPill(x) + '</div><div class=\"kv\"><span>Username</span><code>' + esc(x.number) + '</code><button class=\"copy\" data-copy=\"' + esc(x.number) + '\">copy</button><span>Password</span><code>' + esc(x.password) + '</code><button class=\"copy\" data-copy=\"' + esc(x.password) + '\">copy</button></div></div>'; }).join('') + '</div></div>';\n    h += '<div class=\"card\"><h3>What’s happening</h3><div class=\"log\">' + esc(S.log.slice(-18).join('\\n')) + '</div></div>';\n    if (!S.tts) h += '<div class=\"card\"><h3>Voice prompts</h3><p class=\"muted\" style=\"margin:0\">No text-to-speech engine was found on this computer, so menus and voicemail use short chimes instead of spoken words. On Windows, speech is built in.</p></div>';\n    return h;\n  }\n  function phones() {\n    return '<div class=\"card\"><h3>Extensions <small>change them in the chat, e.g. \"rename 101 to Alicia\"</small></h3><div class=\"tw\"><table><thead><tr><th>Ext</th><th>Name</th><th>Status</th><th>Password</th><th>Forwarding</th><th>Voicemail</th><th></th></tr></thead><tbody>' +\n      S.extensions.map(function (x) {\n        return '<tr><td><b>' + esc(x.number) + '</b></td><td>' + esc(x.name) + (x.record ? ' <span class=\"pill\">rec</span>' : '') + '</td><td>' + statusPill(x) + (x.ua ? '<div class=\"muted\" style=\"font-size:12px\">' + esc(x.ua) + '</div>' : '') + '</td>' +\n          '<td><span class=\"pw\" data-pw=\"' + esc(x.number) + '\">' + (reveal[x.number] ? esc(x.password) : '••••••') + '</span></td><td>' + fwdText(x.forward) + '</td>' +\n          '<td>' + (x.voicemail ? (x.vmNew ? '<b>' + x.vmNew + ' new</b>' : x.vmTotal + ' msgs') : '<span class=\"muted\">off</span>') + '</td>' +\n          '<td style=\"white-space:nowrap\"><button class=\"mini\" data-say=\"DND ' + (x.dnd ? 'off' : 'on') + ' for ' + esc(x.number) + '\">' + (x.dnd ? 'DND off' : 'DND on') + '</button> <button class=\"mini danger\" data-say=\"delete extension ' + esc(x.number) + '\">Delete</button></td></tr>';\n      }).join('') + '</tbody></table></div></div>';\n  }\n  function features() {\n    function sec(title, obj, fn, empty) { var k = Object.keys(obj); return '<div class=\"card\"><h3>' + title + ' <small>' + k.length + '</small></h3>' + (k.length ? k.map(function (n) { return '<div class=\"feat\">' + fn(n, obj[n]) + '</div>'; }).join('') : '<div class=\"empty\">' + empty + '</div>') + '</div>'; }\n    var h = '';\n    h += sec('Menus (IVR)', S.ivrs, function (n, v) { return '<div class=\"t\"><b>' + n + (v.name ? ' · ' + esc(v.name) : '') + '</b><button class=\"mini danger\" data-say=\"delete IVR ' + n + '\">Delete</button></div><div>' + (v.optionList.map(function (o) { return '<span class=\"pill\">' + esc(o) + '</span>'; }).join(' ') || '<span class=\"muted\">no options</span>') + '</div><q>' + esc(v.says) + '</q>'; }, 'Say: \"make an IVR at 500 saying \\'Welcome to Acme\\', press 1 for 101, 2 for 102\".');\n    h += sec('Ring groups', S.groups, function (n, g) { return '<div class=\"t\"><b>' + n + (g.name ? ' · ' + esc(g.name) : '') + '</b><button class=\"mini danger\" data-say=\"delete ring group ' + n + '\">Delete</button></div><div class=\"muted\">' + esc(g.summary) + '</div>'; }, 'Say: \"create ring group 600 Sales with 101 and 102\".');\n    h += sec('Queues', S.queues, function (n, q) { return '<div class=\"t\"><b>' + n + (q.name ? ' · ' + esc(q.name) : '') + '</b><button class=\"mini danger\" data-say=\"delete queue ' + n + '\">Delete</button></div><div class=\"muted\">' + esc(q.summary) + '</div>'; }, 'Say: \"create queue 700 Support with agents 101 and 102\".');\n    h += sec('Conference rooms', S.conferences, function (n, c) { return '<div class=\"t\"><b>' + n + (c.name ? ' · ' + esc(c.name) : '') + '</b><button class=\"mini danger\" data-say=\"delete conference ' + n + '\">Delete</button></div><div class=\"muted\">' + (c.pin ? 'PIN ' + esc(c.pin) : 'no PIN') + ' · ' + c.inRoom + ' in the room now</div>'; }, 'Say: \"create conference room 800 with PIN 1234\".');\n    h += '<div class=\"card\"><h3>Outside line</h3>' + (S.trunk.host ? '<div class=\"kv\"><span>Provider</span><code>' + esc(S.trunk.host) + '</code><span></span><span>Account</span><code>' + esc(S.trunk.username) + '</code><span></span><span>Status</span><b>' + (!S.trunk.enabled ? 'off' : S.trunk.registered ? 'registered' : 'not registered ' + esc(S.trunk.lastError)) + '</b><span></span><span>Incoming calls</span><span style=\"color:var(--ink)\">' + esc(S.trunk.inbound) + '</span><span></span></div>' : '<div class=\"empty\">Say: \"add trunk host sip.provider.com user 12345 password secret\".</div>') + '</div>';\n    h += '<div class=\"card\"><h3>Feature codes <small>dial them from any phone</small></h3><div class=\"kv\">' + S.codes.map(function (c) { return '<code>' + esc(c[0]) + '</code><span style=\"grid-column:span 2\">' + esc(c[1]) + '</span>'; }).join('') + '</div></div>';\n    return h;\n  }\n  function voicemail() {\n    var boxes = S.voicemail.filter(function (b) { return b.messages.length; });\n    if (!boxes.length) return '<div class=\"card\"><h3>Voicemail</h3><div class=\"empty\">No messages. When nobody answers, callers can leave a message; listen here or dial <code>*97</code> from your phone.</div></div>';\n    return boxes.map(function (b) {\n      return '<div class=\"card\"><h3>' + esc(b.box) + ' ' + esc(b.name) + ' <small>' + b.messages.filter(function (m) { return m.isNew; }).length + ' new</small></h3>' + b.messages.map(function (m) {\n        return '<div class=\"vm\"><div><b class=\"' + (m.isNew ? 'new' : '') + '\">From ' + esc(m.fromName || m.from) + (m.fromName && m.fromName !== m.from ? ' (' + esc(m.from) + ')' : '') + '</b> <span class=\"muted\">' + new Date(m.date).toLocaleString() + ' · ' + m.duration + ' s</span><br><audio controls preload=\"none\" src=\"' + m.url + '\"></audio></div>' +\n          '<button class=\"mini danger\" data-act=\\'' + esc(JSON.stringify({ type: 'vm.delete', number: b.box, id: m.id })) + '\\'>Delete</button></div>';\n      }).join('') + '</div>';\n    }).join('');\n  }\n  function recordings() {\n    return '<div class=\"card\"><h3>Recordings <small>' + (S.settings.recordAll ? 'all calls are recorded' : 'say \"record all calls\" to start') + '</small></h3>' + (S.recordings.length ? S.recordings.map(function (r) {\n      return '<div class=\"vm\"><div><b>' + esc(r.name.replace(/\\.wav$/, '').replace(/_/g, ' ')) + '</b> <span class=\"muted\">' + r.secs + ' s · left: caller, right: callee</span><br><audio controls preload=\"none\" src=\"' + r.url + '\"></audio></div><button class=\"mini danger\" data-act=\\'' + esc(JSON.stringify({ type: 'rec.clear', file: r.name })) + '\\'>Delete</button></div>';\n    }).join('') : '<div class=\"empty\">No recordings yet.</div>') + '</div>';\n  }\n  function calllog() {\n    return '<div class=\"card\"><h3>Call log <small>last ' + S.cdr.length + '</small></h3>' + (S.cdr.length ? '<div class=\"tw\"><table><thead><tr><th>When</th><th>From</th><th>To</th><th>Result</th><th>Talk time</th></tr></thead><tbody>' + S.cdr.map(function (c) {\n      return '<tr><td>' + new Date(c.start).toLocaleString() + '</td><td>' + esc(c.fromName && c.fromName !== c.from ? c.fromName + ' (' + c.from + ')' : c.from) + '</td><td>' + esc(c.answeredBy || c.to) + '</td><td>' + esc(c.result || 'in progress') + '</td><td>' + (c.duration ? c.duration + ' s' : '') + '</td></tr>';\n    }).join('') + '</tbody></table></div>' : '<div class=\"empty\">No calls yet.</div>') + '</div>';\n  }\n  function sip() {\n    return '<div class=\"card sip\"><h3>SIP messages <small>newest first · click one to see it in full</small></h3>' + (S.sip.length ? S.sip.slice().reverse().map(function (m) {\n      return '<details><summary><span class=\"muted\">' + new Date(m.t).toLocaleTimeString() + '</span><span class=\"' + m.dir + '\">' + (m.dir === 'in' ? '←' : '→') + '</span><span class=\"muted\">' + esc(m.addr) + '</span><span>' + esc(m.first) + '</span></summary><pre>' + esc(m.text) + '</pre></details>';\n    }).join('') : '<div class=\"empty\">Nothing yet.</div>') + '</div>';\n  }\n  function render() {\n    if (!S) return;\n    chips(); tabs(); suggestions();\n    var open = {}; document.querySelectorAll('.sip details[open] summary').forEach(function (s) { open[s.textContent] = 1; });\n    var playing = [].slice.call(document.querySelectorAll('audio')).some(function (a) { return !a.paused; });\n    if (playing && (tab === 'voicemail' || tab === 'recordings')) return;\n    var y = $('pane').scrollTop;\n    $('pane').innerHTML = { overview: overview, phones: phones, features: features, voicemail: voicemail, recordings: recordings, log: calllog, sip: sip }[tab]();\n    document.querySelectorAll('.sip details summary').forEach(function (s) { if (open[s.textContent]) s.parentNode.open = true; });\n    $('pane').scrollTop = y;\n  }\n  function refresh(force) {\n    fetch('/api/state').then(function (r) { if (r.status === 401 || r.status === 403) throw new Error('auth'); return r.text(); }).then(function (t) {\n      if (!force && t === lastJson) return; lastJson = t; S = JSON.parse(t); render();\n    }).catch(function () { $('chips').innerHTML = '<span class=\"chip\"><span class=\"dot bad\"></span>Can’t reach aiVoice PBX: is it running?</span>'; });\n  }\n  refresh(true);\n  setInterval(refresh, 1500);\n})();\n</script>\n</body>\n</html>\n";

// ================================================================ web page: chat and live switchboard
function stateJson() {
  const vm = Object.keys(CFG.extensions).map(b => ({ box: b, name: CFG.extensions[b].name, messages: vmList(b).map(m => ({ ...m, url: `/files/vm/${encodeURIComponent(b)}/${encodeURIComponent(m.file)}` })) }));
  return {
    version: VERSION, ip: LAN_IP, ipMode: IP_FROM_ARGS ? 'start command' : CFG.lanIp && CFG.lanIp === LAN_IP ? 'chosen' : 'automatic', ips: localIps().map(c => ({ ip: c.ip, adapter: c.adapter })), sipPort: CFG.sipPort, httpPort: CFG.httpPort, tts: ttsWorks !== false,
    extensions: Object.entries(CFG.extensions).map(([n, e]) => {
      const r = REGS.get(n), c = vmCounts(n);
      return { number: n, name: e.name, password: e.password, online: !!r, ua: r ? r.ua : '', busy: isBusy(n), dnd: e.dnd, voicemail: e.voicemail, vmNew: c.neu, vmTotal: c.neu + c.old, record: e.record || CFG.recordAll, forward: e.forward };
    }),
    groups: Object.fromEntries(Object.entries(CFG.groups).map(([n, g]) => [n, { name: g.name, summary: `${(g.members || []).map(extName).join(', ') || 'no members'} · ${g.strategy === 'sequential' ? 'one by one' : 'all at once'}${g.fallback ? ` · then ${destName(g.fallback)}` : ''}` }])),
    queues: Object.fromEntries(Object.entries(CFG.queues).map(([n, q]) => [n, { name: q.name, summary: `agents ${(q.agents || []).map(extName).join(', ') || 'none'} · max wait ${q.maxWait} s · ${(QWAIT.get(n) || []).filter(l => l.state !== 'ended').length} waiting${q.fallback ? ` · then ${destName(q.fallback)}` : ''}` }])),
    ivrs: Object.fromEntries(Object.entries(CFG.ivrs).map(([n, v]) => [n, { name: v.name, says: v.greeting || autoGreeting(v), optionList: [...Object.entries(v.options || {}).sort().map(([k, d]) => `${k} → ${destName(d)}`), ...(v.timeoutDest ? [`no input → ${destName(v.timeoutDest)}`] : [])] }])),
    conferences: Object.fromEntries(Object.entries(CFG.conferences).map(([n, c]) => [n, { name: c.name, pin: c.pin, inRoom: ROOMS.get(n) ? ROOMS.get(n).members.size : 0 }])),
    calls: activeCalls(),
    trunk: { host: CFG.trunk.host, username: CFG.trunk.username, enabled: CFG.trunk.enabled, registered: TRUNK.registered, lastError: TRUNK.lastError, inbound: CFG.trunk.host ? destName(trunkInboundDest()) : '' },
    voicemail: vm,
    recordings: listRecordings().slice(0, 200).map(r => ({ ...r, url: `/files/rec/${encodeURIComponent(r.name)}` })),
    cdr: CDR.slice(0, 100), log: LOG.slice(-60), sip: SIPLOG.slice(-150), codes: FEATURE_CODES,
    ai: { enabled: CFG.ai.enabled, model: CFG.ai.model, hasKey: !!CFG.ai.apiKey },
    settings: { ringTime: CFG.ringTime, callWaiting: CFG.callWaiting, recordAll: CFG.recordAll, publicIp: CFG.publicIp, adminRemote: CFG.adminRemote },
  };
}
function readBody(req) {
  return new Promise((res, rej) => { let s = ''; req.on('data', c => { s += c; if (s.length > 100000) req.destroy(); }); req.on('end', () => res(s)); req.on('error', rej); });
}
function allowed(req) {
  const ip = (req.socket.remoteAddress || '').replace(/^::ffff:/, '');
  const local = ip === '127.0.0.1' || ip === '::1';
  if (local) return true;
  if (!CFG.adminRemote) return false;
  if (!CFG.adminPassword) return true;
  const a = req.headers.authorization || '';
  const dec = a.startsWith('Basic ') ? Buffer.from(a.slice(6), 'base64').toString() : '';
  return dec === `admin:${CFG.adminPassword}`;
}
async function onHttp(req, res) {
  const send = (code, body, type = 'application/json; charset=utf-8', extra = {}) => { res.writeHead(code, { 'Content-Type': type, 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff', ...extra }); res.end(body); };
  if (!allowed(req)) {
    if (CFG.adminRemote && CFG.adminPassword) return send(401, 'Password required', 'text/plain', { 'WWW-Authenticate': 'Basic realm="aiVoice PBX"' });
    return send(403, 'aiVoice PBX: this page only opens on the computer running it. Say "allow remote access with password ..." in the chat there to change that.', 'text/plain; charset=utf-8');
  }
  const url = new URL(req.url, 'http://x');
  try {
    if (req.method === 'GET' && (url.pathname === '/' || url.pathname === '/index.html')) return send(200, UI_HTML, 'text/html; charset=utf-8');
    if (req.method === 'GET' && url.pathname === '/api/state') return send(200, JSON.stringify(stateJson()));
    if (req.method === 'POST' && url.pathname === '/api/chat') {
      const { text } = JSON.parse(await readBody(req) || '{}');
      const r = await chat(text);
      log(`Chat: "${String(text).replace(/sk-ant-[\w-]+/g, 'sk-ant-•••').replace(/\b(password|pass|pwd|pin)(\s*(?:is|to|=|:|as)?\s*)\S+/gi, '$1$2•••').slice(0, 80)}"`);
      return send(200, JSON.stringify({ reply: r.reply, changed: !!r.changed }));
    }
    if (req.method === 'POST' && url.pathname === '/api/action') {
      const a = JSON.parse(await readBody(req) || '{}');
      if (!['vm.delete', 'rec.clear', 'call.hangup'].includes(a.type)) return send(400, JSON.stringify({ reply: 'Not allowed' }));
      return send(200, JSON.stringify({ reply: runActions([a]).msgs.join('\n') }));
    }
    const fm = /^\/files\/(vm|rec)\/(.+)$/.exec(url.pathname);
    if (req.method === 'GET' && fm) {
      const parts = fm[2].split('/').map(decodeURIComponent);
      if (parts.some(p => !p || p.includes('..') || /[\\:]/.test(p))) return send(400, 'bad path', 'text/plain');
      const file = fm[1] === 'vm' ? path.join(DIRS.vm, ...parts) : path.join(DIRS.rec, ...parts);
      if (!file.startsWith(fm[1] === 'vm' ? DIRS.vm : DIRS.rec) || !fs.existsSync(file)) return send(404, 'not found', 'text/plain');
      return send(200, fs.readFileSync(file), 'audio/wav');
    }
    send(404, 'not found', 'text/plain');
  } catch (e) {
    log('Web error:', e.message);
    send(500, JSON.stringify({ reply: `Something went wrong: ${e.message}` }));
  }
}

// ================================================================ start-up
function bindUdp(port) {
  return new Promise(res => {
    const s = dgram.createSocket('udp4');
    s.once('error', () => { try { s.close(); } catch (e) {} res(null); });
    s.bind(port, () => res(s));
  });
}
function listenHttp(server, port) {
  return new Promise(res => { server.once('error', () => res(false)); server.listen(port, () => res(true)); });
}
function openBrowser(url) {
  const cmd = process.platform === 'win32' ? ['cmd', ['/c', 'start', '', url]] : process.platform === 'darwin' ? ['open', [url]] : ['xdg-open', [url]];
  try { execFile(cmd[0], cmd[1], { windowsHide: true }, () => {}); } catch (e) {}
}
async function main() {
  loadConfig();
  if (!IP_FROM_ARGS && CFG.lanIp) {
    if (localIps().some(c => c.ip === CFG.lanIp)) LAN_IP = CFG.lanIp;
    else log(`The saved server IP ${CFG.lanIp} is not on this computer any more (the network changed?), so I'm using ${LAN_IP} for now. Say "show IP addresses" in the chat to pick one.`);
  }
  // SIP socket (try the configured port, then a few more if something else uses it)
  let sock = null, port = +arg('sip-port', CFG.sipPort);
  for (let p = port; p < port + 20 && !sock; p += (p === port ? 10 : 1)) { sock = await bindUdp(p); if (sock && p !== port) log(`Port ${port} was busy, so SIP uses port ${p}.`); if (sock) CFG.sipPort = p; }
  if (!sock) { console.error('Could not open a SIP port. Is another phone system or softphone using port 5060?'); process.exit(1); }
  sipSock = sock;
  sipSock.on('message', (buf, rinfo) => {
    if (buf.length < 8) return; // keep-alive pings
    let m; try { m = parseSip(buf); } catch (e) { m = null; }
    if (!m) return;
    m.src = { address: rinfo.address, port: rinfo.port };
    sipTrace('in', m.src, buf.toString('utf8'));
    try { if (m.status) onSipResponse(m); else onSipRequest(m); } catch (e) { log('SIP error:', e.stack || e.message); }
  });
  sipSock.on('error', e => log('SIP socket error:', e.message));
  // web page
  const server = http.createServer((q, r) => { onHttp(q, r); });
  let hp = +arg('http-port', CFG.httpPort), ok = false;
  for (let p = hp; p < hp + 20 && !ok; p++) { ok = await listenHttp(server, p); if (ok) CFG.httpPort = p; }
  const url = `http://localhost:${CFG.httpPort}`;
  // warm up the prompts and the outside line
  say(PROMPTS.vmDefault('')).catch(() => {});
  afterConfigChange();
  trunkRegister();
  console.log(`
  aiVoice PBX ${VERSION} is running.
  Chat and switchboard:  ${url}
  Phones connect to:     ${LAN_IP}${CFG.sipPort !== 5060 ? ':' + CFG.sipPort : ''} (UDP)
  Data folder:           ${DATA}
  Press Ctrl+C to stop.
`);
  log('aiVoice PBX started');
  try { fs.writeFileSync(path.join(DATA, 'running.json'), JSON.stringify({ ip: LAN_IP, sipPort: CFG.sipPort, httpPort: CFG.httpPort, pid: process.pid, started: new Date().toISOString() })); } catch (e) {}
  if (!flag('no-browser')) openBrowser(url);
}
process.on('uncaughtException', e => log('Unexpected error:', e.stack || e.message));
process.on('unhandledRejection', e => log('Unexpected error:', (e && e.stack) || e));
function shutdown() {
  for (const l of [...LEGS.values()]) { try { l.hangup('shutting down'); } catch (e) {} }
  try { fs.writeFileSync(CDR_FILE, JSON.stringify(CDR)); } catch (e) {}
  try { fs.unlinkSync(path.join(DATA, 'running.json')); } catch (e) {}
  setTimeout(() => process.exit(0), 300);
}
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown); // systemd / docker stop
main();
AIVOICEPBX_SOURCE_END
"$NODE" --check "$APP/aivoice-pbx.new.js" || die "the program inside this file is damaged; download it again"
mv -f "$APP/aivoice-pbx.new.js" "$APP/aivoice-pbx.js"
ok "program saved"

# first install: let the chat page be opened from other computers (with a password)
FIRST=0
if [ ! -f "$DATA/config.json" ]; then
  FIRST=1
  if [ $LOCAL_ONLY = 0 ]; then
    ADMIN_PW=$("$NODE" -e "process.stdout.write(require('crypto').randomBytes(9).toString('base64').replace(/[^A-Za-z0-9]/g,'x'))")
    printf '{\n  "adminRemote": true,\n  "adminPassword": "%s"\n}\n' "$ADMIN_PW" > "$DATA/config.json"
  fi
fi

# a user account just for the PBX (it does not need root)
RUN_AS=root
if id aivoice-pbx >/dev/null 2>&1; then RUN_AS=aivoice-pbx
elif have useradd && useradd --system --no-create-home --home-dir "$DATA" --shell /usr/sbin/nologin aivoice-pbx >/dev/null 2>&1; then RUN_AS=aivoice-pbx
elif have adduser && adduser -S -D -H -h "$DATA" -s /sbin/nologin aivoice-pbx >/dev/null 2>&1; then RUN_AS=aivoice-pbx
fi
chown -R "$RUN_AS" "$DATA" 2>/dev/null
chmod 700 "$DATA"

cat > "$APP/run.sh" <<EOF
#!/bin/sh
exec "$NODE" "$APP/aivoice-pbx.js" --data "$DATA" --no-browser
EOF
chmod 755 "$APP/run.sh"

# ---------- 4. start it as a service
say "Starting aiVoice PBX"
rm -f "$DATA/running.json"
if [ $HAS_SYSTEMD = 1 ]; then
  cat > /etc/systemd/system/$SVC.service <<EOF
[Unit]
Description=aiVoice PBX phone system
After=network-online.target
Wants=network-online.target

[Service]
User=$RUN_AS
WorkingDirectory=$APP
ExecStart=$APP/run.sh
Restart=always
RestartSec=3
NoNewPrivileges=true

[Install]
WantedBy=multi-user.target
EOF
  systemctl daemon-reload
  systemctl enable $SVC >/dev/null 2>&1
  systemctl restart $SVC || die "the service did not start. See: journalctl -u $SVC -n 50"
  ok "running as a service (starts by itself after reboot)"
else
  pkill -f "$APP/aivoice-pbx.js" 2>/dev/null; sleep 1
  if [ "$RUN_AS" != root ] && have su; then su -s /bin/sh "$RUN_AS" -c "nohup $APP/run.sh >> $DATA/aivoice-pbx.log 2>&1 &"
  else nohup "$APP/run.sh" >> "$DATA/aivoice-pbx.log" 2>&1 & fi
  ok "running in the background (log: $DATA/aivoice-pbx.log)"
  warn "this Linux has no systemd, so aiVoice PBX will NOT start by itself after a reboot; run this file again after rebooting"
fi

# wait until it is up
for _ in $(seq 1 30); do [ -f "$DATA/running.json" ] && break; sleep 0.5; done
[ -f "$DATA/running.json" ] || die "aiVoice PBX did not start. See: $( [ $HAS_SYSTEMD = 1 ] && echo "journalctl -u $SVC -n 50" || echo "$DATA/aivoice-pbx.log")"
INFO=$("$NODE" -e "const r=require('$DATA/running.json');console.log(r.ip,r.sipPort,r.httpPort)")
set -- $INFO; IP=$1; SIP_PORT=$2; WEB_PORT=$3

# ---------- 5. firewall
open_port() { # port/proto
  if have ufw && ufw status 2>/dev/null | grep -q "Status: active"; then ufw allow "$1" >/dev/null && ok "firewall (ufw): opened $1"
  elif have firewall-cmd && firewall-cmd --state >/dev/null 2>&1; then firewall-cmd -q --permanent --add-port="${1/:/-}" && firewall-cmd -q --reload && ok "firewall (firewalld): opened $1"
  fi
}
open_port "$SIP_PORT/udp"
open_port "$RTP_PORTS/udp"
[ $LOCAL_ONLY = 0 ] && open_port "$WEB_PORT/tcp"

# ---------- 6. tell the user
"$NODE" - "$DATA/config.json" "$IP" "$SIP_PORT" "$WEB_PORT" "$FIRST" <<'EOF'
const [file, ip, sip, web, first] = process.argv.slice(2);
const c = JSON.parse(require('fs').readFileSync(file, 'utf8'));
const b = s => `\x1b[1m${s}\x1b[0m`;
console.log(`\n\x1b[1;32m aiVoice PBX is running.\x1b[0m\n`);
if (c.adminRemote) {
  console.log(` Chat & switchboard:  ${b(`http://${ip}:${web}`)}   (open it from any computer on your network)`);
  console.log(`   login:             user ${b('admin')}   password ${b(c.adminPassword || '(none)')}`);
} else console.log(` Chat & switchboard:  ${b(`http://localhost:${web}`)}   (on this machine only)`);
console.log(`\n Phones (MicroSIP, Zoiper, Linphone...):  SIP server ${b(ip + (sip !== '5060' ? ':' + sip : ''))}, transport UDP`);
for (const [n, e] of Object.entries(c.extensions || {})) console.log(`   ${n} ${e.name.padEnd(12)} username ${b(n)}   password ${b(e.password)}`);
console.log(`\n Add more in the chat, e.g. "add extension 103 for Carol".`);
EOF
if [ $HAS_SYSTEMD = 1 ]; then cat <<EOF

 Useful commands:
   sudo systemctl status $SVC        is it running?
   sudo journalctl -u $SVC -f        live log
   sudo systemctl restart $SVC       restart
EOF
else cat <<EOF

 Useful commands:
   tail -f $DATA/aivoice-pbx.log        live log
EOF
fi
cat <<EOF
   sudo bash $(basename "$0")         update (run this file again; settings are kept)
   sudo bash $(basename "$0") uninstall
 Data (settings, voicemail, recordings): $DATA
EOF
[ $LOCAL_ONLY = 0 ] && echo " Note: the chat page uses plain http with a password. On a server reachable from the internet, block port $WEB_PORT or reinstall with --local-only and use an SSH tunnel."
exit 0
