commit bddb07431dd4e38a7642f8b75e6f57c82c35492b Author: Marc Date: Sat Apr 25 00:49:31 2026 +0200 Initial release: Matrix Messenger Home Assistant integration - Config flow (GUI): Homeserver, Passwort- oder Token-Anmeldung, Raumauswahl - E2EE-Unterstützung via matrix-nio mit SQLite Key-Store - Aktionen: send_message, ask_question (Text + Emoji-Reaktion) - notify.*-Entitäten pro Raum - Optionaler Hintergrund-Sync, 30-Min-Timeout für Fragen - HACS-kompatibel, HA >= 2026.4.0 Co-Authored-By: Claude Sonnet 4.6 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..1082685 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,5 @@ +* text=auto eol=lf +*.py text eol=lf +*.json text eol=lf +*.yaml text eol=lf +*.md text eol=lf diff --git a/README.md b/README.md new file mode 100644 index 0000000..db889af --- /dev/null +++ b/README.md @@ -0,0 +1,308 @@ +# 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. + +--- + +## 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) +- **E2EE support** — end-to-end encrypted rooms via [matrix-nio](https://github.com/poljar/matrix-nio) +- **Two auth methods** — username + password, or access token +- **Fully GUI-configurable** — no YAML required, all settings via the HA config flow +- **HACS-installable** + +--- + +## Requirements + +| Requirement | Details | +|---|---| +| Home Assistant | ≥ 2026.4.0 | +| Python package | `matrix-nio[e2e] >= 0.21.0` (installed automatically) | +| Native library | `libolm` — pre-installed in HA OS, Supervised, and Container | + +> **Note for HA Core (venv) installations:** `libolm` must be installed manually on the host system (`apt install libolm-dev` on Debian/Ubuntu). + +--- + +## Installation + +### Via HACS (recommended) + +1. Open HACS → **Integrations** → ⋮ → **Custom repositories** +2. Add your Gitea repository URL, category: **Integration** +3. Search for **Matrix Messenger** and install +4. Restart Home Assistant + +### Manual + +1. Copy the `custom_components/matrix_messenger/` folder into your HA config directory +2. Restart Home Assistant + +--- + +## Configuration + +Go to **Settings → Devices & Services → Add Integration** and search for **Matrix Messenger**. + +### Step 1 — Matrix Server + +| Field | Example | +|---|---| +| Homeserver URL | `https://matrix.org` | +| Authentication method | Username + Password *or* Access Token | + +### Step 2a — Username + Password + +| Field | Notes | +|---|---| +| Matrix User ID | `@youruser:matrix.org` | +| Password | Your Matrix account password | +| Device name | Shown in your Matrix client's session list (optional) | + +### Step 2b — Access Token + +Retrieve your token in your Matrix client: **Settings → Security → Sessions → Show access token**. + +| Field | Notes | +|---|---| +| Matrix User ID | `@youruser:matrix.org` | +| Access Token | Paste the token here; the device ID is fetched automatically | + +### Step 3 — Room Selection + +All rooms the account has joined are listed. Select one or more rooms. + +**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. + +--- + +## Reconfiguring rooms + +Go to **Settings → Devices & Services → Matrix Messenger → Configure** to change the selected rooms or toggle background sync at any time. + +--- + +## Services / Actions + +### `matrix_messenger.send_message` + +Sends a plain-text message to a room. + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `room_id` | string | ✓ | Matrix room ID, e.g. `!abc123:matrix.org` | +| `message` | string | ✓ | Message text | + +**Example:** +```yaml +action: matrix_messenger.send_message +data: + room_id: "!abc123:matrix.org" + message: "Front door opened." +``` + +--- + +### `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 | +| `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 | + +**Example — free-text reply:** +```yaml +action: matrix_messenger.ask_question +data: + room_id: "!abc123:matrix.org" + question: "Should I water the plants?" +``` + +**Example — constrained options:** +```yaml +action: matrix_messenger.ask_question +data: + room_id: "!abc123:matrix.org" + question: "Alarm triggered — is this a false alarm?" + options: + - "Yes" + - "No" + timeout: 300 +``` + +The integration appends the options to the message: +> Alarm triggered — is this a false alarm? +> +> Mögliche Antworten: Yes / No + +--- + +## Notify entities + +Each configured room also creates a `notify.*` entity: + +``` +notify.matrix_ +``` + +These appear in the HA notification UI and can be used anywhere a notify target is accepted. + +**Example:** +```yaml +action: notify.matrix_wohnzimmer +data: + title: "Waschmaschine" + message: "Programm beendet." +``` + +The title is prepended in bold: **Waschmaschine**\nProgramm beendet. + +--- + +## Events + +### `matrix_messenger_response` + +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) | +| `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 | +| `sender` | string | Matrix user ID of the person who replied | + +--- + +## Automation examples + +### Send a notification when motion is detected + +```yaml +automation: + trigger: + - platform: state + entity_id: binary_sensor.front_door_motion + to: "on" + action: + - action: matrix_messenger.send_message + data: + room_id: "!abc123:matrix.org" + message: "Motion detected at the front door." +``` + +--- + +### Ask a question and branch based on the answer + +```yaml +automation: + alias: "Alarm confirmation" + trigger: + - platform: state + 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" + question: "Alarm triggered! False alarm?" + options: + - "Yes" + - "No" + timeout: 300 + # 2. Wait for the response event + - wait_for_trigger: + - platform: event + event_type: matrix_messenger_response + event_data: + room_id: "!abc123:matrix.org" + timeout: "00:05:00" + continue_on_timeout: true + # 3. Branch + - choose: + - conditions: + - condition: template + value_template: "{{ wait.trigger.event.data.response == 'Yes' }}" + sequence: + - action: alarm_control_panel.alarm_disarm + target: + entity_id: alarm_control_panel.home + default: + - action: notify.matrix_wohnzimmer + data: + message: "Alarm not confirmed — police notified." +``` + +--- + +### React to emoji reactions (👍 / 👎) + +```yaml +automation: + alias: "Heating approval" + trigger: + - platform: time + at: "17:00:00" + action: + - action: matrix_messenger.ask_question + data: + room_id: "!abc123:matrix.org" + question: "Turn on heating now?" + options: + - "👍" + - "👎" + timeout: 900 + - wait_for_trigger: + - platform: event + event_type: matrix_messenger_response + event_data: + room_id: "!abc123:matrix.org" + timeout: "00:15:00" + continue_on_timeout: true + - if: + - condition: template + value_template: "{{ wait.trigger.event.data.response == '👍' }}" + then: + - action: climate.turn_on + target: + entity_id: climate.living_room +``` + +--- + +## E2EE key storage + +The integration stores matrix-nio's E2EE keys (Olm/Megolm sessions) in: + +``` +/.storage/matrix_messenger/ +``` + +**Back this directory up** together with your HA config. If it is deleted, the integration will re-upload keys and may lose access to past encrypted messages. + +--- + +## Troubleshooting + +| Symptom | Cause | Fix | +|---|---|---| +| `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 | +| 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 | + +--- + +## License + +MIT diff --git a/custom_components/matrix_messenger/__init__.py b/custom_components/matrix_messenger/__init__.py new file mode 100644 index 0000000..da4c7be --- /dev/null +++ b/custom_components/matrix_messenger/__init__.py @@ -0,0 +1,316 @@ +"""Matrix Messenger – Home Assistant Integration. + +Ermöglicht das Senden von Nachrichten an Matrix-Räume sowie das +Stellen von Fragen mit Antwortwartezeit (Text oder Emoji-Reaktion). +""" +from __future__ import annotations + +import asyncio +import logging +import time +import uuid +from dataclasses import dataclass, field +from typing import Any + +import voluptuous as vol +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, ServiceCall + +from .config_flow import _effective_rooms, _effective_sync +from .const import ( + CONF_ACCESS_TOKEN, + CONF_DEVICE_ID, + CONF_HOMESERVER, + CONF_USERNAME, + DEFAULT_QUESTION_TIMEOUT, + DEFAULT_SYNC_INTERVAL, + DOMAIN, + EVENT_MATRIX_RESPONSE, +) +from .matrix_client import MatrixClient + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS = ["notify"] + + +# ------------------------------------------------------------------ +# Runtime data structures +# ------------------------------------------------------------------ + + +@dataclass +class PendingQuestion: + question_id: str + room_id: str + options: list[str] + expires_at: float + + +@dataclass +class MatrixEntryData: + client: MatrixClient + pending_questions: dict[str, PendingQuestion] = field(default_factory=dict) + sync_task: asyncio.Task | None = None + + +# ------------------------------------------------------------------ +# Integration setup / teardown +# ------------------------------------------------------------------ + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Matrix Messenger from a config entry.""" + hass.data.setdefault(DOMAIN, {}) + + store_path = hass.config.path(f".storage/{DOMAIN}") + client = MatrixClient( + homeserver=entry.data[CONF_HOMESERVER], + user_id=entry.data[CONF_USERNAME], + store_path=store_path, + ) + await client.async_setup() + await client.async_restore_login( + access_token=entry.data[CONF_ACCESS_TOKEN], + device_id=entry.data.get(CONF_DEVICE_ID, ""), + ) + + data = MatrixEntryData(client=client) + hass.data[DOMAIN][entry.entry_id] = data + + async def on_message(room: Any, event: Any) -> None: + await _handle_message(hass, data, room, event) + + async def on_reaction(room: Any, event: Any) -> None: + await _handle_reaction(hass, data, room, event) + + client.add_message_callback(on_message) + client.add_reaction_callback(on_reaction) + + if _effective_sync(entry): + data.sync_task = hass.async_create_background_task( + _sync_loop(hass, entry, data, stop_when_idle=False), + name=f"{DOMAIN}_sync_{entry.entry_id}", + ) + + _register_services(hass, entry, data) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + entry.async_on_unload(entry.add_update_listener(_async_options_updated)) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + data: MatrixEntryData | None = hass.data[DOMAIN].pop(entry.entry_id, None) + if data: + _cancel_sync(data) + await data.client.async_close() + + for service_name in ("send_message", "ask_question"): + if hass.services.has_service(DOMAIN, service_name): + hass.services.async_remove(DOMAIN, service_name) + + await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + return True + + +async def _async_options_updated(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Reload the entry when options change.""" + await hass.config_entries.async_reload(entry.entry_id) + + +# ------------------------------------------------------------------ +# Service registration +# ------------------------------------------------------------------ + + +def _register_services( + hass: HomeAssistant, entry: ConfigEntry, data: MatrixEntryData +) -> None: + rooms = _effective_rooms(entry) + room_ids = list(rooms.keys()) + room_validator = vol.In(room_ids) if room_ids else str + + async def handle_send_message(call: ServiceCall) -> None: + room_id: str = call.data["room_id"] + message: str = call.data["message"] + success = await data.client.async_send_message(room_id, message) + if not success: + _LOGGER.error("Nachricht an %s konnte nicht gesendet werden", room_id) + + async def handle_ask_question(call: ServiceCall) -> None: + room_id: str = call.data["room_id"] + question: str = call.data["question"] + 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)}" + + await data.client.async_send_message(room_id, text) + + qid = str(uuid.uuid4()) + data.pending_questions[qid] = PendingQuestion( + question_id=qid, + room_id=room_id, + options=options, + expires_at=time.monotonic() + timeout, + ) + _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}", + ) + + hass.services.async_register( + DOMAIN, + "send_message", + handle_send_message, + schema=vol.Schema( + { + vol.Required("room_id"): room_validator, + vol.Required("message"): str, + } + ), + ) + + hass.services.async_register( + DOMAIN, + "ask_question", + handle_ask_question, + schema=vol.Schema( + { + vol.Required("room_id"): room_validator, + vol.Required("question"): str, + vol.Optional("options", default=[]): [str], + vol.Optional("timeout", default=DEFAULT_QUESTION_TIMEOUT): vol.All( + int, vol.Range(min=60, max=7200) + ), + } + ), + ) + + +# ------------------------------------------------------------------ +# Background sync loop +# ------------------------------------------------------------------ + + +async def _sync_loop( + hass: HomeAssistant, + entry: ConfigEntry, + data: MatrixEntryData, + stop_when_idle: bool, +) -> None: + """Poll Matrix every DEFAULT_SYNC_INTERVAL seconds. + + When stop_when_idle=True, the loop exits automatically once all + pending questions have been answered or expired. + """ + while True: + try: + await data.client.async_sync_once(timeout_ms=5000) + + # Expire old questions + now = time.monotonic() + expired = [qid for qid, q in data.pending_questions.items() if now > q.expires_at] + for qid in expired: + _LOGGER.debug("Frage %s ist abgelaufen (30 min Timeout)", qid) + data.pending_questions.pop(qid, None) + + if stop_when_idle and not data.pending_questions and not _effective_sync(entry): + _LOGGER.debug("Keine offenen Fragen – Sync-Loop wird beendet") + return + + except asyncio.CancelledError: + raise + except Exception: + _LOGGER.exception("Fehler im Matrix Sync-Loop") + + await asyncio.sleep(DEFAULT_SYNC_INTERVAL) + + +def _cancel_sync(data: MatrixEntryData) -> None: + if data.sync_task and not data.sync_task.done(): + data.sync_task.cancel() + + +# ------------------------------------------------------------------ +# Incoming event handlers +# ------------------------------------------------------------------ + + +async def _handle_message( + hass: HomeAssistant, + data: MatrixEntryData, + room: Any, + event: Any, +) -> None: + """Match an incoming text message against open questions.""" + if not data.pending_questions: + return + + response_text: str = getattr(event, "body", "") + + for qid, question in list(data.pending_questions.items()): + if question.room_id != room.room_id: + continue + if question.options and response_text not in question.options: + continue + + data.pending_questions.pop(qid, None) + hass.bus.async_fire( + EVENT_MATRIX_RESPONSE, + { + "question_id": qid, + "room_id": room.room_id, + "response": response_text, + "response_type": "text", + "sender": event.sender, + }, + ) + _LOGGER.debug("Antwort auf Frage %s empfangen: %s", qid, response_text) + break + + +async def _handle_reaction( + hass: HomeAssistant, + data: MatrixEntryData, + room: Any, + event: Any, +) -> None: + """Match an incoming m.reaction against open questions.""" + if not data.pending_questions: + return + + content: dict = getattr(event, "source", {}).get("content", {}) + relates_to: dict = content.get("m.relates_to", {}) + if relates_to.get("rel_type") != "m.annotation": + return + + emoji: str = relates_to.get("key", "") + + for qid, question in list(data.pending_questions.items()): + if question.room_id != room.room_id: + continue + if question.options and emoji not in question.options: + continue + + data.pending_questions.pop(qid, None) + hass.bus.async_fire( + EVENT_MATRIX_RESPONSE, + { + "question_id": qid, + "room_id": room.room_id, + "response": emoji, + "response_type": "emoji", + "sender": event.sender, + }, + ) + _LOGGER.debug("Emoji-Reaktion auf Frage %s empfangen: %s", qid, emoji) + break diff --git a/custom_components/matrix_messenger/config_flow.py b/custom_components/matrix_messenger/config_flow.py new file mode 100644 index 0000000..6047a3d --- /dev/null +++ b/custom_components/matrix_messenger/config_flow.py @@ -0,0 +1,326 @@ +"""Config flow and options flow for Matrix Messenger.""" +from __future__ import annotations + +import logging + +import voluptuous as vol +from homeassistant import config_entries +from homeassistant.core import callback +from homeassistant.helpers import selector + +from .const import ( + AUTH_METHOD_PASSWORD, + AUTH_METHOD_TOKEN, + CONF_ACCESS_TOKEN, + CONF_AUTH_METHOD, + CONF_DEVICE_ID, + CONF_DEVICE_NAME, + CONF_ENABLE_SYNC, + CONF_HOMESERVER, + CONF_PASSWORD, + CONF_ROOMS, + CONF_USERNAME, + DEFAULT_DEVICE_NAME, + DOMAIN, +) +from .matrix_client import MatrixClient, MatrixClientError + +_LOGGER = logging.getLogger(__name__) + + +class MatrixMessengerConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Multi-step config flow: server → credentials → room selection.""" + + VERSION = 1 + + def __init__(self) -> None: + self._data: dict = {} + self._available_rooms: dict[str, str] = {} + + # ------------------------------------------------------------------ + # Step 1: homeserver + auth method + # ------------------------------------------------------------------ + + async def async_step_user(self, user_input=None): + errors: dict[str, str] = {} + if user_input is not None: + self._data.update(user_input) + if user_input[CONF_AUTH_METHOD] == AUTH_METHOD_PASSWORD: + return await self.async_step_credentials_password() + return await self.async_step_credentials_token() + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_HOMESERVER): selector.TextSelector( + selector.TextSelectorConfig(type=selector.TextSelectorType.URL) + ), + vol.Required(CONF_AUTH_METHOD, default=AUTH_METHOD_PASSWORD): selector.SelectSelector( + selector.SelectSelectorConfig( + options=[ + selector.SelectOptionDict( + value=AUTH_METHOD_PASSWORD, + label="Benutzername + Passwort", + ), + selector.SelectOptionDict( + value=AUTH_METHOD_TOKEN, + label="Access Token", + ), + ], + mode=selector.SelectSelectorMode.LIST, + ) + ), + } + ), + errors=errors, + ) + + # ------------------------------------------------------------------ + # Step 2a: password login + # ------------------------------------------------------------------ + + async def async_step_credentials_password(self, user_input=None): + errors: dict[str, str] = {} + if user_input is not None: + store_path = self.hass.config.path(f".storage/{DOMAIN}") + client = MatrixClient( + homeserver=self._data[CONF_HOMESERVER], + user_id=user_input[CONF_USERNAME], + store_path=store_path, + ) + try: + await client.async_setup() + token, device_id = await client.async_login_password( + user_input[CONF_PASSWORD], + user_input.get(CONF_DEVICE_NAME, DEFAULT_DEVICE_NAME), + ) + self._available_rooms = await client.async_get_joined_rooms() + except MatrixClientError as err: + _LOGGER.error("Login fehlgeschlagen: %s", err) + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unerwarteter Fehler beim Login") + errors["base"] = "unknown" + else: + self._data.update( + { + CONF_USERNAME: user_input[CONF_USERNAME], + CONF_ACCESS_TOKEN: token, + CONF_DEVICE_ID: device_id, + CONF_DEVICE_NAME: user_input.get(CONF_DEVICE_NAME, DEFAULT_DEVICE_NAME), + } + ) + return await self.async_step_rooms() + finally: + await client.async_close() + + return self.async_show_form( + step_id="credentials_password", + data_schema=vol.Schema( + { + vol.Required(CONF_USERNAME): selector.TextSelector( + selector.TextSelectorConfig( + type=selector.TextSelectorType.TEXT, + autocomplete="username", + ) + ), + vol.Required(CONF_PASSWORD): selector.TextSelector( + selector.TextSelectorConfig( + type=selector.TextSelectorType.PASSWORD, + autocomplete="current-password", + ) + ), + vol.Optional(CONF_DEVICE_NAME, default=DEFAULT_DEVICE_NAME): selector.TextSelector(), + } + ), + errors=errors, + ) + + # ------------------------------------------------------------------ + # Step 2b: token login + # ------------------------------------------------------------------ + + async def async_step_credentials_token(self, user_input=None): + errors: dict[str, str] = {} + if user_input is not None: + store_path = self.hass.config.path(f".storage/{DOMAIN}") + client = MatrixClient( + homeserver=self._data[CONF_HOMESERVER], + user_id=user_input[CONF_USERNAME], + store_path=store_path, + ) + try: + await client.async_setup() + # Fetch device_id from server using the provided token + _, device_id = await client.async_whoami_device_id( + user_input[CONF_ACCESS_TOKEN] + ) + device_id = await client.async_restore_login( + access_token=user_input[CONF_ACCESS_TOKEN], + device_id=device_id, + ) + self._available_rooms = await client.async_get_joined_rooms() + except MatrixClientError as err: + _LOGGER.error("Token-Login fehlgeschlagen: %s", err) + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unerwarteter Fehler beim Token-Login") + errors["base"] = "unknown" + else: + self._data.update( + { + CONF_USERNAME: user_input[CONF_USERNAME], + CONF_ACCESS_TOKEN: user_input[CONF_ACCESS_TOKEN], + CONF_DEVICE_ID: device_id, + } + ) + return await self.async_step_rooms() + finally: + await client.async_close() + + return self.async_show_form( + step_id="credentials_token", + data_schema=vol.Schema( + { + vol.Required(CONF_USERNAME): selector.TextSelector( + selector.TextSelectorConfig(type=selector.TextSelectorType.TEXT) + ), + vol.Required(CONF_ACCESS_TOKEN): selector.TextSelector( + selector.TextSelectorConfig(type=selector.TextSelectorType.PASSWORD) + ), + } + ), + errors=errors, + description_placeholders={ + "token_help": "Den Access Token findest du in deinem Matrix-Client unter Einstellungen → Sicherheit → Sitzungen." + }, + ) + + # ------------------------------------------------------------------ + # Step 3: room selection + # ------------------------------------------------------------------ + + async def async_step_rooms(self, user_input=None): + errors: dict[str, str] = {} + if user_input is not None: + selected = user_input.get(CONF_ROOMS, []) + self._data[CONF_ROOMS] = { + rid: self._available_rooms[rid] + for rid in selected + if rid in self._available_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, + ) + + room_options = [ + selector.SelectOptionDict(value=rid, label=f"{name} ({rid})") + 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( + selector.SelectSelectorConfig( + options=room_options, + multiple=True, + mode=selector.SelectSelectorMode.LIST, + ) + ), + vol.Optional(CONF_ENABLE_SYNC, default=False): selector.BooleanSelector(), + } + ), + errors=errors, + ) + + @staticmethod + @callback + def async_get_options_flow(config_entry): + return MatrixMessengerOptionsFlow(config_entry) + + +# ---------------------------------------------------------------------- +# Options flow – reconfigure rooms and sync after initial setup +# ---------------------------------------------------------------------- + + +class MatrixMessengerOptionsFlow(config_entries.OptionsFlow): + """Allow re-selection of rooms and sync toggle without re-authentication.""" + + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: + self._entry = config_entry + self._available_rooms: dict[str, str] = {} + + async def async_step_init(self, user_input=None): + errors: dict[str, str] = {} + + if user_input is not None: + selected = user_input.get(CONF_ROOMS, []) + new_rooms = { + rid: self._available_rooms.get(rid, rid) + for rid in selected + } + return self.async_create_entry( + title="", + data={ + CONF_ROOMS: new_rooms, + CONF_ENABLE_SYNC: user_input.get(CONF_ENABLE_SYNC, False), + }, + ) + + # Try to load fresh room list from the running client + domain_data = self.hass.data.get(DOMAIN, {}) + entry_data = domain_data.get(self._entry.entry_id) + if entry_data is not None: + try: + self._available_rooms = await entry_data.client.async_get_joined_rooms() + except Exception: + _LOGGER.warning("Konnte Räume nicht neu laden, zeige gespeicherte Auswahl.") + + if not self._available_rooms: + self._available_rooms = _effective_rooms(self._entry) + + current_rooms = list(_effective_rooms(self._entry).keys()) + + room_options = [ + selector.SelectOptionDict(value=rid, label=f"{name} ({rid})") + for rid, name in self._available_rooms.items() + ] + + return self.async_show_form( + step_id="init", + data_schema=vol.Schema( + { + vol.Required(CONF_ROOMS, default=current_rooms): selector.SelectSelector( + selector.SelectSelectorConfig( + options=room_options, + multiple=True, + mode=selector.SelectSelectorMode.LIST, + ) + ), + vol.Optional( + CONF_ENABLE_SYNC, + default=_effective_sync(self._entry), + ): selector.BooleanSelector(), + } + ), + errors=errors, + ) + + +# ------------------------------------------------------------------ +# Helpers shared between flows +# ------------------------------------------------------------------ + + +def _effective_rooms(entry: config_entries.ConfigEntry) -> dict[str, str]: + return entry.options.get(CONF_ROOMS, entry.data.get(CONF_ROOMS, {})) + + +def _effective_sync(entry: config_entries.ConfigEntry) -> bool: + return entry.options.get(CONF_ENABLE_SYNC, entry.data.get(CONF_ENABLE_SYNC, False)) diff --git a/custom_components/matrix_messenger/const.py b/custom_components/matrix_messenger/const.py new file mode 100644 index 0000000..accf566 --- /dev/null +++ b/custom_components/matrix_messenger/const.py @@ -0,0 +1,21 @@ +"""Constants for Matrix Messenger integration.""" +DOMAIN = "matrix_messenger" + +CONF_HOMESERVER = "homeserver" +CONF_AUTH_METHOD = "auth_method" +CONF_USERNAME = "username" +CONF_PASSWORD = "password" +CONF_ACCESS_TOKEN = "access_token" +CONF_DEVICE_ID = "device_id" +CONF_DEVICE_NAME = "device_name" +CONF_ROOMS = "rooms" +CONF_ENABLE_SYNC = "enable_sync" + +AUTH_METHOD_PASSWORD = "password" +AUTH_METHOD_TOKEN = "token" + +DEFAULT_DEVICE_NAME = "Home Assistant Matrix Messenger" +DEFAULT_SYNC_INTERVAL = 5 +DEFAULT_QUESTION_TIMEOUT = 1800 + +EVENT_MATRIX_RESPONSE = "matrix_messenger_response" diff --git a/custom_components/matrix_messenger/manifest.json b/custom_components/matrix_messenger/manifest.json new file mode 100644 index 0000000..179017e --- /dev/null +++ b/custom_components/matrix_messenger/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "matrix_messenger", + "name": "Matrix Messenger", + "codeowners": [], + "config_flow": true, + "documentation": "", + "homeassistant": "2026.4.0", + "iot_class": "cloud_push", + "requirements": ["matrix-nio[e2e]>=0.21.0"], + "version": "1.0.0", + "integration_type": "hub" +} diff --git a/custom_components/matrix_messenger/matrix_client.py b/custom_components/matrix_messenger/matrix_client.py new file mode 100644 index 0000000..bd869b7 --- /dev/null +++ b/custom_components/matrix_messenger/matrix_client.py @@ -0,0 +1,173 @@ +"""Async Matrix client wrapper with E2EE support via matrix-nio.""" +from __future__ import annotations + +import logging +import os +from typing import Any, Awaitable, Callable + +from nio import ( + AsyncClient, + AsyncClientConfig, + KeysUploadResponse, + LoginResponse, + MegolmEvent, + RoomMessageText, + RoomSendResponse, + UnknownEvent, + WhoamiResponse, +) + +_LOGGER = logging.getLogger(__name__) + +MessageCallback = Callable[[Any, Any], Awaitable[None]] + + +class MatrixClientError(Exception): + """Raised on unrecoverable Matrix client errors.""" + + +class MatrixClient: + """Async wrapper around nio.AsyncClient with E2EE and callback support.""" + + def __init__(self, homeserver: str, user_id: str, store_path: str) -> None: + self._homeserver = homeserver + self._user_id = user_id + self._store_path = store_path + self._client: AsyncClient | None = None + self._message_callbacks: list[MessageCallback] = [] + self._reaction_callbacks: list[MessageCallback] = [] + + 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, + ) + self._client = AsyncClient( + homeserver=self._homeserver, + user=self._user_id, + store_path=self._store_path, + config=config, + ) + 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) + + # ------------------------------------------------------------------ + # Login helpers + # ------------------------------------------------------------------ + + async def async_login_password(self, password: str, device_name: str) -> tuple[str, str]: + """Login with username/password. Returns (access_token, device_id).""" + resp = await self._client.login(password, device_name=device_name) + if not isinstance(resp, LoginResponse): + raise MatrixClientError(f"Login fehlgeschlagen: {resp}") + await self._upload_keys_if_needed() + return resp.access_token, resp.device_id + + async def async_restore_login(self, access_token: str, device_id: str) -> str: + """Restore an existing session. Returns the device_id (fetched if empty).""" + self._client.access_token = access_token + self._client.user_id = self._user_id + + if not device_id: + resp = await self._client.whoami() + if isinstance(resp, WhoamiResponse): + device_id = resp.device_id or "" + else: + _LOGGER.warning("whoami() failed: %s", resp) + + if device_id: + self._client.restore_login( + user_id=self._user_id, + device_id=device_id, + access_token=access_token, + ) + self._client.load_store() + + await self._upload_keys_if_needed() + return device_id + + async def async_whoami_device_id(self, access_token: str) -> tuple[str, str]: + """Fetch (user_id, device_id) for a given access token (used during config flow).""" + self._client.access_token = access_token + resp = await self._client.whoami() + if isinstance(resp, WhoamiResponse): + return resp.user_id or self._user_id, resp.device_id or "" + raise MatrixClientError(f"Konnte Gerätedaten nicht abrufen: {resp}") + + # ------------------------------------------------------------------ + # Core operations + # ------------------------------------------------------------------ + + 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() + } + + async def async_send_message(self, room_id: str, message: str) -> bool: + """Send a plain-text message. Returns True on success.""" + resp = await self._client.room_send( + room_id=room_id, + message_type="m.room.message", + content={"msgtype": "m.text", "body": message}, + ) + if not isinstance(resp, RoomSendResponse): + _LOGGER.error("send_message fehlgeschlagen (%s): %s", room_id, resp) + 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_close(self) -> None: + """Close the HTTP session.""" + if self._client: + await self._client.close() + self._client = None + + # ------------------------------------------------------------------ + # Callback registration + # ------------------------------------------------------------------ + + def add_message_callback(self, cb: MessageCallback) -> None: + self._message_callbacks.append(cb) + + def add_reaction_callback(self, cb: MessageCallback) -> None: + self._reaction_callbacks.append(cb) + + # ------------------------------------------------------------------ + # Internal nio callbacks + # ------------------------------------------------------------------ + + async def _on_message(self, room: Any, event: RoomMessageText) -> None: + for cb in self._message_callbacks: + try: + await cb(room, event) + except Exception: + _LOGGER.exception("Fehler im Nachrichten-Callback") + + async def _on_unknown_event(self, room: Any, event: UnknownEvent) -> None: + if event.type == "m.reaction": + for cb in self._reaction_callbacks: + try: + await cb(room, event) + except Exception: + _LOGGER.exception("Fehler im Reaktions-Callback") + + async def _on_megolm(self, room: Any, event: MegolmEvent) -> None: + _LOGGER.debug("Undecryptable MegolmEvent in %s (fehlende Session-Keys?)", room.room_id) + + async def _upload_keys_if_needed(self) -> None: + if self._client.should_upload_keys: + resp = await self._client.keys_upload() + if not isinstance(resp, KeysUploadResponse): + _LOGGER.warning("E2EE Key-Upload fehlgeschlagen: %s", resp) + await self._client.keys_query() diff --git a/custom_components/matrix_messenger/notify.py b/custom_components/matrix_messenger/notify.py new file mode 100644 index 0000000..d4fc3ee --- /dev/null +++ b/custom_components/matrix_messenger/notify.py @@ -0,0 +1,75 @@ +"""Notify platform – creates one notify entity per configured Matrix room. + +Ergebnis: notify.matrix_ Entitäten, die im HA-Benachrichtigungsdialog +und in Automationen als Ziel auswählbar sind. +""" +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Any + +from homeassistant.components.notify import NotifyEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .config_flow import _effective_rooms +from .const import CONF_USERNAME, DOMAIN + +if TYPE_CHECKING: + from . import MatrixEntryData + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Registriert für jeden konfigurierten Raum eine NotifyEntity.""" + data: MatrixEntryData = hass.data[DOMAIN][entry.entry_id] + rooms = _effective_rooms(entry) + async_add_entities( + MatrixRoomNotifyEntity(entry, data, room_id, room_name) + for room_id, room_name in rooms.items() + ) + + +class MatrixRoomNotifyEntity(NotifyEntity): + """Eine Benachrichtigungsentität für einen einzelnen Matrix-Raum.""" + + _attr_has_entity_name = True + + def __init__( + self, + entry: ConfigEntry, + data: Any, + room_id: str, + room_name: str, + ) -> None: + self._entry = entry + self._data = data + self._room_id = room_id + self._attr_name = room_name + # Unique ID: entry-ID + Raum-ID verhindert Kollisionen bei umbenahnten Räumen + self._attr_unique_id = f"{entry.entry_id}_{room_id}" + + @property + def device_info(self) -> DeviceInfo: + return DeviceInfo( + identifiers={(DOMAIN, self._entry.entry_id)}, + name=f"Matrix ({self._entry.data.get(CONF_USERNAME, '')})", + manufacturer="Matrix.org", + model="Matrix Messenger", + ) + + async def async_send_message(self, message: str, title: str | None = None) -> None: + """Sendet eine Nachricht. Optionaler Titel wird fett vorangestellt.""" + text = f"**{title}**\n{message}" if title else message + success = await self._data.client.async_send_message(self._room_id, text) + if not success: + _LOGGER.error( + "notify: Nachricht an Raum %s konnte nicht gesendet werden", self._room_id + ) diff --git a/custom_components/matrix_messenger/services.yaml b/custom_components/matrix_messenger/services.yaml new file mode 100644 index 0000000..108cc03 --- /dev/null +++ b/custom_components/matrix_messenger/services.yaml @@ -0,0 +1,66 @@ +send_message: + name: Matrix-Nachricht senden + description: Sendet eine Textnachricht an einen konfigurierten Matrix-Raum. + fields: + room_id: + name: Raum + description: "Matrix-Raum-ID (z. B. !abc123:matrix.org). Wird beim Einrichten der Integration ausgewählt." + required: true + example: "!abc123:matrix.org" + selector: + text: + message: + name: Nachricht + description: Der zu sendende Text. + required: true + 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 diff --git a/custom_components/matrix_messenger/strings.json b/custom_components/matrix_messenger/strings.json new file mode 100644 index 0000000..d16744b --- /dev/null +++ b/custom_components/matrix_messenger/strings.json @@ -0,0 +1,56 @@ +{ + "config": { + "step": { + "user": { + "title": "Matrix-Server", + "description": "Verbinde Home Assistant mit deinem Matrix-Homeserver.", + "data": { + "homeserver": "Homeserver-URL", + "auth_method": "Anmeldeverfahren" + } + }, + "credentials_password": { + "title": "Anmeldung", + "data": { + "username": "Matrix-Benutzer-ID (z. B. @nutzer:matrix.org)", + "password": "Passwort", + "device_name": "Gerätename (optional)" + } + }, + "credentials_token": { + "title": "Access Token", + "description": "Den Access Token findest du in deinem Matrix-Client unter Einstellungen → Sicherheit → Sitzungen.", + "data": { + "username": "Matrix-Benutzer-ID (z. B. @nutzer:matrix.org)", + "access_token": "Access Token" + } + }, + "rooms": { + "title": "Matrix-Räume", + "description": "Wähle die Räume aus, in die Nachrichten gesendet werden sollen.", + "data": { + "rooms": "Räume", + "enable_sync": "Hintergrund-Sync aktivieren (für Antwortempfang)" + } + } + }, + "error": { + "cannot_connect": "Verbindung zum Matrix-Server fehlgeschlagen. Bitte URL und Zugangsdaten prüfen.", + "unknown": "Ein unerwarteter Fehler ist aufgetreten." + }, + "abort": { + "already_configured": "Diese Matrix-Instanz ist bereits konfiguriert." + } + }, + "options": { + "step": { + "init": { + "title": "Matrix Messenger – Optionen", + "data": { + "rooms": "Räume", + "enable_sync": "Hintergrund-Sync aktivieren" + } + } + } + } +} diff --git a/custom_components/matrix_messenger/translations/de.json b/custom_components/matrix_messenger/translations/de.json new file mode 100644 index 0000000..1d459ac --- /dev/null +++ b/custom_components/matrix_messenger/translations/de.json @@ -0,0 +1,56 @@ +{ + "config": { + "step": { + "user": { + "title": "Matrix-Server", + "description": "Verbinde Home Assistant mit deinem Matrix-Homeserver.", + "data": { + "homeserver": "Homeserver-URL", + "auth_method": "Anmeldeverfahren" + } + }, + "credentials_password": { + "title": "Anmeldung", + "data": { + "username": "Matrix-Benutzer-ID (z. B. @nutzer:matrix.org)", + "password": "Passwort", + "device_name": "Gerätename (optional)" + } + }, + "credentials_token": { + "title": "Access Token", + "description": "Den Access Token findest du in deinem Matrix-Client unter Einstellungen → Sicherheit → Sitzungen.", + "data": { + "username": "Matrix-Benutzer-ID (z. B. @nutzer:matrix.org)", + "access_token": "Access Token" + } + }, + "rooms": { + "title": "Matrix-Räume auswählen", + "description": "Wähle die Räume aus, in die Nachrichten gesendet werden sollen.", + "data": { + "rooms": "Räume", + "enable_sync": "Hintergrund-Sync aktivieren (für Antwortempfang)" + } + } + }, + "error": { + "cannot_connect": "Verbindung zum Matrix-Server fehlgeschlagen. Bitte URL und Zugangsdaten prüfen.", + "unknown": "Ein unerwarteter Fehler ist aufgetreten." + }, + "abort": { + "already_configured": "Diese Matrix-Instanz ist bereits konfiguriert." + } + }, + "options": { + "step": { + "init": { + "title": "Matrix Messenger – Optionen", + "data": { + "rooms": "Räume", + "enable_sync": "Hintergrund-Sync aktivieren" + } + } + } + } +} diff --git a/custom_components/matrix_messenger/translations/en.json b/custom_components/matrix_messenger/translations/en.json new file mode 100644 index 0000000..0ae6a97 --- /dev/null +++ b/custom_components/matrix_messenger/translations/en.json @@ -0,0 +1,56 @@ +{ + "config": { + "step": { + "user": { + "title": "Matrix Server", + "description": "Connect Home Assistant to your Matrix homeserver.", + "data": { + "homeserver": "Homeserver URL", + "auth_method": "Authentication method" + } + }, + "credentials_password": { + "title": "Login", + "data": { + "username": "Matrix User ID (e.g. @user:matrix.org)", + "password": "Password", + "device_name": "Device name (optional)" + } + }, + "credentials_token": { + "title": "Access Token", + "description": "You can find your access token in your Matrix client under Settings → Security → Sessions.", + "data": { + "username": "Matrix User ID (e.g. @user:matrix.org)", + "access_token": "Access Token" + } + }, + "rooms": { + "title": "Select Matrix Rooms", + "description": "Choose the rooms you want to send messages to.", + "data": { + "rooms": "Rooms", + "enable_sync": "Enable background sync (required to receive replies)" + } + } + }, + "error": { + "cannot_connect": "Could not connect to the Matrix server. Please check the URL and credentials.", + "unknown": "An unexpected error occurred." + }, + "abort": { + "already_configured": "This Matrix instance is already configured." + } + }, + "options": { + "step": { + "init": { + "title": "Matrix Messenger – Options", + "data": { + "rooms": "Rooms", + "enable_sync": "Enable background sync" + } + } + } + } +} diff --git a/hacs.json b/hacs.json new file mode 100644 index 0000000..90867ef --- /dev/null +++ b/hacs.json @@ -0,0 +1,6 @@ +{ + "name": "Matrix Messenger", + "content_in_root": false, + "render_readme": true, + "homeassistant": "2026.4.0" +}