feat: add SMS (Twilio) platform adapter
Add SMS as a first-class messaging platform via the Twilio API. Shares credentials with the existing telephony skill — same TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, TWILIO_PHONE_NUMBER env vars. Adapter (gateway/platforms/sms.py): - aiohttp webhook server for inbound (Twilio form-encoded POSTs) - Twilio REST API with Basic auth for outbound - Markdown stripping, smart chunking at 1600 chars - Echo loop prevention, phone number redaction in logs Integration (13 files): - gateway config, run, channel_directory - agent prompt_builder (SMS platform hint) - cron scheduler, cronjob tools - send_message_tool (_send_sms via Twilio API) - toolsets (hermes-sms + hermes-gateway) - gateway setup wizard, status display - pyproject.toml (sms optional extra) - 21 tests Docs: - website/docs/user-guide/messaging/sms.md (full setup guide) - Updated messaging index (architecture, toolsets, security, links) - Updated environment-variables.md reference Inspired by PR #1575 (@sunsakis), rewritten for Twilio.
This commit is contained in:
@@ -161,6 +161,11 @@ PLATFORM_HINTS = {
|
|||||||
"You are a CLI AI Agent. Try not to use markdown but simple text "
|
"You are a CLI AI Agent. Try not to use markdown but simple text "
|
||||||
"renderable inside a terminal."
|
"renderable inside a terminal."
|
||||||
),
|
),
|
||||||
|
"sms": (
|
||||||
|
"You are communicating via SMS. Keep responses concise and use plain text "
|
||||||
|
"only — no markdown, no formatting. SMS messages are limited to ~1600 "
|
||||||
|
"characters, so be brief and direct."
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
CONTEXT_FILE_MAX_CHARS = 20_000
|
CONTEXT_FILE_MAX_CHARS = 20_000
|
||||||
|
|||||||
@@ -132,6 +132,7 @@ def _deliver_result(job: dict, content: str) -> None:
|
|||||||
"whatsapp": Platform.WHATSAPP,
|
"whatsapp": Platform.WHATSAPP,
|
||||||
"signal": Platform.SIGNAL,
|
"signal": Platform.SIGNAL,
|
||||||
"email": Platform.EMAIL,
|
"email": Platform.EMAIL,
|
||||||
|
"sms": Platform.SMS,
|
||||||
}
|
}
|
||||||
platform = platform_map.get(platform_name.lower())
|
platform = platform_map.get(platform_name.lower())
|
||||||
if not platform:
|
if not platform:
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ def build_channel_directory(adapters: Dict[Any, Any]) -> Dict[str, Any]:
|
|||||||
logger.warning("Channel directory: failed to build %s: %s", platform.value, e)
|
logger.warning("Channel directory: failed to build %s: %s", platform.value, e)
|
||||||
|
|
||||||
# Telegram, WhatsApp & Signal can't enumerate chats -- pull from session history
|
# Telegram, WhatsApp & Signal can't enumerate chats -- pull from session history
|
||||||
for plat_name in ("telegram", "whatsapp", "signal", "email"):
|
for plat_name in ("telegram", "whatsapp", "signal", "email", "sms"):
|
||||||
if plat_name not in platforms:
|
if plat_name not in platforms:
|
||||||
platforms[plat_name] = _build_from_sessions(plat_name)
|
platforms[plat_name] = _build_from_sessions(plat_name)
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ class Platform(Enum):
|
|||||||
SIGNAL = "signal"
|
SIGNAL = "signal"
|
||||||
HOMEASSISTANT = "homeassistant"
|
HOMEASSISTANT = "homeassistant"
|
||||||
EMAIL = "email"
|
EMAIL = "email"
|
||||||
|
SMS = "sms"
|
||||||
DINGTALK = "dingtalk"
|
DINGTALK = "dingtalk"
|
||||||
|
|
||||||
|
|
||||||
@@ -232,6 +233,9 @@ class GatewayConfig:
|
|||||||
# Email uses extra dict for config (address + imap_host + smtp_host)
|
# Email uses extra dict for config (address + imap_host + smtp_host)
|
||||||
elif platform == Platform.EMAIL and config.extra.get("address"):
|
elif platform == Platform.EMAIL and config.extra.get("address"):
|
||||||
connected.append(platform)
|
connected.append(platform)
|
||||||
|
# SMS uses api_key (Twilio auth token) — SID checked via env
|
||||||
|
elif platform == Platform.SMS and os.getenv("TWILIO_ACCOUNT_SID"):
|
||||||
|
connected.append(platform)
|
||||||
return connected
|
return connected
|
||||||
|
|
||||||
def get_home_channel(self, platform: Platform) -> Optional[HomeChannel]:
|
def get_home_channel(self, platform: Platform) -> Optional[HomeChannel]:
|
||||||
@@ -564,6 +568,21 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
|
|||||||
name=os.getenv("EMAIL_HOME_ADDRESS_NAME", "Home"),
|
name=os.getenv("EMAIL_HOME_ADDRESS_NAME", "Home"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# SMS (Twilio)
|
||||||
|
twilio_sid = os.getenv("TWILIO_ACCOUNT_SID")
|
||||||
|
if twilio_sid:
|
||||||
|
if Platform.SMS not in config.platforms:
|
||||||
|
config.platforms[Platform.SMS] = PlatformConfig()
|
||||||
|
config.platforms[Platform.SMS].enabled = True
|
||||||
|
config.platforms[Platform.SMS].api_key = os.getenv("TWILIO_AUTH_TOKEN", "")
|
||||||
|
sms_home = os.getenv("SMS_HOME_CHANNEL")
|
||||||
|
if sms_home:
|
||||||
|
config.platforms[Platform.SMS].home_channel = HomeChannel(
|
||||||
|
platform=Platform.SMS,
|
||||||
|
chat_id=sms_home,
|
||||||
|
name=os.getenv("SMS_HOME_CHANNEL_NAME", "Home"),
|
||||||
|
)
|
||||||
|
|
||||||
# Session settings
|
# Session settings
|
||||||
idle_minutes = os.getenv("SESSION_IDLE_MINUTES")
|
idle_minutes = os.getenv("SESSION_IDLE_MINUTES")
|
||||||
if idle_minutes:
|
if idle_minutes:
|
||||||
|
|||||||
261
gateway/platforms/sms.py
Normal file
261
gateway/platforms/sms.py
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
"""SMS (Twilio) platform adapter.
|
||||||
|
|
||||||
|
Connects to the Twilio REST API for outbound SMS and runs an aiohttp
|
||||||
|
webhook server to receive inbound messages.
|
||||||
|
|
||||||
|
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_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 json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import urllib.parse
|
||||||
|
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__)
|
||||||
|
|
||||||
|
TWILIO_API_BASE = "https://api.twilio.com/2010-04-01/Accounts"
|
||||||
|
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 -> +1555***4567."""
|
||||||
|
if not phone:
|
||||||
|
return "<none>"
|
||||||
|
if len(phone) <= 8:
|
||||||
|
return phone[:2] + "***" + phone[-2:] if len(phone) > 4 else "****"
|
||||||
|
return phone[:5] + "***" + phone[-4:]
|
||||||
|
|
||||||
|
|
||||||
|
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("TWILIO_ACCOUNT_SID") and os.getenv("TWILIO_AUTH_TOKEN"))
|
||||||
|
|
||||||
|
|
||||||
|
class SmsAdapter(BasePlatformAdapter):
|
||||||
|
"""
|
||||||
|
Twilio SMS <-> Hermes gateway adapter.
|
||||||
|
|
||||||
|
Each inbound phone number gets its own Hermes session (multi-tenant).
|
||||||
|
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 = os.environ["TWILIO_ACCOUNT_SID"]
|
||||||
|
self._auth_token: str = os.environ["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._runner = None
|
||||||
|
|
||||||
|
def _basic_auth_header(self) -> str:
|
||||||
|
"""Build HTTP Basic auth header value for Twilio."""
|
||||||
|
creds = f"{self._account_sid}:{self._auth_token}"
|
||||||
|
encoded = base64.b64encode(creds.encode("ascii")).decode("ascii")
|
||||||
|
return f"Basic {encoded}"
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Required abstract methods
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def connect(self) -> bool:
|
||||||
|
import aiohttp
|
||||||
|
from aiohttp import web
|
||||||
|
|
||||||
|
if not self._from_number:
|
||||||
|
logger.error("[sms] TWILIO_PHONE_NUMBER not set — cannot send replies")
|
||||||
|
return False
|
||||||
|
|
||||||
|
app = web.Application()
|
||||||
|
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, "0.0.0.0", self._webhook_port)
|
||||||
|
await site.start()
|
||||||
|
self._running = True
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"[sms] Twilio webhook server listening on port %d, from: %s",
|
||||||
|
self._webhook_port,
|
||||||
|
_redact_phone(self._from_number),
|
||||||
|
)
|
||||||
|
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
|
||||||
|
|
||||||
|
formatted = self.format_message(content)
|
||||||
|
chunks = self.truncate_message(formatted)
|
||||||
|
last_result = SendResult(success=True)
|
||||||
|
|
||||||
|
url = f"{TWILIO_API_BASE}/{self._account_sid}/Messages.json"
|
||||||
|
headers = {
|
||||||
|
"Authorization": self._basic_auth_header(),
|
||||||
|
}
|
||||||
|
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
for chunk in chunks:
|
||||||
|
form_data = aiohttp.FormData()
|
||||||
|
form_data.add_field("From", self._from_number)
|
||||||
|
form_data.add_field("To", chat_id)
|
||||||
|
form_data.add_field("Body", 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}",
|
||||||
|
)
|
||||||
|
msg_sid = body.get("sid", "")
|
||||||
|
last_result = SendResult(success=True, message_id=msg_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))
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Twilio webhook handler
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def _handle_webhook(self, request) -> "aiohttp.web.Response":
|
||||||
|
from aiohttp import web
|
||||||
|
|
||||||
|
try:
|
||||||
|
raw = await request.read()
|
||||||
|
# Twilio sends form-encoded data, not JSON
|
||||||
|
form = urllib.parse.parse_qs(raw.decode("utf-8"))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("[sms] webhook parse error: %s", e)
|
||||||
|
return web.Response(
|
||||||
|
text='<?xml version="1.0" encoding="UTF-8"?><Response></Response>',
|
||||||
|
content_type="application/xml",
|
||||||
|
status=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Extract fields (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 web.Response(
|
||||||
|
text='<?xml version="1.0" encoding="UTF-8"?><Response></Response>',
|
||||||
|
content_type="application/xml",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Ignore messages from our own number (echo prevention)
|
||||||
|
if from_number == self._from_number:
|
||||||
|
logger.debug("[sms] ignoring echo from own number %s", _redact_phone(from_number))
|
||||||
|
return web.Response(
|
||||||
|
text='<?xml version="1.0" encoding="UTF-8"?><Response></Response>',
|
||||||
|
content_type="application/xml",
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
||||||
|
asyncio.create_task(self.handle_message(event))
|
||||||
|
|
||||||
|
# Return empty TwiML — we send replies via the REST API, not inline TwiML
|
||||||
|
return web.Response(
|
||||||
|
text='<?xml version="1.0" encoding="UTF-8"?><Response></Response>',
|
||||||
|
content_type="application/xml",
|
||||||
|
)
|
||||||
@@ -848,6 +848,7 @@ class GatewayRunner:
|
|||||||
os.getenv(v)
|
os.getenv(v)
|
||||||
for v in ("TELEGRAM_ALLOWED_USERS", "DISCORD_ALLOWED_USERS",
|
for v in ("TELEGRAM_ALLOWED_USERS", "DISCORD_ALLOWED_USERS",
|
||||||
"WHATSAPP_ALLOWED_USERS", "SLACK_ALLOWED_USERS",
|
"WHATSAPP_ALLOWED_USERS", "SLACK_ALLOWED_USERS",
|
||||||
|
"SMS_ALLOWED_USERS",
|
||||||
"GATEWAY_ALLOWED_USERS")
|
"GATEWAY_ALLOWED_USERS")
|
||||||
)
|
)
|
||||||
_allow_all = os.getenv("GATEWAY_ALLOW_ALL_USERS", "").lower() in ("true", "1", "yes")
|
_allow_all = os.getenv("GATEWAY_ALLOW_ALL_USERS", "").lower() in ("true", "1", "yes")
|
||||||
@@ -1132,6 +1133,13 @@ class GatewayRunner:
|
|||||||
return None
|
return None
|
||||||
return EmailAdapter(config)
|
return EmailAdapter(config)
|
||||||
|
|
||||||
|
elif platform == Platform.SMS:
|
||||||
|
from gateway.platforms.sms import SmsAdapter, check_sms_requirements
|
||||||
|
if not check_sms_requirements():
|
||||||
|
logger.warning("SMS: aiohttp not installed or TWILIO_ACCOUNT_SID/TWILIO_AUTH_TOKEN not set")
|
||||||
|
return None
|
||||||
|
return SmsAdapter(config)
|
||||||
|
|
||||||
elif platform == Platform.DINGTALK:
|
elif platform == Platform.DINGTALK:
|
||||||
from gateway.platforms.dingtalk import DingTalkAdapter, check_dingtalk_requirements
|
from gateway.platforms.dingtalk import DingTalkAdapter, check_dingtalk_requirements
|
||||||
if not check_dingtalk_requirements():
|
if not check_dingtalk_requirements():
|
||||||
@@ -1169,6 +1177,7 @@ class GatewayRunner:
|
|||||||
Platform.SLACK: "SLACK_ALLOWED_USERS",
|
Platform.SLACK: "SLACK_ALLOWED_USERS",
|
||||||
Platform.SIGNAL: "SIGNAL_ALLOWED_USERS",
|
Platform.SIGNAL: "SIGNAL_ALLOWED_USERS",
|
||||||
Platform.EMAIL: "EMAIL_ALLOWED_USERS",
|
Platform.EMAIL: "EMAIL_ALLOWED_USERS",
|
||||||
|
Platform.SMS: "SMS_ALLOWED_USERS",
|
||||||
}
|
}
|
||||||
platform_allow_all_map = {
|
platform_allow_all_map = {
|
||||||
Platform.TELEGRAM: "TELEGRAM_ALLOW_ALL_USERS",
|
Platform.TELEGRAM: "TELEGRAM_ALLOW_ALL_USERS",
|
||||||
@@ -1177,6 +1186,7 @@ class GatewayRunner:
|
|||||||
Platform.SLACK: "SLACK_ALLOW_ALL_USERS",
|
Platform.SLACK: "SLACK_ALLOW_ALL_USERS",
|
||||||
Platform.SIGNAL: "SIGNAL_ALLOW_ALL_USERS",
|
Platform.SIGNAL: "SIGNAL_ALLOW_ALL_USERS",
|
||||||
Platform.EMAIL: "EMAIL_ALLOW_ALL_USERS",
|
Platform.EMAIL: "EMAIL_ALLOW_ALL_USERS",
|
||||||
|
Platform.SMS: "SMS_ALLOW_ALL_USERS",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Per-platform allow-all flag (e.g., DISCORD_ALLOW_ALL_USERS=true)
|
# Per-platform allow-all flag (e.g., DISCORD_ALLOW_ALL_USERS=true)
|
||||||
|
|||||||
@@ -1039,6 +1039,33 @@ _PLATFORMS = [
|
|||||||
"help": "Only emails from these addresses will be processed."},
|
"help": "Only emails from these addresses will be processed."},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"key": "sms",
|
||||||
|
"label": "SMS (Twilio)",
|
||||||
|
"emoji": "📱",
|
||||||
|
"token_var": "TWILIO_ACCOUNT_SID",
|
||||||
|
"setup_instructions": [
|
||||||
|
"1. Create a Twilio account at https://www.twilio.com/",
|
||||||
|
"2. Get your Account SID and Auth Token from the Twilio Console dashboard",
|
||||||
|
"3. Buy or configure a phone number capable of sending SMS",
|
||||||
|
"4. Set up your webhook URL for inbound SMS:",
|
||||||
|
" Twilio Console → Phone Numbers → Active Numbers → your number",
|
||||||
|
" → Messaging → A MESSAGE COMES IN → Webhook → https://your-server:8080/webhooks/twilio",
|
||||||
|
],
|
||||||
|
"vars": [
|
||||||
|
{"name": "TWILIO_ACCOUNT_SID", "prompt": "Twilio Account SID", "password": False,
|
||||||
|
"help": "Found on the Twilio Console dashboard."},
|
||||||
|
{"name": "TWILIO_AUTH_TOKEN", "prompt": "Twilio Auth Token", "password": True,
|
||||||
|
"help": "Found on the Twilio Console dashboard (click to reveal)."},
|
||||||
|
{"name": "TWILIO_PHONE_NUMBER", "prompt": "Twilio phone number (E.164 format, e.g. +15551234567)", "password": False,
|
||||||
|
"help": "The Twilio phone number to send SMS from."},
|
||||||
|
{"name": "SMS_ALLOWED_USERS", "prompt": "Allowed phone numbers (comma-separated, E.164 format)", "password": False,
|
||||||
|
"is_allowlist": True,
|
||||||
|
"help": "Only messages from these phone numbers will be processed."},
|
||||||
|
{"name": "SMS_HOME_CHANNEL", "prompt": "Home channel phone number (for cron/notification delivery, or empty)", "password": False,
|
||||||
|
"help": "Phone number to deliver cron job results and notifications to."},
|
||||||
|
],
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -252,6 +252,7 @@ def show_status(args):
|
|||||||
"Signal": ("SIGNAL_HTTP_URL", "SIGNAL_HOME_CHANNEL"),
|
"Signal": ("SIGNAL_HTTP_URL", "SIGNAL_HOME_CHANNEL"),
|
||||||
"Slack": ("SLACK_BOT_TOKEN", None),
|
"Slack": ("SLACK_BOT_TOKEN", None),
|
||||||
"Email": ("EMAIL_ADDRESS", "EMAIL_HOME_ADDRESS"),
|
"Email": ("EMAIL_ADDRESS", "EMAIL_HOME_ADDRESS"),
|
||||||
|
"SMS": ("TWILIO_ACCOUNT_SID", "SMS_HOME_CHANNEL"),
|
||||||
}
|
}
|
||||||
|
|
||||||
for name, (token_var, home_var) in platforms.items():
|
for name, (token_var, home_var) in platforms.items():
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ pty = [
|
|||||||
honcho = ["honcho-ai>=2.0.1"]
|
honcho = ["honcho-ai>=2.0.1"]
|
||||||
mcp = ["mcp>=1.2.0"]
|
mcp = ["mcp>=1.2.0"]
|
||||||
homeassistant = ["aiohttp>=3.9.0"]
|
homeassistant = ["aiohttp>=3.9.0"]
|
||||||
|
sms = ["aiohttp>=3.9.0"]
|
||||||
acp = ["agent-client-protocol>=0.8.1,<1.0"]
|
acp = ["agent-client-protocol>=0.8.1,<1.0"]
|
||||||
rl = [
|
rl = [
|
||||||
"atroposlib @ git+https://github.com/NousResearch/atropos.git",
|
"atroposlib @ git+https://github.com/NousResearch/atropos.git",
|
||||||
@@ -78,6 +79,7 @@ all = [
|
|||||||
"hermes-agent[honcho]",
|
"hermes-agent[honcho]",
|
||||||
"hermes-agent[mcp]",
|
"hermes-agent[mcp]",
|
||||||
"hermes-agent[homeassistant]",
|
"hermes-agent[homeassistant]",
|
||||||
|
"hermes-agent[sms]",
|
||||||
"hermes-agent[acp]",
|
"hermes-agent[acp]",
|
||||||
"hermes-agent[voice]",
|
"hermes-agent[voice]",
|
||||||
]
|
]
|
||||||
|
|||||||
215
tests/gateway/test_sms.py
Normal file
215
tests/gateway/test_sms.py
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
"""Tests for SMS (Twilio) platform integration.
|
||||||
|
|
||||||
|
Covers config loading, format/truncate, echo prevention,
|
||||||
|
requirements check, and toolset verification.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from gateway.config import Platform, PlatformConfig, HomeChannel
|
||||||
|
|
||||||
|
|
||||||
|
# ── Config loading ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class TestSmsConfigLoading:
|
||||||
|
"""Verify _apply_env_overrides wires SMS correctly."""
|
||||||
|
|
||||||
|
def test_sms_platform_enum_exists(self):
|
||||||
|
assert Platform.SMS.value == "sms"
|
||||||
|
|
||||||
|
def test_env_overrides_create_sms_config(self):
|
||||||
|
from gateway.config import load_gateway_config
|
||||||
|
|
||||||
|
env = {
|
||||||
|
"TWILIO_ACCOUNT_SID": "ACtest123",
|
||||||
|
"TWILIO_AUTH_TOKEN": "token_abc",
|
||||||
|
"TWILIO_PHONE_NUMBER": "+15551234567",
|
||||||
|
}
|
||||||
|
with patch.dict(os.environ, env, clear=False):
|
||||||
|
config = load_gateway_config()
|
||||||
|
assert Platform.SMS in config.platforms
|
||||||
|
pc = config.platforms[Platform.SMS]
|
||||||
|
assert pc.enabled is True
|
||||||
|
assert pc.api_key == "token_abc"
|
||||||
|
|
||||||
|
def test_env_overrides_set_home_channel(self):
|
||||||
|
from gateway.config import load_gateway_config
|
||||||
|
|
||||||
|
env = {
|
||||||
|
"TWILIO_ACCOUNT_SID": "ACtest123",
|
||||||
|
"TWILIO_AUTH_TOKEN": "token_abc",
|
||||||
|
"TWILIO_PHONE_NUMBER": "+15551234567",
|
||||||
|
"SMS_HOME_CHANNEL": "+15559876543",
|
||||||
|
"SMS_HOME_CHANNEL_NAME": "My Phone",
|
||||||
|
}
|
||||||
|
with patch.dict(os.environ, env, clear=False):
|
||||||
|
config = load_gateway_config()
|
||||||
|
hc = config.platforms[Platform.SMS].home_channel
|
||||||
|
assert hc is not None
|
||||||
|
assert hc.chat_id == "+15559876543"
|
||||||
|
assert hc.name == "My Phone"
|
||||||
|
assert hc.platform == Platform.SMS
|
||||||
|
|
||||||
|
def test_sms_in_connected_platforms(self):
|
||||||
|
from gateway.config import load_gateway_config
|
||||||
|
|
||||||
|
env = {
|
||||||
|
"TWILIO_ACCOUNT_SID": "ACtest123",
|
||||||
|
"TWILIO_AUTH_TOKEN": "token_abc",
|
||||||
|
}
|
||||||
|
with patch.dict(os.environ, env, clear=False):
|
||||||
|
config = load_gateway_config()
|
||||||
|
connected = config.get_connected_platforms()
|
||||||
|
assert Platform.SMS in connected
|
||||||
|
|
||||||
|
|
||||||
|
# ── Format / truncate ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
class TestSmsFormatAndTruncate:
|
||||||
|
"""Test SmsAdapter.format_message strips markdown."""
|
||||||
|
|
||||||
|
def _make_adapter(self):
|
||||||
|
from gateway.platforms.sms import SmsAdapter
|
||||||
|
|
||||||
|
env = {
|
||||||
|
"TWILIO_ACCOUNT_SID": "ACtest",
|
||||||
|
"TWILIO_AUTH_TOKEN": "tok",
|
||||||
|
"TWILIO_PHONE_NUMBER": "+15550001111",
|
||||||
|
}
|
||||||
|
with patch.dict(os.environ, env):
|
||||||
|
pc = PlatformConfig(enabled=True, api_key="tok")
|
||||||
|
adapter = object.__new__(SmsAdapter)
|
||||||
|
adapter.config = pc
|
||||||
|
adapter._platform = Platform.SMS
|
||||||
|
adapter._account_sid = "ACtest"
|
||||||
|
adapter._auth_token = "tok"
|
||||||
|
adapter._from_number = "+15550001111"
|
||||||
|
return adapter
|
||||||
|
|
||||||
|
def test_strips_bold(self):
|
||||||
|
adapter = self._make_adapter()
|
||||||
|
assert adapter.format_message("**hello**") == "hello"
|
||||||
|
|
||||||
|
def test_strips_italic(self):
|
||||||
|
adapter = self._make_adapter()
|
||||||
|
assert adapter.format_message("*world*") == "world"
|
||||||
|
|
||||||
|
def test_strips_code_blocks(self):
|
||||||
|
adapter = self._make_adapter()
|
||||||
|
result = adapter.format_message("```python\nprint('hi')\n```")
|
||||||
|
assert "```" not in result
|
||||||
|
assert "print('hi')" in result
|
||||||
|
|
||||||
|
def test_strips_inline_code(self):
|
||||||
|
adapter = self._make_adapter()
|
||||||
|
assert adapter.format_message("`code`") == "code"
|
||||||
|
|
||||||
|
def test_strips_headers(self):
|
||||||
|
adapter = self._make_adapter()
|
||||||
|
assert adapter.format_message("## Title") == "Title"
|
||||||
|
|
||||||
|
def test_strips_links(self):
|
||||||
|
adapter = self._make_adapter()
|
||||||
|
assert adapter.format_message("[click](https://example.com)") == "click"
|
||||||
|
|
||||||
|
def test_collapses_newlines(self):
|
||||||
|
adapter = self._make_adapter()
|
||||||
|
result = adapter.format_message("a\n\n\n\nb")
|
||||||
|
assert result == "a\n\nb"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Echo prevention ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class TestSmsEchoPrevention:
|
||||||
|
"""Adapter should ignore messages from its own number."""
|
||||||
|
|
||||||
|
def test_own_number_detection(self):
|
||||||
|
"""The adapter stores _from_number for echo prevention."""
|
||||||
|
from gateway.platforms.sms import SmsAdapter
|
||||||
|
|
||||||
|
env = {
|
||||||
|
"TWILIO_ACCOUNT_SID": "ACtest",
|
||||||
|
"TWILIO_AUTH_TOKEN": "tok",
|
||||||
|
"TWILIO_PHONE_NUMBER": "+15550001111",
|
||||||
|
}
|
||||||
|
with patch.dict(os.environ, env):
|
||||||
|
pc = PlatformConfig(enabled=True, api_key="tok")
|
||||||
|
adapter = SmsAdapter(pc)
|
||||||
|
assert adapter._from_number == "+15550001111"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Requirements check ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
class TestSmsRequirements:
|
||||||
|
def test_check_sms_requirements_missing_sid(self):
|
||||||
|
from gateway.platforms.sms import check_sms_requirements
|
||||||
|
|
||||||
|
env = {"TWILIO_AUTH_TOKEN": "tok"}
|
||||||
|
with patch.dict(os.environ, env, clear=True):
|
||||||
|
assert check_sms_requirements() is False
|
||||||
|
|
||||||
|
def test_check_sms_requirements_missing_token(self):
|
||||||
|
from gateway.platforms.sms import check_sms_requirements
|
||||||
|
|
||||||
|
env = {"TWILIO_ACCOUNT_SID": "ACtest"}
|
||||||
|
with patch.dict(os.environ, env, clear=True):
|
||||||
|
assert check_sms_requirements() is False
|
||||||
|
|
||||||
|
def test_check_sms_requirements_both_set(self):
|
||||||
|
from gateway.platforms.sms import check_sms_requirements
|
||||||
|
|
||||||
|
env = {
|
||||||
|
"TWILIO_ACCOUNT_SID": "ACtest",
|
||||||
|
"TWILIO_AUTH_TOKEN": "tok",
|
||||||
|
}
|
||||||
|
with patch.dict(os.environ, env, clear=False):
|
||||||
|
# Only returns True if aiohttp is also importable
|
||||||
|
result = check_sms_requirements()
|
||||||
|
try:
|
||||||
|
import aiohttp # noqa: F401
|
||||||
|
assert result is True
|
||||||
|
except ImportError:
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
|
||||||
|
# ── Toolset verification ───────────────────────────────────────────
|
||||||
|
|
||||||
|
class TestSmsToolset:
|
||||||
|
def test_hermes_sms_toolset_exists(self):
|
||||||
|
from toolsets import get_toolset
|
||||||
|
|
||||||
|
ts = get_toolset("hermes-sms")
|
||||||
|
assert ts is not None
|
||||||
|
assert "tools" in ts
|
||||||
|
|
||||||
|
def test_hermes_sms_in_gateway_includes(self):
|
||||||
|
from toolsets import get_toolset
|
||||||
|
|
||||||
|
gw = get_toolset("hermes-gateway")
|
||||||
|
assert gw is not None
|
||||||
|
assert "hermes-sms" in gw["includes"]
|
||||||
|
|
||||||
|
def test_sms_platform_hint_exists(self):
|
||||||
|
from agent.prompt_builder import PLATFORM_HINTS
|
||||||
|
|
||||||
|
assert "sms" in PLATFORM_HINTS
|
||||||
|
assert "concise" in PLATFORM_HINTS["sms"].lower()
|
||||||
|
|
||||||
|
def test_sms_in_scheduler_platform_map(self):
|
||||||
|
"""Verify cron scheduler recognizes 'sms' as a valid platform."""
|
||||||
|
# Just check the Platform enum has SMS — the scheduler imports it dynamically
|
||||||
|
assert Platform.SMS.value == "sms"
|
||||||
|
|
||||||
|
def test_sms_in_send_message_platform_map(self):
|
||||||
|
"""Verify send_message_tool recognizes 'sms'."""
|
||||||
|
# The platform_map is built inside _handle_send; verify SMS enum exists
|
||||||
|
assert hasattr(Platform, "SMS")
|
||||||
|
|
||||||
|
def test_sms_in_cronjob_deliver_description(self):
|
||||||
|
"""Verify cronjob_tools mentions sms in deliver description."""
|
||||||
|
from tools.cronjob_tools import CRONJOB_SCHEMA
|
||||||
|
deliver_desc = CRONJOB_SCHEMA["parameters"]["properties"]["deliver"]["description"]
|
||||||
|
assert "sms" in deliver_desc.lower()
|
||||||
@@ -372,7 +372,7 @@ Important safety rule: cron-run sessions should not recursively schedule more cr
|
|||||||
},
|
},
|
||||||
"deliver": {
|
"deliver": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Delivery target: origin, local, telegram, discord, signal, or platform:chat_id"
|
"description": "Delivery target: origin, local, telegram, discord, signal, sms, or platform:chat_id"
|
||||||
},
|
},
|
||||||
"model": {
|
"model": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
|
|||||||
@@ -125,6 +125,7 @@ def _handle_send(args):
|
|||||||
"whatsapp": Platform.WHATSAPP,
|
"whatsapp": Platform.WHATSAPP,
|
||||||
"signal": Platform.SIGNAL,
|
"signal": Platform.SIGNAL,
|
||||||
"email": Platform.EMAIL,
|
"email": Platform.EMAIL,
|
||||||
|
"sms": Platform.SMS,
|
||||||
}
|
}
|
||||||
platform = platform_map.get(platform_name)
|
platform = platform_map.get(platform_name)
|
||||||
if not platform:
|
if not platform:
|
||||||
@@ -334,6 +335,8 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None,
|
|||||||
result = await _send_signal(pconfig.extra, chat_id, chunk)
|
result = await _send_signal(pconfig.extra, chat_id, chunk)
|
||||||
elif platform == Platform.EMAIL:
|
elif platform == Platform.EMAIL:
|
||||||
result = await _send_email(pconfig.extra, chat_id, chunk)
|
result = await _send_email(pconfig.extra, chat_id, chunk)
|
||||||
|
elif platform == Platform.SMS:
|
||||||
|
result = await _send_sms(pconfig.api_key, chat_id, chunk)
|
||||||
else:
|
else:
|
||||||
result = {"error": f"Direct sending not yet implemented for {platform.value}"}
|
result = {"error": f"Direct sending not yet implemented for {platform.value}"}
|
||||||
|
|
||||||
@@ -562,6 +565,59 @@ async def _send_email(extra, chat_id, message):
|
|||||||
return {"error": f"Email send failed: {e}"}
|
return {"error": f"Email send failed: {e}"}
|
||||||
|
|
||||||
|
|
||||||
|
async def _send_sms(auth_token, chat_id, message):
|
||||||
|
"""Send a single SMS via Twilio REST API.
|
||||||
|
|
||||||
|
Uses HTTP Basic auth (Account SID : Auth Token) and form-encoded POST.
|
||||||
|
Chunking is handled by _send_to_platform() before this is called.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
import aiohttp
|
||||||
|
except ImportError:
|
||||||
|
return {"error": "aiohttp not installed. Run: pip install aiohttp"}
|
||||||
|
|
||||||
|
import base64
|
||||||
|
|
||||||
|
account_sid = os.getenv("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)"}
|
||||||
|
|
||||||
|
# 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)
|
||||||
|
message = message.strip()
|
||||||
|
|
||||||
|
try:
|
||||||
|
creds = f"{account_sid}:{auth_token}"
|
||||||
|
encoded = base64.b64encode(creds.encode("ascii")).decode("ascii")
|
||||||
|
url = f"https://api.twilio.com/2010-04-01/Accounts/{account_sid}/Messages.json"
|
||||||
|
headers = {"Authorization": f"Basic {encoded}"}
|
||||||
|
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
form_data = aiohttp.FormData()
|
||||||
|
form_data.add_field("From", from_number)
|
||||||
|
form_data.add_field("To", chat_id)
|
||||||
|
form_data.add_field("Body", message)
|
||||||
|
|
||||||
|
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))
|
||||||
|
return {"error": f"Twilio API error ({resp.status}): {error_msg}"}
|
||||||
|
msg_sid = body.get("sid", "")
|
||||||
|
return {"success": True, "platform": "sms", "chat_id": chat_id, "message_id": msg_sid}
|
||||||
|
except Exception as e:
|
||||||
|
return {"error": f"SMS send failed: {e}"}
|
||||||
|
|
||||||
|
|
||||||
def _check_send_message():
|
def _check_send_message():
|
||||||
"""Gate send_message on gateway running (always available on messaging platforms)."""
|
"""Gate send_message on gateway running (always available on messaging platforms)."""
|
||||||
platform = os.getenv("HERMES_SESSION_PLATFORM", "")
|
platform = os.getenv("HERMES_SESSION_PLATFORM", "")
|
||||||
|
|||||||
@@ -292,10 +292,16 @@ TOOLSETS = {
|
|||||||
"includes": []
|
"includes": []
|
||||||
},
|
},
|
||||||
|
|
||||||
|
"hermes-sms": {
|
||||||
|
"description": "SMS bot toolset - interact with Hermes via SMS (Twilio)",
|
||||||
|
"tools": _HERMES_CORE_TOOLS,
|
||||||
|
"includes": []
|
||||||
|
},
|
||||||
|
|
||||||
"hermes-gateway": {
|
"hermes-gateway": {
|
||||||
"description": "Gateway toolset - union of all messaging platform tools",
|
"description": "Gateway toolset - union of all messaging platform tools",
|
||||||
"tools": [],
|
"tools": [],
|
||||||
"includes": ["hermes-telegram", "hermes-discord", "hermes-whatsapp", "hermes-slack", "hermes-signal", "hermes-homeassistant", "hermes-email"]
|
"includes": ["hermes-telegram", "hermes-discord", "hermes-whatsapp", "hermes-slack", "hermes-signal", "hermes-homeassistant", "hermes-email", "hermes-sms"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -152,6 +152,14 @@ For native Anthropic auth, Hermes prefers Claude Code's own credential files whe
|
|||||||
| `SIGNAL_HOME_CHANNEL_NAME` | Display name for the Signal home channel |
|
| `SIGNAL_HOME_CHANNEL_NAME` | Display name for the Signal home channel |
|
||||||
| `SIGNAL_IGNORE_STORIES` | Ignore Signal stories/status updates |
|
| `SIGNAL_IGNORE_STORIES` | Ignore Signal stories/status updates |
|
||||||
| `SIGNAL_ALLOW_ALL_USERS` | Allow all Signal users without an allowlist |
|
| `SIGNAL_ALLOW_ALL_USERS` | Allow all Signal users without an allowlist |
|
||||||
|
| `TWILIO_ACCOUNT_SID` | Twilio Account SID (shared with telephony skill) |
|
||||||
|
| `TWILIO_AUTH_TOKEN` | Twilio Auth Token (shared with telephony skill) |
|
||||||
|
| `TWILIO_PHONE_NUMBER` | Twilio phone number in E.164 format (shared with telephony skill) |
|
||||||
|
| `SMS_WEBHOOK_PORT` | Webhook listener port for inbound SMS (default: `8080`) |
|
||||||
|
| `SMS_ALLOWED_USERS` | Comma-separated E.164 phone numbers allowed to chat |
|
||||||
|
| `SMS_ALLOW_ALL_USERS` | Allow all SMS senders without an allowlist |
|
||||||
|
| `SMS_HOME_CHANNEL` | Phone number for cron job / notification delivery |
|
||||||
|
| `SMS_HOME_CHANNEL_NAME` | Display name for the SMS home channel |
|
||||||
| `EMAIL_ADDRESS` | Email address for the Email gateway adapter |
|
| `EMAIL_ADDRESS` | Email address for the Email gateway adapter |
|
||||||
| `EMAIL_PASSWORD` | Password or app password for the email account |
|
| `EMAIL_PASSWORD` | Password or app password for the email account |
|
||||||
| `EMAIL_IMAP_HOST` | IMAP hostname for the email adapter |
|
| `EMAIL_IMAP_HOST` | IMAP hostname for the email adapter |
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
---
|
---
|
||||||
sidebar_position: 1
|
sidebar_position: 1
|
||||||
title: "Messaging Gateway"
|
title: "Messaging Gateway"
|
||||||
description: "Chat with Hermes from Telegram, Discord, Slack, WhatsApp, Signal, Email, Home Assistant, or your browser — architecture and setup overview"
|
description: "Chat with Hermes from Telegram, Discord, Slack, WhatsApp, Signal, SMS, Email, Home Assistant, or your browser — architecture and setup overview"
|
||||||
---
|
---
|
||||||
|
|
||||||
# Messaging Gateway
|
# Messaging Gateway
|
||||||
|
|
||||||
Chat with Hermes from Telegram, Discord, Slack, WhatsApp, Signal, Email, Home Assistant, or your browser. The gateway is a single background process that connects to all your configured platforms, handles sessions, runs cron jobs, and delivers voice messages.
|
Chat with Hermes from Telegram, Discord, Slack, WhatsApp, Signal, SMS, Email, Home Assistant, or your browser. The gateway is a single background process that connects to all your configured platforms, handles sessions, runs cron jobs, and delivers voice messages.
|
||||||
|
|
||||||
For the full voice feature set — including CLI microphone mode, spoken replies in messaging, and Discord voice-channel conversations — see [Voice Mode](/docs/user-guide/features/voice-mode) and [Use Voice Mode with Hermes](/docs/guides/use-voice-mode-with-hermes).
|
For the full voice feature set — including CLI microphone mode, spoken replies in messaging, and Discord voice-channel conversations — see [Voice Mode](/docs/user-guide/features/voice-mode) and [Use Voice Mode with Hermes](/docs/guides/use-voice-mode-with-hermes).
|
||||||
|
|
||||||
@@ -21,6 +21,7 @@ flowchart TB
|
|||||||
wa[WhatsApp]
|
wa[WhatsApp]
|
||||||
sl[Slack]
|
sl[Slack]
|
||||||
sig[Signal]
|
sig[Signal]
|
||||||
|
sms[SMS]
|
||||||
em[Email]
|
em[Email]
|
||||||
ha[Home Assistant]
|
ha[Home Assistant]
|
||||||
end
|
end
|
||||||
@@ -35,6 +36,7 @@ flowchart TB
|
|||||||
wa --> store
|
wa --> store
|
||||||
sl --> store
|
sl --> store
|
||||||
sig --> store
|
sig --> store
|
||||||
|
sms --> store
|
||||||
em --> store
|
em --> store
|
||||||
ha --> store
|
ha --> store
|
||||||
store --> agent
|
store --> agent
|
||||||
@@ -129,6 +131,7 @@ Configure per-platform overrides in `~/.hermes/gateway.json`:
|
|||||||
TELEGRAM_ALLOWED_USERS=123456789,987654321
|
TELEGRAM_ALLOWED_USERS=123456789,987654321
|
||||||
DISCORD_ALLOWED_USERS=123456789012345678
|
DISCORD_ALLOWED_USERS=123456789012345678
|
||||||
SIGNAL_ALLOWED_USERS=+155****4567,+155****6543
|
SIGNAL_ALLOWED_USERS=+155****4567,+155****6543
|
||||||
|
SMS_ALLOWED_USERS=+155****4567,+155****6543
|
||||||
EMAIL_ALLOWED_USERS=trusted@example.com,colleague@work.com
|
EMAIL_ALLOWED_USERS=trusted@example.com,colleague@work.com
|
||||||
|
|
||||||
# Or allow
|
# Or allow
|
||||||
@@ -288,6 +291,7 @@ Each platform has its own toolset:
|
|||||||
| WhatsApp | `hermes-whatsapp` | Full tools including terminal |
|
| WhatsApp | `hermes-whatsapp` | Full tools including terminal |
|
||||||
| Slack | `hermes-slack` | Full tools including terminal |
|
| Slack | `hermes-slack` | Full tools including terminal |
|
||||||
| Signal | `hermes-signal` | Full tools including terminal |
|
| Signal | `hermes-signal` | Full tools including terminal |
|
||||||
|
| SMS | `hermes-sms` | Full tools including terminal |
|
||||||
| Email | `hermes-email` | Full tools including terminal |
|
| Email | `hermes-email` | Full tools including terminal |
|
||||||
| Home Assistant | `hermes-homeassistant` | Full tools + HA device control (ha_list_entities, ha_get_state, ha_call_service, ha_list_services) |
|
| Home Assistant | `hermes-homeassistant` | Full tools + HA device control (ha_list_entities, ha_get_state, ha_call_service, ha_list_services) |
|
||||||
|
|
||||||
@@ -298,5 +302,6 @@ Each platform has its own toolset:
|
|||||||
- [Slack Setup](slack.md)
|
- [Slack Setup](slack.md)
|
||||||
- [WhatsApp Setup](whatsapp.md)
|
- [WhatsApp Setup](whatsapp.md)
|
||||||
- [Signal Setup](signal.md)
|
- [Signal Setup](signal.md)
|
||||||
|
- [SMS Setup (Twilio)](sms.md)
|
||||||
- [Email Setup](email.md)
|
- [Email Setup](email.md)
|
||||||
- [Home Assistant Integration](homeassistant.md)
|
- [Home Assistant Integration](homeassistant.md)
|
||||||
|
|||||||
175
website/docs/user-guide/messaging/sms.md
Normal file
175
website/docs/user-guide/messaging/sms.md
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
---
|
||||||
|
sidebar_position: 8
|
||||||
|
title: "SMS (Twilio)"
|
||||||
|
description: "Set up Hermes Agent as an SMS chatbot via Twilio"
|
||||||
|
---
|
||||||
|
|
||||||
|
# SMS Setup (Twilio)
|
||||||
|
|
||||||
|
Hermes connects to SMS through the [Twilio](https://www.twilio.com/) API. People text your Twilio phone number and get AI responses back — same conversational experience as Telegram or Discord, but over standard text messages.
|
||||||
|
|
||||||
|
:::info Shared Credentials
|
||||||
|
The SMS gateway shares credentials with the optional [telephony skill](/docs/reference/skills-catalog). If you've already set up Twilio for voice calls or one-off SMS, the gateway works with the same `TWILIO_ACCOUNT_SID`, `TWILIO_AUTH_TOKEN`, and `TWILIO_PHONE_NUMBER`.
|
||||||
|
:::
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- **Twilio account** — [Sign up at twilio.com](https://www.twilio.com/try-twilio) (free trial available)
|
||||||
|
- **A Twilio phone number** with SMS capability
|
||||||
|
- **A publicly accessible server** — Twilio sends webhooks to your server when SMS arrives
|
||||||
|
- **aiohttp** — `pip install 'hermes-agent[sms]'`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 1: Get Your Twilio Credentials
|
||||||
|
|
||||||
|
1. Go to the [Twilio Console](https://console.twilio.com/)
|
||||||
|
2. Copy your **Account SID** and **Auth Token** from the dashboard
|
||||||
|
3. Go to **Phone Numbers → Manage → Active Numbers** — note your phone number in E.164 format (e.g., `+15551234567`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 2: Configure Hermes
|
||||||
|
|
||||||
|
### Interactive setup (recommended)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
hermes gateway setup
|
||||||
|
```
|
||||||
|
|
||||||
|
Select **SMS (Twilio)** from the platform list. The wizard will prompt for your credentials.
|
||||||
|
|
||||||
|
### Manual setup
|
||||||
|
|
||||||
|
Add to `~/.hermes/.env`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
TWILIO_ACCOUNT_SID=ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||||
|
TWILIO_AUTH_TOKEN=your_auth_token_here
|
||||||
|
TWILIO_PHONE_NUMBER=+15551234567
|
||||||
|
|
||||||
|
# Security: restrict to specific phone numbers (recommended)
|
||||||
|
SMS_ALLOWED_USERS=+15559876543,+15551112222
|
||||||
|
|
||||||
|
# Optional: set a home channel for cron job delivery
|
||||||
|
SMS_HOME_CHANNEL=+15559876543
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 3: Configure Twilio Webhook
|
||||||
|
|
||||||
|
Twilio needs to know where to send incoming messages. In the [Twilio Console](https://console.twilio.com/):
|
||||||
|
|
||||||
|
1. Go to **Phone Numbers → Manage → Active Numbers**
|
||||||
|
2. Click your phone number
|
||||||
|
3. Under **Messaging → A MESSAGE COMES IN**, set:
|
||||||
|
- **Webhook**: `https://your-server:8080/webhooks/twilio`
|
||||||
|
- **HTTP Method**: `POST`
|
||||||
|
|
||||||
|
:::tip Exposing Your Webhook
|
||||||
|
If you're running Hermes locally, use a tunnel to expose the webhook:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Using cloudflared
|
||||||
|
cloudflared tunnel --url http://localhost:8080
|
||||||
|
|
||||||
|
# Using ngrok
|
||||||
|
ngrok http 8080
|
||||||
|
```
|
||||||
|
|
||||||
|
Set the resulting public URL as your Twilio webhook.
|
||||||
|
:::
|
||||||
|
|
||||||
|
The webhook port defaults to `8080`. Override with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
SMS_WEBHOOK_PORT=3000
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 4: Start the Gateway
|
||||||
|
|
||||||
|
```bash
|
||||||
|
hermes gateway
|
||||||
|
```
|
||||||
|
|
||||||
|
You should see:
|
||||||
|
|
||||||
|
```
|
||||||
|
[sms] Twilio webhook server listening on port 8080, from: +1555***4567
|
||||||
|
```
|
||||||
|
|
||||||
|
Text your Twilio number — Hermes will respond via SMS.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
| Variable | Required | Description |
|
||||||
|
|----------|----------|-------------|
|
||||||
|
| `TWILIO_ACCOUNT_SID` | Yes | Twilio Account SID (starts with `AC`) |
|
||||||
|
| `TWILIO_AUTH_TOKEN` | Yes | Twilio Auth Token |
|
||||||
|
| `TWILIO_PHONE_NUMBER` | Yes | Your Twilio phone number (E.164 format) |
|
||||||
|
| `SMS_WEBHOOK_PORT` | No | Webhook listener port (default: `8080`) |
|
||||||
|
| `SMS_ALLOWED_USERS` | No | Comma-separated E.164 phone numbers allowed to chat |
|
||||||
|
| `SMS_ALLOW_ALL_USERS` | No | Set to `true` to allow anyone (not recommended) |
|
||||||
|
| `SMS_HOME_CHANNEL` | No | Phone number for cron job / notification delivery |
|
||||||
|
| `SMS_HOME_CHANNEL_NAME` | No | Display name for the home channel (default: `Home`) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SMS-Specific Behavior
|
||||||
|
|
||||||
|
- **Plain text only** — Markdown is automatically stripped since SMS renders it as literal characters
|
||||||
|
- **1600 character limit** — Longer responses are split across multiple messages at natural boundaries (newlines, then spaces)
|
||||||
|
- **Echo prevention** — Messages from your own Twilio number are ignored to prevent loops
|
||||||
|
- **Phone number redaction** — Phone numbers are redacted in logs for privacy
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
**The gateway denies all users by default.** Configure an allowlist:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Recommended: restrict to specific phone numbers
|
||||||
|
SMS_ALLOWED_USERS=+15559876543,+15551112222
|
||||||
|
|
||||||
|
# Or allow all (NOT recommended for bots with terminal access)
|
||||||
|
SMS_ALLOW_ALL_USERS=true
|
||||||
|
```
|
||||||
|
|
||||||
|
:::warning
|
||||||
|
SMS has no built-in encryption. Don't use SMS for sensitive operations unless you understand the security implications. For sensitive use cases, prefer Signal or Telegram.
|
||||||
|
:::
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Messages not arriving
|
||||||
|
|
||||||
|
1. Check your Twilio webhook URL is correct and publicly accessible
|
||||||
|
2. Verify `TWILIO_ACCOUNT_SID` and `TWILIO_AUTH_TOKEN` are correct
|
||||||
|
3. Check the Twilio Console → **Monitor → Logs → Messaging** for delivery errors
|
||||||
|
4. Ensure your phone number is in `SMS_ALLOWED_USERS` (or `SMS_ALLOW_ALL_USERS=true`)
|
||||||
|
|
||||||
|
### Replies not sending
|
||||||
|
|
||||||
|
1. Check `TWILIO_PHONE_NUMBER` is set correctly (E.164 format with `+`)
|
||||||
|
2. Verify your Twilio account has SMS-capable numbers
|
||||||
|
3. Check Hermes gateway logs for Twilio API errors
|
||||||
|
|
||||||
|
### Webhook port conflicts
|
||||||
|
|
||||||
|
If port 8080 is already in use, change it:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
SMS_WEBHOOK_PORT=3001
|
||||||
|
```
|
||||||
|
|
||||||
|
Update the webhook URL in Twilio Console to match.
|
||||||
Reference in New Issue
Block a user