Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
23 changes: 23 additions & 0 deletions docs/06-cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,28 @@ wasila gateway add customer webhook

Enables the first generic HTTP webhook gateway for customer conversations.

Now supported customer gateway types are:

```bash
wasila gateway add customer webhook
wasila gateway add customer telegram
wasila gateway add customer whatsapp
```

When customer gateway type is `telegram` or `whatsapp`, daemon also accepts dedicated endpoints:

```bash
POST /webhook/telegram
POST /webhook/whatsapp
```

and fallback endpoint:

```bash
POST /webhook/customer
POST /customer
```

### Add Owner Gateway

```bash
Expand All @@ -60,6 +82,7 @@ wasila gateway add owner hermes
```

OpenClaw and Hermes are configured as dedicated gateway types and currently resolve through the same webhook transport for bootstrap compatibility.
Customer gateway expansion (`telegram`, `whatsapp`) is now supported in Stage 2 and currently shares the same webhook transport for bootstrap compatibility.

### Start Daemon

Expand Down
28 changes: 22 additions & 6 deletions src/wasila/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,11 @@ def build_parser() -> argparse.ArgumentParser:
gateway_sub = gateway_parser.add_subparsers(dest="gateway_command", required=True)
gateway_add = gateway_sub.add_parser("add", help="Set gateway type and metadata.")
gateway_add.add_argument("role", choices=["customer", "owner"])
gateway_add.add_argument("type", choices=["webhook", "openclaw", "hermes"])
gateway_add.add_argument(
"type",
choices=["webhook", "telegram", "whatsapp", "openclaw", "hermes"],
help="Gateway type to use for the selected role.",
)
gateway_add.add_argument("--metadata", action="append", default=[], help="KEY=VALUE metadata")
gateway_add.set_defaults(func=handle_gateway_add)

Expand Down Expand Up @@ -163,8 +167,10 @@ def handle_provider_set(args: argparse.Namespace) -> None:
def handle_gateway_add(args: argparse.Namespace) -> None:
config = _load_or_default(args.config)
metadata = _parse_metadata_args(args.metadata)
if args.role == "customer" and args.type not in {"webhook"}:
raise SystemExit("customer gateway currently only supports webhook in Stage 1")
if args.role == "customer" and args.type not in {"webhook", "telegram", "whatsapp"}:
raise SystemExit("customer gateway only supports webhook, telegram, or whatsapp")
if args.role == "owner" and args.type not in {"webhook", "openclaw", "hermes"}:
raise SystemExit("owner gateway only supports webhook, openclaw, or hermes")
if args.role == "customer":
config.customer_gateway = GatewayConfig(
type=args.type,
Expand All @@ -190,8 +196,8 @@ def handle_daemon_start(args: argparse.Namespace) -> None:
config.customer_gateway.metadata,
)

def process(event_payload: dict[str, Any]) -> dict[str, Any]:
event = gateway.normalize(event_payload)
def process(event_payload: dict[str, Any] | CustomerEvent) -> dict[str, Any]:
event = event_payload if isinstance(event_payload, CustomerEvent) else gateway.normalize(event_payload)
result = workflow.run(event)
return {
"customer_response": result.customer_response,
Expand All @@ -200,7 +206,17 @@ def process(event_payload: dict[str, Any]) -> dict[str, Any]:
"gateway": event.gateway,
}

daemon = WebhookDaemon(handler=process, gateway=gateway, host=args.host, port=args.port)
route_gateways = {}
if gateway.name in {"telegram", "whatsapp"}:
route_gateways[f"/webhook/{gateway.name}"] = gateway

daemon = WebhookDaemon(
handler=process,
gateway=gateway,
route_gateways=route_gateways,
host=args.host,
port=args.port,
)
daemon.start()


Expand Down
176 changes: 174 additions & 2 deletions src/wasila/gateways/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,179 @@

from __future__ import annotations

from typing import Any

from wasila.core.contracts import CustomerEvent
from wasila.core.ports import CustomerGateway, OwnerGateway

from wasila.gateways.webhook import WebhookCustomerGateway, WebhookOwnerGateway


class TelegramCustomerGateway(WebhookCustomerGateway):
"""Customer gateway adapter for Telegram payloads.

Stage 2 keeps Telegram transport compatible with webhook payload ingestion.
"""

def __init__(self, metadata: dict[str, str] | None = None) -> None:
super().__init__(metadata=metadata)
self.name = "telegram"

def normalize(self, payload: dict) -> CustomerEvent:
message = payload.get("message")
raw_text = message if isinstance(message, str) else (
message.get("text")
if isinstance(message, dict)
else ""
)
if raw_text == "" and isinstance(message, dict):
raw_text = message.get("body") or message.get("caption") or ""
if raw_text == "":
raw_text = payload.get("text") or payload.get("body") or ""

if not isinstance(raw_text, str):
raw_text = ""

sender = payload.get("from")
if not isinstance(sender, dict):
sender = payload.get("sender")
Comment on lines +36 to +38
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 Parse Telegram sender from nested message payload

Telegram webhook updates carry sender info under message.from, but this normalization only looks for top-level from/sender, so sender_id stays empty for standard Telegram payloads unless callers prefill external_customer_id. In that case Workflow.run passes an empty external customer id into ensure_customer, which creates a new random customer record instead of reusing the prior one, breaking conversation continuity and customer history for repeated messages from the same Telegram user.

Useful? React with 👍 / 👎.

if not isinstance(sender, dict) and isinstance(message, dict):
sender = message.get("from")

event_id = payload.get("event_id")
if event_id is not None and not isinstance(event_id, str):
event_id = str(event_id)
Comment on lines +42 to +44
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 Use Telegram update_id when normalizing event IDs

This normalization only reads payload["event_id"], but native Telegram updates provide uniqueness via fields like update_id (and message date under message.date). As written, typical Telegram payloads produce event.id == None and empty timestamp, so repeated identical texts from the same sender collapse to the same _build_dedup_event_id and later messages can be dropped as duplicates by find_message_by_event.

Useful? React with 👍 / 👎.

sender_id = ""
sender_name = ""
if isinstance(sender, dict):
sender_id = str(
sender.get("id")
or sender.get("username")
or sender.get("first_name")
or ""
)
sender_name = str(sender.get("first_name") or sender.get("username") or "")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

Potential AttributeError if the chat key exists in the payload but its value is explicitly None. In such cases, payload.get("chat", {}) returns None, and calling .get("id") on it will fail. Using (payload.get("chat") or {}) is a safer pattern to handle both missing keys and explicit None values.

Suggested change
external_conversation_id=str((payload.get("chat") or {}).get("id") or payload.get("conversation_id") or ""),

return CustomerEvent(
gateway=payload.get("gateway", self.metadata.get("id", "telegram")),
gateway_role=payload.get("gateway_role", "customer"),
external_conversation_id=str(
payload.get("external_conversation_id")
or (payload.get("chat") or {}).get("id")
or payload.get("conversation_id")
Comment on lines +60 to +62
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 Extract Telegram chat ID from nested message.chat

TelegramCustomerGateway.normalize derives external_conversation_id from top-level chat/conversation_id, but Telegram webhook updates place chat identity under message.chat.id. For standard Telegram payloads this leaves external_conversation_id empty, and Workflow.run then falls back to external_customer_id, which merges different chats (e.g., separate groups/threads for the same sender) into one conversation and corrupts message/ticket grouping.

Useful? React with 👍 / 👎.

or ""
),
external_customer_id=str(payload.get("external_customer_id") or payload.get("customer_id") or sender_id or ""),
message_text=raw_text,
message_timestamp=payload.get("message_timestamp") or "",
id=event_id,
customer_id=payload.get("customer_id"),
metadata_json={
"name": sender_name or payload.get("name") or payload.get("display_name"),
"source": "telegram",
"raw": payload,
},
)


class WhatsAppCustomerGateway(WebhookCustomerGateway):
"""Customer gateway adapter for WhatsApp payloads.

Stage 2 keeps WhatsApp transport compatible with webhook payload ingestion.
"""

def __init__(self, metadata: dict[str, str] | None = None) -> None:
super().__init__(metadata=metadata)
self.name = "whatsapp"

def normalize(self, payload: dict) -> CustomerEvent:
raw_text = payload.get("message") or payload.get("body") or ""
raw_msg: dict | None = None
if isinstance(payload.get("entry"), list) and payload["entry"]:
first_entry = payload["entry"][0]
if isinstance(first_entry, dict):
changes = first_entry.get("changes")
if isinstance(changes, list) and changes:
raw_msg = changes[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.

high

Potential IndexError if value.get("messages") returns an empty list. The isinstance(..., list) check passes for empty lists, but accessing index [0] will crash. It is safer to check if the list is non-empty before accessing its elements.

                        messages = value.get("messages")
                        if isinstance(messages, list) and messages:
                            raw_msg = messages[0]

if isinstance(raw_msg, dict):
value = raw_msg.get("value")
if isinstance(value, dict):
messages = value.get("messages")
if isinstance(messages, list) and messages:
raw_msg = messages[0]
if isinstance(raw_msg, dict):
text_candidate = (
raw_msg.get("text")
if isinstance(raw_msg.get("text"), str)
else (
raw_msg.get("text", {}).get("body")
if isinstance(raw_msg.get("text"), dict)
else None
)
)
if isinstance(text_candidate, str):
raw_text = text_candidate
else:
raw_text = raw_msg.get("message") or raw_msg.get("body") or raw_text

payload["from"] = raw_msg.get("from", first_entry.get("from"))

if isinstance(raw_text, dict):
raw_text = ""
if not isinstance(raw_text, str):
raw_text = str(raw_text)

event_id = payload.get("event_id")
if event_id is not None and not isinstance(event_id, str):
event_id = str(event_id)
Comment on lines +125 to +127
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 Populate WhatsApp event_id from nested message data

After extracting entry[].changes[].value.messages[0], the code still sets event_id only from top-level payload["event_id"] and ignores the native WhatsApp message id in the nested message object. Because _build_dedup_event_id depends on event.id, repeated inbound texts from the same sender can collapse into the same dedup key and be treated as duplicates, dropping legitimate later messages when integrations forward raw WhatsApp webhook payloads.

Useful? React with 👍 / 👎.


sender = payload.get("from")
if isinstance(raw_msg, dict) and isinstance(raw_msg.get("from"), dict):
sender = raw_msg.get("from")

sender_id = ""
sender_name = ""
if isinstance(sender, dict):
sender_id = str(
sender.get("id")
or sender.get("wa_id")
or sender.get("phone")
or sender.get("username")
or ""
)
sender_name = str(sender.get("name") or sender.get("profile_name") or sender_id)
else:
sender_id = str(sender or "")

if event_id is None and isinstance(raw_msg, dict):
msg_event_id = raw_msg.get("id")
if msg_event_id is not None and not isinstance(msg_event_id, str):
msg_event_id = str(msg_event_id)
if msg_event_id is not None:
event_id = msg_event_id

return CustomerEvent(
gateway=payload.get("gateway", self.metadata.get("id", "whatsapp")),
gateway_role=payload.get("gateway_role", "customer"),
external_conversation_id=str(
payload.get("external_conversation_id")
or (raw_msg or {}).get("conversation")
or payload.get("conversation_id")
or payload.get("wa_id")
or sender_id
or ""
),
external_customer_id=str(payload.get("external_customer_id") or payload.get("customer_id") or sender_id or ""),
message_text=raw_text,
message_timestamp=payload.get("message_timestamp") or "",
id=event_id,
customer_id=payload.get("customer_id"),
metadata_json={
"name": sender_name or payload.get("name") or payload.get("display_name"),
"source": "whatsapp",
"raw": payload,
},
)


class OpenClawOwnerGateway(WebhookOwnerGateway):
"""Owner gateway adapter for OpenClaw.

Expand All @@ -35,6 +201,10 @@ def __init__(self, metadata: dict[str, str] | None = None) -> None:
def build_customer_gateway(gateway_type: str, metadata: dict[str, str] | None = None) -> CustomerGateway:
if gateway_type == "webhook":
return WebhookCustomerGateway(metadata=metadata)
if gateway_type == "telegram":
return TelegramCustomerGateway(metadata=metadata)
if gateway_type == "whatsapp":
return WhatsAppCustomerGateway(metadata=metadata)
raise ValueError(f"unsupported customer gateway type: {gateway_type}")


Expand All @@ -52,6 +222,8 @@ def build_owner_gateway(gateway_type: str, metadata: dict[str, str] | None = Non
__all__ = [
"WebhookCustomerGateway",
"WebhookOwnerGateway",
"TelegramCustomerGateway",
"WhatsAppCustomerGateway",
"OpenClawOwnerGateway",
"HermesOwnerGateway",
"build_customer_gateway",
Expand Down
Loading