v1.1.0: per-room services, send_to_user, mautrix bridge support, searchable room picker

- Add per-room convenience actions (matrix_messenger.send_to_<roomname>)
- Add send_to_user action: finds existing portal/DM room or creates one;
  supports mautrix-whatsapp, -signal, -telegram puppet IDs
- Inject service descriptions dynamically so room dropdowns show friendly
  names instead of room IDs (full-state sync + direct state API fallback)
- Switch all room selectors to searchable dropdown mode
- Fix _find_or_create_dm to match bridge portal rooms (3+ members)
- Fix async_get_joined_rooms to use full_state sync
- Bump version to 1.1.0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marc
2026-04-25 15:46:28 +02:00
parent bddb07431d
commit 7196e334a2
6 changed files with 459 additions and 123 deletions
+72 -22
View File
@@ -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_<roomname>`
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_<number without +>:<server>` |
| Signal | `@signal_<number without +>:<server>` |
| Telegram | `@telegram_<user id>:<server>` |
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 |
---
+166 -4
View File
@@ -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_<slug>
# (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
@@ -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
}
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=f"{name} ({rid})")
selector.SelectOptionDict(value=rid, label=name)
for rid, name in self._available_rooms.items()
]
return self.async_show_form(
step_id="rooms",
data_schema=vol.Schema(
{
vol.Required(CONF_ROOMS): selector.SelectSelector(
schema_dict[vol.Required(CONF_ROOMS)] = selector.SelectSelector(
selector.SelectSelectorConfig(
options=room_options,
multiple=True,
mode=selector.SelectSelectorMode.LIST,
mode=selector.SelectSelectorMode.DROPDOWN,
)
),
vol.Optional(CONF_ENABLE_SYNC, default=False): selector.BooleanSelector(),
}
),
)
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)
)
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(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(
@@ -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"
}
@@ -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)
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,6 +72,7 @@ class MatrixClient:
)
self._client.add_event_callback(self._on_message, RoomMessageText)
self._client.add_event_callback(self._on_unknown_event, UnknownEvent)
if self._encryption_enabled:
self._client.add_event_callback(self._on_megolm, MegolmEvent)
# ------------------------------------------------------------------
@@ -86,6 +105,7 @@ class MatrixClient:
device_id=device_id,
access_token=access_token,
)
if self._encryption_enabled:
self._client.load_store()
await self._upload_keys_if_needed()
@@ -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):
@@ -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_<raum> 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