From 15b981007cb06fec88973dab09721ac671eef48e Mon Sep 17 00:00:00 2001 From: Fernando Baz Date: Wed, 22 Apr 2026 14:31:37 -0600 Subject: [PATCH 1/5] feat: add twilio webhook transport --- .env.example | 18 ++++ .github/workflows/ci.yml | 8 +- install.sh | 86 ++++++++++++++-- requirements.txt | 18 ++-- tests/test_webhook.py | 122 ++++++++++++++++++++++ webhook/__init__.py | 0 webhook/server.py | 213 +++++++++++++++++++++++++++++++++++++++ 7 files changed, 445 insertions(+), 20 deletions(-) create mode 100644 tests/test_webhook.py create mode 100644 webhook/__init__.py create mode 100644 webhook/server.py diff --git a/.env.example b/.env.example index df5a872..36d52fc 100644 --- a/.env.example +++ b/.env.example @@ -24,3 +24,21 @@ CLAUDE_TIMEOUT_SECONDS=60 # WhatsApp bridge REST API port and MCP server target URL. WHATSAPP_BRIDGE_PORT=8080 WHATSAPP_API_URL=http://127.0.0.1:8080/api + +# --------------------------------------------------------------------------- +# Twilio webhook transport (only needed when using --transport twilio) +# --------------------------------------------------------------------------- + +# Twilio Auth Token — used to validate X-Twilio-Signature on each request. +# If unset, signature validation is skipped (unsafe; only for local testing). +# TWILIO_AUTH_TOKEN=your_twilio_auth_token_here + +# Public URL Twilio will POST to, used for signature validation. +# PUBLIC_WEBHOOK_URL=https://your-domain.example.com/webhook + +# Port and host for the FastAPI webhook server. +# WEBHOOK_PORT=8000 +# WEBHOOK_HOST=0.0.0.0 + +# SQLite DB for webhook inbound/outbound messages. +# WEBHOOK_DB_PATH=/home/deet/whatsapp-claude/webhook_messages.db diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d919b3a..32edaac 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,8 +18,8 @@ jobs: with: python-version: "3.11" - - name: Install tooling - run: pip install ruff mypy pytest + - name: Install tooling and dependencies + run: pip install -r requirements.txt - name: Lint (ruff check) run: ruff check . @@ -33,5 +33,5 @@ jobs: - name: Validate docker-compose run: docker compose config - - name: Collect tests (pytest) - run: pytest --co -q + - name: Run tests (pytest) + run: pytest -q diff --git a/install.sh b/install.sh index d145dc5..35b1c9f 100755 --- a/install.sh +++ b/install.sh @@ -7,6 +7,7 @@ BRIDGE_DIR="$SUBMODULE_DIR/whatsapp-bridge" MCP_DIR="$SUBMODULE_DIR/whatsapp-mcp-server" BIN_DIR="$REPO_ROOT/.local/bin" VENV_DIR="$REPO_ROOT/.venvs/whatsapp-mcp-server" +WEBHOOK_VENV_DIR="$REPO_ROOT/.venvs/webhook" STORE_DIR="$BRIDGE_DIR/store" WHATSAPP_SUBMODULE_DB="$STORE_DIR/messages.db" SYSTEMD_USER_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user" @@ -14,6 +15,8 @@ ENV_FILE="$REPO_ROOT/.env" BRIDGE_BIN="$BIN_DIR/whatsapp-bridge" POLLER_BIN="${PYTHON_BIN:-python3}" +TRANSPORT="bridge" + err() { echo "Error: $*" >&2 exit 1 @@ -89,6 +92,13 @@ setup_mcp_venv() { "$VENV_DIR/bin/pip" install "anyio<4.9" } +setup_webhook_venv() { + echo "Setting up webhook virtualenv..." + python3 -m venv "$WEBHOOK_VENV_DIR" + "$WEBHOOK_VENV_DIR/bin/pip" install --upgrade pip + "$WEBHOOK_VENV_DIR/bin/pip" install "fastapi>=0.110" "uvicorn[standard]>=0.27" "twilio>=8.0" +} + write_service() { local name="$1" local content="$2" @@ -96,7 +106,7 @@ write_service() { printf '%s\n' "$content" > "$SYSTEMD_USER_DIR/$name.service" } -install_services() { +install_bridge_services() { local bridge_port="${WHATSAPP_BRIDGE_PORT:-8080}" local api_url="${WHATSAPP_API_URL:-http://127.0.0.1:${bridge_port}/api}" local db_path="${WA_DB_PATH:-$WHATSAPP_SUBMODULE_DB}" @@ -163,6 +173,29 @@ WantedBy=default.target" systemctl --user enable whatsapp-bridge.service whatsapp-mcp-server.service whatsapp-poller.service } +install_webhook_service() { + local bot_dir="${BOT_WORKING_DIR:-$REPO_ROOT}" + + write_service "whatsapp-webhook" "[Unit] +Description=WhatsApp Twilio webhook +After=network.target + +[Service] +Type=simple +WorkingDirectory=$REPO_ROOT +EnvironmentFile=$ENV_FILE +Environment=BOT_WORKING_DIR=$bot_dir +ExecStart=$WEBHOOK_VENV_DIR/bin/python $REPO_ROOT/webhook/server.py +Restart=on-failure +RestartSec=3 + +[Install] +WantedBy=default.target" + + systemctl --user daemon-reload + systemctl --user enable whatsapp-webhook.service +} + first_auth() { mkdir -p "$STORE_DIR" if [ -f "$STORE_DIR/whatsmeow.db" ]; then @@ -177,25 +210,58 @@ first_auth() { ) } -start_services() { +start_bridge_services() { systemctl --user restart whatsapp-bridge.service sleep 2 systemctl --user restart whatsapp-mcp-server.service whatsapp-poller.service } +start_webhook_service() { + systemctl --user restart whatsapp-webhook.service +} + +parse_args() { + while [[ $# -gt 0 ]]; do + case "$1" in + --transport) + TRANSPORT="${2:-}" + shift 2 + ;; + *) + err "Unknown option: $1" + ;; + esac + done + + case "$TRANSPORT" in + bridge | twilio) ;; + *) err "Unknown transport '$TRANSPORT'. Valid values: bridge, twilio" ;; + esac +} + main() { - check_go + parse_args "$@" + check_python check_required_tools check_systemd_user ensure_env_file - sync_submodule - build_bridge - setup_mcp_venv - install_services - first_auth - start_services - echo "Install complete. Check status with: systemctl --user status whatsapp-bridge whatsapp-mcp-server whatsapp-poller" + + if [ "$TRANSPORT" = "twilio" ]; then + setup_webhook_venv + install_webhook_service + start_webhook_service + echo "Install complete (twilio). Check status with: systemctl --user status whatsapp-webhook" + else + check_go + sync_submodule + build_bridge + setup_mcp_venv + install_bridge_services + first_auth + start_bridge_services + echo "Install complete (bridge). Check status with: systemctl --user status whatsapp-bridge whatsapp-mcp-server whatsapp-poller" + fi } main "$@" diff --git a/requirements.txt b/requirements.txt index 4b94792..6e124a6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,12 @@ -# All runtime dependencies are Python stdlib (sqlite3, subprocess, json, logging, etc.) -# No third-party packages required for the poller. -# -# Dev / tooling (optional): -# ruff # linter -# mypy # type checking +# Poller: all runtime deps are Python stdlib — no packages required. + +# Webhook transport (Twilio ingress) +fastapi>=0.110 +uvicorn[standard]>=0.27 +twilio>=8.0 + +# Test / dev +httpx>=0.27 # required by FastAPI TestClient +pytest>=8.0 +ruff +mypy diff --git a/tests/test_webhook.py b/tests/test_webhook.py new file mode 100644 index 0000000..15d507b --- /dev/null +++ b/tests/test_webhook.py @@ -0,0 +1,122 @@ +import os +import subprocess +import sys +from unittest.mock import MagicMock, patch + +import pytest +from fastapi.testclient import TestClient + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +import webhook.server as server + +ALLOWED_JID = "15551234567@s.whatsapp.net" +FORM_DATA = {"From": "whatsapp:+15551234567", "Body": "Hello"} + + +@pytest.fixture +def client(tmp_path, monkeypatch): + db_path = str(tmp_path / "test.db") + monkeypatch.setattr(server, "WEBHOOK_DB_PATH", db_path) + monkeypatch.setattr(server, "TWILIO_AUTH_TOKEN", "test_token") + monkeypatch.setattr(server, "PUBLIC_WEBHOOK_URL", "https://example.com/webhook") + monkeypatch.setattr(server, "ALLOWED_CHATS", set()) + monkeypatch.setattr(server, "BOT_WORKING_DIR", str(tmp_path)) + server._init_db(db_path) + return TestClient(server.app) + + +def _mock_validator(valid: bool): + mock_cls = MagicMock() + mock_cls.return_value.validate.return_value = valid + return mock_cls + + +def _mock_claude(stdout: str = "Hi!", returncode: int = 0): + result = MagicMock() + result.returncode = returncode + result.stdout = stdout + result.stderr = "" + return result + + +def test_valid_post_allowed_returns_twiml(client): + with ( + patch("webhook.server.RequestValidator", _mock_validator(True)), + patch("subprocess.run", return_value=_mock_claude("Hello back!")), + ): + r = client.post( + "/webhook", + data=FORM_DATA, + headers={"X-Twilio-Signature": "sig"}, + ) + assert r.status_code == 200 + assert "Hello back!" in r.text + assert r.headers["content-type"].startswith("application/xml") + + +def test_valid_post_disallowed_jid_returns_empty(tmp_path, monkeypatch): + db_path = str(tmp_path / "test.db") + monkeypatch.setattr(server, "WEBHOOK_DB_PATH", db_path) + monkeypatch.setattr(server, "TWILIO_AUTH_TOKEN", "test_token") + monkeypatch.setattr(server, "PUBLIC_WEBHOOK_URL", "https://example.com/webhook") + monkeypatch.setattr(server, "ALLOWED_CHATS", {"other@s.whatsapp.net"}) + monkeypatch.setattr(server, "BOT_WORKING_DIR", str(tmp_path)) + server._init_db(db_path) + c = TestClient(server.app) + + with patch("webhook.server.RequestValidator", _mock_validator(True)): + r = c.post("/webhook", data=FORM_DATA, headers={"X-Twilio-Signature": "sig"}) + + assert r.status_code == 200 + assert r.text.strip() == server.EMPTY_TWIML + + +def test_wrong_signature_returns_403(client): + with patch("webhook.server.RequestValidator", _mock_validator(False)): + r = client.post( + "/webhook", + data=FORM_DATA, + headers={"X-Twilio-Signature": "bad_sig"}, + ) + assert r.status_code == 403 + + +def test_no_auth_token_skips_validation(tmp_path, monkeypatch): + db_path = str(tmp_path / "test.db") + monkeypatch.setattr(server, "WEBHOOK_DB_PATH", db_path) + monkeypatch.setattr(server, "TWILIO_AUTH_TOKEN", None) + monkeypatch.setattr(server, "ALLOWED_CHATS", set()) + monkeypatch.setattr(server, "BOT_WORKING_DIR", str(tmp_path)) + server._init_db(db_path) + c = TestClient(server.app) + + with patch("subprocess.run", return_value=_mock_claude("Reply")): + r = c.post("/webhook", data=FORM_DATA) + + assert r.status_code == 200 + assert "Reply" in r.text + + +def test_claude_timeout_returns_empty_response(client): + with ( + patch("webhook.server.RequestValidator", _mock_validator(True)), + patch( + "subprocess.run", + side_effect=subprocess.TimeoutExpired(cmd="claude", timeout=60), + ), + ): + r = client.post( + "/webhook", + data=FORM_DATA, + headers={"X-Twilio-Signature": "sig"}, + ) + assert r.status_code == 200 + assert r.text.strip() == server.EMPTY_TWIML + + +def test_health_endpoint(): + c = TestClient(server.app) + r = c.get("/health") + assert r.status_code == 200 + assert r.json() == {"status": "ok"} diff --git a/webhook/__init__.py b/webhook/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/webhook/server.py b/webhook/server.py new file mode 100644 index 0000000..569f1f6 --- /dev/null +++ b/webhook/server.py @@ -0,0 +1,213 @@ +import logging +import os +import sqlite3 +import subprocess +import uuid +from datetime import datetime, timezone +from xml.sax.saxutils import escape + +from fastapi import FastAPI, HTTPException, Request, Response +from twilio.request_validator import RequestValidator + +log = logging.getLogger(__name__) +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s") + +TWILIO_AUTH_TOKEN: str | None = os.getenv("TWILIO_AUTH_TOKEN") +PUBLIC_WEBHOOK_URL: str = os.getenv("PUBLIC_WEBHOOK_URL", "") +WEBHOOK_DB_PATH: str = os.getenv("WEBHOOK_DB_PATH", "webhook_messages.db") +HISTORY_MESSAGES: int = int(os.getenv("HISTORY_MESSAGES", "10")) +BOT_WORKING_DIR: str = os.getenv("BOT_WORKING_DIR", ".") +CLAUDE_TIMEOUT: int = int(os.getenv("CLAUDE_TIMEOUT_SECONDS", "60")) +_allowed_raw = os.getenv("ALLOWED_CHATS", "") +ALLOWED_CHATS: set[str] = ( + {c.strip() for c in _allowed_raw.split(",") if c.strip()} if _allowed_raw else set() +) + +EMPTY_TWIML = '' + +app = FastAPI() + + +def _init_db(path: str) -> None: + con = sqlite3.connect(path) + con.execute( + """ + CREATE TABLE IF NOT EXISTS messages ( + id TEXT PRIMARY KEY, + chat_jid TEXT, + sender TEXT, + content TEXT, + timestamp INTEGER, + is_from_me INTEGER + ) + """ + ) + con.commit() + con.close() + + +def _normalize_jid(from_field: str) -> str: + num = from_field.removeprefix("whatsapp:").lstrip("+") + return f"{num}@s.whatsapp.net" + + +def _fmt_msg(msg: dict) -> str: + ts = datetime.fromtimestamp(float(msg["timestamp"]), tz=timezone.utc).strftime( + "%Y-%m-%d %H:%M:%S" + ) + role = "me" if msg["is_from_me"] else msg.get("sender", "them") + return f"[{ts}] {role}: {msg.get('content', '')}" + + +def _build_prompt(recipient_jid: str, history: list[dict], new_msg: dict) -> str: + n = HISTORY_MESSAGES + history_text = "\n".join(_fmt_msg(m) for m in history) if history else "(no prior history)" + new_text = _fmt_msg(new_msg) + return ( + f"You are a WhatsApp assistant. A new message arrived. " + f"Print your reply as plain text to stdout. Do not call any MCP tools.\n\n" + f"Recipient: {recipient_jid}\n\n" + f"Recent conversation (last {n} messages):\n{history_text}\n\n" + f"New message: {new_text}" + ) + + +def _insert_message( + path: str, + msg_id: str, + chat_jid: str, + sender: str, + content: str, + timestamp: int, + is_from_me: int, +) -> None: + con = sqlite3.connect(path) + con.execute( + "INSERT OR IGNORE INTO messages (id, chat_jid, sender, content, timestamp, is_from_me)" + " VALUES (?, ?, ?, ?, ?, ?)", + (msg_id, chat_jid, sender, content, timestamp, is_from_me), + ) + con.commit() + con.close() + + +def _query_history(path: str, chat_jid: str, exclude_id: str) -> list[dict]: + con = sqlite3.connect(path) + rows = con.execute( + """ + SELECT id, chat_jid, sender, content, timestamp, is_from_me + FROM messages + WHERE chat_jid = ? AND id != ? + ORDER BY timestamp DESC + LIMIT ? + """, + (chat_jid, exclude_id, HISTORY_MESSAGES), + ).fetchall() + con.close() + rows.reverse() + return [ + { + "id": r[0], + "chat_jid": r[1], + "sender": r[2], + "content": r[3], + "timestamp": r[4], + "is_from_me": r[5], + } + for r in rows + ] + + +def _twiml_message(body: str) -> str: + return f'{escape(body)}' + + +@app.on_event("startup") +async def startup() -> None: + _init_db(WEBHOOK_DB_PATH) + + +@app.get("/health") +async def health() -> dict: + return {"status": "ok"} + + +@app.post("/webhook") +async def webhook(request: Request) -> Response: + form_data = dict(await request.form()) + + if TWILIO_AUTH_TOKEN: + signature = request.headers.get("X-Twilio-Signature", "") + validator = RequestValidator(TWILIO_AUTH_TOKEN) + if not validator.validate(PUBLIC_WEBHOOK_URL, form_data, signature): + raise HTTPException(status_code=403, detail="Invalid Twilio signature") + else: + log.warning("TWILIO_AUTH_TOKEN is not set — skipping signature validation") + + from_field = str(form_data.get("From", "")) + body = str(form_data.get("Body", "")) + chat_jid = _normalize_jid(from_field) + + if ALLOWED_CHATS and chat_jid not in ALLOWED_CHATS: + log.info("Ignoring message from disallowed JID: %s", chat_jid) + return Response(content=EMPTY_TWIML, media_type="application/xml") + + now = int(datetime.now(tz=timezone.utc).timestamp()) + msg_id = str(uuid.uuid4()) + _insert_message(WEBHOOK_DB_PATH, msg_id, chat_jid, chat_jid, body, now, 0) + + history = _query_history(WEBHOOK_DB_PATH, chat_jid, msg_id) + inbound = { + "id": msg_id, + "chat_jid": chat_jid, + "sender": chat_jid, + "content": body, + "timestamp": now, + "is_from_me": 0, + } + prompt = _build_prompt(chat_jid, history, inbound) + + try: + result = subprocess.run( + ["claude", "-p", prompt], + cwd=BOT_WORKING_DIR, + capture_output=True, + text=True, + timeout=CLAUDE_TIMEOUT, + ) + if result.returncode != 0: + log.error("claude exited %d: %s", result.returncode, result.stderr.strip()[:400]) + return Response(content=EMPTY_TWIML, media_type="application/xml") + reply = result.stdout.strip() + except subprocess.TimeoutExpired: + log.error("claude timed out after %ds", CLAUDE_TIMEOUT) + return Response(content=EMPTY_TWIML, media_type="application/xml") + except FileNotFoundError: + log.error("`claude` CLI not found — is it on PATH?") + return Response(content=EMPTY_TWIML, media_type="application/xml") + except Exception as e: + log.error("Unexpected error calling claude: %s", e) + return Response(content=EMPTY_TWIML, media_type="application/xml") + + if not reply: + log.error("claude returned empty stdout") + return Response(content=EMPTY_TWIML, media_type="application/xml") + + out_id = str(uuid.uuid4()) + _insert_message( + WEBHOOK_DB_PATH, + out_id, + chat_jid, + "me", + reply, + int(datetime.now(tz=timezone.utc).timestamp()), + 1, + ) + + return Response(content=_twiml_message(reply), media_type="application/xml") + + +if __name__ == "__main__": + import uvicorn + + uvicorn.run(app, host=os.getenv("WEBHOOK_HOST", "0.0.0.0"), port=int(os.getenv("WEBHOOK_PORT", "8000"))) From d91467e724d0d16eb366cca0c702aa939b780946 Mon Sep 17 00:00:00 2001 From: Fernando Baz Date: Wed, 22 Apr 2026 14:34:35 -0600 Subject: [PATCH 2/5] fix: tighten twilio webhook transport setup --- CLAUDE.md | 6 ++++-- install.sh | 2 ++ tests/test_webhook.py | 6 +++++- webhook/server.py | 10 ++++++++-- 4 files changed, 19 insertions(+), 5 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 4105e86..17c0448 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,9 +9,11 @@ You are a helpful WhatsApp assistant powered by Claude. - If you do not know something, say so clearly rather than guessing. - Never fabricate facts, links, or phone numbers. -## Sending replies +## Transport mode -**Always** send your reply using the `send_message` MCP tool addressed to the recipient JID provided in the prompt. Do not print the reply as plain text — the poller does not read stdout as a reply. +- **Bridge mode**: use the `send_message` MCP tool to reply. This is the default/current behavior. +- **Webhook mode**: print your reply as plain text to stdout. The webhook server captures it and sends it back to Twilio. Do **not** call `send_message` in webhook mode. +- The prompt will explicitly state which mode is active. ## Available MCP tools diff --git a/install.sh b/install.sh index 35b1c9f..424536d 100755 --- a/install.sh +++ b/install.sh @@ -248,6 +248,8 @@ main() { ensure_env_file if [ "$TRANSPORT" = "twilio" ]; then + [ -n "${TWILIO_AUTH_TOKEN:-}" ] || err "TWILIO_AUTH_TOKEN must be set in .env for --transport twilio" + [ -n "${PUBLIC_WEBHOOK_URL:-}" ] || err "PUBLIC_WEBHOOK_URL must be set in .env for --transport twilio" setup_webhook_venv install_webhook_service start_webhook_service diff --git a/tests/test_webhook.py b/tests/test_webhook.py index 15d507b..4692dbc 100644 --- a/tests/test_webhook.py +++ b/tests/test_webhook.py @@ -65,11 +65,15 @@ def test_valid_post_disallowed_jid_returns_empty(tmp_path, monkeypatch): server._init_db(db_path) c = TestClient(server.app) - with patch("webhook.server.RequestValidator", _mock_validator(True)): + with ( + patch("webhook.server.RequestValidator", _mock_validator(True)), + patch("subprocess.run") as mock_run, + ): r = c.post("/webhook", data=FORM_DATA, headers={"X-Twilio-Signature": "sig"}) assert r.status_code == 200 assert r.text.strip() == server.EMPTY_TWIML + mock_run.assert_not_called() def test_wrong_signature_returns_403(client): diff --git a/webhook/server.py b/webhook/server.py index 569f1f6..b5e75a7 100644 --- a/webhook/server.py +++ b/webhook/server.py @@ -119,7 +119,11 @@ def _query_history(path: str, chat_jid: str, exclude_id: str) -> list[dict]: def _twiml_message(body: str) -> str: - return f'{escape(body)}' + escaped_body = escape(body) + return ( + '' + f"{escaped_body}" + ) @app.on_event("startup") @@ -210,4 +214,6 @@ async def webhook(request: Request) -> Response: if __name__ == "__main__": import uvicorn - uvicorn.run(app, host=os.getenv("WEBHOOK_HOST", "0.0.0.0"), port=int(os.getenv("WEBHOOK_PORT", "8000"))) + uvicorn.run( + app, host=os.getenv("WEBHOOK_HOST", "0.0.0.0"), port=int(os.getenv("WEBHOOK_PORT", "8000")) + ) From 6d4737c763ea582e43d254c8a9e3ecabf48429c9 Mon Sep 17 00:00:00 2001 From: Fernando Baz Date: Wed, 22 Apr 2026 14:36:46 -0600 Subject: [PATCH 3/5] fix: align ci and claude transport docs --- .github/workflows/ci.yml | 3 +++ CLAUDE.md | 7 ++++--- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 32edaac..1b6d6e0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,6 +21,9 @@ jobs: - name: Install tooling and dependencies run: pip install -r requirements.txt + - name: Install webhook dependencies + run: pip install -r webhook/requirements.txt + - name: Lint (ruff check) run: ruff check . diff --git a/CLAUDE.md b/CLAUDE.md index 17c0448..a300916 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -26,7 +26,8 @@ You are a helpful WhatsApp assistant powered by Claude. ## Workflow -1. Read the prompt — it includes the recipient JID, recent conversation history, and the new incoming message(s). +1. Read the prompt — it includes the transport mode, recipient JID, recent conversation history, and the new incoming message(s). 2. Compose a reply appropriate to the context. -3. Call `send_message` with the recipient JID and your reply text. -4. Done. Do not send multiple messages unless the content clearly warrants it. +3. **Bridge mode**: call `send_message` with the recipient JID and your reply text. + **Webhook mode**: print your reply as plain text to stdout, then stop. +4. Do not send multiple messages unless the content clearly warrants it. From 53b8bdab25665d110ecf2a416667307bc5bc4856 Mon Sep 17 00:00:00 2001 From: Fernando Baz Date: Wed, 22 Apr 2026 14:39:16 -0600 Subject: [PATCH 4/5] fix: add webhook dependency manifest --- install.sh | 3 ++- webhook/requirements.txt | 4 ++++ 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 webhook/requirements.txt diff --git a/install.sh b/install.sh index 424536d..e125d1e 100755 --- a/install.sh +++ b/install.sh @@ -96,7 +96,7 @@ setup_webhook_venv() { echo "Setting up webhook virtualenv..." python3 -m venv "$WEBHOOK_VENV_DIR" "$WEBHOOK_VENV_DIR/bin/pip" install --upgrade pip - "$WEBHOOK_VENV_DIR/bin/pip" install "fastapi>=0.110" "uvicorn[standard]>=0.27" "twilio>=8.0" + "$WEBHOOK_VENV_DIR/bin/pip" install "fastapi>=0.111" "uvicorn[standard]>=0.29" "twilio>=9.0" "python-multipart>=0.0.9" } write_service() { @@ -174,6 +174,7 @@ WantedBy=default.target" } install_webhook_service() { + # Webhook prompts explicitly override CLAUDE.md transport mode and require stdout replies. local bot_dir="${BOT_WORKING_DIR:-$REPO_ROOT}" write_service "whatsapp-webhook" "[Unit] diff --git a/webhook/requirements.txt b/webhook/requirements.txt new file mode 100644 index 0000000..fed8c55 --- /dev/null +++ b/webhook/requirements.txt @@ -0,0 +1,4 @@ +fastapi>=0.111 +uvicorn[standard]>=0.29 +twilio>=9.0 +python-multipart>=0.0.9 From a4b6b93e853d72e5a62150e85f0459d5f0c46388 Mon Sep 17 00:00:00 2001 From: Fernando Baz Date: Wed, 22 Apr 2026 14:41:37 -0600 Subject: [PATCH 5/5] fix: use webhook-specific Claude instructions --- install.sh | 1 - webhook/CLAUDE.md | 14 ++++++++++++++ webhook/server.py | 4 +++- 3 files changed, 17 insertions(+), 2 deletions(-) create mode 100644 webhook/CLAUDE.md diff --git a/install.sh b/install.sh index e125d1e..0bd2066 100755 --- a/install.sh +++ b/install.sh @@ -174,7 +174,6 @@ WantedBy=default.target" } install_webhook_service() { - # Webhook prompts explicitly override CLAUDE.md transport mode and require stdout replies. local bot_dir="${BOT_WORKING_DIR:-$REPO_ROOT}" write_service "whatsapp-webhook" "[Unit] diff --git a/webhook/CLAUDE.md b/webhook/CLAUDE.md new file mode 100644 index 0000000..4112b0c --- /dev/null +++ b/webhook/CLAUDE.md @@ -0,0 +1,14 @@ +# WhatsApp Assistant (Webhook / Twilio mode) + +You are a helpful WhatsApp assistant powered by Claude. + +## Behaviour + +- Respond in the same language the user writes in. Default to Spanish if unsure. +- Be concise and friendly. WhatsApp messages should feel natural. +- If you do not know something, say so clearly rather than guessing. +- Never fabricate facts, links, or phone numbers. + +## How to reply + +Print your reply as plain text to stdout. Do not call any MCP tools. Do not use `send_message`. The webhook server captures your stdout and sends it back to the user via Twilio. diff --git a/webhook/server.py b/webhook/server.py index b5e75a7..648fa10 100644 --- a/webhook/server.py +++ b/webhook/server.py @@ -1,5 +1,6 @@ import logging import os +import pathlib import sqlite3 import subprocess import uuid @@ -12,11 +13,12 @@ log = logging.getLogger(__name__) logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s") +_WEBHOOK_DIR = str(pathlib.Path(__file__).parent) TWILIO_AUTH_TOKEN: str | None = os.getenv("TWILIO_AUTH_TOKEN") PUBLIC_WEBHOOK_URL: str = os.getenv("PUBLIC_WEBHOOK_URL", "") WEBHOOK_DB_PATH: str = os.getenv("WEBHOOK_DB_PATH", "webhook_messages.db") HISTORY_MESSAGES: int = int(os.getenv("HISTORY_MESSAGES", "10")) -BOT_WORKING_DIR: str = os.getenv("BOT_WORKING_DIR", ".") +BOT_WORKING_DIR: str = os.getenv("BOT_WORKING_DIR", _WEBHOOK_DIR) CLAUDE_TIMEOUT: int = int(os.getenv("CLAUDE_TIMEOUT_SECONDS", "60")) _allowed_raw = os.getenv("ALLOWED_CHATS", "") ALLOWED_CHATS: set[str] = (