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.
Files changed (70) hide show
  1. kotonebot/__init__.py +40 -0
  2. kotonebot/backend/__init__.py +0 -0
  3. kotonebot/backend/bot.py +302 -0
  4. kotonebot/backend/color.py +525 -0
  5. kotonebot/backend/context/__init__.py +3 -0
  6. kotonebot/backend/context/context.py +1001 -0
  7. kotonebot/backend/context/task_action.py +176 -0
  8. kotonebot/backend/core.py +126 -0
  9. kotonebot/backend/debug/__init__.py +1 -0
  10. kotonebot/backend/debug/entry.py +89 -0
  11. kotonebot/backend/debug/mock.py +79 -0
  12. kotonebot/backend/debug/server.py +223 -0
  13. kotonebot/backend/debug/vars.py +346 -0
  14. kotonebot/backend/dispatch.py +228 -0
  15. kotonebot/backend/flow_controller.py +197 -0
  16. kotonebot/backend/image.py +748 -0
  17. kotonebot/backend/loop.py +277 -0
  18. kotonebot/backend/ocr.py +511 -0
  19. kotonebot/backend/preprocessor.py +103 -0
  20. kotonebot/client/__init__.py +10 -0
  21. kotonebot/client/device.py +500 -0
  22. kotonebot/client/fast_screenshot.py +378 -0
  23. kotonebot/client/host/__init__.py +12 -0
  24. kotonebot/client/host/adb_common.py +94 -0
  25. kotonebot/client/host/custom.py +114 -0
  26. kotonebot/client/host/leidian_host.py +202 -0
  27. kotonebot/client/host/mumu12_host.py +245 -0
  28. kotonebot/client/host/protocol.py +213 -0
  29. kotonebot/client/host/windows_common.py +55 -0
  30. kotonebot/client/implements/__init__.py +7 -0
  31. kotonebot/client/implements/adb.py +85 -0
  32. kotonebot/client/implements/adb_raw.py +159 -0
  33. kotonebot/client/implements/nemu_ipc/__init__.py +8 -0
  34. kotonebot/client/implements/nemu_ipc/external_renderer_ipc.py +280 -0
  35. kotonebot/client/implements/nemu_ipc/nemu_ipc.py +327 -0
  36. kotonebot/client/implements/remote_windows.py +193 -0
  37. kotonebot/client/implements/uiautomator2.py +82 -0
  38. kotonebot/client/implements/windows.py +168 -0
  39. kotonebot/client/protocol.py +69 -0
  40. kotonebot/client/registration.py +24 -0
  41. kotonebot/config/__init__.py +1 -0
  42. kotonebot/config/base_config.py +96 -0
  43. kotonebot/config/manager.py +36 -0
  44. kotonebot/errors.py +72 -0
  45. kotonebot/interop/win/__init__.py +0 -0
  46. kotonebot/interop/win/message_box.py +314 -0
  47. kotonebot/interop/win/reg.py +37 -0
  48. kotonebot/interop/win/shortcut.py +43 -0
  49. kotonebot/interop/win/task_dialog.py +469 -0
  50. kotonebot/logging/__init__.py +2 -0
  51. kotonebot/logging/log.py +18 -0
  52. kotonebot/primitives/__init__.py +17 -0
  53. kotonebot/primitives/geometry.py +290 -0
  54. kotonebot/primitives/visual.py +63 -0
  55. kotonebot/tools/__init__.py +0 -0
  56. kotonebot/tools/mirror.py +354 -0
  57. kotonebot/ui/__init__.py +0 -0
  58. kotonebot/ui/file_host/sensio.py +36 -0
  59. kotonebot/ui/file_host/tmp_send.py +54 -0
  60. kotonebot/ui/pushkit/__init__.py +3 -0
  61. kotonebot/ui/pushkit/image_host.py +87 -0
  62. kotonebot/ui/pushkit/protocol.py +13 -0
  63. kotonebot/ui/pushkit/wxpusher.py +53 -0
  64. kotonebot/ui/user.py +144 -0
  65. kotonebot/util.py +409 -0
  66. kotonebot-0.1.0.dist-info/METADATA +204 -0
  67. kotonebot-0.1.0.dist-info/RECORD +70 -0
  68. kotonebot-0.1.0.dist-info/WHEEL +5 -0
  69. kotonebot-0.1.0.dist-info/licenses/LICENSE +674 -0
  70. kotonebot-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1001 @@
1
+ import os
2
+ import re
3
+ import time
4
+ import logging
5
+ import warnings
6
+ import threading
7
+ from datetime import datetime
8
+ from threading import Event
9
+ from typing import (
10
+ Callable,
11
+ Optional,
12
+ cast,
13
+ overload,
14
+ Any,
15
+ TypeVar,
16
+ Literal,
17
+ ParamSpec,
18
+ Concatenate,
19
+ Generic,
20
+ Type,
21
+ Sequence,
22
+ )
23
+ from typing_extensions import deprecated
24
+
25
+ import cv2
26
+ from cv2.typing import MatLike
27
+
28
+ from kotonebot.client.device import Device, AndroidDevice, WindowsDevice
29
+ from kotonebot.backend.flow_controller import FlowController
30
+ from kotonebot.util import Interval
31
+ import kotonebot.backend.image as raw_image
32
+ from kotonebot.backend.image import (
33
+ TemplateMatchResult,
34
+ MultipleTemplateMatchResult,
35
+ find_all_crop,
36
+ expect,
37
+ find as image_find,
38
+ find_multi as image_find_multi,
39
+ find_all as image_find_all,
40
+ find_all_multi as image_find_all_multi,
41
+ count as image_count
42
+ )
43
+ import kotonebot.backend.color as raw_color
44
+ from kotonebot.backend.color import (
45
+ find as color_find, find_all as color_find_all
46
+ )
47
+ from kotonebot.backend.ocr import (
48
+ Ocr, OcrResult, OcrResultList, jp, en, StringMatchFunction
49
+ )
50
+ from kotonebot.config.manager import load_config, save_config
51
+ from kotonebot.config.base_config import UserConfig
52
+ from kotonebot.backend.core import Image, HintBox
53
+ from kotonebot.errors import ContextNotInitializedError, KotonebotWarning
54
+ from kotonebot.backend.preprocessor import PreprocessorProtocol
55
+ from kotonebot.primitives import Rect
56
+
57
+ OcrLanguage = Literal['jp', 'en']
58
+ ScreenshotMode = Literal['auto', 'manual', 'manual-inherit']
59
+ DEFAULT_TIMEOUT = 120
60
+ DEFAULT_INTERVAL = 0.4
61
+ logger = logging.getLogger(__name__)
62
+
63
+ # https://stackoverflow.com/questions/74714300/paramspec-for-a-pre-defined-function-without-using-generic-callablep
64
+ T = TypeVar('T')
65
+ P = ParamSpec('P')
66
+ ContextClass = TypeVar("ContextClass")
67
+
68
+ def context(
69
+ _: Callable[Concatenate[MatLike, P], T] # 输入函数
70
+ ) -> Callable[
71
+ [Callable[Concatenate[ContextClass, P], T]], # 被装饰函数
72
+ Callable[Concatenate[ContextClass, P], T] # 结果函数
73
+ ]:
74
+ """
75
+ 用于标记 Context 类方法的装饰器。
76
+ 此装饰器仅用于辅助类型标注,运行时无实际功能。
77
+
78
+ 装饰器输入的函数类型为 `(img: MatLike, a, b, c, ...) -> T`,
79
+ 被装饰的函数类型为 `(self: ContextClass, *args, **kwargs) -> T`,
80
+ 结果类型为 `(self: ContextClass, a, b, c, ...) -> T`。
81
+
82
+ 也就是说,`@context` 会把输入函数的第一个参数 `img: MatLike` 删除,
83
+ 然后再添加 `self` 作为第一个参数。
84
+
85
+ 【例】
86
+ ```python
87
+ def find_image(
88
+ img: MatLike,
89
+ mask: MatLike,
90
+ threshold: float = 0.9
91
+ ) -> TemplateMatchResult | None:
92
+ ...
93
+ ```
94
+ ```python
95
+ class ContextImage:
96
+ @context(find_image)
97
+ def find_image(self, *args, **kwargs):
98
+ return find_image(
99
+ self.context.device.screenshot(),
100
+ *args,
101
+ **kwargs
102
+ )
103
+
104
+ ```
105
+ ```python
106
+
107
+ c = ContextImage()
108
+ c.find_image()
109
+ # 此函数类型推断为 (
110
+ # self: ContextImage,
111
+ # img: MatLike,
112
+ # mask: MatLike,
113
+ # threshold: float = 0.9
114
+ # ) -> TemplateMatchResult | None
115
+ ```
116
+ """
117
+ def _decorator(func):
118
+ return func
119
+ return _decorator
120
+
121
+ def interruptible(func: Callable[P, T]) -> Callable[P, T]:
122
+ """
123
+ 将函数包装为可中断函数。
124
+
125
+ 在调用函数前,自动检查用户是否请求中断。
126
+ 如果用户请求中断,则抛出 `KeyboardInterrupt` 异常。
127
+ """
128
+ def _decorator(*args: P.args, **kwargs: P.kwargs) -> T:
129
+ global vars
130
+ vars.flow.check()
131
+ return func(*args, **kwargs)
132
+ return _decorator
133
+
134
+ def interruptible_class(cls: Type[T]) -> Type[T]:
135
+ """
136
+ 将类中的所有方法包装为可中断方法。
137
+
138
+ 在调用方法前,自动检查用户是否请求中断。
139
+ 如果用户请求中断,则抛出 `KeyboardInterrupt` 异常。
140
+ """
141
+ for name, func in cls.__dict__.items():
142
+ if callable(func) and not name.startswith('__'):
143
+ setattr(cls, name, interruptible(func))
144
+ return cls
145
+
146
+ def sleep(seconds: float, /):
147
+ """
148
+ 可中断和可暂停的 sleep 函数。
149
+
150
+ 建议使用本函数代替 `time.sleep()`,
151
+ 这样能以最快速度响应用户请求中断和暂停。
152
+ """
153
+ global vars
154
+ vars.flow.sleep(seconds)
155
+
156
+ def warn_manual_screenshot_mode(name: str, alternative: str):
157
+ """
158
+ 警告在手动截图模式下使用的方法。
159
+ """
160
+ warnings.warn(
161
+ f"You are calling `{name}` function in manual screenshot mode. "
162
+ f"This is meaningless. Write you own while loop and call `{alternative}` in the loop.",
163
+ KotonebotWarning
164
+ )
165
+
166
+ def is_manual_screenshot_mode() -> bool:
167
+ """
168
+ 检查当前是否处于手动截图模式。
169
+ """
170
+ mode = ContextStackVars.ensure_current().screenshot_mode
171
+ return mode == 'manual' or mode == 'manual-inherit'
172
+
173
+ class ContextGlobalVars:
174
+ def __init__(self):
175
+ self.__vars = dict[str, Any]()
176
+ self.flow: FlowController = FlowController()
177
+ """流程控制器,负责停止、暂停、恢复等操作"""
178
+
179
+ def __getitem__(self, key: str) -> Any:
180
+ return self.__vars[key]
181
+
182
+ def __setitem__(self, key: str, value: Any) -> None:
183
+ self.__vars[key] = value
184
+
185
+ def __delitem__(self, key: str) -> None:
186
+ del self.__vars[key]
187
+
188
+ def __contains__(self, key: str) -> bool:
189
+ return key in self.__vars
190
+
191
+ def get(self, key: str, default: Any = None) -> Any:
192
+ return self.__vars.get(key, default)
193
+
194
+ def set(self, key: str, value: Any) -> None:
195
+ self.__vars[key] = value
196
+
197
+ def clear(self):
198
+ self.__vars.clear()
199
+ self.flow.reset() # 重置流程控制器
200
+
201
+ def check_flow_control():
202
+ """
203
+ 统一的流程控制检查函数。
204
+
205
+ 检查用户是否请求中断或暂停,如果是则相应处理:
206
+ - 如果请求中断,抛出 KeyboardInterrupt 异常
207
+ - 如果请求暂停,等待直到恢复
208
+ """
209
+ vars.flow.check()
210
+
211
+ class ContextStackVars:
212
+ stack: list['ContextStackVars'] = []
213
+
214
+ def __init__(self):
215
+ self.screenshot_mode: ScreenshotMode = 'auto'
216
+ """
217
+ 截图模式。
218
+
219
+ * `auto`
220
+ 自动截图。即调用 `color`、`image`、`ocr` 上的方法时,会自动更新截图。
221
+ * `manual`
222
+ 完全手动截图,不自动截图。如果在没有截图数据的情况下调用 `color` 等的方法,会抛出异常。
223
+ * `manual-inherit`:
224
+ 第一张截图继承自调用者,此后同手动截图。
225
+ 如果调用者没有截图数据,则继承的截图数据为空。
226
+ 如果在没有截图数据的情况下调用 `color` 等的方法,会抛出异常。
227
+ """
228
+ self._screenshot: MatLike | None = None
229
+ """截图数据"""
230
+ self._inherit_screenshot: MatLike | None = None
231
+ """继承的截图数据"""
232
+
233
+ @property
234
+ def screenshot(self) -> MatLike:
235
+ match self.screenshot_mode:
236
+ case 'manual':
237
+ if self._screenshot is None:
238
+ raise ValueError("No screenshot data found.")
239
+ return self._screenshot
240
+ case 'manual-inherit':
241
+ # TODO: 这一部分要考虑和 device.screenshot() 合并
242
+ if self._inherit_screenshot is not None:
243
+ self._screenshot = self._inherit_screenshot
244
+ self._inherit_screenshot = None
245
+ if self._screenshot is None:
246
+ raise ValueError("No screenshot data found.")
247
+ return self._screenshot
248
+ case 'auto':
249
+ self._screenshot = device.screenshot()
250
+ return self._screenshot
251
+ case _:
252
+ raise ValueError(f"Invalid screenshot mode: {self.screenshot_mode}")
253
+
254
+ @staticmethod
255
+ def push(*, screenshot_mode: ScreenshotMode | None = None) -> 'ContextStackVars':
256
+ vars = ContextStackVars()
257
+ if screenshot_mode is not None:
258
+ vars.screenshot_mode = screenshot_mode
259
+ current = ContextStackVars.current()
260
+ if current and vars.screenshot_mode == 'manual-inherit':
261
+ vars._inherit_screenshot = current._screenshot
262
+ ContextStackVars.stack.append(vars)
263
+ return vars
264
+
265
+ @staticmethod
266
+ def pop() -> 'ContextStackVars':
267
+ last = ContextStackVars.stack.pop()
268
+ return last
269
+
270
+ @staticmethod
271
+ def current() -> 'ContextStackVars | None':
272
+ if len(ContextStackVars.stack) == 0:
273
+ return None
274
+ return ContextStackVars.stack[-1]
275
+
276
+ @staticmethod
277
+ def ensure_current() -> 'ContextStackVars':
278
+ if len(ContextStackVars.stack) == 0:
279
+ raise ValueError("No context stack found.")
280
+ return ContextStackVars.stack[-1]
281
+
282
+ @interruptible_class
283
+ class ContextOcr:
284
+ def __init__(self, context: 'Context'):
285
+ self.context = context
286
+ self.__engine = jp()
287
+
288
+ def _get_engine(self, lang: OcrLanguage | None = None) -> Ocr:
289
+ """获取指定语言的OCR引擎,如果lang为None则使用默认引擎。"""
290
+ return self.__engine if lang is None else self.raw(lang)
291
+
292
+ def raw(self, lang: OcrLanguage | None = None) -> Ocr:
293
+ """
294
+ 返回 `kotonebot.backend.ocr` 中的 Ocr 对象。\n
295
+ Ocr 对象与此对象(ContextOcr)的区别是,此对象会自动截图,而 Ocr 对象需要手动传入图像参数。
296
+ """
297
+ if lang is None:
298
+ lang = 'jp'
299
+ match lang:
300
+ case 'jp':
301
+ return jp()
302
+ case 'en':
303
+ return en()
304
+ case _:
305
+ raise ValueError(f"Invalid language: {lang}")
306
+
307
+ def ocr(
308
+ self,
309
+ rect: Rect | None = None,
310
+ lang: OcrLanguage | None = None,
311
+ ) -> OcrResultList:
312
+ """OCR 当前设备画面或指定图像。"""
313
+ engine = self._get_engine(lang)
314
+ return engine.ocr(ContextStackVars.ensure_current().screenshot, rect=rect)
315
+
316
+ def find(
317
+ self,
318
+ pattern: str | re.Pattern | StringMatchFunction,
319
+ *,
320
+ hint: HintBox | None = None,
321
+ rect: Rect | None = None,
322
+ lang: OcrLanguage | None = None,
323
+ ) -> OcrResult | None:
324
+ """检查当前设备画面是否包含指定文本。"""
325
+ engine = self._get_engine(lang)
326
+ ret = engine.find(
327
+ ContextStackVars.ensure_current().screenshot,
328
+ pattern,
329
+ hint=hint,
330
+ rect=rect,
331
+ )
332
+ self.context.device.last_find = ret.original_rect if ret else None
333
+ return ret
334
+
335
+ def find_all(
336
+ self,
337
+ patterns: Sequence[str | re.Pattern | StringMatchFunction],
338
+ *,
339
+ hint: HintBox | None = None,
340
+ rect: Rect | None = None,
341
+ lang: OcrLanguage | None = None,
342
+ ) -> list[OcrResult | None]:
343
+ engine = self._get_engine(lang)
344
+ return engine.find_all(
345
+ ContextStackVars.ensure_current().screenshot,
346
+ list(patterns),
347
+ hint=hint,
348
+ rect=rect,
349
+ )
350
+
351
+ def expect(
352
+ self,
353
+ pattern: str | re.Pattern | StringMatchFunction,
354
+ *,
355
+ rect: Rect | None = None,
356
+ hint: HintBox | None = None,
357
+ lang: OcrLanguage | None = None,
358
+ ) -> OcrResult:
359
+
360
+ """
361
+ 检查当前设备画面是否包含指定文本。
362
+
363
+ 与 `find()` 的区别在于,`expect()` 未找到时会抛出异常。
364
+ """
365
+ engine = self._get_engine(lang)
366
+ ret = engine.expect(ContextStackVars.ensure_current().screenshot, pattern, rect=rect, hint=hint)
367
+ self.context.device.last_find = ret.original_rect if ret else None
368
+ return ret
369
+
370
+ def expect_wait(
371
+ self,
372
+ pattern: str | re.Pattern | StringMatchFunction,
373
+ timeout: float = DEFAULT_TIMEOUT,
374
+ *,
375
+ interval: float = DEFAULT_INTERVAL,
376
+ rect: Rect | None = None,
377
+ hint: HintBox | None = None,
378
+ ) -> OcrResult:
379
+ """
380
+ 等待指定文本出现。
381
+ """
382
+ is_manual = is_manual_screenshot_mode()
383
+
384
+ start_time = time.time()
385
+ while True:
386
+ if is_manual:
387
+ device.screenshot()
388
+ result = self.find(pattern, rect=rect, hint=hint)
389
+
390
+ if result is not None:
391
+ self.context.device.last_find = result.original_rect if result else None
392
+ return result
393
+ if time.time() - start_time > timeout:
394
+ raise TimeoutError(f"Timeout waiting for {pattern}")
395
+ sleep(interval)
396
+
397
+ def wait_for(
398
+ self,
399
+ pattern: str | re.Pattern | StringMatchFunction,
400
+ timeout: float = DEFAULT_TIMEOUT,
401
+ *,
402
+ interval: float = DEFAULT_INTERVAL,
403
+ rect: Rect | None = None,
404
+ hint: HintBox | None = None,
405
+ ) -> OcrResult | None:
406
+ """
407
+ 等待指定文本出现。
408
+ """
409
+ is_manual = is_manual_screenshot_mode()
410
+
411
+ start_time = time.time()
412
+ while True:
413
+ if is_manual:
414
+ device.screenshot()
415
+ result = self.find(pattern, rect=rect, hint=hint)
416
+ if result is not None:
417
+ self.context.device.last_find = result.original_rect if result else None
418
+ return result
419
+ if time.time() - start_time > timeout:
420
+ return None
421
+ sleep(interval)
422
+
423
+
424
+ @interruptible_class
425
+ class ContextImage:
426
+ def __init__(self, context: 'Context', crop_rect: Rect | None = None):
427
+ self.context = context
428
+ self.crop_rect = crop_rect
429
+
430
+ def raw(self):
431
+ return raw_image
432
+
433
+ def wait_for(
434
+ self,
435
+ template: MatLike | str | Image,
436
+ mask: MatLike | str | None = None,
437
+ threshold: float = 0.8,
438
+ timeout: float = DEFAULT_TIMEOUT,
439
+ colored: bool = False,
440
+ *,
441
+ transparent: bool = False,
442
+ interval: float = DEFAULT_INTERVAL,
443
+ preprocessors: list[PreprocessorProtocol] | None = None,
444
+ ) -> TemplateMatchResult | None:
445
+ """
446
+ 等待指定图像出现。
447
+ """
448
+ is_manual = is_manual_screenshot_mode()
449
+
450
+ start_time = time.time()
451
+ while True:
452
+ if is_manual:
453
+ device.screenshot()
454
+ ret = self.find(
455
+ template,
456
+ mask,
457
+ transparent=transparent,
458
+ threshold=threshold,
459
+ colored=colored,
460
+ preprocessors=preprocessors,
461
+ )
462
+ if ret is not None:
463
+ self.context.device.last_find = ret
464
+ return ret
465
+ if time.time() - start_time > timeout:
466
+ return None
467
+ sleep(interval)
468
+
469
+ def wait_for_any(
470
+ self,
471
+ templates: list[str | Image],
472
+ masks: list[str | None] | None = None,
473
+ threshold: float = 0.8,
474
+ timeout: float = DEFAULT_TIMEOUT,
475
+ colored: bool = False,
476
+ *,
477
+ transparent: bool = False,
478
+ interval: float = DEFAULT_INTERVAL,
479
+ preprocessors: list[PreprocessorProtocol] | None = None,
480
+ ):
481
+ """
482
+ 等待指定图像中的任意一个出现。
483
+ """
484
+ is_manual = is_manual_screenshot_mode()
485
+
486
+ if masks is None:
487
+ _masks = [None] * len(templates)
488
+ else:
489
+ _masks = masks
490
+ start_time = time.time()
491
+ while True:
492
+ if is_manual:
493
+ device.screenshot()
494
+ for template, mask in zip(templates, _masks):
495
+ if self.find(
496
+ template,
497
+ mask,
498
+ transparent=transparent,
499
+ threshold=threshold,
500
+ colored=colored,
501
+ preprocessors=preprocessors,
502
+ ):
503
+ return True
504
+ if time.time() - start_time > timeout:
505
+ return False
506
+ sleep(interval)
507
+
508
+ def expect_wait(
509
+ self,
510
+ template: str | Image,
511
+ mask: str | None = None,
512
+ threshold: float = 0.8,
513
+ timeout: float = DEFAULT_TIMEOUT,
514
+ colored: bool = False,
515
+ *,
516
+ transparent: bool = False,
517
+ interval: float = DEFAULT_INTERVAL,
518
+ preprocessors: list[PreprocessorProtocol] | None = None,
519
+ ) -> TemplateMatchResult:
520
+ """
521
+ 等待指定图像出现。
522
+ """
523
+ is_manual = is_manual_screenshot_mode()
524
+
525
+ start_time = time.time()
526
+ while True:
527
+ if is_manual:
528
+ device.screenshot()
529
+ ret = self.find(
530
+ template,
531
+ mask,
532
+ transparent=transparent,
533
+ threshold=threshold,
534
+ colored=colored,
535
+ preprocessors=preprocessors,
536
+ )
537
+ if ret is not None:
538
+ self.context.device.last_find = ret
539
+ return ret
540
+ if time.time() - start_time > timeout:
541
+ raise TimeoutError(f"Timeout waiting for {template}")
542
+ sleep(interval)
543
+
544
+ def expect_wait_any(
545
+ self,
546
+ templates: list[str | Image],
547
+ masks: list[str | None] | None = None,
548
+ threshold: float = 0.8,
549
+ timeout: float = DEFAULT_TIMEOUT,
550
+ colored: bool = False,
551
+ *,
552
+ transparent: bool = False,
553
+ interval: float = DEFAULT_INTERVAL,
554
+ preprocessors: list[PreprocessorProtocol] | None = None,
555
+ ) -> TemplateMatchResult:
556
+ """
557
+ 等待指定图像中的任意一个出现。
558
+ """
559
+ is_manual = is_manual_screenshot_mode()
560
+
561
+ if masks is None:
562
+ _masks = [None] * len(templates)
563
+ else:
564
+ _masks = masks
565
+ start_time = time.time()
566
+ while True:
567
+ if is_manual:
568
+ device.screenshot()
569
+ for template, mask in zip(templates, _masks):
570
+ ret = self.find(
571
+ template,
572
+ mask,
573
+ transparent=transparent,
574
+ threshold=threshold,
575
+ colored=colored,
576
+ preprocessors=preprocessors,
577
+ )
578
+ if ret is not None:
579
+ self.context.device.last_find = ret
580
+ return ret
581
+ if time.time() - start_time > timeout:
582
+ raise TimeoutError(f"Timeout waiting for any of {templates}")
583
+ sleep(interval)
584
+
585
+ @context(expect)
586
+ def expect(self, *args, **kwargs):
587
+ ret = expect(ContextStackVars.ensure_current().screenshot, *args, **kwargs)
588
+ self.context.device.last_find = ret
589
+ return ret
590
+
591
+ @context(image_find)
592
+ def find(self, *args, **kwargs):
593
+ ret = image_find(ContextStackVars.ensure_current().screenshot, *args, **kwargs)
594
+ self.context.device.last_find = ret
595
+ return ret
596
+
597
+ @context(image_find_all)
598
+ def find_all(self, *args, **kwargs):
599
+ return image_find_all(ContextStackVars.ensure_current().screenshot, *args, **kwargs)
600
+
601
+ @context(image_find_multi)
602
+ def find_multi(self, *args, **kwargs):
603
+ ret = image_find_multi(ContextStackVars.ensure_current().screenshot, *args, **kwargs)
604
+ self.context.device.last_find = ret
605
+ return ret
606
+
607
+ @context(image_find_all_multi)
608
+ def find_all_multi(self, *args, **kwargs):
609
+ return image_find_all_multi(ContextStackVars.ensure_current().screenshot, *args, **kwargs)
610
+
611
+ @context(find_all_crop)
612
+ def find_all_crop(self, *args, **kwargs):
613
+ return find_all_crop(ContextStackVars.ensure_current().screenshot, *args, **kwargs)
614
+
615
+ @context(image_count)
616
+ def count(self, *args, **kwargs):
617
+ return image_count(ContextStackVars.ensure_current().screenshot, *args, **kwargs)
618
+
619
+ @interruptible_class
620
+ class ContextColor:
621
+ def __init__(self, context: 'Context'):
622
+ self.context = context
623
+
624
+ def raw(self):
625
+ return raw_color
626
+
627
+ @context(color_find)
628
+ def find(self, *args, **kwargs):
629
+ return color_find(ContextStackVars.ensure_current().screenshot, *args, **kwargs)
630
+
631
+ @context(color_find_all)
632
+ def find_all(self, *args, **kwargs):
633
+ return color_find_all(ContextStackVars.ensure_current().screenshot, *args, **kwargs)
634
+
635
+ @deprecated('使用 kotonebot.backend.debug 模块替代')
636
+ class ContextDebug:
637
+ def __init__(self, context: 'Context'):
638
+ self.__context = context
639
+ self.save_images: bool = False
640
+ self.save_images_dir: str = "debug_images"
641
+
642
+
643
+ V = TypeVar('V')
644
+ class ContextConfig(Generic[T]):
645
+ def __init__(self, context: 'Context', config_path: str = 'config.json', config_type: Type[T] = dict[str, Any]):
646
+ self.context = context
647
+ self.config_path: str = config_path
648
+ self.current_key: int | str = 0
649
+ self.config_type: Type = config_type
650
+ self.root = load_config(self.config_path, type=config_type)
651
+
652
+ def to(self, conf_type: Type[V]) -> 'ContextConfig[V]':
653
+ self.config_type = conf_type
654
+ return cast(ContextConfig[V], self)
655
+
656
+ def create(self, config: UserConfig[T]):
657
+ """创建新用户配置"""
658
+ self.root.user_configs.append(config)
659
+ self.save()
660
+
661
+ def get(self, key: str | int | None = None) -> UserConfig[T] | None:
662
+ """
663
+ 获取指定或当前用户配置数据。
664
+
665
+ :param key: 用户配置 ID 或索引(从 0 开始),为 None 时获取当前用户配置
666
+ :return: 用户配置数据
667
+ """
668
+ if isinstance(key, int):
669
+ if key < 0 or key >= len(self.root.user_configs):
670
+ return None
671
+ return self.root.user_configs[key]
672
+ elif isinstance(key, str):
673
+ for user in self.root.user_configs:
674
+ if user.id == key:
675
+ return user
676
+ else:
677
+ return None
678
+ else:
679
+ return self.get(self.current_key)
680
+
681
+ def save(self):
682
+ """保存所有配置数据到本地"""
683
+ save_config(self.root, self.config_path)
684
+
685
+ def load(self):
686
+ """从本地加载所有配置数据"""
687
+ self.root = load_config(self.config_path, type=self.config_type)
688
+
689
+ def switch(self, key: str | int):
690
+ """切换到指定用户配置"""
691
+ self.current_key = key
692
+
693
+ @property
694
+ def current(self) -> UserConfig[T]:
695
+ """
696
+ 当前配置数据。
697
+
698
+ 如果当前配置不存在,则使用默认值自动创建一个新配置。
699
+ (不推荐,建议在 UI 中启动前要求用户手动创建,或自行创建一个默认配置。)
700
+ """
701
+ c = self.get(self.current_key)
702
+ if c is None:
703
+ if not self.config_type:
704
+ raise ValueError("No config type specified.")
705
+ logger.warning("No config found, creating a new one using default values. (NOT RECOMMENDED)")
706
+ c = self.config_type()
707
+ u = UserConfig(options=c)
708
+ self.create(u)
709
+ c = u
710
+ return c
711
+
712
+
713
+ class Forwarded:
714
+ def __init__(self, getter: Callable[[], T] | None = None, name: str | None = None):
715
+ self._FORWARD_getter = getter
716
+ self._FORWARD_name = name
717
+
718
+ def __getattr__(self, name: str) -> Any:
719
+ if name.startswith('_FORWARD_'):
720
+ return object.__getattribute__(self, name)
721
+ if self._FORWARD_getter is None:
722
+ raise ContextNotInitializedError(f"Forwarded object {self._FORWARD_name} called before initialization.")
723
+ return getattr(self._FORWARD_getter(), name)
724
+
725
+ def __setattr__(self, name: str, value: Any):
726
+ if name.startswith('_FORWARD_'):
727
+ return object.__setattr__(self, name, value)
728
+ if self._FORWARD_getter is None:
729
+ raise ContextNotInitializedError(f"Forwarded object {self._FORWARD_name} called before initialization.")
730
+ setattr(self._FORWARD_getter(), name, value)
731
+
732
+
733
+ T_Device = TypeVar('T_Device', bound=Device)
734
+ class ContextDevice(Generic[T_Device], Device):
735
+ def __init__(self, device: T_Device, target_screenshot_interval: float | None = None):
736
+ """
737
+ :param device: 目标设备。
738
+ :param target_screenshot_interval: 见 `ContextDevice.target_screenshot_interval`。
739
+ """
740
+ self._device = device
741
+ self.target_screenshot_interval: float | None = target_screenshot_interval
742
+ """
743
+ 目标截图间隔,可用于限制截图速度。若两次截图实际间隔小于该值,则会自动等待。
744
+ 为 None 时不限制截图速度。
745
+ """
746
+ self._screenshot_interval: Interval | None = None
747
+ if self.target_screenshot_interval is not None:
748
+ self._screenshot_interval = Interval(self.target_screenshot_interval)
749
+
750
+ def screenshot(self, *, force: bool = False):
751
+ """
752
+ 截图。返回截图数据,同时更新当前上下文的截图数据。
753
+ """
754
+ check_flow_control()
755
+ global next_wait, last_screenshot_time, next_wait_time
756
+ current = ContextStackVars.ensure_current()
757
+ if force:
758
+ current._inherit_screenshot = None
759
+ if current._inherit_screenshot is not None:
760
+ img = current._inherit_screenshot
761
+ current._inherit_screenshot = None
762
+ else:
763
+ if self._screenshot_interval is not None:
764
+ self._screenshot_interval.wait()
765
+
766
+ if next_wait == 'screenshot':
767
+ delta = time.time() - last_screenshot_time
768
+ if delta < next_wait_time:
769
+ sleep(next_wait_time - delta)
770
+ last_screenshot_time = time.time()
771
+ next_wait_time = 0
772
+ next_wait = None
773
+ img = self._device.screenshot()
774
+ current._screenshot = img
775
+ return img
776
+
777
+ def __getattribute__(self, name: str):
778
+ if name in ['_device', 'screenshot', 'of_android', 'of_windows']:
779
+ return object.__getattribute__(self, name)
780
+ else:
781
+ return getattr(self._device, name)
782
+
783
+ def __setattr__(self, name: str, value: Any):
784
+ if name in ['_device', 'screenshot', 'of_android', 'of_windows']:
785
+ return object.__setattr__(self, name, value)
786
+ else:
787
+ return setattr(self._device, name, value)
788
+
789
+ def of_android(self) -> 'ContextDevice | AndroidDevice':
790
+ """
791
+ 确保此 ContextDevice 底层为 Android 平台。
792
+ 同时通过返回的对象可以调用 Android 平台特有的方法。
793
+ """
794
+ if not isinstance(self._device, AndroidDevice):
795
+ raise ValueError("Device is not AndroidDevice")
796
+ return self
797
+
798
+ def of_windows(self) -> 'ContextDevice | WindowsDevice':
799
+ """
800
+ 确保此 ContextDevice 底层为 Windows 平台。
801
+ 同时通过返回的对象可以调用 Windows 平台特有的方法。
802
+ """
803
+ if not isinstance(self._device, WindowsDevice):
804
+ raise ValueError("Device is not WindowsDevice")
805
+ return self
806
+
807
+ class Context(Generic[T]):
808
+ def __init__(
809
+ self,
810
+ config_path: str,
811
+ config_type: Type[T],
812
+ device: Device,
813
+ target_screenshot_interval: float | None = None
814
+ ):
815
+ self.__ocr = ContextOcr(self)
816
+ self.__image = ContextImage(self)
817
+ self.__color = ContextColor(self)
818
+ self.__vars = ContextGlobalVars()
819
+ self.__debug = ContextDebug(self)
820
+ self.__config = ContextConfig[T](self, config_path, config_type)
821
+ self.__device = ContextDevice(device, target_screenshot_interval)
822
+
823
+ def inject(
824
+ self,
825
+ *,
826
+ device: Optional[ContextDevice | Device] = None,
827
+ ocr: Optional[ContextOcr] = None,
828
+ image: Optional[ContextImage] = None,
829
+ color: Optional[ContextColor] = None,
830
+ vars: Optional[ContextGlobalVars] = None,
831
+ debug: Optional[ContextDebug] = None,
832
+ config: Optional[ContextConfig] = None,
833
+ ):
834
+ if device is not None:
835
+ if isinstance(device, Device):
836
+ self.__device = ContextDevice(device)
837
+ else:
838
+ self.__device = device
839
+ if ocr is not None:
840
+ self.__ocr = ocr
841
+ if image is not None:
842
+ self.__image = image
843
+ if color is not None:
844
+ self.__color = color
845
+ if vars is not None:
846
+ self.__vars = vars
847
+ if debug is not None:
848
+ self.__debug = debug
849
+ if config is not None:
850
+ self.__config = config
851
+
852
+ @property
853
+ def device(self) -> ContextDevice:
854
+ return self.__device
855
+
856
+ @property
857
+ def ocr(self) -> 'ContextOcr':
858
+ return self.__ocr
859
+
860
+ @property
861
+ def image(self) -> 'ContextImage':
862
+ return self.__image
863
+
864
+ @property
865
+ def color(self) -> 'ContextColor':
866
+ return self.__color
867
+
868
+ @property
869
+ def vars(self) -> 'ContextGlobalVars':
870
+ return self.__vars
871
+
872
+ @property
873
+ def debug(self) -> 'ContextDebug':
874
+ return self.__debug
875
+
876
+ @property
877
+ def config(self) -> 'ContextConfig[T]':
878
+ return self.__config
879
+
880
+ @deprecated('使用 Rect 类的实例方法代替')
881
+ def rect_expand(rect: Rect, left: int = 0, top: int = 0, right: int = 0, bottom: int = 0) -> Rect:
882
+ """
883
+ 向四个方向扩展矩形区域。
884
+ """
885
+ return Rect(rect.x1 - left, rect.y1 - top, rect.w + right + left, rect.h + bottom + top)
886
+
887
+ def use_screenshot(*args: MatLike | None) -> MatLike:
888
+ for img in args:
889
+ if img is not None:
890
+ ContextStackVars.ensure_current()._screenshot = img # HACK
891
+ return img
892
+ return device.screenshot()
893
+
894
+ WaitBeforeType = Literal['screenshot']
895
+ @deprecated('使用普通 sleep 代替')
896
+ def wait(at_least: float = 0.3, *, before: WaitBeforeType) -> None:
897
+ global next_wait, next_wait_time
898
+ if before == 'screenshot':
899
+ if time.time() - last_screenshot_time < at_least:
900
+ next_wait = 'screenshot'
901
+ next_wait_time = at_least
902
+
903
+
904
+ # 这里 Context 类还没有初始化,但是 tasks 中的脚本可能已经引用了这里的变量
905
+ # 为了能够动态更新这里变量的值,这里使用 Forwarded 类再封装一层,
906
+ # 将调用转发到实际的稍后初始化的 Context 类上
907
+ _c: Context | None = None
908
+ device: ContextDevice = cast(ContextDevice, Forwarded(name="device"))
909
+ """当前正在执行任务的设备。"""
910
+ ocr: ContextOcr = cast(ContextOcr, Forwarded(name="ocr"))
911
+ """OCR 引擎。"""
912
+ image: ContextImage = cast(ContextImage, Forwarded(name="image"))
913
+ """图像识别。"""
914
+ color: ContextColor = cast(ContextColor, Forwarded(name="color"))
915
+ """颜色识别。"""
916
+ vars: ContextGlobalVars = cast(ContextGlobalVars, Forwarded(name="vars"))
917
+ """全局变量。"""
918
+ debug: ContextDebug = cast(ContextDebug, Forwarded(name="debug"))
919
+ """调试工具。"""
920
+ config: ContextConfig = cast(ContextConfig, Forwarded(name="config"))
921
+ """配置数据。"""
922
+ last_screenshot_time: float = -1
923
+ """上一次截图的时间。"""
924
+ next_wait: WaitBeforeType | None = None
925
+ next_wait_time: float = 0
926
+
927
+ def init_context(
928
+ *,
929
+ config_path: str = 'config.json',
930
+ config_type: Type[T] = dict[str, Any],
931
+ force: bool = False,
932
+ target_device: Device,
933
+ target_screenshot_interval: float | None = None,
934
+ ):
935
+ """
936
+ 初始化 Context 模块。
937
+
938
+ :param config_path: 配置文件路径。
939
+ :param config_type: 配置数据类类型。
940
+ 配置数据类必须继承自 pydantic 的 `BaseModel`。
941
+ 默认为 `dict[str, Any]`,即普通的 JSON 数据,不包含任何类型信息。
942
+ :param force: 是否强制重新初始化。
943
+ 若为 `True`,则忽略已存在的 Context 实例,并重新创建一个新的实例。
944
+ :param target_device: 目标设备
945
+ :param target_screenshot_interval: 见 `ContextDevice.target_screenshot_interval`。
946
+ """
947
+ global _c, device, ocr, image, color, vars, debug, config
948
+ if _c is not None and not force:
949
+ return
950
+ _c = Context(
951
+ config_path=config_path,
952
+ config_type=config_type,
953
+ device=target_device,
954
+ target_screenshot_interval=target_screenshot_interval,
955
+ )
956
+ device._FORWARD_getter = lambda: _c.device # type: ignore
957
+ ocr._FORWARD_getter = lambda: _c.ocr # type: ignore
958
+ image._FORWARD_getter = lambda: _c.image # type: ignore
959
+ color._FORWARD_getter = lambda: _c.color # type: ignore
960
+ vars._FORWARD_getter = lambda: _c.vars # type: ignore
961
+ debug._FORWARD_getter = lambda: _c.debug # type: ignore
962
+ config._FORWARD_getter = lambda: _c.config # type: ignore
963
+
964
+
965
+ def inject_context(
966
+ *,
967
+ device: Optional[ContextDevice | Device] = None,
968
+ ocr: Optional[ContextOcr] = None,
969
+ image: Optional[ContextImage] = None,
970
+ color: Optional[ContextColor] = None,
971
+ vars: Optional[ContextGlobalVars] = None,
972
+ debug: Optional[ContextDebug] = None,
973
+ config: Optional[ContextConfig] = None,
974
+ ):
975
+ global _c
976
+ if _c is None:
977
+ raise ContextNotInitializedError('Context not initialized')
978
+ _c.inject(device=device, ocr=ocr, image=image, color=color, vars=vars, debug=debug, config=config)
979
+
980
+ class ManualContextManager:
981
+ def __init__(self, screenshot_mode: ScreenshotMode = 'auto'):
982
+ self.screenshot_mode: ScreenshotMode = screenshot_mode
983
+
984
+ def __enter__(self):
985
+ ContextStackVars.push(screenshot_mode=self.screenshot_mode)
986
+
987
+ def __exit__(self, exc_type, exc_value, traceback):
988
+ ContextStackVars.pop()
989
+
990
+ def begin(self):
991
+ self.__enter__()
992
+
993
+ def end(self):
994
+ self.__exit__(None, None, None)
995
+
996
+ def manual_context(screenshot_mode: ScreenshotMode = 'auto') -> ManualContextManager:
997
+ """
998
+ 默认情况下,Context* 类仅允许在 @task/@action 函数中使用。
999
+ 如果想要在其他地方使用,使用此函数手动创建一个上下文。
1000
+ """
1001
+ return ManualContextManager(screenshot_mode)