diff --git a/.dockerignore b/.dockerignore index 7cb6b09a8..30bd2e249 100644 --- a/.dockerignore +++ b/.dockerignore @@ -20,4 +20,5 @@ dashboard/ data/ changelogs/ tests/ -.ruff_cache/ \ No newline at end of file +.ruff_cache/ +.astrbot \ No newline at end of file diff --git a/.gitignore b/.gitignore index 5d4ef6c22..14e6bf8cb 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,4 @@ packages/python_interpreter/workplace .conda/ .idea pytest.ini +.astrbot \ No newline at end of file diff --git a/README.md b/README.md index a36e1b0fb..24ae331f1 100644 --- a/README.md +++ b/README.md @@ -78,14 +78,29 @@ AstrBot 是一个松耦合、异步、支持多消息平台部署、具有易用 #### 手动部署 -推荐使用 `uv`。 +> 推荐使用 `uv`。 + +首先,安装 uv: + +```bash +pip install uv +``` + +通过 Git Clone 安装 AstrBot: ```bash git clone https://github.com/AstrBotDevs/AstrBot && cd AstrBot -pip install uv uv run main.py ``` +或者,直接通过 uvx 安装 AstrBot: + +```bash +mkdir astrbot && cd astrbot +uvx astrbot init +# uvx astrbot run +``` + 或者请参阅官方文档 [通过源码部署 AstrBot](https://astrbot.app/deploy/astrbot/cli.html) 。 #### Replit 部署 @@ -113,21 +128,26 @@ uv run main.py | 名称 | 支持性 | 类型 | 备注 | | -------- | ------- | ------- | ------- | -| OpenAI API | ✔ | 文本生成 | 也支持 DeepSeek、Google Gemini、GLM、Kimi、硅基流动、xAI 等兼容 OpenAI API 的服务 | +| OpenAI API | ✔ | 文本生成 | 也支持 DeepSeek、Google Gemini、GLM、Kimi、xAI 等兼容 OpenAI API 的服务 | | Claude API | ✔ | 文本生成 | | | Google Gemini API | ✔ | 文本生成 | | | Dify | ✔ | LLMOps | | -| DashScope(阿里云百炼应用) | ✔ | LLMOps | | +| 阿里云百炼应用 | ✔ | LLMOps | | | Ollama | ✔ | 模型加载器 | 本地部署 DeepSeek、Llama 等开源语言模型 | | LM Studio | ✔ | 模型加载器 | 本地部署 DeepSeek、Llama 等开源语言模型 | | LLMTuner | ✔ | 模型加载器 | 本地加载 lora 等微调模型 | +| 硅基流动 | ✔ | 模型 API 服务平台 | | +| PPIO 派欧云 | ✔ | 模型 API 服务平台 | | | OneAPI | ✔ | LLM 分发系统 | | | Whisper | ✔ | 语音转文本 | 支持 API、本地部署 | | SenseVoice | ✔ | 语音转文本 | 本地部署 | | OpenAI TTS API | ✔ | 文本转语音 | | | GSVI | ✔ | 文本转语音 | GPT-Sovits-Inference | -| Fishaudio | ✔ | 文本转语音 | GPT-Sovits 作者参与的项目 | -| Edge-TTS | ✔ | 文本转语音 | Edge 浏览器的免费 TTS | +| FishAudio | ✔ | 文本转语音 | GPT-Sovits 作者参与的项目 | +| Edge TTS | ✔ | 文本转语音 | Edge 浏览器的免费 TTS | +| 阿里云百炼 TTS | ✔ | 文本转语音 | | +| Azure TTS | ✔ | 文本转语音 | Microsoft Azure TTS | + ## ❤️ 贡献 diff --git a/astrbot/cli/__init__.py b/astrbot/cli/__init__.py new file mode 100644 index 000000000..25f7f33c3 --- /dev/null +++ b/astrbot/cli/__init__.py @@ -0,0 +1 @@ +__version__ = "3.5.8" diff --git a/astrbot/cli/__main__.py b/astrbot/cli/__main__.py index 580ad2e1c..f2b6651f5 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, conf 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,11 @@ 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) +cli.add_command(conf) + if __name__ == "__main__": cli() diff --git a/astrbot/cli/commands/__init__.py b/astrbot/cli/commands/__init__.py new file mode 100644 index 000000000..9fa9149e2 --- /dev/null +++ b/astrbot/cli/commands/__init__.py @@ -0,0 +1,6 @@ +from .cmd_init import init +from .cmd_run import run +from .cmd_plug import plug +from .cmd_conf import conf + +__all__ = ["init", "run", "plug", "conf"] diff --git a/astrbot/cli/commands/cmd_conf.py b/astrbot/cli/commands/cmd_conf.py new file mode 100644 index 000000000..fea654f20 --- /dev/null +++ b/astrbot/cli/commands/cmd_conf.py @@ -0,0 +1,206 @@ +import json +import click +import hashlib +import zoneinfo +from typing import Any, Callable +from ..utils import get_astrbot_root, check_astrbot_root + + +def _validate_log_level(value: str) -> str: + """验证日志级别""" + value = value.upper() + if value not in ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]: + raise click.ClickException( + "日志级别必须是 DEBUG/INFO/WARNING/ERROR/CRITICAL 之一" + ) + return value + + +def _validate_dashboard_port(value: str) -> int: + """验证 Dashboard 端口""" + try: + port = int(value) + if port < 1 or port > 65535: + raise click.ClickException("端口必须在 1-65535 范围内") + return port + except ValueError: + raise click.ClickException("端口必须是数字") + + +def _validate_dashboard_username(value: str) -> str: + """验证 Dashboard 用户名""" + if not value: + raise click.ClickException("用户名不能为空") + return value + + +def _validate_dashboard_password(value: str) -> str: + """验证 Dashboard 密码""" + if not value: + raise click.ClickException("密码不能为空") + return hashlib.md5(value.encode()).hexdigest() + + +def _validate_timezone(value: str) -> str: + """验证时区""" + try: + zoneinfo.ZoneInfo(value) + except Exception: + raise click.ClickException(f"无效的时区: {value},请使用有效的IANA时区名称") + return value + + +def _validate_callback_api_base(value: str) -> str: + """验证回调接口基址""" + if not value.startswith("http://") and not value.startswith("https://"): + raise click.ClickException("回调接口基址必须以 http:// 或 https:// 开头") + return value + + +# 可通过CLI设置的配置项,配置键到验证器函数的映射 +CONFIG_VALIDATORS: dict[str, Callable[[str], Any]] = { + "timezone": _validate_timezone, + "log_level": _validate_log_level, + "dashboard.port": _validate_dashboard_port, + "dashboard.username": _validate_dashboard_username, + "dashboard.password": _validate_dashboard_password, + "callback_api_base": _validate_callback_api_base, +} + + +def _load_config() -> dict[str, Any]: + """加载或初始化配置文件""" + root = get_astrbot_root() + if not check_astrbot_root(root): + raise click.ClickException( + f"{root}不是有效的 AstrBot 根目录,如需初始化请使用 astrbot init" + ) + + config_path = root / "data" / "cmd_config.json" + if not config_path.exists(): + from astrbot.core.config.default import DEFAULT_CONFIG + + config_path.write_text( + json.dumps(DEFAULT_CONFIG, ensure_ascii=False, indent=2), + encoding="utf-8-sig", + ) + + try: + return json.loads(config_path.read_text(encoding="utf-8-sig")) + except json.JSONDecodeError as e: + raise click.ClickException(f"配置文件解析失败: {str(e)}") + + +def _save_config(config: dict[str, Any]) -> None: + """保存配置文件""" + config_path = get_astrbot_root() / "data" / "cmd_config.json" + + config_path.write_text( + json.dumps(config, ensure_ascii=False, indent=2), encoding="utf-8-sig" + ) + + +def _set_nested_item(obj: dict[str, Any], path: str, value: Any) -> None: + """设置嵌套字典中的值""" + parts = path.split(".") + for part in parts[:-1]: + if part not in obj: + obj[part] = {} + elif not isinstance(obj[part], dict): + raise click.ClickException( + f"配置路径冲突: {'.'.join(parts[: parts.index(part) + 1])} 不是字典" + ) + obj = obj[part] + obj[parts[-1]] = value + + +def _get_nested_item(obj: dict[str, Any], path: str) -> Any: + """获取嵌套字典中的值""" + parts = path.split(".") + for part in parts: + obj = obj[part] + return obj + + +@click.group(name="conf") +def conf(): + """配置管理命令 + + 支持的配置项: + + - timezone: 时区设置 (例如: Asia/Shanghai) + + - log_level: 日志级别 (DEBUG/INFO/WARNING/ERROR/CRITICAL) + + - dashboard.port: Dashboard 端口 + + - dashboard.username: Dashboard 用户名 + + - dashboard.password: Dashboard 密码 + + - callback_api_base: 回调接口基址 + """ + pass + + +@conf.command(name="set") +@click.argument("key") +@click.argument("value") +def set_config(key: str, value: str): + """设置配置项的值""" + if key not in CONFIG_VALIDATORS.keys(): + raise click.ClickException(f"不支持的配置项: {key}") + + config = _load_config() + + try: + old_value = _get_nested_item(config, key) + validated_value = CONFIG_VALIDATORS[key](value) + _set_nested_item(config, key, validated_value) + _save_config(config) + + click.echo(f"配置已更新: {key}") + if key == "dashboard.password": + click.echo(" 原值: ********") + click.echo(" 新值: ********") + else: + click.echo(f" 原值: {old_value}") + click.echo(f" 新值: {validated_value}") + + except KeyError: + raise click.ClickException(f"未知的配置项: {key}") + except Exception as e: + raise click.UsageError(f"设置配置失败: {str(e)}") + + +@conf.command(name="get") +@click.argument("key", required=False) +def get_config(key: str = None): + """获取配置项的值,不提供key则显示所有可配置项""" + config = _load_config() + + if key: + if key not in CONFIG_VALIDATORS.keys(): + raise click.ClickException(f"不支持的配置项: {key}") + + try: + value = _get_nested_item(config, key) + if key == "dashboard.password": + value = "********" + click.echo(f"{key}: {value}") + except KeyError: + raise click.ClickException(f"未知的配置项: {key}") + except Exception as e: + raise click.UsageError(f"获取配置失败: {str(e)}") + else: + click.echo("当前配置:") + for key in CONFIG_VALIDATORS.keys(): + try: + value = ( + "********" + if key == "dashboard.password" + else _get_nested_item(config, key) + ) + click.echo(f" {key}: {value}") + except (KeyError, TypeError): + pass 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..38113744f --- /dev/null +++ b/astrbot/cli/commands/cmd_run.py @@ -0,0 +1,63 @@ +import os +import sys +from pathlib import Path + +import click +import asyncio +import traceback + +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}\n{traceback.format_exc()}") 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 59e61d73b..bce4073ca 100644 --- a/astrbot/core/__init__.py +++ b/astrbot/core/__init__.py @@ -7,27 +7,28 @@ from astrbot.core.utils.pip_installer import PipInstaller 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) astrbot_config = AstrBotConfig() t2i_base_url = astrbot_config.get("t2i_endpoint", "https://t2i.soulter.top/text2img") html_renderer = HtmlRenderer(t2i_base_url) logger = LogManager.GetLogger(log_name="astrbot") - -if os.environ.get("TESTING", ""): - logger.setLevel("DEBUG") - db_helper = SQLiteDatabase(DB_PATH) -sp = ( - SharedPreferences() -) # 简单的偏好设置存储, 这里后续应该存储到数据库中, 一些部分可以存储到配置中 +# 简单的偏好设置存储, 这里后续应该存储到数据库中, 一些部分可以存储到配置中 +sp = SharedPreferences() +# 文件令牌服务 +file_token_service = FileTokenService() pip_installer = PipInstaller( astrbot_config.get("pip_install_arg", ""), astrbot_config.get("pypi_index_url", None), ) web_chat_queue = asyncio.Queue(maxsize=32) web_chat_back_queue = asyncio.Queue(maxsize=32) -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..1ee0fac7f 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") @@ -45,8 +46,6 @@ class AstrBotConfig(dict): with open(config_path, "r", encoding="utf-8-sig") as f: conf_str = f.read() - if conf_str.startswith("/ufeff"): # remove BOM - conf_str = conf_str.encode("utf8")[3:].decode("utf8") conf = json.loads(conf_str) # 检查配置完整性,并插入 diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index 5eb0495a6..dd50b110f 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -2,8 +2,11 @@ 如需修改配置,请在 `data/cmd_config.json` 中修改或者在管理面板中可视化修改。 """ -VERSION = "3.5.7" -DB_PATH = "data/data_v3.db" +import os +from astrbot.core.utils.astrbot_path import get_astrbot_data_path + +VERSION = "3.5.9" +DB_PATH = os.path.join(get_astrbot_data_path(), "data_v3.db") # 默认配置 DEFAULT_CONFIG = { @@ -104,6 +107,7 @@ DEFAULT_CONFIG = { "knowledge_db": {}, "persona": [], "timezone": "", + "callback_api_base": "", } @@ -493,6 +497,7 @@ CONFIG_METADATA_2 = { "OpenAI": { "id": "openai", "type": "openai_chat_completion", + "provider_type": "chat_completion", "enable": True, "key": [], "api_base": "https://api.openai.com/v1", @@ -501,9 +506,10 @@ CONFIG_METADATA_2 = { "model": "gpt-4o-mini", }, }, - "Azure_OpenAI": { + "Azure OpenAI": { "id": "azure", "type": "openai_chat_completion", + "provider_type": "chat_completion", "enable": True, "api_version": "2024-05-01-preview", "key": [], @@ -513,9 +519,10 @@ CONFIG_METADATA_2 = { "model": "gpt-4o-mini", }, }, - "xAI(grok)": { + "xAI": { "id": "xai", "type": "openai_chat_completion", + "provider_type": "chat_completion", "enable": True, "key": [], "api_base": "https://api.x.ai/v1", @@ -524,9 +531,10 @@ CONFIG_METADATA_2 = { "model": "grok-2-latest", }, }, - "Anthropic(claude)": { + "Anthropic": { "id": "claude", "type": "anthropic_chat_completion", + "provider_type": "chat_completion", "enable": True, "key": [], "api_base": "https://api.anthropic.com/v1", @@ -539,6 +547,7 @@ CONFIG_METADATA_2 = { "Ollama": { "id": "ollama_default", "type": "openai_chat_completion", + "provider_type": "chat_completion", "enable": True, "key": ["ollama"], # ollama 的 key 默认是 ollama "api_base": "http://localhost:11434/v1", @@ -546,9 +555,10 @@ CONFIG_METADATA_2 = { "model": "llama3.1-8b", }, }, - "LM_Studio": { + "LM Studio": { "id": "lm_studio", "type": "openai_chat_completion", + "provider_type": "chat_completion", "enable": True, "key": ["lmstudio"], "api_base": "http://localhost:1234/v1", @@ -559,6 +569,7 @@ CONFIG_METADATA_2 = { "Gemini(OpenAI兼容)": { "id": "gemini_default", "type": "openai_chat_completion", + "provider_type": "chat_completion", "enable": True, "key": [], "api_base": "https://generativelanguage.googleapis.com/v1beta/openai/", @@ -567,9 +578,10 @@ CONFIG_METADATA_2 = { "model": "gemini-1.5-flash", }, }, - "Gemini(googlegenai原生)": { + "Gemini": { "id": "gemini_default", "type": "googlegenai_chat_completion", + "provider_type": "chat_completion", "enable": True, "key": [], "api_base": "https://generativelanguage.googleapis.com/", @@ -593,6 +605,7 @@ CONFIG_METADATA_2 = { "DeepSeek": { "id": "deepseek_default", "type": "openai_chat_completion", + "provider_type": "chat_completion", "enable": True, "key": [], "api_base": "https://api.deepseek.com/v1", @@ -601,9 +614,10 @@ CONFIG_METADATA_2 = { "model": "deepseek-chat", }, }, - "Zhipu(智谱)": { + "智谱 AI": { "id": "zhipu_default", "type": "zhipu_chat_completion", + "provider_type": "chat_completion", "enable": True, "key": [], "timeout": 120, @@ -612,9 +626,10 @@ CONFIG_METADATA_2 = { "model": "glm-4-flash", }, }, - "SiliconFlow(硅基流动)": { + "硅基流动": { "id": "siliconflow", "type": "openai_chat_completion", + "provider_type": "chat_completion", "enable": True, "key": [], "timeout": 120, @@ -623,9 +638,10 @@ CONFIG_METADATA_2 = { "model": "deepseek-ai/DeepSeek-V3", }, }, - "MoonShot(Kimi)": { + "Kimi": { "id": "moonshot", "type": "openai_chat_completion", + "provider_type": "chat_completion", "enable": True, "key": [], "timeout": 120, @@ -634,9 +650,22 @@ CONFIG_METADATA_2 = { "model": "moonshot-v1-8k", }, }, + "PPIO派欧云": { + "id": "ppio", + "type": "openai_chat_completion", + "provider_type": "chat_completion", + "enable": True, + "key": [], + "api_base": "https://api.ppinfra.com/v3/openai", + "timeout": 120, + "model_config": { + "model": "deepseek/deepseek-r1", + }, + }, "LLMTuner": { "id": "llmtuner_default", "type": "llm_tuner", + "provider_type": "chat_completion", "enable": True, "base_model_path": "", "adapter_model_path": "", @@ -647,6 +676,7 @@ CONFIG_METADATA_2 = { "Dify": { "id": "dify_app_default", "type": "dify", + "provider_type": "chat_completion", "enable": True, "dify_api_type": "chat", "dify_api_key": "", @@ -656,9 +686,10 @@ CONFIG_METADATA_2 = { "variables": {}, "timeout": 60, }, - "Dashscope(阿里云百炼应用)": { + "阿里云百炼应用": { "id": "dashscope", "type": "dashscope", + "provider_type": "chat_completion", "enable": True, "dashscope_app_type": "agent", "dashscope_api_key": "", @@ -674,6 +705,7 @@ CONFIG_METADATA_2 = { "FastGPT": { "id": "fastgpt", "type": "openai_chat_completion", + "provider_type": "chat_completion", "enable": True, "key": [], "api_base": "https://api.fastgpt.in/api/v1", @@ -682,6 +714,7 @@ CONFIG_METADATA_2 = { "Whisper(API)": { "id": "whisper", "type": "openai_whisper_api", + "provider_type": "speech_to_text", "enable": False, "api_key": "", "api_base": "", @@ -689,22 +722,25 @@ CONFIG_METADATA_2 = { }, "Whisper(本地加载)": { "whisper_hint": "(不用修改我)", + "type": "openai_whisper_selfhost", + "provider_type": "speech_to_text", "enable": False, "id": "whisper", - "type": "openai_whisper_selfhost", "model": "tiny", }, - "sensevoice(本地加载)": { + "SenseVoice(本地加载)": { "sensevoice_hint": "(不用修改我)", + "type": "sensevoice_stt_selfhost", + "provider_type": "speech_to_text", "enable": False, "id": "sensevoice", - "type": "sensevoice_stt_selfhost", "stt_model": "iic/SenseVoiceSmall", "is_emotion": False, }, - "OpenAI_TTS(API)": { + "OpenAI TTS(API)": { "id": "openai_tts", "type": "openai_tts_api", + "provider_type": "text_to_speech", "enable": False, "api_key": "", "api_base": "", @@ -712,41 +748,58 @@ CONFIG_METADATA_2 = { "openai-tts-voice": "alloy", "timeout": "20", }, - "Edge_TTS": { + "Edge TTS": { "edgetts_hint": "提示:使用这个服务前需要安装有 ffmpeg,并且可以直接在终端调用 ffmpeg 指令。", "id": "edge_tts", "type": "edge_tts", + "provider_type": "text_to_speech", "enable": False, "edge-tts-voice": "zh-CN-XiaoxiaoNeural", "timeout": 20, }, - "GSVI_TTS(API)": { + "GSVI TTS(API)": { "id": "gsvi_tts", "type": "gsvi_tts_api", + "provider_type": "text_to_speech", "api_base": "http://127.0.0.1:5000", "character": "", "emotion": "default", "enable": False, "timeout": 20, }, - "FishAudio_TTS(API)": { + "FishAudio TTS(API)": { "id": "fishaudio_tts", "type": "fishaudio_tts_api", + "provider_type": "text_to_speech", "enable": False, "api_key": "", "api_base": "https://api.fish.audio/v1", "fishaudio-tts-character": "可莉", "timeout": "20", }, - "阿里云百炼_TTS(API)": { + "阿里云百炼 TTS(API)": { "id": "dashscope_tts", "type": "dashscope_tts", + "provider_type": "text_to_speech", "enable": False, "api_key": "", "model": "cosyvoice-v1", "dashscope_tts_voice": "loongstella", "timeout": "20", }, + "Azure TTS": { + "id": "azure_tts", + "type": "azure_tts", + "provider_type": "text_to_speech", + "enable": True, + "azure_tts_voice": "zh-CN-YunxiaNeural", + "azure_tts_style": "cheerful", + "azure_tts_role": "Boy", + "azure_tts_rate": "1", + "azure_tts_volume": "100", + "azure_tts_subscription_key": "", + "azure_tts_region": "eastus" + }, "火山引擎_TTS(API)": { "id": "volcengine_tts", "type": "volcengine_tts", @@ -760,6 +813,43 @@ CONFIG_METADATA_2 = { }, }, "items": { + "azure_tts_voice": { + "type": "string", + "description": "音色设置", + "hint": "API 音色" + }, + "azure_tts_style": { + "type": "string", + "description": "风格设置", + "hint": "声音特定的讲话风格。 可以表达快乐、同情和平静等情绪。" + }, + "azure_tts_role": { + "type": "string", + "description": "模仿设置(可选)", + "hint": "讲话角色扮演。 声音可以模仿不同的年龄和性别,但声音名称不会更改。 例如,男性语音可以提高音调和改变语调来模拟女性语音,但语音名称不会更改。 如果角色缺失或不受声音的支持,则会忽略此属性。", + "options": ["Boy","Girl","YoungAdultFemale","YoungAdultMale","OlderAdultFemale","OlderAdultMale","SeniorFemale","SeniorMale","禁用"] + }, + "azure_tts_rate": { + "type": "string", + "description": "语速设置", + "hint": "指示文本的讲出速率。可在字词或句子层面应用语速。 速率变化应为原始音频的 0.5 到 2 倍。" + }, + "azure_tts_volume": { + "type": "string", + "description": "语音音量设置", + "hint": "指示语音的音量级别。 可在句子层面应用音量的变化。以从 0.0 到 100.0(从最安静到最大声,例如 75)的数字表示。 默认值为 100.0。" + }, + "azure_tts_region": { + "type": "string", + "description": "API 地区", + "hint": "Azure_TTS 处理数据所在区域,具体参考 https://learn.microsoft.com/zh-cn/azure/ai-services/speech-service/regions", + "options": ["southafricanorth", "eastasia", "southeastasia", "australiaeast", "centralindia", "japaneast", "japanwest", "koreacentral", "canadacentral", "northeurope", "westeurope", "francecentral", "germanywestcentral", "norwayeast", "swedencentral", "switzerlandnorth", "switzerlandwest", "uksouth", "uaenorth", "brazilsouth", "qatarcentral", "centralus", "eastus", "eastus2", "northcentralus", "southcentralus", "westcentralus", "westus", "westus2", "westus3"] + }, + "azure_tts_subscription_key": { + "type": "string", + "description": "服务订阅密钥", + "hint": "Azure_TTS 服务的订阅密钥(注意不是令牌)" + }, "dashscope_tts_voice": { "description": "语音合成模型", "type": "string", @@ -941,7 +1031,12 @@ CONFIG_METADATA_2 = { "hint": "ID 不能和其它的服务提供商重复,否则将发生严重冲突。", }, "type": { - "description": "模型提供商类型", + "description": "模型提供商种类", + "type": "string", + "invisible": True, + }, + "provider_type": { + "description": "模型提供商能力种类", "type": "string", "invisible": True, }, @@ -1294,6 +1389,12 @@ CONFIG_METADATA_2 = { "obvious_hint": True, "hint": "时区设置。请填写 IANA 时区名称, 如 Asia/Shanghai, 为空时使用系统默认时区。所有时区请查看: https://data.iana.org/time-zones/tzdb-2021a/zone1970.tab", }, + "callback_api_base": { + "description": "对外可达的回调接口地址", + "type": "string", + "obvious_hint": True, + "hint": "外部服务可能会通过 AstrBot 生成的回调链接(如文件下载链接)访问 AstrBot 后端。由于 AstrBot 无法自动判断部署环境中对外可达的主机地址(host),因此需要通过此配置项显式指定 “外部服务如何访问 AstrBot” 的地址。如 http://localhost:6185,https://example.com 等。" + }, "log_level": { "description": "控制台日志级别", "type": "string", 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/file_token_service.py b/astrbot/core/file_token_service.py new file mode 100644 index 000000000..2ed46d433 --- /dev/null +++ b/astrbot/core/file_token_service.py @@ -0,0 +1,68 @@ +import asyncio +import os +import uuid +import time + + +class FileTokenService: + """维护一个简单的基于令牌的文件下载服务,支持超时和懒清除。""" + + def __init__(self, default_timeout: float = 300): + self.lock = asyncio.Lock() + self.staged_files = {} # token: (file_path, expire_time) + self.default_timeout = default_timeout + + async def _cleanup_expired_tokens(self): + """清理过期的令牌""" + now = time.time() + expired_tokens = [token for token, (_, expire) in self.staged_files.items() if expire < now] + for token in expired_tokens: + self.staged_files.pop(token, None) + + async def register_file(self, file_path: str, timeout: float = None) -> str: + """向令牌服务注册一个文件。 + + Args: + file_path(str): 文件路径 + timeout(float): 超时时间,单位秒(可选) + + Returns: + str: 一个单次令牌 + + Raises: + FileNotFoundError: 当路径不存在时抛出 + """ + async with self.lock: + await self._cleanup_expired_tokens() + + if not os.path.exists(file_path): + raise FileNotFoundError(f"文件不存在: {file_path}") + + file_token = str(uuid.uuid4()) + expire_time = time.time() + (timeout if timeout is not None else self.default_timeout) + self.staged_files[file_token] = (file_path, expire_time) + return file_token + + async def handle_file(self, file_token: str) -> str: + """根据令牌获取文件路径,使用后令牌失效。 + + Args: + file_token(str): 注册时返回的令牌 + + Returns: + str: 文件路径 + + Raises: + KeyError: 当令牌不存在或已过期时抛出 + FileNotFoundError: 当文件本身已被删除时抛出 + """ + async with self.lock: + await self._cleanup_expired_tokens() + + if file_token not in self.staged_files: + raise KeyError(f"无效或过期的文件 token: {file_token}") + + file_path, _ = self.staged_files.pop(file_token) + if not os.path.exists(file_path): + raise FileNotFoundError(f"文件不存在: {file_path}") + return file_path diff --git a/astrbot/core/message/components.py b/astrbot/core/message/components.py index 74538d097..a7474cb77 100644 --- a/astrbot/core/message/components.py +++ b/astrbot/core/message/components.py @@ -22,16 +22,19 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ +import asyncio import base64 import json import os -import uuid -import asyncio import typing as T +import uuid 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 import astrbot_config, file_token_service, logger +from astrbot.core.utils.astrbot_path import get_astrbot_data_path +from astrbot.core.utils.io import download_file, download_image_by_url, file_to_base64 class ComponentType(Enum): @@ -167,7 +170,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) @@ -198,6 +202,29 @@ class Record(BaseMessageComponent): bs64_data = bs64_data.removeprefix("base64://") return bs64_data + async def register_to_file_service(self) -> str: + """ + 将语音注册到文件服务。 + + Returns: + str: 注册后的URL + + Raises: + Exception: 如果未配置 callback_api_base + """ + callback_host = astrbot_config.get("callback_api_base") + + if not callback_host: + raise Exception("未配置 callback_api_base,文件服务不可用") + + file_path = await self.convert_to_file_path() + + token = await file_token_service.register_file(file_path) + + logger.debug(f"已注册:{callback_host}/api/file/{token}") + + return f"{callback_host}/api/file/{token}" + class Video(BaseMessageComponent): type: ComponentType = "Video" @@ -371,7 +398,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) @@ -403,6 +431,29 @@ class Image(BaseMessageComponent): bs64_data = bs64_data.removeprefix("base64://") return bs64_data + async def register_to_file_service(self) -> str: + """ + 将图片注册到文件服务。 + + Returns: + str: 注册后的URL + + Raises: + Exception: 如果未配置 callback_api_base + """ + callback_host = astrbot_config.get("callback_api_base") + + if not callback_host: + raise Exception("未配置 callback_api_base,文件服务不可用") + + file_path = await self.convert_to_file_path() + + token = await file_token_service.register_file(file_path) + + logger.debug(f"已注册:{callback_host}/api/file/{token}") + + return f"{callback_host}/api/file/{token}" + class Reply(BaseMessageComponent): type: ComponentType = "Reply" @@ -462,10 +513,10 @@ class Node(BaseMessageComponent): type: ComponentType = "Node" id: T.Optional[int] = 0 # 忽略 name: T.Optional[str] = "" # qq昵称 - uin: T.Optional[int] = 0 # qq号 + uin: T.Optional[str] = "0" # qq号 content: T.Optional[T.Union[str, list, dict]] = "" # 子消息段列表 seq: T.Optional[T.Union[str, list]] = "" # 忽略 - time: T.Optional[int] = 0 + time: T.Optional[int] = 0 # 忽略 def __init__(self, content: T.Union[str, list, dict, "Node", T.List["Node"]], **_): if isinstance(content, list): @@ -494,7 +545,14 @@ class Nodes(BaseMessageComponent): super().__init__(nodes=nodes, **_) def toDict(self): - return {"messages": [node.toDict() for node in self.nodes]} + ret = { + "messages": [], + } + for node in self.nodes: + d = node.toDict() + d["data"]["uin"] = str(node.uin) # 转为字符串 + ret["messages"].append(d) + return ret class Xml(BaseMessageComponent): @@ -559,12 +617,12 @@ class File(BaseMessageComponent): type: ComponentType = "File" name: T.Optional[str] = "" # 名字 - _file: T.Optional[str] = "" # 本地路径 + file_: T.Optional[str] = "" # 本地路径 url: T.Optional[str] = "" # url - _downloaded: bool = False # 是否已经下载 - def __init__(self, name: str = "", file: str = "", url: str = ""): - super().__init__(name=name, _file=file, url=url) + def __init__(self, name: str, file: str = "", url: str = ""): + """文件消息段。""" + super().__init__(name=name, file_=file, url=url) @property def file(self) -> str: @@ -574,23 +632,27 @@ class File(BaseMessageComponent): Returns: str: 文件路径 """ - if self._file and os.path.exists(self._file): - return self._file + if self.file_ and os.path.exists(self.file_): + return os.path.abspath(self.file_) - if self.url and not self._downloaded: + if self.url: try: loop = asyncio.get_event_loop() if loop.is_running(): logger.warning( - "不可以在异步上下文中同步等待下载! 请使用 await get_file() 代替" + ( + "不可以在异步上下文中同步等待下载! " + "这个警告通常发生于某些逻辑试图通过 .file 获取文件消息段的文件内容。" + "请使用 await get_file() 代替直接获取 .file 字段" + ) ) return "" else: # 等待下载完成 loop.run_until_complete(self._download_file()) - if self._file and os.path.exists(self._file): - return self._file + if self.file_ and os.path.exists(self.file_): + return os.path.abspath(self.file_) except Exception as e: logger.error(f"文件下载失败: {e}") @@ -607,38 +669,59 @@ class File(BaseMessageComponent): if value.startswith("http://") or value.startswith("https://"): self.url = value else: - self._file = value + self.file_ = value - async def get_file(self) -> str: - """ - 异步获取文件 - To 插件开发者: 请注意在使用后清理下载的文件, 以免占用过多空间 + async def get_file(self, allow_return_url: bool = False) -> str: + """异步获取文件。请注意在使用后清理下载的文件, 以免占用过多空间 + Args: + allow_return_url: 是否允许以文件 http 下载链接的形式返回,这允许您自行控制是否需要下载文件。 + 注意,如果为 True,也可能返回文件路径。 Returns: - str: 文件路径 + str: 文件路径或者 http 下载链接 """ - if self._file and os.path.exists(self._file): - return self._file + if allow_return_url and self.url: + return self.url + + if self.file_ and os.path.exists(self.file_): + return os.path.abspath(self.file_) if self.url: await self._download_file() - return self._file + return os.path.abspath(self.file_) return "" async def _download_file(self): """下载文件""" - if self._downloaded: - return - - os.makedirs("data/download", exist_ok=True) - filename = self.name or f"{uuid.uuid4().hex}" - file_path = f"data/download/{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) - self._file = file_path - self._downloaded = True + async def register_to_file_service(self): + """ + 将文件注册到文件服务。 + + Returns: + str: 注册后的URL + + Raises: + Exception: 如果未配置 callback_api_base + """ + callback_host = astrbot_config.get("callback_api_base") + + if not callback_host: + raise Exception("未配置 callback_api_base,文件服务不可用") + + file_path = await self.get_file() + + token = await file_token_service.register_file(file_path) + + logger.debug(f"已注册:{callback_host}/api/file/{token}") + + return f"{callback_host}/api/file/{token}" class WechatEmoji(BaseMessageComponent): diff --git a/astrbot/core/pipeline/rate_limit_check/stage.py b/astrbot/core/pipeline/rate_limit_check/stage.py index 7550d84e0..b36a2fbd0 100644 --- a/astrbot/core/pipeline/rate_limit_check/stage.py +++ b/astrbot/core/pipeline/rate_limit_check/stage.py @@ -58,33 +58,30 @@ class RateLimitStage(Stage): now = datetime.now() async with self.locks[session_id]: # 确保同一会话不会并发修改队列 - timestamps = self.event_timestamps[session_id] + # 检查并处理限流,可能需要多次检查直到满足条件 + while True: + timestamps = self.event_timestamps[session_id] + self._remove_expired_timestamps(timestamps, now) - self._remove_expired_timestamps(timestamps, now) + if len(timestamps) < self.rate_limit_count: + timestamps.append(now) + break + else: + next_window_time = timestamps[0] + self.rate_limit_time + stall_duration = (next_window_time - now).total_seconds() + 0.3 - if len(timestamps) >= self.rate_limit_count: - # 达到限流阈值,计算下一个窗口的时间 - next_window_time = timestamps[0] + self.rate_limit_time - stall_duration = (next_window_time - now).total_seconds() - - match self.rl_strategy: - case RateLimitStrategy.STALL.value: - logger.info( - f"会话 {session_id} 被限流。根据限流策略,此会话处理将被暂停 {stall_duration:.2f} 秒。" - ) - await asyncio.sleep(stall_duration) - case RateLimitStrategy.DISCARD.value: - # event.set_result(MessageEventResult().message(f"会话 {session_id} 被限流。根据限流策略,此请求已被丢弃,直到您的限额于 {stall_duration:.2f} 秒后重置。")) - logger.info( - f"会话 {session_id} 被限流。根据限流策略,此请求已被丢弃,直到限额于 {stall_duration:.2f} 秒后重置。" - ) - return event.stop_event() - - self._remove_expired_timestamps( - timestamps, now + timedelta(seconds=stall_duration) - ) - - timestamps.append(now) + match self.rl_strategy: + case RateLimitStrategy.STALL.value: + logger.info( + f"会话 {session_id} 被限流。根据限流策略,此会话处理将被暂停 {stall_duration:.2f} 秒。" + ) + await asyncio.sleep(stall_duration) + now = datetime.now() + case RateLimitStrategy.DISCARD.value: + logger.info( + f"会话 {session_id} 被限流。根据限流策略,此请求已被丢弃,直到限额于 {stall_duration:.2f} 秒后重置。" + ) + return event.stop_event() def _remove_expired_timestamps( self, timestamps: Deque[datetime], now: datetime diff --git a/astrbot/core/pipeline/respond/stage.py b/astrbot/core/pipeline/respond/stage.py index 776f4a625..bff94a64d 100644 --- a/astrbot/core/pipeline/respond/stage.py +++ b/astrbot/core/pipeline/respond/stage.py @@ -26,33 +26,14 @@ class RespondStage(Stage): Comp.Record: lambda comp: bool(comp.file), # 语音 Comp.Video: lambda comp: bool(comp.file), # 视频 Comp.At: lambda comp: bool(comp.qq) or bool(comp.name), # @ - Comp.AtAll: lambda comp: True, # @所有人 - Comp.RPS: lambda comp: True, # 不知道是啥(未完成) - Comp.Dice: lambda comp: True, # 骰子(未完成) - Comp.Shake: lambda comp: True, # 摇一摇(未完成) - Comp.Anonymous: lambda comp: True, # 匿名(未完成) - Comp.Share: lambda comp: bool(comp.url) and bool(comp.title), # 分享 - Comp.Contact: lambda comp: True, # 联系人(未完成) - Comp.Location: lambda comp: bool(comp.lat and comp.lon), # 位置 - Comp.Music: lambda comp: bool(comp._type) - and bool(comp.url) - and bool(comp.audio), # 音乐 Comp.Image: lambda comp: bool(comp.file), # 图片 Comp.Reply: lambda comp: bool(comp.id) and comp.sender_id is not None, # 回复 - Comp.RedBag: lambda comp: bool(comp.title), # 红包 Comp.Poke: lambda comp: comp.id != 0 and comp.qq != 0, # 戳一戳 - Comp.Forward: lambda comp: bool(comp.id and comp.id.strip()), # 转发 Comp.Node: lambda comp: bool(comp.name) and comp.uin != 0 and bool(comp.content), # 一个转发节点 Comp.Nodes: lambda comp: bool(comp.nodes), # 多个转发节点 - Comp.Xml: lambda comp: bool(comp.data and comp.data.strip()), # XML - Comp.Json: lambda comp: bool(comp.data), # JSON - Comp.CardImage: lambda comp: bool(comp.file), # 卡片图片 - Comp.TTS: lambda comp: bool(comp.text and comp.text.strip()), # 语音合成 - Comp.Unknown: lambda comp: bool(comp.text and comp.text.strip()), # 未知消息 - Comp.File: lambda comp: bool(comp.file), # 文件 - Comp.WechatEmoji: lambda comp: bool(comp.md5), # 微信表情 + Comp.File: lambda comp: bool(comp.file_ or comp.url), } async def initialize(self, ctx: PipelineContext): @@ -129,8 +110,6 @@ class RespondStage(Stage): if comp_type in self._component_validators: if self._component_validators[comp_type](comp): return False - else: - logger.info(f"空内容检查: 无法识别的组件类型: {comp_type.__name__}") # 如果所有组件都为空 return True diff --git a/astrbot/core/pipeline/waking_check/stage.py b/astrbot/core/pipeline/waking_check/stage.py index 7162b77ab..e654d07ce 100644 --- a/astrbot/core/pipeline/waking_check/stage.py +++ b/astrbot/core/pipeline/waking_check/stage.py @@ -137,7 +137,7 @@ class WakingCheckStage(Stage): if self.no_permission_reply: await event.send( MessageChain().message( - f"ID {event.get_sender_id()} 权限不足。通过 /sid 获取 ID 并请管理员添加。" + f"您(ID: {event.get_sender_id()})的权限不足以使用此指令。通过 /sid 获取 ID 并请管理员添加。" ) ) await event._post_send() diff --git a/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_message_event.py b/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_message_event.py index 4acb677dd..068a8bf3c 100644 --- a/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_message_event.py +++ b/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_message_event.py @@ -3,8 +3,9 @@ import re from typing import AsyncGenerator, Dict, List from aiocqhttp import CQHttp from astrbot.api.event import AstrMessageEvent, MessageChain -from astrbot.api.message_components import At, Image, Node, Nodes, Plain, Record +from astrbot.api.message_components import At, Image, Node, Nodes, Plain, Record, File from astrbot.api.platform import Group, MessageMember +from astrbot.core import file_token_service, astrbot_config, logger class AiocqhttpMessageEvent(AstrMessageEvent): @@ -34,24 +35,16 @@ class AiocqhttpMessageEvent(AstrMessageEvent): } elif isinstance(segment, At): d["data"] = { - "qq": str(segment.qq) # 转换为字符串 + "qq": str(segment.qq), # 转换为字符串 } ret.append(d) return ret async def send(self, message: MessageChain): - ret = await AiocqhttpMessageEvent._parse_onebot_json(message) - - if not ret: - return - - send_one_by_one = False - for seg in message.chain: - if isinstance(seg, (Node, Nodes)): - # 转发消息不能和普通消息混在一起发送 - send_one_by_one = True - break - + # 转发消息、文件消息不能和普通消息混在一起发送 + send_one_by_one = any( + isinstance(seg, (Node, Nodes, File)) for seg in message.chain + ) if send_one_by_one: for seg in message.chain: if isinstance(seg, (Node, Nodes)): @@ -70,6 +63,26 @@ class AiocqhttpMessageEvent(AstrMessageEvent): await self.bot.call_action( "send_private_forward_msg", **payload ) + elif isinstance(seg, File): + d = seg.toDict() + url_or_path = await seg.get_file(allow_return_url=True) + if url_or_path.startswith("http"): + payload_file = url_or_path + elif callback_host := astrbot_config.get("callback_api_base"): + callback_host = str(callback_host).removesuffix("/") + token = await file_token_service.register_file(url_or_path) + payload_file = f"{callback_host}/api/file/{token}" + logger.debug(f"Generated file callback link: {payload_file}") + else: + payload_file = url_or_path + d["data"] = { + "name": seg.name, + "file": payload_file, + } + await self.bot.send( + self.message_obj.raw_message, + [d], + ) else: await self.bot.send( self.message_obj.raw_message, @@ -79,6 +92,9 @@ class AiocqhttpMessageEvent(AstrMessageEvent): ) await asyncio.sleep(0.5) else: + ret = await AiocqhttpMessageEvent._parse_onebot_json(message) + if not ret: + return await self.bot.send(self.message_obj.raw_message, ret) await super().send(message) 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..528a8cab8 100644 --- a/astrbot/core/platform/sources/wecom/wecom_adapter.py +++ b/astrbot/core/platform/sources/wecom/wecom_adapter.py @@ -1,31 +1,31 @@ +import asyncio +import os import sys import uuid -import asyncio -import quart -import aiohttp +import quart +from requests import Response +from wechatpy.enterprise import WeChatClient, parse_message +from wechatpy.enterprise.crypto import WeChatCrypto +from wechatpy.enterprise.messages import ImageMessage, TextMessage, VoiceMessage +from wechatpy.exceptions import InvalidSignatureException +from wechatpy.messages import BaseMessage + +from astrbot.api.event import MessageChain +from astrbot.api.message_components import Image, Plain, Record from astrbot.api.platform import ( - Platform, AstrBotMessage, MessageMember, - PlatformMetadata, MessageType, + Platform, + PlatformMetadata, + register_platform_adapter, ) -from astrbot.api.event import MessageChain -from astrbot.api.message_components import Plain, Image, Record -from astrbot.core.platform.astr_message_event import MessageSesion -from astrbot.api.platform import register_platform_adapter from astrbot.core import logger -from requests import Response +from astrbot.core.platform.astr_message_event import MessageSesion +from astrbot.core.utils.astrbot_path import get_astrbot_data_path -from wechatpy.enterprise.crypto import WeChatCrypto -from wechatpy.enterprise import WeChatClient -from wechatpy.enterprise.messages import TextMessage, ImageMessage, VoiceMessage -from wechatpy.messages import BaseMessage -from wechatpy.exceptions import InvalidSignatureException -from wechatpy.enterprise import parse_message from .wecom_event import WecomPlatformEvent - from .wecom_kf import WeChatKF from .wecom_kf_message import WeChatKFMessage @@ -146,7 +146,7 @@ class WecomPlatformAdapter(Platform): self.client.kf = self.wechat_kf_api self.client.kf_message = self.wechat_kf_message_api - self.client.API_BASE_URL = self.api_base_url + self.client.API_BASE_URL = self.api_base_url async def callback(msg: BaseMessage): if msg.type == "unknown" and msg._data["Event"] == "kf_msg_or_event": @@ -257,14 +257,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: @@ -296,11 +297,12 @@ class WecomPlatformAdapter(Platform): external_userid = msg.get("external_userid", None) abm = AstrBotMessage() abm.raw_message = msg - abm.raw_message["_wechat_kf_flag"] = None # 方便处理 + abm.raw_message["_wechat_kf_flag"] = None # 方便处理 abm.self_id = msg["open_kfid"] abm.sender = MessageMember(external_userid, external_userid) abm.session_id = external_userid abm.type = MessageType.FRIEND_MESSAGE + abm.message_id = msg.get("msgid", uuid.uuid4().hex[:8]) if msgtype == "text": text = msg.get("text", {}).get("content", "").strip() abm.message = [Plain(text=text)] 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/platform/sources/weixin_official_account/weixin_offacc_adapter.py b/astrbot/core/platform/sources/weixin_official_account/weixin_offacc_adapter.py index d7463d4d1..5ed589516 100644 --- a/astrbot/core/platform/sources/weixin_official_account/weixin_offacc_adapter.py +++ b/astrbot/core/platform/sources/weixin_official_account/weixin_offacc_adapter.py @@ -136,6 +136,8 @@ class WeixinOfficialAccountPlatformAdapter(Platform): self.config["secret"].strip(), ) + self.client.API_BASE_URL = self.api_base_url + async def callback(msg): try: await self.convert_message(msg) 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/manager.py b/astrbot/core/provider/manager.py index 9812a7e6a..e61fbf925 100644 --- a/astrbot/core/provider/manager.py +++ b/astrbot/core/provider/manager.py @@ -202,6 +202,10 @@ class ProviderManager: from .sources.dashscope_tts import ( ProviderDashscopeTTSAPI as ProviderDashscopeTTSAPI, ) + case "azure_tts": + from .sources.azure_tts_source import ( + AzureTTSProvider as AzureTTSProvider, + ) except (ImportError, ModuleNotFoundError) as e: logger.critical( f"加载 {provider_config['type']}({provider_config['id']}) 提供商适配器失败:{e}。可能是因为有未安装的依赖。" diff --git a/astrbot/core/provider/sources/azure_tts_source.py b/astrbot/core/provider/sources/azure_tts_source.py new file mode 100644 index 000000000..18d9bfbac --- /dev/null +++ b/astrbot/core/provider/sources/azure_tts_source.py @@ -0,0 +1,210 @@ +import uuid +import time +import json +import re +import hashlib +import random +import asyncio +from pathlib import Path +from typing import Dict +from xml.sax.saxutils import escape + +from httpx import AsyncClient, Timeout +from astrbot.core.config.default import VERSION + +from ..entities import ProviderType +from ..provider import TTSProvider +from ..register import register_provider_adapter + +TEMP_DIR = Path("data/temp/azure_tts") +TEMP_DIR.mkdir(parents=True, exist_ok=True) + +class OTTSProvider: + def __init__(self, config: Dict): + self.skey = config["OTTS_SKEY"] + self.api_url = config["OTTS_URL"] + self.auth_time_url = config["OTTS_AUTH_TIME"] + self.time_offset = 0 + self.last_sync_time = 0 + self.timeout = Timeout(10.0) + self.retry_count = 3 + self.client = None + + async def __aenter__(self): + self.client = AsyncClient(timeout=self.timeout) + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + if self.client: + await self.client.aclose() + + async def _sync_time(self): + try: + response = await self.client.get(self.auth_time_url) + response.raise_for_status() + server_time = int(response.json()["timestamp"]) + local_time = int(time.time()) + self.time_offset = server_time - local_time + self.last_sync_time = local_time + except Exception as e: + if time.time() - self.last_sync_time > 3600: + raise RuntimeError("时间同步失败") from e + + async def _generate_signature(self) -> str: + await self._sync_time() + timestamp = int(time.time()) + self.time_offset + nonce = ''.join(random.choices('abcdefghijklmnopqrstuvwxyz0123456789', k=10)) + path = re.sub(r'^https?://[^/]+', '', self.api_url) or '/' + return f"{timestamp}-{nonce}-0-{hashlib.md5(f'{path}-{timestamp}-{nonce}-0-{self.skey}'.encode()).hexdigest()}" + + async def get_audio(self, text: str, voice_params: Dict) -> str: + file_path = TEMP_DIR / f"otts-{uuid.uuid4()}.wav" + signature = await self._generate_signature() + for attempt in range(self.retry_count): + try: + response = await self.client.post( + f"{self.api_url}?sign={signature}", + data={ + "text": text, + "voice": voice_params["voice"], + "style": voice_params["style"], + "role": voice_params["role"], + "rate": voice_params["rate"], + "volume": voice_params["volume"] + }, + headers={ + "User-Agent": f"AstrBot/{VERSION}", + "UAK": "AstrBot/AzureTTS" + } + ) + response.raise_for_status() + file_path.parent.mkdir(parents=True, exist_ok=True) + with file_path.open("wb") as f: + async for chunk in response.aiter_bytes(4096): + f.write(chunk) + return str(file_path.resolve()) + except Exception as e: + if attempt == self.retry_count - 1: + raise RuntimeError(f"OTTS请求失败: {str(e)}") from e + await asyncio.sleep(0.5 * (attempt + 1)) + +class AzureNativeProvider(TTSProvider): + def __init__(self, provider_config: dict, provider_settings: dict): + super().__init__(provider_config, provider_settings) + self.subscription_key = provider_config.get("azure_tts_subscription_key", "").strip() + if not re.fullmatch(r'^[a-zA-Z0-9]{32}$', self.subscription_key): + raise ValueError("无效的Azure订阅密钥") + self.region = provider_config.get("azure_tts_region", "eastus").strip() + self.endpoint = f"https://{self.region}.tts.speech.microsoft.com/cognitiveservices/v1" + self.client = None + self.token = None + self.token_expire = 0 + self.voice_params = { + "voice": provider_config.get("azure_tts_voice", "zh-CN-YunxiaNeural"), + "style": provider_config.get("azure_tts_style", "cheerful"), + "role": provider_config.get("azure_tts_role", "Boy"), + "rate": provider_config.get("azure_tts_rate", "1"), + "volume": provider_config.get("azure_tts_volume", "100") + } + + async def __aenter__(self): + self.client = AsyncClient(headers={ + "User-Agent": f"AstrBot/{VERSION}", + "Content-Type": "application/ssml+xml", + "X-Microsoft-OutputFormat": "riff-48khz-16bit-mono-pcm" + }) + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + if self.client: + await self.client.aclose() + + async def _refresh_token(self): + token_url = f"https://{self.region}.api.cognitive.microsoft.com/sts/v1.0/issuetoken" + response = await self.client.post( + token_url, + headers={"Ocp-Apim-Subscription-Key": self.subscription_key} + ) + response.raise_for_status() + self.token = response.text + self.token_expire = time.time() + 540 + + async def get_audio(self, text: str) -> str: + if not self.token or time.time() > self.token_expire: + await self._refresh_token() + file_path = TEMP_DIR / f"azure-{uuid.uuid4()}.wav" + ssml = f""" + + + + {escape(text)} + + + + """ + response = await self.client.post( + self.endpoint, + content=ssml, + headers={ + "Authorization": f"Bearer {self.token}", + "User-Agent": f"AstrBot/{VERSION}" + } + ) + response.raise_for_status() + file_path.parent.mkdir(parents=True, exist_ok=True) + with file_path.open("wb") as f: + for chunk in response.iter_bytes(4096): + f.write(chunk) + return str(file_path.resolve()) + +@register_provider_adapter("azure_tts", "Azure TTS", ProviderType.TEXT_TO_SPEECH) +class AzureTTSProvider(TTSProvider): + def __init__(self, provider_config: dict, provider_settings: dict): + super().__init__(provider_config, provider_settings) + key_value = provider_config.get("azure_tts_subscription_key", "") + self.provider = self._parse_provider(key_value, provider_config) + + def _parse_provider(self, key_value: str, config: dict) -> TTSProvider: + if key_value.lower().startswith("other["): + try: + match = re.match(r"other\[(.*)\]", key_value, re.DOTALL) + if not match: + raise ValueError("无效的other[...]格式,应形如 other[{...}]") + json_str = match.group(1).strip() + otts_config = json.loads(json_str) + required = {"OTTS_SKEY", "OTTS_URL", "OTTS_AUTH_TIME"} + if missing := required - otts_config.keys(): + raise ValueError(f"缺少OTTS参数: {', '.join(missing)}") + return OTTSProvider(otts_config) + except json.JSONDecodeError as e: + error_msg = ( + f"JSON解析失败,请检查格式(错误位置:行 {e.lineno} 列 {e.colno})\n" + f"错误详情: {e.msg}\n" + f"错误上下文: {json_str[max(0, e.pos-30):e.pos+30]}" + ) + raise ValueError(error_msg) from e + except KeyError as e: + raise ValueError(f"配置错误: 缺少必要参数 {e}") from e + if re.fullmatch(r'^[a-zA-Z0-9]{32}$', key_value): + return AzureNativeProvider(config, self.provider_settings) + raise ValueError("订阅密钥格式无效,应为32位字母数字或other[...]格式") + + async def get_audio(self, text: str) -> str: + if isinstance(self.provider, OTTSProvider): + async with self.provider as provider: + return await provider.get_audio( + text, + { + "voice": self.provider_config.get("azure_tts_voice"), + "style": self.provider_config.get("azure_tts_style"), + "role": self.provider_config.get("azure_tts_role"), + "rate": self.provider_config.get("azure_tts_rate"), + "volume": self.provider_config.get("azure_tts_volume") + } + ) + else: + async with self.provider as provider: + return await provider.get_audio(text) 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/gemini_source.py b/astrbot/core/provider/sources/gemini_source.py index fb47143d4..7626df23f 100644 --- a/astrbot/core/provider/sources/gemini_source.py +++ b/astrbot/core/provider/sources/gemini_source.py @@ -189,6 +189,7 @@ class ProviderGoogleGenAI(Provider): ), ) if "gemini-2.5-flash" in self.get_model() + and hasattr(types.ThinkingConfig, "thinking_budget") else None, automatic_function_calling=types.AutomaticFunctionCallingConfig( disable=True 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/filter/command_group.py b/astrbot/core/star/filter/command_group.py index 55106ad9b..67d253636 100755 --- a/astrbot/core/star/filter/command_group.py +++ b/astrbot/core/star/filter/command_group.py @@ -113,7 +113,7 @@ class CommandGroupFilter(HandlerFilter): + self.print_cmd_tree(self.sub_command_filters, event=event, cfg=cfg) ) raise ValueError( - f"指令组 {self.group_name} 未填写完全。这个指令组下有如下指令:\n" + f"参数不足。{self.group_name} 指令组下有如下指令,请参考:\n" + tree ) diff --git a/astrbot/core/star/star_manager.py b/astrbot/core/star/star_manager.py index 60b0e0c69..25d6adfe6 100644 --- a/astrbot/core/star/star_manager.py +++ b/astrbot/core/star/star_manager.py @@ -2,28 +2,40 @@ 插件的重载、启停、安装、卸载等操作。 """ -import inspect +import asyncio import functools +import inspect +import json +import logging import os import sys -import json import traceback -import yaml -import logging -import asyncio from types import ModuleType from typing import List -from astrbot.core.config.astrbot_config import AstrBotConfig -from astrbot.core import logger, sp, pip_installer -from .context import Context -from . import StarMetadata -from .updator import PluginUpdator -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 .filter.permission import PermissionTypeFilter, PermissionType +import yaml + +from astrbot.core import logger, pip_installer, sp +from astrbot.core.config.astrbot_config import AstrBotConfig +from astrbot.core.provider.register import llm_tools +from astrbot.core.utils.astrbot_path import ( + get_astrbot_config_path, + get_astrbot_plugin_path, +) +from astrbot.core.utils.io import remove_dir + +from . import StarMetadata +from .context import Context +from .filter.permission import PermissionType, PermissionTypeFilter +from .star import star_map, star_registry +from .star_handler import star_handlers_registry +from .updator import PluginUpdator + +try: + from watchfiles import PythonFilter, awatch +except ImportError: + if os.getenv("ASTRBOT_RELOAD", "0") == "1": + logger.warning("未安装 watchfiles,无法实现插件的热重载。") class PluginManager: @@ -34,17 +46,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 +60,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 文件)下所有的类""" @@ -84,13 +140,11 @@ class PluginManager: if os.path.exists(os.path.join(path, d, "main.py")) or os.path.exists( os.path.join(path, d, d + ".py") ): - modules.append( - { - "pname": d, - "module": module_str, - "module_path": os.path.join(path, d, module_str), - } - ) + modules.append({ + "pname": d, + "module": module_str, + "module_path": os.path.join(path, d, module_str), + }) return modules def _get_plugin_modules(self) -> List[dict]: 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..d7ee77bf5 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): @@ -45,13 +44,26 @@ class AstrBotUpdator(RepoZipUpdator): def _reboot(self, delay: int = 3): """重启当前程序 在指定的延迟后,终止所有子进程并重新启动程序 + 这里只能使用 os.exec* 来重启程序 """ - py = sys.executable time.sleep(delay) self.terminate_child_processes() - py = py.replace(" ", "\\ ") + if os.name == "nt": + py = f'"{sys.executable}"' + else: + py = sys.executable + try: - os.execl(py, py, *sys.argv) + if "astrbot" in os.path.basename(sys.argv[0]): # 兼容cli + if os.name == "nt": + args = [ + f'"{arg}"' if " " in arg else arg for arg in sys.argv[1:] + ] + else: + args = sys.argv[1:] + os.execl(sys.executable, py, "-m", "astrbot.cli.__main__", *args) + else: + os.execl(sys.executable, py, *sys.argv) except Exception as e: logger.error(f"重启失败({py}, {e}),请尝试手动重启。") raise e @@ -67,6 +79,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/path_util.py b/astrbot/core/utils/path_util.py index 034577e49..0d8511f0c 100644 --- a/astrbot/core/utils/path_util.py +++ b/astrbot/core/utils/path_util.py @@ -1,70 +1,72 @@ import os + from astrbot.core import logger -def path_Mapping(mappings, srcPath: str)->str: - """路径映射处理函数。尝试支援 Windows 和 Linux 的路径映射。 - Args: - mappings: 映射规则列表 - srcPath: 原路径 - Returns: - str: 处理后的路径 - """ - for mapping in mappings: - rule = mapping.split(":") - if len(rule) == 2: - from_, to_ = mapping.split(":") - elif len(rule) > 4 or len(rule) == 1: - # 切割后大于4个项目,或者只有1个项目,那肯定是错误的,只能是2,3,4个项目 - logger.warning(f"路径映射规则错误: {mapping}") - continue + +def path_Mapping(mappings, srcPath: str) -> str: + """路径映射处理函数。尝试支援 Windows 和 Linux 的路径映射。 + Args: + mappings: 映射规则列表 + srcPath: 原路径 + Returns: + str: 处理后的路径 + """ + for mapping in mappings: + rule = mapping.split(":") + if len(rule) == 2: + from_, to_ = mapping.split(":") + elif len(rule) > 4 or len(rule) == 1: + # 切割后大于4个项目,或者只有1个项目,那肯定是错误的,只能是2,3,4个项目 + logger.warning(f"路径映射规则错误: {mapping}") + continue + else: + # rule.len == 3 or 4 + if os.path.exists(rule[0] + ":" + rule[1]): + # 前面两个项目合并路径存在,说明是本地Window路径。后面一个或两个项目组成的路径本地大概率无法解析,直接拼接 + from_ = rule[0] + ":" + rule[1] + if len(rule) == 3: + to_ = rule[2] + else: + to_ = rule[2] + ":" + rule[3] else: - # rule.len == 3 or 4 - if(os.path.exists(rule[0]+":"+rule[1])): - # 前面两个项目合并路径存在,说明是本地Window路径。后面一个或两个项目组成的路径本地大概率无法解析,直接拼接 - from_ = rule[0] + ":" + rule[1] - if len(rule) == 3: - to_ = rule[2] - else: - to_ = rule[2] + ":" + rule[3] + # 前面两个项目合并路径不存在,说明第一个项目是本地Linux路径,后面一个或两个项目直接拼接。 + from_ = rule[0] + if len(rule) == 3: + to_ = rule[1] + ":" + rule[2] else: - # 前面两个项目合并路径不存在,说明第一个项目是本地Linux路径,后面一个或两个项目直接拼接。 - from_ = rule[0] - if len(rule) == 3: - to_ = rule[1] + ":" + rule[2] - else: - # 这种情况下存在四个项目,说明规则也是错误的 - logger.warning(f"路径映射规则错误: {mapping}") - continue + # 这种情况下存在四个项目,说明规则也是错误的 + logger.warning(f"路径映射规则错误: {mapping}") + continue - from_ = from_.removesuffix("/") - from_ = from_.removesuffix("\\") - to_ = to_.removesuffix("/") - to_ = to_.removesuffix("\\") - # logger.debug(f"\t路径映射-规则(处理): {from_} -> {to_}") + from_ = from_.removesuffix("/") + from_ = from_.removesuffix("\\") + to_ = to_.removesuffix("/") + to_ = to_.removesuffix("\\") + # logger.debug(f"\t路径映射-规则(处理): {from_} -> {to_}") - url = srcPath.removeprefix("file://") - if url.startswith(from_): - srcPath = url.replace(from_, to_, 1) - if ":" in srcPath: - # Windows路径处理 - srcPath = srcPath.replace("/", "\\") - else: - has_replaced_processed = False - if srcPath.startswith("."): - # 相对路径处理。如果是相对路径,可能是Linux路径,也可能是Windows路径 - sign = srcPath[1] - # 处理两个点的情况 - if sign == ".": - sign = srcPath[2] - if sign == "/": - srcPath = srcPath.replace("\\", "/") - has_replaced_processed = True - elif sign == "\\": - srcPath = srcPath.replace("/", "\\") - has_replaced_processed = True - if has_replaced_processed == False: - # 如果不是相对路径或不能处理,默认按照Linux路径处理 + url = srcPath.removeprefix("file://") + if url.startswith(from_): + srcPath = url.replace(from_, to_, 1) + if ":" in srcPath: + # Windows路径处理 + srcPath = srcPath.replace("/", "\\") + else: + has_replaced_processed = False + if srcPath.startswith("."): + # 相对路径处理。如果是相对路径,可能是Linux路径,也可能是Windows路径 + sign = srcPath[1] + # 处理两个点的情况 + if sign == ".": + sign = srcPath[2] + if sign == "/": srcPath = srcPath.replace("\\", "/") - logger.info(f"路径映射: {url} -> {srcPath}") - return srcPath - return srcPath \ No newline at end of file + has_replaced_processed = True + elif sign == "\\": + srcPath = srcPath.replace("/", "\\") + has_replaced_processed = True + if not has_replaced_processed: + # 如果不是相对路径或不能处理,默认按照Linux路径处理 + srcPath = srcPath.replace("\\", "/") + logger.info(f"路径映射: {url} -> {srcPath}") + return srcPath + return srcPath 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/__init__.py b/astrbot/dashboard/routes/__init__.py index 3e24583ed..f9309c3eb 100644 --- a/astrbot/dashboard/routes/__init__.py +++ b/astrbot/dashboard/routes/__init__.py @@ -8,6 +8,7 @@ from .static_file import StaticFileRoute from .chat import ChatRoute from .tools import ToolsRoute # 导入新的ToolsRoute from .conversation import ConversationRoute +from .file import FileRoute __all__ = [ @@ -19,6 +20,7 @@ __all__ = [ "LogRoute", "StaticFileRoute", "ChatRoute", - "ToolsRoute", # 添加新的ToolsRoute + "ToolsRoute", "ConversationRoute", + "FileRoute", ] 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/routes/file.py b/astrbot/dashboard/routes/file.py new file mode 100644 index 000000000..8ea73d084 --- /dev/null +++ b/astrbot/dashboard/routes/file.py @@ -0,0 +1,24 @@ +from .route import Route, RouteContext +from astrbot import logger +from quart import abort, send_file +from astrbot.core import file_token_service + + +class FileRoute(Route): + def __init__( + self, + context: RouteContext, + ) -> None: + super().__init__(context) + self.routes = { + "/file/": ("GET", self.serve_file), + } + self.register_routes() + + async def serve_file(self, file_token: str): + try: + file_path = await file_token_service.handle_file(file_token) + return await send_file(file_path) + except (FileNotFoundError, KeyError) as e: + logger.warning(str(e)) + return abort(404) diff --git a/astrbot/dashboard/routes/static_file.py b/astrbot/dashboard/routes/static_file.py index 4503a28e5..729fe8547 100644 --- a/astrbot/dashboard/routes/static_file.py +++ b/astrbot/dashboard/routes/static_file.py @@ -28,7 +28,7 @@ class StaticFileRoute(Route): @self.app.errorhandler(404) async def page_not_found(e): - return "404 Not found。如果你初次使用打开面板发现 404, 请参考文档: https://astrbot.app/faq.html。" + return "404 Not found。如果你初次使用打开面板发现 404, 请参考文档: https://astrbot.app/faq.html。如果你正在测试回调地址可达性,显示这段文字说明测试成功了。" async def index(self): return await self.app.send_static_file("index.html") diff --git a/astrbot/dashboard/routes/tools.py b/astrbot/dashboard/routes/tools.py index 6dd093546..d38014c71 100644 --- a/astrbot/dashboard/routes/tools.py +++ b/astrbot/dashboard/routes/tools.py @@ -1,11 +1,15 @@ -import os import json -import aiohttp +import os import traceback -from .route import Route, Response, RouteContext + +import aiohttp from quart import request -from astrbot.core.core_lifecycle import AstrBotCoreLifecycle + from astrbot.core import logger +from astrbot.core.core_lifecycle import AstrBotCoreLifecycle +from astrbot.core.utils.astrbot_path import get_astrbot_data_path + +from .route import Response, Route, RouteContext DEFAULT_MCP_CONFIG = {"mcpServers": {}} @@ -28,8 +32,7 @@ class ToolsRoute(Route): @property def mcp_config_path(self): - 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() return os.path.join(data_dir, "mcp_server.json") def load_mcp_config(self): @@ -130,13 +133,11 @@ class ToolsRoute(Route): if self.save_mcp_config(config): # 动态初始化新MCP客户端 - await self.tool_mgr.mcp_service_queue.put( - { - "type": "init", - "name": name, - "cfg": config["mcpServers"][name], - } - ) + await self.tool_mgr.mcp_service_queue.put({ + "type": "init", + "name": name, + "cfg": config["mcpServers"][name], + }) return Response().ok(None, f"成功添加 MCP 服务器 {name}").__dict__ else: return Response().error("保存配置失败").__dict__ @@ -194,37 +195,29 @@ class ToolsRoute(Route): if active: # 如果要激活服务器或者配置已更改 if name in self.tool_mgr.mcp_client_dict or not only_update_active: - await self.tool_mgr.mcp_service_queue.put( - { - "type": "terminate", - "name": name, - } - ) - await self.tool_mgr.mcp_service_queue.put( - { - "type": "init", - "name": name, - "cfg": config["mcpServers"][name], - } - ) + await self.tool_mgr.mcp_service_queue.put({ + "type": "terminate", + "name": name, + }) + await self.tool_mgr.mcp_service_queue.put({ + "type": "init", + "name": name, + "cfg": config["mcpServers"][name], + }) else: # 客户端不存在,初始化 - await self.tool_mgr.mcp_service_queue.put( - { - "type": "init", - "name": name, - "cfg": config["mcpServers"][name], - } - ) + await self.tool_mgr.mcp_service_queue.put({ + "type": "init", + "name": name, + "cfg": config["mcpServers"][name], + }) else: # 如果要停用服务器 if name in self.tool_mgr.mcp_client_dict: - self.tool_mgr.mcp_service_queue.put_nowait( - { - "type": "terminate", - "name": name, - } - ) + self.tool_mgr.mcp_service_queue.put_nowait({ + "type": "terminate", + "name": name, + }) return Response().ok(None, f"成功更新 MCP 服务器 {name}").__dict__ else: @@ -252,12 +245,10 @@ class ToolsRoute(Route): if self.save_mcp_config(config): # 关闭并删除MCP客户端 if name in self.tool_mgr.mcp_client_dict: - self.tool_mgr.mcp_service_queue.put_nowait( - { - "type": "terminate", - "name": name, - } - ) + self.tool_mgr.mcp_service_queue.put_nowait({ + "type": "terminate", + "name": name, + }) return Response().ok(None, f"成功删除 MCP 服务器 {name}").__dict__ else: @@ -269,9 +260,11 @@ class ToolsRoute(Route): async def get_mcp_markets(self): page = request.args.get("page", 1, type=int) page_size = request.args.get("page_size", 10, type=int) - BASE_URL = "https://api.soulter.top/astrbot/mcpservers?page={}&page_size={}".format( - page, - page_size, + BASE_URL = ( + "https://api.soulter.top/astrbot/mcpservers?page={}&page_size={}".format( + page, + page_size, + ) ) try: async with aiohttp.ClientSession() as session: @@ -287,4 +280,4 @@ class ToolsRoute(Route): ) except Exception as _: logger.error(traceback.format_exc()) - return Response().error("获取市场数据失败").__dict__ \ No newline at end of file + return Response().error("获取市场数据失败").__dict__ diff --git a/astrbot/dashboard/server.py b/astrbot/dashboard/server.py index 5d1310807..124291718 100644 --- a/astrbot/dashboard/server.py +++ b/astrbot/dashboard/server.py @@ -13,10 +13,7 @@ from .routes.route import RouteContext, Response from astrbot.core import logger, WEBUI_SK from astrbot.core.db import BaseDatabase from astrbot.core.utils.io import get_local_ip_addresses - -DATAPATH = os.path.abspath( - os.path.join(os.path.dirname(os.path.abspath(__file__)), "../../data") -) +from astrbot.core.utils.astrbot_path import get_astrbot_data_path class AstrBotDashboard: @@ -28,7 +25,7 @@ class AstrBotDashboard: ) -> None: self.core_lifecycle = core_lifecycle self.config = core_lifecycle.astrbot_config - self.data_path = os.path.abspath(os.path.join(DATAPATH, "dist")) + self.data_path = os.path.abspath(os.path.join(get_astrbot_data_path(), "dist")) self.app = Quart("dashboard", static_folder=self.data_path, static_url_path="/") self.app.config["MAX_CONTENT_LENGTH"] = ( 128 * 1024 * 1024 @@ -52,15 +49,15 @@ class AstrBotDashboard: self.chat_route = ChatRoute(self.context, db, core_lifecycle) self.tools_root = ToolsRoute(self.context, core_lifecycle) self.conversation_route = ConversationRoute(self.context, db, core_lifecycle) + self.file_route = FileRoute(self.context) self.shutdown_event = shutdown_event async def auth_middleware(self): if not request.path.startswith("/api"): return - if request.path == "/api/auth/login": - return - if request.path == "/api/chat/get_file": + allowed_endpoints = ["/api/auth/login", "/api/chat/get_file", "/api/file"] + if any(request.path.startswith(prefix) for prefix in allowed_endpoints): return # claim jwt token = request.headers.get("Authorization") @@ -125,7 +122,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/changelogs/v3.5.8.md b/changelogs/v3.5.8.md new file mode 100644 index 000000000..ee592d567 --- /dev/null +++ b/changelogs/v3.5.8.md @@ -0,0 +1,5 @@ +# What's Changed + +1. 支持接入微信公众平台,详见 [AstrBot - 微信公众平台](https://astrbot.app/deploy/platform/weixin-official-account.html) @Soulter +2. 优化 gemini_source 方法默认参数 @Raven95676 +3. 优化 persona 错误显示 @Soulter \ No newline at end of file diff --git a/changelogs/v3.5.9.md b/changelogs/v3.5.9.md new file mode 100644 index 000000000..760841e9c --- /dev/null +++ b/changelogs/v3.5.9.md @@ -0,0 +1,13 @@ +# What's Changed + +1. 重构: 采用更好的方式将文件上传到 NapCat 协议端,无需映射路径。**(需要前往 配置->其他配置 中配置`对外可达的回调接口地址`)** @Soulter @anka-afk +2. 修复: 单独发送文件时被认为是空消息导致文件无法发送的问题 @Soulter +3. 修复: Lagrange 下合并转发消息失败的问题 @Soulter +4. 修复: CLI 模式下路径问题导致 WebUI 和 MCP Server 无法加载的问题 @Soulter +5. 修复: 设置 Gemini 的 thinking_budget 前,先检查是否存在 @Raven95676 +6. 修复: 修复企业微信和微信公众平台下无法应用 api_base_url 的问题 @Soulter +7. 优化: 分离 plugin 指令为指令组,优化 plugin 指令权限控制 @Soulter +8. 优化: WebUI 更直观的模型提供商选择 @Soulter +9. 优化: AstrBot 的重启逻辑 @Anchor +10. 新增: CLI 支持部分配置文件项的设定、支持插件管理和检测到插件文件变化时自动热重载 @Raven95676 +11. 新增: 现已支持 Azure TTS @NanoRocky \ No newline at end of file diff --git a/dashboard/src/assets/images/auth/social-google.svg b/dashboard/src/assets/images/auth/social-google.svg deleted file mode 100644 index 2231ce986..000000000 --- a/dashboard/src/assets/images/auth/social-google.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/dashboard/src/assets/images/favicon.svg b/dashboard/src/assets/images/favicon.svg deleted file mode 100644 index 72033ff62..000000000 --- a/dashboard/src/assets/images/favicon.svg +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - - - - - - - - - - - - diff --git a/dashboard/src/assets/images/icons/icon-card.svg b/dashboard/src/assets/images/icons/icon-card.svg deleted file mode 100644 index e877b599e..000000000 --- a/dashboard/src/assets/images/icons/icon-card.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/dashboard/src/assets/images/logos/logo.svg b/dashboard/src/assets/images/logos/logo.svg deleted file mode 100644 index 79eb9bd9d..000000000 --- a/dashboard/src/assets/images/logos/logo.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - - - diff --git a/dashboard/src/assets/images/logos/logolight.svg b/dashboard/src/assets/images/logos/logolight.svg deleted file mode 100644 index a02bbbba9..000000000 --- a/dashboard/src/assets/images/logos/logolight.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - - - diff --git a/dashboard/src/assets/images/maintenance/img-error-bg.svg b/dashboard/src/assets/images/maintenance/img-error-bg.svg deleted file mode 100644 index 57af439c9..000000000 --- a/dashboard/src/assets/images/maintenance/img-error-bg.svg +++ /dev/null @@ -1,34 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/dashboard/src/assets/images/maintenance/img-error-blue.svg b/dashboard/src/assets/images/maintenance/img-error-blue.svg deleted file mode 100644 index a72084386..000000000 --- a/dashboard/src/assets/images/maintenance/img-error-blue.svg +++ /dev/null @@ -1,43 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/dashboard/src/assets/images/maintenance/img-error-purple.svg b/dashboard/src/assets/images/maintenance/img-error-purple.svg deleted file mode 100644 index 12904c1a8..000000000 --- a/dashboard/src/assets/images/maintenance/img-error-purple.svg +++ /dev/null @@ -1,42 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/dashboard/src/assets/images/maintenance/img-error-text.svg b/dashboard/src/assets/images/maintenance/img-error-text.svg deleted file mode 100644 index 16ed50aaf..000000000 --- a/dashboard/src/assets/images/maintenance/img-error-text.svg +++ /dev/null @@ -1,27 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/dashboard/src/assets/images/profile/user-round.svg b/dashboard/src/assets/images/profile/user-round.svg deleted file mode 100644 index db47c4ba8..000000000 --- a/dashboard/src/assets/images/profile/user-round.svg +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - - - - - - - - - diff --git a/dashboard/src/views/ProviderPage.vue b/dashboard/src/views/ProviderPage.vue index 2adc7cfcc..a5219457b 100644 --- a/dashboard/src/views/ProviderPage.vue +++ b/dashboard/src/views/ProviderPage.vue @@ -20,23 +20,9 @@ 服务提供商 {{ config_data.provider?.length || 0 }} - - - - - {{ index }} - - - + + 新增服务提供商 + @@ -97,6 +83,71 @@ + + + + + mdi-plus-circle + 服务提供商 + + + mdi-close + + + + + + + mdi-message-text + 基本 + + + mdi-microphone-message + 语音转文字 + + + mdi-volume-high + 文字转语音 + + + + + + + + + + + {{ name }} + + + {{ getProviderDescription(template, name) }} + + + + + + 暂无{{ getTabTypeName(tabType) }}类型的提供商模板 + + + + + + + + + @@ -170,6 +221,10 @@ export default { save_message_success: "success", showConsole: false, + + // 新增提供商对话框相关 + showAddProviderDialog: false, + activeProviderTab: 'chat_completion', } }, @@ -188,13 +243,83 @@ export default { }); }, - addFromDefaultConfigTmpl(index) { - this.newSelectedProviderName = index[0]; + // 按提供商类型获取模板列表 + getTemplatesByType(type) { + const templates = this.metadata['provider_group']?.metadata?.provider?.config_template || {}; + const filtered = {}; + + for (const [name, template] of Object.entries(templates)) { + if (template.provider_type === type) { + filtered[name] = template; + } + } + + return filtered; + }, + + // 获取提供商类型对应的图标 + getProviderIcon(type) { + const icons = { + 'OpenAI': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/openai.svg', + 'Azure OpenAI': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/openai.svg', + 'Whisper': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/openai.svg', + 'xAI': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/xai.svg', + 'Anthropic': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/anthropic.svg', + 'Ollama': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/anthropic.svg', + 'Gemini': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/gemini-color.svg', + 'Gemini(OpenAI兼容)': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/gemini-color.svg', + 'DeepSeek': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/deepseek.svg', + '智谱 AI': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/zhipu.svg', + '硅基流动': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/siliconcloud.svg', + 'Kimi': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/kimi.svg', + 'PPIO派欧云': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/ppio.svg', + 'Dify': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/dify-color.svg', + '阿里云百炼': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/alibabacloud-color.svg', + 'FastGPT': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/fastgpt-color.svg', + 'LM Studio': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/lmstudio.svg', + 'FishAudio': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/fishaudio.svg', + 'Azure': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/azure.svg', + }; + for (const key in icons) { + if (type.startsWith(key)) { + return icons[key]; + } + } + return '' + }, + + // 获取Tab类型的中文名称 + getTabTypeName(tabType) { + const names = { + 'chat_completion': '基本对话', + 'speech_to_text': '语音转文本', + 'text_to_speech': '文本转语音' + }; + return names[tabType] || tabType; + }, + + // 获取提供商简介 + getProviderDescription(template, name) { + if (name == 'OpenAI') { + return `${template.type} 服务提供商。同时也支持所有兼容 OpenAI API 的模型提供商。`; + } + return `${template.type} 服务提供商`; + }, + + // 选择提供商模板 + selectProviderTemplate(name) { + this.newSelectedProviderName = name; this.showProviderCfg = true; this.updatingMode = false; this.newSelectedProviderConfig = JSON.parse(JSON.stringify( - this.metadata['provider_group']?.metadata?.provider?.config_template[index[0]] || {} + this.metadata['provider_group']?.metadata?.provider?.config_template[name] || {} )); + this.showAddProviderDialog = false; + }, + + // 废弃旧方法,保留为兼容 + addFromDefaultConfigTmpl(index) { + this.selectProviderTemplate(index[0]); }, configExistingProvider(provider) { @@ -326,4 +451,29 @@ export default { padding: 20px; padding-top: 8px; } + +.provider-selection-dialog .v-card-title { + border-top-left-radius: 4px; + border-top-right-radius: 4px; +} + +.provider-card { + transition: all 0.3s ease; + height: 100%; + cursor: pointer; +} + +.provider-card:hover { + transform: translateY(-4px); + box-shadow: 0 4px 25px 0 rgba(0, 0, 0, 0.05); + border-color: var(--v-primary-base); +} + +.v-tabs { + border-radius: 8px; +} + +.v-window { + border-radius: 4px; +} \ No newline at end of file diff --git a/packages/astrbot/main.py b/packages/astrbot/main.py index 9dcd4a688..04d4cadb7 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 @@ -205,105 +207,118 @@ class Main(star.Star): self.context.deactivate_llm_tool(tool.name) event.set_result(MessageEventResult().message("停用所有工具成功。")) - @filter.command("plugin") - async def plugin( - self, event: AstrMessageEvent, oper1: str = None, oper2: str = None - ): - """插件管理""" - if oper1 is None: - plugin_list_info = "已加载的插件:\n" - for plugin in self.context.get_all_stars(): - plugin_list_info += ( - f"- `{plugin.name}` By {plugin.author}: {plugin.desc}" - ) - if not plugin.activated: - plugin_list_info += " (未启用)" - plugin_list_info += "\n" - if plugin_list_info.strip() == "": - plugin_list_info = "没有加载任何插件。" + @filter.command_group("plugin") + def plugin(self): + pass - plugin_list_info += "\n使用 /plugin <插件名> 查看插件帮助和加载的指令。\n使用 /plugin on/off <插件名> 启用或者禁用插件。" - event.set_result( - MessageEventResult().message(f"{plugin_list_info}").use_t2i(False) + @plugin.command("ls") + async def plugin_ls(self, event: AstrMessageEvent): + """获取已经安装的插件列表。""" + plugin_list_info = "已加载的插件:\n" + for plugin in self.context.get_all_stars(): + plugin_list_info += ( + f"- `{plugin.name}` By {plugin.author}: {plugin.desc}" ) - else: - if oper1 == "off": - # 禁用插件 - if oper2 is None: - event.set_result( - MessageEventResult().message("/plugin off <插件名> 禁用插件。") - ) - return - await self.context._star_manager.turn_off_plugin(oper2) - event.set_result(MessageEventResult().message(f"插件 {oper2} 已禁用。")) - elif oper1 == "on": - # 启用插件 - if oper2 is None: - event.set_result( - MessageEventResult().message("/plugin on <插件名> 启用插件。") - ) - return - await self.context._star_manager.turn_on_plugin(oper2) - event.set_result(MessageEventResult().message(f"插件 {oper2} 已启用。")) - elif oper1 == "get": - if not oper2: - raise Exception("请输入插件地址。") - if not event.is_admin(): - raise Exception( - "改指令限制仅管理员使用,且无法通过 /alter_cmd 更改。" - ) - if not oper2.startswith("http"): - oper2 = f"https://github.com/{oper2}" + if not plugin.activated: + plugin_list_info += " (未启用)" + plugin_list_info += "\n" + if plugin_list_info.strip() == "": + plugin_list_info = "没有加载任何插件。" - logger.info(f"准备从 {oper2} 获取插件。") + plugin_list_info += "\n使用 /plugin help <插件名> 查看插件帮助和加载的指令。\n使用 /plugin on/off <插件名> 启用或者禁用插件。" + event.set_result( + MessageEventResult().message(f"{plugin_list_info}").use_t2i(False) + ) - if self.context._star_manager: - star_mgr: PluginManager = self.context._star_manager - try: - await star_mgr.install_plugin(oper2) - event.set_result(MessageEventResult().message("获取插件成功。")) - except Exception as e: - logger.error(f"获取插件失败: {e}") - event.set_result( - MessageEventResult().message(f"获取插件失败: {e}") - ) - return - else: - # 获取插件帮助 - plugin = self.context.get_registered_star(oper1) - if plugin is None: - event.set_result(MessageEventResult().message("未找到此插件。")) - return - help_msg = "" - help_msg += f"\n\n✨ 作者: {plugin.author}\n✨ 版本: {plugin.version}" - command_handlers = [] - command_names = [] - for handler in star_handlers_registry: - assert isinstance(handler, StarHandlerMetadata) - if handler.handler_module_path != plugin.module_path: - continue - for filter_ in handler.event_filters: - if isinstance(filter_, CommandFilter): - command_handlers.append(handler) - command_names.append(filter_.command_name) - break - elif isinstance(filter_, CommandGroupFilter): - command_handlers.append(handler) - command_names.append(filter_.group_name) + @filter.permission_type(filter.PermissionType.ADMIN) + @plugin.command("off") + async def plugin_off(self, event: AstrMessageEvent, plugin_name: str = None): + """禁用插件""" + if not plugin_name: + event.set_result( + MessageEventResult().message("/plugin off <插件名> 禁用插件。") + ) + return + await self.context._star_manager.turn_off_plugin(plugin_name) + event.set_result(MessageEventResult().message(f"插件 {plugin_name} 已禁用。")) - if len(command_handlers) > 0: - help_msg += "\n\n🔧 指令列表:\n" - for i in range(len(command_handlers)): - help_msg += f"- {command_names[i]}" - if command_handlers[i].desc: - help_msg += f": {command_handlers[i].desc}" - help_msg += "\n" + @filter.permission_type(filter.PermissionType.ADMIN) + @plugin.command("on") + async def plugin_on(self, event: AstrMessageEvent, plugin_name: str = None): + """启用插件""" + if not plugin_name: + event.set_result( + MessageEventResult().message("/plugin on <插件名> 启用插件。") + ) + return + await self.context._star_manager.turn_on_plugin(plugin_name) + event.set_result(MessageEventResult().message(f"插件 {plugin_name} 已启用。")) - help_msg += "\nTip: 指令的触发需要添加唤醒前缀,默认为 /。" + @filter.permission_type(filter.PermissionType.ADMIN) + @plugin.command("get") + async def plugin_get(self, event: AstrMessageEvent, plugin_repo: str = None): + """安装插件""" + if not plugin_repo: + event.set_result( + MessageEventResult().message("/plugin get <插件仓库地址> 安装插件") + ) + return + logger.info(f"准备从 {plugin_repo} 安装插件。") + if self.context._star_manager: + star_mgr: PluginManager = self.context._star_manager + try: + await star_mgr.install_plugin(plugin_repo) + event.set_result(MessageEventResult().message("安装插件成功。")) + except Exception as e: + logger.error(f"安装插件失败: {e}") + event.set_result( + MessageEventResult().message(f"安装插件失败: {e}") + ) + return + + @plugin.command("help") + async def plugin_help(self, event: AstrMessageEvent, plugin_name: str = None): + """获取插件帮助""" + if not plugin_name: + event.set_result( + MessageEventResult().message("/plugin help <插件名> 查看插件信息。") + ) + return + plugin = self.context.get_registered_star(plugin_name) + if plugin is None: + event.set_result(MessageEventResult().message("未找到此插件。")) + return + help_msg = "" + help_msg += f"\n\n✨ 作者: {plugin.author}\n✨ 版本: {plugin.version}" + command_handlers = [] + command_names = [] + for handler in star_handlers_registry: + assert isinstance(handler, StarHandlerMetadata) + if handler.handler_module_path != plugin.module_path: + continue + for filter_ in handler.event_filters: + if isinstance(filter_, CommandFilter): + command_handlers.append(handler) + command_names.append(filter_.command_name) + break + elif isinstance(filter_, CommandGroupFilter): + command_handlers.append(handler) + command_names.append(filter_.group_name) + + if len(command_handlers) > 0: + help_msg += "\n\n🔧 指令列表:\n" + for i in range(len(command_handlers)): + help_msg += f"- {command_names[i]}" + if command_handlers[i].desc: + help_msg += f": {command_handlers[i].desc}" + help_msg += "\n" + + help_msg += "\nTip: 指令的触发需要添加唤醒前缀,默认为 /。" + + ret = f"🧩 插件 {plugin_name} 帮助信息:\n" + help_msg + ret += "更多帮助信息请查看插件仓库 README。" + event.set_result(MessageEventResult().message(ret).use_t2i(False)) - ret = f"🧩 插件 {oper1} 帮助信息:\n" + help_msg - ret += "更多帮助信息请查看插件仓库 README。" - event.set_result(MessageEventResult().message(ret).use_t2i(False)) @filter.command("t2i") async def t2i(self, event: AstrMessageEvent): @@ -1017,6 +1032,8 @@ UID: {user_id} 此 ID 可用于设置管理员。 conversation = await self.context.conversation_manager.get_conversation( message.unified_msg_origin, cid ) + if not conversation: + message.set_result(MessageEventResult().message("请先进入一个对话。可以使用 /new 创建。")) if not conversation.persona_id and not conversation.persona_id == "[%None]": curr_persona_name = ( self.context.provider_manager.selected_default_persona["name"] @@ -1159,7 +1176,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 664740190..3f08cb4d0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,33 +1,34 @@ [project] name = "AstrBot" -version = "3.5.7" +version = "3.5.9" description = "易上手的多平台 LLM 聊天机器人及开发框架" readme = "README.md" requires-python = ">=3.10" dependencies = [ "aiocqhttp>=1.4.4", "aiodocker>=0.24.0", - "aiohttp>=3.11.14", - "anthropic>=0.49.0", + "aiohttp>=3.11.18", + "anthropic>=0.51.0", "apscheduler>=3.11.0", - "beautifulsoup4>=4.13.3", - "certifi>=2025.1.31", + "beautifulsoup4>=4.13.4", + "certifi>=2025.4.26", "chardet~=5.1.0", "colorlog>=6.9.0", - "cryptography>=44.0.2", - "dashscope>=1.22.2", + "cryptography>=44.0.3", + "dashscope>=1.23.2", "defusedxml>=0.7.1", "dingtalk-stream>=0.22.1", "docstring-parser>=0.16", - "google-genai>=1.10.0", + "filelock>=3.18.0", + "google-genai>=1.14.0", "googlesearch-python>=1.3.0", - "lark-oapi>=1.4.12", - "lxml-html-clean>=0.4.1", - "mcp>=1.5.0", - "openai>=1.68.2", - "ormsgpack>=1.9.0", - "pillow>=11.1.0", - "pip>=25.0.1", + "lark-oapi>=1.4.15", + "lxml-html-clean>=0.4.2", + "mcp>=1.8.0", + "openai>=1.78.0", + "ormsgpack>=1.9.1", + "pillow>=11.2.1", + "pip>=25.1.1", "psutil>=5.8.0", "pydantic~=2.10.3", "pydub>=0.25.1", @@ -35,9 +36,10 @@ dependencies = [ "python-telegram-bot>=22.0", "qq-botpy>=1.2.1", "quart>=0.20.0", - "readability-lxml>=0.8.1", + "readability-lxml>=0.8.4.1", "silk-python>=0.2.6", - "telegramify-markdown>=0.5.0", + "telegramify-markdown>=0.5.1", + "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..883789fec 100644 --- a/uv.lock +++ b/uv.lock @@ -44,7 +44,7 @@ wheels = [ [[package]] name = "aiohttp" -version = "3.11.16" +version = "3.11.18" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohappyeyeballs" }, @@ -56,72 +56,72 @@ dependencies = [ { name = "propcache" }, { name = "yarl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f1/d9/1c4721d143e14af753f2bf5e3b681883e1f24b592c0482df6fa6e33597fa/aiohttp-3.11.16.tar.gz", hash = "sha256:16f8a2c9538c14a557b4d309ed4d0a7c60f0253e8ed7b6c9a2859a7582f8b1b8", size = 7676826 } +sdist = { url = "https://files.pythonhosted.org/packages/63/e7/fa1a8c00e2c54b05dc8cb5d1439f627f7c267874e3f7bb047146116020f9/aiohttp-3.11.18.tar.gz", hash = "sha256:ae856e1138612b7e412db63b7708735cff4d38d0399f6a5435d3dac2669f558a", size = 7678653 } wheels = [ - { url = "https://files.pythonhosted.org/packages/b8/21/6bd4cb580a323b64cda3b11fcb3f68deba77568e97806727a858de57349d/aiohttp-3.11.16-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:fb46bb0f24813e6cede6cc07b1961d4b04f331f7112a23b5e21f567da4ee50aa", size = 708259 }, - { url = "https://files.pythonhosted.org/packages/96/8c/7b4b9debe90ffc31931b85ee8612a5c83f34d8fdc6d90ee3eb27b43639e4/aiohttp-3.11.16-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:54eb3aead72a5c19fad07219acd882c1643a1027fbcdefac9b502c267242f955", size = 468886 }, - { url = "https://files.pythonhosted.org/packages/13/da/a7fcd68e62acacf0a1930060afd2c970826f989265893082b6fb9eb25cb5/aiohttp-3.11.16-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:38bea84ee4fe24ebcc8edeb7b54bf20f06fd53ce4d2cc8b74344c5b9620597fd", size = 455846 }, - { url = "https://files.pythonhosted.org/packages/5d/12/b73d9423253f4c872d276a3771decb0722cb5f962352593bd617445977ba/aiohttp-3.11.16-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d0666afbe984f6933fe72cd1f1c3560d8c55880a0bdd728ad774006eb4241ecd", size = 1587183 }, - { url = "https://files.pythonhosted.org/packages/75/d3/291b57d54719d996e6cb8c1db8b13d01bdb24dca90434815ac7e6a70393f/aiohttp-3.11.16-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7ba92a2d9ace559a0a14b03d87f47e021e4fa7681dc6970ebbc7b447c7d4b7cd", size = 1634937 }, - { url = "https://files.pythonhosted.org/packages/be/85/4229eba92b433173065b0b459ab677ca11ead4a179f76ccfe55d8738b188/aiohttp-3.11.16-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3ad1d59fd7114e6a08c4814983bb498f391c699f3c78712770077518cae63ff7", size = 1667980 }, - { url = "https://files.pythonhosted.org/packages/2b/0d/d2423936962e3c711fafd5bb9172a99e6b07dd63e086515aa957d8a991fd/aiohttp-3.11.16-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98b88a2bf26965f2015a771381624dd4b0839034b70d406dc74fd8be4cc053e3", size = 1590365 }, - { url = "https://files.pythonhosted.org/packages/ea/93/04209affc20834982c1ef4214b1afc07743667998a9975d69413e9c1e1c1/aiohttp-3.11.16-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:576f5ca28d1b3276026f7df3ec841ae460e0fc3aac2a47cbf72eabcfc0f102e1", size = 1547614 }, - { url = "https://files.pythonhosted.org/packages/f6/fb/194ad4e4cae98023ae19556e576347f402ce159e80d74cc0713d460c4a39/aiohttp-3.11.16-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a2a450bcce4931b295fc0848f384834c3f9b00edfc2150baafb4488c27953de6", size = 1532815 }, - { url = "https://files.pythonhosted.org/packages/33/6d/a4da7adbac90188bf1228c73b6768a607dd279c146721a9ff7dcb75c5ac6/aiohttp-3.11.16-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:37dcee4906454ae377be5937ab2a66a9a88377b11dd7c072df7a7c142b63c37c", size = 1559005 }, - { url = "https://files.pythonhosted.org/packages/7e/88/2fa9fbfd23fc16cb2cfdd1f290343e085e7e327438041e9c6aa0208a854d/aiohttp-3.11.16-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:4d0c970c0d602b1017e2067ff3b7dac41c98fef4f7472ec2ea26fd8a4e8c2149", size = 1535231 }, - { url = "https://files.pythonhosted.org/packages/f5/8f/9623cd2558e3e182d02dcda8b480643e1c48a0550a86e3050210e98dba27/aiohttp-3.11.16-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:004511d3413737700835e949433536a2fe95a7d0297edd911a1e9705c5b5ea43", size = 1609985 }, - { url = "https://files.pythonhosted.org/packages/f8/a2/53a8d1bfc67130710f1c8091f623cdefe7f85cd5d09e14637ed2ed6e1a6d/aiohttp-3.11.16-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:c15b2271c44da77ee9d822552201180779e5e942f3a71fb74e026bf6172ff287", size = 1628842 }, - { url = "https://files.pythonhosted.org/packages/49/3a/35fb43d07489573c6c1f8c6a3e6c657196124a63223705b7feeddaea06f1/aiohttp-3.11.16-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ad9509ffb2396483ceacb1eee9134724443ee45b92141105a4645857244aecc8", size = 1566929 }, - { url = "https://files.pythonhosted.org/packages/d5/82/bb3f4f2cc7677e790ba4c040db7dd8445c234a810ef893a858e217647d38/aiohttp-3.11.16-cp310-cp310-win32.whl", hash = "sha256:634d96869be6c4dc232fc503e03e40c42d32cfaa51712aee181e922e61d74814", size = 416935 }, - { url = "https://files.pythonhosted.org/packages/df/ad/a64db1c18063569d6dff474c46a7d4de7ab85ff55e2a35839b149b1850ea/aiohttp-3.11.16-cp310-cp310-win_amd64.whl", hash = "sha256:938f756c2b9374bbcc262a37eea521d8a0e6458162f2a9c26329cc87fdf06534", size = 442168 }, - { url = "https://files.pythonhosted.org/packages/b1/98/be30539cd84260d9f3ea1936d50445e25aa6029a4cb9707f3b64cfd710f7/aiohttp-3.11.16-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8cb0688a8d81c63d716e867d59a9ccc389e97ac7037ebef904c2b89334407180", size = 708664 }, - { url = "https://files.pythonhosted.org/packages/e6/27/d51116ce18bdfdea7a2244b55ad38d7b01a4298af55765eed7e8431f013d/aiohttp-3.11.16-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0ad1fb47da60ae1ddfb316f0ff16d1f3b8e844d1a1e154641928ea0583d486ed", size = 468953 }, - { url = "https://files.pythonhosted.org/packages/34/23/eedf80ec42865ea5355b46265a2433134138eff9a4fea17e1348530fa4ae/aiohttp-3.11.16-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:df7db76400bf46ec6a0a73192b14c8295bdb9812053f4fe53f4e789f3ea66bbb", size = 456065 }, - { url = "https://files.pythonhosted.org/packages/36/23/4a5b1ef6cff994936bf96d981dd817b487d9db755457a0d1c2939920d620/aiohttp-3.11.16-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cc3a145479a76ad0ed646434d09216d33d08eef0d8c9a11f5ae5cdc37caa3540", size = 1687976 }, - { url = "https://files.pythonhosted.org/packages/d0/5d/c7474b4c3069bb35276d54c82997dff4f7575e4b73f0a7b1b08a39ece1eb/aiohttp-3.11.16-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d007aa39a52d62373bd23428ba4a2546eed0e7643d7bf2e41ddcefd54519842c", size = 1752711 }, - { url = "https://files.pythonhosted.org/packages/64/4c/ee416987b6729558f2eb1b727c60196580aafdb141e83bd78bb031d1c000/aiohttp-3.11.16-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f6ddd90d9fb4b501c97a4458f1c1720e42432c26cb76d28177c5b5ad4e332601", size = 1791305 }, - { url = "https://files.pythonhosted.org/packages/58/28/3e1e1884070b95f1f69c473a1995852a6f8516670bb1c29d6cb2dbb73e1c/aiohttp-3.11.16-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0a2f451849e6b39e5c226803dcacfa9c7133e9825dcefd2f4e837a2ec5a3bb98", size = 1674499 }, - { url = "https://files.pythonhosted.org/packages/ad/55/a032b32fa80a662d25d9eb170ed1e2c2be239304ca114ec66c89dc40f37f/aiohttp-3.11.16-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8df6612df74409080575dca38a5237282865408016e65636a76a2eb9348c2567", size = 1622313 }, - { url = "https://files.pythonhosted.org/packages/b1/df/ca775605f72abbda4e4746e793c408c84373ca2c6ce7a106a09f853f1e89/aiohttp-3.11.16-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:78e6e23b954644737e385befa0deb20233e2dfddf95dd11e9db752bdd2a294d3", size = 1658274 }, - { url = "https://files.pythonhosted.org/packages/cc/6c/21c45b66124df5b4b0ab638271ecd8c6402b702977120cb4d5be6408e15d/aiohttp-3.11.16-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:696ef00e8a1f0cec5e30640e64eca75d8e777933d1438f4facc9c0cdf288a810", size = 1666704 }, - { url = "https://files.pythonhosted.org/packages/1d/e2/7d92adc03e3458edd18a21da2575ab84e58f16b1672ae98529e4eeee45ab/aiohttp-3.11.16-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e3538bc9fe1b902bef51372462e3d7c96fce2b566642512138a480b7adc9d508", size = 1652815 }, - { url = "https://files.pythonhosted.org/packages/3a/52/7549573cd654ad651e3c5786ec3946d8f0ee379023e22deb503ff856b16c/aiohttp-3.11.16-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:3ab3367bb7f61ad18793fea2ef71f2d181c528c87948638366bf1de26e239183", size = 1735669 }, - { url = "https://files.pythonhosted.org/packages/d5/54/dcd24a23c7a5a2922123e07a296a5f79ea87ce605f531be068415c326de6/aiohttp-3.11.16-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:56a3443aca82abda0e07be2e1ecb76a050714faf2be84256dae291182ba59049", size = 1760422 }, - { url = "https://files.pythonhosted.org/packages/a7/53/87327fe982fa310944e1450e97bf7b2a28015263771931372a1dfe682c58/aiohttp-3.11.16-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:61c721764e41af907c9d16b6daa05a458f066015abd35923051be8705108ed17", size = 1694457 }, - { url = "https://files.pythonhosted.org/packages/ce/6d/c5ccf41059267bcf89853d3db9d8d217dacf0a04f4086cb6bf278323011f/aiohttp-3.11.16-cp311-cp311-win32.whl", hash = "sha256:3e061b09f6fa42997cf627307f220315e313ece74907d35776ec4373ed718b86", size = 416817 }, - { url = "https://files.pythonhosted.org/packages/e7/dd/01f6fe028e054ef4f909c9d63e3a2399e77021bb2e1bb51d56ca8b543989/aiohttp-3.11.16-cp311-cp311-win_amd64.whl", hash = "sha256:745f1ed5e2c687baefc3c5e7b4304e91bf3e2f32834d07baaee243e349624b24", size = 442986 }, - { url = "https://files.pythonhosted.org/packages/db/38/100d01cbc60553743baf0fba658cb125f8ad674a8a771f765cdc155a890d/aiohttp-3.11.16-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:911a6e91d08bb2c72938bc17f0a2d97864c531536b7832abee6429d5296e5b27", size = 704881 }, - { url = "https://files.pythonhosted.org/packages/21/ed/b4102bb6245e36591209e29f03fe87e7956e54cb604ee12e20f7eb47f994/aiohttp-3.11.16-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6ac13b71761e49d5f9e4d05d33683bbafef753e876e8e5a7ef26e937dd766713", size = 464564 }, - { url = "https://files.pythonhosted.org/packages/3b/e1/a9ab6c47b62ecee080eeb33acd5352b40ecad08fb2d0779bcc6739271745/aiohttp-3.11.16-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fd36c119c5d6551bce374fcb5c19269638f8d09862445f85a5a48596fd59f4bb", size = 456548 }, - { url = "https://files.pythonhosted.org/packages/80/ad/216c6f71bdff2becce6c8776f0aa32cb0fa5d83008d13b49c3208d2e4016/aiohttp-3.11.16-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d489d9778522fbd0f8d6a5c6e48e3514f11be81cb0a5954bdda06f7e1594b321", size = 1691749 }, - { url = "https://files.pythonhosted.org/packages/bd/ea/7df7bcd3f4e734301605f686ffc87993f2d51b7acb6bcc9b980af223f297/aiohttp-3.11.16-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:69a2cbd61788d26f8f1e626e188044834f37f6ae3f937bd9f08b65fc9d7e514e", size = 1736874 }, - { url = "https://files.pythonhosted.org/packages/51/41/c7724b9c87a29b7cfd1202ec6446bae8524a751473d25e2ff438bc9a02bf/aiohttp-3.11.16-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd464ba806e27ee24a91362ba3621bfc39dbbb8b79f2e1340201615197370f7c", size = 1786885 }, - { url = "https://files.pythonhosted.org/packages/86/b3/f61f8492fa6569fa87927ad35a40c159408862f7e8e70deaaead349e2fba/aiohttp-3.11.16-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1ce63ae04719513dd2651202352a2beb9f67f55cb8490c40f056cea3c5c355ce", size = 1698059 }, - { url = "https://files.pythonhosted.org/packages/ce/be/7097cf860a9ce8bbb0e8960704e12869e111abcd3fbd245153373079ccec/aiohttp-3.11.16-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:09b00dd520d88eac9d1768439a59ab3d145065c91a8fab97f900d1b5f802895e", size = 1626527 }, - { url = "https://files.pythonhosted.org/packages/1d/1d/aaa841c340e8c143a8d53a1f644c2a2961c58cfa26e7b398d6bf75cf5d23/aiohttp-3.11.16-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7f6428fee52d2bcf96a8aa7b62095b190ee341ab0e6b1bcf50c615d7966fd45b", size = 1644036 }, - { url = "https://files.pythonhosted.org/packages/2c/88/59d870f76e9345e2b149f158074e78db457985c2b4da713038d9da3020a8/aiohttp-3.11.16-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:13ceac2c5cdcc3f64b9015710221ddf81c900c5febc505dbd8f810e770011540", size = 1685270 }, - { url = "https://files.pythonhosted.org/packages/2b/b1/c6686948d4c79c3745595efc469a9f8a43cab3c7efc0b5991be65d9e8cb8/aiohttp-3.11.16-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:fadbb8f1d4140825069db3fedbbb843290fd5f5bc0a5dbd7eaf81d91bf1b003b", size = 1650852 }, - { url = "https://files.pythonhosted.org/packages/fe/94/3e42a6916fd3441721941e0f1b8438e1ce2a4c49af0e28e0d3c950c9b3c9/aiohttp-3.11.16-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:6a792ce34b999fbe04a7a71a90c74f10c57ae4c51f65461a411faa70e154154e", size = 1704481 }, - { url = "https://files.pythonhosted.org/packages/b1/6d/6ab5854ff59b27075c7a8c610597d2b6c38945f9a1284ee8758bc3720ff6/aiohttp-3.11.16-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:f4065145bf69de124accdd17ea5f4dc770da0a6a6e440c53f6e0a8c27b3e635c", size = 1735370 }, - { url = "https://files.pythonhosted.org/packages/73/2a/08a68eec3c99a6659067d271d7553e4d490a0828d588e1daa3970dc2b771/aiohttp-3.11.16-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fa73e8c2656a3653ae6c307b3f4e878a21f87859a9afab228280ddccd7369d71", size = 1697619 }, - { url = "https://files.pythonhosted.org/packages/61/d5/fea8dbbfb0cd68fbb56f0ae913270a79422d9a41da442a624febf72d2aaf/aiohttp-3.11.16-cp312-cp312-win32.whl", hash = "sha256:f244b8e541f414664889e2c87cac11a07b918cb4b540c36f7ada7bfa76571ea2", size = 411710 }, - { url = "https://files.pythonhosted.org/packages/33/fb/41cde15fbe51365024550bf77b95a4fc84ef41365705c946da0421f0e1e0/aiohttp-3.11.16-cp312-cp312-win_amd64.whl", hash = "sha256:23a15727fbfccab973343b6d1b7181bfb0b4aa7ae280f36fd2f90f5476805682", size = 438012 }, - { url = "https://files.pythonhosted.org/packages/52/52/7c712b2d9fb4d5e5fd6d12f9ab76e52baddfee71e3c8203ca7a7559d7f51/aiohttp-3.11.16-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a3814760a1a700f3cfd2f977249f1032301d0a12c92aba74605cfa6ce9f78489", size = 698005 }, - { url = "https://files.pythonhosted.org/packages/51/3e/61057814f7247666d43ac538abcd6335b022869ade2602dab9bf33f607d2/aiohttp-3.11.16-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9b751a6306f330801665ae69270a8a3993654a85569b3469662efaad6cf5cc50", size = 461106 }, - { url = "https://files.pythonhosted.org/packages/4f/85/6b79fb0ea6e913d596d5b949edc2402b20803f51b1a59e1bbc5bb7ba7569/aiohttp-3.11.16-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ad497f38a0d6c329cb621774788583ee12321863cd4bd9feee1effd60f2ad133", size = 453394 }, - { url = "https://files.pythonhosted.org/packages/4b/04/e1bb3fcfbd2c26753932c759593a32299aff8625eaa0bf8ff7d9c0c34a36/aiohttp-3.11.16-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca37057625693d097543bd88076ceebeb248291df9d6ca8481349efc0b05dcd0", size = 1666643 }, - { url = "https://files.pythonhosted.org/packages/0e/27/97bc0fdd1f439b8f060beb3ba8fb47b908dc170280090801158381ad7942/aiohttp-3.11.16-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a5abcbba9f4b463a45c8ca8b7720891200658f6f46894f79517e6cd11f3405ca", size = 1721948 }, - { url = "https://files.pythonhosted.org/packages/2c/4f/bc4c5119e75c05ef15c5670ef1563bbe25d4ed4893b76c57b0184d815e8b/aiohttp-3.11.16-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f420bfe862fb357a6d76f2065447ef6f484bc489292ac91e29bc65d2d7a2c84d", size = 1774454 }, - { url = "https://files.pythonhosted.org/packages/73/5b/54b42b2150bb26fdf795464aa55ceb1a49c85f84e98e6896d211eabc6670/aiohttp-3.11.16-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58ede86453a6cf2d6ce40ef0ca15481677a66950e73b0a788917916f7e35a0bb", size = 1677785 }, - { url = "https://files.pythonhosted.org/packages/10/ee/a0fe68916d3f82eae199b8535624cf07a9c0a0958c7a76e56dd21140487a/aiohttp-3.11.16-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6fdec0213244c39973674ca2a7f5435bf74369e7d4e104d6c7473c81c9bcc8c4", size = 1608456 }, - { url = "https://files.pythonhosted.org/packages/8b/48/83afd779242b7cf7e1ceed2ff624a86d3221e17798061cf9a79e0b246077/aiohttp-3.11.16-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:72b1b03fb4655c1960403c131740755ec19c5898c82abd3961c364c2afd59fe7", size = 1622424 }, - { url = "https://files.pythonhosted.org/packages/6f/27/452f1d5fca1f516f9f731539b7f5faa9e9d3bf8a3a6c3cd7c4b031f20cbd/aiohttp-3.11.16-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:780df0d837276276226a1ff803f8d0fa5f8996c479aeef52eb040179f3156cbd", size = 1660943 }, - { url = "https://files.pythonhosted.org/packages/d6/e1/5c7d63143b8d00c83b958b9e78e7048c4a69903c760c1e329bf02bac57a1/aiohttp-3.11.16-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ecdb8173e6c7aa09eee342ac62e193e6904923bd232e76b4157ac0bfa670609f", size = 1622797 }, - { url = "https://files.pythonhosted.org/packages/46/9e/2ac29cca2746ee8e449e73cd2fcb3d454467393ec03a269d50e49af743f1/aiohttp-3.11.16-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:a6db7458ab89c7d80bc1f4e930cc9df6edee2200127cfa6f6e080cf619eddfbd", size = 1687162 }, - { url = "https://files.pythonhosted.org/packages/ad/6b/eaa6768e02edebaf37d77f4ffb74dd55f5cbcbb6a0dbf798ccec7b0ac23b/aiohttp-3.11.16-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:2540ddc83cc724b13d1838026f6a5ad178510953302a49e6d647f6e1de82bc34", size = 1718518 }, - { url = "https://files.pythonhosted.org/packages/e5/18/dda87cbad29472a51fa058d6d8257dfce168289adaeb358b86bd93af3b20/aiohttp-3.11.16-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3b4e6db8dc4879015b9955778cfb9881897339c8fab7b3676f8433f849425913", size = 1675254 }, - { url = "https://files.pythonhosted.org/packages/32/d9/d2fb08c614df401d92c12fcbc60e6e879608d5e8909ef75c5ad8d4ad8aa7/aiohttp-3.11.16-cp313-cp313-win32.whl", hash = "sha256:493910ceb2764f792db4dc6e8e4b375dae1b08f72e18e8f10f18b34ca17d0979", size = 410698 }, - { url = "https://files.pythonhosted.org/packages/ce/ed/853e36d5a33c24544cfa46585895547de152dfef0b5c79fa675f6e4b7b87/aiohttp-3.11.16-cp313-cp313-win_amd64.whl", hash = "sha256:42864e70a248f5f6a49fdaf417d9bc62d6e4d8ee9695b24c5916cb4bb666c802", size = 436395 }, + { url = "https://files.pythonhosted.org/packages/c7/c3/e5f64af7e97a02f547020e6ff861595766bb5ecb37c7492fac9fe3c14f6c/aiohttp-3.11.18-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:96264854fedbea933a9ca4b7e0c745728f01380691687b7365d18d9e977179c4", size = 711703 }, + { url = "https://files.pythonhosted.org/packages/5f/2f/53c26e96efa5fd01ebcfe1fefdfb7811f482bb21f4fa103d85eca4dcf888/aiohttp-3.11.18-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9602044ff047043430452bc3a2089743fa85da829e6fc9ee0025351d66c332b6", size = 471348 }, + { url = "https://files.pythonhosted.org/packages/80/47/dcc248464c9b101532ee7d254a46f6ed2c1fd3f4f0f794cf1f2358c0d45b/aiohttp-3.11.18-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5691dc38750fcb96a33ceef89642f139aa315c8a193bbd42a0c33476fd4a1609", size = 457611 }, + { url = "https://files.pythonhosted.org/packages/4c/ca/67d816ef075e8ac834b5f1f6b18e8db7d170f7aebaf76f1be462ea10cab0/aiohttp-3.11.18-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:554c918ec43f8480b47a5ca758e10e793bd7410b83701676a4782672d670da55", size = 1591976 }, + { url = "https://files.pythonhosted.org/packages/46/00/0c120287aa51c744438d99e9aae9f8c55ca5b9911c42706966c91c9d68d6/aiohttp-3.11.18-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8a4076a2b3ba5b004b8cffca6afe18a3b2c5c9ef679b4d1e9859cf76295f8d4f", size = 1632819 }, + { url = "https://files.pythonhosted.org/packages/54/a3/3923c9040cd4927dfee1aa017513701e35adcfc35d10729909688ecaa465/aiohttp-3.11.18-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:767a97e6900edd11c762be96d82d13a1d7c4fc4b329f054e88b57cdc21fded94", size = 1666567 }, + { url = "https://files.pythonhosted.org/packages/e0/ab/40dacb15c0c58f7f17686ea67bc186e9f207341691bdb777d1d5ff4671d5/aiohttp-3.11.18-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f0ddc9337a0fb0e727785ad4f41163cc314376e82b31846d3835673786420ef1", size = 1594959 }, + { url = "https://files.pythonhosted.org/packages/0d/98/d40c2b7c4a5483f9a16ef0adffce279ced3cc44522e84b6ba9e906be5168/aiohttp-3.11.18-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f414f37b244f2a97e79b98d48c5ff0789a0b4b4609b17d64fa81771ad780e415", size = 1538516 }, + { url = "https://files.pythonhosted.org/packages/cf/10/e0bf3a03524faac45a710daa034e6f1878b24a1fef9c968ac8eb786ae657/aiohttp-3.11.18-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:fdb239f47328581e2ec7744ab5911f97afb10752332a6dd3d98e14e429e1a9e7", size = 1529037 }, + { url = "https://files.pythonhosted.org/packages/ad/d6/5ff5282e00e4eb59c857844984cbc5628f933e2320792e19f93aff518f52/aiohttp-3.11.18-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:f2c50bad73ed629cc326cc0f75aed8ecfb013f88c5af116f33df556ed47143eb", size = 1546813 }, + { url = "https://files.pythonhosted.org/packages/de/96/f1014f84101f9b9ad2d8acf3cc501426475f7f0cc62308ae5253e2fac9a7/aiohttp-3.11.18-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0a8d8f20c39d3fa84d1c28cdb97f3111387e48209e224408e75f29c6f8e0861d", size = 1523852 }, + { url = "https://files.pythonhosted.org/packages/a5/86/ec772c6838dd6bae3229065af671891496ac1834b252f305cee8152584b2/aiohttp-3.11.18-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:106032eaf9e62fd6bc6578c8b9e6dc4f5ed9a5c1c7fb2231010a1b4304393421", size = 1603766 }, + { url = "https://files.pythonhosted.org/packages/84/38/31f85459c9402d409c1499284fc37a96f69afadce3cfac6a1b5ab048cbf1/aiohttp-3.11.18-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:b491e42183e8fcc9901d8dcd8ae644ff785590f1727f76ca86e731c61bfe6643", size = 1620647 }, + { url = "https://files.pythonhosted.org/packages/31/2f/54aba0040764dd3d362fb37bd6aae9b3034fcae0b27f51b8a34864e48209/aiohttp-3.11.18-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ad8c745ff9460a16b710e58e06a9dec11ebc0d8f4dd82091cefb579844d69868", size = 1559260 }, + { url = "https://files.pythonhosted.org/packages/ca/d2/a05c7dd9e1b6948c1c5d04f1a8bcfd7e131923fa809bb87477d5c76f1517/aiohttp-3.11.18-cp310-cp310-win32.whl", hash = "sha256:8e57da93e24303a883146510a434f0faf2f1e7e659f3041abc4e3fb3f6702a9f", size = 418051 }, + { url = "https://files.pythonhosted.org/packages/39/e2/796a6179e8abe267dfc84614a50291560a989d28acacbc5dab3bcd4cbec4/aiohttp-3.11.18-cp310-cp310-win_amd64.whl", hash = "sha256:cc93a4121d87d9f12739fc8fab0a95f78444e571ed63e40bfc78cd5abe700ac9", size = 442908 }, + { url = "https://files.pythonhosted.org/packages/2f/10/fd9ee4f9e042818c3c2390054c08ccd34556a3cb209d83285616434cf93e/aiohttp-3.11.18-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:427fdc56ccb6901ff8088544bde47084845ea81591deb16f957897f0f0ba1be9", size = 712088 }, + { url = "https://files.pythonhosted.org/packages/22/eb/6a77f055ca56f7aae2cd2a5607a3c9e7b9554f1497a069dcfcb52bfc9540/aiohttp-3.11.18-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2c828b6d23b984255b85b9b04a5b963a74278b7356a7de84fda5e3b76866597b", size = 471450 }, + { url = "https://files.pythonhosted.org/packages/78/dc/5f3c0d27c91abf0bb5d103e9c9b0ff059f60cf6031a5f06f456c90731f42/aiohttp-3.11.18-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5c2eaa145bb36b33af1ff2860820ba0589e165be4ab63a49aebfd0981c173b66", size = 457836 }, + { url = "https://files.pythonhosted.org/packages/49/7b/55b65af9ef48b9b811c91ff8b5b9de9650c71147f10523e278d297750bc8/aiohttp-3.11.18-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3d518ce32179f7e2096bf4e3e8438cf445f05fedd597f252de9f54c728574756", size = 1690978 }, + { url = "https://files.pythonhosted.org/packages/a2/5a/3f8938c4f68ae400152b42742653477fc625d6bfe02e764f3521321c8442/aiohttp-3.11.18-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0700055a6e05c2f4711011a44364020d7a10fbbcd02fbf3e30e8f7e7fddc8717", size = 1745307 }, + { url = "https://files.pythonhosted.org/packages/b4/42/89b694a293333ef6f771c62da022163bcf44fb03d4824372d88e3dc12530/aiohttp-3.11.18-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8bd1cde83e4684324e6ee19adfc25fd649d04078179890be7b29f76b501de8e4", size = 1780692 }, + { url = "https://files.pythonhosted.org/packages/e2/ce/1a75384e01dd1bf546898b6062b1b5f7a59b6692ef802e4dd6db64fed264/aiohttp-3.11.18-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:73b8870fe1c9a201b8c0d12c94fe781b918664766728783241a79e0468427e4f", size = 1676934 }, + { url = "https://files.pythonhosted.org/packages/a5/31/442483276e6c368ab5169797d9873b5875213cbcf7e74b95ad1c5003098a/aiohttp-3.11.18-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:25557982dd36b9e32c0a3357f30804e80790ec2c4d20ac6bcc598533e04c6361", size = 1621190 }, + { url = "https://files.pythonhosted.org/packages/7b/83/90274bf12c079457966008a58831a99675265b6a34b505243e004b408934/aiohttp-3.11.18-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7e889c9df381a2433802991288a61e5a19ceb4f61bd14f5c9fa165655dcb1fd1", size = 1658947 }, + { url = "https://files.pythonhosted.org/packages/91/c1/da9cee47a0350b78fdc93670ebe7ad74103011d7778ab4c382ca4883098d/aiohttp-3.11.18-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:9ea345fda05bae217b6cce2acf3682ce3b13d0d16dd47d0de7080e5e21362421", size = 1654443 }, + { url = "https://files.pythonhosted.org/packages/c9/f2/73cbe18dc25d624f79a09448adfc4972f82ed6088759ddcf783cd201956c/aiohttp-3.11.18-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:9f26545b9940c4b46f0a9388fd04ee3ad7064c4017b5a334dd450f616396590e", size = 1644169 }, + { url = "https://files.pythonhosted.org/packages/5b/32/970b0a196c4dccb1b0cfa5b4dc3b20f63d76f1c608f41001a84b2fd23c3d/aiohttp-3.11.18-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:3a621d85e85dccabd700294494d7179ed1590b6d07a35709bb9bd608c7f5dd1d", size = 1728532 }, + { url = "https://files.pythonhosted.org/packages/0b/50/b1dc810a41918d2ea9574e74125eb053063bc5e14aba2d98966f7d734da0/aiohttp-3.11.18-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9c23fd8d08eb9c2af3faeedc8c56e134acdaf36e2117ee059d7defa655130e5f", size = 1750310 }, + { url = "https://files.pythonhosted.org/packages/95/24/39271f5990b35ff32179cc95537e92499d3791ae82af7dcf562be785cd15/aiohttp-3.11.18-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d9e6b0e519067caa4fd7fb72e3e8002d16a68e84e62e7291092a5433763dc0dd", size = 1691580 }, + { url = "https://files.pythonhosted.org/packages/6b/78/75d0353feb77f041460564f12fe58e456436bbc00cbbf5d676dbf0038cc2/aiohttp-3.11.18-cp311-cp311-win32.whl", hash = "sha256:122f3e739f6607e5e4c6a2f8562a6f476192a682a52bda8b4c6d4254e1138f4d", size = 417565 }, + { url = "https://files.pythonhosted.org/packages/ed/97/b912dcb654634a813f8518de359364dfc45976f822116e725dc80a688eee/aiohttp-3.11.18-cp311-cp311-win_amd64.whl", hash = "sha256:e6f3c0a3a1e73e88af384b2e8a0b9f4fb73245afd47589df2afcab6b638fa0e6", size = 443652 }, + { url = "https://files.pythonhosted.org/packages/b5/d2/5bc436f42bf4745c55f33e1e6a2d69e77075d3e768e3d1a34f96ee5298aa/aiohttp-3.11.18-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:63d71eceb9cad35d47d71f78edac41fcd01ff10cacaa64e473d1aec13fa02df2", size = 706671 }, + { url = "https://files.pythonhosted.org/packages/fe/d0/2dbabecc4e078c0474abb40536bbde717fb2e39962f41c5fc7a216b18ea7/aiohttp-3.11.18-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d1929da615840969929e8878d7951b31afe0bac883d84418f92e5755d7b49508", size = 466169 }, + { url = "https://files.pythonhosted.org/packages/70/84/19edcf0b22933932faa6e0be0d933a27bd173da02dc125b7354dff4d8da4/aiohttp-3.11.18-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d0aebeb2392f19b184e3fdd9e651b0e39cd0f195cdb93328bd124a1d455cd0e", size = 457554 }, + { url = "https://files.pythonhosted.org/packages/32/d0/e8d1f034ae5624a0f21e4fb3feff79342ce631f3a4d26bd3e58b31ef033b/aiohttp-3.11.18-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3849ead845e8444f7331c284132ab314b4dac43bfae1e3cf350906d4fff4620f", size = 1690154 }, + { url = "https://files.pythonhosted.org/packages/16/de/2f9dbe2ac6f38f8495562077131888e0d2897e3798a0ff3adda766b04a34/aiohttp-3.11.18-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5e8452ad6b2863709f8b3d615955aa0807bc093c34b8e25b3b52097fe421cb7f", size = 1733402 }, + { url = "https://files.pythonhosted.org/packages/e0/04/bd2870e1e9aef990d14b6df2a695f17807baf5c85a4c187a492bda569571/aiohttp-3.11.18-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3b8d2b42073611c860a37f718b3d61ae8b4c2b124b2e776e2c10619d920350ec", size = 1783958 }, + { url = "https://files.pythonhosted.org/packages/23/06/4203ffa2beb5bedb07f0da0f79b7d9039d1c33f522e0d1a2d5b6218e6f2e/aiohttp-3.11.18-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:40fbf91f6a0ac317c0a07eb328a1384941872f6761f2e6f7208b63c4cc0a7ff6", size = 1695288 }, + { url = "https://files.pythonhosted.org/packages/30/b2/e2285dda065d9f29ab4b23d8bcc81eb881db512afb38a3f5247b191be36c/aiohttp-3.11.18-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:44ff5625413fec55216da5eaa011cf6b0a2ed67a565914a212a51aa3755b0009", size = 1618871 }, + { url = "https://files.pythonhosted.org/packages/57/e0/88f2987885d4b646de2036f7296ebea9268fdbf27476da551c1a7c158bc0/aiohttp-3.11.18-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7f33a92a2fde08e8c6b0c61815521324fc1612f397abf96eed86b8e31618fdb4", size = 1646262 }, + { url = "https://files.pythonhosted.org/packages/e0/19/4d2da508b4c587e7472a032290b2981f7caeca82b4354e19ab3df2f51d56/aiohttp-3.11.18-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:11d5391946605f445ddafda5eab11caf310f90cdda1fd99865564e3164f5cff9", size = 1677431 }, + { url = "https://files.pythonhosted.org/packages/eb/ae/047473ea50150a41440f3265f53db1738870b5a1e5406ece561ca61a3bf4/aiohttp-3.11.18-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:3cc314245deb311364884e44242e00c18b5896e4fe6d5f942e7ad7e4cb640adb", size = 1637430 }, + { url = "https://files.pythonhosted.org/packages/11/32/c6d1e3748077ce7ee13745fae33e5cb1dac3e3b8f8787bf738a93c94a7d2/aiohttp-3.11.18-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:0f421843b0f70740772228b9e8093289924359d306530bcd3926f39acbe1adda", size = 1703342 }, + { url = "https://files.pythonhosted.org/packages/c5/1d/a3b57bfdbe285f0d45572d6d8f534fd58761da3e9cbc3098372565005606/aiohttp-3.11.18-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:e220e7562467dc8d589e31c1acd13438d82c03d7f385c9cd41a3f6d1d15807c1", size = 1740600 }, + { url = "https://files.pythonhosted.org/packages/a5/71/f9cd2fed33fa2b7ce4d412fb7876547abb821d5b5520787d159d0748321d/aiohttp-3.11.18-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ab2ef72f8605046115bc9aa8e9d14fd49086d405855f40b79ed9e5c1f9f4faea", size = 1695131 }, + { url = "https://files.pythonhosted.org/packages/97/97/d1248cd6d02b9de6aa514793d0dcb20099f0ec47ae71a933290116c070c5/aiohttp-3.11.18-cp312-cp312-win32.whl", hash = "sha256:12a62691eb5aac58d65200c7ae94d73e8a65c331c3a86a2e9670927e94339ee8", size = 412442 }, + { url = "https://files.pythonhosted.org/packages/33/9a/e34e65506e06427b111e19218a99abf627638a9703f4b8bcc3e3021277ed/aiohttp-3.11.18-cp312-cp312-win_amd64.whl", hash = "sha256:364329f319c499128fd5cd2d1c31c44f234c58f9b96cc57f743d16ec4f3238c8", size = 439444 }, + { url = "https://files.pythonhosted.org/packages/0a/18/be8b5dd6b9cf1b2172301dbed28e8e5e878ee687c21947a6c81d6ceaa15d/aiohttp-3.11.18-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:474215ec618974054cf5dc465497ae9708543cbfc312c65212325d4212525811", size = 699833 }, + { url = "https://files.pythonhosted.org/packages/0d/84/ecdc68e293110e6f6f6d7b57786a77555a85f70edd2b180fb1fafaff361a/aiohttp-3.11.18-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6ced70adf03920d4e67c373fd692123e34d3ac81dfa1c27e45904a628567d804", size = 462774 }, + { url = "https://files.pythonhosted.org/packages/d7/85/f07718cca55884dad83cc2433746384d267ee970e91f0dcc75c6d5544079/aiohttp-3.11.18-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2d9f6c0152f8d71361905aaf9ed979259537981f47ad099c8b3d81e0319814bd", size = 454429 }, + { url = "https://files.pythonhosted.org/packages/82/02/7f669c3d4d39810db8842c4e572ce4fe3b3a9b82945fdd64affea4c6947e/aiohttp-3.11.18-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a35197013ed929c0aed5c9096de1fc5a9d336914d73ab3f9df14741668c0616c", size = 1670283 }, + { url = "https://files.pythonhosted.org/packages/ec/79/b82a12f67009b377b6c07a26bdd1b81dab7409fc2902d669dbfa79e5ac02/aiohttp-3.11.18-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:540b8a1f3a424f1af63e0af2d2853a759242a1769f9f1ab053996a392bd70118", size = 1717231 }, + { url = "https://files.pythonhosted.org/packages/a6/38/d5a1f28c3904a840642b9a12c286ff41fc66dfa28b87e204b1f242dbd5e6/aiohttp-3.11.18-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f9e6710ebebfce2ba21cee6d91e7452d1125100f41b906fb5af3da8c78b764c1", size = 1769621 }, + { url = "https://files.pythonhosted.org/packages/53/2d/deb3749ba293e716b5714dda06e257f123c5b8679072346b1eb28b766a0b/aiohttp-3.11.18-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f8af2ef3b4b652ff109f98087242e2ab974b2b2b496304063585e3d78de0b000", size = 1678667 }, + { url = "https://files.pythonhosted.org/packages/b8/a8/04b6e11683a54e104b984bd19a9790eb1ae5f50968b601bb202d0406f0ff/aiohttp-3.11.18-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:28c3f975e5ae3dbcbe95b7e3dcd30e51da561a0a0f2cfbcdea30fc1308d72137", size = 1601592 }, + { url = "https://files.pythonhosted.org/packages/5e/9d/c33305ae8370b789423623f0e073d09ac775cd9c831ac0f11338b81c16e0/aiohttp-3.11.18-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c28875e316c7b4c3e745172d882d8a5c835b11018e33432d281211af35794a93", size = 1621679 }, + { url = "https://files.pythonhosted.org/packages/56/45/8e9a27fff0538173d47ba60362823358f7a5f1653c6c30c613469f94150e/aiohttp-3.11.18-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:13cd38515568ae230e1ef6919e2e33da5d0f46862943fcda74e7e915096815f3", size = 1656878 }, + { url = "https://files.pythonhosted.org/packages/84/5b/8c5378f10d7a5a46b10cb9161a3aac3eeae6dba54ec0f627fc4ddc4f2e72/aiohttp-3.11.18-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:0e2a92101efb9f4c2942252c69c63ddb26d20f46f540c239ccfa5af865197bb8", size = 1620509 }, + { url = "https://files.pythonhosted.org/packages/9e/2f/99dee7bd91c62c5ff0aa3c55f4ae7e1bc99c6affef780d7777c60c5b3735/aiohttp-3.11.18-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:e6d3e32b8753c8d45ac550b11a1090dd66d110d4ef805ffe60fa61495360b3b2", size = 1680263 }, + { url = "https://files.pythonhosted.org/packages/03/0a/378745e4ff88acb83e2d5c884a4fe993a6e9f04600a4560ce0e9b19936e3/aiohttp-3.11.18-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:ea4cf2488156e0f281f93cc2fd365025efcba3e2d217cbe3df2840f8c73db261", size = 1715014 }, + { url = "https://files.pythonhosted.org/packages/f6/0b/b5524b3bb4b01e91bc4323aad0c2fcaebdf2f1b4d2eb22743948ba364958/aiohttp-3.11.18-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9d4df95ad522c53f2b9ebc07f12ccd2cb15550941e11a5bbc5ddca2ca56316d7", size = 1666614 }, + { url = "https://files.pythonhosted.org/packages/c7/b7/3d7b036d5a4ed5a4c704e0754afe2eef24a824dfab08e6efbffb0f6dd36a/aiohttp-3.11.18-cp313-cp313-win32.whl", hash = "sha256:cdd1bbaf1e61f0d94aced116d6e95fe25942f7a5f42382195fd9501089db5d78", size = 411358 }, + { url = "https://files.pythonhosted.org/packages/1e/3c/143831b32cd23b5263a995b2a1794e10aa42f8a895aae5074c20fda36c07/aiohttp-3.11.18-cp313-cp313-win_amd64.whl", hash = "sha256:bdd619c27e44382cf642223f11cfd4d795161362a5a1fc1fa3940397bc89db01", size = 437658 }, ] [[package]] @@ -147,7 +147,7 @@ wheels = [ [[package]] name = "anthropic" -version = "0.49.0" +version = "0.51.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -158,9 +158,9 @@ dependencies = [ { name = "sniffio" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/86/e3/a88c8494ce4d1a88252b9e053607e885f9b14d0a32273d47b727cbee4228/anthropic-0.49.0.tar.gz", hash = "sha256:c09e885b0f674b9119b4f296d8508907f6cff0009bc20d5cf6b35936c40b4398", size = 210016 } +sdist = { url = "https://files.pythonhosted.org/packages/63/4a/96f99a61ae299f9e5aa3e765d7342d95ab2e2ba5b69a3ffedb00ef779651/anthropic-0.51.0.tar.gz", hash = "sha256:6f824451277992af079554430d5b2c8ff5bc059cc2c968cdc3f06824437da201", size = 219063 } wheels = [ - { url = "https://files.pythonhosted.org/packages/76/74/5d90ad14d55fbe3f9c474fdcb6e34b4bed99e3be8efac98734a5ddce88c1/anthropic-0.49.0-py3-none-any.whl", hash = "sha256:bbc17ad4e7094988d2fa86b87753ded8dce12498f4b85fe5810f208f454a8375", size = 243368 }, + { url = "https://files.pythonhosted.org/packages/8c/6e/9637122c5f007103bd5a259f4250bd8f1533dd2473227670fd10a1457b62/anthropic-0.51.0-py3-none-any.whl", hash = "sha256:b8b47d482c9aa1f81b923555cebb687c2730309a20d01be554730c8302e0f62a", size = 263957 }, ] [[package]] @@ -192,7 +192,7 @@ wheels = [ [[package]] name = "astrbot" -version = "3.5.7" +version = "3.5.9" source = { editable = "." } dependencies = [ { name = "aiocqhttp" }, @@ -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" }, ] @@ -235,27 +237,28 @@ dependencies = [ requires-dist = [ { name = "aiocqhttp", specifier = ">=1.4.4" }, { name = "aiodocker", specifier = ">=0.24.0" }, - { name = "aiohttp", specifier = ">=3.11.14" }, - { name = "anthropic", specifier = ">=0.49.0" }, + { name = "aiohttp", specifier = ">=3.11.18" }, + { name = "anthropic", specifier = ">=0.51.0" }, { name = "apscheduler", specifier = ">=3.11.0" }, - { name = "beautifulsoup4", specifier = ">=4.13.3" }, - { name = "certifi", specifier = ">=2025.1.31" }, + { name = "beautifulsoup4", specifier = ">=4.13.4" }, + { name = "certifi", specifier = ">=2025.4.26" }, { name = "chardet", specifier = "~=5.1.0" }, { name = "colorlog", specifier = ">=6.9.0" }, - { name = "cryptography", specifier = ">=44.0.2" }, - { name = "dashscope", specifier = ">=1.22.2" }, + { name = "cryptography", specifier = ">=44.0.3" }, + { name = "dashscope", specifier = ">=1.23.2" }, { name = "defusedxml", specifier = ">=0.7.1" }, { name = "dingtalk-stream", specifier = ">=0.22.1" }, { name = "docstring-parser", specifier = ">=0.16" }, - { name = "google-genai", specifier = ">=1.10.0" }, + { name = "filelock", specifier = ">=3.18.0" }, + { name = "google-genai", specifier = ">=1.14.0" }, { name = "googlesearch-python", specifier = ">=1.3.0" }, - { name = "lark-oapi", specifier = ">=1.4.12" }, - { name = "lxml-html-clean", specifier = ">=0.4.1" }, - { name = "mcp", specifier = ">=1.5.0" }, - { name = "openai", specifier = ">=1.68.2" }, - { name = "ormsgpack", specifier = ">=1.9.0" }, - { name = "pillow", specifier = ">=11.1.0" }, - { name = "pip", specifier = ">=25.0.1" }, + { name = "lark-oapi", specifier = ">=1.4.15" }, + { name = "lxml-html-clean", specifier = ">=0.4.2" }, + { name = "mcp", specifier = ">=1.8.0" }, + { name = "openai", specifier = ">=1.78.0" }, + { name = "ormsgpack", specifier = ">=1.9.1" }, + { name = "pillow", specifier = ">=11.2.1" }, + { name = "pip", specifier = ">=25.1.1" }, { name = "psutil", specifier = ">=5.8.0" }, { name = "pydantic", specifier = "~=2.10.3" }, { name = "pydub", specifier = ">=0.25.1" }, @@ -263,9 +266,10 @@ requires-dist = [ { name = "python-telegram-bot", specifier = ">=22.0" }, { name = "qq-botpy", specifier = ">=1.2.1" }, { name = "quart", specifier = ">=0.20.0" }, - { name = "readability-lxml", specifier = ">=0.8.1" }, + { name = "readability-lxml", specifier = ">=0.8.4.1" }, { name = "silk-python", specifier = ">=0.2.6" }, - { name = "telegramify-markdown", specifier = ">=0.5.0" }, + { name = "telegramify-markdown", specifier = ">=0.5.1" }, + { name = "watchfiles", specifier = ">=1.0.5" }, { name = "wechatpy", specifier = ">=1.8.18" }, ] @@ -320,11 +324,11 @@ wheels = [ [[package]] name = "certifi" -version = "2025.1.31" +version = "2025.4.26" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1c/ab/c9f1e32b7b1bf505bf26f0ef697775960db7932abeb7b516de930ba2705f/certifi-2025.1.31.tar.gz", hash = "sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651", size = 167577 } +sdist = { url = "https://files.pythonhosted.org/packages/e8/9e/c05b3920a3b7d20d3d3310465f50348e5b3694f4f88c6daf736eef3024c4/certifi-2025.4.26.tar.gz", hash = "sha256:0a816057ea3cdefcef70270d2c515e4506bbc954f417fa5ade2021213bb8f0c6", size = 160705 } wheels = [ - { url = "https://files.pythonhosted.org/packages/38/fc/bce832fd4fd99766c04d1ee0eead6b0ec6486fb100ae5e74c1d91292b982/certifi-2025.1.31-py3-none-any.whl", hash = "sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe", size = 166393 }, + { url = "https://files.pythonhosted.org/packages/4a/7e/3db2bd1b1f9e95f7cddca6d6e75e2f2bd9f51b1246e546d88addca0106bd/certifi-2025.4.26-py3-none-any.whl", hash = "sha256:30350364dfe371162649852c63336a15c70c6510c2ad5015b21c2345311805f3", size = 159618 }, ] [[package]] @@ -489,47 +493,49 @@ wheels = [ [[package]] name = "cryptography" -version = "44.0.2" +version = "44.0.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/cd/25/4ce80c78963834b8a9fd1cc1266be5ed8d1840785c0f2e1b73b8d128d505/cryptography-44.0.2.tar.gz", hash = "sha256:c63454aa261a0cf0c5b4718349629793e9e634993538db841165b3df74f37ec0", size = 710807 } +sdist = { url = "https://files.pythonhosted.org/packages/53/d6/1411ab4d6108ab167d06254c5be517681f1e331f90edf1379895bcb87020/cryptography-44.0.3.tar.gz", hash = "sha256:fe19d8bc5536a91a24a8133328880a41831b6c5df54599a8417b62fe015d3053", size = 711096 } wheels = [ - { url = "https://files.pythonhosted.org/packages/92/ef/83e632cfa801b221570c5f58c0369db6fa6cef7d9ff859feab1aae1a8a0f/cryptography-44.0.2-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:efcfe97d1b3c79e486554efddeb8f6f53a4cdd4cf6086642784fa31fc384e1d7", size = 6676361 }, - { url = "https://files.pythonhosted.org/packages/30/ec/7ea7c1e4c8fc8329506b46c6c4a52e2f20318425d48e0fe597977c71dbce/cryptography-44.0.2-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29ecec49f3ba3f3849362854b7253a9f59799e3763b0c9d0826259a88efa02f1", size = 3952350 }, - { url = "https://files.pythonhosted.org/packages/27/61/72e3afdb3c5ac510330feba4fc1faa0fe62e070592d6ad00c40bb69165e5/cryptography-44.0.2-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc821e161ae88bfe8088d11bb39caf2916562e0a2dc7b6d56714a48b784ef0bb", size = 4166572 }, - { url = "https://files.pythonhosted.org/packages/26/e4/ba680f0b35ed4a07d87f9e98f3ebccb05091f3bf6b5a478b943253b3bbd5/cryptography-44.0.2-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:3c00b6b757b32ce0f62c574b78b939afab9eecaf597c4d624caca4f9e71e7843", size = 3958124 }, - { url = "https://files.pythonhosted.org/packages/9c/e8/44ae3e68c8b6d1cbc59040288056df2ad7f7f03bbcaca6b503c737ab8e73/cryptography-44.0.2-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7bdcd82189759aba3816d1f729ce42ffded1ac304c151d0a8e89b9996ab863d5", size = 3678122 }, - { url = "https://files.pythonhosted.org/packages/27/7b/664ea5e0d1eab511a10e480baf1c5d3e681c7d91718f60e149cec09edf01/cryptography-44.0.2-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:4973da6ca3db4405c54cd0b26d328be54c7747e89e284fcff166132eb7bccc9c", size = 4191831 }, - { url = "https://files.pythonhosted.org/packages/2a/07/79554a9c40eb11345e1861f46f845fa71c9e25bf66d132e123d9feb8e7f9/cryptography-44.0.2-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:4e389622b6927d8133f314949a9812972711a111d577a5d1f4bee5e58736b80a", size = 3960583 }, - { url = "https://files.pythonhosted.org/packages/bb/6d/858e356a49a4f0b591bd6789d821427de18432212e137290b6d8a817e9bf/cryptography-44.0.2-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:f514ef4cd14bb6fb484b4a60203e912cfcb64f2ab139e88c2274511514bf7308", size = 4191753 }, - { url = "https://files.pythonhosted.org/packages/b2/80/62df41ba4916067fa6b125aa8c14d7e9181773f0d5d0bd4dcef580d8b7c6/cryptography-44.0.2-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1bc312dfb7a6e5d66082c87c34c8a62176e684b6fe3d90fcfe1568de675e6688", size = 4079550 }, - { url = "https://files.pythonhosted.org/packages/f3/cd/2558cc08f7b1bb40683f99ff4327f8dcfc7de3affc669e9065e14824511b/cryptography-44.0.2-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b721b8b4d948b218c88cb8c45a01793483821e709afe5f622861fc6182b20a7", size = 4298367 }, - { url = "https://files.pythonhosted.org/packages/71/59/94ccc74788945bc3bd4cf355d19867e8057ff5fdbcac781b1ff95b700fb1/cryptography-44.0.2-cp37-abi3-win32.whl", hash = "sha256:51e4de3af4ec3899d6d178a8c005226491c27c4ba84101bfb59c901e10ca9f79", size = 2772843 }, - { url = "https://files.pythonhosted.org/packages/ca/2c/0d0bbaf61ba05acb32f0841853cfa33ebb7a9ab3d9ed8bb004bd39f2da6a/cryptography-44.0.2-cp37-abi3-win_amd64.whl", hash = "sha256:c505d61b6176aaf982c5717ce04e87da5abc9a36a5b39ac03905c4aafe8de7aa", size = 3209057 }, - { url = "https://files.pythonhosted.org/packages/9e/be/7a26142e6d0f7683d8a382dd963745e65db895a79a280a30525ec92be890/cryptography-44.0.2-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:8e0ddd63e6bf1161800592c71ac794d3fb8001f2caebe0966e77c5234fa9efc3", size = 6677789 }, - { url = "https://files.pythonhosted.org/packages/06/88/638865be7198a84a7713950b1db7343391c6066a20e614f8fa286eb178ed/cryptography-44.0.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:81276f0ea79a208d961c433a947029e1a15948966658cf6710bbabb60fcc2639", size = 3951919 }, - { url = "https://files.pythonhosted.org/packages/d7/fc/99fe639bcdf58561dfad1faa8a7369d1dc13f20acd78371bb97a01613585/cryptography-44.0.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9a1e657c0f4ea2a23304ee3f964db058c9e9e635cc7019c4aa21c330755ef6fd", size = 4167812 }, - { url = "https://files.pythonhosted.org/packages/53/7b/aafe60210ec93d5d7f552592a28192e51d3c6b6be449e7fd0a91399b5d07/cryptography-44.0.2-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6210c05941994290f3f7f175a4a57dbbb2afd9273657614c506d5976db061181", size = 3958571 }, - { url = "https://files.pythonhosted.org/packages/16/32/051f7ce79ad5a6ef5e26a92b37f172ee2d6e1cce09931646eef8de1e9827/cryptography-44.0.2-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d1c3572526997b36f245a96a2b1713bf79ce99b271bbcf084beb6b9b075f29ea", size = 3679832 }, - { url = "https://files.pythonhosted.org/packages/78/2b/999b2a1e1ba2206f2d3bca267d68f350beb2b048a41ea827e08ce7260098/cryptography-44.0.2-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:b042d2a275c8cee83a4b7ae30c45a15e6a4baa65a179a0ec2d78ebb90e4f6699", size = 4193719 }, - { url = "https://files.pythonhosted.org/packages/72/97/430e56e39a1356e8e8f10f723211a0e256e11895ef1a135f30d7d40f2540/cryptography-44.0.2-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:d03806036b4f89e3b13b6218fefea8d5312e450935b1a2d55f0524e2ed7c59d9", size = 3960852 }, - { url = "https://files.pythonhosted.org/packages/89/33/c1cf182c152e1d262cac56850939530c05ca6c8d149aa0dcee490b417e99/cryptography-44.0.2-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:c7362add18b416b69d58c910caa217f980c5ef39b23a38a0880dfd87bdf8cd23", size = 4193906 }, - { url = "https://files.pythonhosted.org/packages/e1/99/87cf26d4f125380dc674233971069bc28d19b07f7755b29861570e513650/cryptography-44.0.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:8cadc6e3b5a1f144a039ea08a0bdb03a2a92e19c46be3285123d32029f40a922", size = 4081572 }, - { url = "https://files.pythonhosted.org/packages/b3/9f/6a3e0391957cc0c5f84aef9fbdd763035f2b52e998a53f99345e3ac69312/cryptography-44.0.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6f101b1f780f7fc613d040ca4bdf835c6ef3b00e9bd7125a4255ec574c7916e4", size = 4298631 }, - { url = "https://files.pythonhosted.org/packages/e2/a5/5bc097adb4b6d22a24dea53c51f37e480aaec3465285c253098642696423/cryptography-44.0.2-cp39-abi3-win32.whl", hash = "sha256:3dc62975e31617badc19a906481deacdeb80b4bb454394b4098e3f2525a488c5", size = 2773792 }, - { url = "https://files.pythonhosted.org/packages/33/cf/1f7649b8b9a3543e042d3f348e398a061923ac05b507f3f4d95f11938aa9/cryptography-44.0.2-cp39-abi3-win_amd64.whl", hash = "sha256:5f6f90b72d8ccadb9c6e311c775c8305381db88374c65fa1a68250aa8a9cb3a6", size = 3210957 }, - { url = "https://files.pythonhosted.org/packages/99/10/173be140714d2ebaea8b641ff801cbcb3ef23101a2981cbf08057876f89e/cryptography-44.0.2-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:af4ff3e388f2fa7bff9f7f2b31b87d5651c45731d3e8cfa0944be43dff5cfbdb", size = 3396886 }, - { url = "https://files.pythonhosted.org/packages/2f/b4/424ea2d0fce08c24ede307cead3409ecbfc2f566725d4701b9754c0a1174/cryptography-44.0.2-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:0529b1d5a0105dd3731fa65680b45ce49da4d8115ea76e9da77a875396727b41", size = 3892387 }, - { url = "https://files.pythonhosted.org/packages/28/20/8eaa1a4f7c68a1cb15019dbaad59c812d4df4fac6fd5f7b0b9c5177f1edd/cryptography-44.0.2-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:7ca25849404be2f8e4b3c59483d9d3c51298a22c1c61a0e84415104dacaf5562", size = 4109922 }, - { url = "https://files.pythonhosted.org/packages/11/25/5ed9a17d532c32b3bc81cc294d21a36c772d053981c22bd678396bc4ae30/cryptography-44.0.2-pp310-pypy310_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:268e4e9b177c76d569e8a145a6939eca9a5fec658c932348598818acf31ae9a5", size = 3895715 }, - { url = "https://files.pythonhosted.org/packages/63/31/2aac03b19c6329b62c45ba4e091f9de0b8f687e1b0cd84f101401bece343/cryptography-44.0.2-pp310-pypy310_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:9eb9d22b0a5d8fd9925a7764a054dca914000607dff201a24c791ff5c799e1fa", size = 4109876 }, - { url = "https://files.pythonhosted.org/packages/99/ec/6e560908349843718db1a782673f36852952d52a55ab14e46c42c8a7690a/cryptography-44.0.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:2bf7bf75f7df9715f810d1b038870309342bff3069c5bd8c6b96128cb158668d", size = 3131719 }, - { url = "https://files.pythonhosted.org/packages/d6/d7/f30e75a6aa7d0f65031886fa4a1485c2fbfe25a1896953920f6a9cfe2d3b/cryptography-44.0.2-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:909c97ab43a9c0c0b0ada7a1281430e4e5ec0458e6d9244c0e821bbf152f061d", size = 3887513 }, - { url = "https://files.pythonhosted.org/packages/9c/b4/7a494ce1032323ca9db9a3661894c66e0d7142ad2079a4249303402d8c71/cryptography-44.0.2-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:96e7a5e9d6e71f9f4fca8eebfd603f8e86c5225bb18eb621b2c1e50b290a9471", size = 4107432 }, - { url = "https://files.pythonhosted.org/packages/45/f8/6b3ec0bc56123b344a8d2b3264a325646d2dcdbdd9848b5e6f3d37db90b3/cryptography-44.0.2-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:d1b3031093a366ac767b3feb8bcddb596671b3aaff82d4050f984da0c248b615", size = 3891421 }, - { url = "https://files.pythonhosted.org/packages/57/ff/f3b4b2d007c2a646b0f69440ab06224f9cf37a977a72cdb7b50632174e8a/cryptography-44.0.2-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:04abd71114848aa25edb28e225ab5f268096f44cf0127f3d36975bdf1bdf3390", size = 4107081 }, + { url = "https://files.pythonhosted.org/packages/08/53/c776d80e9d26441bb3868457909b4e74dd9ccabd182e10b2b0ae7a07e265/cryptography-44.0.3-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:962bc30480a08d133e631e8dfd4783ab71cc9e33d5d7c1e192f0b7c06397bb88", size = 6670281 }, + { url = "https://files.pythonhosted.org/packages/6a/06/af2cf8d56ef87c77319e9086601bef621bedf40f6f59069e1b6d1ec498c5/cryptography-44.0.3-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4ffc61e8f3bf5b60346d89cd3d37231019c17a081208dfbbd6e1605ba03fa137", size = 3959305 }, + { url = "https://files.pythonhosted.org/packages/ae/01/80de3bec64627207d030f47bf3536889efee8913cd363e78ca9a09b13c8e/cryptography-44.0.3-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58968d331425a6f9eedcee087f77fd3c927c88f55368f43ff7e0a19891f2642c", size = 4171040 }, + { url = "https://files.pythonhosted.org/packages/bd/48/bb16b7541d207a19d9ae8b541c70037a05e473ddc72ccb1386524d4f023c/cryptography-44.0.3-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e28d62e59a4dbd1d22e747f57d4f00c459af22181f0b2f787ea83f5a876d7c76", size = 3963411 }, + { url = "https://files.pythonhosted.org/packages/42/b2/7d31f2af5591d217d71d37d044ef5412945a8a8e98d5a2a8ae4fd9cd4489/cryptography-44.0.3-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:af653022a0c25ef2e3ffb2c673a50e5a0d02fecc41608f4954176f1933b12359", size = 3689263 }, + { url = "https://files.pythonhosted.org/packages/25/50/c0dfb9d87ae88ccc01aad8eb93e23cfbcea6a6a106a9b63a7b14c1f93c75/cryptography-44.0.3-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:157f1f3b8d941c2bd8f3ffee0af9b049c9665c39d3da9db2dc338feca5e98a43", size = 4196198 }, + { url = "https://files.pythonhosted.org/packages/66/c9/55c6b8794a74da652690c898cb43906310a3e4e4f6ee0b5f8b3b3e70c441/cryptography-44.0.3-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:c6cd67722619e4d55fdb42ead64ed8843d64638e9c07f4011163e46bc512cf01", size = 3966502 }, + { url = "https://files.pythonhosted.org/packages/b6/f7/7cb5488c682ca59a02a32ec5f975074084db4c983f849d47b7b67cc8697a/cryptography-44.0.3-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:b424563394c369a804ecbee9b06dfb34997f19d00b3518e39f83a5642618397d", size = 4196173 }, + { url = "https://files.pythonhosted.org/packages/d2/0b/2f789a8403ae089b0b121f8f54f4a3e5228df756e2146efdf4a09a3d5083/cryptography-44.0.3-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c91fc8e8fd78af553f98bc7f2a1d8db977334e4eea302a4bfd75b9461c2d8904", size = 4087713 }, + { url = "https://files.pythonhosted.org/packages/1d/aa/330c13655f1af398fc154089295cf259252f0ba5df93b4bc9d9c7d7f843e/cryptography-44.0.3-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:25cd194c39fa5a0aa4169125ee27d1172097857b27109a45fadc59653ec06f44", size = 4299064 }, + { url = "https://files.pythonhosted.org/packages/10/a8/8c540a421b44fd267a7d58a1fd5f072a552d72204a3f08194f98889de76d/cryptography-44.0.3-cp37-abi3-win32.whl", hash = "sha256:3be3f649d91cb182c3a6bd336de8b61a0a71965bd13d1a04a0e15b39c3d5809d", size = 2773887 }, + { url = "https://files.pythonhosted.org/packages/b9/0d/c4b1657c39ead18d76bbd122da86bd95bdc4095413460d09544000a17d56/cryptography-44.0.3-cp37-abi3-win_amd64.whl", hash = "sha256:3883076d5c4cc56dbef0b898a74eb6992fdac29a7b9013870b34efe4ddb39a0d", size = 3209737 }, + { url = "https://files.pythonhosted.org/packages/34/a3/ad08e0bcc34ad436013458d7528e83ac29910943cea42ad7dd4141a27bbb/cryptography-44.0.3-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:5639c2b16764c6f76eedf722dbad9a0914960d3489c0cc38694ddf9464f1bb2f", size = 6673501 }, + { url = "https://files.pythonhosted.org/packages/b1/f0/7491d44bba8d28b464a5bc8cc709f25a51e3eac54c0a4444cf2473a57c37/cryptography-44.0.3-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3ffef566ac88f75967d7abd852ed5f182da252d23fac11b4766da3957766759", size = 3960307 }, + { url = "https://files.pythonhosted.org/packages/f7/c8/e5c5d0e1364d3346a5747cdcd7ecbb23ca87e6dea4f942a44e88be349f06/cryptography-44.0.3-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:192ed30fac1728f7587c6f4613c29c584abdc565d7417c13904708db10206645", size = 4170876 }, + { url = "https://files.pythonhosted.org/packages/73/96/025cb26fc351d8c7d3a1c44e20cf9a01e9f7cf740353c9c7a17072e4b264/cryptography-44.0.3-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:7d5fe7195c27c32a64955740b949070f21cba664604291c298518d2e255931d2", size = 3964127 }, + { url = "https://files.pythonhosted.org/packages/01/44/eb6522db7d9f84e8833ba3bf63313f8e257729cf3a8917379473fcfd6601/cryptography-44.0.3-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3f07943aa4d7dad689e3bb1638ddc4944cc5e0921e3c227486daae0e31a05e54", size = 3689164 }, + { url = "https://files.pythonhosted.org/packages/68/fb/d61a4defd0d6cee20b1b8a1ea8f5e25007e26aeb413ca53835f0cae2bcd1/cryptography-44.0.3-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:cb90f60e03d563ca2445099edf605c16ed1d5b15182d21831f58460c48bffb93", size = 4198081 }, + { url = "https://files.pythonhosted.org/packages/1b/50/457f6911d36432a8811c3ab8bd5a6090e8d18ce655c22820994913dd06ea/cryptography-44.0.3-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ab0b005721cc0039e885ac3503825661bd9810b15d4f374e473f8c89b7d5460c", size = 3967716 }, + { url = "https://files.pythonhosted.org/packages/35/6e/dca39d553075980ccb631955c47b93d87d27f3596da8d48b1ae81463d915/cryptography-44.0.3-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:3bb0847e6363c037df8f6ede57d88eaf3410ca2267fb12275370a76f85786a6f", size = 4197398 }, + { url = "https://files.pythonhosted.org/packages/9b/9d/d1f2fe681eabc682067c66a74addd46c887ebacf39038ba01f8860338d3d/cryptography-44.0.3-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b0cc66c74c797e1db750aaa842ad5b8b78e14805a9b5d1348dc603612d3e3ff5", size = 4087900 }, + { url = "https://files.pythonhosted.org/packages/c4/f5/3599e48c5464580b73b236aafb20973b953cd2e7b44c7c2533de1d888446/cryptography-44.0.3-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6866df152b581f9429020320e5eb9794c8780e90f7ccb021940d7f50ee00ae0b", size = 4301067 }, + { url = "https://files.pythonhosted.org/packages/a7/6c/d2c48c8137eb39d0c193274db5c04a75dab20d2f7c3f81a7dcc3a8897701/cryptography-44.0.3-cp39-abi3-win32.whl", hash = "sha256:c138abae3a12a94c75c10499f1cbae81294a6f983b3af066390adee73f433028", size = 2775467 }, + { url = "https://files.pythonhosted.org/packages/c9/ad/51f212198681ea7b0deaaf8846ee10af99fba4e894f67b353524eab2bbe5/cryptography-44.0.3-cp39-abi3-win_amd64.whl", hash = "sha256:5d186f32e52e66994dce4f766884bcb9c68b8da62d61d9d215bfe5fb56d21334", size = 3210375 }, + { url = "https://files.pythonhosted.org/packages/7f/10/abcf7418536df1eaba70e2cfc5c8a0ab07aa7aa02a5cbc6a78b9d8b4f121/cryptography-44.0.3-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:cad399780053fb383dc067475135e41c9fe7d901a97dd5d9c5dfb5611afc0d7d", size = 3393192 }, + { url = "https://files.pythonhosted.org/packages/06/59/ecb3ef380f5891978f92a7f9120e2852b1df6f0a849c277b8ea45b865db2/cryptography-44.0.3-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:21a83f6f35b9cc656d71b5de8d519f566df01e660ac2578805ab245ffd8523f8", size = 3898419 }, + { url = "https://files.pythonhosted.org/packages/bb/d0/35e2313dbb38cf793aa242182ad5bc5ef5c8fd4e5dbdc380b936c7d51169/cryptography-44.0.3-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:fc3c9babc1e1faefd62704bb46a69f359a9819eb0292e40df3fb6e3574715cd4", size = 4117892 }, + { url = "https://files.pythonhosted.org/packages/dc/c8/31fb6e33b56c2c2100d76de3fd820afaa9d4d0b6aea1ccaf9aaf35dc7ce3/cryptography-44.0.3-pp310-pypy310_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:e909df4053064a97f1e6565153ff8bb389af12c5c8d29c343308760890560aff", size = 3900855 }, + { url = "https://files.pythonhosted.org/packages/43/2a/08cc2ec19e77f2a3cfa2337b429676406d4bb78ddd130a05c458e7b91d73/cryptography-44.0.3-pp310-pypy310_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:dad80b45c22e05b259e33ddd458e9e2ba099c86ccf4e88db7bbab4b747b18d06", size = 4117619 }, + { url = "https://files.pythonhosted.org/packages/02/68/fc3d3f84022a75f2ac4b1a1c0e5d6a0c2ea259e14cd4aae3e0e68e56483c/cryptography-44.0.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:479d92908277bed6e1a1c69b277734a7771c2b78633c224445b5c60a9f4bc1d9", size = 3136570 }, + { url = "https://files.pythonhosted.org/packages/8d/4b/c11ad0b6c061902de5223892d680e89c06c7c4d606305eb8de56c5427ae6/cryptography-44.0.3-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:896530bc9107b226f265effa7ef3f21270f18a2026bc09fed1ebd7b66ddf6375", size = 3390230 }, + { url = "https://files.pythonhosted.org/packages/58/11/0a6bf45d53b9b2290ea3cec30e78b78e6ca29dc101e2e296872a0ffe1335/cryptography-44.0.3-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:9b4d4a5dbee05a2c390bf212e78b99434efec37b17a4bff42f50285c5c8c9647", size = 3895216 }, + { url = "https://files.pythonhosted.org/packages/0a/27/b28cdeb7270e957f0077a2c2bfad1b38f72f1f6d699679f97b816ca33642/cryptography-44.0.3-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:02f55fb4f8b79c1221b0961488eaae21015b69b210e18c386b69de182ebb1259", size = 4115044 }, + { url = "https://files.pythonhosted.org/packages/35/b0/ec4082d3793f03cb248881fecefc26015813199b88f33e3e990a43f79835/cryptography-44.0.3-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:dd3db61b8fe5be220eee484a17233287d0be6932d056cf5738225b9c05ef4fff", size = 3898034 }, + { url = "https://files.pythonhosted.org/packages/0b/7f/adf62e0b8e8d04d50c9a91282a57628c00c54d4ae75e2b02a223bd1f2613/cryptography-44.0.3-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:978631ec51a6bbc0b7e58f23b68a8ce9e5f09721940933e9c217068388789fe5", size = 4114449 }, + { url = "https://files.pythonhosted.org/packages/87/62/d69eb4a8ee231f4bf733a92caf9da13f1c81a44e874b1d4080c25ecbb723/cryptography-44.0.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:5d20cc348cca3a8aa7312f42ab953a56e15323800ca3ab0706b8cd452a3a056c", size = 3134369 }, ] [[package]] @@ -543,7 +549,7 @@ wheels = [ [[package]] name = "dashscope" -version = "1.23.1" +version = "1.23.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohttp" }, @@ -551,7 +557,7 @@ dependencies = [ { name = "websocket-client" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/e6/59/44d437c31cea0799617eb00862b17f4795ad4a3c19d24c65166e24ba783a/dashscope-1.23.1-py3-none-any.whl", hash = "sha256:2c3bd6ed909de72cc4833ada0f7fdae670031738d01969a76f3676a6bbb56026", size = 1277878 }, + { url = "https://files.pythonhosted.org/packages/00/c8/afd737ff28f63c3ce846985f0865f29e27590148003f4df1edeb0b5761d6/dashscope-1.23.2-py3-none-any.whl", hash = "sha256:d2d17561ca58fcdeef6eef157efbd7d68b655551895be24d3c80e743eff21aa1", size = 1277881 }, ] [[package]] @@ -604,6 +610,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" @@ -730,7 +745,7 @@ wheels = [ [[package]] name = "google-genai" -version = "1.11.0" +version = "1.14.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -741,9 +756,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/73/44/64c6c23724580add879cbcca81ffed500955c1c21850468cd4dcf9c62a03/google_genai-1.11.0.tar.gz", hash = "sha256:0643b2f5373fbeae945d0cd5a37d157eab0c172bb5e14e905f2f8d45aa51cabb", size = 160955 } +sdist = { url = "https://files.pythonhosted.org/packages/00/ba/c8e4c0b60c6dda40e51e2125709d097c2609fce1389b4a05f40cdd51c1ec/google_genai-1.14.0.tar.gz", hash = "sha256:7c608de5bb173486a546f5ec4562255c26bae72d33d758a3207bb26f695d0087", size = 169938 } wheels = [ - { url = "https://files.pythonhosted.org/packages/dc/9b/55f97203720cbda5a1c8e0460793914980e41c6ca4859fea735dd66d2c3a/google_genai-1.11.0-py3-none-any.whl", hash = "sha256:34fbe3c85419adbcddcb8222f99514596b3a69c80ff1a4ae30a01a763da27acc", size = 159687 }, + { url = "https://files.pythonhosted.org/packages/12/86/dde4cd028c8b0716b3f1f6d202647396a87a4ecbbdc7e4beb59b9d9284d3/google_genai-1.14.0-py3-none-any.whl", hash = "sha256:5916ee985bf69ac7b68c4488949225db71e21579afc7ba5ecd5321173b60d3b2", size = 168862 }, ] [[package]] @@ -946,7 +961,7 @@ wheels = [ [[package]] name = "lark-oapi" -version = "1.4.14" +version = "1.4.15" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, @@ -955,9 +970,9 @@ dependencies = [ { name = "requests-toolbelt" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c4/b9/c2c75f9888517d35da2b7bbea9dd2a41a7209de5ae35534c951e28612863/lark-oapi-1.4.14.tar.gz", hash = "sha256:dbb57a864b2f00f87b07b01cd4cacba3fe4a364f0c9a14e32c63148bff2cebbc", size = 1746653 } +sdist = { url = "https://files.pythonhosted.org/packages/41/c5/413d2d720ceec4f61f3b4668938750c16add4c397d27d5ec408a447ef8d9/lark-oapi-1.4.15.tar.gz", hash = "sha256:0f065f2c24d6eb8748c10dbf5b516f5cd8944c4657fea28e9d385fa3ad1aff46", size = 1757006 } wheels = [ - { url = "https://files.pythonhosted.org/packages/ae/05/1ba995744285a363efba73436cff96fe07ba032e803f7ce3e8e25c271de6/lark_oapi-1.4.14-py3-none-any.whl", hash = "sha256:2a7aa8f0f291ba7eee0280be66667b1cd289fc04d4814da2bace770a92ac1e6e", size = 6281669 }, + { url = "https://files.pythonhosted.org/packages/f5/e3/a5f547e5649d625be547a58b9f9d0363c0c73930b0db343fc748186fe14c/lark_oapi-1.4.15-py3-none-any.whl", hash = "sha256:a528777336225ea489768d32463f5557df7709b3d1e2ee737539975faecb02ac", size = 6319420 }, ] [[package]] @@ -1042,6 +1057,11 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6b/40/7d49ff503cc90b03253eba0768feec909b47ce92a90591b025c774a29a95/lxml-5.3.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:0a006390834603e5952a2ff74b9a31a6007c7cc74282a087aa6467afb4eea987", size = 3487898 }, ] +[package.optional-dependencies] +html-clean = [ + { name = "lxml-html-clean" }, +] + [[package]] name = "lxml-html-clean" version = "0.4.2" @@ -1114,7 +1134,7 @@ wheels = [ [[package]] name = "mcp" -version = "1.6.0" +version = "1.8.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -1122,13 +1142,14 @@ dependencies = [ { name = "httpx-sse" }, { name = "pydantic" }, { name = "pydantic-settings" }, + { name = "python-multipart" }, { name = "sse-starlette" }, { name = "starlette" }, - { name = "uvicorn" }, + { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/95/d2/f587cb965a56e992634bebc8611c5b579af912b74e04eb9164bd49527d21/mcp-1.6.0.tar.gz", hash = "sha256:d9324876de2c5637369f43161cd71eebfd803df5a95e46225cab8d280e366723", size = 200031 } +sdist = { url = "https://files.pythonhosted.org/packages/ff/97/0a3e08559557b0ac5799f9fb535fbe5a4e4dcdd66ce9d32e7a74b4d0534d/mcp-1.8.0.tar.gz", hash = "sha256:263dfb700540b726c093f0c3e043f66aded0730d0b51f04eb0a3eb90055fe49b", size = 264641 } wheels = [ - { url = "https://files.pythonhosted.org/packages/10/30/20a7f33b0b884a9d14dd3aa94ff1ac9da1479fe2ad66dd9e2736075d2506/mcp-1.6.0-py3-none-any.whl", hash = "sha256:7bd24c6ea042dbec44c754f100984d186620d8b841ec30f1b19eda9b93a634d0", size = 76077 }, + { url = "https://files.pythonhosted.org/packages/b2/b2/4ac3bd17b1fdd65658f18de4eb0c703517ee0b483dc5f56467802a9197e0/mcp-1.8.0-py3-none-any.whl", hash = "sha256:889d9d3b4f12b7da59e7a3933a0acadae1fce498bfcd220defb590aa291a1334", size = 119544 }, ] [[package]] @@ -1239,7 +1260,7 @@ wheels = [ [[package]] name = "openai" -version = "1.75.0" +version = "1.78.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -1251,9 +1272,9 @@ dependencies = [ { name = "tqdm" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/99/b1/318f5d4c482f19c5fcbcde190801bfaaaec23413cda0b88a29f6897448ff/openai-1.75.0.tar.gz", hash = "sha256:fb3ea907efbdb1bcfd0c44507ad9c961afd7dce3147292b54505ecfd17be8fd1", size = 429492 } +sdist = { url = "https://files.pythonhosted.org/packages/d1/7c/7c48bac9be52680e41e99ae7649d5da3a0184cd94081e028897f9005aa03/openai-1.78.0.tar.gz", hash = "sha256:254aef4980688468e96cbddb1f348ed01d274d02c64c6c69b0334bf001fb62b3", size = 442652 } wheels = [ - { url = "https://files.pythonhosted.org/packages/80/9a/f34f163294345f123673ed03e77c33dee2534f3ac1f9d18120384457304d/openai-1.75.0-py3-none-any.whl", hash = "sha256:fe6f932d2ded3b429ff67cc9ad118c71327db32eb9d32dd723de3acfca337125", size = 646972 }, + { url = "https://files.pythonhosted.org/packages/cc/41/d64a6c56d0ec886b834caff7a07fc4d43e1987895594b144757e7a6b90d7/openai-1.78.0-py3-none-any.whl", hash = "sha256:1ade6a48cd323ad8a7715e7e1669bb97a17e1a5b8a916644261aaef4bf284778", size = 680407 }, ] [[package]] @@ -1384,11 +1405,11 @@ wheels = [ [[package]] name = "pip" -version = "25.0.1" +version = "25.1.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/70/53/b309b4a497b09655cb7e07088966881a57d082f48ac3cb54ea729fd2c6cf/pip-25.0.1.tar.gz", hash = "sha256:88f96547ea48b940a3a385494e181e29fb8637898f88d88737c5049780f196ea", size = 1950850 } +sdist = { url = "https://files.pythonhosted.org/packages/59/de/241caa0ca606f2ec5fe0c1f4261b0465df78d786a38da693864a116c37f4/pip-25.1.1.tar.gz", hash = "sha256:3de45d411d308d5054c2168185d8da7f9a2cd753dbac8acbfa88a8909ecd9077", size = 1940155 } wheels = [ - { url = "https://files.pythonhosted.org/packages/c9/bc/b7db44f5f39f9d0494071bddae6880eb645970366d0a200022a1a93d57f5/pip-25.0.1-py3-none-any.whl", hash = "sha256:c46efd13b6aa8279f33f2864459c8ce587ea6a1a59ee20de055868d8f7688f7f", size = 1841526 }, + { url = "https://files.pythonhosted.org/packages/29/a2/d40fb2460e883eca5199c62cfc2463fd261f760556ae6290f88488c362c0/pip-25.1.1-py3-none-any.whl", hash = "sha256:2913a38a2abf4ea6b64ab507bd9e967f3b53dc1ede74b01b0931e1ce548751af", size = 1825227 }, ] [[package]] @@ -1699,6 +1720,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1e/18/98a99ad95133c6a6e2005fe89faedf294a748bd5dc803008059409ac9b1e/python_dotenv-1.1.0-py3-none-any.whl", hash = "sha256:d7c01d9e2293916c18baf562d95698754b0dbbb5e74d457c45d4f6561fb9d55d", size = 20256 }, ] +[[package]] +name = "python-multipart" +version = "0.0.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/87/f44d7c9f274c7ee665a29b885ec97089ec5dc034c7f3fafa03da9e39a09e/python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13", size = 37158 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546 }, +] + [[package]] name = "python-telegram-bot" version = "22.0" @@ -1791,16 +1821,17 @@ wheels = [ [[package]] name = "readability-lxml" -version = "0.8.1" +version = "0.8.4.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "chardet" }, { name = "cssselect" }, - { name = "lxml" }, + { name = "lxml", extra = ["html-clean"] }, + { name = "lxml-html-clean", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b9/62/6de3a9a8524c1a1ee0f2aee0dfbad13a36ebbca0db402abcf4e790496512/readability-lxml-0.8.1.tar.gz", hash = "sha256:e51fea56b5909aaf886d307d48e79e096293255afa567b7d08bca94d25b1a4e1", size = 15878 } +sdist = { url = "https://files.pythonhosted.org/packages/55/3e/dc87d97532ddad58af786ec89c7036182e352574c1cba37bf2bf783d2b15/readability_lxml-0.8.4.1.tar.gz", hash = "sha256:9d2924f5942dd7f37fb4da353263b22a3e877ccf922d0e45e348e4177b035a53", size = 22874 } wheels = [ - { url = "https://files.pythonhosted.org/packages/39/a6/cfe22aaa19ac69b97d127043a76a5bbcb0ef24f3a0b22793c46608190caa/readability_lxml-0.8.1-py3-none-any.whl", hash = "sha256:e0d366a21b1bd6cca17de71a4e6ea16fcfaa8b0a5b4004e39e2c7eff884e6305", size = 20691 }, + { url = "https://files.pythonhosted.org/packages/c7/75/2cc58965097e351415af420be81c4665cf80da52a17ef43c01ffbe2caf91/readability_lxml-0.8.4.1-py3-none-any.whl", hash = "sha256:874c0cea22c3bf2b78c7f8df831bfaad3c0a89b7301d45a188db581652b4b465", size = 19912 }, ] [[package]] @@ -2076,6 +2107,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"