add stealth system, key detection, click recorder, FOHdin, new routes, and tooling updates

This commit is contained in:
alex
2026-05-21 23:34:20 +02:00
parent 8dc677d2e6
commit 75f64351ee
84 changed files with 3202 additions and 415 deletions
+205
View File
@@ -0,0 +1,205 @@
"""
Native input layer - replaces `keyboard` and `mouse` (pyclick) libraries.
No kernel drivers, no third-party DLLs. Only standard system DLLs (user32, kernel32).
Import as:
from input_layer import keyboard, mouse
This is a drop-in replacement for the existing `import keyboard` and
`from utils.custom_mouse import mouse` patterns.
"""
from .win_input import (
_get_vk, key_down, key_up, key_press, send_key, key_state,
mouse_move, mouse_down, mouse_up, mouse_click, mouse_wheel, get_cursor_pos,
send_text, VK_MAP
)
from .mouse_impl import mouse
class _Keyboard:
"""
Drop-in replacement for the `keyboard` library.
Supports all patterns used in botty: send(), press(), release(), is_pressed(),
add_hotkey(), wait(), write(), hook(), pause().
All key presses include stealth micro-pauses and variable press duration
automatically - no changes to existing code needed.
"""
def _stealth_before(self):
"""Add micro-pause before key press (stealth)."""
try:
from utils.stealth import add_micro_pause
add_micro_pause()
except Exception:
pass
def _stealth_after(self):
"""Add micro-pause after key press (stealth)."""
try:
from utils.stealth import add_micro_pause
add_micro_pause()
except Exception:
pass
def _stealth_duration(self):
"""Get human-like key press duration."""
try:
from utils.stealth import key_press_duration
return key_press_duration()
except Exception:
import random
return random.uniform(0.02, 0.15)
def _skill_rotation_hesitation(self):
"""Add hesitation before skill casts (Tier 2 behavior stealth)."""
try:
from utils.stealth import skill_rotation_hesitation
from utils.misc import wait as _wait
_wait(skill_rotation_hesitation(), skill_rotation_hesitation() * 1.2)
except Exception:
import random
from utils.misc import wait as _wait
_wait(random.uniform(0.08, 0.3), random.uniform(0.08, 0.3) * 1.2)
def _maybe_skill_mistake(self, key: str):
"""
1-2% chance of pressing a random skill key first before the intended one
(Tier 2 behavior stealth - simulates miscasting).
Returns True if a mistake was made and corrected.
"""
try:
from utils.stealth import should_correct_skill_mistake
if not should_correct_skill_mistake():
return False
except Exception:
return False
# Press a random skill key first (simulates miscast)
import random
try:
from config import Config
skill_keys = list(range(ord('1'), ord('0') + 1)) # Keys 1-0
wrong_key = chr(random.choice(skill_keys))
if wrong_key == key:
wrong_key = chr(random.choice(skill_keys))
except Exception:
wrong_key = random.choice(['1', '2', '3', '4', '5'])
# Send the wrong key
wrong_vk = _get_vk(wrong_key)
if wrong_vk is not None:
key_down(wrong_vk)
from utils.misc import wait as _wait
_wait(self._stealth_duration(), self._stealth_duration() * 1.2)
key_up(wrong_vk)
return True
def send(self, key: str, do_press: bool = True, do_release: bool = True, delay=None):
"""
Send a key press event with stealth timing.
Supports combo keys like 'shift + a', 'ctrl + alt + del'.
Supports do_release=False (hold key) and do_press=False (release-only).
"""
self._stealth_before()
# Handle combo keys (e.g. 'shift + a', 'ctrl + alt + del')
if ' + ' in key or '+' in key:
# Parse combo
parts = [p.strip() for p in key.replace('+', ' + ').split(' + ') if p.strip()]
modifiers = []
final_key = parts[-1]
for p in parts[:-1]:
vk = _get_vk(p)
if vk is not None:
modifiers.append(vk)
vk = _get_vk(final_key)
if vk is None:
raise ValueError(f"Unknown key: {key}")
if do_press:
for mvk in modifiers:
key_down(mvk)
key_down(vk)
if do_release:
if do_press:
from utils.misc import wait as _wait
_wait(self._stealth_duration(), self._stealth_duration() * 1.2)
key_up(vk)
for mvk in reversed(modifiers):
key_up(mvk)
self._stealth_after()
return
# Single key
vk = _get_vk(key)
if vk is None:
# Try as a single character
if len(key) == 1:
vk = ord(key.upper()) if key.isalpha() else ord(key)
else:
raise ValueError(f"Unknown key: {key}")
if delay is not None:
from utils.misc import wait as _wait
_wait(delay, delay * 1.2)
# Tier 2 stealth: skill hesitation (only for skill hotkeys 1-0)
if vk in range(ord('1'), ord('0') + 1) or key in ('1', '2', '3', '4', '5', '6', '7', '8', '9', '0'):
self._skill_rotation_hesitation()
self._maybe_skill_mistake(key)
if do_press:
key_down(vk)
if do_release:
from utils.misc import wait as _wait
_wait(self._stealth_duration(), self._stealth_duration() * 1.2)
if do_release:
key_up(vk)
self._stealth_after()
def press(self, key: str):
"""Press a key down (without releasing)."""
self.send(key, do_press=True, do_release=False)
def release(self, key: str):
"""Release a key (without pressing)."""
self.send(key, do_press=False, do_release=True)
def is_pressed(self, key: str) -> bool:
"""Check if a key is currently pressed."""
vk = _get_vk(key)
if vk is None:
return False
return key_state(key)
def add_hotkey(self, key: str, callback, suppress: bool = False):
"""Register a global hotkey callback."""
from .hotkey import add_hotkey as _add_hotkey
_add_hotkey(key, callback, suppress=suppress)
def wait(self, key: str = None, suppress: bool = False):
"""Block until the key is pressed. If key is None, wait for any key."""
from .hotkey import wait as _wait
return _wait(key, suppress=suppress)
def write(self, text: str, delay: float = 0.05):
"""Type text character by character."""
send_text(text, delay=delay)
def hook(self, callback, suppress: bool = False):
"""Register a callback for all key events (dev tools only)."""
from .hotkey import hook as _hook
return _hook(callback, suppress=suppress)
def pause(self, seconds: float = 0, suppress: bool = False):
"""Pause key processing (used by npc_auto_label.py)."""
from .hotkey import pause as _pause
return _pause(seconds, suppress)
# Singleton keyboard object - matches the `keyboard` module API
keyboard = _Keyboard()
# Exports for convenience
__all__ = ["keyboard", "mouse"]
+173
View File
@@ -0,0 +1,173 @@
"""
Global hotkey polling via GetAsyncKeyState.
Replaces keyboard.add_hotkey(), keyboard.wait(), keyboard.is_pressed().
No kernel driver - pure user-mode polling thread.
"""
import threading
import time
import ctypes
from ctypes import wintypes
from .win_input import _get_vk, VK_MAP, user32
class _HotkeyManager:
def __init__(self):
self._callbacks = {} # vk -> [(key_str, callback), ...]
self._running = False
self._thread = None
self._suppress = {} # vk -> bool (suppress key after callback fires)
self._lock = threading.Lock()
self._suppressed = set() # vks currently being held down in suppress mode
def _ensure_running(self):
if not self._running:
self._running = True
self._thread = threading.Thread(target=self._poll_loop, daemon=True)
self._thread.start()
def _poll_loop(self):
"""Poll GetAsyncKeyState for registered hotkeys."""
while self._running:
with self._lock:
items = list(self._callbacks.items())
for vk, entries in items:
for key_str, cb in entries:
if vk in self._suppressed:
# Key already held and suppressed
continue
state = user32.GetAsyncKeyState(vk)
if state & 0x8000: # key is down
try:
cb()
except Exception:
pass
if self._suppress.get(vk, False):
self._suppressed.add(vk)
# Wait for key release if suppressed
if self._suppressed:
still_suppressed = set()
for vk in self._suppressed:
state = user32.GetAsyncKeyState(vk)
if not (state & 0x8000):
# Key released
pass
else:
still_suppressed.add(vk)
self._suppressed = still_suppressed
from utils.misc import wait as _wait
_wait(0.018, 0.024) # ~50Hz polling with jitter (anti-cheat: non-perfect timing)
def add_hotkey(self, key: str, callback, suppress: bool = False):
vk = _get_vk(key)
if vk is None:
raise ValueError(f"Unknown key for hotkey: {key}")
with self._lock:
if vk not in self._callbacks:
self._callbacks[vk] = []
self._callbacks[vk].append((key, callback))
self._suppress[vk] = suppress
self._ensure_running()
def remove_hotkey(self, key: str, callback=None):
vk = _get_vk(key)
if vk is None:
return
with self._lock:
if vk in self._callbacks:
if callback is None:
del self._callbacks[vk]
else:
self._callbacks[vk] = [
(k, cb) for k, cb in self._callbacks[vk] if cb != callback
]
if not self._callbacks[vk]:
del self._callbacks[vk]
if not any(vk in d for d in [self._callbacks, self._suppress]):
self._suppress.pop(vk, None)
def is_pressed(self, key: str) -> bool:
vk = _get_vk(key)
if vk is None:
return False
state = user32.GetAsyncKeyState(vk)
return bool(state & 0x8000)
def wait(self, key: str = None, suppress: bool = False):
"""Block until the key is pressed. If key is None, wait for any key."""
if key is not None:
vk = _get_vk(key)
if vk is None:
raise ValueError(f"Unknown key: {key}")
while True:
state = user32.GetAsyncKeyState(vk)
if state & 0x8000:
if suppress:
while user32.GetAsyncKeyState(vk) & 0x8000:
from utils.misc import wait as _wait
_wait(0.008, 0.012)
return
from utils.misc import wait as _wait
_wait(0.018, 0.024)
else:
# Wait for any key
while True:
for vk in range(1, 256):
if user32.GetAsyncKeyState(vk) & 0x8000:
return vk
from utils.misc import wait as _wait
_wait(0.018, 0.024)
def hook(self, callback, suppress: bool = False):
"""Register a callback for all key events.
This is a simplified version - polls all known keys and calls callback.
Used by gen_ocr_samples.py and node_recorder.py (dev tools only)."""
def _poll_all():
while self._running:
for vk in range(1, 256):
state = user32.GetAsyncKeyState(vk)
if state & 0x8000:
event = {"event_type": "down", "name": None, "scan_code": 0}
try:
callback(event)
except Exception:
pass
if suppress:
while user32.GetAsyncKeyState(vk) & 0x8000:
from utils.misc import wait as _wait
_wait(0.01, 0.01)
break
from utils.misc import wait as _wait
_wait(0.02, 0.02)
self._ensure_running()
# Run hook in its own thread
t = threading.Thread(target=_poll_all, daemon=True)
t.start()
def pause(self, seconds: float = 0, suppress: bool = False):
"""Pause key processing for a duration. Used by npc_auto_label.py."""
from utils.misc import wait as _wait
_wait(seconds, seconds)
# Singleton
_hotkey_manager = _HotkeyManager()
def add_hotkey(key: str, callback, suppress: bool = False):
_hotkey_manager.add_hotkey(key, callback, suppress=suppress)
def remove_hotkey(key: str, callback=None):
_hotkey_manager.remove_hotkey(key, callback)
def is_pressed(key: str) -> bool:
_hotkey_manager._ensure_running()
return _hotkey_manager.is_pressed(key)
def wait(key: str = None, suppress: bool = False):
_hotkey_manager._ensure_running()
return _hotkey_manager.wait(key, suppress=suppress)
def hook(callback, suppress: bool = False):
return _hotkey_manager.hook(callback, suppress=suppress)
def pause(seconds: float = 0, suppress: bool = False):
return _hotkey_manager.pause(seconds, suppress)
+322
View File
@@ -0,0 +1,322 @@
"""
Human-like mouse movement with native Windows API.
Replaces custom_mouse.py (which used the `mouse` / pyclick library with mousetool.dll).
Same public API - drop-in replacement.
"""
import math
import random
import time
import threading
import numpy as np
import pytweening
from .win_input import mouse_move as _native_move, mouse_click as _native_click
from .win_input import mouse_down as _native_down, mouse_up as _native_up
from .win_input import get_cursor_pos as _native_get_pos
from .win_input import mouse_wheel as _native_wheel
def isNumeric(val):
return isinstance(val, (float, int, np.int32, np.int64, np.float32, np.float64))
def isListOfPoints(l):
if not isinstance(l, list):
return False
try:
isPoint = lambda p: ((len(p) == 2) and isNumeric(p[0]) and isNumeric(p[1]))
return all(map(isPoint, l))
except (KeyError, TypeError):
return False
class BezierCurve():
@staticmethod
def binomial(n, k):
return math.factorial(n) / float(math.factorial(k) * math.factorial(n - k))
@staticmethod
def bernsteinPolynomialPoint(x, i, n):
return BezierCurve.binomial(n, i) * (x ** i) * ((1 - x) ** (n - i))
@staticmethod
def bernsteinPolynomial(points):
def bern(t):
n = len(points) - 1
x = y = 0
for i, point in enumerate(points):
bern = BezierCurve.bernsteinPolynomialPoint(t, i, n)
x += point[0] * bern
y += point[1] * bern
return x, y
return bern
@staticmethod
def curvePoints(n, points):
curvePoints = []
bernstein_polynomial = BezierCurve.bernsteinPolynomial(points)
for i in range(n):
t = i / (n - 1)
curvePoints += bernstein_polynomial(t),
return curvePoints
class HumanCurve():
def __init__(self, fromPoint, toPoint, **kwargs):
self.fromPoint = fromPoint
self.toPoint = toPoint
self.points = self.generateCurve(**kwargs)
def generateCurve(self, **kwargs):
offsetBoundaryX = kwargs.get("offsetBoundaryX", 100)
offsetBoundaryY = kwargs.get("offsetBoundaryY", 100)
leftBoundary = kwargs.get("leftBoundary", min(self.fromPoint[0], self.toPoint[0])) - offsetBoundaryX
rightBoundary = kwargs.get("rightBoundary", max(self.fromPoint[0], self.toPoint[0])) + offsetBoundaryX
downBoundary = kwargs.get("downBoundary", min(self.fromPoint[1], self.toPoint[1])) - offsetBoundaryY
upBoundary = kwargs.get("upBoundary", max(self.fromPoint[1], self.toPoint[1])) + offsetBoundaryY
knotsCount = kwargs.get("knotsCount", 2)
distortionMean = kwargs.get("distortionMean", 1)
distortionStdev = kwargs.get("distortionStdev", 1)
distortionFrequency = kwargs.get("distortionFrequency", 0.4)
tween = kwargs.get("tweening", pytweening.easeOutQuad)
targetPoints = kwargs.get("targetPoints", 10)
internalKnots = self.generateInternalKnots(leftBoundary, rightBoundary,
downBoundary, upBoundary, knotsCount)
points = self.generatePoints(internalKnots)
points = self.distortPoints(points, distortionMean, distortionStdev, distortionFrequency)
points = self.tweenPoints(points, tween, targetPoints)
return points
def generateInternalKnots(self, leftBoundary, rightBoundary,
downBoundary, upBoundary, knotsCount):
if not (isNumeric(leftBoundary) and isNumeric(rightBoundary) and
isNumeric(downBoundary) and isNumeric(upBoundary)):
raise ValueError("Boundaries must be numeric")
if not isinstance(knotsCount, int) or knotsCount < 0:
raise ValueError("knotsCount must be non-negative integer")
if leftBoundary > rightBoundary:
raise ValueError("leftBoundary must be less than or equal to rightBoundary")
if downBoundary > upBoundary:
raise ValueError("downBoundary must be less than or equal to upBoundary")
knotsX = np.random.choice(range(leftBoundary, rightBoundary), size=knotsCount)
knotsY = np.random.choice(range(downBoundary, upBoundary), size=knotsCount)
return list(zip(knotsX, knotsY))
def generatePoints(self, knots):
if not isListOfPoints(knots):
raise ValueError("knots must be valid list of points")
midPtsCnt = max(
abs(self.fromPoint[0] - self.toPoint[0]),
abs(self.fromPoint[1] - self.toPoint[1]),
2)
knots = [self.fromPoint] + knots + [self.toPoint]
return BezierCurve.curvePoints(midPtsCnt, knots)
def distortPoints(self, points, distortionMean, distortionStdev, distortionFrequency):
if not (isNumeric(distortionMean) and isNumeric(distortionStdev) and
isNumeric(distortionFrequency)):
raise ValueError("Distortions must be numeric")
if not isListOfPoints(points):
raise ValueError("points must be valid list of points")
if not (0 <= distortionFrequency <= 1):
raise ValueError("distortionFrequency must be in range [0,1]")
distorted = []
for i in range(1, len(points) - 1):
x, y = points[i]
delta = np.random.normal(distortionMean, distortionStdev) if \
random.random() < distortionFrequency else 0
distorted += (x, y + delta),
distorted = [points[0]] + distorted + [points[-1]]
return distorted
def tweenPoints(self, points, tween, targetPoints):
if not isListOfPoints(points):
raise ValueError("points must be valid list of points")
if not isinstance(targetPoints, int) or targetPoints < 2:
raise ValueError("targetPoints must be an integer greater or equal to 2")
res = []
for i in range(targetPoints):
index = int(tween(float(i) / (targetPoints - 1)) * (len(points) - 1))
res += points[index],
return res
class mouse:
"""
Drop-in replacement for the `mouse` (pyclick) library.
Same public API as custom_mouse.py, but uses native SendInput instead of mousetool.dll.
"""
@staticmethod
def sleep(duration):
from utils.misc import wait as _wait
_wait(duration, duration * 1.2)
@staticmethod
def move(x, y, absolute: bool = True, randomize: int | tuple[int, int] = 5,
delay_factor: tuple[float, float] = [0.4, 0.6]):
from_point = _native_get_pos()
dist = math.dist((x, y), from_point)
offsetBoundaryX = max(10, int(0.08 * dist))
offsetBoundaryY = max(10, int(0.08 * dist))
targetPoints = min(6, max(3, int(0.004 * dist)))
if not absolute:
x = from_point[0] + x
y = from_point[1] + y
if type(randomize) is int:
randomize = int(randomize)
if randomize > 0:
x = int(x) + random.randrange(-randomize, +randomize)
y = int(y) + random.randrange(-randomize, +randomize)
else:
randomize = (int(randomize[0]), int(randomize[1]))
if randomize[1] > 0 and randomize[0] > 0:
x = int(x) + random.randrange(-randomize[0], +randomize[0])
y = int(y) + random.randrange(-randomize[1], +randomize[1])
# Apply human curve complexity from stealth config
try:
from config import Config
complexity = Config().stealth.get("human_curve_complexity", 1.0)
except Exception:
complexity = 1.0
distortionMean = 1 * complexity
distortionStdev = 1 * complexity
distortionFreq = min(0.8, 0.4 * complexity)
human_curve = HumanCurve(
from_point, (x, y),
offsetBoundaryX=offsetBoundaryX,
offsetBoundaryY=offsetBoundaryY,
targetPoints=targetPoints,
distortionMean=distortionMean,
distortionStdev=distortionStdev,
distortionFrequency=distortionFreq
)
duration = min(0.5, max(0.05, dist * 0.0004) * random.uniform(delay_factor[0], delay_factor[1]))
delta = duration / len(human_curve.points)
for point in human_curve.points:
_native_move(int(point[0]), int(point[1]))
mouse.sleep(delta)
@staticmethod
def stealth_move(x, y, absolute: bool = True, randomize: int | tuple[int, int] = 5,
delay_factor: tuple[float, float] = [0.4, 0.6]):
"""Move with full stealth chain: pre-movement pause -> randomized position -> endpoint wobble."""
try:
from utils.stealth import randomize_click_position, add_micro_pause, endpoint_wobble
rx, ry = randomize_click_position(x, y)
add_micro_pause()
except Exception:
from logger import Logger
Logger.warning("[Stealth] randomize_click_position/add_micro_pause failed, using manual fallback")
try:
from config import Config
variance = Config().stealth["click_variance"]
except Exception:
variance = 0
rx = x + random.randint(-variance, variance)
ry = y + random.randint(-variance, variance)
mouse.move(rx, ry, absolute=absolute, randomize=5 + variance, delay_factor=delay_factor)
try:
from utils.stealth import endpoint_wobble
wx, wy = endpoint_wobble(rx, ry)
_native_move(wx, wy)
except Exception:
from logger import Logger
Logger.warning("[Stealth] endpoint_wobble failed, skipping micro-adjustment")
pass
@staticmethod
def _is_clicking_safe():
try:
import screen
from config import Config
from utils.misc import is_in_roi
import template_finder
mouse_pos = screen.convert_monitor_to_screen(_native_get_pos())
is_inventory_open = template_finder.search(
"INVENTORY_GOLD_BTN",
screen.grab(),
threshold=0.8,
roi=Config().ui_roi["gold_btn"],
use_grayscale=True
).valid
if is_inventory_open:
is_in_equipped_area = is_in_roi(Config().ui_roi["equipped_inventory_area"], mouse_pos)
is_in_restricted_inventory_area = is_in_roi(Config().ui_roi["restricted_inventory_area"], mouse_pos)
if is_in_restricted_inventory_area or is_in_equipped_area:
from logger import Logger
Logger.error("Mouse wants to click in equipped area. Cancel action.")
return False
except Exception:
pass
return True
@staticmethod
def click(button):
if button != "left" or mouse._is_clicking_safe():
try:
from utils.stealth import apply_click_delay
apply_click_delay()
except Exception:
from logger import Logger
Logger.warning("[Stealth] apply_click_delay failed, falling back to manual delay")
from utils.misc import wait as _wait
_wait(0.05, 0.3)
_native_click(button)
@staticmethod
def press(button):
if button != "left" or mouse._is_clicking_safe():
_native_down(button)
@staticmethod
def release(button):
_native_up(button)
@staticmethod
def get_position():
return _native_get_pos()
@staticmethod
def wheel(delta):
_native_wheel(delta)
@staticmethod
def async_move(x, y, absolute=True, randomize=5, delay_factor=[0.4, 0.6]):
result = {"_done": False, "_lock": threading.Lock()}
def _run():
try:
mouse.move(x, y, absolute=absolute, randomize=randomize, delay_factor=delay_factor)
finally:
with result["_lock"]:
result["_done"] = True
threading.Thread(target=_run, daemon=True).start()
def done():
with result["_lock"]:
return result["_done"]
def wait(timeout=None):
deadline = None if timeout is None else time.monotonic() + timeout
while not done():
if deadline is not None and time.monotonic() >= deadline:
return False
from utils.misc import wait as _wait
_wait(0.01, 0.012)
return True
return {"done": done, "wait": wait}
if __name__ == "__main__":
print("Mouse module loaded OK (native SendInput, no mousetool.dll)")
pos = mouse.get_position()
print(f"Cursor at: {pos}")
+317
View File
@@ -0,0 +1,317 @@
"""
Native Windows input via ctypes SendInput API.
No kernel drivers, no third-party DLLs. Only standard system DLLs (user32, kernel32).
"""
import ctypes
import time
import struct
from ctypes import wintypes
# ─── user32 constants ───
# INPUT_TYPE
INPUT_MOUSE = 0
INPUT_KEYBOARD = 1
INPUT_HARDWARE = 2
# MOUSEEVENTF flags
MOUSEEVENTF_MOVE = 0x0001
MOUSEEVENTF_LEFTDOWN = 0x0002
MOUSEEVENTF_LEFTUP = 0x0004
MOUSEEVENTF_RIGHTDOWN = 0x0008
MOUSEEVENTF_RIGHTUP = 0x0010
MOUSEEVENTF_MIDDLEDOWN = 0x0020
MOUSEEVENTF_MIDDLEUP = 0x0040
MOUSEEVENTF_XDOWN = 0x0080
MOUSEEVENTF_XUP = 0x0100
MOUSEEVENTF_WHEEL = 0x0800
MOUSEEVENTF_ABSOLUTE = 0x8000
MOUSEEVENTF_HWHEEL = 0x01000
# KEYEVENTF flags
KEYEVENTF_EXTENDEDKEY = 0x0001
KEYEVENTF_KEYUP = 0x0002
KEYEVENTF_UNICODE = 0x0004
KEYEVENTF_SCANCODE = 0x0008
# ─── structs ───
class MOUSEINPUT(ctypes.Structure):
_fields_ = [
("dx", wintypes.LONG),
("dy", wintypes.LONG),
("mouseData", wintypes.DWORD),
("dwFlags", wintypes.DWORD),
("time", wintypes.DWORD),
("dwExtraInfo", ctypes.POINTER(wintypes.ULONG)),
]
class KEYBDINPUT(ctypes.Structure):
_fields_ = [
("wVk", wintypes.WORD),
("wScan", wintypes.WORD),
("dwFlags", wintypes.DWORD),
("time", wintypes.DWORD),
("dwExtraInfo", ctypes.POINTER(wintypes.ULONG)),
]
class HARDWAREINPUT(ctypes.Structure):
_fields_ = [
("uMsg", wintypes.DWORD),
("wParamL", wintypes.WORD),
("wParamH", wintypes.WORD),
]
class INPUTUNION(ctypes.Union):
_fields_ = [
("mi", MOUSEINPUT),
("ki", KEYBDINPUT),
("hi", HARDWAREINPUT),
]
class INPUT(ctypes.Structure):
_fields_ = [
("type", wintypes.DWORD),
("union", INPUTUNION),
]
INPUT_ARRAY = ctypes.ARRAY(INPUT, 64)
# ─── load user32 ───
user32 = ctypes.windll.user32
kernel32 = ctypes.windll.kernel32
user32.SendInput.restype = wintypes.UINT
user32.SendInput.argtypes = [wintypes.UINT, ctypes.POINTER(INPUT), ctypes.c_int]
user32.GetAsyncKeyState.restype = wintypes.SHORT
user32.GetAsyncKeyState.argtypes = [wintypes.WORD]
user32.GetCursorPos.restype = wintypes.BOOL
user32.GetCursorPos.argtypes = [ctypes.POINTER(wintypes.POINT)]
user32.GetSystemMetrics.restype = wintypes.INT
user32.GetSystemMetrics.argtypes = [wintypes.INT]
user32.MapVirtualKeyW.restype = wintypes.UINT
user32.MapVirtualKeyW.argtypes = [wintypes.UINT, wintypes.UINT]
# ─── VK code mapping ───
# Extended keys (require KEYEVENTF_EXTENDEDKEY flag)
EXTENDED_KEYS = {
0x27, # numpad /
0x91, # right ctrl
0x9A, # right shift
0xB5, # numpad *
0xB8, # right alt
0xB9, # right win
0xBA, # apps key (menu)
0xC1, # numpad enter
0xC7, # numpad .
0xC8, # snap
}
# Common key name -> VK code
VK_MAP = {
"left": 0x25, "right": 0x27, "up": 0x26, "down": 0x28,
"enter": 0x0D, "return": 0x0D, "space": 0x20, "escape": 0x1B, "esc": 0x1B,
"backspace": 0x08, "tab": 0x09, "delete": 0x2E, "end": 0x23, "home": 0x24,
"insert": 0x2D, "pageup": 0x21, "pagedown": 0x22,
"f1": 0x70, "f2": 0x71, "f3": 0x72, "f4": 0x73,
"f5": 0x74, "f6": 0x75, "f7": 0x76, "f8": 0x77,
"f9": 0x78, "f10": 0x79, "f11": 0x7A, "f12": 0x7B,
"shift": 0xA0, "left shift": 0xA0, "right shift": 0xA1,
"ctrl": 0xA2, "left ctrl": 0xA2, "right ctrl": 0xA3,
"lctrl": 0xA2, "rctrl": 0xA3, "ctrl_l": 0xA2, "ctrl_r": 0xA3,
"alt": 0xA4, "left alt": 0xA4, "right alt": 0xA5,
"lalt": 0xA4, "ralt": 0xA5, "alt_l": 0xA4, "alt_r": 0xA5,
"caps lock": 0x14, "capslock": 0x14,
"num lock": 0x90, "numlock": 0x90,
"scroll lock": 0x91, "scrolllock": 0x91,
"print screen": 0x2C, "printscr": 0x2C,
"pause": 0x13,
"lwin": 0x5B, "rwin": 0x5C, "win": 0x5B,
"apps": 0x5D,
"numpad0": 0x60, "numpad1": 0x61, "numpad2": 0x62,
"numpad3": 0x63, "numpad4": 0x64, "numpad5": 0x65,
"numpad6": 0x66, "numpad7": 0x67, "numpad8": 0x68,
"numpad9": 0x69,
"numpad.": 0x6E, "numpad/": 0x6F,
"numpad*": 0x6B, "numpad+": 0x6C,
"numpad-": 0x6D,
"volume_up": 0xAF, "volume_down": 0xAE, "volume_mute": 0xAD,
"media_next": 0xB0, "media_prev": 0xB1, "media_stop": 0xB2, "media_play_pause": 0xB3,
"back": 0xA6, "forward": 0xA7,
}
# ─── core input functions ───
def _send_input(inp: INPUT):
"""Send a single INPUT event."""
arr = INPUT_ARRAY()
arr[0] = inp
result = user32.SendInput(1, arr, ctypes.sizeof(INPUT))
if result != 1:
raise RuntimeError(f"SendInput failed, returned {result}")
def _make_keyboard_input(vk: int, flags: int = 0, scan: int = 0):
"""Create a KEYBDINPUT struct."""
i = INPUT()
i.type = INPUT_KEYBOARD
i.union.ki.wVk = vk
i.union.ki.wScan = scan
i.union.ki.dwFlags = flags
i.union.ki.dwExtraInfo = None
return i
def _make_mouse_input(flags: int, dx: int = 0, dy: int = 0, data: int = 0):
"""Create a MOUSEINPUT struct."""
i = INPUT()
i.type = INPUT_MOUSE
i.union.mi.dx = dx
i.union.mi.dy = dy
i.union.mi.mouseData = data
i.union.mi.dwFlags = flags | MOUSEEVENTF_ABSOLUTE
i.union.mi.dwExtraInfo = None
return i
def _get_vk(key_name: str):
"""Resolve a key name to its VK code."""
key_name = key_name.strip().lower()
if key_name in VK_MAP:
return VK_MAP[key_name]
# Single character
if len(key_name) == 1:
vk = ord(key_name)
# Digit keys 0-9 map to VK_0 (0x0B) through VK_9 (0x13)
if '0' <= key_name <= '9':
return 0x0B + ord(key_name) - ord('0')
# Letter keys map directly to their ASCII value (uppercase)
if 'a' <= key_name <= 'z':
return ord(key_name.upper())
return vk
# Try pywin32 style (vk_key)
return None
def _is_extended(vk: int) -> bool:
return vk in EXTENDED_KEYS
def _get_scan(vk: int) -> int:
"""Get scan code for a VK."""
return user32.MapVirtualKeyW(vk, 0)
def key_down(vk: int):
"""Press a key down."""
extended = KEYEVENTF_EXTENDEDKEY if _is_extended(vk) else 0
scan = _get_scan(vk) if vk else 0
_send_input(_make_keyboard_input(vk, extended, scan))
def key_up(vk: int):
"""Release a key."""
extended = KEYEVENTF_EXTENDEDKEY if _is_extended(vk) else 0
scan = _get_scan(vk) if vk else 0
_send_input(_make_keyboard_input(vk, extended | KEYEVENTF_KEYUP, scan))
def key_press(vk: int):
"""Press and release a key."""
key_down(vk)
key_up(vk)
def send_key(key: str, down=True, up=True):
"""Press and/or release a key by name."""
vk = _get_vk(key)
if vk is None:
raise ValueError(f"Unknown key: {key}")
if down:
key_down(vk)
if up:
key_up(vk)
def key_state(key: str) -> bool:
"""Check if a key is currently pressed (via GetAsyncKeyState)."""
vk = _get_vk(key)
if vk is None:
return False
state = user32.GetAsyncKeyState(vk)
return bool(state & 0x8000)
# ─── mouse ───
def get_screen_size():
"""Get screen dimensions for absolute mouse positioning."""
return user32.GetSystemMetrics(0), user32.GetSystemMetrics(1)
def mouse_move(x: int, y: int):
"""Move mouse to absolute screen position (uses absolute SendInput)."""
screen_w, screen_h = get_screen_size()
# SendInput expects normalized absolute coordinates 0-65535
norm_x = int(x * 65535 / screen_w)
norm_y = int(y * 65535 / screen_h)
_send_input(_make_mouse_input(MOUSEEVENTF_MOVE | MOUSEEVENTF_ABSOLUTE, norm_x, norm_y))
def mouse_down(button: str = "left"):
"""Press mouse button down."""
if button == "left":
_send_input(_make_mouse_input(MOUSEEVENTF_LEFTDOWN))
elif button == "right":
_send_input(_make_mouse_input(MOUSEEVENTF_RIGHTDOWN))
elif button == "middle":
_send_input(_make_mouse_input(MOUSEEVENTF_MIDDLEDOWN))
else:
raise ValueError(f"Unknown button: {button}")
def mouse_up(button: str = "left"):
"""Release mouse button."""
if button == "left":
_send_input(_make_mouse_input(MOUSEEVENTF_LEFTUP))
elif button == "right":
_send_input(_make_mouse_input(MOUSEEVENTF_RIGHTUP))
elif button == "middle":
_send_input(_make_mouse_input(MOUSEEVENTF_MIDDLEUP))
else:
raise ValueError(f"Unknown button: {button}")
def mouse_click(button: str = "left"):
"""Click (press + release) a mouse button."""
mouse_down(button)
mouse_up(button)
def mouse_wheel(delta: int):
"""Scroll mouse wheel (positive = up, negative = down).
Delta is in "clicks" — each click is 120 units."""
wheel_delta = delta * 120
_send_input(_make_mouse_input(MOUSEEVENTF_WHEEL, 0, 0, wheel_delta))
def get_cursor_pos():
"""Get cursor position as (x, y)."""
from ctypes import wintypes as wt
p = wt.POINT()
if user32.GetCursorPos(ctypes.byref(p)):
return (p.x, p.y)
return (0, 0)
# ─── text input ───
def send_text(text: str, delay: float = 0.05):
"""Send text character by character."""
for ch in text:
vk = _get_vk(ch)
if vk is None:
continue
# Determine if shift is needed
if ch.isupper() and ch.lower() != ch:
# Need shift for uppercase letters
key_down(0xA0) # left shift
key_press(vk)
key_up(0xA0)
elif not ch.isalnum() and ch != ' ':
# Shift needed for most punctuation
key_down(0xA0)
key_press(vk)
key_up(0xA0)
else:
key_press(vk)
from utils.misc import wait as _wait
_wait(delay, delay * 1.2)