# NS Web Responder — SMS Directions & Hours — Implementation Plan

> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

**Goal:** Build a Flask web responder that receives a Netsapiens Auto Attendant POST, looks up the customer domain in `domains.json`, and sends an SMS with hours and directions to the caller's number via the NS SMS API.

**Architecture:** Single Flask endpoint (`POST /webresponder`) running under gunicorn on `localhost:5100`. Caddy on `ns-scripts.pressone.net` routes `/webresponder*` to it. Per-customer config (from_number + message) lives in `domains.json` — reloaded on every request so adding a customer requires no restart. NS super-user Bearer token in `.env`.

**Tech Stack:** Python 3.12, Flask, gunicorn, requests, python-dotenv, pytest · Ubuntu 24.04 · Caddy · systemd

---

## Task 1: Local project scaffold

**Files:**
- Create: `webresponder/requirements.txt`
- Create: `webresponder/.env.example`
- Create: `webresponder/domains.json`
- Create: `webresponder/.gitignore`

**Step 1: Create the project directory**

```bash
mkdir -p /Users/shripald/ClaudProjects/Netsapiens-Scripts/webresponder
cd /Users/shripald/ClaudProjects/Netsapiens-Scripts/webresponder
```

**Step 2: Create `requirements.txt`**

```
flask==3.1.1
gunicorn==23.0.0
requests==2.32.3
python-dotenv==1.1.0
pytest==8.3.5
```

**Step 3: Create `.env.example`**

```
NS_TOKEN=your-super-user-bearer-token-here
```

**Step 4: Create `domains.json`**

```json
{
  "customer1.net": {
    "from_number": "+18135551234",
    "message": "Our hours are Mon-Fri 9am-5pm. We are located at 123 Main St, Tampa FL 33601."
  },
  "customer2.net": {
    "from_number": "+14075559876",
    "message": "Open Mon-Sat 8am-6pm. Find us at 456 Oak Ave, Orlando FL 32801."
  }
}
```

**Step 5: Create `.gitignore`**

```
.env
venv/
__pycache__/
*.pyc
.pytest_cache/
```

**Step 6: Commit**

```bash
git add webresponder/
git commit -m "feat(webresponder): scaffold project structure"
```

---

## Task 2: Write failing tests

**Files:**
- Create: `webresponder/tests/__init__.py`
- Create: `webresponder/tests/test_app.py`

**Step 1: Create the tests directory and empty `__init__.py`**

```bash
mkdir -p /Users/shripald/ClaudProjects/Netsapiens-Scripts/webresponder/tests
touch /Users/shripald/ClaudProjects/Netsapiens-Scripts/webresponder/tests/__init__.py
```

**Step 2: Create `tests/test_app.py`**

```python
import pytest
import requests as req_lib
from unittest.mock import MagicMock, patch

# app.py does not exist yet — these tests will fail with ImportError
from app import app


FAKE_DOMAINS = {
    "testco.net": {
        "from_number": "+18135550001",
        "message": "Hours: M-F 9am-5pm. Address: 1 Test St, Tampa FL.",
    }
}


@pytest.fixture
def client():
    app.testing = True
    return app.test_client()


def _post(client, **overrides):
    data = {"NmsAni": "+18135559999", "AccountDomain": "testco.net", **overrides}
    return client.post("/webresponder", data=data)


# --- happy path ---

def test_happy_path_returns_200(client):
    with patch("app.load_domains", return_value=FAKE_DOMAINS), \
         patch("app.send_sms") as mock_sms:
        mock_sms.return_value = MagicMock()
        resp = _post(client)
    assert resp.status_code == 200


def test_happy_path_xml_says_texted(client):
    with patch("app.load_domains", return_value=FAKE_DOMAINS), \
         patch("app.send_sms") as mock_sms:
        mock_sms.return_value = MagicMock()
        resp = _post(client)
    assert b"texted you" in resp.data
    assert b"Hangup" in resp.data


def test_happy_path_content_type_is_xml(client):
    with patch("app.load_domains", return_value=FAKE_DOMAINS), \
         patch("app.send_sms") as mock_sms:
        mock_sms.return_value = MagicMock()
        resp = _post(client)
    assert "text/xml" in resp.content_type


def test_happy_path_calls_send_sms_with_correct_args(client):
    with patch("app.load_domains", return_value=FAKE_DOMAINS), \
         patch("app.send_sms") as mock_sms:
        mock_sms.return_value = MagicMock()
        _post(client)
    mock_sms.assert_called_once_with(
        "testco.net",
        "+18135550001",
        "+18135559999",
        "Hours: M-F 9am-5pm. Address: 1 Test St, Tampa FL.",
    )


# --- error: missing caller ---

def test_missing_caller_returns_error_xml(client):
    with patch("app.load_domains", return_value=FAKE_DOMAINS):
        resp = _post(client, NmsAni="")
    assert resp.status_code == 200
    assert b"could not send" in resp.data
    assert b"Hangup" in resp.data


# --- error: unknown domain ---

def test_unknown_domain_returns_error_xml(client):
    with patch("app.load_domains", return_value=FAKE_DOMAINS):
        resp = _post(client, AccountDomain="unknown.net")
    assert resp.status_code == 200
    assert b"could not send" in resp.data


# --- error: SMS API failure ---

def test_sms_http_error_returns_error_xml(client):
    mock_response = MagicMock()
    mock_response.status_code = 400
    mock_response.text = "Bad Request"
    http_err = req_lib.HTTPError(response=mock_response)

    with patch("app.load_domains", return_value=FAKE_DOMAINS), \
         patch("app.send_sms", side_effect=http_err):
        resp = _post(client)
    assert resp.status_code == 200
    assert b"could not send" in resp.data


def test_sms_connection_error_returns_error_xml(client):
    with patch("app.load_domains", return_value=FAKE_DOMAINS), \
         patch("app.send_sms", side_effect=req_lib.ConnectionError("timeout")):
        resp = _post(client)
    assert resp.status_code == 200
    assert b"could not send" in resp.data
```

**Step 3: Set up local venv and install deps**

```bash
cd /Users/shripald/ClaudProjects/Netsapiens-Scripts/webresponder
python3 -m venv venv
venv/bin/pip install -r requirements.txt
```

**Step 4: Run tests — verify they fail with ImportError**

```bash
cd /Users/shripald/ClaudProjects/Netsapiens-Scripts/webresponder
venv/bin/pytest tests/ -v
```

Expected output: all tests fail with `ModuleNotFoundError: No module named 'app'`

**Step 5: Commit**

```bash
git add webresponder/tests/
git commit -m "test(webresponder): add failing tests for web responder endpoint"
```

---

## Task 3: Implement `app.py`

**Files:**
- Create: `webresponder/app.py`

**Step 1: Create `app.py`**

```python
import json
import logging
import os
import uuid
from pathlib import Path

import requests
from dotenv import load_dotenv
from flask import Flask, Response, request

load_dotenv()

app = Flask(__name__)
logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s %(levelname)s %(message)s",
)
log = logging.getLogger(__name__)

NS_API_BASE = "https://api.pressone.net/ns-api/v2"
NS_TOKEN = os.environ.get("NS_TOKEN", "")
DOMAINS_FILE = Path(__file__).parent / "domains.json"

_XML_SUCCESS = (
    '<?xml version="1.0" encoding="UTF-8"?>'
    "<Response>"
    "<Say>We've texted you our hours and directions - goodbye!</Say>"
    "<Hangup/>"
    "</Response>"
)

_XML_ERROR = (
    '<?xml version="1.0" encoding="UTF-8"?>'
    "<Response>"
    "<Say>Sorry, we could not send directions at this time.</Say>"
    "<Hangup/>"
    "</Response>"
)


def xml_success():
    return Response(_XML_SUCCESS, content_type="text/xml")


def xml_error():
    return Response(_XML_ERROR, content_type="text/xml")


def load_domains():
    with open(DOMAINS_FILE) as f:
        return json.load(f)


def send_sms(domain, from_number, destination, message):
    session_id = uuid.uuid4().hex  # 32 hex chars, no dashes — meets NS minimum
    url = f"{NS_API_BASE}/domains/{domain}/users/~/messagesessions/{session_id}/messages"
    resp = requests.post(
        url,
        json={
            "type": "sms",
            "message": message,
            "destination": destination,
            "from-number": from_number,
        },
        headers={"Authorization": f"Bearer {NS_TOKEN}"},
        timeout=10,
    )
    resp.raise_for_status()
    return resp


@app.route("/webresponder", methods=["POST"])
def webresponder():
    caller = request.form.get("NmsAni", "").strip()
    domain = request.form.get("AccountDomain", "").strip()

    log.info(
        "webresponder: caller=%s domain=%s digits=%s call_id=%s",
        caller,
        domain,
        request.form.get("Digits"),
        request.form.get("OrigCallID"),
    )

    if not caller:
        log.warning("NmsAni missing or empty")
        return xml_error()

    try:
        domains = load_domains()
    except Exception as exc:
        log.error("failed to load domains.json: %s", exc)
        return xml_error()

    config = domains.get(domain)
    if not config:
        log.warning("domain not configured: %s", domain)
        return xml_error()

    try:
        send_sms(domain, config["from_number"], caller, config["message"])
        log.info("SMS sent to %s via %s", caller, config["from_number"])
        return xml_success()
    except requests.HTTPError as exc:
        log.error("SMS API HTTP error: %s %s", exc.response.status_code, exc.response.text)
        return xml_error()
    except Exception as exc:
        log.error("SMS send failed: %s", exc)
        return xml_error()


if __name__ == "__main__":
    app.run(debug=False)
```

**Step 2: Run tests — verify they all pass**

```bash
cd /Users/shripald/ClaudProjects/Netsapiens-Scripts/webresponder
venv/bin/pytest tests/ -v
```

Expected: all 9 tests pass.

**Step 3: Commit**

```bash
git add webresponder/app.py
git commit -m "feat(webresponder): implement Flask web responder endpoint"
```

---

## Task 4: Create systemd service file

**Files:**
- Create: `webresponder/ns-webresponder.service`

**Step 1: Create the service file**

```ini
[Unit]
Description=NS Web Responder — SMS Directions & Hours
After=network.target

[Service]
Type=simple
User=root
WorkingDirectory=/opt/ns-scripts/webresponder
ExecStart=/opt/ns-scripts/webresponder/venv/bin/gunicorn -w 2 -b 127.0.0.1:5100 app:app
Restart=always
RestartSec=5
StandardOutput=journal
StandardError=journal

[Install]
WantedBy=multi-user.target
```

**Step 2: Commit**

```bash
git add webresponder/ns-webresponder.service
git commit -m "feat(webresponder): add systemd service unit"
```

---

## Task 5: Deploy to server

**Step 1: SSH to server and create deploy directory**

```bash
sshpass -p 'pr3ss*0n3' ssh -o StrictHostKeyChecking=no root@ns-scripts.pressone.net \
  'mkdir -p /opt/ns-scripts/webresponder/tests'
```

**Step 2: Install Python tooling on server**

```bash
sshpass -p 'pr3ss*0n3' ssh -o StrictHostKeyChecking=no root@ns-scripts.pressone.net \
  'apt install -y python3-pip python3-venv'
```

Expected: apt installs without errors.

**Step 3: Copy project files to server**

```bash
sshpass -p 'pr3ss*0n3' scp -o StrictHostKeyChecking=no \
  /Users/shripald/ClaudProjects/Netsapiens-Scripts/webresponder/app.py \
  /Users/shripald/ClaudProjects/Netsapiens-Scripts/webresponder/requirements.txt \
  /Users/shripald/ClaudProjects/Netsapiens-Scripts/webresponder/.env.example \
  /Users/shripald/ClaudProjects/Netsapiens-Scripts/webresponder/domains.json \
  root@ns-scripts.pressone.net:/opt/ns-scripts/webresponder/
```

**Step 4: Create venv and install dependencies on server**

```bash
sshpass -p 'pr3ss*0n3' ssh -o StrictHostKeyChecking=no root@ns-scripts.pressone.net \
  'cd /opt/ns-scripts/webresponder && python3 -m venv venv && venv/bin/pip install -r requirements.txt'
```

Expected: pip installs flask, gunicorn, requests, python-dotenv without errors.

**Step 5: Create `.env` on server with real token**

```bash
sshpass -p 'pr3ss*0n3' ssh -o StrictHostKeyChecking=no root@ns-scripts.pressone.net \
  'echo "NS_TOKEN=REPLACE_WITH_REAL_TOKEN" > /opt/ns-scripts/webresponder/.env && chmod 600 /opt/ns-scripts/webresponder/.env'
```

> **Action required:** Replace `REPLACE_WITH_REAL_TOKEN` with the actual NS super-user Bearer token before running.

**Step 6: Verify files on server**

```bash
sshpass -p 'pr3ss*0n3' ssh -o StrictHostKeyChecking=no root@ns-scripts.pressone.net \
  'ls -la /opt/ns-scripts/webresponder/'
```

Expected: `app.py`, `requirements.txt`, `domains.json`, `.env`, `.env.example`, `venv/` all present.

---

## Task 6: Install and start systemd service

**Step 1: Copy service file to server**

```bash
sshpass -p 'pr3ss*0n3' scp -o StrictHostKeyChecking=no \
  /Users/shripald/ClaudProjects/Netsapiens-Scripts/webresponder/ns-webresponder.service \
  root@ns-scripts.pressone.net:/etc/systemd/system/ns-webresponder.service
```

**Step 2: Enable and start the service**

```bash
sshpass -p 'pr3ss*0n3' ssh -o StrictHostKeyChecking=no root@ns-scripts.pressone.net \
  'systemctl daemon-reload && systemctl enable ns-webresponder && systemctl start ns-webresponder'
```

**Step 3: Verify service is running**

```bash
sshpass -p 'pr3ss*0n3' ssh -o StrictHostKeyChecking=no root@ns-scripts.pressone.net \
  'systemctl status ns-webresponder --no-pager'
```

Expected: `Active: active (running)` and gunicorn worker lines in output.

**Step 4: Verify gunicorn is listening on port 5100**

```bash
sshpass -p 'pr3ss*0n3' ssh -o StrictHostKeyChecking=no root@ns-scripts.pressone.net \
  'ss -tlnp | grep 5100'
```

Expected: `LISTEN 0 ... 127.0.0.1:5100`

---

## Task 7: Update Caddy config

**Step 1: Read current Caddyfile**

```bash
sshpass -p 'pr3ss*0n3' ssh -o StrictHostKeyChecking=no root@ns-scripts.pressone.net \
  'cat /etc/caddy/Caddyfile'
```

**Step 2: Write updated Caddyfile**

Replace the current `ns-scripts.pressone.net` block with:

```
ns-scripts.pressone.net {
    handle /webresponder* {
        reverse_proxy localhost:5100
    }
    handle {
        reverse_proxy localhost:8081
    }
}
```

Write it to the server:

```bash
sshpass -p 'pr3ss*0n3' ssh -o StrictHostKeyChecking=no root@ns-scripts.pressone.net \
  'cat > /tmp/caddyfile_ns_block.txt' << 'CADDY'
ns-scripts.pressone.net {
    handle /webresponder* {
        reverse_proxy localhost:5100
    }
    handle {
        reverse_proxy localhost:8081
    }
}
CADDY
```

Then use `sed` to replace the old block in `/etc/caddy/Caddyfile` — or write the full Caddyfile directly:

```bash
sshpass -p 'pr3ss*0n3' ssh -o StrictHostKeyChecking=no root@ns-scripts.pressone.net 'cat > /etc/caddy/Caddyfile' << 'EOF'
n8n.pressone.net {
    root * /usr/share/caddy
    file_server
    reverse_proxy localhost:5678
}

ns-scripts.pressone.net {
    handle /webresponder* {
        reverse_proxy localhost:5100
    }
    handle {
        reverse_proxy localhost:8081
    }
}
EOF
```

**Step 3: Validate and reload Caddy**

```bash
sshpass -p 'pr3ss*0n3' ssh -o StrictHostKeyChecking=no root@ns-scripts.pressone.net \
  'caddy validate --config /etc/caddy/Caddyfile && systemctl reload caddy'
```

Expected: `Valid configuration.` then caddy reloads without error.

---

## Task 8: Smoke test end-to-end

**Step 1: Test the endpoint is reachable via HTTPS**

```bash
curl -s -o /dev/null -w "%{http_code}" \
  -X POST https://ns-scripts.pressone.net/webresponder \
  -d "NmsAni=%2B18135559999&AccountDomain=customer1.net"
```

Expected: `200`

**Step 2: Check the XML response body**

```bash
curl -s -X POST https://ns-scripts.pressone.net/webresponder \
  -d "NmsAni=%2B18135559999&AccountDomain=customer1.net"
```

Expected:
```xml
<?xml version="1.0" encoding="UTF-8"?><Response><Say>We've texted you our hours and directions - goodbye!</Say><Hangup/></Response>
```

**Step 3: Test unknown domain returns error XML**

```bash
curl -s -X POST https://ns-scripts.pressone.net/webresponder \
  -d "NmsAni=%2B18135559999&AccountDomain=nobody.net"
```

Expected: response contains `could not send`

**Step 4: Check service logs**

```bash
sshpass -p 'pr3ss*0n3' ssh -o StrictHostKeyChecking=no root@ns-scripts.pressone.net \
  'journalctl -u ns-webresponder -n 30 --no-pager'
```

Expected: log lines showing the request was received and SMS was attempted.

**Step 5: Confirm no SMS actually sent (test number)**

The smoke test uses `+18135559999` which is a fake number — verify in NS logs or confirm with the NS team that no real SMS was dispatched, or use the NS portal to check message history.

---

## Task 9: Final commit and note

**Step 1: Ensure all local files are committed**

```bash
git status
git add -A webresponder/
git commit -m "feat(webresponder): complete implementation — Flask + gunicorn + systemd + Caddy"
```

**Step 2: Update `domains.json` per-customer instructions**

To add a new customer, edit `/opt/ns-scripts/webresponder/domains.json` on the server and add:

```json
"newcustomer.net": {
  "from_number": "+1XXXXXXXXXX",
  "message": "Their hours and address here."
}
```

No service restart needed — the file is re-read on every request.

**Step 3: NS Auto Attendant setup**

In the customer's NS portal, go to their Auto Attendant, set the desired digit action to **Web Responder**, and enter:

```
https://ns-scripts.pressone.net/webresponder
```

---

## Summary

| What | Where |
|---|---|
| App code | `/opt/ns-scripts/webresponder/app.py` |
| Customer config | `/opt/ns-scripts/webresponder/domains.json` |
| Credentials | `/opt/ns-scripts/webresponder/.env` |
| Service logs | `journalctl -u ns-webresponder -f` |
| Public URL | `https://ns-scripts.pressone.net/webresponder` |
| Restart service | `systemctl restart ns-webresponder` |
