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

181 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-06-11 22:18 +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 

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.transport import Transport 

40 

41if TYPE_CHECKING: 

42 from custom_components.supernotify.envelope import Envelope 

43 

44_LOGGER = logging.getLogger(__name__) 

45 

46_PRIORITY_MAP = { 

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

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

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

50 "low": True, # silent 

51 "minimum": True, # silent 

52} 

53 

54# Telegram Bot API limits 

55_MAX_MESSAGE_LENGTH = 4096 

56_MAX_CAPTION_LENGTH = 1024 

57 

58 

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

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

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

62 

63 

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

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

66 

67 Accepts: 

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

69 2-element lists. Returned unchanged. 

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

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

72 HA shape. 

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

74 

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

76 """ 

77 if not isinstance(keyboard, list): 

78 return [] 

79 out: list = [] 

80 for row in keyboard: 

81 if not isinstance(row, list): 

82 continue 

83 out_row: list = [] 

84 for btn in row: 

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

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

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

88 elif isinstance(btn, dict): 

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

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

91 if label and callback: 

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

93 if out_row: 

94 out.append(out_row) 

95 return out 

96 

97 

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

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

100 

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

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

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

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

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

106 

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

108 """ 

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

110 return [] 

111 

112 row = [] 

113 for i, action in enumerate(actions): 

114 if i >= 5: 

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

116 break 

117 

118 if not isinstance(action, dict): 

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

120 continue 

121 

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

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

124 # craft the action list manually. 

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

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

127 

128 if not label or not callback: 

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

130 continue 

131 

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

133 callback_str = str(callback) 

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

135 if len(encoded) > 64: 

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

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

138 

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

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

141 

142 return [row] if row else [] 

143 

144 

145class TelegramTransport(Transport): 

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

147 

148 name = TRANSPORT_TELEGRAM 

149 

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

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

152 

153 @property 

154 def supported_features(self) -> TransportFeature: 

155 return ( 

156 TransportFeature.MESSAGE 

157 | TransportFeature.TITLE 

158 | TransportFeature.IMAGES 

159 | TransportFeature.ACTIONS 

160 | TransportFeature.SNAPSHOT_IMAGE 

161 ) 

162 

163 @property 

164 def default_config(self) -> TransportConfig: 

165 config = TransportConfig() 

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

167 config.delivery_defaults.target_required = TargetRequired.ALWAYS 

168 return config 

169 

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

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

172 if not action: 

173 return False 

174 return action in ( 

175 "telegram_bot.send_message", 

176 "telegram_bot.send_photo", 

177 "telegram_bot.send_document", 

178 ) 

179 

180 async def deliver(self, envelope: Envelope, debug_trace: DebugTrace | None = None) -> bool: # noqa: ARG002 

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

182 

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

184 

185 # Pop Telegram-specific data keys 

186 parse_mode = raw_data.pop("telegram_parse_mode", None) 

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

188 # `telegram_protect_content` is accepted but currently NOT forwarded 

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

190 # it out of the residual raw_data merge below. 

191 raw_data.pop("telegram_protect_content", None) 

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

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

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

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

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

197 

198 # Resolve target chat_id. 

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

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

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

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

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

204 raw_target: Any = chat_id_override 

205 if not raw_target and envelope.delivery: 

206 raw_target = envelope.delivery.target 

207 

208 # Target object: extract first id from preferred categories 

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

210 categorised = raw_target.targets 

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

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

213 picked = None 

214 for cat in preferred: 

215 if categorised.get(cat): 

216 picked = categorised[cat][0] 

217 break 

218 if not picked: 

219 for v in categorised.values(): 

220 if isinstance(v, list) and v: 

221 picked = v[0] 

222 break 

223 raw_target = picked 

224 

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

226 if isinstance(raw_target, dict): 

227 for v in raw_target.values(): 

228 if isinstance(v, list) and v: 

229 raw_target = v[0] 

230 break 

231 if v: 

232 raw_target = v 

233 break 

234 elif isinstance(raw_target, list) and raw_target: 

235 raw_target = raw_target[0] 

236 

237 if not raw_target: 

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

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

240 return False 

241 

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

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

244 # are not currently supported by this transport. 

245 try: 

246 chat_id: int = int(raw_target) 

247 except (TypeError, ValueError): 

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

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

250 return False 

251 

252 # Validate and normalise parse_mode. The HA telegram_bot service 

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

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

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

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

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

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

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

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

261 if parse_mode: 

262 normalised = str(parse_mode).lower() 

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

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

265 parse_mode = "plain_text" 

266 else: 

267 parse_mode = normalised 

268 else: 

269 parse_mode = "plain_text" 

270 

271 # Map priority to disable_notification boolean 

272 if disable_notification_override is not None: 

273 disable_notification = boolify(disable_notification_override, default=False) 

274 else: 

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

276 

277 # Build message text with title if present 

278 message_text = envelope.message or "" 

279 if envelope.title: 

280 if parse_mode == "html": 

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

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

283 # plain text) and prepend it bolded. 

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

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

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

287 else: 

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

289 

290 # Truncate message to Telegram limit 

291 if len(message_text) > _MAX_MESSAGE_LENGTH: 

292 message_text = message_text[:_MAX_MESSAGE_LENGTH] 

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

294 

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

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

297 # user passes a Telegram Bot API native dict shape 

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

299 inline_keyboard = None 

300 if custom_keyboard: 

301 inline_keyboard = _normalise_inline_keyboard(custom_keyboard) 

302 elif envelope.actions: 

303 inline_keyboard = _build_inline_keyboard(envelope.actions) 

304 

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

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

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

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

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

310 

311 # Determine if we have an image to attach 

312 image_path = None 

313 if attach_image: 

314 try: 

315 image_path = await envelope.grab_image() 

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

317 except Exception as e: 

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

319 image_path = None 

320 

321 # Select service and build service-specific payload 

322 service_action = "telegram_bot.send_message" 

323 

324 if image_path and image_as_document: 

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

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

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

328 service_action = "telegram_bot.send_document" 

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

330 if message_text: 

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

332 if parse_mode: 

333 action_data["parse_mode"] = parse_mode 

334 elif image_path: 

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

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

337 service_action = "telegram_bot.send_photo" 

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

339 if message_text: 

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

341 if parse_mode: 

342 action_data["parse_mode"] = parse_mode 

343 else: 

344 # Send text message 

345 action_data["message"] = message_text 

346 if parse_mode: 

347 action_data["parse_mode"] = parse_mode 

348 

349 # Add optional parameters 

350 if disable_notification: 

351 action_data["disable_notification"] = True 

352 

353 # protect_content is intentionally NOT forwarded - the HA 

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

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

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

357 

358 if reply_to_message_id: 

359 action_data["reply_to_message_id"] = reply_to_message_id 

360 

361 if inline_keyboard: 

362 action_data["inline_keyboard"] = inline_keyboard 

363 

364 # Merge remaining generic data keys 

365 action_data.update(raw_data) 

366 

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

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

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

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

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

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

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

374 return await self.call_action( 

375 envelope, 

376 qualified_action=service_action, 

377 action_data=action_data, 

378 implied_target=True, 

379 )