Skip to content

Commit 2de98b2

Browse files
authored
Separate Google Voice alert configuration (#51)
1 parent 4305a3c commit 2de98b2

3 files changed

Lines changed: 64 additions & 55 deletions

File tree

docs/strategy_plugin_runtime_contract.md

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -124,13 +124,23 @@ when any of the following is true:
124124
- `suggested_action` is `defend` or `blocked`
125125
- `would_trade_if_enabled` is `true`
126126

127-
Platforms may still choose their delivery sinks, but Google Voice escalation over
128-
SMTP should use
127+
Platforms may still choose their delivery sinks, but Google Voice escalation via
128+
the Gmail-to-Google-Voice SMS gateway should use
129129
`quant_platform_kit.notifications.strategy_plugin_google_voice.publish_strategy_plugin_google_voice_alerts()`.
130130
The publisher builds the shared subject/body, prefixes platform context, returns
131131
structured sent/skipped/failed diagnostics, and can use
132132
`StrategyPluginGoogleVoiceAlertMarkerStore` to skip alert keys that were already
133-
sent. Platforms should expose this as Google Voice notification config, not as a
134-
generic email alert surface.
133+
sent.
134+
135+
Platforms should expose this as Google Voice notification config, not as a
136+
generic email alert surface. The public configuration names should be channel
137+
specific:
138+
139+
- `CRISIS_ALERT_GOOGLE_VOICE_GATEWAY`
140+
- `CRISIS_ALERT_GOOGLE_VOICE_GMAIL_USER`
141+
- `CRISIS_ALERT_GOOGLE_VOICE_GMAIL_APP_PASSWORD`
142+
143+
Future direct email notifications should use a separate namespace such as
144+
`CRISIS_ALERT_EMAIL_*`.
135145
This keeps the Crisis Response plugin behavior consistent across IBKR, Schwab,
136146
LongBridge, Firstrade, and future platform runtimes.

src/quant_platform_kit/notifications/strategy_plugin_google_voice.py

Lines changed: 22 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -20,39 +20,35 @@
2020

2121
@dataclass(frozen=True)
2222
class StrategyPluginGoogleVoiceSettings:
23-
smtp_host: str | None = None
24-
smtp_port: int = 587
25-
sender: str | None = None
26-
recipients: tuple[str, ...] = ()
27-
username: str | None = None
28-
password: str | None = field(default=None, repr=False)
29-
use_starttls: bool = True
30-
use_ssl: bool = False
23+
gateway_recipients: tuple[str, ...] = ()
24+
gmail_user: str | None = None
25+
gmail_app_password: str | None = field(default=None, repr=False)
26+
smtp_host: str = "smtp.gmail.com"
27+
smtp_port: int = 465
28+
use_starttls: bool = False
29+
use_ssl: bool = True
3130
timeout: float = 10.0
3231

3332
@classmethod
3433
def from_object(cls, value: object) -> "StrategyPluginGoogleVoiceSettings":
3534
if isinstance(value, cls):
3635
return value
3736
return cls(
38-
smtp_host=_get_value(value, "crisis_alert_smtp_host"),
39-
smtp_port=int(_get_value(value, "crisis_alert_smtp_port", 587) or 587),
40-
sender=_first_non_empty(_get_value(value, "crisis_alert_smtp_from")),
41-
recipients=tuple(parse_email_recipients(_get_value(value, "crisis_alert_google_voice_to", ()))),
42-
username=_get_value(value, "crisis_alert_smtp_username"),
43-
password=_get_value(value, "crisis_alert_smtp_password"),
44-
use_starttls=_coerce_bool(_get_value(value, "crisis_alert_smtp_starttls", True), default=True),
45-
use_ssl=_coerce_bool(_get_value(value, "crisis_alert_smtp_ssl", False), default=False),
37+
gateway_recipients=tuple(
38+
parse_email_recipients(_get_value(value, "crisis_alert_google_voice_gateway", ()))
39+
),
40+
gmail_user=_first_non_empty(_get_value(value, "crisis_alert_google_voice_gmail_user")),
41+
gmail_app_password=_get_value(value, "crisis_alert_google_voice_gmail_app_password"),
4642
)
4743

4844
def missing_fields(self) -> tuple[str, ...]:
4945
missing: list[str] = []
50-
if not str(self.smtp_host or "").strip():
51-
missing.append("CRISIS_ALERT_SMTP_HOST")
52-
if not str(self.sender or "").strip():
53-
missing.append("CRISIS_ALERT_SMTP_FROM")
54-
if not parse_email_recipients(self.recipients):
55-
missing.append("CRISIS_ALERT_GOOGLE_VOICE_TO")
46+
if not parse_email_recipients(self.gateway_recipients):
47+
missing.append("CRISIS_ALERT_GOOGLE_VOICE_GATEWAY")
48+
if not str(self.gmail_user or "").strip():
49+
missing.append("CRISIS_ALERT_GOOGLE_VOICE_GMAIL_USER")
50+
if not str(self.gmail_app_password or "").strip():
51+
missing.append("CRISIS_ALERT_GOOGLE_VOICE_GMAIL_APP_PASSWORD")
5652
return tuple(missing)
5753

5854
@property
@@ -282,10 +278,10 @@ def _send_message(
282278
body=message.body,
283279
smtp_host=settings.smtp_host,
284280
smtp_port=settings.smtp_port,
285-
sender=settings.sender,
286-
recipients=settings.recipients,
287-
username=settings.username,
288-
password=settings.password,
281+
sender=settings.gmail_user,
282+
recipients=settings.gateway_recipients,
283+
username=settings.gmail_user,
284+
password=settings.gmail_app_password,
289285
use_starttls=settings.use_starttls,
290286
use_ssl=settings.use_ssl,
291287
timeout=settings.timeout,
@@ -367,17 +363,6 @@ def _first_non_empty(*values: Any) -> str | None:
367363
return None
368364

369365

370-
def _coerce_bool(value: Any, *, default: bool) -> bool:
371-
if value is None:
372-
return default
373-
if isinstance(value, bool):
374-
return value
375-
text = str(value).strip().lower()
376-
if not text:
377-
return default
378-
return text in {"1", "true", "yes", "y", "on"}
379-
380-
381366
def _fallback_alert_key(message: StrategyPluginAlertMessage) -> str:
382367
return "strategy_plugin_google_voice_alert/" + _clean_relative_key(message.subject or "unknown")
383368

tests/test_google_voice_notifications.py

Lines changed: 28 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,9 @@ def test_publish_strategy_plugin_google_voice_alerts_skips_missing_config():
8686
assert result.sent_count == 0
8787
assert result.skipped_count == 1
8888
assert result.deliveries[0].reason == "missing_google_voice_config"
89-
assert "CRISIS_ALERT_SMTP_HOST" in result.deliveries[0].error
89+
assert "CRISIS_ALERT_GOOGLE_VOICE_GATEWAY" in result.deliveries[0].error
90+
assert "CRISIS_ALERT_GOOGLE_VOICE_GMAIL_USER" in result.deliveries[0].error
91+
assert "CRISIS_ALERT_GOOGLE_VOICE_GMAIL_APP_PASSWORD" in result.deliveries[0].error
9092
assert observed == []
9193

9294

@@ -97,9 +99,9 @@ def test_publish_strategy_plugin_google_voice_alerts_sends_and_records_marker(tm
9799
result = publish_strategy_plugin_google_voice_alerts(
98100
[_alert_signal()],
99101
google_voice_settings=StrategyPluginGoogleVoiceSettings(
100-
smtp_host="smtp.example.com",
101-
sender="bot@example.com",
102-
recipients=("risk@example.com",),
102+
gateway_recipients=("risk@example.com",),
103+
gmail_user="bot@example.com",
104+
gmail_app_password="app-password",
103105
),
104106
strategy_label="TQQQ",
105107
context_label="ibkr / paper / tqqq",
@@ -112,15 +114,23 @@ def test_publish_strategy_plugin_google_voice_alerts_sends_and_records_marker(tm
112114
assert result.failed_count == 0
113115
assert result.deliveries[0].alert_key
114116
assert "[ibkr / paper / tqqq]" in observed[0]["subject"]
117+
assert observed[0]["smtp_host"] == "smtp.gmail.com"
118+
assert observed[0]["smtp_port"] == 465
119+
assert observed[0]["sender"] == "bot@example.com"
120+
assert observed[0]["recipients"] == ("risk@example.com",)
121+
assert observed[0]["username"] == "bot@example.com"
122+
assert observed[0]["password"] == "app-password"
123+
assert observed[0]["use_starttls"] is False
124+
assert observed[0]["use_ssl"] is True
115125
assert store.has_alert(result.deliveries[0].alert_key)
116126

117127

118128
def test_publish_strategy_plugin_google_voice_alerts_skips_duplicate_marker(tmp_path):
119129
store = StrategyPluginGoogleVoiceAlertMarkerStore(local_dir=tmp_path)
120130
settings = StrategyPluginGoogleVoiceSettings(
121-
smtp_host="smtp.example.com",
122-
sender="bot@example.com",
123-
recipients=("risk@example.com",),
131+
gateway_recipients=("risk@example.com",),
132+
gmail_user="bot@example.com",
133+
gmail_app_password="app-password",
124134
)
125135
first = publish_strategy_plugin_google_voice_alerts(
126136
[_alert_signal()],
@@ -148,16 +158,20 @@ def test_publish_strategy_plugin_google_voice_alerts_skips_duplicate_marker(tmp_
148158
assert second.deliveries[0].reason == "duplicate_alert"
149159

150160

151-
def test_google_voice_settings_read_google_voice_names_only():
161+
def test_google_voice_settings_reads_google_voice_gmail_names_only():
152162
settings = StrategyPluginGoogleVoiceSettings.from_object(
153163
SimpleNamespace(
154-
crisis_alert_smtp_host="smtp.gmail.com",
155-
crisis_alert_smtp_from="sender@gmail.com",
156-
crisis_alert_google_voice_to="gateway@txt.voice.google.com",
157-
crisis_alert_smtp_username="sender@gmail.com",
164+
crisis_alert_google_voice_gateway="gateway@txt.voice.google.com",
165+
crisis_alert_google_voice_gmail_user="sender@gmail.com",
166+
crisis_alert_google_voice_gmail_app_password="app-password",
158167
)
159168
)
160169

161-
assert settings.sender == "sender@gmail.com"
162-
assert settings.recipients == ("gateway@txt.voice.google.com",)
170+
assert settings.gmail_user == "sender@gmail.com"
171+
assert settings.gateway_recipients == ("gateway@txt.voice.google.com",)
172+
assert settings.gmail_app_password == "app-password"
173+
assert settings.smtp_host == "smtp.gmail.com"
174+
assert settings.smtp_port == 465
175+
assert settings.use_ssl is True
176+
assert settings.use_starttls is False
163177
assert settings.missing_fields() == ()

0 commit comments

Comments
 (0)