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

114 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-09-25 21:14 +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, ClassVar 

58 

59from homeassistant.helpers.typing import ConfigType 

60 

61from custom_components.supernotify.common import boolify 

62from custom_components.supernotify.const import ATTR_DATA, ATTR_DISCORD_CHANNEL, TRANSPORT_DISCORD 

63from custom_components.supernotify.model import ( 

64 DebugTrace, 

65 TargetRequired, 

66 TransportConfig, 

67 TransportFeature, 

68) 

69from custom_components.supernotify.options import MEDIA_OPTIONS, DeliveryOption 

70from custom_components.supernotify.target import TargetEntityCategory 

71from custom_components.supernotify.transport import Transport 

72 

73if TYPE_CHECKING: 

74 from custom_components.supernotify.envelope import Envelope 

75 from custom_components.supernotify.hass_api import HomeAssistantAPI 

76 

77_LOGGER = logging.getLogger(__name__) 

78 

79# Discord message content hard limit 

80_MAX_MESSAGE_LENGTH = 2000 

81 

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

83_PRIORITY_PREFIX = { 

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

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

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

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

88} 

89 

90 

91class DiscordTransport(Transport): 

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

93 

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

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

96 

97 name = TRANSPORT_DISCORD 

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

99 

100 @property 

101 def supported_features(self) -> TransportFeature: 

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

103 

104 @property 

105 def default_config(self) -> TransportConfig: 

106 config = TransportConfig() 

107 config.delivery_defaults.action = self.hass_api.find_service("notify", "homeassistant.components.discord.notify") 

108 config.delivery_defaults.target_required = TargetRequired.ALWAYS 

109 config.delivery_defaults.inclusion = self.inclusion_mode 

110 return config 

111 

112 @property 

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

114 # a numeric channel/user snowflake ID has no shape distinct enough for automatic 

115 # matching, so it's only ever reachable here via explicit qualification (prefix, 

116 # mapping, or this transport's/a delivery's own name) - select_channels() below 

117 # still validates the shape itself once it arrives 

118 return [ATTR_DISCORD_CHANNEL] 

119 

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

121 # like validate_action() below, an explicit delivery can supply its own notify.* 

122 # action regardless of whether the service is discoverable here - is_viable() can't 

123 # see delivery-level config, so it can't rule that out; DeliveryRegistry prunes this 

124 # transport entirely once it's confirmed no delivery (explicit or auto) uses it 

125 return True 

126 

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

128 if self.delivery_defaults.action: 

129 return {self.name: {}} 

130 return {} 

131 

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

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

134 

135 The discord integration registers a legacy notify service whose slug 

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

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

138 """ 

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

140 return True 

141 _LOGGER.warning( 

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

143 action, 

144 ) 

145 return False 

146 

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

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

149 

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

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

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

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

154 """ 

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

156 channels: list[str] = [] 

157 for target in raw_targets: 

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

159 try: 

160 numeric_id = int(candidate) 

161 except ValueError: 

162 numeric_id = -1 

163 if numeric_id <= 0: 

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

165 continue 

166 if candidate not in channels: 

167 channels.append(candidate) 

168 return channels 

169 

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

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

172 

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

174 

175 # Pop Discord-specific data keys 

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

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

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

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

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

181 

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

183 channels = self.select_channels(envelope) 

184 if not channels: 

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

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

187 return False 

188 

189 # Validate embed passthrough shape 

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

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

192 embed = None 

193 

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

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

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

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

198 message_text = envelope.message or "" 

199 if envelope.title and not embed_has_title: 

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

201 

202 # Opt-in emoji prefix mapped from SuperNotify priority 

203 if priority_prefix: 

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

205 

206 # Truncate to the Discord content limit 

207 if len(message_text) > _MAX_MESSAGE_LENGTH: 

208 message_text = message_text[:_MAX_MESSAGE_LENGTH] 

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

210 

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

212 # discord integration checks them against allowlist_external_dirs 

213 images: list[str] = [] 

214 if attach_image: 

215 image_path = None 

216 try: 

217 image_path = await envelope.grab_image() 

218 except Exception as e: 

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

220 if image_path: 

221 images.append(str(image_path)) 

222 else: 

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

224 

225 # Normalise image URLs (allowlist_external_urls applies downstream) 

226 urls: list[str] = [] 

227 if image_urls is not None: 

228 if isinstance(image_urls, str): 

229 image_urls = [image_urls] 

230 if isinstance(image_urls, list): 

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

232 else: 

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

234 

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

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

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

238 action_data: dict[str, Any] = { 

239 "message": message_text, 

240 "target": channels, 

241 } 

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

243 if embed: 

244 service_data["embed"] = embed 

245 if images: 

246 service_data["images"] = images 

247 if urls: 

248 service_data["urls"] = urls 

249 if verify_ssl_raw is not None: 

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

251 if service_data: 

252 action_data[ATTR_DATA] = service_data 

253 

254 if raw_data: 

255 _LOGGER.debug( 

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

257 sorted(raw_data), 

258 ) 

259 

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