-
Notifications
You must be signed in to change notification settings - Fork 0
feat: add Twilio webhook transport #10
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 3 commits
15b9810
d91467e
6d4737c
53b8bda
a4b6b93
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -7,13 +7,16 @@ 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" | ||
| 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,14 +92,21 @@ 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" | ||
| mkdir -p "$SYSTEMD_USER_DIR" | ||
| 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}" | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This default points Twilio mode at the repo root, so Useful? React with 👍 / 👎. |
||
|
|
||
| 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,60 @@ 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 | ||
| [ -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 | ||
| 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 "$@" | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,126 @@ | ||
| 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 "<Message>Hello back!</Message>" 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)), | ||
| 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): | ||
| 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 "<Message>Reply</Message>" 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"} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
webhook/server.pyhandles Twilio payloads withawait request.form(), but the Twilio install path only installs FastAPI/Uvicorn/Twilio. In a fresh./install.sh --transport twiliodeployment, inbound webhook requests can fail at form parsing becausepython-multipartis not installed, which prevents any message from reaching Claude. Please includepython-multipartin the webhook venv dependencies (and keeprequirements.txtaligned for CI).Useful? React with 👍 / 👎.