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,176 @@
1
+ import logging
2
+ from typing import Callable, ParamSpec, TypeVar, overload, Literal
3
+ from dataclasses import dataclass
4
+
5
+
6
+ from .context import ContextStackVars, ScreenshotMode
7
+ from ...errors import TaskNotFoundError
8
+
9
+ P = ParamSpec('P')
10
+ R = TypeVar('R')
11
+ logger = logging.getLogger(__name__)
12
+
13
+ TaskRunAtType = Literal['pre', 'post', 'manual', 'regular'] | str
14
+
15
+
16
+ @dataclass
17
+ class Task:
18
+ name: str
19
+ id: str
20
+ description: str
21
+ func: Callable
22
+ priority: int
23
+ """
24
+ 任务优先级,数字越大优先级越高。
25
+ """
26
+ run_at: TaskRunAtType = 'regular'
27
+
28
+
29
+ @dataclass
30
+ class Action:
31
+ name: str
32
+ description: str
33
+ func: Callable
34
+ priority: int
35
+ """
36
+ 动作优先级,数字越大优先级越高。
37
+ """
38
+
39
+
40
+ task_registry: dict[str, Task] = {}
41
+ action_registry: dict[str, Action] = {}
42
+ current_callstack: list[Task|Action] = []
43
+
44
+ def _placeholder():
45
+ raise NotImplementedError('Placeholder function')
46
+
47
+ def task(
48
+ name: str,
49
+ task_id: str|None = None,
50
+ description: str|None = None,
51
+ *,
52
+ pass_through: bool = False,
53
+ priority: int = 0,
54
+ screenshot_mode: ScreenshotMode = 'auto',
55
+ run_at: TaskRunAtType = 'regular'
56
+ ):
57
+ """
58
+ `task` 装饰器,用于标记一个函数为任务函数。
59
+
60
+ :param name: 任务名称
61
+ :param task_id: 任务 ID。如果为 None,则使用函数名称作为 ID。
62
+ :param description: 任务描述。如果为 None,则使用函数的 docstring 作为描述。
63
+ :param pass_through:
64
+ 默认情况下, @task 装饰器会包裹任务函数,跟踪其执行情况。
65
+ 如果不想跟踪,则设置此参数为 False。
66
+ :param priority: 任务优先级,数字越大优先级越高。
67
+ :param run_at: 任务运行时间。
68
+ """
69
+ # 设置 ID
70
+ # 获取 caller 信息
71
+ def _task_decorator(func: Callable[P, R]) -> Callable[P, R]:
72
+ nonlocal description, task_id
73
+ description = description or func.__doc__ or ''
74
+ # TODO: task_id 冲突检测
75
+ task_id = task_id or func.__name__
76
+ task = Task(name, task_id, description, _placeholder, priority, run_at)
77
+ task_registry[name] = task
78
+ logger.debug(f'Task "{name}" registered.')
79
+ if pass_through:
80
+ return func
81
+ else:
82
+ def _wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
83
+ current_callstack.append(task)
84
+ vars = ContextStackVars.push(screenshot_mode=screenshot_mode)
85
+ ret = func(*args, **kwargs)
86
+ ContextStackVars.pop()
87
+ current_callstack.pop()
88
+ return ret
89
+ task.func = _wrapper
90
+ return _wrapper
91
+ return _task_decorator
92
+
93
+ @overload
94
+ def action(func: Callable[P, R]) -> Callable[P, R]: ...
95
+
96
+ @overload
97
+ def action(
98
+ name: str,
99
+ *,
100
+ description: str|None = None,
101
+ pass_through: bool = False,
102
+ priority: int = 0,
103
+ screenshot_mode: ScreenshotMode | None = None,
104
+ ) -> Callable[[Callable[P, R]], Callable[P, R]]:
105
+ """
106
+ `action` 装饰器,用于标记一个函数为动作函数。
107
+
108
+ :param name: 动作名称。如果为 None,则使用函数的名称作为名称。
109
+ :param description: 动作描述。如果为 None,则使用函数的 docstring 作为描述。
110
+ :param pass_through:
111
+ 默认情况下, @action 装饰器会包裹动作函数,跟踪其执行情况。
112
+ 如果不想跟踪,则设置此参数为 False。
113
+ :param priority: 动作优先级,数字越大优先级越高。
114
+ :param screenshot_mode: 截图模式。
115
+ """
116
+ ...
117
+
118
+ # TODO: 需要找个地方统一管理这些属性名
119
+ ATTR_ORIGINAL_FUNC = '_kb_inner'
120
+ ATTR_ACTION_MARK = '__kb_action_mark'
121
+ def action(*args, **kwargs):
122
+ def _register(func: Callable, name: str, description: str|None = None, priority: int = 0) -> Action:
123
+ description = description or func.__doc__ or ''
124
+ action = Action(name, description, func, priority)
125
+ action_registry[name] = action
126
+ logger.debug(f'Action "{name}" registered.')
127
+ return action
128
+
129
+ if len(args) == 1 and isinstance(args[0], Callable):
130
+ func = args[0]
131
+ action = _register(_placeholder, func.__name__, func.__doc__)
132
+ def _wrapper(*args: P.args, **kwargs: P.kwargs):
133
+ current_callstack.append(action)
134
+ vars = ContextStackVars.push()
135
+ ret = func(*args, **kwargs)
136
+ ContextStackVars.pop()
137
+ current_callstack.pop()
138
+ return ret
139
+ setattr(_wrapper, ATTR_ORIGINAL_FUNC, func)
140
+ setattr(_wrapper, ATTR_ACTION_MARK, True)
141
+ action.func = _wrapper
142
+ return _wrapper
143
+ else:
144
+ name = args[0]
145
+ description = kwargs.get('description', None)
146
+ pass_through = kwargs.get('pass_through', False)
147
+ priority = kwargs.get('priority', 0)
148
+ screenshot_mode = kwargs.get('screenshot_mode', None)
149
+ def _action_decorator(func: Callable):
150
+ nonlocal pass_through
151
+ action = _register(_placeholder, name, description)
152
+ pass_through = kwargs.get('pass_through', False)
153
+ if pass_through:
154
+ return func
155
+ else:
156
+ def _wrapper(*args: P.args, **kwargs: P.kwargs):
157
+ current_callstack.append(action)
158
+ vars = ContextStackVars.push(screenshot_mode=screenshot_mode)
159
+ ret = func(*args, **kwargs)
160
+ ContextStackVars.pop()
161
+ current_callstack.pop()
162
+ return ret
163
+ setattr(_wrapper, ATTR_ORIGINAL_FUNC, func)
164
+ setattr(_wrapper, ATTR_ACTION_MARK, True)
165
+ action.func = _wrapper
166
+ return _wrapper
167
+ return _action_decorator
168
+
169
+ def tasks_from_id(task_ids: list[str]) -> list[Task]:
170
+ result = []
171
+ for tid in task_ids:
172
+ target = next(task for task in task_registry.values() if task.id == tid)
173
+ if target is None:
174
+ raise TaskNotFoundError(f'Task "{tid}" not found.')
175
+ result.append(target)
176
+ return result
@@ -0,0 +1,126 @@
1
+ import logging
2
+ from functools import cache
3
+ from typing import Callable
4
+
5
+ import cv2
6
+ from cv2.typing import MatLike
7
+
8
+ from kotonebot.util import cv2_imread
9
+ from kotonebot.primitives import RectTuple, Rect, Point
10
+ from kotonebot.errors import ResourceFileMissingError
11
+
12
+ class Ocr:
13
+ def __init__(
14
+ self,
15
+ text: str | Callable[[str], bool],
16
+ *,
17
+ language: str = 'jp',
18
+ ):
19
+ self.text = text
20
+ self.language = language
21
+
22
+
23
+ class Image:
24
+ def __init__(
25
+ self,
26
+ *,
27
+ path: str | None = None,
28
+ name: str | None = 'untitled',
29
+ data: MatLike | None = None,
30
+ ):
31
+ self.path = path
32
+ self.name = name
33
+ self.__data: MatLike | None = data
34
+ self.__data_with_alpha: MatLike | None = None
35
+
36
+ @cache
37
+ def binary(self) -> 'Image':
38
+ return Image(data=cv2.cvtColor(self.data, cv2.COLOR_BGR2GRAY))
39
+
40
+ @property
41
+ def data(self) -> MatLike:
42
+ if self.__data is None:
43
+ if self.path is None:
44
+ raise ValueError('Either path or data must be provided.')
45
+ self.__data = cv2_imread(self.path)
46
+ if self.__data is None:
47
+ raise ResourceFileMissingError(self.path, 'sprite')
48
+ logger.debug(f'Read image "{self.name}" from {self.path}')
49
+ return self.__data
50
+
51
+ @property
52
+ def data_with_alpha(self) -> MatLike:
53
+ if self.__data_with_alpha is None:
54
+ if self.path is None:
55
+ raise ValueError('Either path or data must be provided.')
56
+ self.__data_with_alpha = cv2_imread(self.path, cv2.IMREAD_UNCHANGED)
57
+ if self.__data_with_alpha is None:
58
+ raise ResourceFileMissingError(self.path, 'sprite with alpha')
59
+ logger.debug(f'Read image "{self.name}" from {self.path}')
60
+ return self.__data_with_alpha
61
+
62
+ def __repr__(self) -> str:
63
+ if self.path is None:
64
+ return f'<Image: memory>'
65
+ else:
66
+ return f'<Image: "{self.name}" at {self.path}>'
67
+
68
+
69
+ class HintBox(Rect):
70
+ def __init__(
71
+ self,
72
+ x1: int,
73
+ y1: int,
74
+ x2: int,
75
+ y2: int,
76
+ *,
77
+ name: str | None = None,
78
+ description: str | None = None,
79
+ source_resolution: tuple[int, int],
80
+ ):
81
+ super().__init__(x1, y1, x2 - x1, y2 - y1, name=name)
82
+ self.description = description
83
+ self.source_resolution = source_resolution
84
+
85
+ @property
86
+ def width(self) -> int:
87
+ return self.x2 - self.x1
88
+
89
+ @property
90
+ def height(self) -> int:
91
+ return self.y2 - self.y1
92
+
93
+ @property
94
+ def rect(self) -> RectTuple:
95
+ return self.x1, self.y1, self.width, self.height
96
+
97
+ class HintPoint(Point):
98
+ def __init__(self, x: int, y: int, *, name: str | None = None, description: str | None = None):
99
+ super().__init__(x, y, name=name)
100
+ self.description = description
101
+
102
+ def __repr__(self) -> str:
103
+ return f'HintPoint<"{self.name}" at ({self.x}, {self.y})>'
104
+
105
+ def unify_image(image: MatLike | str | Image, transparent: bool = False) -> MatLike:
106
+ if isinstance(image, str):
107
+ if not transparent:
108
+ image = cv2_imread(image)
109
+ else:
110
+ image = cv2_imread(image, cv2.IMREAD_UNCHANGED)
111
+ elif isinstance(image, Image):
112
+ if transparent:
113
+ image = image.data_with_alpha
114
+ else:
115
+ image = image.data
116
+ return image
117
+
118
+ logger = logging.getLogger(__name__)
119
+
120
+
121
+ if __name__ == '__main__':
122
+ hint_box = HintBox(100, 100, 200, 200, source_resolution=(1920, 1080))
123
+ print(hint_box.rect)
124
+ print(hint_box.width)
125
+ print(hint_box.height)
126
+
@@ -0,0 +1 @@
1
+ from .vars import result, debug, img, color
@@ -0,0 +1,89 @@
1
+ import os
2
+ import runpy
3
+ import shutil
4
+ import argparse
5
+ import importlib
6
+ from pathlib import Path
7
+ from threading import Thread
8
+
9
+ from . import debug
10
+ from kotonebot import logging
11
+ from kotonebot.backend.context import init_context
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+ def _task_thread(task_module: str):
16
+ """任务线程。"""
17
+ runpy.run_module(task_module, run_name="__main__")
18
+
19
+ def _parse_args():
20
+ """解析命令行参数。"""
21
+ parser = argparse.ArgumentParser(description='KotoneBot visual debug tool')
22
+ parser.add_argument(
23
+ '-s', '--save',
24
+ help='Save dump image and results to the specified folder',
25
+ type=str,
26
+ metavar='PATH'
27
+ )
28
+ parser.add_argument(
29
+ '-c', '--clear',
30
+ help='Clear the dump folder before running',
31
+ action='store_true'
32
+ )
33
+ parser.add_argument(
34
+ '-t', '--config-type',
35
+ help='The full path of the config data type. e.g. `kotonebot.tasks.common.BaseConfig`',
36
+ type=str,
37
+ metavar='TYPE',
38
+ required=True
39
+ )
40
+ parser.add_argument(
41
+ 'input_module',
42
+ help='The module to run'
43
+ )
44
+ return parser.parse_args()
45
+
46
+ def _start_task_thread(module: str):
47
+ """启动任务线程。"""
48
+ thread = Thread(target=_task_thread, args=(module,))
49
+ thread.start()
50
+
51
+ if __name__ == "__main__":
52
+ args = _parse_args()
53
+ debug.enabled = True
54
+
55
+ # 设置保存路径
56
+ if args.save:
57
+ save_path = Path(args.save)
58
+ debug.auto_save_to_folder = str(save_path)
59
+ if not os.path.exists(save_path):
60
+ os.makedirs(save_path)
61
+ if args.clear:
62
+ if debug.auto_save_to_folder:
63
+ try:
64
+ logger.info(f"Removing {debug.auto_save_to_folder}")
65
+ shutil.rmtree(debug.auto_save_to_folder)
66
+ except PermissionError:
67
+ logger.warning(f"Failed to remove {debug.auto_save_to_folder}. Trying to remove all contents instead.")
68
+ for root, dirs, files in os.walk(debug.auto_save_to_folder):
69
+ for file in files:
70
+ try:
71
+ os.remove(os.path.join(root, file))
72
+ except PermissionError:
73
+ raise
74
+
75
+
76
+ # 初始化上下文
77
+ module_name, class_name = args.config_type.rsplit('.', 1)
78
+ class_ = importlib.import_module(module_name).__getattribute__(class_name)
79
+ init_context(config_type=class_)
80
+
81
+ # 启动服务器
82
+ from .server import app
83
+ import uvicorn
84
+
85
+ # 启动任务线程
86
+ _start_task_thread(args.input_module)
87
+
88
+ # 启动服务器
89
+ uvicorn.run(app, host="127.0.0.1", port=8000, log_level='critical' if debug.hide_server_log else None)
@@ -0,0 +1,79 @@
1
+ import time
2
+ from typing_extensions import override
3
+
4
+ import cv2
5
+ from cv2.typing import MatLike
6
+
7
+ from kotonebot import sleep
8
+ from kotonebot.client.device import Device
9
+
10
+ class Video:
11
+ def __init__(self, path: str, fps: int):
12
+ self.path = path
13
+ self.fps = fps
14
+ self.paused = False
15
+ """是否暂停"""
16
+ self.__cap = cv2.VideoCapture(path)
17
+ self.__last_frame = None
18
+ self.__last_time = 0
19
+
20
+ def __iter__(self):
21
+ return self
22
+
23
+ def __next__(self):
24
+ if self.paused:
25
+ return self.__last_frame
26
+ ret, frame = self.__cap.read()
27
+ if not ret:
28
+ raise StopIteration
29
+ self.__last_frame = frame
30
+ self.__last_time = time.time()
31
+ if self.__last_time - time.time() < 1 / self.fps:
32
+ sleep(1 / self.fps)
33
+ return frame
34
+
35
+ def pause(self):
36
+ self.paused = True
37
+
38
+ def resume(self):
39
+ self.paused = False
40
+
41
+ class MockDevice(Device):
42
+ def __init__(
43
+ self
44
+ ):
45
+ super().__init__()
46
+ self.__video_stream = None
47
+ self.__image = None
48
+ self.__screen_size = None
49
+
50
+ def load_video(self, path: str, fps: int):
51
+ self.__video_stream = Video(path, fps)
52
+ return self.__video_stream
53
+
54
+ def load_image(self, img: str | MatLike):
55
+ if isinstance(img, str):
56
+ self.__image = cv2.imread(img)
57
+ else:
58
+ self.__image = img
59
+ return self.__image
60
+
61
+ def set_screen_size(self, width: int, height: int):
62
+ self.__screen_size = (width, height)
63
+
64
+ @override
65
+ def screenshot(self):
66
+ if self.__image is not None:
67
+ return self.__image
68
+ elif self.__video_stream is not None:
69
+ return next(self.__video_stream)
70
+ else:
71
+ raise RuntimeError('No video stream loaded')
72
+
73
+ @property
74
+ @override
75
+ def screen_size(self):
76
+ if self.__screen_size is not None:
77
+ return self.__screen_size
78
+ else:
79
+ raise RuntimeError('No screen size set')
@@ -0,0 +1,223 @@
1
+ import time
2
+ import asyncio
3
+ import inspect
4
+ import threading
5
+ import traceback
6
+ import subprocess
7
+ from io import StringIO
8
+ from pathlib import Path
9
+ from typing import Literal
10
+ from collections import deque
11
+ from contextlib import redirect_stdout
12
+
13
+ import cv2
14
+ import uvicorn
15
+ from thefuzz import fuzz
16
+ from pydantic import BaseModel
17
+ from fastapi.responses import FileResponse, Response
18
+ from fastapi import FastAPI, WebSocket, HTTPException
19
+ from fastapi.middleware.cors import CORSMiddleware
20
+
21
+ import kotonebot
22
+ import kotonebot.backend
23
+ import kotonebot.backend.context
24
+ from kotonebot.backend.core import HintBox, Image
25
+ from ..context import manual_context
26
+ from . import vars as debug_vars
27
+ from .vars import WSImage, WSMessageData, WSMessage, WSCallstack
28
+
29
+ app = FastAPI()
30
+ app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"])
31
+
32
+ # 获取当前文件夹路径
33
+ CURRENT_DIR = Path(__file__).parent
34
+
35
+ APP_DIR = Path.cwd()
36
+
37
+ class File(BaseModel):
38
+ name: str
39
+ full_path: str
40
+ type: Literal["file", "dir"]
41
+
42
+ @app.get("/api/read_file")
43
+ async def read_file(path: str):
44
+ """读取文件内容"""
45
+ try:
46
+ # 确保路径在当前目录下
47
+ full_path = (APP_DIR / path).resolve()
48
+ if not Path(full_path).is_relative_to(APP_DIR):
49
+ raise HTTPException(status_code=403, detail="Access denied")
50
+
51
+ if not full_path.exists():
52
+ raise HTTPException(status_code=404, detail="File not found")
53
+ # 添加缓存控制头
54
+ headers = {
55
+ "Cache-Control": "public, max-age=3600", # 缓存1小时
56
+ "ETag": f'"{hash(full_path)}"' # 使用full_path的哈希值作为ETag
57
+ }
58
+ return FileResponse(full_path, headers=headers)
59
+ except Exception as e:
60
+ raise HTTPException(status_code=500, detail=str(e))
61
+
62
+ @app.get("/api/read_memory")
63
+ async def read_memory(key: str):
64
+ """读取内存中的数据"""
65
+ try:
66
+ image = None
67
+ if (image := debug_vars._read_image(key)) is not None:
68
+ pass
69
+ else:
70
+ raise HTTPException(status_code=404, detail="Key not found")
71
+
72
+ # 编码图片
73
+ encode_params = [cv2.IMWRITE_PNG_COMPRESSION, 4]
74
+ _, buffer = cv2.imencode('.png', image, encode_params)
75
+ # 添加缓存控制头
76
+ headers = {
77
+ "Cache-Control": "public, max-age=3600", # 缓存1小时
78
+ "ETag": f'"{hash(key)}"' # 使用key的哈希值作为ETag
79
+ }
80
+ return Response(
81
+ buffer.tobytes(),
82
+ media_type="image/jpeg",
83
+ headers=headers
84
+ )
85
+ except Exception as e:
86
+ raise HTTPException(status_code=500, detail=str(e))
87
+
88
+ @app.get("/api/screenshot")
89
+ def screenshot():
90
+ from ..context import device
91
+ img = device.screenshot()
92
+ buff = cv2.imencode('.png', img)[1].tobytes()
93
+ return Response(buff, media_type="image/png")
94
+
95
+ class RunCodeRequest(BaseModel):
96
+ code: str
97
+
98
+ @app.post("/api/code/run")
99
+ async def run_code(request: RunCodeRequest):
100
+ event = asyncio.Event()
101
+ stdout = StringIO()
102
+ code = f"from kotonebot import *\n" + request.code
103
+ result = {}
104
+ def _runner():
105
+ nonlocal result
106
+ from kotonebot.backend.context import vars as context_vars
107
+ try:
108
+ with manual_context():
109
+ global_vars = dict(vars(kotonebot.backend.context))
110
+ with redirect_stdout(stdout):
111
+ exec(code, global_vars)
112
+ result = {"status": "ok", "result": stdout.getvalue()}
113
+ except (Exception) as e:
114
+ result = {"status": "error", "result": stdout.getvalue(), "message": str(e), "traceback": traceback.format_exc()}
115
+ except KeyboardInterrupt as e:
116
+ result = {"status": "error", "result": stdout.getvalue(), "message": str(e), "traceback": traceback.format_exc()}
117
+ finally:
118
+ context_vars.flow.clear_interrupt()
119
+ event.set()
120
+ threading.Thread(target=_runner, daemon=True).start()
121
+ await event.wait()
122
+ return result
123
+
124
+ @app.get("/api/code/stop")
125
+ async def stop_code():
126
+ from kotonebot.backend.context import vars
127
+ vars.flow.request_interrupt()
128
+ while vars.flow.is_interrupted:
129
+ await asyncio.sleep(0.1)
130
+ return {"status": "ok"}
131
+
132
+ @app.get("/api/fs/list_dir")
133
+ def list_dir(path: str) -> list[File]:
134
+ result = []
135
+ for item in Path(path).iterdir():
136
+ result.append(File(
137
+ name=item.name,
138
+ full_path=str(item),
139
+ type="file" if item.is_file() else "dir"
140
+ ))
141
+ return result
142
+
143
+ @app.get("/api/resources/autocomplete")
144
+ def autocomplete(class_path: str) -> list[str]:
145
+ from kotonebot.kaa.tasks import R
146
+ class_names = class_path.split(".")[:-1]
147
+ target_class = R
148
+ # 定位到目标类
149
+ for name in class_names:
150
+ target_class = getattr(target_class, name, None)
151
+ if target_class is None:
152
+ return []
153
+ # 获取目标类的所有属性
154
+ attrs = [attr for attr in dir(target_class) if not attr.startswith("_")]
155
+ filtered_attrs = []
156
+ for attr in attrs:
157
+ if inspect.isclass(getattr(target_class, attr)):
158
+ filtered_attrs.append(attr)
159
+ elif isinstance(getattr(target_class, attr), (Image, HintBox)):
160
+ filtered_attrs.append(attr)
161
+ attrs = filtered_attrs
162
+ # 排序
163
+ attrs.sort(key=lambda x: fuzz.ratio(x, class_path), reverse=True)
164
+ return attrs
165
+
166
+ @app.get("/api/ping")
167
+ async def ping():
168
+ return {"status": "ok"}
169
+
170
+ message_queue = deque()
171
+ @app.websocket("/ws")
172
+ async def websocket_endpoint(websocket: WebSocket):
173
+ await websocket.accept()
174
+ try:
175
+ while True:
176
+ if len(message_queue) > 0:
177
+ message = message_queue.popleft()
178
+ await websocket.send_json(message)
179
+ await asyncio.sleep(0.1)
180
+ except:
181
+ await websocket.close()
182
+
183
+ def send_ws_message(title: str, image: list[str], text: str = '', callstack: list[WSCallstack] = [], wait: bool = False):
184
+ """发送 WebSocket 消息"""
185
+ message = WSMessage(
186
+ type="visual",
187
+ data=WSMessageData(
188
+ image=WSImage(type="memory", value=image),
189
+ name=title,
190
+ details=text,
191
+ timestamp=int(time.time() * 1000),
192
+ callstack=callstack
193
+ )
194
+ )
195
+ message_queue.append(message.dict())
196
+ if wait:
197
+ while len(message_queue) > 0:
198
+ time.sleep(0.3)
199
+
200
+
201
+ thread = None
202
+ def start_server():
203
+ global thread
204
+ def run_server():
205
+ uvicorn.run(app, host="127.0.0.1", port=8000, log_level='critical' if debug_vars.debug.hide_server_log else None)
206
+ if thread is None:
207
+ thread = threading.Thread(target=run_server, daemon=True)
208
+ thread.start()
209
+
210
+ def wait_message_all_done():
211
+ global thread
212
+ def _wait():
213
+ while len(message_queue) > 0:
214
+ time.sleep(0.1)
215
+ if thread is not None:
216
+ threading.Thread(target=_wait, daemon=True).start()
217
+
218
+ if __name__ == "__main__":
219
+ debug_vars.debug.hide_server_log = False
220
+ process = subprocess.Popen(["pylsp", "--port", "5479", "--ws"])
221
+ print("LSP started. PID=", process.pid)
222
+ uvicorn.run(app, host="127.0.0.1", port=8000, log_level='critical' if debug_vars.debug.hide_server_log else None)
223
+ process.kill()