Coverage for custom_components/supernotify/transports/sms.py: 100%
60 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-09-25 21:14 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-09-25 21:14 +0000
1from __future__ import annotations
3import logging
4from typing import TYPE_CHECKING, Any
6from homeassistant.components.notify.const import ATTR_DATA, ATTR_TARGET
7from homeassistant.helpers.typing import ConfigType
9from custom_components.supernotify.const import (
10 ATTR_PHONE,
11 INCLUSION_DEFAULT,
12 TRANSPORT_SMS,
13)
14from custom_components.supernotify.model import (
15 DebugTrace,
16 MessageOnlyPolicy,
17 TransportConfig,
18 TransportFeature,
19)
20from custom_components.supernotify.options import (
21 OPTION_MESSAGE_USAGE,
22 OPTION_SIMPLIFY_TEXT,
23 OPTION_STRIP_URLS,
24 OPTION_UNIQUE_TARGETS,
25)
26from custom_components.supernotify.target import TargetEntityCategory
27from custom_components.supernotify.transport import (
28 Transport,
29)
31if TYPE_CHECKING:
32 from custom_components.supernotify.envelope import Envelope
33 from custom_components.supernotify.hass_api import HomeAssistantAPI
35RE_VALID_PHONE = r"^(\+\d{1,3})?\s?\(?\d{1,4}\)?[\s.-]?\d{3}[\s.-]?\d{4}$"
37_LOGGER = logging.getLogger(__name__)
40class SMSTransport(Transport):
41 name = TRANSPORT_SMS
42 MAX_MESSAGE_LENGTH = 158
44 def __init__(self, *args: Any, **kwargs: Any) -> None:
45 super().__init__(*args, **kwargs)
47 @property
48 def supported_features(self) -> TransportFeature:
49 return TransportFeature.MESSAGE | TransportFeature.TITLE
51 @property
52 def inclusion_mode(self) -> list[str]:
53 # a phone number maps cleanly to a recipient, so it's reasonable to fire on
54 # every notification by default
55 return [INCLUSION_DEFAULT]
57 @property
58 def default_config(self) -> TransportConfig:
59 config = TransportConfig()
60 config.delivery_defaults.inclusion = self.inclusion_mode
61 config.delivery_defaults.options = {
62 OPTION_SIMPLIFY_TEXT: True,
63 OPTION_STRIP_URLS: False,
64 OPTION_UNIQUE_TARGETS: True, # disable if people get multiple deliveries on same number
65 OPTION_MESSAGE_USAGE: MessageOnlyPolicy.COMBINE_TITLE,
66 }
67 for module in (
68 "homeassistant.components.twilio_sms.notify",
69 "custom_components.mikrotik_sms.notify",
70 ):
71 action: str | None = self.hass_api.find_service("notify", module)
72 if action:
73 config.delivery_defaults.action = action
74 _LOGGER.info("SUPERNOTIFY SMS action defaults to %s", action)
75 break
76 return config
78 @property
79 def target_categories(self) -> list[str | TargetEntityCategory]:
80 return [ATTR_PHONE]
82 def is_viable(self, hass_api: HomeAssistantAPI) -> bool:
83 # like validate_action() below, an explicit delivery can supply its own action
84 # regardless of whether a gateway service is discoverable here - is_viable() can't
85 # see delivery-level config, so it can't rule that out; DeliveryRegistry prunes this
86 # transport entirely once it's confirmed no delivery (explicit or auto) uses it
87 return True
89 def build_standard_deliveries(self, hass_api: HomeAssistantAPI) -> dict[str, ConfigType]:
90 if self.delivery_defaults.action:
91 return {self.name: {}}
92 return {}
94 def validate_action(self, action: str | None) -> bool:
95 """Override in subclass if transport has fixed action or doesn't require one"""
96 return action is not None
98 async def deliver(self, envelope: Envelope, debug_trace: DebugTrace | None = None) -> bool:
99 _LOGGER.debug("SUPERNOTIFY notify_sms: %s", envelope.delivery_name)
101 data: dict[str, Any] = envelope.data or {}
102 # resolved_targets(), not the typed `.phone` getter: envelope.target is already
103 # scoped to this delivery by Delivery.select_targets(), so this also picks up a
104 # `sms:`/`{sms: ...}`-qualified number that isn't shaped like a validated one
105 # (e.g. a short code)
106 mobile_numbers = envelope.target.resolved_targets() if envelope.target else []
108 if not envelope.message:
109 _LOGGER.warning("SUPERNOTIFY notify_sms: No message to send")
110 return False
112 message: str = envelope.message or ""
113 if len(message) > self.MAX_MESSAGE_LENGTH:
114 _LOGGER.debug(
115 "SUPERNOTIFY notify_sms: Message too long (%d characters), truncating to %d characters",
116 len(message),
117 self.MAX_MESSAGE_LENGTH,
118 )
120 action_data = {"message": message[: self.MAX_MESSAGE_LENGTH], ATTR_TARGET: mobile_numbers}
121 if data and data.get("data"):
122 action_data[ATTR_DATA] = data.get("data", {})
124 return await self.call_action(envelope, action_data=action_data)