diff --git a/astrbot/cli/__init__.py b/astrbot/cli/__init__.py new file mode 100644 index 000000000..37f46012c --- /dev/null +++ b/astrbot/cli/__init__.py @@ -0,0 +1 @@ +__version__ = "3.5.6" diff --git a/astrbot/cli/__main__.py b/astrbot/cli/__main__.py index 580ad2e1c..c8181247e 100644 --- a/astrbot/cli/__main__.py +++ b/astrbot/cli/__main__.py @@ -1,11 +1,11 @@ -import asyncio -import os -import shutil -import sys -import click -from pathlib import Path -from astrbot.core.config.default import VERSION +""" +AstrBot CLI入口 +""" +import click +import sys +from . import __version__ +from .commands import init, run, plug logo_tmpl = r""" ___ _______.___________..______ .______ ______ .___________. @@ -14,210 +14,25 @@ logo_tmpl = r""" / /_\ \ \ \ | | | / | _ < | | | | | | / _____ \ .----) | | | | |\ \----.| |_) | | `--' | | | /__/ \__\ |_______/ |__| | _| `._____||______/ \______/ |__| - """ -# utils -def _get_astrbot_root(path: str | None) -> Path: - """获取astrbot根目录""" - match path: - case None: - match ASTRBOT_ROOT := os.getenv("ASTRBOT_ROOT"): - case None: - astrbot_root = Path.cwd() / "data" - case _: - astrbot_root = Path(ASTRBOT_ROOT).resolve() - case str(): - astrbot_root = Path(path).resolve() - - dot_astrbot = astrbot_root / ".astrbot" - if not dot_astrbot.exists(): - if click.confirm( - f"运行前必须先执行初始化!请检查当前目录是否正确,回车以继续: {astrbot_root}", - default=True, - abort=True, - ): - dot_astrbot.touch() - astrbot_root.mkdir(parents=True, exist_ok=True) - click.echo(f"Created {dot_astrbot}") - - return astrbot_root - - -# 通过类型来验证先后,必须先获取 Path 对象才能对该目录进行检查 -def _check_astrbot_root(astrbot_root: Path) -> None: - """验证""" - dot_astrbot = astrbot_root / ".astrbot" - if not astrbot_root.exists(): - click.echo(f"AstrBot root directory does not exist: {astrbot_root}") - click.echo("Please run 'astrbot init' to create the directory.") - sys.exit(1) - else: - click.echo(f"AstrBot root directory exists: {astrbot_root}") - if not dot_astrbot.exists(): - click.echo( - "如果你确认这是 Astrbot root directory, 你需要在当前目录下创建一个 .astrbot 文件标记该目录为 AstrBot 的数据目录。" - ) - if click.confirm( - f"请检查当前目录是否正确,确认正确请回车: {astrbot_root}", - default=True, - abort=True, - ): - dot_astrbot.touch() - click.echo(f"Created {dot_astrbot}") - else: - click.echo(f"Welcome back! AstrBot root directory: {astrbot_root}") - - -async def _check_dashboard(astrbot_root: Path) -> None: - """检查是否安装了dashboard""" - try: - from ..core.utils.io import get_dashboard_version, download_dashboard - except ImportError: - from astrbot.core.utils.io import get_dashboard_version, download_dashboard - - try: - # 添加 create=True 参数以确保在初始化时不会抛出异常 - dashboard_version = await get_dashboard_version() - match dashboard_version: - case None: - click.echo("未安装管理面板") - if click.confirm( - "是否安装管理面板?", - default=True, - abort=True, - ): - click.echo("正在安装管理面板...") - # 确保使用 create=True 参数 - await download_dashboard( - path="data/dashboard.zip", extract_path=str(astrbot_root) - ) - click.echo("管理面板安装完成") - - case str(): - if dashboard_version == f"v{VERSION}": - click.echo("无需更新") - else: - try: - version = dashboard_version.split("v")[1] - click.echo(f"管理面板版本: {version}") - # 确保使用 create=True 参数 - await download_dashboard( - path="data/dashboard.zip", extract_path=str(astrbot_root) - ) - except Exception as e: - click.echo(f"下载管理面板失败: {e}") - return - except FileNotFoundError: - click.echo("初始化管理面板目录...") - # 初始化模式下,下载到指定位置 - try: - await download_dashboard( - path=str(astrbot_root / "dashboard.zip"), extract_path=str(astrbot_root) - ) - click.echo("管理面板初始化完成") - except Exception as e: - click.echo(f"下载管理面板失败: {e}") - return - - -@click.group(name="astrbot") +@click.group() +@click.version_option(__version__, prog_name="AstrBot") def cli() -> None: """The AstrBot CLI""" click.echo(logo_tmpl) click.echo("Welcome to AstrBot CLI!") - click.echo(f"AstrBot version: {VERSION}") + click.echo(f"AstrBot CLI version: {__version__}") -# region init -@cli.command() -@click.option("--path", "-p", help="AstrBot 数据目录") -@click.option("--force", "-f", is_flag=True, help="强制初始化") -def init(path: str | None, force: bool) -> None: - """Initialize AstrBot""" - click.echo("Initializing AstrBot...") - astrbot_root = _get_astrbot_root(path) - if force: - if click.confirm( - "强制初始化会删除当前目录下的所有文件,是否继续?", - default=False, - abort=True, - ): - click.echo("正在删除当前目录下的所有文件...") - shutil.rmtree(astrbot_root, ignore_errors=True) - - _check_astrbot_root(astrbot_root) - - click.echo(f"AstrBot root directory: {astrbot_root}") - - if not astrbot_root.exists(): - # 创建目录 - astrbot_root.mkdir(parents=True, exist_ok=True) - click.echo(f"Created directory: {astrbot_root}") - else: - click.echo(f"Directory already exists: {astrbot_root}") - - config_path: Path = astrbot_root / "config" - plugins_path: Path = astrbot_root / "plugins" - temp_path: Path = astrbot_root / "temp" - config_path.mkdir(parents=True, exist_ok=True) - plugins_path.mkdir(parents=True, exist_ok=True) - temp_path.mkdir(parents=True, exist_ok=True) - - click.echo(f"Created directories: {config_path}, {plugins_path}, {temp_path}") - - # 检查是否安装了dashboard - asyncio.run(_check_dashboard(astrbot_root)) - - -# region run -@cli.command() -@click.option("--path", "-p", help="AstrBot 数据目录") -def run(path: str | None = None) -> None: - """Run AstrBot""" - # 解析为绝对路径 - try: - from ..core.log import LogBroker - from ..core import db_helper - from ..core.initial_loader import InitialLoader - except ImportError: - from astrbot.core.log import LogBroker - from astrbot.core import db_helper - from astrbot.core.initial_loader import InitialLoader - - astrbot_root = _get_astrbot_root(path) - - _check_astrbot_root(astrbot_root) - - asyncio.run(_check_dashboard(astrbot_root)) - - log_broker = LogBroker() - db = db_helper - - core_lifecycle = InitialLoader(db, log_broker) - try: - asyncio.run(core_lifecycle.start()) - except KeyboardInterrupt: - click.echo("接收到退出信号,正在关闭 AstrBot...") - except Exception as e: - click.echo(f"运行时出现错误: {e}") - - -# region Basic -@cli.command(name="version") -def version() -> None: - """Show the version of AstrBot""" - click.echo(f"AstrBot version: {VERSION}") - - -@cli.command() +@click.command() @click.argument("command_name", required=False, type=str) def help(command_name: str | None) -> None: - """Show help information for commands + """显示命令的帮助信息 - If COMMAND_NAME is provided, show detailed help for that command. - Otherwise, show general help information. + 如果提供了 COMMAND_NAME,则显示该命令的详细帮助信息。 + 否则,显示通用帮助信息。 """ ctx = click.get_current_context() if command_name: @@ -234,5 +49,10 @@ def help(command_name: str | None) -> None: click.echo(cli.get_help(ctx)) +cli.add_command(init) +cli.add_command(run) +cli.add_command(help) +cli.add_command(plug) + if __name__ == "__main__": cli() diff --git a/astrbot/cli/commands/__init__.py b/astrbot/cli/commands/__init__.py new file mode 100644 index 000000000..d250c8c0f --- /dev/null +++ b/astrbot/cli/commands/__init__.py @@ -0,0 +1,5 @@ +from .cmd_init import init +from .cmd_run import run +from .cmd_plug import plug + +__all__ = ["init", "run", "plug"] diff --git a/astrbot/cli/commands/cmd_init.py b/astrbot/cli/commands/cmd_init.py new file mode 100644 index 000000000..d9a42f822 --- /dev/null +++ b/astrbot/cli/commands/cmd_init.py @@ -0,0 +1,55 @@ +import asyncio + +import click +from filelock import FileLock, Timeout + +from ..utils import check_dashboard, get_astrbot_root + + +async def initialize_astrbot(astrbot_root) -> None: + """执行 AstrBot 初始化逻辑""" + dot_astrbot = astrbot_root / ".astrbot" + + if not dot_astrbot.exists(): + click.echo(f"Current Directory: {astrbot_root}") + click.echo( + "如果你确认这是 Astrbot root directory, 你需要在当前目录下创建一个 .astrbot 文件标记该目录为 AstrBot 的数据目录。" + ) + if click.confirm( + f"请检查当前目录是否正确,确认正确请回车: {astrbot_root}", + default=True, + abort=True, + ): + dot_astrbot.touch() + click.echo(f"Created {dot_astrbot}") + + paths = { + "data": astrbot_root / "data", + "config": astrbot_root / "data" / "config", + "plugins": astrbot_root / "data" / "plugins", + "temp": astrbot_root / "data" / "temp", + } + + for name, path in paths.items(): + path.mkdir(parents=True, exist_ok=True) + click.echo(f"{'Created' if not path.exists() else 'Directory exists'}: {path}") + + await check_dashboard(astrbot_root / "data") + + +@click.command() +def init() -> None: + """初始化 AstrBot""" + click.echo("Initializing AstrBot...") + astrbot_root = get_astrbot_root() + lock_file = astrbot_root / "astrbot.lock" + lock = FileLock(lock_file, timeout=5) + + try: + with lock.acquire(): + asyncio.run(initialize_astrbot(astrbot_root)) + except Timeout: + raise click.ClickException("无法获取锁文件,请检查是否有其他实例正在运行") + + except Exception as e: + raise click.ClickException(f"初始化失败: {e!s}") diff --git a/astrbot/cli/commands/cmd_plug.py b/astrbot/cli/commands/cmd_plug.py new file mode 100644 index 000000000..b250ede4b --- /dev/null +++ b/astrbot/cli/commands/cmd_plug.py @@ -0,0 +1,247 @@ +import re +from pathlib import Path + +import click +import shutil + + +from ..utils import ( + get_git_repo, + build_plug_list, + manage_plugin, + PluginStatus, + check_astrbot_root, + get_astrbot_root, +) + + +@click.group() +def plug(): + """插件管理""" + pass + + +def _get_data_path() -> Path: + base = get_astrbot_root() + if not check_astrbot_root(base): + raise click.ClickException( + f"{base}不是有效的 AstrBot 根目录,如需初始化请使用 astrbot init" + ) + return (base / "data").resolve() + + +def display_plugins(plugins, title=None, color=None): + if title: + click.echo(click.style(title, fg=color, bold=True)) + + click.echo(f"{'名称':<20} {'版本':<10} {'状态':<10} {'作者':<15} {'描述':<30}") + click.echo("-" * 85) + + for p in plugins: + desc = p["desc"][:30] + ("..." if len(p["desc"]) > 30 else "") + click.echo( + f"{p['name']:<20} {p['version']:<10} {p['status']:<10} " + f"{p['author']:<15} {desc:<30}" + ) + + +@plug.command() +@click.argument("name") +def new(name: str): + """创建新插件""" + base_path = _get_data_path() + plug_path = base_path / "plugins" / name + + if plug_path.exists(): + raise click.ClickException(f"插件 {name} 已存在") + + author = click.prompt("请输入插件作者", type=str) + desc = click.prompt("请输入插件描述", type=str) + version = click.prompt("请输入插件版本", type=str) + if not re.match(r"^\d+\.\d+(\.\d+)?$", version.lower().lstrip("v")): + raise click.ClickException("版本号必须为 x.y 或 x.y.z 格式") + repo = click.prompt("请输入插件仓库:", type=str) + if not repo.startswith("http"): + raise click.ClickException("仓库地址必须以 http 开头") + + click.echo("下载插件模板...") + get_git_repo( + "https://github.com/Soulter/helloworld", + plug_path, + ) + + click.echo("重写插件信息...") + # 重写 metadata.yaml + with open(plug_path / "metadata.yaml", "w", encoding="utf-8") as f: + f.write( + f"name: {name}\n" + f"desc: {desc}\n" + f"version: {version}\n" + f"author: {author}\n" + f"repo: {repo}\n" + ) + + # 重写 README.md + with open(plug_path / "README.md", "w", encoding="utf-8") as f: + f.write(f"# {name}\n\n{desc}\n\n# 支持\n\n[帮助文档](https://astrbot.app)\n") + + # 重写 main.py + with open(plug_path / "main.py", "r", encoding="utf-8") as f: + content = f.read() + + new_content = content.replace( + '@register("helloworld", "YourName", "一个简单的 Hello World 插件", "1.0.0")', + f'@register("{name}", "{author}", "{desc}", "{version}")', + ) + + with open(plug_path / "main.py", "w", encoding="utf-8") as f: + f.write(new_content) + + click.echo(f"插件 {name} 创建成功") + + +@plug.command() +@click.option("--all", "-a", is_flag=True, help="列出未安装的插件") +def list(all: bool): + """列出插件""" + base_path = _get_data_path() + plugins = build_plug_list(base_path / "plugins") + + # 未发布的插件 + not_published_plugins = [ + p for p in plugins if p["status"] == PluginStatus.NOT_PUBLISHED + ] + if not_published_plugins: + display_plugins(not_published_plugins, "未发布的插件", "red") + + # 需要更新的插件 + need_update_plugins = [ + p for p in plugins if p["status"] == PluginStatus.NEED_UPDATE + ] + if need_update_plugins: + display_plugins(need_update_plugins, "需要更新的插件", "yellow") + + # 已安装的插件 + installed_plugins = [p for p in plugins if p["status"] == PluginStatus.INSTALLED] + if installed_plugins: + display_plugins(installed_plugins, "已安装的插件", "green") + + # 未安装的插件 + not_installed_plugins = [ + p for p in plugins if p["status"] == PluginStatus.NOT_INSTALLED + ] + if not_installed_plugins and all: + display_plugins(not_installed_plugins, "未安装的插件", "blue") + + if ( + not any([not_published_plugins, need_update_plugins, installed_plugins]) + and not all + ): + click.echo("未安装任何插件") + + +@plug.command() +@click.argument("name") +@click.option("--proxy", help="代理服务器地址") +def install(name: str, proxy: str | None): + """安装插件""" + base_path = _get_data_path() + plug_path = base_path / "plugins" + plugins = build_plug_list(base_path / "plugins") + + plugin = next( + ( + p + for p in plugins + if p["name"] == name and p["status"] == PluginStatus.NOT_INSTALLED + ), + None, + ) + + if not plugin: + raise click.ClickException(f"未找到可安装的插件 {name},可能是不存在或已安装") + + manage_plugin(plugin, plug_path, is_update=False, proxy=proxy) + + +@plug.command() +@click.argument("name") +def remove(name: str): + """卸载插件""" + base_path = _get_data_path() + plugins = build_plug_list(base_path / "plugins") + plugin = next((p for p in plugins if p["name"] == name), None) + + if not plugin or not plugin.get("local_path"): + raise click.ClickException(f"插件 {name} 不存在或未安装") + + plugin_path = plugin["local_path"] + + click.confirm(f"确定要卸载插件 {name} 吗?", default=False, abort=True) + + try: + shutil.rmtree(plugin_path) + click.echo(f"插件 {name} 已卸载") + except Exception as e: + raise click.ClickException(f"卸载插件 {name} 失败: {e}") + + +@plug.command() +@click.argument("name", required=False) +@click.option("--proxy", help="Github代理地址") +def update(name: str, proxy: str | None): + """更新插件""" + base_path = _get_data_path() + plug_path = base_path / "plugins" + plugins = build_plug_list(base_path / "plugins") + + if name: + plugin = next( + ( + p + for p in plugins + if p["name"] == name and p["status"] == PluginStatus.NEED_UPDATE + ), + None, + ) + + if not plugin: + raise click.ClickException(f"插件 {name} 不需要更新或无法更新") + + manage_plugin(plugin, plug_path, is_update=True, proxy=proxy) + else: + need_update_plugins = [ + p for p in plugins if p["status"] == PluginStatus.NEED_UPDATE + ] + + if not need_update_plugins: + click.echo("没有需要更新的插件") + return + + click.echo(f"发现 {len(need_update_plugins)} 个插件需要更新") + for plugin in need_update_plugins: + plugin_name = plugin["name"] + click.echo(f"正在更新插件 {plugin_name}...") + manage_plugin(plugin, plug_path, is_update=True, proxy=proxy) + + +@plug.command() +@click.argument("query") +def search(query: str): + """搜索插件""" + base_path = _get_data_path() + plugins = build_plug_list(base_path / "plugins") + + matched_plugins = [ + p + for p in plugins + if query.lower() in p["name"].lower() + or query.lower() in p["desc"].lower() + or query.lower() in p["author"].lower() + ] + + if not matched_plugins: + click.echo(f"未找到匹配 '{query}' 的插件") + return + + display_plugins(matched_plugins, f"搜索结果: '{query}'", "cyan") diff --git a/astrbot/cli/commands/cmd_run.py b/astrbot/cli/commands/cmd_run.py new file mode 100644 index 000000000..76256ae4b --- /dev/null +++ b/astrbot/cli/commands/cmd_run.py @@ -0,0 +1,62 @@ +import os +import sys +from pathlib import Path + +import click +import asyncio + +from filelock import FileLock, Timeout + +from ..utils import check_dashboard, check_astrbot_root, get_astrbot_root + + +async def run_astrbot(astrbot_root: Path): + """运行 AstrBot""" + from astrbot.core import logger, LogManager, LogBroker, db_helper + from astrbot.core.initial_loader import InitialLoader + + await check_dashboard(astrbot_root / "data") + + log_broker = LogBroker() + LogManager.set_queue_handler(logger, log_broker) + db = db_helper + + core_lifecycle = InitialLoader(db, log_broker) + + await core_lifecycle.start() + + +@click.option("--reload", "-r", is_flag=True, help="插件自动重载") +@click.option("--port", "-p", help="Astrbot Dashboard端口", required=False, type=str) +@click.command() +def run(reload: bool, port: str) -> None: + """运行 AstrBot""" + try: + os.environ["ASTRBOT_CLI"] = "1" + astrbot_root = get_astrbot_root() + + if not check_astrbot_root(astrbot_root): + raise click.ClickException( + f"{astrbot_root}不是有效的 AstrBot 根目录,如需初始化请使用 astrbot init" + ) + + os.environ["ASTRBOT_ROOT"] = str(astrbot_root) + sys.path.insert(0, str(astrbot_root)) + + if port: + os.environ["DASHBOARD_PORT"] = port + + if reload: + click.echo("启用插件自动重载") + os.environ["ASTRBOT_RELOAD"] = "1" + + lock_file = astrbot_root / "astrbot.lock" + lock = FileLock(lock_file, timeout=5) + with lock.acquire(): + asyncio.run(run_astrbot(astrbot_root)) + except KeyboardInterrupt: + click.echo("AstrBot 已关闭...") + except Timeout: + raise click.ClickException("无法获取锁文件,请检查是否有其他实例正在运行") + except Exception as e: + raise click.ClickException(f"运行时出现错误: {e!s}") diff --git a/astrbot/cli/utils/__init__.py b/astrbot/cli/utils/__init__.py new file mode 100644 index 000000000..9989dcf26 --- /dev/null +++ b/astrbot/cli/utils/__init__.py @@ -0,0 +1,18 @@ +from .basic import ( + get_astrbot_root, + check_astrbot_root, + check_dashboard, +) +from .plugin import get_git_repo, manage_plugin, build_plug_list, PluginStatus +from .version_comparator import VersionComparator + +__all__ = [ + "get_astrbot_root", + "check_astrbot_root", + "check_dashboard", + "get_git_repo", + "manage_plugin", + "build_plug_list", + "VersionComparator", + "PluginStatus", +] diff --git a/astrbot/cli/utils/basic.py b/astrbot/cli/utils/basic.py new file mode 100644 index 000000000..d9d7e10a5 --- /dev/null +++ b/astrbot/cli/utils/basic.py @@ -0,0 +1,67 @@ +from pathlib import Path + +import click + + +def check_astrbot_root(path: str | Path) -> bool: + """检查路径是否为 AstrBot 根目录""" + if not isinstance(path, Path): + path = Path(path) + if not path.exists() or not path.is_dir(): + return False + if not (path / ".astrbot").exists(): + return False + return True + + +def get_astrbot_root() -> Path: + """获取Astrbot根目录路径""" + return Path.cwd() + + +async def check_dashboard(astrbot_root: Path) -> None: + """检查是否安装了dashboard""" + from astrbot.core.utils.io import get_dashboard_version, download_dashboard + from astrbot.core.config.default import VERSION + from .version_comparator import VersionComparator + + try: + dashboard_version = await get_dashboard_version() + match dashboard_version: + case None: + click.echo("未安装管理面板") + if click.confirm( + "是否安装管理面板?", + default=True, + abort=True, + ): + click.echo("正在安装管理面板...") + await download_dashboard( + path="data/dashboard.zip", extract_path=str(astrbot_root) + ) + click.echo("管理面板安装完成") + + case str(): + if VersionComparator.compare_version(VERSION, dashboard_version) <= 0: + click.echo("管理面板已是最新版本") + return + else: + try: + version = dashboard_version.split("v")[1] + click.echo(f"管理面板版本: {version}") + await download_dashboard( + path="data/dashboard.zip", extract_path=str(astrbot_root) + ) + except Exception as e: + click.echo(f"下载管理面板失败: {e}") + return + except FileNotFoundError: + click.echo("初始化管理面板目录...") + try: + await download_dashboard( + path=str(astrbot_root / "dashboard.zip"), extract_path=str(astrbot_root) + ) + click.echo("管理面板初始化完成") + except Exception as e: + click.echo(f"下载管理面板失败: {e}") + return diff --git a/astrbot/cli/utils/plugin.py b/astrbot/cli/utils/plugin.py new file mode 100644 index 000000000..6992126a2 --- /dev/null +++ b/astrbot/cli/utils/plugin.py @@ -0,0 +1,266 @@ +import shutil +import tempfile + +import httpx +import yaml +import re +from enum import Enum +from io import BytesIO +from pathlib import Path +from zipfile import ZipFile + +import click +from .version_comparator import VersionComparator + + +class PluginStatus(str, Enum): + INSTALLED = "已安装" + NEED_UPDATE = "需更新" + NOT_INSTALLED = "未安装" + NOT_PUBLISHED = "未发布" + + +def get_git_repo(url: str, target_path: Path, proxy: str | None = None): + """从 Git 仓库下载代码并解压到指定路径""" + temp_dir = Path(tempfile.mkdtemp()) + try: + # 解析仓库信息 + repo_namespace = url.split("/")[-2:] + author = repo_namespace[0] + repo = repo_namespace[1] + + # 尝试获取最新的 release + release_url = f"https://api.github.com/repos/{author}/{repo}/releases" + try: + with httpx.Client( + proxy=proxy if proxy else None, follow_redirects=True + ) as client: + resp = client.get(release_url) + resp.raise_for_status() + releases = resp.json() + + if releases: + # 使用最新的 release + download_url = releases[0]["zipball_url"] + else: + # 没有 release,使用默认分支 + click.echo(f"正在从默认分支下载 {author}/{repo}") + download_url = f"https://github.com/{author}/{repo}/archive/refs/heads/master.zip" + except Exception as e: + click.echo(f"获取 release 信息失败: {e},将直接使用提供的 URL") + download_url = url + + # 应用代理 + if proxy: + download_url = f"{proxy}/{download_url}" + + # 下载并解压 + with httpx.Client( + proxy=proxy if proxy else None, follow_redirects=True + ) as client: + resp = client.get(download_url) + resp.raise_for_status() + zip_content = BytesIO(resp.content) + with ZipFile(zip_content) as z: + z.extractall(temp_dir) + namelist = z.namelist() + root_dir = Path(namelist[0]).parts[0] if namelist else "" + if target_path.exists(): + shutil.rmtree(target_path) + shutil.move(temp_dir / root_dir, target_path) + finally: + if temp_dir.exists(): + shutil.rmtree(temp_dir, ignore_errors=True) + + +def load_yaml_metadata(plugin_dir: Path) -> dict: + """从 metadata.yaml 文件加载插件元数据 + + Args: + plugin_dir: 插件目录路径 + + Returns: + dict: 包含元数据的字典,如果读取失败则返回空字典 + """ + yaml_path = plugin_dir / "metadata.yaml" + if yaml_path.exists(): + try: + return yaml.safe_load(yaml_path.read_text(encoding="utf-8")) or {} + except Exception as e: + click.echo(f"读取 {yaml_path} 失败: {e}", err=True) + return {} + + +def extract_py_metadata(plugin_dir: Path) -> dict: + """从 Python 文件中提取插件元数据 + + Args: + plugin_dir: 插件目录路径 + + Returns: + dict: 包含元数据的字典,如果提取失败则返回空字典 + """ + # 检查 main.py 或与目录同名的 py 文件 + for pattern in ["main.py", f"{plugin_dir.name}.py"]: + for py_file in plugin_dir.glob(pattern): + try: + content = py_file.read_text(encoding="utf-8") + register_match = re.search( + r'@register_star\s*\(\s*"([^"]+)"\s*,\s*"([^"]+)"\s*,\s*"([^"]+)"\s*,\s*"([^"]+)"(?:\s*,\s*"?([^")]+)"?)?\s*\)', + content, + ) + if register_match: + # 映射匹配组到元数据键 + metadata = {} + keys = ["name", "author", "desc", "version", "repo"] + for i, key in enumerate(keys): + if i + 1 <= len( + register_match.groups() + ) and register_match.group(i + 1): + metadata[key] = register_match.group(i + 1) + return metadata + except Exception as e: + click.echo(f"读取 {py_file} 失败: {e}", err=True) + return {} + + +def build_plug_list(plugins_dir: Path) -> list: + """构建插件列表,包含本地和在线插件信息 + + Args: + plugins_dir (Path): 插件目录路径 + + Returns: + list: 包含插件信息的字典列表 + """ + # 获取本地插件信息 + result = [] + if plugins_dir.exists(): + for plugin_name in [d.name for d in plugins_dir.glob("*") if d.is_dir()]: + plugin_dir = plugins_dir / plugin_name + + # 从不同来源加载元数据 + metadata = load_yaml_metadata(plugin_dir) + + # 如果元数据不完整,尝试从 Python 文件提取 + if not metadata or not all( + k in metadata for k in ["name", "desc", "version", "author", "repo"] + ): + py_metadata = extract_py_metadata(plugin_dir) + # 合并元数据,保留已有的值 + for key, value in py_metadata.items(): + if key not in metadata or not metadata[key]: + metadata[key] = value + # 如果成功提取元数据,添加到结果列表 + if metadata: + result.append( + { + "name": str(metadata.get("name", "")), + "desc": str(metadata.get("desc", "")), + "version": str(metadata.get("version", "")), + "author": str(metadata.get("author", "")), + "repo": str(metadata.get("repo", "")), + "status": PluginStatus.INSTALLED, + "local_path": str(plugin_dir), + } + ) + + # 获取在线插件列表 + online_plugins = [] + try: + with httpx.Client() as client: + resp = client.get("https://api.soulter.top/astrbot/plugins") + resp.raise_for_status() + data = resp.json() + for plugin_id, plugin_info in data.items(): + online_plugins.append( + { + "name": str(plugin_id), + "desc": str(plugin_info.get("desc", "")), + "version": str(plugin_info.get("version", "")), + "author": str(plugin_info.get("author", "")), + "repo": str(plugin_info.get("repo", "")), + "status": PluginStatus.NOT_INSTALLED, + "local_path": None, + } + ) + except Exception as e: + click.echo(f"获取在线插件列表失败: {e}", err=True) + + # 与在线插件比对,更新状态 + online_plugin_names = {plugin["name"] for plugin in online_plugins} + for local_plugin in result: + if local_plugin["name"] in online_plugin_names: + # 查找对应的在线插件 + online_plugin = next( + p for p in online_plugins if p["name"] == local_plugin["name"] + ) + if ( + VersionComparator.compare_version( + local_plugin["version"], online_plugin["version"] + ) + < 0 + ): + local_plugin["status"] = PluginStatus.NEED_UPDATE + else: + # 本地插件未在线上发布 + local_plugin["status"] = PluginStatus.NOT_PUBLISHED + + # 添加未安装的在线插件 + for online_plugin in online_plugins: + if not any(plugin["name"] == online_plugin["name"] for plugin in result): + result.append(online_plugin) + + return result + + +def manage_plugin( + plugin: dict, plugins_dir: Path, is_update: bool = False, proxy: str | None = None +) -> None: + """安装或更新插件 + + Args: + plugin (dict): 插件信息字典 + plugins_dir (Path): 插件目录 + is_update (bool, optional): 是否为更新操作. 默认为 False + proxy (str, optional): 代理服务器地址 + """ + plugin_name = plugin["name"] + repo_url = plugin["repo"] + + # 如果是更新且有本地路径,直接使用本地路径 + if is_update and plugin.get("local_path"): + target_path = Path(plugin["local_path"]) + else: + target_path = plugins_dir / plugin_name + + backup_path = Path(f"{target_path}_backup") if is_update else None + + # 检查插件是否存在 + if is_update and not target_path.exists(): + raise click.ClickException(f"插件 {plugin_name} 未安装,无法更新") + + # 备份现有插件 + if is_update and backup_path.exists(): + shutil.rmtree(backup_path) + if is_update: + shutil.copytree(target_path, backup_path) + + try: + click.echo( + f"正在从 {repo_url} {'更新' if is_update else '下载'}插件 {plugin_name}..." + ) + get_git_repo(repo_url, target_path, proxy) + + # 更新成功,删除备份 + if is_update and backup_path.exists(): + shutil.rmtree(backup_path) + click.echo(f"插件 {plugin_name} {'更新' if is_update else '安装'}成功") + except Exception as e: + if target_path.exists(): + shutil.rmtree(target_path, ignore_errors=True) + if is_update and backup_path.exists(): + shutil.move(backup_path, target_path) + raise click.ClickException( + f"{'更新' if is_update else '安装'}插件 {plugin_name} 时出错: {e}" + ) diff --git a/astrbot/cli/utils/version_comparator.py b/astrbot/cli/utils/version_comparator.py new file mode 100644 index 000000000..fecab885e --- /dev/null +++ b/astrbot/cli/utils/version_comparator.py @@ -0,0 +1,92 @@ +""" +拷贝自 astrbot.core.utils.version_comparator +""" + +import re + + +class VersionComparator: + @staticmethod + def compare_version(v1: str, v2: str) -> int: + """根据 Semver 语义版本规范来比较版本号的大小。支持不仅局限于 3 个数字的版本号,并处理预发布标签。 + + 参考: https://semver.org/lang/zh-CN/ + + 返回 1 表示 v1 > v2,返回 -1 表示 v1 < v2,返回 0 表示 v1 = v2。 + """ + v1 = v1.lower().replace("v", "") + v2 = v2.lower().replace("v", "") + + def split_version(version): + match = re.match( + r"^([0-9]+(?:\.[0-9]+)*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+(.+))?$", + version, + ) + if not match: + return [], None + major_minor_patch = match.group(1).split(".") + prerelease = match.group(2) + # buildmetadata = match.group(3) # 构建元数据在比较时忽略 + parts = [int(x) for x in major_minor_patch] + prerelease = VersionComparator._split_prerelease(prerelease) + return parts, prerelease + + v1_parts, v1_prerelease = split_version(v1) + v2_parts, v2_prerelease = split_version(v2) + + # 比较数字部分 + length = max(len(v1_parts), len(v2_parts)) + v1_parts.extend([0] * (length - len(v1_parts))) + v2_parts.extend([0] * (length - len(v2_parts))) + + for i in range(length): + if v1_parts[i] > v2_parts[i]: + return 1 + elif v1_parts[i] < v2_parts[i]: + return -1 + + # 比较预发布标签 + if v1_prerelease is None and v2_prerelease is not None: + return 1 # 没有预发布标签的版本高于有预发布标签的版本 + elif v1_prerelease is not None and v2_prerelease is None: + return -1 # 有预发布标签的版本低于没有预发布标签的版本 + elif v1_prerelease is not None and v2_prerelease is not None: + len_pre = max(len(v1_prerelease), len(v2_prerelease)) + for i in range(len_pre): + p1 = v1_prerelease[i] if i < len(v1_prerelease) else None + p2 = v2_prerelease[i] if i < len(v2_prerelease) else None + + if p1 is None and p2 is not None: + return -1 + elif p1 is not None and p2 is None: + return 1 + elif isinstance(p1, int) and isinstance(p2, str): + return -1 + elif isinstance(p1, str) and isinstance(p2, int): + return 1 + elif isinstance(p1, int) and isinstance(p2, int): + if p1 > p2: + return 1 + elif p1 < p2: + return -1 + elif isinstance(p1, str) and isinstance(p2, str): + if p1 > p2: + return 1 + elif p1 < p2: + return -1 + return 0 # 预发布标签完全相同 + + return 0 # 数字部分和预发布标签都相同 + + @staticmethod + def _split_prerelease(prerelease): + if not prerelease: + return None + parts = prerelease.split(".") + result = [] + for part in parts: + if part.isdigit(): + result.append(int(part)) + else: + result.append(part) + return result diff --git a/astrbot/core/__init__.py b/astrbot/core/__init__.py index a9b1fafd5..bce4073ca 100644 --- a/astrbot/core/__init__.py +++ b/astrbot/core/__init__.py @@ -8,9 +8,10 @@ from astrbot.core.db.sqlite import SQLiteDatabase from astrbot.core.config.default import DB_PATH from astrbot.core.config import AstrBotConfig from astrbot.core.file_token_service import FileTokenService +from .utils.astrbot_path import get_astrbot_data_path # 初始化数据存储文件夹 -os.makedirs("data", exist_ok=True) +os.makedirs(get_astrbot_data_path(), exist_ok=True) WEBUI_SK = "Advanced_System_for_Text_Response_and_Bot_Operations_Tool" DEMO_MODE = os.getenv("DEMO_MODE", False) diff --git a/astrbot/core/config/astrbot_config.py b/astrbot/core/config/astrbot_config.py index 09e66ce17..c43536ea5 100644 --- a/astrbot/core/config/astrbot_config.py +++ b/astrbot/core/config/astrbot_config.py @@ -4,8 +4,9 @@ import logging import enum from .default import DEFAULT_CONFIG, DEFAULT_VALUE_MAP from typing import Dict +from astrbot.core.utils.astrbot_path import get_astrbot_data_path -ASTRBOT_CONFIG_PATH = "data/cmd_config.json" +ASTRBOT_CONFIG_PATH = os.path.join(get_astrbot_data_path(), "cmd_config.json") logger = logging.getLogger("astrbot") diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index 341d105a8..9d3c13cda 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -2,8 +2,11 @@ 如需修改配置,请在 `data/cmd_config.json` 中修改或者在管理面板中可视化修改。 """ +import os +from astrbot.core.utils.astrbot_path import get_astrbot_data_path + VERSION = "3.5.8" -DB_PATH = "data/data_v3.db" +DB_PATH = os.path.join(get_astrbot_data_path(), "data_v3.db") # 默认配置 DEFAULT_CONFIG = { diff --git a/astrbot/core/db/plugin/sqlite_impl.py b/astrbot/core/db/plugin/sqlite_impl.py index 5440362af..53cfb8284 100644 --- a/astrbot/core/db/plugin/sqlite_impl.py +++ b/astrbot/core/db/plugin/sqlite_impl.py @@ -3,8 +3,9 @@ import aiosqlite import os from typing import Any from .plugin_storage import PluginStorage +from astrbot.core.utils.astrbot_path import get_astrbot_data_path -DBPATH = "data/plugin_data/sqlite/plugin_data.db" +DBPATH = os.path.join(get_astrbot_data_path(), "plugin_data", "sqlite", "plugin_data.db") class SQLitePluginStorage(PluginStorage): diff --git a/astrbot/core/message/components.py b/astrbot/core/message/components.py index 718fd30fb..48592b20c 100644 --- a/astrbot/core/message/components.py +++ b/astrbot/core/message/components.py @@ -32,6 +32,7 @@ from enum import Enum from pydantic.v1 import BaseModel from astrbot.core import logger from astrbot.core.utils.io import download_image_by_url, file_to_base64, download_file +from astrbot.core.utils.astrbot_path import get_astrbot_data_path class ComponentType(Enum): @@ -167,7 +168,8 @@ class Record(BaseMessageComponent): elif self.file and self.file.startswith("base64://"): bs64_data = self.file.removeprefix("base64://") image_bytes = base64.b64decode(bs64_data) - file_path = f"data/temp/{uuid.uuid4()}.jpg" + temp_dir = os.path.join(get_astrbot_data_path(), "temp") + file_path = os.path.join(temp_dir, f"{uuid.uuid4()}.jpg") with open(file_path, "wb") as f: f.write(image_bytes) return os.path.abspath(file_path) @@ -371,7 +373,8 @@ class Image(BaseMessageComponent): elif url and url.startswith("base64://"): bs64_data = url.removeprefix("base64://") image_bytes = base64.b64decode(bs64_data) - image_file_path = f"data/temp/{uuid.uuid4()}.jpg" + temp_dir = os.path.join(get_astrbot_data_path(), "temp") + image_file_path = os.path.join(temp_dir, f"{uuid.uuid4()}.jpg") with open(image_file_path, "wb") as f: f.write(image_bytes) return os.path.abspath(image_file_path) @@ -637,9 +640,9 @@ class File(BaseMessageComponent): async def _download_file(self): """下载文件""" - os.makedirs("data/temp", exist_ok=True) - filename = self.name or f"{uuid.uuid4().hex}" - file_path = f"data/temp/{filename}" + download_dir = os.path.join(get_astrbot_data_path(), "temp") + os.makedirs(download_dir, exist_ok=True) + file_path = os.path.join(download_dir, f"{uuid.uuid4().hex}") await download_file(self.url, file_path) self.file_ = os.path.abspath(file_path) diff --git a/astrbot/core/platform/sources/dingtalk/dingtalk_adapter.py b/astrbot/core/platform/sources/dingtalk/dingtalk_adapter.py index 7a83a8abe..e61e23854 100644 --- a/astrbot/core/platform/sources/dingtalk/dingtalk_adapter.py +++ b/astrbot/core/platform/sources/dingtalk/dingtalk_adapter.py @@ -1,4 +1,5 @@ import asyncio +import os import uuid import aiohttp import dingtalk_stream @@ -19,6 +20,7 @@ from ...register import register_platform_adapter from astrbot import logger from dingtalk_stream import AckMessage from astrbot.core.utils.io import download_file +from astrbot.core.utils.astrbot_path import get_astrbot_data_path class MyEventHandler(dingtalk_stream.EventHandler): @@ -152,7 +154,8 @@ class DingtalkPlatformAdapter(Platform): "downloadCode": download_code, "robotCode": robot_code, } - f_path = f"data/dingtalk_file_{uuid.uuid4()}.{ext}" + temp_dir = os.path.join(get_astrbot_data_path(), "temp") + f_path = os.path.join(temp_dir, f"dingtalk_file_{uuid.uuid4()}.{ext}") async with aiohttp.ClientSession() as session: async with session.post( "https://api.dingtalk.com/v1.0/robot/messageFiles/download", diff --git a/astrbot/core/platform/sources/gewechat/client.py b/astrbot/core/platform/sources/gewechat/client.py index 36a18ec66..5f97a6778 100644 --- a/astrbot/core/platform/sources/gewechat/client.py +++ b/astrbot/core/platform/sources/gewechat/client.py @@ -15,6 +15,7 @@ from astrbot.api.message_components import Plain, Image, At, Record, Video from astrbot.api.platform import AstrBotMessage, MessageMember, MessageType from astrbot.core.utils.io import download_image_by_url from .downloader import GeweDownloader +from astrbot.core.utils.astrbot_path import get_astrbot_data_path try: from .xml_data_parser import GeweDataParser @@ -250,7 +251,10 @@ class SimpleGewechatClient: # 语音消息 if "ImgBuf" in d and "buffer" in d["ImgBuf"]: voice_data = base64.b64decode(d["ImgBuf"]["buffer"]) - file_path = f"data/temp/gewe_voice_{abm.message_id}.silk" + temp_dir = os.path.join(get_astrbot_data_path(), "temp") + file_path = os.path.join( + temp_dir, f"gewe_voice_{abm.message_id}.silk" + ) async with await anyio.open_file(file_path, "wb") as f: await f.write(voice_data) @@ -458,8 +462,10 @@ class SimpleGewechatClient: retry_cnt -= 1 # 需要验证码 - if os.path.exists("data/temp/gewe_code"): - with open("data/temp/gewe_code", "r") as f: + temp_dir = os.path.join(get_astrbot_data_path(), "temp") + code_file_path = os.path.join(temp_dir, "gewe_code") + if os.path.exists(code_file_path): + with open(code_file_path, "r") as f: code = f.read().strip() if not code: logger.warning( @@ -470,9 +476,9 @@ class SimpleGewechatClient: payload["captchCode"] = code logger.info(f"使用验证码: {code}") try: - os.remove("data/temp/gewe_code") + os.remove(code_file_path) except Exception: - logger.warning("删除验证码文件 data/temp/gewe_code 失败。") + logger.warning(f"删除验证码文件 {code_file_path} 失败。") async with aiohttp.ClientSession() as session: async with session.post( diff --git a/astrbot/core/platform/sources/gewechat/gewechat_event.py b/astrbot/core/platform/sources/gewechat/gewechat_event.py index 3a62b7a81..f549d9ece 100644 --- a/astrbot/core/platform/sources/gewechat/gewechat_event.py +++ b/astrbot/core/platform/sources/gewechat/gewechat_event.py @@ -6,7 +6,7 @@ import traceback import os from typing import AsyncGenerator -from astrbot.core.utils.io import save_temp_img, download_file +from astrbot.core.utils.io import download_file from astrbot.core.utils.tencent_record_helper import wav_to_tencent_silk from astrbot.api import logger from astrbot.api.event import AstrMessageEvent, MessageChain @@ -21,6 +21,7 @@ from astrbot.api.message_components import ( WechatEmoji as Emoji, ) from .client import SimpleGewechatClient +from astrbot.core.utils.astrbot_path import get_astrbot_data_path def get_wav_duration(file_path): @@ -106,7 +107,8 @@ class GewechatPlatformEvent(AstrMessageEvent): # 根据 url 下载视频 if video_url.startswith("http"): video_filename = f"{uuid.uuid4()}.mp4" - video_path = f"data/temp/{video_filename}" + temp_dir = os.path.join(get_astrbot_data_path(), "temp") + video_path = os.path.join(temp_dir, video_filename) await download_file(video_url, video_path) else: video_path = video_url @@ -115,7 +117,10 @@ class GewechatPlatformEvent(AstrMessageEvent): video_callback_url = f"{client.file_server_url}/{video_token}" # 获取视频第一帧 - thumb_path = f"data/temp/gewechat_video_thumb_{uuid.uuid4()}.jpg" + temp_dir = os.path.join(get_astrbot_data_path(), "temp") + thumb_path = os.path.join( + temp_dir, f"gewechat_video_thumb_{uuid.uuid4()}.jpg" + ) video_path = video_path.replace(" ", "\\ ") try: @@ -154,7 +159,8 @@ class GewechatPlatformEvent(AstrMessageEvent): record_url = comp.file record_path = await comp.convert_to_file_path() - silk_path = f"data/temp/{uuid.uuid4()}.silk" + temp_dir = os.path.join(get_astrbot_data_path(), "temp") + silk_path = os.path.join(temp_dir, f"{uuid.uuid4()}.silk") try: duration = await wav_to_tencent_silk(record_path, silk_path) except Exception as e: @@ -173,7 +179,10 @@ class GewechatPlatformEvent(AstrMessageEvent): if file_path.startswith("file:///"): file_path = file_path[8:] elif file_path.startswith("http"): - await download_file(file_path, f"data/temp/{file_name}") + temp_dir = os.path.join(get_astrbot_data_path(), "temp") + temp_file_path = os.path.join(temp_dir, file_name) + await download_file(file_path, temp_file_path) + file_path = temp_file_path else: file_path = file_path diff --git a/astrbot/core/platform/sources/lark/lark_event.py b/astrbot/core/platform/sources/lark/lark_event.py index 600c13e56..994d1495d 100644 --- a/astrbot/core/platform/sources/lark/lark_event.py +++ b/astrbot/core/platform/sources/lark/lark_event.py @@ -1,4 +1,5 @@ import json +import os import uuid import base64 import lark_oapi as lark @@ -9,6 +10,7 @@ from astrbot.api.message_components import Plain, Image as AstrBotImage, At from astrbot.core.utils.io import download_image_by_url from lark_oapi.api.im.v1 import * from astrbot import logger +from astrbot.core.utils.astrbot_path import get_astrbot_data_path class LarkMessageEvent(AstrMessageEvent): @@ -40,7 +42,8 @@ class LarkMessageEvent(AstrMessageEvent): base64_str = comp.file.removeprefix("base64://") image_data = base64.b64decode(base64_str) # save as temp file - file_path = f"data/temp/{uuid.uuid4()}_test.jpg" + temp_dir = os.path.join(get_astrbot_data_path(), "temp") + file_path = os.path.join(temp_dir, f"{uuid.uuid4()}_test.jpg") with open(file_path, "wb") as f: f.write(BytesIO(image_data).getvalue()) else: diff --git a/astrbot/core/platform/sources/telegram/tg_event.py b/astrbot/core/platform/sources/telegram/tg_event.py index 8e26b8960..4b9fd0ade 100644 --- a/astrbot/core/platform/sources/telegram/tg_event.py +++ b/astrbot/core/platform/sources/telegram/tg_event.py @@ -1,3 +1,4 @@ +import os import asyncio import telegramify_markdown from astrbot.api.event import AstrMessageEvent, MessageChain @@ -13,6 +14,7 @@ from astrbot.api.message_components import ( from telegram.ext import ExtBot from astrbot.core.utils.io import download_file from astrbot import logger +from astrbot.core.utils.astrbot_path import get_astrbot_data_path class TelegramPlatformEvent(AstrMessageEvent): @@ -75,7 +77,8 @@ class TelegramPlatformEvent(AstrMessageEvent): await client.send_photo(photo=image_path, **payload) elif isinstance(i, File): if i.file.startswith("https://"): - path = "data/temp/" + i.name + temp_dir = os.path.join(get_astrbot_data_path(), "temp") + path = os.path.join(temp_dir, i.name) await download_file(i.file, path) i.file = path @@ -126,7 +129,8 @@ class TelegramPlatformEvent(AstrMessageEvent): continue elif isinstance(i, File): if i.file.startswith("https://"): - path = "data/temp/" + i.name + temp_dir = os.path.join(get_astrbot_data_path(), "temp") + path = os.path.join(temp_dir, i.name) await download_file(i.file, path) i.file = path diff --git a/astrbot/core/platform/sources/webchat/webchat_adapter.py b/astrbot/core/platform/sources/webchat/webchat_adapter.py index 01a042fb8..fa384ed99 100644 --- a/astrbot/core/platform/sources/webchat/webchat_adapter.py +++ b/astrbot/core/platform/sources/webchat/webchat_adapter.py @@ -17,6 +17,7 @@ from astrbot.core import web_chat_queue from .webchat_event import WebChatMessageEvent from astrbot.core.platform.astr_message_event import MessageSesion from ...register import register_platform_adapter +from astrbot.core.utils.astrbot_path import get_astrbot_data_path class QueueListener: @@ -40,7 +41,8 @@ class WebChatAdapter(Platform): self.config = platform_config self.settings = platform_settings self.unique_session = platform_settings["unique_session"] - self.imgs_dir = "data/webchat/imgs" + self.imgs_dir = os.path.join(get_astrbot_data_path(), "webchat", "imgs") + os.makedirs(self.imgs_dir, exist_ok=True) self.metadata = PlatformMetadata( name="webchat", description="webchat", id=self.config.get("id") diff --git a/astrbot/core/platform/sources/webchat/webchat_event.py b/astrbot/core/platform/sources/webchat/webchat_event.py index e60d6d144..76b5dc85d 100644 --- a/astrbot/core/platform/sources/webchat/webchat_event.py +++ b/astrbot/core/platform/sources/webchat/webchat_event.py @@ -6,8 +6,9 @@ from astrbot.api.event import AstrMessageEvent, MessageChain from astrbot.api.message_components import Plain, Image, Record from astrbot.core.utils.io import download_image_by_url from astrbot.core import web_chat_back_queue +from astrbot.core.utils.astrbot_path import get_astrbot_data_path -imgs_dir = "data/webchat/imgs" +imgs_dir = os.path.join(get_astrbot_data_path(), "webchat", "imgs") class WebChatMessageEvent(AstrMessageEvent): diff --git a/astrbot/core/platform/sources/wecom/wecom_adapter.py b/astrbot/core/platform/sources/wecom/wecom_adapter.py index e4dd9077c..1e71838be 100644 --- a/astrbot/core/platform/sources/wecom/wecom_adapter.py +++ b/astrbot/core/platform/sources/wecom/wecom_adapter.py @@ -1,3 +1,4 @@ +import os import sys import uuid import asyncio @@ -25,6 +26,7 @@ from wechatpy.messages import BaseMessage from wechatpy.exceptions import InvalidSignatureException from wechatpy.enterprise import parse_message from .wecom_event import WecomPlatformEvent +from astrbot.core.utils.astrbot_path import get_astrbot_data_path from .wecom_kf import WeChatKF from .wecom_kf_message import WeChatKFMessage @@ -257,14 +259,15 @@ class WecomPlatformAdapter(Platform): resp: Response = await asyncio.get_event_loop().run_in_executor( None, self.client.media.download, msg.media_id ) - path = f"data/temp/wecom_{msg.media_id}.amr" + temp_dir = os.path.join(get_astrbot_data_path(), "temp") + path = os.path.join(temp_dir, f"wecom_{msg.media_id}.amr") with open(path, "wb") as f: f.write(resp.content) try: from pydub import AudioSegment - path_wav = f"data/temp/wecom_{msg.media_id}.wav" + path_wav = os.path.join(temp_dir, f"wecom_{msg.media_id}.wav") audio = AudioSegment.from_file(path) audio.export(path_wav, format="wav") except Exception as e: diff --git a/astrbot/core/platform/sources/wecom/wecom_event.py b/astrbot/core/platform/sources/wecom/wecom_event.py index 507883e02..1c1c09c91 100644 --- a/astrbot/core/platform/sources/wecom/wecom_event.py +++ b/astrbot/core/platform/sources/wecom/wecom_event.py @@ -1,3 +1,4 @@ +import os import uuid import asyncio from astrbot.api.event import AstrMessageEvent, MessageChain @@ -7,6 +8,7 @@ from wechatpy.enterprise import WeChatClient from .wecom_kf_message import WeChatKFMessage from astrbot.api import logger +from astrbot.core.utils.astrbot_path import get_astrbot_data_path try: import pydub @@ -152,7 +154,8 @@ class WecomPlatformEvent(AstrMessageEvent): elif isinstance(comp, Record): record_path = await comp.convert_to_file_path() # 转成amr - record_path_amr = f"data/temp/{uuid.uuid4()}.amr" + temp_dir = os.path.join(get_astrbot_data_path(), "temp") + record_path_amr = os.path.join(temp_dir, f"{uuid.uuid4()}.amr") pydub.AudioSegment.from_wav(record_path).export( record_path_amr, format="amr" ) diff --git a/astrbot/core/provider/func_tool_manager.py b/astrbot/core/provider/func_tool_manager.py index 1ce1efbae..7059a00f7 100644 --- a/astrbot/core/provider/func_tool_manager.py +++ b/astrbot/core/provider/func_tool_manager.py @@ -12,6 +12,8 @@ from contextlib import AsyncExitStack from astrbot import logger from astrbot.core.utils.log_pipe import LogPipe +from astrbot.core.utils.astrbot_path import get_astrbot_data_path + try: import mcp from mcp.client.sse import sse_client @@ -238,8 +240,7 @@ class FuncCall: } ``` """ - current_dir = os.path.dirname(os.path.abspath(__file__)) - data_dir = os.path.abspath(os.path.join(current_dir, "../../../data")) + data_dir = get_astrbot_data_path() mcp_json_file = os.path.join(data_dir, "mcp_server.json") if not os.path.exists(mcp_json_file): diff --git a/astrbot/core/provider/sources/dashscope_tts.py b/astrbot/core/provider/sources/dashscope_tts.py index f135a35da..29c988d76 100644 --- a/astrbot/core/provider/sources/dashscope_tts.py +++ b/astrbot/core/provider/sources/dashscope_tts.py @@ -1,3 +1,4 @@ +import os import dashscope import uuid import asyncio @@ -5,6 +6,7 @@ from dashscope.audio.tts_v2 import * from ..provider import TTSProvider from ..entities import ProviderType from ..register import register_provider_adapter +from astrbot.core.utils.astrbot_path import get_astrbot_data_path @register_provider_adapter( @@ -24,7 +26,8 @@ class ProviderDashscopeTTSAPI(TTSProvider): dashscope.api_key = self.chosen_api_key async def get_audio(self, text: str) -> str: - path = f"data/temp/dashscope_tts_{uuid.uuid4()}.wav" + temp_dir = os.path.join(get_astrbot_data_path(), "temp") + path = os.path.join(temp_dir, f"dashscope_tts_{uuid.uuid4()}.wav") self.synthesizer = SpeechSynthesizer( model=self.get_model(), voice=self.voice, diff --git a/astrbot/core/provider/sources/dify_source.py b/astrbot/core/provider/sources/dify_source.py index 78e3760c1..ad0605f14 100644 --- a/astrbot/core/provider/sources/dify_source.py +++ b/astrbot/core/provider/sources/dify_source.py @@ -1,5 +1,5 @@ import astrbot.core.message.components as Comp - +import os from typing import List from .. import Provider, Personality from ..entities import LLMResponse @@ -10,6 +10,7 @@ from astrbot.core.utils.dify_api_client import DifyAPIClient from astrbot.core.utils.io import download_image_by_url, download_file from astrbot.core import logger, sp from astrbot.core.message.message_event_result import MessageChain +from astrbot.core.utils.astrbot_path import get_astrbot_data_path @register_provider_adapter("dify", "Dify APP 适配器。") @@ -227,7 +228,8 @@ class ProviderDify(Provider): return Comp.Image(file=item["url"], url=item["url"]) case "audio": # 仅支持 wav - path = f"data/temp/{item['filename']}.wav" + temp_dir = os.path.join(get_astrbot_data_path(), "temp") + path = os.path.join(temp_dir, f"{item['filename']}.wav") await download_file(item["url"], path) return Comp.Image(file=item["url"], url=item["url"]) case "video": diff --git a/astrbot/core/provider/sources/edge_tts_source.py b/astrbot/core/provider/sources/edge_tts_source.py index 338abe263..44c2d1756 100644 --- a/astrbot/core/provider/sources/edge_tts_source.py +++ b/astrbot/core/provider/sources/edge_tts_source.py @@ -7,6 +7,7 @@ from ..provider import TTSProvider from ..entities import ProviderType from ..register import register_provider_adapter from astrbot.core import logger +from astrbot.core.utils.astrbot_path import get_astrbot_data_path """ edge_tts 方式,能够免费、快速生成语音,使用需要先安装edge-tts库 @@ -40,9 +41,9 @@ class ProviderEdgeTTS(TTSProvider): self.set_model("edge_tts") async def get_audio(self, text: str) -> str: - os.makedirs("data/temp", exist_ok=True) - mp3_path = f"data/temp/edge_tts_temp_{uuid.uuid4()}.mp3" - wav_path = f"data/temp/edge_tts_{uuid.uuid4()}.wav" + temp_dir = os.path.join(get_astrbot_data_path(), "temp") + mp3_path = os.path.join(temp_dir, f"edge_tts_temp_{uuid.uuid4()}.mp3") + wav_path = os.path.join(temp_dir, f"edge_tts_{uuid.uuid4()}.wav") # 构建 Edge TTS 参数 kwargs = {"text": text, "voice": self.voice} diff --git a/astrbot/core/provider/sources/fishaudio_tts_api_source.py b/astrbot/core/provider/sources/fishaudio_tts_api_source.py index 07d0c32ab..c0cf044b8 100644 --- a/astrbot/core/provider/sources/fishaudio_tts_api_source.py +++ b/astrbot/core/provider/sources/fishaudio_tts_api_source.py @@ -1,3 +1,4 @@ +import os import uuid import ormsgpack from pydantic import BaseModel, conint @@ -6,6 +7,7 @@ from typing import Annotated, Literal from ..provider import TTSProvider from ..entities import ProviderType from ..register import register_provider_adapter +from astrbot.core.utils.astrbot_path import get_astrbot_data_path class ServeReferenceAudio(BaseModel): @@ -87,7 +89,8 @@ class ProviderFishAudioTTSAPI(TTSProvider): ) async def get_audio(self, text: str) -> str: - path = f"data/temp/fishaudio_tts_api_{uuid.uuid4()}.wav" + temp_dir = os.path.join(get_astrbot_data_path(), "temp") + path = os.path.join(temp_dir, f"fishaudio_tts_api_{uuid.uuid4()}.wav") self.headers["content-type"] = "application/msgpack" request = await self._generate_request(text) async with AsyncClient(base_url=self.api_base).stream( diff --git a/astrbot/core/provider/sources/gsvi_tts_source.py b/astrbot/core/provider/sources/gsvi_tts_source.py index 581eef4dc..c2444819b 100644 --- a/astrbot/core/provider/sources/gsvi_tts_source.py +++ b/astrbot/core/provider/sources/gsvi_tts_source.py @@ -1,9 +1,11 @@ +import os import uuid import aiohttp import urllib.parse from ..provider import TTSProvider from ..entities import ProviderType from ..register import register_provider_adapter +from astrbot.core.utils.astrbot_path import get_astrbot_data_path @register_provider_adapter( @@ -23,7 +25,8 @@ class ProviderGSVITTS(TTSProvider): self.emotion = provider_config.get("emotion") async def get_audio(self, text: str) -> str: - path = f"data/temp/gsvi_tts_{uuid.uuid4()}.wav" + temp_dir = os.path.join(get_astrbot_data_path(), "temp") + path = os.path.join(temp_dir, f"gsvi_tts_{uuid.uuid4()}.wav") params = {"text": text} if self.character: diff --git a/astrbot/core/provider/sources/openai_tts_api_source.py b/astrbot/core/provider/sources/openai_tts_api_source.py index 20b00f949..c188a9fae 100644 --- a/astrbot/core/provider/sources/openai_tts_api_source.py +++ b/astrbot/core/provider/sources/openai_tts_api_source.py @@ -1,8 +1,10 @@ +import os import uuid from openai import AsyncOpenAI, NOT_GIVEN from ..provider import TTSProvider from ..entities import ProviderType from ..register import register_provider_adapter +from astrbot.core.utils.astrbot_path import get_astrbot_data_path @register_provider_adapter( @@ -31,7 +33,8 @@ class ProviderOpenAITTSAPI(TTSProvider): self.set_model(provider_config.get("model", None)) async def get_audio(self, text: str) -> str: - path = f"data/temp/openai_tts_api_{uuid.uuid4()}.wav" + temp_dir = os.path.join(get_astrbot_data_path(), "temp") + path = os.path.join(temp_dir, f"openai_tts_api_{uuid.uuid4()}.wav") async with self.client.audio.speech.with_streaming_response.create( model=self.model_name, voice=self.voice, response_format="wav", input=text ) as response: diff --git a/astrbot/core/provider/sources/whisper_api_source.py b/astrbot/core/provider/sources/whisper_api_source.py index 0009af906..dfe286978 100644 --- a/astrbot/core/provider/sources/whisper_api_source.py +++ b/astrbot/core/provider/sources/whisper_api_source.py @@ -7,6 +7,7 @@ from astrbot.core.utils.io import download_file from ..register import register_provider_adapter from astrbot.core import logger from astrbot.core.utils.tencent_record_helper import tencent_silk_to_wav +from astrbot.core.utils.astrbot_path import get_astrbot_data_path @register_provider_adapter( @@ -50,7 +51,8 @@ class ProviderOpenAIWhisperAPI(STTProvider): is_tencent = True name = str(uuid.uuid4()) - path = os.path.join("data/temp", name) + temp_dir = os.path.join(get_astrbot_data_path(), "temp") + path = os.path.join(temp_dir, name) await download_file(audio_url, path) audio_url = path @@ -61,7 +63,8 @@ class ProviderOpenAIWhisperAPI(STTProvider): is_silk = await self._is_silk_file(audio_url) if is_silk: logger.info("Converting silk file to wav ...") - output_path = os.path.join("data/temp", str(uuid.uuid4()) + ".wav") + temp_dir = os.path.join(get_astrbot_data_path(), "temp") + output_path = os.path.join(temp_dir, str(uuid.uuid4()) + ".wav") await tencent_silk_to_wav(audio_url, output_path) audio_url = output_path diff --git a/astrbot/core/provider/sources/whisper_selfhosted_source.py b/astrbot/core/provider/sources/whisper_selfhosted_source.py index 96f0b6f6d..7cb76cc4c 100644 --- a/astrbot/core/provider/sources/whisper_selfhosted_source.py +++ b/astrbot/core/provider/sources/whisper_selfhosted_source.py @@ -8,6 +8,7 @@ from astrbot.core.utils.io import download_file from ..register import register_provider_adapter from astrbot.core import logger from astrbot.core.utils.tencent_record_helper import tencent_silk_to_wav +from astrbot.core.utils.astrbot_path import get_astrbot_data_path @register_provider_adapter( @@ -53,7 +54,8 @@ class ProviderOpenAIWhisperSelfHost(STTProvider): is_tencent = True name = str(uuid.uuid4()) - path = os.path.join("data/temp", name) + temp_dir = os.path.join(get_astrbot_data_path(), "temp") + path = os.path.join(temp_dir, name) await download_file(audio_url, path) audio_url = path @@ -64,7 +66,8 @@ class ProviderOpenAIWhisperSelfHost(STTProvider): is_silk = await self._is_silk_file(audio_url) if is_silk: logger.info("Converting silk file to wav ...") - output_path = os.path.join("data/temp", str(uuid.uuid4()) + ".wav") + temp_dir = os.path.join(get_astrbot_data_path(), "temp") + output_path = os.path.join(temp_dir, str(uuid.uuid4()) + ".wav") await tencent_silk_to_wav(audio_url, output_path) audio_url = output_path diff --git a/astrbot/core/rag/knowledge_db_mgr.py b/astrbot/core/rag/knowledge_db_mgr.py index 2aed0e448..f1c1f386c 100644 --- a/astrbot/core/rag/knowledge_db_mgr.py +++ b/astrbot/core/rag/knowledge_db_mgr.py @@ -3,11 +3,12 @@ from typing import List, Dict from astrbot.core import logger from .store import Store from astrbot.core.config import AstrBotConfig +from astrbot.core.utils.astrbot_path import get_astrbot_data_path class KnowledgeDBManager: def __init__(self, astrbot_config: AstrBotConfig) -> None: - self.db_path = "data/knowledge_db/" + self.db_path = os.path.join(get_astrbot_data_path(), "knowledge_db") self.config = astrbot_config.get("knowledge_db", {}) self.astrbot_config = astrbot_config if not os.path.exists(self.db_path): diff --git a/astrbot/core/rag/store/chroma_db.py b/astrbot/core/rag/store/chroma_db.py index 30befb978..d4cfae946 100644 --- a/astrbot/core/rag/store/chroma_db.py +++ b/astrbot/core/rag/store/chroma_db.py @@ -4,12 +4,14 @@ from typing import List, Dict from astrbot.api import logger from ..embedding.openai_source import SimpleOpenAIEmbedding from . import Store +from astrbot.core.utils.astrbot_path import get_astrbot_data_path class ChromaVectorStore(Store): def __init__(self, name: str, embedding_cfg: Dict) -> None: + import os self.chroma_client = chromadb.PersistentClient( - path="data/long_term_memory_chroma.db" + path=os.path.join(get_astrbot_data_path(), "long_term_memory_chroma.db") ) self.collection = self.chroma_client.get_or_create_collection(name=name) self.embedding = None diff --git a/astrbot/core/star/config.py b/astrbot/core/star/config.py index dc07fe6f5..23a522dc1 100644 --- a/astrbot/core/star/config.py +++ b/astrbot/core/star/config.py @@ -5,6 +5,7 @@ from typing import Union import os import json +from astrbot.core.utils.astrbot_path import get_astrbot_data_path def load_config(namespace: str) -> Union[dict, bool]: @@ -13,7 +14,7 @@ def load_config(namespace: str) -> Union[dict, bool]: namespace: str, 配置的唯一识别符,也就是配置文件的名字。 返回值: 当配置文件存在时,返回 namespace 对应配置文件的内容dict,否则返回 False。 """ - path = f"data/config/{namespace}.json" + path = os.path.join(get_astrbot_data_path(), "config", f"{namespace}.json") if not os.path.exists(path): return False with open(path, "r", encoding="utf-8-sig") as f: @@ -43,7 +44,10 @@ def put_config(namespace: str, name: str, key: str, value, description: str): raise ValueError("key 只支持 str 类型。") if not isinstance(value, (str, int, float, bool, list)): raise ValueError("value 只支持 str, int, float, bool, list 类型。") - path = f"data/config/{namespace}.json" + + config_dir = os.path.join(get_astrbot_data_path(), "config") + path = os.path.join(config_dir, f"{namespace}.json") + if not os.path.exists(path): with open(path, "w", encoding="utf-8-sig") as f: f.write("{}") @@ -71,7 +75,7 @@ def update_config(namespace: str, key: str, value): key: str, 配置项的键。 value: str, int, float, bool, list, 配置项的值。 """ - path = f"data/config/{namespace}.json" + path = os.path.join(get_astrbot_data_path(), "config", f"{namespace}.json") if not os.path.exists(path): raise FileNotFoundError(f"配置文件 {namespace}.json 不存在。") with open(path, "r", encoding="utf-8-sig") as f: diff --git a/astrbot/core/star/star_manager.py b/astrbot/core/star/star_manager.py index 60b0e0c69..516a9efbd 100644 --- a/astrbot/core/star/star_manager.py +++ b/astrbot/core/star/star_manager.py @@ -22,9 +22,19 @@ from astrbot.core.utils.io import remove_dir from .star import star_registry, star_map from .star_handler import star_handlers_registry from astrbot.core.provider.register import llm_tools +from astrbot.core.utils.astrbot_path import ( + get_astrbot_plugin_path, + get_astrbot_config_path, +) from .filter.permission import PermissionTypeFilter, PermissionType +try: + from watchfiles import awatch, PythonFilter +except ImportError: + if os.getenv("ASTRBOT_RELOAD", "0") == "1": + logger.warning("未安装 watchfiles,无法实现插件的热重载。") + class PluginManager: def __init__(self, context: Context, config: AstrBotConfig): @@ -34,17 +44,9 @@ class PluginManager: self.context._star_manager = self self.config = config - self.plugin_store_path = os.path.abspath( - os.path.join( - os.path.dirname(os.path.abspath(__file__)), "../../../data/plugins" - ) - ) + self.plugin_store_path = get_astrbot_plugin_path() """存储插件的路径。即 data/plugins""" - self.plugin_config_path = os.path.abspath( - os.path.join( - os.path.dirname(os.path.abspath(__file__)), "../../../data/config" - ) - ) + self.plugin_config_path = get_astrbot_config_path() """存储插件配置的路径。data/config""" self.reserved_plugin_path = os.path.abspath( os.path.join( @@ -56,6 +58,58 @@ class PluginManager: """插件配置 Schema 文件名""" self.failed_plugin_info = "" + if os.getenv("ASTRBOT_RELOAD", "0") == "1": + asyncio.create_task(self._watch_plugins_changes()) + + async def _watch_plugins_changes(self): + """监视插件文件变化""" + try: + async for changes in awatch( + self.plugin_store_path, + self.reserved_plugin_path, + watch_filter=PythonFilter(), + recursive=True, + ): + # 处理文件变化 + await self._handle_file_changes(changes) + except asyncio.CancelledError: + pass + except Exception as e: + logger.error(f"插件热重载监视任务异常: {str(e)}") + logger.error(traceback.format_exc()) + + async def _handle_file_changes(self, changes): + """处理文件变化""" + logger.info(f"检测到文件变化: {changes}") + plugins_to_check = [] + + for star in star_registry: + if not star.activated: + continue + if star.root_dir_name is None: + continue + if star.reserved: + plugin_dir_path = os.path.join( + self.reserved_plugin_path, star.root_dir_name + ) + else: + plugin_dir_path = os.path.join( + self.plugin_store_path, star.root_dir_name + ) + plugins_to_check.append((plugin_dir_path, star.name)) + reloaded_plugins = set() + for change in changes: + _, file_path = change + for plugin_dir_path, plugin_name in plugins_to_check: + if ( + os.path.commonpath([plugin_dir_path]) + == os.path.commonpath([plugin_dir_path, file_path]) + and plugin_name not in reloaded_plugins + ): + logger.info(f"检测到插件 {plugin_name} 文件变化,正在重载...") + await self.reload(plugin_name) + reloaded_plugins.add(plugin_name) + break def _get_classes(self, arg: ModuleType): """获取指定模块(可以理解为一个 python 文件)下所有的类""" diff --git a/astrbot/core/star/star_tools.py b/astrbot/core/star/star_tools.py index 405ccc631..40fa3e519 100644 --- a/astrbot/core/star/star_tools.py +++ b/astrbot/core/star/star_tools.py @@ -1,4 +1,6 @@ import inspect +import os +from pathlib import Path from typing import Union, Awaitable, List, Optional, ClassVar from astrbot.core.message.components import BaseMessageComponent from astrbot.core.message.message_event_result import MessageChain @@ -6,7 +8,7 @@ from astrbot.api.platform import MessageMember, AstrBotMessage from astrbot.core.platform.astr_message_event import MessageSesion from astrbot.core.star.context import Context from astrbot.core.star.star import star_map -from pathlib import Path +from astrbot.core.utils.astrbot_path import get_astrbot_data_path class StarTools: @@ -180,7 +182,7 @@ class StarTools: plugin_name = metadata.name - data_dir = Path("data/plugin_data") / plugin_name + data_dir = Path(os.path.join(get_astrbot_data_path(), "plugin_data", plugin_name)) try: data_dir.mkdir(parents=True, exist_ok=True) diff --git a/astrbot/core/star/updator.py b/astrbot/core/star/updator.py index d439e98cc..45f8b8a23 100644 --- a/astrbot/core/star/updator.py +++ b/astrbot/core/star/updator.py @@ -6,16 +6,13 @@ from ..updator import RepoZipUpdator from astrbot.core.utils.io import remove_dir, on_error from ..star.star import StarMetadata from astrbot.core import logger +from astrbot.core.utils.astrbot_path import get_astrbot_plugin_path class PluginUpdator(RepoZipUpdator): def __init__(self, repo_mirror: str = "") -> None: super().__init__(repo_mirror) - self.plugin_store_path = os.path.abspath( - os.path.join( - os.path.dirname(os.path.abspath(__file__)), "../../../data/plugins" - ) - ) + self.plugin_store_path = get_astrbot_plugin_path() def get_plugin_store_path(self) -> str: return self.plugin_store_path diff --git a/astrbot/core/updator.py b/astrbot/core/updator.py index 1e7279a8c..60ed6860f 100644 --- a/astrbot/core/updator.py +++ b/astrbot/core/updator.py @@ -6,6 +6,7 @@ from .zip_updator import ReleaseInfo, RepoZipUpdator from astrbot.core import logger from astrbot.core.config.default import VERSION from astrbot.core.utils.io import download_file +from astrbot.core.utils.astrbot_path import get_astrbot_path class AstrBotUpdator(RepoZipUpdator): @@ -16,9 +17,7 @@ class AstrBotUpdator(RepoZipUpdator): def __init__(self, repo_mirror: str = "") -> None: super().__init__(repo_mirror) - self.MAIN_PATH = os.path.abspath( - os.path.join(os.path.dirname(os.path.abspath(__file__)), "../../") - ) + self.MAIN_PATH = get_astrbot_path() self.ASTRBOT_RELEASE_API = "https://api.soulter.top/releases" def terminate_child_processes(self): @@ -51,7 +50,13 @@ class AstrBotUpdator(RepoZipUpdator): self.terminate_child_processes() py = py.replace(" ", "\\ ") try: - os.execl(py, py, *sys.argv) + if "astrbot" in os.path.basename(sys.argv[0]): # 兼容cli + args = [ + f'"{arg}"' if " " in arg else arg for arg in sys.argv[1:] + ] + os.execl(py, py, "-m", "astrbot.cli.__main__", *args) + else: + os.execl(py, py, *sys.argv) except Exception as e: logger.error(f"重启失败({py}, {e}),请尝试手动重启。") raise e @@ -67,6 +72,9 @@ class AstrBotUpdator(RepoZipUpdator): update_data = await self.fetch_release_info(self.ASTRBOT_RELEASE_API, latest) file_url = None + if os.environ.get("ASTRBOT_CLI"): + raise Exception("不支持更新CLI启动的AstrBot") # 避免版本管理混乱 + if latest: latest_version = update_data[0]["tag_name"] if self.compare_version(VERSION, latest_version) >= 0: diff --git a/astrbot/core/utils/astrbot_path.py b/astrbot/core/utils/astrbot_path.py new file mode 100644 index 000000000..64ed9229f --- /dev/null +++ b/astrbot/core/utils/astrbot_path.py @@ -0,0 +1,41 @@ +""" +Astrbot统一路径获取 + +项目路径:固定为源码所在路径 +根目录路径:默认为当前工作目录,可通过环境变量 ASTRBOT_ROOT 指定 +数据目录路径:固定为根目录下的 data 目录 +配置文件路径:固定为数据目录下的 config 目录 +插件目录路径:固定为数据目录下的 plugins 目录 +""" + +import os + + +def get_astrbot_path() -> str: + """获取Astrbot项目路径""" + return os.path.realpath( + os.path.join(os.path.dirname(os.path.abspath(__file__)), "../../../") + ) + + +def get_astrbot_root() -> str: + """获取Astrbot根目录路径""" + if path := os.environ.get("ASTRBOT_ROOT"): + return os.path.realpath(path) + else: + return os.path.realpath(os.getcwd()) + + +def get_astrbot_data_path() -> str: + """获取Astrbot数据目录路径""" + return os.path.realpath(os.path.join(get_astrbot_root(), "data")) + + +def get_astrbot_config_path() -> str: + """获取Astrbot配置文件路径""" + return os.path.realpath(os.path.join(get_astrbot_data_path(), "config")) + + +def get_astrbot_plugin_path() -> str: + """获取Astrbot插件目录路径""" + return os.path.realpath(os.path.join(get_astrbot_data_path(), "plugins")) diff --git a/astrbot/core/utils/io.py b/astrbot/core/utils/io.py index 37a39de96..2cd8fd9c2 100644 --- a/astrbot/core/utils/io.py +++ b/astrbot/core/utils/io.py @@ -14,6 +14,7 @@ import certifi from typing import Union from PIL import Image +from .astrbot_path import get_astrbot_data_path def on_error(func, path, exc_info): @@ -49,11 +50,11 @@ def port_checker(port: int, host: str = "localhost"): def save_temp_img(img: Union[Image.Image, str]) -> str: - os.makedirs("data/temp", exist_ok=True) + temp_dir = os.path.join(get_astrbot_data_path(), "temp") # 获得文件创建时间,清除超过 12 小时的 try: - for f in os.listdir("data/temp"): - path = os.path.join("data/temp", f) + for f in os.listdir(temp_dir): + path = os.path.join(temp_dir, f) if os.path.isfile(path): ctime = os.path.getctime(path) if time.time() - ctime > 3600 * 12: @@ -63,7 +64,7 @@ def save_temp_img(img: Union[Image.Image, str]) -> str: # 获得时间戳 timestamp = f"{int(time.time())}_{uuid.uuid4().hex[:8]}" - p = f"data/temp/{timestamp}.jpg" + p = os.path.join(temp_dir, f"{timestamp}.jpg") if isinstance(img, Image.Image): img.save(p) @@ -201,28 +202,29 @@ def get_local_ip_addresses(): async def get_dashboard_version(): - if os.path.exists("data/dist"): - if os.path.exists("data/dist/assets/version"): - with open("data/dist/assets/version", "r") as f: + dist_dir = os.path.join(get_astrbot_data_path(), "dist") + if os.path.exists(dist_dir): + version_file = os.path.join(dist_dir, "assets", "version") + if os.path.exists(version_file): + with open(version_file, "r") as f: v = f.read().strip() return v return None -async def download_dashboard(path: str = "data/dashboard.zip", extract_path: str = "data"): +async def download_dashboard(path: str = None, extract_path: str = "data"): """下载管理面板文件""" + if path is None: + path = os.path.join(get_astrbot_data_path(), "dashboard.zip") + dashboard_release_url = "https://astrbot-registry.soulter.top/download/astrbot-dashboard/latest/dist.zip" try: - await download_file( - dashboard_release_url, path, show_progress=True - ) + await download_file(dashboard_release_url, path, show_progress=True) except BaseException as _: dashboard_release_url = ( "https://github.com/Soulter/AstrBot/releases/latest/download/dist.zip" ) - await download_file( - dashboard_release_url, path, show_progress=True - ) + await download_file(dashboard_release_url, path, show_progress=True) print("解压管理面板文件中...") with zipfile.ZipFile(path, "r") as z: z.extractall(extract_path) diff --git a/astrbot/core/utils/shared_preferences.py b/astrbot/core/utils/shared_preferences.py index 33a681419..7a503583b 100644 --- a/astrbot/core/utils/shared_preferences.py +++ b/astrbot/core/utils/shared_preferences.py @@ -1,9 +1,12 @@ import json import os +from .astrbot_path import get_astrbot_data_path class SharedPreferences: - def __init__(self, path="data/shared_preferences.json"): + def __init__(self, path=None): + if path is None: + path = os.path.join(get_astrbot_data_path(), "shared_preferences.json") self.path = path self._data = self._load_preferences() diff --git a/astrbot/core/utils/t2i/local_strategy.py b/astrbot/core/utils/t2i/local_strategy.py index 514e0dd75..19eab2efe 100644 --- a/astrbot/core/utils/t2i/local_strategy.py +++ b/astrbot/core/utils/t2i/local_strategy.py @@ -1,4 +1,5 @@ import re +import os import aiohttp import ssl import certifi @@ -10,38 +11,40 @@ from astrbot.core.config import VERSION from . import RenderStrategy from PIL import ImageFont, Image, ImageDraw from astrbot.core.utils.io import save_temp_img +from astrbot.core.utils.astrbot_path import get_astrbot_data_path class FontManager: """字体管理类,负责加载和缓存字体""" - + _font_cache = {} - + @classmethod def get_font(cls, size: int) -> ImageFont.FreeTypeFont: """获取指定大小的字体,优先从缓存获取""" if size in cls._font_cache: return cls._font_cache[size] - + # 首先尝试加载自定义字体 try: - font = ImageFont.truetype("data/font.ttf", size) + font_path = os.path.join(get_astrbot_data_path(), "font.ttf") + font = ImageFont.truetype(font_path, size) cls._font_cache[size] = font return font except Exception: pass - + # 跨平台常见字体列表 fonts = [ - "msyh.ttc", # Windows + "msyh.ttc", # Windows "NotoSansCJK-Regular.ttc", # Linux - "msyhbd.ttc", # Windows - "PingFang.ttc", # macOS - "Heiti.ttc", # macOS - "Arial.ttf", # 通用 - "DejaVuSans.ttf", # Linux + "msyhbd.ttc", # Windows + "PingFang.ttc", # macOS + "Heiti.ttc", # macOS + "Arial.ttf", # 通用 + "DejaVuSans.ttf", # Linux ] - + for font_name in fonts: try: font = ImageFont.truetype(font_name, size) @@ -49,7 +52,7 @@ class FontManager: return font except Exception: continue - + # 如果所有字体都失败,使用默认字体 try: default_font = ImageFont.load_default() @@ -61,24 +64,30 @@ class FontManager: class TextMeasurer: """测量文本尺寸的工具类""" - + @staticmethod def get_text_size(text: str, font: ImageFont.FreeTypeFont) -> Tuple[int, int]: """获取文本的尺寸""" try: # PIL 9.0.0 以上版本 - return font.getbbox(text)[2:] if hasattr(font, 'getbbox') else font.getsize(text) + return ( + font.getbbox(text)[2:] + if hasattr(font, "getbbox") + else font.getsize(text) + ) except Exception: # 兼容旧版本 return font.getsize(text) @staticmethod - def split_text_to_fit_width(text: str, font: ImageFont.FreeTypeFont, max_width: int) -> List[str]: + def split_text_to_fit_width( + text: str, font: ImageFont.FreeTypeFont, max_width: int + ) -> List[str]: """将文本拆分为多行,确保每行不超过指定宽度""" lines = [] if not text: return lines - + remaining_text = text while remaining_text: # 如果文本宽度小于最大宽度,直接添加 @@ -86,7 +95,7 @@ class TextMeasurer: if text_width <= max_width: lines.append(remaining_text) break - + # 尝试逐字计算能放入当前行的最多字符 for i in range(len(remaining_text), 0, -1): width = TextMeasurer.get_text_size(remaining_text[:i], font)[0] @@ -98,69 +107,99 @@ class TextMeasurer: # 如果单个字符都放不下,强制放一个字符 lines.append(remaining_text[0]) remaining_text = remaining_text[1:] - + return lines class MarkdownElement(ABC): """Markdown元素的基类""" - + def __init__(self, content: str): self.content = content - + @abstractmethod def calculate_height(self, image_width: int, font_size: int) -> int: """计算元素的高度""" pass - + @abstractmethod - def render(self, image: Image.Image, draw: ImageDraw.Draw, x: int, y: int, image_width: int, font_size: int) -> int: + def render( + self, + image: Image.Image, + draw: ImageDraw.Draw, + x: int, + y: int, + image_width: int, + font_size: int, + ) -> int: """渲染元素到图像,返回新的y坐标""" pass class TextElement(MarkdownElement): """普通文本元素""" - + def calculate_height(self, image_width: int, font_size: int) -> int: if not self.content.strip(): return 10 # 空行高度 - + font = FontManager.get_font(font_size) - lines = TextMeasurer.split_text_to_fit_width(self.content, font, image_width - 20) + lines = TextMeasurer.split_text_to_fit_width( + self.content, font, image_width - 20 + ) return len(lines) * (font_size + 8) - - def render(self, image: Image.Image, draw: ImageDraw.Draw, x: int, y: int, image_width: int, font_size: int) -> int: + + def render( + self, + image: Image.Image, + draw: ImageDraw.Draw, + x: int, + y: int, + image_width: int, + font_size: int, + ) -> int: if not self.content.strip(): return y + 10 # 空行 - + font = FontManager.get_font(font_size) - lines = TextMeasurer.split_text_to_fit_width(self.content, font, image_width - 20) - + lines = TextMeasurer.split_text_to_fit_width( + self.content, font, image_width - 20 + ) + for line in lines: draw.text((x, y), line, font=font, fill=(0, 0, 0)) y += font_size + 8 - + return y class BoldTextElement(MarkdownElement): """粗体文本元素""" - + def calculate_height(self, image_width: int, font_size: int) -> int: font = FontManager.get_font(font_size) - lines = TextMeasurer.split_text_to_fit_width(self.content, font, image_width - 20) + lines = TextMeasurer.split_text_to_fit_width( + self.content, font, image_width - 20 + ) return len(lines) * (font_size + 8) - - def render(self, image: Image.Image, draw: ImageDraw.Draw, x: int, y: int, image_width: int, font_size: int) -> int: + + def render( + self, + image: Image.Image, + draw: ImageDraw.Draw, + x: int, + y: int, + image_width: int, + font_size: int, + ) -> int: # 尝试使用粗体字体,如果没有则绘制两次模拟粗体效果 try: bold_fonts = [ - "msyhbd.ttc", # 微软雅黑粗体 (Windows) + "msyhbd.ttc", # 微软雅黑粗体 (Windows) "Arial-Bold.ttf", # Arial粗体 "DejaVuSans-Bold.ttf", # Linux粗体 ] - + bold_font = None for font_name in bold_fonts: try: @@ -168,48 +207,64 @@ class BoldTextElement(MarkdownElement): break except Exception: continue - + if bold_font: - lines = TextMeasurer.split_text_to_fit_width(self.content, bold_font, image_width - 20) + lines = TextMeasurer.split_text_to_fit_width( + self.content, bold_font, image_width - 20 + ) for line in lines: draw.text((x, y), line, font=bold_font, fill=(0, 0, 0)) y += font_size + 8 else: # 如果没有粗体字体,则绘制两次文本轻微偏移以模拟粗体 font = FontManager.get_font(font_size) - lines = TextMeasurer.split_text_to_fit_width(self.content, font, image_width - 20) + lines = TextMeasurer.split_text_to_fit_width( + self.content, font, image_width - 20 + ) for line in lines: draw.text((x, y), line, font=font, fill=(0, 0, 0)) - draw.text((x+1, y), line, font=font, fill=(0, 0, 0)) + draw.text((x + 1, y), line, font=font, fill=(0, 0, 0)) y += font_size + 8 except Exception: # 兜底方案:使用普通字体 font = FontManager.get_font(font_size) - lines = TextMeasurer.split_text_to_fit_width(self.content, font, image_width - 20) + lines = TextMeasurer.split_text_to_fit_width( + self.content, font, image_width - 20 + ) for line in lines: draw.text((x, y), line, font=font, fill=(0, 0, 0)) y += font_size + 8 - + return y class ItalicTextElement(MarkdownElement): """斜体文本元素""" - + def calculate_height(self, image_width: int, font_size: int) -> int: font = FontManager.get_font(font_size) - lines = TextMeasurer.split_text_to_fit_width(self.content, font, image_width - 20) + lines = TextMeasurer.split_text_to_fit_width( + self.content, font, image_width - 20 + ) return len(lines) * (font_size + 8) - - def render(self, image: Image.Image, draw: ImageDraw.Draw, x: int, y: int, image_width: int, font_size: int) -> int: + + def render( + self, + image: Image.Image, + draw: ImageDraw.Draw, + x: int, + y: int, + image_width: int, + font_size: int, + ) -> int: # 尝试使用斜体字体,如果没有则使用倾斜变换模拟斜体效果 try: italic_fonts = [ - "msyhi.ttc", # 微软雅黑斜体 (Windows) + "msyhi.ttc", # 微软雅黑斜体 (Windows) "Arial-Italic.ttf", # Arial斜体 "DejaVuSans-Oblique.ttf", # Linux斜体 ] - + italic_font = None for font_name in italic_fonts: try: @@ -217,312 +272,388 @@ class ItalicTextElement(MarkdownElement): break except Exception: continue - + if italic_font: - lines = TextMeasurer.split_text_to_fit_width(self.content, italic_font, image_width - 20) + lines = TextMeasurer.split_text_to_fit_width( + self.content, italic_font, image_width - 20 + ) for line in lines: draw.text((x, y), line, font=italic_font, fill=(0, 0, 0)) y += font_size + 8 else: # 如果没有斜体字体,使用变换 font = FontManager.get_font(font_size) - lines = TextMeasurer.split_text_to_fit_width(self.content, font, image_width - 20) - + lines = TextMeasurer.split_text_to_fit_width( + self.content, font, image_width - 20 + ) + for line in lines: # 先创建一个临时图像用于倾斜处理 text_width, text_height = TextMeasurer.get_text_size(line, font) - text_img = Image.new('RGBA', (text_width + 20, text_height + 10), (0, 0, 0, 0)) + text_img = Image.new( + "RGBA", (text_width + 20, text_height + 10), (0, 0, 0, 0) + ) text_draw = ImageDraw.Draw(text_img) text_draw.text((0, 0), line, font=font, fill=(0, 0, 0, 255)) - + # 倾斜变换,使用仿射变换实现斜体效果 # 变换矩阵: [1, 0.2, 0, 0, 1, 0] italic_img = text_img.transform( - text_img.size, - Image.AFFINE, - (1, 0.2, 0, 0, 1, 0), - Image.BICUBIC + text_img.size, Image.AFFINE, (1, 0.2, 0, 0, 1, 0), Image.BICUBIC ) - + # 粘贴到原图像 image.paste(italic_img, (x, y), italic_img) y += font_size + 8 except Exception: # 兜底方案:使用普通字体 font = FontManager.get_font(font_size) - lines = TextMeasurer.split_text_to_fit_width(self.content, font, image_width - 20) + lines = TextMeasurer.split_text_to_fit_width( + self.content, font, image_width - 20 + ) for line in lines: draw.text((x, y), line, font=font, fill=(0, 0, 0)) y += font_size + 8 - + return y class UnderlineTextElement(MarkdownElement): """下划线文本元素""" - + def calculate_height(self, image_width: int, font_size: int) -> int: font = FontManager.get_font(font_size) - lines = TextMeasurer.split_text_to_fit_width(self.content, font, image_width - 20) + lines = TextMeasurer.split_text_to_fit_width( + self.content, font, image_width - 20 + ) return len(lines) * (font_size + 8) - - def render(self, image: Image.Image, draw: ImageDraw.Draw, x: int, y: int, image_width: int, font_size: int) -> int: + + def render( + self, + image: Image.Image, + draw: ImageDraw.Draw, + x: int, + y: int, + image_width: int, + font_size: int, + ) -> int: font = FontManager.get_font(font_size) - lines = TextMeasurer.split_text_to_fit_width(self.content, font, image_width - 20) - + lines = TextMeasurer.split_text_to_fit_width( + self.content, font, image_width - 20 + ) + for line in lines: # 绘制文本 draw.text((x, y), line, font=font, fill=(0, 0, 0)) - + # 绘制下划线 text_width, _ = TextMeasurer.get_text_size(line, font) underline_y = y + font_size + 2 - draw.line((x, underline_y, x + text_width, underline_y), fill=(0, 0, 0), width=1) - + draw.line( + (x, underline_y, x + text_width, underline_y), fill=(0, 0, 0), width=1 + ) + y += font_size + 8 - + return y class StrikethroughTextElement(MarkdownElement): """删除线文本元素""" - + def calculate_height(self, image_width: int, font_size: int) -> int: font = FontManager.get_font(font_size) - lines = TextMeasurer.split_text_to_fit_width(self.content, font, image_width - 20) + lines = TextMeasurer.split_text_to_fit_width( + self.content, font, image_width - 20 + ) return len(lines) * (font_size + 8) - - def render(self, image: Image.Image, draw: ImageDraw.Draw, x: int, y: int, image_width: int, font_size: int) -> int: + + def render( + self, + image: Image.Image, + draw: ImageDraw.Draw, + x: int, + y: int, + image_width: int, + font_size: int, + ) -> int: font = FontManager.get_font(font_size) - lines = TextMeasurer.split_text_to_fit_width(self.content, font, image_width - 20) - + lines = TextMeasurer.split_text_to_fit_width( + self.content, font, image_width - 20 + ) + for line in lines: # 绘制文本 draw.text((x, y), line, font=font, fill=(0, 0, 0)) - + # 绘制删除线 text_width, _ = TextMeasurer.get_text_size(line, font) strike_y = y + font_size // 2 draw.line((x, strike_y, x + text_width, strike_y), fill=(0, 0, 0), width=1) - + y += font_size + 8 - + return y class HeaderElement(MarkdownElement): """标题元素""" - + def __init__(self, content: str): # 去除开头的 # 并计算级别 level = 0 for char in content: - if char == '#': + if char == "#": level += 1 else: break - + super().__init__(content[level:].strip()) self.level = min(level, 6) # h1-h6 - + def calculate_height(self, image_width: int, font_size: int) -> int: header_font_size = 42 - (self.level - 1) * 4 font = FontManager.get_font(header_font_size) - lines = TextMeasurer.split_text_to_fit_width(self.content, font, image_width - 20) + lines = TextMeasurer.split_text_to_fit_width( + self.content, font, image_width - 20 + ) return len(lines) * header_font_size + 30 # 包含上下间距和分隔线 - - def render(self, image: Image.Image, draw: ImageDraw.Draw, x: int, y: int, image_width: int, font_size: int) -> int: + + def render( + self, + image: Image.Image, + draw: ImageDraw.Draw, + x: int, + y: int, + image_width: int, + font_size: int, + ) -> int: header_font_size = 42 - (self.level - 1) * 4 font = FontManager.get_font(header_font_size) - + y += 10 # 上间距 draw.text((x, y), self.content, font=font, fill=(0, 0, 0)) - + # 添加分隔线 y += header_font_size + 8 - draw.line( - (x, y, image_width - 10, y), - fill=(230, 230, 230), - width=3 - ) - + draw.line((x, y, image_width - 10, y), fill=(230, 230, 230), width=3) + return y + 10 # 返回包含下间距的新y坐标 class QuoteElement(MarkdownElement): """引用元素""" - + def __init__(self, content: str): # 去除开头的 > super().__init__(content[1:].strip()) - + def calculate_height(self, image_width: int, font_size: int) -> int: font = FontManager.get_font(font_size) - lines = TextMeasurer.split_text_to_fit_width(self.content, font, image_width - 30) # 左边留出引用线的空间 + lines = TextMeasurer.split_text_to_fit_width( + self.content, font, image_width - 30 + ) # 左边留出引用线的空间 return len(lines) * (font_size + 6) + 12 # 包含上下间距 - - def render(self, image: Image.Image, draw: ImageDraw.Draw, x: int, y: int, image_width: int, font_size: int) -> int: + + def render( + self, + image: Image.Image, + draw: ImageDraw.Draw, + x: int, + y: int, + image_width: int, + font_size: int, + ) -> int: font = FontManager.get_font(font_size) - lines = TextMeasurer.split_text_to_fit_width(self.content, font, image_width - 30) - + lines = TextMeasurer.split_text_to_fit_width( + self.content, font, image_width - 30 + ) + total_height = len(lines) * (font_size + 6) - + # 绘制引用线 quote_line_x = x + 3 draw.line( (quote_line_x, y + 6, quote_line_x, y + total_height + 6), fill=(180, 180, 180), - width=5 + width=5, ) - + # 绘制文本 text_x = x + 15 text_y = y + 6 for line in lines: draw.text((text_x, text_y), line, font=font, fill=(180, 180, 180)) text_y += font_size + 6 - + return y + total_height + 12 class ListItemElement(MarkdownElement): """列表项元素""" - + def calculate_height(self, image_width: int, font_size: int) -> int: font = FontManager.get_font(font_size) - lines = TextMeasurer.split_text_to_fit_width(self.content, font, image_width - 30) # 左边留出项目符号的空间 + lines = TextMeasurer.split_text_to_fit_width( + self.content, font, image_width - 30 + ) # 左边留出项目符号的空间 return len(lines) * (font_size + 6) + 16 # 包含上下间距 - - def render(self, image: Image.Image, draw: ImageDraw.Draw, x: int, y: int, image_width: int, font_size: int) -> int: + + def render( + self, + image: Image.Image, + draw: ImageDraw.Draw, + x: int, + y: int, + image_width: int, + font_size: int, + ) -> int: font = FontManager.get_font(font_size) - lines = TextMeasurer.split_text_to_fit_width(self.content, font, image_width - 30) - + lines = TextMeasurer.split_text_to_fit_width( + self.content, font, image_width - 30 + ) + y += 8 # 上间距 - + # 绘制项目符号 bullet_x = x + 5 draw.text((bullet_x, y), "•", font=font, fill=(0, 0, 0)) - + # 绘制文本 text_x = x + 25 text_y = y for line in lines: draw.text((text_x, text_y), line, font=font, fill=(0, 0, 0)) text_y += font_size + 6 - + return text_y + 8 # 包含下间距 class CodeBlockElement(MarkdownElement): """代码块元素""" - + def __init__(self, content: List[str]): super().__init__("\n".join(content)) - + def calculate_height(self, image_width: int, font_size: int) -> int: if not self.content: return 40 # 空代码块的最小高度 - + font = FontManager.get_font(font_size) lines = self.content.split("\n") wrapped_lines = [] - + for line in lines: wrapped = TextMeasurer.split_text_to_fit_width(line, font, image_width - 40) wrapped_lines.extend(wrapped) - + return len(wrapped_lines) * (font_size + 4) + 40 # 包含内边距和上下间距 - - def render(self, image: Image.Image, draw: ImageDraw.Draw, x: int, y: int, image_width: int, font_size: int) -> int: + + def render( + self, + image: Image.Image, + draw: ImageDraw.Draw, + x: int, + y: int, + image_width: int, + font_size: int, + ) -> int: font = FontManager.get_font(font_size) lines = self.content.split("\n") wrapped_lines = [] - + for line in lines: wrapped = TextMeasurer.split_text_to_fit_width(line, font, image_width - 40) wrapped_lines.extend(wrapped) - + content_height = len(wrapped_lines) * (font_size + 4) total_height = content_height + 30 # 包含内边距 - + # 绘制背景 draw.rounded_rectangle( (x, y + 5, image_width - 10, y + total_height), radius=5, fill=(240, 240, 240), - width=1 + width=1, ) - + # 绘制代码 text_y = y + 15 for line in wrapped_lines: draw.text((x + 15, text_y), line, font=font, fill=(0, 0, 0)) text_y += font_size + 4 - + return y + total_height + 10 class InlineCodeElement(MarkdownElement): """行内代码元素""" - + def calculate_height(self, image_width: int, font_size: int) -> int: return font_size + 16 # 包含内边距和上下间距 - - def render(self, image: Image.Image, draw: ImageDraw.Draw, x: int, y: int, image_width: int, font_size: int) -> int: + + def render( + self, + image: Image.Image, + draw: ImageDraw.Draw, + x: int, + y: int, + image_width: int, + font_size: int, + ) -> int: font = FontManager.get_font(font_size) - + # 计算文本大小 text_width, _ = TextMeasurer.get_text_size(self.content, font) text_height = font_size - + # 绘制背景 padding = 4 draw.rounded_rectangle( - ( - x, - y + 4, - x + text_width + padding * 2, - y + text_height + padding * 2 + 4 - ), + (x, y + 4, x + text_width + padding * 2, y + text_height + padding * 2 + 4), radius=5, fill=(230, 230, 230), - width=1 + width=1, ) - + # 绘制文本 - draw.text((x + padding, y + padding + 4), self.content, font=font, fill=(0, 0, 0)) - + draw.text( + (x + padding, y + padding + 4), self.content, font=font, fill=(0, 0, 0) + ) + return y + text_height + 16 # 返回新的y坐标 class ImageElement(MarkdownElement): """图片元素""" - + def __init__(self, content: str, image_url: str): super().__init__(content) self.image_url = image_url self.image = None - + async def load_image(self): """加载图片""" try: ssl_context = ssl.create_default_context(cafile=certifi.where()) connector = aiohttp.TCPConnector(ssl=ssl_context) - - async with aiohttp.ClientSession(trust_env=True, connector=connector) as session: + + async with aiohttp.ClientSession( + trust_env=True, connector=connector + ) as session: async with session.get(self.image_url) as resp: - if (resp.status == 200): + if resp.status == 200: image_data = await resp.read() self.image = Image.open(BytesIO(image_data)) else: print(f"Failed to load image: HTTP {resp.status}") except Exception as e: print(f"Failed to load image: {e}") - + def calculate_height(self, image_width: int, font_size: int) -> int: if self.image is None: return font_size + 20 # 图片加载失败的默认高度 - + # 计算调整大小后的图片高度 max_width = image_width * 0.8 if self.image.width > max_width: @@ -530,52 +661,60 @@ class ImageElement(MarkdownElement): height = int(self.image.height * ratio) else: height = self.image.height - + return height + 30 # 包含上下间距 - - def render(self, image: Image.Image, draw: ImageDraw.Draw, x: int, y: int, image_width: int, font_size: int) -> int: + + def render( + self, + image: Image.Image, + draw: ImageDraw.Draw, + x: int, + y: int, + image_width: int, + font_size: int, + ) -> int: if self.image is None: # 图片加载失败 font = FontManager.get_font(font_size) draw.text((x, y + 10), "[图片加载失败]", font=font, fill=(255, 0, 0)) return y + font_size + 20 - + # 调整图片大小 max_width = image_width * 0.8 pasted_image = self.image - + if pasted_image.width > max_width: ratio = max_width / pasted_image.width new_size = (int(max_width), int(pasted_image.height * ratio)) pasted_image = pasted_image.resize(new_size, Image.LANCZOS) - + # 计算居中位置 paste_x = x + (image_width - pasted_image.width) // 2 - 10 - + # 粘贴图片 - if pasted_image.mode == 'RGBA': + if pasted_image.mode == "RGBA": # 处理透明图片 image.paste(pasted_image, (paste_x, y + 15), pasted_image) else: image.paste(pasted_image, (paste_x, y + 15)) - + return y + pasted_image.height + 30 class MarkdownParser: """Markdown解析器,将文本解析为元素""" - + @staticmethod async def parse(text: str) -> List[MarkdownElement]: elements = [] - lines = text.split('\n') - + lines = text.split("\n") + i = 0 while i < len(lines): line = lines[i].rstrip() - + # 图片检测 - image_match = re.search(r'!\s*\[(.*?)\]\s*\((.*?)\)', line) + image_match = re.search(r"!\s*\[(.*?)\]\s*\((.*?)\)", line) if image_match: image_url = image_match.group(2) element = ImageElement(line, image_url) @@ -583,101 +722,108 @@ class MarkdownParser: elements.append(element) i += 1 continue - + # 标题 - if line.startswith('#'): + if line.startswith("#"): elements.append(HeaderElement(line)) i += 1 continue - + # 引用 - if line.startswith('>'): + if line.startswith(">"): elements.append(QuoteElement(line)) i += 1 continue - + # 列表项 - if line.startswith('-') or line.startswith('*'): + if line.startswith("-") or line.startswith("*"): elements.append(ListItemElement(line[1:].strip())) i += 1 continue - + # 代码块 - if line.startswith('```'): + if line.startswith("```"): code_lines = [] i += 1 # 跳过开始标记行 - - while i < len(lines) and not lines[i].startswith('```'): + + while i < len(lines) and not lines[i].startswith("```"): code_lines.append(lines[i]) i += 1 - + i += 1 # 跳过结束标记行 elements.append(CodeBlockElement(code_lines)) continue - + # 检查行内样式(粗体、斜体、下划线、删除线、行内代码) - if re.search(r'(\*\*.*?\*\*)|(\*.*?\*)|(__.*?__)|(_.*?_)|(~~.*?~~)|(`.*?`)', line): + if re.search( + r"(\*\*.*?\*\*)|(\*.*?\*)|(__.*?__)|(_.*?_)|(~~.*?~~)|(`.*?`)", line + ): # 分析行内样式: # - 粗体: **text** 或 __text__ # - 斜体: *text* 或 _text_ # - 删除线: ~~text~~ # - 行内代码: `text` - + # 定义正则模式和对应的元素类型 patterns = [ - (r'\*\*(.*?)\*\*', BoldTextElement), # **粗体** - (r'__(.*?)__', BoldTextElement), # __粗体__ - (r'\*((?!\*\*).*?)\*', ItalicTextElement), # *斜体* (但不匹配 ** 开头) - (r'_((?!__).*?)_', ItalicTextElement), # _斜体_ (但不匹配 __ 开头) - (r'~~(.*?)~~', StrikethroughTextElement), # ~~删除线~~ - (r'__(.*?)__', UnderlineTextElement), # __下划线__ - (r'`(.*?)`', InlineCodeElement) # `行内代码` + (r"\*\*(.*?)\*\*", BoldTextElement), # **粗体** + (r"__(.*?)__", BoldTextElement), # __粗体__ + ( + r"\*((?!\*\*).*?)\*", + ItalicTextElement, + ), # *斜体* (但不匹配 ** 开头) + (r"_((?!__).*?)_", ItalicTextElement), # _斜体_ (但不匹配 __ 开头) + (r"~~(.*?)~~", StrikethroughTextElement), # ~~删除线~~ + (r"__(.*?)__", UnderlineTextElement), # __下划线__ + (r"`(.*?)`", InlineCodeElement), # `行内代码` ] - + # 创建标记位置列表 markers = [] for pattern, element_class in patterns: for match in re.finditer(pattern, line): - markers.append({ - 'start': match.start(), - 'end': match.end(), - 'text': match.group(1), # 提取内容部分 - 'element_class': element_class - }) - + markers.append( + { + "start": match.start(), + "end": match.end(), + "text": match.group(1), # 提取内容部分 + "element_class": element_class, + } + ) + # 按开始位置排序 - markers.sort(key=lambda x: x['start']) - + markers.sort(key=lambda x: x["start"]) + # 如果没有找到任何匹配,直接添加为普通文本 if not markers: elements.append(TextElement(line)) i += 1 continue - + # 处理每个文本片段 current_pos = 0 for marker in markers: # 添加前面的普通文本 - if marker['start'] > current_pos: - normal_text = line[current_pos:marker['start']] + if marker["start"] > current_pos: + normal_text = line[current_pos : marker["start"]] if normal_text: elements.append(TextElement(normal_text)) - + # 添加特殊样式的文本 - elements.append(marker['element_class'](marker['text'])) - current_pos = marker['end'] - + elements.append(marker["element_class"](marker["text"])) + current_pos = marker["end"] + # 添加最后一段普通文本 if current_pos < len(line): elements.append(TextElement(line[current_pos:])) - + i += 1 continue - + # 行内代码 (如果之前没匹配到混合样式) - inline_code_matches = re.findall(r'`([^`]+)`', line) + inline_code_matches = re.findall(r"`([^`]+)`", line) if inline_code_matches: - parts = re.split(r'`([^`]+)`', line) + parts = re.split(r"`([^`]+)`", line) for j, part in enumerate(parts): if j % 2 == 0: # 普通文本 if part: @@ -686,88 +832,90 @@ class MarkdownParser: elements.append(InlineCodeElement(part)) i += 1 continue - + # 普通文本 elements.append(TextElement(line)) i += 1 - + return elements class MarkdownRenderer: """Markdown渲染器,将元素渲染为图像""" - - def __init__(self, font_size: int = 26, width: int = 800, bg_color: Tuple[int, int, int] = (255, 255, 255)): + + def __init__( + self, + font_size: int = 26, + width: int = 800, + bg_color: Tuple[int, int, int] = (255, 255, 255), + ): self.font_size = font_size self.width = width self.bg_color = bg_color - + async def render(self, markdown_text: str) -> Image.Image: # 解析Markdown文本 elements = await MarkdownParser.parse(markdown_text) - + # 计算总高度 total_height = 20 # 初始边距 for element in elements: total_height += element.calculate_height(self.width, self.font_size) - + # 为页脚添加额外空间 footer_height = 40 total_height += 20 + footer_height # 结束边距 + 页脚高度 - + # 创建图像 - image = Image.new('RGB', (self.width, max(100, total_height)), self.bg_color) + image = Image.new("RGB", (self.width, max(100, total_height)), self.bg_color) draw = ImageDraw.Draw(image) - + # 渲染元素 y = 10 for element in elements: y = element.render(image, draw, 10, y, self.width, self.font_size) - + # 添加页脚 # 克莱因蓝色,近似RGB为(0, 47, 167) klein_blue = (0, 47, 167) # 灰色 grey_color = (130, 130, 130) - + # 绘制"Powered by AstrBot"文本 footer_font_size = 20 footer_font = FontManager.get_font(footer_font_size) - + # 获取"Powered by "和"AstrBot"的宽度以便居中 powered_by_text = "Powered by " astrbot_text = f"AstrBot v{VERSION}" - + powered_by_width, _ = TextMeasurer.get_text_size(powered_by_text, footer_font) astrbot_width, _ = TextMeasurer.get_text_size(astrbot_text, footer_font) - + total_width = powered_by_width + astrbot_width x_start = (self.width - total_width) // 2 - + footer_y = total_height - footer_height - + # 绘制"Powered by "(灰色) draw.text( - (x_start, footer_y), - powered_by_text, - font=footer_font, - fill=grey_color + (x_start, footer_y), powered_by_text, font=footer_font, fill=grey_color ) - + # 绘制"AstrBot"(克莱因蓝) draw.text( - (x_start + powered_by_width, footer_y), - astrbot_text, - font=footer_font, - fill=klein_blue + (x_start + powered_by_width, footer_y), + astrbot_text, + font=footer_font, + fill=klein_blue, ) - + return image class LocalRenderStrategy(RenderStrategy): """本地渲染策略实现""" - + async def render_custom_template( self, tmpl_str: str, tmpl_data: dict, return_url: bool = True ) -> str: @@ -776,9 +924,9 @@ class LocalRenderStrategy(RenderStrategy): async def render(self, text: str, return_url: bool = False) -> str: # 创建渲染器 renderer = MarkdownRenderer(font_size=26, width=800) - + # 渲染Markdown文本 image = await renderer.render(text) - + # 保存图像并返回路径/URL return save_temp_img(image) diff --git a/astrbot/dashboard/routes/chat.py b/astrbot/dashboard/routes/chat.py index d767ddea4..17e8b115e 100644 --- a/astrbot/dashboard/routes/chat.py +++ b/astrbot/dashboard/routes/chat.py @@ -8,6 +8,7 @@ from astrbot.core.db import BaseDatabase import asyncio from astrbot.core import logger from astrbot.core.core_lifecycle import AstrBotCoreLifecycle +from astrbot.core.utils.astrbot_path import get_astrbot_data_path class ChatRoute(Route): @@ -33,7 +34,8 @@ class ChatRoute(Route): self.db = db self.core_lifecycle = core_lifecycle self.register_routes() - self.imgs_dir = "data/webchat/imgs" + self.imgs_dir = os.path.join(get_astrbot_data_path(), "webchat", "imgs") + os.makedirs(self.imgs_dir, exist_ok=True) self.supported_imgs = ["jpg", "jpeg", "png", "gif", "webp"] diff --git a/astrbot/dashboard/server.py b/astrbot/dashboard/server.py index c85ada4e2..e4e4d60b7 100644 --- a/astrbot/dashboard/server.py +++ b/astrbot/dashboard/server.py @@ -125,7 +125,10 @@ class AstrBotDashboard: def run(self): ip_addr = [] - port = self.core_lifecycle.astrbot_config["dashboard"].get("port", 6185) + if p := os.environ.get("DASHBOARD_PORT"): + port = p + else: + port = self.core_lifecycle.astrbot_config["dashboard"].get("port", 6185) host = self.core_lifecycle.astrbot_config["dashboard"].get("host", "0.0.0.0") logger.info(f"正在启动 WebUI, 监听地址: http://{host}:{port}") diff --git a/packages/astrbot/main.py b/packages/astrbot/main.py index 2f7b8ee32..613922eff 100644 --- a/packages/astrbot/main.py +++ b/packages/astrbot/main.py @@ -1,3 +1,4 @@ +import os import aiohttp import datetime import builtins @@ -13,6 +14,7 @@ from astrbot.core.platform.astr_message_event import MessageSesion from astrbot.core.platform.message_type import MessageType from astrbot.core.provider.sources.dify_source import ProviderDify from astrbot.core.utils.io import download_dashboard, get_dashboard_version +from astrbot.core.utils.astrbot_path import get_astrbot_data_path from astrbot.core.star.star_handler import star_handlers_registry, StarHandlerMetadata from astrbot.core.star.star import star_map from astrbot.core.star.star_manager import PluginManager @@ -1161,7 +1163,8 @@ UID: {user_id} 此 ID 可用于设置管理员。 @filter.command("gewe_code") async def gewe_code(self, event: AstrMessageEvent, code: str): """保存 gewechat 验证码""" - with open("data/temp/gewe_code", "w", encoding="utf-8") as f: + code_path = os.path.join(get_astrbot_data_path(), "temp","gewe_code") + with open(code_path, "w", encoding="utf-8") as f: f.write(code) yield event.plain_result("验证码已保存。") diff --git a/packages/python_interpreter/main.py b/packages/python_interpreter/main.py index 20eae0c3c..84d431b36 100644 --- a/packages/python_interpreter/main.py +++ b/packages/python_interpreter/main.py @@ -15,6 +15,7 @@ from astrbot.api.event import filter from astrbot.api.provider import ProviderRequest from astrbot.api.message_components import Image, File from astrbot.core.utils.io import download_image_by_url, download_file +from astrbot.core.utils.astrbot_path import get_astrbot_data_path PROMPT = """ ## Task @@ -90,7 +91,7 @@ DEFAULT_CONFIG = { }, "docker_host_astrbot_abs_path": "", } -PATH = "data/config/python_interpreter.json" +PATH = os.path.join(get_astrbot_data_path(), "config", "python_interpreter.json") @star.register( @@ -212,7 +213,8 @@ class Main(star.Star): if isinstance(comp, File): if comp.file.startswith("http"): name = comp.name if comp.name else uuid.uuid4().hex[:8] - path = f"data/temp/{name}" + temp_dir = os.path.join(get_astrbot_data_path(), "temp") + path = os.path.join(temp_dir, name) await download_file(comp.file, path) else: path = comp.file diff --git a/packages/reminder/main.py b/packages/reminder/main.py index d72624ef4..b15add544 100644 --- a/packages/reminder/main.py +++ b/packages/reminder/main.py @@ -8,6 +8,7 @@ from astrbot.api.event import filter from apscheduler.schedulers.asyncio import AsyncIOScheduler from astrbot.api.event import AstrMessageEvent, MessageEventResult from astrbot.api import llm_tool, logger +from astrbot.core.utils.astrbot_path import get_astrbot_data_path @star.register( @@ -29,10 +30,11 @@ class Main(star.Star): self.scheduler = AsyncIOScheduler(timezone=self.timezone) # set and load config - if not os.path.exists("data/astrbot-reminder.json"): - with open("data/astrbot-reminder.json", "w", encoding="utf-8") as f: + reminder_file = os.path.join(get_astrbot_data_path(), "astrbot-reminder.json") + if not os.path.exists(reminder_file): + with open(reminder_file, "w", encoding="utf-8") as f: f.write("{}") - with open("data/astrbot-reminder.json", "r", encoding="utf-8") as f: + with open(reminder_file, "r", encoding="utf-8") as f: self.reminder_data = json.load(f) self._init_scheduler() @@ -82,7 +84,8 @@ class Main(star.Star): async def _save_data(self): """Save the reminder data.""" - with open("data/astrbot-reminder.json", "w", encoding="utf-8") as f: + reminder_file = os.path.join(get_astrbot_data_path(), "astrbot-reminder.json") + with open(reminder_file, "w", encoding="utf-8") as f: json.dump(self.reminder_data, f, ensure_ascii=False) def _parse_cron_expr(self, cron_expr: str): diff --git a/pyproject.toml b/pyproject.toml index d7e7f8a44..e514fb0ea 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,6 +19,7 @@ dependencies = [ "defusedxml>=0.7.1", "dingtalk-stream>=0.22.1", "docstring-parser>=0.16", + "filelock>=3.18.0", "google-genai>=1.10.0", "googlesearch-python>=1.3.0", "lark-oapi>=1.4.12", @@ -38,6 +39,7 @@ dependencies = [ "readability-lxml>=0.8.1", "silk-python>=0.2.6", "telegramify-markdown>=0.5.0", + "watchfiles>=1.0.5", "wechatpy>=1.8.18", ] diff --git a/requirements.txt b/requirements.txt index 2e0ab1ccc..f800f131e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,6 @@ -pydantic~=2.10.3 aiohttp +pydantic~=2.10.3 +psutil>=5.8.0 openai anthropic qq-botpy @@ -17,7 +18,6 @@ apscheduler docstring_parser aiodocker silk-python -psutil>=5.8.0 lark-oapi ormsgpack cryptography @@ -30,4 +30,7 @@ mcp certifi pip telegramify-markdown -google-genai \ No newline at end of file +google-genai +click +filelock +watchfiles \ No newline at end of file diff --git a/uv.lock b/uv.lock index 7f40f2e51..815d2ac6c 100644 --- a/uv.lock +++ b/uv.lock @@ -209,6 +209,7 @@ dependencies = [ { name = "defusedxml" }, { name = "dingtalk-stream" }, { name = "docstring-parser" }, + { name = "filelock" }, { name = "google-genai" }, { name = "googlesearch-python" }, { name = "lark-oapi" }, @@ -228,6 +229,7 @@ dependencies = [ { name = "readability-lxml" }, { name = "silk-python" }, { name = "telegramify-markdown" }, + { name = "watchfiles" }, { name = "wechatpy" }, ] @@ -247,6 +249,7 @@ requires-dist = [ { name = "defusedxml", specifier = ">=0.7.1" }, { name = "dingtalk-stream", specifier = ">=0.22.1" }, { name = "docstring-parser", specifier = ">=0.16" }, + { name = "filelock", specifier = ">=3.18.0" }, { name = "google-genai", specifier = ">=1.10.0" }, { name = "googlesearch-python", specifier = ">=1.3.0" }, { name = "lark-oapi", specifier = ">=1.4.12" }, @@ -266,6 +269,7 @@ requires-dist = [ { name = "readability-lxml", specifier = ">=0.8.1" }, { name = "silk-python", specifier = ">=0.2.6" }, { name = "telegramify-markdown", specifier = ">=0.5.0" }, + { name = "watchfiles", specifier = ">=1.0.5" }, { name = "wechatpy", specifier = ">=1.8.18" }, ] @@ -604,6 +608,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/02/cc/b7e31358aac6ed1ef2bb790a9746ac2c69bcb3c8588b41616914eb106eaf/exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b", size = 16453 }, ] +[[package]] +name = "filelock" +version = "3.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0a/10/c23352565a6544bdc5353e0b15fc1c563352101f30e24bf500207a54df9a/filelock-3.18.0.tar.gz", hash = "sha256:adbc88eabb99d2fec8c9c1b229b171f18afa655400173ddc653d5d01501fb9f2", size = 18075 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/36/2a115987e2d8c300a974597416d9de88f2444426de9571f4b59b2cca3acc/filelock-3.18.0-py3-none-any.whl", hash = "sha256:c401f4f8377c4464e6db25fff06205fd89bdd83b65eb0488ed1b160f780e21de", size = 16215 }, +] + [[package]] name = "flask" version = "3.1.0" @@ -2076,6 +2089,71 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b1/4b/4cef6ce21a2aaca9d852a6e84ef4f135d99fcd74fa75105e2fc0c8308acd/uvicorn-0.34.2-py3-none-any.whl", hash = "sha256:deb49af569084536d269fe0a6d67e3754f104cf03aba7c11c40f01aadf33c403", size = 62483 }, ] +[[package]] +name = "watchfiles" +version = "1.0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/03/e2/8ed598c42057de7aa5d97c472254af4906ff0a59a66699d426fc9ef795d7/watchfiles-1.0.5.tar.gz", hash = "sha256:b7529b5dcc114679d43827d8c35a07c493ad6f083633d573d81c660abc5979e9", size = 94537 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/af/4d/d02e6ea147bb7fff5fd109c694a95109612f419abed46548a930e7f7afa3/watchfiles-1.0.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:5c40fe7dd9e5f81e0847b1ea64e1f5dd79dd61afbedb57759df06767ac719b40", size = 405632 }, + { url = "https://files.pythonhosted.org/packages/60/31/9ee50e29129d53a9a92ccf1d3992751dc56fc3c8f6ee721be1c7b9c81763/watchfiles-1.0.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8c0db396e6003d99bb2d7232c957b5f0b5634bbd1b24e381a5afcc880f7373fb", size = 395734 }, + { url = "https://files.pythonhosted.org/packages/ad/8c/759176c97195306f028024f878e7f1c776bda66ccc5c68fa51e699cf8f1d/watchfiles-1.0.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b551d4fb482fc57d852b4541f911ba28957d051c8776e79c3b4a51eb5e2a1b11", size = 455008 }, + { url = "https://files.pythonhosted.org/packages/55/1a/5e977250c795ee79a0229e3b7f5e3a1b664e4e450756a22da84d2f4979fe/watchfiles-1.0.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:830aa432ba5c491d52a15b51526c29e4a4b92bf4f92253787f9726fe01519487", size = 459029 }, + { url = "https://files.pythonhosted.org/packages/e6/17/884cf039333605c1d6e296cf5be35fad0836953c3dfd2adb71b72f9dbcd0/watchfiles-1.0.5-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a16512051a822a416b0d477d5f8c0e67b67c1a20d9acecb0aafa3aa4d6e7d256", size = 488916 }, + { url = "https://files.pythonhosted.org/packages/ef/e0/bcb6e64b45837056c0a40f3a2db3ef51c2ced19fda38484fa7508e00632c/watchfiles-1.0.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfe0cbc787770e52a96c6fda6726ace75be7f840cb327e1b08d7d54eadc3bc85", size = 523763 }, + { url = "https://files.pythonhosted.org/packages/24/e9/f67e9199f3bb35c1837447ecf07e9830ec00ff5d35a61e08c2cd67217949/watchfiles-1.0.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d363152c5e16b29d66cbde8fa614f9e313e6f94a8204eaab268db52231fe5358", size = 502891 }, + { url = "https://files.pythonhosted.org/packages/23/ed/a6cf815f215632f5c8065e9c41fe872025ffea35aa1f80499f86eae922db/watchfiles-1.0.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7ee32c9a9bee4d0b7bd7cbeb53cb185cf0b622ac761efaa2eba84006c3b3a614", size = 454921 }, + { url = "https://files.pythonhosted.org/packages/92/4c/e14978599b80cde8486ab5a77a821e8a982ae8e2fcb22af7b0886a033ec8/watchfiles-1.0.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:29c7fd632ccaf5517c16a5188e36f6612d6472ccf55382db6c7fe3fcccb7f59f", size = 631422 }, + { url = "https://files.pythonhosted.org/packages/b2/1a/9263e34c3458f7614b657f974f4ee61fd72f58adce8b436e16450e054efd/watchfiles-1.0.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8e637810586e6fe380c8bc1b3910accd7f1d3a9a7262c8a78d4c8fb3ba6a2b3d", size = 625675 }, + { url = "https://files.pythonhosted.org/packages/96/1f/1803a18bd6ab04a0766386a19bcfe64641381a04939efdaa95f0e3b0eb58/watchfiles-1.0.5-cp310-cp310-win32.whl", hash = "sha256:cd47d063fbeabd4c6cae1d4bcaa38f0902f8dc5ed168072874ea11d0c7afc1ff", size = 277921 }, + { url = "https://files.pythonhosted.org/packages/c2/3b/29a89de074a7d6e8b4dc67c26e03d73313e4ecf0d6e97e942a65fa7c195e/watchfiles-1.0.5-cp310-cp310-win_amd64.whl", hash = "sha256:86c0df05b47a79d80351cd179893f2f9c1b1cae49d96e8b3290c7f4bd0ca0a92", size = 291526 }, + { url = "https://files.pythonhosted.org/packages/39/f4/41b591f59021786ef517e1cdc3b510383551846703e03f204827854a96f8/watchfiles-1.0.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:237f9be419e977a0f8f6b2e7b0475ababe78ff1ab06822df95d914a945eac827", size = 405336 }, + { url = "https://files.pythonhosted.org/packages/ae/06/93789c135be4d6d0e4f63e96eea56dc54050b243eacc28439a26482b5235/watchfiles-1.0.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0da39ff917af8b27a4bdc5a97ac577552a38aac0d260a859c1517ea3dc1a7c4", size = 395977 }, + { url = "https://files.pythonhosted.org/packages/d2/db/1cd89bd83728ca37054512d4d35ab69b5f12b8aa2ac9be3b0276b3bf06cc/watchfiles-1.0.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2cfcb3952350e95603f232a7a15f6c5f86c5375e46f0bd4ae70d43e3e063c13d", size = 455232 }, + { url = "https://files.pythonhosted.org/packages/40/90/d8a4d44ffe960517e487c9c04f77b06b8abf05eb680bed71c82b5f2cad62/watchfiles-1.0.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:68b2dddba7a4e6151384e252a5632efcaa9bc5d1c4b567f3cb621306b2ca9f63", size = 459151 }, + { url = "https://files.pythonhosted.org/packages/6c/da/267a1546f26465dead1719caaba3ce660657f83c9d9c052ba98fb8856e13/watchfiles-1.0.5-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:95cf944fcfc394c5f9de794ce581914900f82ff1f855326f25ebcf24d5397418", size = 489054 }, + { url = "https://files.pythonhosted.org/packages/b1/31/33850dfd5c6efb6f27d2465cc4c6b27c5a6f5ed53c6fa63b7263cf5f60f6/watchfiles-1.0.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ecf6cd9f83d7c023b1aba15d13f705ca7b7d38675c121f3cc4a6e25bd0857ee9", size = 523955 }, + { url = "https://files.pythonhosted.org/packages/09/84/b7d7b67856efb183a421f1416b44ca975cb2ea6c4544827955dfb01f7dc2/watchfiles-1.0.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:852de68acd6212cd6d33edf21e6f9e56e5d98c6add46f48244bd479d97c967c6", size = 502234 }, + { url = "https://files.pythonhosted.org/packages/71/87/6dc5ec6882a2254cfdd8b0718b684504e737273903b65d7338efaba08b52/watchfiles-1.0.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d5730f3aa35e646103b53389d5bc77edfbf578ab6dab2e005142b5b80a35ef25", size = 454750 }, + { url = "https://files.pythonhosted.org/packages/3d/6c/3786c50213451a0ad15170d091570d4a6554976cf0df19878002fc96075a/watchfiles-1.0.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:18b3bd29954bc4abeeb4e9d9cf0b30227f0f206c86657674f544cb032296acd5", size = 631591 }, + { url = "https://files.pythonhosted.org/packages/1b/b3/1427425ade4e359a0deacce01a47a26024b2ccdb53098f9d64d497f6684c/watchfiles-1.0.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:ba5552a1b07c8edbf197055bc9d518b8f0d98a1c6a73a293bc0726dce068ed01", size = 625370 }, + { url = "https://files.pythonhosted.org/packages/15/ba/f60e053b0b5b8145d682672024aa91370a29c5c921a88977eb565de34086/watchfiles-1.0.5-cp311-cp311-win32.whl", hash = "sha256:2f1fefb2e90e89959447bc0420fddd1e76f625784340d64a2f7d5983ef9ad246", size = 277791 }, + { url = "https://files.pythonhosted.org/packages/50/ed/7603c4e164225c12c0d4e8700b64bb00e01a6c4eeea372292a3856be33a4/watchfiles-1.0.5-cp311-cp311-win_amd64.whl", hash = "sha256:b6e76ceb1dd18c8e29c73f47d41866972e891fc4cc7ba014f487def72c1cf096", size = 291622 }, + { url = "https://files.pythonhosted.org/packages/a2/c2/99bb7c96b4450e36877fde33690ded286ff555b5a5c1d925855d556968a1/watchfiles-1.0.5-cp311-cp311-win_arm64.whl", hash = "sha256:266710eb6fddc1f5e51843c70e3bebfb0f5e77cf4f27129278c70554104d19ed", size = 283699 }, + { url = "https://files.pythonhosted.org/packages/2a/8c/4f0b9bdb75a1bfbd9c78fad7d8854369283f74fe7cf03eb16be77054536d/watchfiles-1.0.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b5eb568c2aa6018e26da9e6c86f3ec3fd958cee7f0311b35c2630fa4217d17f2", size = 401511 }, + { url = "https://files.pythonhosted.org/packages/dc/4e/7e15825def77f8bd359b6d3f379f0c9dac4eb09dd4ddd58fd7d14127179c/watchfiles-1.0.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0a04059f4923ce4e856b4b4e5e783a70f49d9663d22a4c3b3298165996d1377f", size = 392715 }, + { url = "https://files.pythonhosted.org/packages/58/65/b72fb817518728e08de5840d5d38571466c1b4a3f724d190cec909ee6f3f/watchfiles-1.0.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3e380c89983ce6e6fe2dd1e1921b9952fb4e6da882931abd1824c092ed495dec", size = 454138 }, + { url = "https://files.pythonhosted.org/packages/3e/a4/86833fd2ea2e50ae28989f5950b5c3f91022d67092bfec08f8300d8b347b/watchfiles-1.0.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fe43139b2c0fdc4a14d4f8d5b5d967f7a2777fd3d38ecf5b1ec669b0d7e43c21", size = 458592 }, + { url = "https://files.pythonhosted.org/packages/38/7e/42cb8df8be9a37e50dd3a818816501cf7a20d635d76d6bd65aae3dbbff68/watchfiles-1.0.5-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ee0822ce1b8a14fe5a066f93edd20aada932acfe348bede8aa2149f1a4489512", size = 487532 }, + { url = "https://files.pythonhosted.org/packages/fc/fd/13d26721c85d7f3df6169d8b495fcac8ab0dc8f0945ebea8845de4681dab/watchfiles-1.0.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a0dbcb1c2d8f2ab6e0a81c6699b236932bd264d4cef1ac475858d16c403de74d", size = 522865 }, + { url = "https://files.pythonhosted.org/packages/a1/0d/7f9ae243c04e96c5455d111e21b09087d0eeaf9a1369e13a01c7d3d82478/watchfiles-1.0.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a2014a2b18ad3ca53b1f6c23f8cd94a18ce930c1837bd891262c182640eb40a6", size = 499887 }, + { url = "https://files.pythonhosted.org/packages/8e/0f/a257766998e26aca4b3acf2ae97dff04b57071e991a510857d3799247c67/watchfiles-1.0.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10f6ae86d5cb647bf58f9f655fcf577f713915a5d69057a0371bc257e2553234", size = 454498 }, + { url = "https://files.pythonhosted.org/packages/81/79/8bf142575a03e0af9c3d5f8bcae911ee6683ae93a625d349d4ecf4c8f7df/watchfiles-1.0.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:1a7bac2bde1d661fb31f4d4e8e539e178774b76db3c2c17c4bb3e960a5de07a2", size = 630663 }, + { url = "https://files.pythonhosted.org/packages/f1/80/abe2e79f610e45c63a70d271caea90c49bbf93eb00fa947fa9b803a1d51f/watchfiles-1.0.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ab626da2fc1ac277bbf752446470b367f84b50295264d2d313e28dc4405d663", size = 625410 }, + { url = "https://files.pythonhosted.org/packages/91/6f/bc7fbecb84a41a9069c2c6eb6319f7f7df113adf113e358c57fc1aff7ff5/watchfiles-1.0.5-cp312-cp312-win32.whl", hash = "sha256:9f4571a783914feda92018ef3901dab8caf5b029325b5fe4558c074582815249", size = 277965 }, + { url = "https://files.pythonhosted.org/packages/99/a5/bf1c297ea6649ec59e935ab311f63d8af5faa8f0b86993e3282b984263e3/watchfiles-1.0.5-cp312-cp312-win_amd64.whl", hash = "sha256:360a398c3a19672cf93527f7e8d8b60d8275119c5d900f2e184d32483117a705", size = 291693 }, + { url = "https://files.pythonhosted.org/packages/7f/7b/fd01087cc21db5c47e5beae507b87965db341cce8a86f9eb12bf5219d4e0/watchfiles-1.0.5-cp312-cp312-win_arm64.whl", hash = "sha256:1a2902ede862969077b97523987c38db28abbe09fb19866e711485d9fbf0d417", size = 283287 }, + { url = "https://files.pythonhosted.org/packages/c7/62/435766874b704f39b2fecd8395a29042db2b5ec4005bd34523415e9bd2e0/watchfiles-1.0.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:0b289572c33a0deae62daa57e44a25b99b783e5f7aed81b314232b3d3c81a11d", size = 401531 }, + { url = "https://files.pythonhosted.org/packages/6e/a6/e52a02c05411b9cb02823e6797ef9bbba0bfaf1bb627da1634d44d8af833/watchfiles-1.0.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a056c2f692d65bf1e99c41045e3bdcaea3cb9e6b5a53dcaf60a5f3bd95fc9763", size = 392417 }, + { url = "https://files.pythonhosted.org/packages/3f/53/c4af6819770455932144e0109d4854437769672d7ad897e76e8e1673435d/watchfiles-1.0.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b9dca99744991fc9850d18015c4f0438865414e50069670f5f7eee08340d8b40", size = 453423 }, + { url = "https://files.pythonhosted.org/packages/cb/d1/8e88df58bbbf819b8bc5cfbacd3c79e01b40261cad0fc84d1e1ebd778a07/watchfiles-1.0.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:894342d61d355446d02cd3988a7326af344143eb33a2fd5d38482a92072d9563", size = 458185 }, + { url = "https://files.pythonhosted.org/packages/ff/70/fffaa11962dd5429e47e478a18736d4e42bec42404f5ee3b92ef1b87ad60/watchfiles-1.0.5-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ab44e1580924d1ffd7b3938e02716d5ad190441965138b4aa1d1f31ea0877f04", size = 486696 }, + { url = "https://files.pythonhosted.org/packages/39/db/723c0328e8b3692d53eb273797d9a08be6ffb1d16f1c0ba2bdbdc2a3852c/watchfiles-1.0.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d6f9367b132078b2ceb8d066ff6c93a970a18c3029cea37bfd7b2d3dd2e5db8f", size = 522327 }, + { url = "https://files.pythonhosted.org/packages/cd/05/9fccc43c50c39a76b68343484b9da7b12d42d0859c37c61aec018c967a32/watchfiles-1.0.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f2e55a9b162e06e3f862fb61e399fe9f05d908d019d87bf5b496a04ef18a970a", size = 499741 }, + { url = "https://files.pythonhosted.org/packages/23/14/499e90c37fa518976782b10a18b18db9f55ea73ca14641615056f8194bb3/watchfiles-1.0.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0125f91f70e0732a9f8ee01e49515c35d38ba48db507a50c5bdcad9503af5827", size = 453995 }, + { url = "https://files.pythonhosted.org/packages/61/d9/f75d6840059320df5adecd2c687fbc18960a7f97b55c300d20f207d48aef/watchfiles-1.0.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:13bb21f8ba3248386337c9fa51c528868e6c34a707f729ab041c846d52a0c69a", size = 629693 }, + { url = "https://files.pythonhosted.org/packages/fc/17/180ca383f5061b61406477218c55d66ec118e6c0c51f02d8142895fcf0a9/watchfiles-1.0.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:839ebd0df4a18c5b3c1b890145b5a3f5f64063c2a0d02b13c76d78fe5de34936", size = 624677 }, + { url = "https://files.pythonhosted.org/packages/bf/15/714d6ef307f803f236d69ee9d421763707899d6298d9f3183e55e366d9af/watchfiles-1.0.5-cp313-cp313-win32.whl", hash = "sha256:4a8ec1e4e16e2d5bafc9ba82f7aaecfeec990ca7cd27e84fb6f191804ed2fcfc", size = 277804 }, + { url = "https://files.pythonhosted.org/packages/a8/b4/c57b99518fadf431f3ef47a610839e46e5f8abf9814f969859d1c65c02c7/watchfiles-1.0.5-cp313-cp313-win_amd64.whl", hash = "sha256:f436601594f15bf406518af922a89dcaab416568edb6f65c4e5bbbad1ea45c11", size = 291087 }, + { url = "https://files.pythonhosted.org/packages/1a/03/81f9fcc3963b3fc415cd4b0b2b39ee8cc136c42fb10a36acf38745e9d283/watchfiles-1.0.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:f59b870db1f1ae5a9ac28245707d955c8721dd6565e7f411024fa374b5362d1d", size = 405947 }, + { url = "https://files.pythonhosted.org/packages/54/97/8c4213a852feb64807ec1d380f42d4fc8bfaef896bdbd94318f8fd7f3e4e/watchfiles-1.0.5-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:9475b0093767e1475095f2aeb1d219fb9664081d403d1dff81342df8cd707034", size = 397276 }, + { url = "https://files.pythonhosted.org/packages/78/12/d4464d19860cb9672efa45eec1b08f8472c478ed67dcd30647c51ada7aef/watchfiles-1.0.5-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fc533aa50664ebd6c628b2f30591956519462f5d27f951ed03d6c82b2dfd9965", size = 455550 }, + { url = "https://files.pythonhosted.org/packages/90/fb/b07bcdf1034d8edeaef4c22f3e9e3157d37c5071b5f9492ffdfa4ad4bed7/watchfiles-1.0.5-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fed1cd825158dcaae36acce7b2db33dcbfd12b30c34317a88b8ed80f0541cc57", size = 455542 }, +] + [[package]] name = "websocket-client" version = "1.8.0"