diff --git a/custom_components/openclaw/__init__.py b/custom_components/openclaw/__init__.py index 2b825d3..e3ce2f2 100644 --- a/custom_components/openclaw/__init__.py +++ b/custom_components/openclaw/__init__.py @@ -50,9 +50,11 @@ from .const import ( ATTR_HA_USER_ID, CONF_ADDON_CONFIG_PATH, CONF_AGENT_ID, + CONF_FALLBACK_AGENT_ID, CONF_GATEWAY_HOST, CONF_GATEWAY_PORT, CONF_GATEWAY_TOKEN, + CONF_USER_AGENT_MAP, CONF_USE_SSL, CONF_VERIFY_SSL, CONF_CONTEXT_MAX_CHARS, @@ -90,6 +92,7 @@ from .coordinator import OpenClawCoordinator from .exposure import apply_context_policy, build_exposed_entities_context from .helpers import extract_text_recursive from .routing import ( + DEFAULT_CHAT_AGENT_ID, build_scoped_session_id, build_scoped_session_prefix, normalize_ha_user_id, @@ -427,7 +430,11 @@ async def _async_send_openclaw_chat( client: OpenClawApiClient = entry_data["client"] coordinator: OpenClawCoordinator = entry_data["coordinator"] options = _get_entry_options(hass, entry_data) - resolved_agent_id = resolve_agent_id(ha_user_id, call_agent_id) + resolved_agent_id = _resolve_agent_id_from_options( + options, + ha_user_id, + call_agent_id, + ) scoped_session_id = build_scoped_session_id( raw_session_id, ha_user_id, @@ -524,7 +531,13 @@ def _async_register_services(hass: HomeAssistant) -> None: raw_session_id: str = call.data.get(ATTR_SESSION_ID) or "default" ha_user_id = normalize_ha_user_id(call.context.user_id) call_agent_id = normalize_optional_text(call.data.get(ATTR_AGENT_ID)) - resolved_agent_id = resolve_agent_id(ha_user_id, call_agent_id) + entry_data = _get_first_entry_data(hass) + options = _get_entry_options(hass, entry_data) if entry_data else {} + resolved_agent_id = _resolve_agent_id_from_options( + options, + ha_user_id, + call_agent_id, + ) scoped_session_id = build_scoped_session_id( raw_session_id, ha_user_id, @@ -568,7 +581,13 @@ def _async_register_services(hass: HomeAssistant) -> None: raw_session_id: str | None = call.data.get(ATTR_SESSION_ID) ha_user_id = normalize_ha_user_id(call.context.user_id) requested_agent_id = normalize_optional_text(call.data.get(ATTR_AGENT_ID)) - agent_id = resolve_agent_id(ha_user_id, requested_agent_id) + entry_data = _get_first_entry_data(hass) + options = _get_entry_options(hass, entry_data) if entry_data else {} + agent_id = _resolve_agent_id_from_options( + options, + ha_user_id, + requested_agent_id, + ) _LOGGER.info( "Clear history requested (user=%s, agent=%s, session=%s)", ha_user_id, @@ -707,6 +726,20 @@ def _get_entry_options(hass: HomeAssistant, entry_data: dict[str, Any]) -> dict[ return latest_entry.options if latest_entry else {} +def _resolve_agent_id_from_options( + options: dict[str, Any], + ha_user_id: str | None, + explicit_agent_id: str | None = None, +) -> str: + """Resolve the OpenClaw agent from config entry options.""" + return resolve_agent_id( + ha_user_id, + explicit_agent_id, + options.get(CONF_USER_AGENT_MAP), + options.get(CONF_FALLBACK_AGENT_ID, DEFAULT_CHAT_AGENT_ID), + ) + + def _summarize_tool_result(value: Any, max_len: int = 240) -> str | None: """Return compact string preview of tool result payload.""" if value is None: @@ -864,7 +897,9 @@ def _async_register_websocket_api(hass: HomeAssistant) -> None: raw_session_id = msg.get("session_id") or "default" connection_user = getattr(connection, "user", None) ha_user_id = normalize_ha_user_id(getattr(connection_user, "id", None)) - agent_id = resolve_agent_id(ha_user_id) + entry_data = _get_first_entry_data(hass) + options = _get_entry_options(hass, entry_data) if entry_data else {} + agent_id = _resolve_agent_id_from_options(options, ha_user_id) scoped_session_id = build_scoped_session_id( raw_session_id, ha_user_id, @@ -939,7 +974,7 @@ def _async_register_websocket_api(hass: HomeAssistant) -> None: options = _get_entry_options(hass, entry_data) if entry_data else {} connection_user = getattr(connection, "user", None) ha_user_id = normalize_ha_user_id(getattr(connection_user, "id", None)) - agent_id = resolve_agent_id(ha_user_id) + agent_id = _resolve_agent_id_from_options(options, ha_user_id) connection.send_result( msg["id"], { diff --git a/custom_components/openclaw/config_flow.py b/custom_components/openclaw/config_flow.py index 6cb7a3d..91c2446 100644 --- a/custom_components/openclaw/config_flow.py +++ b/custom_components/openclaw/config_flow.py @@ -46,6 +46,7 @@ from .const import ( CONF_CONTEXT_MAX_CHARS, CONF_CONTEXT_STRATEGY, CONF_ENABLE_TOOL_CALLS, + CONF_FALLBACK_AGENT_ID, CONF_INCLUDE_EXPOSED_CONTEXT, CONF_WAKE_WORD, CONF_WAKE_WORD_ENABLED, @@ -54,6 +55,7 @@ from .const import ( CONF_BROWSER_VOICE_LANGUAGE, CONF_VOICE_PROVIDER, CONF_THINKING_TIMEOUT, + CONF_USER_AGENT_MAP, BROWSER_VOICE_LANGUAGES, CONTEXT_STRATEGY_CLEAR, CONTEXT_STRATEGY_TRUNCATE, @@ -75,6 +77,13 @@ from .const import ( DOMAIN, OPENCLAW_CONFIG_REL_PATH, ) +from .routing import ( + DEFAULT_CHAT_AGENT_ID, + DEFAULT_HA_USER_AGENT_MAP, + normalize_optional_text, + parse_user_agent_map, + serialize_user_agent_map, +) _LOGGER = logging.getLogger(__name__) @@ -456,11 +465,35 @@ class OpenClawOptionsFlow(OptionsFlowWithReload): self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Manage integration options.""" - if user_input is not None: - return self.async_create_entry(title="", data=user_input) + errors: dict[str, str] = {} - options = self._config_entry.options + if user_input is not None: + try: + user_agent_map = parse_user_agent_map( + user_input.get(CONF_USER_AGENT_MAP) + ) + except ValueError: + errors[CONF_USER_AGENT_MAP] = "invalid_user_agent_map" + else: + fallback_agent_id = normalize_optional_text( + user_input.get(CONF_FALLBACK_AGENT_ID) + ) or DEFAULT_CHAT_AGENT_ID + new_options = dict(self._config_entry.options) + new_options.update(user_input) + new_options[CONF_FALLBACK_AGENT_ID] = fallback_agent_id + new_options[CONF_USER_AGENT_MAP] = serialize_user_agent_map( + user_agent_map + ) + return self.async_create_entry(title="", data=new_options) + + options = dict(self._config_entry.options) + if user_input is not None and errors: + options.update(user_input) selected_provider = options.get(CONF_VOICE_PROVIDER, DEFAULT_VOICE_PROVIDER) + user_agent_map_default = options.get( + CONF_USER_AGENT_MAP, + serialize_user_agent_map(DEFAULT_HA_USER_AGENT_MAP), + ) schema: dict[Any, Any] = { vol.Optional( @@ -470,6 +503,14 @@ class OpenClawOptionsFlow(OptionsFlowWithReload): self._config_entry.data.get(CONF_AGENT_ID, DEFAULT_AGENT_ID), ), ): str, + vol.Optional( + CONF_FALLBACK_AGENT_ID, + default=options.get(CONF_FALLBACK_AGENT_ID, DEFAULT_CHAT_AGENT_ID), + ): str, + vol.Optional( + CONF_USER_AGENT_MAP, + default=user_agent_map_default, + ): str, vol.Optional( CONF_VOICE_AGENT_ID, default=options.get( @@ -557,4 +598,8 @@ class OpenClawOptionsFlow(OptionsFlowWithReload): ) ] = vol.In(BROWSER_VOICE_LANGUAGES) - return self.async_show_form(step_id="init", data_schema=vol.Schema(schema)) + return self.async_show_form( + step_id="init", + data_schema=vol.Schema(schema), + errors=errors, + ) diff --git a/custom_components/openclaw/const.py b/custom_components/openclaw/const.py index adbe079..1bf8523 100644 --- a/custom_components/openclaw/const.py +++ b/custom_components/openclaw/const.py @@ -26,6 +26,8 @@ CONF_ADDON_CONFIG_PATH = "addon_config_path" CONF_AGENT_ID = "agent_id" CONF_VOICE_AGENT_ID = "voice_agent_id" CONF_ASSIST_SESSION_ID = "assist_session_id" +CONF_FALLBACK_AGENT_ID = "fallback_agent_id" +CONF_USER_AGENT_MAP = "user_agent_map" # Options CONF_INCLUDE_EXPOSED_CONTEXT = "include_exposed_context" diff --git a/custom_components/openclaw/conversation.py b/custom_components/openclaw/conversation.py index 2046b67..b293845 100644 --- a/custom_components/openclaw/conversation.py +++ b/custom_components/openclaw/conversation.py @@ -29,7 +29,9 @@ from .const import ( CONF_ASSIST_SESSION_ID, CONF_CONTEXT_MAX_CHARS, CONF_CONTEXT_STRATEGY, + CONF_FALLBACK_AGENT_ID, CONF_INCLUDE_EXPOSED_CONTEXT, + CONF_USER_AGENT_MAP, DEFAULT_ASSIST_SESSION_ID, DEFAULT_CONTEXT_MAX_CHARS, DEFAULT_CONTEXT_STRATEGY, @@ -42,6 +44,7 @@ from .coordinator import OpenClawCoordinator from .exposure import apply_context_policy, build_exposed_entities_context from .helpers import extract_text_recursive from .routing import ( + DEFAULT_CHAT_AGENT_ID, build_scoped_session_id, normalize_ha_user_id, resolve_agent_id, @@ -130,7 +133,11 @@ class OpenClawConversationAgent(conversation.AbstractConversationAgent): context = getattr(user_input, "context", None) ha_user_id = normalize_ha_user_id(getattr(context, "user_id", None)) options = self.entry.options - resolved_agent_id = resolve_agent_id(ha_user_id) + resolved_agent_id = resolve_agent_id( + ha_user_id, + user_agent_map=options.get(CONF_USER_AGENT_MAP), + fallback_agent_id=options.get(CONF_FALLBACK_AGENT_ID, DEFAULT_CHAT_AGENT_ID), + ) conversation_id = self._resolve_conversation_id( user_input, ha_user_id, diff --git a/custom_components/openclaw/routing.py b/custom_components/openclaw/routing.py index dbb65ac..014f8f4 100644 --- a/custom_components/openclaw/routing.py +++ b/custom_components/openclaw/routing.py @@ -2,20 +2,14 @@ from __future__ import annotations +import json import re from typing import Any DEFAULT_CHAT_AGENT_ID = "tetra-home" SYSTEM_HA_USER_ID = "system" -HA_USER_AGENT_MAP: dict[str, str] = { - # TODO: Move this user-to-agent map into config entry options. - "bd3f666f43f941e886ef007a794eefe8": "main", - "1d2be9a622584cf0abb1c243d12a9959": "family-krzysztof", - "eca5887898544556840878ab37a35fbc": "family-ewa", - "2f5bf9f09478400082380bf7f26966d9": "family-tosia", - "f613d90efe8b4afea63c4a5a7ef7b559": "family-domi", -} +DEFAULT_HA_USER_AGENT_MAP: dict[str, str] = {} def normalize_optional_text(value: Any) -> str | None: @@ -34,12 +28,55 @@ def normalize_ha_user_id(value: Any) -> str: def resolve_agent_id( ha_user_id: str | None, explicit_agent_id: str | None = None, + user_agent_map: Any = None, + fallback_agent_id: str | None = None, ) -> str: """Resolve the OpenClaw agent for a Home Assistant user.""" if explicit_agent_id: return explicit_agent_id normalized_user_id = normalize_ha_user_id(ha_user_id) - return HA_USER_AGENT_MAP.get(normalized_user_id, DEFAULT_CHAT_AGENT_ID) + try: + routing_map = parse_user_agent_map(user_agent_map) + except ValueError: + routing_map = dict(DEFAULT_HA_USER_AGENT_MAP) + fallback = normalize_optional_text(fallback_agent_id) or DEFAULT_CHAT_AGENT_ID + return routing_map.get(normalized_user_id, fallback) + + +def parse_user_agent_map(value: Any = None) -> dict[str, str]: + """Parse a HA-user-id to OpenClaw-agent-id mapping from options.""" + if value is None: + return dict(DEFAULT_HA_USER_AGENT_MAP) + + if isinstance(value, str): + cleaned = value.strip() + if not cleaned: + return {} + try: + value = json.loads(cleaned) + except json.JSONDecodeError as err: + raise ValueError("User agent map must be a JSON object") from err + + if not isinstance(value, dict): + raise ValueError("User agent map must be a JSON object") + + parsed: dict[str, str] = {} + for raw_user_id, raw_agent_id in value.items(): + user_id = normalize_optional_text(raw_user_id) + agent_id = normalize_optional_text(raw_agent_id) + if user_id and agent_id: + parsed[user_id] = agent_id + return parsed + + +def serialize_user_agent_map(value: Any = None) -> str: + """Serialize a user-agent routing map for config entry options.""" + return json.dumps( + parse_user_agent_map(value), + ensure_ascii=False, + indent=2, + sort_keys=True, + ) def resolve_model_override(value: Any) -> str | None: diff --git a/custom_components/openclaw/strings.json b/custom_components/openclaw/strings.json index 32fc8cb..8fbd672 100644 --- a/custom_components/openclaw/strings.json +++ b/custom_components/openclaw/strings.json @@ -39,6 +39,8 @@ "description": "Configure context and tool-calling behavior.", "data": { "agent_id": "Agent ID (e.g. main)", + "fallback_agent_id": "Fallback agent ID", + "user_agent_map": "HA user to agent map (JSON)", "voice_agent_id": "Voice agent ID (optional)", "assist_session_id": "Assist session ID override (optional)", "include_exposed_context": "Include exposed entities context", @@ -53,6 +55,9 @@ "thinking_timeout": "Response timeout (seconds)" } } + }, + "error": { + "invalid_user_agent_map": "User agent map must be a JSON object whose keys are Home Assistant user IDs and whose values are OpenClaw agent IDs." } }, "entity": { diff --git a/custom_components/openclaw/translations/en.json b/custom_components/openclaw/translations/en.json index 6690599..2c90b2b 100644 --- a/custom_components/openclaw/translations/en.json +++ b/custom_components/openclaw/translations/en.json @@ -41,6 +41,8 @@ "description": "Configure context and tool-calling behavior.", "data": { "agent_id": "Agent ID (e.g. main)", + "fallback_agent_id": "Fallback agent ID", + "user_agent_map": "HA user to agent map (JSON)", "voice_agent_id": "Voice agent ID (optional)", "assist_session_id": "Assist session ID override (optional)", "include_exposed_context": "Include exposed entities context", @@ -55,6 +57,9 @@ "thinking_timeout": "Response timeout (seconds)" } } + }, + "error": { + "invalid_user_agent_map": "User agent map must be a JSON object whose keys are Home Assistant user IDs and whose values are OpenClaw agent IDs." } }, "entity": {