diff --git a/music_assistant/providers/yandex_alice/__init__.py b/music_assistant/providers/yandex_alice/__init__.py new file mode 100644 index 0000000000..afe589bdc5 --- /dev/null +++ b/music_assistant/providers/yandex_alice/__init__.py @@ -0,0 +1,912 @@ +"""Yandex Alice (Dialogs custom skill) plugin provider for Music Assistant. + +Exposes Music Assistant playback to a Yandex Dialogs custom skill — a Russian +NLU voice control surface invoked via *«Алиса, попроси Music Assistant …»*. + +Setup paths: + +1. **Auto** (since v1.1.0): the *Create skill* button kicks off a Yandex + Passport Device Flow login and registers the skill in + ``https://dialogs.yandex.ru/developer`` programmatically via + ``ya-dialogs-api``. The skill ID is auto-populated on success. +2. **Manual** (still supported): create the skill yourself in the dev console, + point its webhook URL at ``/api/yandex_dialogs/webhook/``, + and paste the skill ID + token into the form. +""" + +from __future__ import annotations + +import dataclasses +import logging +import secrets +import time +from typing import TYPE_CHECKING + +from music_assistant_models.config_entries import ConfigEntry, ConfigValueOption +from music_assistant_models.constants import SECURE_STRING_SUBSTITUTE +from music_assistant_models.enums import ConfigEntryType, ProviderFeature +from music_assistant_models.errors import InvalidDataError, LoginFailed +from ya_dialogs_api import ( + SkillCreationArtifacts, + SkillCreationState, + dump_artifacts, + load_artifacts, +) + +from .auth_page import perform_device_auth +from .auto_create import ( + AutoCreateOutcome, + LocalAutoCreateStage, + adopt_existing_skill, + delete_existing_skill_then_recreate, + run_create_skill, +) +from .auto_update import run_auto_update +from .constants import ( + CONF_ACTION_ADOPT_EXISTING, + CONF_ACTION_AUTO_CREATE_DIALOG, + CONF_ACTION_CANCEL_DIALOG_SKILL_FLOW, + CONF_ACTION_CANCEL_EDIT, + CONF_ACTION_CLEAR_AUTH, + CONF_ACTION_DELETE_SKILL, + CONF_ACTION_EDIT_SKILL, + CONF_ACTION_EXPORT_MANIFEST, + CONF_ACTION_IMPORT_MANIFEST, + CONF_ACTION_RECREATE_DUPLICATE, + CONF_ACTION_REFRESH_STATUS, + CONF_ACTION_REGENERATE_WEBHOOK_SECRET, + CONF_ACTION_RENAME_DIALOG_SKILL, + CONF_ACTION_RESET_MANIFEST, + CONF_ACTION_REVERT_SKILL_NAME, + CONF_ACTION_SIGN_IN, + CONF_ACTION_TEST_WEBHOOK, + CONF_ACTION_UPDATE_SKILL, + CONF_ACTION_VALIDATE_MANIFEST, + CONF_AUTH_USER_NAME, + CONF_AUTH_X_TOKEN, + CONF_DIALOG_ACTIVATION_PHRASE_2, + CONF_DIALOG_ACTIVATION_PHRASE_3, + CONF_DIALOG_ACTIVATION_PHRASE_4, + CONF_DIALOG_AUTO_CREATE_ARTIFACTS, + CONF_DIALOG_PUBLICATION_STATUS, + CONF_DIALOG_SKILL_ID, + CONF_DIALOG_SKILL_NAME, + CONF_DIALOG_SKILL_OVERRIDE_PASTE, + CONF_DIALOG_SKILL_TOKEN, + CONF_DIALOG_SKILL_VOICE, + CONF_DIALOG_WEBHOOK_SECRET, + CONF_EDIT_MODE, + CONF_EXTERNAL_BASE_URL, + CONF_INSTANCE_NAME, + CONF_PENDING_DUPLICATE_SKILL_ID, + CONF_PENDING_DUPLICATE_SKILL_NAME, + CONF_USE_DIFFERENT_INSTANCE_NAME, + DIALOG_DEFAULT_NAME, + DIALOG_VOICE_DEFAULT, +) +from .dialog_skill_meta import ( + build_activation_phrases, + build_backend_uri, + build_skill_description, + build_structured_examples, +) +from .plugin import YandexAlicePlugin +from .publication_status import fetch_skill_publication_status +from .setup_view import build_form_entries +from .skill_manifest_provider import SkillManifestProvider +from .url_helpers import ( + is_public_https_url, + try_detect_any_base_url, + try_detect_public_https_url, +) +from .webhook_probe import probe_webhook_reachability + +if TYPE_CHECKING: + from music_assistant_models.config_entries import ConfigValueType, ProviderConfig + from music_assistant_models.provider import ProviderManifest + + from music_assistant.mass import MusicAssistant + from music_assistant.models import ProviderInstanceType + + +_LOGGER = logging.getLogger(__name__) + +SUPPORTED_FEATURES: set[ProviderFeature] = set() + + +async def setup( + mass: MusicAssistant, + manifest: ProviderManifest, + config: ProviderConfig, +) -> ProviderInstanceType: + """Initialise the provider instance with the given configuration.""" + return YandexAlicePlugin(mass, manifest, config, SUPPORTED_FEATURES) + + +def _generate_webhook_secret() -> str: + """Return a fresh URL-safe random secret for the webhook path.""" + return secrets.token_urlsafe(24) + + +async def _delete_skill_in_yandex(x_token: str, skill_id: str) -> None: + """Hard-delete a skill from the user's Yandex Dialogs account. + + Used by the *Delete skill* action button in Step 3 edit mode. + Errors propagate to the caller (the dispatcher) which wraps them + in a user-visible LABEL. + """ + from ya_dialogs_api import DialogsSkillCreator # noqa: PLC0415 + + from .auth_session import cached_authenticated_session # noqa: PLC0415 + from .constants import DIALOG_CHANNEL # noqa: PLC0415 + + async with cached_authenticated_session(x_token) as session: + creator = DialogsSkillCreator(session, channel=DIALOG_CHANNEL) + csrf = await creator.fetch_csrf() + await creator.delete_skill(csrf, skill_id) + + +async def _list_player_options(mass: MusicAssistant) -> list[ConfigValueOption]: + """List MA players the user can expose to voice control.""" + options: list[ConfigValueOption] = [] + try: + for player in mass.players.all_players(): + options.append( + ConfigValueOption( + title=player.display_name or player.name or player.player_id, + value=player.player_id, + ) + ) + except Exception as exc: + _LOGGER.debug("could not enumerate players: %s", exc) + return options + + +def _build_diagnostics_entries( + mass: MusicAssistant, instance_id: str | None +) -> tuple[ConfigEntry, ...]: + """Render runtime stats from the loaded plugin instance (#17). + + Reads counters off the running ``YandexAlicePlugin`` (set in + ``handle_async_init`` / updated in the webhook handler). When the + provider is not loaded yet (config-edit before first save) we render + a single placeholder LABEL so users know diagnostics is available. + """ + if not instance_id: + return () + try: + plugin = mass.get_provider(instance_id) + except Exception: + return () + if plugin is None or not isinstance(plugin, YandexAlicePlugin): + return () + + stats = plugin.get_diagnostics() + handler_active = bool(stats.get("handler_active")) + if not handler_active: + return ( + ConfigEntry( + key="label_diagnostics_inactive", + type=ConfigEntryType.LABEL, + label=( + "Diagnostics: webhook handler not active — " + "skill_id or webhook_secret missing in saved config. " + "Run Create skill to register a new skill, or paste an " + "existing skill_id + secret in Advanced." + ), + advanced=True, + ), + ) + + webhook_calls = int(stats.get("webhook_calls_total") or 0) + authenticated_calls = int(stats.get("authenticated_calls_total") or 0) + last_ts_raw = stats.get("last_webhook_ts") + if isinstance(last_ts_raw, (int, float)) and last_ts_raw > 0: + delta = max(0, int(time.time() - last_ts_raw)) + if delta < 60: + last_ago = f"{delta} sec ago" + elif delta < 3600: + last_ago = f"{delta // 60} min ago" + else: + last_ago = f"{delta // 3600} h ago" + else: + last_ago = "never" + + summary = ( + f"Diagnostics: {webhook_calls} webhook hits " + f"({authenticated_calls} past auth) · last webhook {last_ago}." + ) + return ( + ConfigEntry( + key="label_diagnostics_summary", + type=ConfigEntryType.LABEL, + label=summary, + advanced=True, + ), + ) + + +def _resolve_saved_value( + values: dict[str, ConfigValueType], + key: str, +) -> str: + """Read a plain config value from form ``values`` (string-coerced).""" + return str(values.get(key) or "") + + +def _saved_provider_config(mass: MusicAssistant, instance_id: str | None) -> object | None: + """Cache helper: return the running provider's ``.config`` once per render. + + SECURE_STRING fallback (see :func:`_resolve_secure_string_from`) + has to look up the persisted value for *every* token field on + every dispatcher invocation. Calling ``mass.get_provider`` 3-4 + times per render is harmless but redundant; this helper resolves + it once and reuses the same object for the lifetime of the call. + """ + if not instance_id: + return None + try: + prov = mass.get_provider(instance_id) + except Exception as exc: + _LOGGER.debug("saved_provider_config lookup failed: %r", exc) + return None + return getattr(prov, "config", None) if prov is not None else None + + +def _resolve_secure_string_from( + saved_config: object | None, + values: dict[str, ConfigValueType], + key: str, +) -> str: + """Read a SECURE_STRING value, resolving the FE substitute. + + MA's frontend never echoes the actual SECURE_STRING value back to + the backend — instead it sends ``SECURE_STRING_SUBSTITUTE`` + ("this_value_is_encrypted") whenever the user hasn't edited the + field. Reading ``values[key]`` raw would therefore hand us the + substitute marker, not the real token, and any downstream call + would fail with an opaque auth error. + + Behaviour, in order: + + 1. Use the user-supplied value from ``values`` only when it's + non-empty AND not the substitute marker (user just typed in a + fresh secret). + 2. Otherwise fall back to the persisted value via + ``saved_config.get_value(key)`` — same pattern as + ``yandex_smarthome._resolve_direct_client_secret``. + 3. Empty string if neither path yields a value. + + The ``saved_config`` argument is the running provider's + ``ProviderConfig`` (resolved once per render via + :func:`_saved_provider_config`) — this avoids repeatedly calling + ``mass.get_provider`` for every secure field on every dispatch. + """ + raw = str(values.get(key) or "") + if raw and raw != SECURE_STRING_SUBSTITUTE: + return raw + if saved_config is None: + return "" + try: + saved = saved_config.get_value(key) # type: ignore[attr-defined] + except Exception as exc: + _LOGGER.debug("secure-string fallback failed for %s: %r", key, exc) + return "" + return str(saved or "") + + +async def get_config_entries( # noqa: PLR0915 + mass: MusicAssistant, + instance_id: str | None = None, + action: str | None = None, + values: dict[str, ConfigValueType] | None = None, +) -> tuple[ConfigEntry, ...]: + """Build the provider config-form entries with auto-create / rename actions. + + Action handling: + + - ``CONF_ACTION_AUTO_CREATE_DIALOG`` — advance the Device Flow + skill + creation state machine by one external-IO step. Re-click drives further + stages (see :mod:`provider.auto_create`). + - ``CONF_ACTION_RENAME_DIALOG_SKILL`` — patch the existing skill draft + via cached x_token; no Device Flow. + - ``CONF_ACTION_CANCEL_DIALOG_SKILL_FLOW`` — drop pending session + + reset artifacts; preserve cached x_token. + + Auto-create / rename state lives in two hidden config entries + (``CONF_AUTH_X_TOKEN``, ``CONF_DIALOG_AUTO_CREATE_ARTIFACTS``) + that round-trip through the form on every save. + """ + values = values or {} + + # Generate a webhook secret on first open if the user hasn't set one yet. + # Read through saved provider config too: the frontend may not echo + # SECURE_STRING fields between action clicks, and regenerating the + # secret per call would orphan webhooks already registered with Yandex + # against an earlier (now-discarded) secret. + # Resolve the running provider config once (#9) — sibling lookups + # for SECURE_STRING substitute fallback all share this handle, so + # we don't call ``mass.get_provider`` redundantly per render. + saved_provider = _saved_provider_config(mass, instance_id) + + existing_secret = _resolve_secure_string_from( + saved_provider, values, CONF_DIALOG_WEBHOOK_SECRET + ).strip() + default_secret = existing_secret or _generate_webhook_secret() + # Stabilise inside this dispatch: any backend_uri assembled below uses + # the same secret as the form will save on user click. + values[CONF_DIALOG_WEBHOOK_SECRET] = default_secret + + instance_name = str(values.get(CONF_INSTANCE_NAME) or DIALOG_DEFAULT_NAME) + + # ---- Pull persistent auto-create / auth state ---- + artifacts = load_artifacts( + _resolve_saved_value(values, CONF_DIALOG_AUTO_CREATE_ARTIFACTS) or None + ) + cached_x_token = _resolve_secure_string_from(saved_provider, values, CONF_AUTH_X_TOKEN) + skill_token_value = _resolve_secure_string_from(saved_provider, values, CONF_DIALOG_SKILL_TOKEN) + # Carried across renders unless a deploy-related action below + # overrides it via a snapshot fetch (or DELETE_SKILL clears it). + publication_status = _resolve_saved_value(values, CONF_DIALOG_PUBLICATION_STATUS) + + # Skill name priority: explicit dialog skill name → instance name → default. + skill_name = ( + str(values.get(CONF_DIALOG_SKILL_NAME) or "").strip() + or str(values.get(CONF_INSTANCE_NAME) or "").strip() + or DIALOG_DEFAULT_NAME + ) + + external_base_url = str(values.get(CONF_EXTERNAL_BASE_URL) or "").strip().rstrip("/") + webhook_secret = default_secret + + action_outcome: AutoCreateOutcome | None = None + update_message: str | None = None + # Surfaced in the manifest banner block, separate from update_message + # so manifest actions don't bleed into the skill-block status line. + manifest_message: str | None = None + + # Effective skill manifest — bundled default unless user wrote an + # override file. Cheap to construct (no I/O until .grammar() / .entities() + # are called). Reused across action branches that ship intents to Yandex. + manifest_provider = SkillManifestProvider(mass) + + # ---- Action dispatcher ---- + if action == CONF_ACTION_SIGN_IN: + # Authorization block: blocking Device Flow with popup. + # session_id MUST come from values["session_id"] — that's the + # channel the MA frontend listens on for the AUTH_SESSION + # popup signal. + session_id_raw = values.get("session_id") + session_id = str(session_id_raw or "").strip() + if not session_id: + msg = "Missing session_id for device authentication" + raise InvalidDataError(msg) + try: + cached_x_token, display_login = await perform_device_auth( + mass, session_id, skill_name=skill_name + ) + values[CONF_AUTH_USER_NAME] = display_login + except LoginFailed as exc: + update_message = str(exc) + except Exception as exc: + _LOGGER.exception("yandex-alice: sign-in raised unexpectedly") + update_message = f"Sign-in error: {exc!r}" + + elif action == CONF_ACTION_CLEAR_AUTH: + # Sign out — drop the cached x_token + cached display name. + # Skill artifacts are reset too so the form snaps back to a + # clean "needs sign-in" state. The skill itself stays in + # Yandex; user can re-sign-in to resume managing it. + cached_x_token = "" + values[CONF_AUTH_USER_NAME] = "" + artifacts = SkillCreationArtifacts() + values[CONF_PENDING_DUPLICATE_SKILL_ID] = "" + values[CONF_PENDING_DUPLICATE_SKILL_NAME] = "" + + elif action == CONF_ACTION_AUTO_CREATE_DIALOG: + # Skill block: Create skill (blocking pipeline). + # Re-click on DONE → reset artifacts so we run a fresh + # create_app (after Delete skill). Backup-restore safety — + # if a skill_id is in config but artifacts are NONE, pre-set + # APP_CREATED so the library skips create_app. + if artifacts.state == SkillCreationState.DONE: + artifacts = SkillCreationArtifacts() + saved_skill_id = str(values.get(CONF_DIALOG_SKILL_ID) or "").strip() + if saved_skill_id and artifacts.state == SkillCreationState.NONE and not artifacts.skill_id: + artifacts = dataclasses.replace( + artifacts, + state=SkillCreationState.APP_CREATED, + skill_id=saved_skill_id, + ) + + try: + backend_uri = build_backend_uri(external_base_url, webhook_secret) + except ValueError as exc: + action_outcome = AutoCreateOutcome( + artifacts=dataclasses.replace( + artifacts, + state=SkillCreationState.FAILED, + last_error=str(exc), + ), + x_token=None, + user_message=str(exc), + stage=LocalAutoCreateStage.FAILED, + ) + else: + action_outcome = await run_create_skill( + cached_x_token=cached_x_token, + skill_name=skill_name, + backend_uri=backend_uri, + description=build_skill_description(skill_name), + structured_examples=build_structured_examples(skill_name), + activation_phrases=build_activation_phrases(skill_name), + intents=manifest_provider.grammar(), + entities=manifest_provider.entities(), + artifacts=artifacts, + ) + + elif action == CONF_ACTION_DELETE_SKILL: + # Hard-delete the registered skill from Yandex and reset + # artifacts so the Skill block flips back to its "create" + # variant. Cached Passport sign-in is kept. + target_skill_id = artifacts.skill_id or str(values.get(CONF_DIALOG_SKILL_ID) or "").strip() + if not target_skill_id or not cached_x_token: + update_message = "Nothing to delete — no skill_id on record." + else: + try: + await _delete_skill_in_yandex(cached_x_token, target_skill_id) + update_message = "Skill deleted from Yandex Dialogs." + artifacts = SkillCreationArtifacts() + values[CONF_DIALOG_SKILL_ID] = "" + publication_status = "" + except Exception as exc: + _LOGGER.exception("yandex-alice: delete_skill failed") + update_message = f"Failed to delete skill: {exc!r}" + + elif action == CONF_ACTION_RECREATE_DUPLICATE: + existing_id = _resolve_saved_value(values, CONF_PENDING_DUPLICATE_SKILL_ID).strip() + try: + backend_uri = build_backend_uri(external_base_url, webhook_secret) + except ValueError as exc: + update_message = str(exc) + else: + if not existing_id or not cached_x_token: + update_message = ( + "Recreate is only available when an existing skill has " + "been detected. Click 'Create skill' first." + ) + else: + action_outcome = await delete_existing_skill_then_recreate( + cached_x_token=cached_x_token, + skill_name=skill_name, + backend_uri=backend_uri, + description=build_skill_description(skill_name), + structured_examples=build_structured_examples(skill_name), + activation_phrases=build_activation_phrases(skill_name), + intents=manifest_provider.grammar(), + entities=manifest_provider.entities(), + existing_skill_id=existing_id, + ) + values[CONF_PENDING_DUPLICATE_SKILL_ID] = "" + values[CONF_PENDING_DUPLICATE_SKILL_NAME] = "" + + elif action == CONF_ACTION_ADOPT_EXISTING: + existing_id = _resolve_saved_value(values, CONF_PENDING_DUPLICATE_SKILL_ID).strip() + try: + backend_uri = build_backend_uri(external_base_url, webhook_secret) + except ValueError as exc: + update_message = str(exc) + else: + if not existing_id or not cached_x_token: + update_message = ( + "Adopt is only available when an existing skill has been " + "detected. Click 'Create skill' first." + ) + else: + action_outcome = await adopt_existing_skill( + cached_x_token=cached_x_token, + skill_name=skill_name, + backend_uri=backend_uri, + description=build_skill_description(skill_name), + structured_examples=build_structured_examples(skill_name), + activation_phrases=build_activation_phrases(skill_name), + intents=manifest_provider.grammar(), + entities=manifest_provider.entities(), + existing_skill_id=existing_id, + ) + values[CONF_PENDING_DUPLICATE_SKILL_ID] = "" + values[CONF_PENDING_DUPLICATE_SKILL_NAME] = "" + + elif action == CONF_ACTION_EDIT_SKILL: + # Toggle edit mode on; render path picks it up via CONF_EDIT_MODE. + values[CONF_EDIT_MODE] = True + + elif action == CONF_ACTION_CANCEL_EDIT: + # Drop edit mode; user-edited values for activation_phrases/voice + # are kept in the form but not pushed to Yandex until Update. + values[CONF_EDIT_MODE] = False + + elif action == CONF_ACTION_UPDATE_SKILL: + # Edit-mode commit — pushes the edited skill_name + up to 3 + # alternative activation phrases + voice to Yandex via + # auto_update_skill. The skill_name itself is the first + # activation phrase; empty alt slots are skipped. + edited_phrases: list[str] = [skill_name.strip()] if skill_name.strip() else [] + for key in ( + CONF_DIALOG_ACTIVATION_PHRASE_2, + CONF_DIALOG_ACTIVATION_PHRASE_3, + CONF_DIALOG_ACTIVATION_PHRASE_4, + ): + extra = str(values.get(key) or "").strip() + if extra: + edited_phrases.append(extra) + if not edited_phrases: + edited_phrases = build_activation_phrases(skill_name) + edited_voice = ( + str(values.get(CONF_DIALOG_SKILL_VOICE) or "").strip() or DIALOG_VOICE_DEFAULT + ) + + try: + backend_uri = build_backend_uri(external_base_url, webhook_secret) + except ValueError as exc: + update_message = str(exc) + else: + update_outcome = await run_auto_update( + cached_x_token=cached_x_token or None, + skill_name=skill_name, + backend_uri=backend_uri, + description=build_skill_description(skill_name), + structured_examples=build_structured_examples(skill_name), + activation_phrases=edited_phrases, + voice=edited_voice, + intents=manifest_provider.grammar(), + entities=manifest_provider.entities(), + artifacts=artifacts, + ) + update_message = update_outcome.user_message + if update_outcome.x_token == "": + cached_x_token = "" + if update_outcome.artifacts.state == SkillCreationState.DONE: + # Successful update — pick up the refreshed snapshot + # (e.g. last_known_name advanced) and exit edit mode. + artifacts = update_outcome.artifacts + values[CONF_EDIT_MODE] = False + # Else: keep the existing DONE artifacts so the form stays + # in Step 3 edit mode with the error LABEL on top — flipping + # to artifacts.state=FAILED would route us back to Step 2. + + elif action == CONF_ACTION_RENAME_DIALOG_SKILL: + try: + backend_uri = build_backend_uri(external_base_url, webhook_secret) + except ValueError as exc: + update_message = str(exc) + artifacts = dataclasses.replace( + artifacts, + state=SkillCreationState.FAILED, + last_error=str(exc), + ) + else: + update_outcome = await run_auto_update( + cached_x_token=cached_x_token or None, + skill_name=skill_name, + backend_uri=backend_uri, + description=build_skill_description(skill_name), + structured_examples=build_structured_examples(skill_name), + activation_phrases=build_activation_phrases(skill_name), + intents=manifest_provider.grammar(), + entities=manifest_provider.entities(), + artifacts=artifacts, + ) + artifacts = update_outcome.artifacts + update_message = update_outcome.user_message + if update_outcome.x_token == "": + cached_x_token = "" + + elif action == CONF_ACTION_CANCEL_DIALOG_SKILL_FLOW: + # Reset artifacts; keep cached x_token (sign-in stays valid). + artifacts = SkillCreationArtifacts() + values[CONF_PENDING_DUPLICATE_SKILL_ID] = "" + values[CONF_PENDING_DUPLICATE_SKILL_NAME] = "" + + elif action == CONF_ACTION_REGENERATE_WEBHOOK_SECRET: + # Webhook secret rotation: invalidates the URL Yandex was registered + # against, so the existing skill's webhook would 404. Reset everything + # and force the user back through auto-create with a fresh secret — + # cached x_token is preserved so the second pass skips Passport login. + default_secret = _generate_webhook_secret() + values[CONF_DIALOG_WEBHOOK_SECRET] = default_secret + webhook_secret = default_secret + artifacts = SkillCreationArtifacts() + values[CONF_DIALOG_SKILL_ID] = "" + publication_status = "" + if cached_x_token: + update_message = ( + "Webhook secret regenerated. Click 'Create skill' to register " + "a fresh skill against the new URL." + ) + else: + update_message = ( + "Webhook secret regenerated. Click 'Sign in to Yandex Passport' " + "to register a fresh skill against the new URL." + ) + + elif action == CONF_ACTION_TEST_WEBHOOK: + # Reachability probe — does Yandex's traffic actually land in our + # handler? Returns ``(ok, message)`` ready for an inline LABEL. + reachable, msg = await probe_webhook_reachability(external_base_url, webhook_secret) + update_message = ("✅ " if reachable else "❌ ") + msg + + elif action == CONF_ACTION_REVERT_SKILL_NAME: + # Drift undo (#13) — copy artifacts.last_known_name back into the + # form field so the user can abandon a half-typed rename and go + # back to whatever Yandex currently has. + if artifacts.last_known_name: + values[CONF_DIALOG_SKILL_NAME] = artifacts.last_known_name + update_message = ( + f"Skill name reverted to «{artifacts.last_known_name}» " + "(matches the value currently registered with Yandex)." + ) + else: + update_message = "Nothing to revert — no last-known name on record yet." + + elif action == CONF_ACTION_REFRESH_STATUS: + # Manual snapshot fetch — single HTTP call, updates the cached + # publication_status field. Used to track Yandex moderation + # transitions (in_moderation → on_air) without re-deploying. + target_skill_id = artifacts.skill_id or str(values.get(CONF_DIALOG_SKILL_ID) or "").strip() + if not target_skill_id or not cached_x_token: + update_message = "Refresh status is only available after a skill has been registered." + else: + fetched = await fetch_skill_publication_status(cached_x_token, target_skill_id) + if fetched is None: + update_message = ( + "Could not fetch publication status — Yandex Dialogs is " + "not reachable, or the skill no longer exists in your account." + ) + else: + publication_status = fetched + update_message = "Publication status refreshed." + + elif action == CONF_ACTION_EXPORT_MANIFEST: + manifest_message = manifest_provider.export_to_override() + + elif action == CONF_ACTION_IMPORT_MANIFEST: + paste = str(values.get(CONF_DIALOG_SKILL_OVERRIDE_PASTE) or "") + manifest_message = manifest_provider.import_from_paste(paste) + if manifest_provider.last_import_success: + # Successful import — clear the paste field so the form + # doesn't redisplay the (now stale) raw TOML. + values[CONF_DIALOG_SKILL_OVERRIDE_PASTE] = "" + + elif action == CONF_ACTION_RESET_MANIFEST: + manifest_message = manifest_provider.reset_override() + + elif action == CONF_ACTION_VALIDATE_MANIFEST: + manifest_message = manifest_provider.validate_override_message() + + # ---- Reflect outcome into values so the next form save persists state ---- + if action_outcome is not None: + artifacts = action_outcome.artifacts + if action_outcome.x_token is not None: + cached_x_token = action_outcome.x_token + # Surface duplicate-name pre-check result into hidden form values + # so the next render shows the Recreate / Adopt resolution UI. + if action_outcome.stage == LocalAutoCreateStage.DUPLICATE_DETECTED: + values[CONF_PENDING_DUPLICATE_SKILL_ID] = ( + action_outcome.pending_duplicate_skill_id or "" + ) + values[CONF_PENDING_DUPLICATE_SKILL_NAME] = ( + action_outcome.pending_duplicate_skill_name or "" + ) + elif action_outcome.stage in ( + LocalAutoCreateStage.DONE, + LocalAutoCreateStage.FAILED, + ): + values[CONF_PENDING_DUPLICATE_SKILL_ID] = "" + values[CONF_PENDING_DUPLICATE_SKILL_NAME] = "" + + values[CONF_DIALOG_AUTO_CREATE_ARTIFACTS] = dump_artifacts(artifacts) + values[CONF_AUTH_X_TOKEN] = cached_x_token + if artifacts.state == SkillCreationState.DONE and artifacts.skill_id: + values[CONF_DIALOG_SKILL_ID] = artifacts.skill_id + + # ---- Post-deploy publication-status snapshot ---- + # One HTTP call right after a deploy-related action so the Step 3 + # banner reflects Yandex's view as of the moment the user clicked. + # CONF_ACTION_REFRESH_STATUS already fetched inside its handler; + # other actions either don't change publication state (sign-in, + # cancel, edit-mode toggles) or run their own snapshot inline. + deploy_actions_for_status_fetch = ( + CONF_ACTION_AUTO_CREATE_DIALOG, + CONF_ACTION_RECREATE_DUPLICATE, + CONF_ACTION_ADOPT_EXISTING, + CONF_ACTION_UPDATE_SKILL, + CONF_ACTION_RENAME_DIALOG_SKILL, + ) + if ( + action in deploy_actions_for_status_fetch + and artifacts.state == SkillCreationState.DONE + and artifacts.skill_id + and cached_x_token + ): + fetched_status = await fetch_skill_publication_status(cached_x_token, artifacts.skill_id) + if fetched_status is not None: + publication_status = fetched_status + + values[CONF_DIALOG_PUBLICATION_STATUS] = publication_status + + # ---- Player options for voice exposure ---- + player_options = await _list_player_options(mass) + + # ---- External base URL: autodetect (#8) + inline HTTPS warning ---- + user_supplied_base_url = str(values.get(CONF_EXTERNAL_BASE_URL) or "").strip() + if not user_supplied_base_url: + # First preference: a public HTTPS URL (ready to use). Second: + # any base URL MA knows about — typically the internal docker / + # LAN URL. We pre-fill it so the user has a starting point to + # edit (e.g. swap the host for their reverse-proxy domain). + detected_public = try_detect_public_https_url(mass) + if detected_public: + values[CONF_EXTERNAL_BASE_URL] = detected_public + external_base_url = detected_public + base_url_description = ( + f"Auto-detected public HTTPS URL: {detected_public}. " + "Edit if you use a different reverse-proxy URL." + ) + else: + detected_any = try_detect_any_base_url(mass) + if detected_any: + values[CONF_EXTERNAL_BASE_URL] = detected_any + external_base_url = detected_any + base_url_description = ( + f"Pre-filled from MA's webserver settings: {detected_any}. " + "Yandex needs a *public HTTPS* URL — replace this with " + "your reverse-proxy / DDNS hostname (e.g. " + "https://ma.example.com) before creating the skill." + ) + else: + base_url_description = ( + "Public HTTPS URL of this Music Assistant instance — the " + "address Yandex will use to reach the webhook. " + "Examples: https://ma.example.com, https://ha.example.com. " + "Required for auto-create." + ) + elif not is_public_https_url(user_supplied_base_url): + base_url_description = ( + "❌ This URL is not a public HTTPS endpoint — Yandex requires " + "https:// and a non-private host. Auto-create will refuse this. " + f"Got: {user_supplied_base_url!r}" + ) + else: + base_url_description = ( + f"Public HTTPS URL: {user_supplied_base_url}. " + "Click 'Test webhook' below to verify Yandex can reach it." + ) + + # ---- Auto-create cluster: Step 1 / 2 / 3 dispatcher ---- + duplicate_skill_id = _resolve_saved_value(values, CONF_PENDING_DUPLICATE_SKILL_ID).strip() + duplicate_skill_name = _resolve_saved_value(values, CONF_PENDING_DUPLICATE_SKILL_NAME).strip() + edit_mode = bool(values.get(CONF_EDIT_MODE, False)) + activation_phrase_2_value = _resolve_saved_value(values, CONF_DIALOG_ACTIVATION_PHRASE_2) + activation_phrase_3_value = _resolve_saved_value(values, CONF_DIALOG_ACTIVATION_PHRASE_3) + activation_phrase_4_value = _resolve_saved_value(values, CONF_DIALOG_ACTIVATION_PHRASE_4) + voice_value = _resolve_saved_value(values, CONF_DIALOG_SKILL_VOICE) or DIALOG_VOICE_DEFAULT + + # Surface a sign-in error in the Authorization block as ✗ LABEL. + sign_in_error: str | None = update_message if update_message and not cached_x_token else None + user_name = _resolve_saved_value(values, CONF_AUTH_USER_NAME) + if not user_name and saved_provider is not None: + try: + user_name = str(saved_provider.get_value(CONF_AUTH_USER_NAME) or "") # type: ignore[attr-defined] + except Exception: + user_name = "" + + # ---- Hidden state-carrier entries (round-trip persistence) ---- + # Use ``value=`` (not ``default_value=``) so MA frontend round-trips + # the actual current state on form Save. + hidden_state_entries = ( + ConfigEntry( + key=CONF_AUTH_X_TOKEN, + type=ConfigEntryType.SECURE_STRING, + label="Yandex Passport x_token (cached)", + description="Cached after first successful Device Flow.", + required=False, + value=cached_x_token, + hidden=True, + ), + ConfigEntry( + key=CONF_AUTH_USER_NAME, + type=ConfigEntryType.STRING, + label="Yandex display login (cached)", + description="Surfaced as 'Authorized as ' banner.", + required=False, + value=user_name, + hidden=True, + ), + ConfigEntry( + key=CONF_DIALOG_AUTO_CREATE_ARTIFACTS, + type=ConfigEntryType.STRING, + label="Auto-create artifacts (JSON)", + description="State machine snapshot — persisted between clicks.", + required=False, + value=dump_artifacts(artifacts), + hidden=True, + ), + ConfigEntry( + key=CONF_PENDING_DUPLICATE_SKILL_ID, + type=ConfigEntryType.STRING, + label="Pending duplicate skill_id", + description="Persisted when duplicate-name pre-check finds a match.", + required=False, + value=duplicate_skill_id, + hidden=True, + ), + ConfigEntry( + key=CONF_PENDING_DUPLICATE_SKILL_NAME, + type=ConfigEntryType.STRING, + label="Pending duplicate skill name", + description="Display name of the duplicate skill (Yandex spelling).", + required=False, + value=duplicate_skill_name, + hidden=True, + ), + ConfigEntry( + key=CONF_EDIT_MODE, + type=ConfigEntryType.BOOLEAN, + label="Edit mode", + description="Reveals editable activation phrases / voice fields.", + required=False, + value=edit_mode, + hidden=True, + ), + ConfigEntry( + key=CONF_DIALOG_PUBLICATION_STATUS, + type=ConfigEntryType.STRING, + label="Yandex skill publication status (cached)", + description=( + "Last known on_air / in_moderation / draft / rejected /" + " unknown classification fetched from Yandex snapshot." + ), + required=False, + value=publication_status, + hidden=True, + ), + ) + + diagnostics_entries = _build_diagnostics_entries(mass, instance_id) + use_different_instance_name = bool(values.get(CONF_USE_DIFFERENT_INSTANCE_NAME, False)) + + manifest_status = manifest_provider.status() + manifest_paste = str(values.get(CONF_DIALOG_SKILL_OVERRIDE_PASTE) or "") + + return build_form_entries( + artifacts=artifacts, + cached_x_token_present=bool(cached_x_token), + user_name=user_name, + skill_id_value=str(values.get(CONF_DIALOG_SKILL_ID) or "").strip(), + skill_token_value=skill_token_value, + webhook_secret=default_secret, + last_error=sign_in_error, + action_outcome=action_outcome, + duplicate_skill_id=duplicate_skill_id or None, + duplicate_skill_name=duplicate_skill_name or None, + edit_mode=edit_mode, + skill_name=skill_name, + activation_phrase_2=activation_phrase_2_value, + activation_phrase_3=activation_phrase_3_value, + activation_phrase_4=activation_phrase_4_value, + voice=voice_value, + update_message=update_message, + external_base_url=external_base_url, + base_url_description=base_url_description, + base_url_valid=bool(external_base_url) and is_public_https_url(external_base_url), + player_options=player_options, + instance_name=instance_name, + use_different_instance_name=use_different_instance_name, + publication_status=publication_status or None, + diagnostics=diagnostics_entries, + manifest_status=manifest_status, + manifest_paste=manifest_paste, + manifest_message=manifest_message, + hidden_state=hidden_state_entries, + ) diff --git a/music_assistant/providers/yandex_alice/auth_page.py b/music_assistant/providers/yandex_alice/auth_page.py new file mode 100644 index 0000000000..3f4673897a --- /dev/null +++ b/music_assistant/providers/yandex_alice/auth_page.py @@ -0,0 +1,486 @@ +"""Yandex Passport Device Flow with a custom HTML user_code page. + +The form-side click on *Sign in to Yandex Passport* is a single +**blocking** action: ``perform_device_auth`` starts a Device Flow, +opens an MA-hosted landing page (with the user_code + a Continue +button) as an ``AuthenticationHelper`` popup, polls Passport until +the user confirms in Yandex, and returns the resulting ``x_token``. +The dispatcher writes that token into ``values[CONF_AUTH_X_TOKEN]``, +and the next form render flips into Step 2. + +The blocking pattern is dictated by MA's frontend: in *add-provider* +mode hidden ``ConfigEntry``s do not round-trip between successive +``ACTION`` clicks until the provider is saved, so a multi-click +self-resuming flow is impossible there. Both ``yandex-music`` and +``yandex-smarthome`` use the same blocking pattern. + +The HTML page is registered as a dynamic route and torn down inside +the same ``async with`` so it never outlives the auth attempt. The +status endpoint flips to ``done`` once the backend captures the +``x_token`` so the page can auto-close itself. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import html +import json +import logging +from collections.abc import Callable +from typing import TYPE_CHECKING + +from aiohttp import web +from music_assistant_models.errors import LoginFailed +from ya_passport_auth import PassportClient +from ya_passport_auth.exceptions import ( + DeviceCodeTimeoutError, + YaPassportError, +) + +if TYPE_CHECKING: + from music_assistant.mass import MusicAssistant + +_LOGGER = logging.getLogger(__name__) + +DEVICE_CODE_PAGE_BASE_PATH = "/yandex_alice/device_code" + +# Seconds to keep the status endpoint alive after the flow finishes so +# the intermediate page has a chance to poll once more and close itself. +_POST_AUTH_GRACE_SECONDS = 3 + +# Callable returning the current device-flow state for status polls. +StateProvider = Callable[[], str] + +__all__ = [ + "DEVICE_CODE_PAGE_BASE_PATH", + "StateProvider", + "build_device_code_page_url", + "perform_device_auth", + "register_device_code_route", + "unregister_device_code_route", +] + + +def build_device_code_page_url(base_url: str, session_id: str) -> str: + """Return the absolute URL of the device-code page for a session.""" + if not base_url or not session_id: + return "" + return f"{base_url.rstrip('/')}{DEVICE_CODE_PAGE_BASE_PATH}/{session_id}" + + +def _build_device_code_page( + *, + user_code: str, + verification_url: str, + status_url: str, + skill_name: str, +) -> str: + """Render the HTML page shown to the user during Device Flow login.""" + safe_code = html.escape(user_code) + safe_url = html.escape(verification_url, quote=True) + safe_skill = html.escape(skill_name or "Music Assistant") + safe_status_url = json.dumps(status_url).replace(" + + + + Yandex Alice — Device Code + + + + +
+

Sign in to Yandex Passport

+

+ Music Assistant will register the + {safe_skill} dialog skill on your behalf. + Open the link below and enter this code in Yandex Passport. +

+
{safe_code}
+
+ +
+ Continue to Yandex +

+ After confirming in Yandex, return to Music Assistant and click + "I confirmed - continue". This page will close itself once the + backend captures your sign-in. +

+
+ + + +""" + + +def _device_code_page_path(session_id: str) -> str: + return f"{DEVICE_CODE_PAGE_BASE_PATH}/{session_id}" + + +def _device_code_status_path(session_id: str) -> str: + return f"{DEVICE_CODE_PAGE_BASE_PATH}/{session_id}/status" + + +def register_device_code_route( + mass: MusicAssistant, + *, + session_id: str, + user_code: str, + verification_url: str, + skill_name: str, + state_provider: StateProvider, +) -> str: + """Register the device-code page + status routes; return the page URL. + + Idempotent: any existing route at the same path is unregistered + first so this can be called on every form render without piling + up handlers. Returns ``""`` if the webserver is unavailable. + + The ``state_provider`` callable is invoked on every status poll + and must return one of ``"pending"``, ``"done"``, ``"failed"``. + Wired by the dispatcher to read live form state — the page closes + itself once the dispatcher has captured a fresh ``cached_x_token`` + and cleared the device-flow session blob. + """ + if not session_id or not user_code or not verification_url: + return "" + webserver = getattr(mass, "webserver", None) + if webserver is None or not hasattr(webserver, "register_dynamic_route"): + _LOGGER.debug("auth_page: webserver unavailable; skipping route registration") + return "" + + page_path = _device_code_page_path(session_id) + status_path = _device_code_status_path(session_id) + # Build full URLs via ``mass.webserver.base_url`` — that handle is + # already ingress-aware in HA add-on mode (matches the yandex-music + # provider's auth flow), so the URLs include the add-on prefix + # ``//`` when MA is reached through HA ingress. + base = (getattr(webserver, "base_url", "") or "").rstrip("/") + page_url = f"{base}{page_path}" if base else page_path + status_url = f"{base}{status_path}" if base else status_path + page_html = _build_device_code_page( + user_code=user_code, + verification_url=verification_url, + status_url=status_url, + skill_name=skill_name, + ) + + 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: + try: + state = state_provider() + except Exception as exc: + _LOGGER.debug("auth_page: state_provider raised: %r", exc) + state = "pending" + return web.json_response( + {"state": state}, + headers={"Cache-Control": "no-store"}, + ) + + # Idempotent registration - drop any existing registration at the + # same paths first so a re-render doesn't blow up. + for path in (page_path, status_path): + with contextlib.suppress(Exception): + webserver.unregister_dynamic_route(path, "GET") + + try: + webserver.register_dynamic_route(page_path, _serve_page, "GET") + webserver.register_dynamic_route(status_path, _serve_status, "GET") + except Exception as exc: + _LOGGER.warning("auth_page: failed to register device-code route: %r", exc) + return "" + _LOGGER.debug( + "auth_page: registered device-code routes session_id=%r page=%s status=%s", + session_id, + page_path, + status_path, + ) + + return page_url + + +# Delay before tearing down `/device_code/{id}` + `/device_code/{id}/status` +# routes after the auth flow completes. The popup HTML polls the status +# endpoint every 2.5 s and closes itself ~800 ms after seeing +# ``state=done``; a 30 s window covers tab-throttling, a sleeping laptop +# rejoining the network, and the popup waking from a backgrounded tab. +# Until the delay elapses the routes keep returning the terminal state +# so a late poll still gets a clean answer instead of a 404. +_UNREGISTER_DELAY_SECONDS = 30 + + +def unregister_device_code_route(mass: MusicAssistant, *, session_id: str) -> None: + """Tear down the device-code page + status routes for a session. + + Synchronous removal — used at module unload / hard cleanup. The + auth-flow happy path uses :func:`schedule_unregister_device_code_route` + instead so the popup has a window to poll the terminal state and + close itself. + """ + if not session_id: + return + webserver = getattr(mass, "webserver", None) + if webserver is None: + return + for path in (_device_code_page_path(session_id), _device_code_status_path(session_id)): + with contextlib.suppress(Exception): + webserver.unregister_dynamic_route(path, "GET") + _LOGGER.debug( + "auth_page: unregistered device-code routes session_id=%r", + session_id, + ) + + +def schedule_unregister_device_code_route( + mass: MusicAssistant, + *, + session_id: str, + delay: float = _UNREGISTER_DELAY_SECONDS, +) -> None: + """Schedule a delayed unregister so the popup sees the terminal state. + + The auth-flow ``finally`` block calls this instead of removing the + routes synchronously. While the delay elapses the ``state_provider`` + closure keeps returning ``"done"`` / ``"failed"`` so the popup + polling the status endpoint still gets a clean reply and closes + itself naturally; only after the delay is the route torn down to + free memory. + """ + if not session_id: + return + + async def _delayed() -> None: + await asyncio.sleep(delay) + unregister_device_code_route(mass, session_id=session_id) + + create_task = getattr(mass, "create_task", None) + if callable(create_task): + # MA's task tracker — preferred so the task is cancelled cleanly + # on shutdown and unhandled exceptions are logged. + create_task(_delayed()) + return + # Fallback: detached asyncio task. Same effect, slightly noisier on + # event-loop shutdown but acceptable for unit-test scenarios where + # ``mass`` is a MagicMock. + asyncio.create_task(_delayed()) + + +# --------------------------------------------------------------------------- +# Blocking Device Flow — single-action sign-in +# --------------------------------------------------------------------------- + + +async def perform_device_auth( + mass: MusicAssistant, + session_id: str, + *, + skill_name: str = "Music Assistant", +) -> tuple[str, str]: + """Run a complete Yandex Passport Device Flow. + + Returns ``(x_token, display_login)`` — the long-lived auth token + plus the user-visible Yandex login (used in the "Authorized as + " banner). ``display_login`` is ``""`` when Yandex didn't + return one. + + Blocks for the lifetime of the user's confirmation step (up to + ~10 min). Hosts an HTML landing page at + ``/yandex_alice/device_code/`` for the duration and + forwards it to the user via ``AuthenticationHelper`` popup. + + ``session_id`` **must** be the ``values["session_id"]`` value MA's + frontend supplies on every ACTION invocation — popping a popup on + any other channel results in a popup the frontend isn't listening + for, so it never appears. + + :raises LoginFailed: the Device Flow timed out, was rejected by + Yandex, or another Passport-level error escaped. + """ + if not session_id: + raise LoginFailed( + "Missing session_id from the config-flow frontend. " + "Sign-in needs the id MA's frontend supplies on every " + "ACTION invocation." + ) + # Local import: ``music_assistant`` is the server package, not the + # models-only public API — only available inside an MA runtime, so + # importing it at module load would break unit tests that exercise + # this module without a real MA instance. + from music_assistant.helpers.auth import AuthenticationHelper # noqa: PLC0415 + + try: + async with PassportClient.create() as client: + session = await client.start_device_login(device_name="Music Assistant") + _LOGGER.info( + "Device flow started: open %s (expires in %ss)", + session.verification_url, + session.expires_in, + ) + state: dict[str, str] = {"value": "pending"} + + page_url = register_device_code_route( + mass, + session_id=session_id, + user_code=session.user_code, + verification_url=session.verification_url, + skill_name=skill_name, + state_provider=lambda: state["value"], + ) + + try: + async with AuthenticationHelper(mass, session_id) as auth_helper: + auth_helper.send_url( + page_url or f"{session.verification_url}?user_code={session.user_code}" + ) + try: + creds = await client.poll_device_until_confirmed(session) + except asyncio.CancelledError: + raise + except Exception: + state["value"] = "failed" + # Give the page one last poll to surface the + # failure before we tear the route down. + await asyncio.sleep(_POST_AUTH_GRACE_SECONDS) + raise + state["value"] = "done" + # Let the page pick up "done" and close itself. + await asyncio.sleep(_POST_AUTH_GRACE_SECONDS) + finally: + # Don't unregister synchronously — the popup polls every + # 2.5 s and may need a few seconds more to land its next + # request after we've returned (slow client, throttled + # background tab, network hiccup). Schedule a delayed + # unregister so the terminal state stays reachable for + # 30 s; the popup sees `done` / `failed` and closes + # itself before the route disappears. The captured + # ``state["value"]`` is the same closure the routes + # already serve, so the answer is correct even after + # this function returns. + schedule_unregister_device_code_route(mass, session_id=session_id) + + x_token = creds.x_token.get_secret() + display_login = (creds.display_login or "").strip() + _LOGGER.debug( + "Device flow complete, captured x_token (len=%d) for %r", + len(x_token), + display_login or "", + ) + return x_token, display_login + + except DeviceCodeTimeoutError as err: + raise LoginFailed("Device authentication timed out — please try again.") from err + except YaPassportError as err: + raise LoginFailed(f"Yandex Passport error: {err}") from err diff --git a/music_assistant/providers/yandex_alice/auth_session.py b/music_assistant/providers/yandex_alice/auth_session.py new file mode 100644 index 0000000000..a1ddd99018 --- /dev/null +++ b/music_assistant/providers/yandex_alice/auth_session.py @@ -0,0 +1,80 @@ +"""Yandex Passport session helpers for the auto-create / auto-update flows. + +Two pieces of plumbing: + +- :func:`passport_client_session` — single context-manager factory for + :class:`PassportClient`, so tests can monkeypatch one entry point. +- :func:`make_cached_authenticator` — produces a no-arg async-context-manager + factory (the ``AuthenticatorCM`` shape ``ya-dialogs-api`` expects) that + populates Passport cookies from a cached ``x_token`` and refuses any + Device Flow fallback. Used by both pipeline-step calls in auto-create + (post-auth) and every auto-update call. + +Cache-only-by-design: we never fall back to interactive Device Flow from +inside the authenticator. The caller (auto_create.py) decides separately +whether to run a Device Flow click; mixing the two would let a stale token +silently re-trigger user-code prompts mid-pipeline. +""" + +from __future__ import annotations + +from collections.abc import AsyncIterator, Callable +from contextlib import AbstractAsyncContextManager, asynccontextmanager + +import aiohttp +from ya_passport_auth import PassportClient, SecretStr + +AuthenticatorCM = Callable[[], AbstractAsyncContextManager[aiohttp.ClientSession]] + + +@asynccontextmanager +async def passport_client_session() -> AsyncIterator[PassportClient]: + """Yield a :class:`PassportClient` that owns its session and cleans it up. + + Single entry point so tests can monkeypatch + ``provider.auth_session.passport_client_session`` instead of patching + ``ya_passport_auth.PassportClient.create`` directly. + """ + async with PassportClient.create() as client: + yield client + + +@asynccontextmanager +async def cached_authenticated_session(x_token: str) -> AsyncIterator[aiohttp.ClientSession]: + """Yield an aiohttp session pre-populated with Yandex Passport cookies. + + Owns the session — closes it on exit. Any + :class:`ya_passport_auth.InvalidCredentialsError` from + ``refresh_passport_cookies`` propagates so the caller can clear the + cached token and start a fresh Device Flow on the next click. + + :raises ValueError: ``x_token`` is empty. + """ + if not x_token: + msg = "x_token is empty — cached authenticator requires an existing token" + raise ValueError(msg) + + secret = SecretStr(x_token) + jar = aiohttp.CookieJar() + async with aiohttp.ClientSession(cookie_jar=jar) as session: + client = PassportClient(session=session) + await client.refresh_passport_cookies(secret) + yield session + + +def make_cached_authenticator(x_token: str) -> AuthenticatorCM: + """Return an ``AuthenticatorCM`` matching the ya-dialogs-api contract. + + Each invocation of the returned factory opens a fresh + :func:`cached_authenticated_session` — short-lived, scoped to a single + pipeline run. ``ya-dialogs-api`` keeps the session open only for the + duration of one ``auto_create_skill`` / ``auto_update_skill`` call. + """ + if not x_token: + msg = "x_token is empty" + raise ValueError(msg) + + def _factory() -> AbstractAsyncContextManager[aiohttp.ClientSession]: + return cached_authenticated_session(x_token) + + return _factory diff --git a/music_assistant/providers/yandex_alice/auto_create.py b/music_assistant/providers/yandex_alice/auto_create.py new file mode 100644 index 0000000000..2898a5ba2e --- /dev/null +++ b/music_assistant/providers/yandex_alice/auto_create.py @@ -0,0 +1,444 @@ +"""Dialog-skill creation orchestrator — blocking single-click pipeline. + +Each click on *Create skill* runs ``auto_create_skill`` end-to-end +against the Yandex Dialogs developer console using the cached +``x_token`` already captured by the Step 1 Device Flow (see +``provider.auth_page.perform_device_auth``). The pipeline is +``create_app → upload_logo → update_draft → request_deploy`` — a +handful of fast HTTP calls to ``dialogs.yandex.ru``; the whole click +typically completes in 5-15 seconds. + +Before the create_app step we run a duplicate-name pre-check — if a +skill with the same name already exists in the user's account we +short-circuit into a UX state that lets the user pick *Recreate* or +*Adopt*. + +Two helpers expose the resolution paths: + +- :func:`adopt_existing_skill` — pre-position artifacts to + ``APP_CREATED`` with the discovered ``skill_id`` and run the rest + of the pipeline (re-deploys against our backend URL). +- :func:`delete_existing_skill_then_recreate` — call ``delete_skill`` + then run a fresh pipeline. + +This module is **stateless w.r.t. the form**: it does not persist +anything between clicks. The previous self-resuming Device Flow +machinery was removed because MA's frontend does not round-trip +hidden ``ConfigEntry``s between successive ACTION clicks in +*add-provider* mode, so a multi-click flow is impossible there. +""" + +from __future__ import annotations + +import contextlib +import dataclasses +import json +import logging +from collections.abc import Callable, Mapping +from dataclasses import dataclass +from enum import StrEnum +from typing import TYPE_CHECKING, Any + +from ya_dialogs_api import ( + DialogsSkillCreator, + EntityDraft, + IntentDraft, + SkillCreationArtifacts, + SkillCreationState, + auto_create_skill, +) +from ya_dialogs_api.errors import DialogsValidationError +from ya_passport_auth.exceptions import InvalidCredentialsError + +from .auth_session import cached_authenticated_session, make_cached_authenticator +from .constants import DIALOG_CHANNEL +from .skill_logo import load_skill_logo_bytes + +if TYPE_CHECKING: + import aiohttp + +_LOGGER = logging.getLogger(__name__) + +__all__ = [ + "AutoCreateOutcome", + "LocalAutoCreateStage", + "adopt_existing_skill", + "delete_existing_skill_then_recreate", + "run_create_skill", +] + + +class LocalAutoCreateStage(StrEnum): + """High-level UX stage derived from artifacts + cached token. + + Drives the section routing in :mod:`provider.auto_create_view` + and the button label / cancel visibility in Step 2. + + Not persisted directly: rendered on every click from + ``(SkillCreationArtifacts, cached_x_token_present, + duplicate_pending)``. + """ + + IDLE = "idle" + PIPELINE_RUNNING = "pipeline_running" + DUPLICATE_DETECTED = "duplicate_detected" + DONE = "done" + FAILED = "failed" + + +@dataclass(frozen=True, slots=True) +class AutoCreateOutcome: + """Result of one click on the Create skill / Recreate / Adopt button. + + The dispatcher in :mod:`provider.__init__` writes ``artifacts`` + and (when set) ``x_token`` into the form ``values`` so MA persists + them on the next save. ``user_message`` feeds the LABEL the user + sees while the flow is in progress. + """ + + artifacts: SkillCreationArtifacts + """Latest snapshot — overwrites CONF_DIALOG_AUTO_CREATE_ARTIFACTS.""" + + x_token: str | None + """Newly minted x_token, ``""`` to clear cache, ``None`` to leave existing.""" + + user_message: str + """Human-readable status for the LABEL entry.""" + + stage: LocalAutoCreateStage + """High-level UX stage — drives button label + section routing.""" + + pending_duplicate_skill_id: str | None = None + """skill_id of a pre-existing same-name skill found by the + duplicate pre-check. Non-None signals + ``stage=DUPLICATE_DETECTED``; the dispatcher persists this in a + hidden config entry so the next render shows the Recreate / Adopt + resolution UI.""" + + pending_duplicate_skill_name: str | None = None + """Display name of the duplicate skill (as registered in Yandex).""" + + +# --------------------------------------------------------------------------- +# Duplicate pre-check +# --------------------------------------------------------------------------- + + +async def _pre_check_duplicate( + cached_x_token: str, + skill_name: str, +) -> tuple[str, str] | None: + """Probe Yandex for an existing skill with the same name (case-insensitive). + + Returns ``(skill_id, registered_name)`` or ``None`` if no match. + Any exception is logged and treated as "no match" — the duplicate + check is best-effort guidance, not a hard gate; if Yandex is + flaky we let ``auto_create_skill`` proceed and surface its own + ``DialogsDuplicateSkillError`` from there. + """ + target = skill_name.strip().casefold() + if not target: + return None + try: + async with cached_authenticated_session(cached_x_token) as session: + creator = DialogsSkillCreator(session, channel=DIALOG_CHANNEL) + csrf = await creator.fetch_csrf() + skills = await creator.list_existing_skills(csrf) + except Exception as exc: + _LOGGER.debug("auto-create: duplicate pre-check failed: %r", exc) + return None + for entry in skills: + candidate = str(entry.get("appName") or entry.get("name") or "").strip() + if not candidate: + continue + if candidate.casefold() == target: + sid = str(entry.get("id") or entry.get("skill_id") or "").strip() + if sid: + return (sid, candidate) + return None + + +# --------------------------------------------------------------------------- +# Pipeline runner +# --------------------------------------------------------------------------- + + +def _make_logging_creator_factory() -> Callable[[aiohttp.ClientSession], DialogsSkillCreator]: + """Build a creator factory that logs update_draft payload + errors. + + Wrapping is applied to the *instance* method post-construction so + we don't need to subclass ``DialogsSkillCreator`` (which has + ``__slots__`` and is monkeypatched in tests). Returns a no-op + factory if patching fails for any reason — production debugging + should never break the create flow. + """ + + def _factory(session: aiohttp.ClientSession) -> DialogsSkillCreator: + creator = DialogsSkillCreator(session, channel=DIALOG_CHANNEL) + original_update_draft = creator.update_draft + + async def _logged_update_draft( + csrf: str, skill_id: str, payload: Mapping[str, Any] + ) -> None: + _LOGGER.debug( + "update_draft payload: %s", + json.dumps(payload, ensure_ascii=False)[:4000], + ) + try: + await original_update_draft(csrf, skill_id, payload) + except DialogsValidationError as exc: + _LOGGER.warning( + "update_draft REJECTED by Yandex: status=%s yandex_error=%r fields=%r", + getattr(exc, "http_status", None), + getattr(exc, "yandex_error", None), + getattr(exc, "fields", None), + ) + raise + + with contextlib.suppress(AttributeError, TypeError): + creator.update_draft = _logged_update_draft # type: ignore[method-assign] + return creator + + return _factory + + +async def _run_pipeline( + *, + cached_x_token: str, + skill_name: str, + backend_uri: str, + description: str, + structured_examples: list[dict[str, Any]] | None, + activation_phrases: list[str] | None, + intents: list[IntentDraft], + entities: list[EntityDraft], + artifacts: SkillCreationArtifacts, + skip_duplicate_check: bool = False, +) -> AutoCreateOutcome: + """Run the OAuth-free aliceSkill pipeline end-to-end on cached cookies. + + Error handling has two layers: + + 1. ``ya_dialogs_api.auto_create_skill`` itself catches every + :class:`DialogsApiError` subclass internally and returns + artifacts with ``state=FAILED`` and a populated ``last_error``. + 2. :class:`InvalidCredentialsError` from + ``ya_passport_auth.refresh_passport_cookies`` is the one + exception that escapes the library because cookie refresh + happens inside the authenticator context manager. We translate + it into ``outcome.x_token=""`` so the dispatcher clears the + cache and the next click triggers a fresh sign-in. + + Before the create_app step (i.e. when ``artifacts.state == NONE``) + we run a duplicate-name pre-check and short-circuit into + ``DUPLICATE_DETECTED`` if a match is found. Bypass with + ``skip_duplicate_check=True`` (Recreate / Adopt resumption paths). + """ + if not skip_duplicate_check and artifacts.state == SkillCreationState.NONE: + duplicate = await _pre_check_duplicate(cached_x_token, skill_name) + if duplicate is not None: + existing_id, existing_name = duplicate + return AutoCreateOutcome( + artifacts=artifacts, + x_token=None, + user_message=( + f"A skill named «{existing_name}» already exists in your " + "Yandex Dialogs account. Choose Recreate or Adopt below." + ), + stage=LocalAutoCreateStage.DUPLICATE_DETECTED, + pending_duplicate_skill_id=existing_id, + pending_duplicate_skill_name=existing_name, + ) + + authenticator = make_cached_authenticator(cached_x_token) + + try: + result = await auto_create_skill( + authenticator=authenticator, + skill_name=skill_name, + artifacts=artifacts, + backend_uri=backend_uri, + channel=DIALOG_CHANNEL, + description=description, + structured_examples=structured_examples, + activation_phrases=activation_phrases, + intents=intents, + entities=entities, + logo_bytes=load_skill_logo_bytes(), + creator_factory=_make_logging_creator_factory(), + ) + except InvalidCredentialsError as exc: + _LOGGER.warning("auto-create: cached x_token rejected by Passport: %s", exc) + failed = dataclasses.replace( + artifacts, + state=SkillCreationState.FAILED, + last_error="Cached auth has expired. Click 'Sign in' again to re-authenticate.", + ) + return AutoCreateOutcome( + artifacts=failed, + x_token="", + user_message=str(failed.last_error), + stage=LocalAutoCreateStage.FAILED, + ) + + if result.state == SkillCreationState.DONE: + skill_url = ( + f"https://dialogs.yandex.ru/developer/skills/{result.skill_id}" + if result.skill_id + else "https://dialogs.yandex.ru/developer" + ) + message = ( + f"Skill created (skill_id={result.skill_id}). " + f"Yandex moderation queue: 5-15 minutes. " + f"Check on-air status: {skill_url}" + ) + return AutoCreateOutcome( + artifacts=result, + x_token=None, + user_message=message, + stage=LocalAutoCreateStage.DONE, + ) + + return _outcome_from_failed_pipeline(result) + + +def _outcome_from_failed_pipeline(result: SkillCreationArtifacts) -> AutoCreateOutcome: + """Translate a FAILED ``SkillCreationArtifacts`` into a UX outcome.""" + msg = result.last_error or "Pipeline failed without a description." + return AutoCreateOutcome( + artifacts=result, + x_token=None, + user_message=msg, + stage=LocalAutoCreateStage.FAILED, + ) + + +# --------------------------------------------------------------------------- +# Public entry points +# --------------------------------------------------------------------------- + + +async def run_create_skill( + *, + cached_x_token: str, + skill_name: str, + backend_uri: str, + description: str, + structured_examples: list[dict[str, Any]] | None, + activation_phrases: list[str] | None, + intents: list[IntentDraft], + entities: list[EntityDraft], + artifacts: SkillCreationArtifacts, +) -> AutoCreateOutcome: + """Drive the create_skill click — duplicate pre-check + full pipeline. + + Pre-conditions: + + - ``cached_x_token`` is non-empty (caller must have run + :func:`provider.auth_page.perform_device_auth` first). + - ``backend_uri`` is fully assembled (HTTPS public URL + + webhook secret). + - ``intents`` / ``entities`` come from + :class:`SkillManifestProvider` (effective manifest). + + Backup-restore safety: if ``artifacts.state == NONE`` but the + caller has a saved ``skill_id`` in form values, the dispatcher + pre-positions ``artifacts.state = APP_CREATED`` *before* invoking + this function so the library skips create_app and patches the + existing skill rather than creating a duplicate. + """ + return await _run_pipeline( + cached_x_token=cached_x_token, + skill_name=skill_name, + backend_uri=backend_uri, + description=description, + structured_examples=structured_examples, + activation_phrases=activation_phrases, + intents=intents, + entities=entities, + artifacts=artifacts, + ) + + +async def adopt_existing_skill( + *, + cached_x_token: str, + skill_name: str, + backend_uri: str, + description: str, + structured_examples: list[dict[str, Any]] | None, + activation_phrases: list[str] | None, + intents: list[IntentDraft], + entities: list[EntityDraft], + existing_skill_id: str, +) -> AutoCreateOutcome: + """Re-deploy an existing skill against this MA's webhook URL. + + Pre-positions ``artifacts`` to ``APP_CREATED`` (with the + discovered ``skill_id``) so ``auto_create_skill`` skips + create_app and runs ``upload_logo → update_draft → + request_deploy`` against the existing skill instead. The library + is idempotent on each sub-step, so adopting an already-on-air + skill is safe. + """ + artifacts = SkillCreationArtifacts( + state=SkillCreationState.APP_CREATED, + skill_id=existing_skill_id, + ) + return await _run_pipeline( + cached_x_token=cached_x_token, + skill_name=skill_name, + backend_uri=backend_uri, + description=description, + structured_examples=structured_examples, + activation_phrases=activation_phrases, + intents=intents, + entities=entities, + artifacts=artifacts, + skip_duplicate_check=True, + ) + + +async def delete_existing_skill_then_recreate( + *, + cached_x_token: str, + skill_name: str, + backend_uri: str, + description: str, + structured_examples: list[dict[str, Any]] | None, + activation_phrases: list[str] | None, + intents: list[IntentDraft], + entities: list[EntityDraft], + existing_skill_id: str, +) -> AutoCreateOutcome: + """Delete the duplicate skill in Yandex, then run a fresh pipeline.""" + try: + async with cached_authenticated_session(cached_x_token) as session: + creator = DialogsSkillCreator(session, channel=DIALOG_CHANNEL) + csrf = await creator.fetch_csrf() + await creator.delete_skill(csrf, existing_skill_id) + except Exception as exc: + _LOGGER.exception("auto-create: delete_skill failed (skill_id=%s)", existing_skill_id) + failed = SkillCreationArtifacts( + state=SkillCreationState.FAILED, + last_error=f"Failed to delete the existing skill: {exc!r}", + ) + return AutoCreateOutcome( + artifacts=failed, + x_token=None, + user_message=str(failed.last_error), + stage=LocalAutoCreateStage.FAILED, + ) + + return await _run_pipeline( + cached_x_token=cached_x_token, + skill_name=skill_name, + backend_uri=backend_uri, + description=description, + structured_examples=structured_examples, + activation_phrases=activation_phrases, + intents=intents, + entities=entities, + artifacts=SkillCreationArtifacts(), + skip_duplicate_check=True, + ) diff --git a/music_assistant/providers/yandex_alice/auto_update.py b/music_assistant/providers/yandex_alice/auto_update.py new file mode 100644 index 0000000000..47c38bd76d --- /dev/null +++ b/music_assistant/providers/yandex_alice/auto_update.py @@ -0,0 +1,152 @@ +"""Auto-update wrapper around ``ya_dialogs_api.auto_update_skill``. + +Used for both rename and drift-sync clicks. ``auto_update_skill`` patches +the full draft (so a rename also re-applies description / activation +phrases / structured examples) and re-deploys; Yandex moderation queue +rules then apply (~5-15 min for ``aliceSkill``). + +Cache-only-by-design: refuses to fall back to interactive Device Flow. +If the cached x_token is missing or rejected by Passport, the outcome +signals the dispatcher to clear the cache so the next click triggers a +fresh ``auto_create`` flow. +""" + +from __future__ import annotations + +import dataclasses +import logging +from dataclasses import dataclass +from typing import Any + +from ya_dialogs_api import ( + EntityDraft, + IntentDraft, + SkillCreationArtifacts, + SkillCreationState, + auto_update_skill, +) +from ya_passport_auth.exceptions import InvalidCredentialsError + +from .auth_session import make_cached_authenticator +from .constants import DIALOG_CHANNEL + +_LOGGER = logging.getLogger(__name__) + +__all__ = ["AutoUpdateOutcome", "run_auto_update"] + + +@dataclass(frozen=True, slots=True) +class AutoUpdateOutcome: + """Result of one click on the rename / drift-sync button.""" + + artifacts: SkillCreationArtifacts + """Latest snapshot — overwrites CONF_DIALOG_AUTO_CREATE_ARTIFACTS.""" + + x_token: str | None + """``""`` to clear cached x_token (auth rejected); ``None`` to leave as-is.""" + + user_message: str + """Status / error string for the LABEL entry.""" + + +async def run_auto_update( + *, + cached_x_token: str | None, + skill_name: str, + backend_uri: str, + description: str, + structured_examples: list[dict[str, Any]] | None, + activation_phrases: list[str] | None, + intents: list[IntentDraft], + entities: list[EntityDraft], + artifacts: SkillCreationArtifacts, + voice: str | None = None, +) -> AutoUpdateOutcome: + """Patch the existing skill draft + re-deploy. No Device Flow fallback. + + Pre-conditions checked synchronously before any network call: + + - ``cached_x_token`` is non-empty — otherwise return FAILED with a hint + to run the Create button first. + - ``artifacts.skill_id`` is set — otherwise FAILED (no skill to update). + + The library itself surfaces ya-dialogs-api errors as ``state=FAILED`` / + ``last_error`` rather than raising. We translate + :class:`InvalidCredentialsError` from Passport into a token-clear + signal so the next click can re-auth cleanly. + """ + if not cached_x_token: + failed = dataclasses.replace( + artifacts, + state=SkillCreationState.FAILED, + last_error=( + "No cached authentication — click 'Sign in to Yandex Passport' " + "first to authenticate." + ), + ) + return AutoUpdateOutcome( + artifacts=failed, + x_token=None, + user_message=str(failed.last_error), + ) + + if not artifacts.skill_id: + failed = dataclasses.replace( + artifacts, + state=SkillCreationState.FAILED, + last_error="skill_id is missing — create the skill first via the 'Create' button.", + ) + return AutoUpdateOutcome( + artifacts=failed, + x_token=None, + user_message=str(failed.last_error), + ) + + authenticator = make_cached_authenticator(cached_x_token) + + try: + result = await auto_update_skill( + authenticator=authenticator, + artifacts=artifacts, + skill_name=skill_name, + backend_uri=backend_uri, + channel=DIALOG_CHANNEL, + description=description, + structured_examples=structured_examples, + activation_phrases=activation_phrases, + intents=intents, + entities=entities, + voice=voice, + ) + except InvalidCredentialsError as exc: + _LOGGER.warning("auto-update: cached x_token rejected: %s", exc) + failed = dataclasses.replace( + artifacts, + state=SkillCreationState.FAILED, + last_error=( + "Cached auth has expired. Click 'Sign in to Yandex Passport' to re-authenticate." + ), + ) + return AutoUpdateOutcome( + artifacts=failed, + x_token="", + user_message=str(failed.last_error), + ) + + if result.state == SkillCreationState.DONE: + message = ( + f"Skill updated (name: {skill_name!r}). " + "Yandex moderation queue: 5-15 minutes before the update is published." + ) + return AutoUpdateOutcome( + artifacts=result, + x_token=None, + user_message=message, + ) + + msg = result.last_error or "Failed to update the skill." + return AutoUpdateOutcome( + artifacts=result, + x_token=None, + user_message=msg, + ) diff --git a/music_assistant/providers/yandex_alice/constants.py b/music_assistant/providers/yandex_alice/constants.py new file mode 100644 index 0000000000..528ad7551e --- /dev/null +++ b/music_assistant/providers/yandex_alice/constants.py @@ -0,0 +1,204 @@ +"""Constants for the Yandex Alice (Dialogs custom skill) plugin provider.""" + +from __future__ import annotations + +import logging +import os +from typing import cast + +from ya_dialogs_api import DIALOG_CHANNEL as _LIB_DIALOG_CHANNEL +from ya_dialogs_api import Channel + +_LOGGER = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Config entry keys (user-facing) +# --------------------------------------------------------------------------- +CONF_INSTANCE_NAME = "instance_name" +# Override for MA's webserver Base URL — used when generating callback / +# webhook URLs for Yandex. Lets users keep MA's global Base URL unset (so +# HA Ingress / local access keep working) while still exposing a public +# HTTPS URL only to Yandex via a reverse proxy. +CONF_EXTERNAL_BASE_URL = "external_base_url" +CONF_EXPOSED_PLAYERS = "exposed_players" + +# Cached Yandex Passport x_token from the first successful Device Flow. +# Reused on subsequent auto-create / rename runs so the user doesn't have +# to re-confirm the device code every time. Long-lived (months); +# automatically refreshed on use. Cleared if Yandex returns 401 on refresh. +CONF_AUTH_X_TOKEN = "auth_x_token" +# Display name of the signed-in Yandex account (login or display name). +# Surfaced as a "Authorized as " banner once auth is complete; not +# used for any API call. +CONF_AUTH_USER_NAME = "auth_user_name" + +# Dialog skill (Yandex Dialogs custom skill — voice playback) +CONF_DIALOG_SKILL_NAME = "dialog_skill_name" +CONF_DIALOG_SKILL_ID = "dialog_skill_id" +CONF_DIALOG_SKILL_TOKEN = "dialog_skill_token" +CONF_DIALOG_WEBHOOK_SECRET = "dialog_webhook_secret" +CONF_DIALOG_AUTO_CREATE_ARTIFACTS = "dialog_auto_create_artifacts" +# v1.2.0 — Yandex skill publication status, classified into one of: +# ``on_air`` / ``in_moderation`` / ``draft`` / ``rejected`` / ``unknown``. +# Refreshed once after every successful Create / Update / Adopt / +# Recreate / Refresh-status action. Read by Step 3 to render the +# moderation banner without making an HTTP call on every render. +CONF_DIALOG_PUBLICATION_STATUS = "dialog_publication_status" + +# --------------------------------------------------------------------------- +# Config actions (config-flow buttons) +# --------------------------------------------------------------------------- +CONF_ACTION_AUTO_CREATE_DIALOG = "auto_create_dialog_skill" +# v1.2.0 UX revamp — split sign-in / create-skill / clear-auth / +# delete-skill into four explicit user-facing actions instead of +# overloading one button. +CONF_ACTION_SIGN_IN = "sign_in" +CONF_ACTION_CLEAR_AUTH = "clear_auth" +CONF_ACTION_DELETE_SKILL = "delete_skill" +# v1.2.0 — manual "Refresh status" trigger in Step 3. The dispatcher +# fetches the live publication status from Yandex Dialogs snapshot +# and updates the cached value used by the Step 3 status banner. +CONF_ACTION_REFRESH_STATUS = "refresh_status" +CONF_ACTION_RENAME_DIALOG_SKILL = "rename_dialog_skill" +# Cancel an in-flight Device Flow / drop partial artifacts. Visible only when +# DEVICE_FLOW_STARTED or FAILED. Cached x_token is preserved across cancel. +CONF_ACTION_CANCEL_DIALOG_SKILL_FLOW = "cancel_dialog_skill_flow" +# Test webhook reachability — outgoing POST to verify DNS + TLS + reverse proxy. +CONF_ACTION_TEST_WEBHOOK = "test_webhook_reachability" +# Regenerate the webhook URL secret. Drops the existing skill registration in +# Yandex (delete_skill) so the next auto-create starts fresh — guards against +# the user editing the webhook secret field by hand and orphaning the route. +CONF_ACTION_REGENERATE_WEBHOOK_SECRET = "regenerate_webhook_secret" +# Revert Skill name back to artifacts.last_known_name (drift undo). +CONF_ACTION_REVERT_SKILL_NAME = "revert_skill_name" + +# v1.2.0 Step 2: pre-check duplicate name flow — two resolution actions. +# RECREATE: delete the existing skill in Yandex + register fresh one with +# the same name. ADOPT: skip create, position artifacts on the discovered +# skill_id and continue the pipeline (re-deploys with our backend URL). +CONF_ACTION_RECREATE_DUPLICATE = "recreate_duplicate" +CONF_ACTION_ADOPT_EXISTING = "adopt_existing" +# Step 3 identity card: open the skill in the Yandex Dialogs dev console +# via the AuthenticationHelper popup channel (signal_event), bypassing +# `help_link` which only renders as a tiny inline `?` icon. +CONF_ACTION_OPEN_DEV_CONSOLE = "open_dev_console" +# Hidden persistence: skill_id of the duplicate found by the pre-check +# during the previous click. When non-empty, the form renders the +# Recreate / Adopt resolution UI instead of the regular Create button. +CONF_PENDING_DUPLICATE_SKILL_ID = "pending_duplicate_skill_id" +CONF_PENDING_DUPLICATE_SKILL_NAME = "pending_duplicate_skill_name" + +# v1.2.0 Step 3: edit-mode toggle + actions. Edit mode is a hidden boolean +# in form values; flipping it in/out reshapes the post-DONE section. +CONF_EDIT_MODE = "edit_mode" +CONF_ACTION_EDIT_SKILL = "edit_skill" +CONF_ACTION_UPDATE_SKILL = "update_skill" +CONF_ACTION_CANCEL_EDIT = "cancel_edit" + +# v1.6.0 — file-based skill manifest override + UI actions. +# Override lives at ``/yandex_alice/skill.toml``. UI exposes +# four actions (export / import / reset / validate) plus a paste +# field for browser-based import; banner reflects bundled vs override. +CONF_ACTION_EXPORT_MANIFEST = "export_manifest" +CONF_ACTION_IMPORT_MANIFEST = "import_manifest" +CONF_ACTION_RESET_MANIFEST = "reset_manifest" +CONF_ACTION_VALIDATE_MANIFEST = "validate_manifest" +CONF_DIALOG_SKILL_OVERRIDE_PASTE = "dialog_skill_override_paste" + +# Voice + activation phrases editable in edit mode (otherwise auto-derived). +# Yandex Dialogs allows up to **three** alternative activation phrases +# in addition to the skill name itself (which is the first phrase). +# Each must be at least 2 words just like the skill name; empty slots +# are skipped when assembling the payload sent to Yandex. +CONF_DIALOG_SKILL_VOICE = "dialog_skill_voice" +CONF_DIALOG_ACTIVATION_PHRASE_2 = "dialog_activation_phrase_2" +CONF_DIALOG_ACTIVATION_PHRASE_3 = "dialog_activation_phrase_3" +CONF_DIALOG_ACTIVATION_PHRASE_4 = "dialog_activation_phrase_4" + +# Toggle: split-personality between MA "Instance name" (internal) and Yandex +# "Skill name" (user-facing voice trigger). Default merged — both come from +# CONF_DIALOG_SKILL_NAME. Power users can flip this to expose a separate +# CONF_INSTANCE_NAME field. +CONF_USE_DIFFERENT_INSTANCE_NAME = "use_different_instance_name" + +# Toggle: keep the conversation open after a play / control success (P1.4). +# Default OFF — historical voice-UX where the skill ends the session and +# the user re-says "Алиса, попроси " for the next command. ON keeps +# `end_session=false` after success so follow-ups skip the activation +# preamble at the cost of a "skill is listening" indicator on screened +# surfaces. Explicit "стоп / останови / выключи / выключи музыку" still +# end the session via the existing `stop` control intent (matched by +# `parse_control` patterns in `dialogs_control.py`). +CONF_DIALOG_VOICE_CONTINUATION = "dialog_voice_continuation" + +# Yandex Dialogs catalog voice options (TTS), passed to draft payload. +# Wire values + display names extracted live from the dev console +# (https://dialogs.yandex.ru/developer → skill → Голос dropdown) on +# 2026-05-07; other strings will be rejected by the draft PATCH. +# Voice selection rarely matters for voice-control skills (the user +# hears Alice, not the skill's TTS), but we expose it for completeness. +DIALOG_VOICE_OPTIONS: tuple[tuple[str, str], ...] = ( + ("good_oksana", "Oksana (default)"), + ("jane", "Jane"), + ("zahar", "Zakhar"), + ("ermil", "Yermil"), + ("erkanyavas", "Erkan Yavas"), + ("shitova.us", "Alisa"), + ("kostya.gpu", "Kostya"), + ("valtz.gpu", "Filipp"), + ("tatyana_abramova.gpu", "Anya"), +) +DIALOG_VOICE_DEFAULT = "good_oksana" + +# --------------------------------------------------------------------------- +# Form categories (progressive disclosure) +# --------------------------------------------------------------------------- +CATEGORY_AUTHORIZATION = "Authorization" +CATEGORY_SKILL = "Skill" +# Frontend renders ``settings.category.{slug}`` and falls back to the +# raw slug when no translation exists — using TitleCase slugs gives +# us readable section headers ("Authorization" / "Skill" / "Settings") +# without shipping i18n. Avoid the bare slug ``"settings"`` because +# the frontend reserves that for the top-level Settings page. +CATEGORY_SETTINGS = "Settings" +CATEGORY_ADVANCED = "advanced" + +# --------------------------------------------------------------------------- +# Webhook routing +# --------------------------------------------------------------------------- +DIALOG_WEBHOOK_BASE_PATH = "/api/yandex_dialogs/webhook" +# Maximum time the dialogs webhook handler may spend resolving / dispatching +# before it must return a response. Yandex's Alice Dialogs protocol enforces +# a 3-second hard cap; we leave 0.5s of headroom. +DIALOG_RESOLVE_TIMEOUT = 2.5 + +# --------------------------------------------------------------------------- +# Dialog skill metadata defaults +# --------------------------------------------------------------------------- +DIALOG_DEFAULT_NAME = "Music Assistant" +# Yandex Dialogs app-store-api channel string for the custom dialog skill. +# Captured from dev console DevTools (POST /apps): channel="aliceSkill". +# Override via MA_YANDEX_DIALOG_CHANNEL env var if Yandex changes the contract. +# Validated against ya_dialogs_api.Channel — invalid values fall back to the +# library default with a warning rather than producing a silent type lie. +_dialog_channel_raw = os.environ.get("MA_YANDEX_DIALOG_CHANNEL", _LIB_DIALOG_CHANNEL) +if _dialog_channel_raw not in ("smartHome", "aliceSkill"): + _LOGGER.warning( + "MA_YANDEX_DIALOG_CHANNEL=%r is not a recognised Yandex Channel " + "wire value; falling back to %r", + _dialog_channel_raw, + _LIB_DIALOG_CHANNEL, + ) + _dialog_channel_raw = _LIB_DIALOG_CHANNEL +DIALOG_CHANNEL: Channel = cast("Channel", _dialog_channel_raw) +DIALOG_NAME_MIN_LEN = 2 +DIALOG_NAME_MAX_LEN = 64 + +# --------------------------------------------------------------------------- +# Yandex Passport / Dialogs reference URLs +# --------------------------------------------------------------------------- +YANDEX_DIALOGS_DEVELOPER_URL = "https://dialogs.yandex.ru/developer" +YANDEX_OAUTH_URL = ( + "https://oauth.yandex.ru/authorize?response_type=token" + "&client_id=c473ca268cd749d3a8371351a8f2bcbd" +) diff --git a/music_assistant/providers/yandex_alice/data/__init__.py b/music_assistant/providers/yandex_alice/data/__init__.py new file mode 100644 index 0000000000..7ca5bce012 --- /dev/null +++ b/music_assistant/providers/yandex_alice/data/__init__.py @@ -0,0 +1,9 @@ +"""Package-data resources bundled with the Yandex Alice provider. + +Holds the declarative skill manifest (``skill.toml``) accessed via +``importlib.resources``. The empty ``__init__.py`` makes +``provider.data`` a real subpackage so ``setuptools.find_packages`` +discovers it and the configured +``[tool.setuptools.package-data] "provider.data" = ["*.toml"]`` glob +attaches the TOML to the wheel. +""" diff --git a/music_assistant/providers/yandex_alice/data/skill.toml b/music_assistant/providers/yandex_alice/data/skill.toml new file mode 100644 index 0000000000..9729db5f3a --- /dev/null +++ b/music_assistant/providers/yandex_alice/data/skill.toml @@ -0,0 +1,572 @@ +# Yandex Alice skill manifest — declarative source of truth for what +# the auto-create / auto-update pipeline ships to dialogs.yandex.ru +# AND for the runtime dispatcher that turns matched intents into +# ParsedControl / ParsedCommand on the webhook side. +# +# Format: TOML. Each ``grammar`` and the entities ``text`` block use +# TOML triple-quoted strings — content is preserved byte-for-byte, so +# you can copy / paste a Granet ``sourceText`` from the dev console +# (https://dialogs.yandex.ru/developer/skills//draft/settings/intents) +# directly into ``grammar = """ ... """``. +# +# Schema: +# schema_version — bump when shape changes (loader rejects newer) +# [entities] — single Granet block, ships via set_entities +# text — full customEntities sourceText +# [[intents]] — one per intent, order-preserving +# form_name — Yandex API formName (stable across edits) +# human_readable_name — Yandex API humanReadableName (UI label) +# is_activation — Yandex API isActivation (default false) +# positive_tests — newline-separated phrases (Yandex positiveTests) +# negative_tests — newline-separated phrases (Yandex negativeTests) +# grammar — Granet sourceText (sent verbatim to Yandex) +# [intents.runtime] — runtime dispatch metadata (since 1.6.0) +# kind — "control" | "play" (consumer-interpreted) +# action — ControlAction literal | ParsedCommand.kind +# [[intents.runtime.mapping]] — optional slot-to-field mapping rules +# field — target ParsedControl/ParsedCommand field +# from_slot — slot name in request.nlu.intents.
.slots +# slot_type — "int" (default) | "str" +# transform — "identity" (default) | "clamp" | "abs_clamp" +# min, max — clamp bounds +# cap — skip the intent if value > cap (post-multiply) +# default — value when slot is missing +# sign — "positive" (default) | "negative" +# reject_if_below — skip the intent if value < this (post-multiply) +# [[intents.runtime.mapping.multiply_when]] — conditional unit multiplier +# slot, equals, factor — multiply value by `factor` when slot==equals +# +# Granet shape rules empirically pinned through v1.4.x: +# - Single-line alternation only (``|`` at start of continuation line +# is rejected as "Пустой элемент"). +# - ``%lemma`` directive: standalone above alternation; never inside +# ``[...]`` optional blocks; never after ``|``. +# - Slot declarations live inside ``sourceText`` as a ``slots:`` +# sub-block — the ya-dialogs-api 2.2.0+ client sees this and skips +# auto-composing structured slots. + +schema_version = 1 + +[entities] +text = """ +entity time_unit: + values: + seconds: + секунда | секунды | секунд | сек + minutes: + минута | минуты | минут | мин +""" + + +# =========================================================================== +# Originals (no slots) — covered by Yandex platform NLU since v1.4.0. +# =========================================================================== + +[[intents]] +form_name = "control.pause" +human_readable_name = "Пауза" +positive_tests = """ +пауза +поставь на паузу +останови музыку +на паузу +""" +negative_tests = """ +включи +следующая +""" +grammar = """ +root: + %lemma + пауза | поставь на паузу | останови музыку | на паузу +""" +[intents.runtime] +kind = "control" +action = "pause" + +[[intents]] +form_name = "control.resume" +human_readable_name = "Продолжить" +positive_tests = """ +продолжи +продолжить +возобнови +включи снова +""" +grammar = """ +root: + %lemma + продолжить | возобновить | включи снова +""" +[intents.runtime] +kind = "control" +action = "resume" + +[[intents]] +form_name = "control.next" +human_readable_name = "Следующий трек" +positive_tests = """ +следующая +следующий трек +дальше +переключи +""" +grammar = """ +root: + %lemma + следующая | следующий трек | дальше | переключи +""" +[intents.runtime] +kind = "control" +action = "next" + +[[intents]] +form_name = "control.previous" +human_readable_name = "Предыдущий трек" +positive_tests = """ +предыдущая +предыдущий трек +назад +вернись +""" +grammar = """ +root: + %lemma + предыдущая | предыдущий трек | назад | вернись +""" +[intents.runtime] +kind = "control" +action = "previous" + +[[intents]] +form_name = "control.stop" +human_readable_name = "Стоп" +positive_tests = """ +стоп +останови +выключи +выключи музыку +""" +grammar = """ +root: + %lemma + стоп | останови | выключи | выключи музыку +""" +[intents.runtime] +kind = "control" +action = "stop" + +[[intents]] +form_name = "control.volume_up" +human_readable_name = "Громче" +positive_tests = """ +громче +сделай громче +прибавь +""" +grammar = """ +root: + %lemma + громче | сделай громче | прибавь +""" +[intents.runtime] +kind = "control" +action = "volume_up" + +[[intents]] +form_name = "control.volume_down" +human_readable_name = "Тише" +positive_tests = """ +тише +сделай тише +убавь +""" +grammar = """ +root: + %lemma + тише | сделай тише | убавь +""" +[intents.runtime] +kind = "control" +action = "volume_down" + +[[intents]] +form_name = "control.shuffle_on" +human_readable_name = "Включить перемешивание" +positive_tests = """ +перемешай +включи перемешивание +случайный порядок +""" +grammar = """ +root: + %lemma + перемешай | включи перемешивание | случайный порядок | в случайном порядке +""" +[intents.runtime] +kind = "control" +action = "shuffle_on" + +[[intents]] +form_name = "control.shuffle_off" +human_readable_name = "Выключить перемешивание" +positive_tests = """ +выключи перемешивание +не перемешивай +по порядку +""" +grammar = """ +root: + выключи перемешивание | не перемешивай | по порядку +""" +[intents.runtime] +kind = "control" +action = "shuffle_off" + +[[intents]] +form_name = "control.now_playing" +human_readable_name = "Что играет" +positive_tests = """ +что играет +что сейчас играет +что мы слушаем +""" +grammar = """ +root: + что играет | что сейчас играет | что мы слушаем | что за песня | что за трек | что за композиция | какой трек | какой сейчас трек | какая песня | какая сейчас песня +""" +[intents.runtime] +kind = "control" +action = "now_playing" + + +# =========================================================================== +# v1.4.0 — additional no-slot intents. +# =========================================================================== + +[[intents]] +form_name = "control.mute" +human_readable_name = "Без звука" +positive_tests = """ +приглуши +выключи звук +беззвучно +""" +grammar = """ +root: + %lemma + приглуши | выключи звук | беззвучно | без звука +""" +[intents.runtime] +kind = "control" +action = "mute" + +[[intents]] +form_name = "control.unmute" +human_readable_name = "Вернуть звук" +positive_tests = """ +включи звук +сделай звук +верни звук +""" +grammar = """ +root: + %lemma + включи звук | сделай звук | верни звук +""" +[intents.runtime] +kind = "control" +action = "unmute" + +[[intents]] +form_name = "control.seek_start" +human_readable_name = "К началу" +positive_tests = """ +к началу +в начало +начни заново +""" +grammar = """ +root: + %lemma + к началу | в начало | начни заново | начни трек заново | перемотай к началу +""" +[intents.runtime] +kind = "control" +action = "seek_start" + +[[intents]] +form_name = "control.repeat_one" +human_readable_name = "Повтор одной песни" +positive_tests = """ +повтори песню +повтори трек +повтори эту песню +""" +grammar = """ +root: + %lemma + повтори песню | повтори трек | повтори эту песню | повтори эту | повтори композицию +""" +[intents.runtime] +kind = "control" +action = "repeat_one" + +[[intents]] +form_name = "control.repeat_all" +human_readable_name = "Повтор всего" +positive_tests = """ +повтори всё +повтори плейлист +повтори очередь +""" +grammar = """ +root: + %lemma + повтори всё | повтори все | повтори плейлист | повтори очередь | повторяй всё +""" +[intents.runtime] +kind = "control" +action = "repeat_all" + +[[intents]] +form_name = "control.repeat_off" +human_readable_name = "Выключить повтор" +positive_tests = """ +выключи повтор +не повторяй +отмени повтор +""" +grammar = """ +root: + %lemma + выключи повтор | не повторяй | отмени повтор +""" +[intents.runtime] +kind = "control" +action = "repeat_off" + +[[intents]] +form_name = "control.list_players" +human_readable_name = "Какие колонки" +positive_tests = """ +сколько колонок +какие колонки +перечисли колонки +""" +grammar = """ +root: + %lemma + сколько колонок | сколько колонок видишь | сколько колонок ты видишь | сколько колонок знаешь | сколько колонок ты знаешь | какие колонки | какие колонки видишь | какие колонки ты видишь | какие колонки знаешь | какие колонки ты знаешь | какие колонки есть | какие у тебя колонки | перечисли колонки | список колонок | покажи колонки | назови колонки +""" +[intents.runtime] +kind = "control" +action = "list_players" + +[[intents]] +form_name = "control.forget_player" +human_readable_name = "Забыть колонку" +positive_tests = """ +забудь колонку +сбрось выбор +поменяй колонку +""" +grammar = """ +root: + %lemma + забудь колонку | сбрось колонку | забудь плеер | забудь выбор | сбрось выбор | поменяй колонку | сменить колонку | выбери колонку заново +""" +[intents.runtime] +kind = "control" +action = "forget_player" + + +# =========================================================================== +# v1.4.0 — slot-bearing intents. +# =========================================================================== + +[[intents]] +form_name = "control.volume_set" +human_readable_name = "Громкость на N процентов" +positive_tests = """ +громкость 50 +громкость на 30 процентов +сделай громкость 75 +""" +grammar = """ +root: + %lemma + громкость [на] $Level [процент | процентов | процента] | сделать громкость [на] $Level [процент | процентов | процента] +$Level: $YANDEX.NUMBER +slots: + level: + type: YANDEX.NUMBER + source: $Level +""" +[intents.runtime] +kind = "control" +action = "volume_set" +[[intents.runtime.mapping]] +field = "value" +from_slot = "level" +transform = "clamp" +min = 0 +max = 100 + +[[intents]] +form_name = "control.volume_increase" +human_readable_name = "Прибавить громкость на N" +positive_tests = """ +прибавь на 20 +прибавь на 15 процентов +на 10 громче +""" +grammar = """ +root: + %lemma + прибавить [на] $Delta [процент | процентов | процента] | сделать громче на $Delta [процент | процентов | процента] | на $Delta [процент | процентов | процента] громче +$Delta: $YANDEX.NUMBER +slots: + delta: + type: YANDEX.NUMBER + source: $Delta +""" +[intents.runtime] +kind = "control" +action = "volume_relative" +[[intents.runtime.mapping]] +field = "value" +from_slot = "delta" +transform = "abs_clamp" +min = 0 +max = 100 +default = 10 +sign = "positive" + +[[intents]] +form_name = "control.volume_decrease" +human_readable_name = "Убавить громкость на N" +positive_tests = """ +убавь на 20 +убавь на 25 процентов +на 15 тише +""" +grammar = """ +root: + %lemma + убавить [на] $Delta [процент | процентов | процента] | сделать тише на $Delta [процент | процентов | процента] | на $Delta [процент | процентов | процента] тише +$Delta: $YANDEX.NUMBER +slots: + delta: + type: YANDEX.NUMBER + source: $Delta +""" +[intents.runtime] +kind = "control" +action = "volume_relative" +[[intents.runtime.mapping]] +field = "value" +from_slot = "delta" +transform = "abs_clamp" +min = 0 +max = 100 +default = 10 +sign = "negative" + +[[intents]] +form_name = "control.seek_forward" +human_readable_name = "Перемотать вперёд" +positive_tests = """ +перемотай вперёд на 30 секунд +перемотай вперёд на 2 минуты +вперёд 15 секунд +""" +negative_tests = """ +назад 30 секунд +""" +grammar = """ +root: + %lemma + вперёд [на] $Amount [$Unit] | перемотать вперёд [на] $Amount [$Unit] +$Amount: $YANDEX.NUMBER +$Unit: $time_unit +slots: + amount: + type: YANDEX.NUMBER + source: $Amount + unit: + type: time_unit + source: $Unit +""" +[intents.runtime] +kind = "control" +action = "seek_forward" +[[intents.runtime.mapping]] +field = "value" +from_slot = "amount" +reject_if_below = 1 +cap = 86400 +[[intents.runtime.mapping.multiply_when]] +slot = "unit" +equals = "minutes" +factor = 60 + +[[intents]] +form_name = "control.seek_back" +human_readable_name = "Перемотать назад" +positive_tests = """ +перемотай назад на 30 секунд +перемотай назад на 1 минуту +назад 15 секунд +""" +negative_tests = """ +вперёд 30 секунд +""" +grammar = """ +root: + %lemma + назад [на] $Amount [$Unit] | перемотать назад [на] $Amount [$Unit] +$Amount: $YANDEX.NUMBER +$Unit: $time_unit +slots: + amount: + type: YANDEX.NUMBER + source: $Amount + unit: + type: time_unit + source: $Unit +""" +[intents.runtime] +kind = "control" +action = "seek_back" +[[intents.runtime.mapping]] +field = "value" +from_slot = "amount" +reject_if_below = 1 +cap = 86400 +[[intents.runtime.mapping.multiply_when]] +slot = "unit" +equals = "minutes" +factor = 60 + + +# =========================================================================== +# Play intents. +# =========================================================================== + +[[intents]] +form_name = "play.my_wave" +human_readable_name = "Моя волна" +positive_tests = """ +включи мою волну +поставь мою волну +включи моё радио +""" +grammar = """ +root: + %lemma + включи мою волну | включи моё радио | поставь мою волну | моя волна +""" +[intents.runtime] +kind = "play" +action = "my_wave" diff --git a/music_assistant/providers/yandex_alice/dialog_skill_meta.py b/music_assistant/providers/yandex_alice/dialog_skill_meta.py new file mode 100644 index 0000000000..3226edbf61 --- /dev/null +++ b/music_assistant/providers/yandex_alice/dialog_skill_meta.py @@ -0,0 +1,139 @@ +"""Pure helpers for assembling Yandex Dialogs skill metadata. + +Side-effect free: no MA / aiohttp / network access. Lives in its own module +so the orchestrator (auto_create.py / auto_update.py) can be tested without +threading these strings through every fixture. +""" + +from __future__ import annotations + +from typing import Any + +from .constants import DIALOG_NAME_MAX_LEN, DIALOG_NAME_MIN_LEN, DIALOG_WEBHOOK_BASE_PATH +from .url_helpers import is_public_https_url + + +def validate_skill_name(value: object) -> bool: + """Pre-flight check for the Yandex Dialogs skill name field. + + Yandex enforces three constraints when a skill is created: + + 1. **At least two whitespace-separated words.** Single-word names are + rejected by the dev-console form (Russian: "Название должно содержать + минимум два слова"). The auto-create pipeline would surface this only + after Device Flow + ``create_app`` round-trip, so we check up front. + 2. **2 to 64 characters** (after stripping leading/trailing whitespace). + 3. Globally unique across all Yandex skills — only checkable at + ``create_app`` time, not here. + + Returns ``True`` when the value passes 1+2 (uniqueness is server-side). + Designed for use as ``ConfigEntry(validate=validate_skill_name)`` — + accepts ``object`` because that's the type the MA frontend hands us. + """ + if not isinstance(value, str): + return False + stripped = value.strip() + if len(stripped.split()) < 2: + return False + return DIALOG_NAME_MIN_LEN <= len(stripped) <= DIALOG_NAME_MAX_LEN + + +def validate_activation_phrase(value: object) -> bool: + """Pre-flight check for an *optional* extra activation phrase. + + Same word/length rules as :func:`validate_skill_name`, but an + **empty / whitespace-only** value is accepted — the slot is + optional. The dispatcher drops empty slots before assembling the + list sent to Yandex. + """ + if not isinstance(value, str): + return False + stripped = value.strip() + if not stripped: + return True # empty slot — optional + if len(stripped.split()) < 2: + return False + return DIALOG_NAME_MIN_LEN <= len(stripped) <= DIALOG_NAME_MAX_LEN + + +def build_backend_uri(base_url: str, webhook_secret: str) -> str: + """Compose the public webhook URL Yandex must call. + + Yandex sends voice phrases directly from its cloud to the URL we + register, so the host must be reachable from the public internet. + We require HTTPS *and* reject private / loopback / link-local hosts + up front — otherwise auto-create would happily register a URL Yandex + can't reach (e.g. ``https://192.168.1.10`` or ``https://localhost``) + and the user would only discover the failure once moderation finishes. + + :raises ValueError: ``base_url`` is empty / not a public HTTPS URL, + or ``webhook_secret`` is empty. + """ + base = (base_url or "").strip().rstrip("/") + if not base: + msg = "External base URL is empty — set a public HTTPS URL for Yandex first" + raise ValueError(msg) + if not is_public_https_url(base): + msg = ( + f"External base URL must be a public HTTPS endpoint Yandex can " + f"reach over the internet (got: {base!r}). Private IPs, loopback " + f"and link-local addresses are rejected." + ) + raise ValueError(msg) + secret = (webhook_secret or "").strip() + if not secret: + msg = "Webhook secret is empty — open the form once to auto-generate one" + raise ValueError(msg) + return f"{base}{DIALOG_WEBHOOK_BASE_PATH}/{secret}" + + +def build_skill_description(skill_name: str) -> str: + """Default Russian description shown in the Alice catalog and to moderators. + + Yandex rejects empty descriptions for ``aliceSkill`` skills, so we always + return a non-empty string. Embeds the skill name so the catalog listing + is self-contained. + """ + name = (skill_name or "").strip() or "Music Assistant" + return ( + f"Голосовое управление Music Assistant через навык «{name}». " + "Включение треков, управление воспроизведением и громкостью, " + "перемещение очереди между колонками." + ) + + +def build_activation_phrases(skill_name: str) -> list[str]: + """Default activation phrase list — single entry: the skill name itself.""" + name = (skill_name or "").strip() or "Music Assistant" + return [name] + + +def build_structured_examples(skill_name: str) -> list[dict[str, Any]]: + """Default structured examples shown to moderators. + + Shape captured from a successful PATCH issued by the dev console after a + manual form fill (see ya_dialogs_api.api_client.build_dialog_draft_payload + docstring). Three concrete commands so reviewers can see playback, + transport, and multi-room intents without us guessing what passes review. + """ + name = (skill_name or "").strip() or "Music Assistant" + return [ + { + "marker": "попроси", + "activationPhrase": name, + "request": "включи джаз", + "is_valid": True, + }, + { + "marker": "попроси", + "activationPhrase": name, + "request": "поставь на паузу", + "is_valid": True, + }, + { + "marker": "попроси", + "activationPhrase": name, + "request": "переведи музыку на кухню", + "is_valid": True, + }, + ] diff --git a/music_assistant/providers/yandex_alice/dialogs.py b/music_assistant/providers/yandex_alice/dialogs.py new file mode 100644 index 0000000000..6cd2fef6b0 --- /dev/null +++ b/music_assistant/providers/yandex_alice/dialogs.py @@ -0,0 +1,1625 @@ +# ruff: noqa: RUF001, RUF003 +"""HTTP handler for the Yandex Dialogs custom-skill webhook (experimental). + +Registers a single exact route on the MA webserver — the secret is +**baked into the path string** at registration time, not a route +template variable: + + POST /api/yandex_dialogs/webhook/ + +Therefore ``request.match_info`` is empty in production; the handler +parses the secret from ``request.path`` (last segment) for the +constant-time compare. Tests that pass an explicit ``match_info`` cover +the alternative branch. + +Yandex Dialogs does not send an Authorization header on webhook calls, +so authentication is two-layered: + + 1. Path secret (``CONF_DIALOG_WEBHOOK_SECRET``) — random UUID stored + only in the user's MA config and in the skill's Backend URL. Knowing + it requires access to the Yandex Dialogs developer console. + 2. ``body.session.skill_id == CONF_DIALOG_SKILL_ID`` — sanity check; + skill_id is not secret on its own but stops cross-skill misroutes. + +A request is rejected with 404 if the secret doesn't match (no leak via +401 timing) and with 401 if the skill_id doesn't match (configured +skill received a payload from a different skill — should never happen). + +Session memory: three-tier strategy. + + 1. Yandex state envelope — primary. ``state.session`` (per + conversation), ``state.application`` (per device), ``state.user`` + (per Yandex account, only when account-linked). The application + and user tiers persist across plugin reloads and MA restarts. + 2. In-process cache — third-tier fallback for surfaces that don't + reliably echo the state envelope back (notably some Yandex + Station configurations). LRU keyed by ``user.user_id`` → + ``application.application_id`` → ``session_id``, with a 5-min + TTL matching Alice's session inactivity timeout. + +Reads check tiers in the order above. Writes mirror to all +applicable tiers via ``_yandex_response``. +""" + +from __future__ import annotations + +import asyncio +import dataclasses +import logging +import re +import secrets +import time +from collections import OrderedDict +from collections.abc import Callable +from typing import TYPE_CHECKING, Any + +from aiohttp import web + +from .constants import ( + DIALOG_RESOLVE_TIMEOUT, + DIALOG_WEBHOOK_BASE_PATH, +) +from .dialogs_control import ( + ParsedControl, + control_confirmation, + execute_control, + format_list_players, + parse_control, +) +from .dialogs_grammar import extract_trailing_player_hint +from .dialogs_nlu import ( + _VERB_RE, + ParsedCommand, + list_exposed_players, + parse_command, + resolve_player, + resolve_player_candidates, +) +from .dialogs_player import play_for_alice, resolve_query +from .skill_manifest_provider import SkillManifestProvider +from .tts_dictionary import PHRASE_REPLACEMENTS, WORD_REPLACEMENTS + +if TYPE_CHECKING: + from music_assistant.mass import MusicAssistant + + +_LOGGER = logging.getLogger(__name__) + + +# P0.2 — TTS pronunciation hints. The `_tts_for` helper rewrites known +# words to add `+` stress markers (Russian) or Cyrillic transliterations +# (foreign artist names) so Alice's TTS reads them naturally. Tables +# live in `tts_dictionary.py` for easier PR contributions; the regex +# matches BOTH Latin and Cyrillic words because foreign artist names +# arrive in Latin (e.g., the user said "Metallica" → command keeps it +# Latin → response text says "Metallica" → tts says "мет+аллика"). +_TTS_WORD_RE = re.compile(r"[A-Za-zА-Яа-яЁё]+") + + +def _tts_for(text: str) -> str: + """Add `+` stress markers and foreign-name transliterations for Alice TTS. + + Two passes: + 1. Multi-word phrase replacement (longest first via the table's + declared order). Required for "Iron Maiden", "Pink Floyd" etc. + which the per-word regex cannot match across whitespace. + 2. Per-word substitution against ``WORD_REPLACEMENTS`` — covers + Russian response stresses and single-word foreign names. + + Unknown words pass through unchanged. The map is intentionally small + and curated — every entry is maintenance debt; add via PR when a + real-user log shows Alice mispronouncing a specific word. + """ + if not text: + return text + + # Phrase pass — case-insensitive whole-substring replacement. Walks + # the table in declared order so longer phrases (e.g. "red hot chili + # peppers") match before any sub-string entries. Result drops the + # original casing on the matched span — for TTS-only output that's + # acceptable (Alice doesn't render visual casing on voice surfaces; + # screen surfaces read `text`, not `tts`). + lowered = text.lower() + if any(phrase in lowered for phrase, _ in PHRASE_REPLACEMENTS): + for phrase, replacement in PHRASE_REPLACEMENTS: + idx = lowered.find(phrase) + while idx != -1: + text = text[:idx] + replacement + text[idx + len(phrase) :] + lowered = text.lower() + idx = lowered.find(phrase, idx + len(replacement)) + + def _sub(match: re.Match[str]) -> str: + word = match.group(0) + replacement = WORD_REPLACEMENTS.get(word.lower()) + if replacement is None: + return word + if word[:1].isupper(): + return replacement[:1].upper() + replacement[1:] + return replacement + + return _TTS_WORD_RE.sub(_sub, text) + + +def _safe_dict(value: Any) -> dict[str, Any]: + """Return value if it's a dict, else an empty dict (defensive parsing).""" + return value if isinstance(value, dict) else {} + + +# Suggestion buttons appended to play-/control-success responses on +# screened surfaces (mobile Alice, station-max, navigator, smart-screen). +# Lets the user tap a follow-up without saying the activation phrase +# again. `hide=False` keeps them on screen until tapped or the next +# response replaces them. Voice-only surfaces (Mini, Pro, dumb speakers) +# don't render buttons — so we omit the field entirely on those. +_PLAYBACK_SUGGESTION_BUTTONS: list[dict[str, Any]] = [ + {"title": "Следующая", "hide": False}, + {"title": "Пауза", "hide": False}, + {"title": "Громче", "hide": False}, + {"title": "Тише", "hide": False}, +] + + +def _has_screen(meta: Any) -> bool: + """Return True if the calling surface has a display. + + Yandex sets ``meta.interfaces.screen = {}`` (empty dict, present-as-key) + on devices that can render visual elements: mobile Alice, station-max, + station-2, navigator, smart-screen, tv-app. Audio-only surfaces + (station-mini, station-pro, dumb speakers) omit the key entirely. + Used to gate ``buttons`` / ``card`` emission so we don't ship UI + bits to surfaces that ignore them. + """ + if not isinstance(meta, dict): + return False + interfaces = meta.get("interfaces") + if not isinstance(interfaces, dict): + return False + return "screen" in interfaces + + +def _without_pending(state: dict[str, Any]) -> dict[str, Any]: + """Return a copy of `state` with disambiguation/elicitation keys removed. + + Strips `pending_command`, `awaiting_query`, and `awaiting_player_id`. + Used after the disambiguation / slot-elicit flow successfully + completes so the next turn doesn't accidentally re-enter the saved + branch. + """ + transient = {"pending_command", "awaiting_query", "awaiting_player_id"} + return {k: v for k, v in state.items() if k not in transient} + + +# Ordinal voice-disambiguation patterns. The user picks a candidate by +# position ("первая", "выбираю первую", "номер три"). Used when a +# screenless audio device makes button-tap impossible. +# +# Two pattern families (all matched via ``re.search`` — leading filler +# words like "ну", "хочу", "выбираю", "давай" don't kill the match): +# +# 1. Russian ordinal stems (``перв\w*`` etc.) — case-insensitive +# word-prefix match. Catches every morphological form ("первая", +# "первый", "первое", "первую", "первой", "первом", …) without +# enumerating each. +# 2. Cardinal numbers and digits — anchored ``^…$`` so only a +# bare-number utterance counts ("один", "1", "номер один"). +# "У меня один вариант" must NOT silently pick the first. +_ORDINAL_PATTERNS: tuple[tuple[re.Pattern[str], int], ...] = ( + (re.compile(r"\bперв\w*\b", re.IGNORECASE), 0), + (re.compile(r"\bвтор\w*\b", re.IGNORECASE), 1), + (re.compile(r"\bтреть\w*\b", re.IGNORECASE), 2), + (re.compile(r"\bчетв[её]рт\w*\b", re.IGNORECASE), 3), + (re.compile(r"\bпят\w*\b", re.IGNORECASE), 4), + # Cardinals — whole-utterance only. + (re.compile(r"^(?:номер\s+)?(?:один|1)$", re.IGNORECASE), 0), + (re.compile(r"^(?:номер\s+)?(?:два|2)$", re.IGNORECASE), 1), + (re.compile(r"^(?:номер\s+)?(?:три|3)$", re.IGNORECASE), 2), + (re.compile(r"^(?:номер\s+)?(?:четыре|4)$", re.IGNORECASE), 3), + (re.compile(r"^(?:номер\s+)?(?:пять|5)$", re.IGNORECASE), 4), +) + + +def _parse_ordinal_choice(text: str) -> int | None: + """Parse 'первая' / 'выбираю первую' / 'номер три' / '2' as 0-based index. + + Returns the index, or None if no ordinal/cardinal pattern matched. + Tolerates leading filler words ("ну", "хочу", "выбираю", "давай") + since users often pad voice replies on smart speakers. + """ + if not text: + return None + cleaned = text.strip().lower() + if not cleaned: + return None + for pattern, index in _ORDINAL_PATTERNS: + if pattern.search(cleaned): + return index + return None + + +# Russian ordinal labels used in the disambiguation prompt. +_ORDINAL_LABELS: tuple[str, ...] = ( + "первая", + "вторая", + "третья", + "четвёртая", + "пятая", +) + + +# In-process state cache (TTL + LRU). Third-tier fallback when Yandex +# doesn't echo `state.session` / `state.application` back on the next +# turn — a quirk reproduced from the Yandex Station dev-console +# emulator, where the request body for every turn after the first +# arrived without any `state.*` field at all. Without this cache the +# disambiguation flow would loop indefinitely on those surfaces. +# The cache is keyed by the most stable identifier from the request +# envelope (preference: `session.user.user_id` → +# `session.application.application_id` → `session.session_id`). +_STATE_CACHE_TTL_SEC = 300 # 5 min — Alice session inactivity timeout +_STATE_CACHE_MAX = 200 + + +class DialogsWebhookHandler: + """Handles incoming voice-command webhook calls from a Yandex Dialogs skill.""" + + def __init__( + self, + mass: MusicAssistant, + *, + skill_id: str, + webhook_secret: str, + exposed_player_ids: set[str] | None = None, + voice_continuation: bool = False, + logger: logging.Logger | None = None, + ) -> None: + """Initialize the handler. + + :param mass: MusicAssistant instance. + :param skill_id: Configured ``CONF_DIALOG_SKILL_ID``; payloads + with a different ``session.skill_id`` are rejected. + :param webhook_secret: Random secret embedded in the webhook URL. + :param exposed_player_ids: Optional restriction set; only these + players are addressable by voice (passed to the player resolver). + :param voice_continuation: When True, play- and control-success + responses keep the conversation open (``end_session=False``) + so the user can issue follow-ups without re-saying the + activation phrase. Default False preserves today's voice-UX. + Stop / pause-with-no-resume utterances still close the session + via the existing control path. + :param logger: Optional logger override. + """ + self._mass = mass + self._skill_id = skill_id + self._webhook_secret = webhook_secret + self._exposed_player_ids = exposed_player_ids + self._voice_continuation = voice_continuation + self._logger = logger or _LOGGER + self._unregister_callbacks: list[Callable[[], None]] = [] + self._manifest_provider = SkillManifestProvider(mass) + # In-process state cache; see _STATE_CACHE_TTL_SEC / _MAX. + self._state_cache: OrderedDict[str, tuple[dict[str, Any], float]] = OrderedDict() + # Diagnostics counters surfaced via ``get_diagnostics()`` on the + # plugin instance — used by the Advanced section of the config form + # to show "N webhooks today, last X seconds ago". + self._webhook_call_count: int = 0 + self._authenticated_call_count: int = 0 + self._last_webhook_ts: float | None = None + + @property + def webhook_call_count(self) -> int: + """Total Yandex webhook invocations this process has handled.""" + return self._webhook_call_count + + @property + def authenticated_call_count(self) -> int: + """Webhook calls that passed the skill_id + secret gate. + + Counts every authenticated request — including slot-elicitation, + disambiguation, and info-only intents — not just calls that + resolved into a player action. Use this as a "we are receiving + traffic from Yandex" health signal, not as a "voice commands + actually did something" metric. + """ + return self._authenticated_call_count + + @property + def last_webhook_ts(self) -> float | None: + """Wall-clock timestamp of the most recent webhook (or None).""" + return self._last_webhook_ts + + def _cache_key(self, session: dict[str, Any]) -> str | None: + """Pick the most stable identifier for the in-process state cache. + + Preference order: ``session.user.user_id`` (per Yandex account, + most specific) → ``session.application.application_id`` (per + device) → ``session.session_id`` (per conversation). Returns + ``None`` if none are available — caller skips caching. + """ + user = session.get("user") + if isinstance(user, dict): + uid = user.get("user_id") + if isinstance(uid, str) and uid: + return f"user:{uid}" + app = session.get("application") + if isinstance(app, dict): + aid = app.get("application_id") + if isinstance(aid, str) and aid: + return f"app:{aid}" + sid = session.get("session_id") + if isinstance(sid, str) and sid: + return f"session:{sid}" + return None + + def _cache_get(self, session: dict[str, Any]) -> dict[str, Any]: + """Return the cached state for this caller, or {} if absent / expired.""" + key = self._cache_key(session) + if key is None: + return {} + entry = self._state_cache.get(key) + if entry is None: + return {} + state, ts = entry + if time.monotonic() - ts > _STATE_CACHE_TTL_SEC: + self._state_cache.pop(key, None) + return {} + # LRU touch. + self._state_cache.move_to_end(key) + return state + + def _cache_put(self, session: dict[str, Any], state: dict[str, Any]) -> None: + """Save state for this caller (LRU + TTL eviction). + + Pass an empty / cleared state dict (rather than skipping the + call) when the action explicitly drops pending/awaiting — this + ensures the cache reflects the post-action state and a stale + pending_command doesn't resurface on the next turn. + """ + key = self._cache_key(session) + if key is None: + return + self._state_cache[key] = (dict(state), time.monotonic()) + self._state_cache.move_to_end(key) + while len(self._state_cache) > _STATE_CACHE_MAX: + self._state_cache.popitem(last=False) + + def register_routes(self) -> None: + """Register the webhook route on mass.webserver.""" + path = f"{DIALOG_WEBHOOK_BASE_PATH}/{self._webhook_secret}" + redacted = f"{DIALOG_WEBHOOK_BASE_PATH}/...{self._webhook_secret[-4:]}" + try: + unregister = self._mass.webserver.register_dynamic_route( + path, self._handle_webhook, "POST" + ) + except RuntimeError: + self._logger.exception("Failed to register Dialogs webhook route %s", redacted) + raise + self._unregister_callbacks.append(unregister) + self._logger.info("Dialogs webhook registered at %s", redacted) + + def unregister_routes(self) -> None: + """Unregister the webhook route.""" + for cb in self._unregister_callbacks: + try: + cb() + except Exception: + self._logger.debug("Error unregistering dialog route", exc_info=True) + self._unregister_callbacks.clear() + + # ------------------------------------------------------------------- + # Webhook entry point + # ------------------------------------------------------------------- + + async def _handle_webhook(self, request: web.Request) -> web.Response: + # Path secret already enforced by the route URL — getting here means + # the secret matches. Still constant-time-compare it via the captured + # path arg in case aiohttp routing ever changes. + url_secret = request.match_info.get("secret") or request.path.rsplit("/", 1)[-1] + if not secrets.compare_digest(url_secret, self._webhook_secret): + return web.Response(status=404) + + # Counters: tick on every reachable POST so the Advanced LABEL can + # show the user when Yandex last hit us. Intent counter ticks only + # when we successfully dispatched a player action — see _dispatch_*. + self._webhook_call_count += 1 + self._last_webhook_ts = time.time() + + try: + body = await request.json() + except asyncio.CancelledError: + raise + except Exception: + return self._yandex_response( + incoming_session={}, + text="Что-то пошло не так с запросом.", + ) + if not isinstance(body, dict): + return self._yandex_response( + incoming_session={}, + text="Что-то пошло не так с запросом.", + ) + + session = body.get("session") or {} + if not isinstance(session, dict): + session = {} + req = body.get("request") or {} + if not isinstance(req, dict): + req = {} + meta = body.get("meta") or {} + if not isinstance(meta, dict): + meta = {} + has_screen = _has_screen(meta) + + # skill_id sanity check — reject if absent or mismatched. + incoming_skill_id = str(session.get("skill_id") or "") + if not incoming_skill_id or not secrets.compare_digest(incoming_skill_id, self._skill_id): + self._logger.warning( + "Rejecting dialog payload: skill_id %r != configured %r", + incoming_skill_id or "", + self._skill_id, + ) + return web.Response(status=401) + + # Past the skill_id gate the request is genuine traffic from our + # registered Yandex skill. Counts ALL authenticated requests + # (slot elicitation, disambiguation, info-only) — NOT just calls + # that resolve into a player action. Health signal only. + self._authenticated_call_count += 1 + + # Wrap post-auth dispatch so any unexpected exception (parser, + # search, MA dispatch, response builder) surfaces as a graceful + # Russian fallback instead of an aiohttp HTTP 500 → Alice silence. + # Logs the original exception for the operator to debug from + # `$HOME/.musicassistant/musicassistant.log`. Flagged in the + # upstream PR review (#3843, @chrisuthe) as a regression risk. + try: + return await self._handle_authenticated_request( + body=body, session=session, req=req, has_screen=has_screen + ) + except asyncio.CancelledError: + raise + except Exception: + self._logger.exception( + "Unhandled error in dialog webhook handler — " + "responding with generic fallback (session_id=%s)", + session.get("session_id", ""), + ) + text = "Что-то пошло не так. Попробуй ещё раз." + return self._yandex_response( + incoming_session=session, + text=text, + tts=_tts_for(text), + end_session=False, + ) + + async def _handle_authenticated_request( # noqa: PLR0915 + self, + *, + body: dict[str, Any], + session: dict[str, Any], + req: dict[str, Any], + has_screen: bool, + ) -> web.Response: + """Dispatch the request body once authentication has cleared. + + Wrapped in ``try / except`` by the caller so any unexpected raise + from a parser, the resolver, or MA dispatch lands as a graceful + fallback response rather than HTTP 500. Returns ``web.Response``. + + :param body: Parsed JSON envelope. + :param session: ``body["session"]`` already coerced to dict. + :param req: ``body["request"]`` already coerced to dict. + :param has_screen: Result of :func:`_has_screen` on the request. + """ + # State buckets. Three-tier read priority: + # 1. ``state.session`` — per-conversation, set by us last turn. + # 2. ``state.application`` — per-device, mirrored fallback. + # 3. In-process cache — server-side LRU keyed by user_id / + # application_id, last-resort for surfaces (notably the + # Yandex Station dev console emulator) that don't echo + # `state.*` back at all. + state = body.get("state") or {} + if not isinstance(state, dict): + state = {} + session_state_in = _safe_dict(state.get("session")) + app_state_in = _safe_dict(state.get("application")) + user_state_in = _safe_dict(state.get("user")) + cached_state = self._cache_get(session) + + default_id_raw = ( + session_state_in.get("last_player_id") + or app_state_in.get("last_player_id") + or user_state_in.get("preferred_player_id") + or cached_state.get("last_player_id") + ) + default_id = str(default_id_raw) if default_id_raw else None + + is_new = bool(session.get("new")) + command = str(req.get("command") or "").strip() + original_utterance = str(req.get("original_utterance") or "").strip() + + # request.markup.dangerous_context — Yandex flags suicide/violence/ + # hate content before passing the phrase through. If raised, refuse + # gracefully without engaging music search; passing flagged content + # to mass.music.search is bad PR (and may surface a result keyed off + # the flagged words). + markup = req.get("markup") or {} + if not isinstance(markup, dict): + markup = {} + dangerous_context = bool(markup.get("dangerous_context")) + + # Pending-command / awaiting-query lookups follow the same + # three-tier order as default_id: session → application → + # in-process cache. Yandex Station devices in particular + # sometimes drop both `state.session` AND `state.application` + # between SimpleUtterance turns — the cache is what makes + # disambiguation actually work on those surfaces. + pending_in = session_state_in.get("pending_command") + if not isinstance(pending_in, dict): + pending_in = app_state_in.get("pending_command") + if not isinstance(pending_in, dict): + pending_in = cached_state.get("pending_command") + awaiting_in = ( + bool(session_state_in.get("awaiting_query")) + or bool(app_state_in.get("awaiting_query")) + or bool(cached_state.get("awaiting_query")) + ) + + # Single summary log per incoming request — surfaces the wire-shape + # bits we route on. Sensitive fields (skill_id, webhook_secret, + # raw payload IDs) are excluded; user/session IDs are opaque + # tokens and DEBUG is opt-in, so they're included as-is. + # `original_utterance` is logged when it differs from the + # normalised `command` (Yandex strips punctuation and converts + # spelled-out numbers; the raw form helps misclassification + # post-mortems). + # Flagged content (`dangerous_context=true`) is redacted from + # both `cmd` and the raw suffix — Yandex flags suicide / hate / + # violence phrasings and we don't want to persist any of that + # in DEBUG logs even at the operator's request. + if dangerous_context: + cmd_for_log: str | None = "" + raw_suffix = "" + else: + cmd_for_log = command + raw_suffix = ( + f" raw={original_utterance!r}" + if original_utterance and original_utterance != command + else "" + ) + self._logger.debug( + "Webhook recv: cmd=%r%s req_type=%s is_new=%s pending=%s " + "(session=%s app=%s cache=%s) awaiting=%s default_player=%s " + "dangerous=%s session_id=%s", + cmd_for_log, + raw_suffix, + req.get("type", "SimpleUtterance"), + is_new, + bool(pending_in), + bool(session_state_in.get("pending_command")), + bool(app_state_in.get("pending_command")), + bool(cached_state.get("pending_command")), + awaiting_in, + default_id, + dangerous_context, + session.get("session_id", ""), + ) + + if is_new and not command: + text = "Привет! Скажи, что включить и на какой колонке." + return self._yandex_response( + incoming_session=session, + text=text, + tts=_tts_for(text), + end_session=False, + session_state=session_state_in, + ) + + if not command: + text = "Не понял команду. Скажи, например: включи рок на кухне." + return self._yandex_response( + incoming_session=session, + text=text, + tts=_tts_for(text), + end_session=False, + session_state=session_state_in, + ) + + if dangerous_context: + # Refuse gracefully and end session. Don't engage NLU or search + # so a flagged phrase never lands in mass.music.search results + # or in our logs as an "intent". Drop pending/awaiting state + # so the next conversation starts clean. + self._logger.info( + "Dropping flagged-content request (dangerous_context=true); session_id=%s", + session.get("session_id", ""), + ) + text = "Не понял команду." + return self._yandex_response( + incoming_session=session, + text=text, + tts=_tts_for(text), + end_session=True, + session_state=_without_pending(session_state_in), + ) + + # P0.6 — try control commands (pause/next/volume/...) FIRST, on + # the raw command. Doing this before the awaiting-query synthesis + # lets the user pivot from a slot-elicit prompt straight into a + # control intent ("Включи." → "Что включить?" → "пауза на кухне") + # without the prefix-prepend turning it into "включи пауза…". + # If control matches, drop any pending/awaiting state — the user + # is no longer in either of those flows. + # `entities` (request.nlu.entities) feeds parse_control's + # YANDEX.NUMBER fallback for relative-volume phrasings where + # the regex didn't anchor on a digit. + nlu = req.get("nlu") or {} + if not isinstance(nlu, dict): + nlu = {} + nlu_entities = nlu.get("entities") if isinstance(nlu.get("entities"), list) else None + nlu_intents = nlu.get("intents") if isinstance(nlu.get("intents"), dict) else None + + # Built-in YANDEX.* intents — emitted automatically by Yandex once + # any custom grammar is declared. Two we care about today: + # + # * YANDEX.REJECT ("отмена / нет / неважно / отстань") — back out + # of any in-flight prompt. Clears pending_command / awaiting_query + # and ends the session so the user can speak again from scratch. + # * YANDEX.HELP ("помоги / что я могу / помощь") — surface a + # contextual hint depending on the current prompt; keeps state + # so the user can answer the original question afterwards. + # + # YANDEX.CONFIRM and YANDEX.REPEAT aren't wired today: confirm is + # ambiguous in our flows (which player are you confirming?) and + # repeat would require caching the last response on session_state + # — both deferred to a later session. + if isinstance(nlu_intents, dict): + if "YANDEX.REJECT" in nlu_intents and (pending_in or awaiting_in): + self._logger.debug("YANDEX.REJECT in pending/awaiting state → cancel") + text = "Хорошо, отменил." + return self._yandex_response( + incoming_session=session, + text=text, + tts=_tts_for(text), + end_session=True, + session_state=_without_pending(session_state_in), + application_state=_without_pending(app_state_in), + ) + if "YANDEX.HELP" in nlu_intents: + if pending_in: + text = "Скажи имя колонки или её номер из списка." + elif awaiting_in: + text = "Скажи имя артиста, песни, альбома или плейлиста." + else: + text = "Скажи, например: включи рок на кухне." + self._logger.debug("YANDEX.HELP → contextual hint") + return self._yandex_response( + incoming_session=session, + text=text, + tts=_tts_for(text), + end_session=False, + # Preserve pending/awaiting state so the user can answer the + # original prompt right after this hint. + session_state=session_state_in, + ) + + # Phase 2 — platform-pre-classified intents take precedence over + # the regex parsers. When grammar matched the phrase upstream, + # the result lands in `request.nlu.intents.`; map it + # back to our existing ParsedControl / ParsedCommand and skip the + # regex pass. Falls through to the regex parsers when the block + # is empty (no grammar declared or no match). + platform = self._manifest_provider.parse_intent(nlu_intents) + if isinstance(platform, ParsedControl): + # Yandex's static intent grammar can't enumerate the user's + # per-skill list of player names, so the "на " suffix + # ("пауза на кухне") doesn't make it into the slots. Recover + # the hint from the raw command text and attach it here. + if platform.player_hint is None: + hint = extract_trailing_player_hint(command) + if hint: + platform = dataclasses.replace(platform, player_hint=hint) + self._logger.debug("Platform intent → control %r (skipping regex parser)", platform) + return self._handle_control( + session=session, + control=platform, + default_id=default_id, + session_state_in=_without_pending(session_state_in), + app_state_in=app_state_in, + has_screen=has_screen, + ) + if isinstance(platform, ParsedCommand): + self._logger.debug("Platform intent → play %r (skipping regex parser)", platform) + return await self._dispatch_play( + session=session, + parsed=platform, + default_id=default_id, + session_state_in=session_state_in, + app_state_in=app_state_in, + has_screen=has_screen, + ) + + if control := parse_control(command, entities=nlu_entities): + self._logger.debug("Parsed dialog control %r → %r", command, control) + return self._handle_control( + session=session, + control=control, + default_id=default_id, + session_state_in=_without_pending(session_state_in), + app_state_in=app_state_in, + has_screen=has_screen, + ) + + # P0.4 — awaiting-query re-entry. If the previous turn asked "Что + # включить?" and the new utterance isn't a control phrase, treat + # it as the missing query slot. Prepend a synthetic "включи " so + # the existing kind classifier runs ("песню X", "альбом Y", + # "мою волну", etc.). Skip the synthetic prefix if the user + # already said one of the verbs. + if awaiting_in and not _VERB_RE.match(command): + command = f"включи {command}" + self._logger.debug("Awaiting-query branch: synthesised cmd=%r", command) + # If slot-elicit was triggered with a player hint that resolved + # to a single exposed player, the follow-up turn should play on + # that player. Surface it as `default_id` so the resolver picks + # it without the user re-stating "на кухне". + if awaiting_in and not default_id: + saved_pid = ( + session_state_in.get("awaiting_player_id") + or app_state_in.get("awaiting_player_id") + or cached_state.get("awaiting_player_id") + ) + if saved_pid: + default_id = str(saved_pid) + self._logger.debug( + "Awaiting-query branch: restored hinted player as default_id=%s", + default_id, + ) + + # P0.3 — pending-command re-entry. If a previous turn asked the + # user to disambiguate which player to use, the new utterance (or + # button press) carries the answer; replay the saved play intent. + # `pending_in` was merged from `state.session` and `state.application` + # earlier so this works even on devices that don't preserve + # session-state between SimpleUtterance turns. + if isinstance(pending_in, dict): + pending: dict[str, Any] = pending_in + self._logger.debug( + "Pending-command branch: kind=%s query=%r radio=%s; cmd=%r payload=%s", + pending.get("kind"), + pending.get("query"), + pending.get("radio_mode"), + command, + bool(_safe_dict(req.get("payload")).get("player_id")), + ) + replay_response = await self._try_resume_pending( + session=session, + req=req, + command=command, + pending=pending, + session_state_in=session_state_in, + app_state_in=app_state_in, + has_screen=has_screen, + ) + if replay_response is not None: + return replay_response + self._logger.debug( + "Pending-command branch: could not resume — falling through to parse_command" + ) + + parsed = parse_command(command) + self._logger.debug("Parsed dialog command %r → %r", command, parsed) + return await self._dispatch_play( + session=session, + parsed=parsed, + default_id=default_id, + session_state_in=session_state_in, + app_state_in=app_state_in, + has_screen=has_screen, + ) + + # ------------------------------------------------------------------- + # Play dispatch (slot-elicit + resolve + disambiguate + play) + # ------------------------------------------------------------------- + + async def _dispatch_play( + self, + *, + session: dict[str, Any], + parsed: ParsedCommand, + default_id: str | None, + session_state_in: dict[str, Any], + app_state_in: dict[str, Any], + has_screen: bool = True, + ) -> web.Response: + """Slot-elicit / resolve player / disambiguate / play (or fail).""" + # P0.4 — slot elicitation: bare verb with no actionable content. + # Triggers whenever the query slot is empty, even if the user + # specified a player hint ("включи на кухне"). Falling through + # would respond "Не нашёл такую музыку: ." which is confusing — + # the user clearly *wants* something, just didn't name it yet. + # If a hint resolves to a single exposed player, save its id as + # `awaiting_player_id` so the follow-up turn plays on it. + if parsed.kind == "search" and not parsed.query: + self._logger.debug( + "Slot-elicit branch: empty query (hint=%r), asking 'Что включить?'", + parsed.player_hint, + ) + awaiting_player_id: str | None = None + if parsed.player_hint: + hinted_candidates = resolve_player_candidates( + self._mass, + parsed.player_hint, + default_id=default_id, + exposed_ids=self._exposed_player_ids, + ) + if len(hinted_candidates) == 1: + awaiting_player_id = hinted_candidates[0].player_id + text = "Что включить? Можно сказать имя артиста, песни или плейлиста." + elicit_state: dict[str, Any] = {"awaiting_query": True} + if awaiting_player_id: + elicit_state["awaiting_player_id"] = awaiting_player_id + return self._yandex_response( + incoming_session=session, + text=text, + tts=_tts_for(text), + end_session=False, + session_state={**_without_pending(session_state_in), **elicit_state}, + # Mirror to application_state so the next turn can find + # the flag even if Yandex didn't echo `state.session`. + application_state={**_without_pending(app_state_in), **elicit_state}, + ) + + candidates = resolve_player_candidates( + self._mass, + parsed.player_hint, + default_id=default_id, + exposed_ids=self._exposed_player_ids, + ) + if not candidates: + # Special case: no hint, no default, multiple exposed players. + # `resolve_player_candidates` returns [] with no hint when it + # can't pick deterministically — for the user that's ambiguity, + # not "not found". Surface all exposed players for + # disambiguation instead of the misleading "не нашёл колонку + # «(не указано)»". + if parsed.player_hint is None and default_id is None: + all_exposed = list_exposed_players(self._mass, exposed_ids=self._exposed_player_ids) + if len(all_exposed) >= 2: + self._logger.debug( + "Play branch: no hint + no default + %d exposed → " + "disambiguation across all exposed players", + len(all_exposed), + ) + return self._build_disambiguation_response( + session=session, + parsed=parsed, + candidates=all_exposed, + session_state_in=session_state_in, + app_state_in=app_state_in, + has_screen=has_screen, + ) + hint = parsed.player_hint or "(не указано)" + self._logger.info( + "Play branch: no player resolved for hint=%r (default_id=%s); " + "responding 'не нашёл колонку'", + parsed.player_hint, + default_id, + ) + text = f"Не нашёл колонку «{hint}». Скажи, например: на кухне." + return self._yandex_response( + incoming_session=session, + text=text, + tts=_tts_for(text), + end_session=False, + session_state=session_state_in, + ) + if len(candidates) > 1: + self._logger.debug( + "Play branch: ambiguous, %d candidates → disambiguation prompt", + len(candidates), + ) + return self._build_disambiguation_response( + session=session, + parsed=parsed, + candidates=candidates, + session_state_in=session_state_in, + app_state_in=app_state_in, + has_screen=has_screen, + ) + + self._logger.debug( + "Play branch: resolved → player %s (%s)", + candidates[0].name or candidates[0].player_id, + candidates[0].player_id, + ) + return await self._play_with_player( + session=session, + parsed=parsed, + player=candidates[0], + base_session_state=session_state_in, + base_app_state=app_state_in, + has_screen=has_screen, + ) + + # ------------------------------------------------------------------- + # Control execution helper (P0.6) + # ------------------------------------------------------------------- + + def _handle_control( # noqa: PLR0915 + self, + *, + session: dict[str, Any], + control: Any, + default_id: str | None, + session_state_in: dict[str, Any], + app_state_in: dict[str, Any], + has_screen: bool = True, + ) -> web.Response: + """Resolve player + dispatch a control action; build response.""" + # list_players is informational — no player resolution / dispatch. + if control.action == "list_players": + players = list_exposed_players(self._mass, exposed_ids=self._exposed_player_ids) + text = format_list_players(players) + self._logger.debug( + "Control list_players → %d player(s): %s", + len(players), + [getattr(p, "name", None) or p.player_id for p in players], + ) + return self._yandex_response( + incoming_session=session, + text=text, + tts=_tts_for(text), + end_session=False, + session_state=session_state_in, + ) + + # now_playing — info query about the current track. Reads + # `mass.player_queues.get(pid).current_item.name` (already + # pre-formatted as "Artist - Title" or stream title for radio). + if control.action == "now_playing": + target_player = resolve_player( + self._mass, + control.player_hint, + default_id=default_id, + exposed_ids=self._exposed_player_ids, + ) + if target_player is None: + if control.player_hint: + text = f"Не нашёл колонку «{control.player_hint}». Скажи, например: на кухне." + else: + text = "Скажи, на какой колонке. Например: что играет на кухне." + return self._yandex_response( + incoming_session=session, + text=text, + tts=_tts_for(text), + end_session=False, + session_state=session_state_in, + ) + queue = None + try: + queue = self._mass.player_queues.get(target_player.player_id) + except Exception: + self._logger.debug("player_queues.get failed", exc_info=True) + current = getattr(queue, "current_item", None) if queue is not None else None + current_name = getattr(current, "name", None) if current is not None else None + display_name = target_player.name or target_player.player_id + if current_name: + text = f"Сейчас играет: {current_name}." + else: + text = f"На {display_name} сейчас ничего не играет." + self._logger.debug( + "Control now_playing on %s → %r", + target_player.player_id, + current_name, + ) + return self._yandex_response( + incoming_session=session, + text=text, + tts=_tts_for(text), + end_session=False, + session_state=session_state_in, + ) + + # transfer — moves the queue from the saved default player to + # the named target player. SOURCE = `default_id` (last-used); + # TARGET = `control.player_hint` (parsed from "переведи на X"). + if control.action == "transfer": + if not default_id: + text = "Не понял, откуда переводить. Сначала включи музыку на колонке." + return self._yandex_response( + incoming_session=session, + text=text, + tts=_tts_for(text), + end_session=False, + session_state=session_state_in, + ) + target_candidates = resolve_player_candidates( + self._mass, + control.player_hint, + default_id=None, + exposed_ids=self._exposed_player_ids, + ) + if not target_candidates: + hint = control.player_hint or "(не указано)" + text = f"Не нашёл колонку «{hint}». Скажи, например: на кухне." + return self._yandex_response( + incoming_session=session, + text=text, + tts=_tts_for(text), + end_session=False, + session_state=session_state_in, + ) + if len(target_candidates) > 1: + # Multi-match: reuse the disambiguation flow, but the + # pending intent for replay is "transfer this queue". + # We don't currently support resuming a transfer through + # `_try_resume_pending` (it's coupled to play intent), + # so for now just respond with a clarification. + names = ", ".join(p.name or p.player_id for p in target_candidates[:5]) + text = f"Не понял на какую колонку. Уточни: {names}?" + return self._yandex_response( + incoming_session=session, + text=text, + tts=_tts_for(text), + end_session=False, + session_state=session_state_in, + ) + target = target_candidates[0] + target_name = target.name or target.player_id + if target.player_id == default_id: + text = f"Уже играет на {target_name}." + return self._yandex_response( + incoming_session=session, + text=text, + tts=_tts_for(text), + end_session=False, + session_state=session_state_in, + ) + self._logger.info( + "Control transfer: %s → %s", + default_id, + target.player_id, + ) + self._mass.create_task( + self._mass.player_queues.transfer_queue( + source_queue_id=default_id, + target_queue_id=target.player_id, + ) + ) + new_session_state = {**session_state_in, "last_player_id": target.player_id} + new_app_state = {**app_state_in, "last_player_id": target.player_id} + user_obj_t = session.get("user") or {} + user_state_update_t: dict[str, Any] | None = None + if isinstance(user_obj_t, dict) and user_obj_t.get("user_id"): + user_state_update_t = {"preferred_player_id": target.player_id} + text = f"Перевожу на {target_name}." + return self._yandex_response( + incoming_session=session, + text=text, + tts=_tts_for(text), + session_state=new_session_state, + application_state=new_app_state, + user_state_update=user_state_update_t, + ) + + # forget_player clears the saved default-player from all three + # state tiers (session / application / cache) AND emits a + # `user_state_update` with `preferred_player_id: None` so the + # next play command without an explicit hint asks the user to + # pick again. Doesn't need a target — purely state management. + if control.action == "forget_player": + self._logger.info("Control forget_player → clearing last_player_id from all tiers") + new_session_state = {k: v for k, v in session_state_in.items() if k != "last_player_id"} + new_app_state = {k: v for k, v in app_state_in.items() if k != "last_player_id"} + user_obj_forget = session.get("user") or {} + user_state_update_forget: dict[str, Any] | None = None + if isinstance(user_obj_forget, dict) and user_obj_forget.get("user_id"): + # Yandex spec: a key set to None in `user_state_update` + # tells the platform to delete it from the merged + # user-scoped state. + user_state_update_forget = {"preferred_player_id": None} + text = control_confirmation(control) + return self._yandex_response( + incoming_session=session, + text=text, + tts=_tts_for(text), + end_session=False, + session_state=new_session_state, + application_state=new_app_state, + user_state_update=user_state_update_forget, + ) + + player = resolve_player( + self._mass, + control.player_hint, + default_id=default_id, + exposed_ids=self._exposed_player_ids, + ) + if player is None: + self._logger.info( + "Control %s: no player resolved (hint=%r, default_id=%s)", + control.action, + control.player_hint, + default_id, + ) + # Distinguish "no hint + ambiguous" from "hint given but unknown" + # so the message matches the actual cause. + if control.player_hint: + text = f"Не нашёл колонку «{control.player_hint}». Скажи, например: на кухне." + else: + text = "Скажи, на какой колонке. Например: пауза на кухне." + return self._yandex_response( + incoming_session=session, + text=text, + tts=_tts_for(text), + end_session=False, + session_state=session_state_in, + ) + self._logger.debug( + "Control %s → player %s (%s) value=%s", + control.action, + player.name or player.player_id, + player.player_id, + control.value, + ) + self._mass.create_task(execute_control(self._mass, control, player)) + # Clear any pending disambiguation / awaiting-query state from + # both tiers — the user took a different path. (`session_state_in` + # was already cleaned by the caller with `_without_pending`; do + # the same defensively here for application_state.) + new_session_state = {**session_state_in, "last_player_id": player.player_id} + new_app_state = { + **_without_pending(app_state_in), + "last_player_id": player.player_id, + } + user_obj = session.get("user") or {} + user_state_update: dict[str, Any] | None = None + if isinstance(user_obj, dict) and user_obj.get("user_id"): + user_state_update = {"preferred_player_id": player.player_id} + text = control_confirmation(control) + # Stop is the natural session-end signal — even with voice + # continuation enabled, "стоп / выключи" should hand the mic + # back to the user instead of staying in the skill listening loop. + end_session = True if control.action == "stop" else not self._voice_continuation + return self._yandex_response( + incoming_session=session, + text=text, + tts=_tts_for(text), + end_session=end_session, + session_state=new_session_state, + application_state=new_app_state, + user_state_update=user_state_update, + buttons=_PLAYBACK_SUGGESTION_BUTTONS if has_screen else None, + ) + + # ------------------------------------------------------------------- + # Play execution helper (shared by initial flow and pending replay) + # ------------------------------------------------------------------- + + async def _play_with_player( + self, + *, + session: dict[str, Any], + parsed: ParsedCommand, + player: Any, + base_session_state: dict[str, Any], + base_app_state: dict[str, Any], + has_screen: bool = True, + ) -> web.Response: + """Search media, fire-and-forget play, build response with persisted state.""" + try: + media = await asyncio.wait_for( + resolve_query(self._mass, parsed), timeout=DIALOG_RESOLVE_TIMEOUT + ) + except TimeoutError: + self._logger.warning( + "Music search timed out (>%.1fs) for query %r", + DIALOG_RESOLVE_TIMEOUT, + parsed.query, + ) + text = "Поиск занял слишком долго, попробуй ещё раз." + return self._yandex_response( + incoming_session=session, + text=text, + tts=_tts_for(text), + session_state=_without_pending(base_session_state), + application_state=_without_pending(base_app_state), + ) + + if media is None: + text = f"Не нашёл такую музыку: {parsed.query}." + return self._yandex_response( + incoming_session=session, + text=text, + tts=_tts_for(text), + session_state=_without_pending(base_session_state), + application_state=_without_pending(base_app_state), + ) + + # Fire-and-forget — Alice has a 4.5s budget; play_media may take longer + # to actually start streaming. mass.create_task tracks the task in the + # MA lifecycle (cancelled on shutdown) and logs unhandled exceptions. + self._mass.create_task( + play_for_alice( + self._mass, + player.player_id, + media, + radio_mode=parsed.radio_mode, + enqueue_option=parsed.enqueue_option, + ) + ) + + new_session_state = { + **_without_pending(base_session_state), + "last_player_id": player.player_id, + } + # Also clear pending/awaiting from `application_state` — it was + # mirrored there as a fallback for devices that don't preserve + # `state.session` between turns. + new_app_state = { + **_without_pending(base_app_state), + "last_player_id": player.player_id, + } + user_obj = session.get("user") or {} + user_state_update: dict[str, Any] | None = None + if isinstance(user_obj, dict) and user_obj.get("user_id"): + user_state_update = {"preferred_player_id": player.player_id} + + spoken_query = parsed.query or "музыку" + player_label = player.name or player.player_id + if parsed.enqueue_option == "add": + text = f"Добавил {spoken_query} в очередь на {player_label}." + elif parsed.enqueue_option == "next": + text = f"Поставил {spoken_query} следующим на {player_label}." + else: + text = f"Включаю {spoken_query} на {player_label}." + return self._yandex_response( + incoming_session=session, + text=text, + tts=_tts_for(text), + end_session=not self._voice_continuation, + session_state=new_session_state, + application_state=new_app_state, + user_state_update=user_state_update, + buttons=_PLAYBACK_SUGGESTION_BUTTONS if has_screen else None, + ) + + # ------------------------------------------------------------------- + # Disambiguation (P0.3) + # ------------------------------------------------------------------- + + def _build_disambiguation_response( + self, + *, + session: dict[str, Any], + parsed: ParsedCommand, + candidates: list[Any], + session_state_in: dict[str, Any], + app_state_in: dict[str, Any] | None = None, + has_screen: bool = True, + ) -> web.Response: + """Ask the user which player to use — voice-first, with optional buttons. + + Most Yandex Stations are screenless audio devices, so the prompt + has to make voice answer obvious. We enumerate candidates with + Russian ordinals (`первая` / `вторая` / …) so a user can say + either the player name (free-text fallback) or the position. + Buttons are emitted only on screened surfaces; voice-only devices + get the same prompt without the button payload. + """ + # Yandex caps ItemsList at 5 anyway; cap our buttons to the same. + capped = candidates[:5] + names = [p.name or p.player_id for p in capped] + + # Voice prompt: ordinal-labelled list + explicit voice instruction. + # Example for 2 candidates: + # "На какой колонке? Первая — Кухня большая, вторая — Кухня + # маленькая. Скажи название или номер." + labelled = [f"{_ORDINAL_LABELS[i]} — {name}" for i, name in enumerate(names)] + text = "На какой колонке? " + ", ".join(labelled) + ". Скажи название или номер." + buttons: list[dict[str, Any]] | None = ( + [ + { + "title": (p.name or p.player_id)[:64], + "payload": {"player_id": p.player_id}, + "hide": True, + } + for p in capped + ] + if has_screen + else None + ) + # Clear any prior `awaiting_query` / `pending_command` before + # writing the new one, and include the saved `pending_command`. + # The same pending entry is mirrored to BOTH `session_state` and + # `application_state` because some Yandex devices (notably + # screenless Stations) don't reliably echo `state.session` back + # across SimpleUtterance turns. The application tier persists + # per-device — it survives session resets and is honoured on + # every surface we've tested. Reads in `_handle_webhook` merge + # the two tiers (session preferred, application as fallback). + pending_command: dict[str, Any] = { + "kind": parsed.kind, + "query": parsed.query[:200], + "radio_mode": parsed.radio_mode, + # Ordered list of player IDs we offered. Used by + # `_try_resume_pending` to (a) resolve "первая"/"вторая" + # to a specific player by position, (b) re-narrow free-text + # matching to just these candidates so a short distinguisher + # wins even if a third matching player exists outside the + # disambiguation set. + "candidate_ids": [p.player_id for p in capped], + } + # Preserve the enqueue option (add / next) across the + # disambiguation re-entry — without this an ambiguous + # "добавь Iron Maiden" would resume as REPLACE after the + # user picks the player, defeating the add-to-queue intent. + if parsed.enqueue_option is not None: + pending_command["enqueue_option"] = parsed.enqueue_option + new_session_state = { + **_without_pending(session_state_in), + "pending_command": pending_command, + } + new_app_state = { + **_without_pending(app_state_in or {}), + "pending_command": pending_command, + } + return self._yandex_response( + incoming_session=session, + text=text, + tts=_tts_for(text), + end_session=False, + session_state=new_session_state, + application_state=new_app_state, + buttons=buttons, + ) + + async def _try_resume_pending( + self, + *, + session: dict[str, Any], + req: dict[str, Any], + command: str, + pending: dict[str, Any], + session_state_in: dict[str, Any], + app_state_in: dict[str, Any], + has_screen: bool = True, + ) -> web.Response | None: + """Attempt to resume a saved pending_command using button payload or text. + + Returns a response if the pending command was resumed (success or + decided failure). Returns None when the new utterance doesn't + resolve to a player at all — caller falls through to normal + parse_command flow. + """ + chosen_player: Any = None + candidate_ids_raw = pending.get("candidate_ids") + candidate_ids: list[str] = ( + [str(x) for x in candidate_ids_raw if isinstance(x, str)] + if isinstance(candidate_ids_raw, list) + else [] + ) + # Preserve enqueue intent (add / next) across the disambiguation + # re-entry; otherwise an ambiguous "добавь Iron Maiden" would + # replay as REPLACE after the user picks a player. + enqueue_raw = pending.get("enqueue_option") + pending_enqueue: str | None = enqueue_raw if isinstance(enqueue_raw, str) else None + exposed = list_exposed_players(self._mass, exposed_ids=self._exposed_player_ids) + exposed_by_id = {p.player_id: p for p in exposed} + + # Step 1 — Button press. Direct UI signal on surfaces with a + # screen. Validate against the currently exposed set + # (defence-in-depth: stale / crafted payloads are rejected). + payload = req.get("payload") + if isinstance(payload, dict): + pid = payload.get("player_id") + if isinstance(pid, str): + chosen_player = exposed_by_id.get(pid) + if chosen_player is None: + self._logger.warning( + "Pending replay: ButtonPressed payload player_id=%r " + "not in exposed-player set; ignoring", + pid, + ) + + # Step 2 — Free-text first. Lets named answers ("Кухня большая" + # / "большая" / "маленькую") and even hypothetical players whose + # names contain ordinal words ("Спальня первая") win over the + # purely-positional ordinal interpretation. Narrow the resolver + # to the saved candidate set so a short distinguisher like + # "большая" doesn't accidentally pick an unrelated third player + # outside the disambiguation set. + if chosen_player is None: + followup = parse_command(command) + hint = followup.player_hint or command + narrowed_ids: set[str] | None + if candidate_ids: + narrowed_ids = set(candidate_ids) + if self._exposed_player_ids is not None: + narrowed_ids &= self._exposed_player_ids + else: + narrowed_ids = self._exposed_player_ids + candidates = resolve_player_candidates( + self._mass, + hint, + default_id=None, + exposed_ids=narrowed_ids, + ) + if len(candidates) == 1: + chosen_player = candidates[0] + self._logger.debug( + "Pending replay: free-text → player %s", + chosen_player.name or chosen_player.player_id, + ) + elif len(candidates) > 1: + # Still ambiguous — re-ask with the saved play intent. + return self._build_disambiguation_response( + session=session, + parsed=ParsedCommand( + kind=str(pending.get("kind", "search")), # type: ignore[arg-type] + query=str(pending.get("query", "")), + radio_mode=bool(pending.get("radio_mode", False)), + enqueue_option=pending_enqueue, # type: ignore[arg-type] + ), + candidates=candidates, + session_state_in=session_state_in, + app_state_in=app_state_in, + has_screen=has_screen, + ) + + # Step 3 — voice ordinal ("первая", "выбираю первую", "номер + # три"). Last because we want named answers to win even when + # they happen to contain an ordinal word ("Спальня первая"). + # On screenless smart speakers ordinal is the natural reply + # when none of the names is easy to pronounce. + if chosen_player is None: + ordinal = _parse_ordinal_choice(command) + if ordinal is not None: + target_pid: str | None = ( + candidate_ids[ordinal] if 0 <= ordinal < len(candidate_ids) else None + ) + if target_pid is not None: + chosen_player = exposed_by_id.get(target_pid) + if chosen_player is not None: + self._logger.debug( + "Pending replay: voice ordinal %d → player %s", + ordinal, + chosen_player.name or chosen_player.player_id, + ) + # If the ordinal couldn't be resolved (out of range, or + # the indexed player is no longer exposed), the user + # clearly *meant* to pick from the disambiguation list — + # re-ask with whichever candidates are still exposed + # instead of falling through and mis-interpreting + # "третья" as a play query. + if chosen_player is None: + still_available = [ + exposed_by_id[pid] for pid in candidate_ids if pid in exposed_by_id + ] + if still_available: + self._logger.info( + "Pending replay: ordinal=%d unresolvable; " + "re-asking with %d remaining candidate(s)", + ordinal, + len(still_available), + ) + return self._build_disambiguation_response( + session=session, + parsed=ParsedCommand( + kind=str(pending.get("kind", "search")), # type: ignore[arg-type] + query=str(pending.get("query", "")), + radio_mode=bool(pending.get("radio_mode", False)), + enqueue_option=pending_enqueue, # type: ignore[arg-type] + ), + candidates=still_available, + session_state_in=session_state_in, + app_state_in=app_state_in, + has_screen=has_screen, + ) + # else: no candidates remain at all — fall through. + + if chosen_player is None: + return None + + replay = ParsedCommand( + kind=str(pending.get("kind", "search")), # type: ignore[arg-type] + query=str(pending.get("query", "")), + radio_mode=bool(pending.get("radio_mode", False)), + enqueue_option=pending_enqueue, # type: ignore[arg-type] + ) + return await self._play_with_player( + session=session, + parsed=replay, + player=chosen_player, + base_session_state=session_state_in, + base_app_state=app_state_in, + has_screen=has_screen, + ) + + # ------------------------------------------------------------------- + # Yandex Dialogs response envelope + # ------------------------------------------------------------------- + + def _yandex_response( + self, + *, + incoming_session: dict[str, Any], + text: str, + tts: str | None = None, + end_session: bool = True, + session_state: dict[str, Any] | None = None, + application_state: dict[str, Any] | None = None, + user_state_update: dict[str, Any] | None = None, + buttons: list[dict[str, Any]] | None = None, + card: dict[str, Any] | None = None, + ) -> web.Response: + """Build a Yandex Dialogs response envelope. + + ``session_state`` / ``application_state`` are full overwrites per + Yandex spec; ``user_state_update`` is merged into the existing + user-scoped state (set keys to None to clear). Omit a parameter + to leave that bucket unchanged on Yandex's side. + + ``card`` accepts one of the three Yandex card shapes: + ``BigImage`` (single image + title + description), + ``ItemsList`` (1-5 items, each with image + title), or + ``ImageGallery`` (1-7 images). Yandex silently drops the field + on voice-only surfaces, so callers must still gate emission on + ``meta.interfaces.screen`` to avoid wasted bandwidth and to + honour the buttons-on-screen-only contract. + + Side effect: any time we set ``session_state`` or + ``application_state``, the merged value is also written to the + in-process state cache as a third-tier fallback (see + ``_cache_put``). The cache is what makes disambiguation work + on Yandex Station devices that don't echo `state.*` back. + """ + # Yandex envelopes carry two user_id fields: the deprecated root + # `session.user_id` (always present in current API revisions for + # backwards compatibility) and the nested `session.user.user_id` + # (set only when the user is account-linked). Prefer the root for + # historical reasons but fall back to the nested form so the + # echo doesn't leak an empty string if a future Yandex API + # revision drops the root field. + user_id = incoming_session.get("user_id") or _safe_dict(incoming_session.get("user")).get( + "user_id", "" + ) + echoed = { + "session_id": incoming_session.get("session_id", ""), + "message_id": incoming_session.get("message_id", 0), + "user_id": user_id, + } + response_body: dict[str, Any] = { + "text": text, + "tts": tts if tts is not None else text, + "end_session": end_session, + } + if buttons: + response_body["buttons"] = buttons + if card: + response_body["card"] = card + payload: dict[str, Any] = { + "version": "1.0", + "session": echoed, + "response": response_body, + } + if session_state is not None: + payload["session_state"] = session_state + if application_state is not None: + payload["application_state"] = application_state + if user_state_update is not None: + payload["user_state_update"] = user_state_update + + # Mirror state into the in-process cache. Prefer session_state + # (most specific to the current conversation); fall back to + # application_state if only that was set. We store a merged + # snapshot so reads on the next turn pick up everything. + cache_state: dict[str, Any] = {} + if application_state is not None: + cache_state.update(application_state) + if session_state is not None: + cache_state.update(session_state) + if cache_state or session_state is not None or application_state is not None: + self._cache_put(incoming_session, cache_state) + + return web.json_response(payload) diff --git a/music_assistant/providers/yandex_alice/dialogs_control.py b/music_assistant/providers/yandex_alice/dialogs_control.py new file mode 100644 index 0000000000..fea827fedc --- /dev/null +++ b/music_assistant/providers/yandex_alice/dialogs_control.py @@ -0,0 +1,313 @@ +# ruff: noqa: RUF001 +"""Playback-control NLU + executor for the Yandex Dialogs custom skill. + +As of v1.4.0 most of the control surface is recognised by Yandex itself +through declarative custom intents (see ``provider.dialogs_grammar``); +this module's regex parser only carries the few phrases that +fundamentally don't fit a static grammar — currently just ``transfer`` +("переведи музыку на кухню"), where the destination is a per-user +dynamic enum of player names. + +The webhook handler always tries the platform-side +``parse_platform_intent`` first; ``parse_control`` is the fallback for +phrases Yandex didn't pre-classify (e.g. before the skill update has +finished moderation, or for the dynamic-target ``transfer`` family). + +The executor (`execute_control`) and confirmation-text helper +(`control_confirmation`) here remain authoritative regardless of which +parser produced the ``ParsedControl`` — they translate it to MA API +calls / spoken responses. +""" + +from __future__ import annotations + +import asyncio +import logging +import re +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, Literal + +from music_assistant_models.enums import RepeatMode + +from .dialogs_nlu import _PUNCT_RE, _SPACE_RE + +if TYPE_CHECKING: + from music_assistant.mass import MusicAssistant + + +_LOGGER = logging.getLogger(__name__) + +ControlAction = Literal[ + "pause", + "resume", + "stop", + "next", + "previous", + "volume_up", + "volume_down", + "volume_set", + "volume_relative", # value = signed delta (+20, -5); executor reads current vol + clamps + "mute", + "unmute", + "list_players", + "forget_player", + "now_playing", # info — handler reads queue.current_item.name + "shuffle_on", + "shuffle_off", + "repeat_off", + "repeat_one", + "repeat_all", + "seek_forward", # value = positive seconds + "seek_back", # value = positive seconds; negated when dispatched + "seek_start", # absolute seek to 0 + "transfer", # player_hint = TARGET player; SOURCE is the saved default +] + + +@dataclass(frozen=True, slots=True) +class ParsedControl: + """Result of classifying a Yandex Dialogs voice command as a control action.""" + + action: ControlAction + value: int | None = None + player_hint: str | None = None + + +# Transfer playback to a target player. The target name is captured into +# `player_hint`; SOURCE comes from the caller's `default_id`. Stays in +# regex because the destination is a per-user dynamic list of player +# names — a static custom-intent grammar can't enumerate it without +# regenerating the skill on every player-list change. +_TRANSFER_RE = re.compile( + r"^(?:переведи|перенеси|продолжи)\s+(?:музыку\s+)?(?:на|в)\s+(?P.+)$", + re.IGNORECASE, +) + + +def parse_control( + text: str, + entities: list[Any] | None = None, # noqa: ARG001 -- kept for backwards-compatible signature +) -> ParsedControl | None: + """Classify a voice utterance as a regex-handled control command, or None. + + Recognises only ``transfer`` ("переведи на X") in v1.4.0+. Every + other control command is recognised by Yandex itself through + declarative custom intents (see :func:`provider.dialogs_grammar.parse_platform_intent`). + + Returns ``None`` when the phrase isn't a transfer — the caller + typically falls through to the play-command parser. + + The ``entities`` parameter is preserved for API compatibility with + callers that used to feed YANDEX.NUMBER through this function for + relative-volume parsing (now handled platform-side). + """ + if not text: + return None + cleaned = _PUNCT_RE.sub(" ", text) + cleaned = _SPACE_RE.sub(" ", cleaned).strip() + cleaned = re.sub(r"^алиса[,\s]+", "", cleaned, flags=re.IGNORECASE) + if not cleaned: + return None + if tmatch := _TRANSFER_RE.match(cleaned): + return ParsedControl( + action="transfer", + player_hint=tmatch.group("target").strip().lower(), + ) + return None + + +# --------------------------------------------------------------------------- +# Executor + confirmation +# --------------------------------------------------------------------------- + + +def _plural_ru(n: int, forms: tuple[str, str, str]) -> str: + """Pick the correct Russian quantitative form for `n`. + + :param n: The number. + :param forms: ``(form_for_1, form_for_2_to_4, form_for_5_plus)``. + + Russian quantitative agreement: + 1, 21, 31, … → form_for_1 (e.g. "колонку") + 2-4, 22-24, … → form_for_2_to_4 ("колонки") + 0, 5-20, 25-30, … → form_for_5_plus ("колонок") + """ + n_abs = abs(n) + if n_abs % 10 == 1 and n_abs % 100 != 11: + return forms[0] + if 2 <= n_abs % 10 <= 4 and not 12 <= n_abs % 100 <= 14: + return forms[1] + return forms[2] + + +def format_list_players(players: list[Any]) -> str: + """Build the spoken response listing exposed players for `list_players` action.""" + n = len(players) + if n == 0: + return "Не вижу ни одной колонки." + names = ", ".join(getattr(p, "name", None) or p.player_id for p in players) + if n == 1: + return f"Вижу одну колонку: {names}." + word = _plural_ru(n, ("колонку", "колонки", "колонок")) + return f"Вижу {n} {word}: {names}." + + +def control_confirmation(control: ParsedControl) -> str: # noqa: PLR0911 + """User-facing confirmation text for a control action. + + Caveat: ``list_players`` is **not** confirmed here — the handler builds + the response text from the live player list via ``format_list_players``. + """ + action = control.action + if action == "pause": + return "Пауза." + if action == "resume": + return "Продолжаю." + if action == "stop": + return "Остановил." + if action == "next": + return "Следующая." + if action == "previous": + return "Предыдущая." + if action == "volume_up": + return "Громче." + if action == "volume_down": + return "Тише." + if action == "volume_set": + return f"Громкость {control.value}." + if action == "volume_relative": + delta = control.value or 0 + if delta > 0: + return f"Громче на {delta}." + if delta < 0: + return f"Тише на {-delta}." + return "Готово." + if action == "mute": + return "Звук выключен." + if action == "unmute": + return "Звук включен." + if action == "forget_player": + return "Хорошо, забыл колонку. В следующий раз спрошу." + if action == "shuffle_on": + return "Включил перемешивание." + if action == "shuffle_off": + return "Выключил перемешивание." + if action == "repeat_off": + return "Выключил повтор." + if action == "repeat_one": + return "Повтор песни." + if action == "repeat_all": + return "Повтор очереди." + if action == "seek_forward": + return f"Перемотал на {control.value} секунд вперёд." + if action == "seek_back": + return f"Перемотал на {control.value} секунд назад." + if action == "seek_start": + return "Перемотал к началу." + # list_players / now_playing / transfer — handler computes the real + # text (live data) and never calls this. Placeholder for safety. + return "Готово." + + +async def execute_control( # noqa: PLR0915 + mass: MusicAssistant, + control: ParsedControl, + player: Any, +) -> None: + """Dispatch a ParsedControl to the matching MA command. + + Errors are logged and swallowed — Alice has already been told the + action was accepted; we don't have a channel to surface failures + back into the same conversation. + + Note: ``list_players`` is a member of ``ControlAction`` for typing + convenience, but it's an *informational* query handled inline by + ``DialogsWebhookHandler._handle_control`` (which never calls this + function for it). The explicit branch below makes that contract + safe — a stray call won't silently no-op, it logs and returns. + """ + pid = player.player_id + action = control.action + try: + if action == "pause": + await mass.player_queues.pause(pid) + elif action == "resume": + await mass.player_queues.resume(pid) + elif action == "stop": + await mass.player_queues.stop(pid) + elif action == "next": + await mass.player_queues.next(pid) + elif action == "previous": + await mass.player_queues.previous(pid) + elif action == "volume_up": + await mass.players.cmd_volume_up(pid) + elif action == "volume_down": + await mass.players.cmd_volume_down(pid) + elif action == "volume_set": + value = max(0, min(100, control.value or 0)) + await mass.players.cmd_volume_set(pid, value) + elif action == "volume_relative": + # Read current volume, apply signed delta, clamp [0, 100]. + # Falls back to 50 if the player exposes no volume_level + # (some virtual players do); the user feedback ("Громче на 20") + # then becomes a no-op rather than mis-targeting. + delta = control.value or 0 + current = getattr(player, "volume_level", None) + if not isinstance(current, int | float): + current = 50 + new_value = max(0, min(100, int(current) + int(delta))) + await mass.players.cmd_volume_set(pid, new_value) + elif action == "mute": + await mass.players.cmd_volume_mute(pid, True) + elif action == "unmute": + await mass.players.cmd_volume_mute(pid, False) + elif action == "list_players": + # Informational query — the handler builds the response + # text from a live `list_exposed_players(...)` call and + # never dispatches here. If we somehow got called for + # this action it's a caller bug, not something to silently + # ignore. + _LOGGER.warning( + "execute_control called with action='list_players'; " + "this is informational and should be handled by the " + "webhook handler, not dispatched here. Skipping.", + ) + elif action == "forget_player": + # State-management query — the handler clears the cached + # default-player from session/application/cache state and + # never dispatches here. Defensive branch. + _LOGGER.warning( + "execute_control called with action='forget_player'; " + "this is a state-management op handled by the webhook " + "handler, not dispatched here. Skipping.", + ) + elif action == "shuffle_on": + await mass.player_queues.set_shuffle(pid, shuffle_enabled=True) + elif action == "shuffle_off": + await mass.player_queues.set_shuffle(pid, shuffle_enabled=False) + elif action == "repeat_off": + # NB: set_repeat is sync, not async — do NOT await. + mass.player_queues.set_repeat(pid, RepeatMode.OFF) + elif action == "repeat_one": + mass.player_queues.set_repeat(pid, RepeatMode.ONE) + elif action == "repeat_all": + mass.player_queues.set_repeat(pid, RepeatMode.ALL) + elif action == "seek_forward": + await mass.player_queues.skip(pid, seconds=control.value or 0) + elif action == "seek_back": + await mass.player_queues.skip(pid, seconds=-(control.value or 0)) + elif action == "seek_start": + await mass.player_queues.seek(pid, position=0) + elif action in ("now_playing", "transfer"): + # Live-data / multi-player actions — the handler builds the + # response from queue.current_item / transfer_queue and + # never dispatches here. Defensive branch. + _LOGGER.warning( + "execute_control called with action=%r — handled by webhook " + "handler, not dispatched here. Skipping.", + action, + ) + except asyncio.CancelledError: + raise + except Exception: + _LOGGER.exception("execute_control(%s) failed for player %s", action, pid) diff --git a/music_assistant/providers/yandex_alice/dialogs_grammar.py b/music_assistant/providers/yandex_alice/dialogs_grammar.py new file mode 100644 index 0000000000..b9aca4d5fa --- /dev/null +++ b/music_assistant/providers/yandex_alice/dialogs_grammar.py @@ -0,0 +1,95 @@ +"""Trailing "на " suffix recovery for platform-classified intents. + +Yandex's static custom intents can't enumerate the per-user list of +player names, so the destination hint at the end of the spoken phrase +("пауза **на кухне**", "следующая **на спальне**") never makes it into +``request.nlu.intents..slots``. The webhook handler recovers it +from the raw command text after :class:`SkillManifestProvider` has +already produced a :class:`ParsedControl`. + +This module is the only piece of provider-level NLU postprocessing +left after the v1.6.0 manifest refactor — :func:`build_grammar` / +:func:`build_entities` / :func:`parse_platform_intent` / the hardcoded +``_CONTROL_INTENT_MAP`` are all gone, replaced by data-driven dispatch +in :class:`SkillManifestProvider`. +""" + +from __future__ import annotations + +import re + +# "на" boundary used to peel the trailing player-hint suffix off a +# platform-parsed command text. Yandex's static custom intents can't +# enumerate the per-user list of player names, so the suffix is +# recovered from the raw command text and attached to the ParsedControl +# alongside. +_NA_BOUNDARY_RE = re.compile(r"\s+на\s+", re.IGNORECASE) + +# Hint candidates that are clearly not a player name. Phrases containing +# multiple "на" tokens (e.g. "громкость на 50 на кухне", "перемотай на 30 +# секунд", "поставь на паузу на кухне") would otherwise misroute the +# slot-side "на N " or the action-side "на " as a hint. +# +# - ``_HINT_UNIT_WORDS`` covers unit nouns that follow numeric slots +# ("на 30 секунд", "на 50 процентов"). +# - ``_HINT_ACTION_WORDS`` covers action-content nouns from grammars +# that themselves use "на " (currently only "паузу" from the +# ``control.pause`` intent in skill.toml — "поставь на паузу" / "на +# паузу"). When a new intent grammar introduces another such token, +# add it here. +_HINT_UNIT_WORDS: frozenset[str] = frozenset( + { + "секунда", + "секунды", + "секунд", + "сек", + "минута", + "минуты", + "минут", + "мин", + "процент", + "процента", + "процентов", + } +) +_HINT_ACTION_WORDS: frozenset[str] = frozenset( + { + "паузу", + } +) + + +def extract_trailing_player_hint(text: str) -> str | None: + """Return the lower-cased trailing "на " suffix, or None. + + Examples: + ``"пауза на кухне"`` → ``"кухне"`` + ``"поставь на паузу на кухне"`` → ``"кухне"`` (only the rightmost + "на " is taken) + ``"перемотай вперёд на 30 секунд"`` → ``None`` (the suffix is a + slot value, not a player name) + ``"громкость на 50"`` → ``None`` + + The suffix is rejected when its first token starts with a digit or + is one of the unit words ("секунд", "минут", "процентов" and their + morphological variants) — those follow "на" as part of a numeric + slot, not as a destination player. + """ + if not text: + return None + parts = _NA_BOUNDARY_RE.split(text) + if len(parts) < 2: + return None + hint = parts[-1].strip().lower() + if not hint: + return None + first_token = hint.split(maxsplit=1)[0] + if first_token[0].isdigit(): + return None + if first_token in _HINT_UNIT_WORDS: + return None + # Single-token hint matching an action-content noun ("паузу") is + # part of the action phrase, not a destination player. + if " " not in hint and hint in _HINT_ACTION_WORDS: + return None + return hint diff --git a/music_assistant/providers/yandex_alice/dialogs_nlu.py b/music_assistant/providers/yandex_alice/dialogs_nlu.py new file mode 100644 index 0000000000..32fb44567d --- /dev/null +++ b/music_assistant/providers/yandex_alice/dialogs_nlu.py @@ -0,0 +1,477 @@ +# ruff: noqa: RUF001, RUF002, RUF003 +"""Server-side NLU parser for Yandex Dialogs custom-skill webhook commands. + +The plugin's Dialogs skill registers in the Yandex Dialogs UI without +declared intents/slots — Yandex passes the raw user phrase as +``request.command``. We classify it ourselves: kind (track/artist/album/ +playlist/my_wave/genre/search), search query, optional player hint. + +Pure-Python; no MA-API dependency for the parser itself, only for +``resolve_player`` which iterates ``mass.players.all_players()``. +""" + +from __future__ import annotations + +import logging +import re +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, Literal + +if TYPE_CHECKING: + from music_assistant.mass import MusicAssistant + + +_LOGGER = logging.getLogger(__name__) + +CommandKind = Literal["track", "artist", "album", "playlist", "my_wave", "genre", "search"] + + +EnqueueOption = Literal["replace", "next", "add"] + + +@dataclass(frozen=True, slots=True) +class ParsedCommand: + """Result of classifying a Yandex Dialogs voice command.""" + + kind: CommandKind + query: str + player_hint: str | None = None + radio_mode: bool = False + # When set, `play_for_alice` passes a matching `QueueOption` to + # `mass.player_queues.play_media` so the new media is added to / + # inserted into the existing queue instead of replacing it. Default + # `None` keeps the historical REPLACE behaviour (start playing + # immediately, replacing the current queue). + enqueue_option: EnqueueOption | None = None + + +# --------------------------------------------------------------------------- +# Command parser +# --------------------------------------------------------------------------- + +# Punctuation we strip up-front. Keep apostrophes (e.g. "rock'n'roll") and +# hyphens (e.g. "rock-n-roll") inside words. +_PUNCT_RE = re.compile(r"[!?.,;:«»\"„“]") +_SPACE_RE = re.compile(r"\s+") + +# Trailing "" hint suffix introduced by the Russian preposition "на". +# The hint can be multi-word (e.g. a phrase like "in the kitchen speaker"). +# Anchored to a word boundary so a name beginning with the same letters +# (e.g. "Natalie" in Russian) isn't mis-split mid-token. +_PLAYER_SUFFIX_RE = re.compile(r"\s+на\s+(?P.+?)\s*$", re.IGNORECASE) + +# Verb regex covering Russian imperative + infinitive forms used to +# start playback ("turn on", "play", "launch"). Yandex's voice-to-text +# sometimes returns the infinitive ("включить") even if the user spoke +# the imperative ("включи"); we accept both. Also covers the plural +# imperatives (-те), informal aspect variants (включай/сыграй), +# and the listening verbs (послушай/послушать). +_VERB_RE = re.compile( + r"^(?:алиса[, ]+)?(?:" + r"включи(?:те)?|включай(?:те)?|включить|" + r"поставь(?:те)?|поставить|" + r"запусти(?:те)?|запустить|" + r"сыграй(?:те)?|сыграть|" + r"играй(?:те)?|" + r"послушай(?:те)?|послушать|" + r"найди(?:те)?|найти|" + r"открой(?:те)?|открыть|" + r"покажи(?:те)?|показать" + r")(?:\s+|$)", + re.IGNORECASE, +) + +# Enqueue verbs — when one of these is the command's leading verb, set +# `enqueue_option="add"` (or "next") on the resulting ParsedCommand +# instead of the default REPLACE-the-queue behaviour. The verb is +# stripped from the rest of the command exactly like `_VERB_RE` does. +_ENQUEUE_VERB_RE = re.compile( + r"^(?:алиса[, ]+)?(?:добавь(?:те)?|добавить)(?:\s+|$)", + re.IGNORECASE, +) + +# Type prefixes inside the intent part. Order matters: longer keywords first. +_KIND_RULES: tuple[tuple[re.Pattern[str], CommandKind, bool], ...] = ( + # my_wave / personal radio wave — no query, the verb is everything + (re.compile(r"^(?:мою|свою|нашу)\s+волну\b", re.IGNORECASE), "my_wave", True), + (re.compile(r"^мо[её]\s+радио\b", re.IGNORECASE), "my_wave", True), + # playlist + (re.compile(r"^(?:плейлист|подборку|подборка)\s+(.+)$", re.IGNORECASE), "playlist", False), + # album + (re.compile(r"^(?:альбом|пластинку|пластинка)\s+(.+)$", re.IGNORECASE), "album", False), + # artist (radio mode) + ( + re.compile(r"^(?:исполнителя|артиста|группу|группа)\s+(.+)$", re.IGNORECASE), + "artist", + True, + ), + # explicit track marker + ( + re.compile(r"^(?:песню|трек|композицию|композиция)\s+(.+)$", re.IGNORECASE), + "track", + False, + ), + # explicit radio + (re.compile(r"^радио\s+(.+)$", re.IGNORECASE), "genre", True), + # genre marker + (re.compile(r"^жанр\s+(.+)$", re.IGNORECASE), "genre", True), +) + + +# Marker words that mean "the next token(s) are content, not the title". +# Used by `parse_command` to detect a wrong "на " split: if the +# split-off remainder is just a marker word (e.g. "включи песню" with +# the title "На заре" mis-split off as a player hint), the suffix +# extraction was almost certainly wrong and we re-parse without it. +_KIND_MARKER_WORDS: frozenset[str] = frozenset( + { + "песню", + "трек", + "композицию", + "композиция", + "альбом", + "пластинку", + "пластинка", + "плейлист", + "подборку", + "подборка", + "исполнителя", + "артиста", + "группу", + "группа", + "радио", + "жанр", + } +) + + +def parse_command(text: str, *, _split_player_hint: bool = True) -> ParsedCommand: + """ + Parse a raw voice command into a structured ParsedCommand. + + Recognised patterns (a few representative cases):: + + "включи Metallica на кухне" → kind=search, query=metallica, hint=кухне + "включи песню Yesterday" → kind=track, query=yesterday + "включи альбом Black Album на спальне" → kind=album, query=black album, hint=спальне + "включи исполнителя Metallica" → kind=artist, query=metallica, radio_mode=True + "включи мою волну" → kind=my_wave, query="", radio_mode=True + "включи джаз" → kind=search, query=джаз + "включи жанр джаз" → kind=genre, query=джаз, radio_mode=True + "включи песню На заре" → kind=track, query=на заре (no false split) + + :param text: Raw voice command string from the Dialogs request. + :param _split_player_hint: Internal recursion guard. When the first + pass produces a suspicious split (the whole content was eaten as + "на ", leaving only a marker word in the query), + the function recurses with the flag off to keep the suffix in + the query. Do not pass it from outside. + """ + if not text: + return ParsedCommand(kind="search", query="") + + cleaned = _PUNCT_RE.sub(" ", text) + cleaned = _SPACE_RE.sub(" ", cleaned).strip() + + # Strip the "Alice, ..." vocative prefix if present + # (Yandex usually strips it on its side, but defensively). + cleaned = re.sub(r"^алиса[,\s]+", "", cleaned, flags=re.IGNORECASE) + + # Split off the trailing "" hint suffix. + player_hint: str | None = None + if _split_player_hint and (match := _PLAYER_SUFFIX_RE.search(cleaned)): + player_hint = match.group("hint").strip().lower() + cleaned = cleaned[: match.start()].strip() + + # Detect enqueue-verb prefix ("добавь X") BEFORE the regular verb + # strip so we know to set `enqueue_option="add"`. The verb itself + # is stripped here so the regular `_VERB_RE.sub` below has nothing + # to do — the residual is the kind+query intent part. + enqueue_option: EnqueueOption | None = None + if enq_match := _ENQUEUE_VERB_RE.match(cleaned): + enqueue_option = "add" + cleaned = cleaned[enq_match.end() :].strip() + + # Strip the imperative verb at the start (e.g. "play this", "turn on that"). + # No-op if `_ENQUEUE_VERB_RE` already consumed the verb. + intent_part = _VERB_RE.sub("", cleaned).strip() + + if not intent_part: + return ParsedCommand( + kind="search", + query="", + player_hint=player_hint, + enqueue_option=enqueue_option, + ) + + # Try kind rules in order. + for pattern, kind, radio in _KIND_RULES: + if rule_match := pattern.match(intent_part): + query = rule_match.group(1).strip() if rule_match.groups() else "" + # For add-to-queue the "radio mode" intent is incoherent + # (you don't add a station, you add a track). Force off. + effective_radio = False if enqueue_option == "add" else radio + return ParsedCommand( + kind=kind, + query=query.lower(), + player_hint=player_hint, + radio_mode=effective_radio, + enqueue_option=enqueue_option, + ) + + # Suspicious-split detector: when a player_hint was extracted AND + # the residual intent_part collapsed to a kind-marker word (e.g. + # "песню", "альбом", "плейлист"), the user probably said something + # like "включи песню На заре" and we mis-split "На заре" as a + # player hint. Re-parse without the suffix split so the title is + # preserved in the query. + if _split_player_hint and player_hint is not None and intent_part.lower() in _KIND_MARKER_WORDS: + return parse_command(text, _split_player_hint=False) + + # Fallback: unstructured search — let mass.music.search figure out + # the type. Force radio_mode=True so when the result is an artist or + # a single track, MA starts a radio based on it instead of playing + # one item and stopping (matches the typical user expectation + # "включи " → "play music"). For add-to-queue, radio_mode + # is incoherent — force off. + return ParsedCommand( + kind="search", + query=intent_part.lower(), + player_hint=player_hint, + radio_mode=enqueue_option != "add", + enqueue_option=enqueue_option, + ) + + +# --------------------------------------------------------------------------- +# Player resolver +# --------------------------------------------------------------------------- + +# Common Russian inflection suffixes we strip for fuzzy player-name matching. +# Not a full lemmatizer — picks up the most frequent endings for short names. +# Order: longest first so multi-letter suffixes match before single-letter ones. +_INFLECTION_SUFFIXES = ( + "ого", + "ому", + "ыми", + "ую", # feminine adjective accusative — "большую", "маленькую" + "ая", # feminine adjective nominative — "большая", "маленькая" + "ой", + "ом", + "ым", + "ы", + "е", + "у", + "а", + "и", + "й", + "ь", + "я", # feminine noun nominative — "Кухня", "Спальня" + "ю", # feminine noun accusative — "Кухню", "Спальню" +) + + +# Generic Russian words for "speaker / player" — fall through to the +# default/only-exposed player if the user said one of these instead of +# a specific player name. Stored as already-normalised stems so we can +# compare against the same normalisation we run on `hint`. +_GENERIC_PLAYER_STEMS = frozenset( + { + "колонк", # колонка / на колонке / колонку + "плеер", # плеер / на плеере / плеера + "пле", # short for "плеер" after stripping the trailing -ер suffix + "проигрыватель", # full word survives stem (no matching suffix) + "проигрывател", # stripped «-ь» + "динамик", # динамик / на динамике + "акустик", # акустика / на акустике + "устройств", # устройство / на устройстве + } +) + + +def _normalize_player_token(name: str) -> str: + """Lowercase + strip common Russian inflection suffix. + + Applied to both haystack (player.name) and needle (hint) so they + match each other after the same shaping. + """ + norm = name.lower().strip() + norm = _PUNCT_RE.sub(" ", norm) + norm = _SPACE_RE.sub(" ", norm).strip() + # Strip a trailing inflection suffix from each whitespace-separated token. + parts: list[str] = [] + for token in norm.split(): + stemmed = token + for suffix in _INFLECTION_SUFFIXES: + if len(stemmed) > len(suffix) + 2 and stemmed.endswith(suffix): + stemmed = stemmed[: -len(suffix)] + break + parts.append(stemmed) + return " ".join(parts) + + +def list_exposed_players( + mass: MusicAssistant, + *, + exposed_ids: set[str] | None = None, +) -> list[Any]: + """Return all available, enabled, non-synced players (filtered by exposure). + + Same filter as ``resolve_player_candidates`` uses for its candidate set, + extracted so the dialog handler can answer "what speakers do you see?" + queries (P0.6 ``list_players`` action) without re-implementing it. + """ + out: list[Any] = [] + for player in mass.players.all_players(): + if not player.available or not player.enabled: + continue + if getattr(player, "synced_to", None): + continue + if exposed_ids and player.player_id not in exposed_ids: + continue + out.append(player) + return out + + +def resolve_player_candidates( + mass: MusicAssistant, + hint: str | None, + *, + default_id: str | None = None, + exposed_ids: set[str] | None = None, +) -> list[Any]: + """Return the best-matching tier of players for ``hint``. + + Filters: only players that are available, enabled, and not synced to + a leader. Optional ``exposed_ids`` further restricts to the user's + exposed-players list. Tier priority: exact → startswith → contains → + generic-word fallback. The caller decides what to do with multiple + matches (typically: ask the user to disambiguate). + + Logs a single DEBUG-level summary on every call describing the + decision: chosen tier, candidate count, and the names of the + candidates returned. + + :returns: A list with all players in the best non-empty tier. + ``[]`` if nothing matched. ``[player]`` for an unambiguous + resolution. + """ + candidates = list_exposed_players(mass, exposed_ids=exposed_ids) + + def _label(p: Any) -> str: + return str(getattr(p, "name", None) or p.player_id) + + def _result(result: list[Any], reason: str) -> list[Any]: + _LOGGER.debug( + "resolve_player: hint=%r default=%s exposed=%d -> %d candidate(s) %s [%s]", + hint, + default_id, + len(candidates), + len(result), + [_label(p) for p in result], + reason, + ) + return result + + if not candidates: + return _result([], "no exposed players") + + # Single-player install or no hint → default / only candidate. + if not hint: + if default_id: + for p in candidates: + if p.player_id == default_id: + return _result([p], "no hint, matched default_id") + if len(candidates) == 1: + return _result(candidates[:], "no hint, single exposed player") + return _result([], "no hint, ambiguous") + + needle = _normalize_player_token(hint) + if not needle: + return _result([], "hint normalised to empty string") + + exact: list[Any] = [] + startswith: list[Any] = [] + contains: list[Any] = [] + haystacks: list[tuple[str, str]] = [] # (raw, normalised) for debug + for p in candidates: + haystack = _normalize_player_token(p.name or p.player_id) + haystacks.append((p.name or p.player_id, haystack)) + if not haystack: + continue + if haystack == needle: + exact.append(p) + elif haystack.startswith(needle) or needle.startswith(haystack): + startswith.append(p) + elif needle in haystack or haystack in needle: + contains.append(p) + + _LOGGER.debug( + "resolve_player tiers: hint=%r needle=%r candidates=%s " + "matches: exact=%d startswith=%d contains=%d", + hint, + needle, + haystacks, + len(exact), + len(startswith), + len(contains), + ) + + for tier_name, tier in ( + ("exact", exact), + ("startswith", startswith), + ("contains", contains), + ): + if tier: + tier.sort(key=lambda p: (p.name or p.player_id).lower()) + return _result(tier, f"tier={tier_name}") + + # Generic-word fallback: "на колонке" / "на проигрывателе" / "на динамике" + # mean "any speaker" — resolve unambiguously only when the choice is + # forced (default_id set, or single exposed player). + if any(stem in needle for stem in _GENERIC_PLAYER_STEMS): + if default_id: + for p in candidates: + if p.player_id == default_id: + _LOGGER.info( + "Generic player hint %r → resolved to default player %r", + hint, + p.name, + ) + return _result([p], "generic word, matched default_id") + if len(candidates) == 1: + _LOGGER.info( + "Generic player hint %r → resolved to the only exposed player %r", + hint, + candidates[0].name, + ) + return _result(candidates[:], "generic word, single exposed player") + _LOGGER.warning( + "Generic player hint %r matches no specific player and there are " + "%d exposed players — caller will ask for clarification", + hint, + len(candidates), + ) + return _result([], "generic word, multiple players, no default") + + return _result([], "no tier matched") + + +def resolve_player( + mass: MusicAssistant, + hint: str | None, + *, + default_id: str | None = None, + exposed_ids: set[str] | None = None, +) -> Any: + """Find an unambiguously-matching MA player for ``hint``, or None. + + Thin wrapper over ``resolve_player_candidates`` — returns the single + candidate when exactly one matches, ``None`` otherwise (zero matches + or ambiguous). Use ``resolve_player_candidates`` directly when you + want to surface the ambiguity to the user. + """ + candidates = resolve_player_candidates( + mass, hint, default_id=default_id, exposed_ids=exposed_ids + ) + return candidates[0] if len(candidates) == 1 else None diff --git a/music_assistant/providers/yandex_alice/dialogs_player.py b/music_assistant/providers/yandex_alice/dialogs_player.py new file mode 100644 index 0000000000..9598f7cbc1 --- /dev/null +++ b/music_assistant/providers/yandex_alice/dialogs_player.py @@ -0,0 +1,313 @@ +# ruff: noqa: RUF001 +"""Music + player resolvers for the Yandex Dialogs custom-skill webhook. + +`resolve_query` turns a `ParsedCommand` into a concrete URI/MediaItem ready +to feed to `mass.player_queues.play_media`. `play_for_alice` wraps the +power-on + queue-play sequence. + +Bound to MA APIs: `mass.music.search`, `mass.music.get_item_by_uri`, +`mass.player_queues.play_media`, `mass.players.cmd_power`. +""" + +from __future__ import annotations + +import asyncio +import logging +from typing import TYPE_CHECKING, Any + +from music_assistant_models.enums import MediaType, QueueOption + +from .dialogs_nlu import _normalize_player_token + +if TYPE_CHECKING: + from music_assistant_models.media_items import MediaItemType + + from music_assistant.mass import MusicAssistant + + from .dialogs_nlu import ParsedCommand + + +_LOGGER = logging.getLogger(__name__) + +_SEARCH_LIMIT_DEFAULT = 5 + + +def _has_cyrillic(text: str) -> bool: + """Return True if `text` contains at least one Cyrillic letter.""" + return any("а" <= c.lower() <= "я" or c.lower() == "ё" for c in text) + + +def _has_feature(player: Any, feature_name: str) -> bool: + """Mirror provider.device._has_feature so we don't import device.py from here.""" + features = getattr(player, "supported_features", None) + if not features: + return False + return any( + str(f) == feature_name or getattr(f, "value", None) == feature_name for f in features + ) + + +def _first(items: Any) -> Any: + """Return the first item of a Sequence, or None if empty/not-a-sequence.""" + try: + return next(iter(items)) + except (StopIteration, TypeError): + return None + + +# --------------------------------------------------------------------------- +# Content resolver +# --------------------------------------------------------------------------- + + +async def resolve_query(mass: MusicAssistant, parsed: ParsedCommand) -> MediaItemType | str | None: + """Pick the best media item for the parsed voice command. + + Returns either a MediaItem or a URI string (both accepted by + play_media); None means we couldn't resolve and the webhook handler + should respond with a "not found" message to the user. + """ + if parsed.kind == "my_wave": + return await _resolve_my_wave(mass) + if parsed.kind == "genre": + return await _resolve_genre(mass, parsed.query) + + if not parsed.query: + return None + + # Map kind → search MediaTypes, prefer-library bias on first try. + media_types_by_kind: dict[str, list[MediaType]] = { + "track": [MediaType.TRACK], + "artist": [MediaType.ARTIST], + "album": [MediaType.ALBUM], + "playlist": [MediaType.PLAYLIST], + "search": [MediaType.PLAYLIST, MediaType.ALBUM, MediaType.ARTIST, MediaType.TRACK], + } + media_types = media_types_by_kind.get(parsed.kind, [MediaType.TRACK]) + + try: + results = await mass.music.search( + search_query=parsed.query, + media_types=media_types, + limit=_SEARCH_LIMIT_DEFAULT, + ) + except asyncio.CancelledError: + raise + except Exception as exc: + _LOGGER.warning("mass.music.search failed for %r: %s", parsed.query, exc) + return None + + picked = _pick_from_results(results, parsed.kind) + if picked is not None: + return picked + + # P0.7 — retry search with the inflection-stripped query if the original + # was Russian. Yandex ASR usually returns words in the case the user + # spoke ("включи металлику" → accusative); music indexes store the + # nominative ("Металлика"). Stripping the trailing suffix ("металлик") + # is enough of a stem to land prefix matches in most providers. + if not _has_cyrillic(parsed.query): + return None + stemmed = _normalize_player_token(parsed.query) + if not stemmed or stemmed == parsed.query.lower(): + return None + try: + results2 = await mass.music.search( + search_query=stemmed, + media_types=media_types, + limit=_SEARCH_LIMIT_DEFAULT, + ) + except asyncio.CancelledError: + raise + except Exception as exc: + _LOGGER.warning("stemmed-retry search failed for %r: %s", stemmed, exc) + return None + return _pick_from_results(results2, parsed.kind) + + +def _pick_from_results(results: object, kind: str) -> MediaItemType | None: + """Pick best MediaItem from SearchResults given the parsed kind.""" + # SearchResults has .artists, .albums, .tracks, .playlists, plus .library_*. + # For "search" (no explicit marker), users almost always say a band / + # song / album name without qualifier ("включи Iron Maiden", + # "включи Yesterday"). Best UX is to resolve to the ARTIST first + # (radio_mode=True will be set on top → starts artist radio), then + # ALBUM, then TRACK; PLAYLIST is least likely to be what the user + # wants when they didn't say "плейлист" or "подборку". + order: list[str] + if kind == "search": + order = ["artists", "albums", "tracks", "playlists"] + elif kind == "track": + order = ["tracks"] + elif kind == "artist": + order = ["artists"] + elif kind == "album": + order = ["albums"] + elif kind == "playlist": + order = ["playlists"] + else: + order = ["tracks"] + + for attr in order: + bucket = getattr(results, attr, None) + if not bucket: + continue + item = _first(bucket) + if item is not None: + return item # type: ignore[no-any-return] + return None + + +# --------------------------------------------------------------------------- +# Yandex-specific specials +# --------------------------------------------------------------------------- + + +def _find_yandex_music_provider(mass: MusicAssistant) -> Any: + """Locate the first available yandex_music music provider instance.""" + for attr in ("music_providers", "providers"): + try: + for prov in getattr(mass, attr, ()): + if getattr(prov, "domain", None) == "yandex_music" and getattr( + prov, "available", True + ): + return prov + except Exception: # noqa: S110 + pass + return None + + +async def _resolve_my_wave(mass: MusicAssistant) -> str | None: + """Resolve "My Wave" radio — yandex_music rotor station user:onyourwave. + + Returns a track URI from the rotor batch; play_media in radio mode + will keep pulling next tracks via the standard queue radio loop. If + yandex_music isn't installed/available, returns None. + """ + provider = _find_yandex_music_provider(mass) + if provider is None: + _LOGGER.info("My Wave requested but yandex_music provider is not available") + return None + try: + client = getattr(provider, "client", None) + if client is None: + return None + tracks, _batch_id = await client.get_rotor_station_tracks("user:onyourwave") + except asyncio.CancelledError: + raise + except Exception as exc: + _LOGGER.warning("My Wave rotor fetch failed: %s", exc) + return None + if not tracks: + return None + first_track = tracks[0] + track_id = getattr(first_track, "id", None) or getattr(first_track, "track_id", None) + if not track_id: + return None + instance_id = getattr(provider, "instance_id", "yandex_music") + return f"{instance_id}://track/{track_id}" + + +async def _resolve_genre(mass: MusicAssistant, query: str) -> MediaItemType | str | None: + """Resolve genre-based radio. + + Best-effort: try yandex_music genre rotor; + fall back to plain artist search with radio_mode upstream. + """ + if not query: + return None + provider = _find_yandex_music_provider(mass) + if provider is not None: + try: + client = getattr(provider, "client", None) + if client is not None: + station_id = f"genre:{query}" + tracks, _ = await client.get_rotor_station_tracks(station_id) + if tracks: + first_track = tracks[0] + track_id = getattr(first_track, "id", None) or getattr( + first_track, "track_id", None + ) + if track_id: + instance_id = getattr(provider, "instance_id", "yandex_music") + return f"{instance_id}://track/{track_id}" + except asyncio.CancelledError: + raise + except Exception as exc: + _LOGGER.debug("Genre rotor fallback for %r: %s", query, exc) + + # Generic fallback: search across artists+tracks; caller will use radio_mode. + try: + results = await mass.music.search( + search_query=query, + media_types=[MediaType.ARTIST, MediaType.TRACK], + limit=_SEARCH_LIMIT_DEFAULT, + ) + except asyncio.CancelledError: + raise + except Exception as exc: + _LOGGER.warning("Genre fallback search failed for %r: %s", query, exc) + return None + return _first(getattr(results, "artists", None) or []) or _first( # type: ignore[no-any-return] + getattr(results, "tracks", None) or [] + ) + + +# --------------------------------------------------------------------------- +# Playback +# --------------------------------------------------------------------------- + + +_ENQUEUE_TO_QUEUE_OPTION: dict[str, QueueOption] = { + "add": QueueOption.ADD, + "next": QueueOption.NEXT, + "replace": QueueOption.REPLACE, +} + + +async def play_for_alice( + mass: MusicAssistant, + player_id: str, + media: MediaItemType | str, + *, + radio_mode: bool = False, + enqueue_option: str | None = None, +) -> None: + """Power the player on if needed, then start playback via player_queues. + + ``enqueue_option`` (None / "replace" / "next" / "add") is mapped to + the matching :class:`QueueOption` and forwarded to + ``mass.player_queues.play_media``. ``None`` lets MA pick the + per-media-type default (typically REPLACE) — the historical + behaviour. + + Power-on policy: regardless of ``enqueue_option``, an off player + gets ``cmd_power(True)``. Voice intent is unambiguous — the user + just asked for music, so a player that's been off needs to wake up. + MA's ``play_media`` will then sequence ADD/NEXT correctly (queue + grows; playback may or may not start depending on current queue + state). If the user wants to enqueue without disturbing playback + on a different player, they should name that other player + explicitly via the ``на `` suffix. + """ + player = mass.players.get_player(player_id) + if player is not None and _has_feature(player, "power"): + powered = getattr(player, "powered", None) + if powered is False: + try: + await mass.players.cmd_power(player_id, True) + except asyncio.CancelledError: + raise + except Exception as exc: + _LOGGER.warning("cmd_power(True) on %s failed: %s", player_id, exc) + + play_kwargs: dict[str, Any] = { + "queue_id": player_id, + "media": media, + "radio_mode": radio_mode, + } + if enqueue_option is not None: + mapped = _ENQUEUE_TO_QUEUE_OPTION.get(enqueue_option) + if mapped is not None: + play_kwargs["option"] = mapped + await mass.player_queues.play_media(**play_kwargs) diff --git a/music_assistant/providers/yandex_alice/icon.svg b/music_assistant/providers/yandex_alice/icon.svg new file mode 100644 index 0000000000..4d1f652eb7 --- /dev/null +++ b/music_assistant/providers/yandex_alice/icon.svg @@ -0,0 +1 @@ + diff --git a/music_assistant/providers/yandex_alice/manifest.json b/music_assistant/providers/yandex_alice/manifest.json new file mode 100644 index 0000000000..9d36809799 --- /dev/null +++ b/music_assistant/providers/yandex_alice/manifest.json @@ -0,0 +1,18 @@ +{ + "type": "plugin", + "domain": "yandex_alice", + "name": "Yandex Alice", + "description": "Voice control of Music Assistant via a Yandex Dialogs custom skill (Russian NLU, full command surface).", + "codeowners": ["@trudenboy"], + "credits": [ + "[AlexxIT/YandexDialogs](https://github.com/AlexxIT/YandexDialogs)" + ], + "requirements": [ + "ya-passport-auth==1.3.0", + "ya-dialogs-api==2.4.0" + ], + "documentation": "https://github.com/trudenboy/ma-provider-yandex-alice", + "stage": "beta", + "multi_instance": false, + "builtin": false +} diff --git a/music_assistant/providers/yandex_alice/plugin.py b/music_assistant/providers/yandex_alice/plugin.py new file mode 100644 index 0000000000..0189419043 --- /dev/null +++ b/music_assistant/providers/yandex_alice/plugin.py @@ -0,0 +1,98 @@ +"""Yandex Alice (Dialogs custom skill) plugin provider for Music Assistant. + +Bridges a Yandex Dialogs custom-skill webhook to MA player commands. The +provider exposes a single HTTP route (``/api/yandex_dialogs/webhook/``) +that Yandex calls on every user utterance; the handler performs Russian NLU, +resolves the addressed player, and dispatches the parsed intent to +``mass.player_queues`` / ``mass.players``. + +Unlike the Smart Home device-bridge in ma-provider-yandex-smarthome, this +provider does not register MA players as Yandex IoT devices — it operates +entirely through the Dialogs custom-skill protocol, which gives it access +to richer commands (search-and-play, transfer queue, shuffle/repeat, +seek, now-playing) at the cost of requiring the user to invoke the skill +explicitly (*«Алиса, попроси Music Assistant …»*). +""" + +from __future__ import annotations + +from typing import Any + +from music_assistant.models.plugin import PluginProvider + +from .constants import ( + CONF_DIALOG_SKILL_ID, + CONF_DIALOG_VOICE_CONTINUATION, + CONF_DIALOG_WEBHOOK_SECRET, + CONF_EXPOSED_PLAYERS, + CONF_INSTANCE_NAME, +) +from .dialogs import DialogsWebhookHandler + + +class YandexAlicePlugin(PluginProvider): + """Plugin provider that wires a Yandex Dialogs custom-skill webhook.""" + + _dialogs_handler: DialogsWebhookHandler | None = None + + async def handle_async_init(self) -> None: + """Read config values and stash them on the instance.""" + self._instance_name = str(self.config.get_value(CONF_INSTANCE_NAME) or "Music Assistant") + self._dialog_skill_id = str(self.config.get_value(CONF_DIALOG_SKILL_ID) or "") + self._dialog_webhook_secret = str(self.config.get_value(CONF_DIALOG_WEBHOOK_SECRET) or "") + exposed_raw = self.config.get_value(CONF_EXPOSED_PLAYERS) + if isinstance(exposed_raw, list) and exposed_raw: + self._exposed_player_ids: set[str] | None = {str(item) for item in exposed_raw} + else: + self._exposed_player_ids = None + # Voice continuation (P1.4) — power-user toggle, no UI surface yet. + # Read directly from the config bag; absent → False (today's behaviour). + self._voice_continuation = bool( + self.config.get_value(CONF_DIALOG_VOICE_CONTINUATION) or False + ) + + async def loaded_in_mass(self) -> None: + """Register the Dialogs webhook route once the webserver is up. + + The provider is implicitly enabled the moment ``skill_id`` and + ``webhook_secret`` are populated — voice control is the *whole* + point of this provider, so a separate "enable" toggle would be + a redundant gate (#removed in v1.2.0). + """ + if not (self._dialog_skill_id and self._dialog_webhook_secret): + return + self._dialogs_handler = DialogsWebhookHandler( + self.mass, + skill_id=self._dialog_skill_id, + webhook_secret=self._dialog_webhook_secret, + exposed_player_ids=self._exposed_player_ids, + voice_continuation=self._voice_continuation, + ) + self._dialogs_handler.register_routes() + + async def unload(self, is_removed: bool = False) -> None: + """Tear down the webhook route (idempotent).""" + _ = is_removed + if self._dialogs_handler is not None: + self._dialogs_handler.unregister_routes() + self._dialogs_handler = None + + # MA may call into the provider for diagnostics; keep a noop attribute hook. + def get_diagnostics(self) -> dict[str, Any]: + """Expose a tiny status snapshot for MA diagnostics.""" + handler = self._dialogs_handler + webhook_calls_total = handler.webhook_call_count if handler else 0 + authenticated_calls_total = handler.authenticated_call_count if handler else 0 + last_webhook_ts = handler.last_webhook_ts if handler else None + return { + "instance_name": self._instance_name, + "dialog_skill_id_present": bool(self._dialog_skill_id), + "dialog_webhook_secret_present": bool(self._dialog_webhook_secret), + "exposed_player_count": ( + len(self._exposed_player_ids) if self._exposed_player_ids else 0 + ), + "handler_active": handler is not None, + "webhook_calls_total": webhook_calls_total, + "authenticated_calls_total": authenticated_calls_total, + "last_webhook_ts": last_webhook_ts, + } diff --git a/music_assistant/providers/yandex_alice/publication_status.py b/music_assistant/providers/yandex_alice/publication_status.py new file mode 100644 index 0000000000..1a4a0077c5 --- /dev/null +++ b/music_assistant/providers/yandex_alice/publication_status.py @@ -0,0 +1,129 @@ +"""Yandex Dialogs skill publication-status helper. + +A single-shot lookup against ``/developer/app-store-api/snapshot`` that +classifies the user's skill into one of five stable status strings used +by the Step 3 banner: + +* ``on_air`` — skill is live (Alice can answer voice commands). +* ``in_moderation`` — Yandex moderation queue, 5-15 min typical. +* ``draft`` — never deployed; waiting for the user to request publishing. +* ``rejected`` — Yandex moderation rejected the latest deploy. +* ``unknown`` — snapshot returned the skill but its state did not match + any of the above (used as a graceful fallback rather than crashing). + +The fetch is intentionally **out of band** of the auto-create / update +pipelines: callers invoke it once after a DONE outcome (or via the +manual *Refresh status* button) and persist the result so subsequent +form renders read it from values without making any HTTP call. +""" + +from __future__ import annotations + +import logging +from typing import Any + +from ya_dialogs_api import DialogsSkillCreator + +from .auth_session import cached_authenticated_session +from .constants import DIALOG_CHANNEL + +_LOGGER = logging.getLogger(__name__) + +__all__ = [ + "STATUS_DRAFT", + "STATUS_IN_MODERATION", + "STATUS_ON_AIR", + "STATUS_REJECTED", + "STATUS_UNKNOWN", + "classify_skill_entry", + "fetch_skill_publication_status", +] + +STATUS_ON_AIR = "on_air" +STATUS_IN_MODERATION = "in_moderation" +STATUS_DRAFT = "draft" +STATUS_REJECTED = "rejected" +STATUS_UNKNOWN = "unknown" + + +def classify_skill_entry(entry: dict[str, Any]) -> str: + """Classify a single ``snapshot.result.skills[]`` dict. + + Priority order (highest first): + + 1. ``draft.status == "rejected"`` → ``rejected``. Wins over on-air + because the user's most recent deploy was rejected and the + previously on-air version may already have been pulled. + 2. ``draft.status == "deployRequested"`` → ``in_moderation``. Wins + over on-air because the user just clicked Update / Recreate and + wants to see the moderation banner; the previous on-air version + remains live for end users in the meantime. + 3. ``onAir`` truthy or ``firstPublishedAt`` non-empty → ``on_air``. + 4. ``draft.status == "inDevelopment"`` → ``draft``. + 5. otherwise → ``unknown``. + """ + draft_obj = entry.get("draft") + draft_status = "" + if isinstance(draft_obj, dict): + draft_status = str(draft_obj.get("status") or "").strip() + + if draft_status == "rejected": + return STATUS_REJECTED + if draft_status == "deployRequested": + return STATUS_IN_MODERATION + + on_air = bool(entry.get("onAir") or entry.get("on_air")) + first_published = entry.get("firstPublishedAt") or entry.get("first_published_at") + if on_air or first_published: + return STATUS_ON_AIR + + if draft_status == "inDevelopment": + return STATUS_DRAFT + return STATUS_UNKNOWN + + +async def fetch_skill_publication_status( + cached_x_token: str, + skill_id: str, +) -> str | None: + """Fetch the live publication status for ``skill_id``. + + Returns one of the ``STATUS_*`` constants, or ``None`` if: + + * ``cached_x_token`` / ``skill_id`` is empty (caller hasn't signed + in yet, or the skill hasn't been created), + * the snapshot HTTP call fails for any reason (network, auth + expiry, Yandex 5xx), + * the skill_id is not present in the user's account (deleted + out-of-band on Yandex side). + + Never raises — failures are logged at DEBUG and surface as ``None`` + so the caller can leave whatever cached value is already on file + untouched. + """ + if not cached_x_token or not skill_id: + return None + + try: + async with cached_authenticated_session(cached_x_token) as session: + creator = DialogsSkillCreator(session, channel=DIALOG_CHANNEL) + csrf = await creator.fetch_csrf() + skills = await creator.list_existing_skills(csrf) + except Exception as exc: + _LOGGER.debug( + "publication-status: snapshot fetch failed (skill_id=%s): %r", + skill_id, + exc, + ) + return None + + for entry in skills: + sid = str(entry.get("id") or entry.get("skill_id") or "").strip() + if sid == skill_id: + return classify_skill_entry(entry) + + _LOGGER.debug( + "publication-status: skill_id=%s not found in snapshot (deleted?)", + skill_id, + ) + return None diff --git a/music_assistant/providers/yandex_alice/setup_view.py b/music_assistant/providers/yandex_alice/setup_view.py new file mode 100644 index 0000000000..10f5f02c52 --- /dev/null +++ b/music_assistant/providers/yandex_alice/setup_view.py @@ -0,0 +1,1275 @@ +r"""Provider settings form — clean rewrite with three top-level categories. + +The form is grouped by *what the entry semantically belongs to*, not +by linear setup steps: + +* **Authorization** — Yandex Passport sign-in / sign-out. Always + visible. Primary CTA when no token is cached, signed-in banner + + secondary "Sign out" otherwise. +* **Skill** — everything that touches the Yandex Dialogs skill + itself: skill name, the public webhook URL, Create / Edit / + Update / Delete buttons, the duplicate-name resolution prompt, + the dev-console link, and (under *Show advanced*) the manual + fields (skill_id, OAuth token, webhook secret) plus diagnostics. + Visible whenever auth is done OR a manual ``skill_id`` is set. +* **Settings** — provider-wide tuning that does not relate to the + skill object: voice-controllable players + the (advanced) + "different MA-side instance name" toggle. + +Each block is a small builder. The top-level +:func:`build_form_entries` composes the three of them, stamps every +entry with its category, and returns the flat tuple MA's frontend +expects. + +Frontend sections render directly off ``ConfigEntry.category``: any +slug that is not ``generic`` / ``advanced`` / ``protocol_general`` +becomes its own visual section, with the header text taken from +``settings.category.{slug}`` (with the slug itself as fallback). We +use TitleCase slugs so the fallback already reads as a proper +section heading without shipping a translation file. +""" + +# ruff: noqa: RUF001, RUF002 + +from __future__ import annotations + +import contextlib +import dataclasses +from typing import TYPE_CHECKING + +from music_assistant_models.config_entries import ConfigEntry, ConfigValueOption +from music_assistant_models.enums import ConfigEntryType +from ya_dialogs_api import SkillCreationArtifacts, SkillCreationState + +from .auto_create import AutoCreateOutcome, LocalAutoCreateStage +from .constants import ( + CATEGORY_AUTHORIZATION, + CATEGORY_SETTINGS, + CATEGORY_SKILL, + CONF_ACTION_ADOPT_EXISTING, + CONF_ACTION_AUTO_CREATE_DIALOG, + CONF_ACTION_CANCEL_DIALOG_SKILL_FLOW, + CONF_ACTION_CANCEL_EDIT, + CONF_ACTION_CLEAR_AUTH, + CONF_ACTION_DELETE_SKILL, + CONF_ACTION_EDIT_SKILL, + CONF_ACTION_EXPORT_MANIFEST, + CONF_ACTION_IMPORT_MANIFEST, + CONF_ACTION_RECREATE_DUPLICATE, + CONF_ACTION_REFRESH_STATUS, + CONF_ACTION_REGENERATE_WEBHOOK_SECRET, + CONF_ACTION_RESET_MANIFEST, + CONF_ACTION_SIGN_IN, + CONF_ACTION_TEST_WEBHOOK, + CONF_ACTION_UPDATE_SKILL, + CONF_ACTION_VALIDATE_MANIFEST, + CONF_DIALOG_ACTIVATION_PHRASE_2, + CONF_DIALOG_ACTIVATION_PHRASE_3, + CONF_DIALOG_ACTIVATION_PHRASE_4, + CONF_DIALOG_SKILL_ID, + CONF_DIALOG_SKILL_NAME, + CONF_DIALOG_SKILL_OVERRIDE_PASTE, + CONF_DIALOG_SKILL_TOKEN, + CONF_DIALOG_SKILL_VOICE, + CONF_DIALOG_WEBHOOK_SECRET, + CONF_EXPOSED_PLAYERS, + CONF_EXTERNAL_BASE_URL, + CONF_INSTANCE_NAME, + CONF_USE_DIFFERENT_INSTANCE_NAME, + DIALOG_DEFAULT_NAME, + DIALOG_VOICE_DEFAULT, + DIALOG_VOICE_OPTIONS, + DIALOG_WEBHOOK_BASE_PATH, +) +from .dialog_skill_meta import validate_activation_phrase, validate_skill_name +from .publication_status import ( + STATUS_DRAFT, + STATUS_IN_MODERATION, + STATUS_ON_AIR, + STATUS_REJECTED, +) +from .skill_manifest_provider import ManifestStatus +from .url_helpers import validate_external_base_url + + +def _publication_status_banner( + status: str | None, +) -> tuple[str, ConfigEntryType]: + """Map a classified publication status to a Step 3 banner. + + Returns ``(label_text, entry_type)``. Negative / actionable states + use :attr:`ConfigEntryType.ALERT` — the MA frontend renders these + as a tonal amber ``v-alert`` to draw the eye. Neutral / success + states use :attr:`ConfigEntryType.LABEL` (plain text) so the form + doesn't scream at a user whose skill is fine. + + Color emoji prefixes carry the semantic differentiation since + ALERT's color is hard-coded amber by the frontend: + + * ✅ on-air (success) — LABEL + * ⏳ in-moderation (waiting) — ALERT + * ❌ rejected (error) — ALERT + * ⚠️ draft (needs user action) — ALERT + * ℹ️ unknown / not yet fetched — LABEL + """ + if status == STATUS_ON_AIR: + return ( + "✅ Yandex moderation passed — your skill is on air. " + "Voice commands routed via Alice will now reach this skill on " + "your Yandex Station.", + ConfigEntryType.LABEL, + ) + if status == STATUS_IN_MODERATION: + return ( + "⏳ Yandex moderation in progress (typically 5-15 min). " + "Click Refresh status below to re-check.", + ConfigEntryType.ALERT, + ) + if status == STATUS_REJECTED: + return ( + "❌ Yandex moderation rejected the latest deploy. " + "Open the dev console to see the rejection reason and edit the " + "skill, then click Update skill to re-submit.", + ConfigEntryType.ALERT, + ) + if status == STATUS_DRAFT: + return ( + "⚠️ Skill is registered but has never been published. " + "Click Update skill (or re-deploy via Recreate) to submit it " + "to Yandex moderation.", + ConfigEntryType.ALERT, + ) + return ( + "ℹ️ Publication status not yet fetched — click Refresh status to query Yandex Dialogs.", + ConfigEntryType.LABEL, + ) + + +if TYPE_CHECKING: + from collections.abc import Iterable + +__all__ = ["build_form_entries"] + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _stamp(entries: Iterable[ConfigEntry], category: str) -> tuple[ConfigEntry, ...]: + """Set ``category`` on every entry that hasn't picked one explicitly. + + Default for ``ConfigEntry.category`` is ``"generic"``; treat that + as "needs a category" and stamp it with *category*. Also sets + ``category_translation_key`` so MA frontend renders the slug as + the section header (``u(translation_key, slug)`` — fallback on + slug when the key isn't translated). + """ + out: list[ConfigEntry] = [] + for e in entries: + current = getattr(e, "category", "") or "" + if current and current != "generic": + out.append(e) + continue + try: + out.append( + dataclasses.replace( + e, + category=category, + category_translation_key=f"yandex_alice.category.{category}", + ) + ) + except (TypeError, ValueError): + with contextlib.suppress(Exception): + e.category = category + e.category_translation_key = f"yandex_alice.category.{category}" + out.append(e) + return tuple(out) + + +# --------------------------------------------------------------------------- +# Authorization +# --------------------------------------------------------------------------- + + +def _authorization_block( + *, signed_in: bool, user_name: str, last_error: str | None +) -> tuple[ConfigEntry, ...]: + """Sign-in CTA or signed-in banner + Sign out.""" + if signed_in: + return ( + ConfigEntry( + key="label_auth_status", + type=ConfigEntryType.LABEL, + label=f"✅ Signed in to Yandex as «{user_name.strip() or 'Yandex account'}».", + ), + ConfigEntry( + key=CONF_ACTION_CLEAR_AUTH, + type=ConfigEntryType.ACTION, + label="Sign out of Yandex", + description=( + "Clears the cached Yandex Passport x_token. The " + "registered skill stays in Yandex Dialogs; sign in " + "again to manage it." + ), + action=CONF_ACTION_CLEAR_AUTH, + action_label="Sign out", + required=False, + default_value="", + ), + ) + + entries: list[ConfigEntry] = [] + if last_error: + entries.append( + ConfigEntry( + key="label_auth_last_error", + type=ConfigEntryType.ALERT, + label=f"❌ {last_error}", + ) + ) + entries.append( + ConfigEntry( + key="label_auth_intro", + type=ConfigEntryType.LABEL, + label=( + "Sign in to Yandex to register a dialog skill on your " + "behalf. We never see your password — Yandex hands us " + "a long-lived x_token only after you confirm a " + "verification code in the popup window." + ), + ) + ) + entries.append( + ConfigEntry( + key=CONF_ACTION_SIGN_IN, + type=ConfigEntryType.ACTION, + label="Sign in to Yandex Passport", + description=( + "Starts the Yandex Passport Device Flow in a popup " + "window. After confirming the code, the form refreshes." + ), + action=CONF_ACTION_SIGN_IN, + action_label="Sign in to Yandex Passport", + required=False, + default_value="", + ) + ) + return tuple(entries) + + +# --------------------------------------------------------------------------- +# Skill +# --------------------------------------------------------------------------- + + +def _skill_create_subblock( + *, + artifacts: SkillCreationArtifacts, + skill_name: str, + external_base_url: str, + base_url_description: str, + base_url_valid: bool, + update_message: str | None, + stage: LocalAutoCreateStage, +) -> tuple[ConfigEntry, ...]: + """Pre-DONE state: Skill name + URL + Create / Continue / Try-again.""" + entries: list[ConfigEntry] = [ + ConfigEntry( + key=CONF_DIALOG_SKILL_NAME, + type=ConfigEntryType.STRING, + label="Skill name", + description=( + "At least 2 words. Globally unique across all Yandex " + "skills. Examples: 'Music Assistant', 'My Music', " + "'Home Audio'." + ), + required=False, + value=skill_name, + default_value="", + validate=validate_skill_name, + ), + ConfigEntry( + key=CONF_EXTERNAL_BASE_URL, + type=ConfigEntryType.STRING, + label="External base URL (HTTPS, required)", + description=base_url_description, + required=False, + value=external_base_url, + default_value="", + validate=validate_external_base_url, + ), + ] + if base_url_valid: + entries.append( + ConfigEntry( + key=CONF_ACTION_TEST_WEBHOOK, + type=ConfigEntryType.ACTION, + label="Test webhook reachability", + description=( + "Sends a sentinel POST to the webhook URL and " + "reports the result. Catches DNS / TLS / " + "reverse-proxy issues *before* you spend a " + "Yandex moderation cycle on a broken setup." + ), + action=CONF_ACTION_TEST_WEBHOOK, + action_label="Test webhook", + required=False, + default_value="", + ) + ) + + if update_message and stage not in ( + LocalAutoCreateStage.FAILED, + LocalAutoCreateStage.DUPLICATE_DETECTED, + ): + entries.append( + ConfigEntry( + key="label_skill_msg", + type=ConfigEntryType.LABEL, + label=update_message, + ) + ) + + if stage == LocalAutoCreateStage.PIPELINE_RUNNING: + entries.append( + ConfigEntry( + key="label_skill_resume", + type=ConfigEntryType.LABEL, + label=( + "⏸ Setup was interrupted. Click 'Continue setup' " + f"to resume from step {artifacts.state.value}." + ), + ) + ) + entries.append( + ConfigEntry( + key=CONF_ACTION_AUTO_CREATE_DIALOG, + type=ConfigEntryType.ACTION, + label="Continue setup", + description="Resumes the skill creation pipeline from the last completed step.", + action=CONF_ACTION_AUTO_CREATE_DIALOG, + action_label="Continue setup", + required=False, + default_value="", + ) + ) + return tuple(entries) + + if stage == LocalAutoCreateStage.FAILED: + err = (artifacts.last_error or "Unknown error.").strip() + entries.append( + ConfigEntry( + key="label_skill_failed", + type=ConfigEntryType.ALERT, + label=f"❌ {err}", + ) + ) + if external_base_url: + entries.append( + ConfigEntry( + key="label_skill_failed_manual", + type=ConfigEntryType.LABEL, + label=( + "Manual fallback: open https://dialogs.yandex.ru/" + "developer in your browser and create the skill " + f"yourself with webhook URL " + f"{external_base_url.rstrip('/')}" + f"{DIALOG_WEBHOOK_BASE_PATH}/. " + "Once the skill is created, paste its Skill ID " + "into the Advanced section below." + ), + ) + ) + entries.append( + ConfigEntry( + key=CONF_ACTION_AUTO_CREATE_DIALOG, + type=ConfigEntryType.ACTION, + label="Try again", + description="Retries the skill creation pipeline from the last completed step.", + action=CONF_ACTION_AUTO_CREATE_DIALOG, + action_label="Try again", + required=False, + default_value="", + ) + ) + entries.append( + ConfigEntry( + key=CONF_ACTION_CANCEL_DIALOG_SKILL_FLOW, + type=ConfigEntryType.ACTION, + label="Reset and start over", + description=( + "Drops partial setup state and clears the failed " + "flag. Cached Passport sign-in is kept." + ), + action=CONF_ACTION_CANCEL_DIALOG_SKILL_FLOW, + action_label="Reset (start over)", + required=False, + default_value="", + ) + ) + return tuple(entries) + + # IDLE — ready to create. + entries.append( + ConfigEntry( + key="label_skill_intro", + type=ConfigEntryType.LABEL, + label=( + "Click 'Create skill' to register a new dialog skill " + "in your Yandex Dialogs account. The external base URL " + "above must be a public HTTPS URL." + ), + ) + ) + entries.append( + ConfigEntry( + key=CONF_ACTION_AUTO_CREATE_DIALOG, + type=ConfigEntryType.ACTION, + label="Create skill", + description=( + "Pre-checks Yandex for an existing skill with the same " + "name; if found, prompts to Recreate or Adopt. " + "Otherwise registers a fresh skill." + ), + action=CONF_ACTION_AUTO_CREATE_DIALOG, + action_label="Create skill", + required=False, + default_value="", + ) + ) + return tuple(entries) + + +def _skill_duplicate_subblock( + *, + duplicate_skill_name: str, + duplicate_skill_id: str, + action_outcome: AutoCreateOutcome | None, +) -> tuple[ConfigEntry, ...]: + """Same-name conflict: Recreate / Adopt resolution prompt.""" + entries: list[ConfigEntry] = [] + if action_outcome and action_outcome.user_message: + entries.append( + ConfigEntry( + key="label_skill_outcome", + type=ConfigEntryType.LABEL, + label=action_outcome.user_message, + ) + ) + entries.append( + ConfigEntry( + key="label_skill_dup_intro", + type=ConfigEntryType.LABEL, + label=( + f"⚠ A skill named «{duplicate_skill_name}» already exists " + "in your Yandex Dialogs account. Choose how to proceed:" + ), + ) + ) + entries.append( + ConfigEntry( + key="label_skill_dup_recreate_hint", + type=ConfigEntryType.LABEL, + label=( + " • Recreate — delete the existing skill and register " + "a fresh one with the same name." + ), + ) + ) + entries.append( + ConfigEntry( + key="label_skill_dup_adopt_hint", + type=ConfigEntryType.LABEL, + label=( + " • Adopt — keep the existing skill and re-deploy it " + "against this Music Assistant's webhook URL." + ), + ) + ) + if duplicate_skill_id: + url = f"https://dialogs.yandex.ru/developer/skills/{duplicate_skill_id}" + entries.append( + ConfigEntry( + key="label_skill_dup_console", + type=ConfigEntryType.LABEL, + label=f"Open existing skill: {url}", + help_link=url, + ) + ) + entries.append( + ConfigEntry( + key=CONF_ACTION_RECREATE_DUPLICATE, + type=ConfigEntryType.ACTION, + label="Recreate (delete + create fresh)", + description="Deletes the existing skill in Yandex and registers a fresh one.", + action=CONF_ACTION_RECREATE_DUPLICATE, + action_label="Recreate skill", + required=False, + default_value="", + ) + ) + entries.append( + ConfigEntry( + key=CONF_ACTION_ADOPT_EXISTING, + type=ConfigEntryType.ACTION, + label="Adopt existing skill", + description="Keeps the existing skill and re-deploys it against this MA's webhook.", + action=CONF_ACTION_ADOPT_EXISTING, + action_label="Adopt existing", + required=False, + default_value="", + ) + ) + return tuple(entries) + + +def _skill_registered_subblock( + *, + artifacts: SkillCreationArtifacts, + edit_mode: bool, + skill_name: str, + activation_phrase_2: str, + activation_phrase_3: str, + activation_phrase_4: str, + voice: str, + update_message: str | None, + publication_status: str | None, +) -> tuple[ConfigEntry, ...]: + """DONE state: identity card + status banner + Edit / Refresh / Delete.""" + name = artifacts.last_known_name or skill_name or "Music Assistant" + skill_id = artifacts.skill_id or "" + dev_console_url = f"https://dialogs.yandex.ru/developer/skills/{skill_id}" if skill_id else "" + + status_text, status_entry_type = _publication_status_banner(publication_status) + entries: list[ConfigEntry] = [ + ConfigEntry( + key="label_skill_registered", + type=ConfigEntryType.LABEL, + label=f"✅ Skill «{name}» is registered. Skill ID: {skill_id}", + ), + ConfigEntry( + key="label_skill_publication_status", + type=status_entry_type, + label=status_text, + ), + ] + if dev_console_url: + entries.append( + ConfigEntry( + key="label_skill_dev_console_url", + type=ConfigEntryType.STRING, + label="Yandex Dialogs dev console", + description="Copy this URL and open it in your browser.", + required=False, + value=dev_console_url, + default_value="", + ) + ) + if update_message: + entries.append( + ConfigEntry( + key="label_skill_update_msg", + type=ConfigEntryType.LABEL, + label=update_message, + ) + ) + if not edit_mode: + entries.append( + ConfigEntry( + key=CONF_ACTION_EDIT_SKILL, + type=ConfigEntryType.ACTION, + label="Edit skill", + description=( + "Reveals editable fields for the skill name, " + "alternative activation phrases, and TTS voice." + ), + action=CONF_ACTION_EDIT_SKILL, + action_label="Edit skill", + required=False, + default_value="", + ) + ) + entries.append( + ConfigEntry( + key=CONF_ACTION_REFRESH_STATUS, + type=ConfigEntryType.ACTION, + label="Refresh publication status", + description=( + "Re-fetches the skill's status from Yandex Dialogs (one " + "HTTP call). Useful while moderation is in progress to " + "see when the skill flips to on-air." + ), + action=CONF_ACTION_REFRESH_STATUS, + action_label="Refresh status", + required=False, + default_value="", + ) + ) + return tuple(entries) + + # Edit mode + entries.append( + ConfigEntry( + key="label_skill_edit_intro", + type=ConfigEntryType.LABEL, + label="Edit the fields below, then click 'Update skill'.", + ) + ) + entries.append( + ConfigEntry( + key=CONF_DIALOG_SKILL_NAME, + type=ConfigEntryType.STRING, + label="Skill name (activation phrase #1)", + description=( + "At least 2 words. This is the activation phrase users " + "say to Alice to invoke your skill." + ), + required=False, + value=skill_name, + default_value="", + validate=validate_skill_name, + ) + ) + for idx, (key, phrase) in enumerate( + ( + (CONF_DIALOG_ACTIVATION_PHRASE_2, activation_phrase_2), + (CONF_DIALOG_ACTIVATION_PHRASE_3, activation_phrase_3), + (CONF_DIALOG_ACTIVATION_PHRASE_4, activation_phrase_4), + ), + start=2, + ): + entries.append( + ConfigEntry( + key=key, + type=ConfigEntryType.STRING, + label=f"Alternative activation phrase #{idx} (optional)", + description="Optional. At least 2 words, or leave empty to skip.", + required=False, + value=phrase, + default_value="", + validate=validate_activation_phrase, + ) + ) + voice_options = [ + ConfigValueOption(title=label, value=value) for value, label in DIALOG_VOICE_OPTIONS + ] + entries.append( + ConfigEntry( + key=CONF_DIALOG_SKILL_VOICE, + type=ConfigEntryType.STRING, + label="TTS voice", + description="Voice used for the skill's text-to-speech replies.", + required=False, + value=voice or DIALOG_VOICE_DEFAULT, + default_value=DIALOG_VOICE_DEFAULT, + options=voice_options, + ) + ) + entries.append( + ConfigEntry( + key=CONF_ACTION_UPDATE_SKILL, + type=ConfigEntryType.ACTION, + label="Update skill", + description=( + "Pushes edits to Yandex via PATCH draft + re-deploy. Yandex moderation: 5-15 min." + ), + action=CONF_ACTION_UPDATE_SKILL, + action_label="Update skill", + required=False, + default_value="", + ) + ) + entries.append( + ConfigEntry( + key=CONF_ACTION_DELETE_SKILL, + type=ConfigEntryType.ACTION, + label="Delete skill", + description=( + "Permanently deletes the skill from Yandex Dialogs. " + "Cached Passport sign-in is kept." + ), + action=CONF_ACTION_DELETE_SKILL, + action_label="Delete skill", + required=False, + default_value="", + ) + ) + entries.append( + ConfigEntry( + key=CONF_ACTION_CANCEL_EDIT, + type=ConfigEntryType.ACTION, + label="Cancel edit", + description="Discards your edits and exits edit mode.", + action=CONF_ACTION_CANCEL_EDIT, + action_label="Cancel edit", + required=False, + default_value="", + ) + ) + return tuple(entries) + + +def _skill_advanced_subblock( # noqa: PLR0913 + *, + is_configured: bool, + skill_id_value: str, + skill_token_value: str, + webhook_secret: str, + activation_phrase_2: str, + activation_phrase_3: str, + activation_phrase_4: str, + voice: str, + suppress_phrase_voice_mirror: bool, + suppress_skill_name_mirror: bool, + suppress_external_base_url_mirror: bool, + skill_name: str, + external_base_url: str, + diagnostics: tuple[ConfigEntry, ...], +) -> tuple[ConfigEntry, ...]: + """Skill-related Advanced fields (visible behind ``Show advanced`` toggle). + + The various ``suppress_*_mirror`` flags avoid duplicate-key + collisions: when a key is already rendered *visible* by + ``_skill_create_subblock`` / ``_skill_registered_subblock`` in + edit mode, it must NOT also appear as a hidden Advanced mirror. + The dispatcher passes ``True`` for whichever keys are visible in + the current state and ``False`` for the rest; the latter need a + mirror so their values still round-trip on form Save (FE only + persists visible field values). + """ + entries: list[ConfigEntry] = [] + if not suppress_skill_name_mirror: + entries.append( + ConfigEntry( + key=CONF_DIALOG_SKILL_NAME, + type=ConfigEntryType.STRING, + label="Skill name", + description="The skill's display name in Yandex Dialogs.", + required=False, + value=skill_name, + default_value="", + advanced=True, + ) + ) + if not suppress_external_base_url_mirror: + entries.append( + ConfigEntry( + key=CONF_EXTERNAL_BASE_URL, + type=ConfigEntryType.STRING, + label="External base URL (HTTPS)", + description=( + "Public HTTPS URL Yandex calls for webhooks. " + "Editable here even after the skill is registered." + ), + required=False, + value=external_base_url, + default_value="", + advanced=True, + validate=validate_external_base_url, + ) + ) + entries.append( + ConfigEntry( + key=CONF_DIALOG_SKILL_ID, + type=ConfigEntryType.STRING, + label="Skill ID", + description=( + "UUID of the skill — populated automatically after a " + "successful auto-create, or paste manually if you set " + "up the skill yourself." + ), + required=False, + value=skill_id_value, + default_value="", + read_only=is_configured, + advanced=True, + ) + ) + entries.append( + ConfigEntry( + key=CONF_DIALOG_SKILL_TOKEN, + type=ConfigEntryType.SECURE_STRING, + label="Skill OAuth token (manual setup only)", + description=( + "Optional OAuth token from " + "https://oauth.yandex.ru/authorize?response_type=token" + "&client_id=c473ca268cd749d3a8371351a8f2bcbd. " + "Used to push state callbacks to Yandex (future " + "feature; stored encrypted)." + ), + help_link=( + "https://oauth.yandex.ru/authorize?response_type=token" + "&client_id=c473ca268cd749d3a8371351a8f2bcbd" + ), + required=False, + value=skill_token_value, + advanced=True, + ) + ) + entries.append( + ConfigEntry( + key=CONF_DIALOG_WEBHOOK_SECRET, + type=ConfigEntryType.SECURE_STRING, + label="Webhook URL secret", + description=( + "Random secret embedded in the webhook URL. The full " + f"URL is {DIALOG_WEBHOOK_BASE_PATH}" + "/. Editable: changes here invalidate " + "Yandex's existing registration." + ), + required=False, + value=webhook_secret, + advanced=True, + ) + ) + if is_configured: + entries.append( + ConfigEntry( + key=CONF_ACTION_REGENERATE_WEBHOOK_SECRET, + type=ConfigEntryType.ACTION, + label="Regenerate webhook secret", + description=( + "Generates a fresh secret + resets the auto-create " + "state. The current Yandex skill registration " + "becomes stale — you'll need to re-create the skill." + ), + action=CONF_ACTION_REGENERATE_WEBHOOK_SECRET, + action_label="Regenerate (forces re-create)", + required=False, + default_value="", + advanced=True, + ) + ) + if not suppress_phrase_voice_mirror: + for idx, (key, value) in enumerate( + ( + (CONF_DIALOG_ACTIVATION_PHRASE_2, activation_phrase_2), + (CONF_DIALOG_ACTIVATION_PHRASE_3, activation_phrase_3), + (CONF_DIALOG_ACTIVATION_PHRASE_4, activation_phrase_4), + ), + start=2, + ): + entries.append( + ConfigEntry( + key=key, + type=ConfigEntryType.STRING, + label=f"Alternative activation phrase #{idx}", + description="Optional. At least 2 words, or empty to skip.", + required=False, + value=value, + default_value="", + advanced=True, + ) + ) + entries.append( + ConfigEntry( + key=CONF_DIALOG_SKILL_VOICE, + type=ConfigEntryType.STRING, + label="TTS voice", + description="Yandex TTS voice id.", + required=False, + value=voice or DIALOG_VOICE_DEFAULT, + default_value=DIALOG_VOICE_DEFAULT, + advanced=True, + ) + ) + entries.extend(diagnostics) + return tuple(entries) + + +def _skill_block( # noqa: PLR0913 + *, + artifacts: SkillCreationArtifacts, + cached_x_token_present: bool, + skill_id_value: str, + skill_token_value: str, + webhook_secret: str, + edit_mode: bool, + skill_name: str, + activation_phrase_2: str, + activation_phrase_3: str, + activation_phrase_4: str, + voice: str, + update_message: str | None, + external_base_url: str, + base_url_description: str, + base_url_valid: bool, + duplicate_skill_id: str | None, + duplicate_skill_name: str | None, + action_outcome: AutoCreateOutcome | None, + publication_status: str | None, + diagnostics: tuple[ConfigEntry, ...], +) -> tuple[ConfigEntry, ...]: + """Skill section visible when authed OR a manual skill_id is set.""" + is_done = artifacts.state == SkillCreationState.DONE + skill_known = is_done or bool(skill_id_value) + visible = cached_x_token_present or skill_known + if not visible: + return () + + duplicate_pending = bool(duplicate_skill_id) + if duplicate_pending: + stage = LocalAutoCreateStage.DUPLICATE_DETECTED + elif is_done: + stage = LocalAutoCreateStage.DONE + elif artifacts.state == SkillCreationState.FAILED: + stage = LocalAutoCreateStage.FAILED + elif cached_x_token_present and artifacts.state in ( + SkillCreationState.APP_CREATED, + SkillCreationState.DRAFT_UPDATED, + SkillCreationState.OAUTH_CREATED, + SkillCreationState.OAUTH_ATTACHED, + SkillCreationState.DEPLOY_REQUESTED, + ): + stage = LocalAutoCreateStage.PIPELINE_RUNNING + else: + stage = LocalAutoCreateStage.IDLE + + visible_entries: tuple[ConfigEntry, ...] + suppress_external_base_url_mirror = False + if duplicate_pending: + visible_entries = _skill_duplicate_subblock( + duplicate_skill_name=duplicate_skill_name or "", + duplicate_skill_id=duplicate_skill_id or "", + action_outcome=action_outcome, + ) + suppress_phrase_voice_mirror = False + suppress_skill_name_mirror = False + elif is_done: + visible_entries = _skill_registered_subblock( + artifacts=artifacts, + edit_mode=edit_mode, + skill_name=skill_name, + activation_phrase_2=activation_phrase_2, + activation_phrase_3=activation_phrase_3, + activation_phrase_4=activation_phrase_4, + voice=voice, + update_message=update_message, + publication_status=publication_status, + ) + suppress_phrase_voice_mirror = edit_mode + suppress_skill_name_mirror = edit_mode + # external_base_url is NOT visible in DONE / edit-mode → keep + # the Advanced mirror so its value still round-trips on Save. + else: + visible_entries = _skill_create_subblock( + artifacts=artifacts, + skill_name=skill_name, + external_base_url=external_base_url, + base_url_description=base_url_description, + base_url_valid=base_url_valid, + update_message=update_message, + stage=stage, + ) + suppress_phrase_voice_mirror = False + suppress_skill_name_mirror = True # rendered visible above + suppress_external_base_url_mirror = True # rendered visible above + + advanced_entries = _skill_advanced_subblock( + is_configured=is_done and bool(artifacts.skill_id), + skill_id_value=skill_id_value, + skill_token_value=skill_token_value, + webhook_secret=webhook_secret, + activation_phrase_2=activation_phrase_2, + activation_phrase_3=activation_phrase_3, + activation_phrase_4=activation_phrase_4, + voice=voice, + suppress_phrase_voice_mirror=suppress_phrase_voice_mirror, + suppress_skill_name_mirror=suppress_skill_name_mirror, + suppress_external_base_url_mirror=suppress_external_base_url_mirror, + skill_name=skill_name, + external_base_url=external_base_url, + diagnostics=diagnostics, + ) + return (*visible_entries, *advanced_entries) + + +# --------------------------------------------------------------------------- +# Settings (provider-wide) +# --------------------------------------------------------------------------- + + +def _manifest_block( + *, + status: ManifestStatus, + paste_value: str, + update_message: str | None, +) -> tuple[ConfigEntry, ...]: + """Skill manifest banner + Export / Import / Reset / Validate actions. + + The manifest controls intents + entities deployed to Yandex (skill + grammar) and the runtime mapping that turns NLU matches into + player actions. Bundled by default; users override by writing + ``/yandex_alice/skill.toml`` (manually or via Import). + """ + if status.source == "bundled": + banner = ( + f"Skill manifest: bundled default " + f"({status.intent_count} intents, {status.entity_count} entities). " + f"Use Export to copy it to {status.override_path} for editing." + ) + elif status.source == "override_valid": + banner = ( + f"Skill manifest: override active at {status.override_path} " + f"({status.intent_count} intents, {status.entity_count} entities)." + ) + else: # override_invalid + banner = ( + f"⚠ Skill manifest: override at {status.override_path} is invalid — " + f"falling back to bundled default ({status.intent_count} intents, " + f"{status.entity_count} entities). " + f"Error: {status.error or 'unknown parse error'}" + ) + + entries: list[ConfigEntry] = [ + ConfigEntry( + key="label_manifest_banner", + type=ConfigEntryType.LABEL, + label=banner, + ), + ] + if update_message: + entries.append( + ConfigEntry( + key="label_manifest_message", + type=ConfigEntryType.LABEL, + label=update_message, + ) + ) + entries.extend( + ( + ConfigEntry( + key=CONF_ACTION_EXPORT_MANIFEST, + type=ConfigEntryType.ACTION, + label="Export manifest", + description=( + "Write the current bundled manifest to " + f"{status.override_path}. Will not overwrite an existing " + "override; use Reset first if you want to start over." + ), + action=CONF_ACTION_EXPORT_MANIFEST, + action_label="Export to file", + required=False, + default_value="", + ), + ConfigEntry( + key=CONF_DIALOG_SKILL_OVERRIDE_PASTE, + type=ConfigEntryType.STRING, + label="Manifest TOML to import", + description=( + "Paste the full TOML contents and click Import. If your " + "browser strips line breaks, paste base64 with prefix " + "data:base64, — base64 -i skill.toml " + "will produce one. Cleared on a successful import." + ), + required=False, + value=paste_value, + default_value="", + ), + ConfigEntry( + key=CONF_ACTION_IMPORT_MANIFEST, + type=ConfigEntryType.ACTION, + label="Import manifest", + description=( + "Validate the pasted TOML and write it to " + f"{status.override_path}. Existing override is replaced " + "atomically. The paste field is cleared on success." + ), + action=CONF_ACTION_IMPORT_MANIFEST, + action_label="Import from paste", + required=False, + default_value="", + ), + ConfigEntry( + key=CONF_ACTION_VALIDATE_MANIFEST, + type=ConfigEntryType.ACTION, + label="Validate manifest", + description=( + "Re-parse the override file and report parse / schema " + "errors without re-deploying to Yandex." + ), + action=CONF_ACTION_VALIDATE_MANIFEST, + action_label="Validate override", + required=False, + default_value="", + ), + ConfigEntry( + key=CONF_ACTION_RESET_MANIFEST, + type=ConfigEntryType.ACTION, + label="Reset manifest", + description=( + "Delete the override file and revert to the bundled " + "default. Idempotent — safe to click when no override " + "exists." + ), + action=CONF_ACTION_RESET_MANIFEST, + action_label="Reset to bundled default", + required=False, + default_value="", + ), + ) + ) + return tuple(entries) + + +def _settings_block( + *, + player_options: list[ConfigValueOption], + instance_name: str, + skill_name: str, + use_different_instance_name: bool, +) -> tuple[ConfigEntry, ...]: + """Voice-controllable players + (advanced) MA-side instance name override.""" + entries: list[ConfigEntry] = [ + ConfigEntry( + key=CONF_EXPOSED_PLAYERS, + type=ConfigEntryType.STRING, + label="Voice-controllable players", + description=( + "Players Alice is allowed to control. Leave empty to " + "expose all players known to MA — Alice will then " + "accept voice commands for any player by name." + ), + multi_value=True, + options=player_options, + required=False, + default_value=[], + ), + ConfigEntry( + key=CONF_USE_DIFFERENT_INSTANCE_NAME, + type=ConfigEntryType.BOOLEAN, + label="Use a different MA-side instance name", + description=( + "By default the Skill name is also used as the " + "instance name shown in MA's provider list. Turn " + "this on to specify a different MA-side name." + ), + required=False, + default_value=False, + advanced=True, + ), + ] + if use_different_instance_name: + entries.append( + ConfigEntry( + key=CONF_INSTANCE_NAME, + type=ConfigEntryType.STRING, + label="Instance name (MA-side display)", + description=( + "Display name shown in Music Assistant's provider " + "list. Independent from the Skill name." + ), + required=False, + default_value=instance_name or DIALOG_DEFAULT_NAME, + advanced=True, + depends_on=CONF_USE_DIFFERENT_INSTANCE_NAME, + depends_on_value=True, + ) + ) + else: + # Hidden tracker: keep instance_name in sync with skill_name + # while the toggle is off, so MA's display label looks right. + entries.append( + ConfigEntry( + key=CONF_INSTANCE_NAME, + type=ConfigEntryType.STRING, + label="Instance name (auto)", + required=False, + default_value=skill_name or DIALOG_DEFAULT_NAME, + hidden=True, + ) + ) + return tuple(entries) + + +# --------------------------------------------------------------------------- +# Top-level composer +# --------------------------------------------------------------------------- + + +def build_form_entries( # noqa: PLR0913 + *, + artifacts: SkillCreationArtifacts, + cached_x_token_present: bool, + user_name: str, + skill_id_value: str, + skill_token_value: str, + webhook_secret: str, + last_error: str | None, + action_outcome: AutoCreateOutcome | None, + duplicate_skill_id: str | None, + duplicate_skill_name: str | None, + edit_mode: bool, + skill_name: str, + activation_phrase_2: str, + activation_phrase_3: str, + activation_phrase_4: str, + voice: str, + update_message: str | None, + external_base_url: str, + base_url_description: str, + base_url_valid: bool, + player_options: list[ConfigValueOption], + instance_name: str, + use_different_instance_name: bool, + publication_status: str | None, + diagnostics: tuple[ConfigEntry, ...], + manifest_status: ManifestStatus, + manifest_paste: str, + manifest_message: str | None, + hidden_state: tuple[ConfigEntry, ...], +) -> tuple[ConfigEntry, ...]: + """Compose Authorization + Skill + Settings + hidden state.""" + auth = _stamp( + _authorization_block( + signed_in=cached_x_token_present, + user_name=user_name, + last_error=last_error, + ), + CATEGORY_AUTHORIZATION, + ) + skill = _stamp( + _skill_block( + artifacts=artifacts, + cached_x_token_present=cached_x_token_present, + skill_id_value=skill_id_value, + skill_token_value=skill_token_value, + webhook_secret=webhook_secret, + edit_mode=edit_mode, + skill_name=skill_name, + activation_phrase_2=activation_phrase_2, + activation_phrase_3=activation_phrase_3, + activation_phrase_4=activation_phrase_4, + voice=voice, + update_message=update_message, + external_base_url=external_base_url, + base_url_description=base_url_description, + base_url_valid=base_url_valid, + duplicate_skill_id=duplicate_skill_id, + duplicate_skill_name=duplicate_skill_name, + action_outcome=action_outcome, + publication_status=publication_status, + diagnostics=diagnostics, + ), + CATEGORY_SKILL, + ) + settings = _stamp( + ( + *_settings_block( + player_options=player_options, + instance_name=instance_name, + skill_name=skill_name, + use_different_instance_name=use_different_instance_name, + ), + *_manifest_block( + status=manifest_status, + paste_value=manifest_paste, + update_message=manifest_message, + ), + ), + CATEGORY_SETTINGS, + ) + # Hidden state-carriers stay in the default "generic" category — + # they're hidden=True so they never render as a section anyway. + return (*auth, *skill, *settings, *hidden_state) diff --git a/music_assistant/providers/yandex_alice/skill_logo.png b/music_assistant/providers/yandex_alice/skill_logo.png new file mode 100755 index 0000000000..fc0a72cc4b Binary files /dev/null and b/music_assistant/providers/yandex_alice/skill_logo.png differ diff --git a/music_assistant/providers/yandex_alice/skill_logo.py b/music_assistant/providers/yandex_alice/skill_logo.py new file mode 100644 index 0000000000..9f39c4d5ac --- /dev/null +++ b/music_assistant/providers/yandex_alice/skill_logo.py @@ -0,0 +1,32 @@ +"""Loader for the bundled Yandex Dialogs skill logo. + +The PNG (``skill_logo.png`` next to this module) is the official Music +Assistant 512x512 PWA icon, used as the catalog / dev-console logo for +the auto-created Yandex Dialogs skill so the skill matches the running +Music Assistant install at a glance. +""" + +from __future__ import annotations + +from functools import lru_cache +from pathlib import Path + +__all__ = ["load_skill_logo_bytes"] + +_LOGO_PATH = Path(__file__).parent / "skill_logo.png" + + +@lru_cache(maxsize=1) +def load_skill_logo_bytes() -> bytes: + """Return the bundled Music Assistant logo PNG bytes. + + Falls back to the ya-dialogs-api default logo if the bundled file + is missing for any reason — keeps the create-skill pipeline alive + on a broken install rather than failing it. + """ + try: + return _LOGO_PATH.read_bytes() + except OSError: + from ya_dialogs_api import load_default_logo_bytes # noqa: PLC0415 + + return load_default_logo_bytes() diff --git a/music_assistant/providers/yandex_alice/skill_manifest_provider.py b/music_assistant/providers/yandex_alice/skill_manifest_provider.py new file mode 100644 index 0000000000..610de72088 --- /dev/null +++ b/music_assistant/providers/yandex_alice/skill_manifest_provider.py @@ -0,0 +1,435 @@ +# ruff: noqa: D107, RUF001 +"""Effective skill manifest with file-based override + UI status reporting. + +Single class :class:`SkillManifestProvider` is the only entry point for +the rest of the provider to read / write / validate the skill TOML +manifest. It encapsulates: + +* Loading the **effective** manifest (override file if present and + valid, else the package-bundled default at + ``provider/data/skill.toml``). +* Status reporting to UI: ``bundled`` / ``override_valid`` / + ``override_invalid`` (with parser error message for the latter) + via :class:`ManifestStatus.source`. +* File operations exposed as user-facing actions: export current + effective manifest to override path; import paste from UI; reset + (delete override); validate override locally. +* Runtime dispatch of an NLU intent block (``request.nlu.intents``) + into a :class:`ParsedControl` / :class:`ParsedCommand` via the + manifest's ``runtime:`` blocks (provider-side ``parse_intent``). + +Override file path: ``/yandex_alice/skill.toml`` where +``storage_root`` is ``mass.storage_path`` if MA exposes it, falling +back to ``$HOME/.musicassistant`` (matching the path convention the +provider already documents in dialogs.py for log files). +""" + +from __future__ import annotations + +import base64 +import binascii +import contextlib +import dataclasses +import importlib.resources +import logging +from pathlib import Path +from typing import TYPE_CHECKING, Any, Literal + +from ya_dialogs_api import ( + EntityDraft, + IntentDraft, + SkillManifest, + SkillManifestError, + apply_runtime_mapping, + iter_intent_matches, + parse_manifest_text, +) + +from .dialogs_control import ParsedControl +from .dialogs_nlu import ParsedCommand + +if TYPE_CHECKING: + from music_assistant.mass import MusicAssistant + +__all__ = [ + "ManifestStatus", + "SkillManifestProvider", +] + + +_LOGGER = logging.getLogger(__name__) + +_BASE64_PASTE_PREFIX = "data:base64," + + +@dataclasses.dataclass(frozen=True, slots=True) +class ManifestStatus: + """User-facing snapshot of the effective manifest. + + :param source: ``"bundled"`` when no override file exists, + ``"override_valid"`` when the override file parses cleanly, + ``"override_invalid"`` when the override file is present but + unusable (the bundled default is loaded as fallback). + :param override_path: path where the override file lives or + would live (always reportable to UI even when absent). + :param intent_count: number of intents in the effective manifest. + :param entity_count: number of entities in the effective manifest. + :param error: parser error message when ``source == + "override_invalid"``, ``None`` otherwise. + """ + + source: Literal["bundled", "override_valid", "override_invalid"] + override_path: Path + intent_count: int + entity_count: int + error: str | None = None + + +_ResolvedSource = Literal["bundled", "override_valid", "override_invalid"] + + +@dataclasses.dataclass(frozen=True, slots=True) +class _Resolved: + """Cached snapshot of one ``stat()`` worth of effective manifest state.""" + + manifest: SkillManifest + source: _ResolvedSource + error: str | None + + +class SkillManifestProvider: + """Effective skill manifest gateway for the rest of the provider. + + Cheap to construct (no I/O until the first call). Subsequent calls + that hit the override path use a stat-keyed cache: the override is + re-parsed only when the file's ``(existence, mtime_ns)`` pair + changes. External edits take effect on the next call without a + restart, but unchanged files do not trigger redundant disk reads + or TOML parses on every webhook hit / UI render. + """ + + def __init__(self, mass: MusicAssistant) -> None: + self._mass = mass + self._last_import_success = False + self._bundled_cache: SkillManifest | None = None + self._resolved_cache_key: tuple[bool, int | None] | None = None + self._resolved_cache: _Resolved | None = None + + # ----------------------------------------------------------------------- + # Path & status + # ----------------------------------------------------------------------- + + @property + def override_path(self) -> Path: + """Where the user override TOML lives (or would live).""" + return self._storage_root() / "yandex_alice" / "skill.toml" + + @property + def last_import_success(self) -> bool: + """``True`` if the most recent ``import_from_paste`` call succeeded.""" + return self._last_import_success + + def status(self) -> ManifestStatus: + """Diagnostic snapshot for UI display.""" + resolved = self._resolve() + path = self.override_path + return ManifestStatus( + source=resolved.source, + override_path=path, + intent_count=len(resolved.manifest.intents), + entity_count=len(resolved.manifest.to_entity_drafts()), + error=resolved.error, + ) + + # ----------------------------------------------------------------------- + # Effective manifest loading + # ----------------------------------------------------------------------- + + def manifest(self) -> SkillManifest: + """Return the effective manifest — override if valid, else bundled.""" + return self._resolve().manifest + + def grammar(self) -> list[IntentDraft]: + """Effective intents as ``IntentDraft`` for ``set_intents``.""" + return self.manifest().to_intent_drafts() + + def entities(self) -> list[EntityDraft]: + """Effective entities as ``EntityDraft`` for ``set_entities``.""" + return self.manifest().to_entity_drafts() + + # ----------------------------------------------------------------------- + # Runtime dispatch — NLU intent block → ParsedControl/ParsedCommand + # ----------------------------------------------------------------------- + + def parse_intent( + self, + nlu_intents: dict[str, Any] | None, + ) -> ParsedControl | ParsedCommand | None: + """Map an NLU intent block to the dispatcher's dataclass. + + Walks ``request.nlu.intents`` matches against the effective + manifest's ``runtime`` blocks; the first matched intent with a + valid runtime mapping yields a :class:`ParsedControl` (kind + ``"control"``) or :class:`ParsedCommand` (kind ``"play"``). + Returns ``None`` when no intent has a runtime mapping that + applies (slot missing without default, value rejected by + cap / reject_if_below, or matched intent has no runtime block). + """ + manifest = self.manifest() + by_form = {i.form_name: i for i in manifest.intents} + for match in iter_intent_matches(nlu_intents): + intent = by_form.get(match.form_name) + if intent is None or intent.runtime is None: + continue + fields = apply_runtime_mapping(match, intent.runtime) + if fields is None: + continue + if intent.runtime.kind == "control": + return ParsedControl( + action=intent.runtime.action, # type: ignore[arg-type] + **fields, + ) + if intent.runtime.kind == "play": + # Play-side ParsedCommand has fixed fields; manifest + # may declare no mapping (my_wave) or future query + # mappings. Defaults match v1.5.0 hardcoded behaviour. + return ParsedCommand( + kind=intent.runtime.action, # type: ignore[arg-type] + query=fields.get("query", ""), + radio_mode=bool(fields.get("radio_mode", True)), + ) + # Unknown kind — silently skip (consumer-side configuration + # error; production logs would catch repeat offenders). + return None + + # ----------------------------------------------------------------------- + # User-facing actions + # ----------------------------------------------------------------------- + + def export_to_override(self) -> str: + """Copy bundled default into the override path (if not already there). + + First-time export bootstraps the override file from the + manifest the user is currently effectively running. Subsequent + invocations are a no-op — user edits aren't trampled. + """ + path = self.override_path + if path.exists(): + return f"Override уже существует: {path}" + try: + self._atomic_write_text(path, self._bundled_manifest_text()) + except OSError as exc: + return f"Экспорт не удался — не могу записать {path}: {exc}" + self._invalidate_cache() + return f"Манифест экспортирован в {path}. Откройте файл во внешнем редакторе." + + def import_from_paste(self, paste: str) -> str: + """Validate and write a TOML paste into the override file. + + ``data:base64,`` prefix triggers base64 decoding (fallback + for MA UI clients that strip newlines from STRING fields). + Sets :attr:`last_import_success` so the dispatcher in + ``__init__.py`` can clear the paste field on success. + """ + self._last_import_success = False + if not paste or not paste.strip(): + return "Поле для вставки пусто, нечего импортировать" + + text, decode_error = self._decode_paste(paste) + if decode_error is not None: + return f"Импорт не удался: {decode_error}" + + try: + manifest = parse_manifest_text(text) + except SkillManifestError as exc: + return f"Импорт не удался — манифест невалиден: {exc}" + + path = self.override_path + try: + self._atomic_write_text(path, text) + except OSError as exc: + return f"Импорт не удался — не могу записать {path}: {exc}" + + self._invalidate_cache() + self._last_import_success = True + return ( + f"Манифест импортирован в {path} — " + f"{len(manifest.intents)} intents, " + f"{len(manifest.to_entity_drafts())} entities" + ) + + def reset_override(self) -> str: + """Delete the override file so the bundled default takes effect. + + Idempotent in effect — calling on an already-clean state is + a no-op. The returned message differs (``удалён`` vs + ``отсутствует``) so the UI can confirm what actually happened. + """ + path = self.override_path + if not path.exists(): + return "Override отсутствует, используется bundled default" + try: + path.unlink() + except OSError as exc: + return f"Сброс не удался — не могу удалить {path}: {exc}" + self._invalidate_cache() + return f"Override удалён ({path}), используется bundled default" + + def validate_override_message(self) -> str: + """Local-only validation of the override file. + + TOML parse + manifest schema check. Granet (Yandex) validation + runs at "Apply skill changes" time; this method is a quick + sanity check before that. + """ + path = self.override_path + if not path.exists(): + return "Override отсутствует, нечего валидировать" + try: + text = path.read_text(encoding="utf-8") + except OSError as exc: + return f"Не могу прочитать {path}: {exc}" + try: + manifest = parse_manifest_text(text) + except SkillManifestError as exc: + return f"✗ Override невалиден: {exc}" + return ( + f"✓ Override валиден ({len(manifest.intents)} intents, " + f"{len(manifest.to_entity_drafts())} entities)" + ) + + # ----------------------------------------------------------------------- + # Internals + # ----------------------------------------------------------------------- + + def _storage_root(self) -> Path: + """MA storage root, with a documented fallback. + + Tries ``mass.storage_path`` first (the path MA-core uses for + its own state). Falls back to ``$HOME/.musicassistant`` — + matches the path the provider already documents for log files + in ``dialogs.py``. + """ + attr = getattr(self._mass, "storage_path", None) + if attr: + return Path(attr) + return Path.home() / ".musicassistant" + + def _resolve(self) -> _Resolved: + """Stat-keyed effective-manifest cache. + + Cache key is ``(override_exists, override_mtime_ns)``. Returns + the cached snapshot when the key matches; otherwise re-reads + the override file (or bundled fallback) and refreshes the + cache. Mutating actions (export / import / reset) must call + :meth:`_invalidate_cache` so the next read picks up the change + even when the new mtime collides with the old (rare on most + filesystems but possible on coarse-resolution clocks). + """ + path = self.override_path + try: + stat = path.stat() + except FileNotFoundError: + key: tuple[bool, int | None] = (False, None) + else: + key = (True, stat.st_mtime_ns) + + if self._resolved_cache_key == key and self._resolved_cache is not None: + return self._resolved_cache + + if not key[0]: + resolved = _Resolved( + manifest=self._bundled_manifest(), + source="bundled", + error=None, + ) + else: + try: + override = parse_manifest_text(path.read_text(encoding="utf-8")) + except (OSError, SkillManifestError) as exc: + _LOGGER.warning( + "skill manifest override at %s is invalid (%s); " + "falling back to bundled default", + path, + exc, + ) + resolved = _Resolved( + manifest=self._bundled_manifest(), + source="override_invalid", + error=str(exc), + ) + else: + resolved = _Resolved( + manifest=override, + source="override_valid", + error=None, + ) + + self._resolved_cache_key = key + self._resolved_cache = resolved + return resolved + + def _invalidate_cache(self) -> None: + """Drop the resolved-manifest cache (called by mutating actions).""" + self._resolved_cache_key = None + self._resolved_cache = None + + def _bundled_manifest(self) -> SkillManifest: + if self._bundled_cache is None: + self._bundled_cache = parse_manifest_text(self._bundled_manifest_text()) + return self._bundled_cache + + @staticmethod + def _bundled_manifest_text() -> str: + # Resolve via this module's own package so the lookup keeps + # working after the upstream sync renames the package from + # ``provider`` to ``music_assistant.providers.yandex_alice``. + ref = importlib.resources.files(__package__).joinpath("data/skill.toml") + return ref.read_text(encoding="utf-8") + + @staticmethod + def _atomic_write_text(path: Path, text: str) -> None: + """Write ``text`` to ``path`` atomically (tmp file + ``os.replace``). + + Replaces the target in-place on POSIX/NT — readers either see + the old content or the fully-written new content, never a + partially-flushed file. Parent directories are created if + missing. Bubbles up :class:`OSError` so callers can surface a + useful message. + """ + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_name(f".{path.name}.tmp") + try: + tmp.write_text(text, encoding="utf-8") + tmp.replace(path) + except OSError: + with contextlib.suppress(OSError): + tmp.unlink() + raise + + @staticmethod + def _decode_paste(paste: str) -> tuple[str, str | None]: + """Return ``(decoded_text, error_message)``. + + On success ``error_message`` is ``None``. On base64 decode + failure the original paste is returned alongside an + explanation. + + Whitespace inside the base64 payload is stripped before + decoding — most ``base64`` CLI tools wrap output at 76 columns + by default, so a copy-paste from ``base64 -i skill.toml`` + contains newlines that ``validate=True`` would otherwise + reject. + """ + if not paste.startswith(_BASE64_PASTE_PREFIX): + return paste, None + raw = paste.removeprefix(_BASE64_PASTE_PREFIX) + encoded = "".join(raw.split()) + try: + decoded_bytes = base64.b64decode(encoded, validate=True) + except (binascii.Error, ValueError) as exc: + return paste, f"base64 декодирование не удалось: {exc}" + try: + return decoded_bytes.decode("utf-8"), None + except UnicodeDecodeError as exc: + return paste, f"декодированные данные не UTF-8: {exc}" diff --git a/music_assistant/providers/yandex_alice/tts_dictionary.py b/music_assistant/providers/yandex_alice/tts_dictionary.py new file mode 100644 index 0000000000..f7537f1338 --- /dev/null +++ b/music_assistant/providers/yandex_alice/tts_dictionary.py @@ -0,0 +1,98 @@ +# ruff: noqa: RUF001 +"""TTS pronunciation hints for Alice's text-to-speech. + +Yandex Alice's TTS gives passable Russian pronunciation out of the box but +often mangles foreign artist / band names — "Metallica" becomes +"мэ-та-ли-ка" (Latin-letters → English-phonemes path), "Coldplay" becomes +"чол-дплай", etc. We intercept by emitting a Cyrillic transliteration with +a `+` stress marker into ``response.tts`` while keeping ``response.text`` +clean (the user reads the original; Alice speaks the cleaned-up form). + +Two tables: + +* ``WORD_REPLACEMENTS`` — single-word, applied via the per-word regex in + ``dialogs._tts_for``. Includes both Russian response words (stress + hints) and foreign single-word artist names (Cyrillic transliteration). +* ``PHRASE_REPLACEMENTS`` — multi-word, applied via whole-string + substitution before the word regex. Required because the per-word + regex can't match phrases like "Iron Maiden" or "Pink Floyd". + +Entries are LOWERCASE keys; case is restored at substitution time. +Add via PR — opportunistically, when a real-user log shows Alice +mangling a name. Don't bulk-import — every entry is config debt. +""" + +from __future__ import annotations + +# --------------------------------------------------------------------------- +# Single-word replacements +# --------------------------------------------------------------------------- + +WORD_REPLACEMENTS: dict[str, str] = { + # ----- Russian response words (P0.2 — stress hints) ----- + "включаю": "включ+аю", + "ставлю": "ст+авлю", + "пауза": "п+ауза", + "продолжаю": "продолж+аю", + "следующая": "сл+едующая", + "предыдущая": "пред+ыдущая", + "громче": "гр+омче", + "тише": "т+ише", + "громкость": "гр+омкость", + "колонке": "кол+онке", + "колонку": "кол+онку", + # ----- Foreign artists (single word) ----- + # Add here when a real log shows mispronunciation. Order: alpha by key. + "abba": "+абба", + "adele": "ад+эль", + "aerosmith": "+аэросмит", + "beatles": "б+итлз", + "beyonce": "бэй+онсэ", + "blur": "блёр", + "coldplay": "к+олдплей", + "depeche": "деп+еш", + "drake": "дрейк", + "eminem": "эмин+эм", + "evanescence": "иванэсс+энс", + "gorillaz": "горил+ас", + "imagine": "имадж+ин", + "kiss": "кисс", + "madonna": "мад+онна", + "metallica": "мет+аллика", + "muse": "мьюз", + "nirvana": "нирв+ана", + "oasis": "о+азис", + "queen": "квин", + "radiohead": "р+адиохед", + "rammstein": "р+амштайн", + "rihanna": "рих+анна", + "scorpions": "ск+орпионс", + "skillet": "ск+иллет", + "sting": "стинг", # codespell:ignore sting +} + + +# --------------------------------------------------------------------------- +# Multi-word phrase replacements (applied before per-word substitution) +# --------------------------------------------------------------------------- + +PHRASE_REPLACEMENTS: tuple[tuple[str, str], ...] = ( + # Order: longer phrases first to avoid sub-string clashes + # (e.g. "red hot chili peppers" must beat any "red"-prefixed entry). + ("red hot chili peppers", "ред хот ч+или п+эпперс"), + ("imagine dragons", "имадж+ин др+агонс"), + ("arctic monkeys", "+арктик м+анкис"), + ("billie eilish", "б+илли +айлиш"), + ("black sabbath", "блэк с+аббат"), + ("foo fighters", "фу ф+айтерс"), + ("guns n roses", "ганз эн р+оузес"), + ("iron maiden", "+айрон м+эйден"), + ("lady gaga", "л+эди г+ага"), + ("led zeppelin", "лед цеппел+ин"), + ("linkin park", "л+инкин парк"), + ("pink floyd", "пинк фл+ойд"), + ("bruno mars", "бр+уно марс"), + ("daft punk", "дафт панк"), + ("ed sheeran", "эд ш+иран"), + ("taylor swift", "т+эйлор свифт"), +) diff --git a/music_assistant/providers/yandex_alice/url_helpers.py b/music_assistant/providers/yandex_alice/url_helpers.py new file mode 100644 index 0000000000..3646777d58 --- /dev/null +++ b/music_assistant/providers/yandex_alice/url_helpers.py @@ -0,0 +1,128 @@ +"""Public-HTTPS URL detection + validation helpers (#8). + +Phase 0 of the v1.2.0 UX overhaul confirmed that the obvious +``mass.streams.base_url`` and ``mass.webserver.base_url`` attributes +return MA's *internal* listen address (e.g. ``http://172.22.0.2:8095`` +inside Docker) — useless for Yandex's webhook because Yandex needs a +**public HTTPS URL** that resolves on the open internet. + +So autodetect only succeeds when the user (or their MA install) has set +a globally reachable HTTPS Base URL on the MA core webserver settings. +That's the rare case; in the typical case we return None and let the +form show a "fill this in" hint. + +We also export a fast HTTPS-validity check so the form can warn inline +when the user pastes ``http://`` or a private IP. +""" + +from __future__ import annotations + +import ipaddress +import logging +from typing import TYPE_CHECKING +from urllib.parse import urlparse + +if TYPE_CHECKING: + from music_assistant.mass import MusicAssistant + +_LOGGER = logging.getLogger(__name__) + +__all__ = [ + "is_public_https_url", + "try_detect_any_base_url", + "try_detect_public_https_url", + "validate_external_base_url", +] + + +def _is_private_or_loopback_host(host: str) -> bool: + """Return True if *host* is private/loopback/link-local — not reachable from Yandex.""" + if not host: + return True + if host.lower() in {"localhost", "127.0.0.1", "::1"}: + return True + try: + ip = ipaddress.ip_address(host) + except ValueError: + # Hostname (not IP) — assume public; DNS may still fail at request time + # but that's a different layer. + return False + return ip.is_private or ip.is_loopback or ip.is_link_local + + +def is_public_https_url(url: str) -> bool: + """Return True iff *url* is HTTPS and points to a non-private host. + + Used by: + + - autodetect: filters out the internal docker / loopback addresses MA's + ``streams.base_url`` typically returns; + - inline form warning: tells the user their pasted URL won't reach + Yandex without exposing diagnostics in the description text. + """ + if not url: + return False + parsed = urlparse(url.strip()) + if parsed.scheme.lower() != "https": + return False + host = (parsed.hostname or "").strip() + return bool(host) and not _is_private_or_loopback_host(host) + + +def validate_external_base_url(value: object) -> bool: + """Pre-flight ``ConfigEntry(validate=...)`` for the External Base URL field. + + Empty is allowed (user can still set up later); non-empty must be HTTPS + and not a private host. + """ + if not isinstance(value, str): + return False + s = value.strip() + if not s: + return True # empty is OK at form-load; auto-create dispatcher will refuse. + return is_public_https_url(s) + + +def try_detect_public_https_url(mass: MusicAssistant) -> str | None: + """Best-effort: return MA's public HTTPS webserver URL, or None. + + Reads ``mass.webserver.base_url`` — the URL where MA's HTTP/WS API + is hosted. The Yandex Dialogs webhook lives on this same server + (``/api/yandex_dialogs/webhook/``), so any URL Yandex needs + must point at the **webserver**, not at ``mass.streams.base_url`` + which is a separate audio streamserver port. + + Returns the URL only when :func:`is_public_https_url` says it's a + real public URL. In the typical Docker / HA Ingress / local-only + setup webserver returns ``http://:port`` and we + return None. + + Safe to call from ``get_config_entries`` — purely attribute access, + no config-controller locks. + """ + webserver_obj = getattr(mass, "webserver", None) + candidate = getattr(webserver_obj, "base_url", None) if webserver_obj else None + if isinstance(candidate, str) and is_public_https_url(candidate): + _LOGGER.debug("autodetected public HTTPS base URL: %r", candidate) + return candidate.strip().rstrip("/") + return None + + +def try_detect_any_base_url(mass: MusicAssistant) -> str | None: + """Best-effort: return *any* base URL of MA's webserver, public or not. + + Falls back from :func:`try_detect_public_https_url` so the user + sees a pre-filled value to edit even when MA is reachable only + via a private IP / HTTP. Like the public-only variant, this + intentionally only inspects ``mass.webserver.base_url`` — the + webhook lives on the webserver, not on the streamserver. + """ + public = try_detect_public_https_url(mass) + if public: + return public + webserver_obj = getattr(mass, "webserver", None) + candidate = getattr(webserver_obj, "base_url", None) if webserver_obj else None + if isinstance(candidate, str) and candidate.strip(): + _LOGGER.debug("autodetected webserver base URL (non-public): %r", candidate) + return candidate.strip().rstrip("/") + return None diff --git a/music_assistant/providers/yandex_alice/webhook_probe.py b/music_assistant/providers/yandex_alice/webhook_probe.py new file mode 100644 index 0000000000..31fb4b1131 --- /dev/null +++ b/music_assistant/providers/yandex_alice/webhook_probe.py @@ -0,0 +1,140 @@ +"""Outgoing webhook reachability probe (#11). + +Used by ``CONF_ACTION_TEST_WEBHOOK`` to verify the public path Yandex will +take to reach our webhook before the user spends a Device Flow + skill +registration only to discover their reverse proxy is misconfigured. Tests +the same chain Yandex itself uses: DNS → TLS → HTTP path → handler reply. + +Returns ``(ok, message)`` where the message is human-readable and ready +to drop straight into a ``ConfigEntry(LABEL)``. + +Yandex Dialogs custom-skill webhooks always receive a JSON envelope with +a populated ``session.skill_id``. Our handler in ``dialogs.py`` rejects +mismatched skill_ids with HTTP 401 — that's the "reachable but rejecting +unknown skill" signal we look for here. HTTP 200 means our handler ran +the request through to dispatch (won't happen with the sentinel skill_id +we send), HTTP 401 means reachable, HTTP 4xx/5xx outside that means +either reverse-proxy or our handler crashed. +""" + +from __future__ import annotations + +import json + +import aiohttp + +from .constants import DIALOG_WEBHOOK_BASE_PATH +from .url_helpers import is_public_https_url + +__all__ = ["probe_webhook_reachability"] + +_TEST_TIMEOUT_SECONDS = 5.0 +_SENTINEL_SKILL_ID = "00000000-0000-0000-0000-000000000000" +_SENTINEL_SESSION_ID = "ma-test-reachability" +_SENTINEL_USER_ID = "ma-test-user" + + +def _build_test_envelope() -> dict[str, object]: + """Minimal Yandex Dialogs custom-skill payload our handler will recognise.""" + return { + "session": { + "skill_id": _SENTINEL_SKILL_ID, + "session_id": _SENTINEL_SESSION_ID, + "user_id": _SENTINEL_USER_ID, + "new": True, + "user": {"user_id": _SENTINEL_USER_ID}, + }, + "request": { + "type": "SimpleUtterance", + "command": "ma reachability test", + "original_utterance": "ma reachability test", + }, + "version": "1.0", + } + + +def _validate_inputs(base_url: str, webhook_secret: str) -> tuple[bool, str] | None: + """Pre-network sanity check; returns failure tuple or None to continue. + + Rejects private / loopback / link-local hosts up front: Yandex's cloud + cannot reach them, and the probe itself would falsely report + ``localhost`` or ``192.168.x.x`` as "reachable" simply because the + request loops back to the same machine. + """ + base = (base_url or "").strip().rstrip("/") + if not base: + return False, "External base URL is empty — fill it in first." + if not is_public_https_url(base): + return ( + False, + f"External base URL must be a public HTTPS endpoint Yandex can " + f"reach over the internet (got: {base!r}). Private IPs, loopback " + f"and link-local addresses are rejected.", + ) + if not webhook_secret.strip(): + return False, "Webhook secret is empty — open the form once to auto-generate." + return None + + +async def probe_webhook_reachability(base_url: str, webhook_secret: str) -> tuple[bool, str]: + """POST a sentinel envelope and classify the response. + + Returns ``(reachable, message)``: + + - ``(True, "Webhook reachable (HTTP 401 — handler rejected unknown skill_id, expected)")`` + means the chain works end-to-end — Yandex traffic with the **real** + skill_id will be accepted. + - ``(False, "")`` for anything else (DNS, TLS, timeout, + connection refused, 5xx reverse-proxy, etc.). Each branch produces + a specific message so the user knows what to fix. + """ + pre = _validate_inputs(base_url, webhook_secret) + if pre is not None: + return pre + + base = base_url.strip().rstrip("/") + url = f"{base}{DIALOG_WEBHOOK_BASE_PATH}/{webhook_secret.strip()}" + payload = _build_test_envelope() + body = json.dumps(payload).encode("utf-8") + headers = {"Content-Type": "application/json"} + timeout = aiohttp.ClientTimeout(total=_TEST_TIMEOUT_SECONDS) + + try: + async with ( + aiohttp.ClientSession(timeout=timeout) as session, + session.post(url, data=body, headers=headers) as resp, + ): + status = resp.status + except aiohttp.ClientConnectorDNSError as exc: + return False, f"DNS resolution failed for {base}: {exc}" + except aiohttp.ClientSSLError as exc: + return False, f"TLS / certificate error: {exc}" + except aiohttp.ClientConnectorError as exc: + return False, f"Connection error (no route / refused): {exc}" + except TimeoutError: + return False, f"Timed out after {_TEST_TIMEOUT_SECONDS:.0f}s — no response." + except aiohttp.ClientError as exc: + return False, f"HTTP client error: {exc}" + + if status == 401: + return ( + True, + "Webhook reachable (HTTP 401 — handler rejected the sentinel skill_id, " + "which is the expected outcome).", + ) + if status == 200: + # Unlikely but possible: the user happened to register a skill with + # all-zero UUID, or our handler regressed and accepts anything. + return True, "Webhook reachable (HTTP 200)." + if status == 404: + return ( + False, + f"HTTP 404 — webhook secret in config does not match the URL " + f"({DIALOG_WEBHOOK_BASE_PATH}/...): no route registered. " + "Make sure 'Enable dialog skill' is on and the provider is loaded.", + ) + if 500 <= status < 600: + return False, f"HTTP {status} — reverse proxy or upstream returned a server error." + if 400 <= status < 500: + return False, f"HTTP {status} — handler rejected the request (not a network problem)." + return False, f"Unexpected HTTP status {status}." diff --git a/requirements_all.txt b/requirements_all.txt index 65aec4004a..c17b434753 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -88,6 +88,7 @@ uv>=0.8.0 websocket-client==1.9.0 wiim==0.1.4 xmltodict==1.0.4 +ya-dialogs-api==2.4.0 ya-passport-auth==1.3.0 yandex-music==3.0.0 ytmusicapi==1.11.5 diff --git a/tests/providers/yandex_alice/__init__.py b/tests/providers/yandex_alice/__init__.py new file mode 100644 index 0000000000..4308dec67c --- /dev/null +++ b/tests/providers/yandex_alice/__init__.py @@ -0,0 +1 @@ +"""Voice-skill provider test suite.""" diff --git a/tests/providers/yandex_alice/test_auth_session.py b/tests/providers/yandex_alice/test_auth_session.py new file mode 100644 index 0000000000..7863fc5eef --- /dev/null +++ b/tests/providers/yandex_alice/test_auth_session.py @@ -0,0 +1,146 @@ +# ruff: noqa: ARG001, PLC0415 +"""Tests for provider/auth_session.py — Passport session helpers. + +The cached authenticator is the join point between the provider's cached +``x_token`` and ya-dialogs-api's ``AuthenticatorCM`` contract: it must +populate Passport cookies via ``refresh_passport_cookies`` and refuse any +fallback to interactive Device Flow (mixing the two would let stale tokens +silently re-trigger user-code prompts mid-pipeline). +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +import pytest +from ya_passport_auth.exceptions import InvalidCredentialsError + +from music_assistant.providers.yandex_alice import auth_session + +if TYPE_CHECKING: + from ya_passport_auth import SecretStr + + +class TestMakeCachedAuthenticatorValidation: + """make_cached_authenticator rejects invalid input synchronously.""" + + def test_empty_token_raises_value_error(self) -> None: + """Empty x_token → ValueError before any network call is set up.""" + with pytest.raises(ValueError, match="empty"): + auth_session.make_cached_authenticator("") + + def test_returns_callable(self) -> None: + """Non-empty x_token → returns a callable (the AuthenticatorCM factory).""" + factory = auth_session.make_cached_authenticator("token-123") + assert callable(factory) + + +class TestCachedAuthenticatedSession: + """cached_authenticated_session yields an aiohttp session with cookies populated.""" + + @pytest.mark.asyncio + async def test_calls_refresh_passport_cookies(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Inside the CM, refresh_passport_cookies is invoked exactly once with the token.""" + captured_token: list[str] = [] + + async def _fake_refresh(self: Any, x_token: SecretStr) -> None: + captured_token.append(x_token.get_secret()) + + # Patch PassportClient.refresh_passport_cookies on the class to avoid touching network. + monkeypatch.setattr( + "ya_passport_auth.PassportClient.refresh_passport_cookies", _fake_refresh + ) + + async with auth_session.cached_authenticated_session("test-x-token") as session: + # Session is a real aiohttp ClientSession — has a closed property. + assert hasattr(session, "closed") + + assert captured_token == ["test-x-token"] + + @pytest.mark.asyncio + async def test_propagates_invalid_credentials_error( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Yandex 401 → InvalidCredentialsError propagates out, no Device Flow fallback.""" + + async def _failing_refresh(self: Any, x_token: SecretStr) -> None: + msg = "x_token rejected" + raise InvalidCredentialsError(msg) + + monkeypatch.setattr( + "ya_passport_auth.PassportClient.refresh_passport_cookies", _failing_refresh + ) + + with pytest.raises(InvalidCredentialsError, match="rejected"): + async with auth_session.cached_authenticated_session("expired-token"): + pytest.fail("body should not execute on auth failure") + + @pytest.mark.asyncio + async def test_empty_token_rejected_synchronously(self) -> None: + """Empty x_token → ValueError before any aiohttp session is created.""" + with pytest.raises(ValueError, match="empty"): + async with auth_session.cached_authenticated_session(""): + pytest.fail("CM body should never execute") + + +class TestPassportClientSession: + """passport_client_session yields a PassportClient and closes it on exit.""" + + @pytest.mark.asyncio + async def test_yields_client_and_closes(self, monkeypatch: pytest.MonkeyPatch) -> None: + """The contextmanager yields a PassportClient that gets closed on exit.""" + close_called: list[bool] = [] + + class _FakePassportClient: + def __init__(self) -> None: + pass + + async def close(self) -> None: + close_called.append(True) + + from collections.abc import AsyncIterator + from contextlib import asynccontextmanager + + @asynccontextmanager + async def _fake_create(config: Any = None) -> AsyncIterator[_FakePassportClient]: + client = _FakePassportClient() + try: + yield client + finally: + await client.close() + + # Patch PassportClient.create to yield our fake. + monkeypatch.setattr("ya_passport_auth.PassportClient.create", _fake_create) + + async with auth_session.passport_client_session() as client: + assert isinstance(client, _FakePassportClient) + + assert close_called == [True] + + +class TestMakeCachedAuthenticatorFactory: + """The factory returned by make_cached_authenticator yields cookie-loaded sessions.""" + + @pytest.mark.asyncio + async def test_factory_yields_authenticated_session( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Each call to the factory opens a fresh refresh_passport_cookies-loaded session.""" + refresh_calls: list[str] = [] + + async def _fake_refresh(self: Any, x_token: SecretStr) -> None: + refresh_calls.append(x_token.get_secret()) + + monkeypatch.setattr( + "ya_passport_auth.PassportClient.refresh_passport_cookies", _fake_refresh + ) + + factory = auth_session.make_cached_authenticator("good-token") + + # Two invocations open two independent CMs. + async with factory() as session1: + assert hasattr(session1, "closed") + async with factory() as session2: + assert hasattr(session2, "closed") + + assert refresh_calls == ["good-token", "good-token"] diff --git a/tests/providers/yandex_alice/test_auto_create.py b/tests/providers/yandex_alice/test_auto_create.py new file mode 100644 index 0000000000..78f686bb0b --- /dev/null +++ b/tests/providers/yandex_alice/test_auto_create.py @@ -0,0 +1,347 @@ +"""Tests for provider/auto_create.py — blocking single-click pipeline. + +The self-resuming Device Flow state machine is gone (see Phase C +refactor) — sign-in now lives in :mod:`provider.auth_page` as a +single blocking call. This module covers what's left: the duplicate +pre-check + the pipeline runner + the Recreate / Adopt resolution +helpers. +""" + +# ruff: noqa: D102 # tests don't need per-method docstrings. + +from __future__ import annotations + +from contextlib import asynccontextmanager +from typing import TYPE_CHECKING, Any +from unittest.mock import AsyncMock, MagicMock + +import pytest +from ya_dialogs_api import SkillCreationArtifacts, SkillCreationState +from ya_passport_auth.exceptions import InvalidCredentialsError + +from music_assistant.providers.yandex_alice import auto_create +from music_assistant.providers.yandex_alice.auto_create import ( + LocalAutoCreateStage, + adopt_existing_skill, + delete_existing_skill_then_recreate, + run_create_skill, +) + +if TYPE_CHECKING: + from collections.abc import AsyncIterator + +# --------------------------------------------------------------------------- +# Test helpers +# --------------------------------------------------------------------------- + + +@asynccontextmanager +async def _fake_session_cm( + fake_session: Any, +) -> AsyncIterator[Any]: + """Async context manager wrapping a fake aiohttp.ClientSession.""" + yield fake_session + + +def _make_session_factory(fake_session: Any) -> Any: + def _factory(_x_token: str) -> Any: + return _fake_session_cm(fake_session) + + return _factory + + +def _patch_cached_session(monkeypatch: pytest.MonkeyPatch, fake_session: Any) -> None: + """Replace ``cached_authenticated_session`` in auto_create.""" + monkeypatch.setattr( + auto_create, "cached_authenticated_session", _make_session_factory(fake_session) + ) + + +def _patch_creator( + monkeypatch: pytest.MonkeyPatch, + *, + list_existing_skills: list[dict[str, Any]] | None = None, + csrf: str = "csrf-tok", + delete_skill: AsyncMock | None = None, +) -> MagicMock: + """Stub DialogsSkillCreator constructor in auto_create.""" + creator = MagicMock() + creator.fetch_csrf = AsyncMock(return_value=csrf) + creator.list_existing_skills = AsyncMock(return_value=list_existing_skills or []) + if delete_skill is not None: + creator.delete_skill = delete_skill + monkeypatch.setattr( + auto_create, + "DialogsSkillCreator", + lambda *a, **kw: creator, # noqa: ARG005 + ) + return creator + + +# --------------------------------------------------------------------------- +# Duplicate pre-check +# --------------------------------------------------------------------------- + + +class TestPreCheckDuplicate: + """``_pre_check_duplicate`` must be best-effort and case-insensitive.""" + + @pytest.mark.asyncio + async def test_no_match_returns_none(self, monkeypatch: pytest.MonkeyPatch) -> None: + _patch_cached_session(monkeypatch, MagicMock()) + _patch_creator( + monkeypatch, + list_existing_skills=[ + {"id": "x", "appName": "Other"}, + {"id": "y", "name": "Yet Another"}, + ], + ) + result = await auto_create._pre_check_duplicate("tok", "Music Assistant") + assert result is None + + @pytest.mark.asyncio + async def test_case_insensitive_match(self, monkeypatch: pytest.MonkeyPatch) -> None: + _patch_cached_session(monkeypatch, MagicMock()) + _patch_creator( + monkeypatch, + list_existing_skills=[ + {"id": "sk-1", "appName": "music ASSISTANT"}, + ], + ) + result = await auto_create._pre_check_duplicate("tok", "Music Assistant") + assert result == ("sk-1", "music ASSISTANT") + + @pytest.mark.asyncio + async def test_blank_target_returns_none(self) -> None: + # No session/creator interaction needed — early return. + result = await auto_create._pre_check_duplicate("tok", " ") + assert result is None + + @pytest.mark.asyncio + async def test_swallows_exception(self, monkeypatch: pytest.MonkeyPatch) -> None: + @asynccontextmanager + async def _raising_factory(_x_token: str) -> AsyncIterator[Any]: + raise RuntimeError("network blip") + yield # type: ignore[unreachable] # pragma: no cover + + monkeypatch.setattr(auto_create, "cached_authenticated_session", _raising_factory) + result = await auto_create._pre_check_duplicate("tok", "Music Assistant") + assert result is None + + +# --------------------------------------------------------------------------- +# run_create_skill — happy path + duplicate + invalid token +# --------------------------------------------------------------------------- + + +class TestRunCreateSkill: + """End-to-end pipeline through ``run_create_skill``.""" + + @pytest.mark.asyncio + async def test_happy_path(self, monkeypatch: pytest.MonkeyPatch) -> None: + _patch_cached_session(monkeypatch, MagicMock()) + _patch_creator(monkeypatch, list_existing_skills=[]) + + async def _fake_auto_create_skill(**kwargs: Any) -> SkillCreationArtifacts: + return SkillCreationArtifacts( + state=SkillCreationState.DONE, + skill_id="sk-new", + last_known_name=str(kwargs["skill_name"]), + ) + + monkeypatch.setattr(auto_create, "auto_create_skill", _fake_auto_create_skill) + + outcome = await run_create_skill( + cached_x_token="tok", + skill_name="Music Assistant", + backend_uri="https://ma.example.com/api/yandex_dialogs/webhook/sec", + description="Voice control for Music Assistant", + structured_examples=None, + activation_phrases=None, + intents=[], + entities=[], + artifacts=SkillCreationArtifacts(), + ) + assert outcome.stage == LocalAutoCreateStage.DONE + assert outcome.artifacts.skill_id == "sk-new" + + @pytest.mark.asyncio + async def test_duplicate_detected_short_circuits(self, monkeypatch: pytest.MonkeyPatch) -> None: + _patch_cached_session(monkeypatch, MagicMock()) + _patch_creator( + monkeypatch, + list_existing_skills=[{"id": "sk-existing", "appName": "Music Assistant"}], + ) + + async def _should_not_run(**_kwargs: Any) -> SkillCreationArtifacts: + raise AssertionError("auto_create_skill should not run when duplicate detected") + + monkeypatch.setattr(auto_create, "auto_create_skill", _should_not_run) + + outcome = await run_create_skill( + cached_x_token="tok", + skill_name="Music Assistant", + backend_uri="https://ma.example.com/api/yandex_dialogs/webhook/sec", + description="d", + structured_examples=None, + activation_phrases=None, + intents=[], + entities=[], + artifacts=SkillCreationArtifacts(), + ) + assert outcome.stage == LocalAutoCreateStage.DUPLICATE_DETECTED + assert outcome.pending_duplicate_skill_id == "sk-existing" + assert outcome.pending_duplicate_skill_name == "Music Assistant" + + @pytest.mark.asyncio + async def test_invalid_credentials_clears_x_token( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + _patch_cached_session(monkeypatch, MagicMock()) + _patch_creator(monkeypatch, list_existing_skills=[]) + + async def _raise_invalid(**_kwargs: Any) -> SkillCreationArtifacts: + raise InvalidCredentialsError("expired") + + monkeypatch.setattr(auto_create, "auto_create_skill", _raise_invalid) + + outcome = await run_create_skill( + cached_x_token="tok", + skill_name="Music Assistant", + backend_uri="https://ma.example.com/api/yandex_dialogs/webhook/sec", + description="d", + structured_examples=None, + activation_phrases=None, + intents=[], + entities=[], + artifacts=SkillCreationArtifacts(), + ) + assert outcome.stage == LocalAutoCreateStage.FAILED + assert outcome.x_token == "" + + @pytest.mark.asyncio + async def test_failed_artifacts_become_failed_outcome( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + _patch_cached_session(monkeypatch, MagicMock()) + _patch_creator(monkeypatch, list_existing_skills=[]) + + async def _fail(**_kwargs: Any) -> SkillCreationArtifacts: + return SkillCreationArtifacts( + state=SkillCreationState.FAILED, + last_error="upload_logo: 502 Bad Gateway", + ) + + monkeypatch.setattr(auto_create, "auto_create_skill", _fail) + + outcome = await run_create_skill( + cached_x_token="tok", + skill_name="Music Assistant", + backend_uri="https://ma.example.com/api/yandex_dialogs/webhook/sec", + description="d", + structured_examples=None, + activation_phrases=None, + intents=[], + entities=[], + artifacts=SkillCreationArtifacts(), + ) + assert outcome.stage == LocalAutoCreateStage.FAILED + assert "Bad Gateway" in outcome.user_message + + +# --------------------------------------------------------------------------- +# Adopt / Recreate +# --------------------------------------------------------------------------- + + +class TestAdoptExisting: + """``adopt_existing_skill`` skips duplicate-check and create_app.""" + + @pytest.mark.asyncio + async def test_pre_positions_app_created(self, monkeypatch: pytest.MonkeyPatch) -> None: + _patch_cached_session(monkeypatch, MagicMock()) + captured: dict[str, Any] = {} + + async def _fake_pipeline(**kwargs: Any) -> SkillCreationArtifacts: + captured.update(kwargs) + return SkillCreationArtifacts( + state=SkillCreationState.DONE, + skill_id=kwargs["artifacts"].skill_id, + last_known_name=str(kwargs["skill_name"]), + ) + + monkeypatch.setattr(auto_create, "auto_create_skill", _fake_pipeline) + + outcome = await adopt_existing_skill( + cached_x_token="tok", + skill_name="Music Assistant", + backend_uri="https://ma.example.com/api/yandex_dialogs/webhook/sec", + description="d", + structured_examples=None, + activation_phrases=None, + intents=[], + entities=[], + existing_skill_id="sk-existing", + ) + assert outcome.stage == LocalAutoCreateStage.DONE + assert outcome.artifacts.skill_id == "sk-existing" + # Pipeline was invoked with state APP_CREATED, NOT NONE + assert captured["artifacts"].state == SkillCreationState.APP_CREATED + + +class TestDeleteThenRecreate: + """``delete_existing_skill_then_recreate`` deletes first, then runs fresh.""" + + @pytest.mark.asyncio + async def test_happy_path(self, monkeypatch: pytest.MonkeyPatch) -> None: + _patch_cached_session(monkeypatch, MagicMock()) + delete_mock = AsyncMock() + _patch_creator(monkeypatch, delete_skill=delete_mock) + + async def _fake_pipeline(**_kwargs: Any) -> SkillCreationArtifacts: + return SkillCreationArtifacts( + state=SkillCreationState.DONE, + skill_id="sk-fresh", + ) + + monkeypatch.setattr(auto_create, "auto_create_skill", _fake_pipeline) + + outcome = await delete_existing_skill_then_recreate( + cached_x_token="tok", + skill_name="Music Assistant", + backend_uri="https://ma.example.com/api/yandex_dialogs/webhook/sec", + description="d", + structured_examples=None, + activation_phrases=None, + intents=[], + entities=[], + existing_skill_id="sk-old", + ) + assert outcome.stage == LocalAutoCreateStage.DONE + assert outcome.artifacts.skill_id == "sk-fresh" + delete_mock.assert_awaited_once_with("csrf-tok", "sk-old") + + @pytest.mark.asyncio + async def test_delete_failure_short_circuits(self, monkeypatch: pytest.MonkeyPatch) -> None: + _patch_cached_session(monkeypatch, MagicMock()) + delete_mock = AsyncMock(side_effect=RuntimeError("boom")) + _patch_creator(monkeypatch, delete_skill=delete_mock) + + async def _should_not_run(**_kwargs: Any) -> SkillCreationArtifacts: + raise AssertionError("pipeline should not run after delete failure") + + monkeypatch.setattr(auto_create, "auto_create_skill", _should_not_run) + + outcome = await delete_existing_skill_then_recreate( + cached_x_token="tok", + skill_name="Music Assistant", + backend_uri="https://ma.example.com/api/yandex_dialogs/webhook/sec", + description="d", + structured_examples=None, + activation_phrases=None, + intents=[], + entities=[], + existing_skill_id="sk-old", + ) + assert outcome.stage == LocalAutoCreateStage.FAILED + assert "boom" in outcome.user_message diff --git a/tests/providers/yandex_alice/test_auto_update.py b/tests/providers/yandex_alice/test_auto_update.py new file mode 100644 index 0000000000..fccdbf0056 --- /dev/null +++ b/tests/providers/yandex_alice/test_auto_update.py @@ -0,0 +1,203 @@ +"""Tests for provider/auto_update.py — rename + drift-sync via cached x_token. + +Mocks ``provider.auto_update.auto_update_skill`` (the ya-dialogs-api +orchestrator) so we don't talk to ``dialogs.yandex.ru``. The auth path is +exercised separately in test_auth_session.py. +""" + +from __future__ import annotations + +from unittest.mock import AsyncMock + +import pytest +from ya_dialogs_api import SkillCreationArtifacts, SkillCreationState +from ya_passport_auth.exceptions import InvalidCredentialsError + +from music_assistant.providers.yandex_alice import auto_update +from music_assistant.providers.yandex_alice.auto_update import run_auto_update + + +def _patch_auto_update_skill( + monkeypatch: pytest.MonkeyPatch, + *, + return_artifacts: SkillCreationArtifacts, + side_effect: Exception | None = None, +) -> AsyncMock: + """Replace auto_update.auto_update_skill with an AsyncMock.""" + mock = AsyncMock(return_value=return_artifacts, side_effect=side_effect) + monkeypatch.setattr(auto_update, "auto_update_skill", mock) + return mock + + +class TestRunAutoUpdatePreconditions: + """Pre-network checks that fail fast with a clear last_error.""" + + @pytest.mark.asyncio + async def test_no_cached_x_token_returns_failed(self) -> None: + """Empty cached_x_token → FAILED before any library call.""" + result = await run_auto_update( + cached_x_token=None, + skill_name="X", + backend_uri="https://example.test/x", + description="d", + structured_examples=None, + activation_phrases=None, + intents=[], + entities=[], + artifacts=SkillCreationArtifacts(skill_id="sk-1"), + ) + assert result.artifacts.state == SkillCreationState.FAILED + assert result.x_token is None + assert "No cached authentication" in result.user_message + + @pytest.mark.asyncio + async def test_empty_cached_x_token_returns_failed(self) -> None: + """Empty string cached_x_token → same FAILED outcome as None.""" + result = await run_auto_update( + cached_x_token="", + skill_name="X", + backend_uri="https://example.test/x", + description="d", + structured_examples=None, + activation_phrases=None, + intents=[], + entities=[], + artifacts=SkillCreationArtifacts(skill_id="sk-1"), + ) + assert result.artifacts.state == SkillCreationState.FAILED + assert "No cached" in result.user_message + + @pytest.mark.asyncio + async def test_no_skill_id_returns_failed(self) -> None: + """Missing skill_id → FAILED with clear "create first" message.""" + result = await run_auto_update( + cached_x_token="token", + skill_name="X", + backend_uri="https://example.test/x", + description="d", + structured_examples=None, + activation_phrases=None, + intents=[], + entities=[], + artifacts=SkillCreationArtifacts(), # no skill_id + ) + assert result.artifacts.state == SkillCreationState.FAILED + assert "skill_id" in result.user_message + + +class TestRunAutoUpdateHappyPath: + """auto_update_skill returns DONE → user-facing success message.""" + + @pytest.mark.asyncio + async def test_returns_done_with_message(self, monkeypatch: pytest.MonkeyPatch) -> None: + """DONE outcome surfaces a confirmation message.""" + done = SkillCreationArtifacts( + state=SkillCreationState.DONE, + skill_id="sk-1", + last_known_name="New Name", + logo_id="lg-1", + ) + skill_mock = _patch_auto_update_skill(monkeypatch, return_artifacts=done) + + result = await run_auto_update( + cached_x_token="token", + skill_name="New Name", + backend_uri="https://example.test/x", + description="Description", + structured_examples=None, + activation_phrases=None, + intents=[], + entities=[], + artifacts=SkillCreationArtifacts(state=SkillCreationState.DONE, skill_id="sk-1"), + ) + + assert result.artifacts.state == SkillCreationState.DONE + assert result.artifacts.last_known_name == "New Name" + assert "updated" in result.user_message + assert "New Name" in result.user_message + skill_mock.assert_awaited_once() + + @pytest.mark.asyncio + async def test_passes_dialog_channel_and_metadata( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Library is called with channel='aliceSkill' + the supplied metadata.""" + done = SkillCreationArtifacts(state=SkillCreationState.DONE, skill_id="sk-1") + skill_mock = _patch_auto_update_skill(monkeypatch, return_artifacts=done) + + await run_auto_update( + cached_x_token="token", + skill_name="My Skill", + backend_uri="https://example.test/api/yandex_dialogs/webhook/sec", + description="Hello world", + structured_examples=[{"marker": "попроси"}], + activation_phrases=["My Skill"], + intents=[], + entities=[], + artifacts=SkillCreationArtifacts(state=SkillCreationState.DONE, skill_id="sk-1"), + ) + + assert skill_mock.await_args is not None + kwargs = skill_mock.await_args.kwargs + assert kwargs["channel"] == "aliceSkill" + assert kwargs["skill_name"] == "My Skill" + assert kwargs["description"] == "Hello world" + assert kwargs["structured_examples"] == [{"marker": "попроси"}] + assert kwargs["activation_phrases"] == ["My Skill"] + + +class TestRunAutoUpdateFailures: + """ya-dialogs-api / Passport failures are surfaced via FAILED artifacts.""" + + @pytest.mark.asyncio + async def test_library_failed_artifacts_surface(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Library returns FAILED → result.user_message uses last_error.""" + failed = SkillCreationArtifacts( + state=SkillCreationState.FAILED, + skill_id="sk-1", + last_error="Yandex rejected: validation failed", + ) + _patch_auto_update_skill(monkeypatch, return_artifacts=failed) + + result = await run_auto_update( + cached_x_token="token", + skill_name="X", + backend_uri="https://example.test/x", + description="d", + structured_examples=None, + activation_phrases=None, + intents=[], + entities=[], + artifacts=SkillCreationArtifacts(state=SkillCreationState.DONE, skill_id="sk-1"), + ) + + assert result.artifacts.state == SkillCreationState.FAILED + assert "validation failed" in result.user_message + assert result.x_token is None # No token clear unless 401 + + @pytest.mark.asyncio + async def test_passport_invalid_credentials_clears_token( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """InvalidCredentialsError from refresh_passport_cookies → x_token=''.""" + _patch_auto_update_skill( + monkeypatch, + return_artifacts=SkillCreationArtifacts(), + side_effect=InvalidCredentialsError("expired"), + ) + + result = await run_auto_update( + cached_x_token="stale-token", + skill_name="X", + backend_uri="https://example.test/x", + description="d", + structured_examples=None, + activation_phrases=None, + intents=[], + entities=[], + artifacts=SkillCreationArtifacts(state=SkillCreationState.DONE, skill_id="sk-1"), + ) + + assert result.artifacts.state == SkillCreationState.FAILED + assert result.x_token == "" # Signal to dispatcher: clear cache + assert "expired" in result.user_message diff --git a/tests/providers/yandex_alice/test_dialog_skill_meta.py b/tests/providers/yandex_alice/test_dialog_skill_meta.py new file mode 100644 index 0000000000..db9760b93f --- /dev/null +++ b/tests/providers/yandex_alice/test_dialog_skill_meta.py @@ -0,0 +1,109 @@ +"""Tests for provider/dialog_skill_meta.py — pure helper functions.""" + +from __future__ import annotations + +import pytest + +from music_assistant.providers.yandex_alice.dialog_skill_meta import ( + build_activation_phrases, + build_backend_uri, + build_skill_description, + build_structured_examples, +) + + +class TestBuildBackendUri: + """build_backend_uri composes the public webhook URL Yandex must call.""" + + def test_happy_path(self) -> None: + """Https + secret → standard /api/yandex_dialogs/webhook/ URL.""" + url = build_backend_uri("https://ma.example.com", "abc123") + assert url == "https://ma.example.com/api/yandex_dialogs/webhook/abc123" + + def test_strips_trailing_slash(self) -> None: + """Trailing slash on base_url is removed before assembly.""" + url = build_backend_uri("https://ma.example.com/", "abc123") + assert url == "https://ma.example.com/api/yandex_dialogs/webhook/abc123" + + def test_rejects_http(self) -> None: + """Plain http:// rejected — Yandex requires HTTPS for skill webhooks.""" + with pytest.raises(ValueError, match="HTTPS"): + build_backend_uri("http://ma.example.com", "secret") + + def test_rejects_empty_base_url(self) -> None: + """Empty base URL → ValueError before any assembly.""" + with pytest.raises(ValueError, match="empty"): + build_backend_uri("", "secret") + + def test_rejects_whitespace_base_url(self) -> None: + """Whitespace-only base URL is treated as empty.""" + with pytest.raises(ValueError, match="empty"): + build_backend_uri(" ", "secret") + + def test_rejects_empty_secret(self) -> None: + """Empty webhook secret → ValueError; no half-baked URL.""" + with pytest.raises(ValueError, match="secret"): + build_backend_uri("https://ma.example.com", "") + + +class TestBuildSkillDescription: + """build_skill_description always returns a non-empty Russian string.""" + + def test_returns_non_empty(self) -> None: + """Default description is non-empty (Yandex rejects empty descriptions).""" + desc = build_skill_description("Music Assistant") + assert desc.strip() + assert len(desc) > 30 + + def test_embeds_skill_name(self) -> None: + """Skill name appears in the description so catalog listing is self-contained.""" + desc = build_skill_description("Моя Колонка") + assert "Моя Колонка" in desc + + def test_falls_back_on_empty_name(self) -> None: + """Empty / whitespace skill name falls back to a generic default.""" + desc_empty = build_skill_description("") + desc_ws = build_skill_description(" ") + assert "Music Assistant" in desc_empty + assert "Music Assistant" in desc_ws + + +class TestBuildActivationPhrases: + """build_activation_phrases returns a single-element list with the skill name.""" + + def test_returns_skill_name(self) -> None: + """Default activation list = [skill_name].""" + assert build_activation_phrases("My Skill") == ["My Skill"] + + def test_strips_whitespace(self) -> None: + """Surrounding whitespace is stripped from the activation phrase.""" + assert build_activation_phrases(" Test ") == ["Test"] + + def test_falls_back_on_empty(self) -> None: + """Empty input falls back to default 'Music Assistant'.""" + assert build_activation_phrases("") == ["Music Assistant"] + + +class TestBuildStructuredExamples: + """build_structured_examples returns the dict shape Yandex moderators expect.""" + + def test_returns_three_examples(self) -> None: + """Default set has playback / transport / multi-room intents.""" + examples = build_structured_examples("Music Assistant") + assert len(examples) == 3 + + def test_each_example_has_required_fields(self) -> None: + """Each entry has marker / activationPhrase / request / is_valid (Yandex-required).""" + examples = build_structured_examples("Music Assistant") + for ex in examples: + assert ex["marker"] == "попроси" + assert ex["activationPhrase"] == "Music Assistant" + assert isinstance(ex["request"], str) + assert ex["request"] + assert ex["is_valid"] is True + + def test_uses_provided_skill_name(self) -> None: + """ActivationPhrase mirrors the supplied skill_name.""" + examples = build_structured_examples("Моя Колонка") + for ex in examples: + assert ex["activationPhrase"] == "Моя Колонка" diff --git a/tests/providers/yandex_alice/test_dialog_skill_meta_v12.py b/tests/providers/yandex_alice/test_dialog_skill_meta_v12.py new file mode 100644 index 0000000000..41f5c020db --- /dev/null +++ b/tests/providers/yandex_alice/test_dialog_skill_meta_v12.py @@ -0,0 +1,54 @@ +"""v1.2.0 #9 — validate_skill_name: pre-flight Yandex skill name rules.""" + +from __future__ import annotations + +import pytest + +from music_assistant.providers.yandex_alice.dialog_skill_meta import validate_skill_name + + +class TestValidateSkillName: + """Yandex enforces ≥ 2 words + 2-64 chars; we check both up front.""" + + @pytest.mark.parametrize( + "value", + [ + "Music Assistant", + "Музыкальный Ассистент", + "Домашняя Музыка", + "My Cool Skill", + "a b", # 3 chars, exactly 2 words — boundary OK + "X" * 30 + " " + "Y" * 30, # 61 chars total, 2 words + ], + ) + def test_valid_names_accepted(self, value: str) -> None: + """Two-or-more words within 2..64 chars → pass.""" + assert validate_skill_name(value) is True + + @pytest.mark.parametrize( + "value", + [ + "", + " ", + "Single", + "Singleword", + "OneWordOnlyButLong", + ], + ) + def test_single_word_rejected(self, value: str) -> None: + """0 or 1 word → fail (Yandex requires ≥ 2).""" + assert validate_skill_name(value) is False + + def test_too_short(self) -> None: + """1 char even with two 'words' → fail (under DIALOG_NAME_MIN_LEN).""" + assert validate_skill_name("a") is False + + def test_too_long(self) -> None: + """> 64 chars → fail.""" + assert validate_skill_name("X" * 33 + " " + "Y" * 33) is False # 67 + + def test_non_string_rejected(self) -> None: + """None / int / list → fail (frontend may hand us anything).""" + assert validate_skill_name(None) is False + assert validate_skill_name(42) is False + assert validate_skill_name(["Music", "Assistant"]) is False diff --git a/tests/providers/yandex_alice/test_dialogs.py b/tests/providers/yandex_alice/test_dialogs.py new file mode 100644 index 0000000000..fe322a25cb --- /dev/null +++ b/tests/providers/yandex_alice/test_dialogs.py @@ -0,0 +1,2224 @@ +# ruff: noqa: RUF001, RUF002 +"""Tests for provider/dialogs.py — webhook handler.""" + +from __future__ import annotations + +import asyncio +import json +import time +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any +from unittest.mock import AsyncMock, MagicMock + +import pytest +from aiohttp.test_utils import make_mocked_request +from music_assistant_models.enums import QueueOption, RepeatMode + +from music_assistant.providers.yandex_alice.dialogs import ( + _STATE_CACHE_TTL_SEC, + DialogsWebhookHandler, + _tts_for, +) + +if TYPE_CHECKING: + from aiohttp import web + + +@dataclass +class MockPlayer: + """Minimal player stub for webhook handler tests.""" + + player_id: str = "p1" + name: str = "Кухня" + available: bool = True + enabled: bool = True + synced_to: str | None = None + supported_features: set[str] = field(default_factory=set) + powered: bool = True + + +class _MockPlayers: + def __init__(self, players: list[MockPlayer]) -> None: + """Initialise with a fixed player list.""" + self._players = players + self.cmd_power = AsyncMock() + + def all_players(self) -> list[MockPlayer]: + """Return all players.""" + return list(self._players) + + def get_player(self, player_id: str) -> MockPlayer | None: + """Return player by id or None.""" + return next((p for p in self._players if p.player_id == player_id), None) + + +def _make_mass(players: list[MockPlayer], search_track: object = None) -> MagicMock: + mass = MagicMock() + mass.players = _MockPlayers(players) + mass.music = MagicMock() + + @dataclass + class _SearchResults: + artists: list[object] = field(default_factory=list) + albums: list[object] = field(default_factory=list) + tracks: list[object] = field(default_factory=list) + playlists: list[object] = field(default_factory=list) + + if search_track is not None: + mass.music.search = AsyncMock(return_value=_SearchResults(tracks=[search_track])) + else: + mass.music.search = AsyncMock(return_value=_SearchResults()) + + mass.music_providers = [] + mass.providers = [] + mass.player_queues = MagicMock() + mass.player_queues.play_media = AsyncMock() + mass.webserver = MagicMock() + mass.webserver.register_dynamic_route = MagicMock(return_value=lambda: None) + # mass.create_task must actually schedule the coroutine so fire-and-forget + # tasks run when the test awaits asyncio.sleep(0). + mass.create_task = lambda coro, **_kw: asyncio.ensure_future(coro) + return mass + + +_TEST_SECRET = "topsecret" + + +def _build_request(body: dict[str, Any], secret: str = _TEST_SECRET) -> web.Request: + """Build a mocked aiohttp Request that returns the given JSON body.""" + req = make_mocked_request( + "POST", + f"/api/yandex_dialogs/webhook/{secret}", + match_info={"secret": secret}, + ) + req.json = AsyncMock(return_value=body) # type: ignore[method-assign] + return req + + +def _response_body(resp: web.Response) -> dict[str, Any]: + """Decode a web.json_response body into a dict for assertions.""" + decoded: dict[str, Any] = json.loads(resp.body) # type: ignore[arg-type] + return decoded + + +@pytest.mark.asyncio +class TestDialogsWebhookHandler: + """End-to-end tests for the webhook entry point.""" + + def _make_handler(self, mass: MagicMock, **kwargs: object) -> DialogsWebhookHandler: + """Build a handler with sensible test defaults.""" + return DialogsWebhookHandler( + mass, + skill_id=str(kwargs.get("skill_id", "skill-uuid-1")), + webhook_secret=str(kwargs.get("webhook_secret", "topsecret")), + exposed_player_ids=kwargs.get("exposed_player_ids"), # type: ignore[arg-type] + ) + + async def test_register_routes_calls_mass_webserver(self) -> None: + """register_routes calls register_dynamic_route with the correct URL.""" + mass = _make_mass([]) + handler = self._make_handler(mass) + handler.register_routes() + mass.webserver.register_dynamic_route.assert_called_once() + path_arg = mass.webserver.register_dynamic_route.call_args[0][0] + assert path_arg == "/api/yandex_dialogs/webhook/topsecret" + + async def test_unregister_routes(self) -> None: + """unregister_routes calls the unregister callback returned by register_dynamic_route.""" + mass = _make_mass([]) + unregister = MagicMock() + mass.webserver.register_dynamic_route = MagicMock(return_value=unregister) + handler = self._make_handler(mass) + handler.register_routes() + handler.unregister_routes() + unregister.assert_called_once() + + async def test_secret_mismatch_returns_404(self) -> None: + """Webhook request with wrong URL secret is rejected with 404.""" + mass = _make_mass([]) + handler = self._make_handler(mass) + body = { + "session": {"skill_id": "skill-uuid-1", "session_id": "s1"}, + "request": {"command": "включи Metallica"}, + } + req = make_mocked_request( + "POST", + "/api/yandex_dialogs/webhook/wrong", + match_info={"secret": "wrong"}, + ) + req.json = AsyncMock(return_value=body) # type: ignore[method-assign] + resp = await handler._handle_webhook(req) + assert resp.status == 404 + + async def test_secret_parsed_from_path_when_no_match_info(self) -> None: + """Cover the production secret-from-path fallback in `_handle_webhook`. + + Production registers an exact route (no `{secret}` variable), so + `request.match_info` is empty and the handler parses the secret + from `request.path`. This test passes `match_info={}` to exercise + that branch. + """ + track = MagicMock(uri="library://track/123", spec_set=["uri"]) + mass = _make_mass([MockPlayer(player_id="p1", name="Кухня")], search_track=track) + handler = self._make_handler(mass) + body = { + "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, + "request": {"command": "включи Metallica на кухне"}, + } + req = make_mocked_request( + "POST", + f"/api/yandex_dialogs/webhook/{_TEST_SECRET}", + match_info={}, + ) + req.json = AsyncMock(return_value=body) # type: ignore[method-assign] + resp = await handler._handle_webhook(req) + # If path parsing works, secret matches and we reach the play branch (200). + assert resp.status == 200 + + async def test_skill_id_mismatch_returns_401(self) -> None: + """Payload with wrong skill_id is rejected with 401.""" + mass = _make_mass([]) + handler = self._make_handler(mass) + body = { + "session": {"skill_id": "different-skill", "session_id": "s1"}, + "request": {"command": "включи Metallica"}, + } + resp = await handler._handle_webhook(_build_request(body)) + assert resp.status == 401 + + async def test_session_new_empty_command_greets(self) -> None: + """New session with empty command returns 200 greeting without playing.""" + mass = _make_mass([]) + handler = self._make_handler(mass) + body = { + "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": True}, + "request": {"command": ""}, + } + resp = await handler._handle_webhook(_build_request(body)) + assert resp.status == 200 + mass.player_queues.play_media.assert_not_awaited() + + async def test_unknown_player_asks_for_clarification(self) -> None: + """Command mentioning an unknown player returns 200 without playing.""" + mass = _make_mass([MockPlayer(player_id="p1", name="Спальня")]) + handler = self._make_handler(mass) + body = { + "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, + "request": {"command": "включи Metallica на Кухне"}, + } + resp = await handler._handle_webhook(_build_request(body)) + assert resp.status == 200 + mass.player_queues.play_media.assert_not_awaited() + + async def test_no_results_says_not_found(self) -> None: + """No search results returns 200 without playing.""" + mass = _make_mass([MockPlayer(player_id="p1", name="Кухня")]) + handler = self._make_handler(mass) + body = { + "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, + "request": {"command": "включи nonexistent на кухне"}, + } + resp = await handler._handle_webhook(_build_request(body)) + assert resp.status == 200 + mass.player_queues.play_media.assert_not_awaited() + + async def test_full_happy_path_starts_play_media(self) -> None: + """Resolved track triggers play_media on the correct player.""" + track = MagicMock(uri="library://track/123", spec_set=["uri"]) + mass = _make_mass([MockPlayer(player_id="p1", name="Кухня")], search_track=track) + handler = self._make_handler(mass) + body = { + "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, + "request": {"command": "включи Metallica на кухне"}, + } + resp = await handler._handle_webhook(_build_request(body)) + assert resp.status == 200 + # Allow the fire-and-forget task to run. + await asyncio.sleep(0) + mass.player_queues.play_media.assert_awaited_once() + call_kwargs = mass.player_queues.play_media.call_args.kwargs + assert call_kwargs["queue_id"] == "p1" + assert call_kwargs["media"] is track + + async def test_dangerous_context_log_redacts_command( + self, caplog: pytest.LogCaptureFixture + ) -> None: + """Flagged content must NOT leak into DEBUG logs even at operator's request. + + Copilot review on PR #18: the structured "Webhook recv" line was + emitting `cmd=...` and `raw=...` *before* the dangerous_context + refusal branch, so flagged phrases ended up in + $HOME/.musicassistant/musicassistant.log when DEBUG was on. + """ + mass = _make_mass([MockPlayer(player_id="p1", name="Кухня")]) + handler = self._make_handler(mass) + sensitive = "очень плохая фраза которую яндекс пометил" + body = { + "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, + "request": { + "command": sensitive, + "original_utterance": sensitive, + "markup": {"dangerous_context": True}, + }, + } + with caplog.at_level("DEBUG", logger="music_assistant.providers.yandex_alice.dialogs"): + await handler._handle_webhook(_build_request(body)) + # Flagged content must not be present in any log record. + for record in caplog.records: + assert sensitive not in record.getMessage() + # Confirm we DID emit the structured log line (with the redaction marker) + # — silent skip would also satisfy the negative assertion above and is + # not what we want. + assert any("redacted: dangerous_context" in r.getMessage() for r in caplog.records) + + async def test_unexpected_inner_exception_returns_graceful_fallback(self) -> None: + """An unexpected raise from inner dispatch surfaces as a Russian fallback, not HTTP 500. + + Flagged in the upstream PR review (#3843, @chrisuthe): only the + ``request.json()`` parse was guarded; everything afterwards + (parsers, search, dispatch) bubbled to aiohttp → HTTP 500 → + Alice silence. The handler now wraps the post-auth body in + ``try / except`` to keep the user-facing response intact. + """ + # Make `mass.players.all_players` raise — this triggers inside the + # play-resolve path so the exception happens DEEP in dispatch, + # well past the auth gate and parser pass. + mass = _make_mass([MockPlayer(player_id="p1", name="Кухня")]) + mass.players.all_players = MagicMock(side_effect=RuntimeError("boom")) + handler = self._make_handler(mass) + body = { + "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, + "request": {"command": "включи Metallica на кухне"}, + } + resp = await handler._handle_webhook(_build_request(body)) + # Critical: 200 OK with a Russian fallback, NOT HTTP 500. + assert resp.status == 200 + body_out = _response_body(resp) + assert "что-то пошло не так" in body_out["response"]["text"].lower() + # Session continues so the user can re-issue a command. + assert body_out["response"]["end_session"] is False + + +@pytest.mark.asyncio +class TestSuggestionButtons: + """Phase 1 / P1.3: play- and control-success responses surface follow-up buttons on screen.""" + + def _make_handler(self, mass: MagicMock) -> DialogsWebhookHandler: + return DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) + + async def test_play_success_emits_buttons_on_screen(self) -> None: + """Play-success on screened surface includes Следующая/Пауза/Громче/Тише buttons.""" + track = MagicMock(uri="library://track/1", spec_set=["uri"]) + mass = _make_mass([MockPlayer(player_id="p1", name="Кухня")], search_track=track) + handler = self._make_handler(mass) + body = { + "meta": {"interfaces": {"screen": {}}}, + "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, + "request": {"command": "включи Metallica на кухне"}, + } + resp = await handler._handle_webhook(_build_request(body)) + await asyncio.sleep(0) + assert resp.status == 200 + body_out = _response_body(resp) + button_titles = [b["title"] for b in body_out["response"]["buttons"]] + assert button_titles == ["Следующая", "Пауза", "Громче", "Тише"] + + async def test_play_success_no_buttons_voice_only(self) -> None: + """Play-success on a voice-only surface omits buttons entirely.""" + track = MagicMock(uri="library://track/1", spec_set=["uri"]) + mass = _make_mass([MockPlayer(player_id="p1", name="Кухня")], search_track=track) + handler = self._make_handler(mass) + body = { + # No meta.interfaces — voice-only (Yandex Mini etc.) + "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, + "request": {"command": "включи Metallica на кухне"}, + } + resp = await handler._handle_webhook(_build_request(body)) + await asyncio.sleep(0) + body_out = _response_body(resp) + assert "buttons" not in body_out["response"] + + async def test_control_success_emits_buttons_on_screen(self) -> None: + """Control-success (e.g. pause) on screened surface includes the same buttons.""" + mass = _make_mass([MockPlayer(player_id="p1", name="Кухня")]) + mass.player_queues.pause = AsyncMock() + handler = self._make_handler(mass) + body = { + "meta": {"interfaces": {"screen": {}}}, + "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, + "request": { + "command": "пауза на кухне", + "nlu": {"intents": {"control.pause": {}}}, + }, + } + resp = await handler._handle_webhook(_build_request(body)) + await asyncio.sleep(0) + body_out = _response_body(resp) + button_titles = [b["title"] for b in body_out["response"]["buttons"]] + assert button_titles == ["Следующая", "Пауза", "Громче", "Тише"] + + +@pytest.mark.asyncio +class TestPlatformIntentDispatch: + """Phase 2: request.nlu.intents pre-classification takes precedence over regex.""" + + def _handler(self, mass: MagicMock) -> DialogsWebhookHandler: + return DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) + + async def test_control_pause_via_platform_intent(self) -> None: + """`request.nlu.intents['control.pause']` → ParsedControl(action='pause').""" + mass = _make_mass([MockPlayer(player_id="p1", name="Кухня")]) + mass.player_queues.pause = AsyncMock() + handler = self._handler(mass) + body = { + "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, + "request": { + "command": "пауза", + "nlu": {"intents": {"control.pause": {}}}, + }, + } + resp = await handler._handle_webhook(_build_request(body)) + await asyncio.sleep(0) + assert resp.status == 200 + # Pause was dispatched even though command="пауза" would also match regex. + mass.player_queues.pause.assert_awaited_once_with("p1") + + async def test_play_my_wave_via_platform_intent(self) -> None: + """`request.nlu.intents['play.my_wave']` → ParsedCommand(kind='my_wave').""" + mass = _make_mass([MockPlayer(player_id="p1", name="Кухня")]) + # _resolve_my_wave returns None when yandex_music provider absent → handler + # surfaces "не нашёл такую музыку" but still went through the my_wave path. + handler = self._handler(mass) + body = { + "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, + "request": { + # `command` is the noisy raw — wouldn't normally classify as my_wave, + # but the platform intent overrides it. + "command": "что-то совсем другое", + "nlu": {"intents": {"play.my_wave": {}}}, + }, + } + resp = await handler._handle_webhook(_build_request(body)) + await asyncio.sleep(0) + body_out = _response_body(resp) + # Platform path responded — it didn't fall through to "не понял". + # When yandex_music isn't available, the response is a graceful + # "не нашёл такую музыку" rather than "не понял команду". + assert "не понял" not in body_out["response"]["text"].lower() + + async def test_unrecognised_intent_falls_back_to_regex_for_transfer(self) -> None: + """Unknown form_name + a transfer phrase → regex parse_control catches it. + + After v1.4.0 ``parse_control`` only handles ``transfer`` (the + per-user dynamic enum that can't fit a static intent grammar). + This is the documented fallback path — the platform-side + intent is unrecognised, so we drop into regex, and only the + transfer family is recognised there. + """ + mass = _make_mass( + [ + MockPlayer(player_id="p1", name="Кухня"), + MockPlayer(player_id="p2", name="Спальня"), + ] + ) + mass.player_queues.transfer_queue = AsyncMock() + handler = self._handler(mass) + body = { + "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, + "request": { + "command": "переведи на спальню", + "nlu": {"intents": {"unknown.intent": {}}}, + }, + "state": {"session": {"last_player_id": "p1"}}, + } + await handler._handle_webhook(_build_request(body)) + await asyncio.sleep(0) + mass.player_queues.transfer_queue.assert_awaited_once() + + async def test_empty_intents_block_falls_through_to_play_parser(self) -> None: + """Empty ``intents={}`` + non-transfer phrase → play parser runs. + + After v1.4.0 the regex control parser only recognises + ``transfer``; a phrase like "следующая" with no platform-side + match no longer dispatches as control. The handler is expected + to treat it as a graceful fallback (the play search path + produces "не нашёл такую музыку: ...") rather than crashing. + """ + mass = _make_mass([MockPlayer(player_id="p1", name="Кухня")]) + mass.player_queues.next = AsyncMock() + handler = self._handler(mass) + body = { + "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, + "request": { + "command": "следующая", + "nlu": {"intents": {}}, + }, + } + resp = await handler._handle_webhook(_build_request(body)) + await asyncio.sleep(0) + # Control wasn't dispatched (no platform intent + regex doesn't cover it). + mass.player_queues.next.assert_not_awaited() + # Handler responded gracefully (HTTP 200). + assert resp.status == 200 + + +@pytest.mark.asyncio +class TestBuiltInIntents: + """Phase 2 follow-up: YANDEX.REJECT / YANDEX.HELP handling in pending flows.""" + + def _handler(self, mass: MagicMock) -> DialogsWebhookHandler: + return DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) + + async def test_reject_in_pending_disambiguation_cancels(self) -> None: + """YANDEX.REJECT clears pending_command and ends session with confirmation.""" + mass = _make_mass([MockPlayer(player_id="p1", name="Кухня")]) + handler = self._handler(mass) + body = { + "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, + "request": { + "command": "отмена", + "nlu": {"intents": {"YANDEX.REJECT": {}}}, + }, + "state": { + "session": { + "pending_command": { + "kind": "search", + "query": "metallica", + "radio_mode": True, + "candidate_ids": ["p1", "p2"], + } + } + }, + } + resp = await handler._handle_webhook(_build_request(body)) + body_out = _response_body(resp) + assert body_out["response"]["end_session"] is True + assert "отменил" in body_out["response"]["text"].lower() + # pending_command cleared from session_state on response. + assert "pending_command" not in body_out["session_state"] + mass.player_queues.play_media.assert_not_awaited() + + async def test_reject_in_awaiting_query_cancels(self) -> None: + """YANDEX.REJECT in slot-elicit ('Что включить?') also exits cleanly.""" + mass = _make_mass([MockPlayer(player_id="p1", name="Кухня")]) + handler = self._handler(mass) + body = { + "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, + "request": { + "command": "неважно", + "nlu": {"intents": {"YANDEX.REJECT": {}}}, + }, + "state": {"session": {"awaiting_query": True}}, + } + resp = await handler._handle_webhook(_build_request(body)) + body_out = _response_body(resp) + assert body_out["response"]["end_session"] is True + assert "awaiting_query" not in body_out["session_state"] + + async def test_reject_with_no_pending_falls_through(self) -> None: + """YANDEX.REJECT outside of any prompt context → falls through to normal flow. + + The intent isn't a free-standing 'cancel app' signal — the user + could just be talking. If parse_command also can't make sense of + 'отмена', it lands as a normal "не нашёл" search response. + """ + mass = _make_mass([MockPlayer(player_id="p1", name="Кухня")]) + handler = self._handler(mass) + body = { + "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, + "request": { + "command": "отмена", + "nlu": {"intents": {"YANDEX.REJECT": {}}}, + }, + } + resp = await handler._handle_webhook(_build_request(body)) + body_out = _response_body(resp) + # NOT the cancel response — handler fell through to play-search. + assert "отменил" not in body_out["response"]["text"].lower() + + async def test_help_in_pending_emits_disambiguation_hint(self) -> None: + """YANDEX.HELP during disambiguation tells the user how to answer.""" + mass = _make_mass([MockPlayer(player_id="p1", name="Кухня")]) + handler = self._handler(mass) + body = { + "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, + "request": { + "command": "помоги", + "nlu": {"intents": {"YANDEX.HELP": {}}}, + }, + "state": { + "session": { + "pending_command": { + "kind": "search", + "query": "metallica", + "radio_mode": True, + "candidate_ids": ["p1", "p2"], + } + } + }, + } + resp = await handler._handle_webhook(_build_request(body)) + body_out = _response_body(resp) + assert body_out["response"]["end_session"] is False + assert "колонки" in body_out["response"]["text"].lower() + + async def test_help_in_awaiting_emits_query_hint(self) -> None: + """YANDEX.HELP during slot-elicit suggests example queries.""" + mass = _make_mass([MockPlayer(player_id="p1", name="Кухня")]) + handler = self._handler(mass) + body = { + "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, + "request": { + "command": "что я могу", + "nlu": {"intents": {"YANDEX.HELP": {}}}, + }, + "state": {"session": {"awaiting_query": True}}, + } + resp = await handler._handle_webhook(_build_request(body)) + body_out = _response_body(resp) + assert body_out["response"]["end_session"] is False + assert "артиста" in body_out["response"]["text"].lower() + + async def test_help_clean_state_emits_generic_hint(self) -> None: + """YANDEX.HELP with no in-flight prompt → generic example.""" + mass = _make_mass([MockPlayer(player_id="p1", name="Кухня")]) + handler = self._handler(mass) + body = { + "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, + "request": { + "command": "помощь", + "nlu": {"intents": {"YANDEX.HELP": {}}}, + }, + } + resp = await handler._handle_webhook(_build_request(body)) + body_out = _response_body(resp) + assert "включи рок" in body_out["response"]["text"].lower() + + +@pytest.mark.asyncio +class TestVoiceContinuation: + """Phase 1 / P1.4: opt-in `end_session=false` after play / control success.""" + + async def test_play_success_ends_session_by_default(self) -> None: + """Without the toggle, play-success closes the session (today's UX).""" + track = MagicMock(uri="library://track/1", spec_set=["uri"]) + mass = _make_mass([MockPlayer(player_id="p1", name="Кухня")], search_track=track) + handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) + body = { + "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, + "request": {"command": "включи Metallica на кухне"}, + } + resp = await handler._handle_webhook(_build_request(body)) + await asyncio.sleep(0) + body_out = _response_body(resp) + assert body_out["response"]["end_session"] is True + + async def test_play_success_keeps_session_open_when_continuation_on(self) -> None: + """With continuation on, play-success keeps the conversation alive.""" + track = MagicMock(uri="library://track/1", spec_set=["uri"]) + mass = _make_mass([MockPlayer(player_id="p1", name="Кухня")], search_track=track) + handler = DialogsWebhookHandler( + mass, + skill_id="skill-uuid-1", + webhook_secret=_TEST_SECRET, + voice_continuation=True, + ) + body = { + "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, + "request": {"command": "включи Metallica на кухне"}, + } + resp = await handler._handle_webhook(_build_request(body)) + await asyncio.sleep(0) + body_out = _response_body(resp) + assert body_out["response"]["end_session"] is False + + async def test_control_success_keeps_session_open_when_continuation_on(self) -> None: + """Continuation also applies to control-success (pause / volume / etc.).""" + mass = _make_mass([MockPlayer(player_id="p1", name="Кухня")]) + mass.player_queues.pause = AsyncMock() + handler = DialogsWebhookHandler( + mass, + skill_id="skill-uuid-1", + webhook_secret=_TEST_SECRET, + voice_continuation=True, + ) + body = { + "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, + "request": { + "command": "пауза на кухне", + "nlu": {"intents": {"control.pause": {}}}, + }, + } + resp = await handler._handle_webhook(_build_request(body)) + await asyncio.sleep(0) + body_out = _response_body(resp) + assert body_out["response"]["end_session"] is False + + async def test_stop_action_ends_session_even_with_continuation_on(self) -> None: + """`стоп / выключи` always closes the session regardless of the toggle.""" + mass = _make_mass([MockPlayer(player_id="p1", name="Кухня")]) + mass.player_queues.stop = AsyncMock() + handler = DialogsWebhookHandler( + mass, + skill_id="skill-uuid-1", + webhook_secret=_TEST_SECRET, + voice_continuation=True, + ) + body = { + "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, + "request": { + "command": "стоп на кухне", + "nlu": {"intents": {"control.stop": {}}}, + }, + } + resp = await handler._handle_webhook(_build_request(body)) + await asyncio.sleep(0) + body_out = _response_body(resp) + assert body_out["response"]["end_session"] is True + + +# --------------------------------------------------------------------------- +# Yandex state envelope (P0.1) + tts split (P0.2) +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +class TestStatePersistence: + """Tests that the handler reads/writes Yandex state envelope correctly.""" + + def _make_handler(self, mass: MagicMock) -> DialogsWebhookHandler: + return DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) + + async def test_resolved_player_persisted_in_session_and_application_state(self) -> None: + """Successful play writes last_player_id to session_state and application_state.""" + track = MagicMock(uri="library://track/1", spec_set=["uri"]) + mass = _make_mass([MockPlayer(player_id="p1", name="Кухня")], search_track=track) + handler = self._make_handler(mass) + body = { + "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, + "request": {"command": "включи Metallica на кухне"}, + } + resp = await handler._handle_webhook(_build_request(body)) + await asyncio.sleep(0) + body_out = _response_body(resp) + assert body_out["session_state"]["last_player_id"] == "p1" + assert body_out["application_state"]["last_player_id"] == "p1" + # No user identity in the request → no user_state_update. + assert "user_state_update" not in body_out + + async def test_user_state_written_when_user_id_present(self) -> None: + """When session.user.user_id is set, response merges preferred_player_id.""" + track = MagicMock(uri="library://track/1", spec_set=["uri"]) + mass = _make_mass([MockPlayer(player_id="p1", name="Кухня")], search_track=track) + handler = self._make_handler(mass) + body = { + "session": { + "skill_id": "skill-uuid-1", + "session_id": "s1", + "new": False, + "user": {"user_id": "yandex-user-1"}, + }, + "request": {"command": "включи Metallica на кухне"}, + } + resp = await handler._handle_webhook(_build_request(body)) + await asyncio.sleep(0) + body_out = _response_body(resp) + assert body_out["user_state_update"] == {"preferred_player_id": "p1"} + + async def test_default_player_priority_session_over_application(self) -> None: + """When command has no player hint, session.last_player_id wins over application's.""" + track = MagicMock(uri="library://track/1", spec_set=["uri"]) + mass = _make_mass( + [MockPlayer(player_id="p1", name="Кухня"), MockPlayer(player_id="p2", name="Спальня")], + search_track=track, + ) + handler = self._make_handler(mass) + body = { + "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, + "request": {"command": "включи Beatles"}, + "state": { + "session": {"last_player_id": "p1"}, + "application": {"last_player_id": "p2"}, + }, + } + await handler._handle_webhook(_build_request(body)) + await asyncio.sleep(0) + assert mass.player_queues.play_media.call_args.kwargs["queue_id"] == "p1" + + async def test_default_player_falls_through_to_application(self) -> None: + """No session.last_player_id — application_state wins.""" + track = MagicMock(uri="library://track/1", spec_set=["uri"]) + mass = _make_mass( + [MockPlayer(player_id="p1", name="Кухня"), MockPlayer(player_id="p2", name="Спальня")], + search_track=track, + ) + handler = self._make_handler(mass) + body = { + "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, + "request": {"command": "включи Beatles"}, + "state": {"application": {"last_player_id": "p2"}}, + } + await handler._handle_webhook(_build_request(body)) + await asyncio.sleep(0) + assert mass.player_queues.play_media.call_args.kwargs["queue_id"] == "p2" + + async def test_default_player_falls_through_to_user(self) -> None: + """Both session and application empty — user.preferred_player_id wins.""" + track = MagicMock(uri="library://track/1", spec_set=["uri"]) + mass = _make_mass( + [MockPlayer(player_id="p1", name="Кухня"), MockPlayer(player_id="p2", name="Спальня")], + search_track=track, + ) + handler = self._make_handler(mass) + body = { + "session": { + "skill_id": "skill-uuid-1", + "session_id": "s1", + "new": False, + "user": {"user_id": "yandex-user-1"}, + }, + "request": {"command": "включи Beatles"}, + "state": {"user": {"preferred_player_id": "p2"}}, + } + await handler._handle_webhook(_build_request(body)) + await asyncio.sleep(0) + assert mass.player_queues.play_media.call_args.kwargs["queue_id"] == "p2" + + async def test_user_id_echo_falls_back_to_nested(self) -> None: + """When root session.user_id is missing, echo the nested session.user.user_id.""" + track = MagicMock(uri="library://track/1", spec_set=["uri"]) + mass = _make_mass([MockPlayer(player_id="p1", name="Кухня")], search_track=track) + handler = self._make_handler(mass) + body = { + "session": { + "skill_id": "skill-uuid-1", + "session_id": "s1", + "new": False, + # No root "user_id"; only the nested one. + "user": {"user_id": "yandex-user-42"}, + }, + "request": {"command": "включи Metallica на кухне"}, + } + resp = await handler._handle_webhook(_build_request(body)) + body_out = _response_body(resp) + assert body_out["session"]["user_id"] == "yandex-user-42" + + async def test_session_state_preserved_on_player_not_found(self) -> None: + """Even on error, existing session_state is echoed back so other keys aren't lost.""" + mass = _make_mass([MockPlayer(player_id="p1", name="Спальня")]) + handler = self._make_handler(mass) + body = { + "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, + "request": {"command": "включи Metallica на Кухне"}, + "state": {"session": {"foo": "bar"}}, + } + resp = await handler._handle_webhook(_build_request(body)) + body_out = _response_body(resp) + assert body_out["session_state"] == {"foo": "bar"} + + +class TestTtsHelper: + """Tests for _tts_for stress-mark substitution.""" + + def test_known_russian_word_gets_stress_mark(self) -> None: + """A known Russian word has `+` injected before the stressed vowel.""" + assert _tts_for("Включаю джаз") == "Включ+аю джаз" + + def test_unknown_word_passes_through(self) -> None: + """A word not in the dict is unchanged.""" + assert _tts_for("Привет мир") == "Привет мир" + + def test_empty_input(self) -> None: + """Empty input is returned as-is.""" + assert _tts_for("") == "" + + def test_capitalisation_preserved(self) -> None: + """Original capitalisation of the first letter is preserved.""" + # All-lowercase original. + assert _tts_for("включаю джаз") == "включ+аю джаз" + # Capitalised original. + assert _tts_for("Включаю джаз") == "Включ+аю джаз" + + def test_foreign_band_transliterated(self) -> None: + """Latin band names are transliterated to Cyrillic with stress marks.""" + # Single-word foreign band (regex pass). + assert _tts_for("Включаю Metallica") == "Включ+аю Мет+аллика" + # Lowercase form preserved. + assert _tts_for("включаю metallica") == "включ+аю мет+аллика" + + def test_foreign_phrase_transliterated(self) -> None: + """Multi-word foreign band names are matched via the phrase pass.""" + result = _tts_for("Включаю Iron Maiden на кухне") + assert "+айрон м+эйден" in result.lower() + # Russian response words still get their stress mark in the same call. + assert "Включ+аю" in result + + def test_phrase_pass_handles_overlap(self) -> None: + """Longer phrases match before shorter sub-phrases (declared order).""" + # "imagine dragons" must win over the single-word "imagine" entry. + result = _tts_for("Imagine Dragons") + assert "имадж+ин др+агонс" in result.lower() + + +@pytest.mark.asyncio +class TestTtsResponseField: + """Test that the handler emits separate text + tts in the response envelope.""" + + async def test_response_tts_differs_from_text_when_known_word_used(self) -> None: + """Happy path response has different `tts` from `text` when stress-mark fires.""" + track = MagicMock(uri="library://track/1", spec_set=["uri"]) + mass = _make_mass([MockPlayer(player_id="p1", name="Кухня")], search_track=track) + handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) + body = { + "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, + "request": {"command": "включи Metallica на кухне"}, + } + resp = await handler._handle_webhook(_build_request(body)) + body_out = _response_body(resp) + text = body_out["response"]["text"] + tts = body_out["response"]["tts"] + assert text != tts + assert "включ+аю" in tts.lower() + + +# --------------------------------------------------------------------------- +# Control commands integration (P0.6) +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +class TestControlCommandsIntegration: + """Integration tests for the control branch in _handle_webhook.""" + + def _setup_mass_with_control_methods(self, players: list[MockPlayer]) -> MagicMock: + mass = _make_mass(players) + mass.player_queues.pause = AsyncMock() + mass.player_queues.resume = AsyncMock() + mass.player_queues.stop = AsyncMock() + mass.player_queues.next = AsyncMock() + mass.player_queues.previous = AsyncMock() + mass.player_queues.set_shuffle = AsyncMock() + mass.player_queues.set_repeat = MagicMock() # NB: sync + mass.player_queues.skip = AsyncMock() + mass.player_queues.seek = AsyncMock() + mass.player_queues.transfer_queue = AsyncMock() + mass.player_queues.get = MagicMock(return_value=None) + mass.players.cmd_volume_up = AsyncMock() + mass.players.cmd_volume_down = AsyncMock() + mass.players.cmd_volume_set = AsyncMock() + mass.players.cmd_volume_mute = AsyncMock() + return mass + + async def test_pause_command_calls_player_queues_pause(self) -> None: + """'пауза на кухне' → mass.player_queues.pause(p1) and confirms in response.""" + mass = self._setup_mass_with_control_methods([MockPlayer(player_id="p1", name="Кухня")]) + handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) + body = { + "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, + "request": { + "command": "пауза на кухне", + "nlu": {"intents": {"control.pause": {}}}, + }, + } + resp = await handler._handle_webhook(_build_request(body)) + await asyncio.sleep(0) + assert resp.status == 200 + mass.player_queues.pause.assert_awaited_once_with("p1") + body_out = _response_body(resp) + assert body_out["response"]["text"] == "Пауза." + # State persisted as in play branch. + assert body_out["session_state"]["last_player_id"] == "p1" + assert body_out["application_state"]["last_player_id"] == "p1" + # play_media should NOT be called for control commands. + mass.player_queues.play_media.assert_not_awaited() + + async def test_volume_set_command(self) -> None: + """'громкость 50 на кухне' → cmd_volume_set(p1, 50).""" + mass = self._setup_mass_with_control_methods([MockPlayer(player_id="p1", name="Кухня")]) + handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) + body = { + "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, + "request": { + "command": "громкость 50 на кухне", + "nlu": { + "intents": { + "control.volume_set": { + "slots": {"level": {"type": "YANDEX.NUMBER", "value": 50}} + } + } + }, + }, + } + resp = await handler._handle_webhook(_build_request(body)) + await asyncio.sleep(0) + assert resp.status == 200 + mass.players.cmd_volume_set.assert_awaited_once_with("p1", 50) + + async def test_control_uses_default_player_from_state(self) -> None: + """A control phrase without explicit hint uses state.session.last_player_id.""" + mass = self._setup_mass_with_control_methods( + [MockPlayer(player_id="p1", name="Кухня"), MockPlayer(player_id="p2", name="Спальня")] + ) + handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) + body = { + "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, + "request": { + "command": "пауза", + "nlu": {"intents": {"control.pause": {}}}, + }, + "state": {"session": {"last_player_id": "p2"}}, + } + resp = await handler._handle_webhook(_build_request(body)) + await asyncio.sleep(0) + assert resp.status == 200 + mass.player_queues.pause.assert_awaited_once_with("p2") + + async def test_control_unknown_player_asks_for_clarification(self) -> None: + """Control command with an unknown player hint returns a clarification.""" + mass = self._setup_mass_with_control_methods([MockPlayer(player_id="p1", name="Спальня")]) + handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) + body = { + "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, + "request": { + "command": "пауза на гостиной", + "nlu": {"intents": {"control.pause": {}}}, + }, + } + resp = await handler._handle_webhook(_build_request(body)) + assert resp.status == 200 + mass.player_queues.pause.assert_not_awaited() + body_out = _response_body(resp) + assert "Не нашёл колонку «гостиной»" in body_out["response"]["text"] + + async def test_forget_player_clears_state_tiers(self) -> None: + """'забудь колонку' clears last_player_id from session/application/cache. + + After the user picks a player via disambiguation, every later play + command without an explicit hint plays on it (by design — sticky + default for ergonomics). Saying 'забудь колонку' resets that so + the next ambiguous command asks again. + """ + mass = self._setup_mass_with_control_methods( + [ + MockPlayer(player_id="p1", name="Кухня"), + MockPlayer(player_id="p2", name="Спальня"), + ] + ) + handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) + # Pre-seed cache with a stale default-player. + handler._state_cache["user:u1"] = ( + {"last_player_id": "p1"}, + time.monotonic(), + ) + body = { + "session": { + "skill_id": "skill-uuid-1", + "session_id": "s1", + "new": False, + "user": {"user_id": "u1"}, + }, + "request": { + "command": "забудь колонку", + "nlu": {"intents": {"control.forget_player": {}}}, + }, + "state": { + "session": {"last_player_id": "p1"}, + "application": {"last_player_id": "p1"}, + }, + } + resp = await handler._handle_webhook(_build_request(body)) + body_out = _response_body(resp) + assert "Хорошо" in body_out["response"]["text"] + # last_player_id removed from session and application state. + assert "last_player_id" not in body_out["session_state"] + assert "last_player_id" not in body_out["application_state"] + # user_state_update sets preferred_player_id to None (Yandex + # protocol: None = delete the key from merged user state). + assert body_out["user_state_update"] == {"preferred_player_id": None} + # Cache rewritten with no last_player_id. + cached = handler._cache_get({"user": {"user_id": "u1"}}) + assert "last_player_id" not in cached + + async def test_list_players_returns_player_names(self) -> None: + """'сколько колонок видишь' → response with the count and names of exposed players.""" + mass = self._setup_mass_with_control_methods( + [ + MockPlayer(player_id="p1", name="Кухня"), + MockPlayer(player_id="p2", name="Спальня"), + MockPlayer(player_id="p3", name="Гостиная"), + ] + ) + handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) + body = { + "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, + "request": { + "command": "сколько колонок видишь", + "nlu": {"intents": {"control.list_players": {}}}, + }, + } + resp = await handler._handle_webhook(_build_request(body)) + assert resp.status == 200 + body_out = _response_body(resp) + text = body_out["response"]["text"] + assert "Вижу 3 колонки" in text + assert "Кухня" in text + assert "Спальня" in text + assert "Гостиная" in text + # Informational query — keep the mic open for follow-ups. + assert body_out["response"]["end_session"] is False + # No playback or control was dispatched. + mass.player_queues.pause.assert_not_awaited() + mass.player_queues.play_media.assert_not_awaited() + + async def test_list_players_skips_unavailable(self) -> None: + """Only available + enabled + non-synced players are counted.""" + mass = self._setup_mass_with_control_methods( + [ + MockPlayer(player_id="p1", name="Кухня"), + MockPlayer(player_id="p2", name="Disabled", enabled=False), + MockPlayer(player_id="p3", name="Unavailable", available=False), + MockPlayer(player_id="p4", name="Synced", synced_to="leader"), + ] + ) + handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) + body = { + "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, + "request": { + "command": "какие колонки", + "nlu": {"intents": {"control.list_players": {}}}, + }, + } + resp = await handler._handle_webhook(_build_request(body)) + body_out = _response_body(resp) + text = body_out["response"]["text"] + assert "Вижу одну колонку: Кухня" in text + assert "Disabled" not in text + assert "Unavailable" not in text + assert "Synced" not in text + + async def test_control_no_hint_no_default_asks_for_player(self) -> None: + """Control with no hint + no default + multi-player → ask for the player. + + Previously responded with the misleading "Не нашёл колонку «(не указано)»"; + now the message tells the user to specify the player. + """ + mass = self._setup_mass_with_control_methods( + [ + MockPlayer(player_id="p1", name="Кухня"), + MockPlayer(player_id="p2", name="Спальня"), + ] + ) + handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) + body = { + "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, + "request": { + "command": "пауза", + "nlu": {"intents": {"control.pause": {}}}, + }, + } + resp = await handler._handle_webhook(_build_request(body)) + assert resp.status == 200 + mass.player_queues.pause.assert_not_awaited() + body_out = _response_body(resp) + text = body_out["response"]["text"] + assert "(не указано)" not in text + assert "на какой колонке" in text.lower() + + # ------------------------------------------------------------------- + # v1.9.0 — six new commands + # ------------------------------------------------------------------- + + async def test_now_playing_returns_track(self) -> None: + """'что играет на кухне' → reads queue.current_item.name. + + Yandex pre-classifies "что играет" via control.now_playing intent; + the player_hint "кухне" is recovered from the trailing "на" suffix + of the raw command text. + """ + mass = self._setup_mass_with_control_methods([MockPlayer(player_id="p1", name="Кухня")]) + # Mock a queue with a current item. + queue = MagicMock() + queue.current_item = MagicMock(name="The Beatles - Let It Be") + queue.current_item.name = "The Beatles - Let It Be" + mass.player_queues.get = MagicMock(return_value=queue) + handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) + body = { + "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, + "request": { + "command": "что играет на кухне", + "nlu": {"intents": {"control.now_playing": {}}}, + }, + } + resp = await handler._handle_webhook(_build_request(body)) + body_out = _response_body(resp) + assert "The Beatles - Let It Be" in body_out["response"]["text"] + # No MA mutation. + mass.player_queues.pause.assert_not_awaited() + mass.player_queues.play_media.assert_not_awaited() + + async def test_now_playing_idle_queue(self) -> None: + """'что играет' on an idle queue → 'ничего не играет'.""" + mass = self._setup_mass_with_control_methods([MockPlayer(player_id="p1", name="Кухня")]) + queue = MagicMock() + queue.current_item = None + mass.player_queues.get = MagicMock(return_value=queue) + handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) + body = { + "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, + "request": { + "command": "что играет на кухне", + "nlu": {"intents": {"control.now_playing": {}}}, + }, + } + resp = await handler._handle_webhook(_build_request(body)) + body_out = _response_body(resp) + assert "ничего не играет" in body_out["response"]["text"] + + async def test_shuffle_on(self) -> None: + """'перемешай на кухне' → set_shuffle(p1, True).""" + mass = self._setup_mass_with_control_methods([MockPlayer(player_id="p1", name="Кухня")]) + handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) + body = { + "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, + "request": { + "command": "перемешай на кухне", + "nlu": {"intents": {"control.shuffle_on": {}}}, + }, + } + await handler._handle_webhook(_build_request(body)) + await asyncio.sleep(0) + mass.player_queues.set_shuffle.assert_awaited_once_with("p1", shuffle_enabled=True) + + async def test_shuffle_off(self) -> None: + """'выключи перемешивание на кухне' → set_shuffle(p1, False).""" + mass = self._setup_mass_with_control_methods([MockPlayer(player_id="p1", name="Кухня")]) + handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) + body = { + "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, + "request": { + "command": "выключи перемешивание на кухне", + "nlu": {"intents": {"control.shuffle_off": {}}}, + }, + } + await handler._handle_webhook(_build_request(body)) + await asyncio.sleep(0) + mass.player_queues.set_shuffle.assert_awaited_once_with("p1", shuffle_enabled=False) + + async def test_repeat_one(self) -> None: + """'повтори песню на кухне' → set_repeat(p1, RepeatMode.ONE) — sync, not awaited.""" + mass = self._setup_mass_with_control_methods([MockPlayer(player_id="p1", name="Кухня")]) + handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) + body = { + "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, + "request": { + "command": "повтори песню на кухне", + "nlu": {"intents": {"control.repeat_one": {}}}, + }, + } + await handler._handle_webhook(_build_request(body)) + await asyncio.sleep(0) + mass.player_queues.set_repeat.assert_called_once_with("p1", RepeatMode.ONE) + + async def test_seek_forward_minute(self) -> None: + """'перемотай вперёд на 1 минуту на кухне' → skip(p1, 60). + + Yandex extracts amount=1 (YANDEX.NUMBER) and unit="minutes" + (custom time_unit entity); the runtime mapper multiplies by 60. + """ + mass = self._setup_mass_with_control_methods([MockPlayer(player_id="p1", name="Кухня")]) + handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) + body = { + "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, + "request": { + "command": "перемотай вперёд на 1 минуту на кухне", + "nlu": { + "intents": { + "control.seek_forward": { + "slots": { + "amount": {"type": "YANDEX.NUMBER", "value": 1}, + "unit": {"type": "time_unit", "value": "minutes"}, + } + } + } + }, + }, + } + await handler._handle_webhook(_build_request(body)) + await asyncio.sleep(0) + mass.player_queues.skip.assert_awaited_once_with("p1", seconds=60) + + async def test_seek_back_seconds(self) -> None: + """'назад на 30 секунд на кухне' → skip(p1, -30).""" + mass = self._setup_mass_with_control_methods([MockPlayer(player_id="p1", name="Кухня")]) + handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) + body = { + "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, + "request": { + "command": "перемотай назад на 30 секунд на кухне", + "nlu": { + "intents": { + "control.seek_back": { + "slots": { + "amount": {"type": "YANDEX.NUMBER", "value": 30}, + "unit": {"type": "time_unit", "value": "seconds"}, + } + } + } + }, + }, + } + await handler._handle_webhook(_build_request(body)) + await asyncio.sleep(0) + mass.player_queues.skip.assert_awaited_once_with("p1", seconds=-30) + + async def test_seek_start(self) -> None: + """'к началу на кухне' → seek(p1, position=0).""" + mass = self._setup_mass_with_control_methods([MockPlayer(player_id="p1", name="Кухня")]) + handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) + body = { + "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, + "request": { + "command": "к началу на кухне", + "nlu": {"intents": {"control.seek_start": {}}}, + }, + } + await handler._handle_webhook(_build_request(body)) + await asyncio.sleep(0) + mass.player_queues.seek.assert_awaited_once_with("p1", position=0) + + async def test_transfer_to_target(self) -> None: + """'переведи на спальню' with default=p1 → transfer_queue(p1, p2); last_player_id→p2.""" + mass = self._setup_mass_with_control_methods( + [ + MockPlayer(player_id="p1", name="Кухня"), + MockPlayer(player_id="p2", name="Спальня"), + ] + ) + handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) + body = { + "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, + "request": {"command": "переведи на спальню"}, + "state": {"session": {"last_player_id": "p1"}}, + } + resp = await handler._handle_webhook(_build_request(body)) + await asyncio.sleep(0) + mass.player_queues.transfer_queue.assert_awaited_once_with( + source_queue_id="p1", target_queue_id="p2" + ) + body_out = _response_body(resp) + assert "Спальня" in body_out["response"]["text"] + assert body_out["session_state"]["last_player_id"] == "p2" + assert body_out["application_state"]["last_player_id"] == "p2" + + async def test_transfer_no_default_replies_with_hint(self) -> None: + """Transfer without saved last_player_id replies with 'сначала включи'.""" + mass = self._setup_mass_with_control_methods( + [MockPlayer(player_id="p1", name="Кухня"), MockPlayer(player_id="p2", name="Спальня")] + ) + handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) + body = { + "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, + "request": {"command": "переведи на спальню"}, + } + resp = await handler._handle_webhook(_build_request(body)) + body_out = _response_body(resp) + assert "Сначала включи" in body_out["response"]["text"] + mass.player_queues.transfer_queue.assert_not_awaited() + + async def test_transfer_to_same_player(self) -> None: + """'переведи на кухню' when default already = кухня → 'уже играет'.""" + mass = self._setup_mass_with_control_methods( + [MockPlayer(player_id="p1", name="Кухня"), MockPlayer(player_id="p2", name="Спальня")] + ) + handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) + body = { + "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, + "request": {"command": "переведи на кухню"}, + "state": {"session": {"last_player_id": "p1"}}, + } + resp = await handler._handle_webhook(_build_request(body)) + body_out = _response_body(resp) + assert "Уже играет" in body_out["response"]["text"] + mass.player_queues.transfer_queue.assert_not_awaited() + + async def test_add_to_queue_preserved_through_disambiguation(self) -> None: + """Ambiguous "добавь Iron Maiden" → disambiguation → user picks → ADD survives. + + Without this fix, the disambiguation flow rebuilt ParsedCommand + from `pending_command` without `enqueue_option`, so the replay + would hit play_media() without `option` (default REPLACE) + instead of `QueueOption.ADD`. + """ + track = MagicMock(uri="library://track/1", spec_set=["uri"]) + mass = _make_mass( + [ + MockPlayer(player_id="p1", name="Кухня большая"), + MockPlayer(player_id="p2", name="Кухня маленькая"), + ], + search_track=track, + ) + handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) + # Turn 1: ambiguous "добавь Iron Maiden на кухне" → disambig prompt. + body1 = { + "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, + "request": {"command": "добавь Iron Maiden на кухне"}, + } + resp1 = await handler._handle_webhook(_build_request(body1)) + body_out1 = _response_body(resp1) + # Pending command must carry enqueue_option across the prompt. + assert body_out1["session_state"]["pending_command"]["enqueue_option"] == "add" + mass.player_queues.play_media.assert_not_awaited() + # Turn 2: ordinal "первая" → replay pending → play_media with ADD option. + body2 = { + "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, + "request": {"command": "первая"}, + "state": {"session": body_out1["session_state"]}, + } + await handler._handle_webhook(_build_request(body2)) + await asyncio.sleep(0) + mass.player_queues.play_media.assert_awaited_once() + assert mass.player_queues.play_media.call_args.kwargs["option"] == QueueOption.ADD + + async def test_add_to_queue_uses_queue_option_add(self) -> None: + """'добавь Metallica на кухне' → play_media(option=QueueOption.ADD).""" + track = MagicMock(uri="library://track/1", spec_set=["uri"]) + mass = _make_mass([MockPlayer(player_id="p1", name="Кухня")], search_track=track) + handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) + body = { + "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, + "request": {"command": "добавь Metallica на кухне"}, + } + resp = await handler._handle_webhook(_build_request(body)) + await asyncio.sleep(0) + mass.player_queues.play_media.assert_awaited_once() + call_kwargs = mass.player_queues.play_media.call_args.kwargs + assert call_kwargs["queue_id"] == "p1" + assert call_kwargs["option"] == QueueOption.ADD + # radio_mode forced off for add-to-queue. + assert call_kwargs["radio_mode"] is False + body_out = _response_body(resp) + assert "Добавил" in body_out["response"]["text"] + assert "в очередь" in body_out["response"]["text"] + + +# --------------------------------------------------------------------------- +# Disambiguation (P0.3) +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +class TestDisambiguation: + """End-to-end tests for the disambiguation prompt + pending-command replay.""" + + async def test_multiple_matches_returns_disambiguation_prompt(self) -> None: + """Two candidates on a screened surface → response carries buttons + pending_command.""" + track = MagicMock(uri="library://track/1", spec_set=["uri"]) + mass = _make_mass( + [ + MockPlayer(player_id="p1", name="Кухня большая"), + MockPlayer(player_id="p2", name="Кухня маленькая"), + ], + search_track=track, + ) + handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) + body = { + "meta": {"interfaces": {"screen": {}}}, + "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, + "request": {"command": "включи Metallica на кухне"}, + } + resp = await handler._handle_webhook(_build_request(body)) + assert resp.status == 200 + body_out = _response_body(resp) + assert body_out["response"]["end_session"] is False + assert "buttons" in body_out["response"] + button_titles = {b["title"] for b in body_out["response"]["buttons"]} + assert button_titles == {"Кухня большая", "Кухня маленькая"} + # pending_command is saved with the original play intent + the + # ordered candidate IDs for voice ordinal resolution. + pending = body_out["session_state"]["pending_command"] + assert pending["kind"] == "search" + assert pending["query"] == "metallica" + assert pending["radio_mode"] is True + assert pending["candidate_ids"] == ["p1", "p2"] + # Nothing is played yet. + mass.player_queues.play_media.assert_not_awaited() + + async def test_disambiguation_voice_only_omits_buttons(self) -> None: + """Voice-only surface (no meta.interfaces.screen) → prompt without buttons.""" + track = MagicMock(uri="library://track/1", spec_set=["uri"]) + mass = _make_mass( + [ + MockPlayer(player_id="p1", name="Кухня большая"), + MockPlayer(player_id="p2", name="Кухня маленькая"), + ], + search_track=track, + ) + handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) + body = { + # No meta.interfaces — defaults to voice-only (Yandex Mini etc.) + "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, + "request": {"command": "включи Metallica на кухне"}, + } + resp = await handler._handle_webhook(_build_request(body)) + assert resp.status == 200 + body_out = _response_body(resp) + assert body_out["response"]["end_session"] is False + # Voice prompt with ordinals is still present, just without buttons. + assert "buttons" not in body_out["response"] + assert "первая" in body_out["response"]["text"].lower() + # Pending command still saved for voice-ordinal resolution. + pending = body_out["session_state"]["pending_command"] + assert pending["candidate_ids"] == ["p1", "p2"] + + async def test_button_press_resolves_pending(self) -> None: + """ButtonPressed payload.player_id triggers a play of the saved pending_command.""" + track = MagicMock(uri="library://track/1", spec_set=["uri"]) + mass = _make_mass( + [ + MockPlayer(player_id="p1", name="Кухня большая"), + MockPlayer(player_id="p2", name="Кухня маленькая"), + ], + search_track=track, + ) + handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) + body = { + "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, + "request": { + "type": "ButtonPressed", + "command": "Кухня большая", + "payload": {"player_id": "p1"}, + }, + "state": { + "session": { + "pending_command": {"kind": "search", "query": "metallica", "radio_mode": True}, + }, + }, + } + resp = await handler._handle_webhook(_build_request(body)) + await asyncio.sleep(0) + assert resp.status == 200 + mass.player_queues.play_media.assert_awaited_once() + assert mass.player_queues.play_media.call_args.kwargs["queue_id"] == "p1" + # pending_command is cleared from the response state. + body_out = _response_body(resp) + assert "pending_command" not in body_out["session_state"] + assert body_out["session_state"]["last_player_id"] == "p1" + + async def test_slot_elicit_with_hint_persists_player(self) -> None: + """'включи на кухне' (player set, no query) elicits + saves hinted player. + + Previously fell through to "Не нашёл такую музыку: ." — the user + clearly wants something, just didn't name it. Now elicits and + plays the follow-up on the hinted player without re-stating it. + """ + track = MagicMock(uri="library://track/1", spec_set=["uri"]) + mass = _make_mass( + [ + MockPlayer(player_id="p1", name="Кухня"), + MockPlayer(player_id="p2", name="Спальня"), + ], + search_track=track, + ) + handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) + # Turn 1: "включи на кухне" — no query, hint=кухне + body1 = { + "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, + "request": {"command": "включи на кухне"}, + } + resp1 = await handler._handle_webhook(_build_request(body1)) + body_out1 = _response_body(resp1) + # Slot-elicit response with hinted player saved. + assert "Что включить" in body_out1["response"]["text"] + assert body_out1["session_state"]["awaiting_query"] is True + assert body_out1["session_state"]["awaiting_player_id"] == "p1" + assert body_out1["application_state"]["awaiting_player_id"] == "p1" + mass.player_queues.play_media.assert_not_awaited() + + # Turn 2: "Metallica" — should play on p1 (the saved hint) + body2 = { + "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, + "request": {"command": "Metallica"}, + "state": { + "session": { + "awaiting_query": True, + "awaiting_player_id": "p1", + }, + }, + } + await handler._handle_webhook(_build_request(body2)) + await asyncio.sleep(0) + mass.player_queues.play_media.assert_awaited_once() + assert mass.player_queues.play_media.call_args.kwargs["queue_id"] == "p1" + + async def test_slot_elicit_when_query_empty(self) -> None: + """Bare verb (empty query) → 'Что включить?' + awaiting_query=True.""" + mass = _make_mass([MockPlayer(player_id="p1", name="Кухня")]) + handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) + body = { + "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, + "request": {"command": "включи"}, + } + resp = await handler._handle_webhook(_build_request(body)) + assert resp.status == 200 + body_out = _response_body(resp) + assert body_out["response"]["end_session"] is False + assert "Что включить" in body_out["response"]["text"] + assert body_out["session_state"]["awaiting_query"] is True + # Nothing played. + mass.player_queues.play_media.assert_not_awaited() + + async def test_followup_with_awaiting_query_resolves(self) -> None: + """Next utterance after slot-elicit is treated as the play query.""" + track = MagicMock(uri="library://track/1", spec_set=["uri"]) + mass = _make_mass([MockPlayer(player_id="p1", name="Кухня")], search_track=track) + handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) + body = { + "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, + "request": {"command": "Metallica"}, + "state": {"session": {"awaiting_query": True}}, + } + resp = await handler._handle_webhook(_build_request(body)) + await asyncio.sleep(0) + assert resp.status == 200 + mass.player_queues.play_media.assert_awaited_once() + body_out = _response_body(resp) + # awaiting_query is cleared on success. + assert "awaiting_query" not in body_out["session_state"] + + async def test_control_during_awaiting_query_dispatches_control(self) -> None: + """Slot-elicit was active, but the user pivots to a control phrase. + + "Включи." → "Что включить?" (awaiting_query=True). Then the user + says "пауза на кухне" — this must dispatch a control command, not + get prefixed with "включи " and turned into a search query. + """ + mass = _make_mass([MockPlayer(player_id="p1", name="Кухня")]) + mass.player_queues.pause = AsyncMock() + handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) + body = { + "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, + "request": { + "command": "пауза на кухне", + "nlu": {"intents": {"control.pause": {}}}, + }, + "state": {"session": {"awaiting_query": True}}, + } + resp = await handler._handle_webhook(_build_request(body)) + await asyncio.sleep(0) + assert resp.status == 200 + mass.player_queues.pause.assert_awaited_once_with("p1") + # awaiting_query must be cleared on successful control dispatch. + body_out = _response_body(resp) + assert "awaiting_query" not in body_out["session_state"] + # play_media not called — this was a control, not a play. + mass.player_queues.play_media.assert_not_awaited() + + async def test_followup_full_play_command_does_not_double_prefix(self) -> None: + """Follow-up like 'включи Yesterday' is parsed as-is, not double-prefixed.""" + track = MagicMock(uri="library://track/1", spec_set=["uri"]) + mass = _make_mass([MockPlayer(player_id="p1", name="Кухня")], search_track=track) + handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) + body = { + "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, + "request": {"command": "включи Yesterday"}, + "state": {"session": {"awaiting_query": True}}, + } + resp = await handler._handle_webhook(_build_request(body)) + await asyncio.sleep(0) + assert resp.status == 200 + mass.player_queues.play_media.assert_awaited_once() + # The search call must use "yesterday" (after parser strips "включи"), + # not "включи yesterday". + search_query = mass.music.search.call_args.kwargs["search_query"] + assert search_query == "yesterday" + + async def test_play_no_hint_no_default_offers_disambiguation(self) -> None: + """Play branch: no hint + no default + 2+ players → disambiguation prompt. + + Without this, the user would see "Не нашёл колонку «(не указано)»". + """ + track = MagicMock(uri="library://track/1", spec_set=["uri"]) + mass = _make_mass( + [ + MockPlayer(player_id="p1", name="Кухня"), + MockPlayer(player_id="p2", name="Спальня"), + ], + search_track=track, + ) + handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) + body = { + "meta": {"interfaces": {"screen": {}}}, + "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, + "request": {"command": "включи Metallica"}, + } + resp = await handler._handle_webhook(_build_request(body)) + body_out = _response_body(resp) + assert body_out["response"]["end_session"] is False + assert "buttons" in body_out["response"] + button_titles = {b["title"] for b in body_out["response"]["buttons"]} + assert button_titles == {"Кухня", "Спальня"} + # pending_command saved with the original play intent + candidate_ids. + # Order is significant — used as the index space for voice ordinal + # resolution ("первая" → candidate_ids[0]). + pending = body_out["session_state"]["pending_command"] + assert pending["kind"] == "search" + assert pending["query"] == "metallica" + assert pending["radio_mode"] is True + assert pending["candidate_ids"] == ["p1", "p2"] + mass.player_queues.play_media.assert_not_awaited() + + async def test_button_payload_validated_against_exposed_set(self) -> None: + """ButtonPressed with a payload targeting a non-exposed player is rejected. + + Defence-in-depth: even though Yandex echoes our own payload back, + we never trust the player_id without re-checking it's currently + exposed/enabled/available. + """ + track = MagicMock(uri="library://track/1", spec_set=["uri"]) + mass = _make_mass( + [ + MockPlayer(player_id="p1", name="Кухня"), + MockPlayer(player_id="p2", name="Спальня"), + ], + search_track=track, + ) + handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) + body = { + "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, + "request": { + "type": "ButtonPressed", + "command": "Гостиная", + "payload": {"player_id": "p99-not-in-set"}, + }, + "state": { + "session": { + "pending_command": {"kind": "search", "query": "metallica", "radio_mode": True}, + }, + }, + } + resp = await handler._handle_webhook(_build_request(body)) + await asyncio.sleep(0) + # play_media must NOT be awaited — invalid payload should not play. + mass.player_queues.play_media.assert_not_awaited() + # Status is still 200; the handler falls through, but no playback. + assert resp.status == 200 + + async def test_disambiguation_clears_awaiting_query(self) -> None: + """Slot-elicit → multi-match → disambiguation prompt drops awaiting_query. + + Without this, the next user utterance ("Кухня маленькая") would get + auto-prefixed with "включи " by the awaiting-query branch and miss + the pending-command resolver. + """ + track = MagicMock(uri="library://track/1", spec_set=["uri"]) + mass = _make_mass( + [ + MockPlayer(player_id="p1", name="Кухня большая"), + MockPlayer(player_id="p2", name="Кухня маленькая"), + ], + search_track=track, + ) + handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) + # Simulate the awaiting_query → ambiguous-resolution turn. + body = { + "meta": {"interfaces": {"screen": {}}}, + "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, + "request": {"command": "Metallica на кухне"}, + "state": {"session": {"awaiting_query": True}}, + } + resp = await handler._handle_webhook(_build_request(body)) + body_out = _response_body(resp) + # Disambiguation prompt is returned (multi-match). + assert body_out["response"]["end_session"] is False + assert "buttons" in body_out["response"] + # And the response carries pending_command but NOT awaiting_query. + assert "pending_command" in body_out["session_state"] + assert "awaiting_query" not in body_out["session_state"] + + async def test_voice_ordinal_resolves_pending(self) -> None: + """User answers disambiguation with 'первая' → first candidate is picked.""" + track = MagicMock(uri="library://track/1", spec_set=["uri"]) + mass = _make_mass( + [ + MockPlayer(player_id="p1", name="Кухня"), + MockPlayer(player_id="p2", name="Спальня"), + ], + search_track=track, + ) + handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) + body = { + "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, + "request": {"command": "первая"}, + "state": { + "session": { + "pending_command": { + "kind": "search", + "query": "metallica", + "radio_mode": True, + "candidate_ids": ["p1", "p2"], + }, + }, + }, + } + resp = await handler._handle_webhook(_build_request(body)) + await asyncio.sleep(0) + assert resp.status == 200 + mass.player_queues.play_media.assert_awaited_once() + assert mass.player_queues.play_media.call_args.kwargs["queue_id"] == "p1" + + async def test_voice_ordinal_second_candidate(self) -> None: + """'вторая' picks the second candidate from candidate_ids.""" + track = MagicMock(uri="library://track/1", spec_set=["uri"]) + mass = _make_mass( + [ + MockPlayer(player_id="p1", name="Кухня"), + MockPlayer(player_id="p2", name="Спальня"), + ], + search_track=track, + ) + handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) + body = { + "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, + "request": {"command": "вторая"}, + "state": { + "session": { + "pending_command": { + "kind": "search", + "query": "metallica", + "radio_mode": True, + "candidate_ids": ["p1", "p2"], + }, + }, + }, + } + await handler._handle_webhook(_build_request(body)) + await asyncio.sleep(0) + assert mass.player_queues.play_media.call_args.kwargs["queue_id"] == "p2" + + async def test_ordinal_out_of_range_reasks_does_not_fall_through(self) -> None: + """User says 'третья' when only 2 candidates → re-ask, don't search for 'третья'. + + Without this, the ordinal would be parsed but skip the lookup, + the free-text path would parse the utterance as a search query, + and a default-player resolution might play "третья" on some + random player. + """ + track = MagicMock(uri="library://track/1", spec_set=["uri"]) + mass = _make_mass( + [ + MockPlayer(player_id="p1", name="Кухня"), + MockPlayer(player_id="p2", name="Спальня"), + ], + search_track=track, + ) + handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) + body = { + "meta": {"interfaces": {"screen": {}}}, + "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, + "request": {"command": "третья"}, + "state": { + "session": { + "pending_command": { + "kind": "search", + "query": "metallica", + "radio_mode": True, + "candidate_ids": ["p1", "p2"], + }, + }, + }, + } + resp = await handler._handle_webhook(_build_request(body)) + await asyncio.sleep(0) + assert resp.status == 200 + body_out = _response_body(resp) + # Disambiguation re-asked, not played. + assert body_out["response"]["end_session"] is False + assert "buttons" in body_out["response"] + # pending_command still set (with same candidate set). + assert body_out["session_state"]["pending_command"]["candidate_ids"] == ["p1", "p2"] + mass.player_queues.play_media.assert_not_awaited() + + async def test_ordinal_targets_unexposed_player_reasks(self) -> None: + """User picks a valid ordinal but the indexed player has been removed → re-ask.""" + track = MagicMock(uri="library://track/1", spec_set=["uri"]) + # Only p1 exposed now — p2 is gone since the buttons were sent. + mass = _make_mass( + [MockPlayer(player_id="p1", name="Кухня")], + search_track=track, + ) + handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) + body = { + "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, + "request": {"command": "вторая"}, + "state": { + "session": { + "pending_command": { + "kind": "search", + "query": "metallica", + "radio_mode": True, + "candidate_ids": ["p1", "p2"], + }, + }, + }, + } + resp = await handler._handle_webhook(_build_request(body)) + await asyncio.sleep(0) + body_out = _response_body(resp) + # Re-asked with the remaining exposed candidate (p1). + assert body_out["response"]["end_session"] is False + assert body_out["session_state"]["pending_command"]["candidate_ids"] == ["p1"] + mass.player_queues.play_media.assert_not_awaited() + + async def test_in_process_cache_recovers_when_yandex_drops_state(self) -> None: + """Reproduce the screenless-Station bug from the dev console transcript. + + Yandex doesn't echo `state.session` OR `state.application` back + on the next turn, despite us setting both on the previous + response. The in-process state cache (keyed by user.user_id / + application_id) is the third-tier fallback that recovers. + """ + track = MagicMock(uri="library://track/1", spec_set=["uri"]) + mass = _make_mass( + [ + MockPlayer(player_id="p1", name="Кухня"), + MockPlayer(player_id="p2", name="Проигрыватель"), + ], + search_track=track, + ) + handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) + sess_common = { + "skill_id": "skill-uuid-1", + "user": {"user_id": "yandex-user-1"}, + "application": {"application_id": "yandex-app-1"}, + } + # Turn 1: disambig fires + saves cache entry. + await handler._handle_webhook( + _build_request( + { + "session": {**sess_common, "session_id": "s1", "new": False}, + "request": {"command": "включи джаз"}, + } + ) + ) + await asyncio.sleep(0) + cached = handler._cache_get( + { + "user": {"user_id": "yandex-user-1"}, + "application": {"application_id": "yandex-app-1"}, + } + ) + assert cached["pending_command"]["query"] == "джаз" + # Turn 2: NO `state` field in request — mimics dev-console emulator. + await handler._handle_webhook( + _build_request( + { + "session": {**sess_common, "session_id": "s1", "new": False}, + "request": {"command": "кухня"}, + } + ) + ) + await asyncio.sleep(0) + # Played the pending command (джаз) on p1 (Кухня). + mass.player_queues.play_media.assert_awaited_once() + assert mass.player_queues.play_media.call_args.kwargs["queue_id"] == "p1" + + async def test_in_process_cache_resolves_via_ordinal(self) -> None: + """Same as above, but turn 2 says '2' (ordinal) — also resolves via cache.""" + track = MagicMock(uri="library://track/1", spec_set=["uri"]) + mass = _make_mass( + [ + MockPlayer(player_id="p1", name="Кухня"), + MockPlayer(player_id="p2", name="Проигрыватель"), + ], + search_track=track, + ) + handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) + sess_common = { + "skill_id": "skill-uuid-1", + "user": {"user_id": "yandex-user-1"}, + "application": {"application_id": "yandex-app-1"}, + } + await handler._handle_webhook( + _build_request( + { + "session": {**sess_common, "session_id": "s1", "new": False}, + "request": {"command": "включи джаз"}, + } + ) + ) + await asyncio.sleep(0) + await handler._handle_webhook( + _build_request( + { + "session": {**sess_common, "session_id": "s1", "new": False}, + "request": {"command": "2"}, + } + ) + ) + await asyncio.sleep(0) + assert mass.player_queues.play_media.call_args.kwargs["queue_id"] == "p2" + + async def test_in_process_cache_ttl_expiry(self) -> None: + """Cached state expires after `_STATE_CACHE_TTL_SEC`; later calls don't see it.""" + mass = _make_mass([MockPlayer(player_id="p1", name="Кухня")]) + handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) + # Inject an expired entry. + handler._state_cache["user:u1"] = ( + {"pending_command": {"kind": "search", "query": "old"}}, + time.monotonic() - _STATE_CACHE_TTL_SEC - 1, + ) + assert handler._cache_get({"user": {"user_id": "u1"}}) == {} + assert "user:u1" not in handler._state_cache + + async def test_pending_command_falls_back_to_application_state(self) -> None: + """Yandex didn't echo `state.session` but kept `state.application` — still resolves. + + Reproduces the screenless-Station bug where the second turn of + a disambiguation arrives without the `pending_command` we put in + `state.session`. The same record is mirrored in `state.application` + so the handler can recover. + """ + track = MagicMock(uri="library://track/1", spec_set=["uri"]) + mass = _make_mass( + [ + MockPlayer(player_id="p1", name="Кухня"), + MockPlayer(player_id="p2", name="Проигрыватель"), + ], + search_track=track, + ) + handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) + body = { + "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, + "request": {"command": "Проигрыватель"}, + "state": { + # state.session is empty — Yandex didn't echo it back. + "application": { + "pending_command": { + "kind": "search", + "query": "джаз", + "radio_mode": True, + "candidate_ids": ["p1", "p2"], + }, + }, + }, + } + await handler._handle_webhook(_build_request(body)) + await asyncio.sleep(0) + mass.player_queues.play_media.assert_awaited_once() + assert mass.player_queues.play_media.call_args.kwargs["queue_id"] == "p2" + + async def test_disambiguation_writes_pending_to_application_state(self) -> None: + """The disambiguation prompt mirrors `pending_command` to application_state. + + Without this, devices that drop `state.session` between turns can + never complete the disambiguation flow. + """ + track = MagicMock(uri="library://track/1", spec_set=["uri"]) + mass = _make_mass( + [ + MockPlayer(player_id="p1", name="Кухня"), + MockPlayer(player_id="p2", name="Проигрыватель"), + ], + search_track=track, + ) + handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) + body = { + "meta": {"interfaces": {"screen": {}}}, + "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, + "request": {"command": "включи джаз"}, + } + resp = await handler._handle_webhook(_build_request(body)) + body_out = _response_body(resp) + # Disambiguation triggered. + assert "buttons" in body_out["response"] + # Pending mirrored in BOTH session_state and application_state. + assert body_out["session_state"]["pending_command"]["candidate_ids"] == ["p1", "p2"] + assert body_out["application_state"]["pending_command"]["candidate_ids"] == ["p1", "p2"] + + async def test_voice_ordinal_with_filler(self) -> None: + """Filler-padded ordinal answers ('выбираю первую', 'хочу вторую') resolve. + + On smart speakers users naturally pad voice replies with filler; + the strict-anchor regex from v1.8.2 missed these. + """ + track = MagicMock(uri="library://track/1", spec_set=["uri"]) + mass = _make_mass( + [ + MockPlayer(player_id="p1", name="Кухня"), + MockPlayer(player_id="p2", name="Спальня"), + ], + search_track=track, + ) + handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) + body = { + "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, + "request": {"command": "выбираю первую"}, + "state": { + "session": { + "pending_command": { + "kind": "search", + "query": "metallica", + "radio_mode": True, + "candidate_ids": ["p1", "p2"], + }, + }, + }, + } + await handler._handle_webhook(_build_request(body)) + await asyncio.sleep(0) + assert mass.player_queues.play_media.call_args.kwargs["queue_id"] == "p1" + + async def test_voice_accusative_adjective(self) -> None: + """Accusative-case answer 'большую' resolves to 'Кухня большая'. + + Caught by the new `ую` suffix in `_INFLECTION_SUFFIXES`. + """ + track = MagicMock(uri="library://track/1", spec_set=["uri"]) + mass = _make_mass( + [ + MockPlayer(player_id="p1", name="Кухня большая"), + MockPlayer(player_id="p2", name="Кухня маленькая"), + ], + search_track=track, + ) + handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) + body = { + "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, + "request": {"command": "большую"}, + "state": { + "session": { + "pending_command": { + "kind": "search", + "query": "metallica", + "radio_mode": True, + "candidate_ids": ["p1", "p2"], + }, + }, + }, + } + await handler._handle_webhook(_build_request(body)) + await asyncio.sleep(0) + assert mass.player_queues.play_media.call_args.kwargs["queue_id"] == "p1" + + async def test_voice_accusative_noun(self) -> None: + """Accusative noun 'Кухню' resolves to 'Кухня' via the new `ю` suffix.""" + track = MagicMock(uri="library://track/1", spec_set=["uri"]) + mass = _make_mass( + [ + MockPlayer(player_id="p1", name="Кухня"), + MockPlayer(player_id="p2", name="Спальня"), + ], + search_track=track, + ) + handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) + body = { + "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, + "request": {"command": "Кухню"}, + "state": { + "session": { + "pending_command": { + "kind": "search", + "query": "metallica", + "radio_mode": True, + "candidate_ids": ["p1", "p2"], + }, + }, + }, + } + await handler._handle_webhook(_build_request(body)) + await asyncio.sleep(0) + assert mass.player_queues.play_media.call_args.kwargs["queue_id"] == "p1" + + async def test_voice_ordinal_digit(self) -> None: + """A bare digit ('2') also works as an ordinal.""" + track = MagicMock(uri="library://track/1", spec_set=["uri"]) + mass = _make_mass( + [ + MockPlayer(player_id="p1", name="Кухня"), + MockPlayer(player_id="p2", name="Спальня"), + ], + search_track=track, + ) + handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) + body = { + "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, + "request": {"command": "2"}, + "state": { + "session": { + "pending_command": { + "kind": "search", + "query": "metallica", + "radio_mode": True, + "candidate_ids": ["p1", "p2"], + }, + }, + }, + } + await handler._handle_webhook(_build_request(body)) + await asyncio.sleep(0) + assert mass.player_queues.play_media.call_args.kwargs["queue_id"] == "p2" + + async def test_freetext_narrows_to_candidate_set(self) -> None: + """Free-text answer is matched only against the saved candidate IDs. + + With 3 exposed players (Кухня большая, Кухня маленькая, Гостиная) + and a saved candidate set covering only the two kitchens, saying + 'большая' must pick "Кухня большая" — even though 'большая' + could ambiguously refer to several players in a larger set. + """ + track = MagicMock(uri="library://track/1", spec_set=["uri"]) + mass = _make_mass( + [ + MockPlayer(player_id="p1", name="Кухня большая"), + MockPlayer(player_id="p2", name="Кухня маленькая"), + MockPlayer(player_id="p3", name="Гостиная большая"), + ], + search_track=track, + ) + handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) + body = { + "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, + "request": {"command": "большая"}, + "state": { + "session": { + "pending_command": { + "kind": "search", + "query": "metallica", + "radio_mode": True, + "candidate_ids": ["p1", "p2"], + }, + }, + }, + } + resp = await handler._handle_webhook(_build_request(body)) + await asyncio.sleep(0) + assert resp.status == 200 + # Must pick p1 (Кухня большая, in candidate set) — not p3 + # (also matches "большая" but excluded from candidate_ids). + assert mass.player_queues.play_media.call_args.kwargs["queue_id"] == "p1" + + async def test_freetext_followup_resolves_pending(self) -> None: + """User says 'на кухне маленькой' after the disambiguation question — plays on p2.""" + track = MagicMock(uri="library://track/1", spec_set=["uri"]) + mass = _make_mass( + [ + MockPlayer(player_id="p1", name="Кухня большая"), + MockPlayer(player_id="p2", name="Кухня маленькая"), + ], + search_track=track, + ) + handler = DialogsWebhookHandler(mass, skill_id="skill-uuid-1", webhook_secret=_TEST_SECRET) + body = { + "session": {"skill_id": "skill-uuid-1", "session_id": "s1", "new": False}, + "request": {"command": "на кухне маленькой"}, + "state": { + "session": { + "pending_command": {"kind": "search", "query": "metallica", "radio_mode": True}, + }, + }, + } + resp = await handler._handle_webhook(_build_request(body)) + await asyncio.sleep(0) + assert resp.status == 200 + mass.player_queues.play_media.assert_awaited_once() + assert mass.player_queues.play_media.call_args.kwargs["queue_id"] == "p2" diff --git a/tests/providers/yandex_alice/test_dialogs_control.py b/tests/providers/yandex_alice/test_dialogs_control.py new file mode 100644 index 0000000000..04bcc4af12 --- /dev/null +++ b/tests/providers/yandex_alice/test_dialogs_control.py @@ -0,0 +1,388 @@ +# ruff: noqa: D102, RUF001 +"""Tests for provider/dialogs_control.py — transfer parser + executor. + +As of v1.4.0 the regex parser only handles ``transfer`` (target player +is a per-user dynamic enum that can't fit a static intent grammar); +every other control command is recognised by Yandex through declarative +custom intents (see ``tests/test_dialogs_grammar.py``). This file +covers what's left in this module: the transfer regex, the ``parse_control`` +fallthrough behaviour, and the executor / confirmation / pluralisation +helpers used by the webhook handler regardless of which parser produced +the ``ParsedControl``. +""" + +from __future__ import annotations + +import logging +from unittest.mock import AsyncMock, MagicMock + +import pytest +from music_assistant_models.enums import RepeatMode + +from music_assistant.providers.yandex_alice.dialogs_control import ( + ParsedControl, + _plural_ru, + control_confirmation, + execute_control, + format_list_players, + parse_control, +) + + +class TestParseControlTransfer: + """``transfer`` is the only regex-handled action remaining in v1.4.0+.""" + + @pytest.mark.parametrize( + ("phrase", "expected_hint"), + [ + ("переведи на спальню", "спальню"), + ("перенеси на спальню", "спальню"), + ("продолжи в спальне", "спальне"), + ("переведи музыку на кухню", "кухню"), + # The "Алиса," prefix is stripped before regex matching. + ("Алиса, переведи на кухню", "кухню"), + ], + ) + def test_transfer_captures_target_into_player_hint( + self, + phrase: str, + expected_hint: str, + ) -> None: + """Target is captured (lower-cased) into player_hint.""" + result = parse_control(phrase) + assert result is not None + assert result.action == "transfer" + assert result.player_hint == expected_hint + assert result.value is None + + +class TestParseControlFallthrough: + """Non-transfer phrases return None. + + The platform-side intent parser (`provider.dialogs_grammar.parse_platform_intent`) + handles them, and the play-command parser handles the rest. + """ + + @pytest.mark.parametrize( + "phrase", + [ + "", + # Originally regex-handled, now platform-only. + "пауза", + "стоп", + "следующая", + "громче", + "громкость 50", + "прибавь на 20", + "перемотай вперёд на 30 секунд", + "повтори песню", + "приглуши", + "к началу", + "что играет", + "какие колонки", + "забудь колонку", + "перемешай", + # Play domain — never handled here. + "включи Metallica", + "включи джаз на кухне", + "включи мою волну", + # Garbage. + "что-то непонятное", + ], + ) + def test_returns_none(self, phrase: str) -> None: + """Anything other than a transfer phrase falls through (None).""" + assert parse_control(phrase) is None + + +class TestPluralRu: + """Russian quantitative-form picker.""" + + @pytest.mark.parametrize( + ("n", "expected"), + [ + (1, "колонку"), + (2, "колонки"), + (3, "колонки"), + (4, "колонки"), + (5, "колонок"), + (10, "колонок"), + (11, "колонок"), # 11 is exception — uses 5+ form + (12, "колонок"), + (14, "колонок"), + (21, "колонку"), # 21 → 1-form + (22, "колонки"), + (25, "колонок"), + (101, "колонку"), + (111, "колонок"), + (0, "колонок"), + ], + ) + def test_plural(self, n: int, expected: str) -> None: + """Russian quantitative agreement matches expected form for `n`.""" + assert _plural_ru(n, ("колонку", "колонки", "колонок")) == expected + + +class TestFormatListPlayers: + """`list_players` confirmation builder.""" + + def test_zero_players(self) -> None: + assert format_list_players([]) == "Не вижу ни одной колонки." + + def test_one_player(self) -> None: + p = MagicMock() + p.name = "Кухня" + p.player_id = "p1" + assert format_list_players([p]) == "Вижу одну колонку: Кухня." + + def test_three_players(self) -> None: + ps = [] + for name, pid in [("Кухня", "p1"), ("Спальня", "p2"), ("Гостиная", "p3")]: + p = MagicMock() + p.name = name + p.player_id = pid + ps.append(p) + assert format_list_players(ps) == "Вижу 3 колонки: Кухня, Спальня, Гостиная." + + def test_five_players(self) -> None: + ps = [] + for i in range(5): + p = MagicMock() + p.name = f"Player{i}" + p.player_id = f"p{i}" + ps.append(p) + text = format_list_players(ps) + assert text.startswith("Вижу 5 колонок:") + + +class TestControlConfirmation: + """User-facing confirmation strings.""" + + @pytest.mark.parametrize( + ("action", "value", "expected"), + [ + ("pause", None, "Пауза."), + ("resume", None, "Продолжаю."), + ("stop", None, "Остановил."), + ("next", None, "Следующая."), + ("previous", None, "Предыдущая."), + ("volume_up", None, "Громче."), + ("volume_down", None, "Тише."), + ("volume_set", 50, "Громкость 50."), + ("volume_relative", 20, "Громче на 20."), + ("volume_relative", -15, "Тише на 15."), + ("mute", None, "Звук выключен."), + ("unmute", None, "Звук включен."), + ("shuffle_on", None, "Включил перемешивание."), + ("shuffle_off", None, "Выключил перемешивание."), + ("repeat_off", None, "Выключил повтор."), + ("repeat_one", None, "Повтор песни."), + ("repeat_all", None, "Повтор очереди."), + ("seek_forward", 60, "Перемотал на 60 секунд вперёд."), + ("seek_back", 30, "Перемотал на 30 секунд назад."), + ("seek_start", None, "Перемотал к началу."), + ], + ) + def test_confirmation(self, action: str, value: int | None, expected: str) -> None: + ctrl = ParsedControl(action=action, value=value) # type: ignore[arg-type] + assert control_confirmation(ctrl) == expected + + +@pytest.mark.asyncio +class TestExecuteControl: + """execute_control dispatches each ParsedControl to the correct MA call.""" + + def _make_mass(self) -> MagicMock: + mass = MagicMock() + mass.player_queues = MagicMock() + mass.player_queues.pause = AsyncMock() + mass.player_queues.resume = AsyncMock() + mass.player_queues.stop = AsyncMock() + mass.player_queues.next = AsyncMock() + mass.player_queues.previous = AsyncMock() + mass.player_queues.set_shuffle = AsyncMock() + mass.player_queues.set_repeat = MagicMock() # NB: sync, not async + mass.player_queues.skip = AsyncMock() + mass.player_queues.seek = AsyncMock() + mass.players = MagicMock() + mass.players.cmd_volume_up = AsyncMock() + mass.players.cmd_volume_down = AsyncMock() + mass.players.cmd_volume_set = AsyncMock() + mass.players.cmd_volume_mute = AsyncMock() + return mass + + def _player(self) -> MagicMock: + player = MagicMock() + player.player_id = "p1" + return player + + async def test_pause_calls_pause(self) -> None: + mass = self._make_mass() + await execute_control(mass, ParsedControl(action="pause"), self._player()) + mass.player_queues.pause.assert_awaited_once_with("p1") + + async def test_resume_calls_resume(self) -> None: + mass = self._make_mass() + await execute_control(mass, ParsedControl(action="resume"), self._player()) + mass.player_queues.resume.assert_awaited_once_with("p1") + + async def test_stop_calls_stop(self) -> None: + mass = self._make_mass() + await execute_control(mass, ParsedControl(action="stop"), self._player()) + mass.player_queues.stop.assert_awaited_once_with("p1") + + async def test_next_calls_next(self) -> None: + mass = self._make_mass() + await execute_control(mass, ParsedControl(action="next"), self._player()) + mass.player_queues.next.assert_awaited_once_with("p1") + + async def test_previous_calls_previous(self) -> None: + mass = self._make_mass() + await execute_control(mass, ParsedControl(action="previous"), self._player()) + mass.player_queues.previous.assert_awaited_once_with("p1") + + async def test_volume_up(self) -> None: + mass = self._make_mass() + await execute_control(mass, ParsedControl(action="volume_up"), self._player()) + mass.players.cmd_volume_up.assert_awaited_once_with("p1") + + async def test_volume_down(self) -> None: + mass = self._make_mass() + await execute_control(mass, ParsedControl(action="volume_down"), self._player()) + mass.players.cmd_volume_down.assert_awaited_once_with("p1") + + async def test_volume_set(self) -> None: + mass = self._make_mass() + await execute_control(mass, ParsedControl(action="volume_set", value=42), self._player()) + mass.players.cmd_volume_set.assert_awaited_once_with("p1", 42) + + async def test_volume_set_none_falls_back_to_zero(self) -> None: + mass = self._make_mass() + await execute_control(mass, ParsedControl(action="volume_set", value=None), self._player()) + mass.players.cmd_volume_set.assert_awaited_once_with("p1", 0) + + async def test_volume_relative_increase(self) -> None: + mass = self._make_mass() + player = self._player() + player.volume_level = 40 + await execute_control(mass, ParsedControl(action="volume_relative", value=20), player) + mass.players.cmd_volume_set.assert_awaited_once_with("p1", 60) + + async def test_volume_relative_decrease(self) -> None: + mass = self._make_mass() + player = self._player() + player.volume_level = 70 + await execute_control(mass, ParsedControl(action="volume_relative", value=-15), player) + mass.players.cmd_volume_set.assert_awaited_once_with("p1", 55) + + async def test_volume_relative_clamps_high(self) -> None: + mass = self._make_mass() + player = self._player() + player.volume_level = 90 + await execute_control(mass, ParsedControl(action="volume_relative", value=50), player) + mass.players.cmd_volume_set.assert_awaited_once_with("p1", 100) + + async def test_volume_relative_clamps_low(self) -> None: + mass = self._make_mass() + player = self._player() + player.volume_level = 5 + await execute_control(mass, ParsedControl(action="volume_relative", value=-30), player) + mass.players.cmd_volume_set.assert_awaited_once_with("p1", 0) + + async def test_volume_relative_missing_volume_level_uses_default(self) -> None: + mass = self._make_mass() + player = self._player() + del player.volume_level + await execute_control(mass, ParsedControl(action="volume_relative", value=10), player) + mass.players.cmd_volume_set.assert_awaited_once_with("p1", 60) + + async def test_mute(self) -> None: + mass = self._make_mass() + await execute_control(mass, ParsedControl(action="mute"), self._player()) + mass.players.cmd_volume_mute.assert_awaited_once_with("p1", True) + + async def test_unmute(self) -> None: + mass = self._make_mass() + await execute_control(mass, ParsedControl(action="unmute"), self._player()) + mass.players.cmd_volume_mute.assert_awaited_once_with("p1", False) + + async def test_list_players_is_a_safe_noop_with_warning( + self, caplog: pytest.LogCaptureFixture + ) -> None: + mass = self._make_mass() + with caplog.at_level( + logging.WARNING, logger="music_assistant.providers.yandex_alice.dialogs_control" + ): + await execute_control(mass, ParsedControl(action="list_players"), self._player()) + mass.player_queues.pause.assert_not_awaited() + mass.player_queues.resume.assert_not_awaited() + mass.players.cmd_volume_set.assert_not_awaited() + assert any("list_players" in r.getMessage() for r in caplog.records) + + async def test_shuffle_on(self) -> None: + mass = self._make_mass() + await execute_control(mass, ParsedControl(action="shuffle_on"), self._player()) + mass.player_queues.set_shuffle.assert_awaited_once_with("p1", shuffle_enabled=True) + + async def test_shuffle_off(self) -> None: + mass = self._make_mass() + await execute_control(mass, ParsedControl(action="shuffle_off"), self._player()) + mass.player_queues.set_shuffle.assert_awaited_once_with("p1", shuffle_enabled=False) + + async def test_repeat_off(self) -> None: + """`set_repeat` is sync, not async — verified via `assert_called_once`.""" + mass = self._make_mass() + await execute_control(mass, ParsedControl(action="repeat_off"), self._player()) + mass.player_queues.set_repeat.assert_called_once_with("p1", RepeatMode.OFF) + + async def test_repeat_one(self) -> None: + mass = self._make_mass() + await execute_control(mass, ParsedControl(action="repeat_one"), self._player()) + mass.player_queues.set_repeat.assert_called_once_with("p1", RepeatMode.ONE) + + async def test_repeat_all(self) -> None: + mass = self._make_mass() + await execute_control(mass, ParsedControl(action="repeat_all"), self._player()) + mass.player_queues.set_repeat.assert_called_once_with("p1", RepeatMode.ALL) + + async def test_seek_forward(self) -> None: + mass = self._make_mass() + await execute_control(mass, ParsedControl(action="seek_forward", value=60), self._player()) + mass.player_queues.skip.assert_awaited_once_with("p1", seconds=60) + + async def test_seek_back_negates_value(self) -> None: + mass = self._make_mass() + await execute_control(mass, ParsedControl(action="seek_back", value=30), self._player()) + mass.player_queues.skip.assert_awaited_once_with("p1", seconds=-30) + + async def test_seek_start(self) -> None: + mass = self._make_mass() + await execute_control(mass, ParsedControl(action="seek_start"), self._player()) + mass.player_queues.seek.assert_awaited_once_with("p1", position=0) + + async def test_now_playing_is_safe_noop(self, caplog: pytest.LogCaptureFixture) -> None: + mass = self._make_mass() + with caplog.at_level( + logging.WARNING, logger="music_assistant.providers.yandex_alice.dialogs_control" + ): + await execute_control(mass, ParsedControl(action="now_playing"), self._player()) + mass.player_queues.skip.assert_not_awaited() + assert any("now_playing" in r.getMessage() for r in caplog.records) + + async def test_transfer_is_safe_noop(self, caplog: pytest.LogCaptureFixture) -> None: + mass = self._make_mass() + with caplog.at_level( + logging.WARNING, logger="music_assistant.providers.yandex_alice.dialogs_control" + ): + await execute_control( + mass, ParsedControl(action="transfer", player_hint="спальню"), self._player() + ) + mass.player_queues.skip.assert_not_awaited() + assert any("transfer" in r.getMessage() for r in caplog.records) + + async def test_underlying_failure_is_swallowed(self) -> None: + """An exception from the MA call is logged + swallowed (no re-raise).""" + mass = self._make_mass() + mass.player_queues.pause = AsyncMock(side_effect=RuntimeError("boom")) + # Must not raise. + await execute_control(mass, ParsedControl(action="pause"), self._player()) diff --git a/tests/providers/yandex_alice/test_dialogs_grammar.py b/tests/providers/yandex_alice/test_dialogs_grammar.py new file mode 100644 index 0000000000..cb1c978c66 --- /dev/null +++ b/tests/providers/yandex_alice/test_dialogs_grammar.py @@ -0,0 +1,71 @@ +# ruff: noqa: D102, RUF001 +"""Unit tests for ``provider.dialogs_grammar`` — trailing player-hint parser. + +After the v1.6.0 manifest refactor this module shrinks to a single +helper: ``extract_trailing_player_hint``. ParsedControl/ParsedCommand +construction and runtime mapping moved to +``provider.skill_manifest_provider`` and are tested in +``tests/test_skill_manifest_provider.py``. +""" + +from __future__ import annotations + +import pytest + +from music_assistant.providers.yandex_alice.dialogs_grammar import extract_trailing_player_hint + + +class TestExtractTrailingPlayerHint: + """Recover trailing "на " suffix attached to intent payloads. + + Yandex's static intents can't enumerate the per-user player list, + so the hint comes from raw command text. The function rejects + "на" tokens that introduce a numeric slot value or an action-content + word like "паузу". + """ + + @pytest.mark.parametrize( + ("text", "expected"), + [ + # Positive — trailing player hint. + ("пауза на кухне", "кухне"), + ("следующая на спальне", "спальне"), + ("приглуши на гостиной", "гостиной"), + # Multiple "на" — only the rightmost suffix is taken. + ("поставь на паузу на кухне", "кухне"), + ("громкость на 50 на кухне", "кухне"), + ("перемотай вперёд на 30 секунд на кухне", "кухне"), + # Multi-word hint stays intact. + ("пауза на колонке у окна", "колонке у окна"), + # Capitalisation is normalised. + ("Пауза На Кухне", "кухне"), + ], + ) + def test_returns_hint_when_trailing_suffix_is_a_player( + self, + text: str, + expected: str, + ) -> None: + assert extract_trailing_player_hint(text) == expected + + @pytest.mark.parametrize( + "text", + [ + # No "на" boundary at all. + "", + "пауза", + "следующий трек", + # Trailing "на" leads into a numeric slot, not a player name. + "громкость на 50", + "прибавь на 20", + "убавь на 25 процентов", + "перемотай вперёд на 30", + "перемотай вперёд на 30 секунд", + "перемотай назад на 1 минуту", + # Trailing "на" leads into a unit word with no number — still + # not a player name. + "поставь на паузу", + ], + ) + def test_returns_none_when_suffix_is_a_slot_value(self, text: str) -> None: + assert extract_trailing_player_hint(text) is None diff --git a/tests/providers/yandex_alice/test_dialogs_nlu.py b/tests/providers/yandex_alice/test_dialogs_nlu.py new file mode 100644 index 0000000000..0c05a30398 --- /dev/null +++ b/tests/providers/yandex_alice/test_dialogs_nlu.py @@ -0,0 +1,292 @@ +# ruff: noqa: RUF001, RUF003 +"""Tests for provider/dialogs_nlu.py — voice command parser + player resolver.""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +import pytest + +from music_assistant.providers.yandex_alice.dialogs_nlu import ( + ParsedCommand, + parse_command, + resolve_player, + resolve_player_candidates, +) + +# --------------------------------------------------------------------------- +# parse_command — table-driven +# --------------------------------------------------------------------------- + + +class TestParseCommand: + """Tests for parse_command across kinds + player suffix.""" + + @pytest.mark.parametrize( + ("phrase", "expected_kind", "expected_query", "expected_hint", "expected_radio"), + [ + # Bare/default search — radio_mode=True so artists and tracks + # start a radio rather than playing one item and stopping. + ("включи Metallica", "search", "metallica", None, True), + ("включи джаз", "search", "джаз", None, True), + # Track explicit + ("включи песню Yesterday", "track", "yesterday", None, False), + ("включи трек Imagine", "track", "imagine", None, False), + # Album explicit + ("включи альбом Black Album", "album", "black album", None, False), + ("включи пластинку Дыхание", "album", "дыхание", None, False), + # Artist explicit (radio) + ("включи исполнителя Metallica", "artist", "metallica", None, True), + ("включи группу Beatles", "artist", "beatles", None, True), + # Playlist explicit + ("включи плейлист утренний джаз", "playlist", "утренний джаз", None, False), + ("включи подборку рок", "playlist", "рок", None, False), + # My wave + ("включи мою волну", "my_wave", "", None, True), + ("включи свою волну", "my_wave", "", None, True), + # Genre / radio + ("включи жанр джаз", "genre", "джаз", None, True), + ("включи радио рок", "genre", "рок", None, True), + # With player suffix + ("включи Metallica на кухне", "search", "metallica", "кухне", True), + ("включи песню Yesterday на спальне", "track", "yesterday", "спальне", False), + ("включи мою волну на кухне", "my_wave", "", "кухне", True), + ("включи альбом Black Album на колонке", "album", "black album", "колонке", False), + # Punctuation, casing, alice prefix + ("Алиса, включи Metallica.", "search", "metallica", None, True), + ("ВКЛЮЧИ ПЕСНЮ Hey Jude!", "track", "hey jude", None, False), + # Different verbs (incl. infinitives Yandex sometimes returns) + ("поставь Metallica", "search", "metallica", None, True), + ("запусти джаз на кухне", "search", "джаз", "кухне", True), + ("включай Metallica", "search", "metallica", None, True), + ("включайте джаз на кухне", "search", "джаз", "кухне", True), + ("включить Iron Maiden", "search", "iron maiden", None, True), + ("сыграй Metallica на кухне", "search", "metallica", "кухне", True), + ("послушать джаз", "search", "джаз", None, True), + # P0.5 — find/open/show verbs as play synonyms + ("найди Metallica", "search", "metallica", None, True), + ("найти джаз на кухне", "search", "джаз", "кухне", True), + ("открой плейлист утренний джаз", "playlist", "утренний джаз", None, False), + ("покажи альбом Black Album", "album", "black album", None, False), + # Suspicious-split detector — content title starts with "На …" + # so the trailing "на " must NOT be treated as a player hint. + # Without the detector "включи песню На заре" → query="песню", + # hint="заре" — wrong. + ("включи песню На заре", "track", "на заре", None, False), + ("включи альбом На заре", "album", "на заре", None, False), + ("включи плейлист На заре", "playlist", "на заре", None, False), + # Genuine " на " still works after the detector. + ("включи песню Yesterday на кухне", "track", "yesterday", "кухне", False), + ("включи песню", "search", "песню", None, True), # no hint, just a marker + # add-to-queue: "добавь" verb sets enqueue_option="add"; radio_mode forced off. + ("добавь Metallica", "search", "metallica", None, False), + ("добавь песню Yesterday", "track", "yesterday", None, False), + ("добавьте альбом Black Album", "album", "black album", None, False), + ("добавить Iron Maiden на кухне", "search", "iron maiden", "кухне", False), + ], + ) + def test_parse( + self, + phrase: str, + expected_kind: str, + expected_query: str, + expected_hint: str | None, + expected_radio: bool, + ) -> None: + """Each parametrized phrase maps to the expected ParsedCommand fields.""" + result = parse_command(phrase) + assert result.kind == expected_kind, f"phrase={phrase!r}" + assert result.query == expected_query, f"phrase={phrase!r}" + assert result.player_hint == expected_hint, f"phrase={phrase!r}" + assert result.radio_mode == expected_radio, f"phrase={phrase!r}" + + def test_empty(self) -> None: + """Empty input returns a search ParsedCommand with empty query.""" + assert parse_command("") == ParsedCommand(kind="search", query="") + + def test_just_alice(self) -> None: + """Bare 'алиса' without a verb keeps the full word as query.""" + assert parse_command("алиса").query == "алиса" + + def test_enqueue_option_set_for_dobavi(self) -> None: + """'добавь Metallica' → enqueue_option='add' (None for regular 'включи').""" + assert parse_command("добавь Metallica").enqueue_option == "add" + assert parse_command("добавьте альбом Black Album").enqueue_option == "add" + assert parse_command("добавить Iron Maiden").enqueue_option == "add" + # Regular play verbs leave it as None (default REPLACE behaviour). + assert parse_command("включи Metallica").enqueue_option is None + assert parse_command("поставь Metallica").enqueue_option is None + + +# --------------------------------------------------------------------------- +# resolve_player — fixtures + cases +# --------------------------------------------------------------------------- + + +@dataclass +class MockPlayer: + """Minimal player stub for resolver tests.""" + + player_id: str = "p1" + name: str = "Player" + available: bool = True + enabled: bool = True + synced_to: str | None = None + supported_features: set[str] = field(default_factory=set) + + +class MockPlayerController: + """Minimal player controller stub.""" + + def __init__(self, players: list[MockPlayer]) -> None: + """Initialise with a fixed player list.""" + self._players = players + + def all_players(self) -> list[MockPlayer]: + """Return all players.""" + return list(self._players) + + +@dataclass +class MockMass: + """Minimal mass stub for NLU resolver tests.""" + + players: MockPlayerController + + +def _mass(players: list[MockPlayer]) -> MockMass: + return MockMass(players=MockPlayerController(players)) + + +class TestResolvePlayer: + """Tests for resolve_player — fuzzy name matching with Russian inflections.""" + + def test_exact_lowercase_match(self) -> None: + """Exact lowercase hint matches the player with that name.""" + players = [ + MockPlayer(player_id="p1", name="Кухня"), + MockPlayer(player_id="p2", name="Спальня"), + ] + result = resolve_player(_mass(players), "кухня") # type: ignore[arg-type] + assert result is not None + assert result.player_id == "p1" + + def test_inflected_match(self) -> None: + """Locative-case hint 'кухне' matches the player named 'Кухня'.""" + players = [ + MockPlayer(player_id="p1", name="Кухня"), + MockPlayer(player_id="p2", name="Спальня"), + ] + result = resolve_player(_mass(players), "кухне") # type: ignore[arg-type] + assert result is not None + assert result.player_id == "p1" + + def test_substring_match(self) -> None: + """Short hint matches a player whose name contains it as substring.""" + players = [ + MockPlayer(player_id="p1", name="Sendspin BT Group"), + MockPlayer(player_id="p2", name="Lenco LS-500"), + ] + result = resolve_player(_mass(players), "lenco") # type: ignore[arg-type] + assert result is not None + assert result.player_id == "p2" + + def test_no_match_returns_none(self) -> None: + """Unrecognised hint returns None.""" + players = [MockPlayer(player_id="p1", name="Кухня")] + assert resolve_player(_mass(players), "гостиная") is None # type: ignore[arg-type] + + def test_skips_disabled(self) -> None: + """Disabled players are excluded from candidates.""" + players = [MockPlayer(player_id="p1", name="Кухня", enabled=False)] + assert resolve_player(_mass(players), "кухня") is None # type: ignore[arg-type] + + def test_skips_unavailable(self) -> None: + """Unavailable players are excluded from candidates.""" + players = [MockPlayer(player_id="p1", name="Кухня", available=False)] + assert resolve_player(_mass(players), "кухня") is None # type: ignore[arg-type] + + def test_skips_synced(self) -> None: + """Players synced to another player are excluded from candidates.""" + players = [MockPlayer(player_id="p1", name="Кухня", synced_to="leader")] + assert resolve_player(_mass(players), "кухня") is None # type: ignore[arg-type] + + def test_default_id_used_when_no_hint(self) -> None: + """No hint falls back to default_id.""" + players = [ + MockPlayer(player_id="p1", name="Кухня"), + MockPlayer(player_id="p2", name="Спальня"), + ] + result = resolve_player(_mass(players), None, default_id="p2") # type: ignore[arg-type] + assert result is not None + assert result.player_id == "p2" + + def test_single_player_no_hint_picked(self) -> None: + """No hint with a single available player returns that player.""" + players = [MockPlayer(player_id="p1", name="Кухня")] + result = resolve_player(_mass(players), None) # type: ignore[arg-type] + assert result is not None + assert result.player_id == "p1" + + def test_exposed_ids_filter(self) -> None: + """Hint matching a player outside exposed_ids returns None.""" + players = [ + MockPlayer(player_id="p1", name="Кухня"), + MockPlayer(player_id="p2", name="Спальня"), + ] + result = resolve_player(_mass(players), "кухня", exposed_ids={"p2"}) # type: ignore[arg-type] + assert result is None + + def test_ambiguous_returns_none(self) -> None: + """When the hint matches multiple players in the same tier, resolve_player returns None. + + The caller is expected to use ``resolve_player_candidates`` directly + when it wants to surface the ambiguity (P0.3 disambiguation). + """ + players = [ + MockPlayer(player_id="p1", name="Кухня большая"), + MockPlayer(player_id="p2", name="Кухня маленькая"), + ] + # Both names start with "Кухня" — startswith tier has 2 → ambiguous. + assert resolve_player(_mass(players), "кухня") is None # type: ignore[arg-type] + + +class TestResolvePlayerCandidates: + """Tests for resolve_player_candidates — same matching, surface tier list.""" + + def test_zero_matches(self) -> None: + """No candidate match → empty list.""" + players = [MockPlayer(player_id="p1", name="Кухня")] + assert resolve_player_candidates(_mass(players), "гостиная") == [] # type: ignore[arg-type] + + def test_single_match(self) -> None: + """One unambiguous match → single-element list.""" + players = [ + MockPlayer(player_id="p1", name="Кухня"), + MockPlayer(player_id="p2", name="Спальня"), + ] + result = resolve_player_candidates(_mass(players), "кухне") # type: ignore[arg-type] + assert len(result) == 1 + assert result[0].player_id == "p1" + + def test_multiple_matches_returned_in_alphabetical_order(self) -> None: + """When multiple players match the same tier, return all sorted by name.""" + players = [ + MockPlayer(player_id="p1", name="Кухня большая"), + MockPlayer(player_id="p2", name="Кухня маленькая"), + MockPlayer(player_id="p3", name="Спальня"), + ] + result = resolve_player_candidates(_mass(players), "кухня") # type: ignore[arg-type] + assert len(result) == 2 + names = [p.name for p in result] + assert names == sorted(names, key=str.lower) + + def test_exact_tier_wins_over_startswith(self) -> None: + """An exact match excludes startswith candidates from the result.""" + players = [ + MockPlayer(player_id="p1", name="Кухня"), + MockPlayer(player_id="p2", name="Кухня большая"), + ] + result = resolve_player_candidates(_mass(players), "кухня") # type: ignore[arg-type] + # Only the exact match is returned. + assert [p.player_id for p in result] == ["p1"] diff --git a/tests/providers/yandex_alice/test_dialogs_player.py b/tests/providers/yandex_alice/test_dialogs_player.py new file mode 100644 index 0000000000..3e9dbc93d3 --- /dev/null +++ b/tests/providers/yandex_alice/test_dialogs_player.py @@ -0,0 +1,191 @@ +# ruff: noqa: RUF003 +"""Tests for provider/dialogs_player.py — content resolver + play wrapper.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from music_assistant.providers.yandex_alice.dialogs_nlu import ParsedCommand +from music_assistant.providers.yandex_alice.dialogs_player import play_for_alice, resolve_query + +# --------------------------------------------------------------------------- +# resolve_query +# --------------------------------------------------------------------------- + + +@dataclass +class _SearchResults: + artists: list[object] = field(default_factory=list) + albums: list[object] = field(default_factory=list) + tracks: list[object] = field(default_factory=list) + playlists: list[object] = field(default_factory=list) + + +def _make_mass(search_results: _SearchResults | None = None) -> MagicMock: + mass = MagicMock() + mass.music = MagicMock() + mass.music.search = AsyncMock(return_value=search_results or _SearchResults()) + mass.music_providers = [] + mass.providers = [] + mass.player_queues = MagicMock() + mass.player_queues.play_media = AsyncMock() + mass.players = MagicMock() + mass.players.get_player = MagicMock(return_value=None) + mass.players.cmd_power = AsyncMock() + return mass + + +@pytest.mark.asyncio +class TestResolveQuery: + """Tests for resolve_query — content resolver dispatching by ParsedCommand.kind.""" + + async def test_track(self) -> None: + """kind=track returns the first track search result.""" + track = MagicMock(uri="library://track/1", spec_set=["uri"]) + mass = _make_mass(_SearchResults(tracks=[track])) + result = await resolve_query(mass, ParsedCommand(kind="track", query="yesterday")) + assert result is track + mass.music.search.assert_awaited_once() + + async def test_artist(self) -> None: + """kind=artist returns the first artist search result.""" + artist = MagicMock(uri="library://artist/1", spec_set=["uri"]) + mass = _make_mass(_SearchResults(artists=[artist])) + result = await resolve_query( + mass, ParsedCommand(kind="artist", query="metallica", radio_mode=True) + ) + assert result is artist + + async def test_album(self) -> None: + """kind=album returns the first album search result.""" + album = MagicMock(uri="library://album/1", spec_set=["uri"]) + mass = _make_mass(_SearchResults(albums=[album])) + result = await resolve_query(mass, ParsedCommand(kind="album", query="black album")) + assert result is album + + async def test_playlist(self) -> None: + """kind=playlist returns the first playlist search result.""" + playlist = MagicMock(uri="library://playlist/1", spec_set=["uri"]) + mass = _make_mass(_SearchResults(playlists=[playlist])) + result = await resolve_query(mass, ParsedCommand(kind="playlist", query="rock")) + assert result is playlist + + async def test_search_kind_prefers_artist(self) -> None: + """kind=search prefers artist over playlist/track for unqualified queries. + + Users typically say a band/artist name without a "плейлист" / + "альбом" qualifier ("включи Iron Maiden"). Picking the artist + (with radio_mode=True downstream) matches the intent better than + starting an unrelated playlist that happens to contain the query. + """ + artist = MagicMock(uri="library://artist/1", spec_set=["uri"]) + playlist = MagicMock(uri="library://playlist/1", spec_set=["uri"]) + track = MagicMock(uri="library://track/1", spec_set=["uri"]) + mass = _make_mass(_SearchResults(artists=[artist], playlists=[playlist], tracks=[track])) + result = await resolve_query(mass, ParsedCommand(kind="search", query="iron maiden")) + assert result is artist + + async def test_search_no_results_returns_none(self) -> None: + """Empty search results return None.""" + mass = _make_mass(_SearchResults()) + result = await resolve_query(mass, ParsedCommand(kind="search", query="nope")) + assert result is None + + async def test_my_wave_no_provider_returns_none(self) -> None: + """kind=my_wave without yandex_music provider returns None.""" + mass = _make_mass() + result = await resolve_query(mass, ParsedCommand(kind="my_wave", query="", radio_mode=True)) + assert result is None + + async def test_search_failure_returns_none(self) -> None: + """Search exception is swallowed and returns None.""" + mass = _make_mass() + mass.music.search = AsyncMock(side_effect=RuntimeError("boom")) + result = await resolve_query(mass, ParsedCommand(kind="track", query="x")) + assert result is None + + async def test_cyrillic_query_retries_with_stemmed_form(self) -> None: + """First search empty + Cyrillic query → retry with inflection stripped.""" + track = MagicMock(uri="library://track/1", spec_set=["uri"]) + empty = _SearchResults() + hit = _SearchResults(tracks=[track]) + mass = _make_mass(empty) + # First call returns empty, second returns the track. + mass.music.search = AsyncMock(side_effect=[empty, hit]) + result = await resolve_query(mass, ParsedCommand(kind="track", query="металлику")) + assert result is track + # Two calls were made. + assert mass.music.search.await_count == 2 + # Second call used the stemmed query ("металлик" — last `у` stripped). + second_call_kwargs = mass.music.search.await_args_list[1].kwargs + assert second_call_kwargs["search_query"] == "металлик" + + async def test_ascii_query_does_not_retry(self) -> None: + """ASCII-only query is not retried — stemming has no effect.""" + empty = _SearchResults() + mass = _make_mass(empty) + mass.music.search = AsyncMock(return_value=empty) + result = await resolve_query(mass, ParsedCommand(kind="track", query="metallica")) + assert result is None + assert mass.music.search.await_count == 1 + + async def test_retry_skipped_when_stemmed_equals_original(self) -> None: + """Russian query already in stemmed form (short word) doesn't trigger retry.""" + empty = _SearchResults() + mass = _make_mass(empty) + mass.music.search = AsyncMock(return_value=empty) + # "рок" (3 chars) — too short for the suffix-strip to produce a different stem. + result = await resolve_query(mass, ParsedCommand(kind="search", query="рок")) + assert result is None + assert mass.music.search.await_count == 1 + + +# --------------------------------------------------------------------------- +# play_for_alice +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +class TestPlayForAlice: + """Tests for play_for_alice — power-on + play_media orchestration.""" + + async def test_no_player_object_still_plays(self) -> None: + """When player object is missing, play_media is called without cmd_power.""" + mass = _make_mass() + await play_for_alice(mass, "p1", "library://track/1", radio_mode=False) + mass.players.cmd_power.assert_not_awaited() + mass.player_queues.play_media.assert_awaited_once_with( + queue_id="p1", media="library://track/1", radio_mode=False + ) + + async def test_powers_on_when_off(self) -> None: + """Player with power feature and powered=False gets cmd_power before play.""" + mass = _make_mass() + player = MagicMock() + player.supported_features = {"power"} + player.powered = False + mass.players.get_player = MagicMock(return_value=player) + await play_for_alice(mass, "p1", "library://track/1", radio_mode=False) + mass.players.cmd_power.assert_awaited_once_with("p1", True) + mass.player_queues.play_media.assert_awaited_once() + + async def test_skips_power_when_already_on(self) -> None: + """Player already powered=True does not get cmd_power.""" + mass = _make_mass() + player = MagicMock() + player.supported_features = {"power"} + player.powered = True + mass.players.get_player = MagicMock(return_value=player) + await play_for_alice(mass, "p1", "library://track/1", radio_mode=False) + mass.players.cmd_power.assert_not_awaited() + + async def test_radio_mode_passed_through(self) -> None: + """radio_mode=True is forwarded to play_media.""" + mass = _make_mass() + await play_for_alice(mass, "p1", "library://artist/1", radio_mode=True) + mass.player_queues.play_media.assert_awaited_once_with( + queue_id="p1", media="library://artist/1", radio_mode=True + ) diff --git a/tests/providers/yandex_alice/test_init_actions.py b/tests/providers/yandex_alice/test_init_actions.py new file mode 100644 index 0000000000..1ed335b64d --- /dev/null +++ b/tests/providers/yandex_alice/test_init_actions.py @@ -0,0 +1,478 @@ +# ruff: noqa: PLC0415 +"""Integration tests for provider/__init__.get_config_entries — action dispatcher. + +Mocks the orchestrator entry points (``run_auto_create_step``, +``run_auto_update``) so we test the dispatcher's ``values`` rehydration, +re-create / cancel reset semantics, and the entries it places into the +returned tuple — not the orchestrator internals (those are tested elsewhere). +""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import AsyncMock, MagicMock + +import pytest +from ya_dialogs_api import ( + SkillCreationArtifacts, + SkillCreationState, + dump_artifacts, +) + +from music_assistant.providers import yandex_alice +from music_assistant.providers.yandex_alice import get_config_entries +from music_assistant.providers.yandex_alice.auto_create import ( + AutoCreateOutcome, + LocalAutoCreateStage, +) +from music_assistant.providers.yandex_alice.auto_update import AutoUpdateOutcome +from music_assistant.providers.yandex_alice.constants import ( + CONF_ACTION_AUTO_CREATE_DIALOG, + CONF_ACTION_CANCEL_DIALOG_SKILL_FLOW, + CONF_ACTION_RENAME_DIALOG_SKILL, + CONF_AUTH_X_TOKEN, + CONF_DIALOG_AUTO_CREATE_ARTIFACTS, + CONF_DIALOG_SKILL_ID, + CONF_DIALOG_SKILL_NAME, + CONF_EXTERNAL_BASE_URL, + CONF_INSTANCE_NAME, +) + + +def _make_mass() -> MagicMock: + """Build a MagicMock MA with empty player + playlist enumeration. + + ``webserver`` is explicitly set to ``None`` so the device-code page + helper short-circuits — unit tests that exercise form rendering + don't need (or want) the dynamic-route side effect. + """ + mass = MagicMock() + mass.players.all_players = MagicMock(return_value=[]) + mass.webserver = None + return mass + + +# v1.2.0: removed CONF_EXPOSED_PLAYLISTS — fetch_playlist_options no longer +# imported. The autouse fixture that used to stub it is gone too. + + +def _entries_by_key(entries: tuple[Any, ...]) -> dict[str, Any]: + """Index entries by their ``key`` for easy lookup.""" + return {e.key: e for e in entries} + + +# --------------------------------------------------------------------------- +# action=None: default form +# --------------------------------------------------------------------------- + + +class TestDefaultForm: + """No action: form has both auto-create button and (conditionally) rename.""" + + @pytest.mark.asyncio + async def test_no_action_renders_sign_in_button(self) -> None: + """When no x_token cached → Authorization block shows Sign in button.""" + from music_assistant.providers.yandex_alice.constants import CONF_ACTION_SIGN_IN + + entries = await get_config_entries(_make_mass(), values={}) + keys = _entries_by_key(entries) + # Sign in is the primary CTA in the Auth block; Create skill + # appears only after auth (or when skill_id is set manually). + assert CONF_ACTION_SIGN_IN in keys + assert CONF_ACTION_AUTO_CREATE_DIALOG not in keys + + @pytest.mark.asyncio + async def test_rename_hidden_without_skill_id_or_token(self) -> None: + """Rename ACTION is suppressed when skill_id or x_token is missing.""" + entries = await get_config_entries(_make_mass(), values={}) + keys = _entries_by_key(entries) + assert CONF_ACTION_RENAME_DIALOG_SKILL not in keys + + # v1.2.0 Phase F: rename / drift-cluster removed — Edit skill in + # Step 3 covers the same use case with a richer set of fields. + + +# --------------------------------------------------------------------------- +# action = CONF_ACTION_AUTO_CREATE_DIALOG +# --------------------------------------------------------------------------- + + +class TestAutoCreateAction: + """auto-create dispatch: invokes run_create_skill (Step 2) with derived inputs.""" + + @pytest.mark.asyncio + async def test_invokes_run_create_skill(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Click with cached_x_token → run_create_skill is awaited with skill_name + backend_uri.""" + outcome = AutoCreateOutcome( + artifacts=SkillCreationArtifacts(), + x_token=None, + user_message="started", + stage=LocalAutoCreateStage.PIPELINE_RUNNING, + ) + step_mock = AsyncMock(return_value=outcome) + monkeypatch.setattr(yandex_alice, "run_create_skill", step_mock) + + values: dict[str, Any] = { + CONF_INSTANCE_NAME: "Music Assistant", + CONF_DIALOG_SKILL_NAME: "MA Test", + CONF_EXTERNAL_BASE_URL: "https://ma.example.com", + CONF_AUTH_X_TOKEN: "tok", + } + await get_config_entries( + _make_mass(), + action=CONF_ACTION_AUTO_CREATE_DIALOG, + values=values, + ) + + step_mock.assert_awaited_once() + assert step_mock.await_args is not None + kwargs = step_mock.await_args.kwargs + assert kwargs["skill_name"] == "MA Test" + assert kwargs["backend_uri"].startswith( + "https://ma.example.com/api/yandex_dialogs/webhook/" + ) + + @pytest.mark.asyncio + async def test_https_required_short_circuits(self, monkeypatch: pytest.MonkeyPatch) -> None: + """http:// base URL → FAILED before run_create_skill is called.""" + step_mock = AsyncMock() + monkeypatch.setattr(yandex_alice, "run_create_skill", step_mock) + + values: dict[str, Any] = { + CONF_INSTANCE_NAME: "MA", + CONF_EXTERNAL_BASE_URL: "http://insecure.example.com", + CONF_AUTH_X_TOKEN: "tok", + } + await get_config_entries( + _make_mass(), + action=CONF_ACTION_AUTO_CREATE_DIALOG, + values=values, + ) + + step_mock.assert_not_awaited() + # The dispatcher writes a FAILED artifacts blob into values + from ya_dialogs_api import load_artifacts + + artifacts = load_artifacts(str(values.get(CONF_DIALOG_AUTO_CREATE_ARTIFACTS) or "") or None) + assert artifacts.state == SkillCreationState.FAILED + assert "HTTPS" in (artifacts.last_error or "") + + @pytest.mark.asyncio + async def test_re_click_on_done_resets_artifacts(self, monkeypatch: pytest.MonkeyPatch) -> None: + """If artifacts.state was DONE, dispatcher resets to NONE before stepping.""" + captured_artifacts: list[SkillCreationArtifacts] = [] + + async def _capture(**kwargs: Any) -> AutoCreateOutcome: + captured_artifacts.append(kwargs["artifacts"]) + return AutoCreateOutcome( + artifacts=SkillCreationArtifacts(), + x_token=None, + user_message="restart", + stage=LocalAutoCreateStage.IDLE, + ) + + monkeypatch.setattr(yandex_alice, "run_create_skill", _capture) + + done = SkillCreationArtifacts( + state=SkillCreationState.DONE, + skill_id="sk-old", + last_known_name="Old", + ) + values: dict[str, Any] = { + CONF_DIALOG_AUTO_CREATE_ARTIFACTS: dump_artifacts(done), + CONF_EXTERNAL_BASE_URL: "https://ma.example.com", + CONF_AUTH_X_TOKEN: "tok", + } + await get_config_entries( + _make_mass(), + action=CONF_ACTION_AUTO_CREATE_DIALOG, + values=values, + ) + assert len(captured_artifacts) == 1 + # The dispatcher reset before stepping — old skill_id is gone + assert captured_artifacts[0].state == SkillCreationState.NONE + assert captured_artifacts[0].skill_id is None + + @pytest.mark.asyncio + async def test_writes_skill_id_on_done(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Successful pipeline → CONF_DIALOG_SKILL_ID auto-populated in values.""" + outcome = AutoCreateOutcome( + artifacts=SkillCreationArtifacts( + state=SkillCreationState.DONE, + skill_id="sk-new-uuid", + last_known_name="MA", + ), + x_token=None, + user_message="✅", + stage=LocalAutoCreateStage.DONE, + ) + monkeypatch.setattr(yandex_alice, "run_create_skill", AsyncMock(return_value=outcome)) + + values: dict[str, Any] = { + CONF_EXTERNAL_BASE_URL: "https://ma.example.com", + CONF_AUTH_X_TOKEN: "tok", + } + await get_config_entries( + _make_mass(), + action=CONF_ACTION_AUTO_CREATE_DIALOG, + values=values, + ) + assert values[CONF_DIALOG_SKILL_ID] == "sk-new-uuid" + + @pytest.mark.asyncio + async def test_backup_restore_pre_sets_app_created( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """skill_id in values + artifacts NONE → pre-set to APP_CREATED to skip create_app.""" + captured_artifacts: list[SkillCreationArtifacts] = [] + + async def _capture(**kwargs: Any) -> AutoCreateOutcome: + captured_artifacts.append(kwargs["artifacts"]) + return AutoCreateOutcome( + artifacts=kwargs["artifacts"], + x_token=None, + user_message="stub", + stage=LocalAutoCreateStage.PIPELINE_RUNNING, + ) + + monkeypatch.setattr(yandex_alice, "run_create_skill", _capture) + + # Empty artifacts but skill_id present (config restored from backup) + values: dict[str, Any] = { + CONF_DIALOG_SKILL_ID: "sk-existing-uuid", + CONF_EXTERNAL_BASE_URL: "https://ma.example.com", + CONF_AUTH_X_TOKEN: "tok", + } + await get_config_entries( + _make_mass(), + action=CONF_ACTION_AUTO_CREATE_DIALOG, + values=values, + ) + + assert captured_artifacts[0].state == SkillCreationState.APP_CREATED + assert captured_artifacts[0].skill_id == "sk-existing-uuid" + + +# --------------------------------------------------------------------------- +# action = CONF_ACTION_RENAME_DIALOG_SKILL +# --------------------------------------------------------------------------- + + +class TestRenameAction: + """Rename dispatch: invokes run_auto_update with skill_name + cached token.""" + + @pytest.mark.asyncio + async def test_invokes_run_auto_update(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Click → run_auto_update awaited with skill_name + backend_uri + cached_x_token.""" + result = AutoUpdateOutcome( + artifacts=SkillCreationArtifacts( + state=SkillCreationState.DONE, + skill_id="sk-1", + last_known_name="New Name", + ), + x_token=None, + user_message="✅ обновлён", + ) + update_mock = AsyncMock(return_value=result) + monkeypatch.setattr(yandex_alice, "run_auto_update", update_mock) + + values: dict[str, Any] = { + CONF_DIALOG_SKILL_NAME: "New Name", + CONF_EXTERNAL_BASE_URL: "https://ma.example.com", + CONF_AUTH_X_TOKEN: "tok", + CONF_DIALOG_AUTO_CREATE_ARTIFACTS: dump_artifacts( + SkillCreationArtifacts( + state=SkillCreationState.DONE, + skill_id="sk-1", + last_known_name="Old Name", + ) + ), + } + await get_config_entries( + _make_mass(), + action=CONF_ACTION_RENAME_DIALOG_SKILL, + values=values, + ) + + update_mock.assert_awaited_once() + assert update_mock.await_args is not None + kwargs = update_mock.await_args.kwargs + assert kwargs["skill_name"] == "New Name" + assert kwargs["cached_x_token"] == "tok" + + @pytest.mark.asyncio + async def test_token_cleared_on_auth_failure(self, monkeypatch: pytest.MonkeyPatch) -> None: + """run_auto_update returns x_token='' → values clears CONF_AUTH_X_TOKEN.""" + result = AutoUpdateOutcome( + artifacts=SkillCreationArtifacts( + state=SkillCreationState.FAILED, + skill_id="sk-1", + last_error="истёк", + ), + x_token="", + user_message="auth expired", + ) + monkeypatch.setattr(yandex_alice, "run_auto_update", AsyncMock(return_value=result)) + + values: dict[str, Any] = { + CONF_AUTH_X_TOKEN: "stale", + CONF_EXTERNAL_BASE_URL: "https://ma.example.com", + CONF_DIALOG_AUTO_CREATE_ARTIFACTS: dump_artifacts( + SkillCreationArtifacts(state=SkillCreationState.DONE, skill_id="sk-1") + ), + } + await get_config_entries( + _make_mass(), + action=CONF_ACTION_RENAME_DIALOG_SKILL, + values=values, + ) + assert values[CONF_AUTH_X_TOKEN] == "" + + +# --------------------------------------------------------------------------- +# action = CONF_ACTION_CANCEL_DIALOG_SKILL_FLOW +# --------------------------------------------------------------------------- + + +class TestCancelAction: + """Cancel: reset artifacts; keep cached x_token (sign-in stays valid).""" + + @pytest.mark.asyncio + async def test_resets_artifacts(self) -> None: + """Cancel resets artifacts to NONE; cached x_token preserved.""" + values: dict[str, Any] = { + CONF_DIALOG_AUTO_CREATE_ARTIFACTS: dump_artifacts( + SkillCreationArtifacts( + state=SkillCreationState.APP_CREATED, + skill_id="sk-orphan", + ) + ), + CONF_AUTH_X_TOKEN: "preserve-me", + CONF_EXTERNAL_BASE_URL: "https://ma.example.com", + } + await get_config_entries( + _make_mass(), + action=CONF_ACTION_CANCEL_DIALOG_SKILL_FLOW, + values=values, + ) + + from ya_dialogs_api import load_artifacts + + # Artifacts reset to NONE + rehydrated = load_artifacts(str(values[CONF_DIALOG_AUTO_CREATE_ARTIFACTS])) + assert rehydrated.state == SkillCreationState.NONE + assert rehydrated.skill_id is None + # Token preserved + assert values[CONF_AUTH_X_TOKEN] == "preserve-me" + + +# --------------------------------------------------------------------------- +# Code-review fixes — targeted regression coverage +# --------------------------------------------------------------------------- + + +class TestStableWebhookSecret: + """Webhook secret must NOT regenerate between action clicks. + + Otherwise auto-create would register a webhook URL containing a secret + that the next render replaces with a different one — orphaning the + Yandex-side webhook against MA's eventual saved secret. + """ + + @pytest.mark.asyncio + async def test_secret_reused_across_action_clicks( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Two consecutive clicks see the same backend_uri/secret (no regen).""" + captured_uris: list[str] = [] + + async def _capture(**kwargs: Any) -> AutoCreateOutcome: + captured_uris.append(kwargs["backend_uri"]) + return AutoCreateOutcome( + artifacts=SkillCreationArtifacts(), + x_token=None, + user_message="ok", + stage=LocalAutoCreateStage.IDLE, + ) + + monkeypatch.setattr(yandex_alice, "run_create_skill", _capture) + + # First click: no secret in values → dispatcher generates + writes back. + values: dict[str, Any] = { + CONF_EXTERNAL_BASE_URL: "https://ma.example.com", + CONF_AUTH_X_TOKEN: "tok", + } + await get_config_entries( + _make_mass(), + action=CONF_ACTION_AUTO_CREATE_DIALOG, + values=values, + ) + + # The dispatcher must have stabilised the secret in values + # so subsequent renders see the same one. + first_secret = str(values.get("dialog_webhook_secret") or "") + assert first_secret + + # Second click — must reuse the same secret in backend_uri. + await get_config_entries( + _make_mass(), + action=CONF_ACTION_AUTO_CREATE_DIALOG, + values=values, + ) + + assert len(captured_uris) == 2 + assert captured_uris[0] == captured_uris[1] + assert first_secret in captured_uris[0] + + +class TestDeriveStageRespectsCachedToken: + """Intermediate artifact state without cached x_token → IDLE, not Resume. + + Otherwise the button label says "Resume" but the next click actually + starts a fresh Device Flow — confusing UX. + """ + + @pytest.mark.asyncio + async def test_intermediate_state_without_token_shows_sign_in(self) -> None: + """artifacts=APP_CREATED + no x_token → Auth block shows Sign in. + + Skill block ALSO renders because skill_id is known (manual + backup-restore path), but the primary CTA stays the Sign in + button until auth is resolved. + """ + from music_assistant.providers.yandex_alice.constants import CONF_ACTION_SIGN_IN + + artifacts = SkillCreationArtifacts( + state=SkillCreationState.APP_CREATED, + skill_id="sk-partial", + ) + values: dict[str, Any] = { + CONF_DIALOG_AUTO_CREATE_ARTIFACTS: dump_artifacts(artifacts), + # No CONF_AUTH_X_TOKEN → next click will hit Device Flow + } + entries = await get_config_entries(_make_mass(), values=values) + keys = _entries_by_key(entries) + assert CONF_ACTION_SIGN_IN in keys + assert keys[CONF_ACTION_SIGN_IN].action_label == "Sign in to Yandex Passport" + + @pytest.mark.asyncio + async def test_intermediate_state_with_token_renders_resume_label(self) -> None: + """artifacts=APP_CREATED + cached x_token → button says 'Resume'.""" + artifacts = SkillCreationArtifacts( + state=SkillCreationState.APP_CREATED, + skill_id="sk-partial", + ) + values: dict[str, Any] = { + CONF_DIALOG_AUTO_CREATE_ARTIFACTS: dump_artifacts(artifacts), + CONF_AUTH_X_TOKEN: "tok", + } + entries = await get_config_entries(_make_mass(), values=values) + keys = _entries_by_key(entries) + # v1.2.0 #19: PIPELINE_RUNNING button = "Continue setup" + assert keys[CONF_ACTION_AUTO_CREATE_DIALOG].action_label == "Continue setup" + + +# v1.2.0 Phase C refactor: the self-resuming Device Flow is gone. +# Sign-in is a single blocking action that opens an AuthenticationHelper +# popup; there is no mid-flow form reload to re-render the user_code in. +# The dedicated test class for that case has been removed. diff --git a/tests/providers/yandex_alice/test_skill_manifest_provider.py b/tests/providers/yandex_alice/test_skill_manifest_provider.py new file mode 100644 index 0000000000..a133ca7e53 --- /dev/null +++ b/tests/providers/yandex_alice/test_skill_manifest_provider.py @@ -0,0 +1,550 @@ +# ruff: noqa: D101, D102, D103, PT018 +# mypy: disable-error-code="union-attr" +"""Unit tests for ``provider.skill_manifest_provider``. + +Covers: + +* parse_intent — runtime dispatch via the manifest's ``[intents.runtime]`` + blocks, replicating the v1.5.0 behaviour 1:1. +* status / manifest / grammar / entities — bundled-default vs + override-valid vs override-invalid file-state matrix. +* export_to_override / import_from_paste / reset_override / + validate_override_message — UI action helpers, including base64 + paste fallback. +""" + +from __future__ import annotations + +import base64 +from pathlib import Path +from typing import Any +from unittest.mock import MagicMock + +import pytest + +from music_assistant.providers.yandex_alice.dialogs_control import ParsedControl +from music_assistant.providers.yandex_alice.dialogs_nlu import ParsedCommand +from music_assistant.providers.yandex_alice.skill_manifest_provider import ( + SkillManifestProvider, +) + + +@pytest.fixture +def fake_mass(tmp_path: Path) -> MagicMock: + """MA mock with ``storage_path`` pointing to a tmpdir.""" + mass = MagicMock() + mass.storage_path = str(tmp_path) + return mass + + +@pytest.fixture +def provider(fake_mass: MagicMock) -> SkillManifestProvider: + return SkillManifestProvider(fake_mass) + + +def _intent(slots: dict[str, Any] | None = None) -> dict[str, Any]: + return {"slots": slots} if slots is not None else {} + + +# --------------------------------------------------------------------------- +# parse_intent — runtime dispatch (parity with v1.5.0 behaviour) +# --------------------------------------------------------------------------- + + +class TestParseIntentNoSlot: + """No-slot control intents map directly to ParsedControl(action).""" + + @pytest.mark.parametrize( + ("form_name", "expected_action"), + [ + ("control.pause", "pause"), + ("control.resume", "resume"), + ("control.next", "next"), + ("control.previous", "previous"), + ("control.stop", "stop"), + ("control.volume_up", "volume_up"), + ("control.volume_down", "volume_down"), + ("control.shuffle_on", "shuffle_on"), + ("control.shuffle_off", "shuffle_off"), + ("control.now_playing", "now_playing"), + ("control.mute", "mute"), + ("control.unmute", "unmute"), + ("control.seek_start", "seek_start"), + ("control.repeat_one", "repeat_one"), + ("control.repeat_all", "repeat_all"), + ("control.repeat_off", "repeat_off"), + ("control.list_players", "list_players"), + ("control.forget_player", "forget_player"), + ], + ) + def test_no_slot_intent( + self, + provider: SkillManifestProvider, + form_name: str, + expected_action: str, + ) -> None: + result = provider.parse_intent({form_name: {}}) + assert isinstance(result, ParsedControl) + assert result.action == expected_action + + +class TestParseIntentVolumeSet: + def test_int_value_passes_through(self, provider: SkillManifestProvider) -> None: + result = provider.parse_intent({"control.volume_set": _intent({"level": {"value": 50}})}) + assert isinstance(result, ParsedControl) + assert result.action == "volume_set" and result.value == 50 + + def test_float_value_coerced_to_int(self, provider: SkillManifestProvider) -> None: + result = provider.parse_intent({"control.volume_set": _intent({"level": {"value": 50.7}})}) + assert result is not None and result.value == 50 + + def test_above_max_clamped(self, provider: SkillManifestProvider) -> None: + result = provider.parse_intent({"control.volume_set": _intent({"level": {"value": 150}})}) + assert result is not None and result.value == 100 + + def test_below_min_clamped(self, provider: SkillManifestProvider) -> None: + result = provider.parse_intent({"control.volume_set": _intent({"level": {"value": -5}})}) + assert result is not None and result.value == 0 + + def test_missing_slot_skips(self, provider: SkillManifestProvider) -> None: + assert provider.parse_intent({"control.volume_set": _intent({})}) is None + + +class TestParseIntentVolumeRelative: + def test_increase_with_delta(self, provider: SkillManifestProvider) -> None: + result = provider.parse_intent( + {"control.volume_increase": _intent({"delta": {"value": 20}})} + ) + assert isinstance(result, ParsedControl) + assert result.action == "volume_relative" and result.value == 20 + + def test_decrease_negates(self, provider: SkillManifestProvider) -> None: + result = provider.parse_intent( + {"control.volume_decrease": _intent({"delta": {"value": 15}})} + ) + assert result is not None and result.value == -15 + + def test_increase_default_when_missing(self, provider: SkillManifestProvider) -> None: + # default=10 for missing slot. + result = provider.parse_intent({"control.volume_increase": _intent({})}) + assert result is not None and result.value == 10 + + def test_decrease_default_when_missing(self, provider: SkillManifestProvider) -> None: + result = provider.parse_intent({"control.volume_decrease": _intent({})}) + assert result is not None and result.value == -10 + + def test_negative_delta_normalised(self, provider: SkillManifestProvider) -> None: + # abs_clamp normalises sign. + result = provider.parse_intent( + {"control.volume_increase": _intent({"delta": {"value": -5}})} + ) + assert result is not None and result.value == 5 + + def test_huge_delta_clamped(self, provider: SkillManifestProvider) -> None: + result = provider.parse_intent( + {"control.volume_increase": _intent({"delta": {"value": 999}})} + ) + assert result is not None and result.value == 100 + + +class TestParseIntentSeek: + def test_forward_seconds(self, provider: SkillManifestProvider) -> None: + result = provider.parse_intent( + { + "control.seek_forward": _intent( + {"amount": {"value": 30}, "unit": {"value": "seconds"}} + ) + } + ) + assert isinstance(result, ParsedControl) + assert result.action == "seek_forward" and result.value == 30 + + def test_forward_minutes_converts(self, provider: SkillManifestProvider) -> None: + result = provider.parse_intent( + { + "control.seek_forward": _intent( + {"amount": {"value": 2}, "unit": {"value": "minutes"}} + ) + } + ) + assert result is not None and result.value == 120 + + def test_back_minutes_converts(self, provider: SkillManifestProvider) -> None: + result = provider.parse_intent( + {"control.seek_back": _intent({"amount": {"value": 1}, "unit": {"value": "minutes"}})} + ) + assert result is not None and result.action == "seek_back" and result.value == 60 + + def test_unit_missing_defaults_seconds(self, provider: SkillManifestProvider) -> None: + result = provider.parse_intent({"control.seek_forward": _intent({"amount": {"value": 45}})}) + assert result is not None and result.value == 45 + + def test_zero_amount_skipped(self, provider: SkillManifestProvider) -> None: + # reject_if_below=1 → 0 is skipped. + assert ( + provider.parse_intent({"control.seek_forward": _intent({"amount": {"value": 0}})}) + is None + ) + + def test_huge_amount_capped(self, provider: SkillManifestProvider) -> None: + # cap=86400 → 100000 sec rejected, falls through. + assert ( + provider.parse_intent({"control.seek_forward": _intent({"amount": {"value": 100_000}})}) + is None + ) + + def test_minutes_above_cap_rejected(self, provider: SkillManifestProvider) -> None: + # 2000 minutes * 60 = 120000 sec > cap. + assert ( + provider.parse_intent( + { + "control.seek_forward": _intent( + {"amount": {"value": 2000}, "unit": {"value": "minutes"}} + ) + } + ) + is None + ) + + +class TestParseIntentPlay: + def test_my_wave(self, provider: SkillManifestProvider) -> None: + result = provider.parse_intent({"play.my_wave": {}}) + assert isinstance(result, ParsedCommand) + assert result.kind == "my_wave" + assert result.radio_mode is True + assert result.query == "" + + +class TestParseIntentEdgeCases: + def test_none_returns_none(self, provider: SkillManifestProvider) -> None: + assert provider.parse_intent(None) is None + + def test_empty_dict_returns_none(self, provider: SkillManifestProvider) -> None: + assert provider.parse_intent({}) is None + + def test_unknown_intent_returns_none(self, provider: SkillManifestProvider) -> None: + assert provider.parse_intent({"unknown.intent": {}}) is None + + def test_first_recognised_wins(self, provider: SkillManifestProvider) -> None: + result = provider.parse_intent( + {"unknown.intent": {}, "control.pause": {}, "control.next": {}} + ) + assert result is not None and result.action in ("pause", "next") + + +# --------------------------------------------------------------------------- +# status / manifest / grammar / entities — file-state matrix +# --------------------------------------------------------------------------- + + +class TestManifestStatus: + """``status()`` reports bundled / override_valid / override_invalid.""" + + def test_no_override_file(self, provider: SkillManifestProvider) -> None: + s = provider.status() + assert s.source == "bundled" + assert s.error is None + assert s.intent_count > 0 + + def test_override_valid(self, provider: SkillManifestProvider) -> None: + provider.export_to_override() + s = provider.status() + assert s.source == "override_valid" + assert s.override_path.exists() + assert s.error is None + + def test_override_invalid(self, provider: SkillManifestProvider) -> None: + path = provider.override_path + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("not = valid = toml = ===", encoding="utf-8") + s = provider.status() + assert s.source == "override_invalid" + assert s.error is not None + # Bundled defaults still load as fallback. + assert s.intent_count > 0 + + +class TestEffectiveManifest: + """``manifest()`` / ``grammar()`` / ``entities()`` use override when valid.""" + + def test_bundled_when_no_override(self, provider: SkillManifestProvider) -> None: + bundled = provider.manifest() + assert len(bundled.intents) > 0 + # All slot-bearing intents have runtime mapping in the bundled default. + slot_bearing = [i for i in bundled.intents if i.runtime and i.runtime.mapping] + assert len(slot_bearing) >= 5 # volume_set + volume_inc/dec + seek_fwd/back + + def test_override_replaces_bundled(self, provider: SkillManifestProvider) -> None: + path = provider.override_path + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + 'schema_version = 1\n[entities]\ntext = ""\n' + '[[intents]]\nform_name = "control.test"\ngrammar = "root: x"\n' + '[intents.runtime]\nkind = "control"\naction = "pause"\n', + encoding="utf-8", + ) + m = provider.manifest() + assert len(m.intents) == 1 + assert m.intents[0].form_name == "control.test" + + def test_invalid_override_falls_back(self, provider: SkillManifestProvider) -> None: + provider.override_path.parent.mkdir(parents=True, exist_ok=True) + provider.override_path.write_text("garbage = = =", encoding="utf-8") + bundled = provider.manifest() + assert len(bundled.intents) > 0 + # Sanity — bundled has the original intents. + assert any(i.form_name == "control.pause" for i in bundled.intents) + + +# --------------------------------------------------------------------------- +# UI actions — export / import / reset / validate +# --------------------------------------------------------------------------- + + +class TestExportToOverride: + def test_first_export_creates_file(self, provider: SkillManifestProvider) -> None: + msg = provider.export_to_override() + assert "Манифест экспортирован" in msg + assert provider.override_path.exists() + text = provider.override_path.read_text(encoding="utf-8") + assert "schema_version" in text + assert "control.pause" in text + + def test_second_export_does_not_overwrite(self, provider: SkillManifestProvider) -> None: + provider.export_to_override() + # Tamper with the override. + provider.override_path.write_text("schema_version = 1\n", encoding="utf-8") + msg = provider.export_to_override() + assert "уже существует" in msg + # File still has the user's edit. + assert provider.override_path.read_text(encoding="utf-8") == "schema_version = 1\n" + + +class TestImportFromPaste: + def _valid_toml(self) -> str: + return ( + 'schema_version = 1\n[entities]\ntext = ""\n' + '[[intents]]\nform_name = "control.x"\ngrammar = "root: x"\n' + '[intents.runtime]\nkind = "control"\naction = "pause"\n' + ) + + def test_empty_paste_no_op(self, provider: SkillManifestProvider) -> None: + msg = provider.import_from_paste("") + assert "пусто" in msg + assert not provider.last_import_success + assert not provider.override_path.exists() + + def test_whitespace_paste_no_op(self, provider: SkillManifestProvider) -> None: + msg = provider.import_from_paste(" \n\n ") + assert "пусто" in msg + assert not provider.last_import_success + + def test_valid_toml_writes_override(self, provider: SkillManifestProvider) -> None: + text = self._valid_toml() + msg = provider.import_from_paste(text) + assert "импортирован" in msg.lower() + assert provider.last_import_success + assert provider.override_path.read_text(encoding="utf-8") == text + + def test_invalid_toml_does_not_write(self, provider: SkillManifestProvider) -> None: + msg = provider.import_from_paste("not = = valid") + assert "невалиден" in msg.lower() or "не удался" in msg + assert not provider.last_import_success + assert not provider.override_path.exists() + + def test_valid_toml_invalid_schema(self, provider: SkillManifestProvider) -> None: + # TOML parses but schema_version missing. + msg = provider.import_from_paste('[entities]\ntext = ""\n') + assert not provider.last_import_success + assert not provider.override_path.exists() + assert "schema_version" in msg or "невалиден" in msg.lower() + + def test_base64_paste_decoded(self, provider: SkillManifestProvider) -> None: + text = self._valid_toml() + encoded = base64.b64encode(text.encode("utf-8")).decode("ascii") + msg = provider.import_from_paste(f"data:base64,{encoded}") + assert provider.last_import_success + assert "импортирован" in msg.lower() + assert provider.override_path.read_text(encoding="utf-8") == text + + def test_base64_invalid_paste_rejected(self, provider: SkillManifestProvider) -> None: + msg = provider.import_from_paste("data:base64,!!!!not-base64!!!!") + assert not provider.last_import_success + assert "base64" in msg.lower() or "не удался" in msg + + def test_base64_paste_with_wrapped_lines(self, provider: SkillManifestProvider) -> None: + # `base64 -i skill.toml` wraps at 76 cols by default — the decoder + # must tolerate the embedded newlines & arbitrary whitespace. + text = self._valid_toml() + encoded = base64.b64encode(text.encode("utf-8")).decode("ascii") + wrapped = "\n".join(encoded[i : i + 60] for i in range(0, len(encoded), 60)) + wrapped_with_spaces = f" {wrapped}\n " + msg = provider.import_from_paste(f"data:base64,{wrapped_with_spaces}") + assert provider.last_import_success, msg + assert provider.override_path.read_text(encoding="utf-8") == text + + +class TestResetOverride: + def test_idempotent_when_absent(self, provider: SkillManifestProvider) -> None: + msg = provider.reset_override() + assert "отсутствует" in msg + assert not provider.override_path.exists() + + def test_removes_override_file(self, provider: SkillManifestProvider) -> None: + provider.export_to_override() + assert provider.override_path.exists() + msg = provider.reset_override() + assert "удалён" in msg + assert not provider.override_path.exists() + + +class TestValidateOverrideMessage: + def test_no_override(self, provider: SkillManifestProvider) -> None: + msg = provider.validate_override_message() + assert "отсутствует" in msg + + def test_valid_override(self, provider: SkillManifestProvider) -> None: + provider.export_to_override() + msg = provider.validate_override_message() + assert "✓" in msg + assert "валиден" in msg + + def test_invalid_override(self, provider: SkillManifestProvider) -> None: + path = provider.override_path + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text('schema_version = 999\n[entities]\ntext = ""\n', encoding="utf-8") + msg = provider.validate_override_message() + assert "✗" in msg + assert "невалиден" in msg.lower() + + +# --------------------------------------------------------------------------- +# Storage path resolution +# --------------------------------------------------------------------------- + + +class TestStorageRoot: + def test_uses_mass_storage_path_when_set(self, tmp_path: Path) -> None: + mass = MagicMock() + mass.storage_path = str(tmp_path) + provider = SkillManifestProvider(mass) + assert provider.override_path == tmp_path / "yandex_alice" / "skill.toml" + + def test_falls_back_to_home_dir_when_missing(self) -> None: + mass = MagicMock(spec=[]) # no storage_path attribute + provider = SkillManifestProvider(mass) + # Just check shape — we don't actually want to write into ~/.musicassistant. + assert provider.override_path.parts[-2:] == ("yandex_alice", "skill.toml") + + +# --------------------------------------------------------------------------- +# Effective-manifest cache +# --------------------------------------------------------------------------- + + +class TestResolvedCache: + """``manifest()`` / ``status()`` reuse the parsed manifest until stat changes. + + The cache is keyed by ``(exists, mtime_ns)``: identical stat → same + object; mtime change → re-parse; mutating actions invalidate even + when mtime stays put (some filesystems coalesce sub-ms writes). + """ + + def test_bundled_path_returns_same_object(self, provider: SkillManifestProvider) -> None: + first = provider.manifest() + second = provider.manifest() + assert first is second + + def test_override_path_returns_same_object_until_mtime_changes( + self, provider: SkillManifestProvider + ) -> None: + provider.export_to_override() + first = provider.manifest() + second = provider.manifest() + assert first is second + # Touch the file with a fresh mtime — cache must invalidate. + import os as _os # noqa: PLC0415 + + st = provider.override_path.stat() + _os.utime(provider.override_path, ns=(st.st_atime_ns, st.st_mtime_ns + 1_000_000)) + third = provider.manifest() + assert third is not first + + def test_export_invalidates_cache(self, provider: SkillManifestProvider) -> None: + # Prime cache on bundled. + before = provider.manifest() + assert provider.status().source == "bundled" + provider.export_to_override() + # Now status must observe the freshly-written override. + assert provider.status().source == "override_valid" + # Effective manifest still parses to a SkillManifest equivalent + # to the bundled one (Export copies bundled bytes verbatim) but + # is a fresh object — i.e. cache was actually invalidated. + after = provider.manifest() + assert after is not before + + def test_reset_invalidates_cache(self, provider: SkillManifestProvider) -> None: + provider.export_to_override() + assert provider.status().source == "override_valid" + provider.reset_override() + assert provider.status().source == "bundled" + + def test_import_invalidates_cache(self, provider: SkillManifestProvider) -> None: + toml = ( + 'schema_version = 1\n[entities]\ntext = ""\n' + '[[intents]]\nform_name = "control.test"\ngrammar = "root: x"\n' + '[intents.runtime]\nkind = "control"\naction = "pause"\n' + ) + # Prime cache on bundled. + provider.manifest() + provider.import_from_paste(toml) + m = provider.manifest() + assert len(m.intents) == 1 + assert m.intents[0].form_name == "control.test" + + +class TestBundledResourceLookup: + """Bundled manifest resolution must work after the upstream package rename. + + Locally this provider is ``provider``; upstream-synced into + ``music-assistant/server`` it lives under + ``music_assistant.providers.yandex_alice``. The bundled-resource + lookup must not hardcode a package name — regressing this breaks + MA startup as soon as the wheel ships, which is much harder to + catch than a unit-test failure. + """ + + def test_bundled_lookup_uses_dunder_package(self, provider: SkillManifestProvider) -> None: + # Smoke: the lookup resolves at all. + text = provider._bundled_manifest_text() + assert "schema_version" in text + + def test_no_hardcoded_package_string_literal(self) -> None: + # Static guard: source must not contain the legacy literal. + from music_assistant.providers.yandex_alice import skill_manifest_provider as smp # noqa: PLC0415 + + source = Path(smp.__file__).read_text(encoding="utf-8") + assert '"music_assistant.providers.yandex_alice.data"' not in source + assert "'provider.data'" not in source + + +class TestAtomicWrite: + """Export / Import use tmp+rename so readers never see a half-written file.""" + + def test_export_does_not_leave_tmp_file(self, provider: SkillManifestProvider) -> None: + provider.export_to_override() + siblings = list(provider.override_path.parent.iterdir()) + assert len(siblings) == 1 + assert siblings[0] == provider.override_path + + def test_import_does_not_leave_tmp_file(self, provider: SkillManifestProvider) -> None: + toml = ( + 'schema_version = 1\n[entities]\ntext = ""\n' + '[[intents]]\nform_name = "control.test"\ngrammar = "root: x"\n' + '[intents.runtime]\nkind = "control"\naction = "pause"\n' + ) + provider.import_from_paste(toml) + siblings = list(provider.override_path.parent.iterdir()) + assert len(siblings) == 1 + assert siblings[0] == provider.override_path diff --git a/tests/providers/yandex_alice/test_url_helpers.py b/tests/providers/yandex_alice/test_url_helpers.py new file mode 100644 index 0000000000..2718e0858f --- /dev/null +++ b/tests/providers/yandex_alice/test_url_helpers.py @@ -0,0 +1,107 @@ +"""Tests for provider/url_helpers.py — public-HTTPS detection + validation.""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +from music_assistant.providers.yandex_alice.url_helpers import ( + is_public_https_url, + try_detect_public_https_url, + validate_external_base_url, +) + + +class TestIsPublicHttpsUrl: + """is_public_https_url accepts only public HTTPS URLs.""" + + def test_public_https_hostname(self) -> None: + """Hostname over HTTPS → True.""" + assert is_public_https_url("https://ma.example.com") is True + + def test_public_https_with_port_and_path(self) -> None: + """Port + path don't change the verdict.""" + assert is_public_https_url("https://ha.example.com:8123/api") is True + + def test_http_rejected(self) -> None: + """Plain http:// → False (Yandex requires TLS).""" + assert is_public_https_url("http://ma.example.com") is False + + def test_empty_rejected(self) -> None: + """Empty string → False.""" + assert is_public_https_url("") is False + + def test_loopback_rejected(self) -> None: + """Localhost / 127.0.0.1 → False (not reachable from Yandex).""" + assert is_public_https_url("https://127.0.0.1") is False + assert is_public_https_url("https://localhost:8095") is False + + def test_private_ip_rejected(self) -> None: + """RFC1918 / Docker bridge addresses → False.""" + assert is_public_https_url("https://192.168.1.10") is False + assert is_public_https_url("https://10.0.0.1") is False + assert is_public_https_url("https://172.22.0.2:8095") is False + + def test_invalid_scheme_rejected(self) -> None: + """ws://, ftp://, etc. → False.""" + assert is_public_https_url("ws://ma.example.com") is False + + +class TestValidateExternalBaseUrl: + """validate_external_base_url is the ConfigEntry-compatible front.""" + + def test_empty_string_allowed(self) -> None: + """Empty value is OK at form-load (user hasn't typed yet).""" + assert validate_external_base_url("") is True + + def test_whitespace_allowed(self) -> None: + """Whitespace-only → treated as empty, OK.""" + assert validate_external_base_url(" ") is True + + def test_https_allowed(self) -> None: + """Valid HTTPS URL → True.""" + assert validate_external_base_url("https://ma.example.com") is True + + def test_http_rejected(self) -> None: + """http:// → False.""" + assert validate_external_base_url("http://ma.example.com") is False + + def test_non_string_rejected(self) -> None: + """Other types (e.g. int, None) → False.""" + assert validate_external_base_url(None) is False + assert validate_external_base_url(42) is False + + +class TestTryDetectPublicHttpsUrl: + """try_detect_public_https_url reads mass.webserver.base_url only. + + The Yandex webhook lives on the webserver (port 8095 by default), + not on the streamserver (8097). Probing ``mass.streams.base_url`` + would hand the user the streamserver URL — we must read the + webserver URL exclusively. + """ + + def test_webserver_public_https_returned(self) -> None: + """When mass.webserver.base_url is a public HTTPS URL, return it.""" + mass = MagicMock() + mass.streams.base_url = "http://172.22.0.2:8097" # ignored + mass.webserver.base_url = "https://ma.example.com" + assert try_detect_public_https_url(mass) == "https://ma.example.com" + + def test_streams_public_https_does_not_leak_through(self) -> None: + """Even if streams happens to be public HTTPS, we don't return it.""" + mass = MagicMock() + mass.streams.base_url = "https://stream.example.com" + mass.webserver.base_url = "http://172.22.0.2:8095" + assert try_detect_public_https_url(mass) is None + + def test_webserver_internal_returns_none(self) -> None: + """Typical Docker setup — internal → None.""" + mass = MagicMock() + mass.streams.base_url = "http://172.22.0.2:8097" + mass.webserver.base_url = "http://172.22.0.2:8095" + assert try_detect_public_https_url(mass) is None + + def test_no_attributes_returns_none(self) -> None: + """Older MA without these attrs → None, no exception.""" + mass = MagicMock(spec=[]) # no attributes whatsoever + assert try_detect_public_https_url(mass) is None diff --git a/tests/providers/yandex_alice/test_webhook_probe.py b/tests/providers/yandex_alice/test_webhook_probe.py new file mode 100644 index 0000000000..76fa4bbfff --- /dev/null +++ b/tests/providers/yandex_alice/test_webhook_probe.py @@ -0,0 +1,150 @@ +"""Tests for provider/webhook_probe.py — outgoing reachability probe.""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import MagicMock + +import aiohttp +import pytest + +from music_assistant.providers.yandex_alice.webhook_probe import probe_webhook_reachability + + +def _patch_session_post(monkeypatch: pytest.MonkeyPatch, *, status: int) -> None: + """Replace aiohttp.ClientSession with one whose POST returns the given status.""" + + class _FakeResp: + def __init__(self, code: int) -> None: + self.status = code + + async def __aenter__(self) -> _FakeResp: + return self + + async def __aexit__(self, *_: object) -> None: + return None + + class _FakeSession: + def __init__(self, *args: Any, **kwargs: Any) -> None: + pass + + async def __aenter__(self) -> _FakeSession: + return self + + async def __aexit__(self, *_: object) -> None: + return None + + def post(self, *_args: Any, **_kwargs: Any) -> _FakeResp: + return _FakeResp(status) + + monkeypatch.setattr(aiohttp, "ClientSession", _FakeSession) + + +def _patch_session_raises(monkeypatch: pytest.MonkeyPatch, exc: BaseException) -> None: + """Replace aiohttp.ClientSession so .post(...) raises *exc*.""" + + class _FakeSession: + def __init__(self, *args: Any, **kwargs: Any) -> None: + pass + + async def __aenter__(self) -> _FakeSession: + return self + + async def __aexit__(self, *_: object) -> None: + return None + + def post(self, *_args: Any, **_kwargs: Any) -> Any: + class _Ctx: + async def __aenter__(self) -> Any: + raise exc + + async def __aexit__(self, *_: object) -> None: + return None + + return _Ctx() + + monkeypatch.setattr(aiohttp, "ClientSession", _FakeSession) + + +class TestPreflightValidation: + """Pre-network input checks short-circuit before any HTTP call.""" + + @pytest.mark.asyncio + async def test_empty_base_url(self) -> None: + """Empty external_base_url → fail with friendly hint.""" + ok, msg = await probe_webhook_reachability("", "secret") + assert ok is False + assert "External base URL is empty" in msg + + @pytest.mark.asyncio + async def test_http_base_url(self) -> None: + """http:// scheme → fail before any network call.""" + ok, msg = await probe_webhook_reachability("http://ma.example.com", "secret") + assert ok is False + assert "HTTPS" in msg + + @pytest.mark.asyncio + async def test_empty_secret(self) -> None: + """Empty webhook secret → fail.""" + ok, msg = await probe_webhook_reachability("https://ma.example.com", "") + assert ok is False + assert "secret" in msg.lower() + + +class TestStatusClassification: + """HTTP status codes map to specific human-readable verdicts.""" + + @pytest.mark.asyncio + async def test_401_means_reachable(self, monkeypatch: pytest.MonkeyPatch) -> None: + """HTTP 401 from sentinel skill_id → reachable.""" + _patch_session_post(monkeypatch, status=401) + ok, msg = await probe_webhook_reachability("https://ma.example.com", "secret") + assert ok is True + assert "reachable" in msg.lower() + assert "401" in msg + + @pytest.mark.asyncio + async def test_200_also_reachable(self, monkeypatch: pytest.MonkeyPatch) -> None: + """HTTP 200 — also reachable (rare but possible).""" + _patch_session_post(monkeypatch, status=200) + ok, msg = await probe_webhook_reachability("https://ma.example.com", "secret") + assert ok is True + assert "reachable" in msg.lower() + + @pytest.mark.asyncio + async def test_404_no_route(self, monkeypatch: pytest.MonkeyPatch) -> None: + """HTTP 404 → user has wrong webhook secret in config.""" + _patch_session_post(monkeypatch, status=404) + ok, msg = await probe_webhook_reachability("https://ma.example.com", "secret") + assert ok is False + assert "404" in msg + + @pytest.mark.asyncio + async def test_502_reverse_proxy(self, monkeypatch: pytest.MonkeyPatch) -> None: + """HTTP 502 → reverse proxy / upstream issue.""" + _patch_session_post(monkeypatch, status=502) + ok, msg = await probe_webhook_reachability("https://ma.example.com", "secret") + assert ok is False + assert "502" in msg + + +class TestNetworkErrors: + """aiohttp exceptions are mapped to specific user-facing messages.""" + + @pytest.mark.asyncio + async def test_timeout(self, monkeypatch: pytest.MonkeyPatch) -> None: + """asyncio.TimeoutError → user-readable timeout message.""" + _patch_session_raises(monkeypatch, TimeoutError()) + ok, msg = await probe_webhook_reachability("https://ma.example.com", "secret") + assert ok is False + assert "imed out" in msg + + @pytest.mark.asyncio + async def test_ssl_error(self, monkeypatch: pytest.MonkeyPatch) -> None: + """aiohttp.ClientSSLError → 'TLS / certificate' message.""" + connection = MagicMock() + ssl_err = aiohttp.ClientSSLError(connection, OSError("bad cert")) + _patch_session_raises(monkeypatch, ssl_err) + ok, msg = await probe_webhook_reachability("https://ma.example.com", "secret") + assert ok is False + assert "TLS" in msg or "certificate" in msg.lower()