initial Commit
This commit is contained in:
279
plugins/clipboard_tools.py
Normal file
279
plugins/clipboard_tools.py
Normal file
@@ -0,0 +1,279 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
|
||||
TOKEN_RE = re.compile(r"{{\s*([a-zA-Z0-9_.-]+)\s*}}")
|
||||
|
||||
|
||||
class ClipboardToolsPlugin:
|
||||
name = "Clipboard Tools"
|
||||
desc = "Copy preset text, paste snippets, and transform clipboard text."
|
||||
version = "0.1.0"
|
||||
actions = [
|
||||
{
|
||||
"id": "copy_text",
|
||||
"name": "Copy Preset Text",
|
||||
"desc": "Put preset text on the system clipboard.",
|
||||
"fields": [
|
||||
{
|
||||
"id": "text",
|
||||
"label": "Text",
|
||||
"type": "textarea",
|
||||
"required": True,
|
||||
"placeholder": "Paste the text or template to copy.",
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "paste_snippet",
|
||||
"name": "Paste Snippet",
|
||||
"desc": "Copy text to the clipboard, send Ctrl+V, and optionally restore the previous clipboard value.",
|
||||
"fields": [
|
||||
{
|
||||
"id": "text",
|
||||
"label": "Snippet",
|
||||
"type": "textarea",
|
||||
"required": True,
|
||||
"placeholder": "Snippet to paste into the active app.",
|
||||
},
|
||||
{
|
||||
"id": "restore_clipboard",
|
||||
"label": "Restore previous clipboard",
|
||||
"type": "boolean",
|
||||
"default": True,
|
||||
},
|
||||
{
|
||||
"id": "restore_delay_ms",
|
||||
"label": "Restore Delay (ms)",
|
||||
"type": "number",
|
||||
"default": 120,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "transform_clipboard",
|
||||
"name": "Transform Clipboard Text",
|
||||
"desc": "Read text from the clipboard, transform it, then write it back.",
|
||||
"fields": [
|
||||
{
|
||||
"id": "transform",
|
||||
"label": "Transform",
|
||||
"type": "select",
|
||||
"default": "uppercase",
|
||||
"options": [
|
||||
{"label": "UPPERCASE", "value": "uppercase"},
|
||||
{"label": "lowercase", "value": "lowercase"},
|
||||
{"label": "Title Case", "value": "titlecase"},
|
||||
{"label": "Sentence case", "value": "sentencecase"},
|
||||
{"label": "Trim edges", "value": "trim"},
|
||||
{"label": "Collapse whitespace", "value": "collapse_whitespace"},
|
||||
{"label": "snake_case", "value": "snake_case"},
|
||||
{"label": "kebab-case", "value": "kebab_case"},
|
||||
{"label": "camelCase", "value": "camel_case"},
|
||||
{"label": "Replace text", "value": "replace"},
|
||||
{"label": "Add prefix", "value": "prefix"},
|
||||
{"label": "Add suffix", "value": "suffix"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "find",
|
||||
"label": "Find",
|
||||
"type": "text",
|
||||
"placeholder": "Used by Replace text",
|
||||
},
|
||||
{
|
||||
"id": "replace",
|
||||
"label": "Replace With",
|
||||
"type": "text",
|
||||
"placeholder": "Used by Replace text",
|
||||
},
|
||||
{
|
||||
"id": "extra_text",
|
||||
"label": "Prefix / Suffix Text",
|
||||
"type": "text",
|
||||
"placeholder": "Used by Add prefix or Add suffix",
|
||||
},
|
||||
{
|
||||
"id": "auto_paste",
|
||||
"label": "Paste after transform",
|
||||
"type": "boolean",
|
||||
"default": False,
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
def on_load(self, ctx):
|
||||
ctx.db.add_event("plugin.loaded", {"plugin": self.name})
|
||||
|
||||
async def execute_action(self, ctx, action_id, config, event):
|
||||
if action_id == "copy_text":
|
||||
text = self._render_text(config.get("text", ""), event)
|
||||
self._set_clipboard_text(text)
|
||||
ctx.db.add_event("plugin.clipboard.copy", {"plugin": self.name, "chars": len(text)})
|
||||
return
|
||||
|
||||
if action_id == "paste_snippet":
|
||||
text = self._render_text(config.get("text", ""), event)
|
||||
restore_clipboard = bool(config.get("restore_clipboard", True))
|
||||
restore_delay_ms = max(0, min(5000, int(config.get("restore_delay_ms", 120) or 0)))
|
||||
previous = self._get_clipboard_text() if restore_clipboard else None
|
||||
self._set_clipboard_text(text)
|
||||
await self._paste_via_shortcut(ctx)
|
||||
if restore_clipboard:
|
||||
if restore_delay_ms:
|
||||
await asyncio.sleep(restore_delay_ms / 1000)
|
||||
self._set_clipboard_text(previous or "")
|
||||
ctx.db.add_event(
|
||||
"plugin.clipboard.paste",
|
||||
{"plugin": self.name, "chars": len(text), "restore_clipboard": restore_clipboard},
|
||||
)
|
||||
return
|
||||
|
||||
if action_id == "transform_clipboard":
|
||||
original = self._get_clipboard_text()
|
||||
if not original:
|
||||
raise ValueError("Clipboard does not contain text to transform.")
|
||||
transformed = self._transform_text(original, config)
|
||||
self._set_clipboard_text(transformed)
|
||||
if config.get("auto_paste"):
|
||||
await self._paste_via_shortcut(ctx)
|
||||
ctx.db.add_event(
|
||||
"plugin.clipboard.transform",
|
||||
{
|
||||
"plugin": self.name,
|
||||
"transform": str(config.get("transform", "uppercase")),
|
||||
"before_chars": len(original),
|
||||
"after_chars": len(transformed),
|
||||
},
|
||||
)
|
||||
return
|
||||
|
||||
raise ValueError(f"Unknown clipboard action '{action_id}'.")
|
||||
|
||||
def _render_text(self, value: Any, event: dict[str, Any] | None) -> str:
|
||||
template = str(value or "")
|
||||
context = {"event": event or {}}
|
||||
|
||||
def replace(match: re.Match[str]) -> str:
|
||||
current: Any = context
|
||||
for part in match.group(1).split("."):
|
||||
if isinstance(current, dict):
|
||||
current = current.get(part, "")
|
||||
else:
|
||||
current = getattr(current, part, "")
|
||||
return "" if current is None else str(current)
|
||||
|
||||
return TOKEN_RE.sub(replace, template)
|
||||
|
||||
def _transform_text(self, text: str, config: dict[str, Any]) -> str:
|
||||
transform = str(config.get("transform", "uppercase") or "uppercase")
|
||||
if transform == "uppercase":
|
||||
return text.upper()
|
||||
if transform == "lowercase":
|
||||
return text.lower()
|
||||
if transform == "titlecase":
|
||||
return text.title()
|
||||
if transform == "sentencecase":
|
||||
stripped = text.strip()
|
||||
if not stripped:
|
||||
return ""
|
||||
return stripped[:1].upper() + stripped[1:].lower()
|
||||
if transform == "trim":
|
||||
return text.strip()
|
||||
if transform == "collapse_whitespace":
|
||||
return re.sub(r"\s+", " ", text).strip()
|
||||
if transform == "snake_case":
|
||||
return self._to_delimited_case(text, "_")
|
||||
if transform == "kebab_case":
|
||||
return self._to_delimited_case(text, "-")
|
||||
if transform == "camel_case":
|
||||
words = self._split_words(text)
|
||||
if not words:
|
||||
return ""
|
||||
return words[0] + "".join(word[:1].upper() + word[1:] for word in words[1:])
|
||||
if transform == "replace":
|
||||
find = str(config.get("find", ""))
|
||||
if not find:
|
||||
raise ValueError("Replace text requires a Find value.")
|
||||
return text.replace(find, str(config.get("replace", "")))
|
||||
if transform == "prefix":
|
||||
return f"{str(config.get('extra_text', ''))}{text}"
|
||||
if transform == "suffix":
|
||||
return f"{text}{str(config.get('extra_text', ''))}"
|
||||
raise ValueError(f"Unknown transform '{transform}'.")
|
||||
|
||||
def _split_words(self, text: str) -> list[str]:
|
||||
normalized = re.sub(r"([a-z0-9])([A-Z])", r"\1 \2", text)
|
||||
tokens = re.split(r"[^a-zA-Z0-9]+", normalized)
|
||||
return [token.lower() for token in tokens if token]
|
||||
|
||||
def _to_delimited_case(self, text: str, delimiter: str) -> str:
|
||||
return delimiter.join(self._split_words(text))
|
||||
|
||||
async def _paste_via_shortcut(self, ctx) -> None:
|
||||
keys = getattr(getattr(ctx.app, "actions", None), "keys", None)
|
||||
if keys is None:
|
||||
raise RuntimeError("Action engine is not available for paste shortcuts.")
|
||||
result = keys.press_combo("ctrl+v")
|
||||
if hasattr(result, "__await__"):
|
||||
await result
|
||||
|
||||
def _get_clipboard_text(self) -> str:
|
||||
try:
|
||||
import win32clipboard
|
||||
import win32con
|
||||
except ImportError:
|
||||
return self._get_clipboard_text_tk()
|
||||
|
||||
win32clipboard.OpenClipboard()
|
||||
try:
|
||||
if not win32clipboard.IsClipboardFormatAvailable(win32con.CF_UNICODETEXT):
|
||||
return ""
|
||||
data = win32clipboard.GetClipboardData(win32con.CF_UNICODETEXT)
|
||||
return str(data or "")
|
||||
finally:
|
||||
win32clipboard.CloseClipboard()
|
||||
|
||||
def _set_clipboard_text(self, text: str) -> None:
|
||||
try:
|
||||
import win32clipboard
|
||||
import win32con
|
||||
except ImportError:
|
||||
self._set_clipboard_text_tk(text)
|
||||
return
|
||||
|
||||
win32clipboard.OpenClipboard()
|
||||
try:
|
||||
win32clipboard.EmptyClipboard()
|
||||
win32clipboard.SetClipboardData(win32con.CF_UNICODETEXT, text)
|
||||
finally:
|
||||
win32clipboard.CloseClipboard()
|
||||
|
||||
def _get_clipboard_text_tk(self) -> str:
|
||||
import tkinter
|
||||
|
||||
root = tkinter.Tk()
|
||||
root.withdraw()
|
||||
try:
|
||||
return root.clipboard_get()
|
||||
except tkinter.TclError:
|
||||
return ""
|
||||
finally:
|
||||
root.destroy()
|
||||
|
||||
def _set_clipboard_text_tk(self, text: str) -> None:
|
||||
import tkinter
|
||||
|
||||
root = tkinter.Tk()
|
||||
root.withdraw()
|
||||
root.clipboard_clear()
|
||||
root.clipboard_append(text)
|
||||
root.update()
|
||||
root.destroy()
|
||||
|
||||
|
||||
PLUGIN = ClipboardToolsPlugin()
|
||||
134
plugins/http_requests.py
Normal file
134
plugins/http_requests.py
Normal file
@@ -0,0 +1,134 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from typing import Any
|
||||
|
||||
|
||||
TOKEN_RE = re.compile(r"{{\s*([a-zA-Z0-9_.-]+)\s*}}")
|
||||
|
||||
|
||||
class HTTPRequestsPlugin:
|
||||
name = "HTTP Requests"
|
||||
desc = "Send configurable HTTP requests to webhooks, local services, and automation tools."
|
||||
version = "0.1.0"
|
||||
actions = [
|
||||
{
|
||||
"id": "send_request",
|
||||
"name": "Send Request",
|
||||
"desc": "Send a GET, POST, PUT, PATCH, or DELETE request.",
|
||||
"fields": [
|
||||
{
|
||||
"id": "method",
|
||||
"label": "Method",
|
||||
"type": "select",
|
||||
"default": "POST",
|
||||
"options": [
|
||||
{"label": "GET", "value": "GET"},
|
||||
{"label": "POST", "value": "POST"},
|
||||
{"label": "PUT", "value": "PUT"},
|
||||
{"label": "PATCH", "value": "PATCH"},
|
||||
{"label": "DELETE", "value": "DELETE"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "url",
|
||||
"label": "URL",
|
||||
"type": "url",
|
||||
"required": True,
|
||||
"placeholder": "http://127.0.0.1:8080/webhook",
|
||||
},
|
||||
{
|
||||
"id": "headers",
|
||||
"label": "Headers",
|
||||
"type": "key_value",
|
||||
"default": [{"key": "Content-Type", "value": "application/json"}],
|
||||
},
|
||||
{
|
||||
"id": "body",
|
||||
"label": "Body",
|
||||
"type": "json",
|
||||
"default": '{\n "button": "{{event.button}}",\n "event": "{{event.event}}"\n}',
|
||||
},
|
||||
{"id": "timeout", "label": "Timeout Seconds", "type": "number", "default": 5},
|
||||
{"id": "log_response", "label": "Log response", "type": "boolean", "default": True},
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
def on_load(self, ctx):
|
||||
ctx.db.add_event("plugin.loaded", {"plugin": self.name})
|
||||
|
||||
def execute_action(self, ctx, action_id, config, event):
|
||||
if action_id != "send_request":
|
||||
raise ValueError(f"Unknown HTTP action '{action_id}'.")
|
||||
|
||||
method = str(config.get("method", "POST")).upper()
|
||||
if method not in {"GET", "POST", "PUT", "PATCH", "DELETE"}:
|
||||
raise ValueError(f"Unsupported HTTP method '{method}'.")
|
||||
|
||||
context = {"event": event or {}}
|
||||
url = self._render(str(config.get("url", "")).strip(), context)
|
||||
if not url.startswith(("http://", "https://")):
|
||||
raise ValueError("HTTP Request URL must start with http:// or https://.")
|
||||
|
||||
timeout = max(1.0, min(30.0, float(config.get("timeout", 5) or 5)))
|
||||
headers = self._headers(config.get("headers"), context)
|
||||
body_text = self._render(str(config.get("body", "") or ""), context).strip()
|
||||
data = None
|
||||
if method not in {"GET", "DELETE"} and body_text:
|
||||
data = body_text.encode("utf-8")
|
||||
headers.setdefault("Content-Type", "application/json")
|
||||
|
||||
request = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||||
response_body = response.read(4096).decode("utf-8", errors="replace")
|
||||
status = response.status
|
||||
except urllib.error.HTTPError as exc:
|
||||
response_body = exc.read(4096).decode("utf-8", errors="replace")
|
||||
raise RuntimeError(f"HTTP request failed with {exc.code}: {response_body[:300]}") from exc
|
||||
except urllib.error.URLError as exc:
|
||||
raise RuntimeError(f"HTTP request failed: {exc.reason}") from exc
|
||||
|
||||
if config.get("log_response", True):
|
||||
ctx.db.add_event(
|
||||
"plugin.http_response",
|
||||
{"status": status, "url": url, "method": method, "body": response_body[:1000]},
|
||||
)
|
||||
|
||||
def _headers(self, raw_headers: Any, context: dict[str, Any]) -> dict[str, str]:
|
||||
if isinstance(raw_headers, dict):
|
||||
items = raw_headers.items()
|
||||
elif isinstance(raw_headers, list):
|
||||
items = ((item.get("key"), item.get("value")) for item in raw_headers if isinstance(item, dict))
|
||||
else:
|
||||
items = []
|
||||
headers = {}
|
||||
for key, value in items:
|
||||
rendered_key = self._render(str(key or "").strip(), context)
|
||||
if not rendered_key:
|
||||
continue
|
||||
headers[rendered_key] = self._render(str(value or ""), context)
|
||||
return headers
|
||||
|
||||
def _render(self, template: str, context: dict[str, Any]) -> str:
|
||||
def replace(match):
|
||||
value: Any = context
|
||||
for part in match.group(1).split("."):
|
||||
if isinstance(value, dict):
|
||||
value = value.get(part, "")
|
||||
else:
|
||||
value = getattr(value, part, "")
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, (dict, list)):
|
||||
return json.dumps(value)
|
||||
return str(value)
|
||||
|
||||
return TOKEN_RE.sub(replace, template)
|
||||
|
||||
|
||||
PLUGIN = HTTPRequestsPlugin()
|
||||
90
plugins/media_controls.py
Normal file
90
plugins/media_controls.py
Normal file
@@ -0,0 +1,90 @@
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class MediaControlsPlugin:
|
||||
name = "Media Controls"
|
||||
desc = "Send system media keys through the backend plugin action path."
|
||||
version = "0.1.0"
|
||||
actions = [
|
||||
{
|
||||
"id": "media_key",
|
||||
"name": "Media Key",
|
||||
"desc": "Play/pause, skip, stop, mute, or adjust volume.",
|
||||
"fields": [
|
||||
{
|
||||
"id": "command",
|
||||
"label": "Command",
|
||||
"type": "select",
|
||||
"default": "play_pause",
|
||||
"options": [
|
||||
{"label": "Play / Pause", "value": "play_pause"},
|
||||
{"label": "Next Track", "value": "next"},
|
||||
{"label": "Previous Track", "value": "previous"},
|
||||
{"label": "Stop", "value": "stop"},
|
||||
{"label": "Volume Up", "value": "volume_up"},
|
||||
{"label": "Volume Down", "value": "volume_down"},
|
||||
{"label": "Mute", "value": "mute"},
|
||||
],
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "volume_repeat",
|
||||
"name": "Volume Repeat",
|
||||
"desc": "Send volume up or down multiple times.",
|
||||
"fields": [
|
||||
{
|
||||
"id": "direction",
|
||||
"label": "Direction",
|
||||
"type": "select",
|
||||
"default": "volume_up",
|
||||
"options": [
|
||||
{"label": "Volume Up", "value": "volume_up"},
|
||||
{"label": "Volume Down", "value": "volume_down"},
|
||||
],
|
||||
},
|
||||
{"id": "steps", "label": "Steps", "type": "number", "default": 3},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
def on_load(self, ctx):
|
||||
ctx.db.add_event("plugin.loaded", {"plugin": self.name})
|
||||
|
||||
def execute_action(self, ctx, action_id, config, event):
|
||||
if action_id == "media_key":
|
||||
self._press(config.get("command", "play_pause"))
|
||||
return
|
||||
if action_id == "volume_repeat":
|
||||
steps = max(1, min(20, int(config.get("steps", 3) or 3)))
|
||||
command = config.get("direction", "volume_up")
|
||||
for _ in range(steps):
|
||||
self._press(command)
|
||||
return
|
||||
raise ValueError(f"Unknown media action '{action_id}'.")
|
||||
|
||||
def _press(self, command):
|
||||
from pynput.keyboard import Controller, Key
|
||||
|
||||
keys = {
|
||||
"play_pause": "media_play_pause",
|
||||
"next": "media_next",
|
||||
"previous": "media_previous",
|
||||
"stop": "media_stop",
|
||||
"volume_up": "media_volume_up",
|
||||
"volume_down": "media_volume_down",
|
||||
"mute": "media_volume_mute",
|
||||
}
|
||||
key_name = keys.get(command)
|
||||
if not key_name:
|
||||
raise ValueError(f"Unknown media command '{command}'.")
|
||||
key = getattr(Key, key_name, None)
|
||||
if key is None:
|
||||
raise RuntimeError(f"pynput does not expose Key.{key_name} on this platform.")
|
||||
keyboard = Controller()
|
||||
keyboard.press(key)
|
||||
keyboard.release(key)
|
||||
|
||||
|
||||
PLUGIN = MediaControlsPlugin()
|
||||
|
||||
40
plugins/obs_integration.py
Normal file
40
plugins/obs_integration.py
Normal file
@@ -0,0 +1,40 @@
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class OBSIntegrationPlugin:
|
||||
name = "OBS Integration"
|
||||
desc = "Sample backend plugin showing typed actions for future OBS control."
|
||||
version = "0.1.0"
|
||||
actions = [
|
||||
{
|
||||
"id": "switch_scene",
|
||||
"name": "Switch Scene",
|
||||
"desc": "Switch OBS to a named scene. This sample logs the intended action.",
|
||||
"fields": [
|
||||
{"id": "scene", "label": "Scene Name", "type": "text", "required": True, "default": "Starting Soon"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "toggle_stream",
|
||||
"name": "Toggle Stream",
|
||||
"desc": "Toggle stream state. This sample logs the intended action.",
|
||||
"fields": [],
|
||||
},
|
||||
]
|
||||
|
||||
def on_load(self, ctx):
|
||||
ctx.db.add_event("plugin.loaded", {"plugin": self.name})
|
||||
|
||||
def on_event(self, ctx, event):
|
||||
if event["type"].startswith("button."):
|
||||
ctx.db.add_event("plugin.event", {"plugin": self.name, "event": event["type"]})
|
||||
|
||||
def execute_action(self, ctx, action_id, config, event):
|
||||
ctx.db.add_event(
|
||||
"plugin.action",
|
||||
{"plugin": self.name, "action_id": action_id, "config": config, "source_event": event},
|
||||
)
|
||||
|
||||
|
||||
PLUGIN = OBSIntegrationPlugin()
|
||||
|
||||
Reference in New Issue
Block a user