diff --git a/README.md b/README.md index db889af..5dc2bb9 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,20 @@ # Matrix Messenger for Home Assistant -Send messages from Home Assistant to [Matrix](https://matrix.org) rooms — including encrypted rooms (E2EE). Ask questions and react to text replies or emoji reactions in automations. +Send messages from Home Assistant to [Matrix](https://matrix.org) rooms — including encrypted rooms (E2EE). Ask questions and react to text replies or emoji reactions in automations. Supports mautrix bridges (WhatsApp, Signal, Telegram) out of the box. --- ## Features -- **Send messages** to one or more Matrix rooms via service call or `notify.*` entity -- **Ask questions** — send a question to a room and wait for a reply (text or emoji reaction) +- **Send messages** to configured rooms via service call or `notify.*` entity +- **Per-room services** — one dedicated action per room, selectable by friendly name +- **Direct messages** — send to any Matrix user or mautrix bridge contact without pre-configuring a room +- **Ask questions** — send a question and wait for a text reply or emoji reaction +- **mautrix bridge support** — WhatsApp, Signal, Telegram via `send_to_user` - **E2EE support** — end-to-end encrypted rooms via [matrix-nio](https://github.com/poljar/matrix-nio) +- **Searchable room picker** — filterable dropdown in all actions and config dialogs - **Two auth methods** — username + password, or access token -- **Fully GUI-configurable** — no YAML required, all settings via the HA config flow -- **HACS-installable** +- **Fully GUI-configurable** — no YAML required --- @@ -73,7 +76,7 @@ Retrieve your token in your Matrix client: **Settings → Security → Sessions ### Step 3 — Room Selection -All rooms the account has joined are listed. Select one or more rooms. +All rooms the account has joined are listed with their display names. Select one or more rooms using the searchable dropdown. **Enable background sync** — when enabled, the integration polls Matrix every 5 seconds continuously. Required if you want to receive replies to questions without triggering `ask_question` first. @@ -81,19 +84,32 @@ All rooms the account has joined are listed. Select one or more rooms. ## Reconfiguring rooms -Go to **Settings → Devices & Services → Matrix Messenger → Configure** to change the selected rooms or toggle background sync at any time. +Go to **Settings → Devices & Services → Matrix Messenger → Configure** to change the selected rooms or toggle background sync at any time. Rooms you have since left will no longer appear in the list and are removed from the configuration when you save. --- ## Services / Actions +### `matrix_messenger.send_to_` + +One dedicated action is created for each configured room. No `room_id` parameter needed — just type the message. + +**Example** (room named "Wohnzimmer"): +```yaml +action: matrix_messenger.send_to_wohnzimmer +data: + message: "Waschmaschine fertig." +``` + +--- + ### `matrix_messenger.send_message` -Sends a plain-text message to a room. +Sends a plain-text message to a room selected from a searchable dropdown. | Parameter | Type | Required | Description | |---|---|---|---| -| `room_id` | string | ✓ | Matrix room ID, e.g. `!abc123:matrix.org` | +| `room_id` | string | ✓ | Selected from configured rooms | | `message` | string | ✓ | Message text | **Example:** @@ -106,13 +122,54 @@ data: --- +### `matrix_messenger.send_to_user` + +Sends a direct message to a Matrix user. Searches all joined rooms for one that contains the target user; if none is found, a new DM room is created. + +Works with **mautrix bridge puppets** (WhatsApp, Signal, Telegram) as long as a portal room for that contact already exists (i.e. you have chatted with the contact before via the bridge). + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `user_id` | string | ✓ | Matrix user ID, e.g. `@user:matrix.org` | +| `message` | string | ✓ | Message text | + +**Example — native Matrix user:** +```yaml +action: matrix_messenger.send_to_user +data: + user_id: "@marc:matrix.org" + message: "Alarm triggered!" +``` + +**Example — WhatsApp contact via mautrix-whatsapp:** +```yaml +action: matrix_messenger.send_to_user +data: + user_id: "@whatsapp_4917612345678:your.server" + message: "Doorbell rang." +``` + +#### Bridge puppet ID format + +| Bridge | User ID format | +|---|---| +| WhatsApp | `@whatsapp_:` | +| Signal | `@signal_:` | +| Telegram | `@telegram_:` | + +Find the exact puppet ID in your Matrix client (e.g. Element) under the contact's profile → Matrix ID. + +> **Note:** Messages sent via bridge puppets appear to the recipient as coming from your Matrix account — this is how mautrix bridges work by design. + +--- + ### `matrix_messenger.ask_question` Sends a question to a room and waits for a reply. Once a matching reply arrives, the event `matrix_messenger_response` is fired. After the timeout (default 30 min) the question expires silently. | Parameter | Type | Required | Default | Description | |---|---|---|---|---| -| `room_id` | string | ✓ | — | Matrix room ID | +| `room_id` | string | ✓ | — | Selected from configured rooms | | `question` | string | ✓ | — | Question text | | `options` | list of strings | | `[]` | If set, only these exact replies (or emoji reactions) are accepted | | `timeout` | integer (seconds) | | `1800` | How long to wait. Min 60, max 7200 | @@ -137,11 +194,6 @@ data: timeout: 300 ``` -The integration appends the options to the message: -> Alarm triggered — is this a false alarm? -> -> Mögliche Antworten: Yes / No - --- ## Notify entities @@ -174,7 +226,7 @@ Fired when a reply to an `ask_question` call is received. | Attribute | Type | Description | |---|---|---| -| `question_id` | string (UUID) | Unique ID of the question (from the service call) | +| `question_id` | string (UUID) | Unique ID of the question | | `room_id` | string | Matrix room ID where the reply was received | | `response` | string | The reply text or emoji | | `response_type` | `"text"` or `"emoji"` | How the reply was sent | @@ -193,9 +245,8 @@ automation: entity_id: binary_sensor.front_door_motion to: "on" action: - - action: matrix_messenger.send_message + - action: matrix_messenger.send_to_wohnzimmer data: - room_id: "!abc123:matrix.org" message: "Motion detected at the front door." ``` @@ -211,7 +262,6 @@ automation: entity_id: alarm_control_panel.home to: "triggered" action: - # 1. Send the question — store question_id for matching - action: matrix_messenger.ask_question data: room_id: "!abc123:matrix.org" @@ -220,7 +270,6 @@ automation: - "Yes" - "No" timeout: 300 - # 2. Wait for the response event - wait_for_trigger: - platform: event event_type: matrix_messenger_response @@ -228,7 +277,6 @@ automation: room_id: "!abc123:matrix.org" timeout: "00:05:00" continue_on_timeout: true - # 3. Branch - choose: - conditions: - condition: template @@ -298,8 +346,10 @@ The integration stores matrix-nio's E2EE keys (Olm/Megolm sessions) in: |---|---|---| | `ImportError: libolm` on startup | `libolm` not installed | `apt install libolm-dev` (HA Core only) | | Rooms list is empty after login | Account not joined to any rooms | Join at least one room in your Matrix client first | +| Room names show as IDs | Stale `.pyc` cache on network share | Delete `__pycache__/` in the component folder and restart HA | | Encrypted messages not decrypted | Keys missing (first sync) | Wait for the first full sync to complete after setup | -| `ask_question` never fires event | Sync not running | Enable *background sync* in the integration options, or check that no timeout expired | +| `ask_question` never fires event | Sync not running | Enable *background sync* in options, or check that no timeout expired | +| `send_to_user` fails for bridge contact | No portal room exists yet | Start one conversation with the contact in Element first | --- diff --git a/custom_components/matrix_messenger/__init__.py b/custom_components/matrix_messenger/__init__.py index da4c7be..2f64228 100644 --- a/custom_components/matrix_messenger/__init__.py +++ b/custom_components/matrix_messenger/__init__.py @@ -7,6 +7,7 @@ from __future__ import annotations import asyncio import logging +import re import time import uuid from dataclasses import dataclass, field @@ -50,8 +51,10 @@ class PendingQuestion: @dataclass class MatrixEntryData: client: MatrixClient + display_names: dict[str, str] = field(default_factory=dict) pending_questions: dict[str, PendingQuestion] = field(default_factory=dict) sync_task: asyncio.Task | None = None + room_service_names: list[str] = field(default_factory=list) # ------------------------------------------------------------------ @@ -75,7 +78,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: device_id=entry.data.get(CONF_DEVICE_ID, ""), ) - data = MatrixEntryData(client=client) + # Fetch room display names via direct state API (independent of sync state) + stored_rooms = _effective_rooms(entry) + try: + display_names = await client.async_get_room_names(list(stored_rooms.keys())) + except Exception: + _LOGGER.debug("Could not fetch room display names – falling back to stored names") + display_names = dict(stored_rooms) + + data = MatrixEntryData(client=client, display_names=display_names) hass.data[DOMAIN][entry.entry_id] = data async def on_message(room: Any, event: Any) -> None: @@ -106,10 +117,22 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: _cancel_sync(data) await data.client.async_close() - for service_name in ("send_message", "ask_question"): + all_services = ["send_message", "ask_question", "send_to_user"] + if data: + all_services.extend(data.room_service_names) + for service_name in all_services: if hass.services.has_service(DOMAIN, service_name): hass.services.async_remove(DOMAIN, service_name) + # Remove injected descriptions from cache so stale entries don't linger + try: + from homeassistant.loader import SERVICE_DESCRIPTION_CACHE as _cache_key + except ImportError: + _cache_key = "service_description_cache" + cache = hass.data.get(_cache_key, {}) + for svc in ["send_message", "ask_question"] + (data.room_service_names if data else []): + cache.pop((DOMAIN, svc), None) + await hass.config_entries.async_unload_platforms(entry, PLATFORMS) return True @@ -124,6 +147,96 @@ async def _async_options_updated(hass: HomeAssistant, entry: ConfigEntry) -> Non # ------------------------------------------------------------------ +def _inject_service_descriptions( + hass: HomeAssistant, + rooms: dict[str, str], + room_service_names: list[str], + display_names: dict[str, str] | None = None, +) -> None: + """Inject dynamic service descriptions so HA shows friendly dropdowns and text fields.""" + try: + from homeassistant.loader import SERVICE_DESCRIPTION_CACHE as _cache_key + except ImportError: + _cache_key = "service_description_cache" # legacy fallback + + cache: dict = hass.data.setdefault(_cache_key, {}) + labels = display_names or {} + + msg_field = { + "name": "Nachricht", + "description": "Der zu sendende Text.", + "required": True, + "selector": {"text": {"multiline": True}}, + } + room_options = [ + {"value": rid, "label": labels.get(rid) or stored or rid} + for rid, stored in rooms.items() + ] + room_field = { + "name": "Raum", + "description": "Wähle einen konfigurierten Matrix-Raum.", + "required": True, + "selector": {"select": {"options": room_options, "mode": "dropdown"}}, + } + + cache[(DOMAIN, "send_message")] = { + "name": "Matrix-Nachricht senden", + "description": "Sendet eine Textnachricht an einen konfigurierten Matrix-Raum.", + "fields": {"room_id": room_field, "message": msg_field}, + } + cache[(DOMAIN, "ask_question")] = { + "name": "Frage in Matrix-Raum stellen", + "description": ( + "Sendet eine Frage und wartet auf Antwort (Text oder Emoji-Reaktion). " + "Löst das Event 'matrix_messenger_response' aus." + ), + "fields": { + "room_id": room_field, + "question": { + "name": "Frage", + "description": "Der Fragetext.", + "required": True, + "selector": {"text": {"multiline": True}}, + }, + "options": { + "name": "Antwortoptionen", + "description": "Optionale Liste gültiger Antworten. Leer = jede Antwort.", + "required": False, + "selector": {"object": {}}, + }, + "timeout": { + "name": "Timeout (Sekunden)", + "description": "Wartezeit. Standard: 1800 s (30 Minuten).", + "required": False, + "default": DEFAULT_QUESTION_TIMEOUT, + "selector": { + "number": {"min": 60, "max": 7200, "step": 60, "unit_of_measurement": "s", "mode": "box"} + }, + }, + }, + } + + for service_name in room_service_names: + slug = service_name[len("send_to_"):] + room_id = next( + (rid for rid, name in rooms.items() if _room_slug(name) == slug), + None, + ) + label = (labels.get(room_id) or rooms.get(room_id) or slug) if room_id else slug + cache[(DOMAIN, service_name)] = { + "name": f"Matrix → {label}", + "description": f'Sendet eine Nachricht an den Matrix-Raum "{label}".', + "fields": {"message": msg_field}, + } + + +def _room_slug(name: str) -> str: + """Convert a room display name to a valid HA service name fragment.""" + slug = name.lower() + slug = re.sub(r"[^a-z0-9]+", "_", slug).strip("_") + return slug or "room" + + def _register_services( hass: HomeAssistant, entry: ConfigEntry, data: MatrixEntryData ) -> None: @@ -144,7 +257,6 @@ def _register_services( options: list[str] = call.data.get("options", []) timeout: int = call.data.get("timeout", DEFAULT_QUESTION_TIMEOUT) - # Assemble full message text text = question if options: text = f"{question}\n\nMögliche Antworten: {' / '.join(options)}" @@ -160,13 +272,19 @@ def _register_services( ) _LOGGER.debug("Frage %s wartet auf Antwort in Raum %s", qid, room_id) - # Start a temporary sync loop if none is running if data.sync_task is None or data.sync_task.done(): data.sync_task = hass.async_create_background_task( _sync_loop(hass, entry, data, stop_when_idle=True), name=f"{DOMAIN}_sync_{entry.entry_id}", ) + async def handle_send_to_user(call: ServiceCall) -> None: + user_id: str = call.data["user_id"] + message: str = call.data["message"] + success = await data.client.async_send_to_user(user_id, message) + if not success: + _LOGGER.error("Direktnachricht an %s konnte nicht gesendet werden", user_id) + hass.services.async_register( DOMAIN, "send_message", @@ -195,6 +313,50 @@ def _register_services( ), ) + hass.services.async_register( + DOMAIN, + "send_to_user", + handle_send_to_user, + schema=vol.Schema( + { + vol.Required("user_id"): str, + vol.Required("message"): str, + } + ), + ) + + # Per-room convenience services: matrix_messenger.send_to_ + # (registered first so room_service_names is populated before injection) + used_slugs: set[str] = set() + for room_id, room_name in rooms.items(): + base = _room_slug(room_name) + slug = base + counter = 2 + while slug in used_slugs: + slug = f"{base}_{counter}" + counter += 1 + used_slugs.add(slug) + + service_name = f"send_to_{slug}" + data.room_service_names.append(service_name) + + def _make_handler(rid: str, rname: str): + async def handler(call: ServiceCall) -> None: + msg: str = call.data["message"] + success = await data.client.async_send_message(rid, msg) + if not success: + _LOGGER.error("Nachricht an %s (%s) konnte nicht gesendet werden", rname, rid) + return handler + + hass.services.async_register( + DOMAIN, + service_name, + _make_handler(room_id, room_name), + schema=vol.Schema({vol.Required("message"): str}), + ) + + _inject_service_descriptions(hass, rooms, data.room_service_names, data.display_names) + # ------------------------------------------------------------------ # Background sync loop diff --git a/custom_components/matrix_messenger/config_flow.py b/custom_components/matrix_messenger/config_flow.py index 6047a3d..4de0592 100644 --- a/custom_components/matrix_messenger/config_flow.py +++ b/custom_components/matrix_messenger/config_flow.py @@ -203,38 +203,67 @@ class MatrixMessengerConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): async def async_step_rooms(self, user_input=None): errors: dict[str, str] = {} + has_rooms = bool(self._available_rooms) + if user_input is not None: selected = user_input.get(CONF_ROOMS, []) - self._data[CONF_ROOMS] = { - rid: self._available_rooms[rid] + manual_raw = user_input.get("manual_room_ids", "").strip() + + rooms: dict[str, str] = { + rid: self._available_rooms.get(rid, rid) for rid in selected - if rid in self._available_rooms + if rid } - self._data[CONF_ENABLE_SYNC] = user_input.get(CONF_ENABLE_SYNC, False) - return self.async_create_entry( - title=self._data.get(CONF_USERNAME, self._data[CONF_HOMESERVER]), - data=self._data, + for raw in manual_raw.replace(",", " ").split(): + rid = raw.strip() + if rid: + rooms[rid] = rid + + if not rooms: + errors["base"] = "no_rooms" + else: + self._data[CONF_ROOMS] = rooms + self._data[CONF_ENABLE_SYNC] = user_input.get(CONF_ENABLE_SYNC, False) + return self.async_create_entry( + title=self._data.get(CONF_USERNAME, self._data[CONF_HOMESERVER]), + data=self._data, + ) + + schema_dict: dict = {} + + if has_rooms: + room_options = [ + selector.SelectOptionDict(value=rid, label=name) + for rid, name in self._available_rooms.items() + ] + schema_dict[vol.Required(CONF_ROOMS)] = selector.SelectSelector( + selector.SelectSelectorConfig( + options=room_options, + multiple=True, + mode=selector.SelectSelectorMode.DROPDOWN, + ) + ) + schema_dict[vol.Optional("manual_room_ids", default="")] = selector.TextSelector( + selector.TextSelectorConfig(multiline=False) + ) + else: + schema_dict[vol.Required("manual_room_ids")] = selector.TextSelector( + selector.TextSelectorConfig(multiline=True) ) - room_options = [ - selector.SelectOptionDict(value=rid, label=f"{name} ({rid})") - for rid, name in self._available_rooms.items() - ] + schema_dict[vol.Optional(CONF_ENABLE_SYNC, default=False)] = selector.BooleanSelector() + + placeholders = {} + if not has_rooms: + placeholders["hint"] = ( + "Keine Räume gefunden. Raum-IDs eingeben (z.B. !abc123:chat.example.com), " + "mehrere mit Komma oder Leerzeichen trennen." + ) return self.async_show_form( step_id="rooms", - data_schema=vol.Schema( - { - vol.Required(CONF_ROOMS): selector.SelectSelector( - selector.SelectSelectorConfig( - options=room_options, - multiple=True, - mode=selector.SelectSelectorMode.LIST, - ) - ), - vol.Optional(CONF_ENABLE_SYNC, default=False): selector.BooleanSelector(), - } - ), + data_schema=vol.Schema(schema_dict), + description_placeholders=placeholders if placeholders else None, errors=errors, ) @@ -288,7 +317,7 @@ class MatrixMessengerOptionsFlow(config_entries.OptionsFlow): current_rooms = list(_effective_rooms(self._entry).keys()) room_options = [ - selector.SelectOptionDict(value=rid, label=f"{name} ({rid})") + selector.SelectOptionDict(value=rid, label=name) for rid, name in self._available_rooms.items() ] @@ -300,7 +329,7 @@ class MatrixMessengerOptionsFlow(config_entries.OptionsFlow): selector.SelectSelectorConfig( options=room_options, multiple=True, - mode=selector.SelectSelectorMode.LIST, + mode=selector.SelectSelectorMode.DROPDOWN, ) ), vol.Optional( diff --git a/custom_components/matrix_messenger/manifest.json b/custom_components/matrix_messenger/manifest.json index 179017e..524ff1d 100644 --- a/custom_components/matrix_messenger/manifest.json +++ b/custom_components/matrix_messenger/manifest.json @@ -7,6 +7,6 @@ "homeassistant": "2026.4.0", "iot_class": "cloud_push", "requirements": ["matrix-nio[e2e]>=0.21.0"], - "version": "1.0.0", + "version": "1.1.0", "integration_type": "hub" } diff --git a/custom_components/matrix_messenger/matrix_client.py b/custom_components/matrix_messenger/matrix_client.py index bd869b7..c197e28 100644 --- a/custom_components/matrix_messenger/matrix_client.py +++ b/custom_components/matrix_messenger/matrix_client.py @@ -8,11 +8,15 @@ from typing import Any, Awaitable, Callable from nio import ( AsyncClient, AsyncClientConfig, + JoinedRoomsResponse, KeysUploadResponse, LoginResponse, MegolmEvent, + RoomCreateResponse, + RoomGetStateEventResponse, RoomMessageText, RoomSendResponse, + SyncResponse, UnknownEvent, WhoamiResponse, ) @@ -36,16 +40,30 @@ class MatrixClient: self._client: AsyncClient | None = None self._message_callbacks: list[MessageCallback] = [] self._reaction_callbacks: list[MessageCallback] = [] + self._encryption_enabled = False async def async_setup(self) -> None: """Create the nio client. Must be called before any other method.""" os.makedirs(self._store_path, exist_ok=True) - config = AsyncClientConfig( - max_limit_exceeded=0, - max_timeouts=0, - store_sync_tokens=True, - encryption_enabled=True, - ) + try: + config = AsyncClientConfig( + max_limit_exceeded=0, + max_timeouts=0, + store_sync_tokens=True, + encryption_enabled=True, + ) + self._encryption_enabled = True + except ImportWarning: + _LOGGER.warning( + "E2EE-Abhängigkeiten (python-olm) nicht installiert – " + "Verschlüsselung deaktiviert. Für E2EE 'matrix-nio[e2e]' installieren." + ) + config = AsyncClientConfig( + max_limit_exceeded=0, + max_timeouts=0, + store_sync_tokens=True, + encryption_enabled=False, + ) self._client = AsyncClient( homeserver=self._homeserver, user=self._user_id, @@ -54,7 +72,8 @@ class MatrixClient: ) self._client.add_event_callback(self._on_message, RoomMessageText) self._client.add_event_callback(self._on_unknown_event, UnknownEvent) - self._client.add_event_callback(self._on_megolm, MegolmEvent) + if self._encryption_enabled: + self._client.add_event_callback(self._on_megolm, MegolmEvent) # ------------------------------------------------------------------ # Login helpers @@ -86,7 +105,8 @@ class MatrixClient: device_id=device_id, access_token=access_token, ) - self._client.load_store() + if self._encryption_enabled: + self._client.load_store() await self._upload_keys_if_needed() return device_id @@ -105,11 +125,82 @@ class MatrixClient: async def async_get_joined_rooms(self) -> dict[str, str]: """Return {room_id: display_name} for all joined rooms.""" - await self._client.sync(timeout=10000) - return { - room_id: room.display_name or room_id - for room_id, room in self._client.rooms.items() - } + rooms_resp = await self._client.joined_rooms() + if not isinstance(rooms_resp, JoinedRoomsResponse): + _LOGGER.error("Konnte Raumliste nicht laden: %s", rooms_resp) + return {} + room_ids = list(rooms_resp.rooms) + return await self.async_get_room_names(room_ids) + + async def async_get_room_names(self, room_ids: list[str]) -> dict[str, str]: + """Fetch room display names. + + Strategy: + 1. Direct state API (m.room.name / m.room.canonical_alias) – works + without syncing and is unaffected by incremental sync tokens. + 2. full_state sync fallback – populates client.rooms with current + state so display_name can be calculated from room name / member list. + """ + result: dict[str, str] = {} + + # --- Strategy 1: direct state API --- + for room_id in room_ids: + name = room_id + for event_type, field in ( + ("m.room.name", "name"), + ("m.room.canonical_alias", "alias"), + ): + try: + resp = await self._client.room_get_state_event(room_id, event_type) + # Use duck-typing so we work across different nio versions + content = getattr(resp, "content", None) + _LOGGER.debug( + "state_event(%s, %s) → %s content=%s", + room_id, event_type, type(resp).__name__, content, + ) + if isinstance(content, dict): + val = str(content.get(field, "")).strip() + if val: + name = val + break + except Exception as exc: + _LOGGER.debug("state_event(%s, %s) exception: %s", room_id, event_type, exc) + result[room_id] = name + + # --- Strategy 2: full_state sync for rooms still unresolved --- + unresolved = [rid for rid in room_ids if result.get(rid) == rid] + if unresolved: + _LOGGER.debug( + "%d room(s) unresolved after state API – trying full_state sync", len(unresolved) + ) + try: + sync_resp = await self._client.sync(timeout=12000, full_state=True) + if isinstance(sync_resp, SyncResponse): + for room_id in unresolved: + room = self._client.rooms.get(room_id) + if room: + name = getattr(room, "display_name", None) or getattr(room, "name", None) + _LOGGER.debug(" full_state display_name(%s) → %r", room_id, name) + if name and name != room_id: + result[room_id] = name + except Exception as exc: + _LOGGER.warning("full_state sync fehlgeschlagen: %s", exc) + + return result + + def get_room_display_names(self) -> dict[str, str]: + """Return {room_id: display_name} from currently synced room state.""" + if not self._client: + return {} + result = {} + for room_id, room in self._client.rooms.items(): + name = ( + getattr(room, "display_name", None) + or getattr(room, "name", None) + or room_id + ) + result[room_id] = name + return result async def async_send_message(self, room_id: str, message: str) -> bool: """Send a plain-text message. Returns True on success.""" @@ -123,9 +214,54 @@ class MatrixClient: return False return True - async def async_sync_once(self, timeout_ms: int = 5000) -> None: - """Perform one Matrix /sync call.""" - await self._client.sync(timeout=timeout_ms) + async def async_sync_once(self, timeout_ms: int = 5000, full_state: bool = False) -> None: + """Perform one Matrix /sync call. + + full_state=True forces the server to return complete room state (incl. + m.room.name) regardless of any stored sync token. + """ + resp = await self._client.sync(timeout=timeout_ms, full_state=full_state or None) + if not isinstance(resp, SyncResponse): + _LOGGER.debug("Sync fehlgeschlagen: %s", resp) + + async def async_send_to_user(self, user_id: str, message: str) -> bool: + """Send a direct message to a Matrix user. Finds or creates a DM room.""" + room_id = await self._find_or_create_dm(user_id) + if not room_id: + return False + return await self.async_send_message(room_id, message) + + async def _find_or_create_dm(self, user_id: str) -> str | None: + """Return an existing room_id for user_id, or create one. + + Bridge portal rooms (mautrix-whatsapp, -signal, -telegram) have 3+ + members (user + puppet + bridge bot), so we search all joined rooms + for one that contains the target user and prefer the one with the + fewest members (most likely a direct/portal room). + """ + await self.async_sync_once(timeout_ms=5000) + + my_id = self._client.user_id + candidates: list[tuple[int, str]] = [] + for room_id, room in self._client.rooms.items(): + joined = [ + uid for uid, member in room.users.items() + if getattr(member, "membership", None) == "join" + ] + if user_id in joined and my_id in joined: + candidates.append((len(joined), room_id)) + + if candidates: + candidates.sort() # fewest members first → most likely the direct/portal room + return candidates[0][1] + + # No existing room found – try to create a DM (works for native Matrix + # users; bridge puppets usually require the bridge bot to initiate). + resp = await self._client.room_create(is_direct=True, invite=[user_id]) + if isinstance(resp, RoomCreateResponse): + return resp.room_id + _LOGGER.error("Konnte Raum für %s nicht erstellen: %s", user_id, resp) + return None async def async_close(self) -> None: """Close the HTTP session.""" @@ -166,6 +302,8 @@ class MatrixClient: _LOGGER.debug("Undecryptable MegolmEvent in %s (fehlende Session-Keys?)", room.room_id) async def _upload_keys_if_needed(self) -> None: + if not self._encryption_enabled: + return if self._client.should_upload_keys: resp = await self._client.keys_upload() if not isinstance(resp, KeysUploadResponse): diff --git a/custom_components/matrix_messenger/services.yaml b/custom_components/matrix_messenger/services.yaml index 108cc03..5b7fc49 100644 --- a/custom_components/matrix_messenger/services.yaml +++ b/custom_components/matrix_messenger/services.yaml @@ -1,12 +1,17 @@ -send_message: - name: Matrix-Nachricht senden - description: Sendet eine Textnachricht an einen konfigurierten Matrix-Raum. +# send_message, ask_question und send_to_ werden beim Start dynamisch +# injiziert, damit Räume als lesbares Dropdown erscheinen (siehe __init__.py). + +send_to_user: + name: Matrix-Direktnachricht senden + description: > + Sendet eine Direktnachricht an einen einzelnen Matrix-Benutzer. + Sucht einen vorhandenen DM-Raum oder erstellt automatisch einen neuen. fields: - room_id: - name: Raum - description: "Matrix-Raum-ID (z. B. !abc123:matrix.org). Wird beim Einrichten der Integration ausgewählt." + user_id: + name: Benutzer-ID + description: "Matrix-Benutzer-ID (z. B. @max:matrix.org)." required: true - example: "!abc123:matrix.org" + example: "@max:matrix.org" selector: text: message: @@ -16,51 +21,3 @@ send_message: selector: text: multiline: true - -ask_question: - name: Frage in Matrix-Raum stellen - description: > - Sendet eine Frage an einen Matrix-Raum und wartet auf eine Antwort. - Sobald jemand antwortet (Text oder Emoji-Reaktion), wird das Event - 'matrix_messenger_response' ausgelöst, das in Automationen verwendet - werden kann. Nach Ablauf des Timeouts (Standard 30 min) wird nicht - mehr auf eine Antwort gewartet. - fields: - room_id: - name: Raum - description: "Matrix-Raum-ID (z. B. !abc123:matrix.org)." - required: true - example: "!abc123:matrix.org" - selector: - text: - question: - name: Frage - description: Der Fragetext, der in den Raum gesendet wird. - required: true - selector: - text: - multiline: true - options: - name: Antwortoptionen - description: > - Optionale Liste gültiger Antworten. Nur Nachrichten oder Emoji-Reaktionen, - die einer dieser Optionen entsprechen, werden akzeptiert. - Wenn leer, wird jede Antwort akzeptiert. - required: false - example: - - "Ja" - - "Nein" - selector: - object: - timeout: - name: Timeout (Sekunden) - description: Wartezeit in Sekunden. Standard ist 1800 (30 Minuten). - required: false - default: 1800 - selector: - number: - min: 60 - max: 7200 - step: 60 - unit_of_measurement: s - mode: box