diff --git a/buildroot-external/package/python-ovos-ocp-audio-plugin/python-ovos-ocp-audio-plugin.hash b/buildroot-external/package/python-ovos-ocp-audio-plugin/python-ovos-ocp-audio-plugin.hash index 4aae65ba..0954807f 100644 --- a/buildroot-external/package/python-ovos-ocp-audio-plugin/python-ovos-ocp-audio-plugin.hash +++ b/buildroot-external/package/python-ovos-ocp-audio-plugin/python-ovos-ocp-audio-plugin.hash @@ -1 +1 @@ -sha256 1df8377b36436ebb01d66fbf48d4697b6fe3ce885dacf2126e3c843e6e8e9add python-ovos-ocp-audio-plugin-9c17e4c565c82548abf4650459423254fce85089.tar.gz +sha256 aceb79a99a1f75200ff3e374bb14b65739e8c75e5a1606475c849143682403fd python-ovos-ocp-audio-plugin-60860148e33d30523bde404c9b6b627f15e07931.tar.gz diff --git a/buildroot-external/package/python-ovos-ocp-audio-plugin/python-ovos-ocp-audio-plugin.mk b/buildroot-external/package/python-ovos-ocp-audio-plugin/python-ovos-ocp-audio-plugin.mk index 4fac2cbf..499d2b1a 100644 --- a/buildroot-external/package/python-ovos-ocp-audio-plugin/python-ovos-ocp-audio-plugin.mk +++ b/buildroot-external/package/python-ovos-ocp-audio-plugin/python-ovos-ocp-audio-plugin.mk @@ -4,7 +4,7 @@ # ################################################################################ -PYTHON_OVOS_OCP_AUDIO_PLUGIN_VERSION = 9c17e4c565c82548abf4650459423254fce85089 +PYTHON_OVOS_OCP_AUDIO_PLUGIN_VERSION = 60860148e33d30523bde404c9b6b627f15e07931 PYTHON_OVOS_OCP_AUDIO_PLUGIN_SITE = $(call github,OpenVoiceOS,ovos-ocp-audio-plugin,$(PYTHON_OVOS_OCP_AUDIO_PLUGIN_VERSION)) PYTHON_OVOS_OCP_AUDIO_PLUGIN_SETUP_TYPE = setuptools PYTHON_OVOS_OCP_AUDIO_PLUGIN_LICENSE_FILES = LICENSE diff --git a/buildroot-external/package/python-ovos-utils/0001-initent-utils.patch b/buildroot-external/package/python-ovos-utils/0001-initent-utils.patch new file mode 100644 index 00000000..84a7424a --- /dev/null +++ b/buildroot-external/package/python-ovos-utils/0001-initent-utils.patch @@ -0,0 +1,2312 @@ +From 1f7076cb5c3b1ff17e76947c080a6072b13124c5 Mon Sep 17 00:00:00 2001 +From: jarbasal +Date: Thu, 30 Sep 2021 00:21:18 +0100 +Subject: [PATCH 1/5] intent utils + +--- + ovos_utils/__init__.py | 96 +--- + ovos_utils/bracket_expansion.py | 196 +++++++ + ovos_utils/events.py | 80 ++- + ovos_utils/file_utils.py | 237 +++++++++ + ovos_utils/intents/__init__.py | 421 +-------------- + ovos_utils/intents/converse.py | 201 +++++++ + .../intents/intent_service_interface.py | 495 ++++++++++++++++++ + 7 files changed, 1213 insertions(+), 513 deletions(-) + create mode 100644 ovos_utils/bracket_expansion.py + create mode 100644 ovos_utils/file_utils.py + create mode 100644 ovos_utils/intents/converse.py + create mode 100644 ovos_utils/intents/intent_service_interface.py + +diff --git a/ovos_utils/__init__.py b/ovos_utils/__init__.py +index d8b9134..38c66c7 100644 +--- a/ovos_utils/__init__.py ++++ b/ovos_utils/__init__.py +@@ -12,14 +12,14 @@ + # + from threading import Thread + from time import sleep +-import os +-from os.path import isdir, join, dirname ++from os.path import isdir, join + import re + import datetime + import kthread + from ovos_utils.network_utils import * + from inflection import camelize, titleize, transliterate, parameterize, \ + ordinalize ++from ovos_utils.file_utils import resolve_ovos_resource_file, resolve_resource_file + + + def ensure_mycroft_import(): +@@ -35,33 +35,6 @@ def ensure_mycroft_import(): + raise + + +-def resolve_ovos_resource_file(res_name): +- """Convert a resource into an absolute filename. +- used internally for ovos resources +- """ +- # First look for fully qualified file (e.g. a user setting) +- if os.path.isfile(res_name): +- return res_name +- +- # now look in bundled ovos resources +- filename = join(dirname(__file__), "res", res_name) +- if os.path.isfile(filename): +- return filename +- +- # let's look in ovos_workshop if it's installed +- try: +- import ovos_workshop +- pkg_dir = dirname(ovos_workshop.__file__) +- filename = join(pkg_dir, "res", res_name) +- if os.path.isfile(filename): +- return filename +- filename = join(pkg_dir, "res", "ui", res_name) +- if os.path.isfile(filename): +- return filename +- except: +- pass +- return None # Resource cannot be resolved +- + + def get_mycroft_root(): + paths = [ +@@ -75,71 +48,6 @@ def get_mycroft_root(): + return None + + +-def resolve_resource_file(res_name, root_path=None, config=None): +- """Convert a resource into an absolute filename. +- +- Resource names are in the form: 'filename.ext' +- or 'path/filename.ext' +- +- The system wil look for ~/.mycroft/res_name first, and +- if not found will look at /opt/mycroft/res_name, +- then finally it will look for res_name in the 'mycroft/res' +- folder of the source code package. +- +- Example: +- With mycroft running as the user 'bob', if you called +- resolve_resource_file('snd/beep.wav') +- it would return either '/home/bob/.mycroft/snd/beep.wav' or +- '/opt/mycroft/snd/beep.wav' or '.../mycroft/res/snd/beep.wav', +- where the '...' is replaced by the path where the package has +- been installed. +- +- Args: +- res_name (str): a resource path/name +- config (dict): mycroft.conf, to read data directory from +- Returns: +- str: path to resource or None if no resource found +- """ +- if config is None: +- from ovos_utils.configuration import read_mycroft_config +- config = read_mycroft_config() +- +- # First look for fully qualified file (e.g. a user setting) +- if os.path.isfile(res_name): +- return res_name +- +- # Now look for ~/.mycroft/res_name (in user folder) +- filename = os.path.expanduser("~/.mycroft/" + res_name) +- if os.path.isfile(filename): +- return filename +- +- # Next look for /opt/mycroft/res/res_name +- data_dir = os.path.expanduser(config.get('data_dir', "/opt/mycroft")) +- filename = os.path.expanduser(os.path.join(data_dir, res_name)) +- if os.path.isfile(filename): +- return filename +- +- # look in ovos_utils package itself +- found = resolve_ovos_resource_file(res_name) +- if found: +- return found +- +- # Finally look for it in the source package +- paths = [ +- "/opt/venvs/mycroft-core/lib/python3.7/site-packages/", # mark1/2 +- "/opt/venvs/mycroft-core/lib/python3.4/site-packages/ ", # old mark1 installs +- "/home/pi/mycroft-core" # picroft +- ] +- if root_path: +- paths += [root_path] +- for p in paths: +- filename = os.path.join(p, 'mycroft', 'res', res_name) +- filename = os.path.abspath(os.path.normpath(filename)) +- if os.path.isfile(filename): +- return filename +- +- return None # Resource cannot be resolved +- + + def create_killable_daemon(target, args=(), kwargs=None, autostart=True): + """Helper to quickly create and start a thread with daemon = True""" +diff --git a/ovos_utils/bracket_expansion.py b/ovos_utils/bracket_expansion.py +new file mode 100644 +index 0000000..1c69a6f +--- /dev/null ++++ b/ovos_utils/bracket_expansion.py +@@ -0,0 +1,196 @@ ++import re ++ ++ ++def expand_parentheses(sent): ++ """ ++ ['1', '(', '2', '|', '3, ')'] -> [['1', '2'], ['1', '3']] ++ For example: ++ Will it (rain|pour) (today|tomorrow|)? ++ ----> ++ Will it rain today? ++ Will it rain tomorrow? ++ Will it rain? ++ Will it pour today? ++ Will it pour tomorrow? ++ Will it pour? ++ Args: ++ sent (list): List of tokens in sentence ++ Returns: ++ list>: Multiple possible sentences from original ++ """ ++ return SentenceTreeParser(sent).expand_parentheses() ++ ++ ++def expand_options(parentheses_line: str) -> list: ++ """ ++ Convert 'test (a|b)' -> ['test a', 'test b'] ++ Args: ++ parentheses_line: Input line to expand ++ Returns: ++ List of expanded possibilities ++ """ ++ # 'a(this|that)b' -> [['a', 'this', 'b'], ['a', 'that', 'b']] ++ options = expand_parentheses(re.split(r'([(|)])', parentheses_line)) ++ return [re.sub(r'\s+', ' ', ' '.join(i)).strip() for i in options] ++ ++ ++class Fragment: ++ """(Abstract) empty sentence fragment""" ++ ++ def __init__(self, tree): ++ """ ++ Construct a sentence tree fragment which is merely a wrapper for ++ a list of Strings ++ Args: ++ tree (?): Base tree for the sentence fragment, type depends on ++ subclass, refer to those subclasses ++ """ ++ self._tree = tree ++ ++ def tree(self): ++ """Return the represented sentence tree as raw data.""" ++ return self._tree ++ ++ def expand(self): ++ """ ++ Expanded version of the fragment. In this case an empty sentence. ++ Returns: ++ List>: A list with an empty sentence (= token/string list) ++ """ ++ return [[]] ++ ++ def __str__(self): ++ return self._tree.__str__() ++ ++ def __repr__(self): ++ return self._tree.__repr__() ++ ++ ++class Word(Fragment): ++ """ ++ Single word in the sentence tree. ++ Construct with a string as argument. ++ """ ++ ++ def expand(self): ++ """ ++ Creates one sentence that contains exactly that word. ++ Returns: ++ List>: A list with the given string as sentence ++ (= token/string list) ++ """ ++ return [[self._tree]] ++ ++ ++class Sentence(Fragment): ++ """ ++ A Sentence made of several concatenations/words. ++ Construct with a List as argument. ++ """ ++ ++ def expand(self): ++ """ ++ Creates a combination of all sub-sentences. ++ Returns: ++ List>: A list with all subsentence expansions combined in ++ every possible way ++ """ ++ old_expanded = [[]] ++ for sub in self._tree: ++ sub_expanded = sub.expand() ++ new_expanded = [] ++ while len(old_expanded) > 0: ++ sentence = old_expanded.pop() ++ for new in sub_expanded: ++ new_expanded.append(sentence + new) ++ old_expanded = new_expanded ++ return old_expanded ++ ++ ++class Options(Fragment): ++ """ ++ A Combination of possible sub-sentences. ++ Construct with List as argument. ++ """ ++ ++ def expand(self): ++ """ ++ Returns all of its options as seperated sub-sentences. ++ Returns: ++ List>: A list containing the sentences created by all ++ expansions of its sub-sentences ++ """ ++ options = [] ++ for option in self._tree: ++ options.extend(option.expand()) ++ return options ++ ++ ++class SentenceTreeParser: ++ """ ++ Generate sentence token trees from a list of tokens ++ ['1', '(', '2', '|', '3, ')'] -> [['1', '2'], ['1', '3']] ++ """ ++ ++ def __init__(self, tokens): ++ self.tokens = tokens ++ ++ def _parse(self): ++ """ ++ Generate sentence token trees ++ ['1', '(', '2', '|', '3, ')'] -> ['1', ['2', '3']] ++ """ ++ self._current_position = 0 ++ return self._parse_expr() ++ ++ def _parse_expr(self): ++ """ ++ Generate sentence token trees from the current position to ++ the next closing parentheses / end of the list and return it ++ ['1', '(', '2', '|', '3, ')'] -> ['1', [['2'], ['3']]] ++ ['2', '|', '3'] -> [['2'], ['3']] ++ """ ++ # List of all generated sentences ++ sentence_list = [] ++ # Currently active sentence ++ cur_sentence = [] ++ sentence_list.append(Sentence(cur_sentence)) ++ # Determine which form the current expression has ++ while self._current_position < len(self.tokens): ++ cur = self.tokens[self._current_position] ++ self._current_position += 1 ++ if cur == '(': ++ # Parse the subexpression ++ subexpr = self._parse_expr() ++ # Check if the subexpression only has one branch ++ # -> If so, append "(" and ")" and add it as is ++ normal_brackets = False ++ if len(subexpr.tree()) == 1: ++ normal_brackets = True ++ cur_sentence.append(Word('(')) ++ # add it to the sentence ++ cur_sentence.append(subexpr) ++ if normal_brackets: ++ cur_sentence.append(Word(')')) ++ elif cur == '|': ++ # Begin parsing a new sentence ++ cur_sentence = [] ++ sentence_list.append(Sentence(cur_sentence)) ++ elif cur == ')': ++ # End parsing the current subexpression ++ break ++ # TODO anything special about {sth}? ++ else: ++ cur_sentence.append(Word(cur)) ++ return Options(sentence_list) ++ ++ def _expand_tree(self, tree): ++ """ ++ Expand a list of sub sentences to all combinated sentences. ++ ['1', ['2', '3']] -> [['1', '2'], ['1', '3']] ++ """ ++ return tree.expand() ++ ++ def expand_parentheses(self): ++ tree = self._parse() ++ return self._expand_tree(tree) +diff --git a/ovos_utils/events.py b/ovos_utils/events.py +index fddd2a5..a8b6421 100644 +--- a/ovos_utils/events.py ++++ b/ovos_utils/events.py +@@ -1,9 +1,81 @@ +-from ovos_utils.log import LOG +-from ovos_utils.messagebus import Message, FakeBus + import time + from datetime import datetime, timedelta + from inspect import signature + ++from ovos_utils.intents.intent_service_interface import to_alnum ++from ovos_utils.log import LOG ++from ovos_utils.messagebus import Message, FakeBus ++ ++ ++def unmunge_message(message, skill_id): ++ """Restore message keywords by removing the Letterified skill ID. ++ Args: ++ message (Message): Intent result message ++ skill_id (str): skill identifier ++ Returns: ++ Message without clear keywords ++ """ ++ if isinstance(message, Message) and isinstance(message.data, dict): ++ skill_id = to_alnum(skill_id) ++ for key in list(message.data.keys()): ++ if key.startswith(skill_id): ++ # replace the munged key with the real one ++ new_key = key[len(skill_id):] ++ message.data[new_key] = message.data.pop(key) ++ ++ return message ++ ++ ++def get_handler_name(handler): ++ """Name (including class if available) of handler function. ++ ++ Args: ++ handler (function): Function to be named ++ ++ Returns: ++ string: handler name as string ++ """ ++ if '__self__' in dir(handler) and 'name' in dir(handler.__self__): ++ return handler.__self__.name + '.' + handler.__name__ ++ else: ++ return handler.__name__ ++ ++ ++def create_wrapper(handler, skill_id, on_start, on_end, on_error): ++ """Create the default skill handler wrapper. ++ ++ This wrapper handles things like metrics, reporting handler start/stop ++ and errors. ++ handler (callable): method/function to call ++ skill_id: skill_id for associated skill ++ on_start (function): function to call before executing the handler ++ on_end (function): function to call after executing the handler ++ on_error (function): function to call for error reporting ++ """ ++ ++ def wrapper(message): ++ try: ++ message = unmunge_message(message, skill_id) ++ if on_start: ++ on_start(message) ++ ++ if len(signature(handler).parameters) == 0: ++ handler() ++ else: ++ handler(message) ++ ++ except Exception as e: ++ if on_error: ++ if len(signature(on_error).parameters) == 2: ++ on_error(e, message) ++ else: ++ on_error(e) ++ finally: ++ if on_end: ++ on_end(message) ++ ++ return wrapper ++ + + def create_basic_wrapper(handler, on_error=None): + """Create the default skill handler wrapper. +@@ -18,6 +90,7 @@ def create_basic_wrapper(handler, on_error=None): + Returns: + Wrapped callable + """ ++ + def wrapper(message): + try: + if len(signature(handler).parameters) == 0: +@@ -37,6 +110,7 @@ class EventContainer: + This container tracks events added by a skill, allowing unregistering + all events on shutdown. + """ ++ + def __init__(self, bus=None): + self.bus = bus or FakeBus() + self.events = [] +@@ -53,6 +127,7 @@ def add(self, name, handler, once=False): + once (bool, optional): Event handler will be removed after it has + been run once. + """ ++ + def once_wrapper(message): + # Remove registered one-time handler before invoking, + # allowing them to re-schedule themselves. +@@ -113,6 +188,7 @@ def clear(self): + + class EventSchedulerInterface: + """Interface for accessing the event scheduler over the message bus.""" ++ + def __init__(self, name, sched_id=None, bus=None): + self.name = name + self.sched_id = sched_id +diff --git a/ovos_utils/file_utils.py b/ovos_utils/file_utils.py +new file mode 100644 +index 0000000..de9122a +--- /dev/null ++++ b/ovos_utils/file_utils.py +@@ -0,0 +1,237 @@ ++import collections ++import csv ++import re ++import os ++from os import walk ++from os.path import splitext, join, dirname ++ ++from ovos_utils.bracket_expansion import expand_options ++from ovos_utils.intents.intent_service_interface import to_alnum, munge_regex ++from ovos_utils.log import LOG ++ ++ ++def resolve_ovos_resource_file(res_name): ++ """Convert a resource into an absolute filename. ++ used internally for ovos resources ++ """ ++ # First look for fully qualified file (e.g. a user setting) ++ if os.path.isfile(res_name): ++ return res_name ++ ++ # now look in bundled ovos resources ++ filename = join(dirname(__file__), "res", res_name) ++ if os.path.isfile(filename): ++ return filename ++ ++ # let's look in ovos_workshop if it's installed ++ try: ++ import ovos_workshop ++ pkg_dir = dirname(ovos_workshop.__file__) ++ filename = join(pkg_dir, "res", res_name) ++ if os.path.isfile(filename): ++ return filename ++ filename = join(pkg_dir, "res", "ui", res_name) ++ if os.path.isfile(filename): ++ return filename ++ except: ++ pass ++ return None # Resource cannot be resolved ++ ++ ++def resolve_resource_file(res_name, root_path=None, config=None): ++ """Convert a resource into an absolute filename. ++ ++ Resource names are in the form: 'filename.ext' ++ or 'path/filename.ext' ++ ++ The system wil look for ~/.mycroft/res_name first, and ++ if not found will look at /opt/mycroft/res_name, ++ then finally it will look for res_name in the 'mycroft/res' ++ folder of the source code package. ++ ++ Example: ++ With mycroft running as the user 'bob', if you called ++ resolve_resource_file('snd/beep.wav') ++ it would return either '/home/bob/.mycroft/snd/beep.wav' or ++ '/opt/mycroft/snd/beep.wav' or '.../mycroft/res/snd/beep.wav', ++ where the '...' is replaced by the path where the package has ++ been installed. ++ ++ Args: ++ res_name (str): a resource path/name ++ config (dict): mycroft.conf, to read data directory from ++ Returns: ++ str: path to resource or None if no resource found ++ """ ++ if config is None: ++ from ovos_utils.configuration import read_mycroft_config ++ config = read_mycroft_config() ++ ++ # First look for fully qualified file (e.g. a user setting) ++ if os.path.isfile(res_name): ++ return res_name ++ ++ # Now look for ~/.mycroft/res_name (in user folder) ++ filename = os.path.expanduser("~/.mycroft/" + res_name) ++ if os.path.isfile(filename): ++ return filename ++ ++ # Next look for /opt/mycroft/res/res_name ++ data_dir = os.path.expanduser(config.get('data_dir', "/opt/mycroft")) ++ filename = os.path.expanduser(os.path.join(data_dir, res_name)) ++ if os.path.isfile(filename): ++ return filename ++ ++ # look in ovos_utils package itself ++ found = resolve_ovos_resource_file(res_name) ++ if found: ++ return found ++ ++ # Finally look for it in the source package ++ paths = [ ++ "/opt/venvs/mycroft-core/lib/python3.7/site-packages/", # mark1/2 ++ "/opt/venvs/mycroft-core/lib/python3.4/site-packages/ ", ++ # old mark1 installs ++ "/home/pi/mycroft-core" # picroft ++ ] ++ if root_path: ++ paths += [root_path] ++ for p in paths: ++ filename = os.path.join(p, 'mycroft', 'res', res_name) ++ filename = os.path.abspath(os.path.normpath(filename)) ++ if os.path.isfile(filename): ++ return filename ++ ++ return None # Resource cannot be resolved ++ ++ ++def read_vocab_file(path): ++ """ Read voc file. ++ ++ This reads a .voc file, stripping out empty lines comments and expand ++ parentheses. It returns each line as a list of all expanded ++ alternatives. ++ ++ Args: ++ path (str): path to vocab file. ++ ++ Returns: ++ List of Lists of strings. ++ """ ++ vocab = [] ++ with open(path, 'r', encoding='utf8') as voc_file: ++ for line in voc_file.readlines(): ++ if line.startswith('#') or line.strip() == '': ++ continue ++ vocab.append(expand_options(line.lower())) ++ return vocab ++ ++ ++def load_regex_from_file(path, skill_id): ++ """Load regex from file ++ The regex is sent to the intent handler using the message bus ++ ++ Args: ++ path: path to vocabulary file (*.voc) ++ skill_id: skill_id to the regex is tied to ++ """ ++ regexes = [] ++ if path.endswith('.rx'): ++ with open(path, 'r', encoding='utf8') as reg_file: ++ for line in reg_file.readlines(): ++ if line.startswith("#"): ++ continue ++ LOG.debug('regex pre-munge: ' + line.strip()) ++ regex = munge_regex(line.strip(), skill_id) ++ LOG.debug('regex post-munge: ' + regex) ++ # Raise error if regex can't be compiled ++ try: ++ re.compile(regex) ++ regexes.append(regex) ++ except Exception as e: ++ LOG.warning(f'Failed to compile regex {regex}: {e}') ++ ++ return regexes ++ ++ ++def load_vocabulary(basedir, skill_id): ++ """Load vocabulary from all files in the specified directory. ++ ++ Args: ++ basedir (str): path of directory to load from (will recurse) ++ skill_id: skill the data belongs to ++ Returns: ++ dict with intent_type as keys and list of list of lists as value. ++ """ ++ vocabs = {} ++ for path, _, files in walk(basedir): ++ for f in files: ++ if f.endswith(".voc"): ++ vocab_type = to_alnum(skill_id) + splitext(f)[0] ++ vocs = read_vocab_file(join(path, f)) ++ if vocs: ++ vocabs[vocab_type] = vocs ++ return vocabs ++ ++ ++def load_regex(basedir, skill_id): ++ """Load regex from all files in the specified directory. ++ ++ Args: ++ basedir (str): path of directory to load from ++ bus (messagebus emitter): messagebus instance used to send the vocab to ++ the intent service ++ skill_id (str): skill identifier ++ """ ++ regexes = [] ++ for path, _, files in walk(basedir): ++ for f in files: ++ if f.endswith(".rx"): ++ regexes += load_regex_from_file(join(path, f), skill_id) ++ return regexes ++ ++ ++def read_value_file(filename, delim): ++ """Read value file. ++ ++ The value file is a simple csv structure with a key and value. ++ ++ Args: ++ filename (str): file to read ++ delim (str): csv delimiter ++ ++ Returns: ++ OrderedDict with results. ++ """ ++ result = collections.OrderedDict() ++ ++ if filename: ++ with open(filename) as f: ++ reader = csv.reader(f, delimiter=delim) ++ for row in reader: ++ # skip blank or comment lines ++ if not row or row[0].startswith("#"): ++ continue ++ if len(row) != 2: ++ continue ++ ++ result[row[0]] = row[1] ++ return result ++ ++ ++def read_translated_file(filename, data): ++ """Read a file inserting data. ++ ++ Args: ++ filename (str): file to read ++ data (dict): dictionary with data to insert into file ++ ++ Returns: ++ list of lines. ++ """ ++ if filename: ++ with open(filename) as f: ++ text = f.read().replace('{{', '{').replace('}}', '}') ++ return text.format(**data or {}).rstrip('\n').split('\n') ++ else: ++ return None +diff --git a/ovos_utils/intents/__init__.py b/ovos_utils/intents/__init__.py +index 6165119..ff9e985 100644 +--- a/ovos_utils/intents/__init__.py ++++ b/ovos_utils/intents/__init__.py +@@ -1,417 +1,4 @@ +-from ovos_utils.log import LOG +-from ovos_utils.messagebus import get_mycroft_bus, Message +-import time +-from os.path import isfile +- +- +-class IntentQueryApi: +- """ +- Query Intent Service at runtime +- """ +- +- def __init__(self, bus=None, timeout=5): +- self.bus = bus or get_mycroft_bus() +- self.timeout = timeout +- +- def get_adapt_intent(self, utterance, lang="en-us"): +- """ get best adapt intent for utterance """ +- msg = Message("intent.service.adapt.get", +- {"utterance": utterance, "lang": lang}, +- context={"destination": "intent_service", +- "source": "intent_api"}) +- +- resp = self.bus.wait_for_response(msg, +- 'intent.service.adapt.reply', +- timeout=self.timeout) +- data = resp.data if resp is not None else {} +- if not data: +- LOG.error("Intent Service timed out!") +- return None +- return data["intent"] +- +- def get_padatious_intent(self, utterance, lang="en-us"): +- """ get best padatious intent for utterance """ +- msg = Message("intent.service.padatious.get", +- {"utterance": utterance, "lang": lang}, +- context={"destination": "intent_service", +- "source": "intent_api"}) +- resp = self.bus.wait_for_response(msg, +- 'intent.service.padatious.reply', +- timeout=self.timeout) +- data = resp.data if resp is not None else {} +- if not data: +- LOG.error("Intent Service timed out!") +- return None +- return data["intent"] +- +- def get_intent(self, utterance, lang="en-us"): +- """ get best intent for utterance """ +- msg = Message("intent.service.intent.get", +- {"utterance": utterance, "lang": lang}, +- context={"destination": "intent_service", +- "source": "intent_api"}) +- resp = self.bus.wait_for_response(msg, +- 'intent.service.intent.reply', +- timeout=self.timeout) +- data = resp.data if resp is not None else {} +- if not data: +- LOG.error("Intent Service timed out!") +- return None +- return data["intent"] +- +- def get_skill(self, utterance, lang="en-us"): +- """ get skill that utterance will trigger """ +- intent = self.get_intent(utterance, lang) +- if not intent: +- return None +- # theoretically skill_id might be missing +- if intent.get("skill_id"): +- return intent["skill_id"] +- # retrieve skill from munged intent name +- if intent.get("intent_name"): # padatious + adapt +- return intent["name"].split(":")[0] +- if intent.get("intent_type"): # adapt +- return intent["intent_type"].split(":")[0] +- return None # raise some error here maybe? this should never happen +- +- def get_skills_manifest(self): +- msg = Message("intent.service.skills.get", +- context={"destination": "intent_service", +- "source": "intent_api"}) +- resp = self.bus.wait_for_response(msg, +- 'intent.service.skills.reply', +- timeout=self.timeout) +- data = resp.data if resp is not None else {} +- if not data: +- LOG.error("Intent Service timed out!") +- return None +- return data["skills"] +- +- def get_active_skills(self, include_timestamps=False): +- msg = Message("intent.service.active_skills.get", +- context={"destination": "intent_service", +- "source": "intent_api"}) +- resp = self.bus.wait_for_response(msg, +- 'intent.service.active_skills.reply', +- timeout=self.timeout) +- data = resp.data if resp is not None else {} +- if not data: +- LOG.error("Intent Service timed out!") +- return None +- if include_timestamps: +- return data["skills"] +- # HACK waiting for https://github.com/MycroftAI/mycroft-core/pull/2786 +- if len(data["skills"]) and not len(data["skills"][0]) == 2: +- return data["skills"] +- return [s[0] for s in data["skills"]] +- +- def get_adapt_manifest(self): +- msg = Message("intent.service.adapt.manifest.get", +- context={"destination": "intent_service", +- "source": "intent_api"}) +- resp = self.bus.wait_for_response(msg, +- 'intent.service.adapt.manifest', +- timeout=self.timeout) +- data = resp.data if resp is not None else {} +- if not data: +- LOG.error("Intent Service timed out!") +- return None +- return data["intents"] +- +- def get_padatious_manifest(self): +- msg = Message("intent.service.padatious.manifest.get", +- context={"destination": "intent_service", +- "source": "intent_api"}) +- resp = self.bus.wait_for_response(msg, +- 'intent.service.padatious.manifest', +- timeout=self.timeout) +- data = resp.data if resp is not None else {} +- if not data: +- LOG.error("Intent Service timed out!") +- return None +- return data["intents"] +- +- def get_intent_manifest(self): +- padatious = self.get_padatious_manifest() +- adapt = self.get_adapt_manifest() +- return {"adapt": adapt, +- "padatious": padatious} +- +- def get_vocab_manifest(self): +- msg = Message("intent.service.adapt.vocab.manifest.get", +- context={"destination": "intent_service", +- "source": "intent_api"}) +- reply_msg_type = 'intent.service.adapt.vocab.manifest' +- resp = self.bus.wait_for_response(msg, +- reply_msg_type, +- timeout=self.timeout) +- data = resp.data if resp is not None else {} +- if not data: +- LOG.error("Intent Service timed out!") +- return None +- +- vocab = {} +- for voc in data["vocab"]: +- if voc.get("regex"): +- continue +- if voc["end"] not in vocab: +- vocab[voc["end"]] = {"samples": []} +- vocab[voc["end"]]["samples"].append(voc["start"]) +- return [{"name": voc, "samples": vocab[voc]["samples"]} +- for voc in vocab] +- +- def get_regex_manifest(self): +- msg = Message("intent.service.adapt.vocab.manifest.get", +- context={"destination": "intent_service", +- "source": "intent_api"}) +- reply_msg_type = 'intent.service.adapt.vocab.manifest' +- resp = self.bus.wait_for_response(msg, +- reply_msg_type, +- timeout=self.timeout) +- data = resp.data if resp is not None else {} +- if not data: +- LOG.error("Intent Service timed out!") +- return None +- +- vocab = {} +- for voc in data["vocab"]: +- if not voc.get("regex"): +- continue +- name = voc["regex"].split("(?P<")[-1].split(">")[0] +- if name not in vocab: +- vocab[name] = {"samples": []} +- vocab[name]["samples"].append(voc["regex"]) +- return [{"name": voc, "regexes": vocab[voc]["samples"]} +- for voc in vocab] +- +- def get_entities_manifest(self): +- msg = Message("intent.service.padatious.entities.manifest.get", +- context={"destination": "intent_service", +- "source": "intent_api"}) +- reply_msg_type = 'intent.service.padatious.entities.manifest' +- resp = self.bus.wait_for_response(msg, +- reply_msg_type, +- timeout=self.timeout) +- data = resp.data if resp is not None else {} +- if not data: +- LOG.error("Intent Service timed out!") +- return None +- +- entities = [] +- # read files +- for ent in data["entities"]: +- if isfile(ent["file_name"]): +- with open(ent["file_name"]) as f: +- lines = f.read().replace("(", "").replace(")", "").split( +- "\n") +- samples = [] +- for l in lines: +- samples += [a.strip() for a in l.split("|") if a.strip()] +- entities.append({"name": ent["name"], "samples": samples}) +- return entities +- +- def get_keywords_manifest(self): +- padatious = self.get_entities_manifest() +- adapt = self.get_vocab_manifest() +- regex = self.get_regex_manifest() +- return {"adapt": adapt, +- "padatious": padatious, +- "regex": regex} +- +- +-class ConverseTracker: +- """ Using the messagebus this class recreates/keeps track of the state +- of the converse system, it uses both passive listening and active +- queries to sync it's state, it also emits 2 new bus events +- +- Implements https://github.com/MycroftAI/mycroft-core/pull/1468 +- """ +- bus = None +- active_skills = [] +- converse_timeout = 5 # MAGIC NUMBER hard coded in mycroft-core +- last_conversed = None +- intent_api = None +- +- @classmethod +- def connect_bus(cls, mycroft_bus): +- """Registers the bus object to use.""" +- # PATCH - in mycroft-core this would be handled in intent_service +- # in here it is done in MycroftSkill.bind so i added this +- # conditional check +- if cls.bus is None and mycroft_bus is not None: +- cls.bus = mycroft_bus +- cls.intent_api = IntentQueryApi(cls.bus) +- cls.register_bus_events() +- +- @classmethod +- def register_bus_events(cls): +- cls.bus.on('active_skill_request', cls.handle_activate_request) +- cls.bus.on('skill.converse.response', cls.handle_converse_response) +- cls.bus.on("mycroft.skill.handler.start", cls.handle_intent_start) +- cls.bus.on("recognizer_loop:utterance", cls.handle_utterance) +- +- # public methods +- @classmethod +- def check_skill(cls, skill_id): +- """ Check if a skill is active """ +- cls.filter_active_skills() +- for skill in list(cls.active_skills): +- if skill[0] == skill_id: +- return True +- return False +- +- @classmethod +- def filter_active_skills(cls): +- """ Removes expired skills from active skill list """ +- # filter timestamps +- for skill in list(cls.active_skills): +- if time.time() - skill[1] <= cls.converse_timeout * 60: +- cls.remove_active_skill(skill[0]) +- +- @classmethod +- def sync_with_intent_service(cls): +- """sync active skill list using intent api +- +- WARNING +- we don't have the timestamps so order might be messed up!! +- avoid calling this until +- """ +- skill_ids = cls.intent_api.get_active_skills(include_timestamps=True) +- if skill_ids: +- if len(skill_ids[0]) == 2: +- # PR was merged! hurray! +- cls.active_skills = skill_ids +- else: +- # hoping they come sorted by timestamp.... +- # older to newer (most recently used) +- for skill_id in reversed(skill_ids): +- # are we tracking this skill ? +- if not cls.check_skill(skill_id): +- # we missed adding this skill in our tracking +- cls.add_active_skill(skill_id) +- for skill in cls.active_skills: +- if skill[0] not in skill_ids: +- # we missed removing this skill in our tracking +- cls.remove_active_skill(skill[0]) +- +- # https://github.com/MycroftAI/mycroft-core/pull/1468 +- @classmethod +- def remove_active_skill(cls, skill_id, silent=False): +- """ +- Emits "converse.skill.deactivated" event, improvement of #1468 +- """ +- for skill in list(cls.active_skills): +- if skill[0] == skill_id: +- cls.active_skills.remove(skill) +- if not silent: +- cls.bus.emit(Message("converse.skill.deactivated", +- {"skill_id": skill[0]})) +- +- @classmethod +- def add_active_skill(cls, skill_id): +- """ +- Emits "converse.skill.activated" event, improvement of #1468 +- """ +- # search the list for an existing entry that already contains it +- # and remove that reference +- if skill_id != '': +- cls.remove_active_skill(skill_id, silent=True) +- # add skill with timestamp to start of skill_list +- cls.active_skills.insert(0, [skill_id, time.time()]) +- # this might be sent more than once and it's perfectly fine +- # it's just a new info message not consumed anywhere by default +- cls.bus.emit(Message("converse.skill.activated", +- {"skill_id": skill_id})) +- else: +- LOG.warning('Skill ID was empty, won\'t add to list of ' +- 'active skills.') +- +- # status tracking +- @classmethod +- def handle_activate_request(cls, message): +- """ +- a skill bumped itself to the top of active skills list +- duplicate functionality from mycroft-core, keeping list in sync +- """ +- skill_id = message.data["skill_id"] +- cls.add_active_skill(skill_id) +- +- @classmethod +- def handle_converse_error(cls, message): +- """ +- a skill was removed from active skill list due to converse error +- duplicate functionality from mycroft-core, keeping list in sync +- """ +- skill_id = message.data["skill_id"] +- if message.data["error"] == "skill id does not exist": +- cls.remove_active_skill(skill_id) +- +- @classmethod +- def handle_intent_start(cls, message): +- """ +- duplicate functionality from mycroft-core, keeping list in sync +- +- TODO skill_id from message, core is not passing it along... it used +- to be possible to retrieve it from munged message but that changed. +- send a PR (if those got merged this code wouldn't exist) +- +- handle_utterance will take over this functionality for now +- handle_converse_response will take corrective action +- """ +- # skill_id = message.data["skill_id"] +- # bump skill to top of active list +- # cls.add_active_skill(skill_id) +- +- @classmethod +- def handle_utterance(cls, message): +- """ +- duplicate functionality from mycroft-core, keeping list in sync +- +- WORKAROUND - skill_id missing in handle_intent_start, will keep list +- in sync by using the IntentAPI to check what skill the utterance +- should trigger +- +- handle_converse_response will take corrective action +- """ +- # NOTE borked in mycroft-core +- # needs https://github.com/MycroftAI/mycroft-core/pull/2786 +- skill_id = cls.intent_api.get_skill(message.data["utterances"][0]) +- if skill_id: +- # this skill will trigger and therefore is the last active skill +- cls.add_active_skill(skill_id) +- # will remove expired intents from list +- cls.filter_active_skills() +- +- @classmethod +- def handle_converse_response(cls, message): +- """ +- tracks last_conversed skill +- +- FAILSAFE - additional checks to correct active skills list, +- but that should never happen, accounts for mistakes in +- handle_utterance / intent_api +- +- accounts for https://github.com/MycroftAI/mycroft-core/pull/2786 +- not yet being merged +- """ +- skill_id = message.data["skill_id"] +- if 'error' in message.data: +- cls.handle_converse_error(message) +- elif message.data.get('result') is True: +- cls.last_conversed = skill_id +- if not cls.check_skill(skill_id): +- # seems like we missed adding this skill to active list +- # NOTE this is a failsafe and should never trigger +- # since this answered True we have the real timestamp +- cls.add_active_skill(skill_id) +- elif not cls.check_skill(skill_id): +- # seems like we missed adding this skill to active list +- # NOTE this is a failsafe and should never trigger +- # since this answered false and we don't have the real timestamp +- # let's add it to the end of the active_skills list +- ts = time.time() +- if len(cls.active_skills): +- ts = cls.active_skills[-1][1] +- cls.active_skills.append([skill_id, ts]) +- +- ++from ovos_utils.intents.intent_service_interface import IntentQueryApi, \ ++ IntentServiceInterface ++from ovos_utils.intents.converse import ConverseTracker ++from ovos_utils.intents.layers import IntentLayers +diff --git a/ovos_utils/intents/converse.py b/ovos_utils/intents/converse.py +new file mode 100644 +index 0000000..7970585 +--- /dev/null ++++ b/ovos_utils/intents/converse.py +@@ -0,0 +1,201 @@ ++import time ++ ++from ovos_utils.intents.intent_service_interface import IntentQueryApi ++from ovos_utils.log import LOG ++from ovos_utils.messagebus import Message ++ ++ ++class ConverseTracker: ++ """ Using the messagebus this class recreates/keeps track of the state ++ of the converse system, it uses both passive listening and active ++ queries to sync it's state, it also emits 2 new bus events ++ ++ Implements https://github.com/MycroftAI/mycroft-core/pull/1468 ++ """ ++ bus = None ++ active_skills = [] ++ converse_timeout = 5 # MAGIC NUMBER hard coded in mycroft-core ++ last_conversed = None ++ intent_api = None ++ ++ @classmethod ++ def connect_bus(cls, mycroft_bus): ++ """Registers the bus object to use.""" ++ # PATCH - in mycroft-core this would be handled in intent_service ++ # in here it is done in MycroftSkill.bind so i added this ++ # conditional check ++ if cls.bus is None and mycroft_bus is not None: ++ cls.bus = mycroft_bus ++ cls.intent_api = IntentQueryApi(cls.bus) ++ cls.register_bus_events() ++ ++ @classmethod ++ def register_bus_events(cls): ++ cls.bus.on('active_skill_request', cls.handle_activate_request) ++ cls.bus.on('skill.converse.response', cls.handle_converse_response) ++ cls.bus.on("mycroft.skill.handler.start", cls.handle_intent_start) ++ cls.bus.on("recognizer_loop:utterance", cls.handle_utterance) ++ ++ # public methods ++ @classmethod ++ def check_skill(cls, skill_id): ++ """ Check if a skill is active """ ++ cls.filter_active_skills() ++ for skill in list(cls.active_skills): ++ if skill[0] == skill_id: ++ return True ++ return False ++ ++ @classmethod ++ def filter_active_skills(cls): ++ """ Removes expired skills from active skill list """ ++ # filter timestamps ++ for skill in list(cls.active_skills): ++ if time.time() - skill[1] <= cls.converse_timeout * 60: ++ cls.remove_active_skill(skill[0]) ++ ++ @classmethod ++ def sync_with_intent_service(cls): ++ """sync active skill list using intent api ++ ++ WARNING ++ we don't have the timestamps so order might be messed up!! ++ avoid calling this until ++ """ ++ skill_ids = cls.intent_api.get_active_skills(include_timestamps=True) ++ if skill_ids: ++ if len(skill_ids[0]) == 2: ++ # PR was merged! hurray! ++ cls.active_skills = skill_ids ++ else: ++ # hoping they come sorted by timestamp.... ++ # older to newer (most recently used) ++ for skill_id in reversed(skill_ids): ++ # are we tracking this skill ? ++ if not cls.check_skill(skill_id): ++ # we missed adding this skill in our tracking ++ cls.add_active_skill(skill_id) ++ for skill in cls.active_skills: ++ if skill[0] not in skill_ids: ++ # we missed removing this skill in our tracking ++ cls.remove_active_skill(skill[0]) ++ ++ # https://github.com/MycroftAI/mycroft-core/pull/1468 ++ @classmethod ++ def remove_active_skill(cls, skill_id, silent=False): ++ """ ++ Emits "converse.skill.deactivated" event, improvement of #1468 ++ """ ++ for skill in list(cls.active_skills): ++ if skill[0] == skill_id: ++ cls.active_skills.remove(skill) ++ if not silent: ++ cls.bus.emit(Message("converse.skill.deactivated", ++ {"skill_id": skill[0]})) ++ ++ @classmethod ++ def add_active_skill(cls, skill_id): ++ """ ++ Emits "converse.skill.activated" event, improvement of #1468 ++ """ ++ # search the list for an existing entry that already contains it ++ # and remove that reference ++ if skill_id != '': ++ cls.remove_active_skill(skill_id, silent=True) ++ # add skill with timestamp to start of skill_list ++ cls.active_skills.insert(0, [skill_id, time.time()]) ++ # this might be sent more than once and it's perfectly fine ++ # it's just a new info message not consumed anywhere by default ++ cls.bus.emit(Message("converse.skill.activated", ++ {"skill_id": skill_id})) ++ else: ++ LOG.warning('Skill ID was empty, won\'t add to list of ' ++ 'active skills.') ++ ++ # status tracking ++ @classmethod ++ def handle_activate_request(cls, message): ++ """ ++ a skill bumped itself to the top of active skills list ++ duplicate functionality from mycroft-core, keeping list in sync ++ """ ++ skill_id = message.data["skill_id"] ++ cls.add_active_skill(skill_id) ++ ++ @classmethod ++ def handle_converse_error(cls, message): ++ """ ++ a skill was removed from active skill list due to converse error ++ duplicate functionality from mycroft-core, keeping list in sync ++ """ ++ skill_id = message.data["skill_id"] ++ if message.data["error"] == "skill id does not exist": ++ cls.remove_active_skill(skill_id) ++ ++ @classmethod ++ def handle_intent_start(cls, message): ++ """ ++ duplicate functionality from mycroft-core, keeping list in sync ++ ++ TODO skill_id from message, core is not passing it along... it used ++ to be possible to retrieve it from munged message but that changed. ++ send a PR (if those got merged this code wouldn't exist) ++ ++ handle_utterance will take over this functionality for now ++ handle_converse_response will take corrective action ++ """ ++ # skill_id = message.data["skill_id"] ++ # bump skill to top of active list ++ # cls.add_active_skill(skill_id) ++ ++ @classmethod ++ def handle_utterance(cls, message): ++ """ ++ duplicate functionality from mycroft-core, keeping list in sync ++ ++ WORKAROUND - skill_id missing in handle_intent_start, will keep list ++ in sync by using the IntentAPI to check what skill the utterance ++ should trigger ++ ++ handle_converse_response will take corrective action ++ """ ++ # NOTE borked in mycroft-core ++ # needs https://github.com/MycroftAI/mycroft-core/pull/2786 ++ skill_id = cls.intent_api.get_skill(message.data["utterances"][0]) ++ if skill_id: ++ # this skill will trigger and therefore is the last active skill ++ cls.add_active_skill(skill_id) ++ # will remove expired intents from list ++ cls.filter_active_skills() ++ ++ @classmethod ++ def handle_converse_response(cls, message): ++ """ ++ tracks last_conversed skill ++ ++ FAILSAFE - additional checks to correct active skills list, ++ but that should never happen, accounts for mistakes in ++ handle_utterance / intent_api ++ ++ accounts for https://github.com/MycroftAI/mycroft-core/pull/2786 ++ not yet being merged ++ """ ++ skill_id = message.data["skill_id"] ++ if 'error' in message.data: ++ cls.handle_converse_error(message) ++ elif message.data.get('result') is True: ++ cls.last_conversed = skill_id ++ if not cls.check_skill(skill_id): ++ # seems like we missed adding this skill to active list ++ # NOTE this is a failsafe and should never trigger ++ # since this answered True we have the real timestamp ++ cls.add_active_skill(skill_id) ++ elif not cls.check_skill(skill_id): ++ # seems like we missed adding this skill to active list ++ # NOTE this is a failsafe and should never trigger ++ # since this answered false and we don't have the real timestamp ++ # let's add it to the end of the active_skills list ++ ts = time.time() ++ if len(cls.active_skills): ++ ts = cls.active_skills[-1][1] ++ cls.active_skills.append([skill_id, ts]) +diff --git a/ovos_utils/intents/intent_service_interface.py b/ovos_utils/intents/intent_service_interface.py +new file mode 100644 +index 0000000..25eaceb +--- /dev/null ++++ b/ovos_utils/intents/intent_service_interface.py +@@ -0,0 +1,495 @@ ++from os.path import exists, isfile ++ ++from adapt.intent import Intent ++from mycroft_bus_client import MessageBusClient ++from mycroft_bus_client.message import Message, dig_for_message ++from ovos_utils import create_daemon ++from ovos_utils.log import LOG ++ ++ ++def to_alnum(skill_id): ++ """Convert a skill id to only alphanumeric characters ++ ++ Non alpha-numeric characters are converted to "_" ++ ++ Args: ++ skill_id (str): identifier to be converted ++ Returns: ++ (str) String of letters ++ """ ++ return ''.join(c if c.isalnum() else '_' for c in str(skill_id)) ++ ++ ++def munge_regex(regex, skill_id): ++ """Insert skill id as letters into match groups. ++ ++ Args: ++ regex (str): regex string ++ skill_id (str): skill identifier ++ Returns: ++ (str) munged regex ++ """ ++ base = '(?P<' + to_alnum(skill_id) ++ return base.join(regex.split('(?P<')) ++ ++ ++def munge_intent_parser(intent_parser, name, skill_id): ++ """Rename intent keywords to make them skill exclusive ++ This gives the intent parser an exclusive name in the ++ format :. The keywords are given unique ++ names in the format . ++ ++ The function will not munge instances that's already been ++ munged ++ ++ Args: ++ intent_parser: (IntentParser) object to update ++ name: (str) Skill name ++ skill_id: (int) skill identifier ++ """ ++ # Munge parser name ++ if not name.startswith(str(skill_id) + ':'): ++ intent_parser.name = str(skill_id) + ':' + name ++ else: ++ intent_parser.name = name ++ ++ # Munge keywords ++ skill_id = to_alnum(skill_id) ++ # Munge required keyword ++ reqs = [] ++ for i in intent_parser.requires: ++ if not i[0].startswith(skill_id): ++ kw = (skill_id + i[0], skill_id + i[0]) ++ reqs.append(kw) ++ else: ++ reqs.append(i) ++ intent_parser.requires = reqs ++ ++ # Munge optional keywords ++ opts = [] ++ for i in intent_parser.optional: ++ if not i[0].startswith(skill_id): ++ kw = (skill_id + i[0], skill_id + i[0]) ++ opts.append(kw) ++ else: ++ opts.append(i) ++ intent_parser.optional = opts ++ ++ # Munge at_least_one keywords ++ at_least_one = [] ++ for i in intent_parser.at_least_one: ++ element = [skill_id + e.replace(skill_id, '') for e in i] ++ at_least_one.append(tuple(element)) ++ intent_parser.at_least_one = at_least_one ++ ++ ++class IntentServiceInterface: ++ """Interface to communicate with the Mycroft intent service. ++ ++ This class wraps the messagebus interface of the intent service allowing ++ for easier interaction with the service. It wraps both the Adapt and ++ Padatious parts of the intent services. ++ """ ++ ++ def __init__(self, bus=None): ++ self.bus = bus ++ self.skill_id = self.__class__.__name__ ++ self.registered_intents = [] ++ self.detached_intents = [] ++ ++ def set_bus(self, bus): ++ self.bus = bus ++ ++ def set_id(self, skill_id): ++ self.skill_id = skill_id ++ ++ def register_adapt_keyword(self, vocab_type, entity, aliases=None, ++ lang=None): ++ """Send a message to the intent service to add an Adapt keyword. ++ ++ vocab_type(str): Keyword reference ++ entity (str): Primary keyword ++ aliases (list): List of alternative kewords ++ """ ++ aliases = aliases or [] ++ msg = dig_for_message() or Message("") ++ if "skill_id" not in msg.context: ++ msg.context["skill_id"] = self.skill_id ++ self.bus.emit(msg.forward("register_vocab", ++ {'start': entity, ++ 'end': vocab_type, ++ 'lang': lang})) ++ for alias in aliases: ++ self.bus.emit(msg.forward("register_vocab", ++ {'start': alias, ++ 'end': vocab_type, ++ 'alias_of': entity, ++ 'lang': lang})) ++ ++ def register_adapt_regex(self, regex, lang=None): ++ """Register a regex with the intent service. ++ ++ Args: ++ regex (str): Regex to be registered, (Adapt extracts keyword ++ reference from named match group. ++ """ ++ msg = dig_for_message() or Message("") ++ if "skill_id" not in msg.context: ++ msg.context["skill_id"] = self.skill_id ++ self.bus.emit(msg.forward("register_vocab", ++ {'regex': regex, 'lang': lang})) ++ ++ def register_adapt_intent(self, name, intent_parser): ++ """Register an Adapt intent parser object. ++ ++ Serializes the intent_parser and sends it over the messagebus to ++ registered. ++ """ ++ msg = dig_for_message() or Message("") ++ if "skill_id" not in msg.context: ++ msg.context["skill_id"] = self.skill_id ++ self.bus.emit(msg.forward("register_intent", intent_parser.__dict__)) ++ self.registered_intents.append((name, intent_parser)) ++ self.detached_intents = [detached for detached in self.detached_intents ++ if detached[0] != name] ++ ++ def detach_intent(self, intent_name): ++ """Remove an intent from the intent service. ++ ++ The intent is saved in the list of detached intents for use when ++ re-enabling an intent. ++ ++ Args: ++ intent_name(str): Intent reference ++ """ ++ name = intent_name.split(':')[1] ++ if name in self: ++ msg = dig_for_message() or Message("") ++ if "skill_id" not in msg.context: ++ msg.context["skill_id"] = self.skill_id ++ self.bus.emit(msg.forward("detach_intent", ++ {"intent_name": intent_name})) ++ self.detached_intents.append((name, self.get_intent(name))) ++ self.registered_intents = [pair for pair in self.registered_intents ++ if pair[0] != name] ++ ++ def set_adapt_context(self, context, word, origin): ++ """Set an Adapt context. ++ ++ Args: ++ context (str): context keyword name ++ word (str): word to register ++ origin (str): original origin of the context (for cross context) ++ """ ++ msg = dig_for_message() or Message("") ++ if "skill_id" not in msg.context: ++ msg.context["skill_id"] = self.skill_id ++ self.bus.emit(msg.forward('add_context', ++ {'context': context, 'word': word, ++ 'origin': origin})) ++ ++ def remove_adapt_context(self, context): ++ """Remove an active Adapt context. ++ ++ Args: ++ context(str): name of context to remove ++ """ ++ msg = dig_for_message() or Message("") ++ if "skill_id" not in msg.context: ++ msg.context["skill_id"] = self.skill_id ++ self.bus.emit(msg.forward('remove_context', {'context': context})) ++ ++ def register_padatious_intent(self, intent_name, filename, lang): ++ """Register a padatious intent file with Padatious. ++ ++ Args: ++ intent_name(str): intent identifier ++ filename(str): complete file path for entity file ++ """ ++ if not isinstance(filename, str): ++ raise ValueError('Filename path must be a string') ++ if not exists(filename): ++ raise FileNotFoundError('Unable to find "{}"'.format(filename)) ++ ++ data = {'file_name': filename, ++ 'name': intent_name, ++ 'lang': lang} ++ msg = dig_for_message() or Message("") ++ if "skill_id" not in msg.context: ++ msg.context["skill_id"] = self.skill_id ++ self.bus.emit(msg.forward("padatious:register_intent", data)) ++ self.registered_intents.append((intent_name.split(':')[-1], data)) ++ ++ def register_padatious_entity(self, entity_name, filename, lang): ++ """Register a padatious entity file with Padatious. ++ ++ Args: ++ entity_name(str): entity name ++ filename(str): complete file path for entity file ++ """ ++ if not isinstance(filename, str): ++ raise ValueError('Filename path must be a string') ++ if not exists(filename): ++ raise FileNotFoundError('Unable to find "{}"'.format(filename)) ++ msg = dig_for_message() or Message("") ++ if "skill_id" not in msg.context: ++ msg.context["skill_id"] = self.skill_id ++ self.bus.emit(msg.forward('padatious:register_entity', ++ {'file_name': filename, ++ 'name': entity_name, ++ 'lang': lang})) ++ ++ def __iter__(self): ++ """Iterator over the registered intents. ++ ++ Returns an iterator returning name-handler pairs of the registered ++ intent handlers. ++ """ ++ return iter(self.registered_intents) ++ ++ def __contains__(self, val): ++ """Checks if an intent name has been registered.""" ++ return val in [i[0] for i in self.registered_intents] ++ ++ def get_intent(self, intent_name): ++ """Get intent from intent_name. ++ ++ This will find both enabled and disabled intents. ++ ++ Args: ++ intent_name (str): name to find. ++ ++ Returns: ++ Found intent or None if none were found. ++ """ ++ for name, intent in self: ++ if name == intent_name: ++ return intent ++ for name, intent in self.detached_intents: ++ if name == intent_name: ++ return intent ++ return None ++ ++ ++class IntentQueryApi: ++ """ ++ Query Intent Service at runtime ++ """ ++ ++ def __init__(self, bus=None, timeout=5): ++ if bus is None: ++ bus = MessageBusClient() ++ create_daemon(bus.run_forever) ++ self.bus = bus ++ self.timeout = timeout ++ ++ def get_adapt_intent(self, utterance, lang="en-us"): ++ """ get best adapt intent for utterance """ ++ msg = Message("intent.service.adapt.get", ++ {"utterance": utterance, "lang": lang}, ++ context={"destination": "intent_service", ++ "source": "intent_api"}) ++ ++ resp = self.bus.wait_for_response(msg, ++ 'intent.service.adapt.reply', ++ timeout=self.timeout) ++ data = resp.data if resp is not None else {} ++ if not data: ++ LOG.error("Intent Service timed out!") ++ return None ++ return data["intent"] ++ ++ def get_padatious_intent(self, utterance, lang="en-us"): ++ """ get best padatious intent for utterance """ ++ msg = Message("intent.service.padatious.get", ++ {"utterance": utterance, "lang": lang}, ++ context={"destination": "intent_service", ++ "source": "intent_api"}) ++ resp = self.bus.wait_for_response(msg, ++ 'intent.service.padatious.reply', ++ timeout=self.timeout) ++ data = resp.data if resp is not None else {} ++ if not data: ++ LOG.error("Intent Service timed out!") ++ return None ++ return data["intent"] ++ ++ def get_intent(self, utterance, lang="en-us"): ++ """ get best intent for utterance """ ++ msg = Message("intent.service.intent.get", ++ {"utterance": utterance, "lang": lang}, ++ context={"destination": "intent_service", ++ "source": "intent_api"}) ++ resp = self.bus.wait_for_response(msg, ++ 'intent.service.intent.reply', ++ timeout=self.timeout) ++ data = resp.data if resp is not None else {} ++ if not data: ++ LOG.error("Intent Service timed out!") ++ return None ++ return data["intent"] ++ ++ def get_skill(self, utterance, lang="en-us"): ++ """ get skill that utterance will trigger """ ++ intent = self.get_intent(utterance, lang) ++ if not intent: ++ return None ++ # theoretically skill_id might be missing ++ if intent.get("skill_id"): ++ return intent["skill_id"] ++ # retrieve skill from munged intent name ++ if intent.get("intent_name"): # padatious + adapt ++ return intent["name"].split(":")[0] ++ if intent.get("intent_type"): # adapt ++ return intent["intent_type"].split(":")[0] ++ return None # raise some error here maybe? this should never happen ++ ++ def get_skills_manifest(self): ++ msg = Message("intent.service.skills.get", ++ context={"destination": "intent_service", ++ "source": "intent_api"}) ++ resp = self.bus.wait_for_response(msg, ++ 'intent.service.skills.reply', ++ timeout=self.timeout) ++ data = resp.data if resp is not None else {} ++ if not data: ++ LOG.error("Intent Service timed out!") ++ return None ++ return data["skills"] ++ ++ def get_active_skills(self, include_timestamps=False): ++ msg = Message("intent.service.active_skills.get", ++ context={"destination": "intent_service", ++ "source": "intent_api"}) ++ resp = self.bus.wait_for_response(msg, ++ 'intent.service.active_skills.reply', ++ timeout=self.timeout) ++ data = resp.data if resp is not None else {} ++ if not data: ++ LOG.error("Intent Service timed out!") ++ return None ++ if include_timestamps: ++ return data["skills"] ++ return [s[0] for s in data["skills"]] ++ ++ def get_adapt_manifest(self): ++ msg = Message("intent.service.adapt.manifest.get", ++ context={"destination": "intent_service", ++ "source": "intent_api"}) ++ resp = self.bus.wait_for_response(msg, ++ 'intent.service.adapt.manifest', ++ timeout=self.timeout) ++ data = resp.data if resp is not None else {} ++ if not data: ++ LOG.error("Intent Service timed out!") ++ return None ++ return data["intents"] ++ ++ def get_padatious_manifest(self): ++ msg = Message("intent.service.padatious.manifest.get", ++ context={"destination": "intent_service", ++ "source": "intent_api"}) ++ resp = self.bus.wait_for_response(msg, ++ 'intent.service.padatious.manifest', ++ timeout=self.timeout) ++ data = resp.data if resp is not None else {} ++ if not data: ++ LOG.error("Intent Service timed out!") ++ return None ++ return data["intents"] ++ ++ def get_intent_manifest(self): ++ padatious = self.get_padatious_manifest() ++ adapt = self.get_adapt_manifest() ++ return {"adapt": adapt, ++ "padatious": padatious} ++ ++ def get_vocab_manifest(self): ++ msg = Message("intent.service.adapt.vocab.manifest.get", ++ context={"destination": "intent_service", ++ "source": "intent_api"}) ++ reply_msg_type = 'intent.service.adapt.vocab.manifest' ++ resp = self.bus.wait_for_response(msg, ++ reply_msg_type, ++ timeout=self.timeout) ++ data = resp.data if resp is not None else {} ++ if not data: ++ LOG.error("Intent Service timed out!") ++ return None ++ ++ vocab = {} ++ for voc in data["vocab"]: ++ if voc.get("regex"): ++ continue ++ if voc["end"] not in vocab: ++ vocab[voc["end"]] = {"samples": []} ++ vocab[voc["end"]]["samples"].append(voc["start"]) ++ return [{"name": voc, "samples": vocab[voc]["samples"]} ++ for voc in vocab] ++ ++ def get_regex_manifest(self): ++ msg = Message("intent.service.adapt.vocab.manifest.get", ++ context={"destination": "intent_service", ++ "source": "intent_api"}) ++ reply_msg_type = 'intent.service.adapt.vocab.manifest' ++ resp = self.bus.wait_for_response(msg, ++ reply_msg_type, ++ timeout=self.timeout) ++ data = resp.data if resp is not None else {} ++ if not data: ++ LOG.error("Intent Service timed out!") ++ return None ++ ++ vocab = {} ++ for voc in data["vocab"]: ++ if not voc.get("regex"): ++ continue ++ name = voc["regex"].split("(?P<")[-1].split(">")[0] ++ if name not in vocab: ++ vocab[name] = {"samples": []} ++ vocab[name]["samples"].append(voc["regex"]) ++ return [{"name": voc, "regexes": vocab[voc]["samples"]} ++ for voc in vocab] ++ ++ def get_entities_manifest(self): ++ msg = Message("intent.service.padatious.entities.manifest.get", ++ context={"destination": "intent_service", ++ "source": "intent_api"}) ++ reply_msg_type = 'intent.service.padatious.entities.manifest' ++ resp = self.bus.wait_for_response(msg, ++ reply_msg_type, ++ timeout=self.timeout) ++ data = resp.data if resp is not None else {} ++ if not data: ++ LOG.error("Intent Service timed out!") ++ return None ++ ++ entities = [] ++ # read files ++ for ent in data["entities"]: ++ if isfile(ent["file_name"]): ++ with open(ent["file_name"]) as f: ++ lines = f.read().replace("(", "").replace(")", "").split( ++ "\n") ++ samples = [] ++ for l in lines: ++ samples += [a.strip() for a in l.split("|") if a.strip()] ++ entities.append({"name": ent["name"], "samples": samples}) ++ return entities ++ ++ def get_keywords_manifest(self): ++ padatious = self.get_entities_manifest() ++ adapt = self.get_vocab_manifest() ++ regex = self.get_regex_manifest() ++ return {"adapt": adapt, ++ "padatious": padatious, ++ "regex": regex} ++ ++ ++def open_intent_envelope(message): ++ """Convert dictionary received over messagebus to Intent.""" ++ intent_dict = message.data ++ return Intent(intent_dict.get('name'), ++ intent_dict.get('requires'), ++ intent_dict.get('at_least_one'), ++ intent_dict.get('optional')) + +From c3b7ce30d1060251d1f3db894adcc780f10a7a1f Mon Sep 17 00:00:00 2001 +From: jarbasal +Date: Thu, 30 Sep 2021 00:55:13 +0100 +Subject: [PATCH 2/5] dialog utils + +--- + ovos_utils/dialog.py | 193 ++++++++++++++++++++++++++++++++++++ + ovos_utils/lang/__init__.py | 26 +++++ + 2 files changed, 219 insertions(+) + create mode 100644 ovos_utils/dialog.py + +diff --git a/ovos_utils/dialog.py b/ovos_utils/dialog.py +new file mode 100644 +index 0000000..7b51900 +--- /dev/null ++++ b/ovos_utils/dialog.py +@@ -0,0 +1,193 @@ ++import os ++import random ++import re ++from os.path import join ++from pathlib import Path ++ ++from ovos_utils.bracket_expansion import expand_options ++from ovos_utils.configuration import read_mycroft_config ++from ovos_utils.file_utils import resolve_resource_file ++from ovos_utils.lang import translate_word ++from ovos_utils.log import LOG ++ ++ ++class MustacheDialogRenderer: ++ """A dialog template renderer based on the mustache templating language.""" ++ ++ def __init__(self): ++ self.templates = {} ++ self.recent_phrases = [] ++ ++ # TODO magic numbers are bad! ++ self.max_recent_phrases = 3 ++ # We cycle through lines in .dialog files to keep Mycroft from ++ # repeating the same phrase over and over. However, if a .dialog ++ # file only contains a few entries, this can cause it to loop. ++ # ++ # This offset will override max_recent_phrases on very short .dialog ++ # files. With the offset at 2, .dialog files with 3 or more lines will ++ # be managed to avoid repetition, but .dialog files with only 1 or 2 ++ # lines will be unaffected. Dialog should never get stuck in a loop. ++ self.loop_prevention_offset = 2 ++ ++ def load_template_file(self, template_name, filename): ++ """Load a template by file name into the templates cache. ++ ++ Args: ++ template_name (str): a unique identifier for a group of templates ++ filename (str): a fully qualified filename of a mustache template. ++ """ ++ with open(filename, 'r', encoding='utf8') as f: ++ for line in f: ++ template_text = line.strip() ++ # Skip all lines starting with '#' and all empty lines ++ if (not template_text.startswith('#') and ++ template_text != ''): ++ if template_name not in self.templates: ++ self.templates[template_name] = [] ++ ++ # convert to standard python format string syntax. From ++ # double (or more) '{' followed by any number of ++ # whitespace followed by actual key followed by any number ++ # of whitespace followed by double (or more) '}' ++ template_text = re.sub(r'\{\{+\s*(.*?)\s*\}\}+', r'{\1}', ++ template_text) ++ ++ self.templates[template_name].append(template_text) ++ ++ def render(self, template_name, context=None, index=None): ++ """ ++ Given a template name, pick a template and render it using the context. ++ If no matching template exists use template_name as template. ++ ++ Tries not to let Mycroft say exactly the same thing twice in a row. ++ ++ Args: ++ template_name (str): the name of a template group. ++ context (dict): dictionary representing values to be rendered ++ index (int): optional, the specific index in the collection of ++ templates ++ ++ Returns: ++ str: the rendered string ++ """ ++ context = context or {} ++ if template_name not in self.templates: ++ # When not found, return the name itself as the dialog ++ # This allows things like render("record.not.found") to either ++ # find a translation file "record.not.found.dialog" or return ++ # "record not found" literal. ++ return template_name.replace('.', ' ') ++ ++ # Get the .dialog file's contents, minus any which have been spoken ++ # recently. ++ template_functions = self.templates.get(template_name) ++ ++ if index is None: ++ template_functions = ([t for t in template_functions ++ if t not in self.recent_phrases] or ++ template_functions) ++ line = random.choice(template_functions) ++ else: ++ line = template_functions[index % len(template_functions)] ++ # Replace {key} in line with matching values from context ++ line = line.format(**context) ++ line = random.choice(expand_options(line)) ++ ++ # Here's where we keep track of what we've said recently. Remember, ++ # this is by line in the .dialog file, not by exact phrase ++ self.recent_phrases.append(line) ++ if (len(self.recent_phrases) > ++ min(self.max_recent_phrases, len(self.templates.get( ++ template_name)) - self.loop_prevention_offset)): ++ self.recent_phrases.pop(0) ++ return line ++ ++ ++def load_dialogs(dialog_dir, renderer=None): ++ """Load all dialog files within the specified directory. ++ ++ Args: ++ dialog_dir (str): directory that contains dialog files ++ ++ Returns: ++ a loaded instance of a dialog renderer ++ """ ++ if renderer is None: ++ renderer = MustacheDialogRenderer() ++ ++ directory = Path(dialog_dir) ++ if not directory.exists() or not directory.is_dir(): ++ LOG.warning('No dialog files found: {}'.format(dialog_dir)) ++ return renderer ++ ++ for path, _, files in os.walk(str(directory)): ++ for f in files: ++ if f.endswith('.dialog'): ++ renderer.load_template_file(f.replace('.dialog', ''), ++ join(path, f)) ++ return renderer ++ ++ ++def get_dialog(phrase, lang=None, context=None): ++ """Looks up a resource file for the given phrase. ++ ++ If no file is found, the requested phrase is returned as the string. This ++ will use the default language for translations. ++ ++ Args: ++ phrase (str): resource phrase to retrieve/translate ++ lang (str): the language to use ++ context (dict): values to be inserted into the string ++ ++ Returns: ++ str: a randomized and/or translated version of the phrase ++ """ ++ ++ if not lang: ++ try: ++ conf = read_mycroft_config() ++ lang = conf.get('lang') ++ except FileNotFoundError: ++ lang = "en-us" ++ ++ filename = join('text', lang.lower(), phrase + '.dialog') ++ template = resolve_resource_file(filename) ++ if not template: ++ LOG.debug('Resource file not found: {}'.format(filename)) ++ return phrase ++ ++ stache = MustacheDialogRenderer() ++ stache.load_template_file('template', template) ++ if not context: ++ context = {} ++ return stache.render('template', context) ++ ++ ++def join_list(items, connector, sep=None, lang=''): ++ """ Join a list into a phrase using the given connector word ++ Examples: ++ join_list([1,2,3], "and") -> "1, 2 and 3" ++ join_list([1,2,3], "and", ";") -> "1; 2 and 3" ++ Args: ++ items (array): items to be joined ++ connector (str): connecting word (resource name), like "and" or "or" ++ sep (str, optional): separator character, default = "," ++ lang (str, optional): an optional BCP-47 language code, if omitted ++ the default language will be used. ++ Returns: ++ str: the connected list phrase ++ """ ++ ++ if not items: ++ return "" ++ if len(items) == 1: ++ return str(items[0]) ++ ++ if not sep: ++ sep = ", " ++ else: ++ sep += " " ++ return (sep.join(str(item) for item in items[:-1]) + ++ " " + translate_word(connector, lang) + ++ " " + items[-1]) +diff --git a/ovos_utils/lang/__init__.py b/ovos_utils/lang/__init__.py +index 28c8235..b43ccae 100644 +--- a/ovos_utils/lang/__init__.py ++++ b/ovos_utils/lang/__init__.py +@@ -2,6 +2,7 @@ + from os import system, makedirs, listdir + from ovos_utils.lang.detect import detect_lang + from ovos_utils.lang.translate import translate_text ++from ovos_utils.file_utils import resolve_resource_file + + + def get_tts(sentence, lang="en-us", mp3_file="/tmp/google_tx_tts.mp3"): +@@ -42,3 +43,28 @@ def get_language_dir(base_path, lang="en-us"): + if len(paths): + return paths[0] + return join(base_path, lang) ++ ++ ++def translate_word(name, lang='en-us'): ++ """ Helper to get word translations ++ Args: ++ name (str): Word name. Returned as the default value if not translated ++ lang (str, optional): an optional BCP-47 language code, if omitted ++ the default language will be used. ++ Returns: ++ str: translated version of resource name ++ """ ++ filename = resolve_resource_file(join("text", lang, name + ".word")) ++ if filename: ++ # open the file ++ try: ++ with open(filename, 'r', encoding='utf8') as f: ++ for line in f: ++ word = line.strip() ++ if word.startswith("#"): ++ continue # skip comment lines ++ return word ++ except Exception: ++ pass ++ return name # use resource name as the word ++ + +From 3b271d6fa3c95b311b11438ab63be7ac1371e6ed Mon Sep 17 00:00:00 2001 +From: jarbasal +Date: Thu, 30 Sep 2021 06:17:20 +0100 +Subject: [PATCH 3/5] circular import + +--- + ovos_utils/file_utils.py | 5 ++++- + 1 file changed, 4 insertions(+), 1 deletion(-) + +diff --git a/ovos_utils/file_utils.py b/ovos_utils/file_utils.py +index de9122a..9b3c44b 100644 +--- a/ovos_utils/file_utils.py ++++ b/ovos_utils/file_utils.py +@@ -6,7 +6,6 @@ + from os.path import splitext, join, dirname + + from ovos_utils.bracket_expansion import expand_options +-from ovos_utils.intents.intent_service_interface import to_alnum, munge_regex + from ovos_utils.log import LOG + + +@@ -135,6 +134,8 @@ def load_regex_from_file(path, skill_id): + path: path to vocabulary file (*.voc) + skill_id: skill_id to the regex is tied to + """ ++ from ovos_utils.intents.intent_service_interface import munge_regex ++ + regexes = [] + if path.endswith('.rx'): + with open(path, 'r', encoding='utf8') as reg_file: +@@ -163,6 +164,8 @@ def load_vocabulary(basedir, skill_id): + Returns: + dict with intent_type as keys and list of list of lists as value. + """ ++ from ovos_utils.intents.intent_service_interface import to_alnum ++ + vocabs = {} + for path, _, files in walk(basedir): + for f in files: + +From 1771ed406432ae85cae172c19e255f915c730a7d Mon Sep 17 00:00:00 2001 +From: jarbasal +Date: Thu, 30 Sep 2021 16:43:59 +0100 +Subject: [PATCH 4/5] delayed bus init + +--- + ovos_utils/gui.py | 32 ++++++++++++++++++++++++++++---- + 1 file changed, 28 insertions(+), 4 deletions(-) + +diff --git a/ovos_utils/gui.py b/ovos_utils/gui.py +index 9b4a472..ea62f9c 100644 +--- a/ovos_utils/gui.py ++++ b/ovos_utils/gui.py +@@ -429,7 +429,7 @@ class GUIInterface: + """ + + def __init__(self, skill_id, bus=None, remote_server=None, config=None): +- self.bus = bus or get_mycroft_bus() ++ self.bus = bus + self.__session_data = {} # synced to GUI for use by this skill's pages + self.pages = [] + self.current_page_idx = -1 +@@ -437,6 +437,11 @@ def __init__(self, skill_id, bus=None, remote_server=None, config=None): + self.on_gui_changed_callback = None + self.remote_url = remote_server + self._events = [] ++ if bus: ++ self.set_bus(bus) ++ ++ def set_bus(self, bus=None): ++ self.bus = bus or get_mycroft_bus() + self.setup_default_handlers() + + @property +@@ -448,6 +453,8 @@ def page(self): + def connected(self): + """Returns True if at least 1 remote gui is connected or if gui is + installed and running locally, else False""" ++ if not self.bus: ++ return False + return can_use_gui(self.bus) + + def build_message_type(self, event): +@@ -475,6 +482,8 @@ def register_handler(self, event, handler): + event (str): event to catch + handler: function to handle the event + """ ++ if not self.bus: ++ raise RuntimeError("bus not set, did you call self.bind() ?") + event = self.build_message_type(event) + self._events.append((event, handler)) + self.bus.on(event, handler) +@@ -501,6 +510,8 @@ def gui_set(self, message): + self.on_gui_changed_callback() + + def _sync_data(self): ++ if not self.bus: ++ raise RuntimeError("bus not set, did you call self.bind() ?") + data = self.__session_data.copy() + data.update({'__from': self.skill_id}) + self.bus.emit(Message("gui.value.set", data)) +@@ -539,6 +550,8 @@ def clear(self): + self.__session_data = {} + self.pages = [] + self.current_page_idx = -1 ++ if not self.bus: ++ raise RuntimeError("bus not set, did you call self.bind() ?") + self.bus.emit(Message("gui.clear.namespace", + {"__from": self.skill_id})) + +@@ -551,6 +564,8 @@ def send_event(self, event_name, params=None): + should be sent along with the request. + """ + params = params or {} ++ if not self.bus: ++ raise RuntimeError("bus not set, did you call self.bind() ?") + self.bus.emit(Message("gui.event.send", + {"__from": self.skill_id, + "event_name": event_name, +@@ -610,6 +625,8 @@ def show_pages(self, page_names, index=0, override_idle=None, + True: Disables showing all platform skill animations. + False: 'Default' always show animations. + """ ++ if not self.bus: ++ raise RuntimeError("bus not set, did you call self.bind() ?") + if isinstance(page_names, str): + page_names = [page_names] + if not isinstance(page_names, list): +@@ -649,6 +666,8 @@ def remove_pages(self, page_names): + page_names (list): List of page names (str) to display, such as + ["Weather.qml", "Forecast.qml", "Other.qml"] + """ ++ if not self.bus: ++ raise RuntimeError("bus not set, did you call self.bind() ?") + if not isinstance(page_names, list): + page_names = [page_names] + page_urls = self._pages2uri(page_names) +@@ -671,6 +690,8 @@ def show_notification(self, content, action=None, + transient: 'Default' displays a notification with a timeout. + sticky: displays a notification that sticks to the screen. + """ ++ if not self.bus: ++ raise RuntimeError("bus not set, did you call self.bind() ?") + self.bus.emit(Message("homescreen.notification.set", + data={ + "sender": self.skill_id, +@@ -794,6 +815,8 @@ def release(self): + allow different platforms to properly handle this event. + Also calls self.clear() to reset the state variables + Platforms can close the window or go back to previous page""" ++ if not self.bus: ++ raise RuntimeError("bus not set, did you call self.bind() ?") + self.clear() + self.bus.emit(Message("mycroft.gui.screen.close", + {"skill_id": self.skill_id})) +@@ -803,9 +826,10 @@ def shutdown(self): + + Clear pages loaded through this interface and remove the bus events + """ +- self.release() +- for event, handler in self._events: +- self.bus.remove(event, handler) ++ if self.bus: ++ self.release() ++ for event, handler in self._events: ++ self.bus.remove(event, handler) + + + if __name__ == "__main__": + +From 6db561b684ee88fbf3bb9bd9906bf25d95432873 Mon Sep 17 00:00:00 2001 +From: jarbasal +Date: Fri, 1 Oct 2021 15:46:26 +0100 +Subject: [PATCH 5/5] added some helper functions for intent handling / + disabling + +--- + ovos_utils/intents/intent_service_interface.py | 16 +++++++++++++++- + 1 file changed, 15 insertions(+), 1 deletion(-) + +diff --git a/ovos_utils/intents/intent_service_interface.py b/ovos_utils/intents/intent_service_interface.py +index 25eaceb..a014880 100644 +--- a/ovos_utils/intents/intent_service_interface.py ++++ b/ovos_utils/intents/intent_service_interface.py +@@ -162,7 +162,12 @@ def detach_intent(self, intent_name): + Args: + intent_name(str): Intent reference + """ +- name = intent_name.split(':')[1] ++ # split skill_id if needed ++ if intent_name not in self and ":" in intent_name: ++ name = intent_name.split(':')[1] ++ else: ++ name = intent_name ++ + if name in self: + msg = dig_for_message() or Message("") + if "skill_id" not in msg.context: +@@ -251,6 +256,15 @@ def __contains__(self, val): + """Checks if an intent name has been registered.""" + return val in [i[0] for i in self.registered_intents] + ++ def get_intent_names(self): ++ return [i[0] for i in self.registered_intents] ++ ++ def detach_all(self): ++ for name in self.get_intent_names(): ++ self.detach_intent(name) ++ self.registered_intents = [] ++ self.detached_intents = [] ++ + def get_intent(self, intent_name): + """Get intent from intent_name. +