2026-04-25 00:49:31 +02:00
|
|
|
|
"""Config flow and options flow for Matrix Messenger."""
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
import logging
|
2026-06-03 00:30:56 +02:00
|
|
|
|
import re
|
2026-04-25 00:49:31 +02:00
|
|
|
|
|
|
|
|
|
|
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,
|
2026-06-03 00:30:56 +02:00
|
|
|
|
CONF_ACCOUNT_LABEL,
|
2026-04-25 00:49:31 +02:00
|
|
|
|
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__)
|
|
|
|
|
|
|
2026-06-03 00:30:56 +02:00
|
|
|
|
_LABEL_RE = re.compile(r'^[a-z0-9][a-z0-9_]{0,29}$')
|
|
|
|
|
|
|
2026-04-25 00:49:31 +02:00
|
|
|
|
|
|
|
|
|
|
class MatrixMessengerConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
2026-06-03 00:30:56 +02:00
|
|
|
|
"""Multi-step config flow: label + server → credentials → room selection."""
|
2026-04-25 00:49:31 +02:00
|
|
|
|
|
|
|
|
|
|
VERSION = 1
|
|
|
|
|
|
|
|
|
|
|
|
def __init__(self) -> None:
|
|
|
|
|
|
self._data: dict = {}
|
|
|
|
|
|
self._available_rooms: dict[str, str] = {}
|
|
|
|
|
|
|
|
|
|
|
|
# ------------------------------------------------------------------
|
2026-06-03 00:30:56 +02:00
|
|
|
|
# Step 1: account label + homeserver + auth method
|
2026-04-25 00:49:31 +02:00
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
async def async_step_user(self, user_input=None):
|
|
|
|
|
|
errors: dict[str, str] = {}
|
|
|
|
|
|
if user_input is not None:
|
2026-06-03 00:30:56 +02:00
|
|
|
|
label = user_input.get(CONF_ACCOUNT_LABEL, "").strip().lower()
|
|
|
|
|
|
if not _LABEL_RE.match(label):
|
|
|
|
|
|
errors[CONF_ACCOUNT_LABEL] = "invalid_label"
|
|
|
|
|
|
else:
|
|
|
|
|
|
self._data.update(user_input)
|
|
|
|
|
|
self._data[CONF_ACCOUNT_LABEL] = label
|
|
|
|
|
|
if user_input[CONF_AUTH_METHOD] == AUTH_METHOD_PASSWORD:
|
|
|
|
|
|
return await self.async_step_credentials_password()
|
|
|
|
|
|
return await self.async_step_credentials_token()
|
2026-04-25 00:49:31 +02:00
|
|
|
|
|
|
|
|
|
|
return self.async_show_form(
|
|
|
|
|
|
step_id="user",
|
|
|
|
|
|
data_schema=vol.Schema(
|
|
|
|
|
|
{
|
2026-06-03 00:30:56 +02:00
|
|
|
|
vol.Required(CONF_ACCOUNT_LABEL): selector.TextSelector(
|
|
|
|
|
|
selector.TextSelectorConfig(type=selector.TextSelectorType.TEXT)
|
|
|
|
|
|
),
|
2026-04-25 00:49:31 +02:00
|
|
|
|
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()
|
|
|
|
|
|
_, 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."
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# ------------------------------------------------------------------
|
2026-06-03 00:30:56 +02:00
|
|
|
|
# Step 3: room selection (optional – bridge-only accounts need no rooms)
|
2026-04-25 00:49:31 +02:00
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
async def async_step_rooms(self, user_input=None):
|
|
|
|
|
|
if user_input is not None:
|
|
|
|
|
|
selected = user_input.get(CONF_ROOMS, [])
|
2026-04-25 15:46:28 +02:00
|
|
|
|
manual_raw = user_input.get("manual_room_ids", "").strip()
|
|
|
|
|
|
|
|
|
|
|
|
rooms: dict[str, str] = {
|
|
|
|
|
|
rid: self._available_rooms.get(rid, rid)
|
2026-04-25 00:49:31 +02:00
|
|
|
|
for rid in selected
|
2026-04-25 15:46:28 +02:00
|
|
|
|
if rid
|
2026-04-25 00:49:31 +02:00
|
|
|
|
}
|
2026-04-25 15:46:28 +02:00
|
|
|
|
for raw in manual_raw.replace(",", " ").split():
|
|
|
|
|
|
rid = raw.strip()
|
|
|
|
|
|
if rid:
|
|
|
|
|
|
rooms[rid] = rid
|
|
|
|
|
|
|
2026-06-03 00:30:56 +02:00
|
|
|
|
self._data[CONF_ROOMS] = rooms
|
|
|
|
|
|
self._data[CONF_ENABLE_SYNC] = user_input.get(CONF_ENABLE_SYNC, False)
|
|
|
|
|
|
label = self._data.get(CONF_ACCOUNT_LABEL) or self._data.get(CONF_USERNAME, self._data[CONF_HOMESERVER])
|
|
|
|
|
|
return self.async_create_entry(title=label, data=self._data)
|
2026-04-25 15:46:28 +02:00
|
|
|
|
|
2026-06-03 00:30:56 +02:00
|
|
|
|
has_rooms = bool(self._available_rooms)
|
2026-04-25 15:46:28 +02:00
|
|
|
|
schema_dict: dict = {}
|
|
|
|
|
|
|
|
|
|
|
|
if has_rooms:
|
|
|
|
|
|
room_options = [
|
|
|
|
|
|
selector.SelectOptionDict(value=rid, label=name)
|
|
|
|
|
|
for rid, name in self._available_rooms.items()
|
|
|
|
|
|
]
|
2026-06-03 00:30:56 +02:00
|
|
|
|
schema_dict[vol.Optional(CONF_ROOMS, default=[])] = selector.SelectSelector(
|
2026-04-25 15:46:28 +02:00
|
|
|
|
selector.SelectSelectorConfig(
|
|
|
|
|
|
options=room_options,
|
|
|
|
|
|
multiple=True,
|
|
|
|
|
|
mode=selector.SelectSelectorMode.DROPDOWN,
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
2026-04-25 00:49:31 +02:00
|
|
|
|
|
2026-06-03 00:30:56 +02:00
|
|
|
|
schema_dict[vol.Optional("manual_room_ids", default="")] = selector.TextSelector(
|
|
|
|
|
|
selector.TextSelectorConfig(multiline=False)
|
|
|
|
|
|
)
|
2026-04-25 15:46:28 +02:00
|
|
|
|
schema_dict[vol.Optional(CONF_ENABLE_SYNC, default=False)] = selector.BooleanSelector()
|
|
|
|
|
|
|
2026-04-25 00:49:31 +02:00
|
|
|
|
return self.async_show_form(
|
|
|
|
|
|
step_id="rooms",
|
2026-04-25 15:46:28 +02:00
|
|
|
|
data_schema=vol.Schema(schema_dict),
|
2026-06-03 00:30:56 +02:00
|
|
|
|
description_placeholders={
|
|
|
|
|
|
"hint": (
|
|
|
|
|
|
"Räume sind optional. Bridge-Konten (WhatsApp, Signal, Telegram) "
|
|
|
|
|
|
"benötigen keine Räume – dort genügt der Service 'Direktnachricht senden'."
|
|
|
|
|
|
)
|
|
|
|
|
|
},
|
2026-04-25 00:49:31 +02:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
@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),
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
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 = [
|
2026-04-25 15:46:28 +02:00
|
|
|
|
selector.SelectOptionDict(value=rid, label=name)
|
2026-04-25 00:49:31 +02:00
|
|
|
|
for rid, name in self._available_rooms.items()
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
return self.async_show_form(
|
|
|
|
|
|
step_id="init",
|
|
|
|
|
|
data_schema=vol.Schema(
|
|
|
|
|
|
{
|
2026-06-03 00:30:56 +02:00
|
|
|
|
vol.Optional(CONF_ROOMS, default=current_rooms): selector.SelectSelector(
|
2026-04-25 00:49:31 +02:00
|
|
|
|
selector.SelectSelectorConfig(
|
|
|
|
|
|
options=room_options,
|
|
|
|
|
|
multiple=True,
|
2026-04-25 15:46:28 +02:00
|
|
|
|
mode=selector.SelectSelectorMode.DROPDOWN,
|
2026-04-25 00:49:31 +02:00
|
|
|
|
)
|
|
|
|
|
|
),
|
|
|
|
|
|
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))
|