mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
* refactor: add shared helper modules for code deduplication New modules: - gateway/platforms/helpers.py: MessageDeduplicator, TextBatchAggregator, strip_markdown, ThreadParticipationTracker, redact_phone - hermes_cli/cli_output.py: print_info/success/warning/error, prompt helpers - tools/path_security.py: validate_within_dir, has_traversal_component - utils.py additions: safe_json_loads, read_json_file, read_jsonl, append_jsonl, env_str/lower/int/bool helpers - hermes_constants.py additions: get_config_path, get_skills_dir, get_logs_dir, get_env_path * refactor: migrate gateway adapters to shared helpers - MessageDeduplicator: discord, slack, dingtalk, wecom, weixin, mattermost - strip_markdown: bluebubbles, feishu, sms - redact_phone: sms, signal - ThreadParticipationTracker: discord, matrix - _acquire/_release_platform_lock: telegram, discord, slack, whatsapp, signal, weixin Net -316 lines across 19 files. * refactor: migrate CLI modules to shared helpers - tools_config.py: use cli_output print/prompt + curses_radiolist (-117 lines) - setup.py: use cli_output print helpers + curses_radiolist (-101 lines) - mcp_config.py: use cli_output prompt (-15 lines) - memory_setup.py: use curses_radiolist (-86 lines) Net -263 lines across 5 files. * refactor: migrate to shared utility helpers - safe_json_loads: agent/display.py (4 sites) - get_config_path: skill_utils.py, hermes_logging.py, hermes_time.py - get_skills_dir: skill_utils.py, prompt_builder.py - Token estimation dedup: skills_tool.py imports from model_metadata - Path security: skills_tool, cronjob_tools, skill_manager_tool, credential_files - Non-atomic YAML writes: doctor.py, config.py now use atomic_yaml_write - Platform dict: new platforms.py, skills_config + tools_config derive from it - Anthropic key: new get_anthropic_key() in auth.py, used by doctor/status/config/main * test: update tests for shared helper migrations - test_dingtalk: use _dedup.is_duplicate() instead of _is_duplicate() - test_mattermost: use _dedup instead of _seen_posts/_prune_seen - test_signal: import redact_phone from helpers instead of signal - test_discord_connect: _platform_lock_identity instead of _token_lock_identity - test_telegram_conflict: updated lock error message format - test_skill_manager_tool: 'escapes' instead of 'boundary' in error msgs
255 lines
9 KiB
Python
255 lines
9 KiB
Python
"""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 logging
|
|
import os
|
|
import urllib.parse
|
|
from typing import Any, Dict, Optional
|
|
|
|
from gateway.config import Platform, PlatformConfig
|
|
from gateway.platforms.base import (
|
|
BasePlatformAdapter,
|
|
MessageEvent,
|
|
MessageType,
|
|
SendResult,
|
|
)
|
|
from gateway.platforms.helpers import redact_phone, strip_markdown
|
|
|
|
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
|
|
|
|
|
|
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
|
|
self._http_session: Optional["aiohttp.ClientSession"] = 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._http_session = aiohttp.ClientSession(
|
|
timeout=aiohttp.ClientTimeout(total=30),
|
|
)
|
|
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._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")
|
|
|
|
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(),
|
|
}
|
|
|
|
session = self._http_session or aiohttp.ClientSession(
|
|
timeout=aiohttp.ClientTimeout(total=30),
|
|
)
|
|
try:
|
|
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))
|
|
finally:
|
|
# Close session only if we created a fallback (no persistent session)
|
|
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"}
|
|
|
|
# ------------------------------------------------------------------
|
|
# SMS-specific formatting
|
|
# ------------------------------------------------------------------
|
|
|
|
def format_message(self, content: str) -> str:
|
|
"""Strip markdown — SMS renders it as literal characters."""
|
|
return strip_markdown(content)
|
|
|
|
# ------------------------------------------------------------------
|
|
# 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
|
|
task = asyncio.create_task(self.handle_message(event))
|
|
self._background_tasks.add(task)
|
|
task.add_done_callback(self._background_tasks.discard)
|
|
|
|
# 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",
|
|
)
|