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:
@@ -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
|
||||
}
|
||||
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(
|
||||
|
||||
@@ -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)
|
||||
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):
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user