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
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-01 18:25 +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
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
64if TYPE_CHECKING:
65 from custom_components.supernotify.envelope import Envelope
67_LOGGER = logging.getLogger(__name__)
69# Discord message content hard limit
70_MAX_MESSAGE_LENGTH = 2000
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}
81class DiscordTransport(Transport):
82 """Notify via Discord channels or users using Home Assistant discord integration."""
84 def __init__(self, *args: Any, **kwargs: Any) -> None:
85 super().__init__(*args, **kwargs)
87 name = TRANSPORT_DISCORD
89 @property
90 def supported_features(self) -> TransportFeature:
91 return TransportFeature.MESSAGE | TransportFeature.TITLE | TransportFeature.IMAGES | TransportFeature.SNAPSHOT_IMAGE
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
100 def validate_action(self, action: str | None) -> bool:
101 """Validate that action is a notify.* service.
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
115 def select_channels(self, envelope: Envelope) -> list[str]:
116 """Filter envelope targets down to numeric Discord channel/user IDs.
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
138 async def deliver(self, envelope: Envelope, debug_trace: DebugTrace | None = None) -> bool:
139 _LOGGER.debug("SUPERNOTIFY discord %s", envelope.message)
141 raw_data: dict[str, Any] = dict(envelope.data) if envelope.data else {}
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)
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
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
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}"
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
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)
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")
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)
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
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 )
228 return await self.call_action(envelope, action_data=action_data)