kotonebot 0.5.0__py3-none-any.whl → 0.6.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 +39 -39
- kotonebot/backend/bot.py +312 -312
- kotonebot/backend/color.py +525 -525
- kotonebot/backend/context/__init__.py +3 -3
- kotonebot/backend/context/context.py +1002 -1002
- kotonebot/backend/context/task_action.py +183 -183
- kotonebot/backend/core.py +86 -129
- kotonebot/backend/debug/entry.py +89 -89
- kotonebot/backend/debug/mock.py +78 -78
- kotonebot/backend/debug/server.py +222 -222
- kotonebot/backend/debug/vars.py +351 -351
- kotonebot/backend/dispatch.py +227 -227
- kotonebot/backend/flow_controller.py +196 -196
- kotonebot/backend/image.py +36 -5
- kotonebot/backend/loop.py +222 -208
- kotonebot/backend/ocr.py +535 -535
- kotonebot/backend/preprocessor.py +103 -103
- kotonebot/client/__init__.py +9 -9
- kotonebot/client/device.py +369 -529
- kotonebot/client/fast_screenshot.py +377 -377
- kotonebot/client/host/__init__.py +43 -43
- kotonebot/client/host/adb_common.py +101 -107
- kotonebot/client/host/custom.py +118 -118
- kotonebot/client/host/leidian_host.py +196 -196
- kotonebot/client/host/mumu12_host.py +353 -353
- kotonebot/client/host/protocol.py +214 -214
- kotonebot/client/host/windows_common.py +58 -58
- kotonebot/client/implements/__init__.py +65 -70
- kotonebot/client/implements/adb.py +89 -89
- kotonebot/client/implements/nemu_ipc/__init__.py +11 -11
- kotonebot/client/implements/nemu_ipc/external_renderer_ipc.py +284 -284
- kotonebot/client/implements/nemu_ipc/nemu_ipc.py +327 -327
- kotonebot/client/implements/remote_windows.py +188 -188
- kotonebot/client/implements/uiautomator2.py +85 -85
- kotonebot/client/implements/windows.py +176 -176
- kotonebot/client/protocol.py +69 -69
- kotonebot/client/registration.py +24 -24
- kotonebot/client/scaler.py +467 -0
- kotonebot/config/base_config.py +96 -96
- kotonebot/config/config.py +61 -0
- kotonebot/config/manager.py +36 -36
- kotonebot/core/__init__.py +13 -0
- kotonebot/core/entities/base.py +182 -0
- kotonebot/core/entities/compound.py +75 -0
- kotonebot/core/entities/ocr.py +117 -0
- kotonebot/core/entities/template_match.py +198 -0
- kotonebot/devtools/__init__.py +42 -0
- kotonebot/devtools/cli/__init__.py +6 -0
- kotonebot/devtools/cli/main.py +53 -0
- kotonebot/{tools → devtools}/mirror.py +354 -354
- kotonebot/devtools/project/project.py +41 -0
- kotonebot/devtools/project/scanner.py +202 -0
- kotonebot/devtools/project/schema.py +99 -0
- kotonebot/devtools/resgen/__init__.py +42 -0
- kotonebot/devtools/resgen/codegen.py +331 -0
- kotonebot/devtools/resgen/core.py +94 -0
- kotonebot/devtools/resgen/parsers.py +360 -0
- kotonebot/devtools/resgen/utils.py +158 -0
- kotonebot/devtools/resgen/validation.py +115 -0
- kotonebot/devtools/web/dist/assets/bootstrap-icons-BOrJxbIo.woff +0 -0
- kotonebot/devtools/web/dist/assets/bootstrap-icons-BtvjY1KL.woff2 +0 -0
- kotonebot/devtools/web/dist/assets/ext-language_tools-CD021WJ2.js +2577 -0
- kotonebot/devtools/web/dist/assets/index-B_m5f2LF.js +2836 -0
- kotonebot/devtools/web/dist/assets/index-BlEDyGGa.css +9 -0
- kotonebot/devtools/web/dist/assets/language-client-C9muzqaq.js +128 -0
- kotonebot/devtools/web/dist/assets/mode-python-CtHp76XS.js +476 -0
- kotonebot/devtools/web/dist/icons/symbol-class.svg +3 -0
- kotonebot/devtools/web/dist/icons/symbol-file.svg +3 -0
- kotonebot/devtools/web/dist/icons/symbol-method.svg +3 -0
- kotonebot/devtools/web/dist/index.html +25 -0
- kotonebot/devtools/web/server/__init__.py +0 -0
- kotonebot/devtools/web/server/rest_api.py +217 -0
- kotonebot/devtools/web/server/server.py +85 -0
- kotonebot/errors.py +76 -76
- kotonebot/interop/win/__init__.py +11 -9
- kotonebot/interop/win/_mouse.py +310 -310
- kotonebot/interop/win/message_box.py +313 -313
- kotonebot/interop/win/reg.py +37 -37
- kotonebot/interop/win/shake_mouse.py +224 -0
- kotonebot/interop/win/shortcut.py +43 -43
- kotonebot/interop/win/task_dialog.py +513 -513
- kotonebot/logging/__init__.py +2 -2
- kotonebot/logging/log.py +17 -17
- kotonebot/primitives/__init__.py +19 -17
- kotonebot/primitives/geometry.py +1067 -862
- kotonebot/primitives/visual.py +143 -63
- kotonebot/ui/file_host/sensio.py +36 -36
- kotonebot/ui/file_host/tmp_send.py +54 -54
- kotonebot/ui/pushkit/__init__.py +3 -3
- kotonebot/ui/pushkit/image_host.py +88 -88
- kotonebot/ui/pushkit/protocol.py +13 -13
- kotonebot/ui/pushkit/wxpusher.py +54 -54
- kotonebot/ui/user.py +148 -148
- kotonebot/util.py +436 -436
- {kotonebot-0.5.0.dist-info → kotonebot-0.6.0.dist-info}/METADATA +84 -82
- kotonebot-0.6.0.dist-info/RECORD +105 -0
- kotonebot-0.6.0.dist-info/entry_points.txt +2 -0
- {kotonebot-0.5.0.dist-info → kotonebot-0.6.0.dist-info}/licenses/LICENSE +673 -673
- kotonebot/client/implements/adb_raw.py +0 -163
- kotonebot-0.5.0.dist-info/RECORD +0 -71
- /kotonebot/{tools → devtools/project}/__init__.py +0 -0
- {kotonebot-0.5.0.dist-info → kotonebot-0.6.0.dist-info}/WHEEL +0 -0
- {kotonebot-0.5.0.dist-info → kotonebot-0.6.0.dist-info}/top_level.txt +0 -0
kotonebot/backend/bot.py
CHANGED
|
@@ -1,312 +1,312 @@
|
|
|
1
|
-
import io
|
|
2
|
-
import os
|
|
3
|
-
import logging
|
|
4
|
-
import pkgutil
|
|
5
|
-
import importlib
|
|
6
|
-
import threading
|
|
7
|
-
from typing_extensions import Self
|
|
8
|
-
from dataclasses import dataclass, field
|
|
9
|
-
from typing import Any, Literal, Callable, Generic, TypeVar, ParamSpec
|
|
10
|
-
|
|
11
|
-
from kotonebot.client import Device
|
|
12
|
-
from kotonebot.client.host.protocol import Instance
|
|
13
|
-
from kotonebot.backend.context import init_context, vars
|
|
14
|
-
from kotonebot.backend.context import task_registry, action_registry, Task, Action
|
|
15
|
-
from kotonebot.errors import StopCurrentTask, UserFriendlyError
|
|
16
|
-
from kotonebot.util import is_windows
|
|
17
|
-
|
|
18
|
-
# 条件导入 TaskDialog(仅在 Windows 上)
|
|
19
|
-
if is_windows():
|
|
20
|
-
try:
|
|
21
|
-
from kotonebot.interop.win.task_dialog import TaskDialog
|
|
22
|
-
except ImportError:
|
|
23
|
-
TaskDialog = None
|
|
24
|
-
else:
|
|
25
|
-
TaskDialog = None
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
@dataclass
|
|
29
|
-
class PostTaskContext:
|
|
30
|
-
has_error: bool
|
|
31
|
-
exception: Exception | None
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
log_stream = io.StringIO()
|
|
35
|
-
stream_handler = logging.StreamHandler(log_stream)
|
|
36
|
-
stream_handler.setFormatter(logging.Formatter('[%(asctime)s] [%(levelname)s] [%(name)s] [%(filename)s:%(lineno)d] - %(message)s'))
|
|
37
|
-
logging.getLogger('kotonebot').addHandler(stream_handler)
|
|
38
|
-
logger = logging.getLogger(__name__)
|
|
39
|
-
|
|
40
|
-
TaskStatusValue = Literal['pending', 'running', 'finished', 'error', 'cancelled', 'stopped']
|
|
41
|
-
@dataclass
|
|
42
|
-
class TaskStatus:
|
|
43
|
-
task: Task
|
|
44
|
-
status: TaskStatusValue
|
|
45
|
-
|
|
46
|
-
@dataclass
|
|
47
|
-
class RunStatus:
|
|
48
|
-
running: bool = False
|
|
49
|
-
tasks: list[TaskStatus] = field(default_factory=list)
|
|
50
|
-
current_task: Task | None = None
|
|
51
|
-
callstack: list[Task | Action] = field(default_factory=list)
|
|
52
|
-
|
|
53
|
-
def interrupt(self):
|
|
54
|
-
vars.flow.request_interrupt()
|
|
55
|
-
|
|
56
|
-
# Modified from https://stackoverflow.com/questions/70982565/how-do-i-make-an-event-listener-with-decorators-in-python
|
|
57
|
-
Params = ParamSpec('Params')
|
|
58
|
-
Return = TypeVar('Return')
|
|
59
|
-
class Event(Generic[Params, Return]):
|
|
60
|
-
def __init__(self):
|
|
61
|
-
self.__listeners = []
|
|
62
|
-
|
|
63
|
-
@property
|
|
64
|
-
def on(self):
|
|
65
|
-
def wrapper(func: Callable[Params, Return]):
|
|
66
|
-
self.add_listener(func)
|
|
67
|
-
return func
|
|
68
|
-
return wrapper
|
|
69
|
-
|
|
70
|
-
def add_listener(self, func: Callable[Params, Return]) -> None:
|
|
71
|
-
if func in self.__listeners:
|
|
72
|
-
return
|
|
73
|
-
self.__listeners.append(func)
|
|
74
|
-
|
|
75
|
-
def remove_listener(self, func: Callable[Params, Return]) -> None:
|
|
76
|
-
if func not in self.__listeners:
|
|
77
|
-
return
|
|
78
|
-
self.__listeners.remove(func)
|
|
79
|
-
|
|
80
|
-
def __iadd__(self, func: Callable[Params, Return]) -> Self:
|
|
81
|
-
self.add_listener(func)
|
|
82
|
-
return self
|
|
83
|
-
|
|
84
|
-
def __isub__(self, func: Callable[Params, Return]) -> Self:
|
|
85
|
-
self.remove_listener(func)
|
|
86
|
-
return self
|
|
87
|
-
|
|
88
|
-
def trigger(self, *args: Params.args, **kwargs: Params.kwargs) -> None:
|
|
89
|
-
for func in self.__listeners:
|
|
90
|
-
func(*args, **kwargs)
|
|
91
|
-
|
|
92
|
-
class KotoneBotEvents:
|
|
93
|
-
def __init__(self):
|
|
94
|
-
self.task_status_changed = Event[
|
|
95
|
-
[Task, TaskStatusValue], None
|
|
96
|
-
]()
|
|
97
|
-
self.task_error = Event[
|
|
98
|
-
[Task, Exception], None
|
|
99
|
-
]()
|
|
100
|
-
self.finished = Event[[], None]()
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
class KotoneBot:
|
|
104
|
-
def __init__(
|
|
105
|
-
self,
|
|
106
|
-
module: str,
|
|
107
|
-
config_path: str,
|
|
108
|
-
config_type: type = dict[str, Any],
|
|
109
|
-
*,
|
|
110
|
-
debug: bool = False,
|
|
111
|
-
resume_on_error: bool = False,
|
|
112
|
-
auto_save_error_report: bool = False,
|
|
113
|
-
):
|
|
114
|
-
"""
|
|
115
|
-
初始化 KotoneBot。
|
|
116
|
-
|
|
117
|
-
:param module: 主模块名。此模块及其所有子模块都会被载入。
|
|
118
|
-
:param config_type: 配置类型。
|
|
119
|
-
:param debug: 调试模式。
|
|
120
|
-
:param resume_on_error: 在错误时是否恢复。
|
|
121
|
-
:param auto_save_error_report: 是否自动保存错误报告。
|
|
122
|
-
"""
|
|
123
|
-
self.module = module
|
|
124
|
-
self.config_path = config_path
|
|
125
|
-
self.config_type = config_type
|
|
126
|
-
# HACK: 硬编码
|
|
127
|
-
self.current_config: int | str = 0
|
|
128
|
-
self.debug = debug
|
|
129
|
-
self.resume_on_error = resume_on_error
|
|
130
|
-
self.auto_save_error_report = auto_save_error_report
|
|
131
|
-
self.events = KotoneBotEvents()
|
|
132
|
-
self.backend_instance: Instance | None = None
|
|
133
|
-
|
|
134
|
-
if self.auto_save_error_report:
|
|
135
|
-
raise NotImplementedError('auto_save_error_report not implemented yet.')
|
|
136
|
-
|
|
137
|
-
def initialize(self):
|
|
138
|
-
"""
|
|
139
|
-
初始化并载入所有任务和动作。
|
|
140
|
-
"""
|
|
141
|
-
logger.info('Initializing tasks and actions...')
|
|
142
|
-
logger.debug(f'Loading module: {self.module}')
|
|
143
|
-
# 加载主模块
|
|
144
|
-
importlib.import_module(self.module)
|
|
145
|
-
|
|
146
|
-
# 加载所有子模块
|
|
147
|
-
pkg = importlib.import_module(self.module)
|
|
148
|
-
for loader, name, is_pkg in pkgutil.walk_packages(pkg.__path__, pkg.__name__ + '.'):
|
|
149
|
-
logger.debug(f'Loading sub-module: {name}')
|
|
150
|
-
try:
|
|
151
|
-
importlib.import_module(name)
|
|
152
|
-
except Exception:
|
|
153
|
-
logger.error(f'Failed to load sub-module: {name}')
|
|
154
|
-
logger.exception('Error: ')
|
|
155
|
-
|
|
156
|
-
logger.info('Tasks and actions initialized.')
|
|
157
|
-
logger.info(f'{len(task_registry)} task(s) and {len(action_registry)} action(s) loaded.')
|
|
158
|
-
|
|
159
|
-
def _on_create_device(self) -> Device:
|
|
160
|
-
"""
|
|
161
|
-
抽象方法,用于创建 Device 类,在 `run()` 方法执行前会被调用。
|
|
162
|
-
|
|
163
|
-
所有子类都需要重写该方法。
|
|
164
|
-
"""
|
|
165
|
-
raise NotImplementedError('Implement `_create_device` before using Kotonebot.')
|
|
166
|
-
|
|
167
|
-
def _on_init_context(self) -> None:
|
|
168
|
-
"""
|
|
169
|
-
初始化 Context 的钩子方法。子类可以重写此方法来自定义初始化逻辑。
|
|
170
|
-
默认实现调用 init_context 而不传入 target_screenshot_interval。
|
|
171
|
-
"""
|
|
172
|
-
d = self._on_create_device()
|
|
173
|
-
init_context(
|
|
174
|
-
config_path=self.config_path,
|
|
175
|
-
config_type=self.config_type,
|
|
176
|
-
target_device=d
|
|
177
|
-
)
|
|
178
|
-
|
|
179
|
-
def _on_after_init_context(self):
|
|
180
|
-
"""
|
|
181
|
-
抽象方法,在 init_context() 被调用后立即执行。
|
|
182
|
-
"""
|
|
183
|
-
pass
|
|
184
|
-
|
|
185
|
-
def run(self, tasks: list[Task], *, by_priority: bool = True):
|
|
186
|
-
"""
|
|
187
|
-
按优先级顺序运行所有任务。
|
|
188
|
-
"""
|
|
189
|
-
self._on_init_context()
|
|
190
|
-
self._on_after_init_context()
|
|
191
|
-
vars.flow.clear_interrupt()
|
|
192
|
-
|
|
193
|
-
pre_tasks = [task for task in tasks if task.run_at == 'pre']
|
|
194
|
-
regular_tasks = [task for task in tasks if task.run_at == 'regular']
|
|
195
|
-
post_tasks = [task for task in tasks if task.run_at == 'post']
|
|
196
|
-
|
|
197
|
-
if by_priority:
|
|
198
|
-
pre_tasks = sorted(pre_tasks, key=lambda x: x.priority, reverse=True)
|
|
199
|
-
regular_tasks = sorted(regular_tasks, key=lambda x: x.priority, reverse=True)
|
|
200
|
-
post_tasks = sorted(post_tasks, key=lambda x: x.priority, reverse=True)
|
|
201
|
-
|
|
202
|
-
all_tasks = pre_tasks + regular_tasks + post_tasks
|
|
203
|
-
for task in all_tasks:
|
|
204
|
-
self.events.task_status_changed.trigger(task, 'pending')
|
|
205
|
-
|
|
206
|
-
has_error = False
|
|
207
|
-
exception: Exception | None = None
|
|
208
|
-
|
|
209
|
-
for task in all_tasks:
|
|
210
|
-
logger.info(f'Task started: {task.name}')
|
|
211
|
-
self.events.task_status_changed.trigger(task, 'running')
|
|
212
|
-
|
|
213
|
-
if self.debug:
|
|
214
|
-
if task.run_at == 'post':
|
|
215
|
-
task.func(PostTaskContext(has_error, exception))
|
|
216
|
-
else:
|
|
217
|
-
task.func()
|
|
218
|
-
else:
|
|
219
|
-
try:
|
|
220
|
-
if task.run_at == 'post':
|
|
221
|
-
task.func(PostTaskContext(has_error, exception))
|
|
222
|
-
else:
|
|
223
|
-
task.func()
|
|
224
|
-
self.events.task_status_changed.trigger(task, 'finished')
|
|
225
|
-
except StopCurrentTask:
|
|
226
|
-
logger.info(f'Task skipped/stopped: {task.name}')
|
|
227
|
-
self.events.task_status_changed.trigger(task, 'stopped')
|
|
228
|
-
# 用户中止
|
|
229
|
-
except KeyboardInterrupt as e:
|
|
230
|
-
logger.exception('Keyboard interrupt detected.')
|
|
231
|
-
for task1 in all_tasks[all_tasks.index(task):]:
|
|
232
|
-
self.events.task_status_changed.trigger(task1, 'cancelled')
|
|
233
|
-
vars.flow.clear_interrupt()
|
|
234
|
-
break
|
|
235
|
-
# 用户可以自行处理的错误
|
|
236
|
-
except UserFriendlyError as e:
|
|
237
|
-
logger.error(f'Task failed: {task.name}')
|
|
238
|
-
logger.exception('Error: ')
|
|
239
|
-
has_error = True
|
|
240
|
-
exception = e
|
|
241
|
-
if TaskDialog:
|
|
242
|
-
dialog = TaskDialog(
|
|
243
|
-
title='琴音小助手',
|
|
244
|
-
common_buttons=0,
|
|
245
|
-
main_instruction='任务执行失败',
|
|
246
|
-
content=e.message,
|
|
247
|
-
custom_buttons=e.action_buttons,
|
|
248
|
-
main_icon='error'
|
|
249
|
-
)
|
|
250
|
-
result_custom, _, _ = dialog.show()
|
|
251
|
-
e.invoke(result_custom)
|
|
252
|
-
# 其他错误
|
|
253
|
-
except Exception as e:
|
|
254
|
-
logger.error(f'Task failed: {task.name}')
|
|
255
|
-
logger.exception(f'Error: ')
|
|
256
|
-
has_error = True
|
|
257
|
-
exception = e
|
|
258
|
-
report_path = None
|
|
259
|
-
if self.auto_save_error_report:
|
|
260
|
-
raise NotImplementedError
|
|
261
|
-
self.events.task_status_changed.trigger(task, 'error')
|
|
262
|
-
if not self.resume_on_error:
|
|
263
|
-
for task1 in all_tasks[all_tasks.index(task)+1:]:
|
|
264
|
-
self.events.task_status_changed.trigger(task1, 'cancelled')
|
|
265
|
-
break
|
|
266
|
-
logger.info(f'Task ended: {task.name}')
|
|
267
|
-
logger.info('All tasks ended.')
|
|
268
|
-
self.events.finished.trigger()
|
|
269
|
-
|
|
270
|
-
def run_all(self) -> None:
|
|
271
|
-
return self.run(list(task_registry.values()), by_priority=True)
|
|
272
|
-
|
|
273
|
-
def start(self, tasks: list[Task], *, by_priority: bool = True) -> RunStatus:
|
|
274
|
-
"""
|
|
275
|
-
在单独的线程中按优先级顺序运行指定的任务。
|
|
276
|
-
|
|
277
|
-
:param tasks: 要运行的任务列表
|
|
278
|
-
:param by_priority: 是否按优先级排序
|
|
279
|
-
:return: 运行状态对象
|
|
280
|
-
"""
|
|
281
|
-
run_status = RunStatus(running=True)
|
|
282
|
-
def _on_finished():
|
|
283
|
-
run_status.running = False
|
|
284
|
-
run_status.current_task = None
|
|
285
|
-
run_status.callstack = []
|
|
286
|
-
self.events.finished -= _on_finished
|
|
287
|
-
self.events.task_status_changed -= _on_task_status_changed
|
|
288
|
-
|
|
289
|
-
def _on_task_status_changed(task: Task, status: TaskStatusValue):
|
|
290
|
-
def _find(task: Task) -> TaskStatus:
|
|
291
|
-
for task_status in run_status.tasks:
|
|
292
|
-
if task_status.task == task:
|
|
293
|
-
return task_status
|
|
294
|
-
raise ValueError(f'Task {task.name} not found in run_status.tasks')
|
|
295
|
-
if status == 'pending':
|
|
296
|
-
run_status.tasks.append(TaskStatus(task=task, status='pending'))
|
|
297
|
-
else:
|
|
298
|
-
_find(task).status = status
|
|
299
|
-
|
|
300
|
-
self.events.task_status_changed += _on_task_status_changed
|
|
301
|
-
self.events.finished += _on_finished
|
|
302
|
-
thread = threading.Thread(target=lambda: self.run(tasks, by_priority=by_priority))
|
|
303
|
-
thread.start()
|
|
304
|
-
return run_status
|
|
305
|
-
|
|
306
|
-
def start_all(self) -> RunStatus:
|
|
307
|
-
"""
|
|
308
|
-
在单独的线程中运行所有任务。
|
|
309
|
-
|
|
310
|
-
:return: 运行状态对象
|
|
311
|
-
"""
|
|
312
|
-
return self.start(list(task_registry.values()), by_priority=True)
|
|
1
|
+
import io
|
|
2
|
+
import os
|
|
3
|
+
import logging
|
|
4
|
+
import pkgutil
|
|
5
|
+
import importlib
|
|
6
|
+
import threading
|
|
7
|
+
from typing_extensions import Self
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from typing import Any, Literal, Callable, Generic, TypeVar, ParamSpec
|
|
10
|
+
|
|
11
|
+
from kotonebot.client import Device
|
|
12
|
+
from kotonebot.client.host.protocol import Instance
|
|
13
|
+
from kotonebot.backend.context import init_context, vars
|
|
14
|
+
from kotonebot.backend.context import task_registry, action_registry, Task, Action
|
|
15
|
+
from kotonebot.errors import StopCurrentTask, UserFriendlyError
|
|
16
|
+
from kotonebot.util import is_windows
|
|
17
|
+
|
|
18
|
+
# 条件导入 TaskDialog(仅在 Windows 上)
|
|
19
|
+
if is_windows():
|
|
20
|
+
try:
|
|
21
|
+
from kotonebot.interop.win.task_dialog import TaskDialog
|
|
22
|
+
except ImportError:
|
|
23
|
+
TaskDialog = None
|
|
24
|
+
else:
|
|
25
|
+
TaskDialog = None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class PostTaskContext:
|
|
30
|
+
has_error: bool
|
|
31
|
+
exception: Exception | None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
log_stream = io.StringIO()
|
|
35
|
+
stream_handler = logging.StreamHandler(log_stream)
|
|
36
|
+
stream_handler.setFormatter(logging.Formatter('[%(asctime)s] [%(levelname)s] [%(name)s] [%(filename)s:%(lineno)d] - %(message)s'))
|
|
37
|
+
logging.getLogger('kotonebot').addHandler(stream_handler)
|
|
38
|
+
logger = logging.getLogger(__name__)
|
|
39
|
+
|
|
40
|
+
TaskStatusValue = Literal['pending', 'running', 'finished', 'error', 'cancelled', 'stopped']
|
|
41
|
+
@dataclass
|
|
42
|
+
class TaskStatus:
|
|
43
|
+
task: Task
|
|
44
|
+
status: TaskStatusValue
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class RunStatus:
|
|
48
|
+
running: bool = False
|
|
49
|
+
tasks: list[TaskStatus] = field(default_factory=list)
|
|
50
|
+
current_task: Task | None = None
|
|
51
|
+
callstack: list[Task | Action] = field(default_factory=list)
|
|
52
|
+
|
|
53
|
+
def interrupt(self):
|
|
54
|
+
vars.flow.request_interrupt()
|
|
55
|
+
|
|
56
|
+
# Modified from https://stackoverflow.com/questions/70982565/how-do-i-make-an-event-listener-with-decorators-in-python
|
|
57
|
+
Params = ParamSpec('Params')
|
|
58
|
+
Return = TypeVar('Return')
|
|
59
|
+
class Event(Generic[Params, Return]):
|
|
60
|
+
def __init__(self):
|
|
61
|
+
self.__listeners = []
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def on(self):
|
|
65
|
+
def wrapper(func: Callable[Params, Return]):
|
|
66
|
+
self.add_listener(func)
|
|
67
|
+
return func
|
|
68
|
+
return wrapper
|
|
69
|
+
|
|
70
|
+
def add_listener(self, func: Callable[Params, Return]) -> None:
|
|
71
|
+
if func in self.__listeners:
|
|
72
|
+
return
|
|
73
|
+
self.__listeners.append(func)
|
|
74
|
+
|
|
75
|
+
def remove_listener(self, func: Callable[Params, Return]) -> None:
|
|
76
|
+
if func not in self.__listeners:
|
|
77
|
+
return
|
|
78
|
+
self.__listeners.remove(func)
|
|
79
|
+
|
|
80
|
+
def __iadd__(self, func: Callable[Params, Return]) -> Self:
|
|
81
|
+
self.add_listener(func)
|
|
82
|
+
return self
|
|
83
|
+
|
|
84
|
+
def __isub__(self, func: Callable[Params, Return]) -> Self:
|
|
85
|
+
self.remove_listener(func)
|
|
86
|
+
return self
|
|
87
|
+
|
|
88
|
+
def trigger(self, *args: Params.args, **kwargs: Params.kwargs) -> None:
|
|
89
|
+
for func in self.__listeners:
|
|
90
|
+
func(*args, **kwargs)
|
|
91
|
+
|
|
92
|
+
class KotoneBotEvents:
|
|
93
|
+
def __init__(self):
|
|
94
|
+
self.task_status_changed = Event[
|
|
95
|
+
[Task, TaskStatusValue], None
|
|
96
|
+
]()
|
|
97
|
+
self.task_error = Event[
|
|
98
|
+
[Task, Exception], None
|
|
99
|
+
]()
|
|
100
|
+
self.finished = Event[[], None]()
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class KotoneBot:
|
|
104
|
+
def __init__(
|
|
105
|
+
self,
|
|
106
|
+
module: str,
|
|
107
|
+
config_path: str,
|
|
108
|
+
config_type: type = dict[str, Any],
|
|
109
|
+
*,
|
|
110
|
+
debug: bool = False,
|
|
111
|
+
resume_on_error: bool = False,
|
|
112
|
+
auto_save_error_report: bool = False,
|
|
113
|
+
):
|
|
114
|
+
"""
|
|
115
|
+
初始化 KotoneBot。
|
|
116
|
+
|
|
117
|
+
:param module: 主模块名。此模块及其所有子模块都会被载入。
|
|
118
|
+
:param config_type: 配置类型。
|
|
119
|
+
:param debug: 调试模式。
|
|
120
|
+
:param resume_on_error: 在错误时是否恢复。
|
|
121
|
+
:param auto_save_error_report: 是否自动保存错误报告。
|
|
122
|
+
"""
|
|
123
|
+
self.module = module
|
|
124
|
+
self.config_path = config_path
|
|
125
|
+
self.config_type = config_type
|
|
126
|
+
# HACK: 硬编码
|
|
127
|
+
self.current_config: int | str = 0
|
|
128
|
+
self.debug = debug
|
|
129
|
+
self.resume_on_error = resume_on_error
|
|
130
|
+
self.auto_save_error_report = auto_save_error_report
|
|
131
|
+
self.events = KotoneBotEvents()
|
|
132
|
+
self.backend_instance: Instance | None = None
|
|
133
|
+
|
|
134
|
+
if self.auto_save_error_report:
|
|
135
|
+
raise NotImplementedError('auto_save_error_report not implemented yet.')
|
|
136
|
+
|
|
137
|
+
def initialize(self):
|
|
138
|
+
"""
|
|
139
|
+
初始化并载入所有任务和动作。
|
|
140
|
+
"""
|
|
141
|
+
logger.info('Initializing tasks and actions...')
|
|
142
|
+
logger.debug(f'Loading module: {self.module}')
|
|
143
|
+
# 加载主模块
|
|
144
|
+
importlib.import_module(self.module)
|
|
145
|
+
|
|
146
|
+
# 加载所有子模块
|
|
147
|
+
pkg = importlib.import_module(self.module)
|
|
148
|
+
for loader, name, is_pkg in pkgutil.walk_packages(pkg.__path__, pkg.__name__ + '.'):
|
|
149
|
+
logger.debug(f'Loading sub-module: {name}')
|
|
150
|
+
try:
|
|
151
|
+
importlib.import_module(name)
|
|
152
|
+
except Exception:
|
|
153
|
+
logger.error(f'Failed to load sub-module: {name}')
|
|
154
|
+
logger.exception('Error: ')
|
|
155
|
+
|
|
156
|
+
logger.info('Tasks and actions initialized.')
|
|
157
|
+
logger.info(f'{len(task_registry)} task(s) and {len(action_registry)} action(s) loaded.')
|
|
158
|
+
|
|
159
|
+
def _on_create_device(self) -> Device:
|
|
160
|
+
"""
|
|
161
|
+
抽象方法,用于创建 Device 类,在 `run()` 方法执行前会被调用。
|
|
162
|
+
|
|
163
|
+
所有子类都需要重写该方法。
|
|
164
|
+
"""
|
|
165
|
+
raise NotImplementedError('Implement `_create_device` before using Kotonebot.')
|
|
166
|
+
|
|
167
|
+
def _on_init_context(self) -> None:
|
|
168
|
+
"""
|
|
169
|
+
初始化 Context 的钩子方法。子类可以重写此方法来自定义初始化逻辑。
|
|
170
|
+
默认实现调用 init_context 而不传入 target_screenshot_interval。
|
|
171
|
+
"""
|
|
172
|
+
d = self._on_create_device()
|
|
173
|
+
init_context(
|
|
174
|
+
config_path=self.config_path,
|
|
175
|
+
config_type=self.config_type,
|
|
176
|
+
target_device=d
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
def _on_after_init_context(self):
|
|
180
|
+
"""
|
|
181
|
+
抽象方法,在 init_context() 被调用后立即执行。
|
|
182
|
+
"""
|
|
183
|
+
pass
|
|
184
|
+
|
|
185
|
+
def run(self, tasks: list[Task], *, by_priority: bool = True):
|
|
186
|
+
"""
|
|
187
|
+
按优先级顺序运行所有任务。
|
|
188
|
+
"""
|
|
189
|
+
self._on_init_context()
|
|
190
|
+
self._on_after_init_context()
|
|
191
|
+
vars.flow.clear_interrupt()
|
|
192
|
+
|
|
193
|
+
pre_tasks = [task for task in tasks if task.run_at == 'pre']
|
|
194
|
+
regular_tasks = [task for task in tasks if task.run_at == 'regular']
|
|
195
|
+
post_tasks = [task for task in tasks if task.run_at == 'post']
|
|
196
|
+
|
|
197
|
+
if by_priority:
|
|
198
|
+
pre_tasks = sorted(pre_tasks, key=lambda x: x.priority, reverse=True)
|
|
199
|
+
regular_tasks = sorted(regular_tasks, key=lambda x: x.priority, reverse=True)
|
|
200
|
+
post_tasks = sorted(post_tasks, key=lambda x: x.priority, reverse=True)
|
|
201
|
+
|
|
202
|
+
all_tasks = pre_tasks + regular_tasks + post_tasks
|
|
203
|
+
for task in all_tasks:
|
|
204
|
+
self.events.task_status_changed.trigger(task, 'pending')
|
|
205
|
+
|
|
206
|
+
has_error = False
|
|
207
|
+
exception: Exception | None = None
|
|
208
|
+
|
|
209
|
+
for task in all_tasks:
|
|
210
|
+
logger.info(f'Task started: {task.name}')
|
|
211
|
+
self.events.task_status_changed.trigger(task, 'running')
|
|
212
|
+
|
|
213
|
+
if self.debug:
|
|
214
|
+
if task.run_at == 'post':
|
|
215
|
+
task.func(PostTaskContext(has_error, exception))
|
|
216
|
+
else:
|
|
217
|
+
task.func()
|
|
218
|
+
else:
|
|
219
|
+
try:
|
|
220
|
+
if task.run_at == 'post':
|
|
221
|
+
task.func(PostTaskContext(has_error, exception))
|
|
222
|
+
else:
|
|
223
|
+
task.func()
|
|
224
|
+
self.events.task_status_changed.trigger(task, 'finished')
|
|
225
|
+
except StopCurrentTask:
|
|
226
|
+
logger.info(f'Task skipped/stopped: {task.name}')
|
|
227
|
+
self.events.task_status_changed.trigger(task, 'stopped')
|
|
228
|
+
# 用户中止
|
|
229
|
+
except KeyboardInterrupt as e:
|
|
230
|
+
logger.exception('Keyboard interrupt detected.')
|
|
231
|
+
for task1 in all_tasks[all_tasks.index(task):]:
|
|
232
|
+
self.events.task_status_changed.trigger(task1, 'cancelled')
|
|
233
|
+
vars.flow.clear_interrupt()
|
|
234
|
+
break
|
|
235
|
+
# 用户可以自行处理的错误
|
|
236
|
+
except UserFriendlyError as e:
|
|
237
|
+
logger.error(f'Task failed: {task.name}')
|
|
238
|
+
logger.exception('Error: ')
|
|
239
|
+
has_error = True
|
|
240
|
+
exception = e
|
|
241
|
+
if TaskDialog:
|
|
242
|
+
dialog = TaskDialog(
|
|
243
|
+
title='琴音小助手',
|
|
244
|
+
common_buttons=0,
|
|
245
|
+
main_instruction='任务执行失败',
|
|
246
|
+
content=e.message,
|
|
247
|
+
custom_buttons=e.action_buttons,
|
|
248
|
+
main_icon='error'
|
|
249
|
+
)
|
|
250
|
+
result_custom, _, _ = dialog.show()
|
|
251
|
+
e.invoke(result_custom)
|
|
252
|
+
# 其他错误
|
|
253
|
+
except Exception as e:
|
|
254
|
+
logger.error(f'Task failed: {task.name}')
|
|
255
|
+
logger.exception(f'Error: ')
|
|
256
|
+
has_error = True
|
|
257
|
+
exception = e
|
|
258
|
+
report_path = None
|
|
259
|
+
if self.auto_save_error_report:
|
|
260
|
+
raise NotImplementedError
|
|
261
|
+
self.events.task_status_changed.trigger(task, 'error')
|
|
262
|
+
if not self.resume_on_error:
|
|
263
|
+
for task1 in all_tasks[all_tasks.index(task)+1:]:
|
|
264
|
+
self.events.task_status_changed.trigger(task1, 'cancelled')
|
|
265
|
+
break
|
|
266
|
+
logger.info(f'Task ended: {task.name}')
|
|
267
|
+
logger.info('All tasks ended.')
|
|
268
|
+
self.events.finished.trigger()
|
|
269
|
+
|
|
270
|
+
def run_all(self) -> None:
|
|
271
|
+
return self.run(list(task_registry.values()), by_priority=True)
|
|
272
|
+
|
|
273
|
+
def start(self, tasks: list[Task], *, by_priority: bool = True) -> RunStatus:
|
|
274
|
+
"""
|
|
275
|
+
在单独的线程中按优先级顺序运行指定的任务。
|
|
276
|
+
|
|
277
|
+
:param tasks: 要运行的任务列表
|
|
278
|
+
:param by_priority: 是否按优先级排序
|
|
279
|
+
:return: 运行状态对象
|
|
280
|
+
"""
|
|
281
|
+
run_status = RunStatus(running=True)
|
|
282
|
+
def _on_finished():
|
|
283
|
+
run_status.running = False
|
|
284
|
+
run_status.current_task = None
|
|
285
|
+
run_status.callstack = []
|
|
286
|
+
self.events.finished -= _on_finished
|
|
287
|
+
self.events.task_status_changed -= _on_task_status_changed
|
|
288
|
+
|
|
289
|
+
def _on_task_status_changed(task: Task, status: TaskStatusValue):
|
|
290
|
+
def _find(task: Task) -> TaskStatus:
|
|
291
|
+
for task_status in run_status.tasks:
|
|
292
|
+
if task_status.task == task:
|
|
293
|
+
return task_status
|
|
294
|
+
raise ValueError(f'Task {task.name} not found in run_status.tasks')
|
|
295
|
+
if status == 'pending':
|
|
296
|
+
run_status.tasks.append(TaskStatus(task=task, status='pending'))
|
|
297
|
+
else:
|
|
298
|
+
_find(task).status = status
|
|
299
|
+
|
|
300
|
+
self.events.task_status_changed += _on_task_status_changed
|
|
301
|
+
self.events.finished += _on_finished
|
|
302
|
+
thread = threading.Thread(target=lambda: self.run(tasks, by_priority=by_priority))
|
|
303
|
+
thread.start()
|
|
304
|
+
return run_status
|
|
305
|
+
|
|
306
|
+
def start_all(self) -> RunStatus:
|
|
307
|
+
"""
|
|
308
|
+
在单独的线程中运行所有任务。
|
|
309
|
+
|
|
310
|
+
:return: 运行状态对象
|
|
311
|
+
"""
|
|
312
|
+
return self.start(list(task_registry.values()), by_priority=True)
|