diff --git a/music_assistant/providers/yandex_smarthome/__init__.py b/music_assistant/providers/yandex_smarthome/__init__.py index 8e02b43438..aa8bd42b1a 100644 --- a/music_assistant/providers/yandex_smarthome/__init__.py +++ b/music_assistant/providers/yandex_smarthome/__init__.py @@ -16,6 +16,9 @@ from __future__ import annotations +import asyncio +import contextlib +import dataclasses import logging import uuid from typing import TYPE_CHECKING, cast @@ -25,15 +28,23 @@ from music_assistant_models.enums import ConfigEntryType, ProviderFeature from ._compat import SecretStr +from .auto_skill import ( + auto_create_skill, + load_default_logo_bytes, +) +from .auto_skill_state import ( + SkillCreationState, + dump_artifacts, + load_artifacts, +) +from .auto_skill_ui import build_cloud_plus_entries, build_direct_entries from .cloud import get_cloud_otp, register_cloud_instance from .constants import ( - CLOUD_OAUTH_AUTHORIZE_URL, - CLOUD_OAUTH_TOKEN_URL, - CLOUD_SKILL_CLIENT_ID_TEMPLATE, - CLOUD_SKILL_CLIENT_SECRET, - CLOUD_SKILL_WEBHOOK_TEMPLATE, + CONF_ACTION_AUTO_CREATE, CONF_ACTION_GET_OTP, CONF_ACTION_REGISTER, + CONF_AUTO_CREATE_ARTIFACTS, + CONF_AUTO_CREATE_SESSION_ID, CONF_CLOUD_CONNECTION_TOKEN, CONF_CLOUD_INSTANCE_ID, CONF_CLOUD_INSTANCE_PASSWORD, @@ -47,11 +58,6 @@ CONNECTION_TYPE_CLOUD, CONNECTION_TYPE_CLOUD_PLUS, CONNECTION_TYPE_DIRECT, - DIRECT_API_BASE_PATH, - DIRECT_AUTH_BASE_PATH, - DIRECT_OAUTH_CLIENT_ID, - YANDEX_DIALOGS_DEVELOPER_URL, - YANDEX_OAUTH_URL, ) from .plugin import YandexSmartHomePlugin @@ -94,25 +100,6 @@ def _build_status_label(otp_code: str | None, is_cloud_plus: bool, is_registered ) -def _build_cloud_plus_label(is_cloud_plus: bool, is_registered: bool) -> str: - """Build the Cloud Plus instruction label.""" - if not is_cloud_plus: - return "" - if is_registered: - return ( - "Cloud Plus setup: " - "1) Open Yandex.Dialogs console (link below) → Smart Home → Create skill. " - "2) Fill 'Basic info': Backend URL = webhook URL below, Access = Private. " - "3) Save, then fill 'Account linking' section with values below. " - "4) Save & Publish. " - "5) Get OAuth token → enter skill_id and token → Save." - ) - return ( - "Cloud Plus mode requires a private skill in Yandex.Dialogs. " - "First register a cloud instance, then follow the setup instructions." - ) - - async def setup( mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig ) -> ProviderInstanceType: @@ -120,14 +107,37 @@ async def setup( return YandexSmartHomePlugin(mass, manifest, config, SUPPORTED_FEATURES) +def _resolve_direct_client_secret( + mass: MusicAssistant, + instance_id: str | None, + values: dict[str, ConfigValueType], +) -> str: + """Return the direct-mode OAuth client secret for the current install. + + `CONF_DIRECT_CLIENT_SECRET` is a SECURE_STRING: MA's frontend does + not echo saved secrets back into ``values`` on re-open, so reading + from ``values`` alone returns an empty string for existing instances. + Prefer the persisted value from saved config and fall back to + ``values`` only for first-time setup before any save. + """ + if instance_id: + prov = mass.get_provider(instance_id) + if prov and prov.config: + saved = prov.config.get_value(CONF_DIRECT_CLIENT_SECRET) + if saved: + return str(saved) + return str(values.get(CONF_DIRECT_CLIENT_SECRET) or "") + + async def _handle_config_actions( mass: MusicAssistant, action: str | None, values: dict[str, ConfigValueType], instance_id: str | None, is_cloud_plus: bool, + connection_type: str, ) -> str | None: - """Execute register/OTP actions and return OTP code if obtained.""" + """Execute config-flow actions and return OTP code if obtained.""" saved_config = None if instance_id: prov = mass.get_provider(instance_id) @@ -161,19 +171,78 @@ async def _handle_config_actions( except Exception: _LOGGER.exception("Failed to get OTP code") - if action == CONF_ACTION_REGISTER and not otp_code: - cloud_id = str(values.get(CONF_CLOUD_INSTANCE_ID, "")) - cloud_token = str(values.get(CONF_CLOUD_CONNECTION_TOKEN, "")) - if cloud_id and cloud_token: - try: - async with aiohttp.ClientSession() as session: - otp_code = await get_cloud_otp(session, cloud_id, SecretStr(cloud_token)) - except Exception: - _LOGGER.exception("Failed to get OTP after registration") + # NOTE: the old flow used to auto-fetch OTP right after Register so + # the user saw the code immediately. In the 3-step cloud_plus flow + # (Register → Create skill → Get OTP), that leaks the OTP into Step 1. + # OTP is now fetched only when the user explicitly presses Get OTP + # in Step 3. + + if action == CONF_ACTION_AUTO_CREATE: + await _run_auto_create_action(mass, values, connection_type, instance_id) return otp_code +async def _run_auto_create_action( + mass: MusicAssistant, + values: dict[str, ConfigValueType], + connection_type: str, + instance_id: str | None, +) -> None: + """Execute the experimental auto-create-skill action. + + Never re-raises: all errors are persisted into the artifacts blob so + the UI can show a FAILED state on the next render rather than + crashing the config form. + """ + # MA's frontend supplies ``values["session_id"]`` when it triggers an + # action — AuthenticationHelper listens on that exact id to open + # and later close the popup. If we roll our own id nothing listens + # and the popup never appears. Fall back to a local uuid only if the + # frontend happened not to pass one (shouldn't happen in practice). + session_id = str(values.get("session_id") or uuid.uuid4().hex) + values[CONF_AUTO_CREATE_SESSION_ID] = session_id + artifacts_raw = values.get(CONF_AUTO_CREATE_ARTIFACTS) + artifacts = load_artifacts(str(artifacts_raw) if artifacts_raw else None) + + try: + new_artifacts = await auto_create_skill( + mass=mass, + connection_type=connection_type, + skill_name=str(values.get(CONF_INSTANCE_NAME) or "Music Assistant"), + artifacts=artifacts, + cloud_instance_id=str(values.get(CONF_CLOUD_INSTANCE_ID, "")), + direct_client_secret=_resolve_direct_client_secret(mass, instance_id, values), + logo_bytes=load_default_logo_bytes(), + session_id=session_id, + ) + except asyncio.CancelledError: + # Preserve cooperative cancellation so config-flow shutdown + # doesn't get converted into a FAILED artifact. + raise + except ValueError as exc: + # Precondition failures come back here — surface as FAILED. + new_artifacts = dataclasses.replace( + artifacts, + state=SkillCreationState.FAILED, + last_error=str(exc), + ) + _LOGGER.warning("auto-create precondition failed: %s", exc) + except Exception as exc: # defensive — never crash the config form + new_artifacts = dataclasses.replace( + artifacts, + state=SkillCreationState.FAILED, + last_error=repr(exc), + ) + _LOGGER.exception("auto-create hit unexpected error") + + values[CONF_AUTO_CREATE_ARTIFACTS] = dump_artifacts(new_artifacts) + if new_artifacts.state == SkillCreationState.DONE and new_artifacts.skill_id: + # Only set CONF_SKILL_ID on full success so the runtime doesn't + # try to use a half-built skill mid-pipeline. + values[CONF_SKILL_ID] = new_artifacts.skill_id + + async def get_config_entries( mass: MusicAssistant, instance_id: str | None = None, @@ -185,10 +254,24 @@ async def get_config_entries( values = {} connection_type = str(values.get(CONF_CONNECTION_TYPE, CONNECTION_TYPE_CLOUD)) + is_cloud = connection_type == CONNECTION_TYPE_CLOUD is_cloud_plus = connection_type == CONNECTION_TYPE_CLOUD_PLUS is_direct = connection_type == CONNECTION_TYPE_DIRECT - otp_code = await _handle_config_actions(mass, action, values, instance_id, is_cloud_plus) + otp_code = await _handle_config_actions( + mass, action, values, instance_id, is_cloud_plus, connection_type + ) + + # Auto-create-skill state — loaded once and threaded through the + # per-mode builders below. + artifacts_raw = values.get(CONF_AUTO_CREATE_ARTIFACTS) + artifacts_str = str(artifacts_raw) if artifacts_raw else None + artifacts = load_artifacts(artifacts_str) + session_id_val = values.get(CONF_AUTO_CREATE_SESSION_ID) + session_id_str = str(session_id_val) if session_id_val else None + ma_base_url_for_ui = "" + with contextlib.suppress(Exception): + ma_base_url_for_ui = str(mass.webserver.base_url) is_registered = bool(values.get(CONF_CLOUD_INSTANCE_ID)) and bool( values.get(CONF_CLOUD_CONNECTION_TOKEN) @@ -196,27 +279,6 @@ async def get_config_entries( cloud_instance_id = str(values.get(CONF_CLOUD_INSTANCE_ID, "")) label_text = _build_status_label(otp_code, is_cloud_plus, is_registered) - cloud_plus_label = _build_cloud_plus_label(is_cloud_plus, is_registered) - - # Compute copyable values for Cloud Plus mode - webhook_url = "" - client_id = "" - if is_cloud_plus and is_registered: - webhook_url = CLOUD_SKILL_WEBHOOK_TEMPLATE - client_id = CLOUD_SKILL_CLIENT_ID_TEMPLATE.format(instance_id=cloud_instance_id) - - # Compute direct mode endpoint URLs - direct_base_url = "" - direct_auth_url = "" - direct_token_url = "" - if is_direct: - try: - ma_base_url = mass.webserver.base_url.rstrip("/") - except Exception: - ma_base_url = "https://" - direct_base_url = f"{ma_base_url}{DIRECT_API_BASE_PATH}" - direct_auth_url = f"{ma_base_url}{DIRECT_AUTH_BASE_PATH}/authorize" - direct_token_url = f"{ma_base_url}{DIRECT_AUTH_BASE_PATH}/token" # Build player options for exposed players filter player_options: list[ConfigValueOption] = [] @@ -229,7 +291,7 @@ async def get_config_entries( except Exception: # noqa: S110 pass - return ( + entries: list[ConfigEntry] = [ # Instance name ConfigEntry( key=CONF_INSTANCE_NAME, @@ -243,6 +305,17 @@ async def get_config_entries( required=False, default_value="Music Assistant", ), + # Save-and-reopen notice — the form doesn't re-render on + # dropdown change, so the user has to Save + reopen to see + # the next mode's fields. + ConfigEntry( + key="label_connection_type_notice", + type=ConfigEntryType.LABEL, + label=( + "💡 After changing Connection Type below, click Save and " + "reopen this settings page to see the fields for the new mode." + ), + ), # Connection type selector ConfigEntry( key=CONF_CONNECTION_TYPE, @@ -260,16 +333,93 @@ async def get_config_entries( ConfigValueOption(title="Cloud Plus (private skill)", value="cloud_plus"), ConfigValueOption(title="Direct (no relay, requires public URL)", value="direct"), ], - advanced=True, + # NOTE: immediate_apply produced glitchy mixed-mode renders + # (entries from old mode stayed on screen next to new ones), + # so users need Save + reopen after changing Connection + # Type. Kept here to stop someone re-adding it. + ), + ] + + # -- Per-mode sections (each builder returns only the fields for its mode) + if is_cloud: + entries.extend(_cloud_mode_entries(label_text, otp_code, is_registered)) + elif is_cloud_plus: + entries.extend( + build_cloud_plus_entries( + otp_code=otp_code, + is_registered=is_registered, + cloud_instance_id=cloud_instance_id, + artifacts=artifacts, + session_id=session_id_str, + user_code=None, # popup URL carries the code + verification_url=None, + existing_artifacts_raw=artifacts_str, + base_url=ma_base_url_for_ui, + skill_id=str(values.get(CONF_SKILL_ID) or ""), + skill_token_set=bool(values.get(CONF_SKILL_TOKEN)), + ) + ) + elif is_direct: + # Pre-generate the per-install direct client secret once so it + # survives round-trips (auto-skill pipeline reads it later). + # SECURE_STRING is not echoed back into ``values`` on re-open, + # so prefer the persisted value from saved config first and + # only mint a fresh UUID on true first-time setup. + direct_secret = _resolve_direct_client_secret(mass, instance_id, values) + if not direct_secret: + direct_secret = uuid.uuid4().hex + values[CONF_DIRECT_CLIENT_SECRET] = direct_secret + entries.extend( + build_direct_entries( + artifacts=artifacts, + session_id=session_id_str, + user_code=None, + verification_url=None, + existing_artifacts_raw=artifacts_str, + base_url=ma_base_url_for_ui, + direct_client_secret=direct_secret, + skill_id=str(values.get(CONF_SKILL_ID) or ""), + skill_token_set=bool(values.get(CONF_SKILL_TOKEN)), + ) + ) + # NB: CONF_DIRECT_CLIENT_SECRET is now emitted by the manual + # fallback block (advanced/hidden per state), so we don't add a + # duplicate hidden round-trip entry here. + + # -- Tail: player filter + hidden round-trip fields (all modes) -- + entries.extend(_common_tail_entries(player_options, values)) + return tuple(entries) + + +def _cloud_mode_entries( + label_text: str, otp_code: str | None, is_registered: bool +) -> list[ConfigEntry]: + """Public-cloud mode: simple register + get-OTP flow.""" + return [ + # Advisory — the public Yaha Cloud skill can only be linked to one + # instance per Yandex account, so users who already set up Yaha + # Cloud in Home Assistant (or another MA install) need Cloud Plus. + # There's no pre-flight API to detect this, so the warning is + # static — cheaper than a failed OTP attempt. + ConfigEntry( + key="label_cloud_conflict_warning", + type=ConfigEntryType.LABEL, + label=( + "⚠️ If this Yandex account already uses the Yaha Cloud skill " + "via Home Assistant or another Music Assistant install, " + "pick 'Cloud Plus' above instead — the public skill can " + "only be linked to one instance per account." + ), + depends_on=CONF_CONNECTION_TYPE, + depends_on_value=CONNECTION_TYPE_CLOUD, ), - # Status label (cloud modes only) ConfigEntry( key="label_status", type=ConfigEntryType.LABEL, label=label_text, - hidden=is_direct, + depends_on=CONF_CONNECTION_TYPE, + depends_on_value=CONNECTION_TYPE_CLOUD, ), - # OTP code — copyable text field (shown only when OTP is available) ConfigEntry( key="otp_code", type=ConfigEntryType.STRING, @@ -277,9 +427,10 @@ async def get_config_entries( description="Copy this code and enter it in the Yandex app.", required=False, value=otp_code, - hidden=not otp_code or is_direct, + hidden=not otp_code, + depends_on=CONF_CONNECTION_TYPE, + depends_on_value=CONNECTION_TYPE_CLOUD, ), - # Register action (hidden after registration or in direct mode) ConfigEntry( key=CONF_ACTION_REGISTER, type=ConfigEntryType.ACTION, @@ -287,9 +438,11 @@ async def get_config_entries( description="Register a new instance on yaha-cloud.ru relay service.", action=CONF_ACTION_REGISTER, action_label="Register with cloud", - hidden=is_registered or is_direct, + hidden=is_registered, + # No depends_on — MA disables actions with an unsaved + # dependency value until the user clicks Save, which breaks + # the flow right after picking a connection type. ), - # Get OTP action (shown after registration, hidden in direct mode) ConfigEntry( key=CONF_ACTION_GET_OTP, type=ConfigEntryType.ACTION, @@ -297,252 +450,16 @@ async def get_config_entries( description="Get a fresh one-time password to link with Yandex Smart Home app.", action=CONF_ACTION_GET_OTP, action_label="Get OTP code", - hidden=not is_registered or is_direct, - ), - # --- Direct connection section --- - ConfigEntry( - key="label_direct", - type=ConfigEntryType.LABEL, - label=( - "Direct connection setup: " - "1) Create a private skill in Yandex.Dialogs (Smart Home type). " - "2) Set Backend URL, Authorization URL, Token URL from values below. " - "3) Set Client ID and Client Secret from values below. " - "4) Publish skill, then link account in Yandex app. " - "5) Fill Skill ID and Skill Token below and Save." - ), - depends_on=CONF_CONNECTION_TYPE, - depends_on_value=CONNECTION_TYPE_DIRECT, - category="Direct Connection Setup", - ), - # Yandex Dialogs developer console link (direct) - ConfigEntry( - key="direct_dialogs_url", - type=ConfigEntryType.STRING, - label="Yandex.Dialogs Console (create skill here)", - required=False, - default_value=YANDEX_DIALOGS_DEVELOPER_URL, - help_link=YANDEX_DIALOGS_DEVELOPER_URL, - depends_on=CONF_CONNECTION_TYPE, - depends_on_value=CONNECTION_TYPE_DIRECT, - category="Direct Connection Setup", - ), - # Backend URL (for Yandex.Dialogs skill config) - ConfigEntry( - key="direct_backend_url", - type=ConfigEntryType.STRING, - label="Backend URL (→ Basic info)", - description="Copy to your skill's Backend URL field in Yandex.Dialogs.", - required=False, - value=direct_base_url or None, - depends_on=CONF_CONNECTION_TYPE, - depends_on_value=CONNECTION_TYPE_DIRECT, - category="Copy to Yandex.Dialogs skill", - ), - # Authorization URL (direct) - ConfigEntry( - key="direct_auth_url", - type=ConfigEntryType.STRING, - label="Authorization URL (→ Account linking)", - description="Copy to 'Account linking' → 'Authorization URL' field.", - required=False, - value=direct_auth_url or None, - depends_on=CONF_CONNECTION_TYPE, - depends_on_value=CONNECTION_TYPE_DIRECT, - category="Copy to Yandex.Dialogs skill", - ), - # Token URL (direct) - ConfigEntry( - key="direct_token_url", - type=ConfigEntryType.STRING, - label="Token URL (→ Account linking, both fields)", - description=("Copy to both 'Token endpoint' and 'Refresh token URL' fields."), - required=False, - value=direct_token_url or None, - depends_on=CONF_CONNECTION_TYPE, - depends_on_value=CONNECTION_TYPE_DIRECT, - category="Copy to Yandex.Dialogs skill", - ), - # Client ID (direct — always the same) - ConfigEntry( - key="direct_client_id", - type=ConfigEntryType.STRING, - label="Client ID (→ Account linking)", - description="Copy to 'Account linking' → 'Client identifier' field.", - required=False, - default_value=DIRECT_OAUTH_CLIENT_ID, - depends_on=CONF_CONNECTION_TYPE, - depends_on_value=CONNECTION_TYPE_DIRECT, - category="Copy to Yandex.Dialogs skill", - ), - # Client Secret (direct — auto-generated per install) - ConfigEntry( - key=CONF_DIRECT_CLIENT_SECRET, - type=ConfigEntryType.SECURE_STRING, - label="Client Secret (→ Account linking)", - description=( - "Copy to 'Account linking' → 'Client secret' field. Auto-generated on first setup." - ), - required=False, - default_value=( - cast("str", values.get(CONF_DIRECT_CLIENT_SECRET)) - if values and values.get(CONF_DIRECT_CLIENT_SECRET) - else uuid.uuid4().hex - ), - depends_on=CONF_CONNECTION_TYPE, - depends_on_value=CONNECTION_TYPE_DIRECT, - category="Copy to Yandex.Dialogs skill", - ), - # OAuth URL for getting skill token (direct) - ConfigEntry( - key="direct_oauth_url", - type=ConfigEntryType.STRING, - label="OAuth URL (open to get skill token)", - required=False, - default_value=YANDEX_OAUTH_URL, - help_link=YANDEX_OAUTH_URL, - depends_on=CONF_CONNECTION_TYPE, - depends_on_value=CONNECTION_TYPE_DIRECT, - category="Fill in from Yandex.Dialogs", - ), - # Skill ID (cloud_plus and direct) - ConfigEntry( - key=CONF_SKILL_ID, - type=ConfigEntryType.STRING, - label="Skill ID", - description=( - "UUID of your private Smart Home skill from Yandex.Dialogs. " - "Find it in the skill URL: /developer/skills/{skill_id}/" - ), - required=False, - depends_on=CONF_CONNECTION_TYPE, - depends_on_value_not=CONNECTION_TYPE_CLOUD, - category="Fill in from Yandex.Dialogs", - ), - # Skill OAuth Token (cloud_plus and direct) - ConfigEntry( - key=CONF_SKILL_TOKEN, - type=ConfigEntryType.SECURE_STRING, - label="Skill OAuth Token", - description="Paste the OAuth token obtained from the URL above.", - required=False, - depends_on=CONF_CONNECTION_TYPE, - depends_on_value_not=CONNECTION_TYPE_CLOUD, - category="Fill in from Yandex.Dialogs", - ), - # --- Cloud Plus section (advanced) --- - # Cloud Plus instructions - ConfigEntry( - key="label_cloud_plus", - type=ConfigEntryType.LABEL, - label=cloud_plus_label, - depends_on=CONF_CONNECTION_TYPE, - depends_on_value=CONNECTION_TYPE_CLOUD_PLUS, - advanced=True, - category="Cloud Plus Setup", - ), - # Yandex Dialogs developer console link - ConfigEntry( - key="dialogs_url", - type=ConfigEntryType.STRING, - label="Yandex.Dialogs Console (create skill here)", - required=False, - default_value=YANDEX_DIALOGS_DEVELOPER_URL, - help_link=YANDEX_DIALOGS_DEVELOPER_URL, - depends_on=CONF_CONNECTION_TYPE, - depends_on_value=CONNECTION_TYPE_CLOUD_PLUS, - advanced=True, - category="Cloud Plus Setup", - ), - # --- Copy to Yandex.Dialogs --- - # Webhook URL - ConfigEntry( - key="webhook_url", - type=ConfigEntryType.STRING, - label="Backend URL (→ Basic info)", - description="Copy and paste into your private skill's Backend URL field.", - required=False, - value=webhook_url or None, - hidden=not webhook_url, - depends_on=CONF_CONNECTION_TYPE, - depends_on_value=CONNECTION_TYPE_CLOUD_PLUS, - advanced=True, - category="Copy to Yandex.Dialogs skill", - ), - # Client ID - ConfigEntry( - key="skill_client_id", - type=ConfigEntryType.STRING, - label="Client ID (→ Account linking)", - description="Copy to 'Account linking' → 'Client identifier' field.", - required=False, - value=client_id or None, - hidden=not client_id, - depends_on=CONF_CONNECTION_TYPE, - depends_on_value=CONNECTION_TYPE_CLOUD_PLUS, - advanced=True, - category="Copy to Yandex.Dialogs skill", - ), - # Client Secret - ConfigEntry( - key="skill_client_secret", - type=ConfigEntryType.STRING, - label="Client Secret (→ Account linking)", - description="Copy to 'Account linking' → 'Client secret' field.", - required=False, - default_value=CLOUD_SKILL_CLIENT_SECRET, hidden=not is_registered, - depends_on=CONF_CONNECTION_TYPE, - depends_on_value=CONNECTION_TYPE_CLOUD_PLUS, - advanced=True, - category="Copy to Yandex.Dialogs skill", ), - # Authorization URL - ConfigEntry( - key="skill_auth_url", - type=ConfigEntryType.STRING, - label="Authorization URL (→ Account linking)", - description="Copy to 'Account linking' → 'Authorization URL' field.", - required=False, - default_value=CLOUD_OAUTH_AUTHORIZE_URL, - hidden=not is_registered, - depends_on=CONF_CONNECTION_TYPE, - depends_on_value=CONNECTION_TYPE_CLOUD_PLUS, - advanced=True, - category="Copy to Yandex.Dialogs skill", - ), - # Token URL - ConfigEntry( - key="skill_token_url", - type=ConfigEntryType.STRING, - label="Token URL (→ Account linking, both fields)", - description=( - "Copy to both 'Token endpoint' and 'Refresh token URL' fields " - "in the 'Account linking' section." - ), - required=False, - default_value=CLOUD_OAUTH_TOKEN_URL, - hidden=not is_registered, - depends_on=CONF_CONNECTION_TYPE, - depends_on_value=CONNECTION_TYPE_CLOUD_PLUS, - advanced=True, - category="Copy to Yandex.Dialogs skill", - ), - # OAuth URL — link to get skill token (Cloud Plus) - ConfigEntry( - key="oauth_url", - type=ConfigEntryType.STRING, - label="OAuth URL (open to get token)", - required=False, - default_value=YANDEX_OAUTH_URL, - help_link=YANDEX_OAUTH_URL, - hidden=not is_registered, - depends_on=CONF_CONNECTION_TYPE, - depends_on_value=CONNECTION_TYPE_CLOUD_PLUS, - advanced=True, - category="Fill in from Yandex.Dialogs", - ), - # --- Player filter --- + ] + + +def _common_tail_entries( + player_options: list[ConfigValueOption], values: dict[str, ConfigValueType] +) -> list[ConfigEntry]: + """Player filter + hidden round-trip fields shared by every mode.""" + return [ ConfigEntry( key=CONF_EXPOSED_PLAYERS, type=ConfigEntryType.STRING, @@ -556,7 +473,6 @@ async def get_config_entries( default_value=[], options=list(player_options) if player_options else [], ), - # --- Auto-managed fields (hidden, populated by actions) --- ConfigEntry( key=CONF_CLOUD_INSTANCE_ID, type=ConfigEntryType.STRING, @@ -589,4 +505,4 @@ async def get_config_entries( required=False, value=(cast("str", values.get(CONF_DIRECT_ACCESS_TOKEN)) if values else None), ), - ) + ] diff --git a/music_assistant/providers/yandex_smarthome/auto_skill.py b/music_assistant/providers/yandex_smarthome/auto_skill.py new file mode 100644 index 0000000000..bdca544abe --- /dev/null +++ b/music_assistant/providers/yandex_smarthome/auto_skill.py @@ -0,0 +1,1279 @@ +"""Low-level client for the undocumented dialogs.yandex.ru developer API. + +Implements the 8-step sequence captured from Chrome DevTools HAR for +creating a Smart Home skill with account-linking: + + 1. GET /developer → extract CSRF (secretkey) + 2. GET /developer/app-store-api/snapshot → existing skills list (optional) + 3. POST /developer/app-store-api/apps → skill_id + 4. POST /developer/app-store-api/apps/{id}/draft/upload-logo → logo_id + 5. PATCH /developer/app-store-api/apps/{id}/draft/update → settings + 6. POST /developer/app-store-api/oauth/apps → oauth_app_id + 7. POST /developer/app-store-api/apps/{id}/oauthApp → bind oauth + 8. POST /developer/app-store-api/apps/{id}/draft/request-deploy → publish + +This is an UNDOCUMENTED, PRIVATE API. It may break at any time. The +caller is responsible for surfacing that risk to the user (see +``provider.auto_skill_ui``). + +Authentication: passport session cookies (``Session_id`` / ``sessionid2``) +must already be present in the supplied ``aiohttp.ClientSession``'s +cookie jar. Obtain them via ``ya_passport_auth.PassportClient``: + + creds = await client.login_device_code(...) + await client.refresh_passport_cookies(creds.x_token) + creator = DialogsSkillCreator(client._session) + +The CSRF token (returned by ``fetch_csrf``) must be passed as the +``x-csrf-token`` header on every mutating request. +""" + +from __future__ import annotations + +import asyncio +import dataclasses +import json +import logging +import re +from contextlib import asynccontextmanager +from pathlib import Path +from typing import TYPE_CHECKING, Any + +import aiohttp + +from .auto_skill_state import SkillCreationArtifacts, SkillCreationState +from .constants import ( + CLOUD_OAUTH_AUTHORIZE_URL, + CLOUD_OAUTH_TOKEN_URL, + CLOUD_SKILL_CLIENT_ID_TEMPLATE, + CLOUD_SKILL_CLIENT_SECRET, + CLOUD_SKILL_WEBHOOK_TEMPLATE, + CONNECTION_TYPE_CLOUD_PLUS, + CONNECTION_TYPE_DIRECT, + DIRECT_API_BASE_PATH, + DIRECT_AUTH_BASE_PATH, + DIRECT_OAUTH_CLIENT_ID, +) + +if TYPE_CHECKING: + from collections.abc import AsyncIterator, Awaitable, Callable, Mapping + + from music_assistant.mass import MusicAssistant + +__all__ = [ + "DEVICE_FLOW_TIMEOUT_SECONDS", + "DIALOGS_API_BASE", + "DIALOGS_CSRF_REGEX", + "DIALOGS_DEV_BASE", + "DIALOGS_DEV_HTML_URL", + "DialogsApiError", + "DialogsCsrfError", + "DialogsDuplicateSkillError", + "DialogsSkillCreator", + "auto_create_skill", + "build_draft_payload", + "build_oauth_app_payload", + "check_preconditions", + "derive_auth_urls", + "derive_backend_uri", + "derive_client_id", + "load_default_logo_bytes", +] + +_LOGGER = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Endpoints / patterns +# --------------------------------------------------------------------------- +DIALOGS_DEV_BASE = "https://dialogs.yandex.ru" +DIALOGS_DEV_HTML_URL = f"{DIALOGS_DEV_BASE}/developer" +DIALOGS_API_BASE = f"{DIALOGS_DEV_BASE}/developer/app-store-api" + +# The developer console embeds a CSRF token in its HTML as: +# ..."secretkey":"u9c94f1aca53bf156be4..."... +# Captured from HAR 2026-04-24. If Yandex re-renders differently, this +# regex will miss and ``fetch_csrf`` raises ``DialogsCsrfError`` so the +# user falls back to manual setup. +DIALOGS_CSRF_REGEX = re.compile(r'"secretkey":"([^"]+)"') + +SMART_HOME_CHANNEL = "smartHome" +_MAX_HTML_RESPONSE_BYTES = 2 * 1024 * 1024 # 2 MiB + +# --------------------------------------------------------------------------- +# Errors +# --------------------------------------------------------------------------- + + +class DialogsApiError(Exception): + """Base error for dialogs.yandex.ru API failures.""" + + def __init__( + self, + message: str, + *, + step: str, + http_status: int | None = None, + yandex_error: str | None = None, + ) -> None: + """Initialise with the pipeline step that failed for clearer messages.""" + super().__init__(message) + self.step = step + self.http_status = http_status + self.yandex_error = yandex_error + + +class DialogsCsrfError(DialogsApiError): + """Raised when the CSRF token cannot be extracted from the developer page.""" + + +class DialogsDuplicateSkillError(DialogsApiError): + """Raised when create_app rejects because a skill with the same name exists.""" + + +# --------------------------------------------------------------------------- +# Client +# --------------------------------------------------------------------------- + + +class DialogsSkillCreator: + """Thin async wrapper over dialogs.yandex.ru developer-console API. + + Every method is idempotent on the transport layer: a single call + either succeeds or raises. Retry / state-machine logic lives in the + orchestrator (see :func:`auto_create_skill`). + """ + + __slots__ = ("_logger", "_session") + + def __init__( + self, + session: aiohttp.ClientSession, + logger: logging.Logger | None = None, + ) -> None: + """Take a session that already carries Passport auth cookies.""" + self._session = session + self._logger = logger or _LOGGER + + # ----------------------------------------------------------------------- + # Step 1: CSRF token extraction + # ----------------------------------------------------------------------- + + async def fetch_csrf(self) -> str: + """Fetch the developer page HTML and extract the CSRF ``secretkey``. + + Caller uses the returned value as the ``x-csrf-token`` header on + all mutating requests. Returns a fresh token on every call; the + orchestrator caches it for the duration of a single attempt. + """ + async with self._session.get(DIALOGS_DEV_HTML_URL) as resp: + if resp.status == 401: + raise DialogsApiError( + "not authenticated — passport session cookies missing or expired", + step="fetch_csrf", + http_status=401, + ) + if resp.status != 200: + raise DialogsApiError( + f"dialogs.yandex.ru/developer returned HTTP {resp.status}", + step="fetch_csrf", + http_status=resp.status, + ) + # Enforce the size cap while reading so an oversized + # response can't buffer fully in memory (T5 pattern from + # ya-passport-auth). + body = bytearray() + async for chunk in resp.content.iter_chunked(8192): + body.extend(chunk) + if len(body) > _MAX_HTML_RESPONSE_BYTES: + raise DialogsApiError( + "developer page response exceeded size cap", + step="fetch_csrf", + ) + html = body.decode(resp.get_encoding() or "utf-8", errors="replace") + + match = DIALOGS_CSRF_REGEX.search(html) + if not match: + raise DialogsCsrfError( + "could not locate CSRF token in developer page HTML — " + "Yandex may have changed the rendering format", + step="fetch_csrf", + ) + token = match.group(1).strip() + if not token: + raise DialogsCsrfError( + "CSRF token matched but is empty", + step="fetch_csrf", + ) + self._logger.debug("dialogs CSRF token fetched (len=%d)", len(token)) + return token + + # ----------------------------------------------------------------------- + # Step 2: list existing skills (for duplicate-name detection) + # ----------------------------------------------------------------------- + + async def list_existing_skills(self, csrf: str) -> list[dict[str, Any]]: + """Return the user's existing skills from the snapshot endpoint. + + The dashboard uses this to populate its skill list; we use it to + warn the user before they hit a duplicate-name 4xx on create_app. + """ + url = f"{DIALOGS_API_BASE}/snapshot" + data = await self._get_json(url, csrf=csrf, step="list_existing_skills") + result = data.get("result") + if not isinstance(result, dict): + return [] + skills = result.get("skills") + if not isinstance(skills, list): + return [] + return [s for s in skills if isinstance(s, dict)] + + # ----------------------------------------------------------------------- + # Step 3: create the skill app + # ----------------------------------------------------------------------- + + async def create_app(self, csrf: str, name: str) -> str: + """Create a Smart Home skill with the given name. + + Returns the newly-minted ``skill_id`` (UUID). Raises + :class:`DialogsDuplicateSkillError` if the name is already taken + by another skill on this account. + """ + url = f"{DIALOGS_API_BASE}/apps" + payload = { + "channel": SMART_HOME_CHANNEL, + "language": "ru", + "isYangoConsole": False, + "appName": name, + } + data = await self._post_json(url, payload, csrf=csrf, step="create_app") + result = data.get("result") + if not isinstance(result, dict): + raise DialogsApiError( + "create_app response missing 'result' object", + step="create_app", + ) + skill_id = result.get("id") or result.get("skill_id") + if not isinstance(skill_id, str) or not skill_id: + raise DialogsApiError( + "create_app response missing skill id", + step="create_app", + ) + self._logger.info("dialogs skill created: id=%s name=%r", skill_id, name) + return skill_id + + # ----------------------------------------------------------------------- + # Step 4: upload logo + # ----------------------------------------------------------------------- + + async def upload_logo(self, csrf: str, skill_id: str, png: bytes) -> str: + """Upload a PNG logo for the skill. + + Returns a ``logo_id`` that must be referenced in ``update_draft``. + The logo file is sent as multipart with the field name ``file`` + and filename ``icon.png`` (matching the HAR capture). + """ + url = f"{DIALOGS_API_BASE}/apps/{skill_id}/draft/upload-logo?channel={SMART_HOME_CHANNEL}" + form = aiohttp.FormData() + form.add_field( + "file", + png, + filename="icon.png", + content_type="image/png", + ) + headers = {"x-csrf-token": csrf} + async with self._session.post(url, data=form, headers=headers) as resp: + body = await resp.text() + if resp.status != 200: + raise DialogsApiError( + f"upload_logo HTTP {resp.status}: {body[:200]}", + step="upload_logo", + http_status=resp.status, + ) + data = _try_json(body) + result = data.get("result") if isinstance(data, dict) else None + if not isinstance(result, dict): + raise DialogsApiError( + "upload_logo response missing 'result'", + step="upload_logo", + ) + logo_id = result.get("id") + if not isinstance(logo_id, str) or not logo_id: + raise DialogsApiError( + "upload_logo response missing logo id", + step="upload_logo", + ) + return logo_id + + # ----------------------------------------------------------------------- + # Step 5: update draft settings + # ----------------------------------------------------------------------- + + async def update_draft(self, csrf: str, skill_id: str, payload: Mapping[str, Any]) -> None: + """PATCH the skill draft with backend URL / publishing metadata.""" + url = f"{DIALOGS_API_BASE}/apps/{skill_id}/draft/update" + await self._patch_json(url, dict(payload), csrf=csrf, step="update_draft") + + # ----------------------------------------------------------------------- + # Step 6: create OAuth app (account-linking) + # ----------------------------------------------------------------------- + + async def create_oauth_app( + self, + csrf: str, + *, + name: str, + client_id: str, + client_secret: str, + authorize_url: str, + token_url: str, + refresh_url: str, + ) -> str: + """Create the OAuth app that powers account-linking in the skill. + + Returns the OAuth-app UUID which is then bound to the skill via + ``attach_oauth``. + """ + url = f"{DIALOGS_API_BASE}/oauth/apps" + payload = { + "name": name, + "clientId": client_id, + "clientSecret": client_secret, + "authorizationUrl": authorize_url, + "tokenUrl": token_url, + "refreshTokenUrl": refresh_url, + "scope": "", + "yandexClientId": "", + } + data = await self._post_json(url, payload, csrf=csrf, step="create_oauth_app") + result = data.get("result") + if not isinstance(result, dict): + raise DialogsApiError( + "create_oauth_app response missing 'result'", + step="create_oauth_app", + ) + oauth_app_id = result.get("id") + if not isinstance(oauth_app_id, str) or not oauth_app_id: + raise DialogsApiError( + "create_oauth_app response missing oauth app id", + step="create_oauth_app", + ) + return oauth_app_id + + # ----------------------------------------------------------------------- + # Step 7: bind OAuth app to the skill + # ----------------------------------------------------------------------- + + async def attach_oauth(self, csrf: str, skill_id: str, oauth_app_id: str) -> None: + """Attach an existing OAuth app to the skill's account-linking slot.""" + url = f"{DIALOGS_API_BASE}/apps/{skill_id}/oauthApp?channel={SMART_HOME_CHANNEL}" + payload = {"oauthAppId": oauth_app_id} + await self._post_json(url, payload, csrf=csrf, step="attach_oauth") + + # ----------------------------------------------------------------------- + # Step 8: publish (send for moderation) + # ----------------------------------------------------------------------- + + async def request_deploy(self, csrf: str, skill_id: str) -> None: + """Send the draft to moderation / publish. + + Body is empty; all params are in the query string. Returns on + 2xx; otherwise raises. + """ + url = ( + f"{DIALOGS_API_BASE}/apps/{skill_id}/draft/request-deploy?channel={SMART_HOME_CHANNEL}" + ) + headers = {"x-csrf-token": csrf} + async with self._session.post(url, headers=headers) as resp: + body = await resp.text() + if resp.status not in (200, 201, 202, 204): + raise DialogsApiError( + f"request_deploy HTTP {resp.status}: {body[:200]}", + step="request_deploy", + http_status=resp.status, + ) + + # ----------------------------------------------------------------------- + # Internal helpers + # ----------------------------------------------------------------------- + + async def _get_json(self, url: str, *, csrf: str, step: str) -> dict[str, Any]: + headers = {"x-csrf-token": csrf} + async with self._session.get(url, headers=headers) as resp: + body = await resp.text() + if resp.status != 200: + raise DialogsApiError( + f"GET {url} HTTP {resp.status}: {body[:200]}", + step=step, + http_status=resp.status, + ) + data = _try_json(body) + if not isinstance(data, dict): + raise DialogsApiError( + f"GET {url} returned non-object JSON", + step=step, + ) + return data + + async def _post_json( + self, url: str, payload: dict[str, Any], *, csrf: str, step: str + ) -> dict[str, Any]: + return await self._send_json("POST", url, payload, csrf=csrf, step=step) + + async def _patch_json( + self, url: str, payload: dict[str, Any], *, csrf: str, step: str + ) -> dict[str, Any]: + return await self._send_json("PATCH", url, payload, csrf=csrf, step=step) + + async def _send_json( + self, + method: str, + url: str, + payload: dict[str, Any], + *, + csrf: str, + step: str, + ) -> dict[str, Any]: + headers = { + "x-csrf-token": csrf, + "content-type": "application/json", + } + async with self._session.request(method, url, json=payload, headers=headers) as resp: + body = await resp.text() + # Only ``create_app`` can fail with duplicate-name errors — + # other endpoints use 409 for unrelated conflicts and would + # be misclassified as duplicates if the mapping were global. + duplicate_candidate = step == "create_app" and ( + resp.status == 409 or (resp.status in (400, 422) and _looks_like_duplicate(body)) + ) + if duplicate_candidate: + raise DialogsDuplicateSkillError( + f"{step}: skill with this name already exists", + step=step, + http_status=resp.status, + yandex_error=_extract_error_code(body), + ) + if resp.status not in (200, 201, 202): + raise DialogsApiError( + f"{method} {url} HTTP {resp.status}: {body[:200]}", + step=step, + http_status=resp.status, + yandex_error=_extract_error_code(body), + ) + data = _try_json(body) + if not isinstance(data, dict): + raise DialogsApiError( + f"{method} {url} returned non-object JSON", + step=step, + ) + return data + + +# --------------------------------------------------------------------------- +# Module-private helpers +# --------------------------------------------------------------------------- + + +def _try_json(body: str) -> Any: + """Parse JSON defensively — return None on any error.""" + if not body: + return None + try: + return json.loads(body) + except (ValueError, TypeError): + return None + + +def _looks_like_duplicate(body: str) -> bool: + """Heuristic for whether a 4xx body indicates a duplicate-name error.""" + if not body: + return False + lowered = body.lower() + return any( + kw in lowered for kw in ("already exists", "duplicate", "exists with name", "not_unique") + ) + + +def _extract_error_code(body: str) -> str | None: + """Pull Yandex error code/message out of a 4xx response body (best-effort).""" + data = _try_json(body) + if not isinstance(data, dict): + return None + for key in ("error", "errorCode", "message", "code"): + value = data.get(key) + if isinstance(value, str) and value: + return value + return None + + +# --------------------------------------------------------------------------- +# Pure helpers: backend/oauth URLs, payload builders, preconditions +# +# All of these are side-effect-free and separately unit-testable; the +# orchestrator in a later commit wires them together. +# --------------------------------------------------------------------------- + + +def derive_backend_uri(mass: MusicAssistant, connection_type: str) -> str: + """Return the Backend URL the skill should point at for *connection_type*. + + cloud_plus → yaha-cloud.ru relay (fixed URL). + direct → ``{mass.webserver.base_url}`` + our API path (requires + HTTPS base URL; see :func:`check_preconditions`). + """ + if connection_type == CONNECTION_TYPE_CLOUD_PLUS: + return CLOUD_SKILL_WEBHOOK_TEMPLATE + if connection_type == CONNECTION_TYPE_DIRECT: + base = str(mass.webserver.base_url).rstrip("/") + return f"{base}{DIRECT_API_BASE_PATH}" + msg = f"auto-create is not supported for connection_type={connection_type!r}" + raise ValueError(msg) + + +def derive_auth_urls(mass: MusicAssistant, connection_type: str) -> tuple[str, str]: + """Return (authorize_url, token_url) for the OAuth app. + + cloud_plus uses the yaha-cloud relay's OAuth endpoints; direct + uses the MA webserver's own authorize/token endpoints (served by + provider/direct.py). + """ + if connection_type == CONNECTION_TYPE_CLOUD_PLUS: + return CLOUD_OAUTH_AUTHORIZE_URL, CLOUD_OAUTH_TOKEN_URL + if connection_type == CONNECTION_TYPE_DIRECT: + base = str(mass.webserver.base_url).rstrip("/") + return ( + f"{base}{DIRECT_AUTH_BASE_PATH}/authorize", + f"{base}{DIRECT_AUTH_BASE_PATH}/token", + ) + msg = f"auto-create is not supported for connection_type={connection_type!r}" + raise ValueError(msg) + + +def derive_client_id(connection_type: str, cloud_instance_id: str) -> str: + """Return the OAuth client_id to register in the skill's account linking. + + cloud_plus uses ``yandex_smart_home:{instance_id}`` (yaha-cloud + protocol); direct uses the fixed Yandex social redirect base URL + (its existing Yandex OAuth client expects this exact ID). + """ + if connection_type == CONNECTION_TYPE_CLOUD_PLUS: + if not cloud_instance_id: + msg = "cloud_plus requires a registered cloud_instance_id" + raise ValueError(msg) + return CLOUD_SKILL_CLIENT_ID_TEMPLATE.format(instance_id=cloud_instance_id) + if connection_type == CONNECTION_TYPE_DIRECT: + return DIRECT_OAUTH_CLIENT_ID + msg = f"auto-create is not supported for connection_type={connection_type!r}" + raise ValueError(msg) + + +def build_draft_payload( + *, + connection_type: str, + skill_name: str, + backend_uri: str, + logo_id: str | None, + developer_name: str = "Music Assistant user", +) -> dict[str, Any]: + """Compose the PATCH /draft/update body for a Smart Home skill. + + Matches the HAR sample field-for-field; every key that the + dashboard UI sends on save is reproduced so Yandex's validator + sees a complete draft and allows ``request-deploy`` afterwards. + """ + if connection_type not in (CONNECTION_TYPE_CLOUD_PLUS, CONNECTION_TYPE_DIRECT): + msg = f"auto-create is not supported for connection_type={connection_type!r}" + raise ValueError(msg) + + return { + "logo2": None, + "name": skill_name, + "voice": "shitova.us", + "logoId": logo_id, + "skillAccess": "private", + "hideInStore": False, + "noteForModerator": "", + "backendSettings": { + "uri": backend_uri, + "functionId": "", + "backendType": "webhook", + }, + "publishingSettings": { + "brandVerificationWebsite": "", + "category": "smart_home", + "developerName": developer_name, + "secondaryTitle": "", + "email": "", # server pulls from the authenticated session + "smartHome": { + "deepLinks": { + "android": {"url": ""}, + "ios": {"url": "", "fallbackUrl": ""}, + }, + }, + "multilingualSettings": { + "ru": { + "name": skill_name, + "secondaryTitle": "", + "externalSettingsDescription": skill_name, + "supportedUnitsDescription": skill_name, + }, + }, + }, + "oauthAppId": None, + "isTrustedSmartHomeSkill": False, + "enableAllAvailableRegions": True, + "selectedRegions": [], + "channel": SMART_HOME_CHANNEL, + } + + +def build_oauth_app_payload( + *, + skill_name: str, + client_id: str, + client_secret: str, + authorize_url: str, + token_url: str, +) -> dict[str, Any]: + """Compose the POST /oauth/apps body for account-linking. + + Values come from :func:`derive_client_id`, :func:`derive_auth_urls`, + and :func:`derive_backend_uri`'s caller context. ``refreshTokenUrl`` + always equals ``token_url`` — Yandex's flow uses the same endpoint + for both grant types. + """ + return { + "name": skill_name, + "clientId": client_id, + "clientSecret": client_secret, + "authorizationUrl": authorize_url, + "tokenUrl": token_url, + "refreshTokenUrl": token_url, + "scope": "", + "yandexClientId": "", + } + + +def check_preconditions( + *, + connection_type: str, + mass: MusicAssistant, + cloud_instance_id: str, + direct_client_secret: str, +) -> None: + """Validate that auto-create can run for the given connection type. + + Raises :class:`ValueError` with a human-readable message on failure. + Called before any network I/O so the UI can surface the error + without a half-created skill on Yandex's side. + """ + if connection_type == CONNECTION_TYPE_CLOUD_PLUS: + if not cloud_instance_id: + msg = ( + "Cloud Plus requires a registered yaha-cloud instance first. " + "Use the 'Register with cloud' action." + ) + raise ValueError(msg) + return + + if connection_type == CONNECTION_TYPE_DIRECT: + if not direct_client_secret: + msg = "Direct mode requires a generated Client Secret" + raise ValueError(msg) + try: + base = str(mass.webserver.base_url) + except Exception as exc: + msg = f"MA webserver base URL is not available: {exc}" + raise ValueError(msg) from exc + if not base.startswith("https://"): + msg = ( + "Direct mode requires MA to be reachable over HTTPS from the " + f"public internet (got base_url={base!r}). Yandex will reject " + "a skill with a non-HTTPS backend." + ) + raise ValueError(msg) + return + + msg = ( + f"auto-create is not supported for connection_type={connection_type!r}; " + "use cloud_plus or direct." + ) + raise ValueError(msg) + + +# --------------------------------------------------------------------------- +# Orchestrator: device flow + resumable pipeline +# --------------------------------------------------------------------------- + +# Hard cap on how long we'll wait for the user to enter the code. +DEVICE_FLOW_TIMEOUT_SECONDS = 300.0 + +_DEVICE_CODE_PAGE_PATH = "/yandex_smarthome/device_code" +# Keep the intermediate HTML page alive long enough for one more poll +# after state flips to done/failed — ~1s is plenty, the page polls +# every 2s so we're just covering the in-flight window. +_POST_AUTH_GRACE_SECONDS = 1 +# Server-suggested interval from Yandex is 5s (RFC 8628) but after the +# user has confirmed the code we want to detect it promptly; 2s is the +# RFC-recommended minimum. If Yandex ever returns SLOW_DOWN, the library +# bumps the interval automatically. +_DEVICE_FLOW_POLL_INTERVAL = 2.0 +_SAFE_SESSION_ID_RE = re.compile(r"\A[A-Za-z0-9_-]{1,64}\Z") + + +def _build_device_code_page(user_code: str, verification_url: str, status_url: str) -> str: + """Render the HTML page shown during Device Flow login. + + Yandex's ya.ru/device page does not pre-fill from query params and + strips them on redirect-to-login, so the only reliable way to show + the code is to host our own page in MA's webserver that displays + the code prominently and opens ya.ru/device in a new tab. + + Pattern copied from ``ma-provider-yandex-station/provider/auth.py``. + """ + import html # noqa: PLC0415 + + safe_code = html.escape(user_code) + safe_url = html.escape(verification_url, quote=True) + safe_status_url = json.dumps(status_url).replace(" + + + + Yandex Smart Home — Device Code + + + + +
+

Authorise Music Assistant for skill creation

+

Open the link below, log in to your Yandex account, and enter this code.

+
{safe_code}
+
+ +
+ Continue to Yandex +
+ + +""" + + +async def _default_authenticator( + *, + mass: MusicAssistant, + session_id: str, + timeout: float, +) -> AsyncIterator[aiohttp.ClientSession]: + """Real-world authentication path — runs Device Flow and yields a session. + + Serves an intermediate HTML page through MA's webserver so the user + sees the short ``user_code`` (Yandex's ya.ru/device does not pre-fill + from query params). The popup is opened via + :class:`AuthenticationHelper` using the frontend-provided + ``session_id`` — that's how MA's UI knows which popup session to + render and later close. + + Pattern copied from ``ma-provider-yandex-station/provider/auth.py``. + """ + from aiohttp import web # noqa: PLC0415 + from ya_passport_auth import ClientConfig, PassportClient # noqa: PLC0415 + from ya_passport_auth.config import DEFAULT_ALLOWED_HOSTS # noqa: PLC0415 + + from music_assistant.helpers.auth import AuthenticationHelper # noqa: PLC0415 + + if not _SAFE_SESSION_ID_RE.match(session_id): + msg = "invalid session_id for device authentication" + raise ValueError(msg) + + allowed = DEFAULT_ALLOWED_HOSTS | frozenset({"dialogs.yandex.ru"}) + config = ClientConfig(allowed_hosts=allowed) + + async with PassportClient.create(config=config) as client: + device_session = await client.start_device_login() + # Don't log user_code — it's a time-limited credential (grants + # Yandex sign-in for the device-flow window) and writing it to + # shared log backends would leak access. + _LOGGER.info( + "device flow started — verification_url=%s", + device_session.verification_url, + ) + + page_path = f"{_DEVICE_CODE_PAGE_PATH}/{session_id}" + status_path = f"{page_path}/status" + # MA frontend requires an absolute URL in signal_event(AUTH_SESSION, + # ...). The URL comes from mass.webserver.base_url which the user + # configures in Settings → Core → Webserver → Base URL. If they + # haven't touched it and MA is behind Docker/reverse-proxy, it may + # point at an unreachable internal address — the warning log below + # gives them the path so they can open it manually if the popup + # fails to load. + base_url = str(mass.webserver.base_url).rstrip("/") + status_url = f"{base_url}{status_path}" + page_url = f"{base_url}{page_path}" + state = {"value": "pending"} + + page_html = _build_device_code_page( + device_session.user_code, + device_session.verification_url, + status_url, + ) + + async def _serve_page(_request: web.Request) -> web.Response: + return web.Response( + text=page_html, + content_type="text/html", + charset="utf-8", + headers={ + "Cache-Control": "no-store", + "Pragma": "no-cache", + "Expires": "0", + }, + ) + + async def _serve_status(_request: web.Request) -> web.Response: + return web.json_response( + {"state": state["value"]}, + headers={"Cache-Control": "no-store"}, + ) + + mass.webserver.register_dynamic_route(page_path, _serve_page, "GET") + mass.webserver.register_dynamic_route(status_path, _serve_status, "GET") + _LOGGER.warning( + "auto-skill: device-code popup URL %s (path=%s) " + "— if the popup does not open or points at an unreachable " + "address, open the path directly in your browser (the page " + "displays the user_code) or fix Settings → Core → Webserver " + "→ Base URL", + page_url, + page_path, + ) + try: + async with AuthenticationHelper(mass, session_id) as auth_helper: + auth_helper.send_url(page_url) + try: + creds = await client.poll_device_until_confirmed( + device_session, + total_timeout=timeout, + poll_interval=_DEVICE_FLOW_POLL_INTERVAL, + ) + except asyncio.CancelledError: + raise + except Exception: + state["value"] = "failed" + await asyncio.sleep(_POST_AUTH_GRACE_SECONDS) + raise + state["value"] = "done" + await asyncio.sleep(_POST_AUTH_GRACE_SECONDS) + finally: + mass.webserver.unregister_dynamic_route(page_path, "GET") + mass.webserver.unregister_dynamic_route(status_path, "GET") + + await client.refresh_passport_cookies(creds.x_token) + yield client._session + + +def _build_authenticator_cm( + authenticator: Callable[..., AsyncIterator[aiohttp.ClientSession]], + *, + mass: MusicAssistant, + session_id: str, + timeout: float, +) -> Any: + """Wrap *authenticator* so it supports ``async with`` uniformly. + + The default implementation is a plain async generator, but callers + may inject an already-decorated ``@asynccontextmanager``. Re-wrapping + a CM factory with ``asynccontextmanager`` is *not* idempotent — the + outer wrapper would call ``__anext__`` on the inner CM object and + crash — so detect the CM result and pass it through unchanged. + """ + result = authenticator(mass=mass, session_id=session_id, timeout=timeout) + if hasattr(result, "__aenter__") and hasattr(result, "__aexit__"): + return result + + # Adapt the async iterator returned above into a proper context + # manager. We have to drive the existing iterator (not call the + # authenticator again) to avoid leaving a half-created generator + # unawaited and to preserve any work it already did (e.g. starting + # a Device Flow session). + @asynccontextmanager + async def _cm() -> AsyncIterator[aiohttp.ClientSession]: + session = await result.__anext__() + try: + yield session + finally: + try: + await result.__anext__() + except StopAsyncIteration: + pass + else: + msg = "authenticator yielded more than one session" + raise RuntimeError(msg) + + return _cm() + + +async def auto_create_skill( # noqa: PLR0913 + *, + mass: MusicAssistant, + connection_type: str, + skill_name: str, + artifacts: SkillCreationArtifacts, + cloud_instance_id: str, + direct_client_secret: str, + logo_bytes: bytes, + session_id: str, + progress_cb: Callable[[SkillCreationArtifacts], Awaitable[None]] | None = None, + authenticator: Callable[..., AsyncIterator[aiohttp.ClientSession]] | None = None, + creator_factory: Callable[[aiohttp.ClientSession], DialogsSkillCreator] | None = None, + timeout: float = DEVICE_FLOW_TIMEOUT_SECONDS, + developer_name: str = "Music Assistant user", +) -> SkillCreationArtifacts: + """End-to-end flow: Device Flow → passport cookies → skill pipeline. + + Resumes from ``artifacts.state`` — steps that already completed + (skill_id present, etc.) are skipped. On any exception, returns + artifacts with ``state=FAILED`` and a human-readable ``last_error`` + instead of re-raising, so the config-flow UI can render the message + without crashing. + + ``progress_cb`` is invoked after each successful step with the + updated artifacts; the caller uses it to persist state to MA config + so a subsequent retry resumes from the latest completed step. + + ``authenticator`` and ``creator_factory`` are injection points for + tests; production callers leave them as ``None`` to use the real + Device Flow and a real :class:`DialogsSkillCreator`. + """ + # Precondition failures surface unmodified (caller decides message). + check_preconditions( + connection_type=connection_type, + mass=mass, + cloud_instance_id=cloud_instance_id, + direct_client_secret=direct_client_secret, + ) + + auth_fn = authenticator or _default_authenticator + creator_fn = creator_factory or DialogsSkillCreator + + try: + async with _build_authenticator_cm( + auth_fn, mass=mass, session_id=session_id, timeout=timeout + ) as session: + creator = creator_fn(session) + return await _run_pipeline_with_recovery( + creator=creator, + artifacts=artifacts, + connection_type=connection_type, + skill_name=skill_name, + cloud_instance_id=cloud_instance_id, + direct_client_secret=direct_client_secret, + logo_bytes=logo_bytes, + mass=mass, + developer_name=developer_name, + progress_cb=progress_cb, + ) + except asyncio.CancelledError: + # Preserve cooperative cancellation — do not absorb into FAILED. + raise + except ValueError: + raise + except Exception as exc: + _LOGGER.exception("auto-create hit unexpected error") + return dataclasses.replace(artifacts, state=SkillCreationState.FAILED, last_error=repr(exc)) + + +async def _run_pipeline_with_recovery( + *, + creator: DialogsSkillCreator, + artifacts: SkillCreationArtifacts, + connection_type: str, + skill_name: str, + cloud_instance_id: str, + direct_client_secret: str, + logo_bytes: bytes, + mass: MusicAssistant, + developer_name: str, + progress_cb: Callable[[SkillCreationArtifacts], Awaitable[None]] | None, +) -> SkillCreationArtifacts: + """Fetch CSRF and run the pipeline, preserving partial state on failure. + + Holds a ``current`` reference that ``_execute_pipeline`` updates via + ``progress_cb``, so a mid-pipeline raise lets us surface whatever + progress was captured (skill_id / logo_id / oauth_app_id) as a + FAILED artifact instead of losing it. + """ + current = artifacts + + async def _track(a: SkillCreationArtifacts) -> None: + nonlocal current + current = a + if progress_cb is not None: + await progress_cb(a) + + try: + _LOGGER.info("auto-skill: fetching CSRF from dialogs.yandex.ru") + csrf = await creator.fetch_csrf() + _LOGGER.info("auto-skill: CSRF acquired, starting skill pipeline") + return await _execute_pipeline( + creator=creator, + csrf=csrf, + artifacts=artifacts, + connection_type=connection_type, + skill_name=skill_name, + cloud_instance_id=cloud_instance_id, + direct_client_secret=direct_client_secret, + logo_bytes=logo_bytes, + mass=mass, + developer_name=developer_name, + progress_cb=_track, + ) + except DialogsApiError as exc: + _LOGGER.warning("auto-create failed at %s: %s", exc.step, exc, exc_info=True) + return dataclasses.replace(current, state=SkillCreationState.FAILED, last_error=str(exc)) + + +async def _execute_pipeline( # noqa: PLR0913, PLR0915 + *, + creator: DialogsSkillCreator, + csrf: str, + artifacts: SkillCreationArtifacts, + connection_type: str, + skill_name: str, + cloud_instance_id: str, + direct_client_secret: str, + logo_bytes: bytes, + mass: MusicAssistant, + developer_name: str, + progress_cb: Callable[[SkillCreationArtifacts], Awaitable[None]] | None, +) -> SkillCreationArtifacts: + """Advance through states sequentially, skipping completed steps.""" + state = artifacts.state + + # -- Step 3: create app -- + if state in (SkillCreationState.NONE, SkillCreationState.FAILED): + _LOGGER.info("auto-skill: [1/5] creating skill app") + new_skill_id = await creator.create_app(csrf, skill_name) + artifacts = dataclasses.replace( + artifacts, + state=SkillCreationState.APP_CREATED, + skill_id=new_skill_id, + last_error=None, + ) + await _maybe_save(progress_cb, artifacts) + state = artifacts.state + + if artifacts.skill_id is None: + msg = "internal error: skill_id missing after create_app" + raise RuntimeError(msg) + skill_id: str = artifacts.skill_id + + # -- Step 4+5: upload logo and update draft (merged step) -- + if state == SkillCreationState.APP_CREATED: + logo_id = artifacts.logo_id + if logo_id is None: + _LOGGER.info("auto-skill: [2/5] uploading logo") + logo_id = await creator.upload_logo(csrf, skill_id, logo_bytes) + artifacts = dataclasses.replace(artifacts, logo_id=logo_id) + + backend_uri = derive_backend_uri(mass, connection_type) + draft = build_draft_payload( + connection_type=connection_type, + skill_name=skill_name, + backend_uri=backend_uri, + logo_id=logo_id, + developer_name=developer_name, + ) + _LOGGER.info("auto-skill: [3/5] updating draft with settings") + await creator.update_draft(csrf, skill_id, draft) + artifacts = dataclasses.replace(artifacts, state=SkillCreationState.DRAFT_UPDATED) + await _maybe_save(progress_cb, artifacts) + state = artifacts.state + + # -- Step 6: create OAuth app -- + if state == SkillCreationState.DRAFT_UPDATED: + client_id = derive_client_id(connection_type, cloud_instance_id) + client_secret = ( + CLOUD_SKILL_CLIENT_SECRET + if connection_type == CONNECTION_TYPE_CLOUD_PLUS + else direct_client_secret + ) + authorize_url, token_url = derive_auth_urls(mass, connection_type) + _LOGGER.info("auto-skill: [4/5] creating OAuth app + attaching") + oauth_app_id = await creator.create_oauth_app( + csrf, + name=skill_name, + client_id=client_id, + client_secret=client_secret, + authorize_url=authorize_url, + token_url=token_url, + refresh_url=token_url, + ) + artifacts = dataclasses.replace( + artifacts, + state=SkillCreationState.OAUTH_CREATED, + oauth_app_id=oauth_app_id, + ) + await _maybe_save(progress_cb, artifacts) + state = artifacts.state + + if artifacts.oauth_app_id is None: + msg = "internal error: oauth_app_id missing after create_oauth_app" + raise RuntimeError(msg) + oauth_app_id_str: str = artifacts.oauth_app_id + + # -- Step 7: attach OAuth app to skill -- + if state == SkillCreationState.OAUTH_CREATED: + await creator.attach_oauth(csrf, skill_id, oauth_app_id_str) + artifacts = dataclasses.replace(artifacts, state=SkillCreationState.OAUTH_ATTACHED) + await _maybe_save(progress_cb, artifacts) + state = artifacts.state + + # -- Step 8: publish -- + # Checkpoint state=DEPLOY_REQUESTED *before* calling request_deploy + # so a crash after the call reached Yandex but before we returned + # can skip straight to DONE on retry (Yandex accepts the idempotent + # re-deploy but we'd rather not re-drive the flow end-to-end). + if state == SkillCreationState.OAUTH_ATTACHED: + artifacts = dataclasses.replace(artifacts, state=SkillCreationState.DEPLOY_REQUESTED) + await _maybe_save(progress_cb, artifacts) + state = artifacts.state + + if state == SkillCreationState.DEPLOY_REQUESTED: + _LOGGER.info("auto-skill: [5/5] publishing skill") + await creator.request_deploy(csrf, skill_id) + artifacts = dataclasses.replace(artifacts, state=SkillCreationState.DONE) + await _maybe_save(progress_cb, artifacts) + + return artifacts + + +# Minimal 1x1 transparent PNG — used when the packaged logo is missing +# (e.g. during unit tests before the asset commit lands). Real installs +# pick up provider/auto_skill_logo.png instead. +_FALLBACK_LOGO_PNG = bytes.fromhex( + "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c489" + "0000000d49444154789c6300010000050001d0a0c9a30000000049454e44ae426082" +) + + +def load_default_logo_bytes() -> bytes: + """Return PNG bytes for the skill logo. + + Reads ``provider/auto_skill_logo.png`` if it exists; otherwise + returns a 1x1 transparent PNG so tests can run without the asset. + """ + path = Path(__file__).parent / "auto_skill_logo.png" + if path.is_file(): + return path.read_bytes() + return _FALLBACK_LOGO_PNG + + +async def _maybe_save( + progress_cb: Callable[[SkillCreationArtifacts], Awaitable[None]] | None, + artifacts: SkillCreationArtifacts, +) -> None: + """Call ``progress_cb`` if provided, swallowing any save errors.""" + if progress_cb is None: + return + try: + await progress_cb(artifacts) + except Exception: + _LOGGER.exception("progress_cb raised; continuing pipeline anyway") diff --git a/music_assistant/providers/yandex_smarthome/auto_skill_logo.png b/music_assistant/providers/yandex_smarthome/auto_skill_logo.png new file mode 100644 index 0000000000..86904a3613 Binary files /dev/null and b/music_assistant/providers/yandex_smarthome/auto_skill_logo.png differ diff --git a/music_assistant/providers/yandex_smarthome/auto_skill_state.py b/music_assistant/providers/yandex_smarthome/auto_skill_state.py new file mode 100644 index 0000000000..6f2b8e8b8e --- /dev/null +++ b/music_assistant/providers/yandex_smarthome/auto_skill_state.py @@ -0,0 +1,119 @@ +"""State model for experimental auto-create-skill feature. + +Tracks progress of the multi-step skill creation flow against +dialogs.yandex.ru so partial failures can be retried from the +last successful step rather than starting over. +""" + +from __future__ import annotations + +import dataclasses +import json +import logging +from dataclasses import dataclass +from enum import StrEnum + +_LOGGER = logging.getLogger(__name__) + +__all__ = [ + "SkillCreationArtifacts", + "SkillCreationState", + "dump_artifacts", + "load_artifacts", +] + + +class SkillCreationState(StrEnum): + """Progress marker for the skill-creation pipeline. + + Linear states advance through the 6 HAR-captured API calls. + ``FAILED`` replaces the stored linear state in the artifact; + failure details are kept separately in ``last_error``, while + captured artifact IDs (``skill_id`` / ``logo_id`` / ``oauth_app_id``) + stay intact so a retry can resume from the partial results. + """ + + NONE = "none" + APP_CREATED = "app_created" + DRAFT_UPDATED = "draft_updated" + OAUTH_CREATED = "oauth_created" + OAUTH_ATTACHED = "oauth_attached" + DEPLOY_REQUESTED = "deploy_requested" + DONE = "done" + FAILED = "failed" + + +@dataclass(frozen=True, slots=True) +class SkillCreationArtifacts: + """Persistent state for a skill-creation attempt. + + Stored as a JSON blob in the ``CONF_AUTO_CREATE_ARTIFACTS`` + config entry and round-tripped through every call to + ``get_config_entries``. + """ + + state: SkillCreationState = SkillCreationState.NONE + skill_id: str | None = None + logo_id: str | None = None + oauth_app_id: str | None = None + last_error: str | None = None + + +def dump_artifacts(artifacts: SkillCreationArtifacts) -> str: + """Serialise artifacts to a JSON string for config storage.""" + return json.dumps( + { + "state": artifacts.state.value, + "skill_id": artifacts.skill_id, + "logo_id": artifacts.logo_id, + "oauth_app_id": artifacts.oauth_app_id, + "last_error": artifacts.last_error, + }, + ensure_ascii=False, + ) + + +def load_artifacts(raw: str | None) -> SkillCreationArtifacts: + """Deserialise artifacts from a config-stored JSON string. + + Returns a fresh ``SkillCreationArtifacts`` on any parse error + or missing input — the feature is optional so config stays + usable even if the blob is corrupted. + """ + if not raw: + return SkillCreationArtifacts() + try: + data = json.loads(raw) + except (ValueError, TypeError): + _LOGGER.warning("auto-skill artifacts corrupt, resetting") + return SkillCreationArtifacts() + if not isinstance(data, dict): + return SkillCreationArtifacts() + + try: + state = SkillCreationState(str(data.get("state", SkillCreationState.NONE.value))) + except ValueError: + state = SkillCreationState.NONE + + def _opt_str(key: str) -> str | None: + value = data.get(key) + if value is None: + return None + return str(value) if value else None + + return SkillCreationArtifacts( + state=state, + skill_id=_opt_str("skill_id"), + logo_id=_opt_str("logo_id"), + oauth_app_id=_opt_str("oauth_app_id"), + last_error=_opt_str("last_error"), + ) + + +def mark_failed(artifacts: SkillCreationArtifacts, error: str) -> SkillCreationArtifacts: + """Return a copy of *artifacts* flipped to ``FAILED`` with an error.""" + return dataclasses.replace( + artifacts, + state=SkillCreationState.FAILED, + last_error=error, + ) diff --git a/music_assistant/providers/yandex_smarthome/auto_skill_ui.py b/music_assistant/providers/yandex_smarthome/auto_skill_ui.py new file mode 100644 index 0000000000..735b504128 --- /dev/null +++ b/music_assistant/providers/yandex_smarthome/auto_skill_ui.py @@ -0,0 +1,838 @@ +"""ConfigEntry builders for the Yandex Smart Home provider. + +Builds the numbered-step config form per connection type: + +* ``cloud_plus`` → 3 steps: Register cloud → Create skill → Link via OTP. +* ``direct`` → 1 step: Create skill (skill is linked by Yandex Dialogs + account-linking UI, no OTP). +* ``cloud`` → 2 steps: Register → Link via OTP (unchanged). + +Each step hides until the previous completes, so the user always sees +the single next action they need to take. + +Kept separate from ``__init__.py`` so the long field list doesn't bloat +``get_config_entries`` and so it can be unit-tested in isolation from +the network-facing parts of the feature. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from music_assistant_models.config_entries import ConfigEntry +from music_assistant_models.enums import ConfigEntryType + +from .auto_skill_state import SkillCreationArtifacts, SkillCreationState +from .constants import ( + CLOUD_OAUTH_AUTHORIZE_URL, + CLOUD_OAUTH_TOKEN_URL, + CLOUD_SKILL_CLIENT_ID_TEMPLATE, + CLOUD_SKILL_CLIENT_SECRET, + CLOUD_SKILL_WEBHOOK_TEMPLATE, + CONF_ACTION_AUTO_CREATE, + CONF_ACTION_GET_OTP, + CONF_ACTION_REGISTER, + CONF_AUTO_CREATE_ARTIFACTS, + CONF_AUTO_CREATE_SESSION_ID, + CONF_CONNECTION_TYPE, + CONF_DIRECT_CLIENT_SECRET, + CONF_SKILL_ID, + CONF_SKILL_TOKEN, + CONNECTION_TYPE_CLOUD, + CONNECTION_TYPE_CLOUD_PLUS, + CONNECTION_TYPE_DIRECT, + DIRECT_API_BASE_PATH, + DIRECT_AUTH_BASE_PATH, + DIRECT_OAUTH_CLIENT_ID, + YANDEX_DIALOGS_DEVELOPER_URL, + YANDEX_OAUTH_URL, +) + +if TYPE_CHECKING: + from collections.abc import Sequence + +__all__ = [ + "AUTO_CREATE_CATEGORY", + "auto_create_entries", + "build_cloud_plus_entries", + "build_direct_entries", + "should_show_button", +] + +AUTO_CREATE_CATEGORY = "Auto-create skill" + +# Category names are used as visual group headers in the MA UI. Numbered +# so they render in order and users see the flow as a sequence. +_CAT_STEP_1_REGISTER = "Step 1 — Register cloud instance" +_CAT_STEP_2_CREATE = "Step 2 — Create Smart Home skill" +_CAT_STEP_3_LINK = "Step 3 — Link skill to Yandex" +# Direct mode has only one step — no cloud registration, no OTP linking. +_CAT_STEP_DIRECT_CREATE = "Create Smart Home skill" + + +def _status_label(state: SkillCreationState, last_error: str | None) -> str: + """Human-readable status line shown in the UI.""" + if state == SkillCreationState.DONE: + return ( + "✅ Skill created and published. Now get the OAuth token " + "(link below) and paste it into 'Skill OAuth Token'." + ) + if state == SkillCreationState.FAILED: + err = last_error or "unknown error" + return ( + f"❌ Creation failed: {err}\n" + f"Press '{_action_label(state)}' to try again, or fill in " + "Skill ID / Skill OAuth Token manually below." + ) + if state == SkillCreationState.NONE: + return "Ready to create skill. Press the button below to start." + # Any partial state — resume is possible. + return ( + f"Partial progress saved ({state.value}). " + f"Press '{_action_label(state)}' to finish, or fill Skill ID manually." + ) + + +def _action_label(state: SkillCreationState) -> str: + if state == SkillCreationState.NONE: + return "Create skill automatically" + if state == SkillCreationState.FAILED: + return "Retry" + return "Retry from last step" + + +def should_show_button( + *, + connection_type: str, + state: SkillCreationState, + cloud_instance_id: str, + base_url: str, +) -> bool: + """Return True iff the auto-create action button is actionable now. + + Hides the button when: + - Mode is plain ``cloud`` (no custom skill exists there). + - Skill creation already reached DONE. + - cloud_plus is selected but no cloud instance has been registered. + - direct is selected but MA base_url is not HTTPS. + """ + if connection_type == CONNECTION_TYPE_CLOUD: + return False + if state == SkillCreationState.DONE: + return False + if connection_type == CONNECTION_TYPE_CLOUD_PLUS and not cloud_instance_id: + return False + return not (connection_type == CONNECTION_TYPE_DIRECT and not base_url.startswith("https://")) + + +def auto_create_entries( + *, + connection_type: str, + artifacts: SkillCreationArtifacts, + cloud_instance_id: str, + base_url: str, + session_id: str | None, + user_code: str | None, + verification_url: str | None, + existing_artifacts_raw: str | None, +) -> Sequence[ConfigEntry]: + """Build the auto-create section of the config form. + + Empty list for ``cloud`` mode — the feature is meaningless without + a custom skill. + """ + if connection_type == CONNECTION_TYPE_CLOUD: + return () + + entries: list[ConfigEntry] = [] + + # Device-flow user code — shown when the flow obtained one this round. + if user_code: + entries.append( + ConfigEntry( + key="auto_create_user_code", + type=ConfigEntryType.STRING, + label="Device code for ya.ru/device", + description=( + "Open the URL below in your browser, log in to your " + "Yandex account, and enter this code." + ), + value=user_code, + required=False, + help_link=verification_url or "https://ya.ru/device", + depends_on=CONF_CONNECTION_TYPE, + depends_on_value_not=CONNECTION_TYPE_CLOUD, + category=AUTO_CREATE_CATEGORY, + ) + ) + + # Status label — dynamic based on current artifact state. + entries.append( + ConfigEntry( + key="label_auto_create_status", + type=ConfigEntryType.LABEL, + label=_status_label(artifacts.state, artifacts.last_error), + depends_on=CONF_CONNECTION_TYPE, + depends_on_value_not=CONNECTION_TYPE_CLOUD, + category=AUTO_CREATE_CATEGORY, + ) + ) + + # The action button — hidden in states where it can't run. + show_button = should_show_button( + connection_type=connection_type, + state=artifacts.state, + cloud_instance_id=cloud_instance_id, + base_url=base_url, + ) + entries.append( + ConfigEntry( + key=CONF_ACTION_AUTO_CREATE, + type=ConfigEntryType.ACTION, + label=_action_label(artifacts.state), + description=( + "Runs the Yandex Device Flow login, then creates and " + "publishes the private Smart Home skill. Takes ~30 seconds " + "after you enter the code." + ), + action=CONF_ACTION_AUTO_CREATE, + action_label=_action_label(artifacts.state), + hidden=not show_button, + depends_on=CONF_CONNECTION_TYPE, + depends_on_value_not=CONNECTION_TYPE_CLOUD, + category=AUTO_CREATE_CATEGORY, + ) + ) + + entries.extend(_hidden_state_entries(existing_artifacts_raw, session_id)) + return entries + + +def _hidden_state_entries( + existing_artifacts_raw: str | None, session_id: str | None +) -> list[ConfigEntry]: + """Round-trip the artifact blob and session id through the config form.""" + return [ + ConfigEntry( + key=CONF_AUTO_CREATE_ARTIFACTS, + type=ConfigEntryType.STRING, + label="Auto-create artifacts (internal)", + hidden=True, + required=False, + value=existing_artifacts_raw, + ), + ConfigEntry( + key=CONF_AUTO_CREATE_SESSION_ID, + type=ConfigEntryType.STRING, + label="Auto-create session id (internal)", + hidden=True, + required=False, + value=session_id, + ), + ] + + +# --------------------------------------------------------------------------- +# Cloud Plus step-flow +# --------------------------------------------------------------------------- + + +def build_cloud_plus_entries( # noqa: PLR0913 + *, + otp_code: str | None, + is_registered: bool, + cloud_instance_id: str, + artifacts: SkillCreationArtifacts, + session_id: str | None, + user_code: str | None, + verification_url: str | None, + existing_artifacts_raw: str | None, + base_url: str, + skill_id: str = "", + skill_token_set: bool = False, +) -> list[ConfigEntry]: + """Return the cloud_plus-mode config entries as three visible steps. + + Step 1 (Register) — always visible. + Step 2 (Create skill) — visible once cloud instance is registered. + Step 3 (Link via OTP) — visible once the skill (id + token) is set. + """ + skill_id_set = bool(skill_id) + fully_configured = skill_id_set and skill_token_set + + entries: list[ConfigEntry] = [] + entries.extend(_step1_register_entries(is_registered, cloud_instance_id)) + entries.extend( + _step2_create_skill_entries( + is_registered=is_registered, + cloud_instance_id=cloud_instance_id, + artifacts=artifacts, + user_code=user_code, + verification_url=verification_url, + base_url=base_url, + skill_id=skill_id, + fully_configured=fully_configured, + ) + ) + entries.extend( + _step3_link_entries( + is_registered=is_registered, + skill_id_set=skill_id_set, + otp_code=otp_code, + ) + ) + entries.extend(_hidden_state_entries(existing_artifacts_raw, session_id)) + return entries + + +def _step1_register_entries(is_registered: bool, cloud_instance_id: str) -> list[ConfigEntry]: + """Step 1 — yaha-cloud.ru instance registration.""" + status_text = ( + f"✅ Cloud instance registered (id: {cloud_instance_id})." + if is_registered + else ( + "Click 'Register with cloud' to create a yaha-cloud.ru relay " + "instance. This is free and takes a second." + ) + ) + return [ + ConfigEntry( + key="label_step1_status", + type=ConfigEntryType.LABEL, + label=status_text, + depends_on=CONF_CONNECTION_TYPE, + depends_on_value=CONNECTION_TYPE_CLOUD_PLUS, + category=_CAT_STEP_1_REGISTER, + ), + ConfigEntry( + key=CONF_ACTION_REGISTER, + type=ConfigEntryType.ACTION, + label="Register cloud instance", + description="Registers a new instance on yaha-cloud.ru relay.", + action=CONF_ACTION_REGISTER, + action_label="Register with cloud", + hidden=is_registered, + # No depends_on — see note above action_auto_create. + category=_CAT_STEP_1_REGISTER, + ), + ] + + +def _create_skill_step_entries( + *, + connection_type: str, + category: str, + cloud_instance_id: str, + artifacts: SkillCreationArtifacts, + user_code: str | None, + verification_url: str | None, + base_url: str, + direct_client_secret: str = "", + skill_id: str = "", + fully_configured: bool = False, +) -> list[ConfigEntry]: + """Shared builder for the Create-Skill step. + + Used by both cloud_plus Step 2 and direct single-step mode. + + Skill ID / Skill OAuth Token are shown after DONE (happy path) or + FAILED (so the user can finish manually). FAILED additionally shows + manual copy-paste fields (Backend URL / Client ID / Secret / Auth + URLs / Dialogs console link) so the user can create the skill by + hand in Yandex.Dialogs without leaving the form. + """ + entries: list[ConfigEntry] = [] + + if user_code: + entries.append( + ConfigEntry( + key="auto_create_user_code", + type=ConfigEntryType.STRING, + label="Device code for ya.ru/device", + description=( + "Open the URL below, log in to your Yandex account, and enter this code." + ), + value=user_code, + required=False, + help_link=verification_url or "https://ya.ru/device", + depends_on=CONF_CONNECTION_TYPE, + depends_on_value=connection_type, + category=category, + ) + ) + + entries.append( + ConfigEntry( + key="label_create_skill_status", + type=ConfigEntryType.LABEL, + label=_status_label(artifacts.state, artifacts.last_error), + depends_on=CONF_CONNECTION_TYPE, + depends_on_value=connection_type, + category=category, + ) + ) + + # direct mode prerequisite: Yandex Dialogs only accepts HTTPS + # backends. If MA's Base URL is not HTTPS, show the user what's + # wrong (and what URL we'd have used) so they can fix it in + # Settings → Core → Webserver → Base URL. + direct_https_missing = connection_type == CONNECTION_TYPE_DIRECT and not base_url.startswith( + "https://" + ) + if direct_https_missing: + entries.append( + ConfigEntry( + key="label_direct_https_warning", + type=ConfigEntryType.LABEL, + label=( + f"⚠️ MA's Base URL is {base_url or ''}. " + "Direct mode requires a **publicly reachable HTTPS URL** — " + "Yandex refuses to talk to a non-HTTPS backend. " + "Set a reverse proxy with a real certificate and " + "update Settings → Core → Webserver → Base URL, then " + "reopen these settings." + ), + depends_on=CONF_CONNECTION_TYPE, + depends_on_value=connection_type, + category=category, + ) + ) + entries.append( + ConfigEntry( + key="current_ma_base_url", + type=ConfigEntryType.STRING, + label="Current MA Base URL (read-only)", + description=( + "For reference. Change it in Settings → Core → Webserver " + "→ Base URL; provider doesn't own this setting." + ), + required=False, + default_value=base_url or "", + depends_on=CONF_CONNECTION_TYPE, + depends_on_value=connection_type, + category=category, + ) + ) + + show_button = should_show_button( + connection_type=connection_type, + state=artifacts.state, + cloud_instance_id=cloud_instance_id, + base_url=base_url, + ) + entries.append( + ConfigEntry( + key=CONF_ACTION_AUTO_CREATE, + type=ConfigEntryType.ACTION, + label=_action_label(artifacts.state), + description=( + "Runs the Yandex Device Flow login, then creates and " + "publishes the private Smart Home skill." + ), + action=CONF_ACTION_AUTO_CREATE, + action_label=_action_label(artifacts.state), + hidden=not show_button, + # No depends_on — MA disables actions with unsaved-dependency + # fields until the user clicks Save, which breaks the flow. + # Visibility is already correctly gated via `hidden`. + category=category, + ) + ) + + # Manual-fallback copy-paste fields — always emitted so advanced + # users can edit them, but on any state other than FAILED they're + # marked ``advanced=True`` so default view stays clean. On FAILED + # they show up unconditionally (auto-fallback UX). + entries.extend( + _manual_fallback_entries( + connection_type=connection_type, + category=category, + cloud_instance_id=cloud_instance_id, + base_url=base_url, + direct_client_secret=direct_client_secret, + advanced=artifacts.state != SkillCreationState.FAILED, + ) + ) + + # Skill ID / Skill OAuth Token / OAuth URL — the actual fields the + # provider needs at runtime. Auto-shown once auto-create reached + # DONE or FAILED; hidden under Advanced once the user has fully + # configured them (so a clean default view stays clean after setup). + token_fields_advanced = fully_configured or artifacts.state not in ( + SkillCreationState.DONE, + SkillCreationState.FAILED, + ) + entries.extend( + [ + ConfigEntry( + key="oauth_url", + type=ConfigEntryType.STRING, + label="OAuth URL (open to get token)", + description=( + "Open this URL in your browser, approve, and copy the " + "access_token from the resulting URL into the field below." + ), + required=False, + default_value=YANDEX_OAUTH_URL, + help_link=YANDEX_OAUTH_URL, + advanced=token_fields_advanced, + depends_on=CONF_CONNECTION_TYPE, + depends_on_value=connection_type, + category=category, + ), + ConfigEntry( + key=CONF_SKILL_TOKEN, + type=ConfigEntryType.SECURE_STRING, + label="Skill OAuth Token", + description="Paste the OAuth token obtained from the URL above.", + required=False, + advanced=token_fields_advanced, + depends_on=CONF_CONNECTION_TYPE, + depends_on_value=connection_type, + category=category, + ), + ConfigEntry( + key=CONF_SKILL_ID, + type=ConfigEntryType.STRING, + label="Skill ID", + description=( + "UUID of your private Smart Home skill. Set automatically " + "when auto-create succeeds; you can paste it manually if " + "you created the skill by hand." + ), + required=False, + advanced=token_fields_advanced, + depends_on=CONF_CONNECTION_TYPE, + depends_on_value=connection_type, + category=category, + ), + ] + ) + + # Once setup is complete, replace the cluster of edit fields with + # a single non-editable link to the skill in Yandex.Dialogs so the + # user can quickly open it (the fields are still available under + # Advanced if they need to re-edit). + if fully_configured and skill_id: + skill_url = f"https://dialogs.yandex.ru/developer/skills/{skill_id}/" + entries.append( + ConfigEntry( + key="skill_dialogs_link", + type=ConfigEntryType.STRING, + label="Open skill in Yandex.Dialogs", + description="Click the link to open the skill's page.", + required=False, + default_value=skill_url, + help_link=skill_url, + depends_on=CONF_CONNECTION_TYPE, + depends_on_value=connection_type, + category=category, + ) + ) + + return entries + + +def _manual_fallback_entries( + *, + connection_type: str, + category: str, + cloud_instance_id: str, + base_url: str, + direct_client_secret: str, + advanced: bool, +) -> list[ConfigEntry]: + """Copy-paste fields for creating the skill by hand. + + ``advanced=True`` → hidden from the default view, shown when the + user toggles Advanced. Used when auto-create is expected to work + but power users still want to see/edit everything. + + ``advanced=False`` → visible unconditionally. Used on FAILED state + so the user has everything they need to finish manually without + needing to click Advanced. + """ + if connection_type == CONNECTION_TYPE_CLOUD_PLUS: + # The cloud_plus Client ID embeds the yaha-cloud instance UUID, + # which doesn't exist until the user registers. Suppress the + # manual block entirely before registration rather than render + # an invalid ``yandex_smart_home:`` Client ID that would lead + # someone to create a broken skill. + if not cloud_instance_id: + return [] + backend_uri = CLOUD_SKILL_WEBHOOK_TEMPLATE + client_id = CLOUD_SKILL_CLIENT_ID_TEMPLATE.format(instance_id=cloud_instance_id) + client_secret = CLOUD_SKILL_CLIENT_SECRET + auth_url = CLOUD_OAUTH_AUTHORIZE_URL + token_url = CLOUD_OAUTH_TOKEN_URL + elif connection_type == CONNECTION_TYPE_DIRECT: + base = base_url.rstrip("/") or "https://" + backend_uri = f"{base}{DIRECT_API_BASE_PATH}" + client_id = DIRECT_OAUTH_CLIENT_ID + client_secret = direct_client_secret or "(auto-generated on save)" + auth_url = f"{base}{DIRECT_AUTH_BASE_PATH}/authorize" + token_url = f"{base}{DIRECT_AUTH_BASE_PATH}/token" + else: + return [] + + label_text = ( + "Auto-create failed — you can create the skill by hand instead. " + "Open Yandex.Dialogs (link below), create a private Smart Home " + "skill, paste the values below into the skill's Basic info and " + "Account linking tabs, then put the skill UUID in the Skill ID " + "field below." + if not advanced + else ( + "Manual setup values — copy these into Yandex.Dialogs if you " + "prefer to create the skill by hand, or want to verify what " + "auto-create used." + ) + ) + + # For direct mode, also surface the hidden generated secret as an + # editable field so user can copy it. + extra: list[ConfigEntry] = [] + if connection_type == CONNECTION_TYPE_DIRECT and direct_client_secret: + extra.append( + ConfigEntry( + key=CONF_DIRECT_CLIENT_SECRET, + type=ConfigEntryType.SECURE_STRING, + label="Client Secret (→ Account linking)", + description="Copy to 'Account linking' → 'Client secret' field.", + required=False, + default_value=direct_client_secret, + advanced=advanced, + depends_on=CONF_CONNECTION_TYPE, + depends_on_value=connection_type, + category=category, + ) + ) + + return [ + ConfigEntry( + key="manual_fallback_label", + type=ConfigEntryType.LABEL, + label=label_text, + advanced=advanced, + depends_on=CONF_CONNECTION_TYPE, + depends_on_value=connection_type, + category=category, + ), + ConfigEntry( + key="manual_dialogs_url", + type=ConfigEntryType.STRING, + label="Yandex.Dialogs Console", + required=False, + default_value=YANDEX_DIALOGS_DEVELOPER_URL, + help_link=YANDEX_DIALOGS_DEVELOPER_URL, + advanced=advanced, + depends_on=CONF_CONNECTION_TYPE, + depends_on_value=connection_type, + category=category, + ), + ConfigEntry( + key="manual_backend_url", + type=ConfigEntryType.STRING, + label="Backend URL (→ Basic info)", + description="Copy to 'Basic info' → 'Backend URL' in your skill.", + required=False, + # Reference-only fields use default_value so MA UI renders + # the text without storing it as a mutable config value + # (``value=`` was returning empty on first render before the + # user clicked Save). + default_value=backend_uri, + advanced=advanced, + depends_on=CONF_CONNECTION_TYPE, + depends_on_value=connection_type, + category=category, + ), + ConfigEntry( + key="manual_client_id", + type=ConfigEntryType.STRING, + label="Client ID (→ Account linking)", + description="Copy to 'Account linking' → 'Client identifier' field.", + required=False, + default_value=client_id, + advanced=advanced, + depends_on=CONF_CONNECTION_TYPE, + depends_on_value=connection_type, + category=category, + ), + *extra, + # manual_client_secret is a plain STRING so it's readable for + # copy-paste in cloud_plus (where the value is the literal + # "secret"). For direct mode we skip it: the real per-install + # UUID is surfaced via the SECURE_STRING CONF_DIRECT_CLIENT_SECRET + # entry in ``extra`` above, and showing the same value in a + # second unmasked STRING would leak it. + *( + [ + ConfigEntry( + key="manual_client_secret", + type=ConfigEntryType.STRING, + label="Client Secret value (for reference)", + description=("Copy this string into 'Account linking' → 'Client secret'."), + required=False, + default_value=client_secret, + advanced=advanced, + depends_on=CONF_CONNECTION_TYPE, + depends_on_value=connection_type, + category=category, + ) + ] + if connection_type != CONNECTION_TYPE_DIRECT + else [] + ), + ConfigEntry( + key="manual_auth_url", + type=ConfigEntryType.STRING, + label="Authorization URL (→ Account linking)", + required=False, + default_value=auth_url, + advanced=advanced, + depends_on=CONF_CONNECTION_TYPE, + depends_on_value=connection_type, + category=category, + ), + ConfigEntry( + key="manual_token_url", + type=ConfigEntryType.STRING, + label="Token URL (→ Account linking, both fields)", + description="Paste into BOTH 'Token endpoint' and 'Refresh token URL'.", + required=False, + default_value=token_url, + advanced=advanced, + depends_on=CONF_CONNECTION_TYPE, + depends_on_value=connection_type, + category=category, + ), + ] + + +def _step2_create_skill_entries( + *, + is_registered: bool, # noqa: ARG001 — kept for call-site symmetry + cloud_instance_id: str, + artifacts: SkillCreationArtifacts, + user_code: str | None, + verification_url: str | None, + base_url: str, + skill_id: str = "", + fully_configured: bool = False, +) -> list[ConfigEntry]: + """cloud_plus Step 2 — always emitted. + + The Create-Skill action button self-hides via ``should_show_button`` + when no cloud instance exists yet, but the section's other fields + (status label + advanced manual-setup references) stay visible so + power users can see everything under Advanced without first going + through the register step. + """ + return _create_skill_step_entries( + connection_type=CONNECTION_TYPE_CLOUD_PLUS, + category=_CAT_STEP_2_CREATE, + cloud_instance_id=cloud_instance_id, + artifacts=artifacts, + user_code=user_code, + verification_url=verification_url, + base_url=base_url, + skill_id=skill_id, + fully_configured=fully_configured, + ) + + +def _step3_link_entries( + *, is_registered: bool, skill_id_set: bool, otp_code: str | None +) -> list[ConfigEntry]: + """Step 3 — get an OTP from the cloud and enter it in the Yandex app. + + Hidden until both Step 1 (cloud registration) and Step 2 (skill + created — ``skill_id_set``) are done: OTP linking only makes sense + once the private skill exists in Yandex.Dialogs for the user to + link against in the Yandex app. + """ + if not is_registered or not skill_id_set: + return [] + + # Banner priority: fresh OTP > linked (skill configured). + if otp_code: + banner_text = f"Enter this OTP in the Yandex app: {otp_code}" + else: + banner_text = ( + "✅ Skill configured. Press 'Get OTP code' to link it with your " + "Yandex account (or re-link if you ever unlinked it)." + ) + + entries: list[ConfigEntry] = [ + ConfigEntry( + key="label_step3_status", + type=ConfigEntryType.LABEL, + label=banner_text, + depends_on=CONF_CONNECTION_TYPE, + depends_on_value=CONNECTION_TYPE_CLOUD_PLUS, + category=_CAT_STEP_3_LINK, + ), + ConfigEntry( + key="otp_code", + type=ConfigEntryType.STRING, + label="OTP Code", + description="Copy this code and enter it in the Yandex app.", + required=False, + value=otp_code, + hidden=not otp_code, + depends_on=CONF_CONNECTION_TYPE, + depends_on_value=CONNECTION_TYPE_CLOUD_PLUS, + category=_CAT_STEP_3_LINK, + ), + ConfigEntry( + key=CONF_ACTION_GET_OTP, + type=ConfigEntryType.ACTION, + label="Get OTP code", + description="Get a fresh one-time password to link with Yandex.", + action=CONF_ACTION_GET_OTP, + action_label="Get OTP code", + # No depends_on — see note above action_auto_create. + category=_CAT_STEP_3_LINK, + ), + ] + return entries + + +# --------------------------------------------------------------------------- +# Direct-mode flow (auto-create only; no cloud registration, no OTP) +# --------------------------------------------------------------------------- + + +def build_direct_entries( + *, + artifacts: SkillCreationArtifacts, + session_id: str | None, + user_code: str | None, + verification_url: str | None, + existing_artifacts_raw: str | None, + base_url: str, + direct_client_secret: str = "", + skill_id: str = "", + skill_token_set: bool = False, +) -> list[ConfigEntry]: + """Return the direct-mode config entries as a single Create-Skill step. + + direct mode has no yaha-cloud registration (Step 1) and no OTP + linking (Step 3) — Yandex Dialogs' account-linking UI handles that + once the skill exists. + """ + fully_configured = bool(skill_id) and skill_token_set + entries = _create_skill_step_entries( + connection_type=CONNECTION_TYPE_DIRECT, + category=_CAT_STEP_DIRECT_CREATE, + cloud_instance_id="", + artifacts=artifacts, + user_code=user_code, + verification_url=verification_url, + base_url=base_url, + direct_client_secret=direct_client_secret, + skill_id=skill_id, + fully_configured=fully_configured, + ) + entries.extend(_hidden_state_entries(existing_artifacts_raw, session_id)) + return entries diff --git a/music_assistant/providers/yandex_smarthome/constants.py b/music_assistant/providers/yandex_smarthome/constants.py index 3f70005dab..9df04d3ea8 100644 --- a/music_assistant/providers/yandex_smarthome/constants.py +++ b/music_assistant/providers/yandex_smarthome/constants.py @@ -14,11 +14,16 @@ CONF_SKILL_TOKEN = "skill_token" CONF_EXPOSED_PLAYERS = "exposed_players" +# Auto-create-skill feature state (round-trips through the config form) +CONF_AUTO_CREATE_ARTIFACTS = "auto_create_artifacts" +CONF_AUTO_CREATE_SESSION_ID = "auto_create_session_id" + # --------------------------------------------------------------------------- # Config actions # --------------------------------------------------------------------------- CONF_ACTION_REGISTER = "register_cloud" CONF_ACTION_GET_OTP = "get_otp" +CONF_ACTION_AUTO_CREATE = "auto_create_skill" # --------------------------------------------------------------------------- # Connection types diff --git a/tests/providers/yandex_smarthome/__snapshots__/test_auto_skill.ambr b/tests/providers/yandex_smarthome/__snapshots__/test_auto_skill.ambr new file mode 100644 index 0000000000..52f586be00 --- /dev/null +++ b/tests/providers/yandex_smarthome/__snapshots__/test_auto_skill.ambr @@ -0,0 +1,121 @@ +# serializer version: 1 +# name: TestBuildDraftPayload.test_cloud_plus_snapshot + dict({ + 'backendSettings': dict({ + 'backendType': 'webhook', + 'functionId': '', + 'uri': 'https://yaha-cloud.ru/api/yandex_smart_home', + }), + 'channel': 'smartHome', + 'enableAllAvailableRegions': True, + 'hideInStore': False, + 'isTrustedSmartHomeSkill': False, + 'logo2': None, + 'logoId': 'be043706-a868-4999-83c8-f17bbd60745d', + 'name': 'Music Assistant', + 'noteForModerator': '', + 'oauthAppId': None, + 'publishingSettings': dict({ + 'brandVerificationWebsite': '', + 'category': 'smart_home', + 'developerName': 'alice', + 'email': '', + 'multilingualSettings': dict({ + 'ru': dict({ + 'externalSettingsDescription': 'Music Assistant', + 'name': 'Music Assistant', + 'secondaryTitle': '', + 'supportedUnitsDescription': 'Music Assistant', + }), + }), + 'secondaryTitle': '', + 'smartHome': dict({ + 'deepLinks': dict({ + 'android': dict({ + 'url': '', + }), + 'ios': dict({ + 'fallbackUrl': '', + 'url': '', + }), + }), + }), + }), + 'selectedRegions': list([ + ]), + 'skillAccess': 'private', + 'voice': 'shitova.us', + }) +# --- +# name: TestBuildDraftPayload.test_direct_snapshot + dict({ + 'backendSettings': dict({ + 'backendType': 'webhook', + 'functionId': '', + 'uri': 'https://ma.example.com/api/yandex_smarthome/v1.0', + }), + 'channel': 'smartHome', + 'enableAllAvailableRegions': True, + 'hideInStore': False, + 'isTrustedSmartHomeSkill': False, + 'logo2': None, + 'logoId': None, + 'name': 'Music Assistant', + 'noteForModerator': '', + 'oauthAppId': None, + 'publishingSettings': dict({ + 'brandVerificationWebsite': '', + 'category': 'smart_home', + 'developerName': 'alice', + 'email': '', + 'multilingualSettings': dict({ + 'ru': dict({ + 'externalSettingsDescription': 'Music Assistant', + 'name': 'Music Assistant', + 'secondaryTitle': '', + 'supportedUnitsDescription': 'Music Assistant', + }), + }), + 'secondaryTitle': '', + 'smartHome': dict({ + 'deepLinks': dict({ + 'android': dict({ + 'url': '', + }), + 'ios': dict({ + 'fallbackUrl': '', + 'url': '', + }), + }), + }), + }), + 'selectedRegions': list([ + ]), + 'skillAccess': 'private', + 'voice': 'shitova.us', + }) +# --- +# name: TestBuildOAuthAppPayload.test_cloud_plus_snapshot + dict({ + 'authorizationUrl': 'https://yaha-cloud.ru/oauth/authorize', + 'clientId': 'yandex_smart_home:abc123', + 'clientSecret': 'secret', + 'name': 'Music Assistant', + 'refreshTokenUrl': 'https://yaha-cloud.ru/oauth/token', + 'scope': '', + 'tokenUrl': 'https://yaha-cloud.ru/oauth/token', + 'yandexClientId': '', + }) +# --- +# name: TestBuildOAuthAppPayload.test_direct_snapshot + dict({ + 'authorizationUrl': 'https://ma.example.com/api/yandex_smarthome/auth/authorize', + 'clientId': 'https://social.yandex.net/', + 'clientSecret': 'abc123deadbeef', + 'name': 'Music Assistant', + 'refreshTokenUrl': 'https://ma.example.com/api/yandex_smarthome/auth/token', + 'scope': '', + 'tokenUrl': 'https://ma.example.com/api/yandex_smarthome/auth/token', + 'yandexClientId': '', + }) +# --- diff --git a/tests/providers/yandex_smarthome/test_auto_skill.py b/tests/providers/yandex_smarthome/test_auto_skill.py new file mode 100644 index 0000000000..a409a0b26b --- /dev/null +++ b/tests/providers/yandex_smarthome/test_auto_skill.py @@ -0,0 +1,1004 @@ +"""Tests for provider/auto_skill.py — DialogsSkillCreator low-level client.""" + +from __future__ import annotations + +import json +from contextlib import asynccontextmanager +from typing import TYPE_CHECKING, Any +from unittest.mock import AsyncMock, MagicMock + +if TYPE_CHECKING: + from collections.abc import AsyncIterator + +import aiohttp +import pytest + +from music_assistant.providers.yandex_smarthome.auto_skill import ( + DIALOGS_API_BASE, + DIALOGS_DEV_HTML_URL, + DialogsApiError, + DialogsCsrfError, + DialogsDuplicateSkillError, + DialogsSkillCreator, + auto_create_skill, + build_draft_payload, + build_oauth_app_payload, + check_preconditions, + derive_auth_urls, + derive_backend_uri, + derive_client_id, + load_default_logo_bytes, +) +from music_assistant.providers.yandex_smarthome.auto_skill_state import ( + SkillCreationArtifacts, + SkillCreationState, +) +from music_assistant.providers.yandex_smarthome.constants import ( + CONNECTION_TYPE_CLOUD, + CONNECTION_TYPE_CLOUD_PLUS, + CONNECTION_TYPE_DIRECT, +) + +# --------------------------------------------------------------------------- +# Test helpers: mock aiohttp session matching existing test_cloud.py pattern +# --------------------------------------------------------------------------- + + +def _mock_response(*, status: int = 200, body_text: str = "", body_json: Any = None) -> AsyncMock: + """Build a mock aiohttp.ClientResponse. + + If *body_json* is given, ``text()`` returns its JSON-encoded form; + otherwise ``body_text`` is used verbatim. Also wires up + ``content.iter_chunked(size)`` as a single-chunk async iterator over + ``body_text.encode()`` so ``fetch_csrf``'s streaming reader works. + """ + resp = AsyncMock() + resp.status = status + if body_json is not None: + body_text = json.dumps(body_json, ensure_ascii=False) + resp.text = AsyncMock(return_value=body_text) + resp.get_encoding = MagicMock(return_value="utf-8") + + body_bytes = body_text.encode("utf-8") + + async def _aiter(_size: int) -> Any: # pragma: no cover — trivial + if body_bytes: + yield body_bytes + + resp.content = MagicMock() + resp.content.iter_chunked = _aiter + return resp + + +def _install_ctx(session_method: MagicMock, mock_resp: AsyncMock) -> MagicMock: + """Attach an async context manager returning *mock_resp* to a session method.""" + ctx = MagicMock() + ctx.__aenter__ = AsyncMock(return_value=mock_resp) + ctx.__aexit__ = AsyncMock(return_value=False) + session_method.return_value = ctx + return ctx + + +def _make_session() -> MagicMock: + session = MagicMock(spec=aiohttp.ClientSession) + session.get = MagicMock() + session.post = MagicMock() + session.request = MagicMock() + return session + + +# --------------------------------------------------------------------------- +# fetch_csrf +# --------------------------------------------------------------------------- + + +class TestFetchCsrf: + """CSRF token extraction from developer console HTML.""" + + @pytest.mark.asyncio + async def test_happy_path_returns_token(self) -> None: + """CSRF is extracted from the secretkey field in the developer HTML.""" + html = ( + "" + ) + session = _make_session() + _install_ctx(session.get, _mock_response(status=200, body_text=html)) + creator = DialogsSkillCreator(session) + + token = await creator.fetch_csrf() + assert token == "u9c94f1aca53bf156be4abc" + # Verify we hit the expected URL + session.get.assert_called_once_with(DIALOGS_DEV_HTML_URL) + + @pytest.mark.asyncio + async def test_regex_miss_raises_csrf_error(self) -> None: + """Yandex changed the HTML format → clean typed error for fallback.""" + html = "no secretkey here" + session = _make_session() + _install_ctx(session.get, _mock_response(status=200, body_text=html)) + creator = DialogsSkillCreator(session) + + with pytest.raises(DialogsCsrfError) as exc_info: + await creator.fetch_csrf() + assert exc_info.value.step == "fetch_csrf" + + @pytest.mark.asyncio + async def test_401_maps_to_auth_error(self) -> None: + """401 means passport cookies missing/expired — retryable via relogin.""" + session = _make_session() + _install_ctx(session.get, _mock_response(status=401, body_text="nope")) + creator = DialogsSkillCreator(session) + + with pytest.raises(DialogsApiError) as exc_info: + await creator.fetch_csrf() + assert exc_info.value.http_status == 401 + assert exc_info.value.step == "fetch_csrf" + + @pytest.mark.asyncio + async def test_empty_token_raises(self) -> None: + """Regex matched but captured empty string — treat as miss.""" + html = '{"secretkey":""}' + session = _make_session() + _install_ctx(session.get, _mock_response(status=200, body_text=html)) + creator = DialogsSkillCreator(session) + + with pytest.raises(DialogsCsrfError): + await creator.fetch_csrf() + + +# --------------------------------------------------------------------------- +# create_app +# --------------------------------------------------------------------------- + + +class TestCreateApp: + """POST /apps — returns skill_id on success.""" + + @pytest.mark.asyncio + async def test_happy_path_returns_skill_id(self) -> None: + """POST /apps returns a skill UUID and sends the expected payload.""" + session = _make_session() + _install_ctx( + session.request, + _mock_response( + status=200, + body_json={"result": {"id": "7584419b-6815-4a68-8e32-b9fb111b596d"}}, + ), + ) + creator = DialogsSkillCreator(session) + + skill_id = await creator.create_app("csrf-token", "My Skill") + assert skill_id == "7584419b-6815-4a68-8e32-b9fb111b596d" + + # Verify request shape + method, url = session.request.call_args.args[:2] + assert method == "POST" + assert url == f"{DIALOGS_API_BASE}/apps" + payload = session.request.call_args.kwargs["json"] + assert payload["channel"] == "smartHome" + assert payload["language"] == "ru" + assert payload["isYangoConsole"] is False + assert payload["appName"] == "My Skill" + headers = session.request.call_args.kwargs["headers"] + assert headers["x-csrf-token"] == "csrf-token" + + @pytest.mark.asyncio + async def test_duplicate_name_raises_typed_error(self) -> None: + """HTTP 409 with a duplicate-indicator body maps to DialogsDuplicateSkillError.""" + session = _make_session() + _install_ctx( + session.request, + _mock_response( + status=409, + body_json={"error": "not_unique", "message": "skill already exists"}, + ), + ) + creator = DialogsSkillCreator(session) + + with pytest.raises(DialogsDuplicateSkillError) as exc_info: + await creator.create_app("csrf", "duplicate name") + assert exc_info.value.http_status == 409 + + @pytest.mark.asyncio + async def test_generic_4xx_raises_api_error(self) -> None: + """Non-duplicate 4xx surfaces as plain DialogsApiError, not the subclass.""" + session = _make_session() + _install_ctx( + session.request, + _mock_response(status=400, body_text="bad request"), + ) + creator = DialogsSkillCreator(session) + + with pytest.raises(DialogsApiError) as exc_info: + await creator.create_app("csrf", "X") + # Not a duplicate — should be plain DialogsApiError, not subclass + assert not isinstance(exc_info.value, DialogsDuplicateSkillError) + assert exc_info.value.http_status == 400 + + @pytest.mark.asyncio + async def test_missing_skill_id_raises(self) -> None: + """A 200 response without an id field is treated as a protocol break.""" + session = _make_session() + _install_ctx( + session.request, + _mock_response(status=200, body_json={"result": {}}), + ) + creator = DialogsSkillCreator(session) + + with pytest.raises(DialogsApiError): + await creator.create_app("csrf", "X") + + +# --------------------------------------------------------------------------- +# upload_logo +# --------------------------------------------------------------------------- + + +class TestUploadLogo: + """POST /apps/{id}/draft/upload-logo — multipart file upload.""" + + @pytest.mark.asyncio + async def test_happy_path_returns_logo_id(self) -> None: + """upload_logo posts multipart PNG and returns the avatar id.""" + session = _make_session() + _install_ctx( + session.post, + _mock_response( + status=200, + body_json={ + "result": { + "id": "be043706-a868-4999-83c8-f17bbd60745d", + "url": "https://avatars.mds.yandex.net/...", + } + }, + ), + ) + creator = DialogsSkillCreator(session) + + logo_id = await creator.upload_logo("csrf", "skill-1", b"\x89PNG\r\n\x1a\n...") + assert logo_id == "be043706-a868-4999-83c8-f17bbd60745d" + + call = session.post.call_args + url = call.args[0] + assert "/draft/upload-logo" in url + assert "channel=smartHome" in url + # FormData used for multipart + assert isinstance(call.kwargs["data"], aiohttp.FormData) + assert call.kwargs["headers"]["x-csrf-token"] == "csrf" + + @pytest.mark.asyncio + async def test_upload_500_raises(self) -> None: + """Server-side 500 surfaces as DialogsApiError carrying the step name.""" + session = _make_session() + _install_ctx(session.post, _mock_response(status=500, body_text="oops")) + creator = DialogsSkillCreator(session) + + with pytest.raises(DialogsApiError) as exc_info: + await creator.upload_logo("csrf", "sk", b"data") + assert exc_info.value.step == "upload_logo" + assert exc_info.value.http_status == 500 + + +# --------------------------------------------------------------------------- +# update_draft +# --------------------------------------------------------------------------- + + +class TestUpdateDraft: + """PATCH /apps/{id}/draft/update — accepts arbitrary payload dict.""" + + @pytest.mark.asyncio + async def test_happy_path(self) -> None: + """PATCH goes to the right URL with the forwarded payload and CSRF header.""" + session = _make_session() + _install_ctx( + session.request, + _mock_response(status=200, body_json={"result": {"ok": True}}), + ) + creator = DialogsSkillCreator(session) + + payload = {"name": "Music Assistant", "channel": "smartHome"} + await creator.update_draft("csrf", "skill-1", payload) + + method, url = session.request.call_args.args[:2] + assert method == "PATCH" + assert url == f"{DIALOGS_API_BASE}/apps/skill-1/draft/update" + assert session.request.call_args.kwargs["json"] == payload + + +# --------------------------------------------------------------------------- +# create_oauth_app +# --------------------------------------------------------------------------- + + +class TestCreateOAuthApp: + """POST /oauth/apps — returns oauth_app_id.""" + + @pytest.mark.asyncio + async def test_happy_path(self) -> None: + """create_oauth_app builds the correct payload and returns the new id.""" + session = _make_session() + _install_ctx( + session.request, + _mock_response( + status=200, + body_json={"result": {"id": "oauth-uuid-123"}}, + ), + ) + creator = DialogsSkillCreator(session) + + oauth_id = await creator.create_oauth_app( + "csrf", + name="My Skill", + client_id="yandex_smart_home:inst1", + client_secret="secret", + authorize_url="https://yaha-cloud.ru/oauth/authorize", + token_url="https://yaha-cloud.ru/oauth/token", + refresh_url="https://yaha-cloud.ru/oauth/token", + ) + assert oauth_id == "oauth-uuid-123" + + payload = session.request.call_args.kwargs["json"] + assert payload["clientId"] == "yandex_smart_home:inst1" + assert payload["clientSecret"] == "secret" + assert payload["authorizationUrl"] == "https://yaha-cloud.ru/oauth/authorize" + assert payload["scope"] == "" + + +# --------------------------------------------------------------------------- +# attach_oauth +# --------------------------------------------------------------------------- + + +class TestAttachOAuth: + """POST /apps/{id}/oauthApp — links oauth_app to skill.""" + + @pytest.mark.asyncio + async def test_happy_path(self) -> None: + """attach_oauth sends POST with channel query + oauthAppId body.""" + session = _make_session() + _install_ctx( + session.request, + _mock_response(status=200, body_json={"result": "oauth-id"}), + ) + creator = DialogsSkillCreator(session) + + await creator.attach_oauth("csrf", "skill-1", "oauth-1") + + method, url = session.request.call_args.args[:2] + assert method == "POST" + assert "channel=smartHome" in url + assert session.request.call_args.kwargs["json"] == {"oauthAppId": "oauth-1"} + + +# --------------------------------------------------------------------------- +# request_deploy +# --------------------------------------------------------------------------- + + +class TestRequestDeploy: + """POST /apps/{id}/draft/request-deploy — publishes draft.""" + + @pytest.mark.asyncio + async def test_happy_path_empty_body(self) -> None: + """Request-deploy uses an empty body and relies on the URL query only.""" + session = _make_session() + _install_ctx(session.post, _mock_response(status=200, body_json={"result": {}})) + creator = DialogsSkillCreator(session) + + await creator.request_deploy("csrf", "skill-1") + + call = session.post.call_args + url = call.args[0] + assert "request-deploy" in url + assert "channel=smartHome" in url + # No body (only headers) + assert "data" not in call.kwargs + assert "json" not in call.kwargs + assert call.kwargs["headers"]["x-csrf-token"] == "csrf" + + @pytest.mark.asyncio + async def test_deploy_accepts_2xx_variants(self) -> None: + """Some publish responses are 202/204 depending on server side.""" + for status in (201, 202, 204): + session = _make_session() + _install_ctx(session.post, _mock_response(status=status, body_text="")) + creator = DialogsSkillCreator(session) + await creator.request_deploy("csrf", "skill-1") # must not raise + + +# --------------------------------------------------------------------------- +# list_existing_skills +# --------------------------------------------------------------------------- + + +class TestListExistingSkills: + """GET /snapshot — returns existing skills; raises DialogsApiError on malformed JSON.""" + + @pytest.mark.asyncio + async def test_returns_skill_dicts(self) -> None: + """Snapshot is parsed into a list of skill dicts.""" + session = _make_session() + _install_ctx( + session.get, + _mock_response( + status=200, + body_json={ + "result": { + "skills": [ + {"id": "s1", "name": "First"}, + {"id": "s2", "name": "Second"}, + ] + } + }, + ), + ) + creator = DialogsSkillCreator(session) + + skills = await creator.list_existing_skills("csrf") + assert len(skills) == 2 + assert skills[0]["name"] == "First" + + @pytest.mark.asyncio + async def test_raises_when_malformed(self) -> None: + """Non-JSON snapshot body is a protocol break — raise instead of hiding it.""" + session = _make_session() + _install_ctx(session.get, _mock_response(status=200, body_text="not json")) + creator = DialogsSkillCreator(session) + + with pytest.raises(DialogsApiError): + await creator.list_existing_skills("csrf") + + +# --------------------------------------------------------------------------- +# Payload builders + preconditions (pure functions) +# --------------------------------------------------------------------------- + + +def _mass_with_base_url(base_url: str) -> MagicMock: + mass = MagicMock() + mass.webserver.base_url = base_url + return mass + + +class TestDeriveBackendUri: + """derive_backend_uri routes per-mode.""" + + def test_cloud_plus_uses_yaha_relay_constant(self) -> None: + """cloud_plus always points at the fixed yaha-cloud webhook.""" + mass = _mass_with_base_url("https://my-ma.example.com") + assert ( + derive_backend_uri(mass, CONNECTION_TYPE_CLOUD_PLUS) + == "https://yaha-cloud.ru/api/yandex_smart_home" + ) + + def test_direct_uses_ma_base_plus_api_path(self) -> None: + """Direct concatenates MA base_url with the provider's API path.""" + mass = _mass_with_base_url("https://my-ma.example.com/") + # Trailing slash on base_url should be stripped so the full URL is clean. + assert ( + derive_backend_uri(mass, CONNECTION_TYPE_DIRECT) + == "https://my-ma.example.com/api/yandex_smarthome/v1.0" + ) + + def test_cloud_raises(self) -> None: + """Plain 'cloud' mode has no custom skill — function must reject it.""" + mass = _mass_with_base_url("https://x") + with pytest.raises(ValueError, match="connection_type"): + derive_backend_uri(mass, CONNECTION_TYPE_CLOUD) + + +class TestDeriveAuthUrls: + """derive_auth_urls returns (authorize_url, token_url).""" + + def test_cloud_plus_urls(self) -> None: + """cloud_plus uses yaha-cloud OAuth endpoints.""" + mass = _mass_with_base_url("https://x") + auth, token = derive_auth_urls(mass, CONNECTION_TYPE_CLOUD_PLUS) + assert auth == "https://yaha-cloud.ru/oauth/authorize" + assert token == "https://yaha-cloud.ru/oauth/token" + + def test_direct_urls_use_ma_base(self) -> None: + """Direct uses the MA webserver's own authorize/token endpoints.""" + mass = _mass_with_base_url("https://ma.example.com") + auth, token = derive_auth_urls(mass, CONNECTION_TYPE_DIRECT) + assert auth == "https://ma.example.com/api/yandex_smarthome/auth/authorize" + assert token == "https://ma.example.com/api/yandex_smarthome/auth/token" + + +class TestDeriveClientId: + """derive_client_id formats the OAuth client_id per mode.""" + + def test_cloud_plus_templated(self) -> None: + """cloud_plus wraps the instance_id in the yaha protocol prefix.""" + assert derive_client_id(CONNECTION_TYPE_CLOUD_PLUS, "abc123") == "yandex_smart_home:abc123" + + def test_cloud_plus_missing_instance_raises(self) -> None: + """Empty instance_id is a configuration bug — raise early.""" + with pytest.raises(ValueError, match="cloud_instance_id"): + derive_client_id(CONNECTION_TYPE_CLOUD_PLUS, "") + + def test_direct_fixed_value(self) -> None: + """Direct mode uses the fixed Yandex social redirect base.""" + assert derive_client_id(CONNECTION_TYPE_DIRECT, "") == "https://social.yandex.net/" + + +class TestBuildDraftPayload: + """Snapshot coverage for the 100+-field draft/update payload.""" + + def test_cloud_plus_snapshot(self, snapshot) -> None: # type: ignore[no-untyped-def] + """cloud_plus draft payload matches the captured HAR shape.""" + payload = build_draft_payload( + connection_type=CONNECTION_TYPE_CLOUD_PLUS, + skill_name="Music Assistant", + backend_uri="https://yaha-cloud.ru/api/yandex_smart_home", + logo_id="be043706-a868-4999-83c8-f17bbd60745d", + developer_name="alice", + ) + assert payload == snapshot + + def test_direct_snapshot(self, snapshot) -> None: # type: ignore[no-untyped-def] + """Direct draft payload matches the captured HAR shape.""" + payload = build_draft_payload( + connection_type=CONNECTION_TYPE_DIRECT, + skill_name="Music Assistant", + backend_uri="https://ma.example.com/api/yandex_smarthome/v1.0", + logo_id=None, + developer_name="alice", + ) + assert payload == snapshot + + def test_invalid_mode_raises(self) -> None: + """Plain 'cloud' has no auto-create path.""" + with pytest.raises(ValueError, match="connection_type"): + build_draft_payload( + connection_type=CONNECTION_TYPE_CLOUD, + skill_name="x", + backend_uri="https://x", + logo_id=None, + ) + + +class TestBuildOAuthAppPayload: + """OAuth-app payload is simpler — both modes round-trip the given fields.""" + + def test_cloud_plus_snapshot(self, snapshot) -> None: # type: ignore[no-untyped-def] + """cloud_plus payload uses literal 'secret' and the yaha-prefixed client_id.""" + payload = build_oauth_app_payload( + skill_name="Music Assistant", + client_id="yandex_smart_home:abc123", + client_secret="secret", + authorize_url="https://yaha-cloud.ru/oauth/authorize", + token_url="https://yaha-cloud.ru/oauth/token", + ) + assert payload == snapshot + + def test_direct_snapshot(self, snapshot) -> None: # type: ignore[no-untyped-def] + """Direct payload uses social.yandex.net client_id and a per-install secret.""" + payload = build_oauth_app_payload( + skill_name="Music Assistant", + client_id="https://social.yandex.net/", + client_secret="abc123deadbeef", + authorize_url="https://ma.example.com/api/yandex_smarthome/auth/authorize", + token_url="https://ma.example.com/api/yandex_smarthome/auth/token", + ) + assert payload == snapshot + + +class TestCheckPreconditions: + """check_preconditions rejects invalid configurations early.""" + + def test_cloud_plus_requires_instance(self) -> None: + """cloud_plus without a registered cloud instance is rejected.""" + mass = _mass_with_base_url("https://x") + with pytest.raises(ValueError, match="yaha-cloud instance"): + check_preconditions( + connection_type=CONNECTION_TYPE_CLOUD_PLUS, + mass=mass, + cloud_instance_id="", + direct_client_secret="", + ) + + def test_cloud_plus_with_instance_ok(self) -> None: + """cloud_plus with a registered instance_id passes.""" + mass = _mass_with_base_url("https://x") + check_preconditions( + connection_type=CONNECTION_TYPE_CLOUD_PLUS, + mass=mass, + cloud_instance_id="abc", + direct_client_secret="", + ) + + def test_direct_requires_https_base_url(self) -> None: + """Direct rejects non-HTTPS base URLs (Yandex won't accept).""" + mass = _mass_with_base_url("http://ma.local:8095") + with pytest.raises(ValueError, match="HTTPS"): + check_preconditions( + connection_type=CONNECTION_TYPE_DIRECT, + mass=mass, + cloud_instance_id="", + direct_client_secret="secret", + ) + + def test_direct_requires_client_secret(self) -> None: + """Direct rejects empty client_secret (would break account-linking).""" + mass = _mass_with_base_url("https://ma.example.com") + with pytest.raises(ValueError, match="Client Secret"): + check_preconditions( + connection_type=CONNECTION_TYPE_DIRECT, + mass=mass, + cloud_instance_id="", + direct_client_secret="", + ) + + def test_direct_happy_path(self) -> None: + """Direct with HTTPS base URL and a secret passes.""" + mass = _mass_with_base_url("https://ma.example.com") + check_preconditions( + connection_type=CONNECTION_TYPE_DIRECT, + mass=mass, + cloud_instance_id="", + direct_client_secret="my-secret", + ) + + def test_cloud_mode_rejected(self) -> None: + """Plain 'cloud' never uses a custom skill — reject.""" + mass = _mass_with_base_url("https://x") + with pytest.raises(ValueError, match="cloud_plus or direct"): + check_preconditions( + connection_type=CONNECTION_TYPE_CLOUD, + mass=mass, + cloud_instance_id="", + direct_client_secret="", + ) + + +# --------------------------------------------------------------------------- +# Orchestrator (auto_create_skill) +# --------------------------------------------------------------------------- + + +def _make_creator_mock() -> AsyncMock: + """Build an AsyncMock matching DialogsSkillCreator's interface.""" + creator = AsyncMock(spec=DialogsSkillCreator) + creator.fetch_csrf = AsyncMock(return_value="csrf-token") + creator.create_app = AsyncMock(return_value="skill-uuid") + creator.upload_logo = AsyncMock(return_value="logo-uuid") + creator.update_draft = AsyncMock(return_value=None) + creator.create_oauth_app = AsyncMock(return_value="oauth-uuid") + creator.attach_oauth = AsyncMock(return_value=None) + creator.request_deploy = AsyncMock(return_value=None) + return creator + + +def _fake_authenticator_factory( + *, + session: MagicMock | None = None, + session_id_captor: list[str] | None = None, +) -> Any: + """Build an async-generator authenticator usable as *authenticator*. + + If *session_id_captor* is given, the session_id passed in by the + orchestrator is appended to it so tests can assert it was forwarded. + """ + if session is None: + session = MagicMock(spec=aiohttp.ClientSession) + + async def _auth(mass, session_id, timeout): # type: ignore[no-untyped-def] + if session_id_captor is not None: + session_id_captor.append(session_id) + _ = (mass, timeout) + yield session + + return _auth + + +async def _run_orch( + *, + creator: AsyncMock, + connection_type: str = CONNECTION_TYPE_CLOUD_PLUS, + artifacts: SkillCreationArtifacts | None = None, + base_url: str = "https://ma.example.com", + cloud_instance_id: str = "inst-1", + direct_client_secret: str = "", + session_id: str = "test-session-id", + progress_cb: Any = None, +) -> SkillCreationArtifacts: + """Run auto_create_skill with sensible test defaults. + + Injects *creator* via ``creator_factory`` and a fake authenticator + so no real ya-passport-auth or aiohttp traffic is attempted. + """ + mass = _mass_with_base_url(base_url) + return await auto_create_skill( + mass=mass, + connection_type=connection_type, + skill_name="Music Assistant", + artifacts=artifacts if artifacts is not None else SkillCreationArtifacts(), + cloud_instance_id=cloud_instance_id, + direct_client_secret=direct_client_secret, + logo_bytes=b"\x89PNG", + session_id=session_id, + authenticator=_fake_authenticator_factory(), + creator_factory=lambda _s: creator, + progress_cb=progress_cb, + ) + + +class TestAutoCreateSkillHappyPath: + """auto_create_skill on a fresh artifact runs all steps to DONE.""" + + @pytest.mark.asyncio + async def test_fresh_artifact_reaches_done(self) -> None: + """A NONE-state artifact runs the full pipeline to DONE.""" + creator = _make_creator_mock() + + result = await _run_orch(creator=creator) + + assert result.state == SkillCreationState.DONE + assert result.skill_id == "skill-uuid" + assert result.logo_id == "logo-uuid" + assert result.oauth_app_id == "oauth-uuid" + assert result.last_error is None + + for method in ( + creator.fetch_csrf, + creator.create_app, + creator.upload_logo, + creator.update_draft, + creator.create_oauth_app, + creator.attach_oauth, + creator.request_deploy, + ): + method.assert_awaited_once() + + @pytest.mark.asyncio + async def test_progress_cb_invoked_after_each_step(self) -> None: + """progress_cb receives a snapshot after every state transition.""" + creator = _make_creator_mock() + progress_calls: list[SkillCreationArtifacts] = [] + + async def _progress(a: SkillCreationArtifacts) -> None: + progress_calls.append(a) + + await _run_orch(creator=creator, progress_cb=_progress) + + observed_states = [a.state for a in progress_calls] + assert observed_states == [ + SkillCreationState.APP_CREATED, + SkillCreationState.DRAFT_UPDATED, + SkillCreationState.OAUTH_CREATED, + SkillCreationState.OAUTH_ATTACHED, + SkillCreationState.DEPLOY_REQUESTED, + SkillCreationState.DONE, + ] + + +class TestAutoCreateSkillResume: + """Non-NONE artifacts skip completed steps.""" + + @pytest.mark.asyncio + async def test_resume_from_app_created(self) -> None: + """create_app is not re-called when a skill_id is already present.""" + creator = _make_creator_mock() + starting = SkillCreationArtifacts( + state=SkillCreationState.APP_CREATED, skill_id="existing-skill" + ) + result = await _run_orch(creator=creator, artifacts=starting) + + creator.create_app.assert_not_awaited() + creator.upload_logo.assert_awaited_once() + assert result.skill_id == "existing-skill" + assert result.state == SkillCreationState.DONE + + @pytest.mark.asyncio + async def test_resume_from_oauth_attached(self) -> None: + """A near-done artifact only needs request_deploy.""" + creator = _make_creator_mock() + starting = SkillCreationArtifacts( + state=SkillCreationState.OAUTH_ATTACHED, + skill_id="s1", + logo_id="l1", + oauth_app_id="o1", + ) + result = await _run_orch(creator=creator, artifacts=starting) + + creator.create_app.assert_not_awaited() + creator.upload_logo.assert_not_awaited() + creator.update_draft.assert_not_awaited() + creator.create_oauth_app.assert_not_awaited() + creator.attach_oauth.assert_not_awaited() + creator.request_deploy.assert_awaited_once() + assert result.state == SkillCreationState.DONE + + @pytest.mark.asyncio + async def test_resume_from_deploy_requested(self) -> None: + """DEPLOY_REQUESTED is a real checkpoint: resume re-runs publish only.""" + creator = _make_creator_mock() + starting = SkillCreationArtifacts( + state=SkillCreationState.DEPLOY_REQUESTED, + skill_id="s1", + logo_id="l1", + oauth_app_id="o1", + ) + result = await _run_orch(creator=creator, artifacts=starting) + + creator.create_app.assert_not_awaited() + creator.upload_logo.assert_not_awaited() + creator.update_draft.assert_not_awaited() + creator.create_oauth_app.assert_not_awaited() + creator.attach_oauth.assert_not_awaited() + creator.request_deploy.assert_awaited_once() + assert result.state == SkillCreationState.DONE + + @pytest.mark.asyncio + async def test_resume_from_failed_restarts_pipeline(self) -> None: + """FAILED state is treated like NONE — full retry from create_app.""" + creator = _make_creator_mock() + starting = SkillCreationArtifacts( + state=SkillCreationState.FAILED, + last_error="some earlier error", + ) + result = await _run_orch(creator=creator, artifacts=starting) + + creator.create_app.assert_awaited_once() + assert result.state == SkillCreationState.DONE + assert result.last_error is None + + +class TestAutoCreateSkillFailure: + """Pipeline failures convert to FAILED state with preserved partial data.""" + + @pytest.mark.asyncio + async def test_failure_at_create_app_preserves_nothing(self) -> None: + """Duplicate-name error bubbles up as FAILED with no skill_id captured.""" + creator = _make_creator_mock() + creator.create_app.side_effect = DialogsDuplicateSkillError( + "exists", step="create_app", http_status=409 + ) + + result = await _run_orch(creator=creator) + + assert result.state == SkillCreationState.FAILED + assert result.skill_id is None + assert "exists" in (result.last_error or "") + + @pytest.mark.asyncio + async def test_failure_after_app_created_preserves_skill_id(self) -> None: + """A partial failure keeps the skill_id so retry resumes from DRAFT_UPDATED.""" + creator = _make_creator_mock() + creator.upload_logo.side_effect = DialogsApiError( + "500", step="upload_logo", http_status=500 + ) + + result = await _run_orch(creator=creator) + + assert result.state == SkillCreationState.FAILED + assert result.skill_id == "skill-uuid" + + @pytest.mark.asyncio + async def test_csrf_miss_becomes_failed_state(self) -> None: + """CSRF extraction failure doesn't crash — surfaces as FAILED.""" + creator = _make_creator_mock() + creator.fetch_csrf.side_effect = DialogsCsrfError("secretkey not found", step="fetch_csrf") + + result = await _run_orch(creator=creator) + + assert result.state == SkillCreationState.FAILED + assert "secretkey" in (result.last_error or "") + + @pytest.mark.asyncio + async def test_precondition_raises_unmodified(self) -> None: + """Preconditions raise ValueError so UI can show the exact message.""" + with pytest.raises(ValueError, match="HTTPS"): + await _run_orch( + creator=_make_creator_mock(), + connection_type=CONNECTION_TYPE_DIRECT, + base_url="http://not-https.example.com", + direct_client_secret="secret", + ) + + @pytest.mark.asyncio + async def test_progress_cb_exception_does_not_abort(self) -> None: + """If the config-save callback raises, the pipeline still reaches DONE.""" + + async def _boom(_a: SkillCreationArtifacts) -> None: + msg = "cannot write config" + raise RuntimeError(msg) + + result = await _run_orch(creator=_make_creator_mock(), progress_cb=_boom) + assert result.state == SkillCreationState.DONE + + @pytest.mark.asyncio + async def test_cancelled_error_propagates(self) -> None: + """CancelledError must propagate, not be converted into a FAILED artifact. + + Swallowing it would break cooperative task cancellation during + HA shutdown or config-flow abort. + """ + import asyncio # noqa: PLC0415 + + creator = _make_creator_mock() + creator.create_app.side_effect = asyncio.CancelledError + with pytest.raises(asyncio.CancelledError): + await _run_orch(creator=creator) + + +class TestAutoCreateSkillDirectMode: + """Direct mode wires the MA webserver URLs into the payloads.""" + + @pytest.mark.asyncio + async def test_direct_mode_passes_ma_base_to_draft(self) -> None: + """Backend URL and auth URLs must use the MA webserver base URL.""" + creator = _make_creator_mock() + await _run_orch( + creator=creator, + connection_type=CONNECTION_TYPE_DIRECT, + base_url="https://ma.example.com", + cloud_instance_id="", + direct_client_secret="my-secret", + ) + + draft_payload = creator.update_draft.call_args.args[2] + assert ( + draft_payload["backendSettings"]["uri"] + == "https://ma.example.com/api/yandex_smarthome/v1.0" + ) + oauth_call = creator.create_oauth_app.call_args + assert oauth_call.kwargs["client_id"] == "https://social.yandex.net/" + assert oauth_call.kwargs["client_secret"] == "my-secret" + assert ( + oauth_call.kwargs["authorize_url"] + == "https://ma.example.com/api/yandex_smarthome/auth/authorize" + ) + + +class TestAuthenticatorInjection: + """Both async-generator and already-decorated async-CM authenticators work. + + Re-wrapping a callable that already returns an async CM with + ``@asynccontextmanager`` breaks at runtime — the orchestrator must + detect that shape and pass it through. + """ + + @pytest.mark.asyncio + async def test_accepts_already_decorated_context_manager(self) -> None: + """Authenticator whose call returns an async CM must not be re-wrapped.""" + session = MagicMock(spec=aiohttp.ClientSession) + creator = _make_creator_mock() + + @asynccontextmanager + async def _cm_auth( + *, mass: Any, session_id: str, timeout: float + ) -> AsyncIterator[aiohttp.ClientSession]: + _ = (mass, session_id, timeout) + yield session + + mass = _mass_with_base_url("https://ma.example.com") + result = await auto_create_skill( + mass=mass, + connection_type=CONNECTION_TYPE_CLOUD_PLUS, + skill_name="Music Assistant", + artifacts=SkillCreationArtifacts(), + cloud_instance_id="inst-1", + direct_client_secret="", + logo_bytes=b"\x89PNG", + session_id="test-session-id", + authenticator=_cm_auth, # type: ignore[arg-type] + creator_factory=lambda _s: creator, + ) + assert result.state == SkillCreationState.DONE + + +class TestLoadDefaultLogoBytes: + """load_default_logo_bytes reads the bundled PNG from disk.""" + + def test_returns_real_png(self) -> None: + """Bundled provider/auto_skill_logo.png exists and has a PNG magic header.""" + data = load_default_logo_bytes() + # PNG magic: 89 50 4E 47 0D 0A 1A 0A + assert data[:8] == bytes.fromhex("89504e470d0a1a0a") + # Sanity: the bundled asset is non-trivial, not the 1x1 fallback. + assert len(data) > 1000, f"expected real logo asset, got {len(data)} bytes (fallback?)" diff --git a/tests/providers/yandex_smarthome/test_auto_skill_state.py b/tests/providers/yandex_smarthome/test_auto_skill_state.py new file mode 100644 index 0000000000..f87953fb88 --- /dev/null +++ b/tests/providers/yandex_smarthome/test_auto_skill_state.py @@ -0,0 +1,129 @@ +"""Tests for auto_skill_state: artifact serialisation and state transitions.""" + +from __future__ import annotations + +import dataclasses + +from music_assistant.providers.yandex_smarthome.auto_skill_state import ( + SkillCreationArtifacts, + SkillCreationState, + dump_artifacts, + load_artifacts, + mark_failed, +) + + +def test_default_artifacts_state_is_none() -> None: + """Freshly-constructed artifacts sit at state NONE with no fields set.""" + artifacts = SkillCreationArtifacts() + assert artifacts.state == SkillCreationState.NONE + assert artifacts.skill_id is None + assert artifacts.logo_id is None + assert artifacts.oauth_app_id is None + assert artifacts.last_error is None + + +def test_dump_load_roundtrip_none() -> None: + """NONE state with no fields round-trips cleanly.""" + original = SkillCreationArtifacts() + restored = load_artifacts(dump_artifacts(original)) + assert restored == original + + +def test_dump_load_roundtrip_populated() -> None: + """Fully-populated artifacts round-trip without loss.""" + original = SkillCreationArtifacts( + state=SkillCreationState.OAUTH_ATTACHED, + skill_id="7584419b-6815-4a68-8e32-b9fb111b596d", + logo_id="be043706-a868-4999-83c8-f17bbd60745d", + oauth_app_id="50de2b35-e593-417d-83d1-764544996fbb", + last_error=None, + ) + restored = load_artifacts(dump_artifacts(original)) + assert restored == original + + +def test_dump_load_roundtrip_failed_with_error() -> None: + """FAILED state with an error string round-trips.""" + original = SkillCreationArtifacts( + state=SkillCreationState.FAILED, + skill_id="some-skill-id", + last_error="HTTP 401: Unauthorized", + ) + restored = load_artifacts(dump_artifacts(original)) + assert restored == original + + +def test_load_empty_returns_default() -> None: + """Empty/None input yields a fresh default artifacts object.""" + assert load_artifacts(None) == SkillCreationArtifacts() + assert load_artifacts("") == SkillCreationArtifacts() + + +def test_load_invalid_json_returns_default() -> None: + """Corrupt JSON doesn't crash config — returns default.""" + assert load_artifacts("{not-json") == SkillCreationArtifacts() + assert load_artifacts("null") == SkillCreationArtifacts() + assert load_artifacts('"just-a-string"') == SkillCreationArtifacts() + + +def test_load_unknown_state_falls_back_to_none() -> None: + """Unrecognised state values don't crash — reset to NONE.""" + raw = '{"state": "totally_bogus", "skill_id": "abc"}' + result = load_artifacts(raw) + assert result.state == SkillCreationState.NONE + # Other fields still parse correctly + assert result.skill_id == "abc" + + +def test_load_ignores_extra_fields() -> None: + """Forward-compat: extra JSON keys are silently dropped.""" + raw = ( + '{"state": "done", "skill_id": "s1", "logo_id": "l1", ' + '"oauth_app_id": "o1", "future_field": "ignored"}' + ) + result = load_artifacts(raw) + assert result.state == SkillCreationState.DONE + assert result.skill_id == "s1" + + +def test_load_empty_string_fields_become_none() -> None: + """Empty-string fields normalise to None (matches default).""" + raw = '{"state": "none", "skill_id": "", "logo_id": ""}' + result = load_artifacts(raw) + assert result.skill_id is None + assert result.logo_id is None + + +def test_artifacts_frozen() -> None: + """Artifacts are immutable — must copy via dataclasses.replace.""" + artifacts = SkillCreationArtifacts() + try: + artifacts.state = SkillCreationState.DONE # type: ignore[misc] + except dataclasses.FrozenInstanceError: + pass + else: + msg = "SkillCreationArtifacts must be frozen" + raise AssertionError(msg) + + +def test_mark_failed_preserves_ids() -> None: + """mark_failed() flips state to FAILED but keeps skill/oauth IDs for retry.""" + partial = SkillCreationArtifacts( + state=SkillCreationState.OAUTH_CREATED, + skill_id="s1", + logo_id="l1", + oauth_app_id="o1", + ) + failed = mark_failed(partial, "network error") + assert failed.state == SkillCreationState.FAILED + assert failed.last_error == "network error" + assert failed.skill_id == "s1" + assert failed.logo_id == "l1" + assert failed.oauth_app_id == "o1" + + +def test_state_is_string_enum() -> None: + """State values compare equal to plain strings — useful for config storage.""" + assert SkillCreationState.DONE.value == "done" + assert SkillCreationState.FAILED.value == "failed" diff --git a/tests/providers/yandex_smarthome/test_auto_skill_ui.py b/tests/providers/yandex_smarthome/test_auto_skill_ui.py new file mode 100644 index 0000000000..d5d4a1a343 --- /dev/null +++ b/tests/providers/yandex_smarthome/test_auto_skill_ui.py @@ -0,0 +1,405 @@ +"""Tests for auto_skill_ui — ConfigEntry visibility and action label logic.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from music_assistant.providers.yandex_smarthome.auto_skill_state import ( + SkillCreationArtifacts, + SkillCreationState, +) +from music_assistant.providers.yandex_smarthome.auto_skill_ui import ( + auto_create_entries, + build_cloud_plus_entries, + build_direct_entries, + should_show_button, +) +from music_assistant.providers.yandex_smarthome.constants import ( + CONF_ACTION_AUTO_CREATE, + CONF_ACTION_GET_OTP, + CONF_ACTION_REGISTER, + CONF_AUTO_CREATE_ARTIFACTS, + CONF_SKILL_ID, + CONF_SKILL_TOKEN, + CONNECTION_TYPE_CLOUD, + CONNECTION_TYPE_CLOUD_PLUS, + CONNECTION_TYPE_DIRECT, +) + +if TYPE_CHECKING: + from collections.abc import Iterable, Sequence + + from music_assistant_models.config_entries import ConfigEntry + + +def _find(entries: Iterable[ConfigEntry], key: str) -> ConfigEntry | None: + for e in entries: + if e.key == key: + return e + return None + + +# --------------------------------------------------------------------------- +# should_show_button +# --------------------------------------------------------------------------- + + +class TestShouldShowButton: + """should_show_button captures the full visibility truth table.""" + + def test_hidden_in_cloud_mode(self) -> None: + """Plain cloud has no custom skill — button hidden.""" + assert not should_show_button( + connection_type=CONNECTION_TYPE_CLOUD, + state=SkillCreationState.NONE, + cloud_instance_id="abc", + base_url="https://x", + ) + + def test_hidden_when_state_done(self) -> None: + """DONE means skill already created — don't offer re-creation.""" + assert not should_show_button( + connection_type=CONNECTION_TYPE_CLOUD_PLUS, + state=SkillCreationState.DONE, + cloud_instance_id="abc", + base_url="https://x", + ) + + def test_hidden_cloud_plus_no_instance(self) -> None: + """cloud_plus requires a registered cloud instance first.""" + assert not should_show_button( + connection_type=CONNECTION_TYPE_CLOUD_PLUS, + state=SkillCreationState.NONE, + cloud_instance_id="", + base_url="https://x", + ) + + def test_hidden_direct_non_https(self) -> None: + """Direct needs an HTTPS base URL or Yandex will reject the skill.""" + assert not should_show_button( + connection_type=CONNECTION_TYPE_DIRECT, + state=SkillCreationState.NONE, + cloud_instance_id="", + base_url="http://localhost:8095", + ) + + def test_shown_cloud_plus_ready(self) -> None: + """All gates satisfied for cloud_plus → button visible.""" + assert should_show_button( + connection_type=CONNECTION_TYPE_CLOUD_PLUS, + state=SkillCreationState.NONE, + cloud_instance_id="abc", + base_url="https://x", + ) + + def test_shown_direct_ready(self) -> None: + """All gates satisfied for direct → button visible.""" + assert should_show_button( + connection_type=CONNECTION_TYPE_DIRECT, + state=SkillCreationState.NONE, + cloud_instance_id="", + base_url="https://ma.example.com", + ) + + def test_shown_after_failed(self) -> None: + """FAILED state keeps the button visible for retry.""" + assert should_show_button( + connection_type=CONNECTION_TYPE_CLOUD_PLUS, + state=SkillCreationState.FAILED, + cloud_instance_id="abc", + base_url="https://x", + ) + + +# --------------------------------------------------------------------------- +# auto_create_entries +# --------------------------------------------------------------------------- + + +class TestAutoCreateEntries: + """auto_create_entries renders a list of ConfigEntries matching state.""" + + def _entries( + self, + *, + connection_type: str = CONNECTION_TYPE_CLOUD_PLUS, + state: SkillCreationState = SkillCreationState.NONE, + cloud_instance_id: str = "abc", + base_url: str = "https://ma.example.com", + ) -> Sequence[ConfigEntry]: + return auto_create_entries( + connection_type=connection_type, + artifacts=SkillCreationArtifacts(state=state), + cloud_instance_id=cloud_instance_id, + base_url=base_url, + session_id=None, + user_code=None, + verification_url=None, + existing_artifacts_raw=None, + ) + + def test_empty_for_cloud_mode(self) -> None: + """Plain cloud returns no entries — the section is meaningless.""" + assert self._entries(connection_type=CONNECTION_TYPE_CLOUD) == () + + def test_action_shown_and_enabled_when_ready(self) -> None: + """In the ready-to-create state, action is present and not hidden.""" + entries = list(self._entries()) + action = _find(entries, CONF_ACTION_AUTO_CREATE) + assert action is not None + assert action.hidden is False + + def test_action_hidden_when_state_done(self) -> None: + """state=DONE renders the action with hidden=True.""" + entries = list(self._entries(state=SkillCreationState.DONE)) + action = _find(entries, CONF_ACTION_AUTO_CREATE) + assert action is not None + assert action.hidden is True + + def test_action_label_changes_on_failed(self) -> None: + """After a failure the button label switches to 'Retry'.""" + entries = list(self._entries(state=SkillCreationState.FAILED)) + action = _find(entries, CONF_ACTION_AUTO_CREATE) + assert action is not None + assert action.action_label == "Retry" + + def test_action_label_says_retry_on_partial(self) -> None: + """Partial (non-FAILED) progress state uses the 'Retry from last step' label.""" + entries = list(self._entries(state=SkillCreationState.OAUTH_CREATED)) + action = _find(entries, CONF_ACTION_AUTO_CREATE) + assert action is not None + assert action.action_label is not None + assert "Retry" in action.action_label + + def test_hidden_artifacts_always_round_tripped(self) -> None: + """Artifacts blob is included even when the section is mostly empty.""" + entries = list(self._entries()) + artifacts_entry = _find(entries, CONF_AUTO_CREATE_ARTIFACTS) + assert artifacts_entry is not None + assert artifacts_entry.hidden is True + + def test_status_label_reflects_failed_error(self) -> None: + """The status LABEL carries the last_error text for user visibility.""" + entries = auto_create_entries( + connection_type=CONNECTION_TYPE_CLOUD_PLUS, + artifacts=SkillCreationArtifacts( + state=SkillCreationState.FAILED, + last_error="HTTP 401: session expired", + ), + cloud_instance_id="abc", + base_url="https://x", + session_id=None, + user_code=None, + verification_url=None, + existing_artifacts_raw=None, + ) + status = _find(list(entries), "label_auto_create_status") + assert status is not None + assert "401" in status.label + + +# --------------------------------------------------------------------------- +# build_cloud_plus_entries — 3-step structure +# --------------------------------------------------------------------------- + + +class TestBuildCloudPlusEntries: + """cloud_plus entries render as numbered steps with proper gating.""" + + def _call( + self, + *, + is_registered: bool = False, + state: SkillCreationState = SkillCreationState.NONE, + otp_code: str | None = None, + skill_id: str = "", + ) -> list[ConfigEntry]: + return build_cloud_plus_entries( + otp_code=otp_code, + is_registered=is_registered, + cloud_instance_id="inst-1" if is_registered else "", + artifacts=SkillCreationArtifacts(state=state), + session_id=None, + user_code=None, + verification_url=None, + existing_artifacts_raw=None, + base_url="https://ma.example.com", + skill_id=skill_id, + ) + + def test_step1_register_always_visible(self) -> None: + """Step 1 (Register) is always rendered — even before first registration.""" + entries = self._call(is_registered=False) + keys = [e.key for e in entries] + assert CONF_ACTION_REGISTER in keys + + def test_step2_button_inactive_until_registered(self) -> None: + """Step 2 fields are emitted, but the Create-Skill button is hidden.""" + entries = self._call(is_registered=False) + action = _find(list(entries), CONF_ACTION_AUTO_CREATE) + # Entry exists (so advanced users can see other Step 2 fields), + # but the action button self-hides without a cloud instance. + assert action is not None + assert action.hidden is True + + def test_step2_visible_after_register(self) -> None: + """After register, Step 2 renders the auto-create action.""" + entries = self._call(is_registered=True) + keys = [e.key for e in entries] + assert CONF_ACTION_AUTO_CREATE in keys + + def test_step3_hidden_until_registered(self) -> None: + """Step 3 (Get OTP) requires Step 1 done (cloud registration).""" + entries = self._call(is_registered=False, skill_id="s1") + keys = [e.key for e in entries] + assert CONF_ACTION_GET_OTP not in keys + + def test_step3_hidden_until_skill_created(self) -> None: + """Step 3 also requires Step 2 done — OTP linking needs an existing skill.""" + entries = self._call(is_registered=True, skill_id="") + keys = [e.key for e in entries] + assert CONF_ACTION_GET_OTP not in keys + + def test_step3_visible_after_register_and_skill(self) -> None: + """After register AND skill created, Step 3 renders the Get-OTP action.""" + entries = self._call(is_registered=True, skill_id="s1") + keys = [e.key for e in entries] + assert CONF_ACTION_GET_OTP in keys + + def test_register_action_hidden_once_registered(self) -> None: + """The Register button disappears after a cloud instance exists.""" + entries = self._call(is_registered=True) + register = _find(list(entries), CONF_ACTION_REGISTER) + assert register is not None + assert register.hidden is True + + def test_skill_token_shown_on_done(self) -> None: + """Skill OAuth Token input is surfaced (non-advanced) on DONE.""" + entries = self._call(is_registered=True, state=SkillCreationState.DONE) + token = _find(list(entries), CONF_SKILL_TOKEN) + assert token is not None + assert getattr(token, "advanced", False) is False + + def test_skill_token_shown_on_failed(self) -> None: + """Skill OAuth Token input is also surfaced on FAILED for manual entry.""" + entries = self._call(is_registered=True, state=SkillCreationState.FAILED) + token = _find(list(entries), CONF_SKILL_TOKEN) + assert token is not None + assert getattr(token, "advanced", False) is False + + def test_skill_token_advanced_on_none(self) -> None: + """Before user interacts, the token field is hidden behind Advanced.""" + entries = self._call(is_registered=True, state=SkillCreationState.NONE) + token = _find(list(entries), CONF_SKILL_TOKEN) + assert token is not None + assert getattr(token, "advanced", False) is True + + def test_manual_fallback_appears_on_failed(self) -> None: + """FAILED renders manual copy-paste fields inline (non-advanced).""" + entries = self._call(is_registered=True, state=SkillCreationState.FAILED) + keys = [e.key for e in entries] + assert "manual_backend_url" in keys + assert "manual_client_id" in keys + assert "manual_auth_url" in keys + assert "manual_token_url" in keys + # On FAILED the block is NOT advanced — auto-visible + backend = _find(list(entries), "manual_backend_url") + assert getattr(backend, "advanced", False) is False + + def test_manual_fallback_shown_under_advanced_on_done(self) -> None: + """Happy path keeps manual fields under Advanced (power-user visibility).""" + entries = self._call(is_registered=True, state=SkillCreationState.DONE) + keys = [e.key for e in entries] + # Still emitted — but advanced so the default view stays clean + assert "manual_backend_url" in keys + backend = _find(list(entries), "manual_backend_url") + assert getattr(backend, "advanced", False) is True + + def test_manual_fallback_suppressed_before_cloud_register(self) -> None: + """Don't render manual fields with an invalid `yandex_smart_home:` Client ID. + + cloud_plus Client ID embeds the yaha-cloud instance UUID; before + the user clicks Register there's no UUID, and emitting a + half-formed ``yandex_smart_home:`` value would lead power users + (Advanced view) to create a skill with broken account-linking. + """ + entries = self._call(is_registered=False, state=SkillCreationState.NONE) + keys = [e.key for e in entries] + assert "manual_backend_url" not in keys + assert "manual_client_id" not in keys + assert "manual_fallback_label" not in keys + + def test_otp_code_appears_when_present(self) -> None: + """OTP code field is visible once an OTP has been fetched.""" + entries = self._call(is_registered=True, skill_id="s1", otp_code="ABC123") + otp = _find(list(entries), "otp_code") + assert otp is not None + assert otp.hidden is False + + +# --------------------------------------------------------------------------- +# build_direct_entries — 1-step structure (no register, no OTP) +# --------------------------------------------------------------------------- + + +class TestBuildDirectEntries: + """direct mode renders a single Create-Skill step (no yaha, no OTP).""" + + def _call( + self, + *, + state: SkillCreationState = SkillCreationState.NONE, + base_url: str = "https://ma.example.com", + direct_client_secret: str = "secret-123", # noqa: S107 + ) -> list[ConfigEntry]: + return build_direct_entries( + artifacts=SkillCreationArtifacts(state=state), + session_id=None, + user_code=None, + verification_url=None, + existing_artifacts_raw=None, + base_url=base_url, + direct_client_secret=direct_client_secret, + ) + + def test_no_register_action(self) -> None: + """Direct mode has no yaha registration step.""" + entries = self._call() + keys = [e.key for e in entries] + assert CONF_ACTION_REGISTER not in keys + + def test_no_get_otp_action(self) -> None: + """Direct mode has no OTP linking step.""" + entries = self._call() + keys = [e.key for e in entries] + assert CONF_ACTION_GET_OTP not in keys + + def test_has_create_skill_action(self) -> None: + """Direct mode renders the Create-Skill action.""" + entries = self._call() + keys = [e.key for e in entries] + assert CONF_ACTION_AUTO_CREATE in keys + + def test_create_hidden_when_base_url_not_https(self) -> None: + """Non-HTTPS base URL disables the create button (Yandex rejects).""" + entries = self._call(base_url="http://ma.local:8095") + action = _find(list(entries), CONF_ACTION_AUTO_CREATE) + assert action is not None + assert action.hidden is True + + def test_manual_fallback_includes_per_install_secret(self) -> None: + """FAILED fallback for direct shows the per-install client_secret.""" + entries = self._call(state=SkillCreationState.FAILED) + # The fallback block surfaces the generated secret in a visible + # field, plus the Backend URL with the MA base URL. + backend = _find(list(entries), "manual_backend_url") + assert backend is not None + assert "ma.example.com" in str(backend.default_value) + assert "/api/yandex_smarthome/v1.0" in str(backend.default_value) + + def test_skill_id_field_shown_on_done(self) -> None: + """Skill ID input field is surfaced (non-advanced) on DONE.""" + entries = self._call(state=SkillCreationState.DONE) + skill_id = _find(list(entries), CONF_SKILL_ID) + assert skill_id is not None + assert getattr(skill_id, "advanced", False) is False diff --git a/tests/providers/yandex_smarthome/test_config_actions.py b/tests/providers/yandex_smarthome/test_config_actions.py new file mode 100644 index 0000000000..b338176598 --- /dev/null +++ b/tests/providers/yandex_smarthome/test_config_actions.py @@ -0,0 +1,407 @@ +"""Tests for the auto-create action wired into get_config_entries. + +Exercises _run_auto_create_action via the public _handle_config_actions +entry point so the integration between config values, artifacts, and +the orchestrator is covered end-to-end (with the orchestrator itself +mocked). +""" + +from __future__ import annotations + +import json +from typing import Any +from unittest.mock import MagicMock + +import aiohttp +import pytest + +from music_assistant.providers import yandex_smarthome as provider_module +from music_assistant.providers.yandex_smarthome import _handle_config_actions +from music_assistant.providers.yandex_smarthome.auto_skill_state import ( + SkillCreationArtifacts, + SkillCreationState, +) +from music_assistant.providers.yandex_smarthome.constants import ( + CONF_ACTION_AUTO_CREATE, + CONF_AUTO_CREATE_ARTIFACTS, + CONF_CLOUD_INSTANCE_ID, + CONF_DIRECT_CLIENT_SECRET, + CONF_INSTANCE_NAME, + CONF_SKILL_ID, + CONNECTION_TYPE_CLOUD_PLUS, + CONNECTION_TYPE_DIRECT, +) + + +def _make_mass() -> MagicMock: + mass = MagicMock() + mass.get_provider.return_value = None + mass.signal_event = MagicMock() + return mass + + +@pytest.mark.asyncio +async def test_auto_create_done_populates_skill_id(monkeypatch) -> None: # type: ignore[no-untyped-def] + """On DONE, CONF_SKILL_ID is written and artifacts are persisted.""" + + async def _fake_auto_create(**_kwargs: Any) -> SkillCreationArtifacts: + return SkillCreationArtifacts( + state=SkillCreationState.DONE, + skill_id="new-skill-uuid", + logo_id="l1", + oauth_app_id="o1", + ) + + monkeypatch.setattr( + provider_module, + "auto_create_skill", + _fake_auto_create, + ) + + mass = _make_mass() + values: dict[str, Any] = { + CONF_INSTANCE_NAME: "My Instance", + CONF_CLOUD_INSTANCE_ID: "inst-1", + } + + await _handle_config_actions( + mass, + CONF_ACTION_AUTO_CREATE, + values, + instance_id=None, + is_cloud_plus=True, + connection_type=CONNECTION_TYPE_CLOUD_PLUS, + ) + + assert values[CONF_SKILL_ID] == "new-skill-uuid" + stored = json.loads(values[CONF_AUTO_CREATE_ARTIFACTS]) + assert stored["state"] == "done" + assert stored["skill_id"] == "new-skill-uuid" + + +@pytest.mark.asyncio +async def test_auto_create_failed_preserves_artifacts(monkeypatch) -> None: # type: ignore[no-untyped-def] + """On FAILED, CONF_SKILL_ID is NOT set so runtime doesn't use a half-skill.""" + + async def _fake_auto_create(**_kwargs: Any) -> SkillCreationArtifacts: + return SkillCreationArtifacts( + state=SkillCreationState.FAILED, + skill_id="partial-skill-id", + last_error="upload failed", + ) + + monkeypatch.setattr( + provider_module, + "auto_create_skill", + _fake_auto_create, + ) + + mass = _make_mass() + values: dict[str, Any] = { + CONF_INSTANCE_NAME: "X", + CONF_CLOUD_INSTANCE_ID: "inst-1", + } + + await _handle_config_actions( + mass, + CONF_ACTION_AUTO_CREATE, + values, + instance_id=None, + is_cloud_plus=True, + connection_type=CONNECTION_TYPE_CLOUD_PLUS, + ) + + # CONF_SKILL_ID must remain unset — skill is incomplete + assert CONF_SKILL_ID not in values + stored = json.loads(values[CONF_AUTO_CREATE_ARTIFACTS]) + assert stored["state"] == "failed" + assert stored["last_error"] == "upload failed" + assert stored["skill_id"] == "partial-skill-id" + + +@pytest.mark.asyncio +async def test_auto_create_precondition_valueerror_becomes_failed( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """ValueError from preconditions is caught and stored as a FAILED artifact.""" + + async def _raises_value_error(**_kwargs: Any) -> SkillCreationArtifacts: + msg = "direct mode requires HTTPS" + raise ValueError(msg) + + monkeypatch.setattr( + provider_module, + "auto_create_skill", + _raises_value_error, + ) + + mass = _make_mass() + values: dict[str, Any] = { + CONF_INSTANCE_NAME: "X", + CONF_DIRECT_CLIENT_SECRET: "secret", + } + + await _handle_config_actions( + mass, + CONF_ACTION_AUTO_CREATE, + values, + instance_id=None, + is_cloud_plus=False, + connection_type=CONNECTION_TYPE_DIRECT, + ) + + stored = json.loads(values[CONF_AUTO_CREATE_ARTIFACTS]) + assert stored["state"] == "failed" + assert "HTTPS" in stored["last_error"] + assert CONF_SKILL_ID not in values + + +@pytest.mark.asyncio +async def test_auto_create_unexpected_error_caught(monkeypatch) -> None: # type: ignore[no-untyped-def] + """Any other exception from orchestrator also surfaces as FAILED, not a crash.""" + + async def _raises_runtime(**_kwargs: Any) -> SkillCreationArtifacts: + msg = "network unreachable" + raise RuntimeError(msg) + + monkeypatch.setattr( + provider_module, + "auto_create_skill", + _raises_runtime, + ) + + mass = _make_mass() + values: dict[str, Any] = { + CONF_INSTANCE_NAME: "X", + CONF_CLOUD_INSTANCE_ID: "inst-1", + } + + await _handle_config_actions( + mass, + CONF_ACTION_AUTO_CREATE, + values, + instance_id=None, + is_cloud_plus=True, + connection_type=CONNECTION_TYPE_CLOUD_PLUS, + ) + + stored = json.loads(values[CONF_AUTO_CREATE_ARTIFACTS]) + assert stored["state"] == "failed" + assert "network" in stored["last_error"] + + +@pytest.mark.asyncio +async def test_auto_create_non_action_is_noop(monkeypatch) -> None: # type: ignore[no-untyped-def] + """Any action other than AUTO_CREATE must not touch auto-create values.""" + # Spy: if auto_create_skill is somehow called, this fails the test. + called = [] + + async def _fake(**_kwargs: Any) -> SkillCreationArtifacts: + called.append(True) + return SkillCreationArtifacts() + + monkeypatch.setattr( + provider_module, + "auto_create_skill", + _fake, + ) + + mass = _make_mass() + values: dict[str, Any] = {} + + # A different action — should not trigger auto-create + await _handle_config_actions( + mass, + "some_other_action", + values, + instance_id=None, + is_cloud_plus=True, + connection_type=CONNECTION_TYPE_CLOUD_PLUS, + ) + + assert not called + assert CONF_AUTO_CREATE_ARTIFACTS not in values + + +@pytest.mark.asyncio +async def test_session_id_forwarded_from_frontend(monkeypatch) -> None: # type: ignore[no-untyped-def] + """Frontend-supplied ``values['session_id']`` is forwarded to the orchestrator. + + The session_id is what AuthenticationHelper listens on — if the + wiring drops it and generates a local uuid instead, MA's popup + never opens (silent failure the user won't see). + """ + captured: dict[str, Any] = {} + + async def _capture(**kwargs: Any) -> SkillCreationArtifacts: + captured["session_id"] = kwargs.get("session_id") + return SkillCreationArtifacts(state=SkillCreationState.DONE, skill_id="s") + + monkeypatch.setattr(provider_module, "auto_create_skill", _capture) + + mass = _make_mass() + values: dict[str, Any] = { + CONF_INSTANCE_NAME: "X", + CONF_CLOUD_INSTANCE_ID: "inst-1", + "session_id": "frontend-supplied-id-123", + } + + await _handle_config_actions( + mass, + CONF_ACTION_AUTO_CREATE, + values, + instance_id=None, + is_cloud_plus=True, + connection_type=CONNECTION_TYPE_CLOUD_PLUS, + ) + + assert captured["session_id"] == "frontend-supplied-id-123" + + +@pytest.mark.asyncio +async def test_auto_create_cancelled_error_propagates(monkeypatch) -> None: # type: ignore[no-untyped-def] + """CancelledError must not be absorbed into a FAILED artifact. + + Config-flow shutdown and HA stop rely on cooperative cancellation; + converting it into state=FAILED would both leak the error into the + UI and break clean task teardown. + """ + import asyncio # noqa: PLC0415 + + async def _raises_cancelled(**_kwargs: Any) -> SkillCreationArtifacts: + raise asyncio.CancelledError + + monkeypatch.setattr(provider_module, "auto_create_skill", _raises_cancelled) + + mass = _make_mass() + values: dict[str, Any] = { + CONF_INSTANCE_NAME: "X", + CONF_CLOUD_INSTANCE_ID: "inst-1", + } + + with pytest.raises(asyncio.CancelledError): + await _handle_config_actions( + mass, + CONF_ACTION_AUTO_CREATE, + values, + instance_id=None, + is_cloud_plus=True, + connection_type=CONNECTION_TYPE_CLOUD_PLUS, + ) + + # The FAILED artifact must NOT have been written on cancellation. + assert CONF_AUTO_CREATE_ARTIFACTS not in values + + +@pytest.mark.asyncio +async def test_auto_create_prefers_saved_direct_secret(monkeypatch) -> None: # type: ignore[no-untyped-def] + """SECURE_STRING client secret: saved_config beats empty ``values`` on re-open. + + On re-open MA does not echo SECURE_STRING values back into ``values``, + so reading from ``values`` alone would pass an empty secret into the + orchestrator. The helper must pull it from the persisted provider + config instead. + """ + captured: dict[str, Any] = {} + + async def _capture(**kwargs: Any) -> SkillCreationArtifacts: + captured["direct_client_secret"] = kwargs.get("direct_client_secret") + return SkillCreationArtifacts(state=SkillCreationState.DONE, skill_id="s") + + monkeypatch.setattr(provider_module, "auto_create_skill", _capture) + + mass = _make_mass() + saved_cfg = MagicMock() + saved_cfg.get_value.side_effect = lambda key: ( + "persisted-secret" if key == CONF_DIRECT_CLIENT_SECRET else None + ) + prov = MagicMock() + prov.config = saved_cfg + mass.get_provider.return_value = prov + + # Frontend re-open: SECURE_STRING not echoed back -> values has no secret. + values: dict[str, Any] = { + CONF_INSTANCE_NAME: "X", + } + + await _handle_config_actions( + mass, + CONF_ACTION_AUTO_CREATE, + values, + instance_id="inst-42", + is_cloud_plus=False, + connection_type=CONNECTION_TYPE_DIRECT, + ) + + assert captured["direct_client_secret"] == "persisted-secret" + + +@pytest.mark.asyncio +async def test_auto_create_falls_back_to_values_for_first_setup(monkeypatch) -> None: # type: ignore[no-untyped-def] + """First-time setup: no instance yet, secret is read from ``values``.""" + captured: dict[str, Any] = {} + + async def _capture(**kwargs: Any) -> SkillCreationArtifacts: + captured["direct_client_secret"] = kwargs.get("direct_client_secret") + return SkillCreationArtifacts(state=SkillCreationState.DONE, skill_id="s") + + monkeypatch.setattr(provider_module, "auto_create_skill", _capture) + + mass = _make_mass() # get_provider returns None + values: dict[str, Any] = { + CONF_INSTANCE_NAME: "X", + CONF_DIRECT_CLIENT_SECRET: "fresh-secret", + } + + await _handle_config_actions( + mass, + CONF_ACTION_AUTO_CREATE, + values, + instance_id=None, + is_cloud_plus=False, + connection_type=CONNECTION_TYPE_DIRECT, + ) + + assert captured["direct_client_secret"] == "fresh-secret" + + +@pytest.mark.asyncio +async def test_existing_action_register_still_works(monkeypatch) -> None: # type: ignore[no-untyped-def] + """Gap-fill: pre-existing CONF_ACTION_REGISTER path is still covered.""" + + async def _fake_register( + _session: Any, + platform: str | None = None, # noqa: ARG001 + ) -> dict[str, str]: + return {"id": "inst-new", "password": "p", "connection_token": "t"} + + async def _fake_otp(_session: Any, _id: str, _tok: Any) -> str: + return "111111" + + class _NoopSession: + async def __aenter__(self): # type: ignore[no-untyped-def] + return self + + async def __aexit__(self, *_a): # type: ignore[no-untyped-def] + return False + + monkeypatch.setattr(provider_module, "register_cloud_instance", _fake_register) + monkeypatch.setattr(provider_module, "get_cloud_otp", _fake_otp) + monkeypatch.setattr(aiohttp, "ClientSession", _NoopSession) + + mass = _make_mass() + values: dict[str, Any] = {} + + otp = await _handle_config_actions( + mass, + "register_cloud", + values, + instance_id=None, + is_cloud_plus=True, + connection_type=CONNECTION_TYPE_CLOUD_PLUS, + ) + + assert values[CONF_CLOUD_INSTANCE_ID] == "inst-new" + # Register no longer auto-fetches OTP — that's a separate Step 3 action. + # The handler returns None because no OTP was requested. + assert otp is None