"""SMS (Twilio) platform adapter. Outbound SMS via the Twilio REST API; inbound via an aiohttp webhook server. Shares credentials with the optional telephony skill — same env vars: - TWILIO_ACCOUNT_SID - TWILIO_AUTH_TOKEN - TWILIO_PHONE_NUMBER (E.164 from-number, e.g. +15551234567) Gateway-specific env vars: - SMS_WEBHOOK_PORT (default 8080) - SMS_WEBHOOK_HOST (default 127.0.0.1) - SMS_WEBHOOK_URL (public URL for Twilio signature validation — required) - SMS_INSECURE_NO_SIGNATURE (true to disable signature validation — dev only) - SMS_ALLOWED_USERS (comma-separated E.164 phone numbers) - SMS_ALLOW_ALL_USERS (true/false) - SMS_HOME_CHANNEL (phone number for cron delivery) """ import asyncio import base64 import hashlib import hmac import logging import os import re import urllib.parse from typing import Any, Dict, Optional from gateway.config import Platform, PlatformConfig from gateway.platforms.base import ( gateway_trust_env, BasePlatformAdapter, MessageEvent, MessageType, SendResult, ) from gateway.platforms.helpers import redact_phone, strip_markdown from gateway.platforms._shared import get_scoped_secret as _get_scoped_secret logger = logging.getLogger(__name__) TWILIO_API_BASE = "https://api.twilio.com/2010-04-01/Accounts" MAX_SMS_LENGTH = 1600 # ~10 SMS segments DEFAULT_WEBHOOK_PORT = 8080 DEFAULT_WEBHOOK_HOST = "127.0.0.1" _TWILIO_WEBHOOK_MAX_BODY_BYTES = 65_536 # 64 KiB — Twilio payloads are small _EMPTY_TWIML = '' def _twiml_response(status: int = 200): """Empty TwiML reply — replies go out via the REST API, never inline TwiML.""" from aiohttp import web return web.Response(text=_EMPTY_TWIML, content_type="application/xml", status=status) def _basic_auth(account_sid: str, auth_token: str) -> str: """HTTP Basic auth header value for Twilio.""" encoded = base64.b64encode(f"{account_sid}:{auth_token}".encode("ascii")).decode("ascii") return f"Basic {encoded}" def _twilio_form(from_number: str, to_number: str, body: str): """Twilio Messages.json form payload (aiohttp FormData).""" import aiohttp form_data = aiohttp.FormData() form_data.add_field("From", from_number) form_data.add_field("To", to_number) form_data.add_field("Body", body) return form_data def check_sms_requirements() -> bool: """Check if SMS adapter dependencies are available.""" try: import aiohttp # noqa: F401 except ImportError: return False return bool(_get_scoped_secret("TWILIO_ACCOUNT_SID") and _get_scoped_secret("TWILIO_AUTH_TOKEN")) class SmsAdapter(BasePlatformAdapter): """Twilio SMS <-> Hermes gateway adapter. Each inbound phone number gets its own Hermes session; replies are always sent from the configured TWILIO_PHONE_NUMBER. """ MAX_MESSAGE_LENGTH = MAX_SMS_LENGTH def __init__(self, config: PlatformConfig): super().__init__(config, Platform.SMS) self._account_sid: str = _get_scoped_secret("TWILIO_ACCOUNT_SID", "") self._auth_token: str = _get_scoped_secret("TWILIO_AUTH_TOKEN", "") self._from_number: str = os.getenv("TWILIO_PHONE_NUMBER", "") self._webhook_port: int = int(os.getenv("SMS_WEBHOOK_PORT", str(DEFAULT_WEBHOOK_PORT))) self._webhook_host: str = os.getenv("SMS_WEBHOOK_HOST", DEFAULT_WEBHOOK_HOST) self._webhook_url: str = os.getenv("SMS_WEBHOOK_URL", "").strip() self._runner = None self._http_session: Optional["aiohttp.ClientSession"] = None def _basic_auth_header(self) -> str: return _basic_auth(self._account_sid, self._auth_token) # -- Lifecycle ----------------------------------------------------------- async def connect(self, *, is_reconnect: bool = False) -> bool: import aiohttp from aiohttp import web if not self._from_number: msg = "[sms] TWILIO_PHONE_NUMBER not set — cannot send replies" logger.error(msg) self._set_fatal_error("sms_missing_phone_number", msg, retryable=False) return False insecure_no_sig = os.getenv("SMS_INSECURE_NO_SIGNATURE", "").lower() == "true" if not self._webhook_url and not insecure_no_sig: msg = ( "[sms] Refusing to start: SMS_WEBHOOK_URL is required for Twilio " "signature validation. Set it to the public URL configured in your " "Twilio console (e.g. https://example.com/webhooks/twilio). " "For local development without validation, set " "SMS_INSECURE_NO_SIGNATURE=true (NOT recommended for production)." ) logger.error(msg) self._set_fatal_error("sms_missing_webhook_url", msg, retryable=False) return False if insecure_no_sig and not self._webhook_url: logger.warning( "[sms] SMS_INSECURE_NO_SIGNATURE=true — Twilio signature validation " "is DISABLED. Any client that can reach port %d can inject messages. " "Do NOT use this in production.", self._webhook_port, ) # client_max_size bounds every read path (incl. chunked bodies with no # Content-Length) before the handler's own 413 checks run. app = web.Application(client_max_size=_TWILIO_WEBHOOK_MAX_BODY_BYTES) app.router.add_post("/webhooks/twilio", self._handle_webhook) app.router.add_get("/health", lambda _: web.Response(text="ok")) self._runner = web.AppRunner(app) await self._runner.setup() site = web.TCPSite(self._runner, self._webhook_host, self._webhook_port) await site.start() self._http_session = aiohttp.ClientSession( timeout=aiohttp.ClientTimeout(total=30), trust_env=gateway_trust_env(), ) self._running = True logger.info( "[sms] Twilio webhook server listening on %s:%d, from: %s", self._webhook_host, self._webhook_port, redact_phone(self._from_number), ) self._wire_plugin_handlers(None) return True async def disconnect(self) -> None: if self._http_session: await self._http_session.close() self._http_session = None if self._runner: await self._runner.cleanup() self._runner = None self._running = False logger.info("[sms] Disconnected") # -- Outbound ------------------------------------------------------------ async def send( self, chat_id: str, content: str, reply_to: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None, ) -> SendResult: import aiohttp chunks = self.truncate_message(self.format_message(content)) last_result = SendResult(success=True) url = f"{TWILIO_API_BASE}/{self._account_sid}/Messages.json" headers = {"Authorization": self._basic_auth_header()} session = self._http_session or aiohttp.ClientSession( timeout=aiohttp.ClientTimeout(total=30), trust_env=gateway_trust_env(), ) try: for chunk in chunks: form_data = _twilio_form(self._from_number, chat_id, chunk) try: async with session.post(url, data=form_data, headers=headers) as resp: body = await resp.json() if resp.status >= 400: error_msg = body.get("message", str(body)) logger.error( "[sms] send failed to %s: %s %s", redact_phone(chat_id), resp.status, error_msg, ) return SendResult(success=False, error=f"Twilio {resp.status}: {error_msg}") last_result = SendResult(success=True, message_id=body.get("sid", "")) except Exception as e: logger.error("[sms] send error to %s: %s", redact_phone(chat_id), e) return SendResult(success=False, error=str(e)) finally: # Close only a fallback session we created ourselves. if not self._http_session and session: await session.close() return last_result async def get_chat_info(self, chat_id: str) -> Dict[str, Any]: return {"name": chat_id, "type": "dm"} def format_message(self, content: str) -> str: """Strip markdown — SMS renders it as literal characters.""" return strip_markdown(content) # -- Twilio signature validation ----------------------------------------- def _validate_twilio_signature(self, url: str, post_params: dict, signature: str) -> bool: """Validate ``X-Twilio-Signature`` (HMAC-SHA1, base64). Twilio may sign the URL with or without the scheme's default port, so both variants are tried. https://www.twilio.com/docs/usage/security#validating-requests """ if self._check_signature(url, post_params, signature): return True variant = self._port_variant_url(url) return bool(variant and self._check_signature(variant, post_params, signature)) def _check_signature(self, url: str, post_params: dict, signature: str) -> bool: data_to_sign = url + "".join(key + post_params[key] for key in sorted(post_params.keys())) mac = hmac.new(self._auth_token.encode("utf-8"), data_to_sign.encode("utf-8"), hashlib.sha1) computed = base64.b64encode(mac.digest()).decode("utf-8") # Compare as bytes: compare_digest raises TypeError on non-ASCII str, # and the signature is a raw request header. return hmac.compare_digest(computed.encode(), signature.encode()) @staticmethod def _port_variant_url(url: str) -> str | None: """URL with the scheme's default port toggled (added/stripped); None for non-default ports.""" parsed = urllib.parse.urlparse(url) default_port = {"https": 443, "http": 80}.get(parsed.scheme) if default_port is None: return None if parsed.port == default_port: netloc = parsed.hostname elif parsed.port is None: netloc = f"{parsed.hostname}:{default_port}" else: return None return urllib.parse.urlunparse( (parsed.scheme, netloc, parsed.path, parsed.params, parsed.query, parsed.fragment) ) # -- Inbound webhook ----------------------------------------------------- async def _handle_webhook(self, request) -> "aiohttp.web.Response": try: content_length = request.content_length if content_length is not None and content_length > _TWILIO_WEBHOOK_MAX_BODY_BYTES: return _twiml_response(413) raw = await request.read() if len(raw) > _TWILIO_WEBHOOK_MAX_BODY_BYTES: return _twiml_response(413) # Twilio sends form-encoded data, not JSON form = urllib.parse.parse_qs(raw.decode("utf-8"), keep_blank_values=True) except Exception as e: logger.error("[sms] webhook parse error: %s", e) return _twiml_response(400) if self._webhook_url: twilio_sig = request.headers.get("X-Twilio-Signature", "") if not twilio_sig: logger.warning("[sms] Rejected: missing X-Twilio-Signature header") return _twiml_response(403) flat_params = {k: v[0] for k, v in form.items() if v} if not self._validate_twilio_signature(self._webhook_url, flat_params, twilio_sig): logger.warning("[sms] Rejected: invalid Twilio signature") return _twiml_response(403) # parse_qs returns lists from_number = form.get("From", [""])[0].strip() to_number = form.get("To", [""])[0].strip() text = form.get("Body", [""])[0].strip() message_sid = form.get("MessageSid", [""])[0].strip() if not from_number or not text: return _twiml_response() if from_number == self._from_number: # echo prevention logger.debug("[sms] ignoring echo from own number %s", redact_phone(from_number)) return _twiml_response() logger.info( "[sms] inbound from %s -> %s: %s", redact_phone(from_number), redact_phone(to_number), text[:80], ) source = self.build_source( chat_id=from_number, chat_name=from_number, chat_type="dm", user_id=from_number, user_name=from_number, ) event = MessageEvent( text=text, message_type=MessageType.TEXT, source=source, raw_message=form, message_id=message_sid, ) # Non-blocking: Twilio expects a fast response task = asyncio.create_task(self.handle_message(event)) self._background_tasks.add(task) task.add_done_callback(self._background_tasks.discard) return _twiml_response() # -- Plugin registration ----------------------------------------------------- # TWILIO_* env→PlatformConfig seeding stays in core (gateway/config.py). def _strip_markdown_for_sms(message: str) -> str: """Strip markdown — SMS renders it as literal characters.""" message = re.sub(r"\*\*(.+?)\*\*", r"\1", message, flags=re.DOTALL) message = re.sub(r"\*(.+?)\*", r"\1", message, flags=re.DOTALL) message = re.sub(r"__(.+?)__", r"\1", message, flags=re.DOTALL) message = re.sub(r"_(.+?)_", r"\1", message, flags=re.DOTALL) message = re.sub(r"```[a-z]*\n?", "", message) message = re.sub(r"`(.+?)`", r"\1", message) message = re.sub(r"^#{1,6}\s+", "", message, flags=re.MULTILINE) message = re.sub(r"\[([^\]]+)\]\([^\)]+\)", r"\1", message) message = re.sub(r"\n{3,}", "\n\n", message) return message.strip() async def _standalone_send( pconfig, chat_id, message, *, thread_id=None, media_files=None, force_document=False, ): """Out-of-process SMS delivery via the Twilio REST API (standalone_sender_fn contract).""" auth_token = getattr(pconfig, "api_key", None) or _get_scoped_secret("TWILIO_AUTH_TOKEN", "") try: import aiohttp except ImportError: return {"error": "aiohttp not installed. Run: pip install aiohttp"} account_sid = _get_scoped_secret("TWILIO_ACCOUNT_SID", "") from_number = os.getenv("TWILIO_PHONE_NUMBER", "") if not account_sid or not auth_token or not from_number: return {"error": "SMS not configured (TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, TWILIO_PHONE_NUMBER required)"} message = _strip_markdown_for_sms(message) def _redacted_error(text): try: from tools.send_message_tool import _error as _e return _e(text) except Exception: return {"error": text} try: from gateway.platforms.base import resolve_proxy_url, proxy_kwargs_for_aiohttp _sess_kw, _req_kw = proxy_kwargs_for_aiohttp(resolve_proxy_url()) url = f"https://api.twilio.com/2010-04-01/Accounts/{account_sid}/Messages.json" headers = {"Authorization": _basic_auth(account_sid, auth_token)} async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=30), **_sess_kw) as session: form_data = _twilio_form(from_number, chat_id, message) async with session.post(url, data=form_data, headers=headers, **_req_kw) as resp: body = await resp.json() if resp.status >= 400: error_msg = body.get("message", str(body)) return _redacted_error(f"Twilio API error ({resp.status}): {error_msg}") return {"success": True, "platform": "sms", "chat_id": chat_id, "message_id": body.get("sid", "")} except Exception as e: return _redacted_error(f"SMS send failed: {e}") def _is_connected(config) -> bool: """SMS is connected when Twilio credentials are present (bool(TWILIO_ACCOUNT_SID)).""" import hermes_cli.gateway as gateway_mod return bool((gateway_mod.get_env_value("TWILIO_ACCOUNT_SID") or "").strip()) def _build_adapter(config): return SmsAdapter(config) def register(ctx) -> None: """Plugin entry point — called by the Hermes plugin system.""" ctx.register_platform( name="sms", label="SMS (Twilio)", adapter_factory=_build_adapter, check_fn=check_sms_requirements, is_connected=_is_connected, required_env=["TWILIO_ACCOUNT_SID", "TWILIO_AUTH_TOKEN", "TWILIO_PHONE_NUMBER"], install_hint="pip install aiohttp", allowed_users_env="SMS_ALLOWED_USERS", allow_all_env="SMS_ALLOW_ALL_USERS", cron_deliver_env_var="SMS_HOME_CHANNEL", standalone_sender_fn=_standalone_send, max_message_length=MAX_SMS_LENGTH, pii_safe=True, emoji="📱", allow_update_command=True, )