diff --git a/README.md b/README.md index 25144e8..6f3f332 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ Scanned folders: ## Examples * "Open Volume Control" * "Launch Firefox" +* "Close Firefox" ## Category **Productivity** diff --git a/__init__.py b/__init__.py index 6486e9f..e32bdde 100644 --- a/__init__.py +++ b/__init__.py @@ -1,9 +1,16 @@ import os +import shlex +import subprocess +import time from os import listdir from os.path import expanduser, isdir, join +import psutil +from langcodes import closest_match +from ovos_utils.bracket_expansion import expand_options +from ovos_utils.lang import standardize_lang_tag from ovos_utils.log import LOG -from ovos_utils.parse import match_one +from ovos_utils.parse import match_one, fuzzy_match from ovos_workshop.skills.fallback import FallbackSkill from padacioso import IntentContainer @@ -31,14 +38,20 @@ def initialize(self): self.register_fallback(self.handle_fallback, 4) def register_fallback_intents(self): - # TODO close application intent + intents = ["close", "launch"] for lang in os.listdir(f"{self.root_dir}/locale"): - self.intent_matchers[lang] = IntentContainer() - launch = join(self.root_dir, "locale", self.lang, "launch.intent") - with open(launch) as f: - samples = [l for l in f.read().split("\n") - if not l.startswith("#") and l.strip()] - self.intent_matchers[lang].add_intent('launch', samples) + for intent_name in intents: + launch = join(self.root_dir, "locale", lang, f"{intent_name}.intent") + if not os.path.isfile(launch): + continue + lang = standardize_lang_tag(lang) + if lang not in self.intent_matchers: + self.intent_matchers[lang] = IntentContainer() + with open(launch) as f: + samples = [option for line in f.read().split("\n") + if not line.startswith("#") and line.strip() + for option in expand_options(line)] + self.intent_matchers[lang].add_intent(intent_name, samples) def get_app_aliases(self): apps = self.settings.get("user_commands") or {} @@ -55,8 +68,8 @@ def get_app_aliases(self): is_app = True if os.path.isdir(path): continue - with open(path) as f: - for l in f.read().split("\n"): + with open(path) as fi: + for l in fi.read().split("\n"): if "Name=" in l: name = l.split("Name=")[-1] names.append(norm(name)) @@ -91,26 +104,73 @@ def get_app_aliases(self): LOG.debug(f"found app {f} with aliases: {names}") return apps + def launch_app(self, app: str) -> bool: + applist = self.get_app_aliases() + cmd, score = match_one(app.title(), applist) + if score >= self.settings.get("thresh", 0.85): + LOG.info(f"Matched application: {app} (command: {cmd})") + try: + # Launch the application in a new process without blocking + subprocess.Popen(shlex.split(cmd)) + return True + except Exception as e: + LOG.error(f"Failed to launch {app}: {e}") + return False + + def close_app(self, app: str) -> bool: + """Close the application with the given name.""" + applist = self.get_app_aliases() + + cmd, _ = match_one(app.title(), applist) + cmd = cmd.split(" ")[0].split("/")[-1] + terminated = [] + + for proc in psutil.process_iter(['pid', 'name']): + score = fuzzy_match(cmd, proc.info['name']) + if score > 0.9: + LOG.debug(f"Matched '{app}' to {proc}") + try: + LOG.info(f"Terminating process: {proc.info['name']} (PID: {proc.info['pid']})") + proc.terminate() # or process.kill() to forcefully kill + terminated.append(proc.info['pid']) + + except (psutil.NoSuchProcess, psutil.AccessDenied): + LOG.error(f"Failed to terminate {proc}") + + if terminated: + LOG.debug(f"Terminated PIDs: {terminated}") + return True + return False + def handle_fallback(self, message): utterance = message.data.get("utterance", "") - if self.lang not in self.intent_matchers: + best_lang, score = closest_match(self.lang, list(self.intent_matchers.keys())) + + if score >= 10: + # unsupported lang return False - res = self.intent_matchers[self.lang].calc_intent(utterance) + best_lang = standardize_lang_tag(best_lang) + + res = self.intent_matchers[best_lang].calc_intent(utterance) + app = res.get('entities', {}).get("application") if app: - applist = self.get_app_aliases() - cmd, score = match_one(app.title(), applist) - if score >= self.settings.get("thresh", 0.85): - LOG.info(f"Executing command: {cmd}") - os.system(cmd) - return True + if res["name"] == "launch": + return self.launch_app(app) + elif res["name"] == "close": + return self.close_app(app) if __name__ == "__main__": + LOG.set_level("DEBUG") from ovos_utils.fakebus import FakeBus from ovos_bus_client.message import Message s = ApplicationLauncherSkill(skill_id="fake.test", bus=FakeBus()) - s.handle_fallback(Message("", {"utterance": "launch firefox"})) + s.handle_fallback(Message("", {"utterance": "abrir firefox", "lang": "pt-pt"})) + time.sleep(2) + # s.handle_fallback(Message("", {"utterance": "kill firefox"})) + time.sleep(2) + s.handle_fallback(Message("", {"utterance": "launch firefox", "lang": "en-UK"})) diff --git a/locale/en-us/close.intent b/locale/en-us/close.intent new file mode 100644 index 0000000..17edaef --- /dev/null +++ b/locale/en-us/close.intent @@ -0,0 +1,5 @@ +close {application} +terminate {application} +kill {application} +exit {application} +quit {application} \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 743b627..8c6208e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,4 @@ padacioso>=0.1.1 -ovos-workshop>=0.0.15,<2.0.0 \ No newline at end of file +ovos-workshop>=0.0.15,<2.0.0 +ovos-utils>=0.3.5 +psutil \ No newline at end of file diff --git a/translations/en-us/intents.json b/translations/en-us/intents.json index b3255cd..9617ab2 100644 --- a/translations/en-us/intents.json +++ b/translations/en-us/intents.json @@ -1,7 +1,14 @@ { - "launch.intent": [ - "launch {application}", - "open {application}", - "run {application}" - ] + "launch.intent": [ + "launch {application}", + "open {application}", + "run {application}" + ], + "close.intent": [ + "close {application}", + "terminate {application}", + "kill {application}", + "exit {application}", + "quit {application}" + ] } \ No newline at end of file