kotones-auto-assistant/kotonebot/backend/bot.py

293 lines
11 KiB
Python

import logging
import pkgutil
import importlib
from typing_extensions import Self
from dataclasses import dataclass, field
import threading
import traceback
import os
import zipfile
import cv2
from datetime import datetime
import io
from typing import Any, Literal, Callable, Generic, TypeVar, ParamSpec
from kotonebot.backend.context import Task, Action
from kotonebot.backend.context import init_context, vars
from kotonebot.backend.context import task_registry, action_registry, current_callstack, Task, Action
from kotonebot.client.host.protocol import Instance
from kotonebot.ui import user
log_stream = io.StringIO()
stream_handler = logging.StreamHandler(log_stream)
stream_handler.setFormatter(logging.Formatter('[%(asctime)s] [%(levelname)s] [%(name)s] [%(filename)s:%(lineno)d] - %(message)s'))
logging.getLogger('kotonebot').addHandler(stream_handler)
logger = logging.getLogger(__name__)
@dataclass
class TaskStatus:
task: Task
status: Literal['pending', 'running', 'finished', 'error', 'cancelled']
@dataclass
class RunStatus:
running: bool = False
tasks: list[TaskStatus] = field(default_factory=list)
current_task: Task | None = None
callstack: list[Task | Action] = field(default_factory=list)
def interrupt(self):
vars.interrupted.set()
def _save_error_report(
exception: Exception,
*,
path: str | None = None
) -> str:
"""
保存错误报告
:param path: 保存的路径。若为 `None`,则保存到 `./reports/{YY-MM-DD HH-MM-SS}.zip`。
:return: 保存的路径
"""
from kotonebot import device
try:
if path is None:
path = f'./reports/{datetime.now().strftime("%Y-%m-%d %H-%M-%S")}.zip'
exception_msg = '\n'.join(traceback.format_exception(exception))
task_callstack = '\n'.join([f'{i+1}. name={task.name} priority={task.priority}' for i, task in enumerate(current_callstack)])
screenshot = device.screenshot()
logs = log_stream.getvalue()
with open('config.json', 'r', encoding='utf-8') as f:
config_content = f.read()
if not os.path.exists(os.path.dirname(path)):
os.makedirs(os.path.dirname(path))
with zipfile.ZipFile(path, 'w') as zipf:
zipf.writestr('exception.txt', exception_msg)
zipf.writestr('task_callstack.txt', task_callstack)
zipf.writestr('screenshot.png', cv2.imencode('.png', screenshot)[1].tobytes())
zipf.writestr('config.json', config_content)
zipf.writestr('logs.txt', logs)
return path
except Exception as e:
logger.exception(f'Failed to save error report:')
return ''
# Modified from https://stackoverflow.com/questions/70982565/how-do-i-make-an-event-listener-with-decorators-in-python
Params = ParamSpec('Params')
Return = TypeVar('Return')
class Event(Generic[Params, Return]):
def __init__(self):
self.__listeners = []
@property
def on(self):
def wrapper(func: Callable[Params, Return]):
self.add_listener(func)
return func
return wrapper
def add_listener(self, func: Callable[Params, Return]) -> None:
if func in self.__listeners:
return
self.__listeners.append(func)
def remove_listener(self, func: Callable[Params, Return]) -> None:
if func not in self.__listeners:
return
self.__listeners.remove(func)
def __iadd__(self, func: Callable[Params, Return]) -> Self:
self.add_listener(func)
return self
def __isub__(self, func: Callable[Params, Return]) -> Self:
self.remove_listener(func)
return self
def trigger(self, *args: Params.args, **kwargs: Params.kwargs) -> None:
for func in self.__listeners:
func(*args, **kwargs)
class KotoneBotEvents:
def __init__(self):
self.task_status_changed = Event[
[Task, Literal['pending', 'running', 'finished', 'error', 'cancelled']], None
]()
self.task_error = Event[
[Task, Exception], None
]()
self.finished = Event[[], None]()
class KotoneBot:
def __init__(
self,
module: str,
config_type: type = dict[str, Any],
*,
debug: bool = False,
resume_on_error: bool = False,
auto_save_error_report: bool = True,
):
"""
初始化 KotoneBot。
:param module: 主模块名。此模块及其所有子模块都会被载入。
:param config_type: 配置类型。
:param debug: 调试模式。
:param resume_on_error: 在错误时是否恢复。
:param auto_save_error_report: 是否自动保存错误报告。
"""
self.module = module
self.config_type = config_type
# HACK: 硬编码
self.current_config: int | str = 0
self.debug = debug
self.resume_on_error = resume_on_error
self.auto_save_error_report = auto_save_error_report
self.events = KotoneBotEvents()
self.backend_instance: Instance | None = None
def initialize(self):
"""
初始化并载入所有任务和动作。
"""
logger.info('Initializing tasks and actions...')
logger.debug(f'Loading module: {self.module}')
# 加载主模块
importlib.import_module(self.module)
# 加载所有子模块
pkg = importlib.import_module(self.module)
for loader, name, is_pkg in pkgutil.walk_packages(pkg.__path__, pkg.__name__ + '.'):
logger.debug(f'Loading sub-module: {name}')
try:
importlib.import_module(name)
except Exception as e:
logger.error(f'Failed to load sub-module: {name}')
logger.exception(f'Error: ')
logger.info('Tasks and actions initialized.')
logger.info(f'{len(task_registry)} task(s) and {len(action_registry)} action(s) loaded.')
def check_backend(self):
from kotonebot.client.host import create_custom
from kotonebot.config.manager import load_config
# HACK: 硬编码
config = load_config('config.json', type=self.config_type)
config = config.user_configs[0]
logger.info('Checking backend...')
if config.backend.type == 'custom' and config.backend.check_emulator:
exe = config.backend.emulator_path
if exe is None:
user.error('「检查并启动模拟器」已开启但未配置「模拟器 exe 文件路径」。')
raise ValueError('Emulator executable path is not set.')
if not os.path.exists(exe):
user.error('「模拟器 exe 文件路径」对应的文件不存在!请检查路径是否正确。')
raise FileNotFoundError(f'Emulator executable not found: {exe}')
self.backend_instance = create_custom(
adb_ip=config.backend.adb_ip,
adb_port=config.backend.adb_port,
adb_emulator_name=config.backend.adb_emulator_name,
exe_path=exe
)
if not self.backend_instance.running():
logger.info('Starting custom backend...')
self.backend_instance.start()
logger.info('Waiting for custom backend to be available...')
self.backend_instance.wait_available()
else:
logger.info('Custom backend "%s" already running.', self.backend_instance)
def run(self, tasks: list[Task], *, by_priority: bool = True):
"""
按优先级顺序运行所有任务。
"""
self.check_backend()
init_context(config_type=self.config_type)
vars.interrupted.clear()
if by_priority:
tasks = sorted(tasks, key=lambda x: x.priority, reverse=True)
for task in tasks:
self.events.task_status_changed.trigger(task, 'pending')
for task in tasks:
logger.info(f'Task started: {task.name}')
self.events.task_status_changed.trigger(task, 'running')
if self.debug:
task.func()
else:
try:
task.func()
self.events.task_status_changed.trigger(task, 'finished')
# 用户中止
except KeyboardInterrupt as e:
logger.exception('Keyboard interrupt detected.')
for task1 in tasks[tasks.index(task):]:
self.events.task_status_changed.trigger(task1, 'cancelled')
vars.interrupted.clear()
break
# 其他错误
except Exception as e:
logger.error(f'Task failed: {task.name}')
logger.exception(f'Error: ')
report_path = None
if self.auto_save_error_report:
report_path = _save_error_report(e)
self.events.task_status_changed.trigger(task, 'error')
if not self.resume_on_error:
for task1 in tasks[tasks.index(task)+1:]:
self.events.task_status_changed.trigger(task1, 'cancelled')
break
logger.info(f'Task finished: {task.name}')
logger.info('All tasks finished.')
self.events.finished.trigger()
def run_all(self) -> None:
return self.run(list(task_registry.values()), by_priority=True)
def start(self, tasks: list[Task], *, by_priority: bool = True) -> RunStatus:
"""
在单独的线程中按优先级顺序运行指定的任务。
:param tasks: 要运行的任务列表
:param by_priority: 是否按优先级排序
:return: 运行状态对象
"""
run_status = RunStatus(running=True)
def _on_finished():
run_status.running = False
run_status.current_task = None
run_status.callstack = []
self.events.finished -= _on_finished
self.events.task_status_changed -= _on_task_status_changed
def _on_task_status_changed(task: Task, status: Literal['pending', 'running', 'finished', 'error', 'cancelled']):
def _find(task: Task) -> TaskStatus:
for task_status in run_status.tasks:
if task_status.task == task:
return task_status
raise ValueError(f'Task {task.name} not found in run_status.tasks')
if status == 'pending':
run_status.tasks.append(TaskStatus(task=task, status='pending'))
else:
_find(task).status = status
self.events.task_status_changed += _on_task_status_changed
self.events.finished += _on_finished
thread = threading.Thread(target=lambda: self.run(tasks, by_priority=by_priority))
thread.start()
return run_status
def start_all(self) -> RunStatus:
"""
在单独的线程中运行所有任务。
:return: 运行状态对象
"""
return self.start(list(task_registry.values()), by_priority=True)