kotonebot 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- kotonebot/__init__.py +40 -0
- kotonebot/backend/__init__.py +0 -0
- kotonebot/backend/bot.py +302 -0
- kotonebot/backend/color.py +525 -0
- kotonebot/backend/context/__init__.py +3 -0
- kotonebot/backend/context/context.py +1001 -0
- kotonebot/backend/context/task_action.py +176 -0
- kotonebot/backend/core.py +126 -0
- kotonebot/backend/debug/__init__.py +1 -0
- kotonebot/backend/debug/entry.py +89 -0
- kotonebot/backend/debug/mock.py +79 -0
- kotonebot/backend/debug/server.py +223 -0
- kotonebot/backend/debug/vars.py +346 -0
- kotonebot/backend/dispatch.py +228 -0
- kotonebot/backend/flow_controller.py +197 -0
- kotonebot/backend/image.py +748 -0
- kotonebot/backend/loop.py +277 -0
- kotonebot/backend/ocr.py +511 -0
- kotonebot/backend/preprocessor.py +103 -0
- kotonebot/client/__init__.py +10 -0
- kotonebot/client/device.py +500 -0
- kotonebot/client/fast_screenshot.py +378 -0
- kotonebot/client/host/__init__.py +12 -0
- kotonebot/client/host/adb_common.py +94 -0
- kotonebot/client/host/custom.py +114 -0
- kotonebot/client/host/leidian_host.py +202 -0
- kotonebot/client/host/mumu12_host.py +245 -0
- kotonebot/client/host/protocol.py +213 -0
- kotonebot/client/host/windows_common.py +55 -0
- kotonebot/client/implements/__init__.py +7 -0
- kotonebot/client/implements/adb.py +85 -0
- kotonebot/client/implements/adb_raw.py +159 -0
- kotonebot/client/implements/nemu_ipc/__init__.py +8 -0
- kotonebot/client/implements/nemu_ipc/external_renderer_ipc.py +280 -0
- kotonebot/client/implements/nemu_ipc/nemu_ipc.py +327 -0
- kotonebot/client/implements/remote_windows.py +193 -0
- kotonebot/client/implements/uiautomator2.py +82 -0
- kotonebot/client/implements/windows.py +168 -0
- kotonebot/client/protocol.py +69 -0
- kotonebot/client/registration.py +24 -0
- kotonebot/config/__init__.py +1 -0
- kotonebot/config/base_config.py +96 -0
- kotonebot/config/manager.py +36 -0
- kotonebot/errors.py +72 -0
- kotonebot/interop/win/__init__.py +0 -0
- kotonebot/interop/win/message_box.py +314 -0
- kotonebot/interop/win/reg.py +37 -0
- kotonebot/interop/win/shortcut.py +43 -0
- kotonebot/interop/win/task_dialog.py +469 -0
- kotonebot/logging/__init__.py +2 -0
- kotonebot/logging/log.py +18 -0
- kotonebot/primitives/__init__.py +17 -0
- kotonebot/primitives/geometry.py +290 -0
- kotonebot/primitives/visual.py +63 -0
- kotonebot/tools/__init__.py +0 -0
- kotonebot/tools/mirror.py +354 -0
- kotonebot/ui/__init__.py +0 -0
- kotonebot/ui/file_host/sensio.py +36 -0
- kotonebot/ui/file_host/tmp_send.py +54 -0
- kotonebot/ui/pushkit/__init__.py +3 -0
- kotonebot/ui/pushkit/image_host.py +87 -0
- kotonebot/ui/pushkit/protocol.py +13 -0
- kotonebot/ui/pushkit/wxpusher.py +53 -0
- kotonebot/ui/user.py +144 -0
- kotonebot/util.py +409 -0
- kotonebot-0.1.0.dist-info/METADATA +204 -0
- kotonebot-0.1.0.dist-info/RECORD +70 -0
- kotonebot-0.1.0.dist-info/WHEEL +5 -0
- kotonebot-0.1.0.dist-info/licenses/LICENSE +674 -0
- kotonebot-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
from ctypes import windll
|
|
2
|
+
from typing import Literal
|
|
3
|
+
from importlib import resources
|
|
4
|
+
from functools import cached_property
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
import cv2
|
|
8
|
+
import win32ui
|
|
9
|
+
import win32gui
|
|
10
|
+
import numpy as np
|
|
11
|
+
from ahk import AHK, MsgBoxIcon
|
|
12
|
+
from cv2.typing import MatLike
|
|
13
|
+
|
|
14
|
+
from ..device import Device, WindowsDevice
|
|
15
|
+
from ..protocol import Commandable, Touchable, Screenshotable
|
|
16
|
+
from ..registration import ImplConfig
|
|
17
|
+
|
|
18
|
+
# 1. 定义配置模型
|
|
19
|
+
@dataclass
|
|
20
|
+
class WindowsImplConfig(ImplConfig):
|
|
21
|
+
window_title: str
|
|
22
|
+
ahk_exe_path: str
|
|
23
|
+
|
|
24
|
+
class WindowsImpl(Touchable, Screenshotable):
|
|
25
|
+
def __init__(self, device: Device, window_title: str, ahk_exe_path: str):
|
|
26
|
+
self.__hwnd: int | None = None
|
|
27
|
+
self.window_title = window_title
|
|
28
|
+
self.ahk = AHK(executable_path=ahk_exe_path)
|
|
29
|
+
self.device = device
|
|
30
|
+
|
|
31
|
+
# 设置 DPI aware,否则高缩放显示器上返回的坐标会错误
|
|
32
|
+
windll.user32.SetProcessDPIAware()
|
|
33
|
+
# TODO: 这个应该移动到其他地方去
|
|
34
|
+
def _stop():
|
|
35
|
+
from kotonebot.backend.context.context import vars
|
|
36
|
+
vars.flow.request_interrupt()
|
|
37
|
+
self.ahk.msg_box('任务已停止。', title='琴音小助手', icon=MsgBoxIcon.EXCLAMATION)
|
|
38
|
+
|
|
39
|
+
def _toggle_pause():
|
|
40
|
+
from kotonebot.backend.context.context import vars
|
|
41
|
+
if vars.flow.is_paused:
|
|
42
|
+
self.ahk.msg_box('任务即将恢复。\n关闭此消息框后将会继续执行', title='琴音小助手', icon=MsgBoxIcon.EXCLAMATION)
|
|
43
|
+
vars.flow.request_resume()
|
|
44
|
+
else:
|
|
45
|
+
vars.flow.request_pause()
|
|
46
|
+
self.ahk.msg_box('任务已暂停。\n关闭此消息框后再按一次快捷键恢复执行。', title='琴音小助手', icon=MsgBoxIcon.EXCLAMATION)
|
|
47
|
+
|
|
48
|
+
self.ahk.add_hotkey('^F4', _toggle_pause) # Ctrl+F4 暂停/恢复
|
|
49
|
+
self.ahk.add_hotkey('^F3', _stop) # Ctrl+F3 停止
|
|
50
|
+
self.ahk.start_hotkeys()
|
|
51
|
+
# 将点击坐标设置为相对 Client
|
|
52
|
+
self.ahk.set_coord_mode('Mouse', 'Client')
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def hwnd(self) -> int:
|
|
56
|
+
if self.__hwnd is None:
|
|
57
|
+
self.__hwnd = win32gui.FindWindow(None, self.window_title)
|
|
58
|
+
if self.__hwnd is None or self.__hwnd == 0:
|
|
59
|
+
raise RuntimeError(f'Failed to find window: {self.window_title}')
|
|
60
|
+
return self.__hwnd
|
|
61
|
+
|
|
62
|
+
def __client_rect(self) -> tuple[int, int, int, int]:
|
|
63
|
+
"""获取 Client 区域屏幕坐标"""
|
|
64
|
+
hwnd = self.hwnd
|
|
65
|
+
client_left, client_top, client_right, client_bottom = win32gui.GetClientRect(hwnd)
|
|
66
|
+
client_left, client_top = win32gui.ClientToScreen(hwnd, (client_left, client_top))
|
|
67
|
+
client_right, client_bottom = win32gui.ClientToScreen(hwnd, (client_right, client_bottom))
|
|
68
|
+
return client_left, client_top, client_right, client_bottom
|
|
69
|
+
|
|
70
|
+
def __client_to_screen(self, hwnd: int, x: int, y: int) -> tuple[int, int]:
|
|
71
|
+
"""将 Client 区域坐标转换为屏幕坐标"""
|
|
72
|
+
return win32gui.ClientToScreen(hwnd, (x, y))
|
|
73
|
+
|
|
74
|
+
def screenshot(self) -> MatLike:
|
|
75
|
+
if not self.ahk.win_is_active(self.window_title):
|
|
76
|
+
self.ahk.win_activate(self.window_title)
|
|
77
|
+
hwnd = self.hwnd
|
|
78
|
+
|
|
79
|
+
# TODO: 需要检查下面这些 WinAPI 的返回结果
|
|
80
|
+
# 获取整个窗口的坐标
|
|
81
|
+
left, top, right, bot = win32gui.GetWindowRect(hwnd)
|
|
82
|
+
w = right - left
|
|
83
|
+
h = bot - top
|
|
84
|
+
|
|
85
|
+
# 获取客户区域的坐标
|
|
86
|
+
client_left, client_top, client_right, client_bot = self.__client_rect()
|
|
87
|
+
|
|
88
|
+
# 获取整个屏幕的截图
|
|
89
|
+
hwndDC = win32gui.GetWindowDC(0)
|
|
90
|
+
mfcDC = win32ui.CreateDCFromHandle(hwndDC)
|
|
91
|
+
saveDC = mfcDC.CreateCompatibleDC()
|
|
92
|
+
|
|
93
|
+
saveBitMap = win32ui.CreateBitmap()
|
|
94
|
+
saveBitMap.CreateCompatibleBitmap(mfcDC, w, h)
|
|
95
|
+
|
|
96
|
+
saveDC.SelectObject(saveBitMap)
|
|
97
|
+
|
|
98
|
+
# 截图整个屏幕
|
|
99
|
+
result = windll.gdi32.BitBlt(saveDC.GetSafeHdc(), 0, 0, w, h, mfcDC.GetSafeHdc(), left, top, 0x00CC0020)
|
|
100
|
+
|
|
101
|
+
# 将截图转换为OpenCV格式
|
|
102
|
+
bmpinfo = saveBitMap.GetInfo()
|
|
103
|
+
bmpstr = saveBitMap.GetBitmapBits(True)
|
|
104
|
+
im = np.frombuffer(bmpstr, dtype=np.uint8)
|
|
105
|
+
im = im.reshape((bmpinfo['bmHeight'], bmpinfo['bmWidth'], 4))
|
|
106
|
+
|
|
107
|
+
# 裁剪出客户区域
|
|
108
|
+
cropped_im = im[client_top - top:client_bot - top, client_left - left:client_right - left]
|
|
109
|
+
|
|
110
|
+
# 释放资源
|
|
111
|
+
win32gui.DeleteObject(saveBitMap.GetHandle())
|
|
112
|
+
saveDC.DeleteDC()
|
|
113
|
+
mfcDC.DeleteDC()
|
|
114
|
+
win32gui.ReleaseDC(hwnd, hwndDC)
|
|
115
|
+
|
|
116
|
+
# 将 RGBA 转换为 RGB
|
|
117
|
+
cropped_im = cv2.cvtColor(cropped_im, cv2.COLOR_RGBA2RGB)
|
|
118
|
+
return cropped_im
|
|
119
|
+
|
|
120
|
+
@property
|
|
121
|
+
def screen_size(self) -> tuple[int, int]:
|
|
122
|
+
left, top, right, bot = self.__client_rect()
|
|
123
|
+
w = right - left
|
|
124
|
+
h = bot - top
|
|
125
|
+
return w, h
|
|
126
|
+
|
|
127
|
+
def detect_orientation(self) -> None | Literal['portrait'] | Literal['landscape']:
|
|
128
|
+
pos = self.ahk.win_get_position(self.window_title)
|
|
129
|
+
if pos is None:
|
|
130
|
+
return None
|
|
131
|
+
w, h = pos.width, pos.height
|
|
132
|
+
if w > h:
|
|
133
|
+
return 'landscape'
|
|
134
|
+
else:
|
|
135
|
+
return 'portrait'
|
|
136
|
+
|
|
137
|
+
def click(self, x: int, y: int) -> None:
|
|
138
|
+
# x, y = self.__client_to_screen(self.hwnd, x, y)
|
|
139
|
+
# (0, 0) 很可能会点到窗口边框上
|
|
140
|
+
if x == 0:
|
|
141
|
+
x = 2
|
|
142
|
+
if y == 0:
|
|
143
|
+
y = 2
|
|
144
|
+
if not self.ahk.win_is_active(self.window_title):
|
|
145
|
+
self.ahk.win_activate(self.window_title)
|
|
146
|
+
self.ahk.click(x, y)
|
|
147
|
+
|
|
148
|
+
def swipe(self, x1: int, y1: int, x2: int, y2: int, duration: float | None = None) -> None:
|
|
149
|
+
if not self.ahk.win_is_active(self.window_title):
|
|
150
|
+
self.ahk.win_activate(self.window_title)
|
|
151
|
+
# TODO: 这个 speed 的单位是什么?
|
|
152
|
+
self.ahk.mouse_drag(x2, y2, from_position=(x1, y1), coord_mode='Client', speed=10)
|
|
153
|
+
|
|
154
|
+
if __name__ == '__main__':
|
|
155
|
+
from ..device import Device
|
|
156
|
+
device = Device()
|
|
157
|
+
# 在测试环境中直接使用默认路径
|
|
158
|
+
ahk_path = str(resources.files('kaa.res.bin') / 'AutoHotkey.exe')
|
|
159
|
+
impl = WindowsImpl(device, window_title='gakumas', ahk_exe_path=ahk_path)
|
|
160
|
+
device._screenshot = impl
|
|
161
|
+
device._touch = impl
|
|
162
|
+
device.swipe_scaled(0.5, 0.8, 0.5, 0.2)
|
|
163
|
+
# impl.swipe(0, 100, 0, 0)
|
|
164
|
+
# impl.click(100, 100)
|
|
165
|
+
# while True:
|
|
166
|
+
# im = impl.screenshot()
|
|
167
|
+
# cv2.imshow('test', im)
|
|
168
|
+
# cv2.waitKey(1)
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
from typing import Protocol, TYPE_CHECKING, runtime_checkable, Literal
|
|
2
|
+
|
|
3
|
+
from cv2.typing import MatLike
|
|
4
|
+
|
|
5
|
+
from kotonebot.primitives import Rect
|
|
6
|
+
if TYPE_CHECKING:
|
|
7
|
+
from .device import Device
|
|
8
|
+
|
|
9
|
+
@runtime_checkable
|
|
10
|
+
class ClickableObjectProtocol(Protocol):
|
|
11
|
+
"""
|
|
12
|
+
可点击对象的协议
|
|
13
|
+
"""
|
|
14
|
+
@property
|
|
15
|
+
def rect(self) -> Rect:
|
|
16
|
+
...
|
|
17
|
+
|
|
18
|
+
class DeviceScreenshotProtocol(Protocol):
|
|
19
|
+
def screenshot(self) -> MatLike:
|
|
20
|
+
"""
|
|
21
|
+
截图
|
|
22
|
+
"""
|
|
23
|
+
...
|
|
24
|
+
|
|
25
|
+
@runtime_checkable
|
|
26
|
+
class Commandable(Protocol):
|
|
27
|
+
def __init__(self, device: 'Device'): ...
|
|
28
|
+
def launch_app(self, package_name: str) -> None: ...
|
|
29
|
+
def current_package(self) -> str | None: ...
|
|
30
|
+
|
|
31
|
+
@runtime_checkable
|
|
32
|
+
class AndroidCommandable(Protocol):
|
|
33
|
+
"""定义 Android 平台的特定命令"""
|
|
34
|
+
def launch_app(self, package_name: str) -> None: ...
|
|
35
|
+
def current_package(self) -> str | None: ...
|
|
36
|
+
def adb_shell(self, cmd: str) -> str: ...
|
|
37
|
+
|
|
38
|
+
@runtime_checkable
|
|
39
|
+
class WindowsCommandable(Protocol):
|
|
40
|
+
"""定义 Windows 平台的特定命令"""
|
|
41
|
+
def get_foreground_window(self) -> tuple[int, str]: ...
|
|
42
|
+
def exec_command(self, command: str) -> tuple[int, str, str]: ...
|
|
43
|
+
|
|
44
|
+
@runtime_checkable
|
|
45
|
+
class Screenshotable(Protocol):
|
|
46
|
+
def __init__(self, device: 'Device'): ...
|
|
47
|
+
@property
|
|
48
|
+
def screen_size(self) -> tuple[int, int]:
|
|
49
|
+
"""
|
|
50
|
+
屏幕尺寸。格式为 `(width, height)`。
|
|
51
|
+
|
|
52
|
+
**注意**: 此属性返回的分辨率会随设备方向变化。
|
|
53
|
+
如果 `self.orientation` 为 `landscape`,则返回的分辨率是横屏下的分辨率,
|
|
54
|
+
否则返回竖屏下的分辨率。
|
|
55
|
+
|
|
56
|
+
`self.orientation` 属性默认为竖屏。如果需要自动检测,
|
|
57
|
+
调用 `self.detect_orientation()` 方法。
|
|
58
|
+
如果已知方向,也可以直接设置 `self.orientation` 属性。
|
|
59
|
+
"""
|
|
60
|
+
...
|
|
61
|
+
|
|
62
|
+
def detect_orientation(self) -> Literal['portrait', 'landscape'] | None: ...
|
|
63
|
+
def screenshot(self) -> MatLike: ...
|
|
64
|
+
|
|
65
|
+
@runtime_checkable
|
|
66
|
+
class Touchable(Protocol):
|
|
67
|
+
def __init__(self, device: 'Device'): ...
|
|
68
|
+
def click(self, x: int, y: int) -> None: ...
|
|
69
|
+
def swipe(self, x1: int, y1: int, x2: int, y2: int, duration: float|None = None) -> None: ...
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from typing import TypeVar, Callable, Dict, Type, Any, overload, Literal, cast, TYPE_CHECKING
|
|
3
|
+
|
|
4
|
+
from ..errors import KotonebotError
|
|
5
|
+
from .device import Device
|
|
6
|
+
if TYPE_CHECKING:
|
|
7
|
+
from .implements.adb import AdbImplConfig
|
|
8
|
+
from .implements.remote_windows import RemoteWindowsImplConfig
|
|
9
|
+
from .implements.windows import WindowsImplConfig
|
|
10
|
+
from .implements.nemu_ipc import NemuIpcImplConfig
|
|
11
|
+
|
|
12
|
+
AdbBasedImpl = Literal['adb', 'adb_raw', 'uiautomator2']
|
|
13
|
+
DeviceImpl = str | AdbBasedImpl | Literal['windows', 'remote_windows', 'nemu_ipc']
|
|
14
|
+
|
|
15
|
+
# --- 核心类型定义 ---
|
|
16
|
+
|
|
17
|
+
class ImplRegistrationError(KotonebotError):
|
|
18
|
+
"""与 impl 注册相关的错误"""
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class ImplConfig:
|
|
23
|
+
"""所有设备实现配置模型的名义上的基类,便于类型约束。"""
|
|
24
|
+
pass
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from .base_config import UserConfig
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import uuid
|
|
2
|
+
from typing import Generic, TypeVar, Literal
|
|
3
|
+
|
|
4
|
+
from pydantic import BaseModel, ConfigDict
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
T = TypeVar('T')
|
|
8
|
+
BackendType = Literal['custom', 'mumu12', 'leidian', 'dmm']
|
|
9
|
+
DeviceRecipes = Literal['adb', 'adb_raw', 'uiautomator2', 'windows', 'remote_windows', 'nemu_ipc']
|
|
10
|
+
|
|
11
|
+
class ConfigBaseModel(BaseModel):
|
|
12
|
+
model_config = ConfigDict(use_attribute_docstrings=True)
|
|
13
|
+
|
|
14
|
+
class BackendConfig(ConfigBaseModel):
|
|
15
|
+
type: BackendType = 'custom'
|
|
16
|
+
"""后端类型。"""
|
|
17
|
+
instance_id: str | None = None
|
|
18
|
+
"""模拟器实例 ID。"""
|
|
19
|
+
adb_ip: str = '127.0.0.1'
|
|
20
|
+
"""adb 连接的 ip 地址。"""
|
|
21
|
+
adb_port: int = 5555
|
|
22
|
+
"""adb 连接的端口。"""
|
|
23
|
+
adb_emulator_name: str | None = None
|
|
24
|
+
"""
|
|
25
|
+
adb 连接的模拟器名,用于 自动启动模拟器 功能。
|
|
26
|
+
|
|
27
|
+
雷电模拟器需要设置正确的模拟器名,否则 自动启动模拟器 功能将无法正常工作。
|
|
28
|
+
其他功能不受影响。
|
|
29
|
+
"""
|
|
30
|
+
screenshot_impl: DeviceRecipes = 'adb'
|
|
31
|
+
"""
|
|
32
|
+
截图方法。暂时推荐使用【adb】截图方式。
|
|
33
|
+
|
|
34
|
+
如果使用 remote_windows,需要在 adb_ip 中填写远程 Windows 的 IP 地址,在 adb_port 中填写远程 Windows 的端口号。
|
|
35
|
+
"""
|
|
36
|
+
check_emulator: bool = False
|
|
37
|
+
"""
|
|
38
|
+
检查并启动模拟器
|
|
39
|
+
|
|
40
|
+
启动脚本的时候,如果检测到模拟器未启动,则自动启动模拟器。
|
|
41
|
+
如果模拟器已经启动,则不启动。
|
|
42
|
+
"""
|
|
43
|
+
emulator_path: str | None = None
|
|
44
|
+
"""模拟器 exe 文件路径"""
|
|
45
|
+
emulator_args: str = ""
|
|
46
|
+
"""模拟器启动时的命令行参数"""
|
|
47
|
+
windows_window_title: str = 'gakumas'
|
|
48
|
+
"""Windows 截图方式的窗口标题"""
|
|
49
|
+
windows_ahk_path: str | None = None
|
|
50
|
+
"""Windows 截图方式的 AutoHotkey 可执行文件路径,为 None 时使用默认路径"""
|
|
51
|
+
mumu_background_mode: bool = False
|
|
52
|
+
"""MuMu12 模拟器后台保活模式"""
|
|
53
|
+
target_screenshot_interval: float | None = None
|
|
54
|
+
"""最小截图间隔,单位为秒。为 None 时不限制截图速度。"""
|
|
55
|
+
|
|
56
|
+
class PushConfig(ConfigBaseModel):
|
|
57
|
+
"""推送配置。"""
|
|
58
|
+
|
|
59
|
+
wx_pusher_enabled: bool = False
|
|
60
|
+
"""是否启用 WxPusher 推送。"""
|
|
61
|
+
wx_pusher_app_token: str | None = None
|
|
62
|
+
"""WxPusher 的 app token。"""
|
|
63
|
+
wx_pusher_uid: str | None = None
|
|
64
|
+
"""WxPusher 的 uid。"""
|
|
65
|
+
|
|
66
|
+
free_image_host_key: str | None = None
|
|
67
|
+
"""FreeImageHost API key。用于在推送通知时显示图片。"""
|
|
68
|
+
|
|
69
|
+
class UserConfig(ConfigBaseModel, Generic[T]):
|
|
70
|
+
"""用户可以自由添加、删除的配置数据。"""
|
|
71
|
+
|
|
72
|
+
name: str = 'default_config'
|
|
73
|
+
"""显示名称。通常由用户输入。"""
|
|
74
|
+
id: str = uuid.uuid4().hex
|
|
75
|
+
"""唯一标识符。"""
|
|
76
|
+
category: str = 'default'
|
|
77
|
+
"""类别。如:'global'、'china'、'asia' 等。"""
|
|
78
|
+
description: str = ''
|
|
79
|
+
"""描述。通常由用户输入。"""
|
|
80
|
+
backend: BackendConfig = BackendConfig()
|
|
81
|
+
"""后端配置。"""
|
|
82
|
+
keep_screenshots: bool = False
|
|
83
|
+
"""
|
|
84
|
+
是否保留截图。
|
|
85
|
+
若启用,则会保存每一张截图到 `dumps` 目录下。启用该选项有助于辅助调试。
|
|
86
|
+
"""
|
|
87
|
+
options: T
|
|
88
|
+
"""下游脚本储存的具体数据。"""
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class RootConfig(ConfigBaseModel, Generic[T]):
|
|
92
|
+
version: int = 5
|
|
93
|
+
"""配置版本。"""
|
|
94
|
+
user_configs: list[UserConfig[T]] = []
|
|
95
|
+
"""用户配置。"""
|
|
96
|
+
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from typing import Type, Generic, TypeVar
|
|
3
|
+
|
|
4
|
+
from .base_config import RootConfig, UserConfig
|
|
5
|
+
|
|
6
|
+
T = TypeVar('T')
|
|
7
|
+
|
|
8
|
+
def load_config(
|
|
9
|
+
config_path: str,
|
|
10
|
+
*,
|
|
11
|
+
type: Type[T],
|
|
12
|
+
use_default_if_not_found: bool = True
|
|
13
|
+
) -> RootConfig[T]:
|
|
14
|
+
"""
|
|
15
|
+
从指定路径读取配置文件
|
|
16
|
+
|
|
17
|
+
:param config_path: 配置文件路径
|
|
18
|
+
:param use_default_if_not_found: 如果配置文件不存在,是否使用默认配置
|
|
19
|
+
"""
|
|
20
|
+
if not os.path.exists(config_path):
|
|
21
|
+
if use_default_if_not_found:
|
|
22
|
+
return RootConfig[type]()
|
|
23
|
+
else:
|
|
24
|
+
raise FileNotFoundError(f"Config file not found: {config_path}")
|
|
25
|
+
|
|
26
|
+
with open(config_path, 'r', encoding='utf-8') as f:
|
|
27
|
+
return RootConfig[type].model_validate_json(f.read())
|
|
28
|
+
|
|
29
|
+
def save_config(
|
|
30
|
+
config: RootConfig[T],
|
|
31
|
+
config_path: str,
|
|
32
|
+
):
|
|
33
|
+
"""将配置保存到指定路径"""
|
|
34
|
+
RootConfig[T].model_validate(config)
|
|
35
|
+
with open(config_path, 'w+', encoding='utf-8') as f:
|
|
36
|
+
f.write(config.model_dump_json(indent=4))
|
kotonebot/errors.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
from typing import Callable
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class KotonebotError(Exception):
|
|
5
|
+
pass
|
|
6
|
+
|
|
7
|
+
class KotonebotWarning(Warning):
|
|
8
|
+
pass
|
|
9
|
+
|
|
10
|
+
class UserFriendlyError(KotonebotError):
|
|
11
|
+
def __init__(
|
|
12
|
+
self,
|
|
13
|
+
message: str,
|
|
14
|
+
actions: list[tuple[int, str, Callable[[], None]]] = [],
|
|
15
|
+
*args, **kwargs
|
|
16
|
+
) -> None:
|
|
17
|
+
super().__init__(*args, **kwargs)
|
|
18
|
+
self.message = message
|
|
19
|
+
self.actions = actions or []
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def action_buttons(self) -> list[tuple[int, str]]:
|
|
23
|
+
"""
|
|
24
|
+
以 (id: int, btn_text: str) 的形式返回所有按钮定义。
|
|
25
|
+
"""
|
|
26
|
+
return [(id, text) for id, text, _ in self.actions]
|
|
27
|
+
|
|
28
|
+
def invoke(self, action_id: int):
|
|
29
|
+
"""
|
|
30
|
+
执行指定 ID 的 action。
|
|
31
|
+
"""
|
|
32
|
+
for id, _, func in self.actions:
|
|
33
|
+
if id == action_id:
|
|
34
|
+
func()
|
|
35
|
+
break
|
|
36
|
+
else:
|
|
37
|
+
raise ValueError(f'Action with id {action_id} not found.')
|
|
38
|
+
|
|
39
|
+
class UnrecoverableError(KotonebotError):
|
|
40
|
+
pass
|
|
41
|
+
|
|
42
|
+
class GameUpdateNeededError(UnrecoverableError):
|
|
43
|
+
def __init__(self):
|
|
44
|
+
super().__init__(
|
|
45
|
+
'Game update required. '
|
|
46
|
+
'Please go to Play Store and update the game manually.'
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
class ResourceFileMissingError(KotonebotError):
|
|
50
|
+
def __init__(self, file_path: str, description: str):
|
|
51
|
+
self.file_path = file_path
|
|
52
|
+
self.description = description
|
|
53
|
+
super().__init__(f'Resource file ({description}) "{file_path}" is missing.')
|
|
54
|
+
|
|
55
|
+
class TaskNotFoundError(KotonebotError):
|
|
56
|
+
def __init__(self, task_id: str):
|
|
57
|
+
self.task_id = task_id
|
|
58
|
+
super().__init__(f'Task "{task_id}" not found.')
|
|
59
|
+
|
|
60
|
+
class UnscalableResolutionError(KotonebotError):
|
|
61
|
+
def __init__(self, target_resolution: tuple[int, int], screen_size: tuple[int, int]):
|
|
62
|
+
self.target_resolution = target_resolution
|
|
63
|
+
self.screen_size = screen_size
|
|
64
|
+
super().__init__(f'Cannot scale to target resolution {target_resolution}. '
|
|
65
|
+
f'Screen size: {screen_size}')
|
|
66
|
+
|
|
67
|
+
class ContextNotInitializedError(KotonebotError):
|
|
68
|
+
def __init__(self, msg: str = 'Context not initialized'):
|
|
69
|
+
super().__init__(msg)
|
|
70
|
+
|
|
71
|
+
class StopCurrentTask(KotonebotError):
|
|
72
|
+
pass
|
|
File without changes
|