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 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
* text=auto eol=lf
|
||||||
|
*.py text eol=lf
|
||||||
|
*.json text eol=lf
|
||||||
|
*.yaml text eol=lf
|
||||||
|
*.md text eol=lf
|
||||||
@@ -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_<room display name>
|
||||||
|
```
|
||||||
|
|
||||||
|
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:
|
||||||
|
|
||||||
|
```
|
||||||
|
<ha-config-dir>/.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
|
||||||
@@ -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
|
||||||
@@ -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))
|
||||||
@@ -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"
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
"""Notify platform – creates one notify entity per configured Matrix room.
|
||||||
|
|
||||||
|
Ergebnis: notify.matrix_<raumname> 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
|
||||||
|
)
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user