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

100 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-09-01 18:25 +0000

1"""Discord transport for SuperNotify. 

2 

3Sends messages to Discord channels or users via Home Assistant's `discord` 

4integration (legacy notify platform). The service is typically 

5`notify.discord`, but the actual slug depends on the config entry name 

6(e.g. `notify.discord_2`), so any `notify.*` action is accepted. 

7 

8Supported data keys (all optional): 

9 discord_embed dict Discord embed passthrough, forwarded as 

10 service `data.embed` (title, description, 

11 color, url, fields, footer, author, 

12 thumbnail, image — HA core schema). 

13 `color` must be an integer (e.g. 0xFF0000 

14 in YAML == 16711680); hex strings are 

15 passed through unchanged and may be 

16 rejected downstream by nextcord. 

17 discord_attach_image bool Attach camera snapshot as a local file 

18 path in `data.images` (default: False) 

19 discord_image_urls list Image URLs forwarded as `data.urls` 

20 (a single string is wrapped into a list) 

21 discord_verify_ssl bool SSL verification for `data.urls` 

22 downloads (service default: True; only 

23 forwarded when explicitly set) 

24 discord_priority_prefix bool Prefix message with an emoji derived from 

25 the SuperNotify priority (default: False): 

26 critical=siren, high=warning, 

27 low/minimum=small diamond, medium=none 

28 

29Notes on the HA `discord` notify service: 

30- `target` is REQUIRED: a list of numeric Discord channel or user IDs 

31 (snowflakes, strings of digits). Without a target the service logs an 

32 error and sends nothing, so targets are pre-filtered here: non-numeric 

33 entries are dropped with a debug log and an empty result fails the 

34 delivery (TargetRequired.ALWAYS, no sensible default exists). 

35- There is no `title` field in the service schema: the title is composed 

36 into the message body as Discord markdown (`**title**` + newline + 

37 message) — but ONLY when `discord_embed` does not carry its own `title` 

38 (the embed already renders a title in that case, so the body stays plain). 

39- `data.images` is a list of LOCAL paths checked by the integration with 

40 `hass.config.is_allowed_path()`: the SuperNotify media path must be listed 

41 in `homeassistant.allowlist_external_dirs` in configuration.yaml, 

42 otherwise the attachment is dropped by the integration. 

43- `data.urls` entries are checked with `hass.config.is_allowed_external_url()`: 

44 every URL must be covered by `homeassistant.allowlist_external_urls` in 

45 configuration.yaml. The integration downloads at most 8MB per attachment. 

46- The service `data` dict is permissive and unknown keys are silently 

47 ignored downstream: for cleanliness residual generic data keys are NOT 

48 forwarded (dropped with a debug log), consistent with the Matrix transport. 

49- Discord has no native message priority: the only mapping offered is the 

50 opt-in emoji prefix above. 

51- Message content is truncated to the Discord limit of 2000 characters. 

52""" 

53 

54from __future__ import annotations 

55 

56import logging 

57from typing import TYPE_CHECKING, Any 

58 

59from custom_components.supernotify.common import boolify 

60from custom_components.supernotify.const import ATTR_DATA, TRANSPORT_DISCORD 

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

62from custom_components.supernotify.transport import Transport 

63 

64if TYPE_CHECKING: 

65 from custom_components.supernotify.envelope import Envelope 

66 

67_LOGGER = logging.getLogger(__name__) 

68 

69# Discord message content hard limit 

70_MAX_MESSAGE_LENGTH = 2000 

71 

72# Opt-in emoji prefix per SuperNotify priority (medium: no prefix) 

73_PRIORITY_PREFIX = { 

74 "critical": "\U0001f6a8 ", # police car light 

75 "high": "⚠️ ", # warning sign 

76 "low": "\U0001f539 ", # small blue diamond 

77 "minimum": "\U0001f539 ", # small blue diamond 

78} 

79 

80 

81class DiscordTransport(Transport): 

82 """Notify via Discord channels or users using Home Assistant discord integration.""" 

83 

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

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

86 

87 name = TRANSPORT_DISCORD 

88 

89 @property 

90 def supported_features(self) -> TransportFeature: 

91 return TransportFeature.MESSAGE | TransportFeature.TITLE | TransportFeature.IMAGES | TransportFeature.SNAPSHOT_IMAGE 

92 

93 @property 

94 def default_config(self) -> TransportConfig: 

95 config = TransportConfig() 

96 config.delivery_defaults.action = "notify.discord" 

97 config.delivery_defaults.target_required = TargetRequired.ALWAYS 

98 return config 

99 

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

101 """Validate that action is a notify.* service. 

102 

103 The discord integration registers a legacy notify service whose slug 

104 depends on the config entry name (notify.discord, notify.discord_2, 

105 ...), so any non-empty notify domain action is accepted. 

106 """ 

107 if action and action.startswith("notify.") and action.split(".", 1)[1]: 

108 return True 

109 _LOGGER.warning( 

110 "SUPERNOTIFY discord: action must be a notify.* service (e.g. notify.discord), got: %r", 

111 action, 

112 ) 

113 return False 

114 

115 def select_channels(self, envelope: Envelope) -> list[str]: 

116 """Filter envelope targets down to numeric Discord channel/user IDs. 

117 

118 Discord IDs are snowflakes (positive integers, passed as strings of 

119 digits). The service handles an invalid ID with a warning and moves 

120 on, but targets are cleaned here anyway: non-numeric entries are 

121 dropped with a debug log. Duplicates are removed preserving order. 

122 """ 

123 raw_targets: list[Any] = envelope.target.resolved_targets() if envelope.target else [] 

124 channels: list[str] = [] 

125 for target in raw_targets: 

126 candidate = str(target).strip() if target is not None else "" 

127 try: 

128 numeric_id = int(candidate) 

129 except ValueError: 

130 numeric_id = -1 

131 if numeric_id <= 0: 

132 _LOGGER.debug("SUPERNOTIFY discord: skipping non-numeric channel target %r", target) 

133 continue 

134 if candidate not in channels: 

135 channels.append(candidate) 

136 return channels 

137 

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

139 _LOGGER.debug("SUPERNOTIFY discord %s", envelope.message) 

140 

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

142 

143 # Pop Discord-specific data keys 

144 embed = raw_data.pop("discord_embed", None) 

145 attach_image = boolify(raw_data.pop("discord_attach_image", False), default=False) 

146 image_urls = raw_data.pop("discord_image_urls", None) 

147 verify_ssl_raw = raw_data.pop("discord_verify_ssl", None) 

148 priority_prefix = boolify(raw_data.pop("discord_priority_prefix", False), default=False) 

149 

150 # Resolve and pre-validate numeric channel/user ID targets 

151 channels = self.select_channels(envelope) 

152 if not channels: 

153 _LOGGER.warning("SUPERNOTIFY discord: no valid targets (expected numeric channel or user IDs)") 

154 self.record_error("no valid Discord channel or user ID targets", "deliver") 

155 return False 

156 

157 # Validate embed passthrough shape 

158 if embed is not None and not isinstance(embed, dict): 

159 _LOGGER.warning("SUPERNOTIFY discord: discord_embed must be a dict, ignoring %r", embed) 

160 embed = None 

161 

162 # Compose title into the message body as Discord markdown (the 

163 # service has no title field), unless the embed carries its own 

164 # title (the embed then renders the title and the body stays plain). 

165 embed_has_title = bool(embed and embed.get("title")) 

166 message_text = envelope.message or "" 

167 if envelope.title and not embed_has_title: 

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

169 

170 # Opt-in emoji prefix mapped from SuperNotify priority 

171 if priority_prefix: 

172 message_text = _PRIORITY_PREFIX.get(envelope.priority or "medium", "") + message_text 

173 

174 # Truncate to the Discord content limit 

175 if len(message_text) > _MAX_MESSAGE_LENGTH: 

176 message_text = message_text[:_MAX_MESSAGE_LENGTH] 

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

178 

179 # Grab camera snapshot if requested; images are local paths and the 

180 # discord integration checks them against allowlist_external_dirs 

181 images: list[str] = [] 

182 if attach_image: 

183 image_path = None 

184 try: 

185 image_path = await envelope.grab_image() 

186 except Exception as e: 

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

188 if image_path: 

189 images.append(str(image_path)) 

190 else: 

191 _LOGGER.debug("SUPERNOTIFY discord: no image available, sending text only") 

192 

193 # Normalise image URLs (allowlist_external_urls applies downstream) 

194 urls: list[str] = [] 

195 if image_urls is not None: 

196 if isinstance(image_urls, str): 

197 image_urls = [image_urls] 

198 if isinstance(image_urls, list): 

199 urls = [str(u) for u in image_urls if u] 

200 else: 

201 _LOGGER.warning("SUPERNOTIFY discord: discord_image_urls must be a list, ignoring %r", image_urls) 

202 

203 # Build the payload. The service data dict is whitelist-only here 

204 # (embed / images / urls / verify_ssl): residual generic data keys 

205 # are NOT merged, the service would silently ignore them anyway. 

206 action_data: dict[str, Any] = { 

207 "message": message_text, 

208 "target": channels, 

209 } 

210 service_data: dict[str, Any] = {} 

211 if embed: 

212 service_data["embed"] = embed 

213 if images: 

214 service_data["images"] = images 

215 if urls: 

216 service_data["urls"] = urls 

217 if verify_ssl_raw is not None: 

218 service_data["verify_ssl"] = boolify(verify_ssl_raw, default=True) 

219 if service_data: 

220 action_data[ATTR_DATA] = service_data 

221 

222 if raw_data: 

223 _LOGGER.debug( 

224 "SUPERNOTIFY discord: dropping data keys not supported by the service data whitelist: %s", 

225 sorted(raw_data), 

226 ) 

227 

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