mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
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:
parent
71c6b1ee99
commit
ef67037f8e
13 changed files with 645 additions and 4 deletions
282
gateway/platforms/sms.py
Normal file
282
gateway/platforms/sms.py
Normal 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"
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue