initial Commit

This commit is contained in:
2026-05-10 12:46:33 +02:00
commit 108f08645c
36 changed files with 8688 additions and 0 deletions

279
plugins/clipboard_tools.py Normal file
View 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
View 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
View 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()

View 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()