From 86875008195aece95281d7ddb9da9110d67196f2 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Mon, 25 May 2026 19:49:16 +0800 Subject: [PATCH] Add SMS channel for strategy plugin alerts --- docs/strategy_plugin_runtime_contract.md | 15 +- .../notifications/__init__.py | 16 + src/quant_platform_kit/notifications/sms.py | 111 +++++ .../notifications/strategy_plugin_sms.py | 400 ++++++++++++++++++ .../test_strategy_plugin_sms_notifications.py | 185 ++++++++ 5 files changed, 721 insertions(+), 6 deletions(-) create mode 100644 src/quant_platform_kit/notifications/sms.py create mode 100644 src/quant_platform_kit/notifications/strategy_plugin_sms.py create mode 100644 tests/test_strategy_plugin_sms_notifications.py diff --git a/docs/strategy_plugin_runtime_contract.md b/docs/strategy_plugin_runtime_contract.md index 7606f53..ee5645e 100644 --- a/docs/strategy_plugin_runtime_contract.md +++ b/docs/strategy_plugin_runtime_contract.md @@ -124,12 +124,15 @@ when any of the following is true: - `suggested_action` is `defend` or `blocked` - `would_trade_if_enabled` is `true` -Platforms may still choose their delivery sinks, but email escalation can use -`quant_platform_kit.notifications.strategy_plugin_email.publish_strategy_plugin_email_alerts()`. -The publisher builds the shared subject/body, prefixes platform context, returns -structured sent/skipped/failed diagnostics, and can use -`StrategyPluginEmailAlertMarkerStore` to skip alert keys that were already -sent. +Platforms may still choose their delivery sinks, but shared escalation helpers +are available for email and SMS: + +- `quant_platform_kit.notifications.strategy_plugin_email.publish_strategy_plugin_email_alerts()` +- `quant_platform_kit.notifications.strategy_plugin_sms.publish_strategy_plugin_sms_alerts()` + +The publishers build the shared subject/body, prefix platform context, return +structured sent/skipped/failed diagnostics, and can use marker stores to skip +alert keys that were already sent for that channel. Delivery credentials, routes, and transport settings are platform runtime configuration. The plugin artifact and strategy code only decide whether an diff --git a/src/quant_platform_kit/notifications/__init__.py b/src/quant_platform_kit/notifications/__init__.py index 2e9bd09..8a6174f 100644 --- a/src/quant_platform_kit/notifications/__init__.py +++ b/src/quant_platform_kit/notifications/__init__.py @@ -2,6 +2,7 @@ from .email import parse_email_recipients, send_smtp_email from .events import NotificationPublisher, RenderedNotification, publish_rendered_notification +from .sms import normalize_sms_recipient, parse_sms_recipients, send_twilio_sms from .strategy_plugin_email import ( StrategyPluginEmailAlertDelivery, StrategyPluginEmailAlertMarkerStore, @@ -10,6 +11,13 @@ build_strategy_plugin_alert_context_label, publish_strategy_plugin_email_alerts, ) +from .strategy_plugin_sms import ( + StrategyPluginSmsAlertDelivery, + StrategyPluginSmsAlertMarkerStore, + StrategyPluginSmsAlertPublishResult, + StrategyPluginSmsSettings, + publish_strategy_plugin_sms_alerts, +) __all__ = [ "NotificationPublisher", @@ -18,9 +26,17 @@ "StrategyPluginEmailAlertMarkerStore", "StrategyPluginEmailAlertPublishResult", "StrategyPluginEmailSettings", + "StrategyPluginSmsAlertDelivery", + "StrategyPluginSmsAlertMarkerStore", + "StrategyPluginSmsAlertPublishResult", + "StrategyPluginSmsSettings", "build_strategy_plugin_alert_context_label", + "normalize_sms_recipient", "parse_email_recipients", + "parse_sms_recipients", "publish_rendered_notification", "publish_strategy_plugin_email_alerts", + "publish_strategy_plugin_sms_alerts", "send_smtp_email", + "send_twilio_sms", ] diff --git a/src/quant_platform_kit/notifications/sms.py b/src/quant_platform_kit/notifications/sms.py new file mode 100644 index 0000000..3ebb1c3 --- /dev/null +++ b/src/quant_platform_kit/notifications/sms.py @@ -0,0 +1,111 @@ +"""SMS notification helpers.""" + +from __future__ import annotations + +import base64 +import urllib.parse +import urllib.request +from collections.abc import Sequence +from typing import Any + + +def normalize_sms_recipient(value: str) -> str: + """Normalize common US phone-number formatting to E.164. + + Non-phone identifiers are returned trimmed so tests and future providers can + still pass explicit values through unchanged. + """ + + text = str(value or "").strip() + if not text: + return "" + digits = "".join(char for char in text if char.isdigit()) + if text.startswith("+") and digits: + return f"+{digits}" + if len(digits) == 10: + return f"+1{digits}" + if len(digits) == 11 and digits.startswith("1"): + return f"+{digits}" + return text + + +def parse_sms_recipients(raw_value: str | Sequence[str] | None) -> tuple[str, ...]: + if raw_value is None: + return () + if isinstance(raw_value, str): + values = raw_value.replace(";", ",").replace("\n", ",").split(",") + else: + values = raw_value + recipients = [] + seen = set() + for value in values: + recipient = normalize_sms_recipient(str(value or "")) + if not recipient or recipient in seen: + continue + recipients.append(recipient) + seen.add(recipient) + return tuple(recipients) + + +def send_twilio_sms( + *, + body: str, + recipients: Sequence[str], + account_sid: str | None, + auth_token: str | None, + from_number: str | None = None, + messaging_service_sid: str | None = None, + api_base_url: str = "https://api.twilio.com", + timeout: float = 10.0, + opener: Any = None, + printer=print, +) -> bool: + resolved_recipients = parse_sms_recipients(recipients) + sid = str(account_sid or "").strip() + token = str(auth_token or "").strip() + sender = normalize_sms_recipient(str(from_number or "")) + service_sid = str(messaging_service_sid or "").strip() + text = str(body or "").strip() + if not resolved_recipients or not sid or not token or not text: + return False + if not sender and not service_sid: + return False + + request_opener = opener or urllib.request.urlopen + base_url = str(api_base_url or "https://api.twilio.com").rstrip("/") + endpoint = f"{base_url}/2010-04-01/Accounts/{urllib.parse.quote(sid)}/Messages.json" + auth_header = base64.b64encode(f"{sid}:{token}".encode("utf-8")).decode("ascii") + all_sent = True + for recipient in resolved_recipients: + payload = { + "To": recipient, + "Body": text, + } + if service_sid: + payload["MessagingServiceSid"] = service_sid + else: + payload["From"] = sender + data = urllib.parse.urlencode(payload).encode("utf-8") + request = urllib.request.Request( + endpoint, + data=data, + headers={ + "Authorization": f"Basic {auth_header}", + "Content-Type": "application/x-www-form-urlencoded", + }, + method="POST", + ) + try: + with request_opener(request, timeout=timeout) as response: + status = getattr(response, "status", None) + if status is None: + status = response.getcode() + status = int(status) + except Exception as exc: + printer(f"SMS send failed for {recipient}: {exc}", flush=True) + all_sent = False + continue + if status < 200 or status >= 300: + printer(f"SMS send failed for {recipient}: HTTP {status}", flush=True) + all_sent = False + return all_sent diff --git a/src/quant_platform_kit/notifications/strategy_plugin_sms.py b/src/quant_platform_kit/notifications/strategy_plugin_sms.py new file mode 100644 index 0000000..a8b82c6 --- /dev/null +++ b/src/quant_platform_kit/notifications/strategy_plugin_sms.py @@ -0,0 +1,400 @@ +"""SMS notification helpers for strategy plugin alerts.""" + +from __future__ import annotations + +import json +import tempfile +from collections.abc import Callable, Mapping, Sequence +from dataclasses import dataclass, field +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from quant_platform_kit.common.strategy_plugins import ( + StrategyPluginAlertMessage, + build_strategy_plugin_alert_messages, +) + +from .sms import parse_sms_recipients, send_twilio_sms + +_DEFAULT_SMS_PROVIDER = "twilio" +_DEFAULT_SMS_API_BASE_URL = "https://api.twilio.com" +_DEFAULT_SMS_BODY_MAX_CHARS = 800 + + +@dataclass(frozen=True) +class StrategyPluginSmsSettings: + recipients: tuple[str, ...] = () + provider: str = _DEFAULT_SMS_PROVIDER + account_id: str | None = None + auth_token: str | None = field(default=None, repr=False) + sender: str | None = None + messaging_service_id: str | None = None + api_base_url: str = _DEFAULT_SMS_API_BASE_URL + body_max_chars: int = _DEFAULT_SMS_BODY_MAX_CHARS + timeout: float = 10.0 + + @classmethod + def from_object(cls, value: object) -> "StrategyPluginSmsSettings": + if isinstance(value, cls): + return value + return cls( + recipients=tuple(parse_sms_recipients(_get_value(value, "crisis_alert_sms_recipients", ()))), + provider=(_first_non_empty(_get_value(value, "crisis_alert_sms_provider")) or _DEFAULT_SMS_PROVIDER).lower(), + account_id=_first_non_empty(_get_value(value, "crisis_alert_sms_account_id")), + auth_token=_first_non_empty(_get_value(value, "crisis_alert_sms_auth_token")), + sender=_first_non_empty(_get_value(value, "crisis_alert_sms_sender")), + messaging_service_id=_first_non_empty( + _get_value(value, "crisis_alert_sms_messaging_service_id") + ), + api_base_url=_first_non_empty(_get_value(value, "crisis_alert_sms_api_base_url")) + or _DEFAULT_SMS_API_BASE_URL, + body_max_chars=_coerce_int( + _get_value(value, "crisis_alert_sms_body_max_chars"), + _DEFAULT_SMS_BODY_MAX_CHARS, + ), + ) + + def missing_fields(self) -> tuple[str, ...]: + missing: list[str] = [] + if self.provider != _DEFAULT_SMS_PROVIDER: + missing.append("CRISIS_ALERT_SMS_PROVIDER=twilio") + if not parse_sms_recipients(self.recipients): + missing.append("CRISIS_ALERT_SMS_RECIPIENTS") + if not str(self.account_id or "").strip(): + missing.append("CRISIS_ALERT_SMS_ACCOUNT_ID") + if not str(self.auth_token or "").strip(): + missing.append("CRISIS_ALERT_SMS_AUTH_TOKEN") + if not str(self.sender or "").strip() and not str(self.messaging_service_id or "").strip(): + missing.append("CRISIS_ALERT_SMS_SENDER or CRISIS_ALERT_SMS_MESSAGING_SERVICE_ID") + return tuple(missing) + + @property + def is_configured(self) -> bool: + return not self.missing_fields() + + +@dataclass(frozen=True) +class StrategyPluginSmsAlertDelivery: + alert_key: str + subject: str + status: str + reason: str | None = None + error: str | None = None + metadata: Mapping[str, Any] = field(default_factory=dict) + + def to_dict(self) -> dict[str, Any]: + payload = { + "alert_key": self.alert_key, + "subject": self.subject, + "status": self.status, + "reason": self.reason, + "error": self.error, + **dict(self.metadata or {}), + } + return {key: value for key, value in payload.items() if value not in (None, "", (), [])} + + +@dataclass(frozen=True) +class StrategyPluginSmsAlertPublishResult: + deliveries: tuple[StrategyPluginSmsAlertDelivery, ...] = () + + @property + def attempted_count(self) -> int: + return len(self.deliveries) + + @property + def sent_count(self) -> int: + return sum(1 for delivery in self.deliveries if delivery.status == "sent") + + @property + def skipped_count(self) -> int: + return sum(1 for delivery in self.deliveries if delivery.status == "skipped") + + @property + def failed_count(self) -> int: + return sum(1 for delivery in self.deliveries if delivery.status == "failed") + + def to_report_fields(self, *, prefix: str = "strategy_plugin_alert_sms") -> dict[str, Any]: + return { + f"{prefix}_attempted_count": self.attempted_count, + f"{prefix}_sent_count": self.sent_count, + f"{prefix}_skipped_count": self.skipped_count, + f"{prefix}_failed_count": self.failed_count, + f"{prefix}_deliveries": [delivery.to_dict() for delivery in self.deliveries], + } + + +@dataclass(frozen=True) +class StrategyPluginSmsAlertMarkerStore: + local_dir: str | Path | None = None + gcs_prefix_uri: str | None = None + gcp_project_id: str | None = None + namespace: str = "strategy_plugin_sms_alerts" + client_factory: Any = None + + def has_alert(self, alert_key: str) -> bool: + if self.gcs_prefix_uri and self._gcs_blob(alert_key, namespace=self.namespace).exists(): + return True + if self.local_dir and self._local_path(alert_key, namespace=self.namespace).exists(): + return True + return False + + def record_alert( + self, + alert_key: str, + *, + metadata: Mapping[str, Any] | None = None, + ) -> None: + payload = { + "schema_version": "strategy_plugin_sms_alert_marker.v1", + "alert_key": str(alert_key), + "recorded_at": datetime.now(timezone.utc).isoformat(), + "metadata": dict(metadata or {}), + } + encoded = json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True) + if self.gcs_prefix_uri: + self._gcs_blob(alert_key, namespace=self.namespace).upload_from_string( + encoded, + content_type="application/json", + ) + return + if self.local_dir: + path = self._local_path(alert_key, namespace=self.namespace) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(encoded, encoding="utf-8") + + def _local_path(self, alert_key: str, *, namespace: str) -> Path: + root = Path(self.local_dir or tempfile.gettempdir()).expanduser() + return root / namespace / f"{_clean_relative_key(alert_key)}.json" + + def _gcs_blob(self, alert_key: str, *, namespace: str): + bucket_name, prefix = _parse_gcs_uri(str(self.gcs_prefix_uri or "")) + object_name = "/".join( + part.strip("/") + for part in (prefix, namespace, f"{_clean_relative_key(alert_key)}.json") + if part and part.strip("/") + ) + if self.client_factory is None: + try: + from google.cloud import storage # type: ignore + except ImportError as exc: + raise RuntimeError("google-cloud-storage is required for GCS alert markers") from exc + client_factory = storage.Client + else: + client_factory = self.client_factory + client = client_factory(project=self.gcp_project_id) if self.gcp_project_id else client_factory() + return client.bucket(bucket_name).blob(object_name) + + +def publish_strategy_plugin_sms_alerts( + signals: Sequence[object], + *, + sms_settings: StrategyPluginSmsSettings | object, + translator: Callable[..., str] | None = None, + strategy_label: str | None = None, + context_label: str | None = None, + alert_store: StrategyPluginSmsAlertMarkerStore | object | None = None, + send_notification: Callable[..., bool] = send_twilio_sms, + log_message: Callable[..., Any] = print, +) -> StrategyPluginSmsAlertPublishResult: + settings = StrategyPluginSmsSettings.from_object(sms_settings) + messages = build_strategy_plugin_alert_messages( + signals, + translator=translator, + strategy_label=strategy_label, + context_label=context_label, + alert_namespace="strategy_plugin_sms_alert", + ) + deliveries: list[StrategyPluginSmsAlertDelivery] = [] + missing_fields = settings.missing_fields() + if missing_fields: + for message in messages: + deliveries.append( + _delivery( + message, + status="skipped", + reason="missing_sms_config", + error=",".join(missing_fields), + ) + ) + result = StrategyPluginSmsAlertPublishResult(tuple(deliveries)) + _log_publish_result(result, log_message=log_message) + return result + + for message in messages: + alert_key = message.alert_key or _fallback_alert_key(message) + try: + duplicate = _store_has_alert(alert_store, alert_key) + store_error = None + except Exception as exc: + duplicate = False + store_error = f"alert_store_check_failed:{type(exc).__name__}: {exc}" + if duplicate: + deliveries.append(_delivery(message, status="skipped", reason="duplicate_alert")) + continue + sent, send_error = _send_message(send_notification, message, settings) + if not sent: + deliveries.append(_delivery(message, status="failed", reason="send_failed", error=send_error)) + continue + record_error = _store_record_error(alert_store, alert_key, message) + combined_error = "; ".join(error for error in (store_error, record_error) if error) + deliveries.append(_delivery(message, status="sent", error=combined_error or None)) + result = StrategyPluginSmsAlertPublishResult(tuple(deliveries)) + _log_publish_result(result, log_message=log_message) + return result + + +def _delivery( + message: StrategyPluginAlertMessage, + *, + status: str, + reason: str | None = None, + error: str | None = None, +) -> StrategyPluginSmsAlertDelivery: + return StrategyPluginSmsAlertDelivery( + alert_key=message.alert_key or _fallback_alert_key(message), + subject=message.subject, + status=status, + reason=reason, + error=error, + metadata=message.metadata, + ) + + +def _send_message( + send_notification: Callable[..., bool], + message: StrategyPluginAlertMessage, + settings: StrategyPluginSmsSettings, +) -> tuple[bool, str | None]: + try: + sent = send_notification( + body=_build_sms_body(message, max_chars=settings.body_max_chars), + recipients=settings.recipients, + account_sid=settings.account_id, + auth_token=settings.auth_token, + from_number=settings.sender, + messaging_service_sid=settings.messaging_service_id, + api_base_url=settings.api_base_url, + timeout=settings.timeout, + ) + except Exception as exc: + return False, f"{type(exc).__name__}: {exc}" + return bool(sent), None + + +def _build_sms_body(message: StrategyPluginAlertMessage, *, max_chars: int) -> str: + body = "\n".join(part for part in (message.subject, message.body) if str(part or "").strip()) + limit = max(80, int(max_chars or _DEFAULT_SMS_BODY_MAX_CHARS)) + if len(body) <= limit: + return body + return body[: max(0, limit - 3)].rstrip() + "..." + + +def _store_has_alert(alert_store: object | None, alert_key: str) -> bool: + if alert_store is None: + return False + checker = getattr(alert_store, "has_alert", None) + if checker is None: + return False + return bool(checker(alert_key)) + + +def _store_record_error( + alert_store: object | None, + alert_key: str, + message: StrategyPluginAlertMessage, +) -> str | None: + if alert_store is None: + return None + recorder = getattr(alert_store, "record_alert", None) + if recorder is None: + return None + try: + recorder( + alert_key, + metadata={ + "subject": message.subject, + **dict(message.metadata or {}), + }, + ) + except Exception as exc: + return f"alert_store_record_failed:{type(exc).__name__}: {exc}" + return None + + +def _log_publish_result( + result: StrategyPluginSmsAlertPublishResult, + *, + log_message: Callable[..., Any], +) -> None: + if result.attempted_count <= 0: + return + _call_log_message( + log_message, + ( + "strategy_plugin_alert_sms_result " + f"attempted={result.attempted_count} " + f"sent={result.sent_count} " + f"skipped={result.skipped_count} " + f"failed={result.failed_count}" + ), + ) + + +def _call_log_message(log_message: Callable[..., Any], text: str) -> None: + try: + log_message(text, flush=True) + except TypeError: + log_message(text) + + +def _get_value(value: object, name: str, default: Any = None) -> Any: + if isinstance(value, Mapping): + return value.get(name, default) + return getattr(value, name, default) + + +def _first_non_empty(*values: Any) -> str | None: + for value in values: + text = str(value or "").strip() + if text: + return text + return None + + +def _coerce_int(value: Any, default: int) -> int: + text = str(value or "").strip() + if not text: + return default + try: + return int(text) + except (TypeError, ValueError): + return default + + +def _fallback_alert_key(message: StrategyPluginAlertMessage) -> str: + return "strategy_plugin_sms_alert/" + _clean_relative_key(message.subject or "unknown") + + +def _clean_relative_key(value: str) -> str: + parts = [] + for raw_part in str(value or "").replace("\\", "/").split("/"): + cleaned = "".join( + char if char.isalnum() or char in {"-", "_", "."} else "-" + for char in raw_part.strip() + ).strip("-._") + if cleaned: + parts.append(cleaned[:100]) + return "/".join(parts) or "unknown" + + +def _parse_gcs_uri(uri: str) -> tuple[str, str]: + raw_uri = str(uri or "").strip() + if not raw_uri.startswith("gs://"): + raise ValueError(f"gcs uri must start with gs://, got: {uri!r}") + remainder = raw_uri[5:] + bucket_name, _, object_prefix = remainder.partition("/") + if not bucket_name: + raise ValueError(f"gcs uri must include a bucket name, got: {uri!r}") + return bucket_name, object_prefix.strip("/") diff --git a/tests/test_strategy_plugin_sms_notifications.py b/tests/test_strategy_plugin_sms_notifications.py new file mode 100644 index 0000000..0a1ad4d --- /dev/null +++ b/tests/test_strategy_plugin_sms_notifications.py @@ -0,0 +1,185 @@ +import base64 +import urllib.parse +from types import SimpleNamespace + +from quant_platform_kit.notifications.sms import parse_sms_recipients, send_twilio_sms +from quant_platform_kit.notifications.strategy_plugin_sms import ( + StrategyPluginSmsAlertMarkerStore, + StrategyPluginSmsSettings, + publish_strategy_plugin_sms_alerts, +) + + +def test_parse_sms_recipients_normalizes_and_deduplicates(): + assert parse_sms_recipients("(516) 548-0265;15165480265,+1 516 548 0265\n+8613800000000") == ( + "+15165480265", + "+8613800000000", + ) + + +def test_send_twilio_sms_uses_configured_http_request(): + observed = {} + + class FakeResponse: + status = 201 + + def __enter__(self): + return self + + def __exit__(self, *_args): + return None + + def fake_open(request, timeout): + observed["url"] = request.full_url + observed["timeout"] = timeout + observed["headers"] = dict(request.header_items()) + observed["body"] = urllib.parse.parse_qs(request.data.decode("utf-8")) + return FakeResponse() + + assert send_twilio_sms( + body="Crisis alert", + recipients=("(516) 548-0265",), + account_sid="AC123", + auth_token="secret", + from_number="+15551234567", + api_base_url="https://twilio.example.test", + timeout=3.0, + opener=fake_open, + printer=lambda *_args, **_kwargs: None, + ) + + assert observed["url"] == "https://twilio.example.test/2010-04-01/Accounts/AC123/Messages.json" + assert observed["timeout"] == 3.0 + assert observed["headers"]["Authorization"] == ( + "Basic " + base64.b64encode(b"AC123:secret").decode("ascii") + ) + assert observed["headers"]["Content-type"] == "application/x-www-form-urlencoded" + assert observed["body"] == { + "To": ["+15165480265"], + "Body": ["Crisis alert"], + "From": ["+15551234567"], + } + + +def _alert_signal(): + return SimpleNamespace( + strategy="tqqq_growth_income", + plugin="crisis_response_shadow", + effective_mode="shadow", + as_of="2026-05-24", + canonical_route="true_crisis", + suggested_action="defend", + would_trade_if_enabled=True, + ) + + +def test_publish_strategy_plugin_sms_alerts_skips_missing_config(): + observed = [] + + result = publish_strategy_plugin_sms_alerts( + [_alert_signal()], + sms_settings=StrategyPluginSmsSettings(), + strategy_label="TQQQ", + context_label="ibkr / paper / tqqq", + send_notification=lambda **_kwargs: observed.append(_kwargs) or True, + log_message=lambda *_args, **_kwargs: None, + ) + + assert result.sent_count == 0 + assert result.skipped_count == 1 + assert result.deliveries[0].reason == "missing_sms_config" + assert "CRISIS_ALERT_SMS_RECIPIENTS" in result.deliveries[0].error + assert "CRISIS_ALERT_SMS_ACCOUNT_ID" in result.deliveries[0].error + assert "CRISIS_ALERT_SMS_AUTH_TOKEN" in result.deliveries[0].error + assert "CRISIS_ALERT_SMS_SENDER or CRISIS_ALERT_SMS_MESSAGING_SERVICE_ID" in result.deliveries[0].error + assert observed == [] + + +def test_publish_strategy_plugin_sms_alerts_sends_and_records_marker(tmp_path): + observed = [] + store = StrategyPluginSmsAlertMarkerStore(local_dir=tmp_path) + + result = publish_strategy_plugin_sms_alerts( + [_alert_signal()], + sms_settings=StrategyPluginSmsSettings( + recipients=("+15165480265",), + account_id="AC123", + auth_token="secret", + sender="+15551234567", + ), + strategy_label="TQQQ", + context_label="ibkr / paper / tqqq", + alert_store=store, + send_notification=lambda **kwargs: observed.append(kwargs) or True, + log_message=lambda *_args, **_kwargs: None, + ) + + assert result.sent_count == 1 + assert result.failed_count == 0 + assert result.deliveries[0].alert_key + assert "[ibkr / paper / tqqq]" in observed[0]["body"] + assert observed[0]["recipients"] == ("+15165480265",) + assert observed[0]["account_sid"] == "AC123" + assert observed[0]["auth_token"] == "secret" + assert observed[0]["from_number"] == "+15551234567" + assert observed[0]["messaging_service_sid"] is None + assert observed[0]["api_base_url"] == "https://api.twilio.com" + assert observed[0]["timeout"] == 10.0 + assert store.has_alert(result.deliveries[0].alert_key) + + +def test_publish_strategy_plugin_sms_alerts_skips_duplicate_marker(tmp_path): + store = StrategyPluginSmsAlertMarkerStore(local_dir=tmp_path) + settings = StrategyPluginSmsSettings( + recipients=("+15165480265",), + account_id="AC123", + auth_token="secret", + sender="+15551234567", + ) + first = publish_strategy_plugin_sms_alerts( + [_alert_signal()], + sms_settings=settings, + strategy_label="TQQQ", + context_label="ibkr / paper / tqqq", + alert_store=store, + send_notification=lambda **_kwargs: True, + log_message=lambda *_args, **_kwargs: None, + ) + + second = publish_strategy_plugin_sms_alerts( + [_alert_signal()], + sms_settings=settings, + strategy_label="TQQQ", + context_label="ibkr / paper / tqqq", + alert_store=store, + send_notification=lambda **_kwargs: True, + log_message=lambda *_args, **_kwargs: None, + ) + + assert first.sent_count == 1 + assert second.sent_count == 0 + assert second.skipped_count == 1 + assert second.deliveries[0].reason == "duplicate_alert" + + +def test_sms_settings_reads_twilio_config_from_object(): + settings = StrategyPluginSmsSettings.from_object( + SimpleNamespace( + crisis_alert_sms_recipients="(516) 548-0265", + crisis_alert_sms_provider="twilio", + crisis_alert_sms_account_id="AC123", + crisis_alert_sms_auth_token="secret", + crisis_alert_sms_messaging_service_id="MG123", + crisis_alert_sms_api_base_url="https://twilio.example.test", + crisis_alert_sms_body_max_chars="160", + ) + ) + + assert settings.recipients == ("+15165480265",) + assert settings.provider == "twilio" + assert settings.account_id == "AC123" + assert settings.auth_token == "secret" + assert settings.messaging_service_id == "MG123" + assert settings.api_base_url == "https://twilio.example.test" + assert settings.body_max_chars == 160 + assert settings.missing_fields() == ()