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
« prev ^ index » next coverage.py v7.16.2, created at 2026-11-08 17:42 +0000
1"""Discord transport for SuperNotify.
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.
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.
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
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"""
59from __future__ import annotations
61import logging
62from typing import TYPE_CHECKING, Any, ClassVar
64from homeassistant.const import ATTR_ENTITY_ID
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
78if TYPE_CHECKING:
79 from custom_components.supernotify.envelope import Envelope
81_LOGGER = logging.getLogger(__name__)
83# Discord message content hard limit
84_MAX_MESSAGE_LENGTH = 2000
86HA_DISCORD_DOMAIN = "discord"
89class DiscordTransport(Transport):
90 """Notify via Discord channels or users using Home Assistant discord integration."""
92 def __init__(self, *args: Any, **kwargs: Any) -> None:
93 super().__init__(*args, **kwargs)
95 name = TRANSPORT_DISCORD
96 integration_domain = HA_DISCORD_DOMAIN
97 action_configured = True
98 declared_options: ClassVar[list[DeliveryOption]] = [*MEDIA_OPTIONS]
100 @property
101 def supported_features(self) -> TransportFeature:
102 return TransportFeature.MESSAGE | TransportFeature.TITLE | TransportFeature.IMAGES | TransportFeature.SNAPSHOT_IMAGE
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
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)]
122 def validate_action(self, action: str | None) -> bool:
123 return self.validate_notify_action(action)
125 def select_channels(self, envelope: Envelope) -> list[str]:
126 """Filter envelope targets down to numeric Discord channel/user IDs.
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
150 async def deliver(self, envelope: Envelope, debug_trace: DebugTrace | None = None) -> bool:
151 _LOGGER.debug("SUPERNOTIFY discord %s", envelope.message)
153 raw_data: dict[str, Any] = dict(envelope.data) if envelope.data else {}
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)
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
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
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)
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")
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)
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
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 )
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)
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