feat: add SMS (Telnyx) platform adapter

Implement SMS as a first-class messaging platform following
ADDING_A_PLATFORM.md checklist. All 16 integration points covered:

- gateway/platforms/sms.py: Core adapter with aiohttp webhook server,
  Telnyx REST API send, markdown stripping, 1600-char chunking,
  echo loop prevention, multi-number reply-from tracking
- gateway/config.py: Platform.SMS enum + env override block
- gateway/run.py: Adapter factory + auth maps (SMS_ALLOWED_USERS,
  SMS_ALLOW_ALL_USERS)
- toolsets.py: hermes-sms toolset + included in hermes-gateway
- cron/scheduler.py: SMS in platform_map for cron delivery
- tools/send_message_tool.py: SMS routing + _send_sms() standalone sender
- tools/cronjob_tools.py: 'sms' in deliver description
- gateway/channel_directory.py: SMS in session-based discovery
- agent/prompt_builder.py: SMS platform hint (plain text, concise)
- hermes_cli/status.py: SMS in platforms status display
- hermes_cli/gateway.py: SMS in setup wizard with Telnyx instructions
- pyproject.toml: sms optional dependency group (aiohttp>=3.9.0)
- tests/gateway/test_sms.py: Unit tests for config, format, truncate,
  echo prevention, requirements, toolset integration

Co-authored-by: sunsakis <teo@sunsakis.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Teknium 2026-03-17 02:52:34 -07:00 committed by GitHub
parent 71c6b1ee99
commit ef67037f8e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 645 additions and 4 deletions

282
gateway/platforms/sms.py Normal file
View file

@ -0,0 +1,282 @@
"""SMS (Telnyx) platform adapter.
Connects to the Telnyx REST API for outbound SMS and runs an aiohttp
webhook server to receive inbound messages.
Requires:
- aiohttp installed: pip install 'hermes-agent[sms]'
- TELNYX_API_KEY environment variable set
- TELNYX_FROM_NUMBERS: comma-separated E.164 numbers (e.g. +15551234567)
"""
import asyncio
import json
import logging
import os
import re
from typing import Any, Dict, List, Optional
from gateway.config import Platform, PlatformConfig
from gateway.platforms.base import (
BasePlatformAdapter,
MessageEvent,
MessageType,
SendResult,
)
logger = logging.getLogger(__name__)
TELNYX_BASE = "https://api.telnyx.com/v2"
MAX_SMS_LENGTH = 1600 # ~10 SMS segments
DEFAULT_WEBHOOK_PORT = 8080
# E.164 phone number pattern for redaction
_PHONE_RE = re.compile(r"\+[1-9]\d{6,14}")
def _redact_phone(phone: str) -> str:
"""Redact a phone number for logging: +15551234567 -> +155****4567."""
if not phone:
return "<none>"
if len(phone) <= 8:
return phone[:2] + "****" + phone[-2:] if len(phone) > 4 else "****"
return phone[:4] + "****" + phone[-4:]
def _parse_comma_list(value: str) -> List[str]:
"""Split a comma-separated string into a list, stripping whitespace."""
return [v.strip() for v in value.split(",") if v.strip()]
def check_sms_requirements() -> bool:
"""Check if SMS adapter dependencies are available."""
try:
import aiohttp # noqa: F401
except ImportError:
return False
return bool(os.getenv("TELNYX_API_KEY"))
class SmsAdapter(BasePlatformAdapter):
"""
Telnyx SMS <-> Hermes gateway adapter.
Each inbound phone number gets its own Hermes session (multi-tenant).
Tracks which owned number received each user's message to reply from
the same number.
"""
def __init__(self, config: PlatformConfig):
super().__init__(config, Platform.SMS)
self._api_key: str = os.environ["TELNYX_API_KEY"]
self._webhook_port: int = int(
os.getenv("SMS_WEBHOOK_PORT", str(DEFAULT_WEBHOOK_PORT))
)
# Set of owned numbers
self._from_numbers: set = set(
_parse_comma_list(os.getenv("TELNYX_FROM_NUMBERS", ""))
)
# Runtime map: user phone -> which owned number to reply from
self._reply_from: Dict[str, str] = {}
self._runner = None
# ------------------------------------------------------------------
# Required abstract methods
# ------------------------------------------------------------------
async def connect(self) -> bool:
import aiohttp
from aiohttp import web
app = web.Application()
app.router.add_post("/webhooks/telnyx", 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, "0.0.0.0", self._webhook_port)
await site.start()
self._running = True
from_display = ", ".join(_redact_phone(n) for n in self._from_numbers) or "(none)"
logger.info(
"[sms] Webhook server listening on port %d, from numbers: %s",
self._webhook_port,
from_display,
)
return True
async def disconnect(self) -> None:
if self._runner:
await self._runner.cleanup()
self._runner = None
self._running = False
logger.info("[sms] Disconnected")
async def send(
self,
chat_id: str,
content: str,
reply_to: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> SendResult:
import aiohttp
from_number = self._get_reply_from(chat_id, metadata)
formatted = self.format_message(content)
chunks = self.truncate_message(formatted)
last_result = SendResult(success=True)
async with aiohttp.ClientSession() as session:
for i, chunk in enumerate(chunks):
payload = {"from": from_number, "to": chat_id, "text": chunk}
headers = {
"Authorization": f"Bearer {self._api_key}",
"Content-Type": "application/json",
}
try:
async with session.post(
f"{TELNYX_BASE}/messages",
json=payload,
headers=headers,
) as resp:
body = await resp.json()
if resp.status >= 400:
logger.error(
"[sms] send failed %s: %s %s",
_redact_phone(chat_id),
resp.status,
body,
)
return SendResult(
success=False,
error=f"Telnyx {resp.status}: {body}",
)
msg_id = body.get("data", {}).get("id", "")
last_result = SendResult(success=True, message_id=msg_id)
except Exception as e:
logger.error("[sms] send error %s: %s", _redact_phone(chat_id), e)
return SendResult(success=False, error=str(e))
return last_result
async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
return {"name": chat_id, "type": "dm"}
# ------------------------------------------------------------------
# SMS-specific formatting
# ------------------------------------------------------------------
def format_message(self, content: str) -> str:
"""Strip markdown -- SMS renders it as literal characters."""
content = re.sub(r"\*\*(.+?)\*\*", r"\1", content, flags=re.DOTALL)
content = re.sub(r"\*(.+?)\*", r"\1", content, flags=re.DOTALL)
content = re.sub(r"__(.+?)__", r"\1", content, flags=re.DOTALL)
content = re.sub(r"_(.+?)_", r"\1", content, flags=re.DOTALL)
content = re.sub(r"```[a-z]*\n?", "", content)
content = re.sub(r"`(.+?)`", r"\1", content)
content = re.sub(r"^#{1,6}\s+", "", content, flags=re.MULTILINE)
content = re.sub(r"\[([^\]]+)\]\([^\)]+\)", r"\1", content)
content = re.sub(r"\n{3,}", "\n\n", content)
return content.strip()
def truncate_message(
self, content: str, max_length: int = MAX_SMS_LENGTH
) -> List[str]:
"""Split into <=1600-char chunks (10 SMS segments)."""
if len(content) <= max_length:
return [content]
chunks: List[str] = []
while content:
if len(content) <= max_length:
chunks.append(content)
break
split_at = content.rfind("\n", 0, max_length)
if split_at < max_length // 2:
split_at = content.rfind(" ", 0, max_length)
if split_at < 1:
split_at = max_length
chunks.append(content[:split_at].strip())
content = content[split_at:].strip()
return chunks
# ------------------------------------------------------------------
# Telnyx webhook handler
# ------------------------------------------------------------------
async def _handle_webhook(self, request) -> "aiohttp.web.Response":
from aiohttp import web
try:
raw = await request.read()
body = json.loads(raw.decode("utf-8"))
except Exception as e:
logger.error("[sms] webhook parse error: %s", e)
return web.json_response({"error": "invalid json"}, status=400)
# Only handle inbound messages
if body.get("data", {}).get("event_type") != "message.received":
return web.json_response({"received": True})
payload = body["data"]["payload"]
from_number: str = payload.get("from", {}).get("phone_number", "")
to_list = payload.get("to", [])
to_number: str = to_list[0].get("phone_number", "") if to_list else ""
text: str = payload.get("text", "").strip()
if not from_number or not text:
return web.json_response({"received": True})
# Ignore messages sent FROM one of our own numbers (echo loop prevention)
if from_number in self._from_numbers:
logger.debug("[sms] ignoring echo from own number %s", _redact_phone(from_number))
return web.json_response({"received": True})
# Remember which owned number received this user's message
if to_number and to_number in self._from_numbers:
self._reply_from[from_number] = to_number
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=body,
message_id=payload.get("id"),
)
# Non-blocking: Telnyx expects a fast 200
asyncio.create_task(self.handle_message(event))
return web.json_response({"received": True})
# ------------------------------------------------------------------
# Internal helpers
# ------------------------------------------------------------------
def _get_reply_from(
self, user_phone: str, metadata: Optional[Dict] = None
) -> str:
"""Determine which owned number to send from."""
if metadata and "from_number" in metadata:
return metadata["from_number"]
if user_phone in self._reply_from:
return self._reply_from[user_phone]
if self._from_numbers:
return next(iter(self._from_numbers))
raise RuntimeError(
"No FROM number configured (TELNYX_FROM_NUMBERS) and no prior "
"reply_from mapping for this user"
)