Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
11 changes: 7 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,11 @@ 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: Install webhook dependencies
run: pip install -r webhook/requirements.txt

- name: Lint (ruff check)
run: ruff check .
Expand All @@ -33,5 +36,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
13 changes: 8 additions & 5 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -24,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.
88 changes: 78 additions & 10 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Add python-multipart to webhook venv setup

webhook/server.py handles Twilio payloads with await request.form(), but the Twilio install path only installs FastAPI/Uvicorn/Twilio. In a fresh ./install.sh --transport twilio deployment, inbound webhook requests can fail at form parsing because python-multipart is not installed, which prevents any message from reaching Claude. Please include python-multipart in the webhook venv dependencies (and keep requirements.txt aligned for CI).

Useful? React with 👍 / 👎.

}

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}"
Expand Down Expand Up @@ -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}"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Use a Twilio-specific BOT_WORKING_DIR default

This default points Twilio mode at the repo root, so webhook/server.py runs claude -p under the existing root CLAUDE.md instructions (bridge flow: send via MCP, not stdout). The webhook transport expects the opposite behavior (stdout reply), and treats empty stdout as failure, so default Twilio installs can silently return empty TwiML replies unless operators override BOT_WORKING_DIR manually.

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
Expand All @@ -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 "$@"
18 changes: 12 additions & 6 deletions requirements.txt
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
126 changes: 126 additions & 0 deletions tests/test_webhook.py
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"}
Empty file added webhook/__init__.py
Empty file.
Loading
Loading