Coverage for custom_components/supernotify/transports/telegram.py: 100%

187 statements  

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

1"""Telegram transport for SuperNotify. 

2 

3Sends push notifications via Telegram using Home Assistant's telegram_bot integration. 

4Supports text messages, photos (with optional captions), and inline action buttons. 

5Uses telegram_bot service for granular control over formatting, media types, and protection. 

6 

7Supported data keys (all optional): 

8 telegram_parse_mode str "HTML" | "Markdown" (default: None, plain text) 

9 telegram_disable_notification bool Override silent mode (overrides priority mapping) 

10 telegram_protect_content bool Block forward/save (default: False) 

11 telegram_chat_id str|int Override target chat_id (default: None) 

12 telegram_reply_to_message_id int Reply to message ID (default: None) 

13 telegram_inline_keyboard list Custom action buttons in [[{text, callback_data}]] format 

14 telegram_attach_image bool Attach camera snapshot (default: False) 

15 telegram_image_as_document bool Send image as document without compression (default: False) 

16 

17Notes on the HA telegram_bot service schema: 

18- `parse_mode` accepts only lowercase values: 'html', 'markdown', 

19 'markdownv2', 'plain_text'. We normalise the user-provided value to 

20 lowercase before forwarding. 

21- `protect_content` is a Telegram Bot API parameter but the HA 

22 `telegram_bot` integration does not currently expose it as a service-data 

23 key (voluptuous rejects it as `extra keys not allowed`). We accept the 

24 data key for forward-compatibility but do NOT forward it to the service. 

25- `inline_keyboard` for HA is a list of rows where each row is a list of 

26 `[label, callback_or_url]` 2-element lists (NOT dicts with 

27 `text`/`callback_data` keys). Example: `[[["OK","/ok"],["Cancel","/cancel"]]]`. 

28""" 

29 

30from __future__ import annotations 

31 

32import html 

33import logging 

34from typing import TYPE_CHECKING, Any, ClassVar, cast 

35 

36from custom_components.supernotify.common import boolify 

37from custom_components.supernotify.const import TRANSPORT_TELEGRAM 

38from custom_components.supernotify.model import DebugTrace, TargetRequired, TransportConfig, TransportFeature 

39from custom_components.supernotify.options import MEDIA_OPTIONS, DeliveryOption 

40from custom_components.supernotify.transport import Transport 

41 

42if TYPE_CHECKING: 

43 from custom_components.supernotify.envelope import Envelope 

44 from custom_components.supernotify.hass_api import HomeAssistantAPI 

45from homeassistant.helpers.typing import ConfigType 

46 

47_LOGGER = logging.getLogger(__name__) 

48 

49HA_TELEGRAM_BOT_DOMAIN = "telegram_bot" 

50 

51_PRIORITY_MAP = { 

52 "critical": False, # notify (sound + vibration) 

53 "high": False, # notify (sound) 

54 "medium": False, # notify (sound) 

55 "low": True, # silent 

56 "minimum": True, # silent 

57} 

58 

59# Telegram Bot API limits 

60_MAX_MESSAGE_LENGTH = 4096 

61_MAX_CAPTION_LENGTH = 1024 

62 

63 

64def _escape_html(text: str) -> str: 

65 """Escape HTML special characters for Telegram HTML parse mode.""" 

66 return html.escape(text) if text else "" 

67 

68 

69def _normalise_inline_keyboard(keyboard: list) -> list: 

70 """Normalise a user-supplied inline keyboard to the HA telegram_bot shape. 

71 

72 Accepts: 

73 - HA-native: list of rows where each row is a list of `[label, callback]` 

74 2-element lists. Returned unchanged. 

75 - Telegram Bot API native: list of rows where each row is a list of 

76 `{"text": label, "callback_data": callback}` dicts. Converted to the 

77 HA shape. 

78 - Mixed rows are tolerated (each button is normalised independently). 

79 

80 Returns the keyboard in the HA shape, or `[]` if the input is malformed. 

81 """ 

82 if not isinstance(keyboard, list): 

83 return [] 

84 out: list = [] 

85 for row in keyboard: 

86 if not isinstance(row, list): 

87 continue 

88 out_row: list = [] 

89 for btn in row: 

90 if isinstance(btn, list) and len(btn) >= 2: 

91 # Already in HA shape: [label, callback_or_url] 

92 out_row.append([str(btn[0]), str(btn[1])]) 

93 elif isinstance(btn, dict): 

94 label = btn.get("text") or btn.get("title") or btn.get("label") 

95 callback = btn.get("callback_data") or btn.get("action") or btn.get("url") 

96 if label and callback: 

97 out_row.append([str(label), str(callback)]) 

98 if out_row: 

99 out.append(out_row) 

100 return out 

101 

102 

103def _build_inline_keyboard(actions: list) -> list: 

104 """Convert SuperNotify actions to HA telegram_bot inline keyboard format. 

105 

106 SuperNotify actions are dicts with keys "title" (button label) 

107 and "action" (callback identifier). The HA telegram_bot service expects 

108 a list of rows where each row is a list of [label, callback_or_url] 

109 2-element lists (NOT dicts with text/callback_data keys). 

110 Example shape: [[["OK", "/ack_ok"], ["Open HA", "/open"]]] 

111 

112 Limits to max 5 buttons (single row) and 64-byte callback_data. 

113 """ 

114 if not actions or not isinstance(actions, list): 

115 return [] 

116 

117 row = [] 

118 for i, action in enumerate(actions): 

119 if i >= 5: 

120 _LOGGER.warning("SUPERNOTIFY telegram: more than 5 actions, truncating to 5") 

121 break 

122 

123 if not isinstance(action, dict): 

124 _LOGGER.warning("SUPERNOTIFY telegram: action not a dict, skipped") 

125 continue 

126 

127 # SuperNotify action keys: "title" for label, "action" for callback id. 

128 # Also tolerate Telegram-native "text"/"callback_data" for users who 

129 # craft the action list manually. 

130 label = action.get("title") or action.get("text") or action.get("label") 

131 callback = action.get("action") or action.get("callback_data") or action.get("id") 

132 

133 if not label or not callback: 

134 _LOGGER.warning("SUPERNOTIFY telegram: action missing 'title' or 'action' key, skipped") 

135 continue 

136 

137 # Truncate callback_data to 64 bytes UTF-8 per Telegram API 

138 callback_str = str(callback) 

139 encoded = callback_str.encode("utf-8") 

140 if len(encoded) > 64: 

141 callback_str = encoded[:64].decode("utf-8", errors="ignore") 

142 _LOGGER.warning("SUPERNOTIFY telegram: callback_data truncated to 64 bytes: %s", callback_str) 

143 

144 # HA format: [label, callback_or_url] 2-element list 

145 row.append([str(label), callback_str]) 

146 

147 return [row] if row else [] 

148 

149 

150class TelegramTransport(Transport): 

151 """Notify via Telegram using Home Assistant telegram_bot integration.""" 

152 

153 name = TRANSPORT_TELEGRAM 

154 declared_options: ClassVar[list[DeliveryOption]] = [*MEDIA_OPTIONS] 

155 

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

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

158 

159 @property 

160 def supported_features(self) -> TransportFeature: 

161 return ( 

162 TransportFeature.MESSAGE 

163 | TransportFeature.TITLE 

164 | TransportFeature.IMAGES 

165 | TransportFeature.ACTIONS 

166 | TransportFeature.SNAPSHOT_IMAGE 

167 ) 

168 

169 @property 

170 def default_config(self) -> TransportConfig: 

171 config = TransportConfig() 

172 config.delivery_defaults.action = "telegram_bot.send_message" 

173 config.delivery_defaults.target_required = TargetRequired.ALWAYS 

174 config.delivery_defaults.inclusion = self.inclusion_mode 

175 return config 

176 

177 def is_viable(self, hass_api: HomeAssistantAPI) -> bool: 

178 return hass_api.find_config_entry_data(HA_TELEGRAM_BOT_DOMAIN) is not None 

179 

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

181 return {self.name: {}} 

182 

183 def validate_action(self, action: str | None) -> bool: 

184 """Validate that action is one of the supported telegram_bot services.""" 

185 if not action: 

186 return False 

187 return action in ( 

188 "telegram_bot.send_message", 

189 "telegram_bot.send_photo", 

190 "telegram_bot.send_document", 

191 ) 

192 

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

194 _LOGGER.debug("SUPERNOTIFY telegram %s", envelope.message) 

195 

196 raw_data: dict[str, Any] = dict(envelope.data) if envelope.data else {} 

197 

198 # Pop Telegram-specific data keys 

199 parse_mode: str | None = raw_data.pop("telegram_parse_mode", None) 

200 disable_notification_override = raw_data.pop("telegram_disable_notification", None) 

201 # `telegram_protect_content` is accepted but currently NOT forwarded 

202 # because the HA telegram_bot service schema rejects it. Pop to keep 

203 # it out of the residual raw_data merge below. 

204 raw_data.pop("telegram_protect_content", None) 

205 chat_id_override = raw_data.pop("telegram_chat_id", None) 

206 reply_to_message_id = raw_data.pop("telegram_reply_to_message_id", None) 

207 custom_keyboard = raw_data.pop("telegram_inline_keyboard", None) 

208 attach_image = boolify(raw_data.pop("telegram_attach_image", False), default=False) 

209 image_as_document = boolify(raw_data.pop("telegram_image_as_document", False), default=False) 

210 

211 # Resolve target chat_id. 

212 # `envelope.delivery.target` is a SuperNotify `Target` object with a 

213 # `.targets` attribute (dict[category, list[id]]), where category is 

214 # ATTR_PHONE, ATTR_EMAIL, ATTR_MOBILE_APP_ID, etc. For Telegram we 

215 # prefer `phone` (Telegram chat IDs are stored there by convention). 

216 # Also accept legacy raw shapes: dict, list, or scalar string/int. 

217 raw_target: Any = chat_id_override 

218 if not raw_target and envelope.delivery: 

219 # TODO: this should probably be envelope.target like all the other transports 

220 raw_target = envelope.delivery.target 

221 

222 # Target object: extract first id from preferred categories 

223 if hasattr(raw_target, "targets") and isinstance(raw_target.targets, dict): 

224 categorised = raw_target.targets 

225 # Prefer phone (Telegram convention), then any non-empty list 

226 preferred = ("phone", "chat_id") 

227 picked = None 

228 for cat in preferred: 

229 if categorised.get(cat): 

230 picked = categorised[cat][0] 

231 break 

232 if not picked: 

233 for v in categorised.values(): 

234 if isinstance(v, list) and v: 

235 picked = v[0] 

236 break 

237 raw_target = picked 

238 

239 # Legacy dict shape (pre-Target object): pick first list value 

240 if isinstance(raw_target, dict): 

241 for v in raw_target.values(): 

242 if isinstance(v, list) and v: 

243 raw_target = v[0] 

244 break 

245 if v: 

246 raw_target = v 

247 break 

248 elif isinstance(raw_target, list) and raw_target: 

249 raw_target = raw_target[0] 

250 

251 if not raw_target: 

252 _LOGGER.warning("SUPERNOTIFY telegram: chat_id not configured in delivery data") 

253 self.record_error("chat_id not configured", "deliver") 

254 return False 

255 

256 # telegram_bot service expects int (or list of int). Group chat IDs are 

257 # negative integers; channel usernames (@channel) would be string but 

258 # are not currently supported by this transport. 

259 try: 

260 chat_id: int = int(cast("str | int", raw_target)) 

261 except (TypeError, ValueError): 

262 _LOGGER.warning("SUPERNOTIFY telegram: chat_id %r is not numeric (expected int)", raw_target) 

263 self.record_error(f"chat_id {raw_target!r} not numeric", "deliver") 

264 return False 

265 

266 # Validate and normalise parse_mode. The HA telegram_bot service 

267 # schema accepts only lowercase values: html / markdown / markdownv2 

268 # / plain_text. We accept the camel-cased aliases the user may type 

269 # (HTML, Markdown, MarkdownV2) and normalise. 

270 # If the user does NOT specify a parse_mode, we default to plain_text 

271 # rather than relying on the telegram_bot service default (markdown), 

272 # because plain text bodies often contain `_` or `*` characters 

273 # (e.g. "protect_content") that markdown would interpret as opening 

274 # italic/bold and trigger "Can't parse entities" errors at Telegram. 

275 if parse_mode: 

276 normalised = str(parse_mode).lower() 

277 if normalised not in ("html", "markdown", "markdownv2", "plain_text"): 

278 _LOGGER.warning("SUPERNOTIFY telegram: invalid parse_mode '%s', ignoring", parse_mode) 

279 parse_mode = "plain_text" 

280 else: 

281 parse_mode = normalised 

282 else: 

283 parse_mode = "plain_text" 

284 

285 # Map priority to disable_notification boolean 

286 if disable_notification_override is not None: 

287 disable_notification = boolify(disable_notification_override, default=False) 

288 else: 

289 disable_notification = _PRIORITY_MAP.get(envelope.priority or "medium", False) 

290 

291 # Build message text with title if present 

292 message_text = envelope.message or "" 

293 if envelope.title: 

294 if parse_mode == "html": 

295 # The body may already contain HTML the user wrote intentionally 

296 # (e.g. <b>...</b>). Only escape the title (which is typically 

297 # plain text) and prepend it bolded. 

298 message_text = f"<b>{_escape_html(envelope.title)}</b>\n\n{message_text}" 

299 elif parse_mode in ("markdown", "markdownv2"): 

300 message_text = f"*{envelope.title}*\n\n{message_text}" 

301 else: 

302 message_text = f"{envelope.title}\n\n{message_text}" 

303 

304 # Truncate message to Telegram limit 

305 if len(message_text) > _MAX_MESSAGE_LENGTH: 

306 message_text = message_text[:_MAX_MESSAGE_LENGTH] 

307 _LOGGER.debug("SUPERNOTIFY telegram: message truncated to %d chars", _MAX_MESSAGE_LENGTH) 

308 

309 # Convert actions to inline keyboard. The HA telegram_bot service 

310 # expects rows of [label, callback_or_url] 2-element lists. If the 

311 # user passes a Telegram Bot API native dict shape 

312 # ([[{text, callback_data}, ...], ...]), normalise it on the fly. 

313 inline_keyboard = None 

314 if custom_keyboard: 

315 inline_keyboard = _normalise_inline_keyboard(custom_keyboard) 

316 elif envelope.actions: 

317 inline_keyboard = _build_inline_keyboard(envelope.actions) 

318 

319 # Build base action data. The HA telegram_bot service schema uses 

320 # `target` (list of int chat IDs) as the primary recipient field; we 

321 # pass a single-element list for one chat. send_message/send_photo/ 

322 # send_document all accept the same `target` key. 

323 action_data: dict[str, Any] = {"target": [chat_id]} 

324 

325 # Determine if we have an image to attach 

326 image_path = None 

327 if attach_image: 

328 try: 

329 image_path = await envelope.grab_image() 

330 _LOGGER.debug("SUPERNOTIFY telegram: image grabbed at %s", image_path) 

331 except Exception as e: 

332 _LOGGER.warning("SUPERNOTIFY telegram: failed to grab image: %s", e) 

333 image_path = None 

334 

335 # Select service and build service-specific payload 

336 service_action = "telegram_bot.send_message" 

337 

338 if image_path and image_as_document: 

339 # Send image as document (no compression). The telegram_bot HA 

340 # service schema uses `file` for both send_photo and send_document 

341 # (local path or http(s) URL). 

342 service_action = "telegram_bot.send_document" 

343 action_data["file"] = str(image_path) 

344 if message_text: 

345 action_data["caption"] = message_text[:_MAX_CAPTION_LENGTH] 

346 action_data["parse_mode"] = parse_mode 

347 elif image_path: 

348 # Send image as photo with caption (`file` is the schema field 

349 # name; the telegram_bot service infers content-type). 

350 service_action = "telegram_bot.send_photo" 

351 action_data["file"] = str(image_path) 

352 if message_text: 

353 action_data["caption"] = message_text[:_MAX_CAPTION_LENGTH] 

354 action_data["parse_mode"] = parse_mode 

355 else: 

356 # Send text message 

357 action_data["message"] = message_text 

358 action_data["parse_mode"] = parse_mode 

359 

360 # Add optional parameters 

361 if disable_notification: 

362 action_data["disable_notification"] = True 

363 

364 # protect_content is intentionally NOT forwarded - the HA 

365 # telegram_bot service schema does not accept this key (voluptuous 

366 # rejects it as `extra keys not allowed`). Kept as a documented data 

367 # key for forward-compatibility once HA exposes it. 

368 

369 if reply_to_message_id: 

370 action_data["reply_to_message_id"] = reply_to_message_id 

371 

372 if inline_keyboard: 

373 action_data["inline_keyboard"] = inline_keyboard 

374 

375 # Merge remaining generic data keys 

376 action_data.update(raw_data) 

377 

378 # Use the base-class call_action() to invoke the dynamically chosen 

379 # telegram_bot service (send_message / send_photo / send_document). 

380 # call_action() handles call-record tracking, error capture, and the 

381 # envelope.delivered/calls bookkeeping that SuperNotify uses to 

382 # decide success vs. fallback. `implied_target=True` tells SuperNotify 

383 # the target is implied by the in-payload chat_id (so the 

384 # `TargetRequired.ALWAYS` check does not skip the delivery). 

385 return await self.call_action( 

386 envelope, 

387 qualified_action=service_action, 

388 action_data=action_data, 

389 implied_target=True, 

390 )