Coverage for custom_components/supernotify/transports/sms.py: 100%
60 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-11-08 17:42 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-11-08 17:42 +0000
1from __future__ import annotations
3import logging
4import unicodedata
5from typing import TYPE_CHECKING, Any
7from homeassistant.components.notify.const import ATTR_DATA, ATTR_TARGET
9from custom_components.supernotify.const import (
10 ATTR_PHONE,
11 TRANSPORT_SMS,
12)
13from custom_components.supernotify.model import (
14 DebugTrace,
15 MessageOnlyPolicy,
16 SuppressionReason,
17 TransportConfig,
18 TransportFeature,
19)
20from custom_components.supernotify.options import (
21 OPTION_MESSAGE_USAGE,
22 OPTION_SIMPLIFY_TEXT,
23 OPTION_STRIP_URLS,
24)
25from custom_components.supernotify.target import TargetEntityCategory
26from custom_components.supernotify.transport import (
27 Transport,
28)
30if TYPE_CHECKING:
31 from custom_components.supernotify.envelope import Envelope
33RE_VALID_PHONE = r"^(\+\d{1,3})?\s?\(?\d{1,4}\)?[\s.-]?\d{3}[\s.-]?\d{4}$"
35_LOGGER = logging.getLogger(__name__)
38class SMSTransport(Transport):
39 name = TRANSPORT_SMS
40 action_configured = True
41 MAX_MESSAGE_LENGTH = 158
43 def __init__(self, *args: Any, **kwargs: Any) -> None:
44 super().__init__(*args, **kwargs)
46 @property
47 def supported_features(self) -> TransportFeature:
48 return TransportFeature.MESSAGE
50 @property
51 def default_config(self) -> TransportConfig:
52 config = super().default_config
53 config.delivery_defaults.options = {
54 OPTION_SIMPLIFY_TEXT: True,
55 OPTION_STRIP_URLS: False,
56 OPTION_MESSAGE_USAGE: MessageOnlyPolicy.COMBINE_TITLE,
57 }
58 for module in (
59 "homeassistant.components.twilio_sms.notify",
60 "custom_components.mikrotik_sms.notify",
61 ):
62 action: str | None = self.hass_api.find_service("notify", module)
63 if action:
64 config.delivery_defaults.action = action
65 _LOGGER.info("SUPERNOTIFY SMS action defaults to %s", action)
66 break
67 return config
69 @property
70 def unique_target_categories(self) -> list[str | TargetEntityCategory]:
71 return [ATTR_PHONE]
73 def validate_action(self, action: str | None) -> bool:
74 """Override in subclass if transport has fixed action or doesn't require one"""
75 return action is not None
77 def simplify(self, text: str | None, strip_urls: bool = False) -> str | None:
78 """Remove emoji and other pictographic symbols, but not currency signs, brackets or maths
79 symbols as for a voice assistant, since SMS can carry these and the message needs them"""
80 if not text:
81 return None
82 if strip_urls:
83 text = self._strip_urls(text)
84 text = unicodedata.normalize("NFC", text)
85 return "".join(c for c in text if unicodedata.category(c) not in ("So", "Sk", "Mn"))
87 async def deliver(self, envelope: Envelope, debug_trace: DebugTrace | None = None) -> bool:
88 _LOGGER.debug("SUPERNOTIFY notify_sms: %s", envelope.delivery_name)
90 data: dict[str, Any] = envelope.data or {}
91 # resolved_targets(), not the typed `.phone` getter: envelope.target is already
92 # scoped to this delivery by Delivery.select_targets(), so this also picks up a
93 # `sms:`/`{sms: ...}`-qualified number that isn't shaped like a validated one
94 # (e.g. a short code)
95 mobile_numbers = envelope.target.resolved_targets() if envelope.target else []
97 if not envelope.message:
98 _LOGGER.warning("SUPERNOTIFY notify_sms: No message to send")
99 envelope.skipped = 1
100 envelope.skip_reason = SuppressionReason.INVALID_ACTION_DATA
101 return False
103 message: str = envelope.message or ""
104 if len(message) > self.MAX_MESSAGE_LENGTH:
105 _LOGGER.debug(
106 "SUPERNOTIFY notify_sms: Message too long (%d characters), truncating to %d characters",
107 len(message),
108 self.MAX_MESSAGE_LENGTH,
109 )
111 action_data = {"message": message[: self.MAX_MESSAGE_LENGTH], ATTR_TARGET: mobile_numbers}
112 if data and data.get("data"):
113 action_data[ATTR_DATA] = data.get("data", {})
115 return await self.call_action(envelope, action_data=action_data)