diff --git a/packages/asyntai-ai-assistant/0.1.0/LICENSE b/packages/asyntai-ai-assistant/0.1.0/LICENSE new file mode 100644 index 00000000..6a5c63c7 --- /dev/null +++ b/packages/asyntai-ai-assistant/0.1.0/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Asyntai + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/asyntai-ai-assistant/0.1.0/README.md b/packages/asyntai-ai-assistant/0.1.0/README.md new file mode 100644 index 00000000..9a402d55 --- /dev/null +++ b/packages/asyntai-ai-assistant/0.1.0/README.md @@ -0,0 +1,39 @@ +# Asyntai AI Assistant + +[Asyntai](https://asyntai.com) is an AI chatbot for your website. It answers your visitors from your own pages, docs and FAQ. This package brings those answers into every text field on your computer: your email, your help desk, Slack, Word, a browser form. + +Type `:ask`, write a question, and Espanso types the answer your chatbot gives, with the same facts, prices and links your visitors get. + +## Triggers + +- `:ask` opens a small form. Write a question, press Enter, and the answer is typed where your cursor is. +- `:clipask` answers the text on your clipboard. Copy a customer's question from an email or a ticket, type `:clipask` in your reply, and the answer is typed in. +- `:asyntaikey` saves your Asyntai API key. You do this one time. + +Links in an answer are typed as `text (https://...)`, so they work in plain text fields too. + +## Setup + +1. Install the package: + + ``` + espanso install asyntai-ai-assistant + ``` + +2. Sign in to [Asyntai](https://asyntai.com), open **Settings**, then **API**, and copy your API key. +3. Open an empty text field, for example a new note, and type `:asyntaikey`. Paste the key and press Enter. Asyntai checks the key, and the package saves it in your user folder (`%APPDATA%\asyntai` on Windows, `~/.config/asyntai` on macOS and Linux). +4. Type `:ask` anywhere and ask your first question. + +The package is free. It uses the Asyntai API, which needs an Asyntai account on the Starter plan or higher. + +## Requirements + +- Espanso 2 +- Windows: nothing else. The package uses Windows PowerShell, which every Windows 10 and 11 has. +- macOS and Linux: Python 3 (`python3`). The package uses only the Python standard library, so there is nothing to `pip install`. Your default shell must be sh, bash or zsh. + +## Support + +Documentation: https://asyntai.com/documentation/integrations/espanso/ + +Questions: hello@asyntai.com diff --git a/packages/asyntai-ai-assistant/0.1.0/_manifest.yml b/packages/asyntai-ai-assistant/0.1.0/_manifest.yml new file mode 100644 index 00000000..cf794aa5 --- /dev/null +++ b/packages/asyntai-ai-assistant/0.1.0/_manifest.yml @@ -0,0 +1,7 @@ +name: "asyntai-ai-assistant" +title: "Asyntai AI Assistant" +description: "Type :ask anywhere and get the answer your Asyntai AI chatbot gives from your own website, docs and FAQ. Great for support replies." +version: "0.1.0" +author: "Asyntai" +homepage: "https://asyntai.com/documentation/integrations/espanso/" +tags: ["AI", "chatbot", "assistant", "customer support", "support", "LLM", "knowledge base", "Asyntai"] diff --git a/packages/asyntai-ai-assistant/0.1.0/asyntai.ps1 b/packages/asyntai-ai-assistant/0.1.0/asyntai.ps1 new file mode 100644 index 00000000..bfca9e6e --- /dev/null +++ b/packages/asyntai-ai-assistant/0.1.0/asyntai.ps1 @@ -0,0 +1,207 @@ +# Copyright (c) Asyntai. Licensed under the MIT license. +# +# Asyntai AI Assistant for Espanso, Windows side. +# +# Espanso runs this script through the shell extension (see package.yml) and +# types whatever it prints. macOS and Linux run asyntai.py, which does the +# same work. +# +# ask question from the :ask form (ESPANSO_FORM1_QUESTION) +# clip question from the clipboard (ESPANSO_CLIP) +# key save the API key from the :asyntaikey form (ESPANSO_FORM1_KEY) +# +# The script only talks to asyntai.com, only sends the API key in the +# Authorization header, and only writes one file: the saved API key. +# It needs nothing but Windows PowerShell 5.1, which every Windows 10 and 11 +# has. + +param([string]$Mode) + +$ErrorActionPreference = 'Stop' + +$LiveBase = 'https://asyntai.com' +$UserAgent = 'AsyntaiEspanso/0.1' +$TimeoutMs = 90000 +# The other Asyntai launchers mark their own questions the same way, so the +# owner's test questions are easy to tell apart from visitor chats. +$AskMarker = 'espanso_ask_' + +$NoKey = 'Asyntai: add your API key first. Type :asyntaikey in an empty text field.' +$BadKey = 'Asyntai: this API key no longer works. Type :asyntaikey and paste a new key.' + +function New-AsyntaiError([string]$Message, [int]$Status = 0) { + $e = New-Object System.Exception $Message + $e.Data['asyntai'] = $true + $e.Data['status'] = $Status + return $e +} + +function Get-KeyPath { + $root = $env:APPDATA + if (-not $root) { $root = $env:USERPROFILE } + return [IO.Path]::Combine($root, 'asyntai', 'espanso-api-key') +} + +function Read-Key { + if ($env:ASYNTAI_API_KEY -and $env:ASYNTAI_API_KEY.Trim()) { return $env:ASYNTAI_API_KEY.Trim() } + $path = Get-KeyPath + if (Test-Path -LiteralPath $path) { + return ([IO.File]::ReadAllText($path, [Text.Encoding]::UTF8)).Trim() + } + return '' +} + +function Save-Key([string]$Key) { + $path = Get-KeyPath + [void][IO.Directory]::CreateDirectory([IO.Path]::GetDirectoryName($path)) + [IO.File]::WriteAllText($path, $Key, (New-Object Text.UTF8Encoding $false)) +} + +function Get-BaseUrl { + # A local Asyntai server can be used for development, nothing else, so + # the key can never be sent to a third-party host. + $base = "$env:ASYNTAI_BASE_URL".Trim().TrimEnd('/') + if ($base) { + try { + $uri = New-Object Uri $base + if ($uri.Host -eq '127.0.0.1' -or $uri.Host -eq 'localhost') { return $base } + } catch { } + } + return $LiveBase +} + +function Invoke-Asyntai([string]$Method, [string]$Path, [string]$Key, $Body) { + [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 + $request = [Net.HttpWebRequest]::Create((Get-BaseUrl) + $Path) + $request.Method = $Method + # Never follow a redirect: it could send the question or the API key to + # another host. A 3xx answer becomes an error instead. + $request.AllowAutoRedirect = $false + $request.Timeout = $TimeoutMs + $request.ReadWriteTimeout = $TimeoutMs + $request.UserAgent = $UserAgent + $request.Accept = 'application/json' + $request.Headers['Authorization'] = 'Bearer ' + $Key + + $response = $null + try { + if ($null -ne $Body) { + $bytes = [Text.Encoding]::UTF8.GetBytes(($Body | ConvertTo-Json -Compress)) + $request.ContentType = 'application/json; charset=utf-8' + $request.ContentLength = $bytes.Length + $stream = $request.GetRequestStream() + $stream.Write($bytes, 0, $bytes.Length) + $stream.Close() + } + $response = $request.GetResponse() + } catch { + $inner = $_.Exception + while ($inner -and -not ($inner -is [Net.WebException])) { $inner = $inner.InnerException } + if ($inner -and $inner.Response) { $response = $inner.Response } + } + if ($null -eq $response) { + throw (New-AsyntaiError 'Asyntai: no answer from asyntai.com. Check your internet connection and try again.') + } + + $status = [int]$response.StatusCode + $reader = New-Object IO.StreamReader($response.GetResponseStream(), [Text.Encoding]::UTF8) + $text = $reader.ReadToEnd() + $reader.Close() + $response.Close() + + $root = $null + if ($text.Trim()) { + try { $root = $text | ConvertFrom-Json } catch { $root = $null } + } else { + $root = New-Object PSObject + } + $message = '' + if ($root -and ($root.error -is [string])) { $message = $root.error } + + if ($status -eq 401) { throw (New-AsyntaiError $BadKey $status) } + if ($status -eq 403) { + if (-not $message) { $message = 'the Asyntai API needs the Starter plan or higher.' } + throw (New-AsyntaiError ('Asyntai: ' + $message) $status) + } + if ($status -eq 429) { + if (-not $message) { $message = 'your chatbot reached its message limit.' } + throw (New-AsyntaiError ('Asyntai: ' + $message) $status) + } + if ($status -lt 200 -or $status -ge 300 -or $null -eq $root -or $root.success -eq $false) { + if (-not $message) { $message = "the server answered with status $status." } + throw (New-AsyntaiError ('Asyntai: ' + $message) $status) + } + return $root +} + +function ConvertTo-Plain([string]$Text) { + # Make a chat answer fit a plain text field: links as "text (url)", no bold. + $Text = [regex]::Replace($Text, '\[([^\]]*)\]\((\S+?)\)', { + param($m) + $label = $m.Groups[1].Value.Trim() + $url = $m.Groups[2].Value.Trim() + if (-not $label -or $label -eq $url -or $url.StartsWith('mailto:') -or $url.StartsWith('tel:')) { + if ($label) { return $label } else { return $url } + } + return "$label ($url)" + }) + $Text = [regex]::Replace($Text, '(?s)\*\*(.+?)\*\*', '$1') + $Text = [regex]::Replace($Text, '(?s)__(.+?)__', '$1') + $Text = [regex]::Replace($Text, '(?m)^#{1,6}[ \t]+', '') + $Text = $Text -replace "`r`n", "`n" + $Text = [regex]::Replace($Text, '\n{3,}', "`n`n") + return $Text.Trim() +} + +function Invoke-Ask([string]$Question) { + $Question = "$Question".Trim() + if (-not $Question) { return 'Asyntai: the question is empty. Type :ask and write a question.' } + $key = Read-Key + if (-not $key) { return $NoKey } + $session = $AskMarker + [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds() + '_' + [guid]::NewGuid().ToString('N').Substring(0, 6) + $root = Invoke-Asyntai 'POST' '/api/v1/chat/' $key @{ message = $Question; session_id = $session } + $answer = '' + if ($root.response -is [string]) { $answer = ConvertTo-Plain $root.response } + if (-not $answer) { return 'Asyntai: your chatbot gave an empty answer.' } + return $answer +} + +function Set-AsyntaiKey([string]$Key) { + $Key = "$Key".Trim() + if (-not $Key) { return 'Asyntai: the API key is empty. Type :asyntaikey and paste your key.' } + try { + $root = Invoke-Asyntai 'GET' '/api/v1/websites/' $Key $null + } catch { + if ($_.Exception.Data['status'] -eq 401) { + return 'Asyntai: this is not a valid API key. In Asyntai, open Settings, then API, and copy the key again.' + } + throw + } + # Save only a key that Asyntai accepts. + Save-Key $Key + $sites = @($root.websites | Where-Object { $_ -ne $null }) + $primary = @($sites | Where-Object { $_.is_primary -eq $true }) + $sites | Select-Object -First 1 + if ($primary -and ($primary.domain -is [string]) -and $primary.domain) { + return "Asyntai API key saved. Your chatbot for $($primary.domain) is ready: type :ask to ask it." + } + return 'Asyntai API key saved. Type :ask to ask your chatbot.' +} + +try { + switch ($Mode) { + 'ask' { $out = Invoke-Ask $env:ESPANSO_FORM1_QUESTION } + 'clip' { $out = Invoke-Ask $env:ESPANSO_CLIP } + 'key' { $out = Set-AsyntaiKey $env:ESPANSO_FORM1_KEY } + default { $out = 'Asyntai: unknown command.' } + } +} catch { + if ($_.Exception.Data['asyntai']) { $out = $_.Exception.Message } + else { $out = 'Asyntai: something went wrong. Please try again.' } +} + +# Espanso reads stdout as UTF-8. Write the bytes directly, so the console +# code page cannot change the text. +$bytes = [Text.Encoding]::UTF8.GetBytes($out) +$stdout = [Console]::OpenStandardOutput() +$stdout.Write($bytes, 0, $bytes.Length) +$stdout.Flush() diff --git a/packages/asyntai-ai-assistant/0.1.0/asyntai.py b/packages/asyntai-ai-assistant/0.1.0/asyntai.py new file mode 100644 index 00000000..43c12fd9 --- /dev/null +++ b/packages/asyntai-ai-assistant/0.1.0/asyntai.py @@ -0,0 +1,198 @@ +# Copyright (c) Asyntai. Licensed under the MIT license. +"""Asyntai AI Assistant for Espanso, macOS and Linux side. + +Espanso runs this script through the shell extension (see package.yml) and +types whatever it prints. Windows runs asyntai.ps1, which does the same work. + + python3 asyntai.py ask question from the :ask form (ESPANSO_FORM1_QUESTION) + python3 asyntai.py clip question from the clipboard (ESPANSO_CLIP) + python3 asyntai.py key save the API key from the :asyntaikey form (ESPANSO_FORM1_KEY) + +The script only talks to asyntai.com, only sends the API key in the +Authorization header, and only writes one file: the saved API key. It uses the +Python standard library alone, so there is nothing to pip install. +""" + +import json +import os +import re +import secrets +import sys +import time +import urllib.error +import urllib.parse +import urllib.request + +LIVE_BASE = "https://asyntai.com" +USER_AGENT = "AsyntaiEspanso/0.1" +TIMEOUT = 90 +# The other Asyntai launchers mark their own questions the same way, so the +# owner's test questions are easy to tell apart from visitor chats. +ASK_MARKER = "espanso_ask_" + +NO_KEY = "Asyntai: add your API key first. Type :asyntaikey in an empty text field." +BAD_KEY = "Asyntai: this API key no longer works. Type :asyntaikey and paste a new key." + + +class AsyntaiError(Exception): + def __init__(self, message, status=0): + super().__init__(message) + self.status = status + + +def key_path(): + if sys.platform == "win32": + root = os.environ.get("APPDATA") or os.path.expanduser("~") + else: + root = os.environ.get("XDG_CONFIG_HOME") or os.path.join(os.path.expanduser("~"), ".config") + return os.path.join(root, "asyntai", "espanso-api-key") + + +def read_key(): + key = os.environ.get("ASYNTAI_API_KEY", "").strip() + if key: + return key + try: + with open(key_path(), encoding="utf-8") as f: + return f.read().strip() + except OSError: + return "" + + +def save_key(key): + path = key_path() + os.makedirs(os.path.dirname(path), exist_ok=True) + # The key is a secret: create the file readable by its owner only. + fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(fd, "w", encoding="utf-8") as f: + f.write(key) + + +def base_url(): + # A local Asyntai server can be used for development, nothing else, so + # the key can never be sent to a third-party host. + base = os.environ.get("ASYNTAI_BASE_URL", "").strip().rstrip("/") + if base: + host = urllib.parse.urlsplit(base).hostname or "" + if host in ("127.0.0.1", "localhost"): + return base + return LIVE_BASE + + +class _NoRedirect(urllib.request.HTTPRedirectHandler): + # Never follow a redirect: it could send the question or the API key to + # another host. A 3xx answer becomes an error instead. + def redirect_request(self, req, fp, code, msg, headers, newurl): + return None + + +_opener = urllib.request.build_opener(_NoRedirect) + + +def call(method, path, key, body=None): + headers = { + "Authorization": "Bearer " + key, + "Accept": "application/json", + "User-Agent": USER_AGENT, + } + data = None + if body is not None: + data = json.dumps(body).encode("utf-8") + headers["Content-Type"] = "application/json" + request = urllib.request.Request(base_url() + path, data=data, headers=headers, method=method) + try: + with _opener.open(request, timeout=TIMEOUT) as response: + status, raw = response.status, response.read() + except urllib.error.HTTPError as e: + status, raw = e.code, (e.read() if e.fp else b"") + except Exception: + raise AsyntaiError("Asyntai: no answer from asyntai.com. Check your internet connection and try again.") + + try: + root = json.loads(raw.decode("utf-8", "replace")) if raw.strip() else {} + except ValueError: + root = None + error = root.get("error") if isinstance(root, dict) and isinstance(root.get("error"), str) else "" + + if status == 401: + raise AsyntaiError(BAD_KEY, status) + if status == 403: + raise AsyntaiError("Asyntai: " + (error or "the Asyntai API needs the Starter plan or higher.")) + if status == 429: + raise AsyntaiError("Asyntai: " + (error or "your chatbot reached its message limit.")) + if not 200 <= status < 300 or not isinstance(root, dict) or root.get("success") is False: + raise AsyntaiError("Asyntai: " + (error or "the server answered with status %d." % status)) + return root + + +def plain(text): + """Make a chat answer fit a plain text field: links as "text (url)", no bold.""" + def link(m): + label, url = m.group(1).strip(), m.group(2).strip() + if not label or label == url or url.startswith("mailto:") or url.startswith("tel:"): + return label or url + return "%s (%s)" % (label, url) + + text = re.sub(r"\[([^\]]*)\]\((\S+?)\)", link, text) + text = re.sub(r"\*\*(.+?)\*\*", r"\1", text, flags=re.S) + text = re.sub(r"__(.+?)__", r"\1", text, flags=re.S) + text = re.sub(r"^#{1,6}\s+", "", text, flags=re.M) + text = re.sub(r"\n{3,}", "\n\n", text) + return text.strip() + + +def ask(question): + question = (question or "").strip() + if not question: + return "Asyntai: the question is empty. Type :ask and write a question." + key = read_key() + if not key: + return NO_KEY + session = "%s%d_%s" % (ASK_MARKER, int(time.time() * 1000), secrets.token_hex(3)) + root = call("POST", "/api/v1/chat/", key, {"message": question, "session_id": session}) + answer = root.get("response") if isinstance(root.get("response"), str) else "" + return plain(answer) or "Asyntai: your chatbot gave an empty answer." + + +def set_key(key): + key = (key or "").strip() + if not key: + return "Asyntai: the API key is empty. Type :asyntaikey and paste your key." + try: + root = call("GET", "/api/v1/websites/", key) + except AsyntaiError as e: + if e.status == 401: + return "Asyntai: this is not a valid API key. In Asyntai, open Settings, then API, and copy the key again." + raise + # Save only a key that Asyntai accepts. + save_key(key) + sites = [w for w in root.get("websites") or [] if isinstance(w, dict)] + primary = next((w for w in sites if w.get("is_primary")), sites[0] if sites else None) + domain = primary.get("domain") if primary and isinstance(primary.get("domain"), str) else "" + if domain: + return "Asyntai API key saved. Your chatbot for %s is ready: type :ask to ask it." % domain + return "Asyntai API key saved. Type :ask to ask your chatbot." + + +def main(argv): + mode = argv[1] if len(argv) > 1 else "" + try: + if mode == "ask": + out = ask(os.environ.get("ESPANSO_FORM1_QUESTION")) + elif mode == "clip": + out = ask(os.environ.get("ESPANSO_CLIP")) + elif mode == "key": + out = set_key(os.environ.get("ESPANSO_FORM1_KEY")) + else: + out = "Asyntai: unknown command." + except AsyntaiError as e: + out = str(e) + except Exception: + out = "Asyntai: something went wrong. Please try again." + # Espanso reads stdout as UTF-8 on every system. + sys.stdout.buffer.write(out.encode("utf-8")) + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/packages/asyntai-ai-assistant/0.1.0/package.yml b/packages/asyntai-ai-assistant/0.1.0/package.yml new file mode 100644 index 00000000..46930978 --- /dev/null +++ b/packages/asyntai-ai-assistant/0.1.0/package.yml @@ -0,0 +1,58 @@ +# Asyntai AI Assistant: answers from your own website, typed where you write. +# +# Every match runs ONE command through the shell extension, written so that +# both of Espanso's default shells read it: +# - macOS and Linux (sh, bash, zsh): line 1 runs asyntai.py with python3 and +# exits, so the shell never reaches line 2. +# - Windows (PowerShell): "`#" on line 1 is an escaped "#", then "<#" opens +# a block comment that "#>" on line 2 closes. PowerShell skips the sh part +# and runs asyntai.ps1 on line 3. +# The question, the clipboard text and the key reach the scripts as Espanso's +# ESPANSO_* environment variables, never inside the command text. + +matches: + # Ask your chatbot a question and type its answer. + - trigger: ":ask" + replace: "{{answer}}" + vars: + - name: form1 + type: form + params: + layout: "Ask your Asyntai chatbot\n[[question]]" + - name: answer + type: shell + params: + cmd: | + set `# <#` --; p="$CONFIG/match/packages/asyntai-ai-assistant/asyntai.py"; if command -v python3 >/dev/null 2>&1; then python3 "$p" ask; else echo "Asyntai: install Python 3 to use this package on macOS and Linux."; fi; exit 0 + #> + & ([scriptblock]::Create([IO.File]::ReadAllText("$env:CONFIG\match\packages\asyntai-ai-assistant\asyntai.ps1"))) ask + + # Answer the question that is on the clipboard, for example a customer email. + - trigger: ":clipask" + replace: "{{answer}}" + vars: + - name: clip + type: clipboard + - name: answer + type: shell + params: + cmd: | + set `# <#` --; p="$CONFIG/match/packages/asyntai-ai-assistant/asyntai.py"; if command -v python3 >/dev/null 2>&1; then python3 "$p" clip; else echo "Asyntai: install Python 3 to use this package on macOS and Linux."; fi; exit 0 + #> + & ([scriptblock]::Create([IO.File]::ReadAllText("$env:CONFIG\match\packages\asyntai-ai-assistant\asyntai.ps1"))) clip + + # Save your Asyntai API key. Asyntai checks the key before it is saved. + - trigger: ":asyntaikey" + replace: "{{result}}" + vars: + - name: form1 + type: form + params: + layout: "Paste your Asyntai API key (Settings > API in Asyntai)\n[[key]]" + - name: result + type: shell + params: + cmd: | + set `# <#` --; p="$CONFIG/match/packages/asyntai-ai-assistant/asyntai.py"; if command -v python3 >/dev/null 2>&1; then python3 "$p" key; else echo "Asyntai: install Python 3 to use this package on macOS and Linux."; fi; exit 0 + #> + & ([scriptblock]::Create([IO.File]::ReadAllText("$env:CONFIG\match\packages\asyntai-ai-assistant\asyntai.ps1"))) key