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

1from __future__ import annotations 

2 

3import logging 

4from typing import TYPE_CHECKING, Any 

5 

6from homeassistant.components.notify.const import ATTR_DATA, ATTR_TARGET 

7from homeassistant.helpers.typing import ConfigType 

8 

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) 

30 

31if TYPE_CHECKING: 

32 from custom_components.supernotify.envelope import Envelope 

33 from custom_components.supernotify.hass_api import HomeAssistantAPI 

34 

35RE_VALID_PHONE = r"^(\+\d{1,3})?\s?\(?\d{1,4}\)?[\s.-]?\d{3}[\s.-]?\d{4}$" 

36 

37_LOGGER = logging.getLogger(__name__) 

38 

39 

40class SMSTransport(Transport): 

41 name = TRANSPORT_SMS 

42 MAX_MESSAGE_LENGTH = 158 

43 

44 def __init__(self, *args: Any, **kwargs: Any) -> None: 

45 super().__init__(*args, **kwargs) 

46 

47 @property 

48 def supported_features(self) -> TransportFeature: 

49 return TransportFeature.MESSAGE | TransportFeature.TITLE 

50 

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] 

56 

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 

77 

78 @property 

79 def target_categories(self) -> list[str | TargetEntityCategory]: 

80 return [ATTR_PHONE] 

81 

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 

88 

89 def build_standard_deliveries(self, hass_api: HomeAssistantAPI) -> dict[str, ConfigType]: 

90 if self.delivery_defaults.action: 

91 return {self.name: {}} 

92 return {} 

93 

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 

97 

98 async def deliver(self, envelope: Envelope, debug_trace: DebugTrace | None = None) -> bool: 

99 _LOGGER.debug("SUPERNOTIFY notify_sms: %s", envelope.delivery_name) 

100 

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 [] 

107 

108 if not envelope.message: 

109 _LOGGER.warning("SUPERNOTIFY notify_sms: No message to send") 

110 return False 

111 

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 ) 

119 

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", {}) 

123 

124 return await self.call_action(envelope, action_data=action_data)