Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 9 additions & 6 deletions docs/strategy_plugin_runtime_contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 16 additions & 0 deletions src/quant_platform_kit/notifications/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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",
Expand All @@ -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",
]
111 changes: 111 additions & 0 deletions src/quant_platform_kit/notifications/sms.py
Original file line number Diff line number Diff line change
@@ -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
Loading