From e5f1469760af139a111890b69e7500e5b449cec8 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 6 May 2026 08:46:40 +0000 Subject: [PATCH 01/10] feat(yandex_alice): add yandex_alice provider v1.0.0 --- .../providers/yandex_alice/__init__.py | 244 +++ .../providers/yandex_alice/constants.py | 67 + .../providers/yandex_alice/dialogs.py | 1306 +++++++++++++ .../providers/yandex_alice/dialogs_control.py | 468 +++++ .../providers/yandex_alice/dialogs_nlu.py | 474 +++++ .../providers/yandex_alice/dialogs_player.py | 313 ++++ .../providers/yandex_alice/manifest.json | 17 + .../providers/yandex_alice/playlists.py | 57 + .../providers/yandex_alice/plugin.py | 83 + tests/providers/yandex_alice/__init__.py | 1 + tests/providers/yandex_alice/test_dialogs.py | 1648 +++++++++++++++++ .../yandex_alice/test_dialogs_control.py | 474 +++++ .../yandex_alice/test_dialogs_nlu.py | 292 +++ .../yandex_alice/test_dialogs_player.py | 191 ++ 14 files changed, 5635 insertions(+) create mode 100644 music_assistant/providers/yandex_alice/__init__.py create mode 100644 music_assistant/providers/yandex_alice/constants.py create mode 100644 music_assistant/providers/yandex_alice/dialogs.py create mode 100644 music_assistant/providers/yandex_alice/dialogs_control.py create mode 100644 music_assistant/providers/yandex_alice/dialogs_nlu.py create mode 100644 music_assistant/providers/yandex_alice/dialogs_player.py create mode 100644 music_assistant/providers/yandex_alice/manifest.json create mode 100644 music_assistant/providers/yandex_alice/playlists.py create mode 100644 music_assistant/providers/yandex_alice/plugin.py create mode 100644 tests/providers/yandex_alice/__init__.py create mode 100644 tests/providers/yandex_alice/test_dialogs.py create mode 100644 tests/providers/yandex_alice/test_dialogs_control.py create mode 100644 tests/providers/yandex_alice/test_dialogs_nlu.py create mode 100644 tests/providers/yandex_alice/test_dialogs_player.py diff --git a/music_assistant/providers/yandex_alice/__init__.py b/music_assistant/providers/yandex_alice/__init__.py new file mode 100644 index 0000000000..5d1ef08172 --- /dev/null +++ b/music_assistant/providers/yandex_alice/__init__.py @@ -0,0 +1,244 @@ +"""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 is **manual** in this release: +1. User creates a custom dialog skill in https://dialogs.yandex.ru/developer +2. User points the skill's webhook URL at MA's + ``/api/yandex_dialogs/webhook/`` endpoint. +3. User pastes the skill ID, skill token, and webhook secret into the + provider's config form. + +A future release will add an auto-create flow that uses the +``ya-dialogs-api`` library to register the skill programmatically; for now +manual setup keeps the v1.0 surface small and reliable. +""" + +from __future__ import annotations + +import logging +import secrets +from typing import TYPE_CHECKING + +from music_assistant_models.config_entries import ConfigEntry, ConfigValueOption +from music_assistant_models.enums import ConfigEntryType, ProviderFeature + +from .constants import ( + CONF_DIALOG_SKILL_ENABLED, + CONF_DIALOG_SKILL_ID, + CONF_DIALOG_SKILL_NAME, + CONF_DIALOG_SKILL_TOKEN, + CONF_DIALOG_WEBHOOK_SECRET, + CONF_EXPOSED_PLAYERS, + CONF_EXPOSED_PLAYLISTS, + CONF_EXTERNAL_BASE_URL, + CONF_INSTANCE_NAME, + DIALOG_DEFAULT_NAME, + DIALOG_NAME_MAX_LEN, + DIALOG_NAME_MIN_LEN, + DIALOG_WEBHOOK_BASE_PATH, + YANDEX_DIALOGS_DEVELOPER_URL, +) +from .playlists import fetch_playlist_options +from .plugin import YandexAlicePlugin + +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 _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 + + +async def get_config_entries( + 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. + + Args: + mass: MusicAssistant runtime, used to enumerate players/playlists. + instance_id: Stable provider instance ID (unused — single-instance). + action: Action key when the user clicked a button. Currently unused + (auto-create / rename actions ship in a later release). + values: Current form values (echoed back across submissions). + """ + _ = instance_id, action + values = values or {} + + # Generate a webhook secret on first open if the user hasn't set one yet. + existing_secret = str(values.get(CONF_DIALOG_WEBHOOK_SECRET) or "").strip() + default_secret = existing_secret or _generate_webhook_secret() + + instance_name = str(values.get(CONF_INSTANCE_NAME) or DIALOG_DEFAULT_NAME) + + # Player options used both by the exposed-players selector and the + # exposed-playlists selector below. + player_options = await _list_player_options(mass) + try: + playlist_options = await fetch_playlist_options(mass) + except Exception as exc: + _LOGGER.debug("could not enumerate playlists: %s", exc) + playlist_options = [] + + base_url_hint = ( + "Public HTTPS URL of this Music Assistant instance, " + "as Yandex will see it (e.g. https://ma.example.com). " + "Leave empty to use MA's global Base URL setting." + ) + + return ( + ConfigEntry( + key="label_intro", + type=ConfigEntryType.LABEL, + label=( + "🎙️ Yandex Alice voice control. Create a custom dialog " + f"skill at {YANDEX_DIALOGS_DEVELOPER_URL}, point its " + "webhook at the URL shown below, and paste the skill ID + " + "token from the dev console." + ), + ), + ConfigEntry( + key=CONF_INSTANCE_NAME, + type=ConfigEntryType.STRING, + label="Instance name", + description=( + "Display name shown to users. Pick something they will say " + 'to invoke the skill, e.g. "Music Assistant" → ' + "«Алиса, попроси Music Assistant …»" + ), + required=False, + default_value=DIALOG_DEFAULT_NAME, + ), + ConfigEntry( + key=CONF_EXTERNAL_BASE_URL, + type=ConfigEntryType.STRING, + label="External base URL (optional)", + description=base_url_hint, + required=False, + default_value="", + ), + ConfigEntry( + key=CONF_DIALOG_SKILL_ENABLED, + type=ConfigEntryType.BOOLEAN, + label="Enable dialog skill", + description=( + "Turn this on once you have created the custom skill in " + "the Yandex dev console and pasted the credentials below." + ), + required=False, + default_value=False, + ), + ConfigEntry( + key=CONF_DIALOG_SKILL_NAME, + type=ConfigEntryType.STRING, + label="Skill name (informational)", + description=( + f"Used in UI labels only. Min {DIALOG_NAME_MIN_LEN}, " + f"max {DIALOG_NAME_MAX_LEN} characters." + ), + required=False, + default_value=instance_name, + ), + ConfigEntry( + key=CONF_DIALOG_SKILL_ID, + type=ConfigEntryType.STRING, + label="Skill ID", + description=( + "UUID from the dev console URL (https://dialogs.yandex.ru/developer/skills/)." + ), + required=False, + default_value="", + ), + ConfigEntry( + key=CONF_DIALOG_SKILL_TOKEN, + type=ConfigEntryType.SECURE_STRING, + label="Skill OAuth token", + description=( + "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)." + ), + required=False, + default_value="", + ), + ConfigEntry( + key=CONF_DIALOG_WEBHOOK_SECRET, + type=ConfigEntryType.SECURE_STRING, + label="Webhook URL secret", + description=( + "Random secret embedded in the webhook URL. The full URL to " + "paste into the dev console's webhook field is " + f"{DIALOG_WEBHOOK_BASE_PATH}/. " + "Pre-filled with a fresh value; click 'Save' to commit." + ), + required=False, + default_value=default_secret, + ), + ConfigEntry( + key=CONF_EXPOSED_PLAYERS, + type=ConfigEntryType.STRING, + label="Voice-controllable players", + description=( + "Players the skill is allowed to control. Leave empty to " + "expose all players known to MA." + ), + multi_value=True, + options=player_options, + required=False, + default_value=[], + ), + ConfigEntry( + key=CONF_EXPOSED_PLAYLISTS, + type=ConfigEntryType.STRING, + label="Voice-addressable playlists", + description=( + "Optional curated list of playlists the user can ask for by " + "name. Leave empty for full library search." + ), + multi_value=True, + options=playlist_options, + required=False, + default_value=[], + ), + ) diff --git a/music_assistant/providers/yandex_alice/constants.py b/music_assistant/providers/yandex_alice/constants.py new file mode 100644 index 0000000000..787ea70447 --- /dev/null +++ b/music_assistant/providers/yandex_alice/constants.py @@ -0,0 +1,67 @@ +"""Constants for the Yandex Alice (Dialogs custom skill) plugin provider.""" + +from __future__ import annotations + +import os + +# --------------------------------------------------------------------------- +# 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" +CONF_EXPOSED_PLAYLISTS = "exposed_playlists" + +# 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" + +# Dialog skill (Yandex Dialogs custom skill — voice playback) +CONF_DIALOG_SKILL_ENABLED = "dialog_skill_enabled" +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" +CONF_DIALOG_AUTO_CREATE_SESSION_ID = "dialog_auto_create_session_id" + +# --------------------------------------------------------------------------- +# Config actions (config-flow buttons) +# --------------------------------------------------------------------------- +CONF_ACTION_AUTO_CREATE_DIALOG = "auto_create_dialog_skill" +CONF_ACTION_RENAME_DIALOG_SKILL = "rename_dialog_skill" + +# --------------------------------------------------------------------------- +# 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. +DIALOG_CHANNEL = os.environ.get("MA_YANDEX_DIALOG_CHANNEL", "aliceSkill") +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/dialogs.py b/music_assistant/providers/yandex_alice/dialogs.py new file mode 100644 index 0000000000..ff356de1a2 --- /dev/null +++ b/music_assistant/providers/yandex_alice/dialogs.py @@ -0,0 +1,1306 @@ +# 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 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 ( + control_confirmation, + execute_control, + format_list_players, + parse_control, +) +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 + +if TYPE_CHECKING: + from music_assistant.mass import MusicAssistant + + +_LOGGER = logging.getLogger(__name__) + + +# Static stress-mark dictionary for common response words (P0.2). +# Keys are case-insensitive whole-word matches; the marker is `+` placed +# directly before the stressed vowel — Yandex Alice TTS supports this +# inline syntax. Keep small and high-confidence; band/track names are +# left as-is (those need a separate phoneme dict — P2.3). +_TTS_STRESS_MARKS: dict[str, str] = { + "включаю": "включ+аю", + "ставлю": "ст+авлю", + "пауза": "п+ауза", + "продолжаю": "продолж+аю", + "следующая": "сл+едующая", + "предыдущая": "пред+ыдущая", + "громче": "гр+омче", + "тише": "т+ише", + "громкость": "гр+омкость", + "колонке": "кол+онке", + "колонку": "кол+онку", +} + +_TTS_WORD_RE = re.compile(r"[А-Яа-яЁё]+") + + +def _tts_for(text: str) -> str: + """Add `+` stress markers to known words for cleaner Alice TTS. + + Pure substitution — unknown words pass through unchanged. The map is + intentionally small (high-confidence Russian response words only); + expand via PRs as patterns emerge. + """ + if not text: + return text + + def _sub(match: re.Match[str]) -> str: + word = match.group(0) + replacement = _TTS_STRESS_MARKS.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 {} + + +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, + logger: logging.Logger | None = None, + ) -> None: + """Initialize the handler. + + Args: + mass: MusicAssistant instance. + skill_id: Configured ``CONF_DIALOG_SKILL_ID``; payloads with a + different ``session.skill_id`` are rejected. + webhook_secret: Random secret embedded in the webhook URL. + exposed_player_ids: Optional restriction set; only these players + are addressable by voice (passed to the player resolver). + 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._logger = logger or _LOGGER + self._unregister_callbacks: list[Callable[[], None]] = [] + # In-process state cache; see _STATE_CACHE_TTL_SEC / _MAX. + self._state_cache: OrderedDict[str, tuple[dict[str, Any], float]] = OrderedDict() + + 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: # noqa: PLR0915 + # 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) + + 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 = {} + + # 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) + + # 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() + + # 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. + self._logger.debug( + "Webhook recv: cmd=%r req_type=%s is_new=%s pending=%s " + "(session=%s app=%s cache=%s) awaiting=%s default_player=%s " + "session_id=%s", + command, + 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, + 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, + ) + + # 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. + if control := parse_control(command): + 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, + ) + + # 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, + ) + 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, + ) + + # ------------------------------------------------------------------- + # 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], + ) -> 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, + ) + 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, + ) + + 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, + ) + + # ------------------------------------------------------------------- + # 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], + ) -> 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) + 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, + ) + + # ------------------------------------------------------------------- + # 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], + ) -> 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), + session_state=new_session_state, + application_state=new_app_state, + user_state_update=user_state_update, + ) + + # ------------------------------------------------------------------- + # 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, + ) -> 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 kept on the response for screen surfaces, but voice + is the primary channel. + """ + # 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 = [ + { + "title": (p.name or p.player_id)[:64], + "payload": {"player_id": p.player_id}, + "hide": True, + } + for p in capped + ] + # 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], + ) -> 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, + ) + + # 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, + ) + # 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, + ) + + # ------------------------------------------------------------------- + # 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, + ) -> 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. + + 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 + 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..f3d7c0e338 --- /dev/null +++ b/music_assistant/providers/yandex_alice/dialogs_control.py @@ -0,0 +1,468 @@ +# ruff: noqa: RUF001 +"""Playback-control NLU + executor for the Yandex Dialogs custom skill. + +Handles utterances that don't carry a music query — pause/resume/next/ +previous/volume up-down-set/mute/unmute. Runs *before* the play-command +parser in the webhook flow; if `parse_control` returns None the handler +falls through to the existing music-search path. +""" + +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", + "mute", + "unmute", + "list_players", + "forget_player", + # v1.9.0 — six new actions + "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 + + +# Pattern catalogue. Order matters within each tier — first match wins. +# All patterns are anchored (^...$) to require a whole-phrase match. +_CONTROL_PATTERNS: tuple[tuple[re.Pattern[str], ControlAction], ...] = ( + # list_players — informational query "what speakers do you see?". + # Matched before the play-verb-strip can interpret "покажи колонки" + # as a play kind=search query="колонки". + ( + re.compile( + r"^сколько\s+колонок(?:\s+(?:ты\s+)?(?:видишь|знаешь))?$", + re.IGNORECASE, + ), + "list_players", + ), + ( + re.compile( + r"^какие\s+колонки(?:\s+(?:ты\s+)?(?:видишь|знаешь|есть))?$", + re.IGNORECASE, + ), + "list_players", + ), + (re.compile(r"^какие\s+у\s+тебя\s+колонки$", re.IGNORECASE), "list_players"), + (re.compile(r"^перечисли\s+колонки$", re.IGNORECASE), "list_players"), + (re.compile(r"^список\s+колонок$", re.IGNORECASE), "list_players"), + (re.compile(r"^покажи\s+колонки$", re.IGNORECASE), "list_players"), + (re.compile(r"^назови\s+колонки$", re.IGNORECASE), "list_players"), + # forget_player — clears the saved "default player" so the next + # play command without an explicit hint asks again. Useful when + # the user previously picked a player and now wants to change + # without re-stating the name on every turn. + (re.compile(r"^забудь\s+колонку$", re.IGNORECASE), "forget_player"), + (re.compile(r"^сбрось\s+колонку$", re.IGNORECASE), "forget_player"), + (re.compile(r"^забудь\s+плеер$", re.IGNORECASE), "forget_player"), + (re.compile(r"^забудь\s+выбор$", re.IGNORECASE), "forget_player"), + (re.compile(r"^сбрось\s+выбор$", re.IGNORECASE), "forget_player"), + (re.compile(r"^выбери\s+колонку\s+заново$", re.IGNORECASE), "forget_player"), + (re.compile(r"^поменяй\s+колонку$", re.IGNORECASE), "forget_player"), + (re.compile(r"^сменить\s+колонку$", re.IGNORECASE), "forget_player"), + # now_playing — info query about the current track (no MA mutation) + (re.compile(r"^что\s+(?:сейчас\s+)?играет$", re.IGNORECASE), "now_playing"), + (re.compile(r"^что\s+(?:мы\s+)?слушаем$", re.IGNORECASE), "now_playing"), + (re.compile(r"^что\s+за\s+(?:песня|трек|композиция)$", re.IGNORECASE), "now_playing"), + (re.compile(r"^какой\s+(?:сейчас\s+)?(?:трек|играет)$", re.IGNORECASE), "now_playing"), + # shuffle_on / shuffle_off + (re.compile(r"^перемешай$", re.IGNORECASE), "shuffle_on"), + (re.compile(r"^включи\s+перемешивание$", re.IGNORECASE), "shuffle_on"), + (re.compile(r"^случайный\s+порядок$", re.IGNORECASE), "shuffle_on"), + (re.compile(r"^в\s+случайном\s+порядке$", re.IGNORECASE), "shuffle_on"), + (re.compile(r"^выключи\s+перемешивание$", re.IGNORECASE), "shuffle_off"), + (re.compile(r"^не\s+перемешивай$", re.IGNORECASE), "shuffle_off"), + (re.compile(r"^по\s+порядку$", re.IGNORECASE), "shuffle_off"), + # repeat — order matters: more-specific (with object) first, then bare verbs + ( + re.compile( + r"^повтор(?:и)?\s+(?:песн[июя]|трек(?:а)?|композицию|композиция|эту|эту\s+песню)$", + re.IGNORECASE, + ), + "repeat_one", + ), + ( + re.compile( + r"^повтор(?:и)?\s+(?:всё|все|очередь|плейлист|список)$", + re.IGNORECASE, + ), + "repeat_all", + ), + (re.compile(r"^повторяй$", re.IGNORECASE), "repeat_all"), + (re.compile(r"^включи\s+повтор$", re.IGNORECASE), "repeat_all"), + (re.compile(r"^выключи\s+повтор$", re.IGNORECASE), "repeat_off"), + (re.compile(r"^не\s+повторяй$", re.IGNORECASE), "repeat_off"), + # seek_start — absolute seek to position 0 (start of current track) + (re.compile(r"^(?:перемотай\s+)?к\s+началу$", re.IGNORECASE), "seek_start"), + (re.compile(r"^(?:перемотай\s+)?в\s+начало$", re.IGNORECASE), "seek_start"), + (re.compile(r"^начни\s+(?:трек\s+)?заново$", re.IGNORECASE), "seek_start"), + # mute / unmute — explicit "звук" disambiguates from play-verb "включи" + (re.compile(r"^включи\s+звук$", re.IGNORECASE), "unmute"), + (re.compile(r"^сделай\s+звук$", re.IGNORECASE), "unmute"), + (re.compile(r"^приглуши$", re.IGNORECASE), "mute"), + (re.compile(r"^выключи\s+звук$", re.IGNORECASE), "mute"), + (re.compile(r"^беззвучно$", re.IGNORECASE), "mute"), + # resume — must come before "включи" play-verb stripping; we run before + # parse_command anyway, but match anchored phrases here for clarity + (re.compile(r"^продолжи(?:ть)?$", re.IGNORECASE), "resume"), + (re.compile(r"^включи\s+снова$", re.IGNORECASE), "resume"), + (re.compile(r"^возобнови(?:ть)?$", re.IGNORECASE), "resume"), + # pause + (re.compile(r"^пауза$", re.IGNORECASE), "pause"), + (re.compile(r"^на\s+паузу$", re.IGNORECASE), "pause"), + (re.compile(r"^поставь\s+на\s+паузу$", re.IGNORECASE), "pause"), + (re.compile(r"^останови\s+музыку$", re.IGNORECASE), "pause"), + # stop — bare "выключи" maps to stop (safer than power-off) + (re.compile(r"^стоп$", re.IGNORECASE), "stop"), + (re.compile(r"^останови$", re.IGNORECASE), "stop"), + (re.compile(r"^выключи$", re.IGNORECASE), "stop"), + (re.compile(r"^выключи\s+музыку$", re.IGNORECASE), "stop"), + # next track + (re.compile(r"^следующ(?:ая|ий|ее)?(?:\s+трек)?$", re.IGNORECASE), "next"), + (re.compile(r"^дальше$", re.IGNORECASE), "next"), + (re.compile(r"^переключи$", re.IGNORECASE), "next"), + # previous track + (re.compile(r"^предыдущ(?:ая|ий|ее)?(?:\s+трек)?$", re.IGNORECASE), "previous"), + (re.compile(r"^назад$", re.IGNORECASE), "previous"), + (re.compile(r"^верни(?:сь)?$", re.IGNORECASE), "previous"), + # volume up + (re.compile(r"^громче$", re.IGNORECASE), "volume_up"), + (re.compile(r"^сделай\s+громче$", re.IGNORECASE), "volume_up"), + (re.compile(r"^прибавь(?:\s+громкость)?$", re.IGNORECASE), "volume_up"), + # volume down + (re.compile(r"^тише$", re.IGNORECASE), "volume_down"), + (re.compile(r"^сделай\s+тише$", re.IGNORECASE), "volume_down"), + (re.compile(r"^убавь(?:\s+громкость)?$", re.IGNORECASE), "volume_down"), +) + +# Volume-set with explicit number, e.g. "громкость 50", "громкость на 30 процентов". +_VOLUME_SET_RE = re.compile( + r"^(?:сделай\s+)?громкост(?:ь|и)\s+(?:на\s+)?(?P\d{1,3})(?:\s+процентов)?$", + re.IGNORECASE, +) + +# Seek forward / backward with numeric amount + optional unit. Unit defaults +# to seconds when missing. "Минут[уы]" multiplies by 60. +_SEEK_FORWARD_RE = re.compile( + r"^(?:перемотай\s+|перемотать\s+|промотай\s+)?" + r"(?:вперёд|вперед)\s+(?:на\s+)?(?P\d{1,4})" + r"(?:\s+(?Pсек(?:унд[уы]?)?|мин(?:ут[уы]?)?))?$", + re.IGNORECASE, +) +_SEEK_BACK_RE = re.compile( + r"^(?:перемотай\s+|перемотать\s+|промотай\s+)?" + r"назад\s+(?:на\s+)?(?P\d{1,4})" + r"(?:\s+(?Pсек(?:унд[уы]?)?|мин(?:ут[уы]?)?))?$", + re.IGNORECASE, +) + +# Transfer playback to a target player. The target name is captured into +# `player_hint`; SOURCE comes from the caller's `default_id`. +_TRANSFER_RE = re.compile( + r"^(?:переведи|перенеси|продолжи)\s+(?:музыку\s+)?(?:на|в)\s+(?P.+)$", + re.IGNORECASE, +) + + +def _seek_seconds(match: re.Match[str]) -> int | None: + """Parse the digit + optional unit out of a seek-pattern match.""" + try: + n = int(match.group("n")) + except (TypeError, ValueError): + return None + unit = (match.group("unit") or "").lower() + if unit.startswith("мин"): + n *= 60 + return n + + +def _try_match(cleaned: str, player_hint: str | None) -> ParsedControl | None: + """Match `cleaned` against control patterns; return ParsedControl or None.""" + if not cleaned: + return None + if vmatch := _VOLUME_SET_RE.match(cleaned): + try: + value = int(vmatch.group("n")) + except (TypeError, ValueError): + return None + return ParsedControl( + action="volume_set", + value=max(0, min(100, value)), + player_hint=player_hint, + ) + if smatch := _SEEK_FORWARD_RE.match(cleaned): + seconds = _seek_seconds(smatch) + if seconds is not None: + return ParsedControl(action="seek_forward", value=seconds, player_hint=player_hint) + if smatch := _SEEK_BACK_RE.match(cleaned): + seconds = _seek_seconds(smatch) + if seconds is not None: + return ParsedControl(action="seek_back", value=seconds, player_hint=player_hint) + if tmatch := _TRANSFER_RE.match(cleaned): + # For transfer, the captured group goes into `player_hint` — + # it's the TARGET. The handler resolves it; SOURCE is `default_id`. + # `player_hint` from the caller's "на " suffix split is + # ignored here (transfer phrases already include the target). + return ParsedControl( + action="transfer", + player_hint=tmatch.group("target").strip().lower(), + ) + for pattern, action in _CONTROL_PATTERNS: + if pattern.match(cleaned): + return ParsedControl(action=action, player_hint=player_hint) + return None + + +_NA_BOUNDARY_RE = re.compile(r"\s+на\s+", re.IGNORECASE) + + +def parse_control(text: str) -> ParsedControl | None: + """Classify a voice utterance as a control command, or None to fall through. + + Tries each `на`-boundary in the cleaned text as a possible + "на " suffix, starting from the rightmost. First yields + (cleaned, None) for the whole-phrase case so that "поставь на + паузу" still matches `pause` with no hint, even when the phrase + contains "на" inside the action keywords. + """ + 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 + + # Whole-phrase first (no hint). + if direct := _try_match(cleaned, player_hint=None): + return direct + + # Then try each "на " split from right to left, so e.g. + # "поставь на паузу на кухне" splits at the *last* "на". + matches = list(_NA_BOUNDARY_RE.finditer(cleaned)) + for m in reversed(matches): + rest = cleaned[: m.start()].strip() + hint = cleaned[m.end() :].strip().lower() + if not rest or not hint: + continue + if matched := _try_match(rest, player_hint=hint): + return matched + return None + + +# --------------------------------------------------------------------------- +# Executor + confirmation +# --------------------------------------------------------------------------- + + +def _plural_ru(n: int, forms: tuple[str, str, str]) -> str: + """Pick the correct Russian quantitative form for `n`. + + Args: + n: The number. + 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 == "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 == "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_nlu.py b/music_assistant/providers/yandex_alice/dialogs_nlu.py new file mode 100644 index 0000000000..76d98042cd --- /dev/null +++ b/music_assistant/providers/yandex_alice/dialogs_nlu.py @@ -0,0 +1,474 @@ +# 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. + + Examples: + "включи 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) + + The ``_split_player_hint`` parameter is internal: 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. Don't 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/manifest.json b/music_assistant/providers/yandex_alice/manifest.json new file mode 100644 index 0000000000..b5f1c48a75 --- /dev/null +++ b/music_assistant/providers/yandex_alice/manifest.json @@ -0,0 +1,17 @@ +{ + "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": [ + "[dext0r/yandex_smart_home](https://github.com/dext0r/yandex_smart_home)" + ], + "requirements": [ + "ya-passport-auth==1.3.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/playlists.py b/music_assistant/providers/yandex_alice/playlists.py new file mode 100644 index 0000000000..e517d1ddda --- /dev/null +++ b/music_assistant/providers/yandex_alice/playlists.py @@ -0,0 +1,57 @@ +"""Helpers for exposing MA library playlists as Yandex input_source modes. + +Wraps the few MA APIs used by the playlist-source feature so the rest of +the provider stays decoupled from `mass.music`/`player_queues` internals +and so tests can stub a single seam. +""" + +from __future__ import annotations + +import asyncio +import logging +from typing import TYPE_CHECKING + +from music_assistant_models.config_entries import ConfigValueOption + +if TYPE_CHECKING: + from music_assistant.mass import MusicAssistant + + +_LOGGER = logging.getLogger(__name__) + + +async def fetch_playlist_options(mass: MusicAssistant) -> list[ConfigValueOption]: + """Build ConfigValueOption list of all library playlists for the config form. + + Pages through `iter_library_items` so the dropdown is not silently + truncated for users with very large libraries (the underlying + `library_items(limit=...)` defaults to 500). Used at config-render + time only. Fail-soft: returns [] if mass.music or the playlists + controller is not yet available (e.g. provider load order). + """ + options: list[ConfigValueOption] = [] + try: + async for playlist in mass.music.playlists.iter_library_items(): + if not playlist.uri: + continue + provider_label = playlist.provider or "" + title = f"{playlist.name} ({provider_label})" if provider_label else playlist.name + options.append(ConfigValueOption(title=title, value=playlist.uri)) + except asyncio.CancelledError: + raise + except Exception as exc: + # Fail-soft: this runs on every config-form render and races with + # provider/database startup. Don't spam stack traces — debug-level + # is enough for diagnostics, normal renders stay quiet. + _LOGGER.debug("Library playlists not available yet: %s", exc) + return [] + return options + + +async def play_playlist(mass: MusicAssistant, player_id: str, uri: str) -> None: + """Start playback of a playlist URI on the given player's queue. + + `play_media` accepts a URI string directly and resolves the playlist's + tracks into the queue. queue_id == player_id for the player's own queue. + """ + await mass.player_queues.play_media(queue_id=player_id, media=uri) diff --git a/music_assistant/providers/yandex_alice/plugin.py b/music_assistant/providers/yandex_alice/plugin.py new file mode 100644 index 0000000000..84a8051c41 --- /dev/null +++ b/music_assistant/providers/yandex_alice/plugin.py @@ -0,0 +1,83 @@ +"""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_ENABLED, + CONF_DIALOG_SKILL_ID, + 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_enabled = bool(self.config.get_value(CONF_DIALOG_SKILL_ENABLED)) + 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 + + async def loaded_in_mass(self) -> None: + """Register the Dialogs webhook route once the webserver is up.""" + if not ( + self._dialog_skill_enabled and 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, + ) + 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.""" + return { + "instance_name": self._instance_name, + "dialog_skill_enabled": self._dialog_skill_enabled, + "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": self._dialogs_handler is not None, + } 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_dialogs.py b/tests/providers/yandex_alice/test_dialogs.py new file mode 100644 index 0000000000..fb16556be9 --- /dev/null +++ b/tests/providers/yandex_alice/test_dialogs.py @@ -0,0 +1,1648 @@ +# 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 + + +# --------------------------------------------------------------------------- +# 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_word_gets_stress_mark(self) -> None: + """A known word from the dict has `+` injected before the stressed vowel.""" + assert _tts_for("Включаю Metallica") == "Включ+аю Metallica" + + 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("Включаю джаз") == "Включ+аю джаз" + + +@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": "пауза на кухне"}, + } + 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 на кухне"}, + } + 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": "пауза"}, + "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": "пауза на гостиной"}, + } + 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": "забудь колонку"}, + "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": "сколько колонок видишь"}, + } + 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": "какие колонки"}, + } + 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": "пауза"}, + } + 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.""" + 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": "что играет на кухне"}, + } + 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": "что играет на кухне"}, + } + 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": "перемешай на кухне"}, + } + 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": "выключи перемешивание на кухне"}, + } + 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": "повтор песни на кухне"}, + } + 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).""" + 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 минуту на кухне"}, + } + 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 секунд на кухне"}, + } + 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": "к началу на кухне"}, + } + 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 → response carries buttons + pending_command, end_session=False.""" + 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": "включи 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_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": "пауза на кухне"}, + "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 = { + "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 = { + "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 = { + "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 = { + "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..762a7900b0 --- /dev/null +++ b/tests/providers/yandex_alice/test_dialogs_control.py @@ -0,0 +1,474 @@ +# ruff: noqa: RUF001 +"""Tests for provider/dialogs_control.py — playback control NLU + executor.""" + +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 TestParseControl: + """Table-driven tests for parse_control across all action families.""" + + @pytest.mark.parametrize( + ("phrase", "expected_action", "expected_value", "expected_hint"), + [ + # pause + ("пауза", "pause", None, None), + ("на паузу", "pause", None, None), + ("поставь на паузу", "pause", None, None), + ("останови музыку", "pause", None, None), + ("пауза на кухне", "pause", None, "кухне"), + ("поставь на паузу на кухне", "pause", None, "кухне"), + # resume + ("продолжи", "resume", None, None), + ("продолжить", "resume", None, None), + ("включи снова", "resume", None, None), + ("возобнови", "resume", None, None), + # stop + ("стоп", "stop", None, None), + ("останови", "stop", None, None), + ("выключи", "stop", None, None), + ("выключи музыку", "stop", None, None), + ("стоп на спальне", "stop", None, "спальне"), + # next + ("следующая", "next", None, None), + ("следующий трек", "next", None, None), + ("дальше", "next", None, None), + ("переключи", "next", None, None), + # previous + ("предыдущая", "previous", None, None), + ("предыдущий трек", "previous", None, None), + ("назад", "previous", None, None), + ("вернись", "previous", None, None), + # volume relative + ("громче", "volume_up", None, None), + ("сделай громче", "volume_up", None, None), + ("прибавь", "volume_up", None, None), + ("прибавь громкость", "volume_up", None, None), + ("тише", "volume_down", None, None), + ("сделай тише", "volume_down", None, None), + ("убавь", "volume_down", None, None), + ("убавь громкость", "volume_down", None, None), + ("громче на кухне", "volume_up", None, "кухне"), + # volume set + ("громкость 50", "volume_set", 50, None), + ("громкость на 30", "volume_set", 30, None), + ("громкость на 30 процентов", "volume_set", 30, None), + ("сделай громкость 75", "volume_set", 75, None), + ("громкость 50 на кухне", "volume_set", 50, "кухне"), + # volume set clamping + ("громкость 200", "volume_set", 100, None), + # mute / unmute + ("приглуши", "mute", None, None), + ("выключи звук", "mute", None, None), + ("беззвучно", "mute", None, None), + ("включи звук", "unmute", None, None), + ("сделай звук", "unmute", None, None), + # list_players (no player_hint, no value) + ("сколько колонок", "list_players", None, None), + ("сколько колонок ты видишь", "list_players", None, None), + ("сколько колонок ты знаешь", "list_players", None, None), + ("какие колонки", "list_players", None, None), + ("какие колонки видишь", "list_players", None, None), + ("какие колонки ты видишь", "list_players", None, None), + ("какие колонки есть", "list_players", None, None), + ("какие у тебя колонки", "list_players", None, None), + ("перечисли колонки", "list_players", None, None), + ("список колонок", "list_players", None, None), + ("покажи колонки", "list_players", None, None), + ("назови колонки", "list_players", None, None), + # forget_player — clears the saved default-player so the + # next play command without a hint asks again. + ("забудь колонку", "forget_player", None, None), + ("сбрось колонку", "forget_player", None, None), + ("забудь плеер", "forget_player", None, None), + ("забудь выбор", "forget_player", None, None), + ("сбрось выбор", "forget_player", None, None), + ("выбери колонку заново", "forget_player", None, None), + ("поменяй колонку", "forget_player", None, None), + ("сменить колонку", "forget_player", None, None), + # now_playing — info query + ("что играет", "now_playing", None, None), + ("что сейчас играет", "now_playing", None, None), + ("что слушаем", "now_playing", None, None), + ("что мы слушаем", "now_playing", None, None), + ("что за песня", "now_playing", None, None), + ("что за трек", "now_playing", None, None), + ("какой трек", "now_playing", None, None), + ("какой сейчас трек", "now_playing", None, None), + ("что играет на кухне", "now_playing", None, "кухне"), + # shuffle on/off + ("перемешай", "shuffle_on", None, None), + ("включи перемешивание", "shuffle_on", None, None), + ("случайный порядок", "shuffle_on", None, None), + ("в случайном порядке", "shuffle_on", None, None), + ("выключи перемешивание", "shuffle_off", None, None), + ("не перемешивай", "shuffle_off", None, None), + ("по порядку", "shuffle_off", None, None), + ("перемешай на кухне", "shuffle_on", None, "кухне"), + # repeat one/all/off + ("повтор песни", "repeat_one", None, None), + ("повтори песню", "repeat_one", None, None), + ("повтори трек", "repeat_one", None, None), + ("повтор эту", "repeat_one", None, None), + ("повтор всё", "repeat_all", None, None), + ("повтори все", "repeat_all", None, None), + ("повтор очередь", "repeat_all", None, None), + ("повторяй", "repeat_all", None, None), + ("включи повтор", "repeat_all", None, None), + ("выключи повтор", "repeat_off", None, None), + ("не повторяй", "repeat_off", None, None), + # seek_forward (with optional unit) + ("вперёд 30", "seek_forward", 30, None), + ("вперед 30", "seek_forward", 30, None), + ("перемотай вперёд 30", "seek_forward", 30, None), + ("перемотай вперёд на 30", "seek_forward", 30, None), + ("перемотай вперёд на 30 секунд", "seek_forward", 30, None), + ("перемотай вперёд на 1 минуту", "seek_forward", 60, None), + ("перемотай вперёд на 2 минуты", "seek_forward", 120, None), + # seek_back + ("назад 30", "seek_back", 30, None), + ("перемотай назад 30", "seek_back", 30, None), + ("перемотай назад на 1 минуту", "seek_back", 60, None), + ("назад на 5 секунд", "seek_back", 5, None), + # seek_start + ("к началу", "seek_start", None, None), + ("в начало", "seek_start", None, None), + ("перемотай к началу", "seek_start", None, None), + ("начни заново", "seek_start", None, None), + ("начни трек заново", "seek_start", None, None), + # transfer (target captured into player_hint) + ("переведи на спальню", "transfer", None, "спальню"), + ("перенеси на спальню", "transfer", None, "спальню"), + ("продолжи в спальне", "transfer", None, "спальне"), + ("переведи музыку на кухню", "transfer", None, "кухню"), + # alice prefix tolerated + ("Алиса, пауза", "pause", None, None), + ], + ) + def test_parse( + self, + phrase: str, + expected_action: str, + expected_value: int | None, + expected_hint: str | None, + ) -> None: + """Each parametrized phrase maps to the expected ParsedControl.""" + result = parse_control(phrase) + assert result is not None, f"phrase={phrase!r} returned None" + assert result.action == expected_action, f"phrase={phrase!r}" + assert result.value == expected_value, f"phrase={phrase!r}" + assert result.player_hint == expected_hint, f"phrase={phrase!r}" + + @pytest.mark.parametrize( + "phrase", + [ + "", + "включи Metallica", + "включи джаз на кухне", + "включи песню Yesterday", + "включи мою волну", + "что-то непонятное", + "включи альбом Black Album", + ], + ) + def test_play_phrases_return_none(self, phrase: str) -> None: + """Phrases that should fall through to the play parser return None.""" + assert parse_control(phrase) is None + + +class TestPluralRu: + """Tests for the 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: + """Tests for the `list_players` confirmation builder.""" + + def test_zero_players(self) -> None: + """Empty list → 'не вижу'.""" + assert format_list_players([]) == "Не вижу ни одной колонки." + + def test_one_player(self) -> None: + """Single player → singular form.""" + p = MagicMock() + p.name = "Кухня" + p.player_id = "p1" + assert format_list_players([p]) == "Вижу одну колонку: Кухня." + + def test_three_players(self) -> None: + """Three players → 2-4 form ('колонки') with comma-separated names.""" + 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: + """Five players → 5+ form ('колонок').""" + 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: + """Tests for the 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."), + ("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: + """Confirmation text matches the expected per-action template.""" + ctrl = ParsedControl(action=action, value=value) # type: ignore[arg-type] + assert control_confirmation(ctrl) == expected + + +@pytest.mark.asyncio +class TestExecuteControl: + """Tests that execute_control dispatches 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: + """action=pause invokes mass.player_queues.pause.""" + 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: + """action=resume invokes mass.player_queues.resume.""" + 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: + """action=stop invokes mass.player_queues.stop.""" + 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: + """action=next invokes mass.player_queues.next.""" + 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: + """action=previous invokes mass.player_queues.previous.""" + 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: + """action=volume_up invokes cmd_volume_up.""" + 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: + """action=volume_down invokes cmd_volume_down.""" + 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: + """action=volume_set invokes cmd_volume_set with the requested value.""" + 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: + """volume_set with value=None defaults to 0 (defensive).""" + 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_mute(self) -> None: + """action=mute invokes cmd_volume_mute(True).""" + 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: + """action=unmute invokes cmd_volume_mute(False).""" + 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: + """`list_players` reaching execute_control logs a warning and no-ops. + + The handler is supposed to short-circuit `list_players` before + dispatch (it's an informational query), but the typing allows + it as a `ControlAction` so the explicit branch makes a stray + call safe rather than a silent no-op. + """ + 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()) + # No MA command dispatched. + mass.player_queues.pause.assert_not_awaited() + mass.player_queues.resume.assert_not_awaited() + mass.players.cmd_volume_set.assert_not_awaited() + # Warning emitted. + assert any("list_players" in r.getMessage() for r in caplog.records) + + async def test_shuffle_on(self) -> None: + """action=shuffle_on invokes set_shuffle(True).""" + 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: + """action=shuffle_off invokes set_shuffle(False).""" + 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: + """action=repeat_off invokes set_repeat(RepeatMode.OFF) — sync, not awaited.""" + 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: + """action=repeat_one invokes set_repeat(RepeatMode.ONE).""" + 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: + """action=repeat_all invokes set_repeat(RepeatMode.ALL).""" + 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: + """action=seek_forward(value=N) invokes skip(qid, +N).""" + 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: + """action=seek_back(value=N) invokes skip(qid, -N) — value is positive at parse time.""" + 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: + """action=seek_start invokes seek(qid, position=0) — absolute reset.""" + 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: + """`now_playing` reaching execute_control logs warning and no-ops (handler dispatches).""" + 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: + """`transfer` reaching execute_control logs warning and no-ops (handler dispatches).""" + 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_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 + ) From efaadb15dce9d9a35eda2990dbe39cac8999a190 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 6 May 2026 17:38:21 +0000 Subject: [PATCH 02/10] feat(yandex_alice): sync provider from ma-provider-yandex-alice v1.1.0 --- .../providers/yandex_alice/__init__.py | 373 +++++++++++-- .../providers/yandex_alice/auth_session.py | 81 +++ .../providers/yandex_alice/auto_create.py | 509 ++++++++++++++++++ .../yandex_alice/auto_create_view.py | 196 +++++++ .../providers/yandex_alice/auto_update.py | 142 +++++ .../providers/yandex_alice/constants.py | 27 +- .../yandex_alice/dialog_skill_meta.py | 88 +++ .../providers/yandex_alice/manifest.json | 3 +- requirements_all.txt | 1 + .../yandex_alice/test_auth_session.py | 140 +++++ .../yandex_alice/test_auto_create.py | 488 +++++++++++++++++ .../yandex_alice/test_auto_update.py | 188 +++++++ .../yandex_alice/test_dialog_skill_meta.py | 109 ++++ tests/providers/yandex_alice/test_dialogs.py | 6 +- .../yandex_alice/test_dialogs_control.py | 12 +- .../yandex_alice/test_init_actions.py | 506 +++++++++++++++++ 16 files changed, 2824 insertions(+), 45 deletions(-) create mode 100644 music_assistant/providers/yandex_alice/auth_session.py create mode 100644 music_assistant/providers/yandex_alice/auto_create.py create mode 100644 music_assistant/providers/yandex_alice/auto_create_view.py create mode 100644 music_assistant/providers/yandex_alice/auto_update.py create mode 100644 music_assistant/providers/yandex_alice/dialog_skill_meta.py create mode 100644 tests/providers/yandex_alice/test_auth_session.py create mode 100644 tests/providers/yandex_alice/test_auto_create.py create mode 100644 tests/providers/yandex_alice/test_auto_update.py create mode 100644 tests/providers/yandex_alice/test_dialog_skill_meta.py create mode 100644 tests/providers/yandex_alice/test_init_actions.py diff --git a/music_assistant/providers/yandex_alice/__init__.py b/music_assistant/providers/yandex_alice/__init__.py index 5d1ef08172..870479b74e 100644 --- a/music_assistant/providers/yandex_alice/__init__.py +++ b/music_assistant/providers/yandex_alice/__init__.py @@ -3,28 +3,48 @@ Exposes Music Assistant playback to a Yandex Dialogs custom skill — a Russian NLU voice control surface invoked via *«Алиса, попроси Music Assistant …»*. -Setup is **manual** in this release: -1. User creates a custom dialog skill in https://dialogs.yandex.ru/developer -2. User points the skill's webhook URL at MA's - ``/api/yandex_dialogs/webhook/`` endpoint. -3. User pastes the skill ID, skill token, and webhook secret into the - provider's config form. - -A future release will add an auto-create flow that uses the -``ya-dialogs-api`` library to register the skill programmatically; for now -manual setup keeps the v1.0 surface small and reliable. +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 from typing import TYPE_CHECKING from music_assistant_models.config_entries import ConfigEntry, ConfigValueOption from music_assistant_models.enums import ConfigEntryType, ProviderFeature +from ya_dialogs_api import ( + SkillCreationArtifacts, + SkillCreationState, + dump_artifacts, + load_artifacts, +) +from .auto_create import ( + AutoCreateOutcome, + LocalAutoCreateStage, + deserialize_device_session, + run_auto_create_step, +) +from .auto_create_view import build_auto_create_entries +from .auto_update import run_auto_update from .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_AUTO_CREATE_DEVICE_SESSION, CONF_DIALOG_SKILL_ENABLED, CONF_DIALOG_SKILL_ID, CONF_DIALOG_SKILL_NAME, @@ -40,6 +60,12 @@ DIALOG_WEBHOOK_BASE_PATH, YANDEX_DIALOGS_DEVELOPER_URL, ) +from .dialog_skill_meta import ( + build_activation_phrases, + build_backend_uri, + build_skill_description, + build_structured_examples, +) from .playlists import fetch_playlist_options from .plugin import YandexAlicePlugin @@ -86,32 +112,206 @@ async def _list_player_options(mass: MusicAssistant) -> list[ConfigValueOption]: return options -async def get_config_entries( +def _name_drifted(artifacts: SkillCreationArtifacts, skill_name: str) -> bool: + """Detect divergence between MA-side `skill_name` and Yandex `last_known_name`.""" + return bool( + artifacts.last_known_name and artifacts.last_known_name.strip() != skill_name.strip() + ) + + +async def _resolve_saved_value( + mass: MusicAssistant, + instance_id: str | None, + values: dict[str, ConfigValueType], + key: str, +) -> str: + """Read a config value: form ``values`` first, then persisted provider config. + + Frontend may not echo SECURE_STRING entries back in ``values`` between + action clicks. Falling through to ``mass.config.get_provider_config`` + keeps the source of truth stable for keys the user already saved + (cached x_token, generated webhook secret, persisted artifacts blob). + + ``mass.config.get_provider_config`` is async in current MA, so this + helper is async too. Returns ``""`` when neither source has a value, + the instance_id is missing, or the MA config API raises (e.g. on a + fresh provider instance that has not been saved yet). + """ + fresh = values.get(key) + if fresh: + return str(fresh) + if not instance_id: + return "" + try: + cfg = await mass.config.get_provider_config(instance_id) + except Exception: + return "" + try: + saved = cfg.get_value(key) + except Exception: + 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. - - Args: - mass: MusicAssistant runtime, used to enumerate players/playlists. - instance_id: Stable provider instance ID (unused — single-instance). - action: Action key when the user clicked a button. Currently unused - (auto-create / rename actions ship in a later release). - values: Current form values (echoed back across submissions). + """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 three hidden config entries + (``CONF_AUTH_X_TOKEN``, ``CONF_DIALOG_AUTO_CREATE_ARTIFACTS``, + ``CONF_DIALOG_AUTO_CREATE_DEVICE_SESSION``) that round-trip through the + form on every save. """ - _ = instance_id, action values = values or {} # Generate a webhook secret on first open if the user hasn't set one yet. - existing_secret = str(values.get(CONF_DIALOG_WEBHOOK_SECRET) or "").strip() + # 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. + existing_secret = ( + await _resolve_saved_value(mass, instance_id, 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) - # Player options used both by the exposed-players selector and the - # exposed-playlists selector below. + # ---- Pull persistent auto-create / auth state ---- + artifacts = load_artifacts( + (await _resolve_saved_value(mass, instance_id, values, CONF_DIALOG_AUTO_CREATE_ARTIFACTS)) + or None + ) + cached_x_token = await _resolve_saved_value(mass, instance_id, values, CONF_AUTH_X_TOKEN) + device_session_blob = await _resolve_saved_value( + mass, instance_id, values, CONF_DIALOG_AUTO_CREATE_DEVICE_SESSION + ) + + # 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 + + # ---- Action dispatcher ---- + if action == CONF_ACTION_AUTO_CREATE_DIALOG: + # Treat re-click on DONE as "Re-create" → reset artifacts before stepping. + if artifacts.state == SkillCreationState.DONE: + artifacts = SkillCreationArtifacts() + device_session_blob = "" + + # Backup-restore safety: skill_id is set in config but artifacts are + # NONE → pre-position to APP_CREATED so the library skips create_app + # and patches the existing skill rather than creating a duplicate. + 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), + ), + device_session_blob=None, + x_token=None, + user_code=None, + verification_url=None, + user_message=str(exc), + stage=LocalAutoCreateStage.FAILED, + ) + else: + action_outcome = await run_auto_create_step( + 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), + cached_x_token=cached_x_token or None, + pending_device_session_blob=device_session_blob or None, + artifacts=artifacts, + ) + + 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), + 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: + # Drop pending session + reset artifacts; keep cached x_token. + artifacts = SkillCreationArtifacts() + device_session_blob = "" + + # ---- Reflect outcome into values so the next form save persists state ---- + if action_outcome is not None: + artifacts = action_outcome.artifacts + if action_outcome.device_session_blob is not None: + device_session_blob = action_outcome.device_session_blob + elif action_outcome.stage in ( + LocalAutoCreateStage.DONE, + LocalAutoCreateStage.FAILED, + ): + device_session_blob = "" + if action_outcome.x_token is not None: + cached_x_token = action_outcome.x_token + + values[CONF_DIALOG_AUTO_CREATE_ARTIFACTS] = dump_artifacts(artifacts) + values[CONF_AUTH_X_TOKEN] = cached_x_token + values[CONF_DIALOG_AUTO_CREATE_DEVICE_SESSION] = device_session_blob + if artifacts.state == SkillCreationState.DONE and artifacts.skill_id: + values[CONF_DIALOG_SKILL_ID] = artifacts.skill_id + + # ---- Player / playlist options ---- player_options = await _list_player_options(mass) try: playlist_options = await fetch_playlist_options(mass) @@ -125,15 +325,105 @@ async def get_config_entries( "Leave empty to use MA's global Base URL setting." ) + # ---- Auto-create cluster: status LABEL + auto-create ACTION + Cancel ---- + # Surface the pending session details so the LABEL can re-show the + # user_code + verification URL after a form reload mid-Device-Flow. + pending_user_code: str | None = None + pending_verification_url: str | None = None + if device_session_blob: + decoded = deserialize_device_session(device_session_blob) + if decoded is not None: + pending_user_code = decoded[0].user_code + pending_verification_url = decoded[0].verification_url + + auto_create_entries = build_auto_create_entries( + artifacts=artifacts, + pending_session_present=bool(device_session_blob), + cached_x_token_present=bool(cached_x_token), + action_outcome=action_outcome, + pending_user_code=pending_user_code, + pending_verification_url=pending_verification_url, + ) + + # ---- Rename cluster: drift LABEL (conditional) + Rename ACTION ---- + rename_entries: tuple[ConfigEntry, ...] = () + rename_visible = bool(artifacts.skill_id and cached_x_token) + if rename_visible: + drift_text = "" + if update_message: + drift_text = update_message + elif _name_drifted(artifacts, skill_name): + drift_text = ( + f"Name in Yandex ('{artifacts.last_known_name}') differs from " + f"the current 'Skill name' ({skill_name!r}). Click 'Rename'." + ) + rename_entries = ( + *( + ( + ConfigEntry( + key="label_rename_status", + type=ConfigEntryType.LABEL, + label=drift_text, + ), + ) + if drift_text + else () + ), + ConfigEntry( + key=CONF_ACTION_RENAME_DIALOG_SKILL, + type=ConfigEntryType.ACTION, + label="Rename skill in Yandex", + description=( + "Apply the current 'Skill name' value to the existing " + "skill in Yandex Dialogs (PATCH draft + re-deploy). " + "Uses the cached x_token — no re-authentication required." + ), + action=CONF_ACTION_RENAME_DIALOG_SKILL, + action_label="Rename", + required=False, + default_value="", + ), + ) + + # ---- Hidden state-carrier entries (round-trip persistence) ---- + 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, + default_value=cached_x_token, + 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, + default_value=dump_artifacts(artifacts), + hidden=True, + ), + ConfigEntry( + key=CONF_DIALOG_AUTO_CREATE_DEVICE_SESSION, + type=ConfigEntryType.SECURE_STRING, + label="Pending Device Flow session (JSON)", + description="Persisted during DEVICE_FLOW_STARTED stage.", + required=False, + default_value=device_session_blob, + hidden=True, + ), + ) + return ( ConfigEntry( key="label_intro", type=ConfigEntryType.LABEL, label=( - "🎙️ Yandex Alice voice control. Create a custom dialog " - f"skill at {YANDEX_DIALOGS_DEVELOPER_URL}, point its " - "webhook at the URL shown below, and paste the skill ID + " - "token from the dev console." + "Yandex Alice voice control. Use 'Create skill' below " + "for one-click registration via Yandex Passport, or set up " + f"manually at {YANDEX_DIALOGS_DEVELOPER_URL}." ), ), ConfigEntry( @@ -143,7 +433,7 @@ async def get_config_entries( description=( "Display name shown to users. Pick something they will say " 'to invoke the skill, e.g. "Music Assistant" → ' - "«Алиса, попроси Music Assistant …»" + '"Alice, ask Music Assistant ..."' ), required=False, default_value=DIALOG_DEFAULT_NAME, @@ -151,7 +441,7 @@ async def get_config_entries( ConfigEntry( key=CONF_EXTERNAL_BASE_URL, type=ConfigEntryType.STRING, - label="External base URL (optional)", + label="External base URL (HTTPS, required for auto-create)", description=base_url_hint, required=False, default_value="", @@ -161,8 +451,8 @@ async def get_config_entries( type=ConfigEntryType.BOOLEAN, label="Enable dialog skill", description=( - "Turn this on once you have created the custom skill in " - "the Yandex dev console and pasted the credentials below." + "Turn this on once the skill is created (auto or manual) " + "and the credentials below are populated." ), required=False, default_value=False, @@ -170,20 +460,25 @@ async def get_config_entries( ConfigEntry( key=CONF_DIALOG_SKILL_NAME, type=ConfigEntryType.STRING, - label="Skill name (informational)", + label="Skill name", description=( - f"Used in UI labels only. Min {DIALOG_NAME_MIN_LEN}, " - f"max {DIALOG_NAME_MAX_LEN} characters." + "Display name pushed to Yandex Dialogs on auto-create / " + "rename. Min " + f"{DIALOG_NAME_MIN_LEN}, max {DIALOG_NAME_MAX_LEN} characters." ), required=False, default_value=instance_name, ), + *auto_create_entries, + *rename_entries, ConfigEntry( key=CONF_DIALOG_SKILL_ID, type=ConfigEntryType.STRING, label="Skill ID", description=( - "UUID from the dev console URL (https://dialogs.yandex.ru/developer/skills/)." + "UUID of the skill — populated automatically after a " + "successful auto-create, or paste manually if you set up " + "the skill yourself." ), required=False, default_value="", @@ -191,9 +486,9 @@ async def get_config_entries( ConfigEntry( key=CONF_DIALOG_SKILL_TOKEN, type=ConfigEntryType.SECURE_STRING, - label="Skill OAuth token", + label="Skill OAuth token (manual setup only)", description=( - "OAuth token from " + "Optional OAuth token from " "https://oauth.yandex.ru/authorize?response_type=token" "&client_id=c473ca268cd749d3a8371351a8f2bcbd. " "Used to push state callbacks to Yandex (future feature; " @@ -207,8 +502,7 @@ async def get_config_entries( type=ConfigEntryType.SECURE_STRING, label="Webhook URL secret", description=( - "Random secret embedded in the webhook URL. The full URL to " - "paste into the dev console's webhook field is " + "Random secret embedded in the webhook URL. The full URL is " f"{DIALOG_WEBHOOK_BASE_PATH}/. " "Pre-filled with a fresh value; click 'Save' to commit." ), @@ -241,4 +535,5 @@ async def get_config_entries( required=False, default_value=[], ), + *hidden_state_entries, ) 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..83be0f5edc --- /dev/null +++ b/music_assistant/providers/yandex_alice/auth_session.py @@ -0,0 +1,81 @@ +"""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..fe9e4e2d83 --- /dev/null +++ b/music_assistant/providers/yandex_alice/auto_create.py @@ -0,0 +1,509 @@ +"""Self-resuming Device Flow + dialog-skill creation orchestrator. + +Music Assistant config-actions are synchronous: each click of the auto-create +button is one HTTP request, and the form re-renders with whatever entries we +return. Yandex Passport's Device Flow needs the user to walk away and confirm +on a different page (~30s-several minutes), so we can't drive it inside a +single action call. + +Solution: layer a *local* state machine on top of ``SkillCreationArtifacts`` +that survives across clicks via JSON in the provider config. Each click +advances by exactly one external-IO step: + +- ``IDLE`` (no session, no token) → start Device Flow → ``DEVICE_FLOW_STARTED`` +- ``DEVICE_FLOW_STARTED`` → one-shot poll with short window → if confirmed, + refresh cookies and capture ``x_token`` → ``AUTHENTICATED`` +- ``AUTHENTICATED`` (or any post-auth artifact state) → run + ``auto_create_skill(channel="aliceSkill", oauth_*=None)`` end-to-end → + ``DONE`` / ``FAILED`` + +The full pipeline after auth is a single ya-dialogs-api call: all four steps +(``create_app → upload_logo → update_draft → request_deploy``) are fast HTTP +to ``dialogs.yandex.ru``. ``progress_cb`` checkpoints each step so a mid-call +failure resumes from the last completed state on the next click. +""" + +from __future__ import annotations + +import dataclasses +import json +import logging +import time +from dataclasses import dataclass +from enum import StrEnum +from typing import Any + +from ya_dialogs_api import ( + SkillCreationArtifacts, + SkillCreationState, + auto_create_skill, +) +from ya_passport_auth import DeviceCodeSession, SecretStr +from ya_passport_auth.exceptions import ( + DeviceCodeTimeoutError, + InvalidCredentialsError, +) + +from .auth_session import make_cached_authenticator, passport_client_session +from .constants import DIALOG_CHANNEL + +_LOGGER = logging.getLogger(__name__) + +__all__ = [ + "AutoCreateOutcome", + "LocalAutoCreateStage", + "deserialize_device_session", + "run_auto_create_step", + "serialize_device_session", +] + + +class LocalAutoCreateStage(StrEnum): + """High-level UX stage derived from artifacts + pending Device Flow. + + Not persisted directly: rendered on every click from + ``(SkillCreationArtifacts, has_pending_device_session, has_cached_x_token)``. + Drives the button label flip ("Create" / "Confirm and continue" / + "Resume" / "Re-create" / "Retry") and the visibility of the + Cancel button. + """ + + IDLE = "idle" + DEVICE_FLOW_STARTED = "device_flow_started" + PIPELINE_RUNNING = "pipeline_running" + DONE = "done" + FAILED = "failed" + + +@dataclass(frozen=True, slots=True) +class AutoCreateOutcome: + """Result of one click on the auto-create button. + + The dispatcher in :mod:`provider.__init__` writes ``artifacts``, + ``device_session_blob``, and ``x_token`` into the form ``values`` so MA + persists them on the next form save. ``user_message`` and the + ``user_code`` / ``verification_url`` pair feed the status LABEL the user + sees while the flow is in progress. + """ + + artifacts: SkillCreationArtifacts + """Latest snapshot — overwrites CONF_DIALOG_AUTO_CREATE_ARTIFACTS.""" + + device_session_blob: str | None + """Serialised pending session, ``None`` to drop, ``""`` (empty) to keep as-is.""" + + x_token: str | None + """Newly minted x_token, ``""`` to clear cache, ``None`` to leave existing.""" + + user_code: str | None + """Code to display to the user (``DEVICE_FLOW_STARTED`` only).""" + + verification_url: str | None + """URL the user must open (``DEVICE_FLOW_STARTED`` only).""" + + user_message: str + """Human-readable status for the LABEL entry.""" + + stage: LocalAutoCreateStage + """High-level UX stage — drives button label + cancel visibility.""" + + +# --------------------------------------------------------------------------- +# Device session JSON round-trip +# --------------------------------------------------------------------------- + + +def serialize_device_session( + session: DeviceCodeSession | None, expires_at_epoch: float +) -> str | None: + """Serialise a ``DeviceCodeSession`` to JSON for config storage. + + ``device_code`` is unwrapped from :class:`SecretStr` because it must + survive a config round-trip — the SECURE_STRING entry encrypts it at + rest. ``expires_at_epoch`` is the absolute wall-clock deadline we + recompute once at first start and check on every resume so a long-idle + user doesn't see a "still waiting" loop on a code Yandex already killed. + """ + if session is None: + return None + return json.dumps( + { + "device_code": session.device_code.get_secret(), + "user_code": session.user_code, + "verification_url": session.verification_url, + "expires_in": session.expires_in, + "interval": session.interval, + "expires_at_epoch": expires_at_epoch, + }, + ensure_ascii=False, + ) + + +def deserialize_device_session( + raw: str | None, +) -> tuple[DeviceCodeSession, float] | None: + """Inverse of :func:`serialize_device_session`. Returns ``None`` on any failure. + + Returns ``(session, expires_at_epoch)`` so the caller can decide whether + the underlying user_code is still alive on Yandex's side. + """ + if not raw: + return None + try: + data = json.loads(raw) + except (ValueError, TypeError): + return None + if not isinstance(data, dict): + return None + try: + device_code = SecretStr(str(data["device_code"])) + session = DeviceCodeSession( + device_code=device_code, + user_code=str(data["user_code"]), + verification_url=str(data["verification_url"]), + expires_in=int(data["expires_in"]), + interval=int(data["interval"]), + ) + expires_at_epoch = float(data.get("expires_at_epoch", 0.0)) + except (KeyError, ValueError, TypeError): + return None + return session, expires_at_epoch + + +# --------------------------------------------------------------------------- +# Main entry point +# --------------------------------------------------------------------------- + + +async def run_auto_create_step( + *, + skill_name: str, + backend_uri: str, + description: str, + structured_examples: list[dict[str, Any]] | None, + activation_phrases: list[str] | None, + cached_x_token: str | None, + pending_device_session_blob: str | None, + artifacts: SkillCreationArtifacts, + poll_window: float = 8.0, +) -> AutoCreateOutcome: + """Advance the state machine by exactly one external-IO step. + + Branches by current state: + + 1. ``artifacts.state == DONE`` → no-op outcome (caller resets artifacts + on a "Re-create" click before invoking us). + 2. Pending Device Flow session → one-shot poll. If confirmed, capture + x_token and proceed to pipeline in *the same click* (cheap step). + If still pending and underlying code alive, keep stage. If underlying + expired or Yandex rejected, return FAILED with a clear last_error. + 3. Have ``cached_x_token`` (post-auth) → run the full + ``auto_create_skill`` pipeline. + 4. None of the above → start Device Flow. + """ + if artifacts.state == SkillCreationState.DONE: + return AutoCreateOutcome( + artifacts=artifacts, + device_session_blob=None, + x_token=None, + user_code=None, + verification_url=None, + user_message="Skill created. Click 'Save' to apply the settings.", + stage=LocalAutoCreateStage.DONE, + ) + + pending = deserialize_device_session(pending_device_session_blob) + + if pending is not None: + return await _resume_device_flow( + pending_session=pending[0], + expires_at_epoch=pending[1], + poll_window=poll_window, + skill_name=skill_name, + backend_uri=backend_uri, + description=description, + structured_examples=structured_examples, + activation_phrases=activation_phrases, + artifacts=artifacts, + ) + + if cached_x_token: + 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, + artifacts=artifacts, + ) + + return await _start_device_flow(artifacts=artifacts) + + +# --------------------------------------------------------------------------- +# Stage handlers +# --------------------------------------------------------------------------- + + +async def _start_device_flow(*, artifacts: SkillCreationArtifacts) -> AutoCreateOutcome: + """Request a fresh user_code from Yandex Passport and persist the session.""" + try: + async with passport_client_session() as client: + session = await client.start_device_login( + device_name="Music Assistant", + ) + except Exception as exc: + _LOGGER.exception("auto-create: start_device_login failed") + failed = dataclasses.replace( + artifacts, + state=SkillCreationState.FAILED, + last_error=f"Failed to request a device code from Yandex Passport: {exc!r}", + ) + return AutoCreateOutcome( + artifacts=failed, + device_session_blob=None, + x_token=None, + user_code=None, + verification_url=None, + user_message=str(failed.last_error), + stage=LocalAutoCreateStage.FAILED, + ) + + expires_at = time.time() + session.expires_in + blob = serialize_device_session(session, expires_at) + user_message = ( + f"Open {session.verification_url} and enter code {session.user_code}. " + "Click the button again once you've confirmed." + ) + return AutoCreateOutcome( + artifacts=artifacts, + device_session_blob=blob, + x_token=None, + user_code=session.user_code, + verification_url=session.verification_url, + user_message=user_message, + stage=LocalAutoCreateStage.DEVICE_FLOW_STARTED, + ) + + +async def _resume_device_flow( + *, + pending_session: DeviceCodeSession, + expires_at_epoch: float, + poll_window: float, + skill_name: str, + backend_uri: str, + description: str, + structured_examples: list[dict[str, Any]] | None, + activation_phrases: list[str] | None, + artifacts: SkillCreationArtifacts, +) -> AutoCreateOutcome: + """Poll the Device Flow once with a short window; chain into pipeline on success.""" + remaining = expires_at_epoch - time.time() + if remaining <= 0: + failed = dataclasses.replace( + artifacts, + state=SkillCreationState.FAILED, + last_error="Device code expired. Click 'Create' to request a new one.", + ) + return AutoCreateOutcome( + artifacts=failed, + device_session_blob=None, + x_token=None, + user_code=None, + verification_url=None, + user_message=str(failed.last_error), + stage=LocalAutoCreateStage.FAILED, + ) + + window = min(poll_window, remaining) + try: + async with passport_client_session() as client: + credentials = await client.poll_device_until_confirmed( + pending_session, + total_timeout=window, + ) + x_token_secret = credentials.x_token + await client.refresh_passport_cookies(x_token_secret) + except DeviceCodeTimeoutError: + if window < remaining: + blob = serialize_device_session(pending_session, expires_at_epoch) + return AutoCreateOutcome( + artifacts=artifacts, + device_session_blob=blob, + x_token=None, + user_code=pending_session.user_code, + verification_url=pending_session.verification_url, + user_message=( + f"Still waiting for confirmation. Code {pending_session.user_code} " + f"at {pending_session.verification_url}. Click the button again." + ), + stage=LocalAutoCreateStage.DEVICE_FLOW_STARTED, + ) + failed = dataclasses.replace( + artifacts, + state=SkillCreationState.FAILED, + last_error="Device code expired. Click 'Create' to request a new one.", + ) + return AutoCreateOutcome( + artifacts=failed, + device_session_blob=None, + x_token=None, + user_code=None, + verification_url=None, + user_message=str(failed.last_error), + stage=LocalAutoCreateStage.FAILED, + ) + except InvalidCredentialsError as exc: + _LOGGER.warning("auto-create: device flow rejected: %s", exc) + failed = dataclasses.replace( + artifacts, + state=SkillCreationState.FAILED, + last_error=f"Yandex Passport rejected the sign-in: {exc}", + ) + return AutoCreateOutcome( + artifacts=failed, + device_session_blob=None, + x_token=None, + user_code=None, + verification_url=None, + user_message=str(failed.last_error), + stage=LocalAutoCreateStage.FAILED, + ) + except Exception as exc: + _LOGGER.exception("auto-create: device flow unexpected error") + failed = dataclasses.replace( + artifacts, + state=SkillCreationState.FAILED, + last_error=f"Unexpected authentication error: {exc!r}", + ) + return AutoCreateOutcome( + artifacts=failed, + device_session_blob=None, + x_token=None, + user_code=None, + verification_url=None, + user_message=str(failed.last_error), + stage=LocalAutoCreateStage.FAILED, + ) + + x_token = x_token_secret.get_secret() + pipeline_outcome = await _run_pipeline( + cached_x_token=x_token, + skill_name=skill_name, + backend_uri=backend_uri, + description=description, + structured_examples=structured_examples, + activation_phrases=activation_phrases, + artifacts=artifacts, + ) + return dataclasses.replace( + pipeline_outcome, + device_session_blob=None, + x_token=x_token, + ) + + +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, + artifacts: SkillCreationArtifacts, +) -> 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`` (see + library docstring). We don't need to catch those explicitly — they + arrive as a regular return value and flow through + :func:`_outcome_from_failed_pipeline`. + + 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 (before the library's pipeline + starts). We translate it into ``outcome.x_token=""`` so the + dispatcher clears the cache and the next click can re-auth + cleanly via Device Flow. + """ + 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, + ) + 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 the button again to re-authenticate.", + ) + return AutoCreateOutcome( + artifacts=failed, + device_session_blob=None, + x_token="", + user_code=None, + verification_url=None, + 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, + device_session_blob=None, + x_token=None, + user_code=None, + verification_url=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. + + Reads ``last_error`` (already populated by ya-dialogs-api). For typed + sub-errors that the library reports through the artifact, the wire + representation is ``last_error: str`` — we keep it as-is. The caller + (dispatcher) layers domain-aware advice (e.g. duplicate-name → suggest + rename) by inspecting the message before rendering. + """ + msg = result.last_error or "Pipeline failed without a description." + return AutoCreateOutcome( + artifacts=result, + device_session_blob=None, + x_token=None, + user_code=None, + verification_url=None, + user_message=msg, + stage=LocalAutoCreateStage.FAILED, + ) diff --git a/music_assistant/providers/yandex_alice/auto_create_view.py b/music_assistant/providers/yandex_alice/auto_create_view.py new file mode 100644 index 0000000000..530b01a3ea --- /dev/null +++ b/music_assistant/providers/yandex_alice/auto_create_view.py @@ -0,0 +1,196 @@ +"""Pure rendering of auto-create UI entries from state. + +Decoupled from the dispatcher (``provider.__init__`` uses these helpers +verbatim) so the form-shape can be unit-tested without exercising the +actual orchestrator. Returns ``ConfigEntry`` tuples. +""" + +from __future__ import annotations + +from music_assistant_models.config_entries import ConfigEntry +from music_assistant_models.enums import ConfigEntryType +from ya_dialogs_api import SkillCreationArtifacts, SkillCreationState + +from .auto_create import AutoCreateOutcome, LocalAutoCreateStage +from .constants import ( + CONF_ACTION_AUTO_CREATE_DIALOG, + CONF_ACTION_CANCEL_DIALOG_SKILL_FLOW, +) + +__all__ = ["build_auto_create_entries"] + + +def _create_button_label(stage: LocalAutoCreateStage) -> str: + """Button label flips per stage so users see what the click will do.""" + return { + LocalAutoCreateStage.IDLE: "Create skill", + LocalAutoCreateStage.DEVICE_FLOW_STARTED: "Confirm and continue", + LocalAutoCreateStage.PIPELINE_RUNNING: "Resume", + LocalAutoCreateStage.DONE: "Re-create", + LocalAutoCreateStage.FAILED: "Retry", + }[stage] + + +def _derive_stage( + *, + artifacts: SkillCreationArtifacts, + pending_session_present: bool, + cached_x_token_present: bool, +) -> LocalAutoCreateStage: + """Derive the UX stage from persistent state. + + Decision order (most specific first): + + 1. Pending Device Flow session → ``DEVICE_FLOW_STARTED``. + 2. Artifacts ``DONE`` → ``DONE`` regardless of token presence. + 3. Artifacts ``FAILED`` → ``FAILED`` (Retry button). + 4. Artifacts in any post-create state with cached token → + ``PIPELINE_RUNNING`` ("Resume" — next click hits the pipeline). + 5. Same intermediate state but **no** cached token → ``IDLE``: next + click will start a fresh Device Flow first, so the button label + must say "Create skill", not "Resume", to match the actual next step. + 6. Otherwise → ``IDLE``. + """ + if pending_session_present: + return LocalAutoCreateStage.DEVICE_FLOW_STARTED + if artifacts.state == SkillCreationState.DONE: + return LocalAutoCreateStage.DONE + if artifacts.state == SkillCreationState.FAILED: + return LocalAutoCreateStage.FAILED + if cached_x_token_present and artifacts.state in ( + SkillCreationState.APP_CREATED, + SkillCreationState.DRAFT_UPDATED, + SkillCreationState.OAUTH_CREATED, + SkillCreationState.OAUTH_ATTACHED, + SkillCreationState.DEPLOY_REQUESTED, + ): + return LocalAutoCreateStage.PIPELINE_RUNNING + return LocalAutoCreateStage.IDLE + + +def _status_label_text( + *, + stage: LocalAutoCreateStage, + artifacts: SkillCreationArtifacts, + action_outcome: AutoCreateOutcome | None, + pending_user_code: str | None, + pending_verification_url: str | None, +) -> str: + """Compose the status message shown above the auto-create button. + + Priority: a fresh action outcome wins (the user just clicked); otherwise + we fall back to a state-derived static hint so the form still has + context after a re-open. ``pending_user_code`` / + ``pending_verification_url`` are populated when a Device Flow session is + persisted in config — they let us re-show the original code/URL after a + form reload, instead of leaving the LABEL blank with no instructions. + """ + if action_outcome is not None: + return action_outcome.user_message + if stage == LocalAutoCreateStage.DEVICE_FLOW_STARTED: + if pending_user_code and pending_verification_url: + return ( + f"Device Flow in progress. Open {pending_verification_url} and " + f"enter code {pending_user_code}, then click 'Confirm and continue'." + ) + return ( + "Device Flow in progress. Click 'Confirm and continue' to check " + "for confirmation, or 'Cancel' to abort." + ) + if stage == LocalAutoCreateStage.DONE and artifacts.skill_id: + return f"Skill created (skill_id={artifacts.skill_id})." + if stage == LocalAutoCreateStage.FAILED and artifacts.last_error: + return f"Error: {artifacts.last_error}" + if stage == LocalAutoCreateStage.PIPELINE_RUNNING: + return ( + "Skill creation was interrupted. Click 'Resume' to continue " + f"from step {artifacts.state.value}." + ) + if stage == LocalAutoCreateStage.IDLE: + return ( + "Click 'Create skill' — Music Assistant will sign in to Yandex Passport " + "(Device Flow) and register the skill at dialogs.yandex.ru." + ) + return "" + + +def build_auto_create_entries( + *, + artifacts: SkillCreationArtifacts, + pending_session_present: bool, + cached_x_token_present: bool, + action_outcome: AutoCreateOutcome | None, + pending_user_code: str | None = None, + pending_verification_url: str | None = None, +) -> tuple[ConfigEntry, ...]: + """Render the auto-create cluster: status LABEL + ACTION + Cancel. + + The Cancel button is visible only when in DEVICE_FLOW_STARTED or FAILED — + these are the states where the user might want to abandon a partial + flow without waiting for the underlying user_code to expire. + + ``pending_user_code`` / ``pending_verification_url`` come from the + deserialised Device Flow session: passing them in lets the LABEL + remain self-explanatory after a form reload mid-Device-Flow (otherwise + the user has nowhere to read the code they need to confirm). + """ + stage = _derive_stage( + artifacts=artifacts, + pending_session_present=pending_session_present, + cached_x_token_present=cached_x_token_present, + ) + + status_text = _status_label_text( + stage=stage, + artifacts=artifacts, + action_outcome=action_outcome, + pending_user_code=pending_user_code, + pending_verification_url=pending_verification_url, + ) + + entries: list[ConfigEntry] = [] + + if status_text: + entries.append( + ConfigEntry( + key="label_auto_create_status", + type=ConfigEntryType.LABEL, + label=status_text, + ) + ) + + entries.append( + ConfigEntry( + key=CONF_ACTION_AUTO_CREATE_DIALOG, + type=ConfigEntryType.ACTION, + label="Auto-register skill", + description=( + "One click creates a skill at https://dialogs.yandex.ru/developer " + "via the Yandex Passport Device Flow. The button can be clicked " + "repeatedly — the process resumes from the last completed step." + ), + action=CONF_ACTION_AUTO_CREATE_DIALOG, + action_label=_create_button_label(stage), + required=False, + default_value="", + ) + ) + + if stage in (LocalAutoCreateStage.DEVICE_FLOW_STARTED, LocalAutoCreateStage.FAILED): + entries.append( + ConfigEntry( + key=CONF_ACTION_CANCEL_DIALOG_SKILL_FLOW, + type=ConfigEntryType.ACTION, + label="Cancel", + description=( + "Aborts the current authentication / creation process. " + "The cached x_token is preserved." + ), + action=CONF_ACTION_CANCEL_DIALOG_SKILL_FLOW, + action_label="Cancel", + required=False, + default_value="", + ) + ) + + return tuple(entries) 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..4d9c8cd42d --- /dev/null +++ b/music_assistant/providers/yandex_alice/auto_update.py @@ -0,0 +1,142 @@ +"""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 ( + 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, + artifacts: SkillCreationArtifacts, +) -> 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 — run 'Create skill' first " + "to sign in via Yandex Passport." + ), + ) + 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, + ) + 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. Run 'Create skill' 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 index 787ea70447..dd0ead1814 100644 --- a/music_assistant/providers/yandex_alice/constants.py +++ b/music_assistant/providers/yandex_alice/constants.py @@ -2,7 +2,14 @@ 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) @@ -30,12 +37,19 @@ CONF_DIALOG_WEBHOOK_SECRET = "dialog_webhook_secret" CONF_DIALOG_AUTO_CREATE_ARTIFACTS = "dialog_auto_create_artifacts" CONF_DIALOG_AUTO_CREATE_SESSION_ID = "dialog_auto_create_session_id" +# Persisted DeviceCodeSession (JSON) so the auto-create button can advance +# the Device Flow state machine across multiple clicks. Cleared after a +# successful poll, on expiry, or on Cancel. +CONF_DIALOG_AUTO_CREATE_DEVICE_SESSION = "dialog_auto_create_device_session" # --------------------------------------------------------------------------- # Config actions (config-flow buttons) # --------------------------------------------------------------------------- CONF_ACTION_AUTO_CREATE_DIALOG = "auto_create_dialog_skill" 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" # --------------------------------------------------------------------------- # Webhook routing @@ -53,7 +67,18 @@ # 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. -DIALOG_CHANNEL = os.environ.get("MA_YANDEX_DIALOG_CHANNEL", "aliceSkill") +# 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 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..605b86c137 --- /dev/null +++ b/music_assistant/providers/yandex_alice/dialog_skill_meta.py @@ -0,0 +1,88 @@ +"""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_WEBHOOK_BASE_PATH + + +def build_backend_uri(base_url: str, webhook_secret: str) -> str: + """Compose the public webhook URL Yandex must call. + + Yandex requires HTTPS — the dev-console rejects plain http:// at draft-update + time, but we surface the rejection up-front so the user sees a clear error + before the Device Flow even starts. + + Raises: + ValueError: ``base_url`` is empty / not HTTPS, 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 base.lower().startswith("https://"): + msg = f"External base URL must use HTTPS (got: {base!r})" + 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/manifest.json b/music_assistant/providers/yandex_alice/manifest.json index b5f1c48a75..d46a92ad73 100644 --- a/music_assistant/providers/yandex_alice/manifest.json +++ b/music_assistant/providers/yandex_alice/manifest.json @@ -8,7 +8,8 @@ "[dext0r/yandex_smart_home](https://github.com/dext0r/yandex_smart_home)" ], "requirements": [ - "ya-passport-auth==1.3.0" + "ya-passport-auth==1.3.0", + "ya-dialogs-api>=2.0.0" ], "documentation": "https://github.com/trudenboy/ma-provider-yandex-alice", "stage": "beta", diff --git a/requirements_all.txt b/requirements_all.txt index c7a71d869b..6726258914 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.0.0 ya-passport-auth==1.3.0 yandex-music==3.0.0 ytmusicapi==1.11.5 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..fd763860f7 --- /dev/null +++ b/tests/providers/yandex_alice/test_auth_session.py @@ -0,0 +1,140 @@ +# 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 + +import pytest +from ya_passport_auth.exceptions import InvalidCredentialsError + +from music_assistant.providers.yandex_alice import auth_session + + +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 = [] + + async def _fake_refresh(self, x_token): + 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, x_token): + 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 = [] + + class _FakePassportClient: + def __init__(self): + pass + + async def close(self): + close_called.append(True) + + from contextlib import asynccontextmanager + + @asynccontextmanager + async def _fake_create(config=None): + 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, x_token): + 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..5986d2f39d --- /dev/null +++ b/tests/providers/yandex_alice/test_auto_create.py @@ -0,0 +1,488 @@ +"""Tests for provider/auto_create.py — self-resuming Device Flow + skill pipeline. + +We mock two external dependencies: + +- ``provider.auth_session.passport_client_session`` — the async context manager + that yields a PassportClient. Tests inject a fake PassportClient with the + exact ``start_device_login`` / ``poll_device_until_confirmed`` / + ``refresh_passport_cookies`` shape needed for the case under test. +- ``provider.auto_create.auto_create_skill`` — the ya-dialogs-api orchestrator. + Tests inject the desired ``SkillCreationArtifacts`` outcome. +""" + +from __future__ import annotations + +import time +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 import DeviceCodeSession, SecretStr +from ya_passport_auth.exceptions import ( + DeviceCodeTimeoutError, + InvalidCredentialsError, +) + +from music_assistant.providers.yandex_alice import auto_create +from music_assistant.providers.yandex_alice.auto_create import ( + LocalAutoCreateStage, + deserialize_device_session, + run_auto_create_step, + serialize_device_session, +) + +if TYPE_CHECKING: + from collections.abc import AsyncIterator + +# --------------------------------------------------------------------------- +# Test helpers +# --------------------------------------------------------------------------- + + +def _make_device_code_session( + *, + user_code: str = "ABCD-1234", + device_code: str = "device-code-secret", + expires_in: int = 600, + interval: int = 5, +) -> DeviceCodeSession: + """Build a DeviceCodeSession with sensible test defaults.""" + return DeviceCodeSession( + device_code=SecretStr(device_code), + user_code=user_code, + verification_url="https://ya.ru/device", + expires_in=expires_in, + interval=interval, + ) + + +def _patch_passport_client( + monkeypatch: pytest.MonkeyPatch, + *, + start_session: DeviceCodeSession | Exception | None = None, + poll_outcome: Any = None, +) -> MagicMock: + """Install a fake passport_client_session() yielding a configured client. + + *start_session*: result (or Exception) of ``start_device_login``. + *poll_outcome*: tuple ``(credentials, refresh_side_effect)`` or Exception. + ``credentials`` becomes the return of ``poll_device_until_confirmed``; + ``refresh_side_effect`` is set on ``refresh_passport_cookies``. + Returns the fake client (a MagicMock) so tests can assert call_args. + """ + client = MagicMock() + if start_session is not None: + if isinstance(start_session, Exception): + client.start_device_login = AsyncMock(side_effect=start_session) + else: + client.start_device_login = AsyncMock(return_value=start_session) + else: + client.start_device_login = AsyncMock() + + if poll_outcome is not None: + if isinstance(poll_outcome, Exception): + client.poll_device_until_confirmed = AsyncMock(side_effect=poll_outcome) + client.refresh_passport_cookies = AsyncMock() + else: + credentials, refresh_side_effect = poll_outcome + client.poll_device_until_confirmed = AsyncMock(return_value=credentials) + client.refresh_passport_cookies = AsyncMock(side_effect=refresh_side_effect) + else: + client.poll_device_until_confirmed = AsyncMock() + client.refresh_passport_cookies = AsyncMock() + + @asynccontextmanager + async def _fake_cm() -> AsyncIterator[MagicMock]: + yield client + + monkeypatch.setattr(auto_create, "passport_client_session", _fake_cm) + return client + + +def _patch_auto_create_skill( + monkeypatch: pytest.MonkeyPatch, + *, + return_artifacts: SkillCreationArtifacts, + side_effect: Exception | None = None, +) -> AsyncMock: + """Replace auto_create.auto_create_skill with an AsyncMock returning the desired result.""" + mock = AsyncMock(return_value=return_artifacts, side_effect=side_effect) + monkeypatch.setattr(auto_create, "auto_create_skill", mock) + return mock + + +def _make_credentials() -> MagicMock: + """Build a mock Credentials with x_token returning 'fresh-x-token'.""" + creds = MagicMock() + creds.x_token = SecretStr("fresh-x-token") + return creds + + +# --------------------------------------------------------------------------- +# DeviceCodeSession serialisation +# --------------------------------------------------------------------------- + + +class TestSerializeDeviceSession: + """JSON round-trip preserves all fields and excludes plaintext from repr.""" + + def test_round_trip(self) -> None: + """Serialise → deserialise yields equal session + epoch.""" + session = _make_device_code_session() + epoch = 1_746_537_600.0 + blob = serialize_device_session(session, epoch) + assert blob is not None + rehydrated = deserialize_device_session(blob) + assert rehydrated is not None + s2, e2 = rehydrated + assert s2.user_code == session.user_code + assert s2.verification_url == session.verification_url + assert s2.expires_in == session.expires_in + assert s2.interval == session.interval + assert s2.device_code.get_secret() == "device-code-secret" + assert e2 == epoch + + def test_serialise_none(self) -> None: + """``None`` session → ``None`` blob.""" + assert serialize_device_session(None, 0.0) is None + + def test_deserialise_empty(self) -> None: + """Empty / None raw → None outcome.""" + assert deserialize_device_session(None) is None + assert deserialize_device_session("") is None + + def test_deserialise_garbage(self) -> None: + """Non-JSON or non-dict input returns None instead of raising.""" + assert deserialize_device_session("not json {{{") is None + assert deserialize_device_session('"a string"') is None + + def test_deserialise_missing_fields(self) -> None: + """Missing required keys → None (corrupt blob is silently dropped).""" + assert deserialize_device_session('{"user_code": "X"}') is None + + +# --------------------------------------------------------------------------- +# Stage 1: Start Device Flow (IDLE click) +# --------------------------------------------------------------------------- + + +class TestStartDeviceFlow: + """First click on IDLE: request user_code, persist session blob.""" + + @pytest.mark.asyncio + async def test_starts_device_flow_returns_user_code( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Outcome contains user_code + verification_url + serialised session blob.""" + session = _make_device_code_session(user_code="WXYZ-9876") + _patch_passport_client(monkeypatch, start_session=session) + + outcome = await run_auto_create_step( + skill_name="Test", + backend_uri="https://example.test/api/yandex_dialogs/webhook/sec", + description="Test description.", + structured_examples=None, + activation_phrases=None, + cached_x_token=None, + pending_device_session_blob=None, + artifacts=SkillCreationArtifacts(), + ) + + assert outcome.stage == LocalAutoCreateStage.DEVICE_FLOW_STARTED + assert outcome.user_code == "WXYZ-9876" + assert outcome.verification_url == "https://ya.ru/device" + assert outcome.device_session_blob is not None + # The serialised blob round-trips + rehydrated = deserialize_device_session(outcome.device_session_blob) + assert rehydrated is not None + assert rehydrated[0].user_code == "WXYZ-9876" + assert outcome.x_token is None + assert "WXYZ-9876" in outcome.user_message + + @pytest.mark.asyncio + async def test_start_failure_returns_failed(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Network error during start_device_login → FAILED outcome with message.""" + _patch_passport_client(monkeypatch, start_session=RuntimeError("network down")) + + outcome = await run_auto_create_step( + skill_name="Test", + backend_uri="https://example.test/x", + description="d", + structured_examples=None, + activation_phrases=None, + cached_x_token=None, + pending_device_session_blob=None, + artifacts=SkillCreationArtifacts(), + ) + + assert outcome.stage == LocalAutoCreateStage.FAILED + assert outcome.device_session_blob is None + assert "network down" in (outcome.user_message or "") + + +# --------------------------------------------------------------------------- +# Stage 2: Resume Device Flow (subsequent clicks while DEVICE_FLOW_STARTED) +# --------------------------------------------------------------------------- + + +class TestResumeDeviceFlow: + """Second-click polling: confirm → run pipeline; or keep waiting.""" + + @pytest.mark.asyncio + async def test_confirmed_proceeds_to_pipeline(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Successful poll captures x_token and immediately runs the pipeline.""" + session = _make_device_code_session() + blob = serialize_device_session(session, time.time() + 600) + + _patch_passport_client( + monkeypatch, + poll_outcome=(_make_credentials(), None), + ) + done = SkillCreationArtifacts( + state=SkillCreationState.DONE, + skill_id="skill-uuid-1", + last_known_name="Test", + ) + skill_mock = _patch_auto_create_skill(monkeypatch, return_artifacts=done) + + outcome = await run_auto_create_step( + skill_name="Test", + backend_uri="https://example.test/x", + description="d", + structured_examples=None, + activation_phrases=None, + cached_x_token=None, + pending_device_session_blob=blob, + artifacts=SkillCreationArtifacts(), + ) + + assert outcome.stage == LocalAutoCreateStage.DONE + assert outcome.x_token == "fresh-x-token" + # Device session is dropped after successful auth. + assert outcome.device_session_blob is None + assert outcome.artifacts.skill_id == "skill-uuid-1" + skill_mock.assert_awaited_once() + + @pytest.mark.asyncio + async def test_still_waiting_keeps_session(self, monkeypatch: pytest.MonkeyPatch) -> None: + """DeviceCodeTimeoutError within the local poll window → keep waiting.""" + # expires_at_epoch ~10 minutes in the future, but our window is 8s. + session = _make_device_code_session() + epoch = time.time() + 600 + blob = serialize_device_session(session, epoch) + + _patch_passport_client( + monkeypatch, + poll_outcome=DeviceCodeTimeoutError("local poll window elapsed"), + ) + + outcome = await run_auto_create_step( + skill_name="Test", + backend_uri="https://example.test/x", + description="d", + structured_examples=None, + activation_phrases=None, + cached_x_token=None, + pending_device_session_blob=blob, + artifacts=SkillCreationArtifacts(), + ) + + assert outcome.stage == LocalAutoCreateStage.DEVICE_FLOW_STARTED + assert outcome.user_code == "ABCD-1234" + # Session blob is preserved for next click + assert outcome.device_session_blob is not None + + @pytest.mark.asyncio + async def test_underlying_expiry_returns_failed(self, monkeypatch: pytest.MonkeyPatch) -> None: + """If user_code's actual expiry has passed → FAILED, drop session.""" + session = _make_device_code_session() + # expires_at_epoch in the past + blob = serialize_device_session(session, time.time() - 1.0) + _patch_passport_client(monkeypatch) + + outcome = await run_auto_create_step( + skill_name="Test", + backend_uri="https://example.test/x", + description="d", + structured_examples=None, + activation_phrases=None, + cached_x_token=None, + pending_device_session_blob=blob, + artifacts=SkillCreationArtifacts(), + ) + + assert outcome.stage == LocalAutoCreateStage.FAILED + assert outcome.device_session_blob is None + assert "expired" in (outcome.user_message or "") + + @pytest.mark.asyncio + async def test_invalid_credentials_returns_failed( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """InvalidCredentialsError during poll → FAILED + drop session.""" + session = _make_device_code_session() + blob = serialize_device_session(session, time.time() + 600) + _patch_passport_client( + monkeypatch, + poll_outcome=InvalidCredentialsError("auth cancelled"), + ) + + outcome = await run_auto_create_step( + skill_name="Test", + backend_uri="https://example.test/x", + description="d", + structured_examples=None, + activation_phrases=None, + cached_x_token=None, + pending_device_session_blob=blob, + artifacts=SkillCreationArtifacts(), + ) + + assert outcome.stage == LocalAutoCreateStage.FAILED + assert outcome.device_session_blob is None + msg = (outcome.user_message or "").lower() + assert "rejected" in msg or "cancelled" in msg + + +# --------------------------------------------------------------------------- +# Stage 3: Run pipeline with cached x_token +# --------------------------------------------------------------------------- + + +class TestRunPipeline: + """Post-auth click runs the pipeline end-to-end on cached cookies.""" + + @pytest.mark.asyncio + async def test_happy_path(self, monkeypatch: pytest.MonkeyPatch) -> None: + """auto_create_skill returns DONE → outcome stage=DONE with skill_id link.""" + done = SkillCreationArtifacts( + state=SkillCreationState.DONE, + skill_id="skill-uuid-2", + last_known_name="Test", + ) + _patch_auto_create_skill(monkeypatch, return_artifacts=done) + + outcome = await run_auto_create_step( + skill_name="Test", + backend_uri="https://example.test/x", + description="d", + structured_examples=None, + activation_phrases=None, + cached_x_token="cached-token", + pending_device_session_blob=None, + artifacts=SkillCreationArtifacts(), + ) + + assert outcome.stage == LocalAutoCreateStage.DONE + assert outcome.artifacts.skill_id == "skill-uuid-2" + assert "skill-uuid-2" in outcome.user_message + + @pytest.mark.asyncio + async def test_failed_artifacts_become_failed_outcome( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """auto_create_skill returns FAILED → outcome stage=FAILED, last_error rendered.""" + failed = SkillCreationArtifacts( + state=SkillCreationState.FAILED, + last_error="Skill name is already taken — pick another", + ) + _patch_auto_create_skill(monkeypatch, return_artifacts=failed) + + outcome = await run_auto_create_step( + skill_name="Test", + backend_uri="https://example.test/x", + description="d", + structured_examples=None, + activation_phrases=None, + cached_x_token="cached-token", + pending_device_session_blob=None, + artifacts=SkillCreationArtifacts(), + ) + + assert outcome.stage == LocalAutoCreateStage.FAILED + assert "already taken" in outcome.user_message + + @pytest.mark.asyncio + async def test_passport_invalid_credentials_clears_token( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """InvalidCredentialsError from cached refresh → outcome.x_token='' to clear cache.""" + _patch_auto_create_skill( + monkeypatch, + return_artifacts=SkillCreationArtifacts(), # not used + side_effect=InvalidCredentialsError("session expired"), + ) + + outcome = await run_auto_create_step( + skill_name="Test", + backend_uri="https://example.test/x", + description="d", + structured_examples=None, + activation_phrases=None, + cached_x_token="stale-token", + pending_device_session_blob=None, + artifacts=SkillCreationArtifacts(), + ) + + assert outcome.stage == LocalAutoCreateStage.FAILED + assert outcome.x_token == "" # Signal to dispatcher: clear the cache + assert "expired" in (outcome.user_message or "") + + +# --------------------------------------------------------------------------- +# Top-level dispatch decisions +# --------------------------------------------------------------------------- + + +class TestRunAutoCreateStepDispatch: + """run_auto_create_step branches by (artifacts.state, x_token, pending session).""" + + @pytest.mark.asyncio + async def test_done_state_is_no_op(self) -> None: + """artifacts.state=DONE → outcome stage=DONE, no network calls.""" + artifacts = SkillCreationArtifacts( + state=SkillCreationState.DONE, + skill_id="existing", + last_known_name="X", + ) + outcome = await run_auto_create_step( + skill_name="X", + backend_uri="https://example.test/x", + description="d", + structured_examples=None, + activation_phrases=None, + cached_x_token="t", + pending_device_session_blob=None, + artifacts=artifacts, + ) + assert outcome.stage == LocalAutoCreateStage.DONE + # Artifacts pass through unchanged + assert outcome.artifacts.skill_id == "existing" + + @pytest.mark.asyncio + async def test_pending_session_takes_precedence_over_token( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Even with cached x_token, a pending session means we must finish auth first.""" + session = _make_device_code_session() + blob = serialize_device_session(session, time.time() + 600) + _patch_passport_client( + monkeypatch, + poll_outcome=DeviceCodeTimeoutError("waiting"), + ) + + outcome = await run_auto_create_step( + skill_name="X", + backend_uri="https://example.test/x", + description="d", + structured_examples=None, + activation_phrases=None, + cached_x_token="ignored-while-session-pending", + pending_device_session_blob=blob, + artifacts=SkillCreationArtifacts(), + ) + + # We polled the pending session, not jumped to the pipeline. + assert outcome.stage == LocalAutoCreateStage.DEVICE_FLOW_STARTED 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..ed43ec86b5 --- /dev/null +++ b/tests/providers/yandex_alice/test_auto_update.py @@ -0,0 +1,188 @@ +"""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, + 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, + 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, + 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, + 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"], + artifacts=SkillCreationArtifacts(state=SkillCreationState.DONE, skill_id="sk-1"), + ) + + 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, + 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, + 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_dialogs.py b/tests/providers/yandex_alice/test_dialogs.py index fb16556be9..bdc3132309 100644 --- a/tests/providers/yandex_alice/test_dialogs.py +++ b/tests/providers/yandex_alice/test_dialogs.py @@ -14,7 +14,11 @@ 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 +from music_assistant.providers.yandex_alice.dialogs import ( + _STATE_CACHE_TTL_SEC, + DialogsWebhookHandler, + _tts_for, +) if TYPE_CHECKING: from aiohttp import web diff --git a/tests/providers/yandex_alice/test_dialogs_control.py b/tests/providers/yandex_alice/test_dialogs_control.py index 762a7900b0..db392dfa31 100644 --- a/tests/providers/yandex_alice/test_dialogs_control.py +++ b/tests/providers/yandex_alice/test_dialogs_control.py @@ -391,7 +391,9 @@ async def test_list_players_is_a_safe_noop_with_warning( call safe rather than a silent no-op. """ mass = self._make_mass() - with caplog.at_level(logging.WARNING, logger="music_assistant.providers.yandex_alice.dialogs_control"): + with caplog.at_level( + logging.WARNING, logger="music_assistant.providers.yandex_alice.dialogs_control" + ): await execute_control(mass, ParsedControl(action="list_players"), self._player()) # No MA command dispatched. mass.player_queues.pause.assert_not_awaited() @@ -451,7 +453,9 @@ async def test_seek_start(self) -> None: async def test_now_playing_is_safe_noop(self, caplog: pytest.LogCaptureFixture) -> None: """`now_playing` reaching execute_control logs warning and no-ops (handler dispatches).""" mass = self._make_mass() - with caplog.at_level(logging.WARNING, logger="music_assistant.providers.yandex_alice.dialogs_control"): + 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) @@ -459,7 +463,9 @@ async def test_now_playing_is_safe_noop(self, caplog: pytest.LogCaptureFixture) async def test_transfer_is_safe_noop(self, caplog: pytest.LogCaptureFixture) -> None: """`transfer` reaching execute_control logs warning and no-ops (handler dispatches).""" mass = self._make_mass() - with caplog.at_level(logging.WARNING, logger="music_assistant.providers.yandex_alice.dialogs_control"): + 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() ) 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..5089623682 --- /dev/null +++ b/tests/providers/yandex_alice/test_init_actions.py @@ -0,0 +1,506 @@ +# 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, +) + +import provider +from music_assistant.providers.yandex_alice 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_AUTO_CREATE_DEVICE_SESSION, + CONF_DIALOG_SKILL_ID, + CONF_DIALOG_SKILL_NAME, + CONF_EXTERNAL_BASE_URL, + CONF_INSTANCE_NAME, + get_config_entries, +) +from music_assistant.providers.yandex_alice.auto_create import ( + AutoCreateOutcome, + LocalAutoCreateStage, +) +from music_assistant.providers.yandex_alice.auto_update import AutoUpdateOutcome + + +def _make_mass() -> MagicMock: + """Build a MagicMock MA with empty player + playlist enumeration.""" + mass = MagicMock() + mass.players.all_players = MagicMock(return_value=[]) + return mass + + +@pytest.fixture(autouse=True) +def _stub_playlists(monkeypatch: pytest.MonkeyPatch) -> None: + """Empty playlist options for all tests in this module.""" + monkeypatch.setattr(provider, "fetch_playlist_options", AsyncMock(return_value=[])) + + +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_auto_create_button(self) -> None: + """Auto-create ACTION entry is always present.""" + entries = await get_config_entries(_make_mass(), values={}) + keys = _entries_by_key(entries) + assert CONF_ACTION_AUTO_CREATE_DIALOG 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 + + @pytest.mark.asyncio + async def test_rename_visible_when_skill_id_and_token_present(self) -> None: + """Both skill_id and cached x_token populate the form → rename shows up.""" + artifacts = SkillCreationArtifacts( + state=SkillCreationState.DONE, + skill_id="sk-1", + last_known_name="My Skill", + ) + values = { + CONF_AUTH_X_TOKEN: "tok", + CONF_DIALOG_AUTO_CREATE_ARTIFACTS: dump_artifacts(artifacts), + } + entries = await get_config_entries(_make_mass(), values=values) + keys = _entries_by_key(entries) + assert CONF_ACTION_RENAME_DIALOG_SKILL in keys + + +# --------------------------------------------------------------------------- +# action = CONF_ACTION_AUTO_CREATE_DIALOG +# --------------------------------------------------------------------------- + + +class TestAutoCreateAction: + """auto-create dispatch: invokes run_auto_create_step with derived inputs.""" + + @pytest.mark.asyncio + async def test_invokes_run_auto_create_step(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Click → run_auto_create_step is awaited once with skill_name + backend_uri.""" + outcome = AutoCreateOutcome( + artifacts=SkillCreationArtifacts(), + device_session_blob='{"user_code": "X"}', + x_token=None, + user_code="X", + verification_url="https://ya.ru/device", + user_message="started", + stage=LocalAutoCreateStage.DEVICE_FLOW_STARTED, + ) + step_mock = AsyncMock(return_value=outcome) + monkeypatch.setattr(provider, "run_auto_create_step", 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", + } + await get_config_entries( + _make_mass(), + action=CONF_ACTION_AUTO_CREATE_DIALOG, + values=values, + ) + + step_mock.assert_awaited_once() + 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_auto_create_step is called.""" + step_mock = AsyncMock() + monkeypatch.setattr(provider, "run_auto_create_step", step_mock) + + values: dict[str, Any] = { + CONF_INSTANCE_NAME: "MA", + CONF_EXTERNAL_BASE_URL: "http://insecure.example.com", + } + 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): + captured_artifacts.append(kwargs["artifacts"]) + return AutoCreateOutcome( + artifacts=SkillCreationArtifacts(), + device_session_blob=None, + x_token=None, + user_code=None, + verification_url=None, + user_message="restart", + stage=LocalAutoCreateStage.IDLE, + ) + + monkeypatch.setattr(provider, "run_auto_create_step", _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", + } + 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", + ), + device_session_blob=None, + x_token="fresh", + user_code=None, + verification_url=None, + user_message="✅", + stage=LocalAutoCreateStage.DONE, + ) + monkeypatch.setattr(provider, "run_auto_create_step", AsyncMock(return_value=outcome)) + + values: dict[str, Any] = {CONF_EXTERNAL_BASE_URL: "https://ma.example.com"} + await get_config_entries( + _make_mass(), + action=CONF_ACTION_AUTO_CREATE_DIALOG, + values=values, + ) + assert values[CONF_DIALOG_SKILL_ID] == "sk-new-uuid" + assert values[CONF_AUTH_X_TOKEN] == "fresh" + + @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): + captured_artifacts.append(kwargs["artifacts"]) + return AutoCreateOutcome( + artifacts=kwargs["artifacts"], + device_session_blob=None, + x_token=None, + user_code=None, + verification_url=None, + user_message="stub", + stage=LocalAutoCreateStage.PIPELINE_RUNNING, + ) + + monkeypatch.setattr(provider, "run_auto_create_step", _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(provider, "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() + 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(provider, "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: drop pending session + reset artifacts; keep cached x_token.""" + + @pytest.mark.asyncio + async def test_resets_artifacts_and_session(self) -> None: + """Cancel clears CONF_DIALOG_AUTO_CREATE_DEVICE_SESSION + resets artifacts.""" + values: dict[str, Any] = { + CONF_DIALOG_AUTO_CREATE_DEVICE_SESSION: '{"user_code": "X"}', + 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 + # Session dropped + assert values[CONF_DIALOG_AUTO_CREATE_DEVICE_SESSION] == "" + # 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): + captured_uris.append(kwargs["backend_uri"]) + return AutoCreateOutcome( + artifacts=SkillCreationArtifacts(), + device_session_blob=None, + x_token=None, + user_code=None, + verification_url=None, + user_message="ok", + stage=LocalAutoCreateStage.IDLE, + ) + + monkeypatch.setattr(provider, "run_auto_create_step", _capture) + + # First click: no secret in values → dispatcher generates + writes back. + values: dict[str, Any] = {CONF_EXTERNAL_BASE_URL: "https://ma.example.com"} + 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_renders_create_label(self) -> None: + """artifacts=APP_CREATED + no x_token → auto-create button says 'Create skill'.""" + artifacts = SkillCreationArtifacts( + state=SkillCreationState.APP_CREATED, + skill_id="sk-partial", + ) + values = { + 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) + action_entry = keys[CONF_ACTION_AUTO_CREATE_DIALOG] + assert action_entry.action_label == "Create skill" + + @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 = { + 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) + assert keys[CONF_ACTION_AUTO_CREATE_DIALOG].action_label == "Resume" + + +class TestDeviceFlowStartedHintOnReload: + """LABEL re-shows user_code + URL after a form reload mid-Device-Flow.""" + + @pytest.mark.asyncio + async def test_label_renders_user_code_from_persisted_session(self) -> None: + """device_session_blob in values → status LABEL shows the code + URL.""" + import json + + device_session = json.dumps( + { + "device_code": "secret", + "user_code": "WXYZ-1234", + "verification_url": "https://ya.ru/device", + "expires_in": 600, + "interval": 5, + "expires_at_epoch": 9999999999.0, + } + ) + values = {CONF_DIALOG_AUTO_CREATE_DEVICE_SESSION: device_session} + entries = await get_config_entries(_make_mass(), values=values) + keys = _entries_by_key(entries) + + # Status LABEL is rendered with the code + URL inline. + assert "label_auto_create_status" in keys + status_label = keys["label_auto_create_status"].label + assert "WXYZ-1234" in status_label + assert "ya.ru/device" in status_label From 65109bace9d9e1bf96268448f1282135d205382f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 6 May 2026 17:58:56 +0000 Subject: [PATCH 03/10] feat(yandex_alice): sync provider from ma-provider-yandex-alice v1.1.1 --- .../yandex_alice/test_auth_session.py | 22 +++++--- .../yandex_alice/test_auto_update.py | 1 + .../yandex_alice/test_init_actions.py | 50 ++++++++++--------- 3 files changed, 41 insertions(+), 32 deletions(-) diff --git a/tests/providers/yandex_alice/test_auth_session.py b/tests/providers/yandex_alice/test_auth_session.py index fd763860f7..7863fc5eef 100644 --- a/tests/providers/yandex_alice/test_auth_session.py +++ b/tests/providers/yandex_alice/test_auth_session.py @@ -10,11 +10,16 @@ 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.""" @@ -36,9 +41,9 @@ class TestCachedAuthenticatedSession: @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 = [] + captured_token: list[str] = [] - async def _fake_refresh(self, x_token): + 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. @@ -58,7 +63,7 @@ async def test_propagates_invalid_credentials_error( ) -> None: """Yandex 401 → InvalidCredentialsError propagates out, no Device Flow fallback.""" - async def _failing_refresh(self, x_token): + async def _failing_refresh(self: Any, x_token: SecretStr) -> None: msg = "x_token rejected" raise InvalidCredentialsError(msg) @@ -84,19 +89,20 @@ class TestPassportClientSession: @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 = [] + close_called: list[bool] = [] class _FakePassportClient: - def __init__(self): + def __init__(self) -> None: pass - async def close(self): + async def close(self) -> None: close_called.append(True) + from collections.abc import AsyncIterator from contextlib import asynccontextmanager @asynccontextmanager - async def _fake_create(config=None): + async def _fake_create(config: Any = None) -> AsyncIterator[_FakePassportClient]: client = _FakePassportClient() try: yield client @@ -122,7 +128,7 @@ async def test_factory_yields_authenticated_session( """Each call to the factory opens a fresh refresh_passport_cookies-loaded session.""" refresh_calls: list[str] = [] - async def _fake_refresh(self, x_token): + async def _fake_refresh(self: Any, x_token: SecretStr) -> None: refresh_calls.append(x_token.get_secret()) monkeypatch.setattr( diff --git a/tests/providers/yandex_alice/test_auto_update.py b/tests/providers/yandex_alice/test_auto_update.py index ed43ec86b5..2507aea37f 100644 --- a/tests/providers/yandex_alice/test_auto_update.py +++ b/tests/providers/yandex_alice/test_auto_update.py @@ -127,6 +127,7 @@ async def test_passes_dialog_channel_and_metadata( 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" diff --git a/tests/providers/yandex_alice/test_init_actions.py b/tests/providers/yandex_alice/test_init_actions.py index 5089623682..b05d818e20 100644 --- a/tests/providers/yandex_alice/test_init_actions.py +++ b/tests/providers/yandex_alice/test_init_actions.py @@ -19,8 +19,14 @@ dump_artifacts, ) -import provider -from music_assistant.providers.yandex_alice import ( +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, @@ -31,13 +37,7 @@ CONF_DIALOG_SKILL_NAME, CONF_EXTERNAL_BASE_URL, CONF_INSTANCE_NAME, - get_config_entries, ) -from music_assistant.providers.yandex_alice.auto_create import ( - AutoCreateOutcome, - LocalAutoCreateStage, -) -from music_assistant.providers.yandex_alice.auto_update import AutoUpdateOutcome def _make_mass() -> MagicMock: @@ -50,7 +50,7 @@ def _make_mass() -> MagicMock: @pytest.fixture(autouse=True) def _stub_playlists(monkeypatch: pytest.MonkeyPatch) -> None: """Empty playlist options for all tests in this module.""" - monkeypatch.setattr(provider, "fetch_playlist_options", AsyncMock(return_value=[])) + monkeypatch.setattr(yandex_alice, "fetch_playlist_options", AsyncMock(return_value=[])) def _entries_by_key(entries: tuple[Any, ...]) -> dict[str, Any]: @@ -88,7 +88,7 @@ async def test_rename_visible_when_skill_id_and_token_present(self) -> None: skill_id="sk-1", last_known_name="My Skill", ) - values = { + values: dict[str, Any] = { CONF_AUTH_X_TOKEN: "tok", CONF_DIALOG_AUTO_CREATE_ARTIFACTS: dump_artifacts(artifacts), } @@ -118,7 +118,7 @@ async def test_invokes_run_auto_create_step(self, monkeypatch: pytest.MonkeyPatc stage=LocalAutoCreateStage.DEVICE_FLOW_STARTED, ) step_mock = AsyncMock(return_value=outcome) - monkeypatch.setattr(provider, "run_auto_create_step", step_mock) + monkeypatch.setattr(yandex_alice, "run_auto_create_step", step_mock) values: dict[str, Any] = { CONF_INSTANCE_NAME: "Music Assistant", @@ -132,6 +132,7 @@ async def test_invokes_run_auto_create_step(self, monkeypatch: pytest.MonkeyPatc ) 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( @@ -142,7 +143,7 @@ async def test_invokes_run_auto_create_step(self, monkeypatch: pytest.MonkeyPatc async def test_https_required_short_circuits(self, monkeypatch: pytest.MonkeyPatch) -> None: """http:// base URL → FAILED before run_auto_create_step is called.""" step_mock = AsyncMock() - monkeypatch.setattr(provider, "run_auto_create_step", step_mock) + monkeypatch.setattr(yandex_alice, "run_auto_create_step", step_mock) values: dict[str, Any] = { CONF_INSTANCE_NAME: "MA", @@ -167,7 +168,7 @@ async def test_re_click_on_done_resets_artifacts(self, monkeypatch: pytest.Monke """If artifacts.state was DONE, dispatcher resets to NONE before stepping.""" captured_artifacts: list[SkillCreationArtifacts] = [] - async def _capture(**kwargs): + async def _capture(**kwargs: Any) -> AutoCreateOutcome: captured_artifacts.append(kwargs["artifacts"]) return AutoCreateOutcome( artifacts=SkillCreationArtifacts(), @@ -179,7 +180,7 @@ async def _capture(**kwargs): stage=LocalAutoCreateStage.IDLE, ) - monkeypatch.setattr(provider, "run_auto_create_step", _capture) + monkeypatch.setattr(yandex_alice, "run_auto_create_step", _capture) done = SkillCreationArtifacts( state=SkillCreationState.DONE, @@ -216,7 +217,7 @@ async def test_writes_skill_id_on_done(self, monkeypatch: pytest.MonkeyPatch) -> user_message="✅", stage=LocalAutoCreateStage.DONE, ) - monkeypatch.setattr(provider, "run_auto_create_step", AsyncMock(return_value=outcome)) + monkeypatch.setattr(yandex_alice, "run_auto_create_step", AsyncMock(return_value=outcome)) values: dict[str, Any] = {CONF_EXTERNAL_BASE_URL: "https://ma.example.com"} await get_config_entries( @@ -234,7 +235,7 @@ async def test_backup_restore_pre_sets_app_created( """skill_id in values + artifacts NONE → pre-set to APP_CREATED to skip create_app.""" captured_artifacts: list[SkillCreationArtifacts] = [] - async def _capture(**kwargs): + async def _capture(**kwargs: Any) -> AutoCreateOutcome: captured_artifacts.append(kwargs["artifacts"]) return AutoCreateOutcome( artifacts=kwargs["artifacts"], @@ -246,7 +247,7 @@ async def _capture(**kwargs): stage=LocalAutoCreateStage.PIPELINE_RUNNING, ) - monkeypatch.setattr(provider, "run_auto_create_step", _capture) + monkeypatch.setattr(yandex_alice, "run_auto_create_step", _capture) # Empty artifacts but skill_id present (config restored from backup) values: dict[str, Any] = { @@ -285,7 +286,7 @@ async def test_invokes_run_auto_update(self, monkeypatch: pytest.MonkeyPatch) -> user_message="✅ обновлён", ) update_mock = AsyncMock(return_value=result) - monkeypatch.setattr(provider, "run_auto_update", update_mock) + monkeypatch.setattr(yandex_alice, "run_auto_update", update_mock) values: dict[str, Any] = { CONF_DIALOG_SKILL_NAME: "New Name", @@ -306,6 +307,7 @@ async def test_invokes_run_auto_update(self, monkeypatch: pytest.MonkeyPatch) -> ) 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" @@ -322,7 +324,7 @@ async def test_token_cleared_on_auth_failure(self, monkeypatch: pytest.MonkeyPat x_token="", user_message="auth expired", ) - monkeypatch.setattr(provider, "run_auto_update", AsyncMock(return_value=result)) + monkeypatch.setattr(yandex_alice, "run_auto_update", AsyncMock(return_value=result)) values: dict[str, Any] = { CONF_AUTH_X_TOKEN: "stale", @@ -399,7 +401,7 @@ async def test_secret_reused_across_action_clicks( """Two consecutive clicks see the same backend_uri/secret (no regen).""" captured_uris: list[str] = [] - async def _capture(**kwargs): + async def _capture(**kwargs: Any) -> AutoCreateOutcome: captured_uris.append(kwargs["backend_uri"]) return AutoCreateOutcome( artifacts=SkillCreationArtifacts(), @@ -411,7 +413,7 @@ async def _capture(**kwargs): stage=LocalAutoCreateStage.IDLE, ) - monkeypatch.setattr(provider, "run_auto_create_step", _capture) + monkeypatch.setattr(yandex_alice, "run_auto_create_step", _capture) # First click: no secret in values → dispatcher generates + writes back. values: dict[str, Any] = {CONF_EXTERNAL_BASE_URL: "https://ma.example.com"} @@ -452,7 +454,7 @@ async def test_intermediate_state_without_token_renders_create_label(self) -> No state=SkillCreationState.APP_CREATED, skill_id="sk-partial", ) - values = { + values: dict[str, Any] = { CONF_DIALOG_AUTO_CREATE_ARTIFACTS: dump_artifacts(artifacts), # No CONF_AUTH_X_TOKEN → next click will hit Device Flow } @@ -468,7 +470,7 @@ async def test_intermediate_state_with_token_renders_resume_label(self) -> None: state=SkillCreationState.APP_CREATED, skill_id="sk-partial", ) - values = { + values: dict[str, Any] = { CONF_DIALOG_AUTO_CREATE_ARTIFACTS: dump_artifacts(artifacts), CONF_AUTH_X_TOKEN: "tok", } @@ -495,7 +497,7 @@ async def test_label_renders_user_code_from_persisted_session(self) -> None: "expires_at_epoch": 9999999999.0, } ) - values = {CONF_DIALOG_AUTO_CREATE_DEVICE_SESSION: device_session} + values: dict[str, Any] = {CONF_DIALOG_AUTO_CREATE_DEVICE_SESSION: device_session} entries = await get_config_entries(_make_mass(), values=values) keys = _entries_by_key(entries) From 8a4ffdc7f836e20954a418ba39d14bdadc8e46fc Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 6 May 2026 18:47:31 +0000 Subject: [PATCH 04/10] feat(yandex_alice): sync provider from ma-provider-yandex-alice v1.1.2 --- .../providers/yandex_alice/__init__.py | 54 ++++++------------- 1 file changed, 17 insertions(+), 37 deletions(-) diff --git a/music_assistant/providers/yandex_alice/__init__.py b/music_assistant/providers/yandex_alice/__init__.py index 870479b74e..56c6e828c7 100644 --- a/music_assistant/providers/yandex_alice/__init__.py +++ b/music_assistant/providers/yandex_alice/__init__.py @@ -119,38 +119,22 @@ def _name_drifted(artifacts: SkillCreationArtifacts, skill_name: str) -> bool: ) -async def _resolve_saved_value( - mass: MusicAssistant, - instance_id: str | None, +def _resolve_saved_value( values: dict[str, ConfigValueType], key: str, ) -> str: - """Read a config value: form ``values`` first, then persisted provider config. - - Frontend may not echo SECURE_STRING entries back in ``values`` between - action clicks. Falling through to ``mass.config.get_provider_config`` - keeps the source of truth stable for keys the user already saved - (cached x_token, generated webhook secret, persisted artifacts blob). - - ``mass.config.get_provider_config`` is async in current MA, so this - helper is async too. Returns ``""`` when neither source has a value, - the instance_id is missing, or the MA config API raises (e.g. on a - fresh provider instance that has not been saved yet). + """Read a config value from form ``values`` (string-coerced). + + Earlier versions also fell through to ``mass.config.get_provider_config`` + for keys the frontend may not echo back. That call deadlocks against the + config controller's own lock when MA opens the provider settings page — + `get_config_entries` is invoked by MA *while* it holds the config lock, + and the recursive read blocks indefinitely. So we now rely solely on + ``values``, and stabilise critical SECURE_STRING fields by writing the + derived value back into ``values`` early in the dispatcher (so subsequent + action clicks within the same form session see the same value). """ - fresh = values.get(key) - if fresh: - return str(fresh) - if not instance_id: - return "" - try: - cfg = await mass.config.get_provider_config(instance_id) - except Exception: - return "" - try: - saved = cfg.get_value(key) - except Exception: - return "" - return str(saved or "") + return str(values.get(key) or "") async def get_config_entries( # noqa: PLR0915 @@ -183,9 +167,8 @@ async def get_config_entries( # noqa: PLR0915 # 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. - existing_secret = ( - await _resolve_saved_value(mass, instance_id, values, CONF_DIALOG_WEBHOOK_SECRET) - ).strip() + _ = instance_id # reserved for future per-instance config lookups + existing_secret = _resolve_saved_value(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. @@ -195,13 +178,10 @@ async def get_config_entries( # noqa: PLR0915 # ---- Pull persistent auto-create / auth state ---- artifacts = load_artifacts( - (await _resolve_saved_value(mass, instance_id, values, CONF_DIALOG_AUTO_CREATE_ARTIFACTS)) - or None - ) - cached_x_token = await _resolve_saved_value(mass, instance_id, values, CONF_AUTH_X_TOKEN) - device_session_blob = await _resolve_saved_value( - mass, instance_id, values, CONF_DIALOG_AUTO_CREATE_DEVICE_SESSION + _resolve_saved_value(values, CONF_DIALOG_AUTO_CREATE_ARTIFACTS) or None ) + cached_x_token = _resolve_saved_value(values, CONF_AUTH_X_TOKEN) + device_session_blob = _resolve_saved_value(values, CONF_DIALOG_AUTO_CREATE_DEVICE_SESSION) # Skill name priority: explicit dialog skill name → instance name → default. skill_name = ( From e8d933de4b8a8c650331a80cfa5755de6d3101d1 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 7 May 2026 14:38:41 +0000 Subject: [PATCH 05/10] feat(yandex_alice): sync provider from ma-provider-yandex-alice v1.2.0 --- .../providers/yandex_alice/__init__.py | 820 ++++++++---- .../providers/yandex_alice/auth_page.py | 416 ++++++ .../providers/yandex_alice/auto_create.py | 635 ++++----- .../yandex_alice/auto_create_view.py | 196 --- .../providers/yandex_alice/auto_update.py | 2 + .../providers/yandex_alice/constants.py | 106 +- .../yandex_alice/dialog_skill_meta.py | 45 +- .../providers/yandex_alice/dialogs.py | 40 + .../providers/yandex_alice/icon.svg | 1 + .../providers/yandex_alice/manifest.json | 2 +- .../providers/yandex_alice/playlists.py | 57 - .../providers/yandex_alice/plugin.py | 24 +- .../yandex_alice/publication_status.py | 129 ++ .../providers/yandex_alice/setup_view.py | 1136 +++++++++++++++++ .../providers/yandex_alice/skill_logo.png | Bin 0 -> 22189 bytes .../providers/yandex_alice/skill_logo.py | 32 + .../providers/yandex_alice/url_helpers.py | 128 ++ .../providers/yandex_alice/webhook_probe.py | 128 ++ .../yandex_alice/test_auto_create.py | 588 ++++----- .../test_dialog_skill_meta_v12.py | 54 + .../yandex_alice/test_init_actions.py | 154 +-- .../yandex_alice/test_url_helpers.py | 107 ++ .../yandex_alice/test_webhook_probe.py | 150 +++ 23 files changed, 3621 insertions(+), 1329 deletions(-) create mode 100644 music_assistant/providers/yandex_alice/auth_page.py delete mode 100644 music_assistant/providers/yandex_alice/auto_create_view.py create mode 100644 music_assistant/providers/yandex_alice/icon.svg delete mode 100644 music_assistant/providers/yandex_alice/playlists.py create mode 100644 music_assistant/providers/yandex_alice/publication_status.py create mode 100644 music_assistant/providers/yandex_alice/setup_view.py create mode 100755 music_assistant/providers/yandex_alice/skill_logo.png create mode 100644 music_assistant/providers/yandex_alice/skill_logo.py create mode 100644 music_assistant/providers/yandex_alice/url_helpers.py create mode 100644 music_assistant/providers/yandex_alice/webhook_probe.py create mode 100644 tests/providers/yandex_alice/test_dialog_skill_meta_v12.py create mode 100644 tests/providers/yandex_alice/test_url_helpers.py create mode 100644 tests/providers/yandex_alice/test_webhook_probe.py diff --git a/music_assistant/providers/yandex_alice/__init__.py b/music_assistant/providers/yandex_alice/__init__.py index 56c6e828c7..a21f0edf67 100644 --- a/music_assistant/providers/yandex_alice/__init__.py +++ b/music_assistant/providers/yandex_alice/__init__.py @@ -19,10 +19,13 @@ 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, @@ -30,35 +33,51 @@ load_artifacts, ) +from .auth_page import perform_device_auth from .auto_create import ( AutoCreateOutcome, LocalAutoCreateStage, - deserialize_device_session, - run_auto_create_step, + adopt_existing_skill, + delete_existing_skill_then_recreate, + run_create_skill, ) -from .auto_create_view import build_auto_create_entries 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_RECREATE_DUPLICATE, + CONF_ACTION_REFRESH_STATUS, + CONF_ACTION_REGENERATE_WEBHOOK_SECRET, CONF_ACTION_RENAME_DIALOG_SKILL, + CONF_ACTION_REVERT_SKILL_NAME, + CONF_ACTION_SIGN_IN, + CONF_ACTION_TEST_WEBHOOK, + CONF_ACTION_UPDATE_SKILL, + 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_AUTO_CREATE_DEVICE_SESSION, - CONF_DIALOG_SKILL_ENABLED, + CONF_DIALOG_PUBLICATION_STATUS, CONF_DIALOG_SKILL_ID, CONF_DIALOG_SKILL_NAME, CONF_DIALOG_SKILL_TOKEN, + CONF_DIALOG_SKILL_VOICE, CONF_DIALOG_WEBHOOK_SECRET, - CONF_EXPOSED_PLAYERS, - CONF_EXPOSED_PLAYLISTS, + 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_NAME_MAX_LEN, - DIALOG_NAME_MIN_LEN, - DIALOG_WEBHOOK_BASE_PATH, - YANDEX_DIALOGS_DEVELOPER_URL, + DIALOG_VOICE_DEFAULT, ) from .dialog_skill_meta import ( build_activation_phrases, @@ -66,8 +85,15 @@ build_skill_description, build_structured_examples, ) -from .playlists import fetch_playlist_options from .plugin import YandexAlicePlugin +from .publication_status import fetch_skill_publication_status +from .setup_view import build_form_entries +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 @@ -96,6 +122,24 @@ def _generate_webhook_secret() -> str: 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] = [] @@ -112,10 +156,67 @@ async def _list_player_options(mass: MusicAssistant) -> list[ConfigValueOption]: return options -def _name_drifted(artifacts: SkillCreationArtifacts, skill_name: str) -> bool: - """Detect divergence between MA-side `skill_name` and Yandex `last_known_name`.""" - return bool( - artifacts.last_known_name and artifacts.last_known_name.strip() != skill_name.strip() +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, + ), ) @@ -123,20 +224,71 @@ def _resolve_saved_value( values: dict[str, ConfigValueType], key: str, ) -> str: - """Read a config value from form ``values`` (string-coerced). - - Earlier versions also fell through to ``mass.config.get_provider_config`` - for keys the frontend may not echo back. That call deadlocks against the - config controller's own lock when MA opens the provider settings page — - `get_config_entries` is invoked by MA *while* it holds the config lock, - and the recursive read blocks indefinitely. So we now rely solely on - ``values``, and stabilise critical SECURE_STRING fields by writing the - derived value back into ``values`` early in the dispatcher (so subsequent - action clicks within the same form session see the same value). - """ + """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, @@ -155,10 +307,9 @@ async def get_config_entries( # noqa: PLR0915 - ``CONF_ACTION_CANCEL_DIALOG_SKILL_FLOW`` — drop pending session + reset artifacts; preserve cached x_token. - Auto-create / rename state lives in three hidden config entries - (``CONF_AUTH_X_TOKEN``, ``CONF_DIALOG_AUTO_CREATE_ARTIFACTS``, - ``CONF_DIALOG_AUTO_CREATE_DEVICE_SESSION``) that round-trip through the - form on every save. + 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 {} @@ -167,8 +318,14 @@ async def get_config_entries( # noqa: PLR0915 # 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. - _ = instance_id # reserved for future per-instance config lookups - existing_secret = _resolve_saved_value(values, CONF_DIALOG_WEBHOOK_SECRET).strip() + # 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. @@ -180,8 +337,11 @@ async def get_config_entries( # noqa: PLR0915 artifacts = load_artifacts( _resolve_saved_value(values, CONF_DIALOG_AUTO_CREATE_ARTIFACTS) or None ) - cached_x_token = _resolve_saved_value(values, CONF_AUTH_X_TOKEN) - device_session_blob = _resolve_saved_value(values, CONF_DIALOG_AUTO_CREATE_DEVICE_SESSION) + 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 = ( @@ -197,15 +357,46 @@ async def get_config_entries( # noqa: PLR0915 update_message: str | None = None # ---- Action dispatcher ---- - if action == CONF_ACTION_AUTO_CREATE_DIALOG: - # Treat re-click on DONE as "Re-create" → reset artifacts before stepping. + 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() - device_session_blob = "" - - # Backup-restore safety: skill_id is set in config but artifacts are - # NONE → pre-position to APP_CREATED so the library skips create_app - # and patches the existing skill rather than creating a duplicate. 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( @@ -223,24 +414,144 @@ async def get_config_entries( # noqa: PLR0915 state=SkillCreationState.FAILED, last_error=str(exc), ), - device_session_blob=None, x_token=None, - user_code=None, - verification_url=None, user_message=str(exc), stage=LocalAutoCreateStage.FAILED, ) else: - action_outcome = await run_auto_create_step( + 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), + 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), + 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), + 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, - pending_device_session_blob=device_session_blob 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, 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: @@ -268,104 +579,190 @@ async def get_config_entries( # noqa: PLR0915 cached_x_token = "" elif action == CONF_ACTION_CANCEL_DIALOG_SKILL_FLOW: - # Drop pending session + reset artifacts; keep cached x_token. + # 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() - device_session_blob = "" + 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." # ---- Reflect outcome into values so the next form save persists state ---- if action_outcome is not None: artifacts = action_outcome.artifacts - if action_outcome.device_session_blob is not None: - device_session_blob = action_outcome.device_session_blob + 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, ): - device_session_blob = "" - if action_outcome.x_token is not None: - cached_x_token = action_outcome.x_token + 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 - values[CONF_DIALOG_AUTO_CREATE_DEVICE_SESSION] = device_session_blob if artifacts.state == SkillCreationState.DONE and artifacts.skill_id: values[CONF_DIALOG_SKILL_ID] = artifacts.skill_id - # ---- Player / playlist options ---- - player_options = await _list_player_options(mass) - try: - playlist_options = await fetch_playlist_options(mass) - except Exception as exc: - _LOGGER.debug("could not enumerate playlists: %s", exc) - playlist_options = [] - - base_url_hint = ( - "Public HTTPS URL of this Music Assistant instance, " - "as Yandex will see it (e.g. https://ma.example.com). " - "Leave empty to use MA's global Base URL setting." - ) - - # ---- Auto-create cluster: status LABEL + auto-create ACTION + Cancel ---- - # Surface the pending session details so the LABEL can re-show the - # user_code + verification URL after a form reload mid-Device-Flow. - pending_user_code: str | None = None - pending_verification_url: str | None = None - if device_session_blob: - decoded = deserialize_device_session(device_session_blob) - if decoded is not None: - pending_user_code = decoded[0].user_code - pending_verification_url = decoded[0].verification_url - - auto_create_entries = build_auto_create_entries( - artifacts=artifacts, - pending_session_present=bool(device_session_blob), - cached_x_token_present=bool(cached_x_token), - action_outcome=action_outcome, - pending_user_code=pending_user_code, - pending_verification_url=pending_verification_url, + # ---- 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) - # ---- Rename cluster: drift LABEL (conditional) + Rename ACTION ---- - rename_entries: tuple[ConfigEntry, ...] = () - rename_visible = bool(artifacts.skill_id and cached_x_token) - if rename_visible: - drift_text = "" - if update_message: - drift_text = update_message - elif _name_drifted(artifacts, skill_name): - drift_text = ( - f"Name in Yandex ('{artifacts.last_known_name}') differs from " - f"the current 'Skill name' ({skill_name!r}). Click 'Rename'." + # ---- 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." ) - rename_entries = ( - *( - ( - ConfigEntry( - key="label_rename_status", - type=ConfigEntryType.LABEL, - label=drift_text, - ), + 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." ) - if drift_text - else () - ), - ConfigEntry( - key=CONF_ACTION_RENAME_DIALOG_SKILL, - type=ConfigEntryType.ACTION, - label="Rename skill in Yandex", - description=( - "Apply the current 'Skill name' value to the existing " - "skill in Yandex Dialogs (PATCH draft + re-deploy). " - "Uses the cached x_token — no re-authentication required." - ), - action=CONF_ACTION_RENAME_DIALOG_SKILL, - action_label="Rename", - required=False, - default_value="", - ), + 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, @@ -373,147 +770,96 @@ async def get_config_entries( # noqa: PLR0915 label="Yandex Passport x_token (cached)", description="Cached after first successful Device Flow.", required=False, - default_value=cached_x_token, + value=cached_x_token, hidden=True, ), ConfigEntry( - key=CONF_DIALOG_AUTO_CREATE_ARTIFACTS, + key=CONF_AUTH_USER_NAME, type=ConfigEntryType.STRING, - label="Auto-create artifacts (JSON)", - description="State machine snapshot — persisted between clicks.", + label="Yandex display login (cached)", + description="Surfaced as 'Authorized as ' banner.", required=False, - default_value=dump_artifacts(artifacts), + value=user_name, hidden=True, ), ConfigEntry( - key=CONF_DIALOG_AUTO_CREATE_DEVICE_SESSION, - type=ConfigEntryType.SECURE_STRING, - label="Pending Device Flow session (JSON)", - description="Persisted during DEVICE_FLOW_STARTED stage.", + key=CONF_DIALOG_AUTO_CREATE_ARTIFACTS, + type=ConfigEntryType.STRING, + label="Auto-create artifacts (JSON)", + description="State machine snapshot — persisted between clicks.", required=False, - default_value=device_session_blob, + value=dump_artifacts(artifacts), hidden=True, ), - ) - - return ( - ConfigEntry( - key="label_intro", - type=ConfigEntryType.LABEL, - label=( - "Yandex Alice voice control. Use 'Create skill' below " - "for one-click registration via Yandex Passport, or set up " - f"manually at {YANDEX_DIALOGS_DEVELOPER_URL}." - ), - ), ConfigEntry( - key=CONF_INSTANCE_NAME, + key=CONF_PENDING_DUPLICATE_SKILL_ID, type=ConfigEntryType.STRING, - label="Instance name", - description=( - "Display name shown to users. Pick something they will say " - 'to invoke the skill, e.g. "Music Assistant" → ' - '"Alice, ask Music Assistant ..."' - ), + label="Pending duplicate skill_id", + description="Persisted when duplicate-name pre-check finds a match.", required=False, - default_value=DIALOG_DEFAULT_NAME, + value=duplicate_skill_id, + hidden=True, ), ConfigEntry( - key=CONF_EXTERNAL_BASE_URL, + key=CONF_PENDING_DUPLICATE_SKILL_NAME, type=ConfigEntryType.STRING, - label="External base URL (HTTPS, required for auto-create)", - description=base_url_hint, + label="Pending duplicate skill name", + description="Display name of the duplicate skill (Yandex spelling).", required=False, - default_value="", + value=duplicate_skill_name, + hidden=True, ), ConfigEntry( - key=CONF_DIALOG_SKILL_ENABLED, + key=CONF_EDIT_MODE, type=ConfigEntryType.BOOLEAN, - label="Enable dialog skill", - description=( - "Turn this on once the skill is created (auto or manual) " - "and the credentials below are populated." - ), - required=False, - default_value=False, - ), - ConfigEntry( - key=CONF_DIALOG_SKILL_NAME, - type=ConfigEntryType.STRING, - label="Skill name", - description=( - "Display name pushed to Yandex Dialogs on auto-create / " - "rename. Min " - f"{DIALOG_NAME_MIN_LEN}, max {DIALOG_NAME_MAX_LEN} characters." - ), - required=False, - default_value=instance_name, - ), - *auto_create_entries, - *rename_entries, - 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, - default_value="", - ), - 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)." - ), + label="Edit mode", + description="Reveals editable activation phrases / voice fields.", required=False, - default_value="", - ), - ConfigEntry( - key=CONF_DIALOG_WEBHOOK_SECRET, - type=ConfigEntryType.SECURE_STRING, - label="Webhook URL secret", - description=( - "Random secret embedded in the webhook URL. The full URL is " - f"{DIALOG_WEBHOOK_BASE_PATH}/. " - "Pre-filled with a fresh value; click 'Save' to commit." - ), - required=False, - default_value=default_secret, - ), - ConfigEntry( - key=CONF_EXPOSED_PLAYERS, - type=ConfigEntryType.STRING, - label="Voice-controllable players", - description=( - "Players the skill is allowed to control. Leave empty to " - "expose all players known to MA." - ), - multi_value=True, - options=player_options, - required=False, - default_value=[], + value=edit_mode, + hidden=True, ), ConfigEntry( - key=CONF_EXPOSED_PLAYLISTS, + key=CONF_DIALOG_PUBLICATION_STATUS, type=ConfigEntryType.STRING, - label="Voice-addressable playlists", + label="Yandex skill publication status (cached)", description=( - "Optional curated list of playlists the user can ask for by " - "name. Leave empty for full library search." + "Last known on_air / in_moderation / draft / rejected /" + " unknown classification fetched from Yandex snapshot." ), - multi_value=True, - options=playlist_options, required=False, - default_value=[], + value=publication_status, + hidden=True, ), - *hidden_state_entries, + ) + + diagnostics_entries = _build_diagnostics_entries(mass, instance_id) + use_different_instance_name = bool(values.get(CONF_USE_DIFFERENT_INSTANCE_NAME, False)) + + 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, + 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..758d12fbd7 --- /dev/null +++ b/music_assistant/providers/yandex_alice/auth_page.py @@ -0,0 +1,416 @@ +"""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 + +StateProvider = Callable[[], str] +"""Callable returning the current device-flow state for status polls.""" + +__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) + # Both URLs returned to the FE are *path-relative* — that lets the + # browser resolve them against whatever origin the user is hitting + # MA on (e.g. `https://ma.example.com`, `http://localhost:8095`), + # not against ``webserver.base_url`` which can resolve to the docker + # bridge IP and break the popup link. + status_url = 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 "" + + return page_path + + +def unregister_device_code_route(mass: MusicAssistant, *, session_id: str) -> None: + """Tear down the device-code page + status routes for a session.""" + 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") + + +# --------------------------------------------------------------------------- +# 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: + 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/auto_create.py b/music_assistant/providers/yandex_alice/auto_create.py index fe9e4e2d83..168ba5608d 100644 --- a/music_assistant/providers/yandex_alice/auto_create.py +++ b/music_assistant/providers/yandex_alice/auto_create.py @@ -1,408 +1,203 @@ -"""Self-resuming Device Flow + dialog-skill creation orchestrator. - -Music Assistant config-actions are synchronous: each click of the auto-create -button is one HTTP request, and the form re-renders with whatever entries we -return. Yandex Passport's Device Flow needs the user to walk away and confirm -on a different page (~30s-several minutes), so we can't drive it inside a -single action call. - -Solution: layer a *local* state machine on top of ``SkillCreationArtifacts`` -that survives across clicks via JSON in the provider config. Each click -advances by exactly one external-IO step: - -- ``IDLE`` (no session, no token) → start Device Flow → ``DEVICE_FLOW_STARTED`` -- ``DEVICE_FLOW_STARTED`` → one-shot poll with short window → if confirmed, - refresh cookies and capture ``x_token`` → ``AUTHENTICATED`` -- ``AUTHENTICATED`` (or any post-auth artifact state) → run - ``auto_create_skill(channel="aliceSkill", oauth_*=None)`` end-to-end → - ``DONE`` / ``FAILED`` - -The full pipeline after auth is a single ya-dialogs-api call: all four steps -(``create_app → upload_logo → update_draft → request_deploy``) are fast HTTP -to ``dialogs.yandex.ru``. ``progress_cb`` checkpoints each step so a mid-call -failure resumes from the last completed state on the next click. +"""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 -import time +from collections.abc import Callable, Mapping from dataclasses import dataclass from enum import StrEnum -from typing import Any +from typing import TYPE_CHECKING, Any from ya_dialogs_api import ( + DialogsSkillCreator, SkillCreationArtifacts, SkillCreationState, auto_create_skill, ) -from ya_passport_auth import DeviceCodeSession, SecretStr -from ya_passport_auth.exceptions import ( - DeviceCodeTimeoutError, - InvalidCredentialsError, -) +from ya_dialogs_api.errors import DialogsValidationError +from ya_passport_auth.exceptions import InvalidCredentialsError -from .auth_session import make_cached_authenticator, passport_client_session +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", - "deserialize_device_session", - "run_auto_create_step", - "serialize_device_session", + "adopt_existing_skill", + "delete_existing_skill_then_recreate", + "run_create_skill", ] class LocalAutoCreateStage(StrEnum): - """High-level UX stage derived from artifacts + pending Device Flow. + """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, has_pending_device_session, has_cached_x_token)``. - Drives the button label flip ("Create" / "Confirm and continue" / - "Resume" / "Re-create" / "Retry") and the visibility of the - Cancel button. + ``(SkillCreationArtifacts, cached_x_token_present, + duplicate_pending)``. """ IDLE = "idle" - DEVICE_FLOW_STARTED = "device_flow_started" 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 auto-create button. + """Result of one click on the Create skill / Recreate / Adopt button. - The dispatcher in :mod:`provider.__init__` writes ``artifacts``, - ``device_session_blob``, and ``x_token`` into the form ``values`` so MA - persists them on the next form save. ``user_message`` and the - ``user_code`` / ``verification_url`` pair feed the status LABEL the user + 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.""" - device_session_blob: str | None - """Serialised pending session, ``None`` to drop, ``""`` (empty) to keep as-is.""" - x_token: str | None """Newly minted x_token, ``""`` to clear cache, ``None`` to leave existing.""" - user_code: str | None - """Code to display to the user (``DEVICE_FLOW_STARTED`` only).""" - - verification_url: str | None - """URL the user must open (``DEVICE_FLOW_STARTED`` only).""" - user_message: str """Human-readable status for the LABEL entry.""" stage: LocalAutoCreateStage - """High-level UX stage — drives button label + cancel visibility.""" + """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.""" -# --------------------------------------------------------------------------- -# Device session JSON round-trip -# --------------------------------------------------------------------------- + pending_duplicate_skill_name: str | None = None + """Display name of the duplicate skill (as registered in Yandex).""" -def serialize_device_session( - session: DeviceCodeSession | None, expires_at_epoch: float -) -> str | None: - """Serialise a ``DeviceCodeSession`` to JSON for config storage. - - ``device_code`` is unwrapped from :class:`SecretStr` because it must - survive a config round-trip — the SECURE_STRING entry encrypts it at - rest. ``expires_at_epoch`` is the absolute wall-clock deadline we - recompute once at first start and check on every resume so a long-idle - user doesn't see a "still waiting" loop on a code Yandex already killed. - """ - if session is None: - return None - return json.dumps( - { - "device_code": session.device_code.get_secret(), - "user_code": session.user_code, - "verification_url": session.verification_url, - "expires_in": session.expires_in, - "interval": session.interval, - "expires_at_epoch": expires_at_epoch, - }, - ensure_ascii=False, - ) - +# --------------------------------------------------------------------------- +# Duplicate pre-check +# --------------------------------------------------------------------------- -def deserialize_device_session( - raw: str | None, -) -> tuple[DeviceCodeSession, float] | None: - """Inverse of :func:`serialize_device_session`. Returns ``None`` on any failure. - Returns ``(session, expires_at_epoch)`` so the caller can decide whether - the underlying user_code is still alive on Yandex's side. +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. """ - if not raw: + target = skill_name.strip().casefold() + if not target: return None try: - data = json.loads(raw) - except (ValueError, TypeError): - return None - if not isinstance(data, dict): - return None - try: - device_code = SecretStr(str(data["device_code"])) - session = DeviceCodeSession( - device_code=device_code, - user_code=str(data["user_code"]), - verification_url=str(data["verification_url"]), - expires_in=int(data["expires_in"]), - interval=int(data["interval"]), - ) - expires_at_epoch = float(data.get("expires_at_epoch", 0.0)) - except (KeyError, ValueError, TypeError): + 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 - return session, expires_at_epoch + 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 # --------------------------------------------------------------------------- -# Main entry point +# Pipeline runner # --------------------------------------------------------------------------- -async def run_auto_create_step( - *, - skill_name: str, - backend_uri: str, - description: str, - structured_examples: list[dict[str, Any]] | None, - activation_phrases: list[str] | None, - cached_x_token: str | None, - pending_device_session_blob: str | None, - artifacts: SkillCreationArtifacts, - poll_window: float = 8.0, -) -> AutoCreateOutcome: - """Advance the state machine by exactly one external-IO step. - - Branches by current state: - - 1. ``artifacts.state == DONE`` → no-op outcome (caller resets artifacts - on a "Re-create" click before invoking us). - 2. Pending Device Flow session → one-shot poll. If confirmed, capture - x_token and proceed to pipeline in *the same click* (cheap step). - If still pending and underlying code alive, keep stage. If underlying - expired or Yandex rejected, return FAILED with a clear last_error. - 3. Have ``cached_x_token`` (post-auth) → run the full - ``auto_create_skill`` pipeline. - 4. None of the above → start Device Flow. - """ - if artifacts.state == SkillCreationState.DONE: - return AutoCreateOutcome( - artifacts=artifacts, - device_session_blob=None, - x_token=None, - user_code=None, - verification_url=None, - user_message="Skill created. Click 'Save' to apply the settings.", - stage=LocalAutoCreateStage.DONE, - ) - - pending = deserialize_device_session(pending_device_session_blob) - - if pending is not None: - return await _resume_device_flow( - pending_session=pending[0], - expires_at_epoch=pending[1], - poll_window=poll_window, - skill_name=skill_name, - backend_uri=backend_uri, - description=description, - structured_examples=structured_examples, - activation_phrases=activation_phrases, - artifacts=artifacts, - ) - - if cached_x_token: - 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, - artifacts=artifacts, - ) - - return await _start_device_flow(artifacts=artifacts) +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. + """ -# --------------------------------------------------------------------------- -# Stage handlers -# --------------------------------------------------------------------------- - + def _factory(session: aiohttp.ClientSession) -> DialogsSkillCreator: + creator = DialogsSkillCreator(session, channel=DIALOG_CHANNEL) + original_update_draft = creator.update_draft -async def _start_device_flow(*, artifacts: SkillCreationArtifacts) -> AutoCreateOutcome: - """Request a fresh user_code from Yandex Passport and persist the session.""" - try: - async with passport_client_session() as client: - session = await client.start_device_login( - device_name="Music Assistant", + 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], ) - except Exception as exc: - _LOGGER.exception("auto-create: start_device_login failed") - failed = dataclasses.replace( - artifacts, - state=SkillCreationState.FAILED, - last_error=f"Failed to request a device code from Yandex Passport: {exc!r}", - ) - return AutoCreateOutcome( - artifacts=failed, - device_session_blob=None, - x_token=None, - user_code=None, - verification_url=None, - user_message=str(failed.last_error), - stage=LocalAutoCreateStage.FAILED, - ) - - expires_at = time.time() + session.expires_in - blob = serialize_device_session(session, expires_at) - user_message = ( - f"Open {session.verification_url} and enter code {session.user_code}. " - "Click the button again once you've confirmed." - ) - return AutoCreateOutcome( - artifacts=artifacts, - device_session_blob=blob, - x_token=None, - user_code=session.user_code, - verification_url=session.verification_url, - user_message=user_message, - stage=LocalAutoCreateStage.DEVICE_FLOW_STARTED, - ) - - -async def _resume_device_flow( - *, - pending_session: DeviceCodeSession, - expires_at_epoch: float, - poll_window: float, - skill_name: str, - backend_uri: str, - description: str, - structured_examples: list[dict[str, Any]] | None, - activation_phrases: list[str] | None, - artifacts: SkillCreationArtifacts, -) -> AutoCreateOutcome: - """Poll the Device Flow once with a short window; chain into pipeline on success.""" - remaining = expires_at_epoch - time.time() - if remaining <= 0: - failed = dataclasses.replace( - artifacts, - state=SkillCreationState.FAILED, - last_error="Device code expired. Click 'Create' to request a new one.", - ) - return AutoCreateOutcome( - artifacts=failed, - device_session_blob=None, - x_token=None, - user_code=None, - verification_url=None, - user_message=str(failed.last_error), - stage=LocalAutoCreateStage.FAILED, - ) + 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 - window = min(poll_window, remaining) - try: - async with passport_client_session() as client: - credentials = await client.poll_device_until_confirmed( - pending_session, - total_timeout=window, - ) - x_token_secret = credentials.x_token - await client.refresh_passport_cookies(x_token_secret) - except DeviceCodeTimeoutError: - if window < remaining: - blob = serialize_device_session(pending_session, expires_at_epoch) - return AutoCreateOutcome( - artifacts=artifacts, - device_session_blob=blob, - x_token=None, - user_code=pending_session.user_code, - verification_url=pending_session.verification_url, - user_message=( - f"Still waiting for confirmation. Code {pending_session.user_code} " - f"at {pending_session.verification_url}. Click the button again." - ), - stage=LocalAutoCreateStage.DEVICE_FLOW_STARTED, - ) - failed = dataclasses.replace( - artifacts, - state=SkillCreationState.FAILED, - last_error="Device code expired. Click 'Create' to request a new one.", - ) - return AutoCreateOutcome( - artifacts=failed, - device_session_blob=None, - x_token=None, - user_code=None, - verification_url=None, - user_message=str(failed.last_error), - stage=LocalAutoCreateStage.FAILED, - ) - except InvalidCredentialsError as exc: - _LOGGER.warning("auto-create: device flow rejected: %s", exc) - failed = dataclasses.replace( - artifacts, - state=SkillCreationState.FAILED, - last_error=f"Yandex Passport rejected the sign-in: {exc}", - ) - return AutoCreateOutcome( - artifacts=failed, - device_session_blob=None, - x_token=None, - user_code=None, - verification_url=None, - user_message=str(failed.last_error), - stage=LocalAutoCreateStage.FAILED, - ) - except Exception as exc: - _LOGGER.exception("auto-create: device flow unexpected error") - failed = dataclasses.replace( - artifacts, - state=SkillCreationState.FAILED, - last_error=f"Unexpected authentication error: {exc!r}", - ) - return AutoCreateOutcome( - artifacts=failed, - device_session_blob=None, - x_token=None, - user_code=None, - verification_url=None, - user_message=str(failed.last_error), - stage=LocalAutoCreateStage.FAILED, - ) + with contextlib.suppress(AttributeError, TypeError): + creator.update_draft = _logged_update_draft # type: ignore[method-assign] + return creator - x_token = x_token_secret.get_secret() - pipeline_outcome = await _run_pipeline( - cached_x_token=x_token, - skill_name=skill_name, - backend_uri=backend_uri, - description=description, - structured_examples=structured_examples, - activation_phrases=activation_phrases, - artifacts=artifacts, - ) - return dataclasses.replace( - pipeline_outcome, - device_session_blob=None, - x_token=x_token, - ) + return _factory async def _run_pipeline( @@ -414,26 +209,43 @@ async def _run_pipeline( structured_examples: list[dict[str, Any]] | None, activation_phrases: list[str] | None, 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`` (see - library docstring). We don't need to catch those explicitly — they - arrive as a regular return value and flow through - :func:`_outcome_from_failed_pipeline`. - + :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 (before the library's pipeline - starts). We translate it into ``outcome.x_token=""`` so the - dispatcher clears the cache and the next click can re-auth - cleanly via Device Flow. + ``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: @@ -446,20 +258,19 @@ async def _run_pipeline( description=description, structured_examples=structured_examples, activation_phrases=activation_phrases, + 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 the button again to re-authenticate.", + last_error="Cached auth has expired. Click 'Sign in' again to re-authenticate.", ) return AutoCreateOutcome( artifacts=failed, - device_session_blob=None, x_token="", - user_code=None, - verification_url=None, user_message=str(failed.last_error), stage=LocalAutoCreateStage.FAILED, ) @@ -477,10 +288,7 @@ async def _run_pipeline( ) return AutoCreateOutcome( artifacts=result, - device_session_blob=None, x_token=None, - user_code=None, - verification_url=None, user_message=message, stage=LocalAutoCreateStage.DONE, ) @@ -489,21 +297,128 @@ async def _run_pipeline( def _outcome_from_failed_pipeline(result: SkillCreationArtifacts) -> AutoCreateOutcome: - """Translate a FAILED ``SkillCreationArtifacts`` into a UX outcome. - - Reads ``last_error`` (already populated by ya-dialogs-api). For typed - sub-errors that the library reports through the artifact, the wire - representation is ``last_error: str`` — we keep it as-is. The caller - (dispatcher) layers domain-aware advice (e.g. duplicate-name → suggest - rename) by inspecting the message before rendering. - """ + """Translate a FAILED ``SkillCreationArtifacts`` into a UX outcome.""" msg = result.last_error or "Pipeline failed without a description." return AutoCreateOutcome( artifacts=result, - device_session_blob=None, x_token=None, - user_code=None, - verification_url=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, + 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). + + 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, + 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, + 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, + 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, + 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, + artifacts=SkillCreationArtifacts(), + skip_duplicate_check=True, + ) diff --git a/music_assistant/providers/yandex_alice/auto_create_view.py b/music_assistant/providers/yandex_alice/auto_create_view.py deleted file mode 100644 index 530b01a3ea..0000000000 --- a/music_assistant/providers/yandex_alice/auto_create_view.py +++ /dev/null @@ -1,196 +0,0 @@ -"""Pure rendering of auto-create UI entries from state. - -Decoupled from the dispatcher (``provider.__init__`` uses these helpers -verbatim) so the form-shape can be unit-tested without exercising the -actual orchestrator. Returns ``ConfigEntry`` tuples. -""" - -from __future__ import annotations - -from music_assistant_models.config_entries import ConfigEntry -from music_assistant_models.enums import ConfigEntryType -from ya_dialogs_api import SkillCreationArtifacts, SkillCreationState - -from .auto_create import AutoCreateOutcome, LocalAutoCreateStage -from .constants import ( - CONF_ACTION_AUTO_CREATE_DIALOG, - CONF_ACTION_CANCEL_DIALOG_SKILL_FLOW, -) - -__all__ = ["build_auto_create_entries"] - - -def _create_button_label(stage: LocalAutoCreateStage) -> str: - """Button label flips per stage so users see what the click will do.""" - return { - LocalAutoCreateStage.IDLE: "Create skill", - LocalAutoCreateStage.DEVICE_FLOW_STARTED: "Confirm and continue", - LocalAutoCreateStage.PIPELINE_RUNNING: "Resume", - LocalAutoCreateStage.DONE: "Re-create", - LocalAutoCreateStage.FAILED: "Retry", - }[stage] - - -def _derive_stage( - *, - artifacts: SkillCreationArtifacts, - pending_session_present: bool, - cached_x_token_present: bool, -) -> LocalAutoCreateStage: - """Derive the UX stage from persistent state. - - Decision order (most specific first): - - 1. Pending Device Flow session → ``DEVICE_FLOW_STARTED``. - 2. Artifacts ``DONE`` → ``DONE`` regardless of token presence. - 3. Artifacts ``FAILED`` → ``FAILED`` (Retry button). - 4. Artifacts in any post-create state with cached token → - ``PIPELINE_RUNNING`` ("Resume" — next click hits the pipeline). - 5. Same intermediate state but **no** cached token → ``IDLE``: next - click will start a fresh Device Flow first, so the button label - must say "Create skill", not "Resume", to match the actual next step. - 6. Otherwise → ``IDLE``. - """ - if pending_session_present: - return LocalAutoCreateStage.DEVICE_FLOW_STARTED - if artifacts.state == SkillCreationState.DONE: - return LocalAutoCreateStage.DONE - if artifacts.state == SkillCreationState.FAILED: - return LocalAutoCreateStage.FAILED - if cached_x_token_present and artifacts.state in ( - SkillCreationState.APP_CREATED, - SkillCreationState.DRAFT_UPDATED, - SkillCreationState.OAUTH_CREATED, - SkillCreationState.OAUTH_ATTACHED, - SkillCreationState.DEPLOY_REQUESTED, - ): - return LocalAutoCreateStage.PIPELINE_RUNNING - return LocalAutoCreateStage.IDLE - - -def _status_label_text( - *, - stage: LocalAutoCreateStage, - artifacts: SkillCreationArtifacts, - action_outcome: AutoCreateOutcome | None, - pending_user_code: str | None, - pending_verification_url: str | None, -) -> str: - """Compose the status message shown above the auto-create button. - - Priority: a fresh action outcome wins (the user just clicked); otherwise - we fall back to a state-derived static hint so the form still has - context after a re-open. ``pending_user_code`` / - ``pending_verification_url`` are populated when a Device Flow session is - persisted in config — they let us re-show the original code/URL after a - form reload, instead of leaving the LABEL blank with no instructions. - """ - if action_outcome is not None: - return action_outcome.user_message - if stage == LocalAutoCreateStage.DEVICE_FLOW_STARTED: - if pending_user_code and pending_verification_url: - return ( - f"Device Flow in progress. Open {pending_verification_url} and " - f"enter code {pending_user_code}, then click 'Confirm and continue'." - ) - return ( - "Device Flow in progress. Click 'Confirm and continue' to check " - "for confirmation, or 'Cancel' to abort." - ) - if stage == LocalAutoCreateStage.DONE and artifacts.skill_id: - return f"Skill created (skill_id={artifacts.skill_id})." - if stage == LocalAutoCreateStage.FAILED and artifacts.last_error: - return f"Error: {artifacts.last_error}" - if stage == LocalAutoCreateStage.PIPELINE_RUNNING: - return ( - "Skill creation was interrupted. Click 'Resume' to continue " - f"from step {artifacts.state.value}." - ) - if stage == LocalAutoCreateStage.IDLE: - return ( - "Click 'Create skill' — Music Assistant will sign in to Yandex Passport " - "(Device Flow) and register the skill at dialogs.yandex.ru." - ) - return "" - - -def build_auto_create_entries( - *, - artifacts: SkillCreationArtifacts, - pending_session_present: bool, - cached_x_token_present: bool, - action_outcome: AutoCreateOutcome | None, - pending_user_code: str | None = None, - pending_verification_url: str | None = None, -) -> tuple[ConfigEntry, ...]: - """Render the auto-create cluster: status LABEL + ACTION + Cancel. - - The Cancel button is visible only when in DEVICE_FLOW_STARTED or FAILED — - these are the states where the user might want to abandon a partial - flow without waiting for the underlying user_code to expire. - - ``pending_user_code`` / ``pending_verification_url`` come from the - deserialised Device Flow session: passing them in lets the LABEL - remain self-explanatory after a form reload mid-Device-Flow (otherwise - the user has nowhere to read the code they need to confirm). - """ - stage = _derive_stage( - artifacts=artifacts, - pending_session_present=pending_session_present, - cached_x_token_present=cached_x_token_present, - ) - - status_text = _status_label_text( - stage=stage, - artifacts=artifacts, - action_outcome=action_outcome, - pending_user_code=pending_user_code, - pending_verification_url=pending_verification_url, - ) - - entries: list[ConfigEntry] = [] - - if status_text: - entries.append( - ConfigEntry( - key="label_auto_create_status", - type=ConfigEntryType.LABEL, - label=status_text, - ) - ) - - entries.append( - ConfigEntry( - key=CONF_ACTION_AUTO_CREATE_DIALOG, - type=ConfigEntryType.ACTION, - label="Auto-register skill", - description=( - "One click creates a skill at https://dialogs.yandex.ru/developer " - "via the Yandex Passport Device Flow. The button can be clicked " - "repeatedly — the process resumes from the last completed step." - ), - action=CONF_ACTION_AUTO_CREATE_DIALOG, - action_label=_create_button_label(stage), - required=False, - default_value="", - ) - ) - - if stage in (LocalAutoCreateStage.DEVICE_FLOW_STARTED, LocalAutoCreateStage.FAILED): - entries.append( - ConfigEntry( - key=CONF_ACTION_CANCEL_DIALOG_SKILL_FLOW, - type=ConfigEntryType.ACTION, - label="Cancel", - description=( - "Aborts the current authentication / creation process. " - "The cached x_token is preserved." - ), - action=CONF_ACTION_CANCEL_DIALOG_SKILL_FLOW, - action_label="Cancel", - required=False, - default_value="", - ) - ) - - return tuple(entries) diff --git a/music_assistant/providers/yandex_alice/auto_update.py b/music_assistant/providers/yandex_alice/auto_update.py index 4d9c8cd42d..77b0f6f103 100644 --- a/music_assistant/providers/yandex_alice/auto_update.py +++ b/music_assistant/providers/yandex_alice/auto_update.py @@ -56,6 +56,7 @@ async def run_auto_update( structured_examples: list[dict[str, Any]] | None, activation_phrases: list[str] | None, artifacts: SkillCreationArtifacts, + voice: str | None = None, ) -> AutoUpdateOutcome: """Patch the existing skill draft + re-deploy. No Device Flow fallback. @@ -109,6 +110,7 @@ async def run_auto_update( description=description, structured_examples=structured_examples, activation_phrases=activation_phrases, + voice=voice, ) except InvalidCredentialsError as exc: _LOGGER.warning("auto-update: cached x_token rejected: %s", exc) diff --git a/music_assistant/providers/yandex_alice/constants.py b/music_assistant/providers/yandex_alice/constants.py index dd0ead1814..dab2858635 100644 --- a/music_assistant/providers/yandex_alice/constants.py +++ b/music_assistant/providers/yandex_alice/constants.py @@ -21,35 +21,127 @@ # HTTPS URL only to Yandex via a reverse proxy. CONF_EXTERNAL_BASE_URL = "external_base_url" CONF_EXPOSED_PLAYERS = "exposed_players" -CONF_EXPOSED_PLAYLISTS = "exposed_playlists" # 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_ENABLED = "dialog_skill_enabled" 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" -CONF_DIALOG_AUTO_CREATE_SESSION_ID = "dialog_auto_create_session_id" -# Persisted DeviceCodeSession (JSON) so the auto-create button can advance -# the Device Flow state machine across multiple clicks. Cleared after a -# successful poll, on expiry, or on Cancel. -CONF_DIALOG_AUTO_CREATE_DEVICE_SESSION = "dialog_auto_create_device_session" +# 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" + +# 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" + +# 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 diff --git a/music_assistant/providers/yandex_alice/dialog_skill_meta.py b/music_assistant/providers/yandex_alice/dialog_skill_meta.py index 605b86c137..289735e389 100644 --- a/music_assistant/providers/yandex_alice/dialog_skill_meta.py +++ b/music_assistant/providers/yandex_alice/dialog_skill_meta.py @@ -9,7 +9,50 @@ from typing import Any -from .constants import DIALOG_WEBHOOK_BASE_PATH +from .constants import DIALOG_NAME_MAX_LEN, DIALOG_NAME_MIN_LEN, DIALOG_WEBHOOK_BASE_PATH + + +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: diff --git a/music_assistant/providers/yandex_alice/dialogs.py b/music_assistant/providers/yandex_alice/dialogs.py index ff356de1a2..f0eea5c021 100644 --- a/music_assistant/providers/yandex_alice/dialogs.py +++ b/music_assistant/providers/yandex_alice/dialogs.py @@ -243,6 +243,34 @@ def __init__( self._unregister_callbacks: list[Callable[[], None]] = [] # 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. @@ -334,6 +362,12 @@ async def _handle_webhook(self, request: web.Request) -> web.Response: # noqa: 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: @@ -366,6 +400,12 @@ async def _handle_webhook(self, request: web.Request) -> web.Response: # noqa: ) 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 + # State buckets. Three-tier read priority: # 1. ``state.session`` — per-conversation, set by us last turn. # 2. ``state.application`` — per-device, mirrored fallback. 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 index d46a92ad73..b552a4bfcf 100644 --- a/music_assistant/providers/yandex_alice/manifest.json +++ b/music_assistant/providers/yandex_alice/manifest.json @@ -5,7 +5,7 @@ "description": "Voice control of Music Assistant via a Yandex Dialogs custom skill (Russian NLU, full command surface).", "codeowners": ["@trudenboy"], "credits": [ - "[dext0r/yandex_smart_home](https://github.com/dext0r/yandex_smart_home)" + "[ya-dialogs-api](https://github.com/trudenboy/ya-dialogs-api)" ], "requirements": [ "ya-passport-auth==1.3.0", diff --git a/music_assistant/providers/yandex_alice/playlists.py b/music_assistant/providers/yandex_alice/playlists.py deleted file mode 100644 index e517d1ddda..0000000000 --- a/music_assistant/providers/yandex_alice/playlists.py +++ /dev/null @@ -1,57 +0,0 @@ -"""Helpers for exposing MA library playlists as Yandex input_source modes. - -Wraps the few MA APIs used by the playlist-source feature so the rest of -the provider stays decoupled from `mass.music`/`player_queues` internals -and so tests can stub a single seam. -""" - -from __future__ import annotations - -import asyncio -import logging -from typing import TYPE_CHECKING - -from music_assistant_models.config_entries import ConfigValueOption - -if TYPE_CHECKING: - from music_assistant.mass import MusicAssistant - - -_LOGGER = logging.getLogger(__name__) - - -async def fetch_playlist_options(mass: MusicAssistant) -> list[ConfigValueOption]: - """Build ConfigValueOption list of all library playlists for the config form. - - Pages through `iter_library_items` so the dropdown is not silently - truncated for users with very large libraries (the underlying - `library_items(limit=...)` defaults to 500). Used at config-render - time only. Fail-soft: returns [] if mass.music or the playlists - controller is not yet available (e.g. provider load order). - """ - options: list[ConfigValueOption] = [] - try: - async for playlist in mass.music.playlists.iter_library_items(): - if not playlist.uri: - continue - provider_label = playlist.provider or "" - title = f"{playlist.name} ({provider_label})" if provider_label else playlist.name - options.append(ConfigValueOption(title=title, value=playlist.uri)) - except asyncio.CancelledError: - raise - except Exception as exc: - # Fail-soft: this runs on every config-form render and races with - # provider/database startup. Don't spam stack traces — debug-level - # is enough for diagnostics, normal renders stay quiet. - _LOGGER.debug("Library playlists not available yet: %s", exc) - return [] - return options - - -async def play_playlist(mass: MusicAssistant, player_id: str, uri: str) -> None: - """Start playback of a playlist URI on the given player's queue. - - `play_media` accepts a URI string directly and resolves the playlist's - tracks into the queue. queue_id == player_id for the player's own queue. - """ - await mass.player_queues.play_media(queue_id=player_id, media=uri) diff --git a/music_assistant/providers/yandex_alice/plugin.py b/music_assistant/providers/yandex_alice/plugin.py index 84a8051c41..a2a9d28cb5 100644 --- a/music_assistant/providers/yandex_alice/plugin.py +++ b/music_assistant/providers/yandex_alice/plugin.py @@ -21,7 +21,6 @@ from music_assistant.models.plugin import PluginProvider from .constants import ( - CONF_DIALOG_SKILL_ENABLED, CONF_DIALOG_SKILL_ID, CONF_DIALOG_WEBHOOK_SECRET, CONF_EXPOSED_PLAYERS, @@ -38,7 +37,6 @@ class YandexAlicePlugin(PluginProvider): 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_enabled = bool(self.config.get_value(CONF_DIALOG_SKILL_ENABLED)) 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) @@ -48,10 +46,14 @@ async def handle_async_init(self) -> None: self._exposed_player_ids = None async def loaded_in_mass(self) -> None: - """Register the Dialogs webhook route once the webserver is up.""" - if not ( - self._dialog_skill_enabled and self._dialog_skill_id and self._dialog_webhook_secret - ): + """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, @@ -71,13 +73,19 @@ async def unload(self, is_removed: bool = False) -> 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_enabled": self._dialog_skill_enabled, "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": self._dialogs_handler is not None, + "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..23ba0337e9 --- /dev/null +++ b/music_assistant/providers/yandex_alice/setup_view.py @@ -0,0 +1,1136 @@ +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_RECREATE_DUPLICATE, + CONF_ACTION_REFRESH_STATUS, + CONF_ACTION_REGENERATE_WEBHOOK_SECRET, + CONF_ACTION_SIGN_IN, + CONF_ACTION_TEST_WEBHOOK, + CONF_ACTION_UPDATE_SKILL, + 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_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 .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 _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, ...], + 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, + ), + 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 0000000000000000000000000000000000000000..fc0a72cc4bd34861ab45ba81edb44c68b1d520dc GIT binary patch literal 22189 zcmY(rc_38(_Xm8(Fr`Qli9(hnMA?a%PmAn|vL@PS>}!gcX_Xdhmh1_WBo(s7Odq7I zF-6vqeQXnkVa&{Z-lNa=_dL&ExcAPz?{m)Uyw2;K`ySD!txgGUk=O!3kf5o_2^$FF z0l)GJf%T1dZi?65WAMff%ZvOoOkGXMTcs=H` z)iJ*9JREq2;x#uX#g4ix0;z1%Zo++NUtmEhn|BBypj`AaMMMt5BOv@uVNQT=hb4{i zWi#XkL2h>c;%v!oWDS%!0uq3rfr3B%bE^oQ*#`q-jIDn^lKzNjd0-`RpiYvddT3^4 z;0j~M#tF=-;*N=c6F7+oq;{54<=fq)jazQ#cx_$`ISCsZ;&+=v$JXQ4bR2Ht+M?P%%&OZDU!a@-6({E`7miCtv$}N7G8SRX!eGfHWY`18NnO3deIj|Q91nBtJ$ie9hH z`Y8ef2mW`>DnX7Ffvz{6=G@?rn4j>wf+eq^n6|ei#Ss?Vhr3Z5aSj973M|K5ztJI% zcx6_o{?l*UHPk=u5D#_yYkY(DeR3*T7x@U6Q~PO&&)q zUXtx_i5F6bAahlY$X23Y<=*sm+UyaF&lnB$hRp(&NC#nv1VJjre?kk_Nw0B7f73o? z?#rbt)LF(smF=;N2%qHL{`N$3!j7q_9pY)Ick)~lbEavvZBoB!8VEM!@^fP1)3x=Q z80i3o%0n&ND~CpEn>MF@F-KOnBKAWN67oOQfV=g(;&xKqa-^T#G4-Ln)87!Y%{%i( z`aMvn-3X6nCo%C%)Ds8c95wFs@3xPi1IEx8R`~0P7FO1U`oqR@!zTMIlX}Z>^XD39 z4|XGhxF5;*Z#53?3$&*!#X@HIeTv2CGOxgHe$5$aw&c0{H6InJCVZ%fnyTMeOm_mb zv7C3_Bj^v)G~s2H-_qXOK0LW6*zfj=W_zJFbLMQI4(6flLV5c*XXu2CD7+7gnMi;l zHbQ=1gcHy}dGH)RUrA|Y-Y0fI=ObFZ@A2=xDc$!JKi408!L=>_Sikbuy~d>0bo|F1 zQ&`mOjNr9&{67oCi#!`aP~_Cc~3lMfVm zG7;*QsZ%$?pUuM63%ZaIl z+rE`6ueJD1sW8-Lm>kUQp_=n@BjTon=dF9`+hJk$#^U!syS3S$!=dP6aekSf&qaERS zTQp6aUYq%~=T*=XzwZeo6A_ui`#1YX47`N>y^jh&8av^AGi+oL=g@E3YL?1^wV%f* zN3L=aSlgFm4ve5|vBCnhA91;Fn= zKDG+yqrz(~=`q*PQ#THTk)a;Of$BFCB!mdh^UHU<#d$G2kpKF>IFSYE?%AHf${qzy zEsA8}UiSs}XX<@SdfjuiSo8?SW-M==kG!Nq(+}ef8ww-i|A|11P9==I5Y>hb8}!Hn+Z!%J1+PHTSl&H9eL zKbjuwqTC!-N#+D6PhImX8D&_94ZZ2#Qzo6#Zu9^lBKX`u?$QSOE0j6c!$&BkAN@@2 zv}TJu&4uTDd=5BLY*eXJ-Puu5r;Val2ad3B{K+%;Y*O83k)l`mmVe3w`Im8>g>i#* ztLeMoHz#t~hW+U?Oecq*jw+2oubn5~(>o@krD%rn!ifsK<~+&qVHQ_-^rig%rcXd4 zab}bqzt(xzMPax0dJMzH}b==lhLo&*CR}gT^qBn^0cD7qn-dnLQre@suyNpQ0e;Ijq@&T zk#ozD3I$2WUWGY0iT|dJ=Uruvzv>IZX>{T9(0t;)JOUfFCpBn+_+@4x8sR-X)?-e7xOw=z)~5Z_Lsb9sA~Ju?K#lkz8z^6P;3Q=^y${^Vw-*M| ze*e1#9+sD>GSfShV({UMG=MTFPUjnlIJ)0zbmZtm+>v9f#InWMvFlRP!MBy++oqWE zwBAYo<<;Po6K)$}(b$C>ytCTBc|oUGOtyF+{8e{?XNPGEy@h<2V;YqgUU=oL1!T;n zuZJ+b0ZkQwuaDz|y|99qP+>Rre$vEu-Z&JuVwpBld}TOD>p4jUPPfb2sTP+tAuAr# zN%$`b`8+0q)6}N*2AtbvXX0u~?rGo9DrGo2;Xj(Y*oY1)D~2%{Ne67!hUBzpuioyd zut4vz+hk<4aSM#qh+mH|%sC#xR2~3}_;_NFlNcywj;m8Q>@C@5Ve%I#dm4zN9~HO5 z^HR4t#ub!VwB6d!O9PR$*bz-_c5pEMZ`7WzdYO%=krQiAg$)YK2ajhb@v^8#J1-^y zEB#U1f$UAY^ zSF8Op3l-ZIdfXNWi;DatUJ#by?jjELF%k9Y`BO1?Q~J!Ko{v9A{&8HN=k z2H%~oyYTZ#Gwwmgm#*v%k%&_pt8T!#kNDY-5OFwric{`l$L$kc9tqq^B*W1VfzP^0 z&CX7FIlNfxm>HL)-rI2aL)dMhVkR25eu5~lfUYLK48z`U&Xxy42LO6a6z5{iB8NoI z=s(i#tRLJPvBmxs;9YeDa8ae?h+o~Jn$21wFIX;68y*crm$mE<_0p!G-UHaS$Z+_R z(Et4rYRw8!5O|&hYyaONb7fAq8fTpm8hqjWRX>=|lE+BpeN4fIHkZ_3qs^OdWL5hF1B;<*y=ndB3Z`uGjM zggUW`&7nv}*=uV-+sql2|Cw=|MMS@uZoE%@_J3ZgIk(p1{ia1N?yny(<3jR?R4zk} zSpUDjW)oH=lhAS8l?=}Vc<+Rzw-trC>iiTwh?^?7+SOl8-R-f7o6c1YF!i5velrD+ zPdvK0A~Tv4CS~q7Z7j?U$ko>@w3bE4L7cc8{|$Q`w!eur7n`qGzpWGy9`EVZSyLe3 z;L$1tt8nm%aiN}<@p&x>d2YPKD52F-+1g4I zlfO3@8!34)0x#KGEhGOXs2hJHdiMbiF~aF*e_EmO|H45U1W?GfTdsE3e_Y7A{TJ<( zwn^w94(2Z*Y#6fv>$S(kS1iQF0|17fHcJD1sDrBP>dGP-yqX@rPt9nSPJZGf%WgTD zd-tFzh=*Hz19AOsg)9ef=<<8)>!3U}mC@{67UGy|S^*`2>m)H_R?Q*J3fnbeVd;cW zD2Qfc{M2s3W*&gZIkX zm6eL(u0F=WK7&~x?C%I2#rS6Fd3k>BVq|tLRCq7Y=2wYtSNlyC;JRu;MH}81C&Mqp zb*@V6Dq+_l!M7}Qb!a25V#isI24d!x|Dj;wBuneTYN*sqsNh0{ye&&;g+c$YW9nJ6w3}@EWooQP z^a^7Gem!D1NmrIn*aqO50&?7sbYDe6VVs1^rcJ73zgVt)fC*JO64tEPZLh-`I)3N*b1HHa_m_SWNXIblu-9^ifiBq$UNoatPm}}KW<*; ziuoXU@Jd0TcPthC{W{Q$XJWjHRo?k+S^`d}+%*!ZSp$jik|GN23qC#^M5n2p4u z#YsO&02~oK0lTX$dab4D4O#p*K*xD_5lrt4s1e#QZ_F9tItT zNBcHNfb=1nIKH1FG*2OiCjr~UWcXJ{Z{{8}1iav|f5HCH2+`)iE2*R%k$Aolpq%bg z!5w8dJ)*G9zJ%-2Hfu-~@ePHXJSYk)T1(A@fYbrO`JUY_vbT%7)>{pfE*SNQSfI!e z(&-6Fy9WT||E)rdu=x@%hkgM3;pgyv_{sZnGF%ysVXJuf1G+vH}fR#4b57Tkcb*xY5F} z{Onm;POP?sL)`Lk(7FFon>6J6w8SzDIQ$`QenhP|e^vS{WBIlF?4M0gMKe}~y>_%S zlxu$j8n-|p5{vv)K_p2QCh`&c`MKE9dq{MNhVN$XjSy4@%T}*x$;%T-*ho0QL(2`s zh*#EK>!+SgO>y2%=Zk_O@;70C%nC7Fuztz8uz;(qd1<|2G9KJ=fN#ONs~LA{Sr+6S zx74abp(j|X#Vq^a=509O7w$zWcUKkV8nc_FArl}{#PRtexW51c`Q@+JNcqr+gE=wn zkCkWVgrKd7ytll?!5RE&tSVQEFhV~rYn|`p8{|OmFs39&+1gjn>qen{T$ZR>h5gT- zD=uqJLqjTWcC7wTXP>a(wkCG>hd%tZs3;BXD;FJa!HCd~FfAGOJBWn-|eU;B_>PoMU2Sk=^%fjheOtJtz zp(Ba-&=9gv6dfGr-hKY8u)mmj$xL)4H})U~8`1+(KlD`IWg7@9r1D`EzH#=aBybV_ zz3G_s4teguH>>a>9Yd20UPnR$h4K&*GY-E_o_f=mfrdz#W;|jC_#6a~@9;_R$q`qe zY5bcK@O+06OMV*_ zSU8j_!>?=2I`c2_X#QdU0723nEfyDBt(%rlgayRM@v7t@Qk55aPA|Y~N(3eXgg_;V z|7#D_nz^coY=kSUi^7~We*TR{GKqI=V=cHyr=K%vRFwi?h77;MHj%hkgafHL8>|jk zb@d3MR0pcq&&l05EyJHW!#ix(z%9FgHrx&glD->w+@)l{U$xzcxk?RxY9>P5p5^m! zH5B*9t^uyO`})F#)OK#v#IpWRqhO3sZcZbVWs#8MBmaBZ&#{-1T?J|6oE=cwHz2fg!l-hZ;>Ow{2 zLdiVWAu2b8Ph00%TasyF3jCEl3@(A9bB}{84sx2o_yN?8SF1SJL2|!429OtIimBU=Sxd!J zCGi1ioG*h@1lsMn8@N3SJ;Tkg?VhPS;P!ZV8Jaw4@i2m{1y$_hOz*;Zp})RtK<_@k zmR+8gdYKJ@beY)4FK!=OaE+(H1M)Rz`QTUk$GD;$f=VrUexN6eUj52mkV23TL!iq8 z%jhqJ0Qo=zX2LoAmiUl#eEUurb4ib1J8!gD;(G=ulxo^=<%l2XdcZ}OgySqIyiaV^ zDR=lQs}SOoDet@<;d<_3BZO9TM{w_mgp{Wk0^m1D{SIK`QO8_1dwSLaV>NnPM+3Ti z3MLD~MVrV8?ZzbU4cR`bds^dpluHT%avM6lX6qK~TtJZ;bQjJU=n$-|L!@3C#GPO0 z*}Py~-AD-oz4&dk{;O%xQ%oL+q*ZB>ht5{ie?4R;te$Vk;~U3w9?Ve*)DG0G;;W!$ zKf-w|Wvg_K&Z$QDNN|^b-hls_DYLKorZt9=r9F)eBT{b+YguB{*-;jk=NEMVR}qVj zvuaj&rh3P%vCG+y;Jd}Dna{Y=2^#*A(b$#Uj#5B$U*=(k|EymB`Q!5TFNYD;<&pSr zf}~&QE5E97Uv6FL79*x)w}g=l+>h7-_;?pOIs$D3;djd;Ok?@Kb{86n#GTtLq=~5L z5mJASNVOwVbl2B~MBzMZmb9-Q)j7cOuLn!zWvE%kl1;pHOWr5KD~Byf_moJ_lA-;T2pB`7%)D2&>|jo6H3@ZR!$ z9(hz>yi&=yBhWwF^dZB7_J;UC)8xuycxB>2ofuhf z!<4|WqsbglzDL>9Bp<4Vwdjx3L_|g9PX9Iq{O9@lFmz8vd?F_jSZn<=l1hNru5|=*{j9tLc8Z$#0dvZz9?IY+jvYC*@4+q zdg3RW-MX;cI_B(Xj!g4b8kXP%j59AJ6vHX)W+!#h35W+WXU2z)-vdh%12bfO9nu5o zJwNgZ+7G_>m)qc#G!ZKUH@azQqI1eK?D@*E&xr?tVuRkjGbNn~L9fgA8`D+= zS{xPi&*UB6{}Y$ZWSoU1K9XH)sI=)IFw>pk83$(YO;lcG_7m14Bzk`C{MAcOsX}9D za7pj>UrP}^CJ;)CBN)w5qdhbJBK_e+Z1(KT+I_0*??xm9J@$t3;IOo*;UTNg`Wlqa z7EL!L8MzBYyW?&Jh)Um4R~kd`3Fl`-6SJEgvE|R`#d989IKMlg`6vm|NfeY4oHa+x zP6*!KTcI~`5yPg%mj8~`{PCOKZWf9Aa{>l^)6t1W@K&;=KBWLV`a7}pF3tE5AKiC* z9)BoxZyNOEHAgF#*>R24GRy3E$mzGbMi8euKju0*IrfB&T>J`+GBYG`6rQ*J+jTE&*=~`~q|I??`csPR zpC8gVt#lgB_9vm>to5xEz=U^iG%cB330Zd80=){SFKhA+9p&3xV8a&jWVIZfBCN9~ zC;69vbNmUqzRy>B?2ga_S|5;<@}N-~QoW36-=S7G`-MpN>Z8%#+R? ze3kQ#kWMj2LjsNrdRJom1w46pTUXW=-PxncZa+S(J+sBT<$iVhcbe!oTQa&W!Z%G~ z3JprdMJuNQ;!UT$pUy`P?!>$VSG`;8|1<6SvIzd?GVUikBErj*7XoIa=y{pKfms43 z2uuRTYMKJC`xOcG&VVps_SQo6X%p)ohopngze8p3OzqsY{mCQ# zFGI_It<&(iJ=1q;IS2@P8;02SdK83YgreLlXSHR|bI3wm)uD%o3gNiwB?Vn)0X$BP zRAQOq*Kn9E-(BN>#wajC9k@t zD$yEzQ`Sp&|H&2)r~5e^?Ri(?i4b1J@TtPNqMuo#k()Ai&QL;^#ky+2Sv@;WADC34O9qkoC%V z30O?RSUDHC#$*MnW;3Wo-VW#92BaYF%zT06(%~Pq#D&QMG#@{>=?CgO-)VcAOSehR zRneKto^!vP5kgkH`jPQ(CioJw_MW|I2mE6EJ;E4c{RT(n`(R8f-Lm+*1^RrtB2E-G zKkg12M82m};5kl2rUnCOPj$Q6z2^GWM6!!Rs21|N*WxR8jAXc6j@K8sTO;i|C*L|( zwuud}YJoZLw0l56_gXlIKLueNO6@j>{@V+nhIq0Y_NaSKJzjsd39J%&V(FnVoYWc< z|8EN_{f?B6v^V6t2VRHYZ`gW8(}LMIi+x2C;0-I&3*YUw`jPYbPb$gymhTN#M>`JO zAdzgAH=2xUfR865_Q%oZGokfvc%7)^wUqs)qD}qu(=OmNw@Yse5YKLI@te@ZYjzf3 zI(RPRHt#3vYUp)q>xUZMJ@XiB*#&9mAL~+IDU=nq8j4dTlxch5 zTYg8xWQ1SZS#wr|UtHWcr&_fDS2{O&D6k!FOT+n&&}rotIcuKs9ExfSTvhXFMOP`f z%WMjEsg&!{f;_=kM)9By?vzty6>y!@BxAvQc-I?0;s+m-p&IZ#9#f7=pyynXk_ z`KMcqv}c=ZRrL52eNJ}O=hE}W=&z=NONPsKn-;yWJ^V)qCl?=cnX0S3x55H+LTH!N zAZX7Ch4Wfl=j3C<9Cg@L?#y3*-0_x2Jp2ai zj!`fC6P95VD)h*zpBiLuR+18;k=fYQL)TjWcrmQ!Pe*^iYe{wst2E)-d?sri37?@&>w=D859M{5TD3sOi(qIyr7` zt8Xqg8hbe5EPwx(CTfm~p(PC-O(6S5ZGO;R_&n9>64ktg!Uz?_8Z5?AW~!+{=K4Ns z%?)D1Rcf2MjO7gvn5-)hUVb2xo0AZIk236z7jD;8D13O!w>LkI)p8%JrOB2^Gt#a- zTuIv8n&6IiY5Wc}${q-WYtcfNfH_2eM+++g}`YfGwp zabDu?q1a<#=<8iQlz)1$a)Hd7jfGCv!yNpbwnoTSc=u%07^}ud)>Y)=R#~d5m1KwV z8H$MURw|?N6uiEx=HOU(_x3kSlsT)0yvxEe@e1F@iAnMw=&+Yiwu;ZE-nzH}WfP*y zGA_8oX0rDAg;iy&=oyojdXpKKmgM5+4lJl>M_^6W09Y~!Adawx^MP41(#C#6pl)RXMy&C`R^5u|YwuNr#3Kb>bYNe+9 z>@s5x8W}wqa5E6omu@KzX6d1mf=_GvoV3w1`Gh@!d+}UnpEp4&J-$SrF}@% zMnq!GbXV&(wM*vO_$Rs8DeO(Qths+8NhVI^%q5QO*>sJisjkUL)o1gfnURhmOh0W_ zV`$0FYlafHD49VAbql{=w+^&14NFUvO0n>|n0sgIGl$%i5ZCB{z!kb{ML!=kXN=zC zb(_=nfXoo}bBe0OYWOi}{#E z=Lp$$w-(N9-cMZFlVn-LS12t1NI~?_r^6)*biUTDPa71c=M+}V5wu6ErLO?4&3EPG+$O@~V{jH@7~aQW8ul$Jd`FNQO^$1k&d2;=5-gVP8Dt~) zp#3pDF`*<9jx_xKPp`dbr{H|}VgG9T;MeyG0^J^ESfj0+qK&jC33~5#ng~7W^0Uj} zhE=WTc6XN+X9ithB^j@S8OI!~#;9sUVE4aP)DBhpnrjEoya+xUz%0@NF#UZpNTG5l zXtIkM^WAhj*BX^^C)iA;^Q3phV)fH5*^5MgmLe-SY|x%vq@$kA37Jcm?m14b&9&Y~ z(aEqHEs}>870wt4&hIG?-TUuM7`^xD=|4rrq-*cKdXa#|_NC&~!Qjb34 zOLSg-sx5iOY1x{(qI^5}^tRv~=|-CQC!#qvOSp$iZjn;?oeYL@-RJjvo`h$N-8k1* zf5-NvY=n=#OM}(?zx2Ap(&qalU(m^BIh5ixrP!qJk4a^%Ki$XZWw~Xe10}t6Z`L0P z`>y<&Ps!x4?Q)1NL4C?hVky=z^ggCfS+9KOtBW>Xw+GOc9$>a#H!E%CMnDluy}$}L8Bpzr&VAA!Aq1KUcTx`p$*{j$L@*=GpK;w4P! zmAbPRTji_$5cFM3y-A*$m?n)f;-vp=gdm>c^fA~RYQ%&&{K4VTCCq@5wy&1OkEX5~ zK8w5%qHmtTp$<@e>I3_O)6eiHC&?=jp`29+pYVGNX3N*Q6fa_Ni1B+DW^(x2&zr)-V3N_S;Oi_EnILo$c|( z^x!(?g@EdxIKRGkJD3sj+nVbp-3&h&b~?HDom(n3%ONCToUxZeoN=Xmii2v*k({&` zF)O)TKgM1|rM3Qu^TAFvjb7Gz{$UPuwjrodn;K)j?fu#dnM5TW%XV;HQNq;Ui@+n^ z9c~wwx*k3;X`a#!@pb`GQW}^u_*DlnxYAf8Zc@WXoZyfjk&RP3B4RUO!>n@|E03(k zoyFu7mCSTJZ`v)KW7J=YL|xMg4zuH@yEMAq@Js)0nndzDK<^hN+)c=)KH!HRboI=+ z5^(6E-vSpZs9)S7FRKv3ZPn+KG@0;>7TQy??W=$t55ZIKboriZ_j@SkzaOD4OuS|a zA*Zu@#Ct?6R)4I_L%(2T^1>?Rn8aPQ%GyJfN}nCURxg%b-v60Ua&Jlaw))`F@iPJ1 zrYF|x?2oCj0EDlPz14VoB>&OZlE+3W)y^gNGM18D4#!=WC9pq`*}1+xRac)E!GYl^ zpFX571Wm$LQew5ywS^uk`qY0qU6I-zsrSlL>Q>C_8K(GN3H+t1XA$agUn~)af{?Dm zE|ck-EFqH#K~jR@pBGD#B=7r?6~&Hhy`4y6|8Q+fL20i+kxVg4TMnxl=Pr8UgjJtF z=Detdm)@6Qpq=k?eC&?R{um+*G15*T_CkrL%?$cN$xS7~tya2nYgaSE`TT}oN^;ta zspTFTfd})$@3l5SymeHDPrV5=@dZj9Id<`(d@K?1eTtW4?09E3_G8&mL367Mf6PGP zNu8fFYJ5)&7=Z1@Di|tbGWfathe;Txtr^WPYL@9QTY^t^xqtTSm>!tTUO!MQ-y;jN zI-2kCQjKWN@x;}rJEmC`W)k&ii=LLP6_qR5%LB!E9#`I2{iZeUk{-gRcwD>Er}PXh zxz8tOY&we=evD-VCY zQOmm@ycC~w`e6RK%JkotT?4y3Ws)+|Z;H5u8-}XVOKu-i?>BC^vt-bggluaKR&Alo zhV@lGZ|ZW4^9`--3J{)XdyC-QdRqJyHf&v_m58tQz;|I$1(vb?fR zDL8IsvDL=>DfK`6jWs?^sey+(O*O&+rYQtw+hT~wLBrD~I=FeBHQo<0dmwcvc z5ha*UOQenn#u-Gj9Gwn?lwt7fiG&WRP0`x=q(N|ns;=Kyu9Xv zCv*y3O>l}Xj87fOt6ecLNJTja#7pDU@EqcEM1|qnR-b&Ei7EvC{MTttcDcL4{+}Y$ zquM`3V2kH+L{-Re=ElF?GN*&42Dt_El=uZ5xl9`iR!BAh8HV!OF`l>+DYHD1^?vjRCj!T+SpCEvkh0hj|0U?zPNR_ z&;9eMAL;SwYhN7SuU#%e>sV%|)osd29$;;z6|D8&>QM*%s%4N_>sZ(HExR|okrKnE z-~6K5wQyE#q*qm}{{D|ObHwio#ak=>lV8X7E6BEdzmtqFt zXxAc~txkkbnv?CJ&i*z$=(rX}*ty{-%2s22~DpfMlB7rjM>6UuF%|M)6GMsXs4 z9e#7wmd5EYgN#BfktWe?9B=&3?}bi*VK&~@NYeTJ2(l%Zt4tIvW=GA#zw$~J=rwSu z$O9HIm|TijX|X`6U{2t<15Zt8={s9GonEfTo`>J)`$-ib9?cWz7^zztCeTEzxyTwpbiR;mScZxyqgUdE7ba?8fndaY2w60JWq0E zpv_rXke_4$IinrrAe8xWtCayC)^L*u%CRKo6WE_g5~%EZ+FqDp%i5D3JOE%AAx}vQ zVB4Cr#y&4q>&TW4iOBfdbec+LdLZ4Pd4Di(Q%4>OeW^TN$sp#Hq&UNUt%4Z2g%+q} zx?t2n!xG8}3T#*M=TzOuSdvE#kRb89Z0Ox-6h{9Q7(`?q)SEjNRK**&A8Om}YCJQc zgA8-27-&ablWw4cS0&mI7$%bDF1#@VbuxdQ4{H#p5t|ua1IJ#r9dsw6ow_wKkFng1 zc%&o31;A);xBUz4C-XVg;>wA zThy5h)Mj~wv`l8TW(0pmhy^mWuAKG71jBWRC}{2-Plh=yNJ8UD&@G>}`Wr(!N&UhY z51v2ThLXcTzZy!q5qDlI0m=(JAFp&FulY8OL$vqrYsP{ZP%_nLkY2YhvrIFw7CzNk zwxWqgKY5jvI&$SB8M>XfY@)BCY?$_3Ys0Xe4>LeNn;u^#xdwOiL1h-q?!`0f>kp_$ z4>zU>A^+qn)cQ56naZc#l*^IcWI;ffHT(FXDt(ZoNU(|Ja`SEas{@9I^o#FVU91)v z^cI0AY`CCy1%1kEU_Vv%eM^3H;*FL5nv)3!Z09~fjtDh>bBDNSzGgiv5QZ~W$-)uwUGrmNY|*d<6l;wgC^ zPHxLb=*HytgpjFE!bJ{spyJ9TDucb;nz~6`r=&|j)SM9p+)=s?S6&TE)b7`s@NO;6 zGEr0|9{=sw66jc5dsH%KN`1qH=`I=iHE?KP-#SjJ1`fxj1xr;_ppqpfaG?bVH*xN_ zvRcO*m0qsnx{gz{9b*Z*9&rK5~jm0!>*pS z4PNUyQhP0RsN*J&%v$kn*PoWeZVSSD_i8QwNllW|RGI5Gda65y3(Y*hnE&)r-|ZRe zW>XUAsw;!b*-lH@kft!$fS8GTxP-M6=mOklW+h~1b@Hsc?+gCz(8QzDM#e)=SPLK4 zyxCs*KF=@oSkNTu>g$qP;VQyhDZ)sT6@!1;p!7`1Kg|)o=ZAW>&8#ai_pCerMgdSk z#8i;?nwlvF@Y505_zT_nlX{tg+f5Flz+eDq=`ztyfrntuFMbs| zMm7D0Eg3ue?fn|9GD!koKymPRFMy|Bt*3uFl=-EUBB3k5>Vr3?n*HJ%u0CMLgDac; zr`M17nKRFKGX}>mE$!PS!v|m=h%?a>WwH26!lBD^ssnZ7bl&cb2sA_A*|^J|(e#h; zuqG)?U(jC&sh*A$AAxbzNf!-2aRfG z+2V85=FGA)e>{*!4+dy+(CmwJNO>6Axo(L-2S`PnqwA?IORD`$K}jlC2UYTL~6a{^&k7}LBB?q$_R*wZi&jNX`It`Vx5i5Tzw9=J0dhnmfL70H$`STtNHVp}ri>j$TY0^O%SF1bq z1mEq}Gd!U0vuh*6Nf56U=-&Q0S>?l?5rK#dxJh9OL~CeBqB7oBFyroL{){p)J;Osc zMMxW*Gq0=sqRcfbaUSLyT*vJ}f}?Ig_#CA?cmZxo{+9?t6BYpQ}Dp!MhZz#y!<=hcH)t)ctORW#dN_ zbRfZ{raeReflDywjlJQqNkyY==3SQy5WpJ$(1#f%P^c`S_-i&Y?i2%d^bYI)vjCgU zhQiK)Y+JdlPs44!8%KmxOc$Zz)Wp{T96OI-mfwXfDx z18Hf9SKvCe>8{=SZS#|?OYQ3=&;pTmi>aM=&>ZBmXil5{- z%V5J@PMmo-5`b9nw2q7X+Q1sgbmqlU8=j9K%+Hk)DhxhC={im;ZBCfv0$z9L<0MFb z(|BN;;~=b{)6YBn_Bzby(i8OWOGx7?OpNmy4FN(R9VNZ@w;LSbgeQ05)kDxc^;>2_R{kO{U?dZsN${pT~By-@;HZXlrHP6;J#Ck{&3} z3qRRuI;fl`NWNfK2)QN92nZkW_JMem4qp514ba^VyeO7sHq79lRg^1gVO7y8AwhD= z#O&qyo0GE17kb!;e|G5sl0T$3C$=XFc@ae9%=46aSZolM!%A<{R0G}3-_?#B0fbez zNLa`16if%TVUF&NkK)@%hQ~Q@#K;2E?IzBA)C-@na&&i@W)OMHL1JcOfkQAH zE1RriMsXTC8sXa(*(?97OoG{lzb;33`n_;$yE2zO4Noc>s_4#*zgw^-AatYiC$*>{ zne(uzo;m9>$!QQOtsc^MkM;8GFey8klwQ?>X(0ii6e+ z4!2oN(ZaPGo|oPL4kV|<*Yz@&9BJy54Wwj+pzYIH*mxs0khrOWf1jG3VG(cN!7-5h z@$+o>1=cf;O?kM~045c=?$uudB-{)FUngfu?bMars6clU&&E;K;m9*#akdPc+`?Ws zyFVOm3Wh1q!bT(!6hN27S60Z-8GXUis`*jK_>E#`v^n$awg?7nH8$F3V+CqgdI1B= zfKRG4isL0E@v&4*61L^Xhl~Qm7ON-)w38VR7uI?&hV$l3!cKph<_HB%b8zQ$YCVBu zDKnA(X~`5@k3bkr_~!+Ne+n)dZZg-ABbecDfb^0HePmVb>K}GK&`D^?d7Uu`Pt?Fz zPWr*g_dw#Yohr^t^{)VW{H%8P@CMrSa%vg4^0BfVrvHEugr5W$aRjV(JgYLX0ONHqN@-ymH20&HriyI5D1u)*I0E+4Al7T+(U zxZHJ|9cuWpE%UpPB>um100Rc+t&nAT6@s|tk)syKXMce^`(hoQ4@g@z#iu-714?mo zl6eO|0NcDKSchTz=vo@;m-|LfP)ZOETL4r4G8gdLhFA-FQxt0Wquyh7FH|zKW{Cvj z-$SdVi1}T6|1y+&QYwoHI~|x4WA(oYb=T1NDMIc^rrI87c9}6=dn~|qfa)i~ftKK` zEWxPe<30?x|U5A}dF>nC#j`1d*@H_AmEZRENy<4!tY<`WF~s5t&hh6Qp~?3Qpy$zRRr`}F1% zCnawQ5Z+|#ClK&Mf`xyJgwJ76BocxpBnM$V4Qnz4cA>=V*`WGL%MGd@JDEKX>!~A_ zjtFjFEq?VEDXM4qSwnEg!MYiJHRNe?FqK4uZ}xoE;nVds1>fprE4ajbyL+r}>uFVk zxh`(2gjce2T08~6B@Ds_AW`3xls;{s>lxA~&HEyx$n0!o(Af2LoYIKG;*ZG;Dw}MY z!$N0$yk(_$AaN&!yqdQ@_aci!lmicryFt@n%RRLxo%oSujER%ZBtx#wK!Wwswx03t zV&5TObgXvWzM+aNmUwEM|IEOWR0%Jj`f%X;80$+@OMSOc)zNK|rC`%a`UCpDsJbT@ zqZdwsEP0aeXIL_z9>@zpr2iaCbiB%$Ds|N-4%E5SMA|*O#Obqye}6nPpv_t$nknG+fe*%Rygv zeo>M4(uT~w&pUjv&<9$a;rPi%)<2J(WurrtNTw=mrFd+T=xz zjrG7f^{g7$^~VnQ=KT#KP}u=E-n<6Z$py!=dn`Pzz(IeNp@Xa$SEz#yugvyq#X9U$ zPv1{KdbnALxOG(|Q?u19I=o7)y}&rB&lyR%ti)wF^ZC zb+g2N@EX(AxnN12fQ1wXALoHao&Av4P22+f_m>I;=yam$o_m5qGIpBNL#~2XhTvUi z;h=2jN1HXzt*1+ws7m`_eT%&HlLB0IY;hf-Y7(A*ONX=2hKn>@ds2+sb*F*#05C{F zN(jz->5Yz0Aoi;Bwof-}UHzSelAJ}9!Sci&TxY3+o(o$HJSNsYm&)1zR9L zbzJOspUlW56$-D87p%ViQ8OPNv0mCAN4#h8&~n2?ZRmiPDgeC~hm^S55WJx^?u-zNl9r}cHUW_Zs_sv^lM)4~J z2ueU@^$Y1XYj|IE1ORicJC)M&Qo|s5;OTmCM7P3#kNM;A0^jW08DQ1ftpE?G4>))R z9~8>Y3{ZX<6H>y+8V4Y&atM^QScF5j({Ek9&z*o%Crspji2{Bou*0t&VBBqu$@VDp zkf1C)FSI3G(LToyqru|bQ}{4+t1|V^OxF{L0W6p9zMTm+B8cQ z^1iTB6S(dypTy#Z2d|@jq6Q*5!suTcmCU#AzB>#Ull8R-j+6Q+7naZ3IeSXSI?@z2 z@r!(VrcFfc_4jQv&p2<1ph7=kwFO{Yuu$NTpaEWQt zu;3o2RL^-@aUn#mD846h(62v4cx%jH7VPDjUDeyty}A2i@TmobnncI8+o3NNoqGg3 zSCsXg;W54)JmOU}8^i)Bf>--rT%0=T6fmWm<3AiUpBTFj-9Up_KK=sMfz1`~WUHMG zKo@w8W=CqxdahREzLiS7JGvQiGGPWJOL|B%yw!MhS}(&3W3JQu4QpdE)_NYz;!7uL z`41{z@hF8W6phlu-0SHurQw&ha^TUMX_ad(v&2D*y1xy!p)=wn0OlzcdRe#BUm1-P z130pdE0SRgfx7@f14%(=94s<7uYkydW@p_7aG(JIVS*ww;DPqC@q#yjOUNk*?X`A4w;L%PuNE|&Htz)f%47Jsm8R53 z47Gd@A-vY9`xC zmXqCJw9R74?=8k%irZ-(@X`U~+#N;VxT6Gh6)^wWRFW#^-^oMjWb3;dw!!BAC|sqe z{vs;RrD5yZ_1}PQA?Ms8kC`vqU{J9x@YK67I*Xkv{Qi5Umf)U)MFk+PCWcqV}Q902O&w zksKqU+um&$hL4y)an-_(V}E(|hM@GgX_FDS%fIHmtH?MIpbhkSk+9ru%iVtfnj4A= z3pSFK9@BWZHpLW`6StCE^AMuJ8jo6Zjvb(d3>=cBTsMX<#8SDmkzZ{C>#AHh;*A1X zsVT2-)&ir4G80bqnBLi?3251xcCJ3%9DuA(Z3KOU+Yp#CH;%Y2YNb<#8S&b@rq<@# z;3nIew}Mdri0e=Tr}TgIJr4!q+KG|b%I9smqMD@p%w_tPoGR&z3U`7&pk>I6-Y)i6 zE{#pS4u5Qm_`;*BBe36PjRun@3SLiy5N1~3gQSYpzWPm>H&C5#g6S`=R)A7mtC79< zFu|e><6+KCqddfh2Smw6ni1E3Wcve3vgE7}f6*JJU#C-Ldk^ji^uDAVY<}Z-8!}`; zGAkQd+~{-F`?Aia%O$z!x|Fb5e`2sL#Q#&wWsuS9tS# z2k74x#O(YXH#{SQoj=8fhm%(E{>uSfo(x54j-*dmeXQ9^(WT_5K<_};z4Eq_90A{7+Cpf$Jaykp9F0G z3=ll*5X>lnj%Mj`0(Ni-_^}&Ko68l0l+WIV?F)^{7#m4rXNNR zkunijoEQhB^6U3TsTC|}Wcik{=!rF}Z^iy2Ke;aMbuzJUc(BZJL%EsD;ti2V$gEtX z^MLH=*tV?Je9<(S01Kw=woOURQ;LUeOEI!QM>32CCnh6nRES<_*M(@| zKx`BITXl-VBkEb(P$Uh^H6BTxCpD1xuY2MjqX#A@Y(XF$)Ejg703`xu{a!#2ouH>X zw0cRbl#+;3O~%-#!3UiMmQp`cE8qn;a9X;HCY|1t0iPPVHUBeuSVY)>Gct?1_3*4^ z6JjY>NK>$=$xYWaDOXDc{pmFF+CCS|h2pI16W2KYG@iJyp3V(Je%}tC8KU>NBFq2E zC9&xH+dqCfi3}Vz*~7^pix%m=svH+ou_>b75yxu(=lx1gM^H(pyRM;${G$R*RK24l zRTr4YayVt~k&o2*9jHfGvy;e2-<@IBUE1Z3s~5Rrr#joDZ4HhI8tg^(cSr~NhgwAj z$_Bw&f%hdj(%XI4Wca7&Om@ayx#n8Uq9+t#q(sqAs_rS+-?P!W^Y*lw0Vvc5<=cJg z3U`XHtuB_=sLeZQI!CdY(HmJ#S$Ir$;teUUhu`3}O?1D|S@O!{0qqg^kXk=mNS5Bu z9@vw7LA@_yi%yB1L5XRhQTWPuz7a(m^8$#fK?o5~a31uw&imI{jMa36*ZOvvf4D8O zmcAD5XK~H+n83tF&~+Xeqh|u4JjnjP^<5fMcOlnRqXq&7oQ3q@eZPqIWszT0wqMRE z?vW*qxJo`-ScXt!pneEbINpuuPuIRl`{XPN5&}B$gs;r9-dN$^s$cMPAFEhOvBbbi z$tb^M%W?Dy4mr<;k6sx=vck~g=Yq;sGnD*AdxkHu>g>hz(mR?ZAtjJ}#TXqTQ8}TK ziSc3!&-PtEheHdqNmO3u9pMowKVCi^CIlOcx!yysSYh0Uxdb@RhVP%#>12@PkDvWfNP6l(W7C81FJWK${W zw8$Nou4pPR_sWmFaVYx$xi$>a;I&^veQth8e}50!aR^4 zK9hrQlT}ev%67VK)(xZG0JOQNdV8(dxIo7qEB>6T`y`P|MB#Z;{dB1^9HJC{F7*@1 zB-5-MJQ}r)j-5KObmiwPJ6nFw74UP}N48}z_xn`M&nq6Y)_L2K%M{wbqBj&x%C&oY zAl-jfqsJo{-0t$bS=SZM-(c$;cBj`V@mF{VNF_@myEX|$llU_Q99QnWN%Dn!CI7IY z-y^%4viBlgya+i-y@yX(@~8Vcf8&Ed$p`sLs;p=1L`yW`efo9GX;BBu(CklrD>X#- zBBt0b@C0V%@&pB}1#+%6Qu02fTH04qwEh@wg>LLsiETsRXVw&EbC;p#z*o3ek0eR6PVCp^c|P$7ZlH*)^xo((-E1w4w&Cuyd&r@9b1}$ zeP_^!ALJpQwV_rPBj&95qf%uT=e_`F&*ptGBPEXPl@j1+PvUXVw37bsNzeDo-a~rG z|FI6zZx0RM++I%eB*?s6`ukpsm1UlcYsSJrXe!yggcg+F`EYjmXA;;)uv<2cMFe;| zG;6UkV%6nlF<3R(Y(%JUV+44}sQy}NNzim{ecEh2yv?~%sqZ$qP*^^^!EKiXQ!kP} zqq(v}96pG^&S9`8>FY%YC!jvC_mOWkBEEjPWO{S+(E`RBl_Okw*9a!YZrNjy>sB=K zY&Ty#v{Fe{A$h34Z5h14PJEXd>GLLW+;p^Fd9FZdRPYoJ)E8sdgYAMfDaNxp$Z;zK qvA!z6IeuM1dKQDXfR$|n>m@?emUg3t1ISHd1ads=YFB3ynEZd22+VH) literal 0 HcmV?d00001 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/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..6f27b7dac5 --- /dev/null +++ b/music_assistant/providers/yandex_alice/webhook_probe.py @@ -0,0 +1,128 @@ +"""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 + +__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.""" + base = (base_url or "").strip().rstrip("/") + if not base: + return False, "External base URL is empty — fill it in first." + if not base.lower().startswith("https://"): + return False, f"External base URL must use HTTPS (got: {base!r})." + 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/tests/providers/yandex_alice/test_auto_create.py b/tests/providers/yandex_alice/test_auto_create.py index 5986d2f39d..2f89a7a6f5 100644 --- a/tests/providers/yandex_alice/test_auto_create.py +++ b/tests/providers/yandex_alice/test_auto_create.py @@ -1,36 +1,31 @@ -"""Tests for provider/auto_create.py — self-resuming Device Flow + skill pipeline. +"""Tests for provider/auto_create.py — blocking single-click pipeline. -We mock two external dependencies: - -- ``provider.auth_session.passport_client_session`` — the async context manager - that yields a PassportClient. Tests inject a fake PassportClient with the - exact ``start_device_login`` / ``poll_device_until_confirmed`` / - ``refresh_passport_cookies`` shape needed for the case under test. -- ``provider.auto_create.auto_create_skill`` — the ya-dialogs-api orchestrator. - Tests inject the desired ``SkillCreationArtifacts`` outcome. +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, PLW0108 # tests don't need per-method docstrings; +# the lambda-around-async-context-manager-factory is intentional + from __future__ import annotations -import time 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 import DeviceCodeSession, SecretStr -from ya_passport_auth.exceptions import ( - DeviceCodeTimeoutError, - InvalidCredentialsError, -) +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, - deserialize_device_session, - run_auto_create_step, - serialize_device_session, + adopt_existing_skill, + delete_existing_skill_then_recreate, + run_create_skill, ) if TYPE_CHECKING: @@ -41,448 +36,301 @@ # --------------------------------------------------------------------------- -def _make_device_code_session( - *, - user_code: str = "ABCD-1234", - device_code: str = "device-code-secret", - expires_in: int = 600, - interval: int = 5, -) -> DeviceCodeSession: - """Build a DeviceCodeSession with sensible test defaults.""" - return DeviceCodeSession( - device_code=SecretStr(device_code), - user_code=user_code, - verification_url="https://ya.ru/device", - expires_in=expires_in, - interval=interval, - ) +@asynccontextmanager +async def _fake_session_cm( + fake_session: Any, +) -> AsyncIterator[Any]: + """Async context manager wrapping a fake aiohttp.ClientSession.""" + yield fake_session -def _patch_passport_client( - monkeypatch: pytest.MonkeyPatch, - *, - start_session: DeviceCodeSession | Exception | None = None, - poll_outcome: Any = None, -) -> MagicMock: - """Install a fake passport_client_session() yielding a configured client. - - *start_session*: result (or Exception) of ``start_device_login``. - *poll_outcome*: tuple ``(credentials, refresh_side_effect)`` or Exception. - ``credentials`` becomes the return of ``poll_device_until_confirmed``; - ``refresh_side_effect`` is set on ``refresh_passport_cookies``. - Returns the fake client (a MagicMock) so tests can assert call_args. - """ - client = MagicMock() - if start_session is not None: - if isinstance(start_session, Exception): - client.start_device_login = AsyncMock(side_effect=start_session) - else: - client.start_device_login = AsyncMock(return_value=start_session) - else: - client.start_device_login = AsyncMock() - - if poll_outcome is not None: - if isinstance(poll_outcome, Exception): - client.poll_device_until_confirmed = AsyncMock(side_effect=poll_outcome) - client.refresh_passport_cookies = AsyncMock() - else: - credentials, refresh_side_effect = poll_outcome - client.poll_device_until_confirmed = AsyncMock(return_value=credentials) - client.refresh_passport_cookies = AsyncMock(side_effect=refresh_side_effect) - else: - client.poll_device_until_confirmed = AsyncMock() - client.refresh_passport_cookies = AsyncMock() - - @asynccontextmanager - async def _fake_cm() -> AsyncIterator[MagicMock]: - yield client - - monkeypatch.setattr(auto_create, "passport_client_session", _fake_cm) - return client - - -def _patch_auto_create_skill( - monkeypatch: pytest.MonkeyPatch, - *, - return_artifacts: SkillCreationArtifacts, - side_effect: Exception | None = None, -) -> AsyncMock: - """Replace auto_create.auto_create_skill with an AsyncMock returning the desired result.""" - mock = AsyncMock(return_value=return_artifacts, side_effect=side_effect) - monkeypatch.setattr(auto_create, "auto_create_skill", mock) - return mock +def _make_session_factory(fake_session: Any) -> Any: + def _factory(_x_token: str) -> Any: + return _fake_session_cm(fake_session) + return _factory -def _make_credentials() -> MagicMock: - """Build a mock Credentials with x_token returning 'fresh-x-token'.""" - creds = MagicMock() - creds.x_token = SecretStr("fresh-x-token") - return creds - -# --------------------------------------------------------------------------- -# DeviceCodeSession serialisation -# --------------------------------------------------------------------------- +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) + ) -class TestSerializeDeviceSession: - """JSON round-trip preserves all fields and excludes plaintext from repr.""" - - def test_round_trip(self) -> None: - """Serialise → deserialise yields equal session + epoch.""" - session = _make_device_code_session() - epoch = 1_746_537_600.0 - blob = serialize_device_session(session, epoch) - assert blob is not None - rehydrated = deserialize_device_session(blob) - assert rehydrated is not None - s2, e2 = rehydrated - assert s2.user_code == session.user_code - assert s2.verification_url == session.verification_url - assert s2.expires_in == session.expires_in - assert s2.interval == session.interval - assert s2.device_code.get_secret() == "device-code-secret" - assert e2 == epoch - - def test_serialise_none(self) -> None: - """``None`` session → ``None`` blob.""" - assert serialize_device_session(None, 0.0) is None - - def test_deserialise_empty(self) -> None: - """Empty / None raw → None outcome.""" - assert deserialize_device_session(None) is None - assert deserialize_device_session("") is None - - def test_deserialise_garbage(self) -> None: - """Non-JSON or non-dict input returns None instead of raising.""" - assert deserialize_device_session("not json {{{") is None - assert deserialize_device_session('"a string"') is None - - def test_deserialise_missing_fields(self) -> None: - """Missing required keys → None (corrupt blob is silently dropped).""" - assert deserialize_device_session('{"user_code": "X"}') is None +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 # --------------------------------------------------------------------------- -# Stage 1: Start Device Flow (IDLE click) +# Duplicate pre-check # --------------------------------------------------------------------------- -class TestStartDeviceFlow: - """First click on IDLE: request user_code, persist session blob.""" +class TestPreCheckDuplicate: + """``_pre_check_duplicate`` must be best-effort and case-insensitive.""" @pytest.mark.asyncio - async def test_starts_device_flow_returns_user_code( - self, monkeypatch: pytest.MonkeyPatch - ) -> None: - """Outcome contains user_code + verification_url + serialised session blob.""" - session = _make_device_code_session(user_code="WXYZ-9876") - _patch_passport_client(monkeypatch, start_session=session) - - outcome = await run_auto_create_step( - skill_name="Test", - backend_uri="https://example.test/api/yandex_dialogs/webhook/sec", - description="Test description.", - structured_examples=None, - activation_phrases=None, - cached_x_token=None, - pending_device_session_blob=None, - artifacts=SkillCreationArtifacts(), + 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 - assert outcome.stage == LocalAutoCreateStage.DEVICE_FLOW_STARTED - assert outcome.user_code == "WXYZ-9876" - assert outcome.verification_url == "https://ya.ru/device" - assert outcome.device_session_blob is not None - # The serialised blob round-trips - rehydrated = deserialize_device_session(outcome.device_session_blob) - assert rehydrated is not None - assert rehydrated[0].user_code == "WXYZ-9876" - assert outcome.x_token is None - assert "WXYZ-9876" in outcome.user_message + @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_start_failure_returns_failed(self, monkeypatch: pytest.MonkeyPatch) -> None: - """Network error during start_device_login → FAILED outcome with message.""" - _patch_passport_client(monkeypatch, start_session=RuntimeError("network down")) + 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 - outcome = await run_auto_create_step( - skill_name="Test", - backend_uri="https://example.test/x", - description="d", - structured_examples=None, - activation_phrases=None, - cached_x_token=None, - pending_device_session_blob=None, - artifacts=SkillCreationArtifacts(), + @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 # pragma: no cover + + monkeypatch.setattr( + auto_create, "cached_authenticated_session", lambda x: _raising_factory(x) ) - - assert outcome.stage == LocalAutoCreateStage.FAILED - assert outcome.device_session_blob is None - assert "network down" in (outcome.user_message or "") + result = await auto_create._pre_check_duplicate("tok", "Music Assistant") + assert result is None # --------------------------------------------------------------------------- -# Stage 2: Resume Device Flow (subsequent clicks while DEVICE_FLOW_STARTED) +# run_create_skill — happy path + duplicate + invalid token # --------------------------------------------------------------------------- -class TestResumeDeviceFlow: - """Second-click polling: confirm → run pipeline; or keep waiting.""" +class TestRunCreateSkill: + """End-to-end pipeline through ``run_create_skill``.""" @pytest.mark.asyncio - async def test_confirmed_proceeds_to_pipeline(self, monkeypatch: pytest.MonkeyPatch) -> None: - """Successful poll captures x_token and immediately runs the pipeline.""" - session = _make_device_code_session() - blob = serialize_device_session(session, time.time() + 600) - - _patch_passport_client( - monkeypatch, - poll_outcome=(_make_credentials(), None), - ) - done = SkillCreationArtifacts( - state=SkillCreationState.DONE, - skill_id="skill-uuid-1", - last_known_name="Test", - ) - skill_mock = _patch_auto_create_skill(monkeypatch, return_artifacts=done) - - outcome = await run_auto_create_step( - skill_name="Test", - backend_uri="https://example.test/x", - description="d", + 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, - cached_x_token=None, - pending_device_session_blob=blob, artifacts=SkillCreationArtifacts(), ) - assert outcome.stage == LocalAutoCreateStage.DONE - assert outcome.x_token == "fresh-x-token" - # Device session is dropped after successful auth. - assert outcome.device_session_blob is None - assert outcome.artifacts.skill_id == "skill-uuid-1" - skill_mock.assert_awaited_once() + assert outcome.artifacts.skill_id == "sk-new" @pytest.mark.asyncio - async def test_still_waiting_keeps_session(self, monkeypatch: pytest.MonkeyPatch) -> None: - """DeviceCodeTimeoutError within the local poll window → keep waiting.""" - # expires_at_epoch ~10 minutes in the future, but our window is 8s. - session = _make_device_code_session() - epoch = time.time() + 600 - blob = serialize_device_session(session, epoch) - - _patch_passport_client( + async def test_duplicate_detected_short_circuits(self, monkeypatch: pytest.MonkeyPatch) -> None: + _patch_cached_session(monkeypatch, MagicMock()) + _patch_creator( monkeypatch, - poll_outcome=DeviceCodeTimeoutError("local poll window elapsed"), + list_existing_skills=[{"id": "sk-existing", "appName": "Music Assistant"}], ) - outcome = await run_auto_create_step( - skill_name="Test", - backend_uri="https://example.test/x", - description="d", - structured_examples=None, - activation_phrases=None, - cached_x_token=None, - pending_device_session_blob=blob, - artifacts=SkillCreationArtifacts(), - ) + async def _should_not_run(**_kwargs: Any) -> SkillCreationArtifacts: + raise AssertionError("auto_create_skill should not run when duplicate detected") - assert outcome.stage == LocalAutoCreateStage.DEVICE_FLOW_STARTED - assert outcome.user_code == "ABCD-1234" - # Session blob is preserved for next click - assert outcome.device_session_blob is not None + monkeypatch.setattr(auto_create, "auto_create_skill", _should_not_run) - @pytest.mark.asyncio - async def test_underlying_expiry_returns_failed(self, monkeypatch: pytest.MonkeyPatch) -> None: - """If user_code's actual expiry has passed → FAILED, drop session.""" - session = _make_device_code_session() - # expires_at_epoch in the past - blob = serialize_device_session(session, time.time() - 1.0) - _patch_passport_client(monkeypatch) - - outcome = await run_auto_create_step( - skill_name="Test", - backend_uri="https://example.test/x", + 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, - cached_x_token=None, - pending_device_session_blob=blob, artifacts=SkillCreationArtifacts(), ) - - assert outcome.stage == LocalAutoCreateStage.FAILED - assert outcome.device_session_blob is None - assert "expired" in (outcome.user_message or "") + 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_returns_failed( + async def test_invalid_credentials_clears_x_token( self, monkeypatch: pytest.MonkeyPatch ) -> None: - """InvalidCredentialsError during poll → FAILED + drop session.""" - session = _make_device_code_session() - blob = serialize_device_session(session, time.time() + 600) - _patch_passport_client( - monkeypatch, - poll_outcome=InvalidCredentialsError("auth cancelled"), - ) + _patch_cached_session(monkeypatch, MagicMock()) + _patch_creator(monkeypatch, list_existing_skills=[]) - outcome = await run_auto_create_step( - skill_name="Test", - backend_uri="https://example.test/x", - description="d", - structured_examples=None, - activation_phrases=None, - cached_x_token=None, - pending_device_session_blob=blob, - artifacts=SkillCreationArtifacts(), - ) - - assert outcome.stage == LocalAutoCreateStage.FAILED - assert outcome.device_session_blob is None - msg = (outcome.user_message or "").lower() - assert "rejected" in msg or "cancelled" in msg - - -# --------------------------------------------------------------------------- -# Stage 3: Run pipeline with cached x_token -# --------------------------------------------------------------------------- - - -class TestRunPipeline: - """Post-auth click runs the pipeline end-to-end on cached cookies.""" + async def _raise_invalid(**_kwargs: Any) -> SkillCreationArtifacts: + raise InvalidCredentialsError("expired") - @pytest.mark.asyncio - async def test_happy_path(self, monkeypatch: pytest.MonkeyPatch) -> None: - """auto_create_skill returns DONE → outcome stage=DONE with skill_id link.""" - done = SkillCreationArtifacts( - state=SkillCreationState.DONE, - skill_id="skill-uuid-2", - last_known_name="Test", - ) - _patch_auto_create_skill(monkeypatch, return_artifacts=done) + monkeypatch.setattr(auto_create, "auto_create_skill", _raise_invalid) - outcome = await run_auto_create_step( - skill_name="Test", - backend_uri="https://example.test/x", + 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, - cached_x_token="cached-token", - pending_device_session_blob=None, artifacts=SkillCreationArtifacts(), ) - - assert outcome.stage == LocalAutoCreateStage.DONE - assert outcome.artifacts.skill_id == "skill-uuid-2" - assert "skill-uuid-2" in outcome.user_message + 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: - """auto_create_skill returns FAILED → outcome stage=FAILED, last_error rendered.""" - failed = SkillCreationArtifacts( - state=SkillCreationState.FAILED, - last_error="Skill name is already taken — pick another", - ) - _patch_auto_create_skill(monkeypatch, return_artifacts=failed) + _patch_cached_session(monkeypatch, MagicMock()) + _patch_creator(monkeypatch, list_existing_skills=[]) - outcome = await run_auto_create_step( - skill_name="Test", - backend_uri="https://example.test/x", - description="d", - structured_examples=None, - activation_phrases=None, - cached_x_token="cached-token", - pending_device_session_blob=None, - artifacts=SkillCreationArtifacts(), - ) + async def _fail(**_kwargs: Any) -> SkillCreationArtifacts: + return SkillCreationArtifacts( + state=SkillCreationState.FAILED, + last_error="upload_logo: 502 Bad Gateway", + ) - assert outcome.stage == LocalAutoCreateStage.FAILED - assert "already taken" in outcome.user_message + monkeypatch.setattr(auto_create, "auto_create_skill", _fail) - @pytest.mark.asyncio - async def test_passport_invalid_credentials_clears_token( - self, monkeypatch: pytest.MonkeyPatch - ) -> None: - """InvalidCredentialsError from cached refresh → outcome.x_token='' to clear cache.""" - _patch_auto_create_skill( - monkeypatch, - return_artifacts=SkillCreationArtifacts(), # not used - side_effect=InvalidCredentialsError("session expired"), - ) - - outcome = await run_auto_create_step( - skill_name="Test", - backend_uri="https://example.test/x", + 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, - cached_x_token="stale-token", - pending_device_session_blob=None, artifacts=SkillCreationArtifacts(), ) - assert outcome.stage == LocalAutoCreateStage.FAILED - assert outcome.x_token == "" # Signal to dispatcher: clear the cache - assert "expired" in (outcome.user_message or "") + assert "Bad Gateway" in outcome.user_message # --------------------------------------------------------------------------- -# Top-level dispatch decisions +# Adopt / Recreate # --------------------------------------------------------------------------- -class TestRunAutoCreateStepDispatch: - """run_auto_create_step branches by (artifacts.state, x_token, pending session).""" +class TestAdoptExisting: + """``adopt_existing_skill`` skips duplicate-check and create_app.""" @pytest.mark.asyncio - async def test_done_state_is_no_op(self) -> None: - """artifacts.state=DONE → outcome stage=DONE, no network calls.""" - artifacts = SkillCreationArtifacts( - state=SkillCreationState.DONE, - skill_id="existing", - last_known_name="X", - ) - outcome = await run_auto_create_step( - skill_name="X", - backend_uri="https://example.test/x", + 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, - cached_x_token="t", - pending_device_session_blob=None, - artifacts=artifacts, + existing_skill_id="sk-existing", ) assert outcome.stage == LocalAutoCreateStage.DONE - # Artifacts pass through unchanged - assert outcome.artifacts.skill_id == "existing" + 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_pending_session_takes_precedence_over_token( - self, monkeypatch: pytest.MonkeyPatch - ) -> None: - """Even with cached x_token, a pending session means we must finish auth first.""" - session = _make_device_code_session() - blob = serialize_device_session(session, time.time() + 600) - _patch_passport_client( - monkeypatch, - poll_outcome=DeviceCodeTimeoutError("waiting"), + 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, + 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 run_auto_create_step( - skill_name="X", - backend_uri="https://example.test/x", + 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, - cached_x_token="ignored-while-session-pending", - pending_device_session_blob=blob, - artifacts=SkillCreationArtifacts(), + existing_skill_id="sk-old", ) - - # We polled the pending session, not jumped to the pipeline. - assert outcome.stage == LocalAutoCreateStage.DEVICE_FLOW_STARTED + assert outcome.stage == LocalAutoCreateStage.FAILED + assert "boom" in outcome.user_message 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_init_actions.py b/tests/providers/yandex_alice/test_init_actions.py index b05d818e20..1ed335b64d 100644 --- a/tests/providers/yandex_alice/test_init_actions.py +++ b/tests/providers/yandex_alice/test_init_actions.py @@ -32,7 +32,6 @@ CONF_ACTION_RENAME_DIALOG_SKILL, CONF_AUTH_X_TOKEN, CONF_DIALOG_AUTO_CREATE_ARTIFACTS, - CONF_DIALOG_AUTO_CREATE_DEVICE_SESSION, CONF_DIALOG_SKILL_ID, CONF_DIALOG_SKILL_NAME, CONF_EXTERNAL_BASE_URL, @@ -41,16 +40,20 @@ def _make_mass() -> MagicMock: - """Build a MagicMock MA with empty player + playlist enumeration.""" + """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 -@pytest.fixture(autouse=True) -def _stub_playlists(monkeypatch: pytest.MonkeyPatch) -> None: - """Empty playlist options for all tests in this module.""" - monkeypatch.setattr(yandex_alice, "fetch_playlist_options", AsyncMock(return_value=[])) +# 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]: @@ -67,11 +70,16 @@ class TestDefaultForm: """No action: form has both auto-create button and (conditionally) rename.""" @pytest.mark.asyncio - async def test_no_action_renders_auto_create_button(self) -> None: - """Auto-create ACTION entry is always present.""" + 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) - assert CONF_ACTION_AUTO_CREATE_DIALOG in keys + # 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: @@ -80,21 +88,8 @@ async def test_rename_hidden_without_skill_id_or_token(self) -> None: keys = _entries_by_key(entries) assert CONF_ACTION_RENAME_DIALOG_SKILL not in keys - @pytest.mark.asyncio - async def test_rename_visible_when_skill_id_and_token_present(self) -> None: - """Both skill_id and cached x_token populate the form → rename shows up.""" - artifacts = SkillCreationArtifacts( - state=SkillCreationState.DONE, - skill_id="sk-1", - last_known_name="My Skill", - ) - values: dict[str, Any] = { - CONF_AUTH_X_TOKEN: "tok", - CONF_DIALOG_AUTO_CREATE_ARTIFACTS: dump_artifacts(artifacts), - } - entries = await get_config_entries(_make_mass(), values=values) - keys = _entries_by_key(entries) - assert CONF_ACTION_RENAME_DIALOG_SKILL 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. # --------------------------------------------------------------------------- @@ -103,27 +98,25 @@ async def test_rename_visible_when_skill_id_and_token_present(self) -> None: class TestAutoCreateAction: - """auto-create dispatch: invokes run_auto_create_step with derived inputs.""" + """auto-create dispatch: invokes run_create_skill (Step 2) with derived inputs.""" @pytest.mark.asyncio - async def test_invokes_run_auto_create_step(self, monkeypatch: pytest.MonkeyPatch) -> None: - """Click → run_auto_create_step is awaited once with skill_name + backend_uri.""" + 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(), - device_session_blob='{"user_code": "X"}', x_token=None, - user_code="X", - verification_url="https://ya.ru/device", user_message="started", - stage=LocalAutoCreateStage.DEVICE_FLOW_STARTED, + stage=LocalAutoCreateStage.PIPELINE_RUNNING, ) step_mock = AsyncMock(return_value=outcome) - monkeypatch.setattr(yandex_alice, "run_auto_create_step", step_mock) + 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(), @@ -141,13 +134,14 @@ async def test_invokes_run_auto_create_step(self, monkeypatch: pytest.MonkeyPatc @pytest.mark.asyncio async def test_https_required_short_circuits(self, monkeypatch: pytest.MonkeyPatch) -> None: - """http:// base URL → FAILED before run_auto_create_step is called.""" + """http:// base URL → FAILED before run_create_skill is called.""" step_mock = AsyncMock() - monkeypatch.setattr(yandex_alice, "run_auto_create_step", step_mock) + 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(), @@ -172,15 +166,12 @@ async def _capture(**kwargs: Any) -> AutoCreateOutcome: captured_artifacts.append(kwargs["artifacts"]) return AutoCreateOutcome( artifacts=SkillCreationArtifacts(), - device_session_blob=None, x_token=None, - user_code=None, - verification_url=None, user_message="restart", stage=LocalAutoCreateStage.IDLE, ) - monkeypatch.setattr(yandex_alice, "run_auto_create_step", _capture) + monkeypatch.setattr(yandex_alice, "run_create_skill", _capture) done = SkillCreationArtifacts( state=SkillCreationState.DONE, @@ -190,6 +181,7 @@ async def _capture(**kwargs: Any) -> AutoCreateOutcome: 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(), @@ -210,23 +202,22 @@ async def test_writes_skill_id_on_done(self, monkeypatch: pytest.MonkeyPatch) -> skill_id="sk-new-uuid", last_known_name="MA", ), - device_session_blob=None, - x_token="fresh", - user_code=None, - verification_url=None, + x_token=None, user_message="✅", stage=LocalAutoCreateStage.DONE, ) - monkeypatch.setattr(yandex_alice, "run_auto_create_step", AsyncMock(return_value=outcome)) + monkeypatch.setattr(yandex_alice, "run_create_skill", AsyncMock(return_value=outcome)) - values: dict[str, Any] = {CONF_EXTERNAL_BASE_URL: "https://ma.example.com"} + 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" - assert values[CONF_AUTH_X_TOKEN] == "fresh" @pytest.mark.asyncio async def test_backup_restore_pre_sets_app_created( @@ -239,15 +230,12 @@ async def _capture(**kwargs: Any) -> AutoCreateOutcome: captured_artifacts.append(kwargs["artifacts"]) return AutoCreateOutcome( artifacts=kwargs["artifacts"], - device_session_blob=None, x_token=None, - user_code=None, - verification_url=None, user_message="stub", stage=LocalAutoCreateStage.PIPELINE_RUNNING, ) - monkeypatch.setattr(yandex_alice, "run_auto_create_step", _capture) + monkeypatch.setattr(yandex_alice, "run_create_skill", _capture) # Empty artifacts but skill_id present (config restored from backup) values: dict[str, Any] = { @@ -347,13 +335,12 @@ async def test_token_cleared_on_auth_failure(self, monkeypatch: pytest.MonkeyPat class TestCancelAction: - """Cancel: drop pending session + reset artifacts; keep cached x_token.""" + """Cancel: reset artifacts; keep cached x_token (sign-in stays valid).""" @pytest.mark.asyncio - async def test_resets_artifacts_and_session(self) -> None: - """Cancel clears CONF_DIALOG_AUTO_CREATE_DEVICE_SESSION + resets artifacts.""" + async def test_resets_artifacts(self) -> None: + """Cancel resets artifacts to NONE; cached x_token preserved.""" values: dict[str, Any] = { - CONF_DIALOG_AUTO_CREATE_DEVICE_SESSION: '{"user_code": "X"}', CONF_DIALOG_AUTO_CREATE_ARTIFACTS: dump_artifacts( SkillCreationArtifacts( state=SkillCreationState.APP_CREATED, @@ -375,8 +362,6 @@ async def test_resets_artifacts_and_session(self) -> None: rehydrated = load_artifacts(str(values[CONF_DIALOG_AUTO_CREATE_ARTIFACTS])) assert rehydrated.state == SkillCreationState.NONE assert rehydrated.skill_id is None - # Session dropped - assert values[CONF_DIALOG_AUTO_CREATE_DEVICE_SESSION] == "" # Token preserved assert values[CONF_AUTH_X_TOKEN] == "preserve-me" @@ -405,18 +390,18 @@ async def _capture(**kwargs: Any) -> AutoCreateOutcome: captured_uris.append(kwargs["backend_uri"]) return AutoCreateOutcome( artifacts=SkillCreationArtifacts(), - device_session_blob=None, x_token=None, - user_code=None, - verification_url=None, user_message="ok", stage=LocalAutoCreateStage.IDLE, ) - monkeypatch.setattr(yandex_alice, "run_auto_create_step", _capture) + 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"} + 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, @@ -448,8 +433,15 @@ class TestDeriveStageRespectsCachedToken: """ @pytest.mark.asyncio - async def test_intermediate_state_without_token_renders_create_label(self) -> None: - """artifacts=APP_CREATED + no x_token → auto-create button says 'Create skill'.""" + 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", @@ -460,8 +452,8 @@ async def test_intermediate_state_without_token_renders_create_label(self) -> No } entries = await get_config_entries(_make_mass(), values=values) keys = _entries_by_key(entries) - action_entry = keys[CONF_ACTION_AUTO_CREATE_DIALOG] - assert action_entry.action_label == "Create skill" + 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: @@ -476,33 +468,11 @@ async def test_intermediate_state_with_token_renders_resume_label(self) -> None: } entries = await get_config_entries(_make_mass(), values=values) keys = _entries_by_key(entries) - assert keys[CONF_ACTION_AUTO_CREATE_DIALOG].action_label == "Resume" - - -class TestDeviceFlowStartedHintOnReload: - """LABEL re-shows user_code + URL after a form reload mid-Device-Flow.""" + # v1.2.0 #19: PIPELINE_RUNNING button = "Continue setup" + assert keys[CONF_ACTION_AUTO_CREATE_DIALOG].action_label == "Continue setup" - @pytest.mark.asyncio - async def test_label_renders_user_code_from_persisted_session(self) -> None: - """device_session_blob in values → status LABEL shows the code + URL.""" - import json - - device_session = json.dumps( - { - "device_code": "secret", - "user_code": "WXYZ-1234", - "verification_url": "https://ya.ru/device", - "expires_in": 600, - "interval": 5, - "expires_at_epoch": 9999999999.0, - } - ) - values: dict[str, Any] = {CONF_DIALOG_AUTO_CREATE_DEVICE_SESSION: device_session} - entries = await get_config_entries(_make_mass(), values=values) - keys = _entries_by_key(entries) - # Status LABEL is rendered with the code + URL inline. - assert "label_auto_create_status" in keys - status_label = keys["label_auto_create_status"].label - assert "WXYZ-1234" in status_label - assert "ya.ru/device" in status_label +# 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_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() From b6a01358da66309c373e218a86faf743f38f95b4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 7 May 2026 14:55:14 +0000 Subject: [PATCH 06/10] feat(yandex_alice): sync provider from ma-provider-yandex-alice v1.2.1 --- music_assistant/providers/yandex_alice/auth_page.py | 2 +- tests/providers/yandex_alice/test_auto_create.py | 9 +++------ 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/music_assistant/providers/yandex_alice/auth_page.py b/music_assistant/providers/yandex_alice/auth_page.py index 758d12fbd7..76d9c6beb1 100644 --- a/music_assistant/providers/yandex_alice/auth_page.py +++ b/music_assistant/providers/yandex_alice/auth_page.py @@ -49,8 +49,8 @@ # 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] -"""Callable returning the current device-flow state for status polls.""" __all__ = [ "DEVICE_CODE_PAGE_BASE_PATH", diff --git a/tests/providers/yandex_alice/test_auto_create.py b/tests/providers/yandex_alice/test_auto_create.py index 2f89a7a6f5..44c058c900 100644 --- a/tests/providers/yandex_alice/test_auto_create.py +++ b/tests/providers/yandex_alice/test_auto_create.py @@ -7,8 +7,7 @@ helpers. """ -# ruff: noqa: D102, PLW0108 # tests don't need per-method docstrings; -# the lambda-around-async-context-manager-factory is intentional +# ruff: noqa: D102 # tests don't need per-method docstrings. from __future__ import annotations @@ -123,11 +122,9 @@ 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 # pragma: no cover + yield # type: ignore[unreachable] # pragma: no cover - monkeypatch.setattr( - auto_create, "cached_authenticated_session", lambda x: _raising_factory(x) - ) + monkeypatch.setattr(auto_create, "cached_authenticated_session", _raising_factory) result = await auto_create._pre_check_duplicate("tok", "Music Assistant") assert result is None From 4078e2ceb2a426405a89dfc2a50b6b58a21f0778 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 7 May 2026 15:49:43 +0000 Subject: [PATCH 07/10] feat(yandex_alice): sync provider from ma-provider-yandex-alice v1.2.3 --- .../providers/yandex_alice/auth_page.py | 15 ++++++------- .../yandex_alice/dialog_skill_meta.py | 21 +++++++++++++------ .../providers/yandex_alice/webhook_probe.py | 18 +++++++++++++--- 3 files changed, 38 insertions(+), 16 deletions(-) diff --git a/music_assistant/providers/yandex_alice/auth_page.py b/music_assistant/providers/yandex_alice/auth_page.py index 76d9c6beb1..2ef12eb32f 100644 --- a/music_assistant/providers/yandex_alice/auth_page.py +++ b/music_assistant/providers/yandex_alice/auth_page.py @@ -253,12 +253,13 @@ def register_device_code_route( page_path = _device_code_page_path(session_id) status_path = _device_code_status_path(session_id) - # Both URLs returned to the FE are *path-relative* — that lets the - # browser resolve them against whatever origin the user is hitting - # MA on (e.g. `https://ma.example.com`, `http://localhost:8095`), - # not against ``webserver.base_url`` which can resolve to the docker - # bridge IP and break the popup link. - status_url = status_path + # 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, @@ -302,7 +303,7 @@ async def _serve_status(_request: web.Request) -> web.Response: _LOGGER.warning("auth_page: failed to register device-code route: %r", exc) return "" - return page_path + return page_url def unregister_device_code_route(mass: MusicAssistant, *, session_id: str) -> None: diff --git a/music_assistant/providers/yandex_alice/dialog_skill_meta.py b/music_assistant/providers/yandex_alice/dialog_skill_meta.py index 289735e389..b038e274ba 100644 --- a/music_assistant/providers/yandex_alice/dialog_skill_meta.py +++ b/music_assistant/providers/yandex_alice/dialog_skill_meta.py @@ -10,6 +10,7 @@ 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: @@ -58,19 +59,27 @@ def validate_activation_phrase(value: object) -> bool: def build_backend_uri(base_url: str, webhook_secret: str) -> str: """Compose the public webhook URL Yandex must call. - Yandex requires HTTPS — the dev-console rejects plain http:// at draft-update - time, but we surface the rejection up-front so the user sees a clear error - before the Device Flow even starts. + 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 HTTPS, or ``webhook_secret`` is empty. + 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 base.lower().startswith("https://"): - msg = f"External base URL must use HTTPS (got: {base!r})" + 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: diff --git a/music_assistant/providers/yandex_alice/webhook_probe.py b/music_assistant/providers/yandex_alice/webhook_probe.py index 6f27b7dac5..31fb4b1131 100644 --- a/music_assistant/providers/yandex_alice/webhook_probe.py +++ b/music_assistant/providers/yandex_alice/webhook_probe.py @@ -24,6 +24,7 @@ import aiohttp from .constants import DIALOG_WEBHOOK_BASE_PATH +from .url_helpers import is_public_https_url __all__ = ["probe_webhook_reachability"] @@ -53,12 +54,23 @@ def _build_test_envelope() -> dict[str, object]: def _validate_inputs(base_url: str, webhook_secret: str) -> tuple[bool, str] | None: - """Pre-network sanity check; returns failure tuple or None to continue.""" + """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 base.lower().startswith("https://"): - return False, f"External base URL must use HTTPS (got: {base!r})." + 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 From ebd0f948f20831d16ed22d19cea4bde3d8813d25 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 7 May 2026 20:51:35 +0000 Subject: [PATCH 08/10] feat(yandex_alice): sync provider from ma-provider-yandex-alice v1.3.0 --- .../providers/yandex_alice/auth_page.py | 5 +- .../providers/yandex_alice/auth_session.py | 3 +- .../providers/yandex_alice/auto_create.py | 2 + .../providers/yandex_alice/auto_update.py | 2 + .../providers/yandex_alice/constants.py | 10 + .../yandex_alice/dialog_skill_meta.py | 5 +- .../providers/yandex_alice/dialogs.py | 366 ++++++++++++-- .../providers/yandex_alice/dialogs_control.py | 136 ++++- .../providers/yandex_alice/dialogs_grammar.py | 266 ++++++++++ .../providers/yandex_alice/dialogs_nlu.py | 6 +- .../providers/yandex_alice/manifest.json | 2 +- .../providers/yandex_alice/plugin.py | 7 + .../providers/yandex_alice/tts_dictionary.py | 98 ++++ requirements_all.txt | 2 +- tests/providers/yandex_alice/test_dialogs.py | 466 +++++++++++++++++- .../yandex_alice/test_dialogs_control.py | 157 ++++++ 16 files changed, 1459 insertions(+), 74 deletions(-) create mode 100644 music_assistant/providers/yandex_alice/dialogs_grammar.py create mode 100644 music_assistant/providers/yandex_alice/tts_dictionary.py diff --git a/music_assistant/providers/yandex_alice/auth_page.py b/music_assistant/providers/yandex_alice/auth_page.py index 2ef12eb32f..886e4f5f83 100644 --- a/music_assistant/providers/yandex_alice/auth_page.py +++ b/music_assistant/providers/yandex_alice/auth_page.py @@ -346,9 +346,8 @@ async def perform_device_auth( 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. + :raises LoginFailed: the Device Flow timed out, was rejected by + Yandex, or another Passport-level error escaped. """ if not session_id: raise LoginFailed( diff --git a/music_assistant/providers/yandex_alice/auth_session.py b/music_assistant/providers/yandex_alice/auth_session.py index 83be0f5edc..a1ddd99018 100644 --- a/music_assistant/providers/yandex_alice/auth_session.py +++ b/music_assistant/providers/yandex_alice/auth_session.py @@ -48,8 +48,7 @@ async def cached_authenticated_session(x_token: str) -> AsyncIterator[aiohttp.Cl ``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. + :raises ValueError: ``x_token`` is empty. """ if not x_token: msg = "x_token is empty — cached authenticator requires an existing token" diff --git a/music_assistant/providers/yandex_alice/auto_create.py b/music_assistant/providers/yandex_alice/auto_create.py index 168ba5608d..231c25d375 100644 --- a/music_assistant/providers/yandex_alice/auto_create.py +++ b/music_assistant/providers/yandex_alice/auto_create.py @@ -50,6 +50,7 @@ from .auth_session import cached_authenticated_session, make_cached_authenticator from .constants import DIALOG_CHANNEL +from .dialogs_grammar import build_grammar from .skill_logo import load_skill_logo_bytes if TYPE_CHECKING: @@ -258,6 +259,7 @@ async def _run_pipeline( description=description, structured_examples=structured_examples, activation_phrases=activation_phrases, + intents=build_grammar(), logo_bytes=load_skill_logo_bytes(), creator_factory=_make_logging_creator_factory(), ) diff --git a/music_assistant/providers/yandex_alice/auto_update.py b/music_assistant/providers/yandex_alice/auto_update.py index 77b0f6f103..a592f15857 100644 --- a/music_assistant/providers/yandex_alice/auto_update.py +++ b/music_assistant/providers/yandex_alice/auto_update.py @@ -27,6 +27,7 @@ from .auth_session import make_cached_authenticator from .constants import DIALOG_CHANNEL +from .dialogs_grammar import build_grammar _LOGGER = logging.getLogger(__name__) @@ -110,6 +111,7 @@ async def run_auto_update( description=description, structured_examples=structured_examples, activation_phrases=activation_phrases, + intents=build_grammar(), voice=voice, ) except InvalidCredentialsError as exc: diff --git a/music_assistant/providers/yandex_alice/constants.py b/music_assistant/providers/yandex_alice/constants.py index dab2858635..7f738a1b83 100644 --- a/music_assistant/providers/yandex_alice/constants.py +++ b/music_assistant/providers/yandex_alice/constants.py @@ -111,6 +111,16 @@ # 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 diff --git a/music_assistant/providers/yandex_alice/dialog_skill_meta.py b/music_assistant/providers/yandex_alice/dialog_skill_meta.py index b038e274ba..3226edbf61 100644 --- a/music_assistant/providers/yandex_alice/dialog_skill_meta.py +++ b/music_assistant/providers/yandex_alice/dialog_skill_meta.py @@ -66,9 +66,8 @@ def build_backend_uri(base_url: str, webhook_secret: str) -> str: 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. + :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: diff --git a/music_assistant/providers/yandex_alice/dialogs.py b/music_assistant/providers/yandex_alice/dialogs.py index f0eea5c021..6ed88bdf7c 100644 --- a/music_assistant/providers/yandex_alice/dialogs.py +++ b/music_assistant/providers/yandex_alice/dialogs.py @@ -59,11 +59,13 @@ DIALOG_WEBHOOK_BASE_PATH, ) from .dialogs_control import ( + ParsedControl, control_confirmation, execute_control, format_list_players, parse_control, ) +from .dialogs_grammar import parse_platform_intent from .dialogs_nlu import ( _VERB_RE, ParsedCommand, @@ -73,6 +75,7 @@ resolve_player_candidates, ) from .dialogs_player import play_for_alice, resolve_query +from .tts_dictionary import PHRASE_REPLACEMENTS, WORD_REPLACEMENTS if TYPE_CHECKING: from music_assistant.mass import MusicAssistant @@ -81,41 +84,51 @@ _LOGGER = logging.getLogger(__name__) -# Static stress-mark dictionary for common response words (P0.2). -# Keys are case-insensitive whole-word matches; the marker is `+` placed -# directly before the stressed vowel — Yandex Alice TTS supports this -# inline syntax. Keep small and high-confidence; band/track names are -# left as-is (those need a separate phoneme dict — P2.3). -_TTS_STRESS_MARKS: dict[str, str] = { - "включаю": "включ+аю", - "ставлю": "ст+авлю", - "пауза": "п+ауза", - "продолжаю": "продолж+аю", - "следующая": "сл+едующая", - "предыдущая": "пред+ыдущая", - "громче": "гр+омче", - "тише": "т+ише", - "громкость": "гр+омкость", - "колонке": "кол+онке", - "колонку": "кол+онку", -} - -_TTS_WORD_RE = re.compile(r"[А-Яа-яЁё]+") +# 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 to known words for cleaner Alice TTS. - - Pure substitution — unknown words pass through unchanged. The map is - intentionally small (high-confidence Russian response words only); - expand via PRs as patterns emerge. + """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 = _TTS_STRESS_MARKS.get(word.lower()) + replacement = WORD_REPLACEMENTS.get(word.lower()) if replacement is None: return word if word[:1].isupper(): @@ -130,6 +143,38 @@ def _safe_dict(value: Any) -> dict[str, Any]: 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. @@ -222,23 +267,30 @@ def __init__( 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. - Args: - mass: MusicAssistant instance. - skill_id: Configured ``CONF_DIALOG_SKILL_ID``; payloads with a - different ``session.skill_id`` are rejected. - webhook_secret: Random secret embedded in the webhook URL. - exposed_player_ids: Optional restriction set; only these players - are addressable by voice (passed to the player resolver). - logger: Optional logger override. + :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]] = [] # In-process state cache; see _STATE_CACHE_TTL_SEC / _MAX. @@ -354,7 +406,7 @@ def unregister_routes(self) -> None: # Webhook entry point # ------------------------------------------------------------------- - async def _handle_webhook(self, request: web.Request) -> web.Response: # noqa: PLR0915 + 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. @@ -389,6 +441,10 @@ async def _handle_webhook(self, request: web.Request) -> web.Response: # noqa: 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 "") @@ -406,6 +462,51 @@ async def _handle_webhook(self, request: web.Request) -> web.Response: # noqa: # 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. @@ -431,6 +532,17 @@ async def _handle_webhook(self, request: web.Request) -> web.Response: # noqa: 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 → @@ -453,11 +565,30 @@ async def _handle_webhook(self, request: web.Request) -> web.Response: # noqa: # 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 req_type=%s is_new=%s pending=%s " + "Webhook recv: cmd=%r%s req_type=%s is_new=%s pending=%s " "(session=%s app=%s cache=%s) awaiting=%s default_player=%s " - "session_id=%s", - command, + "dangerous=%s session_id=%s", + cmd_for_log, + raw_suffix, req.get("type", "SimpleUtterance"), is_new, bool(pending_in), @@ -466,6 +597,7 @@ async def _handle_webhook(self, request: web.Request) -> web.Response: # noqa: bool(cached_state.get("pending_command")), awaiting_in, default_id, + dangerous_context, session.get("session_id", ""), ) @@ -489,6 +621,24 @@ async def _handle_webhook(self, request: web.Request) -> web.Response: # noqa: 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 @@ -496,7 +646,88 @@ async def _handle_webhook(self, request: web.Request) -> web.Response: # noqa: # 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. - if control := parse_control(command): + # `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 = parse_platform_intent(nlu_intents) + if isinstance(platform, ParsedControl): + 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, @@ -504,6 +735,7 @@ async def _handle_webhook(self, request: web.Request) -> web.Response: # noqa: 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 "Что @@ -555,6 +787,7 @@ async def _handle_webhook(self, request: web.Request) -> web.Response: # noqa: 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 @@ -570,6 +803,7 @@ async def _handle_webhook(self, request: web.Request) -> web.Response: # noqa: default_id=default_id, session_state_in=session_state_in, app_state_in=app_state_in, + has_screen=has_screen, ) # ------------------------------------------------------------------- @@ -584,6 +818,7 @@ async def _dispatch_play( 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. @@ -650,6 +885,7 @@ async def _dispatch_play( 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( @@ -677,6 +913,7 @@ async def _dispatch_play( candidates=candidates, session_state_in=session_state_in, app_state_in=app_state_in, + has_screen=has_screen, ) self._logger.debug( @@ -690,6 +927,7 @@ async def _dispatch_play( player=candidates[0], base_session_state=session_state_in, base_app_state=app_state_in, + has_screen=has_screen, ) # ------------------------------------------------------------------- @@ -704,6 +942,7 @@ def _handle_control( # noqa: PLR0915 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. @@ -927,13 +1166,19 @@ def _handle_control( # noqa: PLR0915 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, ) # ------------------------------------------------------------------- @@ -948,6 +1193,7 @@ async def _play_with_player( 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: @@ -1020,9 +1266,11 @@ async def _play_with_player( 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, ) # ------------------------------------------------------------------- @@ -1037,6 +1285,7 @@ def _build_disambiguation_response( 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. @@ -1044,8 +1293,8 @@ def _build_disambiguation_response( 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 kept on the response for screen surfaces, but voice - is the primary channel. + 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] @@ -1057,14 +1306,18 @@ def _build_disambiguation_response( # маленькая. Скажи название или номер." labelled = [f"{_ORDINAL_LABELS[i]} — {name}" for i, name in enumerate(names)] text = "На какой колонке? " + ", ".join(labelled) + ". Скажи название или номер." - buttons = [ - { - "title": (p.name or p.player_id)[:64], - "payload": {"player_id": p.player_id}, - "hide": True, - } - for p in capped - ] + 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 @@ -1119,6 +1372,7 @@ async def _try_resume_pending( 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. @@ -1199,6 +1453,7 @@ async def _try_resume_pending( candidates=candidates, session_state_in=session_state_in, app_state_in=app_state_in, + has_screen=has_screen, ) # Step 3 — voice ordinal ("первая", "выбираю первую", "номер @@ -1248,6 +1503,7 @@ async def _try_resume_pending( 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. @@ -1266,6 +1522,7 @@ async def _try_resume_pending( player=chosen_player, base_session_state=session_state_in, base_app_state=app_state_in, + has_screen=has_screen, ) # ------------------------------------------------------------------- @@ -1283,6 +1540,7 @@ def _yandex_response( 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. @@ -1291,6 +1549,14 @@ def _yandex_response( 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 @@ -1319,6 +1585,8 @@ def _yandex_response( } if buttons: response_body["buttons"] = buttons + if card: + response_body["card"] = card payload: dict[str, Any] = { "version": "1.0", "session": echoed, diff --git a/music_assistant/providers/yandex_alice/dialogs_control.py b/music_assistant/providers/yandex_alice/dialogs_control.py index f3d7c0e338..905021d8ab 100644 --- a/music_assistant/providers/yandex_alice/dialogs_control.py +++ b/music_assistant/providers/yandex_alice/dialogs_control.py @@ -34,6 +34,7 @@ "volume_up", "volume_down", "volume_set", + "volume_relative", # value = signed delta (+20, -5); executor reads current vol + clamps "mute", "unmute", "list_players", @@ -179,6 +180,58 @@ class ParsedControl: re.IGNORECASE, ) +# Relative-volume phrasings without the keyword "громкость". The verb is +# matched even when no digit is captured — the digit slot is filled from +# the regex group OR from `request.nlu.entities[YANDEX.NUMBER]` (passed +# in via `parse_control(text, entities=...)`). When neither yields a +# number, these patterns intentionally fall through to the bare +# "прибавь"/"убавь" → volume_up/volume_down rules in `_CONTROL_PATTERNS`. +# Yandex normalises spelled-out numbers in `request.command` (тридцать → 30) +# so the regex covers most phrasings; the entity is the defensive fallback. +_VOLUME_INC_RE = re.compile( + r"^(?:сделай\s+)?(?:прибавь(?:те)?|прибавить)" + r"(?:\s+(?:на\s+)?(?P\d{1,3})(?:\s+процентов)?)?$", + re.IGNORECASE, +) +_VOLUME_DEC_RE = re.compile( + r"^(?:сделай\s+)?(?:убавь(?:те)?|убавить)" + r"(?:\s+(?:на\s+)?(?P\d{1,3})(?:\s+процентов)?)?$", + re.IGNORECASE, +) +_VOLUME_NUM_INC_RE = re.compile( + r"^на\s+(?P\d{1,3})\s+(?:громче|погромче)$", + re.IGNORECASE, +) +_VOLUME_NUM_DEC_RE = re.compile( + r"^на\s+(?P\d{1,3})\s+(?:тише|потише)$", + re.IGNORECASE, +) + + +def _yandex_number(entities: list[Any] | None) -> int | None: + """Return the first integer YANDEX.NUMBER value from request entities, or None. + + Yandex's normalised `request.command` already converts most spelled-out + Russian numbers to digits, but the entity is the authoritative fallback + for phrasings the regex didn't anchor on a digit position. The list + type is `list[Any]` because the values come from network JSON and we + defend against mixed-type elements. + """ + if not entities: + return None + for ent in entities: + if not isinstance(ent, dict): + continue + if ent.get("type") != "YANDEX.NUMBER": + continue + value = ent.get("value") + if isinstance(value, bool): # bool is a subclass of int — exclude it + continue + if isinstance(value, (int, float)): + return int(value) + return None + + # Seek forward / backward with numeric amount + optional unit. Unit defaults # to seconds when missing. "Минут[уы]" multiplies by 60. _SEEK_FORWARD_RE = re.compile( @@ -214,8 +267,17 @@ def _seek_seconds(match: re.Match[str]) -> int | None: return n -def _try_match(cleaned: str, player_hint: str | None) -> ParsedControl | None: - """Match `cleaned` against control patterns; return ParsedControl or None.""" +def _try_match( + cleaned: str, + player_hint: str | None, + entities: list[Any] | None = None, +) -> ParsedControl | None: + """Match `cleaned` against control patterns; return ParsedControl or None. + + `entities` is `request.nlu.entities` from the Yandex envelope. When a + relative-volume verb matches without a captured digit, we fall back to + `YANDEX.NUMBER` from there before deciding to surface `volume_relative`. + """ if not cleaned: return None if vmatch := _VOLUME_SET_RE.match(cleaned): @@ -228,6 +290,40 @@ def _try_match(cleaned: str, player_hint: str | None) -> ParsedControl | None: value=max(0, min(100, value)), player_hint=player_hint, ) + # Relative-volume — try INCREASE forms ("прибавь N", "на N громче"), + # then DECREASE ("убавь N", "на N тише"). When the verb matches but + # the digit slot is empty, fall back to YANDEX.NUMBER from the + # request envelope. If neither yields a number, return None so the + # bare-verb fallthrough in `_CONTROL_PATTERNS` handles "прибавь" / + # "убавь" as volume_up / volume_down. + for pattern, sign in ( + (_VOLUME_INC_RE, +1), + (_VOLUME_NUM_INC_RE, +1), + (_VOLUME_DEC_RE, -1), + (_VOLUME_NUM_DEC_RE, -1), + ): + if rel_match := pattern.match(cleaned): + n: int | None = None + try: + raw = rel_match.group("n") + if raw is not None: + n = int(raw) + except (IndexError, TypeError, ValueError): + n = None + if n is None: + n = _yandex_number(entities) + if n is not None: + # Clamp the magnitude so an absurd "прибавь на 999" doesn't + # underflow/overflow downstream arithmetic. ``0`` stays + # ``0`` — "прибавь на 0" is a valid (if pointless) no-op + # rather than the user's spoken zero being silently + # promoted to one. + magnitude = max(0, min(100, abs(n))) + return ParsedControl( + action="volume_relative", + value=sign * magnitude, + player_hint=player_hint, + ) if smatch := _SEEK_FORWARD_RE.match(cleaned): seconds = _seek_seconds(smatch) if seconds is not None: @@ -254,7 +350,10 @@ def _try_match(cleaned: str, player_hint: str | None) -> ParsedControl | None: _NA_BOUNDARY_RE = re.compile(r"\s+на\s+", re.IGNORECASE) -def parse_control(text: str) -> ParsedControl | None: +def parse_control( + text: str, + entities: list[Any] | None = None, +) -> ParsedControl | None: """Classify a voice utterance as a control command, or None to fall through. Tries each `на`-boundary in the cleaned text as a possible @@ -262,6 +361,10 @@ def parse_control(text: str) -> ParsedControl | None: (cleaned, None) for the whole-phrase case so that "поставь на паузу" still matches `pause` with no hint, even when the phrase contains "на" inside the action keywords. + + `entities` is the (optional) `request.nlu.entities` array from the + Yandex Dialogs envelope, used as a fallback source for `YANDEX.NUMBER` + when a relative-volume verb matched without a captured digit. """ if not text: return None @@ -272,7 +375,7 @@ def parse_control(text: str) -> ParsedControl | None: return None # Whole-phrase first (no hint). - if direct := _try_match(cleaned, player_hint=None): + if direct := _try_match(cleaned, player_hint=None, entities=entities): return direct # Then try each "на " split from right to left, so e.g. @@ -283,7 +386,7 @@ def parse_control(text: str) -> ParsedControl | None: hint = cleaned[m.end() :].strip().lower() if not rest or not hint: continue - if matched := _try_match(rest, player_hint=hint): + if matched := _try_match(rest, player_hint=hint, entities=entities): return matched return None @@ -296,9 +399,8 @@ def parse_control(text: str) -> ParsedControl | None: def _plural_ru(n: int, forms: tuple[str, str, str]) -> str: """Pick the correct Russian quantitative form for `n`. - Args: - n: The number. - forms: ``(form_for_1, form_for_2_to_4, form_for_5_plus)``. + :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. "колонку") @@ -348,6 +450,13 @@ def control_confirmation(control: ParsedControl) -> str: # noqa: PLR0911 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": @@ -412,6 +521,17 @@ async def execute_control( # noqa: PLR0915 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": 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..44bdb7cc2e --- /dev/null +++ b/music_assistant/providers/yandex_alice/dialogs_grammar.py @@ -0,0 +1,266 @@ +# ruff: noqa: RUF001 +"""Yandex Dialogs custom-intent grammars for the Music Assistant skill. + +Each intent here is delivered to Yandex via `ya_dialogs_api.IntentDraft` + +`set_intents()` during skill provisioning. At runtime, when Yandex matches +a user's phrase against one of these grammars, it pre-classifies the +intent and surfaces it in `request.nlu.intents.` — the +webhook handler reads that block first and only falls back to the +in-house regex parsers (`parse_command` / `parse_control`) when the +platform produced no match. + +Design notes: + +* **Conservative baseline.** This module ships a *subset* of our + regex-covered intents. The aim is platform-side coverage of the most + common phrasings, not 1:1 parity. Phrases that don't match here fall + through to the regex parsers (which remain authoritative for the + long tail). +* **`%lemma` directive** matches all morphological forms of the lemma + (e.g. `%lemma включить` covers «включи / включите / включай / + включить / включим»). Applied conservatively to verbs that have + multiple commonly-used forms. +* **Grammar source is server-validated synchronously.** Bad syntax + surfaces as `DialogsIntentValidationError` from `set_intents()`, so + the test suite for this module asserts the fixtures load without + raising once contributed. +* **Positive tests** double as documentation and a self-check — + Yandex's "Протестировать" button in the dev console can run them + individually for visual regression. + +Adding a new intent: append a new entry, regenerate the skill via the +plugin's "Apply skill changes" form action, observe the moderation +cycle complete (minutes to hours for private skills), then exercise the +phrase against a live device. +""" + +from __future__ import annotations + +from typing import Any + +from ya_dialogs_api import IntentDraft + +from .dialogs_control import ParsedControl +from .dialogs_nlu import ParsedCommand + +# --------------------------------------------------------------------------- +# Grammar fragments — control intents +# --------------------------------------------------------------------------- + +_PAUSE_GRAMMAR = """\ +root: + %lemma пауза + поставь на паузу + %lemma останови музыку + на паузу +""" + +_RESUME_GRAMMAR = """\ +root: + %lemma продолжить + %lemma возобновить + включи снова +""" + +_NEXT_GRAMMAR = """\ +root: + %lemma следующая + %lemma следующий трек + %lemma дальше + %lemma переключи +""" + +_PREVIOUS_GRAMMAR = """\ +root: + %lemma предыдущая + %lemma предыдущий трек + %lemma назад + %lemma вернись +""" + +_STOP_GRAMMAR = """\ +root: + %lemma стоп + %lemma останови + %lemma выключи + выключи музыку +""" + +_VOLUME_UP_GRAMMAR = """\ +root: + %lemma громче + сделай громче + %lemma прибавь +""" + +_VOLUME_DOWN_GRAMMAR = """\ +root: + %lemma тише + сделай тише + %lemma убавь +""" + +_SHUFFLE_ON_GRAMMAR = """\ +root: + %lemma перемешай + включи перемешивание + случайный порядок + в случайном порядке +""" + +_SHUFFLE_OFF_GRAMMAR = """\ +root: + выключи перемешивание + не перемешивай + по порядку +""" + +_NOW_PLAYING_GRAMMAR = """\ +root: + что играет + что сейчас играет + что мы слушаем + что за песня + что за трек +""" + + +# --------------------------------------------------------------------------- +# Grammar fragments — play intents +# --------------------------------------------------------------------------- + +_MY_WAVE_GRAMMAR = """\ +root: + %lemma включи мою волну + %lemma включи моё радио + %lemma поставь мою волну + моя волна +""" + + +# --------------------------------------------------------------------------- +# Builder +# --------------------------------------------------------------------------- + + +def build_grammar() -> list[IntentDraft]: + """Return the full list of custom intents to declare on the skill.""" + return [ + IntentDraft( + form_name="control.pause", + human_readable_name="Пауза", + source_text=_PAUSE_GRAMMAR, + positive_tests="пауза\nпоставь на паузу\nостанови музыку\nна паузу", + negative_tests="включи\nследующая", + ), + IntentDraft( + form_name="control.resume", + human_readable_name="Продолжить", + source_text=_RESUME_GRAMMAR, + positive_tests="продолжи\nпродолжить\nвозобнови\nвключи снова", + ), + IntentDraft( + form_name="control.next", + human_readable_name="Следующий трек", + source_text=_NEXT_GRAMMAR, + positive_tests="следующая\nследующий трек\nдальше\nпереключи", + ), + IntentDraft( + form_name="control.previous", + human_readable_name="Предыдущий трек", + source_text=_PREVIOUS_GRAMMAR, + positive_tests="предыдущая\nпредыдущий трек\nназад\nвернись", + ), + IntentDraft( + form_name="control.stop", + human_readable_name="Стоп", + source_text=_STOP_GRAMMAR, + positive_tests="стоп\nостанови\nвыключи\nвыключи музыку", + ), + IntentDraft( + form_name="control.volume_up", + human_readable_name="Громче", + source_text=_VOLUME_UP_GRAMMAR, + positive_tests="громче\nсделай громче\nприбавь", + ), + IntentDraft( + form_name="control.volume_down", + human_readable_name="Тише", + source_text=_VOLUME_DOWN_GRAMMAR, + positive_tests="тише\nсделай тише\nубавь", + ), + IntentDraft( + form_name="control.shuffle_on", + human_readable_name="Включить перемешивание", + source_text=_SHUFFLE_ON_GRAMMAR, + positive_tests="перемешай\nвключи перемешивание\nслучайный порядок", + ), + IntentDraft( + form_name="control.shuffle_off", + human_readable_name="Выключить перемешивание", + source_text=_SHUFFLE_OFF_GRAMMAR, + positive_tests="выключи перемешивание\nне перемешивай\nпо порядку", + ), + IntentDraft( + form_name="control.now_playing", + human_readable_name="Что играет", + source_text=_NOW_PLAYING_GRAMMAR, + positive_tests="что играет\nчто сейчас играет\nчто мы слушаем", + ), + IntentDraft( + form_name="play.my_wave", + human_readable_name="Моя волна", + source_text=_MY_WAVE_GRAMMAR, + positive_tests="включи мою волну\nпоставь мою волну\nвключи моё радио", + ), + ] + + +# --------------------------------------------------------------------------- +# Runtime: map platform-classified intents back to our internal dataclasses +# --------------------------------------------------------------------------- + +# Each grammar above declares an intent under a stable ``form_name``. When +# Yandex matches a user phrase against one of them, the webhook receives +# the intent name in ``request.nlu.intents.`` (with any slot +# values, though our control intents declare none yet). This map keeps the +# runtime mapping in lockstep with the grammars — adding a new intent +# requires touching this dict so misclassification can't sneak through +# unnoticed. +_CONTROL_INTENT_MAP: dict[str, str] = { + "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", +} + + +def parse_platform_intent( + nlu_intents: dict[str, Any] | None, +) -> ParsedControl | ParsedCommand | None: + """Map a ``request.nlu.intents`` block to our dispatcher's dataclass. + + Returns ``None`` when: + * The block is missing / empty (no grammar declared, or no match). + * The matched intent name isn't one we ship a runtime handler for. + + Returns the FIRST recognised intent in iteration order. Yandex doesn't + guarantee a single match — when grammars overlap the platform may + surface several — but for our conservative grammar set the overlap + is engineered out (each phrase pattern lives in exactly one intent). + """ + if not isinstance(nlu_intents, dict) or not nlu_intents: + return None + for form_name in nlu_intents: + action = _CONTROL_INTENT_MAP.get(form_name) + if action is not None: + return ParsedControl(action=action) # type: ignore[arg-type] + if form_name == "play.my_wave": + return ParsedCommand(kind="my_wave", query="", radio_mode=True) + return None diff --git a/music_assistant/providers/yandex_alice/dialogs_nlu.py b/music_assistant/providers/yandex_alice/dialogs_nlu.py index 76d98042cd..6955aeffee 100644 --- a/music_assistant/providers/yandex_alice/dialogs_nlu.py +++ b/music_assistant/providers/yandex_alice/dialogs_nlu.py @@ -349,9 +349,9 @@ def resolve_player_candidates( 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. + :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) diff --git a/music_assistant/providers/yandex_alice/manifest.json b/music_assistant/providers/yandex_alice/manifest.json index b552a4bfcf..8f7d87944e 100644 --- a/music_assistant/providers/yandex_alice/manifest.json +++ b/music_assistant/providers/yandex_alice/manifest.json @@ -9,7 +9,7 @@ ], "requirements": [ "ya-passport-auth==1.3.0", - "ya-dialogs-api>=2.0.0" + "ya-dialogs-api==2.1.0" ], "documentation": "https://github.com/trudenboy/ma-provider-yandex-alice", "stage": "beta", diff --git a/music_assistant/providers/yandex_alice/plugin.py b/music_assistant/providers/yandex_alice/plugin.py index a2a9d28cb5..0189419043 100644 --- a/music_assistant/providers/yandex_alice/plugin.py +++ b/music_assistant/providers/yandex_alice/plugin.py @@ -22,6 +22,7 @@ from .constants import ( CONF_DIALOG_SKILL_ID, + CONF_DIALOG_VOICE_CONTINUATION, CONF_DIALOG_WEBHOOK_SECRET, CONF_EXPOSED_PLAYERS, CONF_INSTANCE_NAME, @@ -44,6 +45,11 @@ async def handle_async_init(self) -> None: 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. @@ -60,6 +66,7 @@ async def loaded_in_mass(self) -> None: 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() 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..b2f8f4c655 --- /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": "стинг", +} + + +# --------------------------------------------------------------------------- +# 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/requirements_all.txt b/requirements_all.txt index 5f75680892..9887c971a6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -88,7 +88,7 @@ uv>=0.8.0 websocket-client==1.9.0 wiim==0.1.4 xmltodict==1.0.4 -ya-dialogs-api>=2.0.0 +ya-dialogs-api==2.1.0 ya-passport-auth==1.3.0 yandex-music==3.0.0 ytmusicapi==1.11.5 diff --git a/tests/providers/yandex_alice/test_dialogs.py b/tests/providers/yandex_alice/test_dialogs.py index bdc3132309..f35882e582 100644 --- a/tests/providers/yandex_alice/test_dialogs.py +++ b/tests/providers/yandex_alice/test_dialogs.py @@ -240,6 +240,412 @@ async def test_full_happy_path_starts_play_media(self) -> None: 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": "пауза на кухне"}, + } + 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(self) -> None: + """Unknown form_name in intents → falls through to parse_control / parse_command.""" + 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": "пауза", + # Unknown intent form_name — regex should pick it up instead. + "nlu": {"intents": {"unknown.intent": {}}}, + }, + } + await handler._handle_webhook(_build_request(body)) + await asyncio.sleep(0) + # Regex parse_control caught "пауза" and dispatched. + mass.player_queues.pause.assert_awaited_once_with("p1") + + async def test_empty_intents_block_falls_back_to_regex(self) -> None: + """Empty `intents={}` (no grammar match) → regex parser still runs.""" + 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": {}}, + }, + } + await handler._handle_webhook(_build_request(body)) + await asyncio.sleep(0) + mass.player_queues.next.assert_awaited_once_with("p1") + + +@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": "пауза на кухне"}, + } + 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": "стоп на кухне"}, + } + 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) @@ -384,9 +790,9 @@ async def test_session_state_preserved_on_player_not_found(self) -> None: class TestTtsHelper: """Tests for _tts_for stress-mark substitution.""" - def test_known_word_gets_stress_mark(self) -> None: - """A known word from the dict has `+` injected before the stressed vowel.""" - assert _tts_for("Включаю Metallica") == "Включ+аю Metallica" + 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.""" @@ -403,6 +809,26 @@ def test_capitalisation_preserved(self) -> None: # 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: @@ -874,7 +1300,7 @@ 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 → response carries buttons + pending_command, end_session=False.""" + """Two candidates on a screened surface → response carries buttons + pending_command.""" track = MagicMock(uri="library://track/1", spec_set=["uri"]) mass = _make_mass( [ @@ -885,6 +1311,7 @@ async def test_multiple_matches_returns_disambiguation_prompt(self) -> None: ) 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 на кухне"}, } @@ -905,6 +1332,33 @@ async def test_multiple_matches_returns_disambiguation_prompt(self) -> None: # 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"]) @@ -1079,6 +1533,7 @@ async def test_play_no_hint_no_default_offers_disambiguation(self) -> None: ) 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"}, } @@ -1152,6 +1607,7 @@ async def test_disambiguation_clears_awaiting_query(self) -> None: 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}}, @@ -1243,6 +1699,7 @@ async def test_ordinal_out_of_range_reasks_does_not_fall_through(self) -> None: ) 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": { @@ -1452,6 +1909,7 @@ async def test_disambiguation_writes_pending_to_application_state(self) -> None: ) 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": "включи джаз"}, } diff --git a/tests/providers/yandex_alice/test_dialogs_control.py b/tests/providers/yandex_alice/test_dialogs_control.py index db392dfa31..27b1482709 100644 --- a/tests/providers/yandex_alice/test_dialogs_control.py +++ b/tests/providers/yandex_alice/test_dialogs_control.py @@ -190,6 +190,122 @@ def test_play_phrases_return_none(self, phrase: str) -> None: assert parse_control(phrase) is None +class TestParseControlVolumeRelative: + """volume_relative — phrasings with a number, fed by regex or YANDEX.NUMBER.""" + + @pytest.mark.parametrize( + ("phrase", "expected_value", "expected_hint"), + [ + # increase forms — regex captures the digit (Yandex normalises + # spelled-out numbers in `request.command`). + ("прибавь 20", 20, None), + ("прибавь на 20", 20, None), + ("прибавь на 20 процентов", 20, None), + ("прибавьте на 5", 5, None), + ("сделай прибавь 10", 10, None), + ("на 15 громче", 15, None), + ("на 5 погромче", 5, None), + # decrease forms (regex) + ("убавь 10", -10, None), + ("убавь на 25", -25, None), + ("убавьте 5", -5, None), + ("на 20 тише", -20, None), + ("на 30 потише", -30, None), + # with player hint + ("прибавь на 10 на кухне", 10, "кухне"), + ("убавь 5 на спальне", -5, "спальне"), + # clamping (magnitude > 100 caps at 100) + ("прибавь на 999", 100, None), + ], + ) + def test_relative_with_digit( + self, phrase: str, expected_value: int, expected_hint: str | None + ) -> None: + """Relative-volume phrasings with a captured digit produce signed deltas.""" + result = parse_control(phrase) + assert result is not None, f"phrase={phrase!r}" + assert result.action == "volume_relative", f"phrase={phrase!r}" + assert result.value == expected_value, f"phrase={phrase!r}" + assert result.player_hint == expected_hint, f"phrase={phrase!r}" + + @pytest.mark.parametrize( + ("phrase", "sign"), + [ + ("прибавь", +1), + ("убавь", -1), + ("сделай прибавь", +1), + ], + ) + def test_relative_uses_yandex_number_when_regex_misses_digit( + self, phrase: str, sign: int + ) -> None: + """Verb matched without a digit → fall back to YANDEX.NUMBER entity.""" + entities = [{"type": "YANDEX.NUMBER", "value": 12, "tokens": {"start": 0, "end": 1}}] + result = parse_control(phrase, entities=entities) + assert result is not None + assert result.action == "volume_relative" + assert result.value == sign * 12 + + def test_bare_verb_without_entity_falls_through_to_volume_up(self) -> None: + """Without a digit and without YANDEX.NUMBER, "прибавь" stays on volume_up.""" + # No entities provided → relative pattern matches but yields no number, + # so the fallthrough lands on the bare-verb _CONTROL_PATTERNS rule. + result = parse_control("прибавь") + assert result is not None + assert result.action == "volume_up" + assert result.value is None + + def test_bare_decrease_falls_through_to_volume_down(self) -> None: + """Bare "убавь" without number stays on volume_down (existing behaviour).""" + result = parse_control("убавь") + assert result is not None + assert result.action == "volume_down" + + def test_entity_fallback_skips_non_number_entities(self) -> None: + """Only YANDEX.NUMBER counts; other entity types are ignored.""" + entities = [ + {"type": "YANDEX.GEO", "value": {"city": "Москва"}}, + {"type": "YANDEX.FIO", "value": {"first_name": "Иван"}}, + ] + result = parse_control("прибавь", entities=entities) + assert result is not None + assert result.action == "volume_up" # no number found → fallthrough + + def test_entity_fallback_first_number_wins(self) -> None: + """When several YANDEX.NUMBER entities are present, the first wins.""" + entities = [ + {"type": "YANDEX.NUMBER", "value": 7}, + {"type": "YANDEX.NUMBER", "value": 99}, + ] + result = parse_control("прибавь", entities=entities) + assert result is not None + assert result.action == "volume_relative" + assert result.value == 7 + + def test_volume_set_still_wins_with_keyword(self) -> None: + """volume_set with the explicit "громкость" keyword still matches first.""" + result = parse_control("громкость 30") + assert result is not None + assert result.action == "volume_set" + assert result.value == 30 + + @pytest.mark.parametrize( + ("phrase", "expected_value"), + [ + ("прибавь на 0", 0), + ("убавь 0", 0), + ("на 0 громче", 0), + ("на 0 тише", 0), + ], + ) + def test_zero_magnitude_passes_through_as_zero(self, phrase: str, expected_value: int) -> None: + """Zero magnitude is preserved (not promoted to ±1) — Copilot review on PR #18.""" + result = parse_control(phrase) + assert result is not None + assert result.action == "volume_relative" + assert result.value == expected_value + + class TestPluralRu: """Tests for the Russian quantitative-form picker.""" @@ -368,6 +484,47 @@ async def test_volume_set_none_falls_back_to_zero(self) -> None: 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: + """volume_relative reads current volume and bumps by signed delta.""" + 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: + """volume_relative with negative delta lowers the current level.""" + 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: + """Resulting volume above 100 is clamped to 100.""" + 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: + """Resulting volume below 0 is clamped to 0.""" + 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: + """Player without a volume_level (virtual / unsupported) defaults to 50.""" + mass = self._make_mass() + player = self._player() + # Explicitly drop volume_level so getattr returns None. + 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: """action=mute invokes cmd_volume_mute(True).""" mass = self._make_mass() From a8ccef75e6a0484f31416031c63fd396d666db47 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 7 May 2026 20:59:50 +0000 Subject: [PATCH 09/10] feat(yandex_alice): sync provider from ma-provider-yandex-alice v1.3.1 --- music_assistant/providers/yandex_alice/tts_dictionary.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/music_assistant/providers/yandex_alice/tts_dictionary.py b/music_assistant/providers/yandex_alice/tts_dictionary.py index b2f8f4c655..f7537f1338 100644 --- a/music_assistant/providers/yandex_alice/tts_dictionary.py +++ b/music_assistant/providers/yandex_alice/tts_dictionary.py @@ -68,7 +68,7 @@ "rihanna": "рих+анна", "scorpions": "ск+орпионс", "skillet": "ск+иллет", - "sting": "стинг", + "sting": "стинг", # codespell:ignore sting } From 4a2f905eabbf008d95c0c6d9a3c912c453477296 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 7 May 2026 21:17:00 +0000 Subject: [PATCH 10/10] feat(yandex_alice): sync provider from ma-provider-yandex-alice v1.3.2 --- .../providers/yandex_alice/auth_page.py | 74 ++++++++++++++++++- 1 file changed, 72 insertions(+), 2 deletions(-) diff --git a/music_assistant/providers/yandex_alice/auth_page.py b/music_assistant/providers/yandex_alice/auth_page.py index 886e4f5f83..3f4673897a 100644 --- a/music_assistant/providers/yandex_alice/auth_page.py +++ b/music_assistant/providers/yandex_alice/auth_page.py @@ -302,12 +302,34 @@ async def _serve_status(_request: web.Request) -> web.Response: 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.""" + """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) @@ -316,6 +338,44 @@ def unregister_device_code_route(mass: MusicAssistant, *, session_id: str) -> No 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()) # --------------------------------------------------------------------------- @@ -399,7 +459,17 @@ async def perform_device_auth( # Let the page pick up "done" and close itself. await asyncio.sleep(_POST_AUTH_GRACE_SECONDS) finally: - unregister_device_code_route(mass, session_id=session_id) + # 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()