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

113 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-11-08 17:42 +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 

8From Home Assistant 2026.11, the integration also creates a `notify` entity for 

9each channel or user set up as a subentry. One targeted is sent the message by 

10`notify.send_message`, with the title composed into it as below, since the 

11entity ignores a title - the embed, images and urls only go by the legacy action. 

12 

13Supported data keys (all optional): 

14 discord_embed dict Discord embed passthrough, forwarded as 

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

16 color, url, fields, footer, author, 

17 thumbnail, image — HA core schema). 

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

19 in YAML == 16711680); hex strings are 

20 passed through unchanged and may be 

21 rejected downstream by nextcord. 

22 discord_attach_image bool Attach camera snapshot as a local file 

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

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

25 (a single string is wrapped into a list) 

26 discord_verify_ssl bool SSL verification for `data.urls` 

27 downloads (service default: True; only 

28 forwarded when explicitly set) 

29 discord_priority_prefix bool Prefix message with an emoji derived from 

30 the SuperNotify priority (default: False): 

31 critical=siren, high=warning, 

32 low/minimum=small diamond, medium=none 

33 

34Notes on the HA `discord` notify service: 

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

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

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

38 entries are dropped with a debug log and an empty result skips the 

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

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

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

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

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

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

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

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

47 otherwise the attachment is dropped by the integration. 

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

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

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

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

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

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

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

55 opt-in emoji prefix above. 

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

57""" 

58 

59from __future__ import annotations 

60 

61import logging 

62from typing import TYPE_CHECKING, Any, ClassVar 

63 

64from homeassistant.const import ATTR_ENTITY_ID 

65 

66from custom_components.supernotify.common import boolify 

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

68from custom_components.supernotify.model import ( 

69 DebugTrace, 

70 SuppressionReason, 

71 TransportConfig, 

72 TransportFeature, 

73) 

74from custom_components.supernotify.options import MEDIA_OPTIONS, DeliveryOption 

75from custom_components.supernotify.target import TargetEntityCategory 

76from custom_components.supernotify.transport import Transport 

77 

78if TYPE_CHECKING: 

79 from custom_components.supernotify.envelope import Envelope 

80 

81_LOGGER = logging.getLogger(__name__) 

82 

83# Discord message content hard limit 

84_MAX_MESSAGE_LENGTH = 2000 

85 

86HA_DISCORD_DOMAIN = "discord" 

87 

88 

89class DiscordTransport(Transport): 

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

91 

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

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

94 

95 name = TRANSPORT_DISCORD 

96 integration_domain = HA_DISCORD_DOMAIN 

97 action_configured = True 

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 = super().default_config 

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

108 return config 

109 

110 @property 

111 def unique_target_categories(self) -> list[str | TargetEntityCategory]: 

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

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

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

115 # still validates the shape itself once it arrives. discord_channel is nonetheless a 

116 # dedicated category name no other transport uses, so a value explicitly qualified 

117 # with it is unambiguous - safe to auto-select on 

118 # the notify entity for each channel is the integration's own, claimed ahead of the 

119 # generic notify_entity transport, like ntfy's 

120 return [ATTR_DISCORD_CHANNEL, TargetEntityCategory(domain="notify", platform=HA_DISCORD_DOMAIN)] 

121 

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

123 return self.validate_notify_action(action) 

124 

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

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

127 

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

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

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

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

132 """ 

133 raw_targets: list[Any] = [ 

134 t for t in (envelope.target.resolved_targets() if envelope.target else []) if not str(t).startswith("notify.") 

135 ] 

136 channels: list[str] = [] 

137 for target in raw_targets: 

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

139 try: 

140 numeric_id = int(candidate) 

141 except ValueError: 

142 numeric_id = -1 

143 if numeric_id <= 0: 

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

145 continue 

146 if candidate not in channels: 

147 channels.append(candidate) 

148 return channels 

149 

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

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

152 

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

154 

155 # Pop Discord-specific data keys 

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

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

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

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

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

161 

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

163 channels = self.select_channels(envelope) 

164 entity_ids: list[str] = envelope.target.domain_entity_ids("notify") if envelope.target else [] 

165 if not channels and not entity_ids: 

166 _LOGGER.warning("SUPERNOTIFY discord: no valid targets (expected numeric channel or user IDs, or notify entities)") 

167 envelope.skipped = 1 

168 envelope.skip_reason = SuppressionReason.NO_TARGET 

169 return False 

170 

171 # Validate embed passthrough shape 

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

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

174 embed = None 

175 

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

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

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

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

180 message_text = self.compose_message(envelope, with_title=not embed_has_title, priority_prefix=priority_prefix) 

181 

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

183 # discord integration checks them against allowlist_external_dirs 

184 images: list[str] = [] 

185 if attach_image: 

186 image_path = await self.safe_grab_image(envelope) 

187 if image_path: 

188 images.append(str(image_path)) 

189 else: 

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

191 

192 # Normalise image URLs (allowlist_external_urls applies downstream) 

193 urls: list[str] = [] 

194 if image_urls is not None: 

195 if isinstance(image_urls, str): 

196 image_urls = [image_urls] 

197 if isinstance(image_urls, list): 

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

199 else: 

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

201 

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

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

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

205 action_data: dict[str, Any] = { 

206 "message": message_text, 

207 "target": channels, 

208 } 

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

210 if embed: 

211 service_data["embed"] = embed 

212 if images: 

213 service_data["images"] = images 

214 if urls: 

215 service_data["urls"] = urls 

216 if verify_ssl_raw is not None: 

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

218 if service_data: 

219 action_data[ATTR_DATA] = service_data 

220 

221 if raw_data: 

222 _LOGGER.debug( 

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

224 sorted(raw_data), 

225 ) 

226 

227 results: list[bool] = [] 

228 if channels: 

229 results.append(await self.call_action(envelope, action_data=action_data)) 

230 if entity_ids: 

231 if service_data: 

232 _LOGGER.debug("SUPERNOTIFY discord: notify entities only take text, not %s", sorted(service_data)) 

233 results.append( 

234 await self.call_action( 

235 envelope, 

236 qualified_action="notify.send_message", 

237 # the embed's own title isn't sent to an entity, so the title always goes in the text 

238 action_data={"message": self.compose_message(envelope, with_title=True, priority_prefix=priority_prefix)}, 

239 target_data={ATTR_ENTITY_ID: entity_ids}, 

240 ) 

241 ) 

242 return all(results) 

243 

244 def compose_message(self, envelope: Envelope, with_title: bool, priority_prefix: bool) -> str: 

245 """The title as Discord markdown, an opt-in emoji prefix mapped from the priority, and 

246 the message, truncated to the Discord content limit""" 

247 message_text = envelope.message or "" 

248 if envelope.title and with_title: 

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

250 if priority_prefix: 

251 message_text = self.priority_prefix(envelope) + message_text 

252 if len(message_text) > _MAX_MESSAGE_LENGTH: 

253 message_text = message_text[:_MAX_MESSAGE_LENGTH] 

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

255 return message_text