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
« prev ^ index » next coverage.py v7.15.4, created at 2026-09-25 21:14 +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.
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
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"""
54from __future__ import annotations
56import logging
57from typing import TYPE_CHECKING, Any, ClassVar
59from homeassistant.helpers.typing import ConfigType
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
73if TYPE_CHECKING:
74 from custom_components.supernotify.envelope import Envelope
75 from custom_components.supernotify.hass_api import HomeAssistantAPI
77_LOGGER = logging.getLogger(__name__)
79# Discord message content hard limit
80_MAX_MESSAGE_LENGTH = 2000
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}
91class DiscordTransport(Transport):
92 """Notify via Discord channels or users using Home Assistant discord integration."""
94 def __init__(self, *args: Any, **kwargs: Any) -> None:
95 super().__init__(*args, **kwargs)
97 name = TRANSPORT_DISCORD
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 = 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
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]
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
127 def build_standard_deliveries(self, hass_api: HomeAssistantAPI) -> dict[str, ConfigType]:
128 if self.delivery_defaults.action:
129 return {self.name: {}}
130 return {}
132 def validate_action(self, action: str | None) -> bool:
133 """Validate that action is a notify.* service.
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
147 def select_channels(self, envelope: Envelope) -> list[str]:
148 """Filter envelope targets down to numeric Discord channel/user IDs.
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
170 async def deliver(self, envelope: Envelope, debug_trace: DebugTrace | None = None) -> bool:
171 _LOGGER.debug("SUPERNOTIFY discord %s", envelope.message)
173 raw_data: dict[str, Any] = dict(envelope.data) if envelope.data else {}
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)
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
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
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}"
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
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)
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")
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)
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
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 )
260 return await self.call_action(envelope, action_data=action_data)