v2.0.0: tool-based architecture (no LLM on client)
This commit is contained in:
commit
87d5db0c25
2
easybot_windows_agent/__init__.py
Normal file
2
easybot_windows_agent/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
"""EasyBot Windows Agent — tool-based remote PC automation client."""
|
||||
__version__ = "2.0.0"
|
||||
566
easybot_windows_agent/main.py
Normal file
566
easybot_windows_agent/main.py
Normal file
@ -0,0 +1,566 @@
|
||||
"""
|
||||
EasyBot Windows Agent — tool-based remote automation client.
|
||||
|
||||
The PC exposes granular tools (run_command, get_ui_tree, click, type, etc.)
|
||||
and the EasyBot LLM agent controls everything directly. No LLM on client side.
|
||||
|
||||
Configuration via environment variables:
|
||||
EASYBOT_SERVER WebSocket URL (e.g., wss://app.example.com/ws/windows-agent)
|
||||
EASYBOT_TOKEN Authentication token (eb_wag_...)
|
||||
LOG_LEVEL DEBUG | INFO | WARNING (default: INFO)
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import websockets
|
||||
from websockets.client import WebSocketClientProtocol
|
||||
|
||||
from . import __version__
|
||||
|
||||
logger = logging.getLogger("easybot-windows-agent")
|
||||
|
||||
# ── Configuration ────────────────────────────────────────────────────────────
|
||||
|
||||
RECONNECT_DELAY_BASE = 5
|
||||
RECONNECT_DELAY_MAX = 120
|
||||
TOOL_TIMEOUT = 60 # max seconds for a single tool call
|
||||
|
||||
|
||||
# ── Tool Handlers ────────────────────────────────────────────────────────────
|
||||
|
||||
async def tool_run_command(args: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Execute a shell command and return output."""
|
||||
command = args.get("command", "")
|
||||
shell = args.get("shell", "powershell")
|
||||
|
||||
if not command:
|
||||
return {"error": "No command provided"}
|
||||
|
||||
try:
|
||||
if shell == "cmd":
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"cmd", "/c", command,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||
creationflags=subprocess.CREATE_NO_WINDOW if sys.platform == "win32" else 0,
|
||||
)
|
||||
else:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"powershell", "-NoProfile", "-NonInteractive", "-Command", command,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||
creationflags=subprocess.CREATE_NO_WINDOW if sys.platform == "win32" else 0,
|
||||
)
|
||||
|
||||
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=TOOL_TIMEOUT)
|
||||
return {
|
||||
"stdout": stdout.decode("utf-8", errors="replace").strip(),
|
||||
"stderr": stderr.decode("utf-8", errors="replace").strip(),
|
||||
"exit_code": proc.returncode,
|
||||
}
|
||||
except asyncio.TimeoutError:
|
||||
return {"error": "Command timed out", "exit_code": -1}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
async def tool_get_ui_tree(args: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Get the accessibility tree of a window or the desktop."""
|
||||
import uiautomation as auto
|
||||
|
||||
window_title = args.get("window")
|
||||
max_depth = args.get("depth", 3)
|
||||
|
||||
try:
|
||||
if window_title:
|
||||
root = auto.WindowControl(searchDepth=1, Name=window_title)
|
||||
if not root.Exists(maxSearchSeconds=3):
|
||||
# Try substring match
|
||||
root = auto.WindowControl(searchDepth=1, SubName=window_title)
|
||||
if not root.Exists(maxSearchSeconds=2):
|
||||
return {"error": f"Window '{window_title}' not found"}
|
||||
else:
|
||||
root = auto.GetForegroundControl()
|
||||
if not root:
|
||||
root = auto.GetRootControl()
|
||||
|
||||
def walk(ctrl: Any, depth: int, max_d: int) -> list[dict]:
|
||||
if depth > max_d:
|
||||
return []
|
||||
items = []
|
||||
try:
|
||||
name = ctrl.Name or ""
|
||||
ctrl_type = ctrl.ControlTypeName or ""
|
||||
auto_id = ctrl.AutomationId or ""
|
||||
rect = ctrl.BoundingRectangle
|
||||
value = ""
|
||||
try:
|
||||
value = ctrl.GetValuePattern().Value if ctrl.GetValuePattern() else ""
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
item: dict[str, Any] = {
|
||||
"type": ctrl_type,
|
||||
"name": name,
|
||||
}
|
||||
if auto_id:
|
||||
item["automation_id"] = auto_id
|
||||
if value:
|
||||
item["value"] = value
|
||||
if rect and rect.width() > 0:
|
||||
item["rect"] = {"x": rect.left, "y": rect.top, "w": rect.width(), "h": rect.height()}
|
||||
|
||||
children = []
|
||||
if depth < max_d:
|
||||
for child in ctrl.GetChildren():
|
||||
children.extend(walk(child, depth + 1, max_d))
|
||||
if children:
|
||||
item["children"] = children
|
||||
|
||||
items.append(item)
|
||||
except Exception:
|
||||
pass
|
||||
return items
|
||||
|
||||
tree = walk(root, 0, max_depth)
|
||||
return {"window": root.Name or "Desktop", "tree": tree}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
async def tool_list_windows(args: dict[str, Any]) -> dict[str, Any]:
|
||||
"""List all open top-level windows."""
|
||||
import uiautomation as auto
|
||||
|
||||
try:
|
||||
windows = []
|
||||
for win in auto.GetRootControl().GetChildren():
|
||||
try:
|
||||
if win.ControlTypeName == "WindowControl" or win.ClassName == "Window":
|
||||
name = win.Name or ""
|
||||
if name and name != "Program Manager":
|
||||
windows.append({
|
||||
"title": name,
|
||||
"class": win.ClassName or "",
|
||||
"process_id": win.ProcessId,
|
||||
})
|
||||
except Exception:
|
||||
continue
|
||||
return {"windows": windows}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
async def tool_click_element(args: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Click a UI element by name, automation_id, or coordinates."""
|
||||
name = args.get("name")
|
||||
auto_id = args.get("automation_id")
|
||||
x = args.get("x")
|
||||
y = args.get("y")
|
||||
button = args.get("button", "left")
|
||||
double = args.get("double", False)
|
||||
|
||||
try:
|
||||
if x is not None and y is not None:
|
||||
import pyautogui
|
||||
if double:
|
||||
pyautogui.doubleClick(x, y)
|
||||
elif button == "right":
|
||||
pyautogui.rightClick(x, y)
|
||||
else:
|
||||
pyautogui.click(x, y)
|
||||
return {"success": True, "method": "coordinates", "x": x, "y": y}
|
||||
|
||||
import uiautomation as auto
|
||||
ctrl = None
|
||||
if auto_id:
|
||||
ctrl = auto.Control(searchDepth=8, AutomationId=auto_id)
|
||||
elif name:
|
||||
ctrl = auto.Control(searchDepth=8, Name=name)
|
||||
|
||||
if ctrl and ctrl.Exists(maxSearchSeconds=3):
|
||||
if double:
|
||||
ctrl.DoubleClick()
|
||||
else:
|
||||
ctrl.Click()
|
||||
return {"success": True, "method": "ui_automation", "element": ctrl.Name}
|
||||
else:
|
||||
return {"error": f"Element not found: name={name}, automation_id={auto_id}"}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
async def tool_type_text(args: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Type text, optionally into a specific element."""
|
||||
text = args.get("text", "")
|
||||
element_name = args.get("element_name")
|
||||
|
||||
if not text:
|
||||
return {"error": "No text provided"}
|
||||
|
||||
try:
|
||||
if element_name:
|
||||
import uiautomation as auto
|
||||
ctrl = auto.Control(searchDepth=8, Name=element_name)
|
||||
if ctrl.Exists(maxSearchSeconds=3):
|
||||
try:
|
||||
ctrl.GetValuePattern().SetValue(text)
|
||||
return {"success": True, "method": "set_value", "element": element_name}
|
||||
except Exception:
|
||||
ctrl.Click()
|
||||
await asyncio.sleep(0.2)
|
||||
|
||||
import pyautogui
|
||||
pyautogui.typewrite(text, interval=0.02) if text.isascii() else pyautogui.write(text)
|
||||
return {"success": True, "method": "keyboard"}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
async def tool_press_keys(args: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Press keyboard keys/shortcuts (e.g. 'ctrl+s', 'enter', 'alt+f4')."""
|
||||
keys = args.get("keys", "")
|
||||
if not keys:
|
||||
return {"error": "No keys provided"}
|
||||
|
||||
try:
|
||||
import pyautogui
|
||||
parts = [k.strip() for k in keys.lower().split("+")]
|
||||
if len(parts) > 1:
|
||||
pyautogui.hotkey(*parts)
|
||||
else:
|
||||
pyautogui.press(parts[0])
|
||||
return {"success": True, "keys": keys}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
async def tool_screenshot(args: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Take a screenshot and return as base64 PNG."""
|
||||
from PIL import ImageGrab
|
||||
|
||||
try:
|
||||
region = args.get("region")
|
||||
if region:
|
||||
bbox = (region["x"], region["y"], region["x"] + region["w"], region["y"] + region["h"])
|
||||
img = ImageGrab.grab(bbox=bbox)
|
||||
else:
|
||||
img = ImageGrab.grab()
|
||||
|
||||
# Resize for efficiency (max 1280px wide)
|
||||
if img.width > 1280:
|
||||
ratio = 1280 / img.width
|
||||
img = img.resize((1280, int(img.height * ratio)))
|
||||
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="PNG", optimize=True)
|
||||
b64 = base64.b64encode(buf.getvalue()).decode("ascii")
|
||||
return {"image_base64": b64, "width": img.width, "height": img.height}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
async def tool_read_clipboard(args: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Read text from the clipboard."""
|
||||
try:
|
||||
import pyperclip
|
||||
text = pyperclip.paste()
|
||||
return {"text": text}
|
||||
except ImportError:
|
||||
# Fallback via PowerShell
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"powershell", "-NoProfile", "-Command", "Get-Clipboard",
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||
)
|
||||
stdout, _ = await proc.communicate()
|
||||
return {"text": stdout.decode("utf-8", errors="replace").strip()}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
async def tool_set_clipboard(args: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Set clipboard text content."""
|
||||
text = args.get("text", "")
|
||||
try:
|
||||
import pyperclip
|
||||
pyperclip.copy(text)
|
||||
return {"success": True}
|
||||
except ImportError:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"powershell", "-NoProfile", "-Command", f"Set-Clipboard -Value '{text}'",
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||
)
|
||||
await proc.communicate()
|
||||
return {"success": True}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
async def tool_focus_window(args: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Bring a window to the foreground."""
|
||||
title = args.get("title", "")
|
||||
if not title:
|
||||
return {"error": "No window title provided"}
|
||||
|
||||
try:
|
||||
import uiautomation as auto
|
||||
win = auto.WindowControl(searchDepth=1, SubName=title)
|
||||
if win.Exists(maxSearchSeconds=3):
|
||||
win.SetFocus()
|
||||
win.SetActive()
|
||||
return {"success": True, "window": win.Name}
|
||||
else:
|
||||
return {"error": f"Window '{title}' not found"}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
async def tool_file_read(args: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Read a text file."""
|
||||
path = args.get("path", "")
|
||||
if not path:
|
||||
return {"error": "No path provided"}
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8", errors="replace") as f:
|
||||
content = f.read(100_000) # limit to 100KB
|
||||
return {"content": content, "size": os.path.getsize(path)}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
async def tool_file_write(args: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Write content to a text file."""
|
||||
path = args.get("path", "")
|
||||
content = args.get("content", "")
|
||||
if not path:
|
||||
return {"error": "No path provided"}
|
||||
try:
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True) if os.path.dirname(path) else None
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
f.write(content)
|
||||
return {"success": True, "bytes_written": len(content.encode("utf-8"))}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
# ── Tool Registry ────────────────────────────────────────────────────────────
|
||||
|
||||
TOOLS: dict[str, Any] = {
|
||||
"run_command": tool_run_command,
|
||||
"get_ui_tree": tool_get_ui_tree,
|
||||
"list_windows": tool_list_windows,
|
||||
"click_element": tool_click_element,
|
||||
"type_text": tool_type_text,
|
||||
"press_keys": tool_press_keys,
|
||||
"screenshot": tool_screenshot,
|
||||
"read_clipboard": tool_read_clipboard,
|
||||
"set_clipboard": tool_set_clipboard,
|
||||
"focus_window": tool_focus_window,
|
||||
"file_read": tool_file_read,
|
||||
"file_write": tool_file_write,
|
||||
}
|
||||
|
||||
|
||||
# ── Agent Client ─────────────────────────────────────────────────────────────
|
||||
|
||||
class AgentClient:
|
||||
def __init__(self, server_url: str, token: str):
|
||||
self.server_url = server_url
|
||||
self.token = token
|
||||
self.ws: WebSocketClientProtocol | None = None
|
||||
self.running = True
|
||||
self.current_call_id: str | None = None
|
||||
self.cancel_event = asyncio.Event()
|
||||
self._reconnect_delay = RECONNECT_DELAY_BASE
|
||||
|
||||
async def run(self) -> None:
|
||||
"""Main loop: connect → handle messages → reconnect on failure."""
|
||||
while self.running:
|
||||
try:
|
||||
await self._connect_and_handle()
|
||||
except (websockets.ConnectionClosed, OSError, ConnectionRefusedError) as e:
|
||||
logger.warning("Connection lost: %s — reconnecting in %ds", e, self._reconnect_delay)
|
||||
except Exception:
|
||||
logger.exception("Unexpected error — reconnecting in %ds", self._reconnect_delay)
|
||||
|
||||
if not self.running:
|
||||
break
|
||||
await asyncio.sleep(self._reconnect_delay)
|
||||
self._reconnect_delay = min(self._reconnect_delay * 2, RECONNECT_DELAY_MAX)
|
||||
|
||||
async def _connect_and_handle(self) -> None:
|
||||
"""Single connection lifecycle."""
|
||||
extra_headers = {"Authorization": f"Bearer {self.token}"}
|
||||
|
||||
async with websockets.connect(
|
||||
self.server_url,
|
||||
additional_headers=extra_headers,
|
||||
ping_interval=None,
|
||||
max_size=16 * 1024 * 1024,
|
||||
close_timeout=10,
|
||||
) as ws:
|
||||
self.ws = ws
|
||||
self._reconnect_delay = RECONNECT_DELAY_BASE
|
||||
logger.info("Connected to %s", self.server_url)
|
||||
|
||||
# Send client info with list of available tools
|
||||
await self._send({
|
||||
"type": "client_info",
|
||||
"hostname": platform.node(),
|
||||
"os_version": f"{platform.system()} {platform.version()}",
|
||||
"python_version": platform.python_version(),
|
||||
"client_version": __version__,
|
||||
"tools": list(TOOLS.keys()),
|
||||
})
|
||||
|
||||
try:
|
||||
async for raw in ws:
|
||||
msg = json.loads(raw)
|
||||
await self._handle_message(msg)
|
||||
finally:
|
||||
self.ws = None
|
||||
self.current_call_id = None
|
||||
|
||||
async def _handle_message(self, msg: dict[str, Any]) -> None:
|
||||
"""Dispatch incoming messages from the server."""
|
||||
msg_type = msg.get("type")
|
||||
|
||||
if msg_type == "ping":
|
||||
await self._send({"type": "pong"})
|
||||
|
||||
elif msg_type == "tool_call":
|
||||
call_id = msg["call_id"]
|
||||
tool_name = msg["tool_name"]
|
||||
arguments = msg.get("arguments", {})
|
||||
logger.info("Tool call: %s (call_id=%s)", tool_name, call_id[:8])
|
||||
asyncio.create_task(self._execute_tool(call_id, tool_name, arguments))
|
||||
|
||||
elif msg_type == "cancel":
|
||||
call_id = msg.get("call_id")
|
||||
if self.current_call_id == call_id:
|
||||
logger.info("Cancel requested for %s", call_id)
|
||||
self.cancel_event.set()
|
||||
|
||||
else:
|
||||
logger.debug("Unknown message type: %s", msg_type)
|
||||
|
||||
async def _execute_tool(self, call_id: str, tool_name: str, arguments: dict[str, Any]) -> None:
|
||||
"""Execute a tool and return the result."""
|
||||
self.current_call_id = call_id
|
||||
self.cancel_event.clear()
|
||||
start = time.time()
|
||||
|
||||
try:
|
||||
handler = TOOLS.get(tool_name)
|
||||
if not handler:
|
||||
await self._send({
|
||||
"type": "tool_result",
|
||||
"call_id": call_id,
|
||||
"error": f"Unknown tool: {tool_name}",
|
||||
"duration_ms": 0,
|
||||
})
|
||||
return
|
||||
|
||||
result = await asyncio.wait_for(handler(arguments), timeout=TOOL_TIMEOUT)
|
||||
duration_ms = int((time.time() - start) * 1000)
|
||||
|
||||
if "error" in result:
|
||||
await self._send({
|
||||
"type": "tool_result",
|
||||
"call_id": call_id,
|
||||
"error": result["error"],
|
||||
"duration_ms": duration_ms,
|
||||
})
|
||||
else:
|
||||
await self._send({
|
||||
"type": "tool_result",
|
||||
"call_id": call_id,
|
||||
"result": result,
|
||||
"duration_ms": duration_ms,
|
||||
})
|
||||
|
||||
logger.info("Tool %s completed in %dms", tool_name, duration_ms)
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
await self._send({
|
||||
"type": "tool_result",
|
||||
"call_id": call_id,
|
||||
"error": f"Tool '{tool_name}' timed out after {TOOL_TIMEOUT}s",
|
||||
"duration_ms": int((time.time() - start) * 1000),
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error("Tool %s failed: %s", tool_name, e)
|
||||
await self._send({
|
||||
"type": "tool_result",
|
||||
"call_id": call_id,
|
||||
"error": f"{type(e).__name__}: {e}",
|
||||
"duration_ms": int((time.time() - start) * 1000),
|
||||
})
|
||||
finally:
|
||||
self.current_call_id = None
|
||||
|
||||
async def _send(self, msg: dict[str, Any]) -> None:
|
||||
"""Send a JSON message to the server."""
|
||||
if self.ws:
|
||||
try:
|
||||
await self.ws.send(json.dumps(msg))
|
||||
except (websockets.ConnectionClosed, Exception):
|
||||
pass
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Signal the client to stop."""
|
||||
self.running = False
|
||||
self.cancel_event.set()
|
||||
|
||||
|
||||
# ── Entry Point ──────────────────────────────────────────────────────────────
|
||||
|
||||
def main() -> None:
|
||||
server_url = os.environ.get("EASYBOT_SERVER")
|
||||
token = os.environ.get("EASYBOT_TOKEN")
|
||||
|
||||
if not server_url or not token:
|
||||
print("Error: EASYBOT_SERVER and EASYBOT_TOKEN environment variables are required.")
|
||||
print()
|
||||
print("Usage:")
|
||||
print(" set EASYBOT_SERVER=wss://app.example.com/ws/windows-agent")
|
||||
print(" set EASYBOT_TOKEN=eb_wag_...")
|
||||
print(" easybot-windows-agent")
|
||||
sys.exit(1)
|
||||
|
||||
log_level = os.environ.get("LOG_LEVEL", "INFO").upper()
|
||||
logging.basicConfig(
|
||||
level=getattr(logging, log_level, logging.INFO),
|
||||
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
|
||||
logger.info("EasyBot Windows Agent v%s starting", __version__)
|
||||
logger.info("Server: %s", server_url)
|
||||
logger.info("Token: %s...", token[:12] if len(token) > 12 else "***")
|
||||
logger.info("Available tools: %s", ", ".join(TOOLS.keys()))
|
||||
|
||||
client = AgentClient(server_url, token)
|
||||
|
||||
def handle_signal(*_: Any) -> None:
|
||||
logger.info("Shutdown signal received")
|
||||
client.stop()
|
||||
|
||||
signal.signal(signal.SIGINT, handle_signal)
|
||||
signal.signal(signal.SIGTERM, handle_signal)
|
||||
|
||||
asyncio.run(client.run())
|
||||
logger.info("Agent stopped")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
207
install.ps1
Normal file
207
install.ps1
Normal file
@ -0,0 +1,207 @@
|
||||
# ============================================================================
|
||||
# EasyBot Windows Agent — One-Line Installer
|
||||
# ============================================================================
|
||||
#
|
||||
# Usage:
|
||||
# powershell -ExecutionPolicy Bypass -c "irm https://raw.githubusercontent.com/pyptechnologies/easybot-windows-agent/main/install.ps1 | iex"
|
||||
#
|
||||
# Then configure:
|
||||
# $env:EASYBOT_SERVER = "wss://your-server.com/ws/windows-agent"
|
||||
# $env:EASYBOT_TOKEN = "eb_wag_..."
|
||||
# easybot-windows-agent
|
||||
#
|
||||
# ============================================================================
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$InstallDir = "$env:ProgramData\EasyBot\WindowsAgent"
|
||||
$ServiceName = "EasyBotWindowsAgent"
|
||||
$PythonMinVersion = [version]"3.11.0"
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "========================================" -ForegroundColor Cyan
|
||||
Write-Host " EasyBot Windows Agent Installer v1.0" -ForegroundColor Cyan
|
||||
Write-Host "========================================" -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
|
||||
# ── Check admin ──────────────────────────────────────────────────────────────
|
||||
|
||||
$isAdmin = ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
|
||||
if (-not $isAdmin) {
|
||||
Write-Host "[!] This installer requires administrator privileges." -ForegroundColor Yellow
|
||||
Write-Host " Please run PowerShell as Administrator and try again." -ForegroundColor Yellow
|
||||
exit 1
|
||||
}
|
||||
|
||||
# ── Check Python ─────────────────────────────────────────────────────────────
|
||||
|
||||
Write-Host "[1/5] Checking Python..." -ForegroundColor White
|
||||
$pythonCmd = $null
|
||||
foreach ($cmd in @("python", "python3", "py -3")) {
|
||||
try {
|
||||
$ver = & $cmd.Split()[0] $cmd.Split()[1..99] --version 2>&1
|
||||
if ($ver -match "Python (\d+\.\d+\.\d+)") {
|
||||
$pyVer = [version]$Matches[1]
|
||||
if ($pyVer -ge $PythonMinVersion) {
|
||||
$pythonCmd = $cmd
|
||||
Write-Host " Found Python $pyVer" -ForegroundColor Green
|
||||
break
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (-not $pythonCmd) {
|
||||
Write-Host " Python >= 3.11 not found. Installing via winget..." -ForegroundColor Yellow
|
||||
try {
|
||||
winget install Python.Python.3.12 --accept-source-agreements --accept-package-agreements --silent
|
||||
# Refresh PATH
|
||||
$env:Path = [System.Environment]::GetEnvironmentVariable("Path", "Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path", "User")
|
||||
$pythonCmd = "python"
|
||||
Write-Host " Python installed successfully" -ForegroundColor Green
|
||||
} catch {
|
||||
Write-Host "[X] Failed to install Python. Please install Python 3.11+ manually." -ForegroundColor Red
|
||||
Write-Host " https://www.python.org/downloads/" -ForegroundColor Gray
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
# ── Create install directory ─────────────────────────────────────────────────
|
||||
|
||||
Write-Host "[2/5] Creating install directory..." -ForegroundColor White
|
||||
New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null
|
||||
Write-Host " $InstallDir" -ForegroundColor Gray
|
||||
|
||||
# ── Install Python package from Gitea ────────────────────────────────────────
|
||||
|
||||
Write-Host "[3/5] Installing easybot-windows-agent from repository..." -ForegroundColor White
|
||||
$repoZip = "$env:TEMP\easybot-windows-agent.zip"
|
||||
$repoExtract = "$env:TEMP\easybot-windows-agent"
|
||||
$repoUrl = "https://repo.pyp.ar/public-pull/easybot-windows-agent/archive/main.zip"
|
||||
|
||||
# Download and extract
|
||||
if (Test-Path $repoExtract) { Remove-Item $repoExtract -Recurse -Force }
|
||||
Write-Host " Downloading from repository..." -ForegroundColor Gray
|
||||
Invoke-WebRequest -Uri $repoUrl -OutFile $repoZip -UseBasicParsing
|
||||
Expand-Archive -Path $repoZip -DestinationPath $repoExtract -Force
|
||||
|
||||
# Find the extracted folder (Gitea names it easybot-windows-agent/)
|
||||
$pkgDir = Get-ChildItem -Path $repoExtract -Directory | Select-Object -First 1
|
||||
|
||||
# Temporarily allow errors (pip writes warnings to stderr which PowerShell treats as errors)
|
||||
$ErrorActionPreference = "Continue"
|
||||
|
||||
# pip install from local directory
|
||||
Write-Host " Installing package..." -ForegroundColor Gray
|
||||
& $pythonCmd.Split()[0] $pythonCmd.Split()[1..99] -m pip install --force-reinstall --no-deps $pkgDir.FullName 2>&1 | ForEach-Object {
|
||||
if ($_ -match "Successfully installed") { Write-Host " $_" -ForegroundColor Green }
|
||||
}
|
||||
|
||||
# Install dependencies
|
||||
Write-Host " Installing dependencies..." -ForegroundColor Gray
|
||||
& $pythonCmd.Split()[0] $pythonCmd.Split()[1..99] -m pip install "websockets>=12.0,<14" "uiautomation>=2.0" "pyautogui>=0.9" "pywinauto>=0.6" "Pillow>=10.0" 2>&1 | Out-Null
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
# Cleanup
|
||||
Remove-Item $repoZip -Force -ErrorAction SilentlyContinue
|
||||
Remove-Item $repoExtract -Recurse -Force -ErrorAction SilentlyContinue
|
||||
Write-Host " Package installed successfully" -ForegroundColor Green
|
||||
|
||||
# ── Configure environment ────────────────────────────────────────────────────
|
||||
|
||||
Write-Host "[4/5] Configuration..." -ForegroundColor White
|
||||
|
||||
$configFile = "$InstallDir\config.env"
|
||||
if (Test-Path $configFile) {
|
||||
Write-Host " Existing config found at $configFile" -ForegroundColor Gray
|
||||
} else {
|
||||
# Use env vars if already set, otherwise prompt
|
||||
$server = if ($env:EASYBOT_SERVER) { $env:EASYBOT_SERVER } else { Read-Host " Enter EasyBot server URL (e.g., wss://app.example.com/ws/windows-agent)" }
|
||||
$token = if ($env:EASYBOT_TOKEN) { $env:EASYBOT_TOKEN } else { Read-Host " Enter authentication token (eb_wag_...)" }
|
||||
|
||||
Write-Host " Server: $server" -ForegroundColor Gray
|
||||
Write-Host " Token: $($token.Substring(0, [Math]::Min(12, $token.Length)))..." -ForegroundColor Gray
|
||||
|
||||
@"
|
||||
EASYBOT_SERVER=$server
|
||||
EASYBOT_TOKEN=$token
|
||||
LOG_LEVEL=INFO
|
||||
"@ | Set-Content -Path $configFile -Encoding UTF8
|
||||
|
||||
Write-Host " Config saved to $configFile" -ForegroundColor Green
|
||||
}
|
||||
|
||||
# ── Install as Windows Service via nssm ──────────────────────────────────────
|
||||
|
||||
Write-Host "[5/5] Installing as Windows Service..." -ForegroundColor White
|
||||
|
||||
# Download nssm if not present
|
||||
$nssmPath = "$InstallDir\nssm.exe"
|
||||
if (-not (Test-Path $nssmPath)) {
|
||||
Write-Host " Downloading nssm..." -ForegroundColor Gray
|
||||
$nssmUrl = "https://repo.pyp.ar/public-pull/easybot-windows-agent/raw/branch/main/bin/nssm.exe"
|
||||
Invoke-WebRequest -Uri $nssmUrl -OutFile $nssmPath -UseBasicParsing
|
||||
}
|
||||
|
||||
# Find the easybot-windows-agent script
|
||||
$agentScript = & $pythonCmd.Split()[0] $pythonCmd.Split()[1..99] -c "import shutil; print(shutil.which('easybot-windows-agent') or '')" 2>$null
|
||||
if (-not $agentScript) {
|
||||
# Fallback: use python -m
|
||||
$agentScript = ""
|
||||
}
|
||||
|
||||
# Remove existing service if present (ignore errors if not installed)
|
||||
$ErrorActionPreference = "Continue"
|
||||
& $nssmPath stop $ServiceName 2>&1 | Out-Null
|
||||
& $nssmPath remove $ServiceName confirm 2>&1 | Out-Null
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
# Parse config.env for environment variables
|
||||
$envVars = @()
|
||||
Get-Content $configFile | ForEach-Object {
|
||||
if ($_ -match "^([^#=]+)=(.*)$") {
|
||||
$envVars += "$($Matches[1])=$($Matches[2])"
|
||||
}
|
||||
}
|
||||
|
||||
if ($agentScript) {
|
||||
& $nssmPath install $ServiceName $agentScript
|
||||
} else {
|
||||
$pythonExe = & $pythonCmd.Split()[0] $pythonCmd.Split()[1..99] -c "import sys; print(sys.executable)"
|
||||
& $nssmPath install $ServiceName $pythonExe "-m" "easybot_windows_agent.main"
|
||||
}
|
||||
|
||||
& $nssmPath set $ServiceName DisplayName "EasyBot Windows Agent"
|
||||
& $nssmPath set $ServiceName Description "Remote Windows GUI automation agent for EasyBot"
|
||||
& $nssmPath set $ServiceName AppDirectory $InstallDir
|
||||
& $nssmPath set $ServiceName AppEnvironmentExtra ($envVars -join "`n")
|
||||
& $nssmPath set $ServiceName AppStdout "$InstallDir\agent.log"
|
||||
& $nssmPath set $ServiceName AppStderr "$InstallDir\agent.log"
|
||||
& $nssmPath set $ServiceName AppRotateFiles 1
|
||||
& $nssmPath set $ServiceName AppRotateBytes 10485760
|
||||
& $nssmPath set $ServiceName Start SERVICE_AUTO_START
|
||||
& $nssmPath set $ServiceName AppExit Default Restart
|
||||
& $nssmPath set $ServiceName AppRestartDelay 5000
|
||||
|
||||
Write-Host " Service installed: $ServiceName" -ForegroundColor Green
|
||||
|
||||
# Start the service
|
||||
& $nssmPath start $ServiceName | Out-Null
|
||||
Write-Host " Service started!" -ForegroundColor Green
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "========================================" -ForegroundColor Cyan
|
||||
Write-Host " Installation Complete!" -ForegroundColor Cyan
|
||||
Write-Host "========================================" -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
Write-Host " Install dir: $InstallDir" -ForegroundColor Gray
|
||||
Write-Host " Config file: $configFile" -ForegroundColor Gray
|
||||
Write-Host " Log file: $InstallDir\agent.log" -ForegroundColor Gray
|
||||
Write-Host " Service: $ServiceName" -ForegroundColor Gray
|
||||
Write-Host ""
|
||||
Write-Host " Commands:" -ForegroundColor White
|
||||
Write-Host " nssm status $ServiceName # Check status" -ForegroundColor Gray
|
||||
Write-Host " nssm restart $ServiceName # Restart agent" -ForegroundColor Gray
|
||||
Write-Host " nssm stop $ServiceName # Stop agent" -ForegroundColor Gray
|
||||
Write-Host " nssm remove $ServiceName # Uninstall" -ForegroundColor Gray
|
||||
Write-Host ""
|
||||
19
pyproject.toml
Normal file
19
pyproject.toml
Normal file
@ -0,0 +1,19 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=68.0", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "easybot-windows-agent"
|
||||
version = "2.0.0"
|
||||
description = "EasyBot Windows Agent — remote PC tools client (no LLM)"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"websockets>=12.0,<14",
|
||||
"uiautomation>=2.0",
|
||||
"pyautogui>=0.9",
|
||||
"pywinauto>=0.6",
|
||||
"Pillow>=10.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
easybot-windows-agent = "easybot_windows_agent.main:main"
|
||||
Loading…
Reference in New Issue
Block a user