Coverage for custom_components/supernotify/envelope.py: 99%

239 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-09-25 21:14 +0000

1from __future__ import annotations 

2 

3import copy 

4import logging 

5import string 

6import time 

7import typing 

8import uuid 

9from typing import Any, cast 

10 

11from homeassistant.components.notify.const import ATTR_MESSAGE, ATTR_TITLE 

12from homeassistant.helpers.template import is_template_string 

13from jinja2 import TemplateError 

14 

15from custom_components.supernotify.people import Recipient 

16from custom_components.supernotify.target import Target 

17 

18from .common import DupeCheckable 

19from .const import ( 

20 ATTR_FORCE_RESEND, 

21 ATTR_MEDIA, 

22 ATTR_MEDIA_CAMERA_ENTITY_ID, 

23 ATTR_MEDIA_CLIP_URL, 

24 ATTR_MEDIA_SNAPSHOT_URL, 

25 ATTR_MESSAGE_HTML, 

26 ATTR_PRIORITY, 

27 ATTR_SPOKEN_MESSAGE, 

28 ATTR_TIMESTAMP, 

29 PRIORITY_MEDIUM, 

30) 

31from .media_grab import grab_image 

32from .model import ( 

33 ConditionVariables, 

34 DataFilter, 

35 DeliveryCustomization, 

36 MessageOnlyPolicy, 

37 SuppressionReason, 

38 TargetRequired, 

39 TransportFeature, 

40) 

41from .options import ( 

42 OPTION_DATA_KEYS_SELECT, 

43 OPTION_MESSAGE_USAGE, 

44 OPTION_SIMPLIFY_TEXT, 

45 OPTION_STRIP_URLS, 

46) 

47 

48if typing.TYPE_CHECKING: 

49 from anyio import Path 

50 from homeassistant.core import Context as HAContext 

51 

52 from custom_components.supernotify.common import CallRecord 

53 

54 from .context import Context 

55 from .delivery import Delivery 

56 from .notification import Notification 

57 from .scenario import Scenario 

58 

59_LOGGER = logging.getLogger(__name__) 

60 

61HASH_PREP_TRANSLATION_TABLE = table = str.maketrans("", "", string.punctuation + string.digits) 

62 

63 

64class Envelope(DupeCheckable): 

65 """Wrap a notification with a specific set of targets and service data possibly customized for those targets""" 

66 

67 _SCENARIO_TEMPLATE_DIRECTIVE_KEYS = frozenset({"message_template", "title_template"}) 

68 

69 def __init__( 

70 self, 

71 delivery: Delivery, 

72 notification: Notification | None = None, 

73 target: Target | None = None, # targets only for this delivery 

74 data: dict[str, Any] | None = None, 

75 context: Context | None = None, # notification data customized for this delivery 

76 ha_context: HAContext | None = None, # calling HA service context, propagated to HA service calls 

77 ) -> None: 

78 self.target: Target = target or Target() 

79 self.context: Context | None = context 

80 self.ha_context: HAContext | None = ha_context 

81 self.delivery_name: str = delivery.name 

82 self.delivery: Delivery = delivery 

83 self._notification = notification 

84 self.notification_id = None 

85 self.media = None 

86 self.action_groups = None 

87 self.priority = PRIORITY_MEDIUM 

88 self._message: str | None = None 

89 self._title: str | None = None 

90 self.message_html: str | None = None 

91 self.spoken_message: str | None = None 

92 self.force_resend: bool = False 

93 self.data: dict[str, Any] = {} # delivery/target/scenario/action data 

94 self.actions: list[dict[str, Any]] = [] 

95 if notification: 

96 delivery_config_data: dict[str, Any] = notification.delivery_data(delivery) 

97 self._enabled_scenarios: dict[str, Scenario] = notification.enabled_scenarios 

98 # in reverse of usual logic, delivery config wins over notification data for message and title 

99 self._message = delivery_config_data.pop(ATTR_MESSAGE, notification.message) 

100 self._title = delivery_config_data.pop(ATTR_TITLE, notification._title) 

101 self.id = f"{notification.id}_{self.delivery_name}" 

102 else: 

103 # should be testing scenarios only 

104 delivery_config_data = {} 

105 self._enabled_scenarios = {} 

106 self.id = str(uuid.uuid1()) 

107 if data: 

108 self.data = copy.deepcopy(data) 

109 if delivery_config_data: 

110 # notification-level delivery override wins over scenario/delivery data 

111 self.data |= delivery_config_data 

112 else: 

113 self.data = delivery_config_data or {} 

114 

115 if notification: 

116 self.notification_id = notification.id 

117 self.media = notification.media 

118 self.action_groups = notification.action_groups 

119 self.actions = notification.actions 

120 self.priority = self.data.pop(ATTR_PRIORITY, notification.priority) 

121 self.message_html = self.data.pop(ATTR_MESSAGE_HTML, notification.message_html) 

122 self.spoken_message = self.data.pop(ATTR_SPOKEN_MESSAGE, notification.spoken_message) 

123 self.force_resend = self.data.pop(ATTR_FORCE_RESEND, notification.force_resend) 

124 

125 self.timestamp_format: str | None = self.data.pop(ATTR_TIMESTAMP, None) 

126 

127 # from this point on `self.data` has no internal Supernotify fields 

128 

129 if notification and hasattr(notification, "condition_variables"): # yeuchh 

130 self.condition_variables: ConditionVariables = notification.condition_variables 

131 else: 

132 self.condition_variables = ConditionVariables() 

133 

134 # Scenario/delivery overrides can express `data` values (e.g. volume, 

135 # volume_level, method for alexa_announce) as Jinja2 templates. These 

136 # must be resolved once, here, before any transport sees `self.data` - 

137 # previously they were only rendered for the archive copy built in 

138 # contents(), so the transport call itself received the raw template 

139 # string (#64). Keep the pre-render copy so contents() can still show 

140 # the raw template alongside the resolved value for debugging. 

141 self._raw_data: dict[str, Any] = dict(self.data) 

142 if self.context: 

143 self.data = self._render_data_templates(self.data) 

144 

145 self.message = self._compute_message() 

146 self.title = self._compute_title() 

147 

148 self.delivered: int = 0 

149 self.error_count: int = 0 

150 self.skipped: int = 0 

151 self.skip_reason: SuppressionReason | None = None 

152 self.calls: list[CallRecord] = [] 

153 self.failed_calls: list[CallRecord] = [] 

154 self.delivery_error: list[str] | None = None 

155 

156 def customize_data(self, data: dict[str, Any], prune_empty: bool = True) -> dict[str, Any]: 

157 """Return data filtered by delivery data_keys_select option, pruning empty maps by default.""" 

158 if not data: 

159 return data 

160 rules = self.delivery.options.get(OPTION_DATA_KEYS_SELECT) 

161 return DataFilter(rules).apply(data, prune_empty=prune_empty) 

162 

163 async def grab_image(self) -> Path | None: 

164 """Grab an image from a camera, snapshot URL, MQTT Image etc""" 

165 image_path: Path | None = None 

166 if self._notification: 

167 image_path = await grab_image( 

168 self._notification, self.delivery, self._notification.context, ha_context=self.ha_context 

169 ) 

170 return image_path 

171 

172 def core_action_data(self, force_message: bool = True) -> dict[str, Any]: 

173 """Build the core set of `service_data` dict to pass to underlying notify service""" 

174 # TODO: remove all logic, so only called to pre-populate `data` 

175 data: dict[str, Any] = {} 

176 # message is mandatory for notify platform 

177 if self.message is None: 

178 if force_message: 

179 data[ATTR_MESSAGE] = "" 

180 else: 

181 data[ATTR_MESSAGE] = self.message 

182 

183 if self.timestamp_format and ATTR_MESSAGE in data: 

184 data[ATTR_MESSAGE] = f"{data[ATTR_MESSAGE]} [{time.strftime(self.timestamp_format, time.localtime())}]" 

185 if self.title is not None: 

186 data[ATTR_TITLE] = self.title 

187 return data 

188 

189 def contents(self, minimal: bool = True, **_kwargs: Any) -> dict[str, typing.Any]: 

190 exclude_attrs: list[str] = ["_notification", "context", "ha_context", "condition_variables", "force_resend"] 

191 if minimal: 

192 exclude_attrs.append("delivery") 

193 features: TransportFeature = self.delivery.transport.supported_features 

194 if not features & TransportFeature.ACTIONS: 

195 exclude_attrs.extend(["actions", "action_groups"]) 

196 if not features & TransportFeature.IMAGES and not features & TransportFeature.VIDEO: 

197 exclude_attrs.append(ATTR_MEDIA) 

198 if not features & TransportFeature.MESSAGE: 

199 exclude_attrs.extend(["message_html", "message"]) 

200 if features & TransportFeature.SPOKEN: 

201 exclude_attrs.append("message_html") 

202 else: 

203 exclude_attrs.append("spoken_message") 

204 if not features & TransportFeature.TITLE: 

205 exclude_attrs.append("title") 

206 if self.delivery.target_required == TargetRequired.NEVER: 

207 exclude_attrs.append("target") 

208 

209 json_ready = {k: v for k, v in self.__dict__.items() if k not in exclude_attrs and not k.startswith("_")} 

210 json_ready["data"] = self._resolve_data_templates(self._raw_data) 

211 json_ready["calls"] = [call.contents() for call in self.calls] 

212 json_ready["failedcalls"] = [call.contents() for call in self.failed_calls] 

213 return json_ready 

214 

215 def __eq__(self, other: Any | None) -> bool: # ruff: ignore[any-type] 

216 """Specialized equality check for subset of attributes""" 

217 if other is None or not isinstance(other, Envelope): 

218 return False 

219 return bool( 

220 self.target == other.target 

221 and self.delivery_name == other.delivery_name 

222 and self.data == other.data 

223 and self.notification_id == other.notification_id 

224 ) 

225 

226 def __repr__(self) -> str: 

227 """Return a concise string representation of the Envelope. 

228 

229 The returned string includes the envelope's message, title, and delivery name 

230 in the form: Envelope(message={message},title={title},delivery={delivery_name}). 

231 

232 Primarily intended for debugging and logging; note that attribute values are 

233 inserted directly and may not be quoted or escaped. 

234 """ 

235 return f"Envelope(message={self.message},title={self.title},delivery={self.delivery_name})" 

236 

237 def record_recipient_notifications(self, recorded_person_ids: set[str]) -> None: 

238 """Update every involved recipient's notify.recipient_<name> entity (if it has one), 

239 so its state (or, on HA < 2026.3, its last_notified attribute - see 

240 RecipientNotifyEntity.record_notification()) reflects delivery regardless of which 

241 target form the caller used - a plain person_id, an email/phone/mobile override, 

242 notify.recipient_<name> itself, or anything else Recipient.initialize() folds into the 

243 same Target. Delivery target selection keeps person_ids, and generate_targets() narrows 

244 them to the recipients each envelope actually reaches (see _attach_person_ids()), so 

245 that's the one reliable link back from an arbitrary envelope to the Recipient objects 

246 it reached - see RecipientNotifyEntity.record_notification() for 

247 why this call is needed at all rather than leaving it to HA's own NotifyEntity state 

248 tracking. 

249 

250 `recorded_person_ids` are the recipients already recorded by the notification's other 

251 envelopes, which this adds to, so a recipient reached by several deliveries is only 

252 recorded once - each is a state write, and would otherwise show up in the logbook as 

253 several identical entries at the same moment.""" 

254 if self.target is None or self.context is None: 

255 return 

256 for person_id in self.target.person_ids: 

257 if person_id in recorded_person_ids: 

258 continue 

259 recipient: Recipient | None = self.context.people_registry.people.get(person_id) 

260 if recipient is not None: 

261 recorded_person_ids.add(person_id) 

262 recipient.on_notification(self.ha_context) 

263 

264 def _compute_title(self, ignore_usage: bool = False) -> str | None: 

265 # message and title reverse the usual defaulting, delivery config overrides runtime call 

266 

267 title: str | None = None 

268 message_usage = self.delivery.option_str(OPTION_MESSAGE_USAGE) 

269 if not ignore_usage and message_usage.upper() in (MessageOnlyPolicy.USE_TITLE, MessageOnlyPolicy.COMBINE_TITLE): 

270 # Message sourced from title text, title field dropped 

271 title = None 

272 else: 

273 title = self.delivery.title if self.delivery.title is not None else self._title 

274 title = self._render_scenario_templates(title, "title_template", "notification_title") 

275 if self.delivery.option_bool(OPTION_SIMPLIFY_TEXT) is True or self.delivery.option_bool(OPTION_STRIP_URLS) is True: 

276 title = self.delivery.transport.simplify(title, strip_urls=self.delivery.option_bool(OPTION_STRIP_URLS)) 

277 

278 if title is None: 

279 return None 

280 return str(title) 

281 

282 def _spoken_message(self, msg: str | None) -> str | None: 

283 """Alternative message only for spoken voice transports""" 

284 return self.spoken_message if self.spoken_message is not None else msg 

285 

286 def _compute_message(self) -> str | None: 

287 # message and title reverse the usual defaulting, delivery config overrides runtime call 

288 

289 # self._message could be top level `message` or `message` set in delivery override 

290 msg: str | None = self.delivery.message if self.delivery.message is not None else self._message 

291 if self.delivery.transport.supported_features & TransportFeature.SPOKEN: 

292 msg = self._spoken_message(msg) 

293 

294 if msg and self.context and is_template_string(msg): 

295 try: 

296 context_vars = cast("dict[str,Any]", self.condition_variables.as_dict()) if self.condition_variables else {} 

297 template = self.context.hass_api.template(msg) 

298 msg = template.async_render(variables=context_vars) 

299 except Exception as e: 

300 _LOGGER.warning("SUPERNOTIFY Rendering delivery message template for %s failed: %s", self.delivery_name, e) 

301 

302 message_usage: str = str(self.delivery.option_str(OPTION_MESSAGE_USAGE)) 

303 if message_usage.upper() == MessageOnlyPolicy.USE_TITLE: 

304 title = self._compute_title(ignore_usage=True) 

305 if title: 

306 msg = title 

307 elif message_usage.upper() == MessageOnlyPolicy.COMBINE_TITLE: 

308 title = self._compute_title(ignore_usage=True) 

309 if title: 

310 msg = f"{title} {msg}" 

311 

312 msg = self._render_scenario_templates(msg, "message_template", "notification_message") 

313 if self.delivery.option_bool(OPTION_SIMPLIFY_TEXT) is True or self.delivery.option_bool(OPTION_STRIP_URLS) is True: 

314 msg = self.delivery.transport.simplify(msg, strip_urls=self.delivery.option_bool(OPTION_STRIP_URLS)) 

315 

316 if msg is None: # keep mypy happy 

317 return None 

318 return str(msg) 

319 

320 def _render_scenario_templates(self, original: str | None, template_field: str, matching_ctx: str) -> str | None: 

321 """Apply templating to a field, like message or title""" 

322 rendered = original if original is not None else "" 

323 delivery_configs: list[DeliveryCustomization] = list( 

324 filter(None, (scenario.delivery_config(self.delivery.name) for scenario in self._enabled_scenarios.values())) 

325 ) 

326 template_formats: list[str] = [ 

327 dc.data_value(template_field) 

328 for dc in delivery_configs 

329 if dc is not None and dc.data_value(template_field) is not None 

330 ] 

331 if template_formats and self.context: 

332 if self.condition_variables: 

333 context_vars: dict[str, Any] = cast("dict[str,Any]", self.condition_variables.as_dict()) 

334 else: 

335 context_vars = {} 

336 for template_format in template_formats: 

337 context_vars[matching_ctx] = rendered 

338 try: 

339 template = self.context.hass_api.template(template_format) 

340 rendered = template.async_render(variables=context_vars) 

341 except TemplateError as e: 

342 self.error_count += 1 

343 _LOGGER.warning( 

344 "SUPERNOTIFY Rendering template %s for %s failed: %s", template_field, self.delivery.name, e 

345 ) 

346 return rendered 

347 return original 

348 

349 # DupeCheckable implementation 

350 

351 def hash(self) -> int: 

352 """Alpha hash to reduce noise from messages with timestamps or incrementing counts""" 

353 

354 def alphaize(v: str | None) -> str | None: 

355 return v.translate(HASH_PREP_TRANSLATION_TABLE) if v else v 

356 

357 message: str | None 

358 if self.delivery.transport.supported_features & TransportFeature.SPOKEN: 

359 message = self._spoken_message(self._message) 

360 else: 

361 message = self._message 

362 media = self.media or {} 

363 camera_entity_id = media.get(ATTR_MEDIA_CAMERA_ENTITY_ID) 

364 media_url = media.get(ATTR_MEDIA_CLIP_URL) or media.get(ATTR_MEDIA_SNAPSHOT_URL) 

365 return hash(( 

366 alphaize(message), 

367 alphaize(self.delivery.name), 

368 self.target.hash_resolved(), 

369 alphaize(self._title), 

370 camera_entity_id, 

371 media_url, 

372 )) 

373 

374 def _render_data_templates(self, data: dict[str, Any]) -> dict[str, Any]: 

375 """Render Jinja2 templates in delivery `data` before the transport call. 

376 

377 Scenario or delivery-level overrides can set `data` values as Jinja2 

378 template strings (see `_resolve_data_templates` for the archive/ 

379 diagnostics equivalent). Unlike that method, this returns a plain 

380 resolved dict with no `<key>_template` breadcrumbs, so it is safe to 

381 pass straight through to a transport's underlying HA service call. 

382 """ 

383 if not data or not self.context: 

384 return data 

385 context_vars = cast("dict[str, Any]", self.condition_variables.as_dict()) if self.condition_variables else {} 

386 rendered: dict[str, Any] = {} 

387 for key, value in data.items(): 

388 if key in self._SCENARIO_TEMPLATE_DIRECTIVE_KEYS: 

389 # message_template/title_template are directives consumed by 

390 # _render_scenario_templates() with its own chained render 

391 # context, not literal data values - rendering them here 

392 # would use the wrong (stale) context and produce unused junk. 

393 rendered[key] = value 

394 elif isinstance(value, str) and "{{" in value: 

395 try: 

396 rendered[key] = self.context.hass_api.template(value).async_render(variables=context_vars) 

397 except Exception as e: 

398 _LOGGER.warning( 

399 "SUPERNOTIFY Rendering delivery data template for %s.%s failed: %s", self.delivery_name, key, e 

400 ) 

401 rendered[key] = value 

402 else: 

403 rendered[key] = value 

404 return rendered 

405 

406 def _resolve_data_templates(self, data: dict[str, Any]) -> dict[str, Any]: 

407 """Resolve Jinja2 templates in data dict for archive readability. 

408 

409 Returns a copy of data with template strings replaced by their 

410 resolved values. Raw template string is preserved alongside as 

411 <key>_template for debugging. Non-template values are unchanged. 

412 """ 

413 if not data or not self.context: 

414 return data 

415 resolved: dict[str, Any] = {} 

416 context_vars = cast("dict[str, Any]", self.condition_variables.as_dict()) if self.condition_variables else {} 

417 for key, value in data.items(): 

418 if isinstance(value, str) and "{{" in value: 

419 try: 

420 rendered = self.context.hass_api.template(value).async_render(variables=context_vars) 

421 resolved[key] = rendered 

422 resolved[f"{key}_template"] = value 

423 except Exception as e: 

424 _LOGGER.debug("SUPERNOTIFY Could not resolve template for %s in %s: %s", key, self.delivery_name, e) 

425 resolved[key] = value 

426 else: 

427 resolved[key] = value 

428 return resolved