Release OpenClaw HA integration 0.1.66
This commit is contained in:
@@ -2,6 +2,17 @@
|
||||
|
||||
All notable changes to the OpenClaw Home Assistant Integration will be documented in this file.
|
||||
|
||||
## [0.1.66] - 2026-05-06
|
||||
|
||||
### Added
|
||||
- Added Home Assistant user-aware OpenClaw agent routing for the chat card and Assist sessions.
|
||||
- Added a dedicated `openclaw/send_message` websocket command so non-admin dashboard users receive chat replies without subscribing to custom event-bus events.
|
||||
|
||||
### Fixed
|
||||
- Fixed chat history isolation by scoping backend and frontend history by Home Assistant user, resolved agent, and raw session ID.
|
||||
- Fixed OpenClaw session keys to use canonical `agent:<agentId>:ha:user:<haUserId>:session:<rawSessionId>` values so routed chat messages do not fall back into `main/default`.
|
||||
- Strengthened chat-card cache busting with a versioned static resource path and disabled static-path cache headers.
|
||||
|
||||
## [0.1.62] - 2026-04-04
|
||||
|
||||
### Added
|
||||
|
||||
@@ -91,6 +91,7 @@ from .exposure import apply_context_policy, build_exposed_entities_context
|
||||
from .helpers import extract_text_recursive
|
||||
from .routing import (
|
||||
build_scoped_session_id,
|
||||
build_scoped_session_prefix,
|
||||
normalize_ha_user_id,
|
||||
normalize_optional_text,
|
||||
resolve_agent_id,
|
||||
@@ -111,9 +112,11 @@ _VOICE_REQUEST_HEADERS = {
|
||||
_CARD_FILENAME = "openclaw-chat-card.js"
|
||||
_CARD_PATH = Path(__file__).parent / "www" / _CARD_FILENAME
|
||||
# URL at which the card JS is served (registered via register_static_path)
|
||||
_CARD_STATIC_URL = f"/openclaw/{_CARD_FILENAME}"
|
||||
# Versioned URL used for Lovelace resource registration to avoid stale browser cache
|
||||
_CARD_URL = f"{_CARD_STATIC_URL}?v=0.1.63"
|
||||
_CARD_STATIC_URL = "/openclaw/openclaw-chat-card-0.1.66.js"
|
||||
# Versioned URL used for Lovelace resource registration to avoid stale browser cache.
|
||||
# The path itself carries the version because HA/Lovelace can keep old custom
|
||||
# card modules alive more aggressively than ordinary query-string cache busting.
|
||||
_CARD_URL = _CARD_STATIC_URL
|
||||
|
||||
OpenClawConfigEntry = ConfigEntry
|
||||
|
||||
@@ -324,10 +327,10 @@ async def _async_register_static_path(hass: HomeAssistant) -> str | None:
|
||||
from homeassistant.components.http import StaticPathConfig # noqa: PLC0415
|
||||
|
||||
await hass.http.async_register_static_paths(
|
||||
[StaticPathConfig(_CARD_STATIC_URL, str(_CARD_PATH), cache_headers=True)]
|
||||
[StaticPathConfig(_CARD_STATIC_URL, str(_CARD_PATH), cache_headers=False)]
|
||||
)
|
||||
except (ImportError, AttributeError):
|
||||
hass.http.register_static_path(_CARD_STATIC_URL, str(_CARD_PATH), True)
|
||||
hass.http.register_static_path(_CARD_STATIC_URL, str(_CARD_PATH), False)
|
||||
|
||||
hass.data[static_key] = True
|
||||
_LOGGER.debug("Registered static path: %s", _CARD_STATIC_URL)
|
||||
@@ -358,7 +361,9 @@ async def _async_add_lovelace_resource(hass: HomeAssistant, url: str) -> bool:
|
||||
|
||||
desired_path = urlsplit(url).path
|
||||
legacy_paths = {
|
||||
_CARD_STATIC_URL,
|
||||
"/openclaw/openclaw-chat-card.js",
|
||||
"/openclaw/openclaw-chat-card-0.1.65.js",
|
||||
"/local/openclaw-chat-card.js",
|
||||
"/hacsfiles/openclaw/openclaw-chat-card.js",
|
||||
}
|
||||
@@ -402,6 +407,112 @@ async def _async_add_lovelace_resource(hass: HomeAssistant, url: str) -> bool:
|
||||
|
||||
# ── Service registration ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def _async_send_openclaw_chat(
|
||||
hass: HomeAssistant,
|
||||
*,
|
||||
message: str,
|
||||
source: str | None,
|
||||
raw_session_id: str,
|
||||
ha_user_id: str,
|
||||
call_agent_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Send one chat message to OpenClaw and return the routed response payload."""
|
||||
extra_headers = _VOICE_REQUEST_HEADERS if source == "voice" else None
|
||||
|
||||
entry_data = _get_first_entry_data(hass)
|
||||
if not entry_data:
|
||||
raise OpenClawApiError("No OpenClaw integration configured")
|
||||
|
||||
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)
|
||||
scoped_session_id = build_scoped_session_id(
|
||||
raw_session_id,
|
||||
ha_user_id,
|
||||
resolved_agent_id,
|
||||
)
|
||||
_LOGGER.debug(
|
||||
"OpenClaw chat routing: context_user=%s call_agent=%s "
|
||||
"resolved_agent=%s raw_session=%s scoped_session=%s source=%s",
|
||||
ha_user_id,
|
||||
call_agent_id,
|
||||
resolved_agent_id,
|
||||
raw_session_id,
|
||||
scoped_session_id,
|
||||
source,
|
||||
)
|
||||
|
||||
include_context = options.get(
|
||||
CONF_INCLUDE_EXPOSED_CONTEXT,
|
||||
DEFAULT_INCLUDE_EXPOSED_CONTEXT,
|
||||
)
|
||||
max_chars = int(options.get(CONF_CONTEXT_MAX_CHARS, DEFAULT_CONTEXT_MAX_CHARS))
|
||||
strategy = options.get(CONF_CONTEXT_STRATEGY, DEFAULT_CONTEXT_STRATEGY)
|
||||
if strategy not in {"clear", CONTEXT_STRATEGY_TRUNCATE}:
|
||||
strategy = DEFAULT_CONTEXT_STRATEGY
|
||||
|
||||
raw_context = (
|
||||
build_exposed_entities_context(hass, assistant="conversation")
|
||||
if include_context
|
||||
else None
|
||||
)
|
||||
system_prompt = apply_context_policy(raw_context, max_chars, strategy)
|
||||
active_model = resolve_model_override(options.get("active_model"))
|
||||
|
||||
_append_chat_history(hass, scoped_session_id, "user", message)
|
||||
|
||||
response = await client.async_send_message(
|
||||
message=message,
|
||||
session_id=scoped_session_id,
|
||||
system_prompt=system_prompt,
|
||||
agent_id=resolved_agent_id,
|
||||
model=active_model,
|
||||
extra_headers=extra_headers,
|
||||
)
|
||||
|
||||
if options.get(CONF_ENABLE_TOOL_CALLS, DEFAULT_ENABLE_TOOL_CALLS):
|
||||
tool_results = await _async_execute_tool_calls(hass, response)
|
||||
if tool_results:
|
||||
response = await client.async_send_message(
|
||||
message=(
|
||||
"Tool execution results:\n"
|
||||
+ "\n".join(f"- {line}" for line in tool_results)
|
||||
+ "\nRespond to the user based on these results."
|
||||
),
|
||||
session_id=scoped_session_id,
|
||||
system_prompt=system_prompt,
|
||||
agent_id=resolved_agent_id,
|
||||
model=active_model,
|
||||
extra_headers=extra_headers,
|
||||
)
|
||||
|
||||
assistant_message = _extract_assistant_message(response)
|
||||
model_used = response.get("model", "unknown")
|
||||
|
||||
if not assistant_message:
|
||||
assistant_message = "Response received, but no readable message content was found."
|
||||
_LOGGER.warning(
|
||||
"OpenClaw response had no parseable assistant message. Keys: %s",
|
||||
list(response.keys()),
|
||||
)
|
||||
|
||||
_append_chat_history(hass, scoped_session_id, "assistant", assistant_message)
|
||||
coordinator.update_last_activity()
|
||||
|
||||
return {
|
||||
ATTR_MESSAGE: assistant_message,
|
||||
ATTR_SESSION_ID: raw_session_id,
|
||||
ATTR_SESSION_KEY: scoped_session_id,
|
||||
ATTR_HA_USER_ID: ha_user_id,
|
||||
ATTR_AGENT_ID: resolved_agent_id,
|
||||
ATTR_MODEL: model_used,
|
||||
ATTR_TIMESTAMP: datetime.now(timezone.utc).isoformat(),
|
||||
"messages": _get_chat_history_store(hass).get(scoped_session_id, []),
|
||||
}
|
||||
|
||||
|
||||
@callback
|
||||
def _async_register_services(hass: HomeAssistant) -> None:
|
||||
"""Register openclaw.send_message and openclaw.clear_history services."""
|
||||
@@ -413,16 +524,6 @@ 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))
|
||||
extra_headers = _VOICE_REQUEST_HEADERS if source == "voice" else None
|
||||
|
||||
entry_data = _get_first_entry_data(hass)
|
||||
if not entry_data:
|
||||
_LOGGER.error("No OpenClaw integration configured")
|
||||
return
|
||||
|
||||
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)
|
||||
scoped_session_id = build_scoped_session_id(
|
||||
raw_session_id,
|
||||
@@ -431,77 +532,15 @@ def _async_register_services(hass: HomeAssistant) -> None:
|
||||
)
|
||||
|
||||
try:
|
||||
include_context = options.get(
|
||||
CONF_INCLUDE_EXPOSED_CONTEXT,
|
||||
DEFAULT_INCLUDE_EXPOSED_CONTEXT,
|
||||
)
|
||||
max_chars = int(
|
||||
options.get(CONF_CONTEXT_MAX_CHARS, DEFAULT_CONTEXT_MAX_CHARS)
|
||||
)
|
||||
strategy = options.get(CONF_CONTEXT_STRATEGY, DEFAULT_CONTEXT_STRATEGY)
|
||||
if strategy not in {"clear", CONTEXT_STRATEGY_TRUNCATE}:
|
||||
strategy = DEFAULT_CONTEXT_STRATEGY
|
||||
|
||||
raw_context = (
|
||||
build_exposed_entities_context(hass, assistant="conversation")
|
||||
if include_context
|
||||
else None
|
||||
)
|
||||
system_prompt = apply_context_policy(raw_context, max_chars, strategy)
|
||||
|
||||
active_model = resolve_model_override(options.get("active_model"))
|
||||
|
||||
_append_chat_history(hass, scoped_session_id, "user", message)
|
||||
|
||||
response = await client.async_send_message(
|
||||
result = await _async_send_openclaw_chat(
|
||||
hass,
|
||||
message=message,
|
||||
session_id=scoped_session_id,
|
||||
system_prompt=system_prompt,
|
||||
agent_id=resolved_agent_id,
|
||||
model=active_model,
|
||||
extra_headers=extra_headers,
|
||||
source=source,
|
||||
raw_session_id=raw_session_id,
|
||||
ha_user_id=ha_user_id,
|
||||
call_agent_id=call_agent_id,
|
||||
)
|
||||
|
||||
if options.get(CONF_ENABLE_TOOL_CALLS, DEFAULT_ENABLE_TOOL_CALLS):
|
||||
tool_results = await _async_execute_tool_calls(hass, response)
|
||||
if tool_results:
|
||||
response = await client.async_send_message(
|
||||
message=(
|
||||
"Tool execution results:\n"
|
||||
+ "\n".join(f"- {line}" for line in tool_results)
|
||||
+ "\nRespond to the user based on these results."
|
||||
),
|
||||
session_id=scoped_session_id,
|
||||
system_prompt=system_prompt,
|
||||
agent_id=resolved_agent_id,
|
||||
model=active_model,
|
||||
extra_headers=extra_headers,
|
||||
)
|
||||
|
||||
assistant_message = _extract_assistant_message(response)
|
||||
model_used = response.get("model", "unknown")
|
||||
|
||||
if not assistant_message:
|
||||
assistant_message = "Response received, but no readable message content was found."
|
||||
_LOGGER.warning(
|
||||
"OpenClaw response had no parseable assistant message. Keys: %s",
|
||||
list(response.keys()),
|
||||
)
|
||||
|
||||
_append_chat_history(hass, scoped_session_id, "assistant", assistant_message)
|
||||
hass.bus.async_fire(
|
||||
EVENT_MESSAGE_RECEIVED,
|
||||
{
|
||||
ATTR_MESSAGE: assistant_message,
|
||||
ATTR_SESSION_ID: raw_session_id,
|
||||
ATTR_SESSION_KEY: scoped_session_id,
|
||||
ATTR_HA_USER_ID: ha_user_id,
|
||||
ATTR_AGENT_ID: resolved_agent_id,
|
||||
ATTR_MODEL: model_used,
|
||||
ATTR_TIMESTAMP: datetime.now(timezone.utc).isoformat(),
|
||||
},
|
||||
)
|
||||
coordinator.update_last_activity()
|
||||
hass.bus.async_fire(EVENT_MESSAGE_RECEIVED, result)
|
||||
|
||||
except OpenClawApiError as err:
|
||||
_LOGGER.error("Failed to send message to OpenClaw: %s", err)
|
||||
@@ -545,10 +584,7 @@ def _async_register_services(hass: HomeAssistant) -> None:
|
||||
)
|
||||
store.pop(scoped_session_id, None)
|
||||
else:
|
||||
scoped_prefix = build_scoped_session_id("", ha_user_id, agent_id).rsplit(
|
||||
"__session_",
|
||||
1,
|
||||
)[0] + "__session_"
|
||||
scoped_prefix = build_scoped_session_prefix(ha_user_id, agent_id)
|
||||
for key in list(store):
|
||||
if key.startswith(scoped_prefix):
|
||||
store.pop(key, None)
|
||||
@@ -848,6 +884,45 @@ def _async_register_websocket_api(hass: HomeAssistant) -> None:
|
||||
|
||||
websocket_api.async_register_command(hass, websocket_get_history)
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): f"{DOMAIN}/send_message",
|
||||
vol.Required(ATTR_MESSAGE): cv.string,
|
||||
vol.Optional(ATTR_SOURCE): cv.string,
|
||||
vol.Optional(ATTR_SESSION_ID): cv.string,
|
||||
vol.Optional(ATTR_AGENT_ID): cv.string,
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def websocket_send_message(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Send a chat message and return the assistant reply to the caller."""
|
||||
raw_session_id = msg.get(ATTR_SESSION_ID) or "default"
|
||||
source = normalize_optional_text(msg.get(ATTR_SOURCE))
|
||||
requested_agent_id = normalize_optional_text(msg.get(ATTR_AGENT_ID))
|
||||
connection_user = getattr(connection, "user", None)
|
||||
ha_user_id = normalize_ha_user_id(getattr(connection_user, "id", None))
|
||||
|
||||
try:
|
||||
result = await _async_send_openclaw_chat(
|
||||
hass,
|
||||
message=msg[ATTR_MESSAGE],
|
||||
source=source,
|
||||
raw_session_id=raw_session_id,
|
||||
ha_user_id=ha_user_id,
|
||||
call_agent_id=requested_agent_id,
|
||||
)
|
||||
except OpenClawApiError as err:
|
||||
connection.send_error(msg["id"], "openclaw_error", str(err))
|
||||
return
|
||||
|
||||
connection.send_result(msg["id"], result)
|
||||
|
||||
websocket_api.async_register_command(hass, websocket_send_message)
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): f"{DOMAIN}/get_settings",
|
||||
|
||||
@@ -41,7 +41,12 @@ from .const import (
|
||||
from .coordinator import OpenClawCoordinator
|
||||
from .exposure import apply_context_policy, build_exposed_entities_context
|
||||
from .helpers import extract_text_recursive
|
||||
from .routing import normalize_ha_user_id, resolve_agent_id, resolve_model_override
|
||||
from .routing import (
|
||||
build_scoped_session_id,
|
||||
normalize_ha_user_id,
|
||||
resolve_agent_id,
|
||||
resolve_model_override,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -126,7 +131,11 @@ class OpenClawConversationAgent(conversation.AbstractConversationAgent):
|
||||
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)
|
||||
conversation_id = self._resolve_conversation_id(user_input, resolved_agent_id)
|
||||
conversation_id = self._resolve_conversation_id(
|
||||
user_input,
|
||||
ha_user_id,
|
||||
resolved_agent_id,
|
||||
)
|
||||
active_model = resolve_model_override(options.get("active_model"))
|
||||
include_context = options.get(
|
||||
CONF_INCLUDE_EXPOSED_CONTEXT,
|
||||
@@ -218,9 +227,10 @@ class OpenClawConversationAgent(conversation.AbstractConversationAgent):
|
||||
def _resolve_conversation_id(
|
||||
self,
|
||||
user_input: conversation.ConversationInput,
|
||||
ha_user_id: str | None,
|
||||
agent_id: str | None,
|
||||
) -> str:
|
||||
"""Return conversation id from HA with conservative agent namespacing."""
|
||||
"""Return an OpenClaw agent-scoped session key for HA Assist."""
|
||||
configured_session_id = self._normalize_optional_text(
|
||||
self.entry.options.get(
|
||||
CONF_ASSIST_SESSION_ID,
|
||||
@@ -228,27 +238,37 @@ class OpenClawConversationAgent(conversation.AbstractConversationAgent):
|
||||
)
|
||||
)
|
||||
if configured_session_id:
|
||||
return configured_session_id
|
||||
if configured_session_id.lower().startswith("agent:"):
|
||||
return configured_session_id
|
||||
return build_scoped_session_id(configured_session_id, ha_user_id, agent_id)
|
||||
|
||||
agent_suffix = self._normalize_optional_text(agent_id)
|
||||
|
||||
if user_input.conversation_id:
|
||||
if agent_suffix:
|
||||
return f"{user_input.conversation_id}:{agent_suffix}"
|
||||
return user_input.conversation_id
|
||||
if user_input.conversation_id.lower().startswith("agent:"):
|
||||
return user_input.conversation_id
|
||||
raw_session = (
|
||||
f"{user_input.conversation_id}:{agent_suffix}"
|
||||
if agent_suffix
|
||||
else user_input.conversation_id
|
||||
)
|
||||
return build_scoped_session_id(raw_session, ha_user_id, agent_id)
|
||||
|
||||
context = getattr(user_input, "context", None)
|
||||
user_id = getattr(context, "user_id", None)
|
||||
if user_id:
|
||||
base_id = f"assist_user_{user_id}"
|
||||
return f"{base_id}:{agent_suffix}" if agent_suffix else base_id
|
||||
raw_session = f"{base_id}:{agent_suffix}" if agent_suffix else base_id
|
||||
return build_scoped_session_id(raw_session, ha_user_id, agent_id)
|
||||
|
||||
device_id = getattr(user_input, "device_id", None)
|
||||
if device_id:
|
||||
base_id = f"assist_device_{device_id}"
|
||||
return f"{base_id}:{agent_suffix}" if agent_suffix else base_id
|
||||
raw_session = f"{base_id}:{agent_suffix}" if agent_suffix else base_id
|
||||
return build_scoped_session_id(raw_session, ha_user_id, agent_id)
|
||||
|
||||
return f"assist_default:{agent_suffix}" if agent_suffix else "assist_default"
|
||||
raw_session = f"assist_default:{agent_suffix}" if agent_suffix else "assist_default"
|
||||
return build_scoped_session_id(raw_session, ha_user_id, agent_id)
|
||||
|
||||
def _normalize_optional_text(self, value: Any) -> str | None:
|
||||
"""Return a stripped string or None for blank values."""
|
||||
@@ -310,7 +330,7 @@ class OpenClawConversationAgent(conversation.AbstractConversationAgent):
|
||||
|
||||
# Check if the response ends with a question mark
|
||||
# (allow trailing punctuation like quotes, parens, or emoji)
|
||||
if re.search(r"\?\s*[\"'""»)\]]*\s*$", text):
|
||||
if re.search("\\?\\s*[\"'»)\\]]*\\s*$", text):
|
||||
return True
|
||||
|
||||
# Common follow-up patterns (EN + DE)
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"iot_class": "local_polling",
|
||||
"issue_tracker": "https://github.com/techartdev/OpenClawHomeAssistantIntegration/issues",
|
||||
"requirements": [],
|
||||
"version": "0.1.63",
|
||||
"version": "0.1.66",
|
||||
"dependencies": ["conversation"],
|
||||
"after_dependencies": ["hassio", "lovelace"]
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ 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",
|
||||
@@ -54,18 +55,28 @@ def build_scoped_session_id(
|
||||
ha_user_id: str | None,
|
||||
agent_id: str | None,
|
||||
) -> str:
|
||||
"""Build the backend/gateway session id scoped by user, agent, and raw session."""
|
||||
"""Build an OpenClaw agent-scoped session key for HA user chat sessions."""
|
||||
raw_session = normalize_optional_text(raw_session_id) or "default"
|
||||
user = normalize_ha_user_id(ha_user_id)
|
||||
agent = normalize_optional_text(agent_id) or DEFAULT_CHAT_AGENT_ID
|
||||
return (
|
||||
f"ha_user_{_sanitize_session_part(user)}"
|
||||
f"__agent_{_sanitize_session_part(agent)}"
|
||||
f"__session_{_sanitize_session_part(raw_session)}"
|
||||
f"agent:{_sanitize_session_part(agent)}"
|
||||
f":ha:user:{_sanitize_session_part(user)}"
|
||||
f":session:{_sanitize_session_part(raw_session)}"
|
||||
)
|
||||
|
||||
|
||||
def build_scoped_session_prefix(
|
||||
ha_user_id: str | None,
|
||||
agent_id: str | None,
|
||||
) -> str:
|
||||
"""Build the common OpenClaw session-key prefix for a HA user and agent."""
|
||||
user = normalize_ha_user_id(ha_user_id)
|
||||
agent = normalize_optional_text(agent_id) or DEFAULT_CHAT_AGENT_ID
|
||||
return f"agent:{_sanitize_session_part(agent)}:ha:user:{_sanitize_session_part(user)}:session:"
|
||||
|
||||
|
||||
def _sanitize_session_part(value: str) -> str:
|
||||
"""Keep scoped session ids header-safe and readable."""
|
||||
cleaned = re.sub(r"[^A-Za-z0-9_.-]+", "_", value.strip())
|
||||
return cleaned.strip("_") or "default"
|
||||
cleaned = re.sub(r"[^A-Za-z0-9_.-]+", "-", value.strip().lower())
|
||||
return cleaned.strip("._-") or "default"
|
||||
|
||||
@@ -9,11 +9,11 @@
|
||||
* - Voice input (WebSpeech / MediaRecorder)
|
||||
* - Voice mode toggle (continuous conversation)
|
||||
*
|
||||
* Communication: uses HA WebSocket API → openclaw.send_message service
|
||||
* + subscribes to openclaw_message_received events.
|
||||
* Communication: uses HA WebSocket API → openclaw/send_message and receives
|
||||
* the assistant reply directly on the same request.
|
||||
*/
|
||||
|
||||
const CARD_VERSION = "0.3.14";
|
||||
const CARD_VERSION = "0.3.17";
|
||||
|
||||
// Max time (ms) to show the thinking indicator before falling back to an error (default; overridable via card config `thinking_timeout` in seconds)
|
||||
const THINKING_TIMEOUT_MS = 120_000;
|
||||
@@ -239,16 +239,9 @@ class OpenClawChatCard extends HTMLElement {
|
||||
// ── Event subscription ──────────────────────────────────────────────
|
||||
|
||||
async _subscribeToEvents() {
|
||||
if (!this._hass || this._eventUnsubscribe) return;
|
||||
|
||||
try {
|
||||
this._eventUnsubscribe = await this._hass.connection.subscribeEvents(
|
||||
(event) => this._handleOpenClawEvent(event),
|
||||
"openclaw_message_received"
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("OpenClaw: Failed to subscribe to events:", err);
|
||||
}
|
||||
// Non-admin HA users cannot subscribe to arbitrary custom bus events.
|
||||
// Chat replies are returned through the integration websocket command.
|
||||
return;
|
||||
}
|
||||
|
||||
_unsubscribeEvents() {
|
||||
@@ -416,12 +409,18 @@ class OpenClawChatCard extends HTMLElement {
|
||||
}
|
||||
|
||||
const serverMessages = Array.isArray(result?.messages) ? result.messages : [];
|
||||
if (!serverMessages.length) return;
|
||||
if (!serverMessages.length) {
|
||||
this._retryHistorySyncIfProcessing(retries);
|
||||
return;
|
||||
}
|
||||
|
||||
const validMessages = serverMessages.filter(
|
||||
(m) => m && (m.role === "user" || m.role === "assistant") && typeof m.content === "string"
|
||||
);
|
||||
if (!validMessages.length) return;
|
||||
if (!validMessages.length) {
|
||||
this._retryHistorySyncIfProcessing(retries);
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldReplace =
|
||||
validMessages.length > this._messages.length ||
|
||||
@@ -441,20 +440,30 @@ class OpenClawChatCard extends HTMLElement {
|
||||
this._persistMessages();
|
||||
this._render();
|
||||
this._scrollToBottom();
|
||||
} else {
|
||||
this._retryHistorySyncIfProcessing(retries);
|
||||
}
|
||||
} catch (err) {
|
||||
console.debug("OpenClaw: history sync skipped:", err);
|
||||
if (retries > 0) {
|
||||
if (this._historySyncRetryTimer) {
|
||||
clearTimeout(this._historySyncRetryTimer);
|
||||
}
|
||||
this._historySyncRetryTimer = setTimeout(() => {
|
||||
this._syncHistoryFromBackend(retries - 1);
|
||||
}, 1500);
|
||||
}
|
||||
this._scheduleHistorySyncRetry(retries);
|
||||
}
|
||||
}
|
||||
|
||||
_retryHistorySyncIfProcessing(retries = 0) {
|
||||
if (!this._isProcessing && this._pendingResponses <= 0) return;
|
||||
this._scheduleHistorySyncRetry(retries);
|
||||
}
|
||||
|
||||
_scheduleHistorySyncRetry(retries = 0) {
|
||||
if (retries <= 0) return;
|
||||
if (this._historySyncRetryTimer) {
|
||||
clearTimeout(this._historySyncRetryTimer);
|
||||
}
|
||||
this._historySyncRetryTimer = setTimeout(() => {
|
||||
this._syncHistoryFromBackend(retries - 1);
|
||||
}, 1500);
|
||||
}
|
||||
|
||||
async _loadIntegrationSettings(includePipeline = true) {
|
||||
if (!this._hass) return;
|
||||
|
||||
@@ -588,8 +597,6 @@ class OpenClawChatCard extends HTMLElement {
|
||||
async _sendMessage(text, source = null) {
|
||||
if (!text || !text.trim() || !this._hass) return;
|
||||
|
||||
await this._subscribeToEvents();
|
||||
|
||||
const message = text.trim();
|
||||
this._addMessage("user", message);
|
||||
|
||||
@@ -608,15 +615,56 @@ class OpenClawChatCard extends HTMLElement {
|
||||
this._scrollToBottom();
|
||||
|
||||
try {
|
||||
await this._hass.callService("openclaw", "send_message", {
|
||||
let result;
|
||||
const payload = {
|
||||
type: "openclaw/send_message",
|
||||
message: message,
|
||||
source: source || undefined,
|
||||
session_id: this._config.session_id || undefined,
|
||||
});
|
||||
};
|
||||
if (typeof this._hass.callWS === "function") {
|
||||
result = await this._hass.callWS(payload);
|
||||
} else {
|
||||
result = await this._hass.connection.sendMessagePromise(payload);
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
this._syncHistoryFromBackend(1);
|
||||
}, 1200);
|
||||
const previousStorageIdentity = this._getStorageIdentity();
|
||||
if (result?.ha_user_id) {
|
||||
this._backendHaUserId = result.ha_user_id;
|
||||
}
|
||||
if (result?.agent_id) {
|
||||
this._activeAgentId = result.agent_id;
|
||||
}
|
||||
if (this._getStorageIdentity() !== previousStorageIdentity) {
|
||||
this._resetTransientChatState();
|
||||
}
|
||||
|
||||
const serverMessages = Array.isArray(result?.messages) ? result.messages : [];
|
||||
const validMessages = serverMessages.filter(
|
||||
(m) => m && (m.role === "user" || m.role === "assistant") && typeof m.content === "string"
|
||||
);
|
||||
if (validMessages.length) {
|
||||
this._messages = validMessages;
|
||||
} else {
|
||||
const thinkingIdx = this._messages.findIndex((m) => m._thinking);
|
||||
const assistantText = result?.message || "Response received, but no readable message content was found.";
|
||||
if (thinkingIdx >= 0) {
|
||||
this._messages[thinkingIdx] = {
|
||||
role: "assistant",
|
||||
content: assistantText,
|
||||
timestamp: result?.timestamp || new Date().toISOString(),
|
||||
};
|
||||
} else {
|
||||
this._addMessage("assistant", assistantText);
|
||||
}
|
||||
}
|
||||
|
||||
this._isProcessing = false;
|
||||
this._pendingResponses = 0;
|
||||
this._clearThinkingTimer();
|
||||
this._persistMessages();
|
||||
this._render();
|
||||
this._scrollToBottom();
|
||||
} catch (err) {
|
||||
console.error("OpenClaw: Failed to send message:", err);
|
||||
// Replace thinking with error
|
||||
|
||||
Reference in New Issue
Block a user