v1.1.0: per-room services, send_to_user, mautrix bridge support, searchable room picker
- Add per-room convenience actions (matrix_messenger.send_to_<roomname>) - Add send_to_user action: finds existing portal/DM room or creates one; supports mautrix-whatsapp, -signal, -telegram puppet IDs - Inject service descriptions dynamically so room dropdowns show friendly names instead of room IDs (full-state sync + direct state API fallback) - Switch all room selectors to searchable dropdown mode - Fix _find_or_create_dm to match bridge portal rooms (3+ members) - Fix async_get_joined_rooms to use full_state sync - Bump version to 1.1.0 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,17 +1,20 @@
|
|||||||
# Matrix Messenger for Home Assistant
|
# Matrix Messenger for Home Assistant
|
||||||
|
|
||||||
Send messages from Home Assistant to [Matrix](https://matrix.org) rooms — including encrypted rooms (E2EE). Ask questions and react to text replies or emoji reactions in automations.
|
Send messages from Home Assistant to [Matrix](https://matrix.org) rooms — including encrypted rooms (E2EE). Ask questions and react to text replies or emoji reactions in automations. Supports mautrix bridges (WhatsApp, Signal, Telegram) out of the box.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Send messages** to one or more Matrix rooms via service call or `notify.*` entity
|
- **Send messages** to configured rooms via service call or `notify.*` entity
|
||||||
- **Ask questions** — send a question to a room and wait for a reply (text or emoji reaction)
|
- **Per-room services** — one dedicated action per room, selectable by friendly name
|
||||||
|
- **Direct messages** — send to any Matrix user or mautrix bridge contact without pre-configuring a room
|
||||||
|
- **Ask questions** — send a question and wait for a text reply or emoji reaction
|
||||||
|
- **mautrix bridge support** — WhatsApp, Signal, Telegram via `send_to_user`
|
||||||
- **E2EE support** — end-to-end encrypted rooms via [matrix-nio](https://github.com/poljar/matrix-nio)
|
- **E2EE support** — end-to-end encrypted rooms via [matrix-nio](https://github.com/poljar/matrix-nio)
|
||||||
|
- **Searchable room picker** — filterable dropdown in all actions and config dialogs
|
||||||
- **Two auth methods** — username + password, or access token
|
- **Two auth methods** — username + password, or access token
|
||||||
- **Fully GUI-configurable** — no YAML required, all settings via the HA config flow
|
- **Fully GUI-configurable** — no YAML required
|
||||||
- **HACS-installable**
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -73,7 +76,7 @@ Retrieve your token in your Matrix client: **Settings → Security → Sessions
|
|||||||
|
|
||||||
### Step 3 — Room Selection
|
### Step 3 — Room Selection
|
||||||
|
|
||||||
All rooms the account has joined are listed. Select one or more rooms.
|
All rooms the account has joined are listed with their display names. Select one or more rooms using the searchable dropdown.
|
||||||
|
|
||||||
**Enable background sync** — when enabled, the integration polls Matrix every 5 seconds continuously. Required if you want to receive replies to questions without triggering `ask_question` first.
|
**Enable background sync** — when enabled, the integration polls Matrix every 5 seconds continuously. Required if you want to receive replies to questions without triggering `ask_question` first.
|
||||||
|
|
||||||
@@ -81,19 +84,32 @@ All rooms the account has joined are listed. Select one or more rooms.
|
|||||||
|
|
||||||
## Reconfiguring rooms
|
## Reconfiguring rooms
|
||||||
|
|
||||||
Go to **Settings → Devices & Services → Matrix Messenger → Configure** to change the selected rooms or toggle background sync at any time.
|
Go to **Settings → Devices & Services → Matrix Messenger → Configure** to change the selected rooms or toggle background sync at any time. Rooms you have since left will no longer appear in the list and are removed from the configuration when you save.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Services / Actions
|
## Services / Actions
|
||||||
|
|
||||||
|
### `matrix_messenger.send_to_<roomname>`
|
||||||
|
|
||||||
|
One dedicated action is created for each configured room. No `room_id` parameter needed — just type the message.
|
||||||
|
|
||||||
|
**Example** (room named "Wohnzimmer"):
|
||||||
|
```yaml
|
||||||
|
action: matrix_messenger.send_to_wohnzimmer
|
||||||
|
data:
|
||||||
|
message: "Waschmaschine fertig."
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### `matrix_messenger.send_message`
|
### `matrix_messenger.send_message`
|
||||||
|
|
||||||
Sends a plain-text message to a room.
|
Sends a plain-text message to a room selected from a searchable dropdown.
|
||||||
|
|
||||||
| Parameter | Type | Required | Description |
|
| Parameter | Type | Required | Description |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `room_id` | string | ✓ | Matrix room ID, e.g. `!abc123:matrix.org` |
|
| `room_id` | string | ✓ | Selected from configured rooms |
|
||||||
| `message` | string | ✓ | Message text |
|
| `message` | string | ✓ | Message text |
|
||||||
|
|
||||||
**Example:**
|
**Example:**
|
||||||
@@ -106,13 +122,54 @@ data:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### `matrix_messenger.send_to_user`
|
||||||
|
|
||||||
|
Sends a direct message to a Matrix user. Searches all joined rooms for one that contains the target user; if none is found, a new DM room is created.
|
||||||
|
|
||||||
|
Works with **mautrix bridge puppets** (WhatsApp, Signal, Telegram) as long as a portal room for that contact already exists (i.e. you have chatted with the contact before via the bridge).
|
||||||
|
|
||||||
|
| Parameter | Type | Required | Description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `user_id` | string | ✓ | Matrix user ID, e.g. `@user:matrix.org` |
|
||||||
|
| `message` | string | ✓ | Message text |
|
||||||
|
|
||||||
|
**Example — native Matrix user:**
|
||||||
|
```yaml
|
||||||
|
action: matrix_messenger.send_to_user
|
||||||
|
data:
|
||||||
|
user_id: "@marc:matrix.org"
|
||||||
|
message: "Alarm triggered!"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example — WhatsApp contact via mautrix-whatsapp:**
|
||||||
|
```yaml
|
||||||
|
action: matrix_messenger.send_to_user
|
||||||
|
data:
|
||||||
|
user_id: "@whatsapp_4917612345678:your.server"
|
||||||
|
message: "Doorbell rang."
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Bridge puppet ID format
|
||||||
|
|
||||||
|
| Bridge | User ID format |
|
||||||
|
|---|---|
|
||||||
|
| WhatsApp | `@whatsapp_<number without +>:<server>` |
|
||||||
|
| Signal | `@signal_<number without +>:<server>` |
|
||||||
|
| Telegram | `@telegram_<user id>:<server>` |
|
||||||
|
|
||||||
|
Find the exact puppet ID in your Matrix client (e.g. Element) under the contact's profile → Matrix ID.
|
||||||
|
|
||||||
|
> **Note:** Messages sent via bridge puppets appear to the recipient as coming from your Matrix account — this is how mautrix bridges work by design.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### `matrix_messenger.ask_question`
|
### `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.
|
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 |
|
| Parameter | Type | Required | Default | Description |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| `room_id` | string | ✓ | — | Matrix room ID |
|
| `room_id` | string | ✓ | — | Selected from configured rooms |
|
||||||
| `question` | string | ✓ | — | Question text |
|
| `question` | string | ✓ | — | Question text |
|
||||||
| `options` | list of strings | | `[]` | If set, only these exact replies (or emoji reactions) are accepted |
|
| `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 |
|
| `timeout` | integer (seconds) | | `1800` | How long to wait. Min 60, max 7200 |
|
||||||
@@ -137,11 +194,6 @@ data:
|
|||||||
timeout: 300
|
timeout: 300
|
||||||
```
|
```
|
||||||
|
|
||||||
The integration appends the options to the message:
|
|
||||||
> Alarm triggered — is this a false alarm?
|
|
||||||
>
|
|
||||||
> Mögliche Antworten: Yes / No
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Notify entities
|
## Notify entities
|
||||||
@@ -174,7 +226,7 @@ Fired when a reply to an `ask_question` call is received.
|
|||||||
|
|
||||||
| Attribute | Type | Description |
|
| Attribute | Type | Description |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `question_id` | string (UUID) | Unique ID of the question (from the service call) |
|
| `question_id` | string (UUID) | Unique ID of the question |
|
||||||
| `room_id` | string | Matrix room ID where the reply was received |
|
| `room_id` | string | Matrix room ID where the reply was received |
|
||||||
| `response` | string | The reply text or emoji |
|
| `response` | string | The reply text or emoji |
|
||||||
| `response_type` | `"text"` or `"emoji"` | How the reply was sent |
|
| `response_type` | `"text"` or `"emoji"` | How the reply was sent |
|
||||||
@@ -193,9 +245,8 @@ automation:
|
|||||||
entity_id: binary_sensor.front_door_motion
|
entity_id: binary_sensor.front_door_motion
|
||||||
to: "on"
|
to: "on"
|
||||||
action:
|
action:
|
||||||
- action: matrix_messenger.send_message
|
- action: matrix_messenger.send_to_wohnzimmer
|
||||||
data:
|
data:
|
||||||
room_id: "!abc123:matrix.org"
|
|
||||||
message: "Motion detected at the front door."
|
message: "Motion detected at the front door."
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -211,7 +262,6 @@ automation:
|
|||||||
entity_id: alarm_control_panel.home
|
entity_id: alarm_control_panel.home
|
||||||
to: "triggered"
|
to: "triggered"
|
||||||
action:
|
action:
|
||||||
# 1. Send the question — store question_id for matching
|
|
||||||
- action: matrix_messenger.ask_question
|
- action: matrix_messenger.ask_question
|
||||||
data:
|
data:
|
||||||
room_id: "!abc123:matrix.org"
|
room_id: "!abc123:matrix.org"
|
||||||
@@ -220,7 +270,6 @@ automation:
|
|||||||
- "Yes"
|
- "Yes"
|
||||||
- "No"
|
- "No"
|
||||||
timeout: 300
|
timeout: 300
|
||||||
# 2. Wait for the response event
|
|
||||||
- wait_for_trigger:
|
- wait_for_trigger:
|
||||||
- platform: event
|
- platform: event
|
||||||
event_type: matrix_messenger_response
|
event_type: matrix_messenger_response
|
||||||
@@ -228,7 +277,6 @@ automation:
|
|||||||
room_id: "!abc123:matrix.org"
|
room_id: "!abc123:matrix.org"
|
||||||
timeout: "00:05:00"
|
timeout: "00:05:00"
|
||||||
continue_on_timeout: true
|
continue_on_timeout: true
|
||||||
# 3. Branch
|
|
||||||
- choose:
|
- choose:
|
||||||
- conditions:
|
- conditions:
|
||||||
- condition: template
|
- condition: template
|
||||||
@@ -298,8 +346,10 @@ The integration stores matrix-nio's E2EE keys (Olm/Megolm sessions) in:
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `ImportError: libolm` on startup | `libolm` not installed | `apt install libolm-dev` (HA Core only) |
|
| `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 |
|
| Rooms list is empty after login | Account not joined to any rooms | Join at least one room in your Matrix client first |
|
||||||
|
| Room names show as IDs | Stale `.pyc` cache on network share | Delete `__pycache__/` in the component folder and restart HA |
|
||||||
| Encrypted messages not decrypted | Keys missing (first sync) | Wait for the first full sync to complete after setup |
|
| Encrypted messages not decrypted | Keys missing (first sync) | Wait for the first full sync to complete after setup |
|
||||||
| `ask_question` never fires event | Sync not running | Enable *background sync* in the integration options, or check that no timeout expired |
|
| `ask_question` never fires event | Sync not running | Enable *background sync* in options, or check that no timeout expired |
|
||||||
|
| `send_to_user` fails for bridge contact | No portal room exists yet | Start one conversation with the contact in Element first |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
@@ -50,8 +51,10 @@ class PendingQuestion:
|
|||||||
@dataclass
|
@dataclass
|
||||||
class MatrixEntryData:
|
class MatrixEntryData:
|
||||||
client: MatrixClient
|
client: MatrixClient
|
||||||
|
display_names: dict[str, str] = field(default_factory=dict)
|
||||||
pending_questions: dict[str, PendingQuestion] = field(default_factory=dict)
|
pending_questions: dict[str, PendingQuestion] = field(default_factory=dict)
|
||||||
sync_task: asyncio.Task | None = None
|
sync_task: asyncio.Task | None = None
|
||||||
|
room_service_names: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -75,7 +78,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
device_id=entry.data.get(CONF_DEVICE_ID, ""),
|
device_id=entry.data.get(CONF_DEVICE_ID, ""),
|
||||||
)
|
)
|
||||||
|
|
||||||
data = MatrixEntryData(client=client)
|
# Fetch room display names via direct state API (independent of sync state)
|
||||||
|
stored_rooms = _effective_rooms(entry)
|
||||||
|
try:
|
||||||
|
display_names = await client.async_get_room_names(list(stored_rooms.keys()))
|
||||||
|
except Exception:
|
||||||
|
_LOGGER.debug("Could not fetch room display names – falling back to stored names")
|
||||||
|
display_names = dict(stored_rooms)
|
||||||
|
|
||||||
|
data = MatrixEntryData(client=client, display_names=display_names)
|
||||||
hass.data[DOMAIN][entry.entry_id] = data
|
hass.data[DOMAIN][entry.entry_id] = data
|
||||||
|
|
||||||
async def on_message(room: Any, event: Any) -> None:
|
async def on_message(room: Any, event: Any) -> None:
|
||||||
@@ -106,10 +117,22 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
_cancel_sync(data)
|
_cancel_sync(data)
|
||||||
await data.client.async_close()
|
await data.client.async_close()
|
||||||
|
|
||||||
for service_name in ("send_message", "ask_question"):
|
all_services = ["send_message", "ask_question", "send_to_user"]
|
||||||
|
if data:
|
||||||
|
all_services.extend(data.room_service_names)
|
||||||
|
for service_name in all_services:
|
||||||
if hass.services.has_service(DOMAIN, service_name):
|
if hass.services.has_service(DOMAIN, service_name):
|
||||||
hass.services.async_remove(DOMAIN, service_name)
|
hass.services.async_remove(DOMAIN, service_name)
|
||||||
|
|
||||||
|
# Remove injected descriptions from cache so stale entries don't linger
|
||||||
|
try:
|
||||||
|
from homeassistant.loader import SERVICE_DESCRIPTION_CACHE as _cache_key
|
||||||
|
except ImportError:
|
||||||
|
_cache_key = "service_description_cache"
|
||||||
|
cache = hass.data.get(_cache_key, {})
|
||||||
|
for svc in ["send_message", "ask_question"] + (data.room_service_names if data else []):
|
||||||
|
cache.pop((DOMAIN, svc), None)
|
||||||
|
|
||||||
await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -124,6 +147,96 @@ async def _async_options_updated(hass: HomeAssistant, entry: ConfigEntry) -> Non
|
|||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _inject_service_descriptions(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
rooms: dict[str, str],
|
||||||
|
room_service_names: list[str],
|
||||||
|
display_names: dict[str, str] | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Inject dynamic service descriptions so HA shows friendly dropdowns and text fields."""
|
||||||
|
try:
|
||||||
|
from homeassistant.loader import SERVICE_DESCRIPTION_CACHE as _cache_key
|
||||||
|
except ImportError:
|
||||||
|
_cache_key = "service_description_cache" # legacy fallback
|
||||||
|
|
||||||
|
cache: dict = hass.data.setdefault(_cache_key, {})
|
||||||
|
labels = display_names or {}
|
||||||
|
|
||||||
|
msg_field = {
|
||||||
|
"name": "Nachricht",
|
||||||
|
"description": "Der zu sendende Text.",
|
||||||
|
"required": True,
|
||||||
|
"selector": {"text": {"multiline": True}},
|
||||||
|
}
|
||||||
|
room_options = [
|
||||||
|
{"value": rid, "label": labels.get(rid) or stored or rid}
|
||||||
|
for rid, stored in rooms.items()
|
||||||
|
]
|
||||||
|
room_field = {
|
||||||
|
"name": "Raum",
|
||||||
|
"description": "Wähle einen konfigurierten Matrix-Raum.",
|
||||||
|
"required": True,
|
||||||
|
"selector": {"select": {"options": room_options, "mode": "dropdown"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
cache[(DOMAIN, "send_message")] = {
|
||||||
|
"name": "Matrix-Nachricht senden",
|
||||||
|
"description": "Sendet eine Textnachricht an einen konfigurierten Matrix-Raum.",
|
||||||
|
"fields": {"room_id": room_field, "message": msg_field},
|
||||||
|
}
|
||||||
|
cache[(DOMAIN, "ask_question")] = {
|
||||||
|
"name": "Frage in Matrix-Raum stellen",
|
||||||
|
"description": (
|
||||||
|
"Sendet eine Frage und wartet auf Antwort (Text oder Emoji-Reaktion). "
|
||||||
|
"Löst das Event 'matrix_messenger_response' aus."
|
||||||
|
),
|
||||||
|
"fields": {
|
||||||
|
"room_id": room_field,
|
||||||
|
"question": {
|
||||||
|
"name": "Frage",
|
||||||
|
"description": "Der Fragetext.",
|
||||||
|
"required": True,
|
||||||
|
"selector": {"text": {"multiline": True}},
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"name": "Antwortoptionen",
|
||||||
|
"description": "Optionale Liste gültiger Antworten. Leer = jede Antwort.",
|
||||||
|
"required": False,
|
||||||
|
"selector": {"object": {}},
|
||||||
|
},
|
||||||
|
"timeout": {
|
||||||
|
"name": "Timeout (Sekunden)",
|
||||||
|
"description": "Wartezeit. Standard: 1800 s (30 Minuten).",
|
||||||
|
"required": False,
|
||||||
|
"default": DEFAULT_QUESTION_TIMEOUT,
|
||||||
|
"selector": {
|
||||||
|
"number": {"min": 60, "max": 7200, "step": 60, "unit_of_measurement": "s", "mode": "box"}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for service_name in room_service_names:
|
||||||
|
slug = service_name[len("send_to_"):]
|
||||||
|
room_id = next(
|
||||||
|
(rid for rid, name in rooms.items() if _room_slug(name) == slug),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
label = (labels.get(room_id) or rooms.get(room_id) or slug) if room_id else slug
|
||||||
|
cache[(DOMAIN, service_name)] = {
|
||||||
|
"name": f"Matrix → {label}",
|
||||||
|
"description": f'Sendet eine Nachricht an den Matrix-Raum "{label}".',
|
||||||
|
"fields": {"message": msg_field},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _room_slug(name: str) -> str:
|
||||||
|
"""Convert a room display name to a valid HA service name fragment."""
|
||||||
|
slug = name.lower()
|
||||||
|
slug = re.sub(r"[^a-z0-9]+", "_", slug).strip("_")
|
||||||
|
return slug or "room"
|
||||||
|
|
||||||
|
|
||||||
def _register_services(
|
def _register_services(
|
||||||
hass: HomeAssistant, entry: ConfigEntry, data: MatrixEntryData
|
hass: HomeAssistant, entry: ConfigEntry, data: MatrixEntryData
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -144,7 +257,6 @@ def _register_services(
|
|||||||
options: list[str] = call.data.get("options", [])
|
options: list[str] = call.data.get("options", [])
|
||||||
timeout: int = call.data.get("timeout", DEFAULT_QUESTION_TIMEOUT)
|
timeout: int = call.data.get("timeout", DEFAULT_QUESTION_TIMEOUT)
|
||||||
|
|
||||||
# Assemble full message text
|
|
||||||
text = question
|
text = question
|
||||||
if options:
|
if options:
|
||||||
text = f"{question}\n\nMögliche Antworten: {' / '.join(options)}"
|
text = f"{question}\n\nMögliche Antworten: {' / '.join(options)}"
|
||||||
@@ -160,13 +272,19 @@ def _register_services(
|
|||||||
)
|
)
|
||||||
_LOGGER.debug("Frage %s wartet auf Antwort in Raum %s", qid, room_id)
|
_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():
|
if data.sync_task is None or data.sync_task.done():
|
||||||
data.sync_task = hass.async_create_background_task(
|
data.sync_task = hass.async_create_background_task(
|
||||||
_sync_loop(hass, entry, data, stop_when_idle=True),
|
_sync_loop(hass, entry, data, stop_when_idle=True),
|
||||||
name=f"{DOMAIN}_sync_{entry.entry_id}",
|
name=f"{DOMAIN}_sync_{entry.entry_id}",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def handle_send_to_user(call: ServiceCall) -> None:
|
||||||
|
user_id: str = call.data["user_id"]
|
||||||
|
message: str = call.data["message"]
|
||||||
|
success = await data.client.async_send_to_user(user_id, message)
|
||||||
|
if not success:
|
||||||
|
_LOGGER.error("Direktnachricht an %s konnte nicht gesendet werden", user_id)
|
||||||
|
|
||||||
hass.services.async_register(
|
hass.services.async_register(
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
"send_message",
|
"send_message",
|
||||||
@@ -195,6 +313,50 @@ def _register_services(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
hass.services.async_register(
|
||||||
|
DOMAIN,
|
||||||
|
"send_to_user",
|
||||||
|
handle_send_to_user,
|
||||||
|
schema=vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Required("user_id"): str,
|
||||||
|
vol.Required("message"): str,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Per-room convenience services: matrix_messenger.send_to_<slug>
|
||||||
|
# (registered first so room_service_names is populated before injection)
|
||||||
|
used_slugs: set[str] = set()
|
||||||
|
for room_id, room_name in rooms.items():
|
||||||
|
base = _room_slug(room_name)
|
||||||
|
slug = base
|
||||||
|
counter = 2
|
||||||
|
while slug in used_slugs:
|
||||||
|
slug = f"{base}_{counter}"
|
||||||
|
counter += 1
|
||||||
|
used_slugs.add(slug)
|
||||||
|
|
||||||
|
service_name = f"send_to_{slug}"
|
||||||
|
data.room_service_names.append(service_name)
|
||||||
|
|
||||||
|
def _make_handler(rid: str, rname: str):
|
||||||
|
async def handler(call: ServiceCall) -> None:
|
||||||
|
msg: str = call.data["message"]
|
||||||
|
success = await data.client.async_send_message(rid, msg)
|
||||||
|
if not success:
|
||||||
|
_LOGGER.error("Nachricht an %s (%s) konnte nicht gesendet werden", rname, rid)
|
||||||
|
return handler
|
||||||
|
|
||||||
|
hass.services.async_register(
|
||||||
|
DOMAIN,
|
||||||
|
service_name,
|
||||||
|
_make_handler(room_id, room_name),
|
||||||
|
schema=vol.Schema({vol.Required("message"): str}),
|
||||||
|
)
|
||||||
|
|
||||||
|
_inject_service_descriptions(hass, rooms, data.room_service_names, data.display_names)
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Background sync loop
|
# Background sync loop
|
||||||
|
|||||||
@@ -203,38 +203,67 @@ class MatrixMessengerConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
|
|
||||||
async def async_step_rooms(self, user_input=None):
|
async def async_step_rooms(self, user_input=None):
|
||||||
errors: dict[str, str] = {}
|
errors: dict[str, str] = {}
|
||||||
|
has_rooms = bool(self._available_rooms)
|
||||||
|
|
||||||
if user_input is not None:
|
if user_input is not None:
|
||||||
selected = user_input.get(CONF_ROOMS, [])
|
selected = user_input.get(CONF_ROOMS, [])
|
||||||
self._data[CONF_ROOMS] = {
|
manual_raw = user_input.get("manual_room_ids", "").strip()
|
||||||
rid: self._available_rooms[rid]
|
|
||||||
|
rooms: dict[str, str] = {
|
||||||
|
rid: self._available_rooms.get(rid, rid)
|
||||||
for rid in selected
|
for rid in selected
|
||||||
if rid in self._available_rooms
|
if rid
|
||||||
}
|
}
|
||||||
self._data[CONF_ENABLE_SYNC] = user_input.get(CONF_ENABLE_SYNC, False)
|
for raw in manual_raw.replace(",", " ").split():
|
||||||
return self.async_create_entry(
|
rid = raw.strip()
|
||||||
title=self._data.get(CONF_USERNAME, self._data[CONF_HOMESERVER]),
|
if rid:
|
||||||
data=self._data,
|
rooms[rid] = rid
|
||||||
|
|
||||||
|
if not rooms:
|
||||||
|
errors["base"] = "no_rooms"
|
||||||
|
else:
|
||||||
|
self._data[CONF_ROOMS] = rooms
|
||||||
|
self._data[CONF_ENABLE_SYNC] = user_input.get(CONF_ENABLE_SYNC, False)
|
||||||
|
return self.async_create_entry(
|
||||||
|
title=self._data.get(CONF_USERNAME, self._data[CONF_HOMESERVER]),
|
||||||
|
data=self._data,
|
||||||
|
)
|
||||||
|
|
||||||
|
schema_dict: dict = {}
|
||||||
|
|
||||||
|
if has_rooms:
|
||||||
|
room_options = [
|
||||||
|
selector.SelectOptionDict(value=rid, label=name)
|
||||||
|
for rid, name in self._available_rooms.items()
|
||||||
|
]
|
||||||
|
schema_dict[vol.Required(CONF_ROOMS)] = selector.SelectSelector(
|
||||||
|
selector.SelectSelectorConfig(
|
||||||
|
options=room_options,
|
||||||
|
multiple=True,
|
||||||
|
mode=selector.SelectSelectorMode.DROPDOWN,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
schema_dict[vol.Optional("manual_room_ids", default="")] = selector.TextSelector(
|
||||||
|
selector.TextSelectorConfig(multiline=False)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
schema_dict[vol.Required("manual_room_ids")] = selector.TextSelector(
|
||||||
|
selector.TextSelectorConfig(multiline=True)
|
||||||
)
|
)
|
||||||
|
|
||||||
room_options = [
|
schema_dict[vol.Optional(CONF_ENABLE_SYNC, default=False)] = selector.BooleanSelector()
|
||||||
selector.SelectOptionDict(value=rid, label=f"{name} ({rid})")
|
|
||||||
for rid, name in self._available_rooms.items()
|
placeholders = {}
|
||||||
]
|
if not has_rooms:
|
||||||
|
placeholders["hint"] = (
|
||||||
|
"Keine Räume gefunden. Raum-IDs eingeben (z.B. !abc123:chat.example.com), "
|
||||||
|
"mehrere mit Komma oder Leerzeichen trennen."
|
||||||
|
)
|
||||||
|
|
||||||
return self.async_show_form(
|
return self.async_show_form(
|
||||||
step_id="rooms",
|
step_id="rooms",
|
||||||
data_schema=vol.Schema(
|
data_schema=vol.Schema(schema_dict),
|
||||||
{
|
description_placeholders=placeholders if placeholders else None,
|
||||||
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,
|
errors=errors,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -288,7 +317,7 @@ class MatrixMessengerOptionsFlow(config_entries.OptionsFlow):
|
|||||||
current_rooms = list(_effective_rooms(self._entry).keys())
|
current_rooms = list(_effective_rooms(self._entry).keys())
|
||||||
|
|
||||||
room_options = [
|
room_options = [
|
||||||
selector.SelectOptionDict(value=rid, label=f"{name} ({rid})")
|
selector.SelectOptionDict(value=rid, label=name)
|
||||||
for rid, name in self._available_rooms.items()
|
for rid, name in self._available_rooms.items()
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -300,7 +329,7 @@ class MatrixMessengerOptionsFlow(config_entries.OptionsFlow):
|
|||||||
selector.SelectSelectorConfig(
|
selector.SelectSelectorConfig(
|
||||||
options=room_options,
|
options=room_options,
|
||||||
multiple=True,
|
multiple=True,
|
||||||
mode=selector.SelectSelectorMode.LIST,
|
mode=selector.SelectSelectorMode.DROPDOWN,
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
vol.Optional(
|
vol.Optional(
|
||||||
|
|||||||
@@ -7,6 +7,6 @@
|
|||||||
"homeassistant": "2026.4.0",
|
"homeassistant": "2026.4.0",
|
||||||
"iot_class": "cloud_push",
|
"iot_class": "cloud_push",
|
||||||
"requirements": ["matrix-nio[e2e]>=0.21.0"],
|
"requirements": ["matrix-nio[e2e]>=0.21.0"],
|
||||||
"version": "1.0.0",
|
"version": "1.1.0",
|
||||||
"integration_type": "hub"
|
"integration_type": "hub"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,11 +8,15 @@ from typing import Any, Awaitable, Callable
|
|||||||
from nio import (
|
from nio import (
|
||||||
AsyncClient,
|
AsyncClient,
|
||||||
AsyncClientConfig,
|
AsyncClientConfig,
|
||||||
|
JoinedRoomsResponse,
|
||||||
KeysUploadResponse,
|
KeysUploadResponse,
|
||||||
LoginResponse,
|
LoginResponse,
|
||||||
MegolmEvent,
|
MegolmEvent,
|
||||||
|
RoomCreateResponse,
|
||||||
|
RoomGetStateEventResponse,
|
||||||
RoomMessageText,
|
RoomMessageText,
|
||||||
RoomSendResponse,
|
RoomSendResponse,
|
||||||
|
SyncResponse,
|
||||||
UnknownEvent,
|
UnknownEvent,
|
||||||
WhoamiResponse,
|
WhoamiResponse,
|
||||||
)
|
)
|
||||||
@@ -36,16 +40,30 @@ class MatrixClient:
|
|||||||
self._client: AsyncClient | None = None
|
self._client: AsyncClient | None = None
|
||||||
self._message_callbacks: list[MessageCallback] = []
|
self._message_callbacks: list[MessageCallback] = []
|
||||||
self._reaction_callbacks: list[MessageCallback] = []
|
self._reaction_callbacks: list[MessageCallback] = []
|
||||||
|
self._encryption_enabled = False
|
||||||
|
|
||||||
async def async_setup(self) -> None:
|
async def async_setup(self) -> None:
|
||||||
"""Create the nio client. Must be called before any other method."""
|
"""Create the nio client. Must be called before any other method."""
|
||||||
os.makedirs(self._store_path, exist_ok=True)
|
os.makedirs(self._store_path, exist_ok=True)
|
||||||
config = AsyncClientConfig(
|
try:
|
||||||
max_limit_exceeded=0,
|
config = AsyncClientConfig(
|
||||||
max_timeouts=0,
|
max_limit_exceeded=0,
|
||||||
store_sync_tokens=True,
|
max_timeouts=0,
|
||||||
encryption_enabled=True,
|
store_sync_tokens=True,
|
||||||
)
|
encryption_enabled=True,
|
||||||
|
)
|
||||||
|
self._encryption_enabled = True
|
||||||
|
except ImportWarning:
|
||||||
|
_LOGGER.warning(
|
||||||
|
"E2EE-Abhängigkeiten (python-olm) nicht installiert – "
|
||||||
|
"Verschlüsselung deaktiviert. Für E2EE 'matrix-nio[e2e]' installieren."
|
||||||
|
)
|
||||||
|
config = AsyncClientConfig(
|
||||||
|
max_limit_exceeded=0,
|
||||||
|
max_timeouts=0,
|
||||||
|
store_sync_tokens=True,
|
||||||
|
encryption_enabled=False,
|
||||||
|
)
|
||||||
self._client = AsyncClient(
|
self._client = AsyncClient(
|
||||||
homeserver=self._homeserver,
|
homeserver=self._homeserver,
|
||||||
user=self._user_id,
|
user=self._user_id,
|
||||||
@@ -54,7 +72,8 @@ class MatrixClient:
|
|||||||
)
|
)
|
||||||
self._client.add_event_callback(self._on_message, RoomMessageText)
|
self._client.add_event_callback(self._on_message, RoomMessageText)
|
||||||
self._client.add_event_callback(self._on_unknown_event, UnknownEvent)
|
self._client.add_event_callback(self._on_unknown_event, UnknownEvent)
|
||||||
self._client.add_event_callback(self._on_megolm, MegolmEvent)
|
if self._encryption_enabled:
|
||||||
|
self._client.add_event_callback(self._on_megolm, MegolmEvent)
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Login helpers
|
# Login helpers
|
||||||
@@ -86,7 +105,8 @@ class MatrixClient:
|
|||||||
device_id=device_id,
|
device_id=device_id,
|
||||||
access_token=access_token,
|
access_token=access_token,
|
||||||
)
|
)
|
||||||
self._client.load_store()
|
if self._encryption_enabled:
|
||||||
|
self._client.load_store()
|
||||||
|
|
||||||
await self._upload_keys_if_needed()
|
await self._upload_keys_if_needed()
|
||||||
return device_id
|
return device_id
|
||||||
@@ -105,11 +125,82 @@ class MatrixClient:
|
|||||||
|
|
||||||
async def async_get_joined_rooms(self) -> dict[str, str]:
|
async def async_get_joined_rooms(self) -> dict[str, str]:
|
||||||
"""Return {room_id: display_name} for all joined rooms."""
|
"""Return {room_id: display_name} for all joined rooms."""
|
||||||
await self._client.sync(timeout=10000)
|
rooms_resp = await self._client.joined_rooms()
|
||||||
return {
|
if not isinstance(rooms_resp, JoinedRoomsResponse):
|
||||||
room_id: room.display_name or room_id
|
_LOGGER.error("Konnte Raumliste nicht laden: %s", rooms_resp)
|
||||||
for room_id, room in self._client.rooms.items()
|
return {}
|
||||||
}
|
room_ids = list(rooms_resp.rooms)
|
||||||
|
return await self.async_get_room_names(room_ids)
|
||||||
|
|
||||||
|
async def async_get_room_names(self, room_ids: list[str]) -> dict[str, str]:
|
||||||
|
"""Fetch room display names.
|
||||||
|
|
||||||
|
Strategy:
|
||||||
|
1. Direct state API (m.room.name / m.room.canonical_alias) – works
|
||||||
|
without syncing and is unaffected by incremental sync tokens.
|
||||||
|
2. full_state sync fallback – populates client.rooms with current
|
||||||
|
state so display_name can be calculated from room name / member list.
|
||||||
|
"""
|
||||||
|
result: dict[str, str] = {}
|
||||||
|
|
||||||
|
# --- Strategy 1: direct state API ---
|
||||||
|
for room_id in room_ids:
|
||||||
|
name = room_id
|
||||||
|
for event_type, field in (
|
||||||
|
("m.room.name", "name"),
|
||||||
|
("m.room.canonical_alias", "alias"),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
resp = await self._client.room_get_state_event(room_id, event_type)
|
||||||
|
# Use duck-typing so we work across different nio versions
|
||||||
|
content = getattr(resp, "content", None)
|
||||||
|
_LOGGER.debug(
|
||||||
|
"state_event(%s, %s) → %s content=%s",
|
||||||
|
room_id, event_type, type(resp).__name__, content,
|
||||||
|
)
|
||||||
|
if isinstance(content, dict):
|
||||||
|
val = str(content.get(field, "")).strip()
|
||||||
|
if val:
|
||||||
|
name = val
|
||||||
|
break
|
||||||
|
except Exception as exc:
|
||||||
|
_LOGGER.debug("state_event(%s, %s) exception: %s", room_id, event_type, exc)
|
||||||
|
result[room_id] = name
|
||||||
|
|
||||||
|
# --- Strategy 2: full_state sync for rooms still unresolved ---
|
||||||
|
unresolved = [rid for rid in room_ids if result.get(rid) == rid]
|
||||||
|
if unresolved:
|
||||||
|
_LOGGER.debug(
|
||||||
|
"%d room(s) unresolved after state API – trying full_state sync", len(unresolved)
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
sync_resp = await self._client.sync(timeout=12000, full_state=True)
|
||||||
|
if isinstance(sync_resp, SyncResponse):
|
||||||
|
for room_id in unresolved:
|
||||||
|
room = self._client.rooms.get(room_id)
|
||||||
|
if room:
|
||||||
|
name = getattr(room, "display_name", None) or getattr(room, "name", None)
|
||||||
|
_LOGGER.debug(" full_state display_name(%s) → %r", room_id, name)
|
||||||
|
if name and name != room_id:
|
||||||
|
result[room_id] = name
|
||||||
|
except Exception as exc:
|
||||||
|
_LOGGER.warning("full_state sync fehlgeschlagen: %s", exc)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
def get_room_display_names(self) -> dict[str, str]:
|
||||||
|
"""Return {room_id: display_name} from currently synced room state."""
|
||||||
|
if not self._client:
|
||||||
|
return {}
|
||||||
|
result = {}
|
||||||
|
for room_id, room in self._client.rooms.items():
|
||||||
|
name = (
|
||||||
|
getattr(room, "display_name", None)
|
||||||
|
or getattr(room, "name", None)
|
||||||
|
or room_id
|
||||||
|
)
|
||||||
|
result[room_id] = name
|
||||||
|
return result
|
||||||
|
|
||||||
async def async_send_message(self, room_id: str, message: str) -> bool:
|
async def async_send_message(self, room_id: str, message: str) -> bool:
|
||||||
"""Send a plain-text message. Returns True on success."""
|
"""Send a plain-text message. Returns True on success."""
|
||||||
@@ -123,9 +214,54 @@ class MatrixClient:
|
|||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
async def async_sync_once(self, timeout_ms: int = 5000) -> None:
|
async def async_sync_once(self, timeout_ms: int = 5000, full_state: bool = False) -> None:
|
||||||
"""Perform one Matrix /sync call."""
|
"""Perform one Matrix /sync call.
|
||||||
await self._client.sync(timeout=timeout_ms)
|
|
||||||
|
full_state=True forces the server to return complete room state (incl.
|
||||||
|
m.room.name) regardless of any stored sync token.
|
||||||
|
"""
|
||||||
|
resp = await self._client.sync(timeout=timeout_ms, full_state=full_state or None)
|
||||||
|
if not isinstance(resp, SyncResponse):
|
||||||
|
_LOGGER.debug("Sync fehlgeschlagen: %s", resp)
|
||||||
|
|
||||||
|
async def async_send_to_user(self, user_id: str, message: str) -> bool:
|
||||||
|
"""Send a direct message to a Matrix user. Finds or creates a DM room."""
|
||||||
|
room_id = await self._find_or_create_dm(user_id)
|
||||||
|
if not room_id:
|
||||||
|
return False
|
||||||
|
return await self.async_send_message(room_id, message)
|
||||||
|
|
||||||
|
async def _find_or_create_dm(self, user_id: str) -> str | None:
|
||||||
|
"""Return an existing room_id for user_id, or create one.
|
||||||
|
|
||||||
|
Bridge portal rooms (mautrix-whatsapp, -signal, -telegram) have 3+
|
||||||
|
members (user + puppet + bridge bot), so we search all joined rooms
|
||||||
|
for one that contains the target user and prefer the one with the
|
||||||
|
fewest members (most likely a direct/portal room).
|
||||||
|
"""
|
||||||
|
await self.async_sync_once(timeout_ms=5000)
|
||||||
|
|
||||||
|
my_id = self._client.user_id
|
||||||
|
candidates: list[tuple[int, str]] = []
|
||||||
|
for room_id, room in self._client.rooms.items():
|
||||||
|
joined = [
|
||||||
|
uid for uid, member in room.users.items()
|
||||||
|
if getattr(member, "membership", None) == "join"
|
||||||
|
]
|
||||||
|
if user_id in joined and my_id in joined:
|
||||||
|
candidates.append((len(joined), room_id))
|
||||||
|
|
||||||
|
if candidates:
|
||||||
|
candidates.sort() # fewest members first → most likely the direct/portal room
|
||||||
|
return candidates[0][1]
|
||||||
|
|
||||||
|
# No existing room found – try to create a DM (works for native Matrix
|
||||||
|
# users; bridge puppets usually require the bridge bot to initiate).
|
||||||
|
resp = await self._client.room_create(is_direct=True, invite=[user_id])
|
||||||
|
if isinstance(resp, RoomCreateResponse):
|
||||||
|
return resp.room_id
|
||||||
|
_LOGGER.error("Konnte Raum für %s nicht erstellen: %s", user_id, resp)
|
||||||
|
return None
|
||||||
|
|
||||||
async def async_close(self) -> None:
|
async def async_close(self) -> None:
|
||||||
"""Close the HTTP session."""
|
"""Close the HTTP session."""
|
||||||
@@ -166,6 +302,8 @@ class MatrixClient:
|
|||||||
_LOGGER.debug("Undecryptable MegolmEvent in %s (fehlende Session-Keys?)", room.room_id)
|
_LOGGER.debug("Undecryptable MegolmEvent in %s (fehlende Session-Keys?)", room.room_id)
|
||||||
|
|
||||||
async def _upload_keys_if_needed(self) -> None:
|
async def _upload_keys_if_needed(self) -> None:
|
||||||
|
if not self._encryption_enabled:
|
||||||
|
return
|
||||||
if self._client.should_upload_keys:
|
if self._client.should_upload_keys:
|
||||||
resp = await self._client.keys_upload()
|
resp = await self._client.keys_upload()
|
||||||
if not isinstance(resp, KeysUploadResponse):
|
if not isinstance(resp, KeysUploadResponse):
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
send_message:
|
# send_message, ask_question und send_to_<raum> werden beim Start dynamisch
|
||||||
name: Matrix-Nachricht senden
|
# injiziert, damit Räume als lesbares Dropdown erscheinen (siehe __init__.py).
|
||||||
description: Sendet eine Textnachricht an einen konfigurierten Matrix-Raum.
|
|
||||||
|
send_to_user:
|
||||||
|
name: Matrix-Direktnachricht senden
|
||||||
|
description: >
|
||||||
|
Sendet eine Direktnachricht an einen einzelnen Matrix-Benutzer.
|
||||||
|
Sucht einen vorhandenen DM-Raum oder erstellt automatisch einen neuen.
|
||||||
fields:
|
fields:
|
||||||
room_id:
|
user_id:
|
||||||
name: Raum
|
name: Benutzer-ID
|
||||||
description: "Matrix-Raum-ID (z. B. !abc123:matrix.org). Wird beim Einrichten der Integration ausgewählt."
|
description: "Matrix-Benutzer-ID (z. B. @max:matrix.org)."
|
||||||
required: true
|
required: true
|
||||||
example: "!abc123:matrix.org"
|
example: "@max:matrix.org"
|
||||||
selector:
|
selector:
|
||||||
text:
|
text:
|
||||||
message:
|
message:
|
||||||
@@ -16,51 +21,3 @@ send_message:
|
|||||||
selector:
|
selector:
|
||||||
text:
|
text:
|
||||||
multiline: true
|
multiline: true
|
||||||
|
|
||||||
ask_question:
|
|
||||||
name: Frage in Matrix-Raum stellen
|
|
||||||
description: >
|
|
||||||
Sendet eine Frage an einen Matrix-Raum und wartet auf eine Antwort.
|
|
||||||
Sobald jemand antwortet (Text oder Emoji-Reaktion), wird das Event
|
|
||||||
'matrix_messenger_response' ausgelöst, das in Automationen verwendet
|
|
||||||
werden kann. Nach Ablauf des Timeouts (Standard 30 min) wird nicht
|
|
||||||
mehr auf eine Antwort gewartet.
|
|
||||||
fields:
|
|
||||||
room_id:
|
|
||||||
name: Raum
|
|
||||||
description: "Matrix-Raum-ID (z. B. !abc123:matrix.org)."
|
|
||||||
required: true
|
|
||||||
example: "!abc123:matrix.org"
|
|
||||||
selector:
|
|
||||||
text:
|
|
||||||
question:
|
|
||||||
name: Frage
|
|
||||||
description: Der Fragetext, der in den Raum gesendet wird.
|
|
||||||
required: true
|
|
||||||
selector:
|
|
||||||
text:
|
|
||||||
multiline: true
|
|
||||||
options:
|
|
||||||
name: Antwortoptionen
|
|
||||||
description: >
|
|
||||||
Optionale Liste gültiger Antworten. Nur Nachrichten oder Emoji-Reaktionen,
|
|
||||||
die einer dieser Optionen entsprechen, werden akzeptiert.
|
|
||||||
Wenn leer, wird jede Antwort akzeptiert.
|
|
||||||
required: false
|
|
||||||
example:
|
|
||||||
- "Ja"
|
|
||||||
- "Nein"
|
|
||||||
selector:
|
|
||||||
object:
|
|
||||||
timeout:
|
|
||||||
name: Timeout (Sekunden)
|
|
||||||
description: Wartezeit in Sekunden. Standard ist 1800 (30 Minuten).
|
|
||||||
required: false
|
|
||||||
default: 1800
|
|
||||||
selector:
|
|
||||||
number:
|
|
||||||
min: 60
|
|
||||||
max: 7200
|
|
||||||
step: 60
|
|
||||||
unit_of_measurement: s
|
|
||||||
mode: box
|
|
||||||
|
|||||||
Reference in New Issue
Block a user