refactor(sms): shared session factory, precompiled markdown sub table, drop _build_adapter shim

This commit is contained in:
Teknium
2026-09-02 21:37:04 -07:00
parent ede7bf1869
commit 78453b958d

View File

@@ -28,18 +28,10 @@ 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.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"
@@ -74,6 +66,12 @@ def _twilio_form(from_number: str, to_number: str, body: str):
return form_data
def _new_session(**kwargs):
import aiohttp
return aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=30), **kwargs)
def check_sms_requirements() -> bool:
"""Check if SMS adapter dependencies are available."""
try:
@@ -109,7 +107,6 @@ class SmsAdapter(BasePlatformAdapter):
# -- Lifecycle -----------------------------------------------------------
async def connect(self, *, is_reconnect: bool = False) -> bool:
import aiohttp
from aiohttp import web
if not self._from_number:
@@ -117,7 +114,6 @@ class SmsAdapter(BasePlatformAdapter):
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 = (
@@ -137,20 +133,16 @@ class SmsAdapter(BasePlatformAdapter):
"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._http_session = _new_session(trust_env=gateway_trust_env())
self._running = True
logger.info(
"[sms] Twilio webhook server listening on %s:%d, from: %s",
@@ -172,22 +164,13 @@ class SmsAdapter(BasePlatformAdapter):
# -- Outbound ------------------------------------------------------------
async def send(
self,
chat_id: str,
content: str,
reply_to: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
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(),
)
session = self._http_session or _new_session(trust_env=gateway_trust_env())
try:
for chunk in chunks:
form_data = _twilio_form(self._from_number, chat_id, chunk)
@@ -197,8 +180,7 @@ class SmsAdapter(BasePlatformAdapter):
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,
"[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", ""))
@@ -271,7 +253,6 @@ class SmsAdapter(BasePlatformAdapter):
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:
@@ -281,25 +262,20 @@ class SmsAdapter(BasePlatformAdapter):
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()
from_number, to_number, text, message_sid = (
form.get(key, [""])[0].strip() for key in ("From", "To", "Body", "MessageSid")
)
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,
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,
@@ -314,42 +290,39 @@ class SmsAdapter(BasePlatformAdapter):
# -- Plugin registration -----------------------------------------------------
# TWILIO_* env→PlatformConfig seeding stays in core (gateway/config.py).
# Standalone-send markdown stripping (looser than helpers.strip_markdown: no
# word-boundary guards on underscores, ``[a-z]*`` fence tags — kept for parity).
_SMS_MARKDOWN_SUBS = (
(re.compile(r"\*\*(.+?)\*\*", re.DOTALL), r"\1"),
(re.compile(r"\*(.+?)\*", re.DOTALL), r"\1"),
(re.compile(r"__(.+?)__", re.DOTALL), r"\1"),
(re.compile(r"_(.+?)_", re.DOTALL), r"\1"),
(re.compile(r"```[a-z]*\n?"), ""),
(re.compile(r"`(.+?)`"), r"\1"),
(re.compile(r"^#{1,6}\s+", re.MULTILINE), ""),
(re.compile(r"\[([^\]]+)\]\([^\)]+\)"), r"\1"),
(re.compile(r"\n{3,}"), "\n\n"),
)
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)
for pattern, repl in _SMS_MARKDOWN_SUBS:
message = pattern.sub(repl, message)
return message.strip()
async def _standalone_send(
pconfig,
chat_id,
message,
*,
thread_id=None,
media_files=None,
force_document=False,
):
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
import aiohttp # noqa: F401
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):
@@ -362,9 +335,9 @@ async def _standalone_send(
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"
url = f"{TWILIO_API_BASE}/{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:
async with _new_session(**_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()
@@ -382,16 +355,12 @@ def _is_connected(config) -> bool:
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,
adapter_factory=SmsAdapter,
check_fn=check_sms_requirements,
is_connected=_is_connected,
required_env=["TWILIO_ACCOUNT_SID", "TWILIO_AUTH_TOKEN", "TWILIO_PHONE_NUMBER"],