diff --git a/.dockerignore b/.dockerignore index 965adc9e1..7a61edd14 100644 --- a/.dockerignore +++ b/.dockerignore @@ -17,7 +17,6 @@ ENV/ .conda/ dashboard/ data/ -changelogs/ tests/ .ruff_cache/ .astrbot diff --git a/.github/ISSUE_TEMPLATE/feature-request.yml b/.github/ISSUE_TEMPLATE/feature-request.yml index 484959318..c97eb1a4c 100644 --- a/.github/ISSUE_TEMPLATE/feature-request.yml +++ b/.github/ISSUE_TEMPLATE/feature-request.yml @@ -1,42 +1,40 @@ -name: '🎉 功能建议' +name: '🎉 Feature Request / 功能建议' title: "[Feature]" -description: 提交建议帮助我们改进。 +description: Submit a suggestion to help us improve. / 提交建议帮助我们改进。 labels: [ "enhancement" ] body: - type: markdown attributes: value: | - 感谢您抽出时间提出新功能建议,请准确解释您的想法。 + Thank you for taking the time to suggest a new feature! Please explain your idea clearly and accurately. / 感谢您抽出时间提出新功能建议,请准确解释您的想法。 - type: textarea attributes: - label: 描述 - description: 简短描述您的功能建议。 + label: Description / 描述 + description: Please describe the feature you want to be added in detail. / 请详细描述您希望添加的功能。 - type: textarea attributes: - label: 使用场景 - description: 你想要发生什么? - placeholder: > - 一个清晰且具体的描述这个功能的使用场景。 + label: Use Case / 使用场景 + description: Please describe the use case for this feature. / 请描述这个功能的使用场景。 - type: checkboxes attributes: - label: 你愿意提交PR吗? + label: Willing to Submit PR? / 是否愿意提交PR? description: > - 这不是必须的,但我们欢迎您的贡献。 + This is not required, but if you are willing to submit a PR to implement this feature, it would be greatly appreciated! / 这不是必需的,但如果您愿意提交 PR 来实现这个功能,我们将不胜感激! options: - - label: 是的, 我愿意提交PR! + - label: Yes, I am willing to submit a PR. / 是的,我愿意提交 PR。 - type: checkboxes attributes: label: Code of Conduct options: - label: > - 我已阅读并同意遵守该项目的 [行为准则](https://docs.github.com/zh/site-policy/github-terms/github-community-code-of-conduct)。 + I have read and agree to abide by the project's [Code of Conduct](https://docs.github.com/zh/site-policy/github-terms/github-community-code-of-conduct). / required: true - type: markdown attributes: - value: "感谢您填写我们的表单!" \ No newline at end of file + value: "Thank you for filling out our form!" \ No newline at end of file diff --git a/.github/workflows/code-format.yml b/.github/workflows/code-format.yml index a183f1bb2..3de1bea55 100644 --- a/.github/workflows/code-format.yml +++ b/.github/workflows/code-format.yml @@ -17,7 +17,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v6 with: - python-version: '3.10' + python-version: '3.12' - name: Install UV run: pip install uv diff --git a/.github/workflows/coverage_test.yml b/.github/workflows/coverage_test.yml index 6ae8c7b9b..f0019ee7e 100644 --- a/.github/workflows/coverage_test.yml +++ b/.github/workflows/coverage_test.yml @@ -37,7 +37,7 @@ jobs: mkdir -p data/temp export TESTING=true export ZHIPU_API_KEY=${{ secrets.OPENAI_API_KEY }} - pytest --cov=. -v -o log_cli=true -o log_level=DEBUG + pytest --cov=astrbot -v -o log_cli=true -o log_level=DEBUG - name: Upload results to Codecov uses: codecov/codecov-action@v5 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 59c229b04..8d5791ba3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -102,170 +102,11 @@ jobs: cp "dashboard/AstrBot-${VERSION_TAG}-dashboard.zip" "dashboard/astrbot-webui-${VERSION_TAG}.zip" rclone copy "dashboard/astrbot-webui-${VERSION_TAG}.zip" "r2:${R2_BUCKET_NAME}" --progress - build-desktop: - name: Build ${{ matrix.name }} - runs-on: ${{ matrix.runner }} - strategy: - fail-fast: false - matrix: - include: - - name: linux-x64 - runner: ubuntu-24.04 - os: linux - arch: amd64 - - name: linux-arm64 - runner: ubuntu-24.04-arm - os: linux - arch: arm64 - - name: windows-x64 - runner: windows-2022 - os: win - arch: amd64 - - name: windows-arm64 - runner: windows-11-arm - os: win - arch: arm64 - - name: macos-x64 - runner: macos-15-intel - os: mac - arch: amd64 - - name: macos-arm64 - runner: macos-15 - os: mac - arch: arm64 - env: - CSC_IDENTITY_AUTO_DISCOVERY: "false" - steps: - - name: Checkout repository - uses: actions/checkout@v6 - with: - fetch-depth: 0 - ref: ${{ inputs.ref || github.ref }} - - - name: Resolve tag - id: tag - shell: bash - run: | - if [ "${{ github.event_name }}" = "push" ]; then - tag="${GITHUB_REF_NAME}" - elif [ -n "${{ inputs.tag }}" ]; then - tag="${{ inputs.tag }}" - else - tag="$(git describe --tags --abbrev=0)" - fi - if [ -z "$tag" ]; then - echo "Failed to resolve tag." >&2 - exit 1 - fi - echo "tag=$tag" >> "$GITHUB_OUTPUT" - - - name: Setup uv - uses: astral-sh/setup-uv@v7 - - - name: Setup Python - uses: actions/setup-python@v6 - with: - python-version: "3.12" - - - name: Setup pnpm - uses: pnpm/action-setup@v4 - with: - version: 10.28.2 - - - name: Setup Node.js - uses: actions/setup-node@v6 - with: - node-version: '24.13.0' - cache: "pnpm" - cache-dependency-path: | - dashboard/pnpm-lock.yaml - desktop/pnpm-lock.yaml - - - name: Prepare OpenSSL for Windows ARM64 - if: ${{ matrix.os == 'win' && matrix.arch == 'arm64' }} - shell: pwsh - run: | - git clone https://github.com/microsoft/vcpkg.git C:\vcpkg - & C:\vcpkg\bootstrap-vcpkg.bat -disableMetrics - & C:\vcpkg\vcpkg.exe install openssl:arm64-windows - - "VCPKG_ROOT=C:\vcpkg" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append - "VCPKGRS_TRIPLET=arm64-windows" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append - "OPENSSL_DIR=C:\vcpkg\installed\arm64-windows" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append - "OPENSSL_ROOT_DIR=C:\vcpkg\installed\arm64-windows" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append - "OPENSSL_LIB_DIR=C:\vcpkg\installed\arm64-windows\lib" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append - "OPENSSL_INCLUDE_DIR=C:\vcpkg\installed\arm64-windows\include" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append - - - name: Install dependencies - shell: bash - run: | - uv sync - pnpm --dir dashboard install --frozen-lockfile - pnpm --dir desktop install --frozen-lockfile - - - name: Build desktop package - shell: bash - run: | - pnpm --dir dashboard run build - pnpm --dir desktop run build:webui - pnpm --dir desktop run build:backend - pnpm --dir desktop run sync:version - pnpm --dir desktop exec electron-builder --publish never - - - name: Normalize artifact names - shell: bash - env: - NAME_PREFIX: AstrBot-${{ steps.tag.outputs.tag }}-${{ matrix.arch }}-${{ matrix.os }} - run: | - shopt -s nullglob - out_dir="desktop/dist/release" - mkdir -p "$out_dir" - files=( - desktop/dist/*.AppImage - desktop/dist/*.dmg - desktop/dist/*.zip - desktop/dist/*.exe - ) - if [ ${#files[@]} -eq 0 ]; then - echo "No desktop artifacts found to rename." >&2 - exit 1 - fi - for src in "${files[@]}"; do - file="$(basename "$src")" - case "$file" in - *.AppImage) - dest="$out_dir/${NAME_PREFIX}.AppImage" - ;; - *.dmg) - dest="$out_dir/${NAME_PREFIX}.dmg" - ;; - *.exe) - dest="$out_dir/${NAME_PREFIX}.exe" - ;; - *.zip) - dest="$out_dir/${NAME_PREFIX}.zip" - ;; - *) - continue - ;; - esac - cp "$src" "$dest" - done - ls -la "$out_dir" - - - name: Upload desktop artifacts - uses: actions/upload-artifact@v6 - with: - name: AstrBot-${{ steps.tag.outputs.tag }}-${{ matrix.arch }}-${{ matrix.os }} - if-no-files-found: error - path: desktop/dist/release/* - publish-release: name: Publish GitHub Release runs-on: ubuntu-24.04 needs: - build-dashboard - - build-desktop steps: - name: Checkout repository uses: actions/checkout@v6 @@ -296,12 +137,6 @@ jobs: name: Dashboard-${{ steps.tag.outputs.tag }} path: release-assets - - name: Download desktop artifacts - uses: actions/download-artifact@v7 - with: - pattern: AstrBot-${{ steps.tag.outputs.tag }}-* - path: release-assets - merge-multiple: true - name: Resolve release notes id: notes diff --git a/.gitignore b/.gitignore index e060b85a6..e3ffbd473 100644 --- a/.gitignore +++ b/.gitignore @@ -33,13 +33,6 @@ tests/astrbot_plugin_openai dashboard/node_modules/ dashboard/dist/ .pnpm-store/ -desktop/node_modules/ -desktop/dist/ -desktop/out/ -desktop/resources/backend/astrbot-backend* -desktop/resources/backend/*.exe -desktop/resources/webui/* -desktop/resources/.pyinstaller/ package-lock.json yarn.lock diff --git a/README.md b/README.md index ac1cd2f70..23eebe39c 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ AstrBot 是一个开源的一站式 Agentic 个人和群聊助手,可在 QQ、 2. ✨ AI 大模型对话,多模态,Agent,MCP,Skills,知识库,人格设定,自动压缩对话。 3. 🤖 支持接入 Dify、阿里云百炼、Coze 等智能体平台。 4. 🌐 多平台,支持 QQ、企业微信、飞书、钉钉、微信公众号、Telegram、Slack 以及[更多](#支持的消息平台)。 -5. 📦 插件扩展,已有近 800 个插件可一键安装。 +5. 📦 插件扩展,已有 1000+ 个插件可一键安装。 6. 🛡️ [Agent Sandbox](https://docs.astrbot.app/use/astrbot-agent-sandbox.html) 隔离化环境,安全地执行任何代码、调用 Shell、会话级资源复用。 7. 💻 WebUI 支持。 8. 🌈 Web ChatUI 支持,ChatUI 内置代理沙盒、网页搜索等。 @@ -56,7 +56,7 @@ AstrBot 是一个开源的一站式 Agentic 个人和群聊助手,可在 QQ、 💙 角色扮演 & 情感陪伴 ✨ 主动式 Agent 🚀 通用 Agentic 能力 - 🧩 900+ 社区插件 + 🧩 1000+ 社区插件

99b587c5d35eea09d84f33e6cf6cfd4f

@@ -81,6 +81,16 @@ uv tool install astrbot astrbot ``` +#### 桌面应用部署(Tauri) + +桌面应用仓库 [AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop)。 + +支持多系统架构,安装包直接安装,开箱即用,最适合新手和懒人的一键桌面部署方案,不推荐服务器场景。 + +#### 启动器一键部署(AstrBot Launcher) + +快速部署和多开方案,实现环境隔离,进入 [AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher) 仓库,在 Releases 页最新版本下找到对应的系统安装包安装即可。 + #### 宝塔面板部署 AstrBot 与宝塔面板合作,已上架至宝塔面板。 @@ -142,15 +152,12 @@ yay -S astrbot-git paru -S astrbot-git ``` -#### 桌面端 Electron 打包 - -桌面端(Electron 打包,`pnpm` 工作流)构建流程请参阅:[`desktop/README.md`](desktop/README.md)。 - ## 支持的消息平台 **官方维护** -- QQ (官方平台 & OneBot) +- QQ +- OneBot v11 协议实现 - Telegram - 企微应用 & 企微智能机器人 - 微信客服 & 微信公众号 @@ -158,10 +165,10 @@ paru -S astrbot-git - 钉钉 - Slack - Discord +- LINE - Satori - Misskey - Whatsapp (将支持) -- LINE (将支持) **社区维护** @@ -181,6 +188,7 @@ paru -S astrbot-git - DeepSeek - Ollama (本地部署) - LM Studio (本地部署) +- [AIHubMix](https://aihubmix.com/?aff=4bfH) - [优云智算](https://www.compshare.cn/?ytag=GPU_YY-gh_astrbot&referral_code=FV7DcGowN4hB5UuXKgpE74) - [302.AI](https://share.302.ai/rr1M3l) - [小马算力](https://www.tokenpony.cn/3YPyf) @@ -256,13 +264,23 @@ pre-commit install 特别感谢所有 Contributors 和插件开发者对 AstrBot 的贡献 ❤️ - + 此外,本项目的诞生离不开以下开源项目的帮助: - [NapNeko/NapCatQQ](https://github.com/NapNeko/NapCatQQ) - 伟大的猫猫框架 +开源项目友情链接: + +- [NoneBot2](https://github.com/nonebot/nonebot2) - 优秀的 Python 异步 ChatBot 框架 +- [Koishi](https://github.com/koishijs/koishi) - 优秀的 Node.js ChatBot 框架 +- [MaiBot](https://github.com/Mai-with-u/MaiBot) - 优秀的拟人化 AI ChatBot +- [nekro-agent](https://github.com/KroMiose/nekro-agent) - 优秀的 Agent ChatBot +- [LangBot](https://github.com/langbot-app/LangBot) - 优秀的多平台 AI ChatBot +- [ChatLuna](https://github.com/ChatLunaLab/chatluna) - 优秀的多平台 AI ChatBot Koishi 插件 +- [Operit AI](https://github.com/AAswordman/Operit) - 优秀的 AI 智能助手 Android APP + ## ⭐ Star History > [!TIP] diff --git a/README_en.md b/README_en.md index c0f2536d3..217859d8e 100644 --- a/README_en.md +++ b/README_en.md @@ -37,7 +37,7 @@ AstrBot is an open-source all-in-one Agent chatbot platform that integrates with mainstream instant messaging apps. It provides reliable and scalable conversational AI infrastructure for individuals, developers, and teams. Whether you're building a personal AI companion, intelligent customer service, automation assistant, or enterprise knowledge base, AstrBot enables you to quickly build production-ready AI applications within your IM platform workflows. -![070d50ba43ea3c96980787127bbbe552](https://github.com/user-attachments/assets/6fe147c5-68d9-4f47-a8de-252e63fdcbd8) +![521771166-00782c4c-4437-4d97-aabc-605e3738da5c (1)](https://github.com/user-attachments/assets/61e7b505-f7db-41aa-a75f-4ef8f079b8ba) ## Key Features @@ -45,7 +45,7 @@ AstrBot is an open-source all-in-one Agent chatbot platform that integrates with 2. ✨ AI LLM Conversations, Multimodal, Agent, MCP, Skills, Knowledge Base, Persona Settings, Auto Context Compression. 3. 🤖 Supports integration with Dify, Alibaba Cloud Bailian, Coze, and other agent platforms. 4. 🌐 Multi-Platform: QQ, WeChat Work, Feishu, DingTalk, WeChat Official Accounts, Telegram, Slack, and [more](#supported-messaging-platforms). -5. 📦 Plugin Extensions with nearly 800 plugins available for one-click installation. +5. 📦 Plugin Extensions with 1000+ plugins available for one-click installation. 6. 🛡️ [Agent Sandbox](https://docs.astrbot.app/use/astrbot-agent-sandbox.html) for isolated, safe execution of code, shell calls, and session-level resource reuse. 7. 💻 WebUI Support. 8. 🌈 Web ChatUI Support with built-in agent sandbox and web search. @@ -58,7 +58,7 @@ AstrBot is an open-source all-in-one Agent chatbot platform that integrates with 💙 Role-playing & Emotional Companionship ✨ Proactive Agent 🚀 General Agentic Capabilities - 🧩 900+ Community Plugins + 🧩 1000+ Community Plugins

99b587c5d35eea09d84f33e6cf6cfd4f

@@ -93,6 +93,16 @@ yay -S astrbot-git paru -S astrbot-git ``` +#### Desktop Application (Tauri) + +Desktop repository: [AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop). + +Supports multiple system architectures, direct installation, out-of-the-box experience. Ideal for beginners. + +#### AstrBot Launcher + +Quick deployment and multi-instance solution. Visit the [AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher) repository and find the latest release for your system. + #### BT-Panel Deployment AstrBot has partnered with BT-Panel and is now available in their marketplace. @@ -144,20 +154,6 @@ uv run main.py Or refer to the official documentation: [Deploy AstrBot from Source](https://astrbot.app/deploy/astrbot/cli.html). -#### System Package Manager Installation - -##### Arch Linux - -```bash -yay -S astrbot-git -# or use paru -paru -S astrbot-git -``` - -#### Desktop Electron Build - -For desktop build steps (Electron packaging, `pnpm` workflow), see [`desktop/README.md`](desktop/README.md). - ## Supported Messaging Platforms **Officially Maintained** @@ -172,8 +168,8 @@ For desktop build steps (Electron packaging, `pnpm` workflow), see [`desktop/REA - Discord - Satori - Misskey +- LINE - WhatsApp (Coming Soon) -- LINE (Coming Soon) **Community Maintained** @@ -268,7 +264,7 @@ pre-commit install Special thanks to all Contributors and plugin developers for their contributions to AstrBot ❤️ - + Additionally, the birth of this project would not have been possible without the help of the following open-source projects: diff --git a/README_fr.md b/README_fr.md index ab1d2a1d1..994cb7677 100644 --- a/README_fr.md +++ b/README_fr.md @@ -21,9 +21,9 @@ python -zread +zread Docker pull - + @@ -37,7 +37,7 @@ AstrBot est une plateforme de chatbot Agent tout-en-un open source qui s'intègre aux principales applications de messagerie instantanée. Elle fournit une infrastructure d'IA conversationnelle fiable et évolutive pour les particuliers, les développeurs et les équipes. Que vous construisiez un compagnon IA personnel, un service client intelligent, un assistant d'automatisation ou une base de connaissances d'entreprise, AstrBot vous permet de créer rapidement des applications d'IA prêtes pour la production dans les flux de travail de votre plateforme de messagerie. -image +![521771166-00782c4c-4437-4d97-aabc-605e3738da5c (1)](https://github.com/user-attachments/assets/61e7b505-f7db-41aa-a75f-4ef8f079b8ba) ## Fonctionnalités principales @@ -45,7 +45,7 @@ AstrBot est une plateforme de chatbot Agent tout-en-un open source qui s'intègr 2. ✨ Dialogue avec de grands modèles d'IA, multimodal, Agent, MCP, Skills, Base de connaissances, Paramétrage de personnalité, compression automatique des dialogues. 3. 🤖 Prise en charge de l'accès aux plateformes d'Agents telles que Dify, Alibaba Cloud Bailian, Coze, etc. 4. 🌐 Multiplateforme : supporte QQ, WeChat Enterprise, Feishu, DingTalk, Comptes officiels WeChat, Telegram, Slack et [plus encore](#plateformes-de-messagerie-prises-en-charge). -5. 📦 Extension par plugins, avec près de 800 plugins déjà disponibles pour une installation en un clic. +5. 📦 Extension par plugins, avec plus de 1000 plugins déjà disponibles pour une installation en un clic. 6. 🛡️ Environnement isolé [Agent Sandbox](https://docs.astrbot.app/use/astrbot-agent-sandbox.html) : exécution sécurisée de code, appels Shell et réutilisation des ressources au niveau de la session. 7. 💻 Support WebUI. 8. 🌈 Support Web ChatUI, avec sandbox d'agent intégrée, recherche web, etc. @@ -58,7 +58,7 @@ AstrBot est une plateforme de chatbot Agent tout-en-un open source qui s'intègr 💙 Jeux de rôle & Accompagnement émotionnel ✨ Agent proactif 🚀 Capacités agentiques générales - 🧩 900+ Plugins de communauté + 🧩 1000+ Plugins de communauté

99b587c5d35eea09d84f33e6cf6cfd4f

@@ -83,15 +83,15 @@ uv tool install astrbot astrbot ``` -#### Installation via le gestionnaire de paquets du système +#### Application de bureau (Tauri) -##### Arch Linux +Dépôt de l'application de bureau : [AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop). -```bash -yay -S astrbot-git -# ou utiliser paru -paru -S astrbot-git -``` +Prend en charge plusieurs architectures système, installation directe, prête à l'emploi. La solution de déploiement de bureau en un clic la plus adaptée aux débutants. Non recommandée pour les serveurs. + +#### Déploiement en un clic avec le lanceur (AstrBot Launcher) + +Déploiement rapide et solution multi-instances, isolation de l'environnement. Accédez au dépôt [AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher), trouvez le package d'installation correspondant à votre système sous la dernière version sur la page Releases. #### Déploiement BT-Panel @@ -144,13 +144,13 @@ uv run main.py Ou consultez la documentation officielle : [Déployer AstrBot depuis les sources](https://astrbot.app/deploy/astrbot/cli.html). -#### Установка через системный пакетный менеджер +#### Installation via le gestionnaire de paquets du système ##### Arch Linux ```bash yay -S astrbot-git -# или используйте paru +# ou utiliser paru paru -S astrbot-git ``` @@ -168,8 +168,8 @@ paru -S astrbot-git - Discord - Satori - Misskey +- LINE - WhatsApp (Bientôt disponible) -- LINE (Bientôt disponible) **Maintenues par la communauté** @@ -262,7 +262,7 @@ pre-commit install Un grand merci à tous les contributeurs et développeurs de plugins pour leurs contributions à AstrBot ❤️ - + De plus, la naissance de ce projet n'aurait pas été possible sans l'aide des projets open source suivants : diff --git a/README_ja.md b/README_ja.md index 21023b536..ad3b95022 100644 --- a/README_ja.md +++ b/README_ja.md @@ -21,9 +21,9 @@ python -zread +zread Docker pull - + @@ -37,7 +37,7 @@ AstrBot は、主要なインスタントメッセージングアプリと統合できるオープンソースのオールインワン Agent チャットボットプラットフォームです。個人、開発者、チームに信頼性が高くスケーラブルな会話型 AI インフラストラクチャを提供します。パーソナル AI コンパニオン、インテリジェントカスタマーサービス、オートメーションアシスタント、エンタープライズナレッジベースなど、AstrBot を使用すると、IM プラットフォームのワークフロー内で本番環境対応の AI アプリケーションを迅速に構築できます。 -image +![521771166-00782c4c-4437-4d97-aabc-605e3738da5c (1)](https://github.com/user-attachments/assets/61e7b505-f7db-41aa-a75f-4ef8f079b8ba) ## 主な機能 @@ -45,7 +45,7 @@ AstrBot は、主要なインスタントメッセージングアプリと統合 2. ✨ AI大規模言語モデル対話、マルチモーダル、Agent、MCP、Skills、ナレッジベース、ペルソナ設定、対話の自動圧縮。 3. 🤖 Dify、Alibaba Cloud Bailian(百煉)、Coze などのAgentプラットフォームへの接続をサポート。 4. 🌐 マルチプラットフォーム:QQ、企業微信(WeCom)、飛書(Lark)、釘釘(DingTalk)、WeChat公式アカウント、Telegram、Slack、[その他](#サポートされているメッセージプラットフォーム)に対応。 -5. 📦 プラグイン拡張:800近い既存プラグインをワンクリックでインストール可能。 +5. 📦 プラグイン拡張:1000を超える既存プラグインをワンクリックでインストール可能。 6. 🛡️ 隔離環境[Agent Sandbox](https://docs.astrbot.app/use/astrbot-agent-sandbox.html):コードの安全な実行、Shell呼び出し、セッションレベルのリソース再利用。 7. 💻 WebUI 対応。 8. 🌈 Web ChatUI 対応:ChatUI内にAgent Sandboxやウェブ検索などを内蔵。 @@ -58,7 +58,7 @@ AstrBot は、主要なインスタントメッセージングアプリと統合 💙 ロールプレイ & 感情的な対話 ✨ プロアクティブ・エージェント (Proactive Agent) 🚀 汎用 エージェント的能力 - 🧩 900+ コミュニティプラグイン + 🧩 1000+ コミュニティプラグイン

99b587c5d35eea09d84f33e6cf6cfd4f

@@ -83,15 +83,15 @@ uv tool install astrbot astrbot ``` -#### システムパッケージマネージャーでのインストール +#### デスクトップアプリのデプロイ(Tauri) -##### Arch Linux +デスクトップアプリのリポジトリ [AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop)。 -```bash -yay -S astrbot-git -# または paru を使用 -paru -S astrbot-git -``` +マルチシステムアーキテクチャをサポートし、インストールしてすぐに使用可能。初心者や手軽さを求める人に最適なワンクリックデスクトップデプロイソリューションです。サーバー環境での使用は推奨されません。 + +#### ランチャーによるワンクリックデプロイ(AstrBot Launcher) + +迅速なデプロイとマルチインスタンス対応、環境の隔離が可能。[AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher) リポジトリにアクセスし、Releases ページから最新バージョンのシステム対応パッケージをダウンロードしてインストールしてください。 #### 宝塔パネルデプロイ @@ -144,13 +144,13 @@ uv run main.py または、公式ドキュメント [ソースコードから AstrBot をデプロイ](https://astrbot.app/deploy/astrbot/cli.html) をご参照ください。 -#### Установка через системный пакетный менеджер +#### システムパッケージマネージャーでのインストール ##### Arch Linux ```bash yay -S astrbot-git -# или используйте paru +# または paru を使用 paru -S astrbot-git ``` @@ -168,8 +168,8 @@ paru -S astrbot-git - Discord - Satori - Misskey +- LINE - WhatsApp (近日対応予定) -- LINE (近日対応予定) **コミュニティメンテナンス** @@ -263,7 +263,7 @@ pre-commit install AstrBot への貢献をしていただいたすべてのコントリビューターとプラグイン開発者に特別な感謝を ❤️ - + また、このプロジェクトの誕生は以下のオープンソースプロジェクトの助けなしには実現できませんでした: diff --git a/README_ru.md b/README_ru.md index 2ed768103..970bce277 100644 --- a/README_ru.md +++ b/README_ru.md @@ -21,9 +21,9 @@ python -zread +zread Docker pull - + @@ -37,7 +37,7 @@ AstrBot — это универсальная платформа Agent-чатботов с открытым исходным кодом, которая интегрируется с основными приложениями для обмена мгновенными сообщениями. Она предоставляет надёжную и масштабируемую инфраструктуру разговорного ИИ для частных лиц, разработчиков и команд. Будь то персональный ИИ-компаньон, интеллектуальная служба поддержки, автоматизированный помощник или корпоративная база знаний — AstrBot позволяет быстро создавать готовые к использованию ИИ-приложения в рабочих процессах вашей платформы обмена сообщениями. -image +![521771166-00782c4c-4437-4d97-aabc-605e3738da5c (1)](https://github.com/user-attachments/assets/61e7b505-f7db-41aa-a75f-4ef8f079b8ba) ## Основные возможности @@ -45,7 +45,7 @@ AstrBot — это универсальная платформа Agent-чатб 2. ✨ Диалоги с ИИ-моделями, мультимодальность, Agent, MCP, Skills, База знаний, Настройка личности, автоматическое сжатие диалогов. 3. 🤖 Поддержка интеграции с платформами Agents, такими как Dify, Alibaba Cloud Bailian, Coze и др. 4. 🌐 Мультиплатформенность: поддержка QQ, WeChat для предприятий, Feishu, DingTalk, публичных аккаунтов WeChat, Telegram, Slack и [других](#Поддерживаемые-платформы-обмена-сообщениями). -5. 📦 Расширение плагинами: доступно почти 800 плагинов для установки в один клик. +5. 📦 Расширение плагинами: доступно более 1000 плагинов для установки в один клик. 6. 🛡️ Изолированная среда[Agent Sandbox](https://docs.astrbot.app/use/astrbot-agent-sandbox.html): безопасное выполнение любого кода, вызов Shell, повторное использование ресурсов на уровне сессии. 7. 💻 Поддержка WebUI. 8. 🌈 Поддержка Web ChatUI: встроенная песочница агента, веб-поиск и др. @@ -56,9 +56,9 @@ AstrBot — это универсальная платформа Agent-чатб - - - + + + @@ -83,6 +83,16 @@ uv tool install astrbot astrbot ``` +#### Десктопное приложение (Tauri) + +Репозиторий десктопного приложения: [AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop). + +Поддерживает различные системные архитектуры, устанавливается напрямую, "из коробки", лучшее настольное решение в один клик для новичков и тех, кто ценит простоту. Не рекомендуется для серверных сценариев. + +#### Установка в один клик через лаунчер (AstrBot Launcher) + +Быстрое развёртывание и поддержка нескольких экземпляров, изоляция среды. Перейдите в репозиторий [AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher), найдите последнюю версию на странице Releases и установите соответствующий пакет для вашей системы. + #### Развёртывание BT-Panel AstrBot в партнёрстве с BT-Panel теперь доступен на их маркетплейсе. @@ -158,8 +168,9 @@ paru -S astrbot-git - Discord - Satori - Misskey +- LINE - WhatsApp (Скоро) -- LINE (Скоро) + **Поддерживаемые сообществом** @@ -252,7 +263,7 @@ pre-commit install Особая благодарность всем контрибьюторам и разработчикам плагинов за их вклад в AstrBot ❤️ - + Кроме того, рождение этого проекта было бы невозможно без помощи следующих проектов с открытым исходным кодом: diff --git a/README_zh-TW.md b/README_zh-TW.md index 7232d8cc7..e612a3c42 100644 --- a/README_zh-TW.md +++ b/README_zh-TW.md @@ -37,7 +37,7 @@ AstrBot 是一個開源的一站式 Agent 聊天機器人平台,可接入主流即時通訊軟體,為個人、開發者和團隊打造可靠、可擴展的對話式智慧基礎設施。無論是個人 AI 夥伴、智慧客服、自動化助手,還是企業知識庫,AstrBot 都能在您的即時通訊軟體平台的工作流程中快速構建生產可用的 AI 應用程式。 -image +![521771166-00782c4c-4437-4d97-aabc-605e3738da5c (1)](https://github.com/user-attachments/assets/61e7b505-f7db-41aa-a75f-4ef8f079b8ba) ## 主要功能 @@ -45,7 +45,7 @@ AstrBot 是一個開源的一站式 Agent 聊天機器人平台,可接入主 2. ✨ AI 大模型對話,多模態,Agent,MCP,Skills,知識庫,人格設定,自動壓縮對話。 3. 🤖 支援接入 Dify、阿里雲百煉、Coze 等智慧體 (Agent) 平台。 4. 🌐 多平台,支援 QQ、企業微信、飛書、釘釘、微信公眾號、Telegram、Slack 以及[更多](#支援的訊息平台)。 -5. 📦 插件擴展,已有近 800 個插件可一鍵安裝。 +5. 📦 插件擴展,已有 1000+ 個插件可一鍵安裝。 6. 🛡️ [Agent Sandbox](https://docs.astrbot.app/use/astrbot-agent-sandbox.html) 隔離化環境,安全地執行任何代碼、調用 Shell、會話級資源複用。 7. 💻 WebUI 支援。 8. 🌈 Web ChatUI 支援,ChatUI 內置代理沙盒 (Agent Sandbox)、網頁搜尋等。 @@ -58,7 +58,7 @@ AstrBot 是一個開源的一站式 Agent 聊天機器人平台,可接入主 - + @@ -83,6 +83,16 @@ uv tool install astrbot astrbot ``` +#### 桌面應用部署(Tauri) + +桌面應用倉庫 [AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop)。 + +支援多系統架構,安裝包直接安裝,開箱即用,最適合新手和懶人的一鍵桌面部署方案,不推薦伺服器場景。 + +#### 啟動器一鍵部署(AstrBot Launcher) + +快速部署和多開方案,實現環境隔離,進入 [AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher) 倉庫,在 Releases 頁最新版本下找到對應的系統安裝包安裝即可。 + #### 寶塔面板部署 AstrBot 與寶塔面板合作,已上架至寶塔面板。 @@ -158,8 +168,9 @@ paru -S astrbot-git - Discord - Satori - Misskey +- LINE - Whatsapp(即將支援) -- LINE(即將支援) + **社群維護** @@ -252,7 +263,7 @@ pre-commit install 特別感謝所有 Contributors 和外掛開發者對 AstrBot 的貢獻 ❤️ - + 此外,本專案的誕生離不開以下開源專案的幫助: diff --git a/astrbot/api/event/filter/__init__.py b/astrbot/api/event/filter/__init__.py index 287c60b73..f5ab15ed0 100644 --- a/astrbot/api/event/filter/__init__.py +++ b/astrbot/api/event/filter/__init__.py @@ -24,6 +24,9 @@ from astrbot.core.star.register import ( register_on_llm_tool_respond as on_llm_tool_respond, ) from astrbot.core.star.register import register_on_platform_loaded as on_platform_loaded +from astrbot.core.star.register import register_on_plugin_error as on_plugin_error +from astrbot.core.star.register import register_on_plugin_loaded as on_plugin_loaded +from astrbot.core.star.register import register_on_plugin_unloaded as on_plugin_unloaded from astrbot.core.star.register import register_on_using_llm_tool as on_using_llm_tool from astrbot.core.star.register import ( register_on_waiting_llm_request as on_waiting_llm_request, @@ -52,6 +55,9 @@ __all__ = [ "on_decorating_result", "on_llm_request", "on_llm_response", + "on_plugin_error", + "on_plugin_loaded", + "on_plugin_unloaded", "on_platform_loaded", "on_waiting_llm_request", "permission_type", diff --git a/astrbot/builtin_stars/builtin_commands/commands/conversation.py b/astrbot/builtin_stars/builtin_commands/commands/conversation.py index eb8cfdefa..63561f64e 100644 --- a/astrbot/builtin_stars/builtin_commands/commands/conversation.py +++ b/astrbot/builtin_stars/builtin_commands/commands/conversation.py @@ -4,6 +4,7 @@ from astrbot.api import sp, star from astrbot.api.event import AstrMessageEvent, MessageEventResult from astrbot.core.platform.astr_message_event import MessageSession from astrbot.core.platform.message_type import MessageType +from astrbot.core.utils.active_event_registry import active_event_registry from .utils.rst_scene import RstScene @@ -62,6 +63,7 @@ class ConversationCommands: agent_runner_type = cfg["provider_settings"]["agent_runner_type"] if agent_runner_type in THIRD_PARTY_AGENT_RUNNER_KEY: + active_event_registry.stop_all(umo, exclude=message) await sp.remove_async( scope="umo", scope_id=umo, @@ -86,6 +88,8 @@ class ConversationCommands: ) return + active_event_registry.stop_all(umo, exclude=message) + await self.context.conversation_manager.update_conversation( umo, cid, @@ -98,6 +102,30 @@ class ConversationCommands: message.set_result(MessageEventResult().message(ret)) + async def stop(self, message: AstrMessageEvent) -> None: + """停止当前会话正在运行的 Agent""" + cfg = self.context.get_config(umo=message.unified_msg_origin) + agent_runner_type = cfg["provider_settings"]["agent_runner_type"] + umo = message.unified_msg_origin + + if agent_runner_type in THIRD_PARTY_AGENT_RUNNER_KEY: + stopped_count = active_event_registry.stop_all(umo, exclude=message) + else: + stopped_count = active_event_registry.request_agent_stop_all( + umo, + exclude=message, + ) + + if stopped_count > 0: + message.set_result( + MessageEventResult().message( + f"已请求停止 {stopped_count} 个运行中的任务。" + ) + ) + return + + message.set_result(MessageEventResult().message("当前会话没有运行中的任务。")) + async def his(self, message: AstrMessageEvent, page: int = 1) -> None: """查看对话记录""" if not self.context.get_using_provider(message.unified_msg_origin): @@ -221,6 +249,7 @@ class ConversationCommands: cfg = self.context.get_config(umo=message.unified_msg_origin) agent_runner_type = cfg["provider_settings"]["agent_runner_type"] if agent_runner_type in THIRD_PARTY_AGENT_RUNNER_KEY: + active_event_registry.stop_all(message.unified_msg_origin, exclude=message) await sp.remove_async( scope="umo", scope_id=message.unified_msg_origin, @@ -229,6 +258,7 @@ class ConversationCommands: message.set_result(MessageEventResult().message("已创建新对话。")) return + active_event_registry.stop_all(message.unified_msg_origin, exclude=message) cpersona = await self._get_current_persona_id(message.unified_msg_origin) cid = await self.context.conversation_manager.new_conversation( message.unified_msg_origin, @@ -321,7 +351,8 @@ class ConversationCommands: async def del_conv(self, message: AstrMessageEvent) -> None: """删除当前对话""" - cfg = self.context.get_config(umo=message.unified_msg_origin) + umo = message.unified_msg_origin + cfg = self.context.get_config(umo=umo) is_unique_session = cfg["platform_settings"]["unique_session"] if message.get_group_id() and not is_unique_session and message.role != "admin": # 群聊,没开独立会话,发送人不是管理员 @@ -334,18 +365,17 @@ class ConversationCommands: agent_runner_type = cfg["provider_settings"]["agent_runner_type"] if agent_runner_type in THIRD_PARTY_AGENT_RUNNER_KEY: + active_event_registry.stop_all(umo, exclude=message) await sp.remove_async( scope="umo", - scope_id=message.unified_msg_origin, + scope_id=umo, key=THIRD_PARTY_AGENT_RUNNER_KEY[agent_runner_type], ) message.set_result(MessageEventResult().message("重置对话成功。")) return session_curr_cid = ( - await self.context.conversation_manager.get_curr_conversation_id( - message.unified_msg_origin, - ) + await self.context.conversation_manager.get_curr_conversation_id(umo) ) if not session_curr_cid: @@ -356,8 +386,10 @@ class ConversationCommands: ) return + active_event_registry.stop_all(umo, exclude=message) + await self.context.conversation_manager.delete_conversation( - message.unified_msg_origin, + umo, session_curr_cid, ) diff --git a/astrbot/builtin_stars/builtin_commands/main.py b/astrbot/builtin_stars/builtin_commands/main.py index 9b839ca88..fb4a83403 100644 --- a/astrbot/builtin_stars/builtin_commands/main.py +++ b/astrbot/builtin_stars/builtin_commands/main.py @@ -132,6 +132,11 @@ class Main(star.Star): """重置 LLM 会话""" await self.conversation_c.reset(message) + @filter.command("stop") + async def stop(self, message: AstrMessageEvent) -> None: + """停止当前会话中正在运行的 Agent""" + await self.conversation_c.stop(message) + @filter.permission_type(filter.PermissionType.ADMIN) @filter.command("model") async def model_ls( diff --git a/astrbot/builtin_stars/web_searcher/main.py b/astrbot/builtin_stars/web_searcher/main.py index 85eeffd94..d2c171a92 100644 --- a/astrbot/builtin_stars/web_searcher/main.py +++ b/astrbot/builtin_stars/web_searcher/main.py @@ -70,7 +70,7 @@ class Main(star.Star): header = HEADERS header.update({"User-Agent": random.choice(USER_AGENTS)}) async with aiohttp.ClientSession(trust_env=True) as session: - async with session.get(url, headers=header, timeout=6) as response: + async with session.get(url, headers=header) as response: html = await response.text(encoding="utf-8") doc = Document(html) ret = doc.summary(html_partial=True) @@ -151,7 +151,6 @@ class Main(star.Star): url, json=payload, headers=header, - timeout=6, ) as response: if response.status != 200: reason = await response.text() @@ -183,7 +182,6 @@ class Main(star.Star): url, json=payload, headers=header, - timeout=6, ) as response: if response.status != 200: reason = await response.text() @@ -265,7 +263,7 @@ class Main(star.Star): "transport": "sse", "url": f"http://appbuilder.baidu.com/v2/ai_search/mcp/sse?api_key={key}", "headers": {}, - "timeout": 30, + "timeout": 600, }, ) self.baidu_initialized = True diff --git a/astrbot/cli/__init__.py b/astrbot/cli/__init__.py index 80de35676..60d15c0d5 100644 --- a/astrbot/cli/__init__.py +++ b/astrbot/cli/__init__.py @@ -1 +1 @@ -__version__ = "4.16.0" +__version__ = "4.18.2" diff --git a/astrbot/core/agent/handoff.py b/astrbot/core/agent/handoff.py index 761c50269..8475009d3 100644 --- a/astrbot/core/agent/handoff.py +++ b/astrbot/core/agent/handoff.py @@ -44,6 +44,19 @@ class HandoffTool(FunctionTool, Generic[TContext]): "type": "string", "description": "The input to be handed off to another agent. This should be a clear and concise request or task.", }, + "image_urls": { + "type": "array", + "items": {"type": "string"}, + "description": "Optional: An array of image sources (public HTTP URLs or local file paths) used as references in multimodal tasks such as video generation.", + }, + "background_task": { + "type": "boolean", + "description": ( + "Defaults to false. " + "Set to true if the task may take noticeable time, involves external tools, or the user does not need to wait. " + "Use false only for quick, immediate tasks." + ), + }, }, } diff --git a/astrbot/core/agent/runners/tool_loop_agent_runner.py b/astrbot/core/agent/runners/tool_loop_agent_runner.py index d6aed6dfa..10cf2e96c 100644 --- a/astrbot/core/agent/runners/tool_loop_agent_runner.py +++ b/astrbot/core/agent/runners/tool_loop_agent_runner.py @@ -91,6 +91,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]): custom_token_counter: TokenCounter | None = None, custom_compressor: ContextCompressor | None = None, tool_schema_mode: str | None = "full", + fallback_providers: list[Provider] | None = None, **kwargs: T.Any, ) -> None: self.req = request @@ -120,11 +121,24 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]): self.context_manager = ContextManager(self.context_config) self.provider = provider + self.fallback_providers: list[Provider] = [] + seen_provider_ids: set[str] = {str(provider.provider_config.get("id", ""))} + for fallback_provider in fallback_providers or []: + fallback_id = str(fallback_provider.provider_config.get("id", "")) + if fallback_provider is provider: + continue + if fallback_id and fallback_id in seen_provider_ids: + continue + self.fallback_providers.append(fallback_provider) + if fallback_id: + seen_provider_ids.add(fallback_id) self.final_llm_resp = None self._state = AgentState.IDLE self.tool_executor = tool_executor self.agent_hooks = agent_hooks self.run_context = run_context + self._stop_requested = False + self._aborted = False # These two are used for tool schema mode handling # We now have two modes: @@ -166,16 +180,19 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]): self.stats = AgentStats() self.stats.start_time = time.time() - async def _iter_llm_responses(self) -> T.AsyncGenerator[LLMResponse, None]: + async def _iter_llm_responses( + self, *, include_model: bool = True + ) -> T.AsyncGenerator[LLMResponse, None]: """Yields chunks *and* a final LLMResponse.""" payload = { "contexts": self.run_context.messages, # list[Message] "func_tool": self.req.func_tool, - "model": self.req.model, # NOTE: in fact, this arg is None in most cases "session_id": self.req.session_id, "extra_user_content_parts": self.req.extra_user_content_parts, # list[ContentPart] } - + if include_model: + # For primary provider we keep explicit model selection if provided. + payload["model"] = self.req.model if self.streaming: stream = self.provider.text_chat_stream(**payload) async for resp in stream: # type: ignore @@ -183,6 +200,77 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]): else: yield await self.provider.text_chat(**payload) + async def _iter_llm_responses_with_fallback( + self, + ) -> T.AsyncGenerator[LLMResponse, None]: + """Wrap _iter_llm_responses with provider fallback handling.""" + candidates = [self.provider, *self.fallback_providers] + total_candidates = len(candidates) + last_exception: Exception | None = None + last_err_response: LLMResponse | None = None + + for idx, candidate in enumerate(candidates): + candidate_id = candidate.provider_config.get("id", "") + is_last_candidate = idx == total_candidates - 1 + if idx > 0: + logger.warning( + "Switched from %s to fallback chat provider: %s", + self.provider.provider_config.get("id", ""), + candidate_id, + ) + self.provider = candidate + has_stream_output = False + try: + async for resp in self._iter_llm_responses(include_model=idx == 0): + if resp.is_chunk: + has_stream_output = True + yield resp + continue + + if ( + resp.role == "err" + and not has_stream_output + and (not is_last_candidate) + ): + last_err_response = resp + logger.warning( + "Chat Model %s returns error response, trying fallback to next provider.", + candidate_id, + ) + break + + yield resp + return + + if has_stream_output: + return + except Exception as exc: # noqa: BLE001 + last_exception = exc + logger.warning( + "Chat Model %s request error: %s", + candidate_id, + exc, + exc_info=True, + ) + continue + + if last_err_response: + yield last_err_response + return + if last_exception: + yield LLMResponse( + role="err", + completion_text=( + "All chat models failed: " + f"{type(last_exception).__name__}: {last_exception}" + ), + ) + return + yield LLMResponse( + role="err", + completion_text="All available chat models are unavailable.", + ) + def _simple_print_message_role(self, tag: str = ""): roles = [] for message in self.run_context.messages: @@ -215,7 +303,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]): ) self._simple_print_message_role("[AftCompact]") - async for llm_response in self._iter_llm_responses(): + async for llm_response in self._iter_llm_responses_with_fallback(): if llm_response.is_chunk: # update ttft if self.stats.time_to_first_token == 0: @@ -242,6 +330,14 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]): ), ), ) + if self._stop_requested: + llm_resp_result = LLMResponse( + role="assistant", + completion_text="[SYSTEM: User actively interrupted the response generation. Partial output before interruption is preserved.]", + reasoning_content=llm_response.reasoning_content, + reasoning_signature=llm_response.reasoning_signature, + ) + break continue llm_resp_result = llm_response @@ -253,6 +349,48 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]): break # got final response if not llm_resp_result: + if self._stop_requested: + llm_resp_result = LLMResponse(role="assistant", completion_text="") + else: + return + + if self._stop_requested: + logger.info("Agent execution was requested to stop by user.") + llm_resp = llm_resp_result + if llm_resp.role != "assistant": + llm_resp = LLMResponse( + role="assistant", + completion_text="[SYSTEM: User actively interrupted the response generation. Partial output before interruption is preserved.]", + ) + self.final_llm_resp = llm_resp + self._aborted = True + self._transition_state(AgentState.DONE) + self.stats.end_time = time.time() + + parts = [] + if llm_resp.reasoning_content or llm_resp.reasoning_signature: + parts.append( + ThinkPart( + think=llm_resp.reasoning_content, + encrypted=llm_resp.reasoning_signature, + ) + ) + if llm_resp.completion_text: + parts.append(TextPart(text=llm_resp.completion_text)) + if parts: + self.run_context.messages.append( + Message(role="assistant", content=parts) + ) + + try: + await self.agent_hooks.on_agent_done(self.run_context, llm_resp) + except Exception as e: + logger.error(f"Error in on_agent_done hook: {e}", exc_info=True) + + yield AgentResponse( + type="aborted", + data=AgentResponseData(chain=MessageChain(type="aborted")), + ) return # 处理 LLM 响应 @@ -271,6 +409,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]): ), ), ) + return if not llm_resp.tools_call_name: # 如果没有工具调用,转换到完成状态 @@ -761,5 +900,11 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]): """检查 Agent 是否已完成工作""" return self._state in (AgentState.DONE, AgentState.ERROR) + def request_stop(self) -> None: + self._stop_requested = True + + def was_aborted(self) -> bool: + return self._aborted + def get_final_llm_resp(self) -> LLMResponse | None: return self.final_llm_resp diff --git a/astrbot/core/agent/tool.py b/astrbot/core/agent/tool.py index 04a47b35f..c2536708e 100644 --- a/astrbot/core/agent/tool.py +++ b/astrbot/core/agent/tool.py @@ -285,6 +285,9 @@ class ToolSet: prop_value = convert_schema(value) if "default" in prop_value: del prop_value["default"] + # see #5217 + if "additionalProperties" in prop_value: + del prop_value["additionalProperties"] properties[key] = prop_value if properties: diff --git a/astrbot/core/astr_agent_run_util.py b/astrbot/core/astr_agent_run_util.py index 379e62d6a..017f2cea2 100644 --- a/astrbot/core/astr_agent_run_util.py +++ b/astrbot/core/astr_agent_run_util.py @@ -20,15 +20,81 @@ from astrbot.core.provider.provider import TTSProvider AgentRunner = ToolLoopAgentRunner[AstrAgentContext] +def _should_stop_agent(astr_event) -> bool: + return astr_event.is_stopped() or bool(astr_event.get_extra("agent_stop_requested")) + + +def _truncate_tool_result(text: str, limit: int = 70) -> str: + if limit <= 0: + return "" + if len(text) <= limit: + return text + if limit <= 3: + return text[:limit] + return f"{text[: limit - 3]}..." + + +def _extract_chain_json_data(msg_chain: MessageChain) -> dict | None: + if not msg_chain.chain: + return None + first_comp = msg_chain.chain[0] + if isinstance(first_comp, Json) and isinstance(first_comp.data, dict): + return first_comp.data + return None + + +def _record_tool_call_name( + tool_info: dict | None, tool_name_by_call_id: dict[str, str] +) -> None: + if not isinstance(tool_info, dict): + return + tool_call_id = tool_info.get("id") + tool_name = tool_info.get("name") + if tool_call_id is None or tool_name is None: + return + tool_name_by_call_id[str(tool_call_id)] = str(tool_name) + + +def _build_tool_call_status_message(tool_info: dict | None) -> str: + if tool_info: + return f"🔨 调用工具: {tool_info.get('name', 'unknown')}" + return "🔨 调用工具..." + + +def _build_tool_result_status_message( + msg_chain: MessageChain, tool_name_by_call_id: dict[str, str] +) -> str: + tool_name = "unknown" + tool_result = "" + + result_data = _extract_chain_json_data(msg_chain) + if result_data: + tool_call_id = result_data.get("id") + if tool_call_id is not None: + tool_name = tool_name_by_call_id.pop(str(tool_call_id), "unknown") + tool_result = str(result_data.get("result", "")) + + if not tool_result: + tool_result = msg_chain.get_plain_text(with_other_comps_mark=True) + tool_result = _truncate_tool_result(tool_result, 70) + + status_msg = f"🔨 调用工具: {tool_name}" + if tool_result: + status_msg = f"{status_msg}\n📎 返回结果: {tool_result}" + return status_msg + + async def run_agent( agent_runner: AgentRunner, max_step: int = 30, show_tool_use: bool = True, + show_tool_call_result: bool = False, stream_to_general: bool = False, show_reasoning: bool = False, ) -> AsyncGenerator[MessageChain | None, None]: step_idx = 0 astr_event = agent_runner.run_context.context.event + tool_name_by_call_id: dict[str, str] = {} while step_idx < max_step + 1: step_idx += 1 @@ -48,10 +114,28 @@ async def run_agent( ) ) + stop_watcher = asyncio.create_task( + _watch_agent_stop_signal(agent_runner, astr_event), + ) try: async for resp in agent_runner.step(): - if astr_event.is_stopped(): + if _should_stop_agent(astr_event): + agent_runner.request_stop() + + if resp.type == "aborted": + if not stop_watcher.done(): + stop_watcher.cancel() + try: + await stop_watcher + except asyncio.CancelledError: + pass + astr_event.set_extra("agent_user_aborted", True) + astr_event.set_extra("agent_stop_requested", False) return + + if _should_stop_agent(astr_event): + continue + if resp.type == "tool_call_result": msg_chain = resp.data["chain"] @@ -68,6 +152,13 @@ async def run_agent( continue if astr_event.get_platform_id() == "webchat": await astr_event.send(msg_chain) + elif show_tool_use and show_tool_call_result: + status_msg = _build_tool_result_status_message( + msg_chain, tool_name_by_call_id + ) + await astr_event.send( + MessageChain(type="tool_call").message(status_msg) + ) # 对于其他情况,暂时先不处理 continue elif resp.type == "tool_call": @@ -75,25 +166,22 @@ async def run_agent( # 用来标记流式响应需要分节 yield MessageChain(chain=[], type="break") - tool_info = None - - if resp.data["chain"].chain: - json_comp = resp.data["chain"].chain[0] - if isinstance(json_comp, Json): - tool_info = json_comp.data - astr_event.trace.record( - "agent_tool_call", - tool_name=tool_info if tool_info else "unknown", - ) + tool_info = _extract_chain_json_data(resp.data["chain"]) + astr_event.trace.record( + "agent_tool_call", + tool_name=tool_info if tool_info else "unknown", + ) + _record_tool_call_name(tool_info, tool_name_by_call_id) if astr_event.get_platform_name() == "webchat": await astr_event.send(resp.data["chain"]) elif show_tool_use: - if tool_info: - m = f"🔨 调用工具: {tool_info.get('name', 'unknown')}" - else: - m = "🔨 调用工具..." - chain = MessageChain(type="tool_call").message(m) + if show_tool_call_result and isinstance(tool_info, dict): + # Delay tool status notification until tool_call_result. + continue + chain = MessageChain(type="tool_call").message( + _build_tool_call_status_message(tool_info) + ) await astr_event.send(chain) continue @@ -120,6 +208,12 @@ async def run_agent( # display the reasoning content only when configured continue yield resp.data["chain"] # MessageChain + if not stop_watcher.done(): + stop_watcher.cancel() + try: + await stop_watcher + except asyncio.CancelledError: + pass if agent_runner.done(): # send agent stats to webchat if astr_event.get_platform_name() == "webchat": @@ -133,6 +227,12 @@ async def run_agent( break except Exception as e: + if "stop_watcher" in locals() and not stop_watcher.done(): + stop_watcher.cancel() + try: + await stop_watcher + except asyncio.CancelledError: + pass logger.error(traceback.format_exc()) err_msg = f"\n\nAstrBot 请求失败。\n错误类型: {type(e).__name__}\n错误信息: {e!s}\n\n请在平台日志查看和分享错误详情。\n" @@ -155,11 +255,20 @@ async def run_agent( return +async def _watch_agent_stop_signal(agent_runner: AgentRunner, astr_event) -> None: + while not agent_runner.done(): + if _should_stop_agent(astr_event): + agent_runner.request_stop() + return + await asyncio.sleep(0.5) + + async def run_live_agent( agent_runner: AgentRunner, tts_provider: TTSProvider | None = None, max_step: int = 30, show_tool_use: bool = True, + show_tool_call_result: bool = False, show_reasoning: bool = False, ) -> AsyncGenerator[MessageChain | None, None]: """Live Mode 的 Agent 运行器,支持流式 TTS @@ -169,6 +278,7 @@ async def run_live_agent( tts_provider: TTS Provider 实例 max_step: 最大步数 show_tool_use: 是否显示工具使用 + show_tool_call_result: 是否显示工具返回结果 show_reasoning: 是否显示推理过程 Yields: @@ -180,6 +290,7 @@ async def run_live_agent( agent_runner, max_step=max_step, show_tool_use=show_tool_use, + show_tool_call_result=show_tool_call_result, stream_to_general=False, show_reasoning=show_reasoning, ): @@ -208,7 +319,12 @@ async def run_live_agent( # 1. 启动 Agent Feeder 任务:负责运行 Agent 并将文本分句喂给 text_queue feeder_task = asyncio.create_task( _run_agent_feeder( - agent_runner, text_queue, max_step, show_tool_use, show_reasoning + agent_runner, + text_queue, + max_step, + show_tool_use, + show_tool_call_result, + show_reasoning, ) ) @@ -294,6 +410,7 @@ async def _run_agent_feeder( text_queue: asyncio.Queue, max_step: int, show_tool_use: bool, + show_tool_call_result: bool, show_reasoning: bool, ) -> None: """运行 Agent 并将文本输出分句放入队列""" @@ -303,6 +420,7 @@ async def _run_agent_feeder( agent_runner, max_step=max_step, show_tool_use=show_tool_use, + show_tool_call_result=show_tool_call_result, stream_to_general=False, show_reasoning=show_reasoning, ): diff --git a/astrbot/core/astr_agent_tool_exec.py b/astrbot/core/astr_agent_tool_exec.py index 230faaf1c..46ec4346b 100644 --- a/astrbot/core/astr_agent_tool_exec.py +++ b/astrbot/core/astr_agent_tool_exec.py @@ -17,6 +17,12 @@ from astrbot.core.agent.tool_executor import BaseFunctionToolExecutor from astrbot.core.astr_agent_context import AstrAgentContext from astrbot.core.astr_main_agent_resources import ( BACKGROUND_TASK_RESULT_WOKE_SYSTEM_PROMPT, + EXECUTE_SHELL_TOOL, + FILE_DOWNLOAD_TOOL, + FILE_UPLOAD_TOOL, + LOCAL_EXECUTE_SHELL_TOOL, + LOCAL_PYTHON_TOOL, + PYTHON_TOOL, SEND_MESSAGE_TO_USER_TOOL, ) from astrbot.core.cron.events import CronMessageEvent @@ -45,6 +51,13 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]): """ if isinstance(tool, HandoffTool): + is_bg = tool_args.pop("background_task", False) + if is_bg: + async for r in cls._execute_handoff_background( + tool, run_context, **tool_args + ): + yield r + return async for r in cls._execute_handoff(tool, run_context, **tool_args): yield r return @@ -84,6 +97,65 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]): yield r return + @classmethod + def _get_runtime_computer_tools(cls, runtime: str) -> dict[str, FunctionTool]: + if runtime == "sandbox": + return { + EXECUTE_SHELL_TOOL.name: EXECUTE_SHELL_TOOL, + PYTHON_TOOL.name: PYTHON_TOOL, + FILE_UPLOAD_TOOL.name: FILE_UPLOAD_TOOL, + FILE_DOWNLOAD_TOOL.name: FILE_DOWNLOAD_TOOL, + } + if runtime == "local": + return { + LOCAL_EXECUTE_SHELL_TOOL.name: LOCAL_EXECUTE_SHELL_TOOL, + LOCAL_PYTHON_TOOL.name: LOCAL_PYTHON_TOOL, + } + return {} + + @classmethod + def _build_handoff_toolset( + cls, + run_context: ContextWrapper[AstrAgentContext], + tools: list[str | FunctionTool] | None, + ) -> ToolSet | None: + ctx = run_context.context.context + event = run_context.context.event + cfg = ctx.get_config(umo=event.unified_msg_origin) + provider_settings = cfg.get("provider_settings", {}) + runtime = str(provider_settings.get("computer_use_runtime", "local")) + runtime_computer_tools = cls._get_runtime_computer_tools(runtime) + + # Keep persona semantics aligned with the main agent: tools=None means + # "all tools", including runtime computer-use tools. + if tools is None: + toolset = ToolSet() + for registered_tool in llm_tools.func_list: + if isinstance(registered_tool, HandoffTool): + continue + if registered_tool.active: + toolset.add_tool(registered_tool) + for runtime_tool in runtime_computer_tools.values(): + toolset.add_tool(runtime_tool) + return None if toolset.empty() else toolset + + if not tools: + return None + + toolset = ToolSet() + for tool_name_or_obj in tools: + if isinstance(tool_name_or_obj, str): + registered_tool = llm_tools.get_func(tool_name_or_obj) + if registered_tool and registered_tool.active: + toolset.add_tool(registered_tool) + continue + runtime_tool = runtime_computer_tools.get(tool_name_or_obj) + if runtime_tool: + toolset.add_tool(runtime_tool) + elif isinstance(tool_name_or_obj, FunctionTool): + toolset.add_tool(tool_name_or_obj) + return None if toolset.empty() else toolset + @classmethod async def _execute_handoff( cls, @@ -92,20 +164,10 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]): **tool_args, ): input_ = tool_args.get("input") + image_urls = tool_args.get("image_urls") - # make toolset for the agent - tools = tool.agent.tools - if tools: - toolset = ToolSet() - for t in tools: - if isinstance(t, str): - _t = llm_tools.get_func(t) - if _t: - toolset.add_tool(_t) - elif isinstance(t, FunctionTool): - toolset.add_tool(t) - else: - toolset = None + # Build handoff toolset from registered tools plus runtime computer tools. + toolset = cls._build_handoff_toolset(run_context, tool.agent.tools) ctx = run_context.context.context event = run_context.context.event @@ -136,16 +198,98 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]): event=event, chat_provider_id=prov_id, prompt=input_, + image_urls=image_urls, system_prompt=tool.agent.instructions, tools=toolset, contexts=contexts, max_steps=30, run_hooks=tool.agent.run_hooks, + stream=ctx.get_config().get("provider_settings", {}).get("stream", False), ) yield mcp.types.CallToolResult( content=[mcp.types.TextContent(type="text", text=llm_resp.completion_text)] ) + @classmethod + async def _execute_handoff_background( + cls, + tool: HandoffTool, + run_context: ContextWrapper[AstrAgentContext], + **tool_args, + ): + """Execute a handoff as a background task. + + Immediately yields a success response with a task_id, then runs + the subagent asynchronously. When the subagent finishes, a + ``CronMessageEvent`` is created so the main LLM can inform the + user of the result – the same pattern used by + ``_execute_background`` for regular background tasks. + """ + task_id = uuid.uuid4().hex + + async def _run_handoff_in_background() -> None: + try: + await cls._do_handoff_background( + tool=tool, + run_context=run_context, + task_id=task_id, + **tool_args, + ) + except Exception as e: # noqa: BLE001 + logger.error( + f"Background handoff {task_id} ({tool.name}) failed: {e!s}", + exc_info=True, + ) + + asyncio.create_task(_run_handoff_in_background()) + + text_content = mcp.types.TextContent( + type="text", + text=( + f"Background task dedicated to subagent '{tool.agent.name}' submitted. task_id={task_id}. " + f"The subagent '{tool.agent.name}' is working on the task on hehalf you. " + f"You will be notified when it finishes." + ), + ) + yield mcp.types.CallToolResult(content=[text_content]) + + @classmethod + async def _do_handoff_background( + cls, + tool: HandoffTool, + run_context: ContextWrapper[AstrAgentContext], + task_id: str, + **tool_args, + ) -> None: + """Run the subagent handoff and, on completion, wake the main agent.""" + result_text = "" + try: + async for r in cls._execute_handoff(tool, run_context, **tool_args): + if isinstance(r, mcp.types.CallToolResult): + for content in r.content: + if isinstance(content, mcp.types.TextContent): + result_text += content.text + "\n" + except Exception as e: + result_text = ( + f"error: Background task execution failed, internal error: {e!s}" + ) + + event = run_context.context.event + + await cls._wake_main_agent_for_background_result( + run_context=run_context, + task_id=task_id, + tool_name=tool.name, + result_text=result_text, + tool_args=tool_args, + note=( + event.get_extra("background_note") + or f"Background task for subagent '{tool.agent.name}' finished." + ), + summary_name=f"Dedicated to subagent `{tool.agent.name}`", + extra_result_fields={"subagent_name": tool.agent.name}, + ) + @classmethod async def _execute_background( cls, @@ -154,12 +298,6 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]): task_id: str, **tool_args, ) -> None: - from astrbot.core.astr_main_agent import ( - MainAgentBuildConfig, - _get_session_conv, - build_main_agent, - ) - # run the tool result_text = "" try: @@ -177,21 +315,53 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]): f"error: Background task execution failed, internal error: {e!s}" ) + event = run_context.context.event + + await cls._wake_main_agent_for_background_result( + run_context=run_context, + task_id=task_id, + tool_name=tool.name, + result_text=result_text, + tool_args=tool_args, + note=( + event.get_extra("background_note") + or f"Background task {tool.name} finished." + ), + summary_name=tool.name, + ) + + @classmethod + async def _wake_main_agent_for_background_result( + cls, + run_context: ContextWrapper[AstrAgentContext], + *, + task_id: str, + tool_name: str, + result_text: str, + tool_args: dict[str, T.Any], + note: str, + summary_name: str, + extra_result_fields: dict[str, T.Any] | None = None, + ) -> None: + from astrbot.core.astr_main_agent import ( + MainAgentBuildConfig, + _get_session_conv, + build_main_agent, + ) + event = run_context.context.event ctx = run_context.context.context - note = ( - event.get_extra("background_note") - or f"Background task {tool.name} finished." - ) - extras = { - "background_task_result": { - "task_id": task_id, - "tool_name": tool.name, - "result": result_text or "", - "tool_args": tool_args, - } + task_result = { + "task_id": task_id, + "tool_name": tool_name, + "result": result_text or "", + "tool_args": tool_args, } + if extra_result_fields: + task_result.update(extra_result_fields) + extras = {"background_task_result": task_result} + session = MessageSession.from_str(event.unified_msg_origin) cron_event = CronMessageEvent( context=ctx, @@ -201,7 +371,12 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]): message_type=session.message_type, ) cron_event.role = event.role - config = MainAgentBuildConfig(tool_call_timeout=3600) + config = MainAgentBuildConfig( + tool_call_timeout=3600, + streaming_response=ctx.get_config() + .get("provider_settings", {}) + .get("stream", False), + ) req = ProviderRequest() conv = await _get_session_conv(event=cron_event, plugin_context=ctx) @@ -222,8 +397,11 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]): ) req.prompt = ( "Proceed according to your system instructions. " - "Output using same language as previous conversation." - " After completing your task, summarize and output your actions and results." + "Output using same language as previous conversation. " + "If you need to deliver the result to the user immediately, " + "you MUST use `send_message_to_user` tool to send the message directly to the user, " + "otherwise the user will not see the result. " + "After completing your task, summarize and output your actions and results. " ) if not req.func_tool: req.func_tool = ToolSet() @@ -233,7 +411,7 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]): event=cron_event, plugin_context=ctx, config=config, req=req ) if not result: - logger.error("Failed to build main agent for background task job.") + logger.error(f"Failed to build main agent for background task {tool_name}.") return runner = result.agent_runner @@ -243,7 +421,7 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]): llm_resp = runner.get_final_llm_resp() task_meta = extras.get("background_task_result", {}) summary_note = ( - f"[BackgroundTask] {task_meta.get('tool_name', tool.name)} " + f"[BackgroundTask] {summary_name} " f"(task_id={task_meta.get('task_id', task_id)}) finished. " f"Result: {task_meta.get('result') or result_text or 'no content'}" ) diff --git a/astrbot/core/astr_main_agent.py b/astrbot/core/astr_main_agent.py index 12c4fde1d..7883dca8f 100644 --- a/astrbot/core/astr_main_agent.py +++ b/astrbot/core/astr_main_agent.py @@ -870,6 +870,41 @@ def _get_compress_provider( return provider +def _get_fallback_chat_providers( + provider: Provider, plugin_context: Context, provider_settings: dict +) -> list[Provider]: + fallback_ids = provider_settings.get("fallback_chat_models", []) + if not isinstance(fallback_ids, list): + logger.warning( + "fallback_chat_models setting is not a list, skip fallback providers." + ) + return [] + + provider_id = str(provider.provider_config.get("id", "")) + seen_provider_ids: set[str] = {provider_id} if provider_id else set() + fallbacks: list[Provider] = [] + + for fallback_id in fallback_ids: + if not isinstance(fallback_id, str) or not fallback_id: + continue + if fallback_id in seen_provider_ids: + continue + fallback_provider = plugin_context.get_provider_by_id(fallback_id) + if fallback_provider is None: + logger.warning("Fallback chat provider `%s` not found, skip.", fallback_id) + continue + if not isinstance(fallback_provider, Provider): + logger.warning( + "Fallback chat provider `%s` is invalid type: %s, skip.", + fallback_id, + type(fallback_provider), + ) + continue + fallbacks.append(fallback_provider) + seen_provider_ids.add(fallback_id) + return fallbacks + + async def build_main_agent( *, event: AstrMessageEvent, @@ -1093,6 +1128,9 @@ async def build_main_agent( truncate_turns=config.dequeue_context_length, enforce_max_turns=config.max_context_length, tool_schema_mode=config.tool_schema_mode, + fallback_providers=_get_fallback_chat_providers( + provider, plugin_context, config.provider_settings + ), ) if apply_reset: diff --git a/astrbot/core/computer/tools/fs.py b/astrbot/core/computer/tools/fs.py index 126da4258..31b7f3f51 100644 --- a/astrbot/core/computer/tools/fs.py +++ b/astrbot/core/computer/tools/fs.py @@ -11,6 +11,7 @@ from astrbot.core.message.components import File from astrbot.core.utils.astrbot_path import get_astrbot_temp_path from ..computer_client import get_booter +from .permissions import check_admin_permission # @dataclass # class CreateFileTool(FunctionTool): @@ -102,6 +103,8 @@ class FileUploadTool(FunctionTool): context: ContextWrapper[AstrAgentContext], local_path: str, ) -> str | None: + if permission_error := check_admin_permission(context, "File upload/download"): + return permission_error sb = await get_booter( context.context.context, context.context.event.unified_msg_origin, @@ -161,6 +164,8 @@ class FileDownloadTool(FunctionTool): remote_path: str, also_send_to_user: bool = True, ) -> ToolExecResult: + if permission_error := check_admin_permission(context, "File upload/download"): + return permission_error sb = await get_booter( context.context.context, context.context.event.unified_msg_origin, diff --git a/astrbot/core/computer/tools/permissions.py b/astrbot/core/computer/tools/permissions.py new file mode 100644 index 000000000..489f485f9 --- /dev/null +++ b/astrbot/core/computer/tools/permissions.py @@ -0,0 +1,19 @@ +from astrbot.core.agent.run_context import ContextWrapper +from astrbot.core.astr_agent_context import AstrAgentContext + + +def check_admin_permission( + context: ContextWrapper[AstrAgentContext], operation_name: str +) -> str | None: + cfg = context.context.context.get_config( + umo=context.context.event.unified_msg_origin + ) + provider_settings = cfg.get("provider_settings", {}) + require_admin = provider_settings.get("computer_use_require_admin", True) + if require_admin and context.context.event.role != "admin": + return ( + f"error: Permission denied. {operation_name} is only allowed for admin users. " + "Tell user to set admins in `AstrBot WebUI -> Config -> General Config` by adding their user ID to the admins list if they need this feature. " + f"User's ID is: {context.context.event.get_sender_id()}. User's ID can be found by using /sid command." + ) + return None diff --git a/astrbot/core/computer/tools/python.py b/astrbot/core/computer/tools/python.py index 333f442f9..cc835bc75 100644 --- a/astrbot/core/computer/tools/python.py +++ b/astrbot/core/computer/tools/python.py @@ -5,8 +5,10 @@ import mcp from astrbot.api import FunctionTool from astrbot.core.agent.run_context import ContextWrapper from astrbot.core.agent.tool import ToolExecResult -from astrbot.core.astr_agent_context import AstrAgentContext +from astrbot.core.astr_agent_context import AstrAgentContext, AstrMessageEvent from astrbot.core.computer.computer_client import get_booter, get_local_booter +from astrbot.core.computer.tools.permissions import check_admin_permission +from astrbot.core.message.message_event_result import MessageChain param_schema = { "type": "object", @@ -25,7 +27,7 @@ param_schema = { } -def handle_result(result: dict) -> ToolExecResult: +async def handle_result(result: dict, event: AstrMessageEvent) -> ToolExecResult: data = result.get("data", {}) output = data.get("output", {}) error = data.get("error", "") @@ -44,6 +46,9 @@ def handle_result(result: dict) -> ToolExecResult: type="image", data=img["image/png"], mimeType="image/png" ) ) + + if event.get_platform_name() == "webchat": + await event.send(message=MessageChain().base64_image(img["image/png"])) if text: resp.content.append(mcp.types.TextContent(type="text", text=text)) @@ -62,13 +67,15 @@ class PythonTool(FunctionTool): async def call( self, context: ContextWrapper[AstrAgentContext], code: str, silent: bool = False ) -> ToolExecResult: + if permission_error := check_admin_permission(context, "Python execution"): + return permission_error sb = await get_booter( context.context.context, context.context.event.unified_msg_origin, ) try: result = await sb.python.exec(code, silent=silent) - return handle_result(result) + return await handle_result(result, context.context.event) except Exception as e: return f"Error executing code: {str(e)}" @@ -83,12 +90,11 @@ class LocalPythonTool(FunctionTool): async def call( self, context: ContextWrapper[AstrAgentContext], code: str, silent: bool = False ) -> ToolExecResult: - if context.context.event.role != "admin": - return "error: Permission denied. Local Python execution is only allowed for admin users. Tell user to set admins in AstrBot WebUI." - + if permission_error := check_admin_permission(context, "Python execution"): + return permission_error sb = get_local_booter() try: result = await sb.python.exec(code, silent=silent) - return handle_result(result) + return await handle_result(result, context.context.event) except Exception as e: return f"Error executing code: {str(e)}" diff --git a/astrbot/core/computer/tools/shell.py b/astrbot/core/computer/tools/shell.py index eeeb3f9d4..9e729573a 100644 --- a/astrbot/core/computer/tools/shell.py +++ b/astrbot/core/computer/tools/shell.py @@ -7,6 +7,7 @@ from astrbot.core.agent.tool import ToolExecResult from astrbot.core.astr_agent_context import AstrAgentContext from ..computer_client import get_booter, get_local_booter +from .permissions import check_admin_permission @dataclass @@ -46,8 +47,8 @@ class ExecuteShellTool(FunctionTool): background: bool = False, env: dict = {}, ) -> ToolExecResult: - if context.context.event.role != "admin": - return "error: Permission denied. Shell execution is only allowed for admin users. Tell user to Set admins in AstrBot WebUI." + if permission_error := check_admin_permission(context, "Shell execution"): + return permission_error if self.is_local: sb = get_local_booter() diff --git a/astrbot/core/config/astrbot_config.py b/astrbot/core/config/astrbot_config.py index bda02324e..6a415e56c 100644 --- a/astrbot/core/config/astrbot_config.py +++ b/astrbot/core/config/astrbot_config.py @@ -52,6 +52,9 @@ class AstrBotConfig(dict): with open(config_path, encoding="utf-8-sig") as f: conf_str = f.read() + # Handle UTF-8 BOM if present + if conf_str.startswith("\ufeff"): + conf_str = conf_str[1:] conf = json.loads(conf_str) # 检查配置完整性,并插入 diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index 235915c59..c03027f3b 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -5,7 +5,7 @@ from typing import Any, TypedDict from astrbot.core.utils.astrbot_path import get_astrbot_data_path -VERSION = "4.16.0" +VERSION = "4.18.2" DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db") WEBHOOK_SUPPORTED_PLATFORMS = [ @@ -68,6 +68,7 @@ DEFAULT_CONFIG = { "provider_settings": { "enable": True, "default_provider_id": "", + "fallback_chat_models": [], "default_image_caption_provider_id": "", "image_caption_prompt": "Please describe the image using Chinese.", "provider_pool": ["*"], # "*" 表示使用所有可用的提供者 @@ -99,6 +100,7 @@ DEFAULT_CONFIG = { "dequeue_context_length": 1, "streaming_response": False, "show_tool_use_status": False, + "show_tool_call_result": False, "sanitize_context_by_modalities": False, "max_quoted_fallback_images": 20, "quoted_message_parser": { @@ -127,6 +129,7 @@ DEFAULT_CONFIG = { "add_cron_tools": True, }, "computer_use_runtime": "local", + "computer_use_require_admin": True, "sandbox": { "booter": "shipyard", "shipyard_endpoint": "", @@ -195,6 +198,12 @@ DEFAULT_CONFIG = { "host": "0.0.0.0", "port": 6185, "disable_access_log": True, + "ssl": { + "enable": False, + "cert_file": "", + "key_file": "", + "ca_certs": "", + }, }, "platform": [], "platform_specific": { @@ -971,7 +980,7 @@ CONFIG_METADATA_2 = { "api_base": "https://api.anthropic.com/v1", "timeout": 120, "proxy": "", - "anth_thinking_config": {"budget": 0}, + "anth_thinking_config": {"type": "", "budget": 0, "effort": ""}, }, "Moonshot": { "id": "moonshot", @@ -1022,6 +1031,42 @@ CONFIG_METADATA_2 = { "proxy": "", "custom_headers": {}, }, + "AIHubMix": { + "id": "aihubmix", + "provider": "aihubmix", + "type": "aihubmix_chat_completion", + "provider_type": "chat_completion", + "enable": True, + "key": [], + "timeout": 120, + "api_base": "https://aihubmix.com/v1", + "proxy": "", + "custom_headers": {}, + }, + "OpenRouter": { + "id": "openrouter", + "provider": "openrouter", + "type": "openrouter_chat_completion", + "provider_type": "chat_completion", + "enable": True, + "key": [], + "timeout": 120, + "api_base": "https://openrouter.ai/v1", + "proxy": "", + "custom_headers": {}, + }, + "NVIDIA": { + "id": "nvidia", + "provider": "nvidia", + "type": "openai_chat_completion", + "provider_type": "chat_completion", + "enable": True, + "key": [], + "api_base": "https://integrate.api.nvidia.com/v1", + "timeout": 120, + "proxy": "", + "custom_headers": {}, + }, "Azure OpenAI": { "id": "azure_openai", "provider": "azure", @@ -1920,13 +1965,25 @@ CONFIG_METADATA_2 = { }, }, "anth_thinking_config": { - "description": "Thinking Config", + "description": "思考配置", "type": "object", "items": { + "type": { + "description": "思考类型", + "type": "string", + "options": ["", "adaptive"], + "hint": "Opus 4.6+ / Sonnet 4.6+ 推荐设为 'adaptive'。留空则使用手动 budget 模式。参见: https://platform.claude.com/docs/en/build-with-claude/adaptive-thinking", + }, "budget": { - "description": "Thinking Budget", + "description": "思考预算", "type": "int", - "hint": "Anthropic thinking.budget_tokens param. Must >= 1024. See: https://platform.claude.com/docs/en/build-with-claude/extended-thinking", + "hint": "手动 budget_tokens,需 >= 1024。仅在 type 为空时生效。Opus 4.6 / Sonnet 4.6 上已弃用。参见: https://platform.claude.com/docs/en/build-with-claude/extended-thinking", + }, + "effort": { + "description": "思考深度", + "type": "string", + "options": ["", "low", "medium", "high", "max"], + "hint": "type 为 'adaptive' 时控制思考深度。默认 'high'。'max' 仅限 Opus 4.6。参见: https://platform.claude.com/docs/en/build-with-claude/effort", }, }, }, @@ -2207,6 +2264,10 @@ CONFIG_METADATA_2 = { "default_provider_id": { "type": "string", }, + "fallback_chat_models": { + "type": "list", + "items": {"type": "string"}, + }, "wake_prefix": { "type": "string", }, @@ -2246,6 +2307,9 @@ CONFIG_METADATA_2 = { "show_tool_use_status": { "type": "bool", }, + "show_tool_call_result": { + "type": "bool", + }, "unsupported_streaming_strategy": { "type": "string", }, @@ -2401,6 +2465,19 @@ CONFIG_METADATA_2 = { "type": "string", "options": ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], }, + "dashboard.ssl.enable": {"type": "bool"}, + "dashboard.ssl.cert_file": { + "type": "string", + "condition": {"dashboard.ssl.enable": True}, + }, + "dashboard.ssl.key_file": { + "type": "string", + "condition": {"dashboard.ssl.enable": True}, + }, + "dashboard.ssl.ca_certs": { + "type": "string", + "condition": {"dashboard.ssl.enable": True}, + }, "log_file_enable": {"type": "bool"}, "log_file_path": {"type": "string", "condition": {"log_file_enable": True}}, "log_file_max_mb": {"type": "int", "condition": {"log_file_enable": True}}, @@ -2504,15 +2581,22 @@ CONFIG_METADATA_3 = { }, "ai": { "description": "模型", - "hint": "当使用非内置 Agent 执行器时,默认聊天模型和默认图片转述模型可能会无效,但某些插件会依赖此配置项来调用 AI 能力。", + "hint": "当使用非内置 Agent 执行器时,默认对话模型和默认图片转述模型可能会无效,但某些插件会依赖此配置项来调用 AI 能力。", "type": "object", "items": { "provider_settings.default_provider_id": { - "description": "默认聊天模型", + "description": "默认对话模型", "type": "string", "_special": "select_provider", "hint": "留空时使用第一个模型", }, + "provider_settings.fallback_chat_models": { + "description": "回退对话模型列表", + "type": "list", + "items": {"type": "string"}, + "_special": "select_providers", + "hint": "主聊天模型请求失败时,按顺序切换到这些模型。", + }, "provider_settings.default_image_caption_provider_id": { "description": "默认图片转述模型", "type": "string", @@ -2682,6 +2766,11 @@ CONFIG_METADATA_3 = { "labels": ["无", "本地", "沙箱"], "hint": "选择 Computer Use 运行环境。", }, + "provider_settings.computer_use_require_admin": { + "description": "需要 AstrBot 管理员权限", + "type": "bool", + "hint": "开启后,需要 AstrBot 管理员权限才能调用使用电脑能力。在平台配置->管理员中可添加管理员。使用 /sid 指令查看管理员 ID。", + }, "provider_settings.sandbox.booter": { "description": "沙箱环境驱动器", "type": "string", @@ -2909,6 +2998,15 @@ CONFIG_METADATA_3 = { "provider_settings.agent_runner_type": "local", }, }, + "provider_settings.show_tool_call_result": { + "description": "输出函数调用返回结果", + "type": "bool", + "hint": "仅在输出函数调用状态启用时生效,展示结果前 70 个字符。", + "condition": { + "provider_settings.agent_runner_type": "local", + "provider_settings.show_tool_use_status": True, + }, + }, "provider_settings.sanitize_context_by_modalities": { "description": "按模型能力清理历史上下文", "type": "bool", @@ -3408,6 +3506,29 @@ CONFIG_METADATA_3_SYSTEM = { "hint": "控制台输出日志的级别。", "options": ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], }, + "dashboard.ssl.enable": { + "description": "启用 WebUI HTTPS", + "type": "bool", + "hint": "启用后,WebUI 将直接使用 HTTPS 提供服务。", + }, + "dashboard.ssl.cert_file": { + "description": "SSL 证书文件路径", + "type": "string", + "hint": "证书文件路径(PEM)。支持绝对路径和相对路径(相对于当前工作目录)。", + "condition": {"dashboard.ssl.enable": True}, + }, + "dashboard.ssl.key_file": { + "description": "SSL 私钥文件路径", + "type": "string", + "hint": "私钥文件路径(PEM)。支持绝对路径和相对路径(相对于当前工作目录)。", + "condition": {"dashboard.ssl.enable": True}, + }, + "dashboard.ssl.ca_certs": { + "description": "SSL CA 证书文件路径", + "type": "string", + "hint": "可选。用于指定 CA 证书文件路径。", + "condition": {"dashboard.ssl.enable": True}, + }, "log_file_enable": { "description": "启用文件日志", "type": "bool", diff --git a/astrbot/core/db/__init__.py b/astrbot/core/db/__init__.py index 72837e178..11f408e70 100644 --- a/astrbot/core/db/__init__.py +++ b/astrbot/core/db/__init__.py @@ -8,6 +8,7 @@ from deprecated import deprecated from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine from astrbot.core.db.po import ( + ApiKey, Attachment, ChatUIProject, CommandConfig, @@ -248,6 +249,55 @@ class BaseDatabase(abc.ABC): """ ... + @abc.abstractmethod + async def create_api_key( + self, + name: str, + key_hash: str, + key_prefix: str, + scopes: list[str] | None, + created_by: str, + expires_at: datetime.datetime | None = None, + ) -> ApiKey: + """Create a new API key record.""" + ... + + @abc.abstractmethod + async def list_api_keys(self) -> list[ApiKey]: + """List all API keys.""" + ... + + @abc.abstractmethod + async def get_api_key_by_id(self, key_id: str) -> ApiKey | None: + """Get an API key by key_id.""" + ... + + @abc.abstractmethod + async def get_active_api_key_by_hash(self, key_hash: str) -> ApiKey | None: + """Get an active API key by hash (not revoked, not expired).""" + ... + + @abc.abstractmethod + async def touch_api_key(self, key_id: str) -> None: + """Update last_used_at of an API key.""" + ... + + @abc.abstractmethod + async def revoke_api_key(self, key_id: str) -> bool: + """Revoke an API key. + + Returns True when the key exists and is updated. + """ + ... + + @abc.abstractmethod + async def delete_api_key(self, key_id: str) -> bool: + """Delete an API key. + + Returns True when the key exists and is deleted. + """ + ... + @abc.abstractmethod async def insert_persona( self, @@ -608,6 +658,22 @@ class BaseDatabase(abc.ABC): """ ... + @abc.abstractmethod + async def get_platform_sessions_by_creator_paginated( + self, + creator: str, + platform_id: str | None = None, + page: int = 1, + page_size: int = 20, + exclude_project_sessions: bool = False, + ) -> tuple[list[dict], int]: + """Get paginated platform sessions and total count for a creator. + + Returns: + tuple[list[dict], int]: (sessions_with_project_info, total_count) + """ + ... + @abc.abstractmethod async def update_platform_session( self, diff --git a/astrbot/core/db/po.py b/astrbot/core/db/po.py index 81649c0d7..bf0a94547 100644 --- a/astrbot/core/db/po.py +++ b/astrbot/core/db/po.py @@ -288,6 +288,43 @@ class Attachment(TimestampMixin, SQLModel, table=True): ) +class ApiKey(TimestampMixin, SQLModel, table=True): + """API keys used by external developers to access Open APIs.""" + + __tablename__: str = "api_keys" + + inner_id: int | None = Field( + primary_key=True, + sa_column_kwargs={"autoincrement": True}, + default=None, + ) + key_id: str = Field( + max_length=36, + nullable=False, + unique=True, + default_factory=lambda: str(uuid.uuid4()), + ) + name: str = Field(max_length=255, nullable=False) + key_hash: str = Field(max_length=128, nullable=False, unique=True) + key_prefix: str = Field(max_length=24, nullable=False) + scopes: list | None = Field(default=None, sa_type=JSON) + created_by: str = Field(max_length=255, nullable=False) + last_used_at: datetime | None = Field(default=None) + expires_at: datetime | None = Field(default=None) + revoked_at: datetime | None = Field(default=None) + + __table_args__ = ( + UniqueConstraint( + "key_id", + name="uix_api_key_id", + ), + UniqueConstraint( + "key_hash", + name="uix_api_key_hash", + ), + ) + + class ChatUIProject(TimestampMixin, SQLModel, table=True): """This class represents projects for organizing ChatUI conversations. diff --git a/astrbot/core/db/sqlite.py b/astrbot/core/db/sqlite.py index 922a7299f..bb1a568e3 100644 --- a/astrbot/core/db/sqlite.py +++ b/astrbot/core/db/sqlite.py @@ -10,6 +10,7 @@ from sqlmodel import col, delete, desc, func, or_, select, text, update from astrbot.core.db import BaseDatabase from astrbot.core.db.po import ( + ApiKey, Attachment, ChatUIProject, CommandConfig, @@ -573,6 +574,100 @@ class SQLiteDatabase(BaseDatabase): result = T.cast(CursorResult, await session.execute(query)) return result.rowcount + async def create_api_key( + self, + name: str, + key_hash: str, + key_prefix: str, + scopes: list[str] | None, + created_by: str, + expires_at: datetime | None = None, + ) -> ApiKey: + """Create a new API key record.""" + async with self.get_db() as session: + session: AsyncSession + async with session.begin(): + api_key = ApiKey( + name=name, + key_hash=key_hash, + key_prefix=key_prefix, + scopes=scopes, + created_by=created_by, + expires_at=expires_at, + ) + session.add(api_key) + await session.flush() + await session.refresh(api_key) + return api_key + + async def list_api_keys(self) -> list[ApiKey]: + """List all API keys.""" + async with self.get_db() as session: + session: AsyncSession + result = await session.execute( + select(ApiKey).order_by(desc(ApiKey.created_at)) + ) + return list(result.scalars().all()) + + async def get_api_key_by_id(self, key_id: str) -> ApiKey | None: + """Get an API key by key_id.""" + async with self.get_db() as session: + session: AsyncSession + result = await session.execute( + select(ApiKey).where(ApiKey.key_id == key_id) + ) + return result.scalar_one_or_none() + + async def get_active_api_key_by_hash(self, key_hash: str) -> ApiKey | None: + """Get an active API key by hash (not revoked, not expired).""" + async with self.get_db() as session: + session: AsyncSession + now = datetime.now(timezone.utc) + query = select(ApiKey).where( + ApiKey.key_hash == key_hash, + col(ApiKey.revoked_at).is_(None), + or_(col(ApiKey.expires_at).is_(None), ApiKey.expires_at > now), + ) + result = await session.execute(query) + return result.scalar_one_or_none() + + async def touch_api_key(self, key_id: str) -> None: + """Update last_used_at of an API key.""" + async with self.get_db() as session: + session: AsyncSession + async with session.begin(): + await session.execute( + update(ApiKey) + .where(ApiKey.key_id == key_id) + .values(last_used_at=datetime.now(timezone.utc)), + ) + + async def revoke_api_key(self, key_id: str) -> bool: + """Revoke an API key.""" + async with self.get_db() as session: + session: AsyncSession + async with session.begin(): + query = ( + update(ApiKey) + .where(ApiKey.key_id == key_id) + .values(revoked_at=datetime.now(timezone.utc)) + ) + result = T.cast(CursorResult, await session.execute(query)) + return result.rowcount > 0 + + async def delete_api_key(self, key_id: str) -> bool: + """Delete an API key.""" + async with self.get_db() as session: + session: AsyncSession + async with session.begin(): + result = T.cast( + CursorResult, + await session.execute( + delete(ApiKey).where(ApiKey.key_id == key_id) + ), + ) + return result.rowcount > 0 + async def insert_persona( self, persona_id, @@ -1317,58 +1412,102 @@ class SQLiteDatabase(BaseDatabase): Returns a list of dicts containing session info and project info (if session belongs to a project). """ + ( + sessions_with_projects, + _, + ) = await self.get_platform_sessions_by_creator_paginated( + creator=creator, + platform_id=platform_id, + page=page, + page_size=page_size, + exclude_project_sessions=False, + ) + return sessions_with_projects + + @staticmethod + def _build_platform_sessions_query( + creator: str, + platform_id: str | None = None, + exclude_project_sessions: bool = False, + ): + query = ( + select( + PlatformSession, + col(ChatUIProject.project_id), + col(ChatUIProject.title).label("project_title"), + col(ChatUIProject.emoji).label("project_emoji"), + ) + .outerjoin( + SessionProjectRelation, + col(PlatformSession.session_id) + == col(SessionProjectRelation.session_id), + ) + .outerjoin( + ChatUIProject, + col(SessionProjectRelation.project_id) == col(ChatUIProject.project_id), + ) + .where(col(PlatformSession.creator) == creator) + ) + + if platform_id: + query = query.where(PlatformSession.platform_id == platform_id) + if exclude_project_sessions: + query = query.where(col(ChatUIProject.project_id).is_(None)) + + return query + + @staticmethod + def _rows_to_session_dicts(rows: list[tuple]) -> list[dict]: + sessions_with_projects = [] + for row in rows: + platform_session = row[0] + project_id = row[1] + project_title = row[2] + project_emoji = row[3] + + session_dict = { + "session": platform_session, + "project_id": project_id, + "project_title": project_title, + "project_emoji": project_emoji, + } + sessions_with_projects.append(session_dict) + + return sessions_with_projects + + async def get_platform_sessions_by_creator_paginated( + self, + creator: str, + platform_id: str | None = None, + page: int = 1, + page_size: int = 20, + exclude_project_sessions: bool = False, + ) -> tuple[list[dict], int]: + """Get paginated Platform sessions for a creator with total count.""" async with self.get_db() as session: session: AsyncSession offset = (page - 1) * page_size - # LEFT JOIN with SessionProjectRelation and ChatUIProject to get project info - query = ( - select( - PlatformSession, - col(ChatUIProject.project_id), - col(ChatUIProject.title).label("project_title"), - col(ChatUIProject.emoji).label("project_emoji"), - ) - .outerjoin( - SessionProjectRelation, - col(PlatformSession.session_id) - == col(SessionProjectRelation.session_id), - ) - .outerjoin( - ChatUIProject, - col(SessionProjectRelation.project_id) - == col(ChatUIProject.project_id), - ) - .where(col(PlatformSession.creator) == creator) + base_query = self._build_platform_sessions_query( + creator=creator, + platform_id=platform_id, + exclude_project_sessions=exclude_project_sessions, ) - if platform_id: - query = query.where(PlatformSession.platform_id == platform_id) + total_result = await session.execute( + select(func.count()).select_from(base_query.subquery()) + ) + total = int(total_result.scalar_one() or 0) - query = ( - query.order_by(desc(PlatformSession.updated_at)) + result_query = ( + base_query.order_by(desc(PlatformSession.updated_at)) .offset(offset) .limit(page_size) ) - result = await session.execute(query) + result = await session.execute(result_query) - # Convert to list of dicts with session and project info - sessions_with_projects = [] - for row in result.all(): - platform_session = row[0] - project_id = row[1] - project_title = row[2] - project_emoji = row[3] - - session_dict = { - "session": platform_session, - "project_id": project_id, - "project_title": project_title, - "project_emoji": project_emoji, - } - sessions_with_projects.append(session_dict) - - return sessions_with_projects + sessions_with_projects = self._rows_to_session_dicts(result.all()) + return sessions_with_projects, total async def update_platform_session( self, diff --git a/astrbot/core/knowledge_base/kb_db_sqlite.py b/astrbot/core/knowledge_base/kb_db_sqlite.py index ba25ed7e5..39fc72ac8 100644 --- a/astrbot/core/knowledge_base/kb_db_sqlite.py +++ b/astrbot/core/knowledge_base/kb_db_sqlite.py @@ -13,16 +13,19 @@ from astrbot.core.knowledge_base.models import ( KBMedia, KnowledgeBase, ) +from astrbot.core.utils.astrbot_path import get_astrbot_knowledge_base_path class KBSQLiteDatabase: - def __init__(self, db_path: str = "data/knowledge_base/kb.db") -> None: + def __init__(self, db_path: str | None = None) -> None: """初始化知识库数据库 Args: - db_path: 数据库文件路径, 默认为 data/knowledge_base/kb.db + db_path: 数据库文件路径, 默认位于 AstrBot 数据目录下的 knowledge_base/kb.db """ + if db_path is None: + db_path = str(Path(get_astrbot_knowledge_base_path()) / "kb.db") self.db_path = db_path self.DATABASE_URL = f"sqlite+aiosqlite:///{db_path}" self.inited = False diff --git a/astrbot/core/knowledge_base/kb_mgr.py b/astrbot/core/knowledge_base/kb_mgr.py index ae5a1b9e7..f26409e56 100644 --- a/astrbot/core/knowledge_base/kb_mgr.py +++ b/astrbot/core/knowledge_base/kb_mgr.py @@ -3,6 +3,7 @@ from pathlib import Path from astrbot.core import logger from astrbot.core.provider.manager import ProviderManager +from astrbot.core.utils.astrbot_path import get_astrbot_knowledge_base_path # from .chunking.fixed_size import FixedSizeChunker from .chunking.recursive import RecursiveCharacterChunker @@ -13,7 +14,7 @@ from .retrieval.manager import RetrievalManager, RetrievalResult from .retrieval.rank_fusion import RankFusion from .retrieval.sparse_retriever import SparseRetriever -FILES_PATH = "data/knowledge_base" +FILES_PATH = get_astrbot_knowledge_base_path() DB_PATH = Path(FILES_PATH) / "kb.db" """Knowledge Base storage root directory""" CHUNKER = RecursiveCharacterChunker() @@ -27,7 +28,7 @@ class KnowledgeBaseManager: self, provider_manager: ProviderManager, ) -> None: - Path(DB_PATH).parent.mkdir(parents=True, exist_ok=True) + DB_PATH.parent.mkdir(parents=True, exist_ok=True) self.provider_manager = provider_manager self._session_deleted_callback_registered = False diff --git a/astrbot/core/log.py b/astrbot/core/log.py index 264c43197..66a2f3154 100644 --- a/astrbot/core/log.py +++ b/astrbot/core/log.py @@ -1,24 +1,4 @@ -"""日志系统, 用于支持核心组件和插件的日志记录, 提供了日志订阅功能 - -const: - CACHED_SIZE: 日志缓存大小, 用于限制缓存的日志数量 - log_color_config: 日志颜色配置, 定义了不同日志级别的颜色 - -class: - LogBroker: 日志代理类, 用于缓存和分发日志消息 - LogQueueHandler: 日志处理器, 用于将日志消息发送到 LogBroker - LogManager: 日志管理器, 用于创建和配置日志记录器 - -function: - is_plugin_path: 检查文件路径是否来自插件目录 - get_short_level_name: 将日志级别名称转换为四个字母的缩写 - -工作流程: -1. 通过 LogManager.GetLogger() 获取日志器, 配置了控制台输出和多个格式化过滤器 -2. 通过 set_queue_handler() 设置日志处理器, 将日志消息发送到 LogBroker -3. logBroker 维护一个订阅者列表, 负责将日志分发给所有订阅者 -4. 订阅者可以使用 register() 方法注册到 LogBroker, 订阅日志流 -""" +"""日志系统,统一将标准 logging 输出转发到 loguru。""" import asyncio import logging @@ -27,54 +7,59 @@ import sys import time from asyncio import Queue from collections import deque -from logging.handlers import RotatingFileHandler +from typing import TYPE_CHECKING -import colorlog +from loguru import logger as _raw_loguru_logger from astrbot.core.config.default import VERSION from astrbot.core.utils.astrbot_path import get_astrbot_data_path -# 日志缓存大小 CACHED_SIZE = 500 -# 日志颜色配置 -log_color_config = { - "DEBUG": "green", - "INFO": "bold_cyan", - "WARNING": "bold_yellow", - "ERROR": "red", - "CRITICAL": "bold_red", - "RESET": "reset", - "asctime": "green", -} + +if TYPE_CHECKING: + from loguru import Record -def is_plugin_path(pathname): - """检查文件路径是否来自插件目录 +class _RecordEnricherFilter(logging.Filter): + """为 logging.LogRecord 注入 AstrBot 日志字段。""" - Args: - pathname (str): 文件路径 + def filter(self, record: logging.LogRecord) -> bool: + record.plugin_tag = "[Plug]" if _is_plugin_path(record.pathname) else "[Core]" + record.short_levelname = _get_short_level_name(record.levelname) + record.astrbot_version_tag = ( + f" [v{VERSION}]" if record.levelno >= logging.WARNING else "" + ) + record.source_file = _build_source_file(record.pathname) + record.source_line = record.lineno + record.is_trace = record.name == "astrbot.trace" + return True - Returns: - bool: 如果路径来自插件目录,则返回 True,否则返回 False - """ +class _QueueAnsiColorFilter(logging.Filter): + """Attach ANSI color prefix for WebUI console rendering.""" + + _LEVEL_COLOR = { + "DEBUG": "\u001b[1;34m", + "INFO": "\u001b[1;36m", + "WARNING": "\u001b[1;33m", + "ERROR": "\u001b[31m", + "CRITICAL": "\u001b[1;31m", + } + + def filter(self, record: logging.LogRecord) -> bool: + record.ansi_prefix = self._LEVEL_COLOR.get(record.levelname, "\u001b[0m") + record.ansi_reset = "\u001b[0m" + return True + + +def _is_plugin_path(pathname: str | None) -> bool: if not pathname: return False - norm_path = os.path.normpath(pathname) return ("data/plugins" in norm_path) or ("astrbot/builtin_stars/" in norm_path) -def get_short_level_name(level_name): - """将日志级别名称转换为四个字母的缩写 - - Args: - level_name (str): 日志级别名称, 如 "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL" - - Returns: - str: 四个字母的日志级别缩写 - - """ +def _get_short_level_name(level_name: str) -> str: level_map = { "DEBUG": "DBUG", "INFO": "INFO", @@ -85,44 +70,75 @@ def get_short_level_name(level_name): return level_map.get(level_name, level_name[:4].upper()) -class LogBroker: - """日志代理类, 用于缓存和分发日志消息 +def _build_source_file(pathname: str | None) -> str: + if not pathname: + return "unknown" + dirname = os.path.dirname(pathname) + return ( + os.path.basename(dirname) + "." + os.path.basename(pathname).replace(".py", "") + ) - 发布-订阅模式 - """ + +def _patch_record(record: "Record") -> None: + extra = record["extra"] + extra.setdefault("plugin_tag", "[Core]") + extra.setdefault("short_levelname", _get_short_level_name(record["level"].name)) + level_no = record["level"].no + extra.setdefault("astrbot_version_tag", f" [v{VERSION}]" if level_no >= 30 else "") + extra.setdefault("source_file", _build_source_file(record["file"].path)) + extra.setdefault("source_line", record["line"]) + extra.setdefault("is_trace", False) + + +_loguru = _raw_loguru_logger.patch(_patch_record) + + +class _LoguruInterceptHandler(logging.Handler): + """将 logging 记录转发到 loguru。""" + + def emit(self, record: logging.LogRecord) -> None: + try: + level: str | int = _loguru.level(record.levelname).name + except ValueError: + level = record.levelno + + payload = { + "plugin_tag": getattr(record, "plugin_tag", "[Core]"), + "short_levelname": getattr( + record, + "short_levelname", + _get_short_level_name(record.levelname), + ), + "astrbot_version_tag": getattr(record, "astrbot_version_tag", ""), + "source_file": getattr( + record, "source_file", _build_source_file(record.pathname) + ), + "source_line": getattr(record, "source_line", record.lineno), + "is_trace": getattr(record, "is_trace", record.name == "astrbot.trace"), + } + + _loguru.bind(**payload).opt(exception=record.exc_info).log( + level, + record.getMessage(), + ) + + +class LogBroker: + """日志代理类,用于缓存和分发日志消息。""" def __init__(self) -> None: - self.log_cache = deque(maxlen=CACHED_SIZE) # 环形缓冲区, 保存最近的日志 - self.subscribers: list[Queue] = [] # 订阅者列表 + self.log_cache = deque(maxlen=CACHED_SIZE) + self.subscribers: list[Queue] = [] def register(self) -> Queue: - """注册新的订阅者, 并给每个订阅者返回一个带有日志缓存的队列 - - Returns: - Queue: 订阅者的队列, 可用于接收日志消息 - - """ q = Queue(maxsize=CACHED_SIZE + 10) self.subscribers.append(q) return q def unregister(self, q: Queue) -> None: - """取消订阅 - - Args: - q (Queue): 需要取消订阅的队列 - - """ self.subscribers.remove(q) def publish(self, log_entry: dict) -> None: - """发布新日志到所有订阅者, 使用非阻塞方式投递, 避免一个订阅者阻塞整个系统 - - Args: - log_entry (dict): 日志消息, 包含日志级别和日志内容. - example: {"level": "INFO", "data": "This is a log message.", "time": "2023-10-01 12:00:00"} - - """ self.log_cache.append(log_entry) for q in self.subscribers: try: @@ -132,23 +148,13 @@ class LogBroker: class LogQueueHandler(logging.Handler): - """日志处理器, 用于将日志消息发送到 LogBroker - - 继承自 logging.Handler - """ + """日志处理器,用于将日志消息发送到 LogBroker。""" def __init__(self, log_broker: LogBroker) -> None: super().__init__() self.log_broker = log_broker - def emit(self, record) -> None: - """日志处理的入口方法, 接受一个日志记录, 转换为字符串后由 LogBroker 发布 - 这个方法会在每次日志记录时被调用 - - Args: - record (logging.LogRecord): 日志记录对象, 包含日志信息 - - """ + def emit(self, record: logging.LogRecord) -> None: log_entry = self.format(record) self.log_broker.publish( { @@ -160,117 +166,16 @@ class LogQueueHandler(logging.Handler): class LogManager: - """日志管理器, 用于创建和配置日志记录器 + _LOGGER_HANDLER_FLAG = "_astrbot_loguru_handler" + _ENRICH_FILTER_FLAG = "_astrbot_enrich_filter" - 提供了获取默认日志记录器logger和设置队列处理器的方法 - """ - - _FILE_HANDLER_FLAG = "_astrbot_file_handler" - _TRACE_FILE_HANDLER_FLAG = "_astrbot_trace_file_handler" - - @classmethod - def GetLogger(cls, log_name: str = "default"): - """获取指定名称的日志记录器logger - - Args: - log_name (str): 日志记录器的名称, 默认为 "default" - - Returns: - logging.Logger: 返回配置好的日志记录器 - - """ - logger = logging.getLogger(log_name) - # 检查该logger或父级logger是否已经有处理器, 如果已经有处理器, 直接返回该logger, 避免重复配置 - if logger.hasHandlers(): - return logger - # 如果logger没有处理器 - console_handler = logging.StreamHandler( - sys.stdout, - ) # 创建一个StreamHandler用于控制台输出 - console_handler.setLevel( - logging.DEBUG, - ) # 将日志级别设置为DEBUG(最低级别, 显示所有日志), *如果插件没有设置级别, 默认为DEBUG - - # 创建彩色日志格式化器, 输出日志格式为: [时间] [插件标签] [日志级别] [文件名:行号]: 日志消息 - console_formatter = colorlog.ColoredFormatter( - fmt="%(log_color)s [%(asctime)s] %(plugin_tag)s [%(short_levelname)-4s]%(astrbot_version_tag)s [%(filename)s:%(lineno)d]: %(message)s %(reset)s", - datefmt="%H:%M:%S", - log_colors=log_color_config, - ) - - class PluginFilter(logging.Filter): - """插件过滤器类, 用于标记日志来源是插件还是核心组件""" - - def filter(self, record) -> bool: - record.plugin_tag = ( - "[Plug]" if is_plugin_path(record.pathname) else "[Core]" - ) - return True - - class FileNameFilter(logging.Filter): - """文件名过滤器类, 用于修改日志记录的文件名格式 - 例如: 将文件路径 /path/to/file.py 转换为 file. 格式 - """ - - # 获取这个文件和父文件夹的名字:. 并且去除 .py - def filter(self, record) -> bool: - dirname = os.path.dirname(record.pathname) - record.filename = ( - os.path.basename(dirname) - + "." - + os.path.basename(record.pathname).replace(".py", "") - ) - return True - - class LevelNameFilter(logging.Filter): - """短日志级别名称过滤器类, 用于将日志级别名称转换为四个字母的缩写""" - - # 添加短日志级别名称 - def filter(self, record) -> bool: - record.short_levelname = get_short_level_name(record.levelname) - return True - - class AstrBotVersionTagFilter(logging.Filter): - """在 WARNING 及以上级别日志后追加当前 AstrBot 版本号。""" - - def filter(self, record) -> bool: - if record.levelno >= logging.WARNING: - record.astrbot_version_tag = f" [v{VERSION}]" - else: - record.astrbot_version_tag = "" - return True - - console_handler.setFormatter(console_formatter) # 设置处理器的格式化器 - logger.addFilter(PluginFilter()) # 添加插件过滤器 - logger.addFilter(FileNameFilter()) # 添加文件名过滤器 - logger.addFilter(LevelNameFilter()) # 添加级别名称过滤器 - logger.addFilter(AstrBotVersionTagFilter()) # 追加版本号(WARNING 及以上) - logger.setLevel(logging.DEBUG) # 设置日志级别为DEBUG - logger.addHandler(console_handler) # 添加处理器到logger - - return logger - - @classmethod - def set_queue_handler(cls, logger: logging.Logger, log_broker: LogBroker) -> None: - """设置队列处理器, 用于将日志消息发送到 LogBroker - - Args: - logger (logging.Logger): 日志记录器 - log_broker (LogBroker): 日志代理类, 用于缓存和分发日志消息 - - """ - handler = LogQueueHandler(log_broker) - handler.setLevel(logging.DEBUG) - if logger.handlers: - handler.setFormatter(logger.handlers[0].formatter) - else: - # 为队列处理器设置相同格式的formatter - handler.setFormatter( - logging.Formatter( - "[%(asctime)s] [%(short_levelname)s] %(plugin_tag)s[%(filename)s:%(lineno)d]: %(message)s", - ), - ) - logger.addHandler(handler) + _configured = False + _console_sink_id: int | None = None + _file_sink_id: int | None = None + _trace_sink_id: int | None = None + _NOISY_LOGGER_LEVELS: dict[str, int] = { + "aiosqlite": logging.WARNING, + } @classmethod def _default_log_path(cls) -> str: @@ -285,79 +190,147 @@ class LogManager: return os.path.join(get_astrbot_data_path(), configured_path) @classmethod - def _get_file_handlers(cls, logger: logging.Logger) -> list[logging.Handler]: - return [ - handler - for handler in logger.handlers - if getattr(handler, cls._FILE_HANDLER_FLAG, False) - ] + def _setup_loguru(cls) -> None: + if cls._configured: + return - @classmethod - def _get_trace_file_handlers(cls, logger: logging.Logger) -> list[logging.Handler]: - return [ - handler - for handler in logger.handlers - if getattr(handler, cls._TRACE_FILE_HANDLER_FLAG, False) - ] - - @classmethod - def _remove_file_handlers(cls, logger: logging.Logger) -> None: - for handler in cls._get_file_handlers(logger): - logger.removeHandler(handler) - try: - handler.close() - except Exception: - pass - - @classmethod - def _remove_trace_file_handlers(cls, logger: logging.Logger) -> None: - for handler in cls._get_trace_file_handlers(logger): - logger.removeHandler(handler) - try: - handler.close() - except Exception: - pass - - @classmethod - def _add_file_handler( - cls, - logger: logging.Logger, - file_path: str, - max_mb: int | None = None, - backup_count: int = 3, - trace: bool = False, - ) -> None: - os.makedirs(os.path.dirname(file_path) or ".", exist_ok=True) - max_bytes = 0 - if max_mb and max_mb > 0: - max_bytes = max_mb * 1024 * 1024 - if max_bytes > 0: - file_handler = RotatingFileHandler( - file_path, - maxBytes=max_bytes, - backupCount=backup_count, - encoding="utf-8", - ) - else: - file_handler = logging.FileHandler(file_path, encoding="utf-8") - file_handler.setLevel(logger.level) - if trace: - formatter = logging.Formatter( - "[%(asctime)s] %(message)s", - datefmt="%Y-%m-%d %H:%M:%S", - ) - else: - formatter = logging.Formatter( - "[%(asctime)s] %(plugin_tag)s [%(short_levelname)s]%(astrbot_version_tag)s [%(filename)s:%(lineno)d]: %(message)s", - datefmt="%Y-%m-%d %H:%M:%S", - ) - file_handler.setFormatter(formatter) - setattr( - file_handler, - cls._TRACE_FILE_HANDLER_FLAG if trace else cls._FILE_HANDLER_FLAG, - True, + _loguru.remove() + cls._console_sink_id = _loguru.add( + sys.stdout, + level="DEBUG", + colorize=True, + filter=lambda record: not record["extra"].get("is_trace", False), + format=( + "[{time:HH:mm:ss.SSS}] {extra[plugin_tag]} " + "[{extra[short_levelname]}]{extra[astrbot_version_tag]} " + "[{extra[source_file]}:{extra[source_line]}]: {message}" + ), + ) + cls._configured = True + + @classmethod + def _setup_root_bridge(cls) -> None: + root_logger = logging.getLogger() + + has_handler = any( + getattr(handler, cls._LOGGER_HANDLER_FLAG, False) + for handler in root_logger.handlers + ) + if not has_handler: + handler = _LoguruInterceptHandler() + setattr(handler, cls._LOGGER_HANDLER_FLAG, True) + root_logger.addHandler(handler) + root_logger.setLevel(logging.DEBUG) + for name, level in cls._NOISY_LOGGER_LEVELS.items(): + logging.getLogger(name).setLevel(level) + + @classmethod + def _ensure_logger_enricher_filter(cls, logger: logging.Logger) -> None: + has_filter = any( + getattr(existing_filter, cls._ENRICH_FILTER_FLAG, False) + for existing_filter in logger.filters + ) + if not has_filter: + enrich_filter = _RecordEnricherFilter() + setattr(enrich_filter, cls._ENRICH_FILTER_FLAG, True) + logger.addFilter(enrich_filter) + + @classmethod + def _ensure_logger_intercept_handler(cls, logger: logging.Logger) -> None: + has_handler = any( + getattr(handler, cls._LOGGER_HANDLER_FLAG, False) + for handler in logger.handlers + ) + if not has_handler: + handler = _LoguruInterceptHandler() + setattr(handler, cls._LOGGER_HANDLER_FLAG, True) + logger.addHandler(handler) + + @classmethod + def GetLogger(cls, log_name: str = "default") -> logging.Logger: + cls._setup_loguru() + cls._setup_root_bridge() + + logger = logging.getLogger(log_name) + cls._ensure_logger_enricher_filter(logger) + cls._ensure_logger_intercept_handler(logger) + logger.setLevel(logging.DEBUG) + logger.propagate = False + return logger + + @classmethod + def set_queue_handler(cls, logger: logging.Logger, log_broker: LogBroker) -> None: + cls._ensure_logger_enricher_filter(logger) + + for handler in logger.handlers: + if isinstance(handler, LogQueueHandler): + return + + handler = LogQueueHandler(log_broker) + handler.setLevel(logging.DEBUG) + handler.addFilter(_QueueAnsiColorFilter()) + handler.setFormatter( + logging.Formatter( + "%(ansi_prefix)s[%(asctime)s.%(msecs)03d] %(plugin_tag)s [%(short_levelname)s]%(astrbot_version_tag)s " + "[%(source_file)s:%(source_line)d]: %(message)s%(ansi_reset)s", + datefmt="%Y-%m-%d %H:%M:%S", + ), + ) + logger.addHandler(handler) + + @classmethod + def _remove_sink(cls, sink_id: int | None) -> None: + if sink_id is None: + return + try: + _loguru.remove(sink_id) + except ValueError: + pass + + @classmethod + def _add_file_sink( + cls, + *, + file_path: str, + level: int, + max_mb: int | None, + backup_count: int, + trace: bool, + ) -> int: + os.makedirs(os.path.dirname(file_path) or ".", exist_ok=True) + rotation = f"{max_mb} MB" if max_mb and max_mb > 0 else None + retention = ( + backup_count if rotation and backup_count and backup_count > 0 else None + ) + if trace: + return _loguru.add( + file_path, + level="INFO", + format="[{time:YYYY-MM-DD HH:mm:ss.SSS}] {message}", + encoding="utf-8", + rotation=rotation, + retention=retention, + enqueue=True, + filter=lambda record: record["extra"].get("is_trace", False), + ) + + logging_level_name = logging.getLevelName(level) + if isinstance(logging_level_name, int): + logging_level_name = "INFO" + return _loguru.add( + file_path, + level=logging_level_name, + format=( + "[{time:YYYY-MM-DD HH:mm:ss.SSS}] {extra[plugin_tag]} " + "[{extra[short_levelname]}]{extra[astrbot_version_tag]} " + "[{extra[source_file]}:{extra[source_line]}]: {message}" + ), + encoding="utf-8", + rotation=rotation, + retention=retention, + enqueue=True, + filter=lambda record: not record["extra"].get("is_trace", False), ) - logger.addHandler(file_handler) @classmethod def configure_logger( @@ -366,13 +339,6 @@ class LogManager: config: dict | None, override_level: str | None = None, ) -> None: - """根据配置设置日志级别和文件日志。 - - Args: - logger: 需要配置的 logger - config: 配置字典 - override_level: 若提供,将覆盖配置中的日志级别 - """ if not config: return @@ -383,7 +349,6 @@ class LogManager: except Exception: logger.setLevel(logging.INFO) - # 兼容旧版嵌套配置 if "log_file" in config: file_conf = config.get("log_file") or {} enable_file = bool(file_conf.get("enable", False)) @@ -394,27 +359,25 @@ class LogManager: file_path = config.get("log_file_path") max_mb = config.get("log_file_max_mb") - file_path = cls._resolve_log_path(file_path) + cls._remove_sink(cls._file_sink_id) + cls._file_sink_id = None - existing = cls._get_file_handlers(logger) if not enable_file: - cls._remove_file_handlers(logger) return - # 如果已有文件处理器且路径一致,则仅同步级别 - if existing: - handler = existing[0] - base = getattr(handler, "baseFilename", "") - if base and os.path.abspath(base) == os.path.abspath(file_path): - handler.setLevel(logger.level) - return - cls._remove_file_handlers(logger) - - cls._add_file_handler(logger, file_path, max_mb=max_mb) + try: + cls._file_sink_id = cls._add_file_sink( + file_path=cls._resolve_log_path(file_path), + level=logger.level, + max_mb=max_mb, + backup_count=3, + trace=False, + ) + except Exception as e: + logger.error(f"Failed to add file sink: {e}") @classmethod def configure_trace_logger(cls, config: dict | None) -> None: - """为 trace 事件配置独立的文件日志,不向控制台输出。""" if not config: return @@ -429,28 +392,22 @@ class LogManager: path = path or legacy.get("trace_path") max_mb = max_mb or legacy.get("trace_max_mb") - if not enable: - trace_logger = logging.getLogger("astrbot.trace") - cls._remove_trace_file_handlers(trace_logger) - return - - file_path = cls._resolve_log_path(path or "logs/astrbot.trace.log") trace_logger = logging.getLogger("astrbot.trace") + cls._ensure_logger_enricher_filter(trace_logger) + cls._ensure_logger_intercept_handler(trace_logger) trace_logger.setLevel(logging.INFO) trace_logger.propagate = False - existing = cls._get_trace_file_handlers(trace_logger) - if existing: - handler = existing[0] - base = getattr(handler, "baseFilename", "") - if base and os.path.abspath(base) == os.path.abspath(file_path): - handler.setLevel(trace_logger.level) - return - cls._remove_trace_file_handlers(trace_logger) + cls._remove_sink(cls._trace_sink_id) + cls._trace_sink_id = None - cls._add_file_handler( - trace_logger, - file_path, + if not enable: + return + + cls._trace_sink_id = cls._add_file_sink( + file_path=cls._resolve_log_path(path or "logs/astrbot.trace.log"), + level=logging.INFO, max_mb=max_mb, + backup_count=3, trace=True, ) diff --git a/astrbot/core/message/components.py b/astrbot/core/message/components.py index 2c86bf3d9..15265c38d 100644 --- a/astrbot/core/message/components.py +++ b/astrbot/core/message/components.py @@ -25,10 +25,14 @@ import asyncio import base64 import json import os +import sys import uuid from enum import Enum -from pydantic.v1 import BaseModel +if sys.version_info >= (3, 14): + from pydantic import BaseModel +else: + from pydantic.v1 import BaseModel from astrbot.core import astrbot_config, file_token_service, logger from astrbot.core.utils.astrbot_path import get_astrbot_temp_path @@ -85,7 +89,7 @@ class BaseMessageComponent(BaseModel): class Plain(BaseMessageComponent): - type = ComponentType.Plain + type: ComponentType = ComponentType.Plain text: str convert: bool | None = True @@ -100,7 +104,7 @@ class Plain(BaseMessageComponent): class Face(BaseMessageComponent): - type = ComponentType.Face + type: ComponentType = ComponentType.Face id: int def __init__(self, **_) -> None: @@ -108,13 +112,15 @@ class Face(BaseMessageComponent): class Record(BaseMessageComponent): - type = ComponentType.Record + type: ComponentType = ComponentType.Record file: str | None = "" magic: bool | None = False url: str | None = "" cache: bool | None = True proxy: bool | None = True timeout: int | None = 0 + # Original text content (e.g. TTS source text), used as caption in fallback scenarios + text: str | None = None # 额外 path: str | None @@ -215,7 +221,7 @@ class Record(BaseMessageComponent): class Video(BaseMessageComponent): - type = ComponentType.Video + type: ComponentType = ComponentType.Video file: str cover: str | None = "" c: int | None = 2 @@ -301,7 +307,7 @@ class Video(BaseMessageComponent): class At(BaseMessageComponent): - type = ComponentType.At + type: ComponentType = ComponentType.At qq: int | str # 此处str为all时代表所有人 name: str | None = "" @@ -323,28 +329,28 @@ class AtAll(At): class RPS(BaseMessageComponent): # TODO - type = ComponentType.RPS + type: ComponentType = ComponentType.RPS def __init__(self, **_) -> None: super().__init__(**_) class Dice(BaseMessageComponent): # TODO - type = ComponentType.Dice + type: ComponentType = ComponentType.Dice def __init__(self, **_) -> None: super().__init__(**_) class Shake(BaseMessageComponent): # TODO - type = ComponentType.Shake + type: ComponentType = ComponentType.Shake def __init__(self, **_) -> None: super().__init__(**_) class Share(BaseMessageComponent): - type = ComponentType.Share + type: ComponentType = ComponentType.Share url: str title: str content: str | None = "" @@ -355,7 +361,7 @@ class Share(BaseMessageComponent): class Contact(BaseMessageComponent): # TODO - type = ComponentType.Contact + type: ComponentType = ComponentType.Contact _type: str # type 字段冲突 id: int | None = 0 @@ -364,7 +370,7 @@ class Contact(BaseMessageComponent): # TODO class Location(BaseMessageComponent): # TODO - type = ComponentType.Location + type: ComponentType = ComponentType.Location lat: float lon: float title: str | None = "" @@ -375,7 +381,7 @@ class Location(BaseMessageComponent): # TODO class Music(BaseMessageComponent): - type = ComponentType.Music + type: ComponentType = ComponentType.Music _type: str id: int | None = 0 url: str | None = "" @@ -392,7 +398,7 @@ class Music(BaseMessageComponent): class Image(BaseMessageComponent): - type = ComponentType.Image + type: ComponentType = ComponentType.Image file: str | None = "" _type: str | None = "" subType: int | None = 0 @@ -507,7 +513,7 @@ class Image(BaseMessageComponent): class Reply(BaseMessageComponent): - type = ComponentType.Reply + type: ComponentType = ComponentType.Reply id: str | int """所引用的消息 ID""" chain: list["BaseMessageComponent"] | None = [] @@ -543,7 +549,7 @@ class Poke(BaseMessageComponent): class Forward(BaseMessageComponent): - type = ComponentType.Forward + type: ComponentType = ComponentType.Forward id: str def __init__(self, **_) -> None: @@ -553,7 +559,7 @@ class Forward(BaseMessageComponent): class Node(BaseMessageComponent): """群合并转发消息""" - type = ComponentType.Node + type: ComponentType = ComponentType.Node id: int | None = 0 # 忽略 name: str | None = "" # qq昵称 uin: str | None = "0" # qq号 @@ -605,7 +611,7 @@ class Node(BaseMessageComponent): class Nodes(BaseMessageComponent): - type = ComponentType.Nodes + type: ComponentType = ComponentType.Nodes nodes: list[Node] def __init__(self, nodes: list[Node], **_) -> None: @@ -631,7 +637,7 @@ class Nodes(BaseMessageComponent): class Json(BaseMessageComponent): - type = ComponentType.Json + type: ComponentType = ComponentType.Json data: dict def __init__(self, data: str | dict, **_) -> None: @@ -641,14 +647,14 @@ class Json(BaseMessageComponent): class Unknown(BaseMessageComponent): - type = ComponentType.Unknown + type: ComponentType = ComponentType.Unknown text: str class File(BaseMessageComponent): """文件消息段""" - type = ComponentType.File + type: ComponentType = ComponentType.File name: str | None = "" # 名字 file_: str | None = "" # 本地路径 url: str | None = "" # url @@ -714,13 +720,38 @@ class File(BaseMessageComponent): 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.file_: + path = self.file_ + if path.startswith("file://"): + # 处理 file:// (2 slashes) 或 file:/// (3 slashes) + # pathlib.as_uri() 通常生成 file:/// + path = path[7:] + # 兼容 Windows: file:///C:/path -> /C:/path -> C:/path + if ( + os.name == "nt" + and len(path) > 2 + and path[0] == "/" + and path[2] == ":" + ): + path = path[1:] + + if os.path.exists(path): + return os.path.abspath(path) if self.url: await self._download_file() if self.file_: - return os.path.abspath(self.file_) + path = self.file_ + if path.startswith("file://"): + path = path[7:] + if ( + os.name == "nt" + and len(path) > 2 + and path[0] == "/" + and path[2] == ":" + ): + path = path[1:] + return os.path.abspath(path) return "" @@ -783,7 +814,7 @@ class File(BaseMessageComponent): class WechatEmoji(BaseMessageComponent): - type = ComponentType.WechatEmoji + type: ComponentType = ComponentType.WechatEmoji md5: str | None = "" md5_len: int | None = 0 cdnurl: str | None = "" diff --git a/astrbot/core/pipeline/__init__.py b/astrbot/core/pipeline/__init__.py index 75fef84d3..0363d4692 100644 --- a/astrbot/core/pipeline/__init__.py +++ b/astrbot/core/pipeline/__init__.py @@ -1,30 +1,60 @@ +"""Pipeline package exports. + +This module intentionally avoids eager imports of all pipeline stage modules to +prevent import-time cycles. Stage classes remain available via lazy attribute +resolution for backward compatibility. +""" + +from __future__ import annotations + +from importlib import import_module +from typing import Any + from astrbot.core.message.message_event_result import ( EventResultType, MessageEventResult, ) -from .content_safety_check.stage import ContentSafetyCheckStage -from .preprocess_stage.stage import PreProcessStage -from .process_stage.stage import ProcessStage -from .rate_limit_check.stage import RateLimitStage -from .respond.stage import RespondStage -from .result_decorate.stage import ResultDecorateStage -from .session_status_check.stage import SessionStatusCheckStage -from .waking_check.stage import WakingCheckStage -from .whitelist_check.stage import WhitelistCheckStage +from .stage_order import STAGES_ORDER -# 管道阶段顺序 -STAGES_ORDER = [ - "WakingCheckStage", # 检查是否需要唤醒 - "WhitelistCheckStage", # 检查是否在群聊/私聊白名单 - "SessionStatusCheckStage", # 检查会话是否整体启用 - "RateLimitStage", # 检查会话是否超过频率限制 - "ContentSafetyCheckStage", # 检查内容安全 - "PreProcessStage", # 预处理 - "ProcessStage", # 交由 Stars 处理(a.k.a 插件),或者 LLM 调用 - "ResultDecorateStage", # 处理结果,比如添加回复前缀、t2i、转换为语音 等 - "RespondStage", # 发送消息 -] +_LAZY_EXPORTS = { + "ContentSafetyCheckStage": ( + "astrbot.core.pipeline.content_safety_check.stage", + "ContentSafetyCheckStage", + ), + "PreProcessStage": ( + "astrbot.core.pipeline.preprocess_stage.stage", + "PreProcessStage", + ), + "ProcessStage": ( + "astrbot.core.pipeline.process_stage.stage", + "ProcessStage", + ), + "RateLimitStage": ( + "astrbot.core.pipeline.rate_limit_check.stage", + "RateLimitStage", + ), + "RespondStage": ( + "astrbot.core.pipeline.respond.stage", + "RespondStage", + ), + "ResultDecorateStage": ( + "astrbot.core.pipeline.result_decorate.stage", + "ResultDecorateStage", + ), + "SessionStatusCheckStage": ( + "astrbot.core.pipeline.session_status_check.stage", + "SessionStatusCheckStage", + ), + "WakingCheckStage": ( + "astrbot.core.pipeline.waking_check.stage", + "WakingCheckStage", + ), + "WhitelistCheckStage": ( + "astrbot.core.pipeline.whitelist_check.stage", + "WhitelistCheckStage", + ), +} __all__ = [ "ContentSafetyCheckStage", @@ -36,6 +66,21 @@ __all__ = [ "RespondStage", "ResultDecorateStage", "SessionStatusCheckStage", + "STAGES_ORDER", "WakingCheckStage", "WhitelistCheckStage", ] + + +def __getattr__(name: str) -> Any: + if name not in _LAZY_EXPORTS: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + module_path, attr_name = _LAZY_EXPORTS[name] + module = import_module(module_path) + value = getattr(module, attr_name) + globals()[name] = value + return value + + +def __dir__() -> list[str]: + return sorted(set(globals()) | set(__all__)) diff --git a/astrbot/core/pipeline/bootstrap.py b/astrbot/core/pipeline/bootstrap.py new file mode 100644 index 000000000..4bb7ceadb --- /dev/null +++ b/astrbot/core/pipeline/bootstrap.py @@ -0,0 +1,52 @@ +"""Pipeline bootstrap utilities.""" + +from importlib import import_module + +from .stage import registered_stages + +_BUILTIN_STAGE_MODULES = ( + "astrbot.core.pipeline.waking_check.stage", + "astrbot.core.pipeline.whitelist_check.stage", + "astrbot.core.pipeline.session_status_check.stage", + "astrbot.core.pipeline.rate_limit_check.stage", + "astrbot.core.pipeline.content_safety_check.stage", + "astrbot.core.pipeline.preprocess_stage.stage", + "astrbot.core.pipeline.process_stage.stage", + "astrbot.core.pipeline.result_decorate.stage", + "astrbot.core.pipeline.respond.stage", +) + +_EXPECTED_STAGE_NAMES = { + "WakingCheckStage", + "WhitelistCheckStage", + "SessionStatusCheckStage", + "RateLimitStage", + "ContentSafetyCheckStage", + "PreProcessStage", + "ProcessStage", + "ResultDecorateStage", + "RespondStage", +} + +_builtin_stages_registered = False + + +def ensure_builtin_stages_registered() -> None: + """Ensure built-in pipeline stages are imported and registered.""" + global _builtin_stages_registered + + if _builtin_stages_registered: + return + + stage_names = {stage_cls.__name__ for stage_cls in registered_stages} + if _EXPECTED_STAGE_NAMES.issubset(stage_names): + _builtin_stages_registered = True + return + + for module_path in _BUILTIN_STAGE_MODULES: + import_module(module_path) + + _builtin_stages_registered = True + + +__all__ = ["ensure_builtin_stages_registered"] diff --git a/astrbot/core/pipeline/context.py b/astrbot/core/pipeline/context.py index a6cd567e0..963f4bdac 100644 --- a/astrbot/core/pipeline/context.py +++ b/astrbot/core/pipeline/context.py @@ -1,7 +1,9 @@ +from __future__ import annotations + from dataclasses import dataclass +from typing import Any from astrbot.core.config import AstrBotConfig -from astrbot.core.star import PluginManager from .context_utils import call_event_hook, call_handler @@ -11,7 +13,7 @@ class PipelineContext: """上下文对象,包含管道执行所需的上下文信息""" astrbot_config: AstrBotConfig # AstrBot 配置对象 - plugin_manager: PluginManager # 插件管理器对象 + plugin_manager: Any # 插件管理器对象 astrbot_config_id: str call_handler = call_handler call_event_hook = call_event_hook diff --git a/astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py b/astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py index be517dba9..98cf77fcc 100644 --- a/astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py +++ b/astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py @@ -19,6 +19,7 @@ from astrbot.core.message.message_event_result import ( MessageEventResult, ResultContentType, ) +from astrbot.core.pipeline.stage import Stage from astrbot.core.platform.astr_message_event import AstrMessageEvent from astrbot.core.provider.entities import ( LLMResponse, @@ -30,7 +31,6 @@ from astrbot.core.utils.session_lock import session_lock_manager from .....astr_agent_run_util import run_agent, run_live_agent from ....context import PipelineContext, call_event_hook -from ...stage import Stage class InternalAgentSubStage(Stage): @@ -54,6 +54,7 @@ class InternalAgentSubStage(Stage): if isinstance(self.max_step, bool): # workaround: #2622 self.max_step = 30 self.show_tool_use: bool = settings.get("show_tool_use_status", True) + self.show_tool_call_result: bool = settings.get("show_tool_call_result", False) self.show_reasoning = settings.get("display_reasoning_text", False) self.sanitize_context_by_modalities: bool = settings.get( "sanitize_context_by_modalities", @@ -240,6 +241,7 @@ class InternalAgentSubStage(Stage): tts_provider, self.max_step, self.show_tool_use, + self.show_tool_call_result, show_reasoning=self.show_reasoning, ), ), @@ -247,13 +249,16 @@ class InternalAgentSubStage(Stage): yield # 保存历史记录 - if not event.is_stopped() and agent_runner.done(): + if agent_runner.done() and ( + not event.is_stopped() or agent_runner.was_aborted() + ): await self._save_to_history( event, req, agent_runner.get_final_llm_resp(), agent_runner.run_context.messages, agent_runner.stats, + user_aborted=agent_runner.was_aborted(), ) elif streaming_response and not stream_to_general: @@ -266,6 +271,7 @@ class InternalAgentSubStage(Stage): agent_runner, self.max_step, self.show_tool_use, + self.show_tool_call_result, show_reasoning=self.show_reasoning, ), ), @@ -294,6 +300,7 @@ class InternalAgentSubStage(Stage): agent_runner, self.max_step, self.show_tool_use, + self.show_tool_call_result, stream_to_general, show_reasoning=self.show_reasoning, ): @@ -308,13 +315,14 @@ class InternalAgentSubStage(Stage): ) # 检查事件是否被停止,如果被停止则不保存历史记录 - if not event.is_stopped(): + if not event.is_stopped() or agent_runner.was_aborted(): await self._save_to_history( event, req, final_resp, agent_runner.run_context.messages, agent_runner.stats, + user_aborted=agent_runner.was_aborted(), ) asyncio.create_task( @@ -340,16 +348,29 @@ class InternalAgentSubStage(Stage): llm_response: LLMResponse | None, all_messages: list[Message], runner_stats: AgentStats | None, + user_aborted: bool = False, ) -> None: - if ( - not req - or not req.conversation - or not llm_response - or llm_response.role != "assistant" - ): + if not req or not req.conversation: return - if not llm_response.completion_text and not req.tool_calls_result: + if not llm_response and not user_aborted: + return + + if llm_response and llm_response.role != "assistant": + if not user_aborted: + return + llm_response = LLMResponse( + role="assistant", + completion_text=llm_response.completion_text or "", + ) + elif llm_response is None: + llm_response = LLMResponse(role="assistant", completion_text="") + + if ( + not llm_response.completion_text + and not req.tool_calls_result + and not user_aborted + ): logger.debug("LLM 响应为空,不保存记录。") return @@ -363,6 +384,14 @@ class InternalAgentSubStage(Stage): continue message_to_save.append(message.model_dump()) + # if user_aborted: + # message_to_save.append( + # Message( + # role="assistant", + # content="[User aborted this request. Partial output before abort was preserved.]", + # ).model_dump() + # ) + token_usage = None if runner_stats: # token_usage = runner_stats.token_usage.total diff --git a/astrbot/core/pipeline/process_stage/method/agent_sub_stages/third_party.py b/astrbot/core/pipeline/process_stage/method/agent_sub_stages/third_party.py index b590bd77e..7fb5cee82 100644 --- a/astrbot/core/pipeline/process_stage/method/agent_sub_stages/third_party.py +++ b/astrbot/core/pipeline/process_stage/method/agent_sub_stages/third_party.py @@ -8,6 +8,7 @@ from astrbot.core.agent.runners.dashscope.dashscope_agent_runner import ( DashscopeAgentRunner, ) from astrbot.core.agent.runners.dify.dify_agent_runner import DifyAgentRunner +from astrbot.core.astr_agent_hooks import MAIN_AGENT_HOOKS from astrbot.core.message.components import Image from astrbot.core.message.message_event_result import ( MessageChain, @@ -17,6 +18,7 @@ from astrbot.core.message.message_event_result import ( if TYPE_CHECKING: from astrbot.core.agent.runners.base import BaseAgentRunner +from astrbot.core.pipeline.stage import Stage from astrbot.core.platform.astr_message_event import AstrMessageEvent from astrbot.core.provider.entities import ( ProviderRequest, @@ -25,9 +27,7 @@ from astrbot.core.star.star_handler import EventType from astrbot.core.utils.metrics import Metric from .....astr_agent_context import AgentContextWrapper, AstrAgentContext -from .....astr_agent_hooks import MAIN_AGENT_HOOKS from ....context import PipelineContext, call_event_hook -from ...stage import Stage AGENT_RUNNER_TYPE_KEY = { "dify": "dify_agent_runner_provider_id", diff --git a/astrbot/core/pipeline/process_stage/method/star_request.py b/astrbot/core/pipeline/process_stage/method/star_request.py index 8a79b96c9..9422d6317 100644 --- a/astrbot/core/pipeline/process_stage/method/star_request.py +++ b/astrbot/core/pipeline/process_stage/method/star_request.py @@ -8,9 +8,9 @@ from astrbot.core import logger from astrbot.core.message.message_event_result import MessageEventResult from astrbot.core.platform.astr_message_event import AstrMessageEvent from astrbot.core.star.star import star_map -from astrbot.core.star.star_handler import StarHandlerMetadata +from astrbot.core.star.star_handler import EventType, StarHandlerMetadata -from ...context import PipelineContext, call_handler +from ...context import PipelineContext, call_event_hook, call_handler from ..stage import Stage @@ -48,10 +48,20 @@ class StarRequestSubStage(Stage): yield ret event.clear_result() # 清除上一个 handler 的结果 except Exception as e: - logger.error(traceback.format_exc()) + traceback_text = traceback.format_exc() + logger.error(traceback_text) logger.error(f"Star {handler.handler_full_name} handle error: {e}") - if event.is_at_or_wake_command: + await call_event_hook( + event, + EventType.OnPluginErrorEvent, + md.name, + handler.handler_name, + e, + traceback_text, + ) + + if not event.is_stopped() and event.is_at_or_wake_command: ret = f":(\n\n在调用插件 {md.name} 的处理函数 {handler.handler_name} 时出现异常:{e}" event.set_result(MessageEventResult().message(ret)) yield diff --git a/astrbot/core/pipeline/respond/stage.py b/astrbot/core/pipeline/respond/stage.py index b4a7ee7fa..72e853ffc 100644 --- a/astrbot/core/pipeline/respond/stage.py +++ b/astrbot/core/pipeline/respond/stage.py @@ -33,6 +33,21 @@ class RespondStage(Stage): Comp.Nodes: lambda comp: bool(comp.nodes), # 多个转发节点 Comp.File: lambda comp: bool(comp.file_ or comp.url), Comp.WechatEmoji: lambda comp: comp.md5 is not None, # 微信表情 + Comp.Json: lambda comp: bool(comp.data), # Json 卡片 + Comp.Share: lambda comp: bool(comp.url) or bool(comp.title), + Comp.Music: lambda comp: ( + (comp.id and comp._type and comp._type != "custom") + or (comp._type == "custom" and comp.url and comp.audio and comp.title) + ), # 音乐分享 + Comp.Forward: lambda comp: bool(comp.id), # 合并转发 + Comp.Location: lambda comp: bool( + comp.lat is not None and comp.lon is not None + ), # 位置 + Comp.Contact: lambda comp: bool(comp._type and comp.id), # 推荐好友 or 群 + Comp.Shake: lambda _: True, # 窗口抖动(戳一戳) + Comp.Dice: lambda _: True, # 掷骰子魔法表情 + Comp.RPS: lambda _: True, # 猜拳魔法表情 + Comp.Unknown: lambda comp: bool(comp.text and comp.text.strip()), } async def initialize(self, ctx: PipelineContext) -> None: diff --git a/astrbot/core/pipeline/result_decorate/stage.py b/astrbot/core/pipeline/result_decorate/stage.py index 823aa0eaa..15d68fb22 100644 --- a/astrbot/core/pipeline/result_decorate/stage.py +++ b/astrbot/core/pipeline/result_decorate/stage.py @@ -315,6 +315,7 @@ class ResultDecorateStage(Stage): Record( file=url or audio_path, url=url or audio_path, + text=comp.text, ), ) if dual_output: diff --git a/astrbot/core/pipeline/scheduler.py b/astrbot/core/pipeline/scheduler.py index 71c98778f..ffb9c5c99 100644 --- a/astrbot/core/pipeline/scheduler.py +++ b/astrbot/core/pipeline/scheduler.py @@ -6,16 +6,19 @@ from astrbot.core.platform.sources.webchat.webchat_event import WebChatMessageEv from astrbot.core.platform.sources.wecom_ai_bot.wecomai_event import ( WecomAIBotMessageEvent, ) +from astrbot.core.utils.active_event_registry import active_event_registry -from . import STAGES_ORDER +from .bootstrap import ensure_builtin_stages_registered from .context import PipelineContext from .stage import registered_stages +from .stage_order import STAGES_ORDER class PipelineScheduler: """管道调度器,负责调度各个阶段的执行""" def __init__(self, context: PipelineContext) -> None: + ensure_builtin_stages_registered() registered_stages.sort( key=lambda x: STAGES_ORDER.index(x.__name__), ) # 按照顺序排序 @@ -79,10 +82,14 @@ class PipelineScheduler: event (AstrMessageEvent): 事件对象 """ - await self._process_stages(event) + active_event_registry.register(event) + try: + await self._process_stages(event) - # 如果没有发送操作, 则发送一个空消息, 以便于后续的处理 - if isinstance(event, WebChatMessageEvent | WecomAIBotMessageEvent): - await event.send(None) + # 如果没有发送操作, 则发送一个空消息, 以便于后续的处理 + if isinstance(event, WebChatMessageEvent | WecomAIBotMessageEvent): + await event.send(None) - logger.debug("pipeline 执行完毕。") + logger.debug("pipeline 执行完毕。") + finally: + active_event_registry.unregister(event) diff --git a/astrbot/core/pipeline/stage_order.py b/astrbot/core/pipeline/stage_order.py new file mode 100644 index 000000000..f99f57264 --- /dev/null +++ b/astrbot/core/pipeline/stage_order.py @@ -0,0 +1,15 @@ +"""Pipeline stage execution order.""" + +STAGES_ORDER = [ + "WakingCheckStage", # 检查是否需要唤醒 + "WhitelistCheckStage", # 检查是否在群聊/私聊白名单 + "SessionStatusCheckStage", # 检查会话是否整体启用 + "RateLimitStage", # 检查会话是否超过频率限制 + "ContentSafetyCheckStage", # 检查内容安全 + "PreProcessStage", # 预处理 + "ProcessStage", # 交由 Stars 处理(a.k.a 插件),或者 LLM 调用 + "ResultDecorateStage", # 处理结果,比如添加回复前缀、t2i、转换为语音 等 + "RespondStage", # 发送消息 +] + +__all__ = ["STAGES_ORDER"] diff --git a/astrbot/core/platform/astr_message_event.py b/astrbot/core/platform/astr_message_event.py index 4cd531c53..021a4bff7 100644 --- a/astrbot/core/platform/astr_message_event.py +++ b/astrbot/core/platform/astr_message_event.py @@ -52,9 +52,19 @@ class AstrMessageEvent(abc.ABC): self.is_at_or_wake_command = False """是否是 At 机器人或者带有唤醒词或者是私聊(插件注册的事件监听器会让 is_wake 设为 True, 但是不会让这个属性置为 True)""" self._extras: dict[str, Any] = {} + message_type = getattr(message_obj, "type", None) + if not isinstance(message_type, MessageType): + try: + message_type = MessageType(str(message_type)) + except (ValueError, TypeError, AttributeError): + logger.warning( + f"Failed to convert message type {message_obj.type!r} to MessageType. " + f"Falling back to FRIEND_MESSAGE." + ) + message_type = MessageType.FRIEND_MESSAGE self.session = MessageSession( platform_name=platform_meta.id, - message_type=message_obj.type, + message_type=message_type, session_id=session_id, ) # self.unified_msg_origin = str(self.session) @@ -159,15 +169,18 @@ class AstrMessageEvent(abc.ABC): 除了文本消息外,其他消息类型会被转换为对应的占位符。如图片消息会被转换为 [图片]。 """ - return self._outline_chain(self.message_obj.message) + return self._outline_chain(getattr(self.message_obj, "message", None)) def get_messages(self) -> list[BaseMessageComponent]: """获取消息链。""" - return self.message_obj.message + return getattr(self.message_obj, "message", []) def get_message_type(self) -> MessageType: """获取消息类型。""" - return self.message_obj.type + message_type = getattr(self.message_obj, "type", None) + if isinstance(message_type, MessageType): + return message_type + return self.session.message_type def get_session_id(self) -> str: """获取会话id。""" @@ -175,21 +188,30 @@ class AstrMessageEvent(abc.ABC): def get_group_id(self) -> str: """获取群组id。如果不是群组消息,返回空字符串。""" - return self.message_obj.group_id + return getattr(self.message_obj, "group_id", "") def get_self_id(self) -> str: """获取机器人自身的id。""" - return self.message_obj.self_id + return getattr(self.message_obj, "self_id", "") def get_sender_id(self) -> str: """获取消息发送者的id。""" - return self.message_obj.sender.user_id + sender = getattr(self.message_obj, "sender", None) + if sender and isinstance(getattr(sender, "user_id", None), str): + return sender.user_id + return "" def get_sender_name(self) -> str: """获取消息发送者的名称。(可能会返回空字符串)""" - if isinstance(self.message_obj.sender.nickname, str): - return self.message_obj.sender.nickname - return "" + sender = getattr(self.message_obj, "sender", None) + if not sender: + return "" + nickname = getattr(sender, "nickname", None) + if nickname is None: + return "" + if isinstance(nickname, str): + return nickname + return str(nickname) def set_extra(self, key, value) -> None: """设置额外的信息。""" @@ -208,7 +230,7 @@ class AstrMessageEvent(abc.ABC): def is_private_chat(self) -> bool: """是否是私聊。""" - return self.message_obj.type.value == (MessageType.FRIEND_MESSAGE).value + return self.get_message_type() == MessageType.FRIEND_MESSAGE def is_wake_up(self) -> bool: """是否是唤醒机器人的事件。""" diff --git a/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_message_event.py b/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_message_event.py index 99ea72731..7e42a0fd8 100644 --- a/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_message_event.py +++ b/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_message_event.py @@ -45,6 +45,19 @@ class AiocqhttpMessageEvent(AstrMessageEvent): if isinstance(segment, File): # For File segments, we need to handle the file differently d = await segment.to_dict() + file_val = d.get("data", {}).get("file", "") + if file_val: + import pathlib + + try: + # 使用 pathlib 处理路径,能更好地处理 Windows/Linux 差异 + path_obj = pathlib.Path(file_val) + # 如果是绝对路径且不包含协议头 (://),则转换为标准的 file: URI + if path_obj.is_absolute() and "://" not in file_val: + d["data"]["file"] = path_obj.as_uri() + except Exception: + # 如果不是合法路径(例如已经是特定的特殊字符串),则跳过转换 + pass return d if isinstance(segment, Video): d = await segment.to_dict() diff --git a/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_platform_adapter.py b/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_platform_adapter.py index fb6c99784..45114382f 100644 --- a/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_platform_adapter.py +++ b/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_platform_adapter.py @@ -1,4 +1,5 @@ import asyncio +import inspect import itertools import logging import time @@ -436,7 +437,42 @@ class AiocqhttpAdapter(Platform): return coro async def terminate(self) -> None: - self.shutdown_event.set() + if hasattr(self, "shutdown_event"): + self.shutdown_event.set() + await self._close_reverse_ws_connections() + + async def _close_reverse_ws_connections(self) -> None: + api_clients = getattr(self.bot, "_wsr_api_clients", None) + event_clients = getattr(self.bot, "_wsr_event_clients", None) + + ws_clients: set[Any] = set() + if isinstance(api_clients, dict): + ws_clients.update(api_clients.values()) + if isinstance(event_clients, set): + ws_clients.update(event_clients) + + close_tasks: list[Awaitable[Any]] = [] + for ws in ws_clients: + close_func = getattr(ws, "close", None) + if not callable(close_func): + continue + try: + close_result = close_func(code=1000, reason="Adapter shutdown") + except TypeError: + close_result = close_func() + except Exception: + continue + + if inspect.isawaitable(close_result): + close_tasks.append(close_result) + + if close_tasks: + await asyncio.gather(*close_tasks, return_exceptions=True) + + if isinstance(api_clients, dict): + api_clients.clear() + if isinstance(event_clients, set): + event_clients.clear() async def shutdown_trigger_placeholder(self) -> None: await self.shutdown_event.wait() diff --git a/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py b/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py index 1af4de49b..4a043e2fc 100644 --- a/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py +++ b/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py @@ -7,13 +7,14 @@ from typing import cast import aiofiles import botpy +import botpy.errors import botpy.message import botpy.types import botpy.types.message from botpy import Client from botpy.http import Route from botpy.types import message -from botpy.types.message import Media +from botpy.types.message import MarkdownPayload, Media from astrbot.api import logger from astrbot.api.event import AstrMessageEvent, MessageChain @@ -24,7 +25,29 @@ from astrbot.core.utils.io import download_image_by_url, file_to_base64 from astrbot.core.utils.tencent_record_helper import wav_to_tencent_silk +def _patch_qq_botpy_formdata() -> None: + """Patch qq-botpy for aiohttp>=3.12 compatibility. + + qq-botpy 1.2.1 defines botpy.http._FormData._gen_form_data() and expects + aiohttp.FormData to have a private flag named _is_processed, which is no + longer present in newer aiohttp versions. + """ + + try: + from botpy.http import _FormData # type: ignore + + if not hasattr(_FormData, "_is_processed"): + setattr(_FormData, "_is_processed", False) + except Exception: + logger.debug("[QQOfficial] Skip botpy FormData patch.") + + +_patch_qq_botpy_formdata() + + class QQOfficialMessageEvent(AstrMessageEvent): + MARKDOWN_NOT_ALLOWED_ERROR = "不允许发送原生 markdown" + def __init__( self, message_str: str, @@ -114,7 +137,9 @@ class QQOfficialMessageEvent(AstrMessageEvent): return None payload: dict = { - "content": plain_text, + # "content": plain_text, + "markdown": MarkdownPayload(content=plain_text) if plain_text else None, + "msg_type": 2, "msg_id": self.message_obj.message_id, } @@ -145,9 +170,13 @@ class QQOfficialMessageEvent(AstrMessageEvent): ) payload["media"] = media payload["msg_type"] = 7 - ret = await self.bot.api.post_group_message( - group_openid=source.group_openid, - **payload, + ret = await self._send_with_markdown_fallback( + send_func=lambda retry_payload: self.bot.api.post_group_message( + group_openid=source.group_openid, # type: ignore + **retry_payload, + ), + payload=payload, + plain_text=plain_text, ) case botpy.message.C2CMessage(): @@ -168,30 +197,53 @@ class QQOfficialMessageEvent(AstrMessageEvent): payload["media"] = media payload["msg_type"] = 7 if stream: - ret = await self.post_c2c_message( - openid=source.author.user_openid, - **payload, - stream=stream, + ret = await self._send_with_markdown_fallback( + send_func=lambda retry_payload: self.post_c2c_message( + openid=source.author.user_openid, + **retry_payload, + stream=stream, + ), + payload=payload, + plain_text=plain_text, ) else: - ret = await self.post_c2c_message( - openid=source.author.user_openid, - **payload, + ret = await self._send_with_markdown_fallback( + send_func=lambda retry_payload: self.post_c2c_message( + openid=source.author.user_openid, + **retry_payload, + ), + payload=payload, + plain_text=plain_text, ) logger.debug(f"Message sent to C2C: {ret}") case botpy.message.Message(): if image_path: payload["file_image"] = image_path - ret = await self.bot.api.post_message( - channel_id=source.channel_id, - **payload, + # Guild text-channel send API (/channels/{channel_id}/messages) does not use v2 msg_type. + payload.pop("msg_type", None) + ret = await self._send_with_markdown_fallback( + send_func=lambda retry_payload: self.bot.api.post_message( + channel_id=source.channel_id, + **retry_payload, + ), + payload=payload, + plain_text=plain_text, ) case botpy.message.DirectMessage(): if image_path: payload["file_image"] = image_path - ret = await self.bot.api.post_dms(guild_id=source.guild_id, **payload) + # Guild DM send API (/dms/{guild_id}/messages) does not use v2 msg_type. + payload.pop("msg_type", None) + ret = await self._send_with_markdown_fallback( + send_func=lambda retry_payload: self.bot.api.post_dms( + guild_id=source.guild_id, + **retry_payload, + ), + payload=payload, + plain_text=plain_text, + ) case _: pass @@ -202,6 +254,32 @@ class QQOfficialMessageEvent(AstrMessageEvent): return ret + async def _send_with_markdown_fallback( + self, + send_func, + payload: dict, + plain_text: str, + ): + try: + return await send_func(payload) + except botpy.errors.ServerError as err: + if ( + self.MARKDOWN_NOT_ALLOWED_ERROR not in str(err) + or not payload.get("markdown") + or not plain_text + ): + raise + + logger.warning( + "[QQOfficial] markdown 发送被拒绝,回退到 content 模式重试。" + ) + fallback_payload = payload.copy() + fallback_payload["markdown"] = None + fallback_payload["content"] = plain_text + if fallback_payload.get("msg_type") == 2: + fallback_payload["msg_type"] = 0 + return await send_func(fallback_payload) + async def upload_group_and_c2c_image( self, image_base64: str, diff --git a/astrbot/core/platform/sources/telegram/tg_adapter.py b/astrbot/core/platform/sources/telegram/tg_adapter.py index 3381c14f3..0e7ac5b10 100644 --- a/astrbot/core/platform/sources/telegram/tg_adapter.py +++ b/astrbot/core/platform/sources/telegram/tg_adapter.py @@ -1,4 +1,5 @@ import asyncio +import os import re import sys import uuid @@ -25,6 +26,9 @@ from astrbot.core.star.filter.command import CommandFilter from astrbot.core.star.filter.command_group import CommandGroupFilter from astrbot.core.star.star import star_map from astrbot.core.star.star_handler import star_handlers_registry +from astrbot.core.utils.astrbot_path import get_astrbot_temp_path +from astrbot.core.utils.io import download_image_by_url +from astrbot.core.utils.media_utils import convert_audio_to_wav from .tg_event import TelegramPlatformEvent @@ -174,14 +178,19 @@ class TelegramPlatformAdapter(Platform): if not handler_metadata.enabled: continue for event_filter in handler_metadata.event_filters: - cmd_info = self._extract_command_info( + cmd_info_list = self._extract_command_info( event_filter, handler_metadata, skip_commands, ) - if cmd_info: - cmd_name, description = cmd_info - command_dict.setdefault(cmd_name, description) + if cmd_info_list: + for cmd_name, description in cmd_info_list: + if cmd_name in command_dict: + logger.warning( + f"命令名 '{cmd_name}' 重复注册,将使用首次注册的定义: " + f"'{command_dict[cmd_name]}'" + ) + command_dict.setdefault(cmd_name, description) commands_a = sorted(command_dict.keys()) return [BotCommand(cmd, command_dict[cmd]) for cmd in commands_a] @@ -191,9 +200,9 @@ class TelegramPlatformAdapter(Platform): event_filter, handler_metadata, skip_commands: set, - ) -> tuple[str, str] | None: - """从事件过滤器中提取指令信息""" - cmd_name = None + ) -> list[tuple[str, str]] | None: + """从事件过滤器中提取指令信息,包括所有别名""" + cmd_names = [] is_group = False if isinstance(event_filter, CommandFilter) and event_filter.command_name: if ( @@ -201,26 +210,32 @@ class TelegramPlatformAdapter(Platform): and event_filter.parent_command_names != [""] ): return None - cmd_name = event_filter.command_name + # 收集主命令名和所有别名 + cmd_names = [event_filter.command_name] + if event_filter.alias: + cmd_names.extend(event_filter.alias) elif isinstance(event_filter, CommandGroupFilter): if event_filter.parent_group: return None - cmd_name = event_filter.group_name + cmd_names = [event_filter.group_name] is_group = True - if not cmd_name or cmd_name in skip_commands: - return None + result = [] + for cmd_name in cmd_names: + if not cmd_name or cmd_name in skip_commands: + continue + if not re.match(r"^[a-z0-9_]+$", cmd_name) or len(cmd_name) > 32: + continue - if not re.match(r"^[a-z0-9_]+$", cmd_name) or len(cmd_name) > 32: - return None + # Build description. + description = handler_metadata.desc or ( + f"Command group: {cmd_name}" if is_group else f"Command: {cmd_name}" + ) + if len(description) > 30: + description = description[:30] + "..." + result.append((cmd_name, description)) - # Build description. - description = handler_metadata.desc or ( - f"指令组: {cmd_name} (包含多个子指令)" if is_group else f"指令: {cmd_name}" - ) - if len(description) > 30: - description = description[:30] + "..." - return cmd_name, description + return result if result else None async def start(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: if not update.effective_chat: @@ -364,8 +379,19 @@ class TelegramPlatformAdapter(Platform): elif update.message.voice: file = await update.message.voice.get_file() + + file_basename = os.path.basename(file.file_path) + temp_dir = get_astrbot_temp_path() + temp_path = os.path.join(temp_dir, file_basename) + temp_path = await download_image_by_url(file.file_path, path=temp_path) + path_wav = os.path.join( + temp_dir, + f"{file_basename}.wav", + ) + path_wav = await convert_audio_to_wav(temp_path, path_wav) + message.message = [ - Comp.Record(file=file.file_path, url=file.file_path), + Comp.Record(file=path_wav, url=path_wav), ] elif update.message.photo: diff --git a/astrbot/core/platform/sources/telegram/tg_event.py b/astrbot/core/platform/sources/telegram/tg_event.py index ffa58e1a8..d7e3f1678 100644 --- a/astrbot/core/platform/sources/telegram/tg_event.py +++ b/astrbot/core/platform/sources/telegram/tg_event.py @@ -6,6 +6,7 @@ from typing import Any, cast import telegramify_markdown from telegram import ReactionTypeCustomEmoji, ReactionTypeEmoji from telegram.constants import ChatAction +from telegram.error import BadRequest from telegram.ext import ExtBot from astrbot import logger @@ -119,6 +120,65 @@ class TelegramPlatformEvent(AstrMessageEvent): client, user_name, ChatAction.TYPING, message_thread_id ) + @classmethod + async def _send_voice_with_fallback( + cls, + client: ExtBot, + path: str, + payload: dict[str, Any], + *, + caption: str | None = None, + user_name: str = "", + message_thread_id: str | None = None, + use_media_action: bool = False, + ) -> None: + """Send a voice message, falling back to a document if the user's + privacy settings forbid voice messages (``BadRequest`` with + ``Voice_messages_forbidden``). + + When *use_media_action* is ``True`` the helper wraps the send calls + with ``_send_media_with_action`` (used by the streaming path). + """ + try: + if use_media_action: + await cls._send_media_with_action( + client, + ChatAction.UPLOAD_VOICE, + client.send_voice, + user_name=user_name, + message_thread_id=message_thread_id, + voice=path, + **cast(Any, payload), + ) + else: + await client.send_voice(voice=path, **cast(Any, payload)) + except BadRequest as e: + # python-telegram-bot raises BadRequest for Voice_messages_forbidden; + # distinguish the voice-privacy case via the API error message. + if "Voice_messages_forbidden" not in e.message: + raise + logger.warning( + "User privacy settings prevent receiving voice messages, falling back to sending an audio file. " + "To enable voice messages, go to Telegram Settings → Privacy and Security → Voice Messages → set to 'Everyone'." + ) + if use_media_action: + await cls._send_media_with_action( + client, + ChatAction.UPLOAD_DOCUMENT, + client.send_document, + user_name=user_name, + message_thread_id=message_thread_id, + document=path, + caption=caption, + **cast(Any, payload), + ) + else: + await client.send_document( + document=path, + caption=caption, + **cast(Any, payload), + ) + async def _ensure_typing( self, user_name: str, @@ -211,7 +271,13 @@ class TelegramPlatformEvent(AstrMessageEvent): ) elif isinstance(i, Record): path = await i.convert_to_file_path() - await client.send_voice(voice=path, **cast(Any, payload)) + await cls._send_voice_with_fallback( + client, + path, + payload, + caption=i.text or None, + use_media_action=False, + ) async def send(self, message: MessageChain) -> None: if self.get_message_type() == MessageType.GROUP_MESSAGE: @@ -330,14 +396,14 @@ class TelegramPlatformEvent(AstrMessageEvent): continue elif isinstance(i, Record): path = await i.convert_to_file_path() - await self._send_media_with_action( + await self._send_voice_with_fallback( self.client, - ChatAction.UPLOAD_VOICE, - self.client.send_voice, + path, + payload, + caption=i.text or delta or None, user_name=user_name, message_thread_id=message_thread_id, - voice=path, - **cast(Any, payload), + use_media_action=True, ) continue else: diff --git a/astrbot/core/platform/sources/webchat/webchat_event.py b/astrbot/core/platform/sources/webchat/webchat_event.py index a3d1cc3c3..a680f7617 100644 --- a/astrbot/core/platform/sources/webchat/webchat_event.py +++ b/astrbot/core/platform/sources/webchat/webchat_event.py @@ -11,13 +11,13 @@ from astrbot.core.utils.astrbot_path import get_astrbot_data_path from .webchat_queue_mgr import webchat_queue_mgr -imgs_dir = os.path.join(get_astrbot_data_path(), "webchat", "imgs") +attachments_dir = os.path.join(get_astrbot_data_path(), "attachments") class WebChatMessageEvent(AstrMessageEvent): def __init__(self, message_str, message_obj, platform_meta, session_id) -> None: super().__init__(message_str, message_obj, platform_meta, session_id) - os.makedirs(imgs_dir, exist_ok=True) + os.makedirs(attachments_dir, exist_ok=True) @staticmethod async def _send( @@ -69,7 +69,7 @@ class WebChatMessageEvent(AstrMessageEvent): elif isinstance(comp, Image): # save image to local filename = f"{str(uuid.uuid4())}.jpg" - path = os.path.join(imgs_dir, filename) + path = os.path.join(attachments_dir, filename) image_base64 = await comp.convert_to_base64() with open(path, "wb") as f: f.write(base64.b64decode(image_base64)) @@ -85,7 +85,7 @@ class WebChatMessageEvent(AstrMessageEvent): elif isinstance(comp, Record): # save record to local filename = f"{str(uuid.uuid4())}.wav" - path = os.path.join(imgs_dir, filename) + path = os.path.join(attachments_dir, filename) record_base64 = await comp.convert_to_base64() with open(path, "wb") as f: f.write(base64.b64decode(record_base64)) @@ -104,7 +104,7 @@ class WebChatMessageEvent(AstrMessageEvent): original_name = comp.name or os.path.basename(file_path) ext = os.path.splitext(original_name)[1] or "" filename = f"{uuid.uuid4()!s}{ext}" - dest_path = os.path.join(imgs_dir, filename) + dest_path = os.path.join(attachments_dir, filename) shutil.copy2(file_path, dest_path) data = f"[FILE]{filename}" await web_chat_back_queue.put( 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 28985f757..5020624a8 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 @@ -1,13 +1,14 @@ import asyncio import os import sys +import time import uuid from collections.abc import Awaitable, Callable from typing import Any, cast import quart from requests import Response -from wechatpy import WeChatClient, parse_message +from wechatpy import WeChatClient, create_reply, parse_message from wechatpy.crypto import WeChatCrypto from wechatpy.exceptions import InvalidSignatureException from wechatpy.messages import BaseMessage, ImageMessage, TextMessage, VoiceMessage @@ -38,7 +39,12 @@ else: class WeixinOfficialAccountServer: - def __init__(self, event_queue: asyncio.Queue, config: dict) -> None: + def __init__( + self, + event_queue: asyncio.Queue, + config: dict, + user_buffer: dict[Any, dict[str, Any]], + ) -> None: self.server = quart.Quart(__name__) self.port = int(cast(int | str, config.get("port"))) self.callback_server_host = config.get("callback_server_host", "0.0.0.0") @@ -62,6 +68,10 @@ class WeixinOfficialAccountServer: self.callback: Callable[[BaseMessage], Awaitable[None]] | None = None self.shutdown_event = asyncio.Event() + self._wx_msg_time_out = 4.0 # 微信服务器要求 5 秒内回复 + self.user_buffer: dict[str, dict[str, Any]] = user_buffer # from_user -> state + self.active_send_mode = False # 是否启用主动发送模式,启用后 callback 将直接返回回复内容,无需等待微信回调 + async def verify(self): """内部服务器的 GET 验证入口""" return await self.handle_verify(quart.request) @@ -98,6 +108,22 @@ class WeixinOfficialAccountServer: """内部服务器的 POST 回调入口""" return await self.handle_callback(quart.request) + def _maybe_encrypt(self, xml: str, nonce: str | None, timestamp: str | None) -> str: + if xml and "" not in xml and nonce and timestamp: + return self.crypto.encrypt_message(xml, nonce, timestamp) + return xml or "success" + + def _preview(self, msg: BaseMessage, limit: int = 24) -> str: + """生成消息预览文本,供占位符使用""" + if isinstance(msg, TextMessage): + t = cast(str, msg.content).strip() + return (t[:limit] + "...") if len(t) > limit else (t or "空消息") + if isinstance(msg, ImageMessage): + return "图片" + if isinstance(msg, VoiceMessage): + return "语音" + return getattr(msg, "type", "未知消息") + async def handle_callback(self, request) -> str: """处理回调请求,可被统一 webhook 入口复用 @@ -123,14 +149,152 @@ class WeixinOfficialAccountServer: raise logger.info(f"解析成功: {msg}") - if self.callback: + if not self.callback: + return "success" + + # by pass passive reply logic and return active reply directly. + if self.active_send_mode: result_xml = await self.callback(msg) if not result_xml: return "success" if isinstance(result_xml, str): return result_xml - return "success" + # passive reply + from_user = str(getattr(msg, "source", "")) + msg_id = str(cast(str | int, getattr(msg, "id", ""))) + state = self.user_buffer.get(from_user) + + def _reply_text(text: str) -> str: + reply_obj = create_reply(text, msg) + reply_xml = reply_obj if isinstance(reply_obj, str) else str(reply_obj) + return self._maybe_encrypt(reply_xml, nonce, timestamp) + + # if in cached state, return cached result or placeholder + if state: + logger.debug(f"用户消息缓冲状态: user={from_user} state={state}") + cached = state.get("cached_xml") + # send one cached each time, if cached is empty after pop, remove the buffer + if cached and len(cached) > 0: + logger.info(f"wx buffer hit on trigger: user={from_user}") + cached_xml = cached.pop(0) + if len(cached) == 0: + self.user_buffer.pop(from_user, None) + return _reply_text(cached_xml) + else: + return _reply_text( + cached_xml + + "\n【后续消息还在缓冲中,回复任意文字继续获取】" + ) + + task: asyncio.Task | None = cast(asyncio.Task | None, state.get("task")) + placeholder = ( + f"【正在思考'{state.get('preview', '...')}'中,已思考" + f"{int(time.monotonic() - state.get('started_at', time.monotonic()))}s,回复任意文字尝试获取回复】" + ) + + # same msgid => WeChat retry: wait a little; new msgid => user trigger: just placeholder + if task and state.get("msg_id") == msg_id: + done, _ = await asyncio.wait( + {task}, + timeout=self._wx_msg_time_out, + return_when=asyncio.FIRST_COMPLETED, + ) + if done: + try: + cached = state.get("cached_xml") + # send one cached each time, if cached is empty after pop, remove the buffer + if cached and len(cached) > 0: + logger.info( + f"wx buffer hit on retry window: user={from_user}" + ) + cached_xml = cached.pop(0) + if len(cached) == 0: + self.user_buffer.pop(from_user, None) + logger.debug( + f"wx finished message sending in passive window: user={from_user} msg_id={msg_id} " + ) + return _reply_text(cached_xml) + else: + logger.debug( + f"wx finished message sending in passive window but not final: user={from_user} msg_id={msg_id} " + ) + return _reply_text( + cached_xml + + "\n【后续消息还在缓冲中,回复任意文字继续获取】" + ) + logger.info( + f"wx finished in window but not final; return placeholder: user={from_user} msg_id={msg_id} " + ) + return _reply_text(placeholder) + except Exception: + logger.critical( + "wx task failed in passive window", exc_info=True + ) + self.user_buffer.pop(from_user, None) + return _reply_text("处理消息失败,请稍后再试。") + + logger.info( + f"wx passive window timeout: user={from_user} msg_id={msg_id}" + ) + return _reply_text(placeholder) + + logger.debug(f"wx trigger while thinking: user={from_user}") + return _reply_text(placeholder) + + # create new trigger when state is empty, and store state in buffer + logger.debug(f"wx new trigger: user={from_user} msg_id={msg_id}") + preview = self._preview(msg) + placeholder = ( + f"【正在思考'{preview}'中,已思考0s,回复任意文字尝试获取回复】" + ) + logger.info( + f"wx start task: user={from_user} msg_id={msg_id} preview={preview}" + ) + + self.user_buffer[from_user] = state = { + "msg_id": msg_id, + "preview": preview, + "task": None, # set later after task created + "cached_xml": [], # for passive reply + "started_at": time.monotonic(), + } + self.user_buffer[from_user]["task"] = task = asyncio.create_task( + self.callback(msg) + ) + + # immediate return if done + done, _ = await asyncio.wait( + {task}, + timeout=self._wx_msg_time_out, + return_when=asyncio.FIRST_COMPLETED, + ) + if done: + try: + cached = state.get("cached_xml", None) + # send one cached each time, if cached is empty after pop, remove the buffer + if cached and len(cached) > 0: + logger.info(f"wx buffer hit immediately: user={from_user}") + cached_xml = cached.pop(0) + if len(cached) == 0: + self.user_buffer.pop(from_user, None) + return _reply_text(cached_xml) + else: + return _reply_text( + cached_xml + + "\n【后续消息还在缓冲中,回复任意文字继续获取】" + ) + logger.info( + f"wx not finished in first window; return placeholder: user={from_user} msg_id={msg_id} " + ) + return _reply_text(placeholder) + except Exception: + logger.critical("wx task failed in first window", exc_info=True) + self.user_buffer.pop(from_user, None) + return _reply_text("处理消息失败,请稍后再试。") + + logger.info(f"wx first window timeout: user={from_user} msg_id={msg_id}") + return _reply_text(placeholder) async def start_polling(self) -> None: logger.info( @@ -176,7 +340,10 @@ class WeixinOfficialAccountPlatformAdapter(Platform): if not self.api_base_url.endswith("/"): self.api_base_url += "/" - self.server = WeixinOfficialAccountServer(self._event_queue, self.config) + self.user_buffer: dict[str, dict[str, Any]] = {} # from_user -> state + self.server = WeixinOfficialAccountServer( + self._event_queue, self.config, self.user_buffer + ) self.client = WeChatClient( self.config["appid"].strip(), @@ -193,28 +360,33 @@ class WeixinOfficialAccountPlatformAdapter(Platform): try: if self.active_send_mode: await self.convert_message(msg, None) + return None + + msg_id = str(cast(str | int, msg.id)) + future = self.wexin_event_workers.get(msg_id) + if future: + logger.debug(f"duplicate message id checked: {msg.id}") else: - if str(msg.id) in self.wexin_event_workers: - future = self.wexin_event_workers[str(cast(str | int, msg.id))] - logger.debug(f"duplicate message id checked: {msg.id}") - else: - future = asyncio.get_event_loop().create_future() - self.wexin_event_workers[str(cast(str | int, msg.id))] = future - await self.convert_message(msg, future) + future = asyncio.get_event_loop().create_future() + self.wexin_event_workers[msg_id] = future + await self.convert_message(msg, future) # I love shield so much! result = await asyncio.wait_for( asyncio.shield(future), - 60, - ) # wait for 60s - logger.debug(f"Got future result: {result}") - self.wexin_event_workers.pop(str(cast(str | int, msg.id)), None) - return result # xml. see weixin_offacc_event.py + 180, + ) # wait for 180s + logger.debug(f"Got future result: {result}") + return result except asyncio.TimeoutError: - pass + logger.info(f"callback 处理消息超时: message_id={msg.id}") + return create_reply("处理消息超时,请稍后再试。", msg) except Exception as e: logger.error(f"转换消息时出现异常: {e}") + finally: + self.wexin_event_workers.pop(str(cast(str | int, msg.id)), None) self.server.callback = callback + self.server.active_send_mode = self.active_send_mode @override async def send_by_session( @@ -336,12 +508,19 @@ class WeixinOfficialAccountPlatformAdapter(Platform): await self.handle_msg(abm) async def handle_msg(self, message: AstrBotMessage) -> None: + buffer = self.user_buffer.get(message.sender.user_id, None) + if buffer is None: + logger.critical( + f"用户消息未找到缓冲状态,无法处理消息: user={message.sender.user_id} message_id={message.message_id}" + ) + return message_event = WeixinOfficialAccountPlatformEvent( message_str=message.message_str, message_obj=message, platform_meta=self.meta(), session_id=message.session_id, client=self.client, + message_out=buffer, ) self.commit_event(message_event) diff --git a/astrbot/core/platform/sources/weixin_official_account/weixin_offacc_event.py b/astrbot/core/platform/sources/weixin_official_account/weixin_offacc_event.py index 816893be2..ae536593c 100644 --- a/astrbot/core/platform/sources/weixin_official_account/weixin_offacc_event.py +++ b/astrbot/core/platform/sources/weixin_official_account/weixin_offacc_event.py @@ -1,9 +1,9 @@ import asyncio import os -from typing import cast +from typing import Any, cast from wechatpy import WeChatClient -from wechatpy.replies import ImageReply, TextReply, VoiceReply +from wechatpy.replies import ImageReply, VoiceReply from astrbot.api import logger from astrbot.api.event import AstrMessageEvent, MessageChain @@ -20,9 +20,11 @@ class WeixinOfficialAccountPlatformEvent(AstrMessageEvent): platform_meta: PlatformMetadata, session_id: str, client: WeChatClient, + message_out: dict[Any, Any], ) -> None: super().__init__(message_str, message_obj, platform_meta, session_id) self.client = client + self.message_out = message_out @staticmethod async def send_with_client( @@ -32,8 +34,8 @@ class WeixinOfficialAccountPlatformEvent(AstrMessageEvent): ) -> None: pass - async def split_plain(self, plain: str) -> list[str]: - """将长文本分割成多个小文本, 每个小文本长度不超过 2048 字符 + async def split_plain(self, plain: str, max_length: int = 1024) -> list[str]: + """将长文本分割成多个小文本, 每个小文本长度不超过 max_length 字符 Args: plain (str): 要分割的长文本 @@ -41,18 +43,18 @@ class WeixinOfficialAccountPlatformEvent(AstrMessageEvent): list[str]: 分割后的文本列表 """ - if len(plain) <= 2048: + if len(plain) <= max_length: return [plain] result = [] start = 0 while start < len(plain): - # 剩下的字符串长度<2048时结束 - if start + 2048 >= len(plain): + # 剩下的字符串长度= len(plain): result.append(plain[start:]) break # 向前搜索分割标点符号 - end = min(start + 2048, len(plain)) + end = min(start + max_length, len(plain)) cut_position = end for i in range(end, start, -1): if i < len(plain) and plain[i - 1] in [ @@ -87,19 +89,15 @@ class WeixinOfficialAccountPlatformEvent(AstrMessageEvent): if isinstance(comp, Plain): # Split long text messages if needed plain_chunks = await self.split_plain(comp.text) - for chunk in plain_chunks: - if active_send_mode: + if active_send_mode: + for chunk in plain_chunks: self.client.message.send_text(message_obj.sender.user_id, chunk) - else: - reply = TextReply( - content=chunk, - message=cast(dict, self.message_obj.raw_message)["message"], - ) - xml = reply.render() - future = cast(dict, self.message_obj.raw_message)["future"] - assert isinstance(future, asyncio.Future) - future.set_result(xml) - await asyncio.sleep(0.5) # Avoid sending too fast + else: + # disable passive sending, just store the chunks in + logger.debug( + f"split plain into {len(plain_chunks)} chunks for passive reply. Message not sent." + ) + self.message_out["cached_xml"] = plain_chunks elif isinstance(comp, Image): img_path = await comp.convert_to_file_path() diff --git a/astrbot/core/provider/manager.py b/astrbot/core/provider/manager.py index ff0bb303d..a331c97e9 100644 --- a/astrbot/core/provider/manager.py +++ b/astrbot/core/provider/manager.py @@ -295,6 +295,16 @@ class ProviderManager: from .sources.zhipu_source import ProviderZhipu as ProviderZhipu case "groq_chat_completion": from .sources.groq_source import ProviderGroq as ProviderGroq + case "xai_chat_completion": + from .sources.xai_source import ProviderXAI as ProviderXAI + case "aihubmix_chat_completion": + from .sources.oai_aihubmix_source import ( + ProviderAIHubMix as ProviderAIHubMix, + ) + case "openrouter_chat_completion": + from .sources.openrouter_source import ( + ProviderOpenRouter as ProviderOpenRouter, + ) case "anthropic_chat_completion": from .sources.anthropic_source import ( ProviderAnthropic as ProviderAnthropic, diff --git a/astrbot/core/provider/sources/anthropic_source.py b/astrbot/core/provider/sources/anthropic_source.py index 80684aca6..ec3c395a4 100644 --- a/astrbot/core/provider/sources/anthropic_source.py +++ b/astrbot/core/provider/sources/anthropic_source.py @@ -33,20 +33,29 @@ class ProviderAnthropic(Provider): self, provider_config, provider_settings, + *, + use_api_key: bool = True, ) -> None: super().__init__( provider_config, provider_settings, ) - self.chosen_api_key: str = "" - self.api_keys: list = super().get_keys() - self.chosen_api_key = self.api_keys[0] if len(self.api_keys) > 0 else "" self.base_url = provider_config.get("api_base", "https://api.anthropic.com") self.timeout = provider_config.get("timeout", 120) if isinstance(self.timeout, str): self.timeout = int(self.timeout) + self.thinking_config = provider_config.get("anth_thinking_config", {}) + if use_api_key: + self._init_api_key(provider_config) + + self.set_model(provider_config.get("model", "unknown")) + + def _init_api_key(self, provider_config: dict) -> None: + self.chosen_api_key: str = "" + self.api_keys: list = super().get_keys() + self.chosen_api_key = self.api_keys[0] if len(self.api_keys) > 0 else "" self.client = AsyncAnthropic( api_key=self.chosen_api_key, timeout=self.timeout, @@ -54,15 +63,27 @@ class ProviderAnthropic(Provider): http_client=self._create_http_client(provider_config), ) - self.thinking_config = provider_config.get("anth_thinking_config", {}) - - self.set_model(provider_config.get("model", "unknown")) - def _create_http_client(self, provider_config: dict) -> httpx.AsyncClient | None: """创建带代理的 HTTP 客户端""" proxy = provider_config.get("proxy", "") return create_proxy_client("Anthropic", proxy) + def _apply_thinking_config(self, payloads: dict) -> None: + thinking_type = self.thinking_config.get("type", "") + if thinking_type == "adaptive": + payloads["thinking"] = {"type": "adaptive"} + effort = self.thinking_config.get("effort", "") + output_cfg = dict(payloads.get("output_config", {})) + if effort: + output_cfg["effort"] = effort + if output_cfg: + payloads["output_config"] = output_cfg + elif not thinking_type and self.thinking_config.get("budget"): + payloads["thinking"] = { + "budget_tokens": self.thinking_config.get("budget"), + "type": "enabled", + } + def _prepare_payload(self, messages: list[dict]): """准备 Anthropic API 的请求 payload @@ -213,11 +234,7 @@ class ProviderAnthropic(Provider): if "max_tokens" not in payloads: payloads["max_tokens"] = 1024 - if self.thinking_config.get("budget"): - payloads["thinking"] = { - "budget_tokens": self.thinking_config.get("budget"), - "type": "enabled", - } + self._apply_thinking_config(payloads) try: completion = await self.client.messages.create( @@ -287,11 +304,7 @@ class ProviderAnthropic(Provider): if "max_tokens" not in payloads: payloads["max_tokens"] = 1024 - if self.thinking_config.get("budget"): - payloads["thinking"] = { - "budget_tokens": self.thinking_config.get("budget"), - "type": "enabled", - } + self._apply_thinking_config(payloads) async with self.client.messages.stream( **payloads, extra_body=extra_body diff --git a/astrbot/core/provider/sources/gemini_embedding_source.py b/astrbot/core/provider/sources/gemini_embedding_source.py index 32467056c..61ba9cadb 100644 --- a/astrbot/core/provider/sources/gemini_embedding_source.py +++ b/astrbot/core/provider/sources/gemini_embedding_source.py @@ -48,6 +48,9 @@ class GeminiEmbeddingProvider(EmbeddingProvider): result = await self.client.models.embed_content( model=self.model, contents=text, + config=types.EmbedContentConfig( + output_dimensionality=self.get_dim(), + ), ) assert result.embeddings is not None assert result.embeddings[0].values is not None @@ -61,6 +64,9 @@ class GeminiEmbeddingProvider(EmbeddingProvider): result = await self.client.models.embed_content( model=self.model, contents=cast(types.ContentListUnion, text), + config=types.EmbedContentConfig( + output_dimensionality=self.get_dim(), + ), ) assert result.embeddings is not None diff --git a/astrbot/core/provider/sources/oai_aihubmix_source.py b/astrbot/core/provider/sources/oai_aihubmix_source.py new file mode 100644 index 000000000..ca8ad5959 --- /dev/null +++ b/astrbot/core/provider/sources/oai_aihubmix_source.py @@ -0,0 +1,17 @@ +from ..register import register_provider_adapter +from .openai_source import ProviderOpenAIOfficial + + +@register_provider_adapter( + "aihubmix_chat_completion", "AIHubMix Chat Completion Provider Adapter" +) +class ProviderAIHubMix(ProviderOpenAIOfficial): + def __init__( + self, + provider_config: dict, + provider_settings: dict, + ) -> None: + super().__init__(provider_config, provider_settings) + # Reference to: https://aihubmix.com/appstore + # Use this code can enjoy 10% off prices for AIHubMix API calls. + self.client._custom_headers["APP-Code"] = "KRLC5702" # type: ignore diff --git a/astrbot/core/provider/sources/openai_embedding_source.py b/astrbot/core/provider/sources/openai_embedding_source.py index 170bab833..b686a6ee6 100644 --- a/astrbot/core/provider/sources/openai_embedding_source.py +++ b/astrbot/core/provider/sources/openai_embedding_source.py @@ -36,12 +36,20 @@ class OpenAIEmbeddingProvider(EmbeddingProvider): async def get_embedding(self, text: str) -> list[float]: """获取文本的嵌入""" - embedding = await self.client.embeddings.create(input=text, model=self.model) + embedding = await self.client.embeddings.create( + input=text, + model=self.model, + dimensions=self.get_dim(), + ) return embedding.data[0].embedding async def get_embeddings(self, text: list[str]) -> list[list[float]]: """批量获取文本的嵌入""" - embeddings = await self.client.embeddings.create(input=text, model=self.model) + embeddings = await self.client.embeddings.create( + input=text, + model=self.model, + dimensions=self.get_dim(), + ) return [item.embedding for item in embeddings.data] def get_dim(self) -> int: diff --git a/astrbot/core/provider/sources/openai_source.py b/astrbot/core/provider/sources/openai_source.py index 328da2573..adee24073 100644 --- a/astrbot/core/provider/sources/openai_source.py +++ b/astrbot/core/provider/sources/openai_source.py @@ -323,7 +323,8 @@ class ProviderOpenAIOfficial(Provider): llm_response.reasoning_content = reasoning _y = True if delta.content: - completion_text = delta.content + # Don't strip streaming chunks to preserve spaces between words + completion_text = self._normalize_content(delta.content, strip=False) llm_response.result_chain = MessageChain( chain=[Comp.Plain(completion_text)], ) @@ -371,6 +372,96 @@ class ProviderOpenAIOfficial(Provider): output=completion_tokens, ) + @staticmethod + def _normalize_content(raw_content: Any, strip: bool = True) -> str: + """Normalize content from various formats to plain string. + + Some LLM providers return content as list[dict] format + like [{'type': 'text', 'text': '...'}] instead of + plain string. This method handles both formats. + + Args: + raw_content: The raw content from LLM response, can be str, list, dict, or other. + strip: Whether to strip whitespace from the result. Set to False for + streaming chunks to preserve spaces between words. + + Returns: + Normalized plain text string. + """ + # Handle dict format (e.g., {"type": "text", "text": "..."}) + if isinstance(raw_content, dict): + if "text" in raw_content: + text_val = raw_content.get("text", "") + return str(text_val) if text_val is not None else "" + # For other dict formats, return empty string and log + logger.warning(f"Unexpected dict format content: {raw_content}") + return "" + + if isinstance(raw_content, list): + # Check if this looks like OpenAI content-part format + # Only process if at least one item has {'type': 'text', 'text': ...} structure + has_content_part = any( + isinstance(part, dict) and part.get("type") == "text" + for part in raw_content + ) + if has_content_part: + text_parts = [] + for part in raw_content: + if isinstance(part, dict) and part.get("type") == "text": + text_val = part.get("text", "") + # Coerce to str in case text is null or non-string + text_parts.append(str(text_val) if text_val is not None else "") + return "".join(text_parts) + # Not content-part format, return string representation + return str(raw_content) + + if isinstance(raw_content, str): + content = raw_content.strip() if strip else raw_content + # Check if the string is a JSON-encoded list (e.g., "[{'type': 'text', ...}]") + # This can happen when streaming concatenates content that was originally list format + # Only check if it looks like a complete JSON array (requires strip for check) + check_content = raw_content.strip() + if ( + check_content.startswith("[") + and check_content.endswith("]") + and len(check_content) < 8192 + ): + try: + # First try standard JSON parsing + parsed = json.loads(check_content) + except json.JSONDecodeError: + # If that fails, try parsing as Python literal (handles single quotes) + # This is safer than blind replace("'", '"') which corrupts apostrophes + try: + import ast + + parsed = ast.literal_eval(check_content) + except (ValueError, SyntaxError): + parsed = None + + if isinstance(parsed, list): + # Only convert if it matches OpenAI content-part schema + # i.e., at least one item has {'type': 'text', 'text': ...} + has_content_part = any( + isinstance(part, dict) and part.get("type") == "text" + for part in parsed + ) + if has_content_part: + text_parts = [] + for part in parsed: + if isinstance(part, dict) and part.get("type") == "text": + text_val = part.get("text", "") + # Coerce to str in case text is null or non-string + text_parts.append( + str(text_val) if text_val is not None else "" + ) + if text_parts: + return "".join(text_parts) + return content + + # Fallback for other types (int, float, etc.) + return str(raw_content) if raw_content is not None else "" + async def _parse_openai_completion( self, completion: ChatCompletion, tools: ToolSet | None ) -> LLMResponse: @@ -383,8 +474,7 @@ class ProviderOpenAIOfficial(Provider): # parse the text completion if choice.message.content is not None: - # text completion - completion_text = str(choice.message.content).strip() + completion_text = self._normalize_content(choice.message.content) # specially, some providers may set tags around reasoning content in the completion text, # we use regex to remove them, and store then in reasoning_content field reasoning_pattern = re.compile(r"(.*?)", re.DOTALL) @@ -394,6 +484,8 @@ class ProviderOpenAIOfficial(Provider): [match.strip() for match in matches], ) completion_text = reasoning_pattern.sub("", completion_text).strip() + # Also clean up orphan tags that may leak from some models + completion_text = re.sub(r"\s*$", "", completion_text).strip() llm_response.result_chain = MessageChain().message(completion_text) # parse the reasoning content if any diff --git a/astrbot/core/provider/sources/openrouter_source.py b/astrbot/core/provider/sources/openrouter_source.py new file mode 100644 index 000000000..2cb446cf3 --- /dev/null +++ b/astrbot/core/provider/sources/openrouter_source.py @@ -0,0 +1,19 @@ +from ..register import register_provider_adapter +from .openai_source import ProviderOpenAIOfficial + + +@register_provider_adapter( + "openrouter_chat_completion", "OpenRouter Chat Completion Provider Adapter" +) +class ProviderOpenRouter(ProviderOpenAIOfficial): + def __init__( + self, + provider_config: dict, + provider_settings: dict, + ) -> None: + super().__init__(provider_config, provider_settings) + # Reference to: https://openrouter.ai/docs/api/reference/overview#headers + self.client._custom_headers["HTTP-Referer"] = ( # type: ignore + "https://github.com/AstrBotDevs/AstrBot" + ) + self.client._custom_headers["X-TITLE"] = "AstrBot" # type: ignore diff --git a/astrbot/core/provider/sources/sensevoice_selfhosted_source.py b/astrbot/core/provider/sources/sensevoice_selfhosted_source.py index 965b83a5a..af6c0f631 100644 --- a/astrbot/core/provider/sources/sensevoice_selfhosted_source.py +++ b/astrbot/core/provider/sources/sensevoice_selfhosted_source.py @@ -7,12 +7,14 @@ import asyncio import os import re from datetime import datetime +from pathlib import Path from typing import cast from funasr_onnx import SenseVoiceSmall from funasr_onnx.utils.postprocess_utils import rich_transcription_postprocess from astrbot.core import logger +from astrbot.core.utils.astrbot_path import get_astrbot_temp_path from astrbot.core.utils.io import download_file from astrbot.core.utils.tencent_record_helper import tencent_silk_to_wav @@ -50,7 +52,9 @@ class ProviderSenseVoiceSTTSelfHost(STTProvider): async def get_timestamped_path(self) -> str: timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - return os.path.join("data", "temp", f"{timestamp}") + temp_dir = Path(get_astrbot_temp_path()) + temp_dir.mkdir(parents=True, exist_ok=True) + return str(temp_dir / timestamp) async def _is_silk_file(self, file_path) -> bool: silk_header = b"SILK" diff --git a/astrbot/core/star/__init__.py b/astrbot/core/star/__init__.py index 2bf86872e..796e0bd68 100644 --- a/astrbot/core/star/__init__.py +++ b/astrbot/core/star/__init__.py @@ -1,68 +1,19 @@ -from astrbot.core import html_renderer +# 兼容导出: Provider 从 provider 模块重新导出 from astrbot.core.provider import Provider -from astrbot.core.star.star_tools import StarTools -from astrbot.core.utils.command_parser import CommandParserMixin -from astrbot.core.utils.plugin_kv_store import PluginKVStoreMixin +from .base import Star from .context import Context from .star import StarMetadata, star_map, star_registry from .star_manager import PluginManager +from .star_tools import StarTools - -class Star(CommandParserMixin, PluginKVStoreMixin): - """所有插件(Star)的父类,所有插件都应该继承于这个类""" - - author: str - name: str - - def __init__(self, context: Context, config: dict | None = None) -> None: - StarTools.initialize(context) - self.context = context - - def __init_subclass__(cls, **kwargs): - super().__init_subclass__(**kwargs) - if not star_map.get(cls.__module__): - metadata = StarMetadata( - star_cls_type=cls, - module_path=cls.__module__, - ) - star_map[cls.__module__] = metadata - star_registry.append(metadata) - else: - star_map[cls.__module__].star_cls_type = cls - star_map[cls.__module__].module_path = cls.__module__ - - async def text_to_image(self, text: str, return_url=True) -> str: - """将文本转换为图片""" - return await html_renderer.render_t2i( - text, - return_url=return_url, - template_name=self.context._config.get("t2i_active_template"), - ) - - async def html_render( - self, - tmpl: str, - data: dict, - return_url=True, - options: dict | None = None, - ) -> str: - """渲染 HTML""" - return await html_renderer.render_custom_template( - tmpl, - data, - return_url=return_url, - options=options, - ) - - async def initialize(self) -> None: - """当插件被激活时会调用这个方法""" - - async def terminate(self) -> None: - """当插件被禁用、重载插件时会调用这个方法""" - - def __del__(self) -> None: - """[Deprecated] 当插件被禁用、重载插件时会调用这个方法""" - - -__all__ = ["Context", "PluginManager", "Provider", "Star", "StarMetadata", "StarTools"] +__all__ = [ + "Context", + "PluginManager", + "Provider", + "Star", + "StarMetadata", + "StarTools", + "star_map", + "star_registry", +] diff --git a/astrbot/core/star/base.py b/astrbot/core/star/base.py new file mode 100644 index 000000000..dd3ae3f0e --- /dev/null +++ b/astrbot/core/star/base.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +import logging +from typing import Any, Protocol + +from astrbot.core import html_renderer +from astrbot.core.utils.command_parser import CommandParserMixin +from astrbot.core.utils.plugin_kv_store import PluginKVStoreMixin + +from .star import StarMetadata, star_map, star_registry + +logger = logging.getLogger("astrbot") + + +class Star(CommandParserMixin, PluginKVStoreMixin): + """所有插件(Star)的父类,所有插件都应该继承于这个类""" + + author: str + name: str + + class _ContextLike(Protocol): + def get_config(self, umo: str | None = None) -> Any: ... + + def __init__(self, context: _ContextLike, config: dict | None = None) -> None: + self.context = context + + def _get_context_config(self) -> Any: + get_config = getattr(self.context, "get_config", None) + if callable(get_config): + try: + return get_config() + except Exception as e: + logger.debug(f"get_config() failed: {e}") + return None + return getattr(self.context, "_config", None) + + def __init_subclass__(cls, **kwargs): + super().__init_subclass__(**kwargs) + if not star_map.get(cls.__module__): + metadata = StarMetadata( + star_cls_type=cls, + module_path=cls.__module__, + ) + star_map[cls.__module__] = metadata + star_registry.append(metadata) + else: + star_map[cls.__module__].star_cls_type = cls + star_map[cls.__module__].module_path = cls.__module__ + + async def text_to_image(self, text: str, return_url=True) -> str: + """将文本转换为图片""" + config_obj = self._get_context_config() + template_name = None + if hasattr(config_obj, "get"): + try: + template_name = config_obj.get("t2i_active_template") + except Exception: + template_name = None + return await html_renderer.render_t2i( + text, + return_url=return_url, + template_name=template_name, + ) + + async def html_render( + self, + tmpl: str, + data: dict, + return_url=True, + options: dict | None = None, + ) -> str: + """渲染 HTML""" + return await html_renderer.render_custom_template( + tmpl, + data, + return_url=return_url, + options=options, + ) + + async def initialize(self) -> None: + """当插件被激活时会调用这个方法""" + + async def terminate(self) -> None: + """当插件被禁用、重载插件时会调用这个方法""" + + def __del__(self) -> None: + """[Deprecated] 当插件被禁用、重载插件时会调用这个方法""" diff --git a/astrbot/core/star/context.py b/astrbot/core/star/context.py index 6a74580f6..b5acf952e 100644 --- a/astrbot/core/star/context.py +++ b/astrbot/core/star/context.py @@ -1,7 +1,9 @@ +from __future__ import annotations + import logging from asyncio import Queue from collections.abc import Awaitable, Callable -from typing import Any +from typing import TYPE_CHECKING, Any, Protocol from deprecated import deprecated @@ -12,14 +14,12 @@ from astrbot.core.agent.tool import ToolSet from astrbot.core.astrbot_config_mgr import AstrBotConfigManager from astrbot.core.config.astrbot_config import AstrBotConfig from astrbot.core.conversation_mgr import ConversationManager -from astrbot.core.cron.manager import CronJobManager from astrbot.core.db import BaseDatabase from astrbot.core.knowledge_base.kb_mgr import KnowledgeBaseManager from astrbot.core.message.message_event_result import MessageChain from astrbot.core.persona_mgr import PersonaManager from astrbot.core.platform import Platform from astrbot.core.platform.astr_message_event import AstrMessageEvent, MessageSesion -from astrbot.core.platform.manager import PlatformManager from astrbot.core.platform_message_history_mgr import PlatformMessageHistoryManager from astrbot.core.provider.entities import LLMResponse, ProviderRequest, ProviderType from astrbot.core.provider.func_tool_manager import FunctionTool, FunctionToolManager @@ -45,6 +45,15 @@ from .star_handler import EventType, StarHandlerMetadata, star_handlers_registry logger = logging.getLogger("astrbot") +if TYPE_CHECKING: + from astrbot.core.cron.manager import CronJobManager +else: + CronJobManager = Any + + +class PlatformManagerProtocol(Protocol): + platform_insts: list[Platform] + class Context: """暴露给插件的接口上下文。""" @@ -61,7 +70,7 @@ class Context: config: AstrBotConfig, db: BaseDatabase, provider_manager: ProviderManager, - platform_manager: PlatformManager, + platform_manager: PlatformManagerProtocol, conversation_manager: ConversationManager, message_history_manager: PlatformMessageHistoryManager, persona_manager: PersonaManager, @@ -448,6 +457,9 @@ class Context: if platform.meta().id == session.platform_name: await platform.send_by_session(session, message_chain) return True + logger.warning( + f"cannot find platform for session {str(session)}, message not sent" + ) return False def add_llm_tools(self, *tools: FunctionTool) -> None: diff --git a/astrbot/core/star/filter/platform_adapter_type.py b/astrbot/core/star/filter/platform_adapter_type.py index 1630650a9..3ac8019ef 100644 --- a/astrbot/core/star/filter/platform_adapter_type.py +++ b/astrbot/core/star/filter/platform_adapter_type.py @@ -11,6 +11,7 @@ class PlatformAdapterType(enum.Flag): QQOFFICIAL = enum.auto() TELEGRAM = enum.auto() WECOM = enum.auto() + WECOM_AI_BOT = enum.auto() LARK = enum.auto() DINGTALK = enum.auto() DISCORD = enum.auto() @@ -26,6 +27,7 @@ class PlatformAdapterType(enum.Flag): | QQOFFICIAL | TELEGRAM | WECOM + | WECOM_AI_BOT | LARK | DINGTALK | DISCORD @@ -44,6 +46,7 @@ ADAPTER_NAME_2_TYPE = { "qq_official": PlatformAdapterType.QQOFFICIAL, "telegram": PlatformAdapterType.TELEGRAM, "wecom": PlatformAdapterType.WECOM, + "wecom_ai_bot": PlatformAdapterType.WECOM_AI_BOT, "lark": PlatformAdapterType.LARK, "dingtalk": PlatformAdapterType.DINGTALK, "discord": PlatformAdapterType.DISCORD, diff --git a/astrbot/core/star/register/__init__.py b/astrbot/core/star/register/__init__.py index 4856ffe50..5e99948cd 100644 --- a/astrbot/core/star/register/__init__.py +++ b/astrbot/core/star/register/__init__.py @@ -13,6 +13,9 @@ from .star_handler import ( register_on_llm_response, register_on_llm_tool_respond, register_on_platform_loaded, + register_on_plugin_error, + register_on_plugin_loaded, + register_on_plugin_unloaded, register_on_using_llm_tool, register_on_waiting_llm_request, register_permission_type, @@ -32,6 +35,9 @@ __all__ = [ "register_on_decorating_result", "register_on_llm_request", "register_on_llm_response", + "register_on_plugin_error", + "register_on_plugin_loaded", + "register_on_plugin_unloaded", "register_on_platform_loaded", "register_on_waiting_llm_request", "register_permission_type", diff --git a/astrbot/core/star/register/star.py b/astrbot/core/star/register/star.py index 617cd5ff7..c1a0ce10c 100644 --- a/astrbot/core/star/register/star.py +++ b/astrbot/core/star/register/star.py @@ -1,6 +1,6 @@ import warnings -from astrbot.core.star import StarMetadata, star_map +from astrbot.core.star.star import StarMetadata, star_map _warned_register_star = False diff --git a/astrbot/core/star/register/star_handler.py b/astrbot/core/star/register/star_handler.py index dfca5a25c..1385b5056 100644 --- a/astrbot/core/star/register/star_handler.py +++ b/astrbot/core/star/register/star_handler.py @@ -11,7 +11,6 @@ from astrbot.core.agent.agent import Agent from astrbot.core.agent.handoff import HandoffTool from astrbot.core.agent.hooks import BaseAgentRunHooks from astrbot.core.agent.tool import FunctionTool -from astrbot.core.astr_agent_context import AstrAgentContext from astrbot.core.message.message_event_result import MessageEventResult from astrbot.core.provider.func_tool_manager import PY_TO_JSON_TYPE, SUPPORTED_TYPES from astrbot.core.provider.register import llm_tools @@ -339,6 +338,58 @@ def register_on_platform_loaded(**kwargs): return decorator +def register_on_plugin_error(**kwargs): + """当插件处理消息异常时触发。 + + Hook 参数: + event, plugin_name, handler_name, error, traceback_text + + 说明: + 在 hook 中调用 `event.stop_event()` 可屏蔽默认报错回显, + 并由插件自行决定是否转发到其他会话。 + """ + + def decorator(awaitable): + _ = get_handler_or_create(awaitable, EventType.OnPluginErrorEvent, **kwargs) + return awaitable + + return decorator + + +def register_on_plugin_loaded(**kwargs): + """当有插件加载完成时 + + Hook 参数: + metadata + + 说明: + 当有插件加载完成时,触发该事件并获取到该插件的元数据 + """ + + def decorator(awaitable): + _ = get_handler_or_create(awaitable, EventType.OnPluginLoadedEvent, **kwargs) + return awaitable + + return decorator + + +def register_on_plugin_unloaded(**kwargs): + """当有插件卸载完成时 + + Hook 参数: + metadata + + 说明: + 当有插件卸载完成时,触发该事件并获取到该插件的元数据 + """ + + def decorator(awaitable): + _ = get_handler_or_create(awaitable, EventType.OnPluginUnloadedEvent, **kwargs) + return awaitable + + return decorator + + def register_on_waiting_llm_request(**kwargs): """当等待调用 LLM 时的通知事件(在获取锁之前) @@ -565,7 +616,7 @@ class RegisteringAgent: kwargs["registering_agent"] = self return register_llm_tool(*args, **kwargs) - def __init__(self, agent: Agent[AstrAgentContext]) -> None: + def __init__(self, agent: Agent[Any]) -> None: self._agent = agent @@ -573,7 +624,7 @@ def register_agent( name: str, instruction: str, tools: list[str | FunctionTool] | None = None, - run_hooks: BaseAgentRunHooks[AstrAgentContext] | None = None, + run_hooks: BaseAgentRunHooks[Any] | None = None, ): """注册一个 Agent @@ -587,12 +638,12 @@ def register_agent( tools_ = tools or [] def decorator(awaitable: Callable[..., Awaitable[Any]]): - AstrAgent = Agent[AstrAgentContext] + AstrAgent = Agent[Any] agent = AstrAgent( name=name, instructions=instruction, tools=tools_, - run_hooks=run_hooks or BaseAgentRunHooks[AstrAgentContext](), + run_hooks=run_hooks or BaseAgentRunHooks[Any](), ) handoff_tool = HandoffTool(agent=agent) handoff_tool.handler = awaitable diff --git a/astrbot/core/star/star.py b/astrbot/core/star/star.py index c5b7b1243..8cebbd772 100644 --- a/astrbot/core/star/star.py +++ b/astrbot/core/star/star.py @@ -61,6 +61,12 @@ class StarMetadata: logo_path: str | None = None """插件 Logo 的路径""" + support_platforms: list[str] = field(default_factory=list) + """插件声明支持的平台适配器 ID 列表(对应 ADAPTER_NAME_2_TYPE 的 key)""" + + astrbot_version: str | None = None + """插件要求的 AstrBot 版本范围(PEP 440 specifier,如 >=4.13.0,<4.17.0)""" + def __str__(self) -> str: return f"Plugin {self.name} ({self.version}) by {self.author}: {self.desc}" diff --git a/astrbot/core/star/star_handler.py b/astrbot/core/star/star_handler.py index ced4d7739..762db8655 100644 --- a/astrbot/core/star/star_handler.py +++ b/astrbot/core/star/star_handler.py @@ -97,6 +97,14 @@ class StarHandlerRegistry(Generic[T]): plugins_name: list[str] | None = None, ) -> list[StarHandlerMetadata[Callable[..., Awaitable[Any]]]]: ... + @overload + def get_handlers_by_event_type( + self, + event_type: Literal[EventType.OnPluginErrorEvent], + only_activated=True, + plugins_name: list[str] | None = None, + ) -> list[StarHandlerMetadata[Callable[..., Awaitable[Any]]]]: ... + @overload def get_handlers_by_event_type( self, @@ -136,6 +144,8 @@ class StarHandlerRegistry(Generic[T]): not in ( EventType.OnAstrBotLoadedEvent, EventType.OnPlatformLoadedEvent, + EventType.OnPluginLoadedEvent, + EventType.OnPluginUnloadedEvent, ) and not plugin.reserved ): @@ -192,6 +202,9 @@ class EventType(enum.Enum): OnUsingLLMToolEvent = enum.auto() # 使用 LLM 工具 OnLLMToolRespondEvent = enum.auto() # 调用函数工具后 OnAfterMessageSentEvent = enum.auto() # 发送消息后 + OnPluginErrorEvent = enum.auto() # 插件处理消息异常时 + OnPluginLoadedEvent = enum.auto() # 插件加载完成 + OnPluginUnloadedEvent = enum.auto() # 插件卸载完成 H = TypeVar("H", bound=Callable[..., Any]) diff --git a/astrbot/core/star/star_manager.py b/astrbot/core/star/star_manager.py index ad19884c2..815b306aa 100644 --- a/astrbot/core/star/star_manager.py +++ b/astrbot/core/star/star_manager.py @@ -11,10 +11,13 @@ import traceback from types import ModuleType import yaml +from packaging.specifiers import InvalidSpecifier, SpecifierSet +from packaging.version import InvalidVersion, Version from astrbot.core import logger, pip_installer, sp from astrbot.core.agent.handoff import FunctionTool, HandoffTool from astrbot.core.config.astrbot_config import AstrBotConfig +from astrbot.core.config.default import VERSION from astrbot.core.platform.register import unregister_platform_adapters_by_module from astrbot.core.provider.register import llm_tools from astrbot.core.utils.astrbot_path import ( @@ -30,7 +33,7 @@ from .command_management import sync_command_configs 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 .star_handler import EventType, star_handlers_registry from .updator import PluginUpdator try: @@ -40,12 +43,19 @@ except ImportError: logger.warning("未安装 watchfiles,无法实现插件的热重载。") +class PluginVersionIncompatibleError(Exception): + """Raised when plugin astrbot_version is incompatible with current AstrBot.""" + + class PluginManager: def __init__(self, context: Context, config: AstrBotConfig) -> None: + from .star_tools import StarTools + self.updator = PluginUpdator() self.context = context self.context._star_manager = self # type: ignore + StarTools.initialize(context) self.config = config self.plugin_store_path = get_astrbot_plugin_path() @@ -268,10 +278,58 @@ class PluginManager: version=metadata["version"], repo=metadata["repo"] if "repo" in metadata else None, display_name=metadata.get("display_name", None), + support_platforms=( + [ + platform_id + for platform_id in metadata["support_platforms"] + if isinstance(platform_id, str) + ] + if isinstance(metadata.get("support_platforms"), list) + else [] + ), + astrbot_version=( + metadata["astrbot_version"] + if isinstance(metadata.get("astrbot_version"), str) + else None + ), ) return metadata + @staticmethod + def _validate_astrbot_version_specifier( + version_spec: str | None, + ) -> tuple[bool, str | None]: + if not version_spec: + return True, None + + normalized_spec = version_spec.strip() + if not normalized_spec: + return True, None + + try: + specifier = SpecifierSet(normalized_spec) + except InvalidSpecifier: + return ( + False, + "astrbot_version 格式无效,请使用 PEP 440 版本范围格式,例如 >=4.16,<5。", + ) + + try: + current_version = Version(VERSION) + except InvalidVersion: + return ( + False, + f"AstrBot 当前版本 {VERSION} 无法被解析,无法校验插件版本范围。", + ) + + if current_version not in specifier: + return ( + False, + f"当前 AstrBot 版本为 {VERSION},不满足插件要求的 astrbot_version: {normalized_spec}", + ) + return True, None + @staticmethod def _get_plugin_related_modules( plugin_root_dir: str, @@ -408,7 +466,12 @@ class PluginManager: return result - async def load(self, specified_module_path=None, specified_dir_name=None): + async def load( + self, + specified_module_path=None, + specified_dir_name=None, + ignore_version_check: bool = False, + ): """载入插件。 当 specified_module_path 或者 specified_dir_name 不为 None 时,只载入指定的插件。 @@ -469,8 +532,19 @@ class PluginManager: requirements_path=requirements_path, ) except Exception as e: - logger.error(traceback.format_exc()) + error_trace = traceback.format_exc() + logger.error(error_trace) logger.error(f"插件 {root_dir_name} 导入失败。原因:{e!s}") + fail_rec += f"加载 {root_dir_name} 插件时出现问题,原因 {e!s}。\n" + self.failed_plugin_dict[root_dir_name] = { + "error": str(e), + "traceback": error_trace, + } + if path in star_map: + logger.info("失败插件依旧在插件列表中,正在清理...") + metadata = star_map.pop(path) + if metadata in star_registry: + star_registry.remove(metadata) continue # 检查 _conf_schema.json @@ -507,12 +581,37 @@ class PluginManager: metadata.version = metadata_yaml.version metadata.repo = metadata_yaml.repo metadata.display_name = metadata_yaml.display_name + metadata.support_platforms = metadata_yaml.support_platforms + metadata.astrbot_version = metadata_yaml.astrbot_version except Exception as e: logger.warning( f"插件 {root_dir_name} 元数据载入失败: {e!s}。使用默认元数据。", ) + + if not ignore_version_check: + is_valid, error_message = ( + self._validate_astrbot_version_specifier( + metadata.astrbot_version, + ) + ) + if not is_valid: + raise PluginVersionIncompatibleError( + error_message + or "The plugin is not compatible with the current AstrBot version." + ) + logger.info(metadata) metadata.config = plugin_config + p_name = (metadata.name or "unknown").lower().replace("/", "_") + p_author = (metadata.author or "unknown").lower().replace("/", "_") + plugin_id = f"{p_author}/{p_name}" + + # 在实例化前注入类属性,保证插件 __init__ 可读取这些值 + if metadata.star_cls_type: + setattr(metadata.star_cls_type, "name", p_name) + setattr(metadata.star_cls_type, "author", p_author) + setattr(metadata.star_cls_type, "plugin_id", plugin_id) + if path not in inactivated_plugins: # 只有没有禁用插件时才实例化插件类 if plugin_config and metadata.star_cls_type: @@ -530,17 +629,10 @@ class PluginManager: context=self.context, ) - p_name = (metadata.name or "unknown").lower().replace("/", "_") - p_author = ( - (metadata.author or "unknown").lower().replace("/", "_") - ) - setattr(metadata.star_cls, "name", p_name) - setattr(metadata.star_cls, "author", p_author) - setattr( - metadata.star_cls, - "plugin_id", - f"{p_author}/{p_name}", - ) + if metadata.star_cls: + setattr(metadata.star_cls, "name", p_name) + setattr(metadata.star_cls, "author", p_author) + setattr(metadata.star_cls, "plugin_id", plugin_id) else: logger.info(f"插件 {metadata.name} 已被禁用。") @@ -618,6 +710,19 @@ class PluginManager: ) if not metadata: raise Exception(f"无法找到插件 {plugin_dir_path} 的元数据。") + + if not ignore_version_check: + is_valid, error_message = ( + self._validate_astrbot_version_specifier( + metadata.astrbot_version, + ) + ) + if not is_valid: + raise PluginVersionIncompatibleError( + error_message + or "The plugin is not compatible with the current AstrBot version." + ) + metadata.star_cls = obj metadata.config = plugin_config metadata.module = module @@ -681,6 +786,19 @@ class PluginManager: if hasattr(metadata.star_cls, "initialize") and metadata.star_cls: await metadata.star_cls.initialize() + # 触发插件加载事件 + handlers = star_handlers_registry.get_handlers_by_event_type( + EventType.OnPluginLoadedEvent, + ) + for handler in handlers: + try: + logger.info( + f"hook(on_plugin_loaded) -> {star_map[handler.handler_module_path].name} - {handler.handler_name}", + ) + await handler.handler(metadata) + except Exception: + logger.error(traceback.format_exc()) + except BaseException as e: logger.error(f"----- 插件 {root_dir_name} 载入失败 -----") errors = traceback.format_exc() @@ -693,6 +811,11 @@ class PluginManager: "traceback": errors, } # 记录注册失败的插件名称,以便后续重载插件 + if path in star_map: + logger.info("失败插件依旧在插件列表中,正在清理...") + metadata = star_map.pop(path) + if metadata in star_registry: + star_registry.remove(metadata) # 清除 pip.main 导致的多余的 logging handlers for handler in logging.root.handlers[:]: @@ -751,7 +874,9 @@ class PluginManager: f"清理安装失败插件配置失败: {plugin_config_path},原因: {e!s}", ) - async def install_plugin(self, repo_url: str, proxy=""): + async def install_plugin( + self, repo_url: str, proxy: str = "", ignore_version_check: bool = False + ): """从仓库 URL 安装插件 从指定的仓库 URL 下载并安装插件,然后加载该插件到系统中 @@ -785,7 +910,10 @@ class PluginManager: # reload the plugin dir_name = os.path.basename(plugin_path) - success, error_message = await self.load(specified_dir_name=dir_name) + success, error_message = await self.load( + specified_dir_name=dir_name, + ignore_version_check=ignore_version_check, + ) if not success: raise Exception( error_message @@ -1063,6 +1191,19 @@ class PluginManager: elif "terminate" in star_metadata.star_cls_type.__dict__: await star_metadata.star_cls.terminate() + # 触发插件卸载事件 + handlers = star_handlers_registry.get_handlers_by_event_type( + EventType.OnPluginUnloadedEvent, + ) + for handler in handlers: + try: + logger.info( + f"hook(on_plugin_unloaded) -> {star_map[handler.handler_module_path].name} - {handler.handler_name}", + ) + await handler.handler(star_metadata) + except Exception: + logger.error(traceback.format_exc()) + async def turn_on_plugin(self, plugin_name: str) -> None: plugin = self.context.get_registered_star(plugin_name) if plugin is None: @@ -1089,7 +1230,9 @@ class PluginManager: await self.reload(plugin_name) - async def install_plugin_from_file(self, zip_file_path: str): + async def install_plugin_from_file( + self, zip_file_path: str, ignore_version_check: bool = False + ): dir_name = os.path.basename(zip_file_path).replace(".zip", "") dir_name = dir_name.removesuffix("-master").removesuffix("-main").lower() desti_dir = os.path.join(self.plugin_store_path, dir_name) @@ -1145,7 +1288,10 @@ class PluginManager: except BaseException as e: logger.warning(f"删除插件压缩包失败: {e!s}") # await self.reload() - success, error_message = await self.load(specified_dir_name=dir_name) + success, error_message = await self.load( + specified_dir_name=dir_name, + ignore_version_check=ignore_version_check, + ) if not success: raise Exception( error_message diff --git a/astrbot/core/updator.py b/astrbot/core/updator.py index e7c2aa54b..049a19789 100644 --- a/astrbot/core/updator.py +++ b/astrbot/core/updator.py @@ -148,8 +148,8 @@ 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 os.environ.get("ASTRBOT_CLI") or os.environ.get("ASTRBOT_LAUNCHER"): + raise Exception("不支持更新此方式启动的AstrBot") # 避免版本管理混乱 if latest: latest_version = update_data[0]["tag_name"] diff --git a/astrbot/core/utils/active_event_registry.py b/astrbot/core/utils/active_event_registry.py new file mode 100644 index 000000000..d98cdee37 --- /dev/null +++ b/astrbot/core/utils/active_event_registry.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +from collections import defaultdict +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from astrbot.core.platform import AstrMessageEvent + + +class ActiveEventRegistry: + """维护 unified_msg_origin 到活跃事件的映射。 + + 用于在 reset 等场景下终止该会话正在处理的事件。 + """ + + def __init__(self) -> None: + self._events: dict[str, set[AstrMessageEvent]] = defaultdict(set) + + def register(self, event: AstrMessageEvent) -> None: + self._events[event.unified_msg_origin].add(event) + + def unregister(self, event: AstrMessageEvent) -> None: + umo = event.unified_msg_origin + self._events[umo].discard(event) + if not self._events[umo]: + del self._events[umo] + + def stop_all( + self, + umo: str, + exclude: AstrMessageEvent | None = None, + ) -> int: + """终止指定 UMO 的所有活跃事件。 + + Args: + umo: 统一消息来源标识符。 + exclude: 需要排除的事件(通常是发起 reset 的事件本身)。 + + Returns: + 被终止的事件数量。 + """ + count = 0 + for event in list(self._events.get(umo, [])): + if event is not exclude: + event.stop_event() + count += 1 + return count + + def request_agent_stop_all( + self, + umo: str, + exclude: AstrMessageEvent | None = None, + ) -> int: + """请求停止指定 UMO 的所有活跃事件中的 Agent 运行。 + + 与 stop_all 不同,这里不会调用 event.stop_event(), + 因此不会中断事件传播,后续流程(如历史记录保存)仍可继续。 + """ + count = 0 + for event in list(self._events.get(umo, [])): + if event is not exclude: + event.set_extra("agent_stop_requested", True) + count += 1 + return count + + +active_event_registry = ActiveEventRegistry() diff --git a/astrbot/core/utils/astrbot_path.py b/astrbot/core/utils/astrbot_path.py index 063c8ddfc..987ce110a 100644 --- a/astrbot/core/utils/astrbot_path.py +++ b/astrbot/core/utils/astrbot_path.py @@ -15,7 +15,7 @@ Skills 目录路径:固定为数据目录下的 skills 目录 import os -from astrbot.core.utils.runtime_env import is_packaged_electron_runtime +from astrbot.core.utils.runtime_env import is_packaged_desktop_runtime def get_astrbot_path() -> str: @@ -29,7 +29,7 @@ def get_astrbot_root() -> str: """获取Astrbot根目录路径""" if path := os.environ.get("ASTRBOT_ROOT"): return os.path.realpath(path) - if is_packaged_electron_runtime(): + if is_packaged_desktop_runtime(): return os.path.realpath(os.path.join(os.path.expanduser("~"), ".astrbot")) return os.path.realpath(os.getcwd()) diff --git a/astrbot/core/utils/pip_installer.py b/astrbot/core/utils/pip_installer.py index 1c8da23c1..562a0ed30 100644 --- a/astrbot/core/utils/pip_installer.py +++ b/astrbot/core/utils/pip_installer.py @@ -12,7 +12,7 @@ import threading from collections import deque from astrbot.core.utils.astrbot_path import get_astrbot_site_packages_path -from astrbot.core.utils.runtime_env import is_packaged_electron_runtime +from astrbot.core.utils.runtime_env import is_packaged_desktop_runtime logger = logging.getLogger("astrbot") @@ -35,7 +35,7 @@ def _get_pip_main(): "pip module is unavailable " f"(sys.executable={sys.executable}, " f"frozen={getattr(sys, 'frozen', False)}, " - f"ASTRBOT_ELECTRON_CLIENT={os.environ.get('ASTRBOT_ELECTRON_CLIENT')})" + f"ASTRBOT_DESKTOP_CLIENT={os.environ.get('ASTRBOT_DESKTOP_CLIENT')})" ) from exc return pip_main @@ -556,7 +556,7 @@ class PipInstaller: args.extend(["--trusted-host", "mirrors.aliyun.com", "-i", index_url]) target_site_packages = None - if is_packaged_electron_runtime(): + if is_packaged_desktop_runtime(): target_site_packages = get_astrbot_site_packages_path() os.makedirs(target_site_packages, exist_ok=True) _prepend_sys_path(target_site_packages) @@ -582,7 +582,7 @@ class PipInstaller: def prefer_installed_dependencies(self, requirements_path: str) -> None: """优先使用已安装在插件 site-packages 中的依赖,不执行安装。""" - if not is_packaged_electron_runtime(): + if not is_packaged_desktop_runtime(): return target_site_packages = get_astrbot_site_packages_path() diff --git a/astrbot/core/utils/runtime_env.py b/astrbot/core/utils/runtime_env.py index 2eb1bc7e4..483f5bc0c 100644 --- a/astrbot/core/utils/runtime_env.py +++ b/astrbot/core/utils/runtime_env.py @@ -6,5 +6,5 @@ def is_frozen_runtime() -> bool: return bool(getattr(sys, "frozen", False)) -def is_packaged_electron_runtime() -> bool: - return is_frozen_runtime() and os.environ.get("ASTRBOT_ELECTRON_CLIENT") == "1" +def is_packaged_desktop_runtime() -> bool: + return is_frozen_runtime() and os.environ.get("ASTRBOT_DESKTOP_CLIENT") == "1" diff --git a/astrbot/core/utils/webhook_utils.py b/astrbot/core/utils/webhook_utils.py index 07abc115a..40dada3cb 100644 --- a/astrbot/core/utils/webhook_utils.py +++ b/astrbot/core/utils/webhook_utils.py @@ -1,3 +1,4 @@ +import os import uuid from astrbot.core import astrbot_config, logger @@ -20,6 +21,20 @@ def _get_dashboard_port() -> int: return 6185 +def _is_dashboard_ssl_enabled() -> bool: + env_ssl = os.environ.get("DASHBOARD_SSL_ENABLE") or os.environ.get( + "ASTRBOT_DASHBOARD_SSL_ENABLE" + ) + if env_ssl is not None: + return env_ssl.strip().lower() in {"1", "true", "yes", "on"} + + try: + return bool(astrbot_config.get("dashboard", {}).get("ssl", {}).get("enable")) + except Exception as e: + logger.error(f"获取 dashboard SSL 配置失败: {e!s}") + return False + + def log_webhook_info(platform_name: str, webhook_uuid: str) -> None: """打印美观的 webhook 信息日志 @@ -38,12 +53,13 @@ def log_webhook_info(platform_name: str, webhook_uuid: str) -> None: callback_base = callback_base.rstrip("/") webhook_url = f"{callback_base}/api/platform/webhook/{webhook_uuid}" + scheme = "https" if _is_dashboard_ssl_enabled() else "http" display_log = ( "\n====================\n" f"🔗 机器人平台 {platform_name} 已启用统一 Webhook 模式\n" f"📍 Webhook 回调地址: \n" - f" ➜ http://:{_get_dashboard_port()}/api/platform/webhook/{webhook_uuid}\n" + f" ➜ {scheme}://:{_get_dashboard_port()}/api/platform/webhook/{webhook_uuid}\n" f" ➜ {webhook_url}\n" "====================\n" ) diff --git a/astrbot/dashboard/routes/__init__.py b/astrbot/dashboard/routes/__init__.py index 481be2f89..fbbd0c7a0 100644 --- a/astrbot/dashboard/routes/__init__.py +++ b/astrbot/dashboard/routes/__init__.py @@ -1,3 +1,4 @@ +from .api_key import ApiKeyRoute from .auth import AuthRoute from .backup import BackupRoute from .chat import ChatRoute @@ -9,6 +10,7 @@ from .cron import CronRoute from .file import FileRoute from .knowledge_base import KnowledgeBaseRoute from .log import LogRoute +from .open_api import OpenApiRoute from .persona import PersonaRoute from .platform import PlatformRoute from .plugin import PluginRoute @@ -21,6 +23,7 @@ from .tools import ToolsRoute from .update import UpdateRoute __all__ = [ + "ApiKeyRoute", "AuthRoute", "BackupRoute", "ChatRoute", @@ -32,6 +35,7 @@ __all__ = [ "FileRoute", "KnowledgeBaseRoute", "LogRoute", + "OpenApiRoute", "PersonaRoute", "PlatformRoute", "PluginRoute", diff --git a/astrbot/dashboard/routes/api_key.py b/astrbot/dashboard/routes/api_key.py new file mode 100644 index 000000000..5bc302579 --- /dev/null +++ b/astrbot/dashboard/routes/api_key.py @@ -0,0 +1,146 @@ +import hashlib +import secrets +from datetime import datetime, timedelta, timezone + +from quart import g, request + +from astrbot.core.db import BaseDatabase + +from .route import Response, Route, RouteContext + +ALL_OPEN_API_SCOPES = ("chat", "config", "file", "im") + + +class ApiKeyRoute(Route): + def __init__(self, context: RouteContext, db: BaseDatabase) -> None: + super().__init__(context) + self.db = db + self.routes = { + "/apikey/list": ("GET", self.list_api_keys), + "/apikey/create": ("POST", self.create_api_key), + "/apikey/revoke": ("POST", self.revoke_api_key), + "/apikey/delete": ("POST", self.delete_api_key), + } + self.register_routes() + + @staticmethod + def _normalize_utc(dt: datetime | None) -> datetime | None: + if dt is None: + return None + if dt.tzinfo is None or dt.tzinfo.utcoffset(dt) is None: + return dt.replace(tzinfo=timezone.utc) + return dt.astimezone(timezone.utc) + + @classmethod + def _serialize_datetime(cls, dt: datetime | None) -> str | None: + normalized = cls._normalize_utc(dt) + if normalized is None: + return None + return normalized.astimezone().isoformat() + + @staticmethod + def _hash_key(raw_key: str) -> str: + return hashlib.pbkdf2_hmac( + "sha256", + raw_key.encode("utf-8"), + b"astrbot_api_key", + 100_000, + ).hex() + + @staticmethod + def _serialize_api_key(key) -> dict: + expires_at = ApiKeyRoute._normalize_utc(key.expires_at) + return { + "key_id": key.key_id, + "name": key.name, + "key_prefix": key.key_prefix, + "scopes": key.scopes or [], + "created_by": key.created_by, + "created_at": ApiKeyRoute._serialize_datetime(key.created_at), + "updated_at": ApiKeyRoute._serialize_datetime(key.updated_at), + "last_used_at": ApiKeyRoute._serialize_datetime(key.last_used_at), + "expires_at": ApiKeyRoute._serialize_datetime(key.expires_at), + "revoked_at": ApiKeyRoute._serialize_datetime(key.revoked_at), + "is_revoked": key.revoked_at is not None, + "is_expired": bool(expires_at and expires_at < datetime.now(timezone.utc)), + } + + async def list_api_keys(self): + keys = await self.db.list_api_keys() + return ( + Response().ok(data=[self._serialize_api_key(key) for key in keys]).__dict__ + ) + + async def create_api_key(self): + post_data = await request.json or {} + + name = str(post_data.get("name", "")).strip() or "Untitled API Key" + scopes = post_data.get("scopes") + if scopes is None: + normalized_scopes = list(ALL_OPEN_API_SCOPES) + elif isinstance(scopes, list): + normalized_scopes = [ + scope + for scope in scopes + if isinstance(scope, str) and scope in ALL_OPEN_API_SCOPES + ] + normalized_scopes = list(dict.fromkeys(normalized_scopes)) + if not normalized_scopes: + return Response().error("At least one valid scope is required").__dict__ + else: + return Response().error("Invalid scopes").__dict__ + + expires_at = None + expires_in_days = post_data.get("expires_in_days") + if expires_in_days is not None: + try: + expires_in_days_int = int(expires_in_days) + except (TypeError, ValueError): + return Response().error("expires_in_days must be an integer").__dict__ + if expires_in_days_int <= 0: + return ( + Response().error("expires_in_days must be greater than 0").__dict__ + ) + expires_at = datetime.now(timezone.utc) + timedelta( + days=expires_in_days_int + ) + + raw_key = f"abk_{secrets.token_urlsafe(32)}" + key_hash = self._hash_key(raw_key) + key_prefix = raw_key[:12] + created_by = g.get("username", "unknown") + + api_key = await self.db.create_api_key( + name=name, + key_hash=key_hash, + key_prefix=key_prefix, + scopes=normalized_scopes, # type: ignore + created_by=created_by, + expires_at=expires_at, + ) + + payload = self._serialize_api_key(api_key) + payload["api_key"] = raw_key + return Response().ok(data=payload).__dict__ + + async def revoke_api_key(self): + post_data = await request.json or {} + key_id = post_data.get("key_id") + if not key_id: + return Response().error("Missing key: key_id").__dict__ + + success = await self.db.revoke_api_key(key_id) + if not success: + return Response().error("API key not found").__dict__ + return Response().ok().__dict__ + + async def delete_api_key(self): + post_data = await request.json or {} + key_id = post_data.get("key_id") + if not key_id: + return Response().error("Missing key: key_id").__dict__ + + success = await self.db.delete_api_key(key_id) + if not success: + return Response().error("API key not found").__dict__ + return Response().ok().__dict__ diff --git a/astrbot/dashboard/routes/auth.py b/astrbot/dashboard/routes/auth.py index 4ee0d57d4..40db1f60b 100644 --- a/astrbot/dashboard/routes/auth.py +++ b/astrbot/dashboard/routes/auth.py @@ -64,11 +64,13 @@ class AuthRoute(Route): new_pwd = post_data.get("new_password", None) new_username = post_data.get("new_username", None) if not new_pwd and not new_username: - return ( - Response().error("新用户名和新密码不能同时为空,你改了个寂寞").__dict__ - ) + return Response().error("新用户名和新密码不能同时为空").__dict__ + # Verify password confirmation if new_pwd: + confirm_pwd = post_data.get("confirm_password", None) + if confirm_pwd != new_pwd: + return Response().error("两次输入的新密码不一致").__dict__ self.config["dashboard"]["password"] = new_pwd if new_username: self.config["dashboard"]["username"] = new_username diff --git a/astrbot/dashboard/routes/chat.py b/astrbot/dashboard/routes/chat.py index a7c0e3a57..1235dd381 100644 --- a/astrbot/dashboard/routes/chat.py +++ b/astrbot/dashboard/routes/chat.py @@ -13,7 +13,9 @@ from quart import g, make_response, request, send_file from astrbot.core import logger, sp from astrbot.core.core_lifecycle import AstrBotCoreLifecycle from astrbot.core.db import BaseDatabase +from astrbot.core.platform.message_type import MessageType from astrbot.core.platform.sources.webchat.webchat_queue_mgr import webchat_queue_mgr +from astrbot.core.utils.active_event_registry import active_event_registry from astrbot.core.utils.astrbot_path import get_astrbot_data_path from .route import Response, Route, RouteContext @@ -41,6 +43,7 @@ class ChatRoute(Route): "/chat/new_session": ("GET", self.new_session), "/chat/sessions": ("GET", self.get_sessions), "/chat/get_session": ("GET", self.get_session), + "/chat/stop": ("POST", self.stop_session), "/chat/delete_session": ("GET", self.delete_webchat_session), "/chat/update_session_display_name": ( "POST", @@ -52,8 +55,9 @@ class ChatRoute(Route): } self.core_lifecycle = core_lifecycle self.register_routes() - self.imgs_dir = os.path.join(get_astrbot_data_path(), "webchat", "imgs") - os.makedirs(self.imgs_dir, exist_ok=True) + self.attachments_dir = os.path.join(get_astrbot_data_path(), "attachments") + self.legacy_img_dir = os.path.join(get_astrbot_data_path(), "webchat", "imgs") + os.makedirs(self.attachments_dir, exist_ok=True) self.supported_imgs = ["jpg", "jpeg", "png", "gif", "webp"] self.conv_mgr = core_lifecycle.conversation_manager @@ -69,9 +73,18 @@ class ChatRoute(Route): return Response().error("Missing key: filename").__dict__ try: - file_path = os.path.join(self.imgs_dir, os.path.basename(filename)) + file_path = os.path.join(self.attachments_dir, os.path.basename(filename)) real_file_path = os.path.realpath(file_path) - real_imgs_dir = os.path.realpath(self.imgs_dir) + real_imgs_dir = os.path.realpath(self.attachments_dir) + + if not os.path.exists(real_file_path): + # try legacy + file_path = os.path.join( + self.legacy_img_dir, os.path.basename(filename) + ) + if os.path.exists(file_path): + real_file_path = os.path.realpath(file_path) + real_imgs_dir = os.path.realpath(self.legacy_img_dir) if not real_file_path.startswith(real_imgs_dir): return Response().error("Invalid file path").__dict__ @@ -125,7 +138,7 @@ class ChatRoute(Route): else: attach_type = "file" - path = os.path.join(self.imgs_dir, filename) + path = os.path.join(self.attachments_dir, filename) await file.save(path) # 创建 attachment 记录 @@ -202,8 +215,13 @@ class ChatRoute(Route): filename: 存储的文件名 attach_type: 附件类型 (image, record, file, video) """ - file_path = os.path.join(self.imgs_dir, os.path.basename(filename)) - if not os.path.exists(file_path): + basename = os.path.basename(filename) + candidate_paths = [ + os.path.join(self.attachments_dir, basename), + os.path.join(self.legacy_img_dir, basename), + ] + file_path = next((p for p in candidate_paths if os.path.exists(p)), None) + if not file_path: return None # guess mime type @@ -317,10 +335,13 @@ class ChatRoute(Route): ) return record - async def chat(self): + async def chat(self, post_data: dict | None = None): username = g.get("username", "guest") - post_data = await request.json + if post_data is None: + post_data = await request.json + if post_data is None: + return Response().error("Missing JSON body").__dict__ if "message" not in post_data and "files" not in post_data: return Response().error("Missing key: message or files").__dict__ @@ -373,6 +394,14 @@ class ChatRoute(Route): agent_stats = {} refs = {} try: + # Emit session_id first so clients can bind the stream immediately. + session_info = { + "type": "session_id", + "data": None, + "session_id": webchat_conv_id, + } + yield f"data: {json.dumps(session_info, ensure_ascii=False)}\n\n" + async with track_conversation(self.running_convs, webchat_conv_id): while True: try: @@ -445,13 +474,13 @@ class ChatRoute(Route): if tc_id in tool_calls: tool_calls[tc_id]["result"] = tcr.get("result") tool_calls[tc_id]["finished_ts"] = tcr.get("ts") - accumulated_parts.append( - { - "type": "tool_call", - "tool_calls": [tool_calls[tc_id]], - } - ) - tool_calls.pop(tc_id, None) + accumulated_parts.append( + { + "type": "tool_call", + "tool_calls": [tool_calls[tc_id]], + } + ) + tool_calls.pop(tc_id, None) elif chain_type == "reasoning": accumulated_reasoning += result_text elif streaming: @@ -582,6 +611,36 @@ class ChatRoute(Route): response.timeout = None # fix SSE auto disconnect issue return response + async def stop_session(self): + """Stop active agent runs for a session.""" + post_data = await request.json + if post_data is None: + return Response().error("Missing JSON body").__dict__ + + session_id = post_data.get("session_id") + if not session_id: + return Response().error("Missing key: session_id").__dict__ + + username = g.get("username", "guest") + session = await self.db.get_platform_session_by_id(session_id) + if not session: + return Response().error(f"Session {session_id} not found").__dict__ + if session.creator != username: + return Response().error("Permission denied").__dict__ + + message_type = ( + MessageType.GROUP_MESSAGE.value + if session.is_group + else MessageType.FRIEND_MESSAGE.value + ) + umo = ( + f"{session.platform_id}:{message_type}:" + f"{session.platform_id}!{username}!{session_id}" + ) + stopped_count = active_event_registry.request_agent_stop_all(umo) + + return Response().ok(data={"stopped_count": stopped_count}).__dict__ + async def delete_webchat_session(self): """Delete a Platform session and all its related data.""" session_id = request.args.get("session_id") @@ -705,23 +764,18 @@ class ChatRoute(Route): # 获取可选的 platform_id 参数 platform_id = request.args.get("platform_id") - sessions = await self.db.get_platform_sessions_by_creator( + sessions, _ = await self.db.get_platform_sessions_by_creator_paginated( creator=username, platform_id=platform_id, page=1, page_size=100, # 暂时返回前100个 + exclude_project_sessions=True, ) - # 转换为字典格式,并添加项目信息 - # get_platform_sessions_by_creator 现在返回 list[dict] 包含 session 和项目字段 + # 转换为字典格式 sessions_data = [] for item in sessions: session = item["session"] - project_id = item["project_id"] - - # 跳过属于项目的会话(在侧边栏对话列表中不显示) - if project_id is not None: - continue sessions_data.append( { diff --git a/astrbot/dashboard/routes/open_api.py b/astrbot/dashboard/routes/open_api.py new file mode 100644 index 000000000..c25870ebb --- /dev/null +++ b/astrbot/dashboard/routes/open_api.py @@ -0,0 +1,388 @@ +from pathlib import Path +from uuid import uuid4 + +from quart import g, request + +from astrbot.core import logger +from astrbot.core.core_lifecycle import AstrBotCoreLifecycle +from astrbot.core.db import BaseDatabase +from astrbot.core.message.components import File, Image, Plain, Record, Reply, Video +from astrbot.core.message.message_event_result import MessageChain +from astrbot.core.platform.message_session import MessageSesion + +from .chat import ChatRoute +from .route import Response, Route, RouteContext + + +class OpenApiRoute(Route): + def __init__( + self, + context: RouteContext, + db: BaseDatabase, + core_lifecycle: AstrBotCoreLifecycle, + chat_route: ChatRoute, + ) -> None: + super().__init__(context) + self.db = db + self.core_lifecycle = core_lifecycle + self.platform_manager = core_lifecycle.platform_manager + self.chat_route = chat_route + + self.routes = { + "/v1/chat": ("POST", self.chat_send), + "/v1/chat/sessions": ("GET", self.get_chat_sessions), + "/v1/configs": ("GET", self.get_chat_configs), + "/v1/file": ("POST", self.upload_file), + "/v1/im/message": ("POST", self.send_message), + "/v1/im/bots": ("GET", self.get_bots), + } + self.register_routes() + + @staticmethod + def _resolve_open_username( + raw_username: str | None, + ) -> tuple[str | None, str | None]: + if raw_username is None: + return None, "Missing key: username" + username = str(raw_username).strip() + if not username: + return None, "username is empty" + return username, None + + def _get_chat_config_list(self) -> list[dict]: + conf_list = self.core_lifecycle.astrbot_config_mgr.get_conf_list() + + result = [] + for conf_info in conf_list: + conf_id = str(conf_info.get("id", "")).strip() + result.append( + { + "id": conf_id, + "name": str(conf_info.get("name", "")).strip(), + "path": str(conf_info.get("path", "")).strip(), + "is_default": conf_id == "default", + } + ) + return result + + def _resolve_chat_config_id(self, post_data: dict) -> tuple[str | None, str | None]: + raw_config_id = post_data.get("config_id") + raw_config_name = post_data.get("config_name") + config_id = str(raw_config_id).strip() if raw_config_id is not None else "" + config_name = ( + str(raw_config_name).strip() if raw_config_name is not None else "" + ) + + if not config_id and not config_name: + return None, None + + conf_list = self._get_chat_config_list() + conf_map = {item["id"]: item for item in conf_list} + + if config_id: + if config_id not in conf_map: + return None, f"config_id not found: {config_id}" + return config_id, None + + if not config_name: + return None, "config_name is empty" + + matched = [item for item in conf_list if item["name"] == config_name] + if not matched: + return None, f"config_name not found: {config_name}" + if len(matched) > 1: + return ( + None, + f"config_name is ambiguous, please use config_id: {config_name}", + ) + + return matched[0]["id"], None + + async def _ensure_chat_session( + self, + username: str, + session_id: str, + ) -> str | None: + session = await self.db.get_platform_session_by_id(session_id) + if session: + if session.creator != username: + return "session_id belongs to another username" + return None + + try: + await self.db.create_platform_session( + creator=username, + platform_id="webchat", + session_id=session_id, + is_group=0, + ) + except Exception as e: + # Handle rare race when same session_id is created concurrently. + existing = await self.db.get_platform_session_by_id(session_id) + if existing and existing.creator == username: + return None + logger.error("Failed to create chat session %s: %s", session_id, e) + return f"Failed to create session: {e}" + + return None + + async def chat_send(self): + post_data = await request.get_json(silent=True) or {} + effective_username, username_err = self._resolve_open_username( + post_data.get("username") + ) + if username_err: + return Response().error(username_err).__dict__ + if not effective_username: + return Response().error("Invalid username").__dict__ + + raw_session_id = post_data.get("session_id", post_data.get("conversation_id")) + session_id = str(raw_session_id).strip() if raw_session_id is not None else "" + if not session_id: + session_id = str(uuid4()) + post_data["session_id"] = session_id + ensure_session_err = await self._ensure_chat_session( + effective_username, + session_id, + ) + if ensure_session_err: + return Response().error(ensure_session_err).__dict__ + + config_id, resolve_err = self._resolve_chat_config_id(post_data) + if resolve_err: + return Response().error(resolve_err).__dict__ + + original_username = g.get("username", "guest") + g.username = effective_username + if config_id: + umo = f"webchat:FriendMessage:webchat!{effective_username}!{session_id}" + try: + if config_id == "default": + await self.core_lifecycle.umop_config_router.delete_route(umo) + else: + await self.core_lifecycle.umop_config_router.update_route( + umo, config_id + ) + except Exception as e: + logger.error( + "Failed to update chat config route for %s with %s: %s", + umo, + config_id, + e, + exc_info=True, + ) + return ( + Response() + .error(f"Failed to update chat config route: {e}") + .__dict__ + ) + try: + return await self.chat_route.chat(post_data=post_data) + finally: + g.username = original_username + + async def upload_file(self): + return await self.chat_route.post_file() + + async def get_chat_sessions(self): + username, username_err = self._resolve_open_username( + request.args.get("username") + ) + if username_err: + return Response().error(username_err).__dict__ + + assert username is not None # for type checker + + try: + page = int(request.args.get("page", 1)) + page_size = int(request.args.get("page_size", 20)) + except ValueError: + return Response().error("page and page_size must be integers").__dict__ + + if page < 1: + page = 1 + if page_size < 1: + page_size = 1 + if page_size > 100: + page_size = 100 + + platform_id = request.args.get("platform_id") + + ( + paginated_sessions, + total, + ) = await self.db.get_platform_sessions_by_creator_paginated( + creator=username, + platform_id=platform_id, + page=page, + page_size=page_size, + exclude_project_sessions=True, + ) + + sessions_data = [] + for item in paginated_sessions: + session = item["session"] + sessions_data.append( + { + "session_id": session.session_id, + "platform_id": session.platform_id, + "creator": session.creator, + "display_name": session.display_name, + "is_group": session.is_group, + "created_at": session.created_at.astimezone().isoformat(), + "updated_at": session.updated_at.astimezone().isoformat(), + } + ) + + return ( + Response() + .ok( + data={ + "sessions": sessions_data, + "page": page, + "page_size": page_size, + "total": total, + } + ) + .__dict__ + ) + + async def get_chat_configs(self): + conf_list = self._get_chat_config_list() + return Response().ok(data={"configs": conf_list}).__dict__ + + async def _build_message_chain_from_payload( + self, + message_payload: str | list, + ) -> MessageChain: + if isinstance(message_payload, str): + text = message_payload.strip() + if not text: + raise ValueError("Message is empty") + return MessageChain(chain=[Plain(text=text)]) + + if not isinstance(message_payload, list): + raise ValueError("message must be a string or list") + + components = [] + has_content = False + + for part in message_payload: + if not isinstance(part, dict): + raise ValueError("message part must be an object") + + part_type = str(part.get("type", "")).strip() + if part_type == "plain": + text = str(part.get("text", "")) + if text: + has_content = True + components.append(Plain(text=text)) + continue + + if part_type == "reply": + message_id = part.get("message_id") + if message_id is None: + raise ValueError("reply part missing message_id") + components.append( + Reply( + id=str(message_id), + message_str=str(part.get("selected_text", "")), + chain=[], + ) + ) + continue + + if part_type not in {"image", "record", "file", "video"}: + raise ValueError(f"unsupported message part type: {part_type}") + + has_content = True + file_path: Path | None = None + resolved_type = part_type + filename = str(part.get("filename", "")).strip() + + attachment_id = part.get("attachment_id") + if attachment_id: + attachment = await self.db.get_attachment_by_id(str(attachment_id)) + if not attachment: + raise ValueError(f"attachment not found: {attachment_id}") + file_path = Path(attachment.path) + resolved_type = attachment.type + if not filename: + filename = file_path.name + else: + raise ValueError(f"{part_type} part missing attachment_id") + + if not file_path.exists(): + raise ValueError(f"file not found: {file_path!s}") + + file_path_str = str(file_path.resolve()) + if resolved_type == "image": + components.append(Image.fromFileSystem(file_path_str)) + elif resolved_type == "record": + components.append(Record.fromFileSystem(file_path_str)) + elif resolved_type == "video": + components.append(Video.fromFileSystem(file_path_str)) + else: + components.append( + File(name=filename or file_path.name, file=file_path_str) + ) + + if not components or not has_content: + raise ValueError("Message content is empty (reply only is not allowed)") + + return MessageChain(chain=components) + + async def send_message(self): + post_data = await request.json or {} + message_payload = post_data.get("message", {}) + umo = post_data.get("umo") + + if message_payload is None: + return Response().error("Missing key: message").__dict__ + if not umo: + return Response().error("Missing key: umo").__dict__ + + try: + session = MessageSesion.from_str(str(umo)) + except Exception as e: + return Response().error(f"Invalid umo: {e}").__dict__ + + platform_id = session.platform_name + platform_inst = next( + ( + inst + for inst in self.platform_manager.platform_insts + if inst.meta().id == platform_id + ), + None, + ) + if not platform_inst: + return ( + Response() + .error(f"Bot not found or not running for platform: {platform_id}") + .__dict__ + ) + + try: + message_chain = await self._build_message_chain_from_payload( + message_payload + ) + await platform_inst.send_by_session(session, message_chain) + return Response().ok().__dict__ + except ValueError as e: + return Response().error(str(e)).__dict__ + except Exception as e: + logger.error(f"Open API send_message failed: {e}", exc_info=True) + return Response().error(f"Failed to send message: {e}").__dict__ + + async def get_bots(self): + bot_ids = [] + for platform in self.core_lifecycle.astrbot_config.get("platform", []): + platform_id = platform.get("id") if isinstance(platform, dict) else None + if ( + isinstance(platform_id, str) + and platform_id + and platform_id not in bot_ids + ): + bot_ids.append(platform_id) + return Response().ok(data={"bot_ids": bot_ids}).__dict__ diff --git a/astrbot/dashboard/routes/plugin.py b/astrbot/dashboard/routes/plugin.py index ca271cdf6..a679cf8dc 100644 --- a/astrbot/dashboard/routes/plugin.py +++ b/astrbot/dashboard/routes/plugin.py @@ -19,8 +19,14 @@ from astrbot.core.star.filter.command_group import CommandGroupFilter from astrbot.core.star.filter.permission import PermissionTypeFilter from astrbot.core.star.filter.regex import RegexFilter from astrbot.core.star.star_handler import EventType, star_handlers_registry -from astrbot.core.star.star_manager import PluginManager -from astrbot.core.utils.astrbot_path import get_astrbot_temp_path +from astrbot.core.star.star_manager import ( + PluginManager, + PluginVersionIncompatibleError, +) +from astrbot.core.utils.astrbot_path import ( + get_astrbot_data_path, + get_astrbot_temp_path, +) from .route import Response, Route, RouteContext @@ -46,6 +52,7 @@ class PluginRoute(Route): super().__init__(context) self.routes = { "/plugin/get": ("GET", self.get_plugins), + "/plugin/check-compat": ("POST", self.check_plugin_compatibility), "/plugin/install": ("POST", self.install_plugin), "/plugin/install-upload": ("POST", self.install_plugin_upload), "/plugin/update": ("POST", self.update_plugin), @@ -73,10 +80,32 @@ class PluginRoute(Route): EventType.OnDecoratingResultEvent: "回复消息前", EventType.OnCallingFuncToolEvent: "函数工具", EventType.OnAfterMessageSentEvent: "发送消息后", + EventType.OnPluginErrorEvent: "插件报错时", } self._logo_cache = {} + async def check_plugin_compatibility(self): + try: + data = await request.get_json() + version_spec = data.get("astrbot_version", "") + is_valid, message = self.plugin_manager._validate_astrbot_version_specifier( + version_spec + ) + return ( + Response() + .ok( + { + "compatible": is_valid, + "message": message, + "astrbot_version": version_spec, + } + ) + .__dict__ + ) + except Exception as e: + return Response().error(str(e)).__dict__ + async def reload_failed_plugins(self): if DEMO_MODE: return ( @@ -117,7 +146,7 @@ class PluginRoute(Route): try: success, message = await self.plugin_manager.reload(plugin_name) if not success: - return Response().error(message).__dict__ + return Response().error(message or "插件重载失败").__dict__ return Response().ok(None, "重载成功。").__dict__ except Exception as e: logger.error(f"/api/plugin/reload: {traceback.format_exc()}") @@ -195,10 +224,11 @@ class PluginRoute(Route): def _build_registry_source(self, custom_url: str | None) -> RegistrySource: """构建注册表源信息""" + data_dir = get_astrbot_data_path() if custom_url: # 对自定义URL生成一个安全的文件名 url_hash = hashlib.md5(custom_url.encode()).hexdigest()[:8] - cache_file = f"data/plugins_custom_{url_hash}.json" + cache_file = os.path.join(data_dir, f"plugins_custom_{url_hash}.json") # 更安全的后缀处理方式 if custom_url.endswith(".json"): @@ -208,7 +238,7 @@ class PluginRoute(Route): urls = [custom_url] else: - cache_file = "data/plugins.json" + cache_file = os.path.join(data_dir, "plugins.json") md5_url = "https://api.soulter.top/astrbot/plugins-md5" urls = [ "https://api.soulter.top/astrbot/plugins", @@ -344,6 +374,8 @@ class PluginRoute(Route): ), "display_name": plugin.display_name, "logo": f"/api/file/{logo_url}" if logo_url else None, + "support_platforms": plugin.support_platforms, + "astrbot_version": plugin.astrbot_version, } # 检查是否为全空的幽灵插件 if not any( @@ -438,6 +470,7 @@ class PluginRoute(Route): post_data = await request.get_json() repo_url = post_data["url"] + ignore_version_check = bool(post_data.get("ignore_version_check", False)) proxy: str = post_data.get("proxy", None) if proxy: @@ -445,10 +478,23 @@ class PluginRoute(Route): try: logger.info(f"正在安装插件 {repo_url}") - plugin_info = await self.plugin_manager.install_plugin(repo_url, proxy) + plugin_info = await self.plugin_manager.install_plugin( + repo_url, + proxy, + ignore_version_check=ignore_version_check, + ) # self.core_lifecycle.restart() logger.info(f"安装插件 {repo_url} 成功。") return Response().ok(plugin_info, "安装成功。").__dict__ + except PluginVersionIncompatibleError as e: + return { + "status": "warning", + "message": str(e), + "data": { + "warning_type": "astrbot_version_incompatible", + "can_ignore": True, + }, + } except Exception as e: logger.error(traceback.format_exc()) return Response().error(str(e)).__dict__ @@ -464,16 +510,32 @@ class PluginRoute(Route): try: file = await request.files file = file["file"] + form_data = await request.form + ignore_version_check = ( + str(form_data.get("ignore_version_check", "false")).lower() == "true" + ) logger.info(f"正在安装用户上传的插件 {file.filename}") file_path = os.path.join( get_astrbot_temp_path(), f"plugin_upload_{file.filename}", ) await file.save(file_path) - plugin_info = await self.plugin_manager.install_plugin_from_file(file_path) + plugin_info = await self.plugin_manager.install_plugin_from_file( + file_path, + ignore_version_check=ignore_version_check, + ) # self.core_lifecycle.restart() logger.info(f"安装插件 {file.filename} 成功") return Response().ok(plugin_info, "安装成功。").__dict__ + except PluginVersionIncompatibleError as e: + return { + "status": "warning", + "message": str(e), + "data": { + "warning_type": "astrbot_version_incompatible", + "can_ignore": True, + }, + } except Exception as e: logger.error(traceback.format_exc()) return Response().error(str(e)).__dict__ @@ -636,10 +698,16 @@ class PluginRoute(Route): logger.warning(f"插件 {plugin_name} 目录不存在") return Response().error(f"插件 {plugin_name} 目录不存在").__dict__ - plugin_dir = os.path.join( - self.plugin_manager.plugin_store_path, - plugin_obj.root_dir_name or "", - ) + if plugin_obj.reserved: + plugin_dir = os.path.join( + self.plugin_manager.reserved_plugin_path, + plugin_obj.root_dir_name, + ) + else: + plugin_dir = os.path.join( + self.plugin_manager.plugin_store_path, + plugin_obj.root_dir_name, + ) if not os.path.isdir(plugin_dir): logger.warning(f"无法找到插件目录: {plugin_dir}") @@ -673,6 +741,7 @@ class PluginRoute(Route): logger.debug(f"正在获取插件 {plugin_name} 的更新日志") if not plugin_name: + logger.warning("插件名称为空") return Response().error("插件名称不能为空").__dict__ # 查找插件 @@ -683,15 +752,27 @@ class PluginRoute(Route): break if not plugin_obj: + logger.warning(f"插件 {plugin_name} 不存在") return Response().error(f"插件 {plugin_name} 不存在").__dict__ if not plugin_obj.root_dir_name: + logger.warning(f"插件 {plugin_name} 目录不存在") return Response().error(f"插件 {plugin_name} 目录不存在").__dict__ - plugin_dir = os.path.join( - self.plugin_manager.plugin_store_path, - plugin_obj.root_dir_name, - ) + if plugin_obj.reserved: + plugin_dir = os.path.join( + self.plugin_manager.reserved_plugin_path, + plugin_obj.root_dir_name, + ) + else: + plugin_dir = os.path.join( + self.plugin_manager.plugin_store_path, + plugin_obj.root_dir_name, + ) + + if not os.path.isdir(plugin_dir): + logger.warning(f"无法找到插件目录: {plugin_dir}") + return Response().error(f"无法找到插件 {plugin_name} 的目录").__dict__ # 尝试多种可能的文件名 changelog_names = ["CHANGELOG.md", "changelog.md", "CHANGELOG", "changelog"] @@ -711,6 +792,7 @@ class PluginRoute(Route): return Response().error(f"读取更新日志失败: {e!s}").__dict__ # 没有找到 changelog 文件,返回 ok 但 content 为 null + logger.warning(f"插件 {plugin_name} 没有更新日志文件") return Response().ok({"content": None}, "该插件没有更新日志文件").__dict__ async def get_custom_source(self): diff --git a/astrbot/dashboard/server.py b/astrbot/dashboard/server.py index 9d8dffa37..a9631fc09 100644 --- a/astrbot/dashboard/server.py +++ b/astrbot/dashboard/server.py @@ -1,7 +1,9 @@ import asyncio +import hashlib import logging import os import socket +from pathlib import Path from typing import Protocol, cast import jwt @@ -20,6 +22,7 @@ from astrbot.core.utils.astrbot_path import get_astrbot_data_path from astrbot.core.utils.io import get_local_ip_addresses from .routes import * +from .routes.api_key import ALL_OPEN_API_SCOPES from .routes.backup import BackupRoute from .routes.live_chat import LiveChatRoute from .routes.platform import PlatformRoute @@ -36,6 +39,12 @@ class _AddrWithPort(Protocol): APP: Quart +def _parse_env_bool(value: str | None, default: bool) -> bool: + if value is None: + return default + return value.strip().lower() in {"1", "true", "yes", "on"} + + class AstrBotDashboard: def __init__( self, @@ -46,6 +55,7 @@ class AstrBotDashboard: ) -> None: self.core_lifecycle = core_lifecycle self.config = core_lifecycle.astrbot_config + self.db = db # 参数指定webui目录 if webui_dir and os.path.exists(webui_dir): @@ -81,7 +91,14 @@ class AstrBotDashboard: self.lr = LogRoute(self.context, core_lifecycle.log_broker) self.sfr = StaticFileRoute(self.context) self.ar = AuthRoute(self.context) + self.api_key_route = ApiKeyRoute(self.context, db) self.chat_route = ChatRoute(self.context, db, core_lifecycle) + self.open_api_route = OpenApiRoute( + self.context, + db, + core_lifecycle, + self.chat_route, + ) self.chatui_project_route = ChatUIProjectRoute(self.context, db) self.tools_root = ToolsRoute(self.context, core_lifecycle) self.subagent_route = SubAgentRoute(self.context, core_lifecycle) @@ -123,6 +140,40 @@ class AstrBotDashboard: async def auth_middleware(self): if not request.path.startswith("/api"): return None + if request.path.startswith("/api/v1"): + raw_key = self._extract_raw_api_key() + if not raw_key: + r = jsonify(Response().error("Missing API key").__dict__) + r.status_code = 401 + return r + key_hash = hashlib.pbkdf2_hmac( + "sha256", + raw_key.encode("utf-8"), + b"astrbot_api_key", + 100_000, + ).hex() + api_key = await self.db.get_active_api_key_by_hash(key_hash) + if not api_key: + r = jsonify(Response().error("Invalid API key").__dict__) + r.status_code = 401 + return r + + if isinstance(api_key.scopes, list): + scopes = api_key.scopes + else: + scopes = list(ALL_OPEN_API_SCOPES) + required_scope = self._get_required_open_api_scope(request.path) + if required_scope and "*" not in scopes and required_scope not in scopes: + r = jsonify(Response().error("Insufficient API key scope").__dict__) + r.status_code = 403 + return r + + g.api_key_id = api_key.key_id + g.api_key_scopes = scopes + g.username = f"api_key:{api_key.key_id}" + await self.db.touch_api_key(api_key.key_id) + return None + allowed_endpoints = [ "/api/auth/login", "/api/file", @@ -151,6 +202,29 @@ class AstrBotDashboard: r.status_code = 401 return r + @staticmethod + def _extract_raw_api_key() -> str | None: + if key := request.headers.get("X-API-Key"): + return key.strip() + auth_header = request.headers.get("Authorization", "").strip() + if auth_header.startswith("Bearer "): + return auth_header.removeprefix("Bearer ").strip() + if auth_header.startswith("ApiKey "): + return auth_header.removeprefix("ApiKey ").strip() + return None + + @staticmethod + def _get_required_open_api_scope(path: str) -> str | None: + scope_map = { + "/api/v1/chat": "chat", + "/api/v1/chat/sessions": "chat", + "/api/v1/configs": "config", + "/api/v1/file": "file", + "/api/v1/im/message": "im", + "/api/v1/im/bots": "im", + } + return scope_map.get(path) + def check_port_in_use(self, port: int) -> bool: """跨平台检测端口是否被占用""" try: @@ -201,23 +275,33 @@ class AstrBotDashboard: def run(self): ip_addr = [] + dashboard_config = self.core_lifecycle.astrbot_config.get("dashboard", {}) port = ( os.environ.get("DASHBOARD_PORT") or os.environ.get("ASTRBOT_DASHBOARD_PORT") - or self.core_lifecycle.astrbot_config["dashboard"].get("port", 6185) + or dashboard_config.get("port", 6185) ) host = ( os.environ.get("DASHBOARD_HOST") or os.environ.get("ASTRBOT_DASHBOARD_HOST") - or self.core_lifecycle.astrbot_config["dashboard"].get("host", "0.0.0.0") + or dashboard_config.get("host", "0.0.0.0") ) - enable = self.core_lifecycle.astrbot_config["dashboard"].get("enable", True) + enable = dashboard_config.get("enable", True) + ssl_config = dashboard_config.get("ssl", {}) + if not isinstance(ssl_config, dict): + ssl_config = {} + ssl_enable = _parse_env_bool( + os.environ.get("DASHBOARD_SSL_ENABLE") + or os.environ.get("ASTRBOT_DASHBOARD_SSL_ENABLE"), + bool(ssl_config.get("enable", False)), + ) + scheme = "https" if ssl_enable else "http" if not enable: logger.info("WebUI 已被禁用") return None - logger.info(f"正在启动 WebUI, 监听地址: http://{host}:{port}") + logger.info(f"正在启动 WebUI, 监听地址: {scheme}://{host}:{port}") if host == "0.0.0.0": logger.info( "提示: WebUI 将监听所有网络接口,请注意安全。(可在 data/cmd_config.json 中配置 dashboard.host 以修改 host)", @@ -245,9 +329,9 @@ class AstrBotDashboard: raise Exception(f"端口 {port} 已被占用") parts = [f"\n ✨✨✨\n AstrBot v{VERSION} WebUI 已启动,可访问\n\n"] - parts.append(f" ➜ 本地: http://localhost:{port}\n") + parts.append(f" ➜ 本地: {scheme}://localhost:{port}\n") for ip in ip_addr: - parts.append(f" ➜ 网络: http://{ip}:{port}\n") + parts.append(f" ➜ 网络: {scheme}://{ip}:{port}\n") parts.append(" ➜ 默认用户名和密码: astrbot\n ✨✨✨\n") display = "".join(parts) @@ -261,11 +345,45 @@ class AstrBotDashboard: # 配置 Hypercorn config = HyperConfig() config.bind = [f"{host}:{port}"] + if ssl_enable: + cert_file = ( + os.environ.get("DASHBOARD_SSL_CERT") + or os.environ.get("ASTRBOT_DASHBOARD_SSL_CERT") + or ssl_config.get("cert_file", "") + ) + key_file = ( + os.environ.get("DASHBOARD_SSL_KEY") + or os.environ.get("ASTRBOT_DASHBOARD_SSL_KEY") + or ssl_config.get("key_file", "") + ) + ca_certs = ( + os.environ.get("DASHBOARD_SSL_CA_CERTS") + or os.environ.get("ASTRBOT_DASHBOARD_SSL_CA_CERTS") + or ssl_config.get("ca_certs", "") + ) + + cert_path = Path(cert_file).expanduser() + key_path = Path(key_file).expanduser() + if not cert_file or not key_file: + raise ValueError( + "dashboard.ssl.enable 为 true 时,必须配置 cert_file 和 key_file。", + ) + if not cert_path.is_file(): + raise ValueError(f"SSL 证书文件不存在: {cert_path}") + if not key_path.is_file(): + raise ValueError(f"SSL 私钥文件不存在: {key_path}") + + config.certfile = str(cert_path.resolve()) + config.keyfile = str(key_path.resolve()) + + if ca_certs: + ca_path = Path(ca_certs).expanduser() + if not ca_path.is_file(): + raise ValueError(f"SSL CA 证书文件不存在: {ca_path}") + config.ca_certs = str(ca_path.resolve()) # 根据配置决定是否禁用访问日志 - disable_access_log = self.core_lifecycle.astrbot_config.get( - "dashboard", {} - ).get("disable_access_log", True) + disable_access_log = dashboard_config.get("disable_access_log", True) if disable_access_log: config.accesslog = None else: diff --git a/astrbot/dashboard/utils.py b/astrbot/dashboard/utils.py index b81faad06..3a0ee5bdc 100644 --- a/astrbot/dashboard/utils.py +++ b/astrbot/dashboard/utils.py @@ -1,5 +1,4 @@ import base64 -import os import traceback from io import BytesIO @@ -51,14 +50,14 @@ async def generate_tsne_visualization( return None kb = kb_helper.kb - index_path = f"data/knowledge_base/{kb.kb_id}/index.faiss" + index_path = kb_helper.kb_dir / "index.faiss" # 读取 FAISS 索引 - if not os.path.exists(index_path): - logger.warning(f"FAISS 索引不存在: {index_path}") + if not index_path.exists(): + logger.warning(f"FAISS 索引不存在: {index_path!s}") return None - index = faiss.read_index(index_path) + index = faiss.read_index(str(index_path)) if index.ntotal == 0: logger.warning("索引为空") diff --git a/changelogs/v4.17.0.md b/changelogs/v4.17.0.md new file mode 100644 index 000000000..c8f2e93a1 --- /dev/null +++ b/changelogs/v4.17.0.md @@ -0,0 +1,29 @@ +## What's Changed + +### 新增 +- 新增 LINE 平台适配器与相关配置支持 ([#5085](https://github.com/AstrBotDevs/AstrBot/issues/5085)) +- 新增备用回退聊天模型列表,当主模型报错时自动切换到备用模型 ([#5109](https://github.com/AstrBotDevs/AstrBot/issues/5109)) +- 新增插件加载失败后的热重载支持,便于插件修复后快速恢复 ([#5043](https://github.com/AstrBotDevs/AstrBot/issues/5043)) +- WebUI 新增 SSL 配置选项并同步更新相关日志行为 ([#5117](https://github.com/AstrBotDevs/AstrBot/issues/5117)) + +### 修复 +- 修复 Dockerfile 中依赖导出流程,增加 `uv lock` 步骤并移除不必要的 `--frozen` 参数,提升构建稳定性 ([#5091](https://github.com/AstrBotDevs/AstrBot/issues/5091), [#5089](https://github.com/AstrBotDevs/AstrBot/issues/5089)) +- 修复首次启动公告 `FIRST_NOTICE.md` 的本地化路径解析问题,补充兼容路径处理 ([#5083](https://github.com/AstrBotDevs/AstrBot/issues/5083), [#5082](https://github.com/AstrBotDevs/AstrBot/issues/5082)) + +### 优化 +- 日志系统由 `colorlog` 切换为 `loguru`,增强日志输出与展示能力 ([#5115](https://github.com/AstrBotDevs/AstrBot/issues/5115)) + +## What's Changed (EN) + +### New Features +- Added LINE platform adapter support with related configuration options ([#5085](https://github.com/AstrBotDevs/AstrBot/issues/5085)) +- Added fallback chat model chain support in tool loop runner, with corresponding config and improved provider selection display ([#5109](https://github.com/AstrBotDevs/AstrBot/issues/5109)) +- Added hot reload support after plugin load failure for faster recovery during plugin development and maintenance ([#5043](https://github.com/AstrBotDevs/AstrBot/issues/5043)) +- Added SSL configuration options for WebUI and updated related logging behavior ([#5117](https://github.com/AstrBotDevs/AstrBot/issues/5117)) + +### Fixes +- Fixed Dockerfile dependency export flow by adding a `uv lock` step and removing unnecessary `--frozen` flag to improve build stability ([#5091](https://github.com/AstrBotDevs/AstrBot/issues/5091), [#5089](https://github.com/AstrBotDevs/AstrBot/issues/5089)) +- Fixed locale path resolution for `FIRST_NOTICE.md` and added compatible fallback handling ([#5083](https://github.com/AstrBotDevs/AstrBot/issues/5083), [#5082](https://github.com/AstrBotDevs/AstrBot/issues/5082)) + +### Improvements +- Replaced `colorlog` with `loguru` to improve logging capabilities and console display ([#5115](https://github.com/AstrBotDevs/AstrBot/issues/5115)) diff --git a/changelogs/v4.17.1.md b/changelogs/v4.17.1.md new file mode 100644 index 000000000..13e7daad6 --- /dev/null +++ b/changelogs/v4.17.1.md @@ -0,0 +1,34 @@ +## What's Changed + +hotfix of 4.17.0 + +- 修复:当开启了 “启用文件日志” 后,无法启动 AstrBot,报错 `ValueError: Invalid unit value while parsing duration: 'files'`。这是由于日志轮转设置中保留配置错误导致的,已通过根据备份数量正确设置保留参数进行修复。 +- fix: When "Enable file logging" is turned on, AstrBot fails to start with error `ValueError: Invalid unit value while parsing duration: 'files'`. This is due to an incorrect retention configuration in the log rotation setup, which has been fixed by properly setting the retention parameter based on backup count. + +### 新增 +- 新增 LINE 平台适配器与相关配置支持 ([#5085](https://github.com/AstrBotDevs/AstrBot/issues/5085)) +- 新增备用回退聊天模型列表,当主模型报错时自动切换到备用模型 ([#5109](https://github.com/AstrBotDevs/AstrBot/issues/5109)) +- 新增插件加载失败后的热重载支持,便于插件修复后快速恢复 ([#5043](https://github.com/AstrBotDevs/AstrBot/issues/5043)) +- WebUI 新增 SSL 配置选项并同步更新相关日志行为 ([#5117](https://github.com/AstrBotDevs/AstrBot/issues/5117)) + +### 修复 +- 修复 Dockerfile 中依赖导出流程,增加 `uv lock` 步骤并移除不必要的 `--frozen` 参数,提升构建稳定性 ([#5091](https://github.com/AstrBotDevs/AstrBot/issues/5091), [#5089](https://github.com/AstrBotDevs/AstrBot/issues/5089)) +- 修复首次启动公告 `FIRST_NOTICE.md` 的本地化路径解析问题,补充兼容路径处理 ([#5083](https://github.com/AstrBotDevs/AstrBot/issues/5083), [#5082](https://github.com/AstrBotDevs/AstrBot/issues/5082)) + +### 优化 +- 日志系统由 `colorlog` 切换为 `loguru`,增强日志输出与展示能力 ([#5115](https://github.com/AstrBotDevs/AstrBot/issues/5115)) + +## What's Changed (EN) + +### New Features +- Added LINE platform adapter support with related configuration options ([#5085](https://github.com/AstrBotDevs/AstrBot/issues/5085)) +- Added fallback chat model chain support in tool loop runner, with corresponding config and improved provider selection display ([#5109](https://github.com/AstrBotDevs/AstrBot/issues/5109)) +- Added hot reload support after plugin load failure for faster recovery during plugin development and maintenance ([#5043](https://github.com/AstrBotDevs/AstrBot/issues/5043)) +- Added SSL configuration options for WebUI and updated related logging behavior ([#5117](https://github.com/AstrBotDevs/AstrBot/issues/5117)) + +### Fixes +- Fixed Dockerfile dependency export flow by adding a `uv lock` step and removing unnecessary `--frozen` flag to improve build stability ([#5091](https://github.com/AstrBotDevs/AstrBot/issues/5091), [#5089](https://github.com/AstrBotDevs/AstrBot/issues/5089)) +- Fixed locale path resolution for `FIRST_NOTICE.md` and added compatible fallback handling ([#5083](https://github.com/AstrBotDevs/AstrBot/issues/5083), [#5082](https://github.com/AstrBotDevs/AstrBot/issues/5082)) + +### Improvements +- Replaced `colorlog` with `loguru` to improve logging capabilities and console display ([#5115](https://github.com/AstrBotDevs/AstrBot/issues/5115)) diff --git a/changelogs/v4.17.2.md b/changelogs/v4.17.2.md new file mode 100644 index 000000000..e65972de3 --- /dev/null +++ b/changelogs/v4.17.2.md @@ -0,0 +1,8 @@ +## What's Changed + +hotfix of 4.17.0 + +- 修复:MCP 服务器的 Tools 没有被正确添加到上下文中。 +- 修复:Electron 桌面应用部署时,系统自带插件未被正确加载的问题。 +- fix: Tools from MCP server were not properly added to context. +- fix: built-in plugins were not properly loaded in Electron desktop application deployment. diff --git a/changelogs/v4.17.3.md b/changelogs/v4.17.3.md new file mode 100644 index 000000000..4b87b6243 --- /dev/null +++ b/changelogs/v4.17.3.md @@ -0,0 +1,27 @@ +## What's Changed + +### 修复 +- ‼️ 修复 Python 3.14 环境下 `'Plain' object has no attribute 'text'` 报错问题 ([#5154](https://github.com/AstrBotDevs/AstrBot/issues/5154))。 +- ‼️ 修复插件元数据处理流程:在实例化前注入必要属性,避免初始化阶段元数据缺失 ([#5155](https://github.com/AstrBotDevs/AstrBot/issues/5155))。 +- 修复桌面端后端构建中 AstrBot 内置插件运行时依赖未打包的问题 ([#5146](https://github.com/AstrBotDevs/AstrBot/issues/5146))。 +- 修复通过 AstrBot Launcher 启动时仍被检测并触发更新的问题。 + +### 优化 + +- Webchat 下,使用 `astrbot_execute_ipython` 工具如果返回了图片,会自动将图片发送到聊天中。 + +### 其他 +- 执行 `ruff format` 代码格式整理。 + +## What's Changed (EN) + +### Fixes +- ‼️ Fixed plugin metadata handling by injecting required attributes before instantiation to avoid missing metadata during initialization ([#5155](https://github.com/AstrBotDevs/AstrBot/issues/5155)). +- ‼️ Fixed `'Plain' object has no attribute 'text'` error when using Python 3.14 ([#5154](https://github.com/AstrBotDevs/AstrBot/issues/5154)). +- Fixed missing runtime dependencies for built-in plugins in desktop backend builds ([#5146](https://github.com/AstrBotDevs/AstrBot/issues/5146)). +- Fixed update checks being triggered when AstrBot is launched via AstrBot Launcher. + +### Improvements +- In Webchat, when using the `astrbot_execute_ipython` tool, if an image is returned, it will automatically be sent to the chat. +### Others +- Applied `ruff format` code formatting. diff --git a/changelogs/v4.17.4.md b/changelogs/v4.17.4.md new file mode 100644 index 000000000..667b03060 --- /dev/null +++ b/changelogs/v4.17.4.md @@ -0,0 +1,32 @@ +## What's Changed + +### 新增 +- 新增 NVIDIA Provider 模板,便于快速接入 NVIDIA 模型服务 ([#5157](https://github.com/AstrBotDevs/AstrBot/issues/5157))。 +- 支持在 WebUI 搜索配置 + +### 修复 +- 修复 CronJob 页面操作列按钮重叠问题,提升任务管理可用性 ([#5163](https://github.com/AstrBotDevs/AstrBot/issues/5163))。 + +### 优化 +- 优化 Python / Shell 本地执行工具的权限拒绝提示信息引导,提升排障可读性。 +- Provider 来源面板样式升级,新增菜单交互并完善移动端适配。 +- PersonaForm 组件增强响应式布局与样式细节,改进不同屏幕下的编辑体验 ([#5162](https://github.com/AstrBotDevs/AstrBot/issues/5162))。 +- 配置页面新增未保存变更提示,减少误操作导致的配置丢失。 +- 配置相关组件新增搜索能力并同步更新界面交互,提升配置项定位效率 ([#5168](https://github.com/AstrBotDevs/AstrBot/issues/5168))。 + +## What's Changed (EN) + +### New Features +- Added an NVIDIA provider template for faster integration with NVIDIA model services ([#5157](https://github.com/AstrBotDevs/AstrBot/issues/5157)). +- Added an announcement section to the Welcome page, with localized announcement title support. +- Added an FAQ link to the vertical sidebar and updated navigation for localization. + +### Fixes +- Fixed overlapping action buttons in the CronJob page action column to improve task management usability ([#5163](https://github.com/AstrBotDevs/AstrBot/issues/5163)). +- Improved permission-denied messages for local execution in Python and shell tools for better troubleshooting clarity. + +### Improvements +- Enhanced the provider sources panel with a refined menu style and better mobile support. +- Improved PersonaForm with responsive layout and styling updates for better editing experience across screen sizes ([#5162](https://github.com/AstrBotDevs/AstrBot/issues/5162)). +- Added an unsaved-changes notice on the configuration page to reduce accidental config loss. +- Added search functionality to configuration components and updated related UI interactions for faster settings discovery ([#5168](https://github.com/AstrBotDevs/AstrBot/issues/5168)). diff --git a/changelogs/v4.17.5.md b/changelogs/v4.17.5.md new file mode 100644 index 000000000..c01ba4ea1 --- /dev/null +++ b/changelogs/v4.17.5.md @@ -0,0 +1,37 @@ +## What's Changed + +### 新增 +- 支持 QQ 官方机器人平台发送 Markdown 消息,提升富文本消息呈现能力 ([#5173](https://github.com/AstrBotDevs/AstrBot/issues/5173))。 +- 新增在插件市场中集成随机插件推荐能力 ([#5190](https://github.com/AstrBotDevs/AstrBot/issues/5190))。 +- 新增插件错误钩子(plugin error hook),支持自定义错误路由处理,便于插件统一异常控制 ([#5192](https://github.com/AstrBotDevs/AstrBot/issues/5192))。 + +### 修复 +- 修复全部 LLM Provider 失败时重复显示错误信息的问题,减少冗余报错干扰 ([#5183](https://github.com/AstrBotDevs/AstrBot/issues/5183))。 +- 修复从“选择配置文件”进入配置管理后直接关闭弹窗时,显示配置文件不正确的问题 ([#5174](https://github.com/AstrBotDevs/AstrBot/issues/5174))。 + +### 优化 +- 重构 telegram `Voice_messages_forbidden` 回退逻辑,提取为共享辅助方法并引入类型化 `BadRequest` 异常,提升异常处理一致性 ([#5204](https://github.com/AstrBotDevs/AstrBot/issues/5204))。 + +### 其他 +- 更新 README 相关文档内容。 +- 执行 `ruff format` 代码格式整理。 + +## What's Changed (EN) + +### New Features +- Added a plugin error hook for custom error routing, enabling unified exception handling in plugins ([#5192](https://github.com/AstrBotDevs/AstrBot/issues/5192)). +- Added Markdown message sending support for `qqofficial` to improve rich-text delivery ([#5173](https://github.com/AstrBotDevs/AstrBot/issues/5173)). +- Added the `MarketPluginCard` component and integrated random plugin recommendations in the extension marketplace ([#5190](https://github.com/AstrBotDevs/AstrBot/issues/5190)). +- Added support for the `aihubmix` provider. +- Added LINE support notes to multilingual README files. + +### Fixes +- Fixed duplicate error messages when all LLM providers fail, reducing noisy error output ([#5183](https://github.com/AstrBotDevs/AstrBot/issues/5183)). +- Fixed incorrect displayed profile after opening configuration management from profile selection and closing the dialog directly ([#5174](https://github.com/AstrBotDevs/AstrBot/issues/5174)). + +### Improvements +- Refactored `Voice_messages_forbidden` fallback logic into a shared helper and introduced a typed `BadRequest` exception for more consistent error handling ([#5204](https://github.com/AstrBotDevs/AstrBot/issues/5204)). + +### Others +- Updated README documentation. +- Applied `ruff format` code formatting. diff --git a/changelogs/v4.17.6.md b/changelogs/v4.17.6.md new file mode 100644 index 000000000..1efbc6f77 --- /dev/null +++ b/changelogs/v4.17.6.md @@ -0,0 +1,47 @@ +## What's Changed + +### 新增 +- 新增 Python / Shell 执行工具的管理员权限校验,提升高风险操作安全性 ([#5214](https://github.com/AstrBotDevs/AstrBot/issues/5214))。 +- 新增插件 `astrbot-version` 与平台版本要求校验支持,增强插件兼容性管理能力 ([#5235](https://github.com/AstrBotDevs/AstrBot/issues/5235))。 +- 账号密码修改流程新增“确认新密码”校验,减少误输导致的配置问题 ([#5247](https://github.com/AstrBotDevs/AstrBot/issues/5247))。 + +### 修复 +- 改进微信公众号被动回复处理机制,引入缓冲与分片回复并优化超时行为,提升回复稳定性 ([#5224](https://github.com/AstrBotDevs/AstrBot/issues/5224))。 +- 修复仅发送 JSON 消息段时可能触发空消息回复报错的问题 ([#5208](https://github.com/AstrBotDevs/AstrBot/issues/5208))。 +- 修复会话重置/新建/删除时未终止活动事件导致的陈旧响应问题 ([#5225](https://github.com/AstrBotDevs/AstrBot/issues/5225))。 +- 修复 provider 在 `dict` 格式 `content` 场景下可能残留 JSON 内容的问题 ([#5250](https://github.com/AstrBotDevs/AstrBot/issues/5250))。 +- 修复 MCP 工具未完整暴露给主 Agent 的问题 ([#5252](https://github.com/AstrBotDevs/AstrBot/issues/5252))。 +- 修复工具 schema 属性中的 `additionalProperties` 配置问题 ([#5253](https://github.com/AstrBotDevs/AstrBot/issues/5253))。 +- 优化账号编辑校验错误提示,简化并统一用户名/密码为空场景返回信息。 + +### 优化 +- 优化 PersonaForm 布局与工具选择展示,并完善工具停用状态的本地化显示。 + +### 其他 +- 移除 Electron Desktop 流水线并迁移到 Tauri 仓库 ([#5226](https://github.com/AstrBotDevs/AstrBot/issues/5226))。 +- 更新相关仓库链接与功能请求模板文案,统一中英文表达。 +- 移除过时文档文件 `heihe.md`。 + +## What's Changed (EN) + +### New Features +- Added admin permission checks for Python/Shell execution tools to improve safety for high-risk operations ([#5214](https://github.com/AstrBotDevs/AstrBot/issues/5214)). +- Added support for `astrbot-version` and platform requirement checks for plugins to improve compatibility management ([#5235](https://github.com/AstrBotDevs/AstrBot/issues/5235)). +- Added password confirmation when changing account passwords to reduce misconfiguration caused by typos ([#5247](https://github.com/AstrBotDevs/AstrBot/issues/5247)). + +### Fixes +- Improved passive reply handling for WeChat Official Accounts with buffering/chunking and timeout behavior optimizations for better stability ([#5224](https://github.com/AstrBotDevs/AstrBot/issues/5224)). +- Fixed an empty-message reply error when only JSON message segments were sent ([#5208](https://github.com/AstrBotDevs/AstrBot/issues/5208)). +- Fixed stale responses by terminating active events on reset/new/delete operations ([#5225](https://github.com/AstrBotDevs/AstrBot/issues/5225)). +- Fixed residual JSON content issues in provider handling when `content` was in `dict` format ([#5250](https://github.com/AstrBotDevs/AstrBot/issues/5250)). +- Fixed incomplete exposure of MCP tools to the main agent ([#5252](https://github.com/AstrBotDevs/AstrBot/issues/5252)). +- Fixed `additionalProperties` handling in tool schema properties ([#5253](https://github.com/AstrBotDevs/AstrBot/issues/5253)). +- Simplified and unified account-edit validation error responses for empty username/password scenarios. + +### Improvements +- Enhanced PersonaForm layout and tool selection display, and improved localized labels for inactive tools. + +### Others +- Removed the Electron desktop pipeline and switched to the Tauri repository ([#5226](https://github.com/AstrBotDevs/AstrBot/issues/5226)). +- Updated related repository links and refined feature request template wording in both Chinese and English. +- Removed outdated documentation file `heihe.md`. diff --git a/changelogs/v4.18.0.md b/changelogs/v4.18.0.md new file mode 100644 index 000000000..847da020e --- /dev/null +++ b/changelogs/v4.18.0.md @@ -0,0 +1,29 @@ +## What's Changed + +### 新增 +- 新增 AstrBot HTTP API,支持基于 API Key 的对话、会话查询、配置查询、文件上传与 IM 消息发送能力。详见[AstrBot HTTP API (Beta)](https://docs.astrbot.app/dev/openapi.html) ([#5280](https://github.com/AstrBotDevs/AstrBot/issues/5280))。 +- 新增 Telegram 指令别名注册能力,别名可同步展示在 Telegram 指令菜单中 ([#5234](https://github.com/AstrBotDevs/AstrBot/issues/5234))。 +- 新增 Anthropic 自适应思考参数配置(type/effort),增强思考策略可控性 ([#5209](https://github.com/AstrBotDevs/AstrBot/issues/5209))。 + +### 修复 +- 修复 QQ 官方频道消息发送异常问题,提升消息下发稳定性 ([#5287](https://github.com/AstrBotDevs/AstrBot/issues/5287))。 +- 修复 ChatUI 使用非 default 配置文件对话时仍然使用 default 配置的问题 ([#5292](https://github.com/AstrBotDevs/AstrBot/issues/5292))。 + +### 优化 +- 优化插件市场卡片的平台支持展示,改进移动端可用性与交互体验 ([#5271](https://github.com/AstrBotDevs/AstrBot/issues/5271))。 +- 重构 Dashboard 桌面运行时桥接字段,从 `isElectron` 统一迁移至 `isDesktop`,提升跨端语义一致性 ([#5269](https://github.com/AstrBotDevs/AstrBot/issues/5269))。 + +## What's Changed (EN) + +### New Features +- Added AstrBot HTTP API with API Key support for chat, session listing, config listing, file upload, and IM message sending. See [AstrBot HTTP API (Beta)](https://docs.astrbot.app/en/dev/openapi.html) ([#5280](https://github.com/AstrBotDevs/AstrBot/issues/5280)). +- Added Telegram command alias registration so aliases can also appear in the Telegram command menu ([#5234](https://github.com/AstrBotDevs/AstrBot/issues/5234)). +- Added Anthropic adaptive thinking parameters (`type`/`effort`) for more flexible reasoning strategy control ([#5209](https://github.com/AstrBotDevs/AstrBot/issues/5209)). + +### Fixes +- Fixed QQ official guild message sending errors to improve delivery stability ([#5287](https://github.com/AstrBotDevs/AstrBot/issues/5287)). +- Fixed chat config binding failures caused by missing session IDs when creating new chats, and improved localStorage fault tolerance ([#5292](https://github.com/AstrBotDevs/AstrBot/issues/5292)). + +### Improvements +- Improved plugin marketplace card display for platform compatibility, with better mobile accessibility and interaction ([#5271](https://github.com/AstrBotDevs/AstrBot/issues/5271)). +- Refactored desktop runtime bridge fields in the dashboard from `isElectron` to `isDesktop` for clearer cross-platform semantics ([#5269](https://github.com/AstrBotDevs/AstrBot/issues/5269)). diff --git a/changelogs/v4.18.1.md b/changelogs/v4.18.1.md new file mode 100644 index 000000000..1f17162eb --- /dev/null +++ b/changelogs/v4.18.1.md @@ -0,0 +1,17 @@ +## What's Changed + +### 修复 +- fix: 修复插件市场出现插件显示为空白的 bug;纠正已安装插件卡片的排版,统一大小 ([#5309](https://github.com/AstrBotDevs/AstrBot/issues/5309)) + +### 新增 +- SubAgent 支持后台执行模式配置:当 `background: true` 时,子代理将在后台运行,主对话无需等待子代理完成即可继续进行。当子代理完成后,会收到通知。适用于长时间运行或用户不需要立即结果的任务。([#5081](https://github.com/AstrBotDevs/AstrBot/issues/5081)) +- 配置 Schema 新增密码渲染支持:`string` 与 `text` 类型可通过 `password: true`(或 `render_type: "password"`)在 WebUI 中按密码输入方式显示。 + +## What's Changed (EN) + +### Fixes +- fix: Fixed a bug where the plugin marketplace would show blank cards for plugins; corrected the layout of installed plugin cards for consistent sizing ([#5309](https://github.com/AstrBotDevs/AstrBot/issues/5309)) + +### New Features +- Added background execution mode support for sub-agents: when `background: true` is set, the sub-agent will run in the background, allowing the main conversation to continue without waiting for the sub-agent to finish. You will be notified when the sub-agent completes. This is suitable for long-running tasks or when the user does not need immediate results. ([#5081](https://github.com/AstrBotDevs/AstrBot/issues/5081)) +- Added password rendering support in config schema: `string` and `text` fields can be rendered as password inputs in WebUI with `password: true` (or `render_type: "password"`). diff --git a/changelogs/v4.18.2.md b/changelogs/v4.18.2.md new file mode 100644 index 000000000..aa958ea6f --- /dev/null +++ b/changelogs/v4.18.2.md @@ -0,0 +1,60 @@ +## What's Changed + +### 新增 +- 新增 Agent 会话停止能力,并优化 stop 请求处理流程,支持 /stop 指令终止 Agent 运行并尽量不丢失已运行输出的结果。 ([#5380](https://github.com/AstrBotDevs/AstrBot/issues/5380))。 +- 新增 SubAgent 交接场景下的 computer-use 工具支持 ([#5399](https://github.com/AstrBotDevs/AstrBot/issues/5399))。 +- 新增 Agent 执行过程中展示工具调用结果的能力,提升执行过程可观测性 ([#5388](https://github.com/AstrBotDevs/AstrBot/issues/5388))。 +- 新增插件加载/卸载 Hook,扩展插件生命周期能力 ([#5331](https://github.com/AstrBotDevs/AstrBot/issues/5331))。 +- 新增插件加载失败后的热重载能力,提升插件开发与恢复效率 ([#5334](https://github.com/AstrBotDevs/AstrBot/issues/5334))。 +- 新增 SubAgent 图片 URL/本地路径输入支持 ([#5348](https://github.com/AstrBotDevs/AstrBot/issues/5348))。 +- 新增 Dashboard 发布跳转基础 URL 可配置项 ([#5330](https://github.com/AstrBotDevs/AstrBot/issues/5330))。 + +### 修复 +- 修复 Tavily 请求的硬编码 6 秒超时。 +- 修复 OneBot v11 适配器关闭之后仍然在连接的问题([#5412](https://github.com/AstrBotDevs/AstrBot/issues/5412))。 +- 修复上下文会话中平台缺失时的日志处理,补充 warning 并改进排查信息。 +- 修复 embedding 维度未透传到 provider API 的问题 ([#5411](https://github.com/AstrBotDevs/AstrBot/issues/5411))。 +- 修复 File 组件处理逻辑并增强 OneBot 驱动层路径兼容性 ([#5391](https://github.com/AstrBotDevs/AstrBot/issues/5391))。 +- 修复 sandbox 文件传输工具缺少管理员权限校验的问题 ([#5402](https://github.com/AstrBotDevs/AstrBot/issues/5402))。 +- 修复 pipeline 与 `from ... import *` 引发的循环依赖问题 ([#5353](https://github.com/AstrBotDevs/AstrBot/issues/5353))。 +- 修复配置文件存在 UTF-8 BOM 时的解析问题 ([#5376](https://github.com/AstrBotDevs/AstrBot/issues/5376))。 +- 修复 ChatUI 复制回滚路径缺失与错误提示不清晰的问题 ([#5352](https://github.com/AstrBotDevs/AstrBot/issues/5352))。 +- 修复保留插件目录处理逻辑,避免插件目录行为异常 ([#5369](https://github.com/AstrBotDevs/AstrBot/issues/5369))。 +- 修复 ChatUI 文件消息段无法持久化的问题 ([#5386](https://github.com/AstrBotDevs/AstrBot/issues/5386))。 +- 修复 `.dockerignore` 误排除 `changelogs` 目录的问题。 +- 修复 aiohttp 版本过新导致 qq-botpy 报错的问题 ([#5316](https://github.com/AstrBotDevs/AstrBot/issues/5316))。 + +### 优化 +- 完成 SubAgent 编排页面国际化,补齐多语言支持 ([#5400](https://github.com/AstrBotDevs/AstrBot/issues/5400))。 +- 增补消息事件处理相关测试,并完善测试框架的 fixtures/mocks 覆盖 ([#5355](https://github.com/AstrBotDevs/AstrBot/issues/5355), [#5354](https://github.com/AstrBotDevs/AstrBot/issues/5354))。 + +## What's Changed (EN) + +### New Features +- Added computer-use tools support in sub-agent handoff scenarios ([#5399](https://github.com/AstrBotDevs/AstrBot/issues/5399)). +- Added support for displaying tool call results during agent execution for better observability ([#5388](https://github.com/AstrBotDevs/AstrBot/issues/5388)). +- Added plugin load/unload hooks to extend plugin lifecycle capabilities ([#5331](https://github.com/AstrBotDevs/AstrBot/issues/5331)). +- Added hot reload support when plugin loading fails, improving recovery during plugin development ([#5334](https://github.com/AstrBotDevs/AstrBot/issues/5334)). +- Added image URL/local path input support for sub-agents ([#5348](https://github.com/AstrBotDevs/AstrBot/issues/5348)). +- Added stop control for active agent sessions and improved stop request handling ([#5380](https://github.com/AstrBotDevs/AstrBot/issues/5380)). +- Added configurable base URL for dashboard release redirects ([#5330](https://github.com/AstrBotDevs/AstrBot/issues/5330)). + +### Fixes +- Fixed logging behavior when platform information is missing in context sessions, with clearer warning and diagnostics. +- Fixed missing embedding dimensions being passed to provider APIs ([#5411](https://github.com/AstrBotDevs/AstrBot/issues/5411)). +- Fixed shutdown stability issues in the aiocqhttp adapter ([#5412](https://github.com/AstrBotDevs/AstrBot/issues/5412)). +- Fixed File component handling and improved path compatibility in the OneBot driver layer ([#5391](https://github.com/AstrBotDevs/AstrBot/issues/5391)). +- Fixed missing admin guard for sandbox file transfer tools ([#5402](https://github.com/AstrBotDevs/AstrBot/issues/5402)). +- Fixed circular import issues related to pipeline and `from ... import *` usage ([#5353](https://github.com/AstrBotDevs/AstrBot/issues/5353)). +- Fixed config parsing issues when files contain UTF-8 BOM ([#5376](https://github.com/AstrBotDevs/AstrBot/issues/5376)). +- Fixed missing copy rollback path and unclear error messaging in ChatUI ([#5352](https://github.com/AstrBotDevs/AstrBot/issues/5352)). +- Fixed reserved plugin directory handling to avoid abnormal plugin path behavior ([#5369](https://github.com/AstrBotDevs/AstrBot/issues/5369)). +- Fixed ChatUI file segment persistence issues ([#5386](https://github.com/AstrBotDevs/AstrBot/issues/5386)). +- Fixed accidental exclusion of the `changelogs` directory in `.dockerignore`. +- Fixed compatibility issues caused by a hard-coded 6-second timeout in Tavily requests. +- Fixed qq-botpy runtime errors caused by overly new aiohttp versions ([#5316](https://github.com/AstrBotDevs/AstrBot/issues/5316)). + +### Improvements +- Completed internationalization for the sub-agent orchestration page ([#5400](https://github.com/AstrBotDevs/AstrBot/issues/5400)). +- Added broader message-event test coverage and improved fixtures/mocks in the test framework ([#5355](https://github.com/AstrBotDevs/AstrBot/issues/5355), [#5354](https://github.com/AstrBotDevs/AstrBot/issues/5354)). +- Updated README content and applied repository-wide formatting cleanup (ruff format) ([#5375](https://github.com/AstrBotDevs/AstrBot/issues/5375)). diff --git a/dashboard/README.md b/dashboard/README.md index 52df63351..0cdcae80c 100644 --- a/dashboard/README.md +++ b/dashboard/README.md @@ -1,3 +1,10 @@ # AstrBot 管理面板 -基于 CodedThemes/Berry 模板开发。 \ No newline at end of file +基于 CodedThemes/Berry 模板开发。 + +## 环境变量 + +- `VITE_ASTRBOT_RELEASE_BASE_URL`(可选) + - 默认值:`https://github.com/AstrBotDevs/AstrBot/releases` + - 用途:管理面板内“更新到最新版本”外部跳转所使用的 release 基地址。集成方可按需覆盖(例如 Desktop 指向其自身发布页)。 + - 建议传入仓库的 `.../releases` 基地址(不带 `/latest`)。 diff --git a/dashboard/env.d.ts b/dashboard/env.d.ts index 11f02fe2a..b4b350830 100644 --- a/dashboard/env.d.ts +++ b/dashboard/env.d.ts @@ -1 +1,9 @@ /// + +interface ImportMetaEnv { + readonly VITE_ASTRBOT_RELEASE_BASE_URL?: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/dashboard/src/App.vue b/dashboard/src/App.vue index b580162d2..af23e75ff 100644 --- a/dashboard/src/App.vue +++ b/dashboard/src/App.vue @@ -18,7 +18,6 @@ import { RouterView } from 'vue-router'; import { computed, onBeforeUnmount, onMounted, ref } from 'vue' import { useToastStore } from '@/stores/toast' import WaitingForRestart from '@/components/shared/WaitingForRestart.vue' -import { restartAstrBot } from '@/utils/restartAstrBot' const toastStore = useToastStore() const globalWaitingRef = ref(null) @@ -33,12 +32,12 @@ const snackbarShow = computed({ onMounted(() => { const desktopBridge = window.astrbotDesktop - if (!desktopBridge?.isElectron || !desktopBridge.onTrayRestartBackend) { + if (!desktopBridge?.onTrayRestartBackend) { return } disposeTrayRestartListener = desktopBridge.onTrayRestartBackend(async () => { try { - await restartAstrBot(globalWaitingRef.value) + await globalWaitingRef.value?.check?.() } catch (error) { globalWaitingRef.value?.stop?.() console.error('Tray restart backend failed:', error) diff --git a/dashboard/src/components/chat/Chat.vue b/dashboard/src/components/chat/Chat.vue index 71e46e690..803c5d826 100644 --- a/dashboard/src/components/chat/Chat.vue +++ b/dashboard/src/components/chat/Chat.vue @@ -77,12 +77,14 @@ :stagedAudioUrl="stagedAudioUrl" :stagedFiles="stagedNonImageFiles" :disabled="isStreaming" + :is-running="isStreaming || isConvRunning" :enableStreaming="enableStreaming" :isRecording="isRecording" :session-id="currSessionId || null" :current-session="getCurrentSession" :replyTo="replyTo" @send="handleSendMessage" + @stop="handleStopMessage" @toggleStreaming="toggleStreaming" @removeImage="removeImage" @removeAudio="removeAudio" @@ -106,12 +108,14 @@ :stagedAudioUrl="stagedAudioUrl" :stagedFiles="stagedNonImageFiles" :disabled="isStreaming" + :is-running="isStreaming || isConvRunning" :enableStreaming="enableStreaming" :isRecording="isRecording" :session-id="currSessionId || null" :current-session="getCurrentSession" :replyTo="replyTo" @send="handleSendMessage" + @stop="handleStopMessage" @toggleStreaming="toggleStreaming" @removeImage="removeImage" @removeAudio="removeAudio" @@ -134,12 +138,14 @@ :stagedAudioUrl="stagedAudioUrl" :stagedFiles="stagedNonImageFiles" :disabled="isStreaming" + :is-running="isStreaming || isConvRunning" :enableStreaming="enableStreaming" :isRecording="isRecording" :session-id="currSessionId || null" :current-session="getCurrentSession" :replyTo="replyTo" @send="handleSendMessage" + @stop="handleStopMessage" @toggleStreaming="toggleStreaming" @removeImage="removeImage" @removeAudio="removeAudio" @@ -298,6 +304,7 @@ const { currentSessionProject, getSessionMessages: getSessionMsg, sendMessage: sendMsg, + stopMessage: stopMsg, toggleStreaming } = useMessages(currSessionId, getMediaFile, updateSessionTitle, getSessions); @@ -631,6 +638,10 @@ async function handleSendMessage() { } } +async function handleStopMessage() { + await stopMsg(); +} + // 路由变化监听 watch( () => route.path, diff --git a/dashboard/src/components/chat/ChatInput.vue b/dashboard/src/components/chat/ChatInput.vue index 35ec22cd3..63cb03e3d 100644 --- a/dashboard/src/components/chat/ChatInput.vue +++ b/dashboard/src/components/chat/ChatInput.vue @@ -94,8 +94,29 @@ {{ isRecording ? tm('voice.speaking') : tm('voice.startRecording') }} - + + + + {{ tm('input.stopGenerating') }} + + + @@ -160,6 +181,7 @@ interface Props { disabled: boolean; enableStreaming: boolean; isRecording: boolean; + isRunning: boolean; sessionId?: string | null; currentSession?: Session | null; configId?: string | null; @@ -177,6 +199,7 @@ const props = withDefaults(defineProps(), { const emit = defineEmits<{ 'update:prompt': [value: string]; send: []; + stop: []; toggleStreaming: []; removeImage: [index: number]; removeAudio: []; diff --git a/dashboard/src/components/chat/ConfigSelector.vue b/dashboard/src/components/chat/ConfigSelector.vue index d77e91072..d53e3ed77 100644 --- a/dashboard/src/components/chat/ConfigSelector.vue +++ b/dashboard/src/components/chat/ConfigSelector.vue @@ -77,6 +77,11 @@ import { computed, onMounted, ref, watch } from 'vue'; import axios from 'axios'; import { useToast } from '@/utils/toast'; import { useModuleI18n } from '@/i18n/composables'; +import { + getStoredDashboardUsername, + getStoredSelectedChatConfigId, + setStoredSelectedChatConfigId +} from '@/utils/chatConfigBinding'; interface ConfigInfo { id: string; @@ -88,8 +93,6 @@ interface ConfigChangedPayload { agentRunnerType: string; } -const STORAGE_KEY = 'chat.selectedConfigId'; - const props = withDefaults(defineProps<{ sessionId?: string | null; platformId?: string; @@ -128,7 +131,7 @@ const hasActiveSession = computed(() => !!normalizedSessionId.value); const messageType = computed(() => (props.isGroup ? 'GroupMessage' : 'FriendMessage')); -const username = computed(() => localStorage.getItem('user') || 'guest'); +const username = computed(() => getStoredDashboardUsername()); const sessionKey = computed(() => { if (!normalizedSessionId.value) { @@ -265,10 +268,10 @@ async function confirmSelection() { } const previousId = selectedConfigId.value; await setSelection(tempSelectedConfig.value); - localStorage.setItem(STORAGE_KEY, tempSelectedConfig.value); + setStoredSelectedChatConfigId(tempSelectedConfig.value); const applied = await applySelectionToBackend(tempSelectedConfig.value); if (!applied) { - localStorage.setItem(STORAGE_KEY, previousId); + setStoredSelectedChatConfigId(previousId); await setSelection(previousId); } dialog.value = false; @@ -287,7 +290,7 @@ async function syncSelectionForSession() { await fetchRoutingEntries(); const resolved = resolveConfigId(targetUmo.value); await setSelection(resolved); - localStorage.setItem(STORAGE_KEY, resolved); + setStoredSelectedChatConfigId(resolved); } watch( @@ -299,7 +302,7 @@ watch( onMounted(async () => { await fetchConfigList(); - const stored = props.initialConfigId || localStorage.getItem(STORAGE_KEY) || 'default'; + const stored = props.initialConfigId || getStoredSelectedChatConfigId(); selectedConfigId.value = stored; await setSelection(stored); await syncSelectionForSession(); diff --git a/dashboard/src/components/chat/MessageList.vue b/dashboard/src/components/chat/MessageList.vue index 42129d792..e30a34a40 100644 --- a/dashboard/src/components/chat/MessageList.vue +++ b/dashboard/src/components/chat/MessageList.vue @@ -143,8 +143,8 @@ + :class="{ 'copy-success': isCopySuccess(index), 'copy-failed': isCopyFailure(index) }" + @click="copyBotMessage(msg.content.message, index)" :title="getCopyTitle(index)" /> @@ -185,6 +185,7 @@ import 'markstream-vue/index.css' import 'katex/dist/katex.min.css' import 'highlight.js/styles/github.css'; import axios from 'axios'; +import { useToast } from '@/utils/toast' import ReasoningBlock from './message_list_comps/ReasoningBlock.vue'; import MessagePartsRenderer from './message_list_comps/MessagePartsRenderer.vue'; import RefNode from './message_list_comps/RefNode.vue'; @@ -226,10 +227,12 @@ export default { setup() { const { t } = useI18n(); const { tm } = useModuleI18n('features/chat'); + const toast = useToast() return { t, - tm + tm, + toast }; }, provide() { @@ -241,6 +244,7 @@ export default { data() { return { copiedMessages: new Set(), + copyFailedMessages: new Set(), isUserNearBottom: true, scrollThreshold: 1, scrollTimer: null, @@ -496,91 +500,142 @@ export default { }, // 复制代码到剪贴板 - copyCodeToClipboard(code) { - navigator.clipboard.writeText(code).then(() => { - console.log('代码已复制到剪贴板'); - }).catch(err => { - console.error('复制失败:', err); - // 如果现代API失败,使用传统方法 - const textArea = document.createElement('textarea'); - textArea.value = code; + tryExecCommandCopy(text) { + let textArea = null; + try { + textArea = document.createElement('textarea'); + textArea.value = text; document.body.appendChild(textArea); + textArea.focus(); textArea.select(); + const ok = document.execCommand('copy'); + return ok; + } catch (_) { + return false; + } finally { try { - document.execCommand('copy'); - console.log('代码已复制到剪贴板 (fallback)'); - } catch (fallbackErr) { - console.error('复制失败 (fallback):', fallbackErr); + textArea?.remove?.(); + } catch (_) { + // ignore cleanup errors } - document.body.removeChild(textArea); - }); + } + }, + + async copyTextToClipboard(text) { + // 优先使用同步复制,尽量保留用户手势上下文; + // 在非安全来源(例如通过局域网 IP + vite --host)时成功率更高。 + if (this.tryExecCommandCopy(text)) { + return { ok: true, method: 'execCommand' }; + } + + if (navigator.clipboard?.writeText) { + try { + await navigator.clipboard.writeText(text); + return { ok: true, method: 'clipboard' }; + } catch (error) { + return { ok: false, method: 'clipboard', error }; + } + } + + return { ok: false, method: 'unavailable' }; + }, + + async copyWithFeedback(text, messageIndex = null) { + const result = await this.copyTextToClipboard(text); + const ok = !!result?.ok; + + if (messageIndex !== null && messageIndex !== undefined) { + if (ok) this.showCopySuccess(messageIndex); + else this.showCopyFailure(messageIndex); + } + + if (ok) { + this.toast?.success?.(this.t('core.common.copied')); + } else { + this.toast?.error?.(this.t('core.common.copyFailed')); + } + + return result; + }, + + buildCopyTextFromParts(messageParts) { + if (typeof messageParts === 'string') { + return messageParts.trim(); + } + if (!Array.isArray(messageParts)) { + return ''; + } + + const textContents = messageParts + .filter(part => part && typeof part === 'object' && part.type === 'plain' && part.text) + .map(part => part.text); + + let textToCopy = textContents.join('\n'); + + const imageCount = messageParts.filter(part => part?.type === 'image' && part.embedded_url).length; + if (imageCount > 0) { + if (textToCopy) textToCopy += '\n\n'; + textToCopy += `[包含 ${imageCount} 张图片]`; + } + + const hasAudio = messageParts.some(part => part?.type === 'record' && part.embedded_url); + if (hasAudio) { + if (textToCopy) textToCopy += '\n\n'; + textToCopy += '[包含音频内容]'; + } + + return String(textToCopy || '').trim(); + }, + + async copyCodeToClipboard(code) { + const text = String(code ?? ''); + if (!text) return { ok: false, method: 'empty' }; + return await this.copyWithFeedback(text, null); }, // 复制bot消息到剪贴板 - copyBotMessage(messageParts, messageIndex) { - let textToCopy = ''; - - if (Array.isArray(messageParts)) { - // 提取所有文本内容 - const textContents = messageParts - .filter(part => part.type === 'plain' && part.text) - .map(part => part.text); - textToCopy = textContents.join('\n'); - - // 检查是否有图片 - const imageCount = messageParts.filter(part => part.type === 'image' && part.embedded_url).length; - if (imageCount > 0) { - if (textToCopy) textToCopy += '\n\n'; - textToCopy += `[包含 ${imageCount} 张图片]`; - } - - // 检查是否有音频 - const hasAudio = messageParts.some(part => part.type === 'record' && part.embedded_url); - if (hasAudio) { - if (textToCopy) textToCopy += '\n\n'; - textToCopy += '[包含音频内容]'; - } - } - - // 如果没有任何内容,使用默认文本 - if (!textToCopy.trim()) { - textToCopy = '[媒体内容]'; - } - - navigator.clipboard.writeText(textToCopy).then(() => { - console.log('消息已复制到剪贴板'); - this.showCopySuccess(messageIndex); - }).catch(err => { - console.error('复制失败:', err); - // 如果现代API失败,使用传统方法 - const textArea = document.createElement('textarea'); - textArea.value = textToCopy; - document.body.appendChild(textArea); - textArea.select(); - try { - document.execCommand('copy'); - console.log('消息已复制到剪贴板 (fallback)'); - this.showCopySuccess(messageIndex); - } catch (fallbackErr) { - console.error('复制失败 (fallback):', fallbackErr); - } - document.body.removeChild(textArea); - }); + async copyBotMessage(messageParts, messageIndex) { + let textToCopy = this.buildCopyTextFromParts(messageParts); + if (!textToCopy) textToCopy = '[媒体内容]'; + await this.copyWithFeedback(textToCopy, messageIndex); }, // 显示复制成功提示 showCopySuccess(messageIndex) { + if (this.copyFailedMessages.has(messageIndex)) { + this.copyFailedMessages.delete(messageIndex); + this.copyFailedMessages = new Set(this.copyFailedMessages); + } this.copiedMessages.add(messageIndex); + this.copiedMessages = new Set(this.copiedMessages); // 2秒后移除成功状态 setTimeout(() => { this.copiedMessages.delete(messageIndex); + this.copiedMessages = new Set(this.copiedMessages); + }, 2000); + }, + + // 显示复制失败提示 + showCopyFailure(messageIndex) { + if (this.copiedMessages.has(messageIndex)) { + this.copiedMessages.delete(messageIndex); + this.copiedMessages = new Set(this.copiedMessages); + } + this.copyFailedMessages.add(messageIndex); + this.copyFailedMessages = new Set(this.copyFailedMessages); + + setTimeout(() => { + this.copyFailedMessages.delete(messageIndex); + this.copyFailedMessages = new Set(this.copyFailedMessages); }, 2000); }, // 获取复制按钮图标 getCopyIcon(messageIndex) { - return this.copiedMessages.has(messageIndex) ? 'mdi-check' : 'mdi-content-copy'; + if (this.copiedMessages.has(messageIndex)) return 'mdi-check'; + if (this.copyFailedMessages.has(messageIndex)) return 'mdi-alert-circle-outline'; + return 'mdi-content-copy'; }, // 检查是否为复制成功状态 @@ -588,6 +643,18 @@ export default { return this.copiedMessages.has(messageIndex); }, + // 检查是否为复制失败状态 + isCopyFailure(messageIndex) { + return this.copyFailedMessages.has(messageIndex); + }, + + // 获取复制按钮提示文本 + getCopyTitle(messageIndex) { + if (this.isCopySuccess(messageIndex)) return this.t('core.common.copied'); + if (this.isCopyFailure(messageIndex)) return this.t('core.common.copyFailed'); + return this.t('core.common.copy'); + }, + // 获取复制图标SVG getCopyIconSvg() { return ''; @@ -598,6 +665,11 @@ export default { return ''; }, + // 获取失败图标SVG + getErrorIconSvg() { + return ''; + }, + // 初始化代码块复制按钮 initCodeCopyButtons() { this.$nextTick(() => { @@ -608,15 +680,19 @@ export default { const button = document.createElement('button'); button.className = 'copy-code-btn'; button.innerHTML = this.getCopyIconSvg(); - button.title = '复制代码'; - button.addEventListener('click', () => { - this.copyCodeToClipboard(codeBlock.textContent); - // 显示复制成功提示 - button.innerHTML = this.getSuccessIconSvg(); - button.style.color = '#4caf50'; + button.title = this.t('core.common.copy'); + button.addEventListener('click', async () => { + const res = await this.copyCodeToClipboard(codeBlock.textContent || ''); + const ok = !!res?.ok; + button.innerHTML = ok ? this.getSuccessIconSvg() : this.getErrorIconSvg(); + button.style.color = ok + ? 'rgb(var(--v-theme-success))' + : 'rgb(var(--v-theme-error))'; + button.setAttribute("title", this.t(`core.common.${ok ? "copied" : "copyFailed"}`)); setTimeout(() => { button.innerHTML = this.getCopyIconSvg(); button.style.color = ''; + button.setAttribute("title", this.t('core.common.copy')); }, 2000); }); pre.style.position = 'relative'; @@ -1077,13 +1153,23 @@ export default { } .copy-message-btn.copy-success { - color: #4caf50; + color: rgb(var(--v-theme-success)); opacity: 1; } .copy-message-btn.copy-success:hover { - color: #4caf50; - background-color: rgba(76, 175, 80, 0.1); + color: rgb(var(--v-theme-success)); + background-color: rgba(var(--v-theme-success), 0.1); +} + +.copy-message-btn.copy-failed { + color: rgb(var(--v-theme-error)); + opacity: 1; +} + +.copy-message-btn.copy-failed:hover { + color: rgb(var(--v-theme-error)); + background-color: rgba(var(--v-theme-error), 0.1); } .reply-message-btn { diff --git a/dashboard/src/components/chat/StandaloneChat.vue b/dashboard/src/components/chat/StandaloneChat.vue index 25ca7faf9..69fac13f9 100644 --- a/dashboard/src/components/chat/StandaloneChat.vue +++ b/dashboard/src/components/chat/StandaloneChat.vue @@ -23,12 +23,14 @@ :stagedImagesUrl="stagedImagesUrl" :stagedAudioUrl="stagedAudioUrl" :disabled="isStreaming" + :is-running="isStreaming || isConvRunning" :enableStreaming="enableStreaming" :isRecording="isRecording" :session-id="currSessionId || null" :current-session="getCurrentSession" :config-id="configId" @send="handleSendMessage" + @stop="handleStopMessage" @toggleStreaming="toggleStreaming" @removeImage="removeImage" @removeAudio="removeAudio" @@ -70,6 +72,7 @@ import { useMessages } from '@/composables/useMessages'; import { useMediaHandling } from '@/composables/useMediaHandling'; import { useRecording } from '@/composables/useRecording'; import { useToast } from '@/utils/toast'; +import { buildWebchatUmoDetails } from '@/utils/chatConfigBinding'; interface Props { configId?: string | null; @@ -82,6 +85,7 @@ const props = withDefaults(defineProps(), { const { t } = useI18n(); const { error: showError } = useToast(); + // UI 状态 const imagePreviewDialog = ref(false); const previewImageUrl = ref(''); @@ -90,11 +94,33 @@ const previewImageUrl = ref(''); const currSessionId = ref(''); const getCurrentSession = computed(() => null); // 独立测试模式不需要会话信息 +async function bindConfigToSession(sessionId: string) { + const confId = (props.configId || '').trim(); + if (!confId || confId === 'default') { + return; + } + + const umoDetails = buildWebchatUmoDetails(sessionId, false); + + await axios.post('/api/config/umo_abconf_route/update', { + umo: umoDetails.umo, + conf_id: confId + }); +} + async function newSession() { try { const response = await axios.get('/api/chat/new_session'); const sessionId = response.data.data.session_id; + + try { + await bindConfigToSession(sessionId); + } catch (err) { + console.error('Failed to bind config to session', err); + } + currSessionId.value = sessionId; + return sessionId; } catch (err) { console.error(err); @@ -132,6 +158,7 @@ const { enableStreaming, getSessionMessages: getSessionMsg, sendMessage: sendMsg, + stopMessage: stopMsg, toggleStreaming } = useMessages(currSessionId, getMediaFile, updateSessionTitle, getSessions); @@ -212,6 +239,10 @@ async function handleSendMessage() { } } +async function handleStopMessage() { + await stopMsg(); +} + onMounted(async () => { // 独立模式在挂载时创建新会话 try { diff --git a/dashboard/src/components/config/AstrBotCoreConfigWrapper.vue b/dashboard/src/components/config/AstrBotCoreConfigWrapper.vue index 2a84989df..88029a25a 100644 --- a/dashboard/src/components/config/AstrBotCoreConfigWrapper.vue +++ b/dashboard/src/components/config/AstrBotCoreConfigWrapper.vue @@ -2,18 +2,22 @@
- - {{ tm(metadata[key]['name']) }} + {{ tm(section.value['name']) }} - + -
+
- +
@@ -31,6 +35,11 @@
+ + + {{ tm('search.noResult') }} + + @@ -112,4 +177,4 @@ export default { margin-top: 16px; } } - \ No newline at end of file + diff --git a/dashboard/src/components/config/UnsavedChangesConfirmDialog.vue b/dashboard/src/components/config/UnsavedChangesConfirmDialog.vue new file mode 100644 index 000000000..f81f1167f --- /dev/null +++ b/dashboard/src/components/config/UnsavedChangesConfirmDialog.vue @@ -0,0 +1,98 @@ + + + + + + diff --git a/dashboard/src/components/extension/MarketPluginCard.vue b/dashboard/src/components/extension/MarketPluginCard.vue new file mode 100644 index 000000000..2fbb1917e --- /dev/null +++ b/dashboard/src/components/extension/MarketPluginCard.vue @@ -0,0 +1,309 @@ + + + + + diff --git a/dashboard/src/components/provider/ProviderSourcesPanel.vue b/dashboard/src/components/provider/ProviderSourcesPanel.vue index adca10832..8d230d4bc 100644 --- a/dashboard/src/components/provider/ProviderSourcesPanel.vue +++ b/dashboard/src/components/provider/ProviderSourcesPanel.vue @@ -4,7 +4,7 @@

{{ tm('providerSources.title') }}

- + - - - {{ sourceType.label }} - - - + + + {{ sourceType.label }} + +
-
+
+
+ + + + +
+
+ +
mdi-creation - {{ getSourceDisplayName(source) }} + {{ getSourceDisplayName(source) }} {{ source.api_base || 'N/A' }} @@ -1788,17 +2000,21 @@ watch(activeTab, (newTab) => { -
- -
+
+ mdi-alert-outline + {{ tm("market.sourceSafetyWarning") }} +
+
@@ -1883,6 +2099,42 @@ watch(activeTab, (newTab) => {
+
+

+ {{ tm("market.randomPlugins") }} +

+ + {{ tm("buttons.reshuffle") }} + +
+ + + + + + +
- { }} -
- + - - - - 🥳 推荐 - - - -
- -
- -
- -
- - {{ - plugin.display_name?.length - ? plugin.display_name - : showPluginFullName - ? plugin.name - : plugin.trimmedName - }} - -
- - -
- - - {{ plugin.author }} - - - {{ plugin.author }} - -
- - {{ plugin.version }} -
-
- - -
- {{ plugin.desc }} -
- - -
-
- - {{ plugin.stars }} -
-
- - {{ - new Date(plugin.updated_at).toLocaleString() - }} -
-
-
-
- - - - - {{ tag === "danger" ? tm("tags.danger") : tag }} - - - - - - - {{ tag === "danger" ? tm("tags.danger") : tag }} - - - - - - - - {{ tm("buttons.viewRepo") }} - - - {{ tm("buttons.install") }} - - - ✓ {{ tm("status.installed") }} - - -
+
-
{ + + + + + mdi-alert + {{ tm("dialogs.versionCompatibility.title") }} + + +
{{ tm("dialogs.versionCompatibility.message") }}
+
+ {{ versionCompatibilityDialog.message }} +
+
+ + + + {{ tm("dialogs.versionCompatibility.cancel") }} + + + {{ tm("dialogs.versionCompatibility.confirm") }} + + +
+
+
{ placeholder="https://github.com/username/repo" > +
+ + {{ tm("card.status.astrbotVersion") }}: + {{ selectedInstallPlugin.astrbot_version }} + + + {{ tm("card.status.supportPlatform") }}: + {{ + getPlatformDisplayList(selectedInstallPlugin.support_platforms).join( + ", ", + ) + }} + + + {{ installCompat.message }} + +
+
@@ -2729,38 +2799,6 @@ watch(activeTab, (newTab) => { background-color: #f5f5f5; } -.plugin-description { - color: rgba(var(--v-theme-on-surface), 0.6); - line-height: 1.3; - margin-bottom: 6px; - flex: 1; - overflow-y: hidden; -} - -.plugin-card:hover .plugin-description { - overflow-y: auto; -} - -.plugin-description::-webkit-scrollbar { - width: 8px; - height: 8px; -} - -.plugin-description::-webkit-scrollbar-track { - background: transparent; -} - -.plugin-description::-webkit-scrollbar-thumb { - background-color: rgba(var(--v-theme-primary-rgb), 0.4); - border-radius: 4px; - border: 2px solid transparent; - background-clip: content-box; -} - -.plugin-description::-webkit-scrollbar-thumb:hover { - background-color: rgba(var(--v-theme-primary-rgb), 0.6); -} - .fab-button { transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1); box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); diff --git a/dashboard/src/views/Settings.vue b/dashboard/src/views/Settings.vue index 72bb2a4fb..82568b59d 100644 --- a/dashboard/src/views/Settings.vue +++ b/dashboard/src/views/Settings.vue @@ -63,10 +63,156 @@ {{ tm('system.restart.button') }} + {{ tm('apiKey.title') }} + + + + + + + + + + + + + {{ tm('apiKey.permanentWarning') }} + + + + + mdi-key-plus + {{ tm('apiKey.create') }} + + + + +
{{ tm('apiKey.scopes') }}
+ + + {{ scope.label }} + + +
+ + + +
+ {{ tm('apiKey.plaintextHint') }} + + mdi-content-copy{{ tm('apiKey.copy') }} + +
+ {{ createdApiKeyPlaintext }} +
+
+ + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {{ tm('system.migration.button') }} - @@ -77,7 +223,8 @@ diff --git a/dashboard/src/views/SubAgentPage.vue b/dashboard/src/views/SubAgentPage.vue index 3870f2d0c..029cc5a82 100644 --- a/dashboard/src/views/SubAgentPage.vue +++ b/dashboard/src/views/SubAgentPage.vue @@ -1,155 +1,248 @@ @@ -158,9 +251,12 @@ import { computed, onMounted, ref } from 'vue' import axios from 'axios' import ProviderSelector from '@/components/shared/ProviderSelector.vue' +import PersonaSelector from '@/components/shared/PersonaSelector.vue' +import PersonaQuickPreview from '@/components/shared/PersonaQuickPreview.vue' import { useModuleI18n } from '@/i18n/composables' type SubAgentItem = { + __key: string name: string persona_id: string @@ -196,9 +292,6 @@ const cfg = ref({ agents: [] }) -const personaOptions = ref<{ title: string; value: string }[]>([]) -const personaLoading = ref(false) - const mainStateDescription = computed(() => cfg.value.main_enable ? tm('description.enabled') : tm('description.disabled') ) @@ -244,24 +337,6 @@ async function loadConfig() { } } -async function loadPersonas() { - personaLoading.value = true - try { - const res = await axios.get('/api/persona/list') - if (res.data.status === 'ok') { - const list = Array.isArray(res.data.data) ? res.data.data : [] - personaOptions.value = list.map((p: any) => ({ - title: p.persona_id, - value: p.persona_id - })) - } - } catch (e: any) { - toast(e?.response?.data?.message || tm('messages.loadPersonaFailed'), 'error') - } finally { - personaLoading.value = false - } -} - function addAgent() { cfg.value.agents.push({ __key: `${Date.now()}_${Math.random().toString(16).slice(2)}`, @@ -333,7 +408,7 @@ async function save() { } async function reload() { - await Promise.all([loadConfig(), loadPersonas()]) + await Promise.all([loadConfig()]) } onMounted(() => { @@ -343,101 +418,21 @@ onMounted(() => { - - diff --git a/dashboard/src/views/WelcomePage.vue b/dashboard/src/views/WelcomePage.vue index eb7c80308..5cabdc66a 100644 --- a/dashboard/src/views/WelcomePage.vue +++ b/dashboard/src/views/WelcomePage.vue @@ -70,7 +70,7 @@ {{ tm('resources.title') }} - + @@ -84,7 +84,7 @@ - + @@ -98,10 +98,39 @@ + + + +
+ mdi-hand-heart + {{ tm('resources.afdianTitle') }} +
+

+ {{ tm('resources.afdianDesc') }} +

+
+
+
+ + + + +
+ {{ tm('announcement.title') }} +
+ +
+
+
('pending'); const providerStepState = ref('pending'); +const welcomeAnnouncementRaw = ref(null); + +function resolveWelcomeAnnouncement(raw: unknown, currentLocale: string) { + if (typeof raw === 'string') { + return raw.trim(); + } + + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { + return ''; + } + + const localeMap = raw as Record; + const normalized = currentLocale.replace('-', '_'); + const preferredKeys = + normalized.startsWith('zh') + ? [normalized, 'zh_CN', 'zh-CN', 'zh', 'en_US', 'en-US', 'en'] + : [normalized, 'en_US', 'en-US', 'en', 'zh_CN', 'zh-CN', 'zh']; + + for (const key of preferredKeys) { + const value = localeMap[key]; + if (typeof value === 'string' && value.trim().length > 0) { + return value.trim(); + } + } + + return ''; +} + +const welcomeAnnouncement = computed(() => + resolveWelcomeAnnouncement(welcomeAnnouncementRaw.value, locale.value) +); +const showAnnouncement = computed(() => welcomeAnnouncement.value.length > 0); const springFestivalDates: Record = { 2025: '01-29', @@ -271,7 +336,19 @@ async function syncDefaultConfigProviderIfNeeded() { showSuccess(tm('onboard.providerDefaultUpdated', { id: targetProviderId })); } +async function loadWelcomeAnnouncement() { + try { + const res = await axios.get('https://cloud.astrbot.app/api/v1/announcement'); + welcomeAnnouncementRaw.value = res?.data?.data?.notice?.welcome_page ?? null; + } catch (e) { + welcomeAnnouncementRaw.value = null; + console.error(e); + } +} + onMounted(async () => { + await loadWelcomeAnnouncement(); + try { await loadPlatformConfigBase(); if ((platformConfigData.value.platform || []).length > 0) { @@ -349,4 +426,8 @@ watch(showProviderDialog, async (visible, wasVisible) => { .welcome-card { border-radius: 16px; } + +.welcome-announcement-markdown { + line-height: 1.7; +} diff --git a/dashboard/src/views/persona/PersonaManager.vue b/dashboard/src/views/persona/PersonaManager.vue index f9f3e5883..b29f57715 100644 --- a/dashboard/src/views/persona/PersonaManager.vue +++ b/dashboard/src/views/persona/PersonaManager.vue @@ -117,7 +117,18 @@ {{ viewingPersona.persona_id }} - +
+ + {{ tm('buttons.edit') }} + + +
@@ -414,6 +425,13 @@ export default defineComponent({ this.showViewDialog = true; }, + openEditFromViewDialog() { + if (!this.viewingPersona) return; + this.editingPersona = this.viewingPersona; + this.showViewDialog = false; + this.showPersonaDialog = true; + }, + handlePersonaSaved(message: string) { this.showSuccess(message); this.refreshCurrentFolder(); diff --git a/desktop/README.md b/desktop/README.md deleted file mode 100644 index 48dcb341a..000000000 --- a/desktop/README.md +++ /dev/null @@ -1,131 +0,0 @@ -# AstrBot Desktop (Electron) - -This document describes how to build the Electron desktop app from source. - -## What This Package Contains - -- Electron desktop shell (`desktop/main.js`) -- Bundled WebUI static files (`desktop/resources/webui`) -- App assets (`desktop/assets`) - -Current behavior: - -- Backend executable is bundled in the installer/package. -- App startup checks backend availability and auto-starts bundled backend when needed. -- Runtime data is stored under `~/.astrbot` by default, not as a full AstrBot source project. - -## Prerequisites - -- Python environment ready in repository root (`uv` available) -- Node.js available -- `pnpm` available - -Desktop dependency management uses `pnpm` with a lockfile: - -- `desktop/pnpm-lock.yaml` -- `pnpm --dir desktop install --frozen-lockfile` - -## Build From Scratch - -Run commands from repository root: - -```bash -uv sync -pnpm --dir dashboard install -pnpm --dir dashboard build -pnpm --dir desktop install --frozen-lockfile -pnpm --dir desktop run dist:full -``` - -Output files are generated under: - -- `desktop/dist/` - -## Local Run (Development) - -Start backend first: - -```bash -uv run main.py -``` - -Start Electron shell: - -```bash -pnpm --dir desktop run dev -``` - -## Notes - -- `dist:full` runs WebUI build + backend build + Electron packaging. -- In packaged app mode, backend data root defaults to `~/.astrbot` (can be overridden by `ASTRBOT_ROOT`). -- Backend build uses `uv run --with pyinstaller ...`, so no manual `PyInstaller` install is required. - -## Runtime Directory Layout - -By default (`ASTRBOT_ROOT` not set), packaged desktop app uses this layout: - -```text -~/.astrbot/ - data/ - config/ # Main configuration - plugins/ # Installed plugins - plugin_data/ # Plugin persistent data - site-packages/ # Plugin dependency installation target in packaged mode - temp/ # Runtime temp files - skills/ # Skill-related runtime data - knowledge_base/ # Knowledge base files - backups/ # Backup data -``` - -The app does not store a full AstrBot source tree in home directory. - -## Troubleshooting - -Startup behavior: - -- Packaged app shows a local startup page first, then switches to dashboard after backend is reachable. -- If startup page never switches, check logs and timeout settings below. - -Runtime logs: - -- Electron shell log: `~/.astrbot/logs/electron.log` -- Backend stdout/stderr log: `~/.astrbot/logs/backend.log` -- Both files rotate by size by default: `20MB` per file, keep `3` backups. -- Electron log rotation envs: - - `ASTRBOT_ELECTRON_LOG_MAX_MB` - - `ASTRBOT_ELECTRON_LOG_BACKUP_COUNT` -- Backend log rotation envs: - - `ASTRBOT_BACKEND_LOG_MAX_MB` - - `ASTRBOT_BACKEND_LOG_BACKUP_COUNT` -- Rotation debug logging: - - `ASTRBOT_LOG_ROTATION_DEBUG=1` (or `NODE_ENV=development`) to print filesystem errors from rotation operations. -- On backend startup failure, the app dialog also shows the backend reason and backend log path. - -Timeout and loading controls: - -- `ASTRBOT_BACKEND_TIMEOUT_MS` controls how long Electron waits for backend reachability. -- In packaged mode, default is `0` (auto mode with a 5-minute safety cap). -- In development mode, default is `20000`. -- If backend startup times out, app shows startup failure dialog and exits. -- `ASTRBOT_DASHBOARD_TIMEOUT_MS` controls dashboard page load wait time after backend is ready (default `20000`). -- If you see `Unable to load the AstrBot dashboard.`, increase `ASTRBOT_DASHBOARD_TIMEOUT_MS`. - -Startup page locale: - -- Startup page language follows cached dashboard locale in `~/.astrbot/data/desktop_state.json`. -- Supported startup locales are `zh-CN` and `en-US`. -- Remove that file to reset locale fallback behavior. - -Backend auto-start: - -- `ASTRBOT_BACKEND_AUTO_START=0` disables Electron-managed backend startup. -- When disabled, backend must already be running at `ASTRBOT_BACKEND_URL` before launching app. - -If Electron download times out on restricted networks, configure mirrors before install: - -```bash -export ELECTRON_MIRROR="https://npmmirror.com/mirrors/electron/" -export ELECTRON_BUILDER_BINARIES_MIRROR="https://npmmirror.com/mirrors/electron-builder-binaries/" -pnpm --dir desktop install --frozen-lockfile -``` diff --git a/desktop/assets/icon-no-shadow.svg b/desktop/assets/icon-no-shadow.svg deleted file mode 100644 index 4268e03e2..000000000 --- a/desktop/assets/icon-no-shadow.svg +++ /dev/null @@ -1 +0,0 @@ -
\ No newline at end of file diff --git a/desktop/assets/icon.png b/desktop/assets/icon.png deleted file mode 100644 index 512d1eaed..000000000 Binary files a/desktop/assets/icon.png and /dev/null differ diff --git a/desktop/assets/tray.png b/desktop/assets/tray.png deleted file mode 100644 index 4fcc92ba6..000000000 Binary files a/desktop/assets/tray.png and /dev/null differ diff --git a/desktop/lib/backend-manager.js b/desktop/lib/backend-manager.js deleted file mode 100644 index eb8958a4c..000000000 --- a/desktop/lib/backend-manager.js +++ /dev/null @@ -1,821 +0,0 @@ -'use strict'; - -const fs = require('fs'); -const os = require('os'); -const path = require('path'); -const { spawn, spawnSync } = require('child_process'); -const { BufferedRotatingLogger } = require('./buffered-rotating-logger'); -const { - delay, - ensureDir, - formatLogTimestamp, - normalizeUrl, - parseLogBackupCount, - parseLogMaxBytes, - waitForProcessExit, -} = require('./common'); - -const PACKAGED_BACKEND_TIMEOUT_FALLBACK_MS = 5 * 60 * 1000; -const GRACEFUL_RESTART_WAIT_FALLBACK_MS = 20 * 1000; -const BACKEND_LOG_FLUSH_INTERVAL_MS = 120; -const BACKEND_LOG_MAX_BUFFER_BYTES = 128 * 1024; - -function parseBackendTimeoutMs(app) { - const defaultTimeoutMs = app.isPackaged ? 0 : 20000; - const parsed = Number.parseInt( - process.env.ASTRBOT_BACKEND_TIMEOUT_MS || `${defaultTimeoutMs}`, - 10, - ); - if (Number.isFinite(parsed) && parsed >= 0) { - return parsed; - } - return defaultTimeoutMs; -} - -class BackendManager { - constructor({ app, baseDir, log, shouldSkipStart }) { - this.app = app; - this.baseDir = baseDir; - this.log = typeof log === 'function' ? log : () => {}; - this.shouldSkipStart = - typeof shouldSkipStart === 'function' ? shouldSkipStart : () => false; - - this.backendUrl = normalizeUrl( - process.env.ASTRBOT_BACKEND_URL || 'http://127.0.0.1:6185/', - ); - this.backendAutoStart = process.env.ASTRBOT_BACKEND_AUTO_START !== '0'; - this.backendTimeoutMs = parseBackendTimeoutMs(app); - this.backendLogMaxBytes = parseLogMaxBytes( - process.env.ASTRBOT_BACKEND_LOG_MAX_MB, - ); - this.backendLogBackupCount = parseLogBackupCount( - process.env.ASTRBOT_BACKEND_LOG_BACKUP_COUNT, - ); - - this.backendProcess = null; - this.backendConfig = null; - this.backendLogger = new BufferedRotatingLogger({ - logPath: null, - maxBytes: this.backendLogMaxBytes, - backupCount: this.backendLogBackupCount, - flushIntervalMs: BACKEND_LOG_FLUSH_INTERVAL_MS, - maxBufferBytes: BACKEND_LOG_MAX_BUFFER_BYTES, - }); - this.backendLastExitReason = null; - this.backendStartupFailureReason = null; - this.backendSpawning = false; - this.backendRestarting = false; - } - - getBackendUrl() { - return this.backendUrl; - } - - getBackendTimeoutMs() { - return this.backendTimeoutMs; - } - - getRootDir() { - return ( - process.env.ASTRBOT_ROOT || - this.backendConfig?.rootDir || - this.resolveBackendRoot() - ); - } - - getBackendLogPath() { - const rootDir = this.getRootDir(); - if (!rootDir) { - return null; - } - return path.join(rootDir, 'logs', 'backend.log'); - } - - getStartupFailureReason() { - return this.backendStartupFailureReason; - } - - isSpawning() { - return this.backendSpawning; - } - - isRestarting() { - return this.backendRestarting; - } - - resolveBackendRoot() { - if (!this.app.isPackaged) { - return null; - } - return path.join(os.homedir(), '.astrbot'); - } - - resolveBackendCwd() { - if (!this.app.isPackaged) { - return path.resolve(this.baseDir, '..'); - } - return this.resolveBackendRoot(); - } - - resolveWebuiDir() { - if (process.env.ASTRBOT_WEBUI_DIR) { - return process.env.ASTRBOT_WEBUI_DIR; - } - if (!this.app.isPackaged) { - return null; - } - const candidate = path.join(process.resourcesPath, 'webui'); - const indexPath = path.join(candidate, 'index.html'); - return fs.existsSync(indexPath) ? candidate : null; - } - - getPackagedBackendPath() { - if (!this.app.isPackaged) { - return null; - } - const filename = - process.platform === 'win32' ? 'astrbot-backend.exe' : 'astrbot-backend'; - const candidate = path.join(process.resourcesPath, 'backend', filename); - return fs.existsSync(candidate) ? candidate : null; - } - - buildDefaultBackendLaunch(webuiDir) { - if (this.app.isPackaged) { - const packagedBackend = this.getPackagedBackendPath(); - if (!packagedBackend) { - return null; - } - const args = []; - if (webuiDir) { - args.push('--webui-dir', webuiDir); - } - return { - cmd: packagedBackend, - args, - shell: false, - }; - } - - const args = ['run', 'main.py']; - if (webuiDir) { - args.push('--webui-dir', webuiDir); - } - return { - cmd: 'uv', - args, - shell: process.platform === 'win32', - }; - } - - resolveBackendConfig() { - const webuiDir = this.resolveWebuiDir(); - const customCmd = process.env.ASTRBOT_BACKEND_CMD; - const launch = customCmd - ? { - cmd: customCmd, - args: [], - shell: true, - } - : this.buildDefaultBackendLaunch(webuiDir); - const cwd = process.env.ASTRBOT_BACKEND_CWD || this.resolveBackendCwd(); - const rootDir = process.env.ASTRBOT_ROOT || this.resolveBackendRoot(); - ensureDir(cwd); - if (rootDir) { - ensureDir(rootDir); - } - this.backendConfig = { - cmd: launch ? launch.cmd : null, - args: launch ? launch.args : [], - shell: launch ? launch.shell : true, - cwd, - webuiDir, - rootDir, - }; - return this.backendConfig; - } - - getBackendConfig() { - if (!this.backendConfig) { - return this.resolveBackendConfig(); - } - return this.backendConfig; - } - - getBackendPort() { - try { - const parsed = new URL(this.backendUrl); - if (parsed.port) { - const port = Number.parseInt(parsed.port, 10); - return Number.isFinite(port) ? port : null; - } - return parsed.protocol === 'https:' ? 443 : 80; - } catch { - return null; - } - } - - canManageBackend() { - return Boolean(this.getBackendConfig().cmd); - } - - async flushLogs() { - await this.backendLogger.flush(); - } - - async pingBackend(timeoutMs = 800) { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), timeoutMs); - try { - await fetch(this.backendUrl, { - signal: controller.signal, - redirect: 'manual', - }); - return true; - } catch { - return false; - } finally { - clearTimeout(timeout); - } - } - - getEffectiveWaitMs(maxWaitMs = 0) { - if (maxWaitMs > 0) { - return maxWaitMs; - } - if (this.app.isPackaged) { - return PACKAGED_BACKEND_TIMEOUT_FALLBACK_MS; - } - return 0; - } - - async requestBackendJson(pathname, options = {}) { - const timeoutMs = options.timeoutMs || 2000; - const method = options.method || 'GET'; - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), timeoutMs); - const requestUrl = new URL(pathname, this.backendUrl); - requestUrl.searchParams.set('_ts', `${Date.now()}`); - - const authToken = - typeof options.authToken === 'string' && options.authToken - ? options.authToken - : null; - - try { - const response = await fetch(requestUrl.toString(), { - method, - signal: controller.signal, - redirect: 'manual', - headers: { - Accept: 'application/json', - ...(authToken ? { Authorization: `Bearer ${authToken}` } : {}), - ...(options.headers || {}), - }, - }); - if (!response.ok) { - return { ok: false, data: null }; - } - const data = await response.json(); - return { ok: true, data }; - } catch { - return { ok: false, data: null }; - } finally { - clearTimeout(timeout); - } - } - - async getBackendStartTime() { - const result = await this.requestBackendJson('/api/stat/start-time', { - timeoutMs: 1800, - method: 'GET', - }); - if (!result.ok || !result.data) { - return null; - } - const rawStartTime = result.data?.data?.start_time; - const numericStartTime = Number(rawStartTime); - return Number.isFinite(numericStartTime) ? numericStartTime : null; - } - - async requestGracefulRestart(authToken = null) { - const result = await this.requestBackendJson('/api/stat/restart-core', { - timeoutMs: 2500, - method: 'POST', - authToken, - headers: { - 'Content-Type': 'application/json', - }, - }); - return result.ok; - } - - async waitForGracefulRestart(previousStartTime, maxWaitMs = 0) { - const effectiveMaxWaitMs = this.getEffectiveWaitMs(maxWaitMs); - const gracefulWaitMs = - effectiveMaxWaitMs > 0 - ? effectiveMaxWaitMs - : GRACEFUL_RESTART_WAIT_FALLBACK_MS; - const start = Date.now(); - let sawBackendDown = false; - - while (true) { - const reachable = await this.pingBackend(700); - if (!reachable) { - sawBackendDown = true; - } else { - const currentStartTime = await this.getBackendStartTime(); - if ( - previousStartTime !== null && - currentStartTime !== null && - currentStartTime !== previousStartTime - ) { - return { ok: true, reason: null }; - } - if (sawBackendDown && previousStartTime === null) { - return { ok: true, reason: null }; - } - } - - if (Date.now() - start >= gracefulWaitMs) { - return { - ok: false, - reason: `Timed out after ${gracefulWaitMs}ms waiting for graceful restart.`, - }; - } - - await delay(350); - } - } - - async waitForBackend(maxWaitMs = 0, failOnProcessExit = false) { - const effectiveMaxWaitMs = this.getEffectiveWaitMs(maxWaitMs); - const start = Date.now(); - while (true) { - if (await this.pingBackend()) { - return { ok: true, reason: null }; - } - if (failOnProcessExit && !this.backendProcess) { - return { - ok: false, - reason: - this.backendLastExitReason || - 'Backend process exited before becoming reachable.', - }; - } - if (effectiveMaxWaitMs > 0 && Date.now() - start >= effectiveMaxWaitMs) { - return { - ok: false, - reason: `Timed out after ${effectiveMaxWaitMs}ms waiting for backend startup.`, - }; - } - await delay(600); - } - } - - async startBackend() { - if (this.shouldSkipStart()) { - this.log('Skip backend start because app is quitting.'); - return; - } - if (this.backendProcess) { - return; - } - const backendConfig = this.getBackendConfig(); - if (!backendConfig.cmd) { - return; - } - - this.backendLastExitReason = null; - const env = { - ...process.env, - PYTHONUNBUFFERED: '1', - }; - if (this.app.isPackaged) { - env.ASTRBOT_ELECTRON_CLIENT = '1'; - const hasExplicitDashboardHost = Boolean( - process.env.DASHBOARD_HOST || process.env.ASTRBOT_DASHBOARD_HOST, - ); - const hasExplicitDashboardPort = Boolean( - process.env.DASHBOARD_PORT || process.env.ASTRBOT_DASHBOARD_PORT, - ); - if (!hasExplicitDashboardHost) { - env.DASHBOARD_HOST = '127.0.0.1'; - } - if (!hasExplicitDashboardPort) { - env.DASHBOARD_PORT = '6185'; - } - } - if (backendConfig.webuiDir) { - env.ASTRBOT_WEBUI_DIR = backendConfig.webuiDir; - } - let backendLogPath = null; - if (backendConfig.rootDir) { - env.ASTRBOT_ROOT = backendConfig.rootDir; - const logsDir = path.join(backendConfig.rootDir, 'logs'); - ensureDir(logsDir); - backendLogPath = path.join(logsDir, 'backend.log'); - } - await this.backendLogger.setLogPath(backendLogPath); - const usePipedLogging = Boolean(backendLogPath); - - this.backendProcess = spawn(backendConfig.cmd, backendConfig.args || [], { - cwd: backendConfig.cwd, - env, - shell: backendConfig.shell, - stdio: usePipedLogging ? ['ignore', 'pipe', 'pipe'] : 'ignore', - windowsHide: true, - }); - - if (usePipedLogging) { - if (this.backendProcess.stdout) { - this.backendProcess.stdout.on('data', (chunk) => { - this.backendLogger.log(chunk); - }); - } - if (this.backendProcess.stderr) { - this.backendProcess.stderr.on('data', (chunk) => { - this.backendLogger.log(chunk); - }); - } - } - - if (usePipedLogging) { - const launchLine = [backendConfig.cmd, ...(backendConfig.args || [])] - .map((item) => JSON.stringify(item)) - .join(' '); - this.backendLogger.log( - `[${formatLogTimestamp()}] [Electron] Start backend ${launchLine}\n`, - ); - } - - this.backendProcess.on('error', (error) => { - this.backendLastExitReason = - error instanceof Error ? error.message : String(error); - this.backendLogger.log( - `[${formatLogTimestamp()}] [Electron] Backend spawn error: ${ - error instanceof Error ? error.message : String(error) - }\n`, - ); - void this.backendLogger.flush(); - this.backendProcess = null; - }); - - this.backendProcess.on('exit', (code, signal) => { - this.backendLastExitReason = `Backend process exited (code=${code ?? 'null'}, signal=${signal ?? 'null'}).`; - void this.backendLogger.flush(); - this.backendProcess = null; - }); - } - - async startBackendAndWait(maxWaitMs = this.backendTimeoutMs) { - if (!this.canManageBackend()) { - return { - ok: false, - reason: 'Backend command is not configured.', - }; - } - this.backendSpawning = true; - try { - await this.startBackend(); - return await this.waitForBackend(maxWaitMs, true); - } finally { - this.backendSpawning = false; - } - } - - async stopManagedBackend() { - if (!this.backendProcess) { - return; - } - const processToStop = this.backendProcess; - const pid = processToStop.pid; - this.backendProcess = null; - this.log(`Stop backend requested pid=${pid ?? 'unknown'}`); - - if (process.platform === 'win32' && pid) { - try { - // Synchronous taskkill is acceptable here because stop/restart is - // already a control-path operation and not latency-sensitive. - const result = spawnSync('taskkill', ['/pid', `${pid}`, '/t', '/f'], { - stdio: 'ignore', - windowsHide: true, - }); - if (result.status !== 0) { - this.log( - `taskkill failed pid=${pid} status=${result.status} signal=${result.signal ?? 'null'}`, - ); - } else { - this.log(`taskkill completed pid=${pid}`); - } - } catch (error) { - this.log( - `taskkill threw for pid=${pid}: ${ - error instanceof Error ? error.message : String(error) - }`, - ); - } - await waitForProcessExit(processToStop, 5000); - } else { - if (!processToStop.killed) { - try { - processToStop.kill('SIGTERM'); - } catch (error) { - this.log( - `SIGTERM failed for pid=${pid ?? 'unknown'}: ${ - error instanceof Error ? error.message : String(error) - }`, - ); - } - } - const exitResult = await waitForProcessExit(processToStop, 5000); - if (exitResult === 'timeout' && !processToStop.killed) { - try { - processToStop.kill('SIGKILL'); - } catch {} - await waitForProcessExit(processToStop, 1500); - } - } - await this.backendLogger.flush(); - } - - findListeningPidsOnWindows(port) { - // Synchronous netstat parsing is acceptable here because this helper is - // used only during shutdown/restart cleanup paths. - const result = spawnSync('netstat', ['-ano', '-p', 'tcp'], { - stdio: ['ignore', 'pipe', 'ignore'], - encoding: 'utf8', - windowsHide: true, - }); - - if (result.status !== 0 || !result.stdout) { - return []; - } - - const pids = new Set(); - const lines = result.stdout.split(/\r?\n/); - - for (const line of lines) { - const trimmed = line.trim(); - if (!trimmed || !trimmed.toUpperCase().startsWith('TCP')) { - continue; - } - - const parts = trimmed.split(/\s+/); - if (parts.length < 5) { - continue; - } - - const localAddress = parts[1] || ''; - const state = (parts[3] || '').toUpperCase(); - const pid = parts[parts.length - 1]; - if (!/^\d+$/.test(pid)) { - continue; - } - - if (state !== 'LISTENING') { - continue; - } - - const cleanedLocalAddress = localAddress.replace(/\]$/, ''); - const segments = cleanedLocalAddress.split(':'); - const portStr = segments[segments.length - 1]; - const portNum = Number(portStr); - if (Number.isInteger(portNum) && portNum === Number(port)) { - pids.add(pid); - } - } - - return Array.from(pids); - } - - getWindowsProcessInfo(pid) { - const result = spawnSync( - 'tasklist', - ['/FI', `PID eq ${pid}`, '/FO', 'CSV', '/NH'], - { - stdio: ['ignore', 'pipe', 'ignore'], - encoding: 'utf8', - windowsHide: true, - }, - ); - if (result.status !== 0 || !result.stdout) { - return null; - } - - const firstLine = result.stdout - .split(/\r?\n/) - .map((line) => line.trim()) - .find((line) => line.length > 0); - if (!firstLine || firstLine.startsWith('INFO:')) { - return null; - } - - const fields = firstLine - .replace(/^"/, '') - .replace(/"$/, '') - .split('","'); - const imageName = fields[0] || ''; - const parsedPid = Number.parseInt(fields[1] || '', 10); - if (!imageName || !Number.isInteger(parsedPid) || parsedPid !== Number(pid)) { - return null; - } - return { imageName, pid: parsedPid }; - } - - async stopUnmanagedBackendByPort() { - if (!this.app.isPackaged || process.platform !== 'win32') { - return false; - } - - const port = this.getBackendPort(); - if (!port) { - return false; - } - - const pids = this.findListeningPidsOnWindows(port); - if (!pids.length) { - return false; - } - - this.log( - `Attempting unmanaged backend cleanup by port=${port} pids=${pids.join(',')}`, - ); - - const expectedImageName = ( - path.basename(this.getPackagedBackendPath() || '') || 'astrbot-backend.exe' - ).toLowerCase(); - - for (const pid of pids) { - const processInfo = this.getWindowsProcessInfo(pid); - if (!processInfo) { - this.log(`Skip unmanaged cleanup for pid=${pid}: unable to resolve process info.`); - continue; - } - - const actualImageName = processInfo.imageName.toLowerCase(); - if (actualImageName !== expectedImageName) { - this.log( - `Skip unmanaged cleanup for pid=${pid}: unexpected process image ${processInfo.imageName}.`, - ); - continue; - } - - try { - // Synchronous taskkill is acceptable here because unmanaged cleanup - // is performed only during shutdown/restart control flows. - spawnSync('taskkill', ['/pid', `${pid}`, '/t', '/f'], { - stdio: 'ignore', - windowsHide: true, - }); - } catch {} - } - - await delay(500); - return !(await this.pingBackend(1200)); - } - - async stopAnyBackend() { - if (this.backendProcess) { - await this.stopManagedBackend(); - const running = await this.pingBackend(); - if (!running) { - return { ok: true, reason: null }; - } - } else { - const running = await this.pingBackend(); - if (!running) { - return { ok: true, reason: null }; - } - } - - const cleaned = await this.stopUnmanagedBackendByPort(); - if (cleaned) { - return { ok: true, reason: null }; - } - - return { - ok: false, - reason: 'Backend is running but not managed by Electron.', - }; - } - - async ensureBackend() { - this.backendStartupFailureReason = null; - - const running = await this.pingBackend(); - if (running) { - return true; - } - if (!this.backendAutoStart || !this.canManageBackend()) { - this.backendStartupFailureReason = - 'Backend auto-start is disabled or backend command is not configured.'; - return false; - } - const waitResult = await this.startBackendAndWait(this.backendTimeoutMs); - if (!waitResult.ok) { - this.backendStartupFailureReason = waitResult.reason; - return false; - } - return true; - } - - async getState() { - return { - running: await this.pingBackend(), - spawning: this.backendSpawning, - restarting: this.backendRestarting, - canManage: this.canManageBackend(), - }; - } - - async restartBackend(authToken = null) { - if (!this.canManageBackend()) { - return { - ok: false, - reason: 'Backend command is not configured.', - }; - } - if (this.backendSpawning || this.backendRestarting) { - return { - ok: false, - reason: 'Backend action already in progress.', - }; - } - - this.backendRestarting = true; - try { - const backendRunning = await this.pingBackend(900); - if (backendRunning) { - const previousStartTime = await this.getBackendStartTime(); - const gracefulRequested = await this.requestGracefulRestart(authToken); - if (gracefulRequested) { - const gracefulResult = await this.waitForGracefulRestart( - previousStartTime, - this.backendTimeoutMs, - ); - if (gracefulResult.ok) { - return { - ok: true, - reason: null, - }; - } - this.log( - `Graceful restart did not complete: ${gracefulResult.reason || 'unknown reason'}`, - ); - } else { - this.log( - 'Graceful restart request failed; falling back to managed restart.', - ); - } - } - - await this.stopManagedBackend(); - const startResult = await this.startBackendAndWait(this.backendTimeoutMs); - if (!startResult.ok) { - return { - ok: false, - reason: startResult.reason || 'Failed to restart backend.', - }; - } - return { - ok: true, - reason: null, - }; - } catch (error) { - return { - ok: false, - reason: error instanceof Error ? error.message : String(error), - }; - } finally { - this.backendRestarting = false; - } - } - - async stopBackendForIpc() { - if (!this.canManageBackend()) { - return { - ok: false, - reason: 'Backend command is not configured.', - }; - } - if (this.backendSpawning || this.backendRestarting) { - return { - ok: false, - reason: 'Backend action already in progress.', - }; - } - - try { - return await this.stopAnyBackend(); - } catch (error) { - return { - ok: false, - reason: error instanceof Error ? error.message : String(error), - }; - } - } -} - -module.exports = { - BackendManager, -}; diff --git a/desktop/lib/buffered-rotating-logger.js b/desktop/lib/buffered-rotating-logger.js deleted file mode 100644 index 7a443a97d..000000000 --- a/desktop/lib/buffered-rotating-logger.js +++ /dev/null @@ -1,162 +0,0 @@ -'use strict'; - -const { RotatingLogWriter } = require('./rotating-log-writer'); -const { parseEnvInt } = require('./common'); - -const DEFAULT_FLUSH_INTERVAL_MS = 120; -const DEFAULT_MAX_BUFFER_BYTES = 128 * 1024; -const MIN_FLUSH_INTERVAL_MS = 10; -const MIN_MAX_BUFFER_BYTES = 4 * 1024; -const MAX_MAX_BUFFER_BYTES = 16 * 1024 * 1024; - -function clampIntOption(raw, { defaultValue, min, max }) { - const value = parseEnvInt(raw, defaultValue); - return Math.min(Math.max(value, min), max); -} - -class BufferedRotatingLogger { - constructor({ - logPath = null, - maxBytes, - backupCount, - flushIntervalMs, - maxBufferBytes, - label = 'buffered-log', - }) { - this.logPath = logPath || null; - this.flushIntervalMs = clampIntOption(flushIntervalMs, { - defaultValue: DEFAULT_FLUSH_INTERVAL_MS, - min: MIN_FLUSH_INTERVAL_MS, - max: 60 * 1000, - }); - this.maxBufferBytes = clampIntOption(maxBufferBytes, { - defaultValue: DEFAULT_MAX_BUFFER_BYTES, - min: MIN_MAX_BUFFER_BYTES, - max: MAX_MAX_BUFFER_BYTES, - }); - this.buffer = []; - this.bufferBytes = 0; - this.flushTimer = null; - this.pathSwitch = Promise.resolve(); - this.writer = new RotatingLogWriter({ - logPath: this.logPath, - maxBytes, - backupCount, - label, - }); - } - - setLogPath(logPath) { - const nextLogPath = logPath || null; - this.pathSwitch = this.pathSwitch.then(async () => { - if (nextLogPath === this.logPath) { - await this.flush(); - return; - } - - const previousLogPath = this.logPath; - if (previousLogPath) { - await this.flush(); - } - - this.logPath = null; - await this.writer.setLogPath(nextLogPath); - this.logPath = nextLogPath; - await this.flush(); - }); - return this.pathSwitch; - } - - log(payload) { - if (payload === undefined || payload === null) { - return; - } - const chunk = Buffer.isBuffer(payload) - ? payload - : Buffer.from(String(payload), 'utf8'); - if (!chunk.length) { - return; - } - - if (!this.logPath) { - const boundedChunk = this.clipChunkToBufferLimit(chunk); - this.dropOldestUntilWithinLimit(boundedChunk.length); - this.buffer.push(boundedChunk); - this.bufferBytes += boundedChunk.length; - return; - } - - this.buffer.push(chunk); - this.bufferBytes += chunk.length; - - if (this.bufferBytes >= this.maxBufferBytes) { - void this.flush(); - return; - } - this.scheduleFlush(); - } - - flush() { - this.clearFlushTimer(); - if (!this.buffer.length) { - return this.writer.flush(); - } - if (!this.logPath) { - // Path is switching or temporarily unavailable; keep buffered data. - this.dropOldestUntilWithinLimit(0); - return this.writer.flush(); - } - - const chunks = this.buffer; - this.buffer = []; - this.bufferBytes = 0; - const payload = chunks.length === 1 ? chunks[0] : Buffer.concat(chunks); - this.writer.append(payload); - return this.writer.flush(); - } - - dropOldestUntilWithinLimit(incomingBytes = 0) { - while ( - this.buffer.length && - this.bufferBytes + Math.max(0, incomingBytes) > this.maxBufferBytes - ) { - const removed = this.buffer.shift(); - if (removed) { - this.bufferBytes -= removed.length; - } - } - if (this.bufferBytes < 0) { - this.bufferBytes = 0; - } - } - - clipChunkToBufferLimit(chunk) { - if (chunk.length <= this.maxBufferBytes) { - return chunk; - } - return chunk.subarray(chunk.length - this.maxBufferBytes); - } - - scheduleFlush() { - if (this.flushTimer !== null) { - return; - } - this.flushTimer = setTimeout(() => { - this.flushTimer = null; - void this.flush(); - }, this.flushIntervalMs); - this.flushTimer.unref?.(); - } - - clearFlushTimer() { - if (this.flushTimer === null) { - return; - } - clearTimeout(this.flushTimer); - this.flushTimer = null; - } -} - -module.exports = { - BufferedRotatingLogger, -}; diff --git a/desktop/lib/common.js b/desktop/lib/common.js deleted file mode 100644 index 9f39358dc..000000000 --- a/desktop/lib/common.js +++ /dev/null @@ -1,115 +0,0 @@ -'use strict'; - -const fs = require('fs'); - -const LOG_ROTATION_DEFAULT_MAX_MB = 20; -const LOG_ROTATION_DEFAULT_BACKUP_COUNT = 3; - -function normalizeUrl(value) { - try { - const url = new URL(value); - if (!url.pathname.endsWith('/')) { - url.pathname += '/'; - } - return url.toString(); - } catch { - return 'http://127.0.0.1:6185/'; - } -} - -function ensureDir(value) { - if (!value) { - return; - } - if (fs.existsSync(value)) { - return; - } - fs.mkdirSync(value, { recursive: true }); -} - -function parseEnvInt(raw, defaultValue) { - const parsed = Number.parseInt(`${raw ?? ''}`, 10); - return Number.isFinite(parsed) ? parsed : defaultValue; -} - -function isLogRotationDebugEnabled() { - return ( - process.env.ASTRBOT_LOG_ROTATION_DEBUG === '1' || - process.env.NODE_ENV === 'development' - ); -} - -function parseLogMaxBytes(envValue) { - const mb = parseEnvInt(envValue, LOG_ROTATION_DEFAULT_MAX_MB); - const maxMb = mb > 0 ? mb : LOG_ROTATION_DEFAULT_MAX_MB; - return maxMb * 1024 * 1024; -} - -function parseLogBackupCount(envValue) { - const count = parseEnvInt(envValue, LOG_ROTATION_DEFAULT_BACKUP_COUNT); - return count >= 0 ? count : LOG_ROTATION_DEFAULT_BACKUP_COUNT; -} - -function isIgnorableFsError(error) { - return Boolean(error && typeof error === 'object' && error.code === 'ENOENT'); -} - -function delay(ms) { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -function waitForProcessExit(child, timeoutMs = 5000) { - if (!child) { - return Promise.resolve('missing'); - } - if (child.exitCode !== null || child.signalCode !== null) { - return Promise.resolve('exited'); - } - return new Promise((resolve) => { - let settled = false; - const finish = (reason) => { - if (settled) { - return; - } - settled = true; - clearTimeout(timeout); - resolve(reason); - }; - const timeout = setTimeout(() => finish('timeout'), timeoutMs); - child.once('exit', () => finish('exit')); - child.once('error', () => finish('error')); - }); -} - -function formatLogTimestamp(date = new Date()) { - const year = date.getFullYear(); - const month = `${date.getMonth() + 1}`.padStart(2, '0'); - const day = `${date.getDate()}`.padStart(2, '0'); - const hour = `${date.getHours()}`.padStart(2, '0'); - const minute = `${date.getMinutes()}`.padStart(2, '0'); - const second = `${date.getSeconds()}`.padStart(2, '0'); - const millisecond = `${date.getMilliseconds()}`.padStart(3, '0'); - - const offsetMinutes = -date.getTimezoneOffset(); - const offsetSign = offsetMinutes >= 0 ? '+' : '-'; - const absOffsetMinutes = Math.abs(offsetMinutes); - const offsetHour = `${Math.floor(absOffsetMinutes / 60)}`.padStart(2, '0'); - const offsetMinute = `${absOffsetMinutes % 60}`.padStart(2, '0'); - - return `${year}-${month}-${day} ${hour}:${minute}:${second}.${millisecond} ${offsetSign}${offsetHour}${offsetMinute}`; -} - -module.exports = { - LOG_ROTATION_DEFAULT_BACKUP_COUNT, - LOG_ROTATION_DEFAULT_MAX_MB, - delay, - ensureDir, - formatLogTimestamp, - isIgnorableFsError, - isLogRotationDebugEnabled, - normalizeUrl, - parseEnvInt, - parseLogBackupCount, - parseLogMaxBytes, - waitForProcessExit, -}; diff --git a/desktop/lib/dashboard-loader.js b/desktop/lib/dashboard-loader.js deleted file mode 100644 index 6e858843f..000000000 --- a/desktop/lib/dashboard-loader.js +++ /dev/null @@ -1,30 +0,0 @@ -'use strict'; - -const { delay } = require('./common'); - -async function loadDashboard(mainWindow, backendUrl, maxWaitMs = 20000) { - if (!mainWindow) { - return false; - } - const loadUrl = new URL(backendUrl); - loadUrl.searchParams.set('_electron_ts', `${Date.now()}`); - const start = Date.now(); - let lastError = null; - while (maxWaitMs <= 0 || Date.now() - start < maxWaitMs) { - try { - await mainWindow.loadURL(loadUrl.toString()); - return true; - } catch (error) { - lastError = error; - await delay(600); - } - } - if (lastError) { - throw lastError; - } - throw new Error(`Timed out loading ${backendUrl}`); -} - -module.exports = { - loadDashboard, -}; diff --git a/desktop/lib/electron-logger.js b/desktop/lib/electron-logger.js deleted file mode 100644 index 6a52d1c76..000000000 --- a/desktop/lib/electron-logger.js +++ /dev/null @@ -1,53 +0,0 @@ -'use strict'; - -const path = require('path'); -const { RotatingLogWriter } = require('./rotating-log-writer'); -const { - formatLogTimestamp, - parseLogBackupCount, - parseLogMaxBytes, -} = require('./common'); - -function createElectronLogger({ app, getRootDir }) { - const electronLogMaxBytes = parseLogMaxBytes( - process.env.ASTRBOT_ELECTRON_LOG_MAX_MB, - ); - const electronLogBackupCount = parseLogBackupCount( - process.env.ASTRBOT_ELECTRON_LOG_BACKUP_COUNT, - ); - const writer = new RotatingLogWriter({ - logPath: null, - maxBytes: electronLogMaxBytes, - backupCount: electronLogBackupCount, - label: 'electron-log', - }); - - function getElectronLogPath() { - const rootDir = - process.env.ASTRBOT_ROOT || - (typeof getRootDir === 'function' ? getRootDir() : null) || - app.getPath('userData'); - return path.join(rootDir, 'logs', 'electron.log'); - } - - function logElectron(message) { - const logPath = getElectronLogPath(); - const line = `[${formatLogTimestamp()}] ${message}\n`; - void writer.setLogPath(logPath); - void writer.append(line); - } - - async function flushElectron() { - await writer.flush(); - } - - return { - getElectronLogPath, - logElectron, - flushElectron, - }; -} - -module.exports = { - createElectronLogger, -}; diff --git a/desktop/lib/locale-service.js b/desktop/lib/locale-service.js deleted file mode 100644 index d68039e7d..000000000 --- a/desktop/lib/locale-service.js +++ /dev/null @@ -1,174 +0,0 @@ -'use strict'; - -const fs = require('fs'); -const path = require('path'); -const { delay, ensureDir } = require('./common'); - -const LOCALE_STORAGE_KEY = 'astrbot-locale'; -const SUPPORTED_STARTUP_LOCALES = new Set(['zh-CN', 'en-US']); - -function normalizeLocale(value) { - if (!value) { - return null; - } - const raw = String(value).trim(); - if (!raw) { - return null; - } - if (SUPPORTED_STARTUP_LOCALES.has(raw)) { - return raw; - } - const lower = raw.toLowerCase(); - if (lower.startsWith('zh')) { - return 'zh-CN'; - } - if (lower.startsWith('en')) { - return 'en-US'; - } - return null; -} - -function getStartupTexts(locale) { - if (locale === 'zh-CN') { - return { - title: 'AstrBot 正在启动', - message: '界面很快就会加载完成。', - }; - } - return { - title: 'AstrBot is starting', - message: 'The dashboard will be ready in a moment.', - }; -} - -function getShellTexts(locale) { - if (locale === 'zh-CN') { - return { - trayHide: '隐藏 AstrBot', - trayShow: '显示 AstrBot', - trayReload: '重新加载', - trayRestartBackend: '重启后端', - trayQuit: '退出', - startupFailTitle: 'AstrBot 启动失败', - startupFailMessage: 'AstrBot 后端不可达。', - startupFailReasonPrefix: '原因', - startupFailAction: - '请先启动 http://127.0.0.1:6185 的后端服务,然后重新打开 AstrBot。', - startupFailLogPrefix: '后端日志', - dashboardFailTitle: 'AstrBot 加载失败', - dashboardFailMessage: '无法加载 AstrBot 控制台页面。', - }; - } - return { - trayHide: 'Hide AstrBot', - trayShow: 'Show AstrBot', - trayReload: 'Reload', - trayRestartBackend: 'Restart Backend', - trayQuit: 'Quit', - startupFailTitle: 'AstrBot startup failed', - startupFailMessage: 'AstrBot backend is not reachable.', - startupFailReasonPrefix: 'Reason', - startupFailAction: - 'Please start the backend at http://127.0.0.1:6185 and relaunch AstrBot.', - startupFailLogPrefix: 'Backend log', - dashboardFailTitle: 'Failed to load AstrBot', - dashboardFailMessage: 'Unable to load the AstrBot dashboard.', - }; -} - -function createLocaleService({ app, getRootDir }) { - function resolveStateRoot() { - const callbackRoot = (() => { - try { - return getRootDir ? getRootDir() : null; - } catch { - return null; - } - })(); - return process.env.ASTRBOT_ROOT || callbackRoot || app.getPath('userData'); - } - - function getDesktopStatePath() { - return path.join(resolveStateRoot(), 'data', 'desktop_state.json'); - } - - function readCachedLocale() { - const statePath = getDesktopStatePath(); - try { - const raw = fs.readFileSync(statePath, 'utf8'); - const parsed = JSON.parse(raw); - return normalizeLocale(parsed?.locale); - } catch { - return null; - } - } - - function writeCachedLocale(locale) { - const normalized = normalizeLocale(locale); - if (!normalized) { - return; - } - const statePath = getDesktopStatePath(); - ensureDir(path.dirname(statePath)); - try { - fs.writeFileSync( - statePath, - `${JSON.stringify({ locale: normalized }, null, 2)}\n`, - 'utf8', - ); - } catch {} - } - - function resolveStartupLocale() { - const cached = readCachedLocale(); - if (cached) { - return cached; - } - return normalizeLocale(app.getLocale()) || 'zh-CN'; - } - - async function persistLocaleFromDashboard( - mainWindow, - backendUrl, - timeoutMs = 1200, - ) { - if (!mainWindow || mainWindow.isDestroyed()) { - return; - } - const currentUrl = mainWindow.webContents.getURL(); - if (!currentUrl || !currentUrl.startsWith(backendUrl)) { - return; - } - try { - const localeRaw = await Promise.race([ - mainWindow.webContents.executeJavaScript( - `(() => { - try { - return window.localStorage.getItem('${LOCALE_STORAGE_KEY}') || ''; - } catch { - return ''; - } - })();`, - true, - ), - delay(timeoutMs).then(() => null), - ]); - const locale = normalizeLocale(localeRaw); - if (locale) { - writeCachedLocale(locale); - } - } catch {} - } - - return { - getShellTexts, - getStartupTexts, - persistLocaleFromDashboard, - resolveStartupLocale, - }; -} - -module.exports = { - createLocaleService, - normalizeLocale, -}; diff --git a/desktop/lib/rotating-log-writer.js b/desktop/lib/rotating-log-writer.js deleted file mode 100644 index c6c8f8fb1..000000000 --- a/desktop/lib/rotating-log-writer.js +++ /dev/null @@ -1,178 +0,0 @@ -'use strict'; - -const fs = require('fs/promises'); -const path = require('path'); -const { isIgnorableFsError, isLogRotationDebugEnabled } = require('./common'); - -class RotatingLogWriter { - constructor({ logPath = null, maxBytes = 0, backupCount = 0, label = 'log' }) { - this.logPath = logPath || null; - this.maxBytes = Number.isFinite(maxBytes) && maxBytes > 0 ? maxBytes : 0; - this.backupCount = Number.isFinite(backupCount) && backupCount >= 0 ? backupCount : 0; - this.label = label; - this.cachedSize = null; - this.dirReadyForPath = null; - this.queue = Promise.resolve(); - } - - setLogPath(logPath) { - const nextPath = logPath || null; - if (nextPath === this.logPath) { - return this.queue; - } - return this.enqueue(async () => { - this.logPath = nextPath; - this.cachedSize = null; - this.dirReadyForPath = null; - }); - } - - append(payload) { - if (payload === undefined || payload === null) { - return this.queue; - } - const content = Buffer.isBuffer(payload) - ? payload - : Buffer.from(String(payload), 'utf8'); - if (!content.length) { - return this.queue; - } - return this.enqueue(async () => { - if (!this.logPath) { - return; - } - await this.ensureDirReady(); - await this.ensureSizeLoaded(); - await this.rotateIfNeeded(content.length); - await fs.appendFile(this.logPath, content); - if (!Number.isFinite(this.cachedSize)) { - this.cachedSize = await this.readSize(); - } else { - this.cachedSize += content.length; - } - }); - } - - flush() { - return this.queue; - } - - enqueue(task) { - const run = async () => { - try { - await task(); - } catch (error) { - this.reportError('write', this.logPath || 'unknown', error); - } - }; - this.queue = this.queue.then(run, run); - return this.queue; - } - - async ensureSizeLoaded() { - if (Number.isFinite(this.cachedSize)) { - return; - } - this.cachedSize = await this.readSize(); - } - - async ensureDirReady() { - if (!this.logPath) { - return; - } - if (this.dirReadyForPath === this.logPath) { - return; - } - const dirPath = path.dirname(this.logPath); - try { - await fs.mkdir(dirPath, { recursive: true }); - this.dirReadyForPath = this.logPath; - } catch (error) { - this.reportError('mkdir', dirPath, error); - } - } - - async readSize() { - if (!this.logPath) { - return 0; - } - try { - const stat = await fs.stat(this.logPath); - return stat.size; - } catch (error) { - if (isIgnorableFsError(error)) { - return 0; - } - this.reportError('stat', this.logPath, error); - return 0; - } - } - - async rotateIfNeeded(incomingBytes) { - if (!this.logPath || this.maxBytes <= 0) { - return; - } - - const currentSize = Number.isFinite(this.cachedSize) ? this.cachedSize : 0; - if (currentSize + Math.max(0, incomingBytes) <= this.maxBytes) { - return; - } - - if (this.backupCount <= 0) { - try { - await fs.truncate(this.logPath, 0); - } catch (error) { - if (!isIgnorableFsError(error)) { - this.reportError('truncate', this.logPath, error); - } - } - this.cachedSize = await this.readSize(); - return; - } - - const oldestPath = `${this.logPath}.${this.backupCount}`; - try { - await fs.unlink(oldestPath); - } catch (error) { - if (!isIgnorableFsError(error)) { - this.reportError('unlink', oldestPath, error); - } - } - - for (let index = this.backupCount - 1; index >= 1; index -= 1) { - const sourcePath = `${this.logPath}.${index}`; - const targetPath = `${this.logPath}.${index + 1}`; - try { - await fs.rename(sourcePath, targetPath); - } catch (error) { - if (!isIgnorableFsError(error)) { - this.reportError('rename', `${sourcePath} -> ${targetPath}`, error); - } - } - } - - try { - await fs.rename(this.logPath, `${this.logPath}.1`); - } catch (error) { - if (!isIgnorableFsError(error)) { - this.reportError('rename', `${this.logPath} -> ${this.logPath}.1`, error); - } - } - - this.cachedSize = await this.readSize(); - } - - reportError(action, targetPath, error) { - if (!isLogRotationDebugEnabled()) { - return; - } - const details = error instanceof Error ? error.message : String(error); - console.error( - `[astrbot][${this.label}] ${action} failed for ${targetPath}: ${details}`, - ); - } -} - -module.exports = { - RotatingLogWriter, -}; diff --git a/desktop/lib/startup-screen.js b/desktop/lib/startup-screen.js deleted file mode 100644 index 93a342b81..000000000 --- a/desktop/lib/startup-screen.js +++ /dev/null @@ -1,116 +0,0 @@ -'use strict'; - -const fs = require('fs'); - -async function loadStartupScreen(mainWindow, { getAssetPath, startupTexts }) { - if (!mainWindow) { - return false; - } - let iconUrl = ''; - try { - const iconBuffer = fs.readFileSync(getAssetPath('icon-no-shadow.svg')); - iconUrl = `data:image/svg+xml;base64,${iconBuffer.toString('base64')}`; - } catch {} - - const html = ` - - - - - AstrBot - - - -
- ${ - iconUrl - ? `` - : '' - } - -

${startupTexts.title}

-

${startupTexts.message}

-
- -`; - const startupUrl = `data:text/html;charset=utf-8,${encodeURIComponent(html)}`; - await mainWindow.loadURL(startupUrl); - return true; -} - -module.exports = { - loadStartupScreen, -}; diff --git a/desktop/main.js b/desktop/main.js deleted file mode 100644 index 5adff38b3..000000000 --- a/desktop/main.js +++ /dev/null @@ -1,420 +0,0 @@ -'use strict'; - -const fs = require('fs'); -const path = require('path'); -const { - app, - BrowserWindow, - Menu, - Tray, - nativeImage, - shell, - dialog, - ipcMain, -} = require('electron'); - -const { BackendManager } = require('./lib/backend-manager'); -const { loadDashboard } = require('./lib/dashboard-loader'); -const { createElectronLogger } = require('./lib/electron-logger'); -const { createLocaleService } = require('./lib/locale-service'); -const { loadStartupScreen } = require('./lib/startup-screen'); - -const isMac = process.platform === 'darwin'; -const dashboardTimeoutMsParsed = Number.parseInt( - process.env.ASTRBOT_DASHBOARD_TIMEOUT_MS || '20000', - 10, -); -const dashboardTimeoutMs = Number.isFinite(dashboardTimeoutMsParsed) - ? dashboardTimeoutMsParsed - : 20000; - -let mainWindow = null; -let tray = null; -let isQuitting = false; -let quitInProgress = false; -let backendManager = null; - -app.commandLine.appendSwitch('disable-http-cache'); - -const { logElectron, flushElectron } = createElectronLogger({ - app, - getRootDir: () => (backendManager ? backendManager.getRootDir() : null), -}); - -backendManager = new BackendManager({ - app, - baseDir: __dirname, - log: logElectron, - shouldSkipStart: () => isQuitting || quitInProgress, -}); - -const localeService = createLocaleService({ - app, - getRootDir: () => backendManager.getRootDir(), -}); - -function getAssetPath(filename) { - if (app.isPackaged) { - const packaged = path.join(process.resourcesPath, 'assets', filename); - if (fs.existsSync(packaged)) { - return packaged; - } - } - return path.join(__dirname, 'assets', filename); -} - -function loadImageSafe(imagePath) { - try { - const image = nativeImage.createFromPath(imagePath); - if (!image.isEmpty()) { - return image; - } - } catch {} - return nativeImage.createEmpty(); -} - -function showWindow() { - if (!mainWindow) { - return; - } - mainWindow.show(); - mainWindow.focus(); - updateTrayMenu(); -} - -function toggleWindow() { - if (!mainWindow) { - return; - } - if (mainWindow.isVisible()) { - mainWindow.hide(); - } else { - mainWindow.show(); - mainWindow.focus(); - } - updateTrayMenu(); -} - -function updateTrayMenu() { - if (!tray || !mainWindow) { - return; - } - const shellTexts = localeService.getShellTexts( - localeService.resolveStartupLocale(), - ); - const isVisible = mainWindow.isVisible(); - const contextMenu = Menu.buildFromTemplate([ - { - label: isVisible ? shellTexts.trayHide : shellTexts.trayShow, - click: () => toggleWindow(), - }, - { - label: shellTexts.trayReload, - click: () => { - if (mainWindow) { - mainWindow.reload(); - } - }, - }, - { - label: shellTexts.trayRestartBackend, - click: async () => { - if (!backendManager) { - return; - } - if (mainWindow && !mainWindow.isDestroyed()) { - showWindow(); - const currentUrl = mainWindow.webContents.getURL(); - if (currentUrl.startsWith(backendManager.getBackendUrl())) { - mainWindow.webContents.send('astrbot-desktop:tray-restart-backend'); - return; - } - } - - const result = await backendManager.restartBackend(); - if (!result.ok) { - logElectron( - `Tray restart backend fallback failed: ${result.reason || 'unknown reason'}`, - ); - } - }, - }, - { type: 'separator' }, - { - label: shellTexts.trayQuit, - click: () => app.quit(), - }, - ]); - tray.setContextMenu(contextMenu); -} - -function createTray() { - const traySize = isMac ? 18 : 16; - const trayPath = getAssetPath('tray.png'); - let trayImage = loadImageSafe(trayPath); - if (trayImage.isEmpty()) { - trayImage = loadImageSafe(getAssetPath('icon.png')); - } - if (!trayImage.isEmpty()) { - trayImage = trayImage.resize({ width: traySize, height: traySize }); - if (isMac) { - trayImage.setTemplateImage(true); - } - tray = new Tray(trayImage); - } else { - tray = new Tray(nativeImage.createEmpty()); - } - tray.setToolTip('AstrBot'); - tray.on('click', () => toggleWindow()); - updateTrayMenu(); -} - -function createWindow() { - mainWindow = new BrowserWindow({ - width: 1280, - height: 800, - minWidth: 980, - minHeight: 680, - show: false, - backgroundColor: '#f9fafc', - autoHideMenuBar: !isMac, - icon: getAssetPath('icon.png'), - webPreferences: { - contextIsolation: true, - nodeIntegration: false, - sandbox: true, - preload: path.join(__dirname, 'preload.js'), - ...(isMac - ? { - defaultFontFamily: { - standard: 'PingFang SC', - sansSerif: 'PingFang SC', - serif: 'Songti SC', - monospace: 'SF Mono', - }, - } - : {}), - }, - }); - - mainWindow.on('close', (event) => { - if (isQuitting) { - return; - } - event.preventDefault(); - mainWindow.hide(); - }); - - mainWindow.on('minimize', (event) => { - event.preventDefault(); - mainWindow.hide(); - }); - - mainWindow.on('show', () => updateTrayMenu()); - mainWindow.on('hide', () => updateTrayMenu()); - - mainWindow.webContents.setWindowOpenHandler(({ url }) => { - shell.openExternal(url); - return { action: 'deny' }; - }); - - mainWindow.webContents.on( - 'did-fail-load', - (_event, errorCode, errorDescription, validatedURL, isMainFrame) => { - if (!isMainFrame) { - return; - } - logElectron( - `did-fail-load main-frame code=${errorCode} desc=${errorDescription} url=${validatedURL}`, - ); - }, - ); - - mainWindow.webContents.on('did-finish-load', () => { - const currentUrl = mainWindow.webContents.getURL(); - logElectron(`did-finish-load url=${currentUrl}`); - if (currentUrl.startsWith(backendManager.getBackendUrl())) { - void localeService.persistLocaleFromDashboard( - mainWindow, - backendManager.getBackendUrl(), - ); - } - }); - - mainWindow.webContents.on('render-process-gone', (_event, details) => { - logElectron( - `render-process-gone reason=${details.reason} exitCode=${details.exitCode}`, - ); - }); - - mainWindow.webContents.on( - 'console-message', - (_event, level, message, line, sourceId) => { - if (level >= 2) { - logElectron( - `renderer-console level=${level} source=${sourceId}:${line} message=${message}`, - ); - } - }, - ); - - return mainWindow; -} - -function registerIpcHandlers() { - ipcMain.handle('astrbot-desktop:is-electron-runtime', async () => true); - - ipcMain.handle('astrbot-desktop:get-backend-state', async () => { - return backendManager.getState(); - }); - - ipcMain.handle('astrbot-desktop:restart-backend', async (_event, authToken) => { - return backendManager.restartBackend(authToken); - }); - - ipcMain.handle('astrbot-desktop:stop-backend', async () => { - return backendManager.stopBackendForIpc(); - }); -} - -async function startDesktopFlow() { - createWindow(); - createTray(); - - try { - const startupTexts = localeService.getStartupTexts( - localeService.resolveStartupLocale(), - ); - await loadStartupScreen(mainWindow, { - getAssetPath, - startupTexts, - }); - } catch (error) { - logElectron( - `failed to load startup screen: ${ - error instanceof Error ? error.message : String(error) - }`, - ); - } - - showWindow(); - - const ready = await backendManager.ensureBackend(); - if (isQuitting) { - return; - } - - if (!ready) { - const shellTexts = localeService.getShellTexts( - localeService.resolveStartupLocale(), - ); - const backendLogPath = backendManager.getBackendLogPath(); - const detailLines = []; - const startupFailureReason = backendManager.getStartupFailureReason(); - if (startupFailureReason) { - detailLines.push( - `${shellTexts.startupFailReasonPrefix}: ${startupFailureReason}`, - ); - } - detailLines.push(shellTexts.startupFailAction); - if (backendLogPath) { - detailLines.push(`${shellTexts.startupFailLogPrefix}: ${backendLogPath}`); - } - - await dialog.showMessageBox({ - type: 'error', - title: shellTexts.startupFailTitle, - message: shellTexts.startupFailMessage, - detail: detailLines.join('\n'), - }); - isQuitting = true; - app.quit(); - return; - } - - try { - await loadDashboard( - mainWindow, - backendManager.getBackendUrl(), - dashboardTimeoutMs, - ); - showWindow(); - } catch (error) { - const shellTexts = localeService.getShellTexts( - localeService.resolveStartupLocale(), - ); - await dialog.showMessageBox({ - type: 'error', - title: shellTexts.dashboardFailTitle, - message: shellTexts.dashboardFailMessage, - detail: error instanceof Error ? error.message : String(error), - }); - isQuitting = true; - app.quit(); - } -} - -registerIpcHandlers(); - -app.setAppUserModelId('com.astrbot.desktop'); - -const gotLock = app.requestSingleInstanceLock(); -if (!gotLock) { - app.quit(); -} else { - app.on('second-instance', () => { - showWindow(); - }); -} - -app.on('before-quit', (event) => { - if (quitInProgress) { - event.preventDefault(); - return; - } - event.preventDefault(); - quitInProgress = true; - isQuitting = true; - logElectron('before-quit received, stopping backend.'); - - localeService - .persistLocaleFromDashboard(mainWindow, backendManager.getBackendUrl()) - .catch(() => {}) - .then(() => - backendManager.stopAnyBackend().then((result) => { - if (!result.ok) { - logElectron(`stopBackend failed: ${result.reason || 'unknown reason'}`); - } - }), - ) - .finally(async () => { - logElectron('Backend stop finished, exiting app.'); - await Promise.allSettled([ - flushElectron(), - backendManager ? backendManager.flushLogs() : Promise.resolve(), - ]); - app.exit(0); - }); -}); - -app.whenReady().then(async () => { - if (isMac && app.dock) { - const dockIcon = getAssetPath('icon.png'); - if (fs.existsSync(dockIcon)) { - app.dock.setIcon(dockIcon); - } - } - await startDesktopFlow(); -}); - -app.on('activate', () => { - if (mainWindow) { - showWindow(); - } -}); - -app.on('window-all-closed', () => { - if (!isMac) { - app.quit(); - } -}); diff --git a/desktop/package.json b/desktop/package.json deleted file mode 100644 index b2e9b9766..000000000 --- a/desktop/package.json +++ /dev/null @@ -1,97 +0,0 @@ -{ - "name": "astrbot-desktop", - "version": "4.16.0", - "description": "AstrBot desktop wrapper", - "private": true, - "main": "main.js", - "author": "AstrBot", - "packageManager": "pnpm@10.28.2", - "pnpm": { - "onlyBuiltDependencies": [ - "electron" - ] - }, - "scripts": { - "dev": "electron .", - "start": "electron .", - "sync:version": "node scripts/sync-version.mjs", - "build:webui": "node scripts/prepare-webui.mjs", - "build:backend": "node scripts/build-backend.mjs", - "dist:full": "pnpm run build:webui && pnpm run build:backend && pnpm run dist", - "pack": "pnpm run sync:version && electron-builder --dir", - "dist": "pnpm run sync:version && electron-builder" - }, - "devDependencies": { - "electron": "^40.3.0", - "electron-builder": "^24.13.0" - }, - "build": { - "appId": "com.astrbot.desktop", - "productName": "AstrBot", - "icon": "assets/icon.png", - "extraResources": [ - { - "from": "resources/backend", - "to": "backend", - "filter": [ - "**/*", - "!**/*.map" - ] - }, - { - "from": "resources/webui", - "to": "webui", - "filter": [ - "**/*", - "!**/*.map" - ] - }, - { - "from": "assets", - "to": "assets", - "filter": [ - "**/*", - "!**/*.map" - ] - } - ], - "files": [ - "**/*", - "!**/*.map", - "!**/*.d.ts", - "!**/{test,__tests__,tests,powered-test,example,examples}/**" - ], - "compression": "maximum", - "electronLanguages": [ - "en-US", - "zh-CN" - ], - "asar": true, - "directories": { - "buildResources": "assets" - }, - "linux": { - "target": [ - "AppImage" - ], - "category": "Utility" - }, - "mac": { - "target": [ - "dmg", - "zip" - ], - "category": "public.app-category.productivity" - }, - "win": { - "target": [ - "nsis", - "zip" - ] - }, - "nsis": { - "oneClick": false, - "allowToChangeInstallationDirectory": true - } - } -} diff --git a/desktop/pnpm-lock.yaml b/desktop/pnpm-lock.yaml deleted file mode 100644 index 98411a90e..000000000 --- a/desktop/pnpm-lock.yaml +++ /dev/null @@ -1,2277 +0,0 @@ -lockfileVersion: '9.0' - -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false - -importers: - - .: - devDependencies: - electron: - specifier: ^40.3.0 - version: 40.3.0 - electron-builder: - specifier: ^24.13.0 - version: 24.13.3(electron-builder-squirrel-windows@24.13.3) - -packages: - - 7zip-bin@5.2.0: - resolution: {integrity: sha512-ukTPVhqG4jNzMro2qA9HSCSSVJN3aN7tlb+hfqYCt3ER0yWroeA2VR38MNrOHLQ/cVj+DaIMad0kFCtWWowh/A==} - - '@develar/schema-utils@2.6.5': - resolution: {integrity: sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig==} - engines: {node: '>= 8.9.0'} - - '@electron/asar@3.4.1': - resolution: {integrity: sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA==} - engines: {node: '>=10.12.0'} - hasBin: true - - '@electron/get@2.0.3': - resolution: {integrity: sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==} - engines: {node: '>=12'} - - '@electron/notarize@2.2.1': - resolution: {integrity: sha512-aL+bFMIkpR0cmmj5Zgy0LMKEpgy43/hw5zadEArgmAMWWlKc5buwFvFT9G/o/YJkvXAJm5q3iuTuLaiaXW39sg==} - engines: {node: '>= 10.0.0'} - - '@electron/osx-sign@1.0.5': - resolution: {integrity: sha512-k9ZzUQtamSoweGQDV2jILiRIHUu7lYlJ3c6IEmjv1hC17rclE+eb9U+f6UFlOOETo0JzY1HNlXy4YOlCvl+Lww==} - engines: {node: '>=12.0.0'} - hasBin: true - - '@electron/universal@1.5.1': - resolution: {integrity: sha512-kbgXxyEauPJiQQUNG2VgUeyfQNFk6hBF11ISN2PNI6agUgPl55pv4eQmaqHzTAzchBvqZ2tQuRVaPStGf0mxGw==} - engines: {node: '>=8.6'} - - '@isaacs/cliui@8.0.2': - resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} - engines: {node: '>=12'} - - '@malept/cross-spawn-promise@1.1.1': - resolution: {integrity: sha512-RTBGWL5FWQcg9orDOCcp4LvItNzUPcyEU9bwaeJX0rJ1IQxzucC48Y0/sQLp/g6t99IQgAlGIaesJS+gTn7tVQ==} - engines: {node: '>= 10'} - - '@malept/flatpak-bundler@0.4.0': - resolution: {integrity: sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q==} - engines: {node: '>= 10.0.0'} - - '@pkgjs/parseargs@0.11.0': - resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} - engines: {node: '>=14'} - - '@sindresorhus/is@4.6.0': - resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==} - engines: {node: '>=10'} - - '@szmarczak/http-timer@4.0.6': - resolution: {integrity: sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==} - engines: {node: '>=10'} - - '@tootallnate/once@2.0.0': - resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==} - engines: {node: '>= 10'} - - '@types/cacheable-request@6.0.3': - resolution: {integrity: sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==} - - '@types/debug@4.1.12': - resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} - - '@types/fs-extra@9.0.13': - resolution: {integrity: sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA==} - - '@types/http-cache-semantics@4.2.0': - resolution: {integrity: sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==} - - '@types/keyv@3.1.4': - resolution: {integrity: sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==} - - '@types/ms@2.1.0': - resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} - - '@types/node@24.10.13': - resolution: {integrity: sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg==} - - '@types/node@25.2.2': - resolution: {integrity: sha512-BkmoP5/FhRYek5izySdkOneRyXYN35I860MFAGupTdebyE66uZaR+bXLHq8k4DirE5DwQi3NuhvRU1jqTVwUrQ==} - - '@types/plist@3.0.5': - resolution: {integrity: sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA==} - - '@types/responselike@1.0.3': - resolution: {integrity: sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==} - - '@types/verror@1.10.11': - resolution: {integrity: sha512-RlDm9K7+o5stv0Co8i8ZRGxDbrTxhJtgjqjFyVh/tXQyl/rYtTKlnTvZ88oSTeYREWurwx20Js4kTuKCsFkUtg==} - - '@types/yauzl@2.10.3': - resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} - - '@xmldom/xmldom@0.8.11': - resolution: {integrity: sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==} - engines: {node: '>=10.0.0'} - - agent-base@6.0.2: - resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} - engines: {node: '>= 6.0.0'} - - ajv-keywords@3.5.2: - resolution: {integrity: sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==} - peerDependencies: - ajv: ^6.9.1 - - ajv@6.12.6: - resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} - - ansi-regex@5.0.1: - resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} - engines: {node: '>=8'} - - ansi-regex@6.2.2: - resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} - engines: {node: '>=12'} - - ansi-styles@4.3.0: - resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} - engines: {node: '>=8'} - - ansi-styles@6.2.3: - resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} - engines: {node: '>=12'} - - app-builder-bin@4.0.0: - resolution: {integrity: sha512-xwdG0FJPQMe0M0UA4Tz0zEB8rBJTRA5a476ZawAqiBkMv16GRK5xpXThOjMaEOFnZ6zabejjG4J3da0SXG63KA==} - - app-builder-lib@24.13.3: - resolution: {integrity: sha512-FAzX6IBit2POXYGnTCT8YHFO/lr5AapAII6zzhQO3Rw4cEDOgK+t1xhLc5tNcKlicTHlo9zxIwnYCX9X2DLkig==} - engines: {node: '>=14.0.0'} - peerDependencies: - dmg-builder: 24.13.3 - electron-builder-squirrel-windows: 24.13.3 - - archiver-utils@2.1.0: - resolution: {integrity: sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==} - engines: {node: '>= 6'} - - archiver-utils@3.0.4: - resolution: {integrity: sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==} - engines: {node: '>= 10'} - - archiver@5.3.2: - resolution: {integrity: sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==} - engines: {node: '>= 10'} - - argparse@2.0.1: - resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} - - assert-plus@1.0.0: - resolution: {integrity: sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==} - engines: {node: '>=0.8'} - - astral-regex@2.0.0: - resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==} - engines: {node: '>=8'} - - async-exit-hook@2.0.1: - resolution: {integrity: sha512-NW2cX8m1Q7KPA7a5M2ULQeZ2wR5qI5PAbw5L0UOMxdioVk9PMZ0h1TmyZEkPYrCvYjDlFICusOu1dlEKAAeXBw==} - engines: {node: '>=0.12.0'} - - async@3.2.6: - resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} - - asynckit@0.4.0: - resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} - - at-least-node@1.0.0: - resolution: {integrity: sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==} - engines: {node: '>= 4.0.0'} - - balanced-match@1.0.2: - resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} - - base64-js@1.5.1: - resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - - bl@4.1.0: - resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} - - bluebird-lst@1.0.9: - resolution: {integrity: sha512-7B1Rtx82hjnSD4PGLAjVWeYH3tHAcVUmChh85a3lltKQm6FresXh9ErQo6oAv6CqxttczC3/kEg8SY5NluPuUw==} - - bluebird@3.7.2: - resolution: {integrity: sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==} - - boolean@3.2.0: - resolution: {integrity: sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==} - deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. - - brace-expansion@1.1.12: - resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} - - brace-expansion@2.0.2: - resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} - - buffer-crc32@0.2.13: - resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} - - buffer-equal@1.0.1: - resolution: {integrity: sha512-QoV3ptgEaQpvVwbXdSO39iqPQTCxSF7A5U99AxbHYqUdCizL/lH2Z0A2y6nbZucxMEOtNyZfG2s6gsVugGpKkg==} - engines: {node: '>=0.4'} - - buffer-from@1.1.2: - resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} - - buffer@5.7.1: - resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} - - builder-util-runtime@9.2.4: - resolution: {integrity: sha512-upp+biKpN/XZMLim7aguUyW8s0FUpDvOtK6sbanMFDAMBzpHDqdhgVYm6zc9HJ6nWo7u2Lxk60i2M6Jd3aiNrA==} - engines: {node: '>=12.0.0'} - - builder-util@24.13.1: - resolution: {integrity: sha512-NhbCSIntruNDTOVI9fdXz0dihaqX2YuE1D6zZMrwiErzH4ELZHE6mdiB40wEgZNprDia+FghRFgKoAqMZRRjSA==} - - cacheable-lookup@5.0.4: - resolution: {integrity: sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==} - engines: {node: '>=10.6.0'} - - cacheable-request@7.0.4: - resolution: {integrity: sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==} - engines: {node: '>=8'} - - call-bind-apply-helpers@1.0.2: - resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} - engines: {node: '>= 0.4'} - - chalk@4.1.2: - resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} - engines: {node: '>=10'} - - chownr@2.0.0: - resolution: {integrity: sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==} - engines: {node: '>=10'} - - chromium-pickle-js@0.2.0: - resolution: {integrity: sha512-1R5Fho+jBq0DDydt+/vHWj5KJNJCKdARKOCwZUen84I5BreWoLqRLANH1U87eJy1tiASPtMnGqJJq0ZsLoRPOw==} - - ci-info@3.9.0: - resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==} - engines: {node: '>=8'} - - cli-truncate@2.1.0: - resolution: {integrity: sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==} - engines: {node: '>=8'} - - cliui@8.0.1: - resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} - engines: {node: '>=12'} - - clone-response@1.0.3: - resolution: {integrity: sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==} - - color-convert@2.0.1: - resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} - engines: {node: '>=7.0.0'} - - color-name@1.1.4: - resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} - - combined-stream@1.0.8: - resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} - engines: {node: '>= 0.8'} - - commander@5.1.0: - resolution: {integrity: sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==} - engines: {node: '>= 6'} - - compare-version@0.1.2: - resolution: {integrity: sha512-pJDh5/4wrEnXX/VWRZvruAGHkzKdr46z11OlTPN+VrATlWWhSKewNCJ1futCO5C7eJB3nPMFZA1LeYtcFboZ2A==} - engines: {node: '>=0.10.0'} - - compress-commons@4.1.2: - resolution: {integrity: sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==} - engines: {node: '>= 10'} - - concat-map@0.0.1: - resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} - - config-file-ts@0.2.6: - resolution: {integrity: sha512-6boGVaglwblBgJqGyxm4+xCmEGcWgnWHSWHY5jad58awQhB6gftq0G8HbzU39YqCIYHMLAiL1yjwiZ36m/CL8w==} - - core-util-is@1.0.2: - resolution: {integrity: sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==} - - core-util-is@1.0.3: - resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} - - crc-32@1.2.2: - resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==} - engines: {node: '>=0.8'} - hasBin: true - - crc32-stream@4.0.3: - resolution: {integrity: sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==} - engines: {node: '>= 10'} - - crc@3.8.0: - resolution: {integrity: sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ==} - - cross-spawn@7.0.6: - resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} - engines: {node: '>= 8'} - - debug@4.4.3: - resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} - engines: {node: '>=6.0'} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - - decompress-response@6.0.0: - resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} - engines: {node: '>=10'} - - defer-to-connect@2.0.1: - resolution: {integrity: sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==} - engines: {node: '>=10'} - - define-data-property@1.1.4: - resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} - engines: {node: '>= 0.4'} - - define-properties@1.2.1: - resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} - engines: {node: '>= 0.4'} - - delayed-stream@1.0.0: - resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} - engines: {node: '>=0.4.0'} - - detect-node@2.1.0: - resolution: {integrity: sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==} - - dir-compare@3.3.0: - resolution: {integrity: sha512-J7/et3WlGUCxjdnD3HAAzQ6nsnc0WL6DD7WcwJb7c39iH1+AWfg+9OqzJNaI6PkBwBvm1mhZNL9iY/nRiZXlPg==} - - dmg-builder@24.13.3: - resolution: {integrity: sha512-rcJUkMfnJpfCboZoOOPf4L29TRtEieHNOeAbYPWPxlaBw/Z1RKrRA86dOI9rwaI4tQSc/RD82zTNHprfUHXsoQ==} - - dmg-license@1.0.11: - resolution: {integrity: sha512-ZdzmqwKmECOWJpqefloC5OJy1+WZBBse5+MR88z9g9Zn4VY+WYUkAyojmhzJckH5YbbZGcYIuGAkY5/Ys5OM2Q==} - engines: {node: '>=8'} - os: [darwin] - hasBin: true - - dotenv-expand@5.1.0: - resolution: {integrity: sha512-YXQl1DSa4/PQyRfgrv6aoNjhasp/p4qs9FjJ4q4cQk+8m4r6k4ZSiEyytKG8f8W9gi8WsQtIObNmKd+tMzNTmA==} - - dotenv@9.0.2: - resolution: {integrity: sha512-I9OvvrHp4pIARv4+x9iuewrWycX6CcZtoAu1XrzPxc5UygMJXJZYmBsynku8IkrJwgypE5DGNjDPmPRhDCptUg==} - engines: {node: '>=10'} - - dunder-proto@1.0.1: - resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} - engines: {node: '>= 0.4'} - - eastasianwidth@0.2.0: - resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} - - ejs@3.1.10: - resolution: {integrity: sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==} - engines: {node: '>=0.10.0'} - hasBin: true - - electron-builder-squirrel-windows@24.13.3: - resolution: {integrity: sha512-oHkV0iogWfyK+ah9ZIvMDpei1m9ZRpdXcvde1wTpra2U8AFDNNpqJdnin5z+PM1GbQ5BoaKCWas2HSjtR0HwMg==} - - electron-builder@24.13.3: - resolution: {integrity: sha512-yZSgVHft5dNVlo31qmJAe4BVKQfFdwpRw7sFp1iQglDRCDD6r22zfRJuZlhtB5gp9FHUxCMEoWGq10SkCnMAIg==} - engines: {node: '>=14.0.0'} - hasBin: true - - electron-publish@24.13.1: - resolution: {integrity: sha512-2ZgdEqJ8e9D17Hwp5LEq5mLQPjqU3lv/IALvgp+4W8VeNhryfGhYEQC/PgDPMrnWUp+l60Ou5SJLsu+k4mhQ8A==} - - electron@40.3.0: - resolution: {integrity: sha512-ZaDkTZpNHr863tyZHieoqbaiLI0e3RVCXoEC5y1Ld70/Q5H1mPV9d5TK0h1dWtaSFVOW0w8iDvtdLwAXtasXpg==} - engines: {node: '>= 12.20.55'} - hasBin: true - - emoji-regex@8.0.0: - resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} - - emoji-regex@9.2.2: - resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} - - end-of-stream@1.4.5: - resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} - - env-paths@2.2.1: - resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} - engines: {node: '>=6'} - - err-code@2.0.3: - resolution: {integrity: sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==} - - es-define-property@1.0.1: - resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} - engines: {node: '>= 0.4'} - - es-errors@1.3.0: - resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} - engines: {node: '>= 0.4'} - - es-object-atoms@1.1.1: - resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} - engines: {node: '>= 0.4'} - - es-set-tostringtag@2.1.0: - resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} - engines: {node: '>= 0.4'} - - es6-error@4.1.1: - resolution: {integrity: sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==} - - escalade@3.2.0: - resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} - engines: {node: '>=6'} - - escape-string-regexp@4.0.0: - resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} - engines: {node: '>=10'} - - extract-zip@2.0.1: - resolution: {integrity: sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==} - engines: {node: '>= 10.17.0'} - hasBin: true - - extsprintf@1.4.1: - resolution: {integrity: sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA==} - engines: {'0': node >=0.6.0} - - fast-deep-equal@3.1.3: - resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} - - fast-json-stable-stringify@2.1.0: - resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} - - fd-slicer@1.1.0: - resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==} - - filelist@1.0.4: - resolution: {integrity: sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==} - - foreground-child@3.3.1: - resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} - engines: {node: '>=14'} - - form-data@4.0.5: - resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} - engines: {node: '>= 6'} - - fs-constants@1.0.0: - resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} - - fs-extra@10.1.0: - resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==} - engines: {node: '>=12'} - - fs-extra@8.1.0: - resolution: {integrity: sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==} - engines: {node: '>=6 <7 || >=8'} - - fs-extra@9.1.0: - resolution: {integrity: sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==} - engines: {node: '>=10'} - - fs-minipass@2.1.0: - resolution: {integrity: sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==} - engines: {node: '>= 8'} - - fs.realpath@1.0.0: - resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} - - function-bind@1.1.2: - resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} - - get-caller-file@2.0.5: - resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} - engines: {node: 6.* || 8.* || >= 10.*} - - get-intrinsic@1.3.0: - resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} - engines: {node: '>= 0.4'} - - get-proto@1.0.1: - resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} - engines: {node: '>= 0.4'} - - get-stream@5.2.0: - resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==} - engines: {node: '>=8'} - - glob@10.5.0: - resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} - deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me - hasBin: true - - glob@7.2.3: - resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me - - global-agent@3.0.0: - resolution: {integrity: sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==} - engines: {node: '>=10.0'} - - globalthis@1.0.4: - resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} - engines: {node: '>= 0.4'} - - gopd@1.2.0: - resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} - engines: {node: '>= 0.4'} - - got@11.8.6: - resolution: {integrity: sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==} - engines: {node: '>=10.19.0'} - - graceful-fs@4.2.11: - resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - - has-flag@4.0.0: - resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} - engines: {node: '>=8'} - - has-property-descriptors@1.0.2: - resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} - - has-symbols@1.1.0: - resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} - engines: {node: '>= 0.4'} - - has-tostringtag@1.0.2: - resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} - engines: {node: '>= 0.4'} - - hasown@2.0.2: - resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} - engines: {node: '>= 0.4'} - - hosted-git-info@4.1.0: - resolution: {integrity: sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==} - engines: {node: '>=10'} - - http-cache-semantics@4.2.0: - resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==} - - http-proxy-agent@5.0.0: - resolution: {integrity: sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==} - engines: {node: '>= 6'} - - http2-wrapper@1.0.3: - resolution: {integrity: sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==} - engines: {node: '>=10.19.0'} - - https-proxy-agent@5.0.1: - resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} - engines: {node: '>= 6'} - - iconv-corefoundation@1.1.7: - resolution: {integrity: sha512-T10qvkw0zz4wnm560lOEg0PovVqUXuOFhhHAkixw8/sycy7TJt7v/RrkEKEQnAw2viPSJu6iAkErxnzR0g8PpQ==} - engines: {node: ^8.11.2 || >=10} - os: [darwin] - - iconv-lite@0.6.3: - resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} - engines: {node: '>=0.10.0'} - - ieee754@1.2.1: - resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} - - inflight@1.0.6: - resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} - deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. - - inherits@2.0.4: - resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} - - is-ci@3.0.1: - resolution: {integrity: sha512-ZYvCgrefwqoQ6yTyYUbQu64HsITZ3NfKX1lzaEYdkTDcfKzzCI/wthRRYKkdjHKFVgNiXKAKm65Zo1pk2as/QQ==} - hasBin: true - - is-fullwidth-code-point@3.0.0: - resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} - engines: {node: '>=8'} - - isarray@1.0.0: - resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} - - isbinaryfile@4.0.10: - resolution: {integrity: sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==} - engines: {node: '>= 8.0.0'} - - isbinaryfile@5.0.7: - resolution: {integrity: sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ==} - engines: {node: '>= 18.0.0'} - - isexe@2.0.0: - resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - - jackspeak@3.4.3: - resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} - - jake@10.9.4: - resolution: {integrity: sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==} - engines: {node: '>=10'} - hasBin: true - - js-yaml@4.1.1: - resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} - hasBin: true - - json-buffer@3.0.1: - resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} - - json-schema-traverse@0.4.1: - resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} - - json-stringify-safe@5.0.1: - resolution: {integrity: sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==} - - json5@2.2.3: - resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} - engines: {node: '>=6'} - hasBin: true - - jsonfile@4.0.0: - resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==} - - jsonfile@6.2.0: - resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==} - - keyv@4.5.4: - resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} - - lazy-val@1.0.5: - resolution: {integrity: sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==} - - lazystream@1.0.1: - resolution: {integrity: sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==} - engines: {node: '>= 0.6.3'} - - lodash.defaults@4.2.0: - resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==} - - lodash.difference@4.5.0: - resolution: {integrity: sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==} - - lodash.flatten@4.4.0: - resolution: {integrity: sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==} - - lodash.isplainobject@4.0.6: - resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} - - lodash.union@4.6.0: - resolution: {integrity: sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==} - - lodash@4.17.23: - resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==} - - lowercase-keys@2.0.0: - resolution: {integrity: sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==} - engines: {node: '>=8'} - - lru-cache@10.4.3: - resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} - - lru-cache@6.0.0: - resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} - engines: {node: '>=10'} - - matcher@3.0.0: - resolution: {integrity: sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==} - engines: {node: '>=10'} - - math-intrinsics@1.1.0: - resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} - engines: {node: '>= 0.4'} - - mime-db@1.52.0: - resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} - engines: {node: '>= 0.6'} - - mime-types@2.1.35: - resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} - engines: {node: '>= 0.6'} - - mime@2.6.0: - resolution: {integrity: sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==} - engines: {node: '>=4.0.0'} - hasBin: true - - mimic-response@1.0.1: - resolution: {integrity: sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==} - engines: {node: '>=4'} - - mimic-response@3.1.0: - resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} - engines: {node: '>=10'} - - minimatch@3.1.2: - resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} - - minimatch@5.1.6: - resolution: {integrity: sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==} - engines: {node: '>=10'} - - minimatch@9.0.5: - resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} - engines: {node: '>=16 || 14 >=14.17'} - - minimist@1.2.8: - resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} - - minipass@3.3.6: - resolution: {integrity: sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==} - engines: {node: '>=8'} - - minipass@5.0.0: - resolution: {integrity: sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==} - engines: {node: '>=8'} - - minipass@7.1.2: - resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} - engines: {node: '>=16 || 14 >=14.17'} - - minizlib@2.1.2: - resolution: {integrity: sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==} - engines: {node: '>= 8'} - - mkdirp@1.0.4: - resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==} - engines: {node: '>=10'} - hasBin: true - - ms@2.1.3: - resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - - node-addon-api@1.7.2: - resolution: {integrity: sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg==} - - normalize-path@3.0.0: - resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} - engines: {node: '>=0.10.0'} - - normalize-url@6.1.0: - resolution: {integrity: sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==} - engines: {node: '>=10'} - - object-keys@1.1.1: - resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} - engines: {node: '>= 0.4'} - - once@1.4.0: - resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} - - p-cancelable@2.1.1: - resolution: {integrity: sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==} - engines: {node: '>=8'} - - package-json-from-dist@1.0.1: - resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} - - path-is-absolute@1.0.1: - resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} - engines: {node: '>=0.10.0'} - - path-key@3.1.1: - resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} - engines: {node: '>=8'} - - path-scurry@1.11.1: - resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} - engines: {node: '>=16 || 14 >=14.18'} - - pend@1.2.0: - resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} - - picocolors@1.1.1: - resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - - plist@3.1.0: - resolution: {integrity: sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==} - engines: {node: '>=10.4.0'} - - process-nextick-args@2.0.1: - resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} - - progress@2.0.3: - resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} - engines: {node: '>=0.4.0'} - - promise-retry@2.0.1: - resolution: {integrity: sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==} - engines: {node: '>=10'} - - pump@3.0.3: - resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==} - - punycode@2.3.1: - resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} - engines: {node: '>=6'} - - quick-lru@5.1.1: - resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==} - engines: {node: '>=10'} - - read-config-file@6.3.2: - resolution: {integrity: sha512-M80lpCjnE6Wt6zb98DoW8WHR09nzMSpu8XHtPkiTHrJ5Az9CybfeQhTJ8D7saeBHpGhLPIVyA8lcL6ZmdKwY6Q==} - engines: {node: '>=12.0.0'} - - readable-stream@2.3.8: - resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} - - readable-stream@3.6.2: - resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} - engines: {node: '>= 6'} - - readdir-glob@1.1.3: - resolution: {integrity: sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==} - - require-directory@2.1.1: - resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} - engines: {node: '>=0.10.0'} - - resolve-alpn@1.2.1: - resolution: {integrity: sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==} - - responselike@2.0.1: - resolution: {integrity: sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==} - - retry@0.12.0: - resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} - engines: {node: '>= 4'} - - roarr@2.15.4: - resolution: {integrity: sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==} - engines: {node: '>=8.0'} - - safe-buffer@5.1.2: - resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} - - safe-buffer@5.2.1: - resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} - - safer-buffer@2.1.2: - resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} - - sanitize-filename@1.6.3: - resolution: {integrity: sha512-y/52Mcy7aw3gRm7IrcGDFx/bCk4AhRh2eI9luHOQM86nZsqwiRkkq2GekHXBBD+SmPidc8i2PqtYZl+pWJ8Oeg==} - - sax@1.4.4: - resolution: {integrity: sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw==} - engines: {node: '>=11.0.0'} - - semver-compare@1.0.0: - resolution: {integrity: sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==} - - semver@6.3.1: - resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} - hasBin: true - - semver@7.7.4: - resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} - engines: {node: '>=10'} - hasBin: true - - serialize-error@7.0.1: - resolution: {integrity: sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==} - engines: {node: '>=10'} - - shebang-command@2.0.0: - resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} - engines: {node: '>=8'} - - shebang-regex@3.0.0: - resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} - engines: {node: '>=8'} - - signal-exit@4.1.0: - resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} - engines: {node: '>=14'} - - simple-update-notifier@2.0.0: - resolution: {integrity: sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==} - engines: {node: '>=10'} - - slice-ansi@3.0.0: - resolution: {integrity: sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ==} - engines: {node: '>=8'} - - smart-buffer@4.2.0: - resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} - engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} - - source-map-support@0.5.21: - resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} - - source-map@0.6.1: - resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} - engines: {node: '>=0.10.0'} - - sprintf-js@1.1.3: - resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==} - - stat-mode@1.0.0: - resolution: {integrity: sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg==} - engines: {node: '>= 6'} - - string-width@4.2.3: - resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} - engines: {node: '>=8'} - - string-width@5.1.2: - resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} - engines: {node: '>=12'} - - string_decoder@1.1.1: - resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} - - string_decoder@1.3.0: - resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} - - strip-ansi@6.0.1: - resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} - engines: {node: '>=8'} - - strip-ansi@7.1.2: - resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==} - engines: {node: '>=12'} - - sumchecker@3.0.1: - resolution: {integrity: sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==} - engines: {node: '>= 8.0'} - - supports-color@7.2.0: - resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} - engines: {node: '>=8'} - - tar-stream@2.2.0: - resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} - engines: {node: '>=6'} - - tar@6.2.1: - resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==} - engines: {node: '>=10'} - deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me - - temp-file@3.4.0: - resolution: {integrity: sha512-C5tjlC/HCtVUOi3KWVokd4vHVViOmGjtLwIh4MuzPo/nMYTV/p1urt3RnMz2IWXDdKEGJH3k5+KPxtqRsUYGtg==} - - tmp-promise@3.0.3: - resolution: {integrity: sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==} - - tmp@0.2.5: - resolution: {integrity: sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==} - engines: {node: '>=14.14'} - - truncate-utf8-bytes@1.0.2: - resolution: {integrity: sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ==} - - type-fest@0.13.1: - resolution: {integrity: sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==} - engines: {node: '>=10'} - - typescript@5.9.3: - resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} - engines: {node: '>=14.17'} - hasBin: true - - undici-types@7.16.0: - resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} - - universalify@0.1.2: - resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} - engines: {node: '>= 4.0.0'} - - universalify@2.0.1: - resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} - engines: {node: '>= 10.0.0'} - - uri-js@4.4.1: - resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} - - utf8-byte-length@1.0.5: - resolution: {integrity: sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA==} - - util-deprecate@1.0.2: - resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} - - verror@1.10.1: - resolution: {integrity: sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg==} - engines: {node: '>=0.6.0'} - - which@2.0.2: - resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} - engines: {node: '>= 8'} - hasBin: true - - wrap-ansi@7.0.0: - resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} - engines: {node: '>=10'} - - wrap-ansi@8.1.0: - resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} - engines: {node: '>=12'} - - wrappy@1.0.2: - resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} - - xmlbuilder@15.1.1: - resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==} - engines: {node: '>=8.0'} - - y18n@5.0.8: - resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} - engines: {node: '>=10'} - - yallist@4.0.0: - resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} - - yargs-parser@21.1.1: - resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} - engines: {node: '>=12'} - - yargs@17.7.2: - resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} - engines: {node: '>=12'} - - yauzl@2.10.0: - resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==} - - zip-stream@4.1.1: - resolution: {integrity: sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==} - engines: {node: '>= 10'} - -snapshots: - - 7zip-bin@5.2.0: {} - - '@develar/schema-utils@2.6.5': - dependencies: - ajv: 6.12.6 - ajv-keywords: 3.5.2(ajv@6.12.6) - - '@electron/asar@3.4.1': - dependencies: - commander: 5.1.0 - glob: 7.2.3 - minimatch: 3.1.2 - - '@electron/get@2.0.3': - dependencies: - debug: 4.4.3 - env-paths: 2.2.1 - fs-extra: 8.1.0 - got: 11.8.6 - progress: 2.0.3 - semver: 6.3.1 - sumchecker: 3.0.1 - optionalDependencies: - global-agent: 3.0.0 - transitivePeerDependencies: - - supports-color - - '@electron/notarize@2.2.1': - dependencies: - debug: 4.4.3 - fs-extra: 9.1.0 - promise-retry: 2.0.1 - transitivePeerDependencies: - - supports-color - - '@electron/osx-sign@1.0.5': - dependencies: - compare-version: 0.1.2 - debug: 4.4.3 - fs-extra: 10.1.0 - isbinaryfile: 4.0.10 - minimist: 1.2.8 - plist: 3.1.0 - transitivePeerDependencies: - - supports-color - - '@electron/universal@1.5.1': - dependencies: - '@electron/asar': 3.4.1 - '@malept/cross-spawn-promise': 1.1.1 - debug: 4.4.3 - dir-compare: 3.3.0 - fs-extra: 9.1.0 - minimatch: 3.1.2 - plist: 3.1.0 - transitivePeerDependencies: - - supports-color - - '@isaacs/cliui@8.0.2': - dependencies: - string-width: 5.1.2 - string-width-cjs: string-width@4.2.3 - strip-ansi: 7.1.2 - strip-ansi-cjs: strip-ansi@6.0.1 - wrap-ansi: 8.1.0 - wrap-ansi-cjs: wrap-ansi@7.0.0 - - '@malept/cross-spawn-promise@1.1.1': - dependencies: - cross-spawn: 7.0.6 - - '@malept/flatpak-bundler@0.4.0': - dependencies: - debug: 4.4.3 - fs-extra: 9.1.0 - lodash: 4.17.23 - tmp-promise: 3.0.3 - transitivePeerDependencies: - - supports-color - - '@pkgjs/parseargs@0.11.0': - optional: true - - '@sindresorhus/is@4.6.0': {} - - '@szmarczak/http-timer@4.0.6': - dependencies: - defer-to-connect: 2.0.1 - - '@tootallnate/once@2.0.0': {} - - '@types/cacheable-request@6.0.3': - dependencies: - '@types/http-cache-semantics': 4.2.0 - '@types/keyv': 3.1.4 - '@types/node': 25.2.2 - '@types/responselike': 1.0.3 - - '@types/debug@4.1.12': - dependencies: - '@types/ms': 2.1.0 - - '@types/fs-extra@9.0.13': - dependencies: - '@types/node': 25.2.2 - - '@types/http-cache-semantics@4.2.0': {} - - '@types/keyv@3.1.4': - dependencies: - '@types/node': 25.2.2 - - '@types/ms@2.1.0': {} - - '@types/node@24.10.13': - dependencies: - undici-types: 7.16.0 - - '@types/node@25.2.2': - dependencies: - undici-types: 7.16.0 - - '@types/plist@3.0.5': - dependencies: - '@types/node': 25.2.2 - xmlbuilder: 15.1.1 - optional: true - - '@types/responselike@1.0.3': - dependencies: - '@types/node': 25.2.2 - - '@types/verror@1.10.11': - optional: true - - '@types/yauzl@2.10.3': - dependencies: - '@types/node': 25.2.2 - optional: true - - '@xmldom/xmldom@0.8.11': {} - - agent-base@6.0.2: - dependencies: - debug: 4.4.3 - transitivePeerDependencies: - - supports-color - - ajv-keywords@3.5.2(ajv@6.12.6): - dependencies: - ajv: 6.12.6 - - ajv@6.12.6: - dependencies: - fast-deep-equal: 3.1.3 - fast-json-stable-stringify: 2.1.0 - json-schema-traverse: 0.4.1 - uri-js: 4.4.1 - - ansi-regex@5.0.1: {} - - ansi-regex@6.2.2: {} - - ansi-styles@4.3.0: - dependencies: - color-convert: 2.0.1 - - ansi-styles@6.2.3: {} - - app-builder-bin@4.0.0: {} - - app-builder-lib@24.13.3(dmg-builder@24.13.3)(electron-builder-squirrel-windows@24.13.3): - dependencies: - '@develar/schema-utils': 2.6.5 - '@electron/notarize': 2.2.1 - '@electron/osx-sign': 1.0.5 - '@electron/universal': 1.5.1 - '@malept/flatpak-bundler': 0.4.0 - '@types/fs-extra': 9.0.13 - async-exit-hook: 2.0.1 - bluebird-lst: 1.0.9 - builder-util: 24.13.1 - builder-util-runtime: 9.2.4 - chromium-pickle-js: 0.2.0 - debug: 4.4.3 - dmg-builder: 24.13.3(electron-builder-squirrel-windows@24.13.3) - ejs: 3.1.10 - electron-builder-squirrel-windows: 24.13.3(dmg-builder@24.13.3) - electron-publish: 24.13.1 - form-data: 4.0.5 - fs-extra: 10.1.0 - hosted-git-info: 4.1.0 - is-ci: 3.0.1 - isbinaryfile: 5.0.7 - js-yaml: 4.1.1 - lazy-val: 1.0.5 - minimatch: 5.1.6 - read-config-file: 6.3.2 - sanitize-filename: 1.6.3 - semver: 7.7.4 - tar: 6.2.1 - temp-file: 3.4.0 - transitivePeerDependencies: - - supports-color - - archiver-utils@2.1.0: - dependencies: - glob: 7.2.3 - graceful-fs: 4.2.11 - lazystream: 1.0.1 - lodash.defaults: 4.2.0 - lodash.difference: 4.5.0 - lodash.flatten: 4.4.0 - lodash.isplainobject: 4.0.6 - lodash.union: 4.6.0 - normalize-path: 3.0.0 - readable-stream: 2.3.8 - - archiver-utils@3.0.4: - dependencies: - glob: 7.2.3 - graceful-fs: 4.2.11 - lazystream: 1.0.1 - lodash.defaults: 4.2.0 - lodash.difference: 4.5.0 - lodash.flatten: 4.4.0 - lodash.isplainobject: 4.0.6 - lodash.union: 4.6.0 - normalize-path: 3.0.0 - readable-stream: 3.6.2 - - archiver@5.3.2: - dependencies: - archiver-utils: 2.1.0 - async: 3.2.6 - buffer-crc32: 0.2.13 - readable-stream: 3.6.2 - readdir-glob: 1.1.3 - tar-stream: 2.2.0 - zip-stream: 4.1.1 - - argparse@2.0.1: {} - - assert-plus@1.0.0: - optional: true - - astral-regex@2.0.0: - optional: true - - async-exit-hook@2.0.1: {} - - async@3.2.6: {} - - asynckit@0.4.0: {} - - at-least-node@1.0.0: {} - - balanced-match@1.0.2: {} - - base64-js@1.5.1: {} - - bl@4.1.0: - dependencies: - buffer: 5.7.1 - inherits: 2.0.4 - readable-stream: 3.6.2 - - bluebird-lst@1.0.9: - dependencies: - bluebird: 3.7.2 - - bluebird@3.7.2: {} - - boolean@3.2.0: - optional: true - - brace-expansion@1.1.12: - dependencies: - balanced-match: 1.0.2 - concat-map: 0.0.1 - - brace-expansion@2.0.2: - dependencies: - balanced-match: 1.0.2 - - buffer-crc32@0.2.13: {} - - buffer-equal@1.0.1: {} - - buffer-from@1.1.2: {} - - buffer@5.7.1: - dependencies: - base64-js: 1.5.1 - ieee754: 1.2.1 - - builder-util-runtime@9.2.4: - dependencies: - debug: 4.4.3 - sax: 1.4.4 - transitivePeerDependencies: - - supports-color - - builder-util@24.13.1: - dependencies: - 7zip-bin: 5.2.0 - '@types/debug': 4.1.12 - app-builder-bin: 4.0.0 - bluebird-lst: 1.0.9 - builder-util-runtime: 9.2.4 - chalk: 4.1.2 - cross-spawn: 7.0.6 - debug: 4.4.3 - fs-extra: 10.1.0 - http-proxy-agent: 5.0.0 - https-proxy-agent: 5.0.1 - is-ci: 3.0.1 - js-yaml: 4.1.1 - source-map-support: 0.5.21 - stat-mode: 1.0.0 - temp-file: 3.4.0 - transitivePeerDependencies: - - supports-color - - cacheable-lookup@5.0.4: {} - - cacheable-request@7.0.4: - dependencies: - clone-response: 1.0.3 - get-stream: 5.2.0 - http-cache-semantics: 4.2.0 - keyv: 4.5.4 - lowercase-keys: 2.0.0 - normalize-url: 6.1.0 - responselike: 2.0.1 - - call-bind-apply-helpers@1.0.2: - dependencies: - es-errors: 1.3.0 - function-bind: 1.1.2 - - chalk@4.1.2: - dependencies: - ansi-styles: 4.3.0 - supports-color: 7.2.0 - - chownr@2.0.0: {} - - chromium-pickle-js@0.2.0: {} - - ci-info@3.9.0: {} - - cli-truncate@2.1.0: - dependencies: - slice-ansi: 3.0.0 - string-width: 4.2.3 - optional: true - - cliui@8.0.1: - dependencies: - string-width: 4.2.3 - strip-ansi: 6.0.1 - wrap-ansi: 7.0.0 - - clone-response@1.0.3: - dependencies: - mimic-response: 1.0.1 - - color-convert@2.0.1: - dependencies: - color-name: 1.1.4 - - color-name@1.1.4: {} - - combined-stream@1.0.8: - dependencies: - delayed-stream: 1.0.0 - - commander@5.1.0: {} - - compare-version@0.1.2: {} - - compress-commons@4.1.2: - dependencies: - buffer-crc32: 0.2.13 - crc32-stream: 4.0.3 - normalize-path: 3.0.0 - readable-stream: 3.6.2 - - concat-map@0.0.1: {} - - config-file-ts@0.2.6: - dependencies: - glob: 10.5.0 - typescript: 5.9.3 - - core-util-is@1.0.2: - optional: true - - core-util-is@1.0.3: {} - - crc-32@1.2.2: {} - - crc32-stream@4.0.3: - dependencies: - crc-32: 1.2.2 - readable-stream: 3.6.2 - - crc@3.8.0: - dependencies: - buffer: 5.7.1 - optional: true - - cross-spawn@7.0.6: - dependencies: - path-key: 3.1.1 - shebang-command: 2.0.0 - which: 2.0.2 - - debug@4.4.3: - dependencies: - ms: 2.1.3 - - decompress-response@6.0.0: - dependencies: - mimic-response: 3.1.0 - - defer-to-connect@2.0.1: {} - - define-data-property@1.1.4: - dependencies: - es-define-property: 1.0.1 - es-errors: 1.3.0 - gopd: 1.2.0 - optional: true - - define-properties@1.2.1: - dependencies: - define-data-property: 1.1.4 - has-property-descriptors: 1.0.2 - object-keys: 1.1.1 - optional: true - - delayed-stream@1.0.0: {} - - detect-node@2.1.0: - optional: true - - dir-compare@3.3.0: - dependencies: - buffer-equal: 1.0.1 - minimatch: 3.1.2 - - dmg-builder@24.13.3(electron-builder-squirrel-windows@24.13.3): - dependencies: - app-builder-lib: 24.13.3(dmg-builder@24.13.3)(electron-builder-squirrel-windows@24.13.3) - builder-util: 24.13.1 - builder-util-runtime: 9.2.4 - fs-extra: 10.1.0 - iconv-lite: 0.6.3 - js-yaml: 4.1.1 - optionalDependencies: - dmg-license: 1.0.11 - transitivePeerDependencies: - - electron-builder-squirrel-windows - - supports-color - - dmg-license@1.0.11: - dependencies: - '@types/plist': 3.0.5 - '@types/verror': 1.10.11 - ajv: 6.12.6 - crc: 3.8.0 - iconv-corefoundation: 1.1.7 - plist: 3.1.0 - smart-buffer: 4.2.0 - verror: 1.10.1 - optional: true - - dotenv-expand@5.1.0: {} - - dotenv@9.0.2: {} - - dunder-proto@1.0.1: - dependencies: - call-bind-apply-helpers: 1.0.2 - es-errors: 1.3.0 - gopd: 1.2.0 - - eastasianwidth@0.2.0: {} - - ejs@3.1.10: - dependencies: - jake: 10.9.4 - - electron-builder-squirrel-windows@24.13.3(dmg-builder@24.13.3): - dependencies: - app-builder-lib: 24.13.3(dmg-builder@24.13.3)(electron-builder-squirrel-windows@24.13.3) - archiver: 5.3.2 - builder-util: 24.13.1 - fs-extra: 10.1.0 - transitivePeerDependencies: - - dmg-builder - - supports-color - - electron-builder@24.13.3(electron-builder-squirrel-windows@24.13.3): - dependencies: - app-builder-lib: 24.13.3(dmg-builder@24.13.3)(electron-builder-squirrel-windows@24.13.3) - builder-util: 24.13.1 - builder-util-runtime: 9.2.4 - chalk: 4.1.2 - dmg-builder: 24.13.3(electron-builder-squirrel-windows@24.13.3) - fs-extra: 10.1.0 - is-ci: 3.0.1 - lazy-val: 1.0.5 - read-config-file: 6.3.2 - simple-update-notifier: 2.0.0 - yargs: 17.7.2 - transitivePeerDependencies: - - electron-builder-squirrel-windows - - supports-color - - electron-publish@24.13.1: - dependencies: - '@types/fs-extra': 9.0.13 - builder-util: 24.13.1 - builder-util-runtime: 9.2.4 - chalk: 4.1.2 - fs-extra: 10.1.0 - lazy-val: 1.0.5 - mime: 2.6.0 - transitivePeerDependencies: - - supports-color - - electron@40.3.0: - dependencies: - '@electron/get': 2.0.3 - '@types/node': 24.10.13 - extract-zip: 2.0.1 - transitivePeerDependencies: - - supports-color - - emoji-regex@8.0.0: {} - - emoji-regex@9.2.2: {} - - end-of-stream@1.4.5: - dependencies: - once: 1.4.0 - - env-paths@2.2.1: {} - - err-code@2.0.3: {} - - es-define-property@1.0.1: {} - - es-errors@1.3.0: {} - - es-object-atoms@1.1.1: - dependencies: - es-errors: 1.3.0 - - es-set-tostringtag@2.1.0: - dependencies: - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - has-tostringtag: 1.0.2 - hasown: 2.0.2 - - es6-error@4.1.1: - optional: true - - escalade@3.2.0: {} - - escape-string-regexp@4.0.0: - optional: true - - extract-zip@2.0.1: - dependencies: - debug: 4.4.3 - get-stream: 5.2.0 - yauzl: 2.10.0 - optionalDependencies: - '@types/yauzl': 2.10.3 - transitivePeerDependencies: - - supports-color - - extsprintf@1.4.1: - optional: true - - fast-deep-equal@3.1.3: {} - - fast-json-stable-stringify@2.1.0: {} - - fd-slicer@1.1.0: - dependencies: - pend: 1.2.0 - - filelist@1.0.4: - dependencies: - minimatch: 5.1.6 - - foreground-child@3.3.1: - dependencies: - cross-spawn: 7.0.6 - signal-exit: 4.1.0 - - form-data@4.0.5: - dependencies: - asynckit: 0.4.0 - combined-stream: 1.0.8 - es-set-tostringtag: 2.1.0 - hasown: 2.0.2 - mime-types: 2.1.35 - - fs-constants@1.0.0: {} - - fs-extra@10.1.0: - dependencies: - graceful-fs: 4.2.11 - jsonfile: 6.2.0 - universalify: 2.0.1 - - fs-extra@8.1.0: - dependencies: - graceful-fs: 4.2.11 - jsonfile: 4.0.0 - universalify: 0.1.2 - - fs-extra@9.1.0: - dependencies: - at-least-node: 1.0.0 - graceful-fs: 4.2.11 - jsonfile: 6.2.0 - universalify: 2.0.1 - - fs-minipass@2.1.0: - dependencies: - minipass: 3.3.6 - - fs.realpath@1.0.0: {} - - function-bind@1.1.2: {} - - get-caller-file@2.0.5: {} - - get-intrinsic@1.3.0: - dependencies: - call-bind-apply-helpers: 1.0.2 - es-define-property: 1.0.1 - es-errors: 1.3.0 - es-object-atoms: 1.1.1 - function-bind: 1.1.2 - get-proto: 1.0.1 - gopd: 1.2.0 - has-symbols: 1.1.0 - hasown: 2.0.2 - math-intrinsics: 1.1.0 - - get-proto@1.0.1: - dependencies: - dunder-proto: 1.0.1 - es-object-atoms: 1.1.1 - - get-stream@5.2.0: - dependencies: - pump: 3.0.3 - - glob@10.5.0: - dependencies: - foreground-child: 3.3.1 - jackspeak: 3.4.3 - minimatch: 9.0.5 - minipass: 7.1.2 - package-json-from-dist: 1.0.1 - path-scurry: 1.11.1 - - glob@7.2.3: - dependencies: - fs.realpath: 1.0.0 - inflight: 1.0.6 - inherits: 2.0.4 - minimatch: 3.1.2 - once: 1.4.0 - path-is-absolute: 1.0.1 - - global-agent@3.0.0: - dependencies: - boolean: 3.2.0 - es6-error: 4.1.1 - matcher: 3.0.0 - roarr: 2.15.4 - semver: 7.7.4 - serialize-error: 7.0.1 - optional: true - - globalthis@1.0.4: - dependencies: - define-properties: 1.2.1 - gopd: 1.2.0 - optional: true - - gopd@1.2.0: {} - - got@11.8.6: - dependencies: - '@sindresorhus/is': 4.6.0 - '@szmarczak/http-timer': 4.0.6 - '@types/cacheable-request': 6.0.3 - '@types/responselike': 1.0.3 - cacheable-lookup: 5.0.4 - cacheable-request: 7.0.4 - decompress-response: 6.0.0 - http2-wrapper: 1.0.3 - lowercase-keys: 2.0.0 - p-cancelable: 2.1.1 - responselike: 2.0.1 - - graceful-fs@4.2.11: {} - - has-flag@4.0.0: {} - - has-property-descriptors@1.0.2: - dependencies: - es-define-property: 1.0.1 - optional: true - - has-symbols@1.1.0: {} - - has-tostringtag@1.0.2: - dependencies: - has-symbols: 1.1.0 - - hasown@2.0.2: - dependencies: - function-bind: 1.1.2 - - hosted-git-info@4.1.0: - dependencies: - lru-cache: 6.0.0 - - http-cache-semantics@4.2.0: {} - - http-proxy-agent@5.0.0: - dependencies: - '@tootallnate/once': 2.0.0 - agent-base: 6.0.2 - debug: 4.4.3 - transitivePeerDependencies: - - supports-color - - http2-wrapper@1.0.3: - dependencies: - quick-lru: 5.1.1 - resolve-alpn: 1.2.1 - - https-proxy-agent@5.0.1: - dependencies: - agent-base: 6.0.2 - debug: 4.4.3 - transitivePeerDependencies: - - supports-color - - iconv-corefoundation@1.1.7: - dependencies: - cli-truncate: 2.1.0 - node-addon-api: 1.7.2 - optional: true - - iconv-lite@0.6.3: - dependencies: - safer-buffer: 2.1.2 - - ieee754@1.2.1: {} - - inflight@1.0.6: - dependencies: - once: 1.4.0 - wrappy: 1.0.2 - - inherits@2.0.4: {} - - is-ci@3.0.1: - dependencies: - ci-info: 3.9.0 - - is-fullwidth-code-point@3.0.0: {} - - isarray@1.0.0: {} - - isbinaryfile@4.0.10: {} - - isbinaryfile@5.0.7: {} - - isexe@2.0.0: {} - - jackspeak@3.4.3: - dependencies: - '@isaacs/cliui': 8.0.2 - optionalDependencies: - '@pkgjs/parseargs': 0.11.0 - - jake@10.9.4: - dependencies: - async: 3.2.6 - filelist: 1.0.4 - picocolors: 1.1.1 - - js-yaml@4.1.1: - dependencies: - argparse: 2.0.1 - - json-buffer@3.0.1: {} - - json-schema-traverse@0.4.1: {} - - json-stringify-safe@5.0.1: - optional: true - - json5@2.2.3: {} - - jsonfile@4.0.0: - optionalDependencies: - graceful-fs: 4.2.11 - - jsonfile@6.2.0: - dependencies: - universalify: 2.0.1 - optionalDependencies: - graceful-fs: 4.2.11 - - keyv@4.5.4: - dependencies: - json-buffer: 3.0.1 - - lazy-val@1.0.5: {} - - lazystream@1.0.1: - dependencies: - readable-stream: 2.3.8 - - lodash.defaults@4.2.0: {} - - lodash.difference@4.5.0: {} - - lodash.flatten@4.4.0: {} - - lodash.isplainobject@4.0.6: {} - - lodash.union@4.6.0: {} - - lodash@4.17.23: {} - - lowercase-keys@2.0.0: {} - - lru-cache@10.4.3: {} - - lru-cache@6.0.0: - dependencies: - yallist: 4.0.0 - - matcher@3.0.0: - dependencies: - escape-string-regexp: 4.0.0 - optional: true - - math-intrinsics@1.1.0: {} - - mime-db@1.52.0: {} - - mime-types@2.1.35: - dependencies: - mime-db: 1.52.0 - - mime@2.6.0: {} - - mimic-response@1.0.1: {} - - mimic-response@3.1.0: {} - - minimatch@3.1.2: - dependencies: - brace-expansion: 1.1.12 - - minimatch@5.1.6: - dependencies: - brace-expansion: 2.0.2 - - minimatch@9.0.5: - dependencies: - brace-expansion: 2.0.2 - - minimist@1.2.8: {} - - minipass@3.3.6: - dependencies: - yallist: 4.0.0 - - minipass@5.0.0: {} - - minipass@7.1.2: {} - - minizlib@2.1.2: - dependencies: - minipass: 3.3.6 - yallist: 4.0.0 - - mkdirp@1.0.4: {} - - ms@2.1.3: {} - - node-addon-api@1.7.2: - optional: true - - normalize-path@3.0.0: {} - - normalize-url@6.1.0: {} - - object-keys@1.1.1: - optional: true - - once@1.4.0: - dependencies: - wrappy: 1.0.2 - - p-cancelable@2.1.1: {} - - package-json-from-dist@1.0.1: {} - - path-is-absolute@1.0.1: {} - - path-key@3.1.1: {} - - path-scurry@1.11.1: - dependencies: - lru-cache: 10.4.3 - minipass: 7.1.2 - - pend@1.2.0: {} - - picocolors@1.1.1: {} - - plist@3.1.0: - dependencies: - '@xmldom/xmldom': 0.8.11 - base64-js: 1.5.1 - xmlbuilder: 15.1.1 - - process-nextick-args@2.0.1: {} - - progress@2.0.3: {} - - promise-retry@2.0.1: - dependencies: - err-code: 2.0.3 - retry: 0.12.0 - - pump@3.0.3: - dependencies: - end-of-stream: 1.4.5 - once: 1.4.0 - - punycode@2.3.1: {} - - quick-lru@5.1.1: {} - - read-config-file@6.3.2: - dependencies: - config-file-ts: 0.2.6 - dotenv: 9.0.2 - dotenv-expand: 5.1.0 - js-yaml: 4.1.1 - json5: 2.2.3 - lazy-val: 1.0.5 - - readable-stream@2.3.8: - dependencies: - core-util-is: 1.0.3 - inherits: 2.0.4 - isarray: 1.0.0 - process-nextick-args: 2.0.1 - safe-buffer: 5.1.2 - string_decoder: 1.1.1 - util-deprecate: 1.0.2 - - readable-stream@3.6.2: - dependencies: - inherits: 2.0.4 - string_decoder: 1.3.0 - util-deprecate: 1.0.2 - - readdir-glob@1.1.3: - dependencies: - minimatch: 5.1.6 - - require-directory@2.1.1: {} - - resolve-alpn@1.2.1: {} - - responselike@2.0.1: - dependencies: - lowercase-keys: 2.0.0 - - retry@0.12.0: {} - - roarr@2.15.4: - dependencies: - boolean: 3.2.0 - detect-node: 2.1.0 - globalthis: 1.0.4 - json-stringify-safe: 5.0.1 - semver-compare: 1.0.0 - sprintf-js: 1.1.3 - optional: true - - safe-buffer@5.1.2: {} - - safe-buffer@5.2.1: {} - - safer-buffer@2.1.2: {} - - sanitize-filename@1.6.3: - dependencies: - truncate-utf8-bytes: 1.0.2 - - sax@1.4.4: {} - - semver-compare@1.0.0: - optional: true - - semver@6.3.1: {} - - semver@7.7.4: {} - - serialize-error@7.0.1: - dependencies: - type-fest: 0.13.1 - optional: true - - shebang-command@2.0.0: - dependencies: - shebang-regex: 3.0.0 - - shebang-regex@3.0.0: {} - - signal-exit@4.1.0: {} - - simple-update-notifier@2.0.0: - dependencies: - semver: 7.7.4 - - slice-ansi@3.0.0: - dependencies: - ansi-styles: 4.3.0 - astral-regex: 2.0.0 - is-fullwidth-code-point: 3.0.0 - optional: true - - smart-buffer@4.2.0: - optional: true - - source-map-support@0.5.21: - dependencies: - buffer-from: 1.1.2 - source-map: 0.6.1 - - source-map@0.6.1: {} - - sprintf-js@1.1.3: - optional: true - - stat-mode@1.0.0: {} - - string-width@4.2.3: - dependencies: - emoji-regex: 8.0.0 - is-fullwidth-code-point: 3.0.0 - strip-ansi: 6.0.1 - - string-width@5.1.2: - dependencies: - eastasianwidth: 0.2.0 - emoji-regex: 9.2.2 - strip-ansi: 7.1.2 - - string_decoder@1.1.1: - dependencies: - safe-buffer: 5.1.2 - - string_decoder@1.3.0: - dependencies: - safe-buffer: 5.2.1 - - strip-ansi@6.0.1: - dependencies: - ansi-regex: 5.0.1 - - strip-ansi@7.1.2: - dependencies: - ansi-regex: 6.2.2 - - sumchecker@3.0.1: - dependencies: - debug: 4.4.3 - transitivePeerDependencies: - - supports-color - - supports-color@7.2.0: - dependencies: - has-flag: 4.0.0 - - tar-stream@2.2.0: - dependencies: - bl: 4.1.0 - end-of-stream: 1.4.5 - fs-constants: 1.0.0 - inherits: 2.0.4 - readable-stream: 3.6.2 - - tar@6.2.1: - dependencies: - chownr: 2.0.0 - fs-minipass: 2.1.0 - minipass: 5.0.0 - minizlib: 2.1.2 - mkdirp: 1.0.4 - yallist: 4.0.0 - - temp-file@3.4.0: - dependencies: - async-exit-hook: 2.0.1 - fs-extra: 10.1.0 - - tmp-promise@3.0.3: - dependencies: - tmp: 0.2.5 - - tmp@0.2.5: {} - - truncate-utf8-bytes@1.0.2: - dependencies: - utf8-byte-length: 1.0.5 - - type-fest@0.13.1: - optional: true - - typescript@5.9.3: {} - - undici-types@7.16.0: {} - - universalify@0.1.2: {} - - universalify@2.0.1: {} - - uri-js@4.4.1: - dependencies: - punycode: 2.3.1 - - utf8-byte-length@1.0.5: {} - - util-deprecate@1.0.2: {} - - verror@1.10.1: - dependencies: - assert-plus: 1.0.0 - core-util-is: 1.0.2 - extsprintf: 1.4.1 - optional: true - - which@2.0.2: - dependencies: - isexe: 2.0.0 - - wrap-ansi@7.0.0: - dependencies: - ansi-styles: 4.3.0 - string-width: 4.2.3 - strip-ansi: 6.0.1 - - wrap-ansi@8.1.0: - dependencies: - ansi-styles: 6.2.3 - string-width: 5.1.2 - strip-ansi: 7.1.2 - - wrappy@1.0.2: {} - - xmlbuilder@15.1.1: {} - - y18n@5.0.8: {} - - yallist@4.0.0: {} - - yargs-parser@21.1.1: {} - - yargs@17.7.2: - dependencies: - cliui: 8.0.1 - escalade: 3.2.0 - get-caller-file: 2.0.5 - require-directory: 2.1.1 - string-width: 4.2.3 - y18n: 5.0.8 - yargs-parser: 21.1.1 - - yauzl@2.10.0: - dependencies: - buffer-crc32: 0.2.13 - fd-slicer: 1.1.0 - - zip-stream@4.1.1: - dependencies: - archiver-utils: 3.0.4 - compress-commons: 4.1.2 - readable-stream: 3.6.2 diff --git a/desktop/preload.js b/desktop/preload.js deleted file mode 100644 index 7d699a21f..000000000 --- a/desktop/preload.js +++ /dev/null @@ -1,22 +0,0 @@ -'use strict'; - -const { contextBridge, ipcRenderer } = require('electron'); - -contextBridge.exposeInMainWorld('astrbotDesktop', { - isElectron: true, - isElectronRuntime: () => ipcRenderer.invoke('astrbot-desktop:is-electron-runtime'), - getBackendState: () => ipcRenderer.invoke('astrbot-desktop:get-backend-state'), - restartBackend: (authToken) => - ipcRenderer.invoke('astrbot-desktop:restart-backend', authToken), - stopBackend: () => ipcRenderer.invoke('astrbot-desktop:stop-backend'), - onTrayRestartBackend: (callback) => { - const listener = () => { - if (typeof callback === 'function') { - callback(); - } - }; - ipcRenderer.on('astrbot-desktop:tray-restart-backend', listener); - return () => - ipcRenderer.removeListener('astrbot-desktop:tray-restart-backend', listener); - }, -}); diff --git a/desktop/scripts/build-backend.mjs b/desktop/scripts/build-backend.mjs deleted file mode 100644 index e88297c0e..000000000 --- a/desktop/scripts/build-backend.mjs +++ /dev/null @@ -1,70 +0,0 @@ -import { spawnSync } from 'node:child_process'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const rootDir = path.resolve(__dirname, '..', '..'); -const outputDir = path.join(rootDir, 'desktop', 'resources', 'backend'); -const workDir = path.join(rootDir, 'desktop', 'resources', '.pyinstaller'); -const dataSeparator = process.platform === 'win32' ? ';' : ':'; -const kbStopwordsSrc = path.join( - rootDir, - 'astrbot', - 'core', - 'knowledge_base', - 'retrieval', - 'hit_stopwords.txt', -); -const kbStopwordsDest = 'astrbot/core/knowledge_base/retrieval'; - -const args = [ - 'run', - '--with', - 'pyinstaller', - 'python', - '-m', - 'PyInstaller', - '--noconfirm', - '--clean', - '--onefile', - '--name', - 'astrbot-backend', - '--collect-all', - 'aiosqlite', - '--collect-all', - 'pip', - '--collect-submodules', - 'astrbot.api', - '--collect-data', - 'certifi', - '--add-data', - `${kbStopwordsSrc}${dataSeparator}${kbStopwordsDest}`, - '--distpath', - outputDir, - '--workpath', - workDir, - '--specpath', - workDir, - path.join(rootDir, 'main.py'), -]; - -const result = spawnSync('uv', args, { - cwd: rootDir, - stdio: 'inherit', - shell: process.platform === 'win32', -}); - -if (result.error) { - console.error(`Failed to run 'uv': ${result.error.message}`); - process.exit(typeof result.status === 'number' ? result.status : 1); -} - -if (result.status !== 0) { - console.error( - `'uv' exited with status ${result.status} while running PyInstaller. ` + - 'Verify that uv and pyinstaller are installed and that arguments are valid.', - ); - process.exit(result.status ?? 1); -} - -process.exit(0); diff --git a/desktop/scripts/prepare-webui.mjs b/desktop/scripts/prepare-webui.mjs deleted file mode 100644 index 404ae7ef9..000000000 --- a/desktop/scripts/prepare-webui.mjs +++ /dev/null @@ -1,20 +0,0 @@ -import { cp, mkdir, rm } from 'node:fs/promises'; -import { existsSync } from 'node:fs'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const rootDir = path.resolve(__dirname, '..', '..'); -const distDir = path.join(rootDir, 'dashboard', 'dist'); -const targetDir = path.join(rootDir, 'desktop', 'resources', 'webui'); - -if (!existsSync(distDir)) { - console.error('dashboard/dist is missing. Run `pnpm --dir dashboard build` first.'); - process.exit(1); -} - -await rm(targetDir, { recursive: true, force: true }); -await mkdir(targetDir, { recursive: true }); -await cp(distDir, targetDir, { recursive: true }); - -console.log(`Copied WebUI to ${targetDir}`); diff --git a/desktop/scripts/sync-version.mjs b/desktop/scripts/sync-version.mjs deleted file mode 100644 index 08651d75a..000000000 --- a/desktop/scripts/sync-version.mjs +++ /dev/null @@ -1,66 +0,0 @@ -import { readFile, writeFile } from 'node:fs/promises'; -import { spawnSync } from 'node:child_process'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const rootDir = path.resolve(__dirname, '..', '..'); -const desktopPackagePath = path.join(rootDir, 'desktop', 'package.json'); -const pyprojectPath = path.join(rootDir, 'pyproject.toml'); - -function getGitTag() { - const result = spawnSync('git', ['describe', '--tags', '--abbrev=0'], { - cwd: rootDir, - encoding: 'utf8', - }); - if (result.status === 0) { - const tag = result.stdout.trim(); - return tag.length ? tag : null; - } - return null; -} - -function normalizeTag(tag) { - return tag.replace(/^v/i, ''); -} - -async function getPyprojectVersion() { - try { - const data = await readFile(pyprojectPath, 'utf8'); - const match = data.match(/^\s*version\s*=\s*"([^"]+)"/m); - return match ? match[1] : null; - } catch { - return null; - } -} - -const pkgRaw = await readFile(desktopPackagePath, 'utf8'); -const pkg = JSON.parse(pkgRaw); -const tag = getGitTag(); -const versionFromTag = tag ? normalizeTag(tag) : null; -const versionFromPyproject = await getPyprojectVersion(); -const version = versionFromPyproject || versionFromTag || pkg.version; - -if ( - versionFromPyproject && - versionFromTag && - versionFromPyproject !== versionFromTag -) { - console.log( - `Using pyproject version ${versionFromPyproject} (ignoring git tag ${versionFromTag}).`, - ); -} - -if (!version) { - console.warn('No version found to sync.'); - process.exit(0); -} - -if (pkg.version === version) { - console.log(`Desktop version already ${version}`); - process.exit(0); -} - -pkg.version = version; -await writeFile(desktopPackagePath, `${JSON.stringify(pkg, null, 2)}\n`, 'utf8'); -console.log(`Updated desktop version to ${version}`); diff --git a/main.py b/main.py index be188140c..36c46fca3 100644 --- a/main.py +++ b/main.py @@ -15,6 +15,7 @@ from astrbot.core.initial_loader import InitialLoader # noqa: E402 from astrbot.core.utils.astrbot_path import ( # noqa: E402 get_astrbot_config_path, get_astrbot_data_path, + get_astrbot_knowledge_base_path, get_astrbot_plugin_path, get_astrbot_root, get_astrbot_site_packages_path, @@ -55,6 +56,7 @@ def check_env() -> None: os.makedirs(get_astrbot_config_path(), exist_ok=True) os.makedirs(get_astrbot_plugin_path(), exist_ok=True) os.makedirs(get_astrbot_temp_path(), exist_ok=True) + os.makedirs(get_astrbot_knowledge_base_path(), exist_ok=True) os.makedirs(site_packages_path, exist_ok=True) # 针对问题 #181 的临时解决方案 diff --git a/openapi.json b/openapi.json new file mode 100644 index 000000000..2fadecbc0 --- /dev/null +++ b/openapi.json @@ -0,0 +1,685 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "AstrBot Open API", + "version": "1.0.0", + "description": "Developer HTTP APIs for AstrBot. Use API Key authentication for /api/v1/* endpoints." + }, + "servers": [ + { + "url": "http://localhost:6185" + } + ], + "tags": [ + { + "name": "Open API", + "description": "Developer APIs authenticated by API Key" + } + ], + "paths": { + "/api/v1/im/bots": { + "get": { + "tags": [ + "Open API" + ], + "summary": "List bot IDs", + "description": "Returns configured bot/platform IDs.", + "security": [ + { + "ApiKeyHeader": [] + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponseBotList" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + } + } + }, + "/api/v1/file": { + "post": { + "tags": [ + "Open API" + ], + "summary": "Upload attachment file", + "description": "Upload a file and get attachment_id for later use in chat/message APIs.", + "security": [ + { + "ApiKeyHeader": [] + } + ], + "requestBody": { + "required": true, + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "required": [ + "file" + ], + "properties": { + "file": { + "type": "string", + "format": "binary" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponseUpload" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + } + } + }, + "/api/v1/chat": { + "post": { + "tags": [ + "Open API" + ], + "summary": "Send chat message (SSE)", + "description": "Send message to AstrBot chat pipeline and receive streaming SSE response. Reuses /api/chat/send behavior. If session_id/conversation_id is omitted, server will create a new UUID session_id.", + "security": [ + { + "ApiKeyHeader": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ChatSendRequest" + }, + "examples": { + "plain": { + "value": { + "message": "Hello", + "username": "alice", + "session_id": "my_session_001", + "enable_streaming": true + } + }, + "multipartMessage": { + "value": { + "message": [ + { + "type": "plain", + "text": "Please analyze this file" + }, + { + "type": "file", + "attachment_id": "9a2f8c72-e7af-4c0e-b352-111111111111" + } + ], + "username": "alice", + "session_id": "my_session_001", + "selected_provider": "openai_chat_completion", + "selected_model": "gpt-4.1-mini", + "enable_streaming": true + } + }, + "withConfig": { + "value": { + "message": "Use a specific config for this session", + "username": "alice", + "session_id": "my_session_001", + "config_id": "default", + "enable_streaming": true + } + }, + "autoSessionWithUsername": { + "value": { + "message": "hello", + "username": "alice", + "enable_streaming": true + } + } + } + } + } + }, + "responses": { + "200": { + "description": "SSE stream", + "content": { + "text/event-stream": { + "schema": { + "type": "string" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + } + } + }, + "/api/v1/chat/sessions": { + "get": { + "tags": [ + "Open API" + ], + "summary": "List chat sessions with pagination", + "description": "List chat sessions for the specified username.", + "security": [ + { + "ApiKeyHeader": [] + } + ], + "parameters": [ + { + "name": "page", + "in": "query", + "schema": { + "type": "integer", + "default": 1, + "minimum": 1 + } + }, + { + "name": "page_size", + "in": "query", + "schema": { + "type": "integer", + "default": 20, + "minimum": 1, + "maximum": 100 + } + }, + { + "name": "platform_id", + "in": "query", + "schema": { + "type": "string" + }, + "description": "Optional platform filter" + }, + { + "name": "username", + "in": "query", + "required": true, + "schema": { + "type": "string" + }, + "description": "Target username." + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponseChatSessions" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + } + } + }, + "/api/v1/im/message": { + "post": { + "tags": [ + "Open API" + ], + "summary": "Send proactive message to a platform bot", + "description": "Send message directly to platform bot by umo + message chain payload.", + "security": [ + { + "ApiKeyHeader": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SendMessageRequest" + }, + "examples": { + "plain": { + "value": { + "umo": "webchat:FriendMessage:openapi_probe", + "message": "ping from api key" + } + }, + "chain": { + "value": { + "umo": "webchat:FriendMessage:openapi_probe", + "message": [ + { + "type": "plain", + "text": "hello" + }, + { + "type": "image", + "attachment_id": "9a2f8c72-e7af-4c0e-b352-111111111111" + } + ] + } + } + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponseEmpty" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + } + } + }, + "/api/v1/configs": { + "get": { + "tags": [ + "Open API" + ], + "summary": "List available chat config files", + "description": "Returns all available AstrBot config files that can be selected by Chat API using config_id/config_name.", + "security": [ + { + "ApiKeyHeader": [] + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponseChatConfigList" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + } + } + } + } + }, + "components": { + "securitySchemes": { + "ApiKeyHeader": { + "type": "apiKey", + "in": "header", + "name": "X-API-Key", + "description": "Open API key. Authorization: Bearer is also accepted." + } + }, + "responses": { + "Unauthorized": { + "description": "Unauthorized" + }, + "Forbidden": { + "description": "Forbidden" + } + }, + "schemas": { + "ApiResponseEmpty": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "ok" + }, + "message": { + "type": [ + "string", + "null" + ] + }, + "data": { + "type": "object", + "additionalProperties": true + } + } + }, + "ApiResponseBotList": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "ok" + }, + "message": { + "type": [ + "string", + "null" + ] + }, + "data": { + "type": "object", + "properties": { + "bot_ids": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + }, + "ApiResponseUpload": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "ok" + }, + "message": { + "type": [ + "string", + "null" + ] + }, + "data": { + "type": "object", + "properties": { + "attachment_id": { + "type": "string" + }, + "filename": { + "type": "string" + }, + "type": { + "type": "string" + } + } + } + } + }, + "ApiResponseChatSessions": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "ok" + }, + "message": { + "type": [ + "string", + "null" + ] + }, + "data": { + "type": "object", + "properties": { + "sessions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ChatSessionItem" + } + }, + "page": { + "type": "integer" + }, + "page_size": { + "type": "integer" + }, + "total": { + "type": "integer" + } + } + } + } + }, + "ChatSessionItem": { + "type": "object", + "properties": { + "session_id": { + "type": "string" + }, + "platform_id": { + "type": "string" + }, + "creator": { + "type": "string" + }, + "display_name": { + "type": [ + "string", + "null" + ] + }, + "is_group": { + "type": "integer" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + } + }, + "MessagePart": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "plain", + "reply", + "image", + "record", + "file", + "video" + ] + }, + "text": { + "type": "string" + }, + "message_id": { + "type": [ + "string", + "integer" + ] + }, + "selected_text": { + "type": "string" + }, + "attachment_id": { + "type": "string" + }, + "filename": { + "type": "string" + }, + "path": { + "type": "string" + } + }, + "required": [ + "type" + ] + }, + "ChatSendRequest": { + "type": "object", + "required": [ + "message", + "username" + ], + "properties": { + "message": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "$ref": "#/components/schemas/MessagePart" + } + } + ] + }, + "session_id": { + "type": "string", + "description": "Optional chat session ID. If omitted (and conversation_id is also omitted), server creates a UUID automatically." + }, + "conversation_id": { + "type": "string", + "description": "Alias of session_id." + }, + "username": { + "type": "string", + "description": "Target username." + }, + "selected_provider": { + "type": "string" + }, + "selected_model": { + "type": "string" + }, + "enable_streaming": { + "type": "boolean", + "default": true + }, + "config_id": { + "type": "string", + "description": "Optional AstrBot config file ID. If provided, the chat session will use this config file. Use \"default\" to reset to default config." + }, + "config_name": { + "type": "string", + "description": "Optional AstrBot config file name. Used only when config_id is not provided." + } + } + }, + "SendMessageRequest": { + "type": "object", + "required": [ + "umo", + "message" + ], + "properties": { + "umo": { + "type": "string", + "description": "Unified message origin. Format: platform:message_type:session_id" + }, + "message": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "$ref": "#/components/schemas/MessagePart" + } + } + ] + } + } + }, + "ChatConfigFile": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "path": { + "type": "string" + }, + "is_default": { + "type": "boolean" + } + }, + "required": [ + "id", + "name", + "path", + "is_default" + ] + }, + "ApiResponseChatConfigList": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "ok" + }, + "message": { + "type": [ + "string", + "null" + ] + }, + "data": { + "type": "object", + "properties": { + "configs": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ChatConfigFile" + } + } + } + } + } + } + } + } +} diff --git a/pyproject.toml b/pyproject.toml index 4be4e3741..9e421c303 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "AstrBot" -version = "4.16.0" +version = "4.18.2" description = "Easy-to-use multi-platform LLM chatbot and development framework" readme = "README.md" requires-python = ">=3.12" @@ -17,7 +17,7 @@ dependencies = [ "beautifulsoup4>=4.13.4", "certifi>=2025.4.26", "chardet~=5.1.0", - "colorlog>=6.9.0", + "loguru>=0.7.2", "cryptography>=44.0.3", "dashscope>=1.23.2", "defusedxml>=0.7.1", @@ -62,6 +62,7 @@ dependencies = [ "tenacity>=9.1.2", "shipyard-python-sdk>=0.2.4", "python-socks>=2.8.0", + "packaging>=24.2", ] [dependency-groups] diff --git a/requirements.txt b/requirements.txt index a2ea1a656..dd19a02c3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,7 +10,7 @@ apscheduler>=3.11.0 beautifulsoup4>=4.13.4 certifi>=2025.4.26 chardet~=5.1.0 -colorlog>=6.9.0 +loguru>=0.7.2 cryptography>=44.0.3 dashscope>=1.23.2 defusedxml>=0.7.1 @@ -53,4 +53,5 @@ jieba>=0.42.1 markitdown-no-magika[docx,xls,xlsx]>=0.1.2 xinference-client tenacity>=9.1.2 -shipyard-python-sdk>=0.2.4 \ No newline at end of file +shipyard-python-sdk>=0.2.4 +packaging>=24.2 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 000000000..b9807c1de --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,381 @@ +""" +AstrBot 测试配置 + +提供共享的 pytest fixtures 和测试工具。 +""" + +import json +import os +import sys +from asyncio import Queue +from pathlib import Path +from typing import Any +from unittest.mock import AsyncMock, MagicMock + +import pytest +import pytest_asyncio + +# 使用 tests/fixtures/helpers.py 中的共享工具函数,避免重复定义 +from tests.fixtures.helpers import create_mock_llm_response, create_mock_message_component + +# 将项目根目录添加到 sys.path +PROJECT_ROOT = Path(__file__).parent.parent +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +# 设置测试环境变量 +os.environ.setdefault("TESTING", "true") +os.environ.setdefault("ASTRBOT_TEST_MODE", "true") + + +# ============================================================ +# 测试收集和排序 +# ============================================================ + + +def pytest_collection_modifyitems(session, config, items): # noqa: ARG001 + """重新排序测试:单元测试优先,集成测试在后。""" + unit_tests = [] + integration_tests = [] + deselected = [] + profile = config.getoption("--test-profile") or os.environ.get( + "ASTRBOT_TEST_PROFILE", "all" + ) + + for item in items: + item_path = Path(str(item.path)) + is_integration = "integration" in item_path.parts + + if is_integration: + if item.get_closest_marker("integration") is None: + item.add_marker(pytest.mark.integration) + item.add_marker(pytest.mark.tier_d) + integration_tests.append(item) + else: + if item.get_closest_marker("unit") is None: + item.add_marker(pytest.mark.unit) + if any( + item.get_closest_marker(marker) is not None + for marker in ("platform", "provider", "slow") + ): + item.add_marker(pytest.mark.tier_c) + unit_tests.append(item) + + # 单元测试 -> 集成测试 + ordered_items = unit_tests + integration_tests + if profile == "blocking": + selected_items = [] + for item in ordered_items: + if item.get_closest_marker("tier_c") or item.get_closest_marker("tier_d"): + deselected.append(item) + else: + selected_items.append(item) + if deselected: + config.hook.pytest_deselected(items=deselected) + items[:] = selected_items + return + + items[:] = ordered_items + + +def pytest_addoption(parser): + """增加测试执行档位选择。""" + parser.addoption( + "--test-profile", + action="store", + default=None, + choices=["all", "blocking"], + help="Select test profile. 'blocking' excludes auto-classified tier_c/tier_d tests.", + ) + + +def pytest_configure(config): + """注册自定义标记。""" + config.addinivalue_line("markers", "unit: 单元测试") + config.addinivalue_line("markers", "integration: 集成测试") + config.addinivalue_line("markers", "slow: 慢速测试") + config.addinivalue_line("markers", "platform: 平台适配器测试") + config.addinivalue_line("markers", "provider: LLM Provider 测试") + config.addinivalue_line("markers", "db: 数据库相关测试") + config.addinivalue_line("markers", "tier_c: C-tier tests (optional / non-blocking)") + config.addinivalue_line("markers", "tier_d: D-tier tests (extended / integration)") + + +# ============================================================ +# 临时目录和文件 Fixtures +# ============================================================ + + +@pytest.fixture +def temp_dir(tmp_path: Path) -> Path: + """创建临时目录用于测试。""" + return tmp_path + + +@pytest.fixture +def event_queue() -> Queue: + """Create a shared asyncio queue fixture for tests.""" + return Queue() + + +@pytest.fixture +def platform_settings() -> dict: + """Create a shared empty platform settings fixture for adapter tests.""" + return {} + + +@pytest.fixture +def temp_data_dir(temp_dir: Path) -> Path: + """创建模拟的 data 目录结构。""" + data_dir = temp_dir / "data" + data_dir.mkdir() + + # 创建必要的子目录 + (data_dir / "config").mkdir() + (data_dir / "plugins").mkdir() + (data_dir / "temp").mkdir() + (data_dir / "attachments").mkdir() + + return data_dir + + +@pytest.fixture +def temp_config_file(temp_data_dir: Path) -> Path: + """创建临时配置文件。""" + config_path = temp_data_dir / "config" / "cmd_config.json" + default_config = { + "provider": [], + "platform": [], + "provider_settings": {}, + "default_personality": None, + "timezone": "Asia/Shanghai", + } + config_path.write_text(json.dumps(default_config, indent=2), encoding="utf-8") + return config_path + + +@pytest.fixture +def temp_db_file(temp_data_dir: Path) -> Path: + """创建临时数据库文件路径。""" + return temp_data_dir / "test.db" + + +# ============================================================ +# Mock Fixtures +# ============================================================ + + +@pytest.fixture +def mock_provider(): + """创建模拟的 Provider。""" + provider = MagicMock() + provider.provider_config = { + "id": "test-provider", + "type": "openai_chat_completion", + "model": "gpt-4o-mini", + } + provider.get_model = MagicMock(return_value="gpt-4o-mini") + provider.text_chat = AsyncMock() + provider.text_chat_stream = AsyncMock() + provider.terminate = AsyncMock() + return provider + + +@pytest.fixture +def mock_platform(): + """创建模拟的 Platform。""" + platform = MagicMock() + platform.platform_name = "test_platform" + platform.platform_meta = MagicMock() + platform.platform_meta.support_proactive_message = False + platform.send_message = AsyncMock() + platform.terminate = AsyncMock() + return platform + + +@pytest.fixture +def mock_conversation(): + """创建模拟的 Conversation。""" + from astrbot.core.db.po import ConversationV2 + + return ConversationV2( + conversation_id="test-conv-id", + platform_id="test_platform", + user_id="test_user", + content=[], + persona_id=None, + ) + + +@pytest.fixture +def mock_event(): + """创建模拟的 AstrMessageEvent。""" + event = MagicMock() + event.unified_msg_origin = "test_umo" + event.session_id = "test_session" + event.message_str = "Hello, world!" + event.message_obj = MagicMock() + event.message_obj.message = [] + event.message_obj.sender = MagicMock() + event.message_obj.sender.user_id = "test_user" + event.message_obj.sender.nickname = "Test User" + event.message_obj.group_id = None + event.message_obj.group = None + event.get_platform_name = MagicMock(return_value="test_platform") + event.get_platform_id = MagicMock(return_value="test_platform") + event.get_group_id = MagicMock(return_value=None) + event.get_extra = MagicMock(return_value=None) + event.set_extra = MagicMock() + event.trace = MagicMock() + event.platform_meta = MagicMock() + event.platform_meta.support_proactive_message = False + return event + + +# ============================================================ +# 配置 Fixtures +# ============================================================ + + +@pytest.fixture +def astrbot_config(temp_config_file: Path): + """创建 AstrBotConfig 实例。""" + from astrbot.core.config.astrbot_config import AstrBotConfig + + config = AstrBotConfig() + config._config_path = str(temp_config_file) # noqa: SLF001 + return config + + +@pytest.fixture +def main_agent_build_config(): + """创建 MainAgentBuildConfig 实例。""" + from astrbot.core.astr_main_agent import MainAgentBuildConfig + + return MainAgentBuildConfig( + tool_call_timeout=60, + tool_schema_mode="full", + provider_wake_prefix="", + streaming_response=True, + sanitize_context_by_modalities=False, + kb_agentic_mode=False, + file_extract_enabled=False, + context_limit_reached_strategy="truncate_by_turns", + llm_safety_mode=True, + computer_use_runtime="local", + add_cron_tools=True, + ) + + +# ============================================================ +# 数据库 Fixtures +# ============================================================ + + +@pytest_asyncio.fixture +async def temp_db(temp_db_file: Path): + """创建临时数据库实例。""" + from astrbot.core.db.sqlite import SQLiteDatabase + + db = SQLiteDatabase(str(temp_db_file)) + try: + yield db + finally: + await db.engine.dispose() + if temp_db_file.exists(): + temp_db_file.unlink() + + +# ============================================================ +# Context Fixtures +# ============================================================ + + +@pytest_asyncio.fixture +async def mock_context( + astrbot_config, + temp_db, + mock_provider, + mock_platform, +): + """创建模拟的插件上下文。""" + from asyncio import Queue + + from astrbot.core.star.context import Context + + event_queue = Queue() + + provider_manager = MagicMock() + provider_manager.get_using_provider = MagicMock(return_value=mock_provider) + provider_manager.get_provider_by_id = MagicMock(return_value=mock_provider) + + platform_manager = MagicMock() + conversation_manager = MagicMock() + message_history_manager = MagicMock() + persona_manager = MagicMock() + persona_manager.personas_v3 = [] + astrbot_config_mgr = MagicMock() + knowledge_base_manager = MagicMock() + cron_manager = MagicMock() + subagent_orchestrator = None + + context = Context( + event_queue, + astrbot_config, + temp_db, + provider_manager, + platform_manager, + conversation_manager, + message_history_manager, + persona_manager, + astrbot_config_mgr, + knowledge_base_manager, + cron_manager, + subagent_orchestrator, + ) + + return context + + +# ============================================================ +# Provider Request Fixtures +# ============================================================ + + +@pytest.fixture +def provider_request(): + """创建 ProviderRequest 实例。""" + from astrbot.core.provider.entities import ProviderRequest + + return ProviderRequest( + prompt="Hello", + session_id="test_session", + image_urls=[], + contexts=[], + system_prompt="You are a helpful assistant.", + ) + + +# ============================================================ +# 跳过条件 +# ============================================================ + + +def pytest_runtest_setup(item): + """在测试运行前检查跳过条件。""" + # 跳过需要 API Key 但未设置的 Provider 测试 + if item.get_closest_marker("provider"): + if not os.environ.get("TEST_PROVIDER_API_KEY"): + pytest.skip("TEST_PROVIDER_API_KEY not set") + + # 跳过需要特定平台的测试 + if item.get_closest_marker("platform"): + required_platform = None + marker = item.get_closest_marker("platform") + if marker and marker.args: + required_platform = marker.args[0] + + if required_platform and not os.environ.get( + f"TEST_{required_platform.upper()}_ENABLED" + ): + pytest.skip(f"TEST_{required_platform.upper()}_ENABLED not set") diff --git a/tests/fixtures/__init__.py b/tests/fixtures/__init__.py new file mode 100644 index 000000000..16e927d2c --- /dev/null +++ b/tests/fixtures/__init__.py @@ -0,0 +1,64 @@ +""" +AstrBot 测试数据 + +此目录存放测试用的静态数据和配置文件。 + +目录结构: +- fixtures/ + ├── configs/ # 测试配置文件 + ├── messages/ # 测试消息数据 + ├── plugins/ # 测试插件 + ├── knowledge_base/ # 测试知识库数据 + ├── mocks/ # Mock 模块 + └── helpers.py # 辅助函数 +""" + +import json +from pathlib import Path + +from .helpers import ( + NoopAwaitable, + create_mock_discord_attachment, + create_mock_discord_channel, + create_mock_discord_user, + create_mock_file, + create_mock_llm_response, + create_mock_message_component, + create_mock_update, + make_platform_config, +) + +FIXTURES_DIR = Path(__file__).parent + + +def load_fixture(filename: str) -> dict: + """加载 JSON 格式的测试数据。""" + filepath = FIXTURES_DIR / filename + if not filepath.exists(): + raise FileNotFoundError(f"Fixture not found: {filepath}") + return json.loads(filepath.read_text(encoding="utf-8")) + + +def get_fixture_path(filename: str) -> Path: + """获取测试数据文件路径。""" + filepath = FIXTURES_DIR / filename + if not filepath.exists(): + raise FileNotFoundError(f"Fixture not found: {filepath}") + return filepath + + +__all__ = [ + "FIXTURES_DIR", + "load_fixture", + "get_fixture_path", + # 辅助函数 + "NoopAwaitable", + "make_platform_config", + "create_mock_update", + "create_mock_file", + "create_mock_discord_attachment", + "create_mock_discord_user", + "create_mock_discord_channel", + "create_mock_message_component", + "create_mock_llm_response", +] diff --git a/tests/fixtures/configs/test_cmd_config.json b/tests/fixtures/configs/test_cmd_config.json new file mode 100644 index 000000000..2b92302a4 --- /dev/null +++ b/tests/fixtures/configs/test_cmd_config.json @@ -0,0 +1,21 @@ +{ + "provider": [ + { + "id": "test-openai", + "type": "openai_chat_completion", + "model": "gpt-4o-mini", + "key": ["test-key"] + } + ], + "platform": [], + "provider_settings": { + "default_personality": null, + "prompt_prefix": "", + "image_caption_provider_id": "", + "datetime_system_prompt": true, + "identifier": true, + "group_name_display": true + }, + "default_personality": null, + "timezone": "Asia/Shanghai" +} diff --git a/tests/fixtures/helpers.py b/tests/fixtures/helpers.py new file mode 100644 index 000000000..8f64ab6c9 --- /dev/null +++ b/tests/fixtures/helpers.py @@ -0,0 +1,332 @@ +"""测试辅助函数和工具类。 + +提供统一的测试辅助工具,减少测试代码重复。 +""" + +from typing import Any +from unittest.mock import AsyncMock, MagicMock + +from astrbot.core.message.components import BaseMessageComponent + + +class NoopAwaitable: + """可等待的空操作对象。 + + 用于 mock 需要返回 awaitable 对象的方法。 + """ + + def __await__(self): + if False: + yield + return None + + +# ============================================================ +# 平台配置工厂 +# ============================================================ + + +def make_platform_config(platform_type: str, **kwargs) -> dict: + """平台配置工厂函数。 + + Args: + platform_type: 平台类型 (telegram, discord, aiocqhttp 等) + **kwargs: 覆盖默认配置的字段 + + Returns: + dict: 平台配置字典 + """ + configs = { + "telegram": { + "id": "test_telegram", + "telegram_token": "test_token_123", + "telegram_api_base_url": "https://api.telegram.org/bot", + "telegram_file_base_url": "https://api.telegram.org/file/bot", + "telegram_command_register": True, + "telegram_command_auto_refresh": True, + "telegram_command_register_interval": 300, + "telegram_media_group_timeout": 2.5, + "telegram_media_group_max_wait": 10.0, + "start_message": "Welcome to AstrBot!", + }, + "discord": { + "id": "test_discord", + "discord_token": "test_token_123", + "discord_proxy": None, + "discord_command_register": True, + "discord_guild_id_for_debug": None, + "discord_activity_name": "Playing AstrBot", + }, + "aiocqhttp": { + "id": "test_aiocqhttp", + "ws_reverse_host": "0.0.0.0", + "ws_reverse_port": 6199, + "ws_reverse_token": "test_token", + }, + "webchat": { + "id": "test_webchat", + }, + "wecom": { + "id": "test_wecom", + "wecom_corpid": "test_corpid", + "wecom_secret": "test_secret", + }, + } + config = configs.get(platform_type, {"id": f"test_{platform_type}"}).copy() + config.update(kwargs) + return config + + +# ============================================================ +# Telegram 辅助函数 +# ============================================================ + + +def create_mock_update( + message_text: str | None = "Hello World", + chat_type: str = "private", + chat_id: int = 123456789, + user_id: int = 987654321, + username: str = "test_user", + message_id: int = 1, + media_group_id: str | None = None, + photo: list | None = None, + video: MagicMock | None = None, + document: MagicMock | None = None, + voice: MagicMock | None = None, + sticker: MagicMock | None = None, + reply_to_message: MagicMock | None = None, + caption: str | None = None, + entities: list | None = None, + caption_entities: list | None = None, + message_thread_id: int | None = None, + is_topic_message: bool = False, +): + """创建模拟的 Telegram Update 对象。 + + Args: + message_text: 消息文本 + chat_type: 聊天类型 + chat_id: 聊天 ID + user_id: 用户 ID + username: 用户名 + message_id: 消息 ID + media_group_id: 媒体组 ID + photo: 图片列表 + video: 视频对象 + document: 文档对象 + voice: 语音对象 + sticker: 贴纸对象 + reply_to_message: 回复的消息 + caption: 说明文字 + entities: 实体列表 + caption_entities: 说明实体列表 + message_thread_id: 消息线程 ID + is_topic_message: 是否为主题消息 + + Returns: + MagicMock: 模拟的 Update 对象 + """ + update = MagicMock() + update.update_id = 1 + + # Create message mock + message = MagicMock() + message.message_id = message_id + message.chat = MagicMock() + message.chat.id = chat_id + message.chat.type = chat_type + message.message_thread_id = message_thread_id + message.is_topic_message = is_topic_message + + # Create user mock + from_user = MagicMock() + from_user.id = user_id + from_user.username = username + message.from_user = from_user + + # Set message content + message.text = message_text + message.media_group_id = media_group_id + message.photo = photo + message.video = video + message.document = document + message.voice = voice + message.sticker = sticker + message.reply_to_message = reply_to_message + message.caption = caption + message.entities = entities + message.caption_entities = caption_entities + + update.message = message + update.effective_chat = message.chat + + return update + + +def create_mock_file(file_path: str = "https://api.telegram.org/file/test.jpg"): + """创建模拟的 Telegram File 对象。 + + Args: + file_path: 文件路径 + + Returns: + MagicMock: 模拟的 File 对象 + """ + file = MagicMock() + file.file_path = file_path + file.get_file = AsyncMock(return_value=file) + return file + + +# ============================================================ +# Discord 辅助函数 +# ============================================================ + + +def create_mock_discord_attachment( + filename: str = "test.txt", + url: str = "https://cdn.discordapp.com/test.txt", + content_type: str | None = None, + size: int = 1024, +): + """创建模拟的 Discord Attachment 对象。 + + Args: + filename: 文件名 + url: 文件 URL + content_type: 内容类型 + size: 文件大小 + + Returns: + MagicMock: 模拟的 Attachment 对象 + """ + attachment = MagicMock() + attachment.filename = filename + attachment.url = url + attachment.content_type = content_type + attachment.size = size + return attachment + + +def create_mock_discord_user( + user_id: int = 123456789, + name: str = "TestUser", + display_name: str = "Test User", + bot: bool = False, +): + """创建模拟的 Discord User 对象。 + + Args: + user_id: 用户 ID + name: 用户名 + display_name: 显示名 + bot: 是否为机器人 + + Returns: + MagicMock: 模拟的 User 对象 + """ + user = MagicMock() + user.id = user_id + user.name = name + user.display_name = display_name + user.bot = bot + user.mention = f"<@{user_id}>" + return user + + +def create_mock_discord_channel( + channel_id: int = 111222333, + channel_type: str = "text", + name: str = "general", + guild_id: int | None = 444555666, +): + """创建模拟的 Discord Channel 对象。 + + Args: + channel_id: 频道 ID + channel_type: 频道类型 + name: 频道名 + guild_id: 服务器 ID + + Returns: + MagicMock: 模拟的 Channel 对象 + """ + channel = MagicMock() + channel.id = channel_id + channel.name = name + channel.type = channel_type + + if guild_id: + channel.guild = MagicMock() + channel.guild.id = guild_id + else: + channel.guild = None + + return channel + + +# ============================================================ +# 消息组件辅助函数 +# ============================================================ + + +def create_mock_message_component( + component_type: str, + **kwargs: Any, +) -> BaseMessageComponent: + """创建模拟的消息组件。 + + Args: + component_type: 组件类型 (plain, image, at, reply, file) + **kwargs: 组件参数 + + Returns: + BaseMessageComponent: 消息组件实例 + """ + from astrbot.core.message import components as Comp + + component_map = { + "plain": Comp.Plain, + "image": Comp.Image, + "at": Comp.At, + "reply": Comp.Reply, + "file": Comp.File, + } + + component_class = component_map.get(component_type.lower()) + if not component_class: + raise ValueError(f"Unknown component type: {component_type}") + + return component_class(**kwargs) + + +def create_mock_llm_response( + completion_text: str = "Hello! How can I help you?", + role: str = "assistant", + tools_call_name: list[str] | None = None, + tools_call_args: list[dict] | None = None, + tools_call_ids: list[str] | None = None, +): + """创建模拟的 LLM 响应。 + + Args: + completion_text: 完成文本 + role: 角色 + tools_call_name: 工具调用名称列表 + tools_call_args: 工具调用参数列表 + tools_call_ids: 工具调用 ID 列表 + + Returns: + LLMResponse: 模拟的 LLM 响应 + """ + from astrbot.core.provider.entities import LLMResponse, TokenUsage + + return LLMResponse( + role=role, + completion_text=completion_text, + tools_call_name=tools_call_name or [], + tools_call_args=tools_call_args or [], + tools_call_ids=tools_call_ids or [], + usage=TokenUsage(input_other=10, output=5), + ) diff --git a/tests/fixtures/messages/test_messages.json b/tests/fixtures/messages/test_messages.json new file mode 100644 index 000000000..0a3a7073f --- /dev/null +++ b/tests/fixtures/messages/test_messages.json @@ -0,0 +1,33 @@ +{ + "plain_message": { + "type": "plain", + "text": "Hello, this is a test message." + }, + "image_message": { + "type": "image", + "url": "https://example.com/test.jpg", + "file": null + }, + "at_message": { + "type": "at", + "user_id": "12345", + "nickname": "TestUser" + }, + "reply_message": { + "type": "reply", + "id": "msg_123", + "sender_nickname": "OriginalSender", + "message_str": "This is the original message" + }, + "file_message": { + "type": "file", + "name": "test.pdf", + "url": "https://example.com/test.pdf" + }, + "combined_message": { + "components": [ + {"type": "at", "user_id": "bot_id"}, + {"type": "plain", "text": " Hello bot!"} + ] + } +} diff --git a/tests/fixtures/mocks/__init__.py b/tests/fixtures/mocks/__init__.py new file mode 100644 index 000000000..c6497f1f2 --- /dev/null +++ b/tests/fixtures/mocks/__init__.py @@ -0,0 +1,43 @@ +"""测试 Mock 模块。 + +提供统一的 mock 工具和 fixture,减少测试代码重复。 + +使用方式: + # 在测试文件顶部导入需要的 fixture + from tests.fixtures.mocks import mock_telegram_modules + + # 或使用 Builder 类创建 mock 对象 + from tests.fixtures.mocks import MockTelegramBuilder + bot = MockTelegramBuilder.create_bot() +""" + +from .aiocqhttp import ( + MockAiocqhttpBuilder, + create_mock_aiocqhttp_modules, + mock_aiocqhttp_modules, +) +from .discord import ( + MockDiscordBuilder, + create_mock_discord_modules, + mock_discord_modules, +) +from .telegram import ( + MockTelegramBuilder, + create_mock_telegram_modules, + mock_telegram_modules, +) + +__all__ = [ + # Telegram + "mock_telegram_modules", + "create_mock_telegram_modules", + "MockTelegramBuilder", + # Discord + "mock_discord_modules", + "create_mock_discord_modules", + "MockDiscordBuilder", + # Aiocqhttp + "mock_aiocqhttp_modules", + "create_mock_aiocqhttp_modules", + "MockAiocqhttpBuilder", +] diff --git a/tests/fixtures/mocks/aiocqhttp.py b/tests/fixtures/mocks/aiocqhttp.py new file mode 100644 index 000000000..d5e3c8229 --- /dev/null +++ b/tests/fixtures/mocks/aiocqhttp.py @@ -0,0 +1,58 @@ +"""Aiocqhttp 模块 Mock 工具。 + +提供统一的 aiocqhttp 相关模块 mock 设置,避免在测试文件中重复定义。 +""" + +import sys +from unittest.mock import AsyncMock, MagicMock + +import pytest + + +def create_mock_aiocqhttp_modules(): + """创建 aiocqhttp 相关的 mock 模块。 + + Returns: + dict: 包含 aiocqhttp 和相关模块的 mock 对象 + """ + mock_aiocqhttp = MagicMock() + mock_aiocqhttp.CQHttp = MagicMock + mock_aiocqhttp.Event = MagicMock + mock_aiocqhttp.exceptions = MagicMock() + mock_aiocqhttp.exceptions.ActionFailed = Exception + + return mock_aiocqhttp + + +@pytest.fixture(scope="module", autouse=True) +def mock_aiocqhttp_modules(): + """Mock aiocqhttp 相关模块的 fixture。 + + 自动应用于使用此 fixture 的测试模块。 + """ + mock_aiocqhttp = create_mock_aiocqhttp_modules() + monkeypatch = pytest.MonkeyPatch() + + monkeypatch.setitem(sys.modules, "aiocqhttp", mock_aiocqhttp) + monkeypatch.setitem(sys.modules, "aiocqhttp.exceptions", mock_aiocqhttp.exceptions) + yield + monkeypatch.undo() + + +class MockAiocqhttpBuilder: + """构建 aiocqhttp 测试 mock 对象的工具类。""" + + @staticmethod + def create_bot(): + """创建 mock CQHttp bot 实例。""" + from tests.fixtures.helpers import NoopAwaitable + + bot = MagicMock() + bot.send = AsyncMock() + bot.call_action = AsyncMock() + bot.on_request = MagicMock() + bot.on_notice = MagicMock() + bot.on_message = MagicMock() + bot.on_websocket_connection = MagicMock() + bot.run_task = MagicMock(return_value=NoopAwaitable()) + return bot diff --git a/tests/fixtures/mocks/discord.py b/tests/fixtures/mocks/discord.py new file mode 100644 index 000000000..e13786af1 --- /dev/null +++ b/tests/fixtures/mocks/discord.py @@ -0,0 +1,140 @@ +"""Discord 模块 Mock 工具。 + +提供统一的 Discord 相关模块 mock 设置,避免在测试文件中重复定义。 +""" + +import sys +from unittest.mock import AsyncMock, MagicMock + +import pytest + + +def create_mock_discord_modules(): + """创建 Discord 相关的 mock 模块。 + + Returns: + dict: 包含 discord 和相关模块的 mock 对象 + """ + mock_discord = MagicMock() + + # Mock discord.Intents + mock_intents = MagicMock() + mock_intents.default = MagicMock(return_value=mock_intents) + mock_discord.Intents = mock_intents + + # Mock discord.Status + mock_discord.Status = MagicMock() + mock_discord.Status.online = "online" + + # Mock discord.Bot + mock_bot = MagicMock() + mock_discord.Bot = MagicMock(return_value=mock_bot) + + # Mock discord.Embed + mock_embed = MagicMock() + mock_discord.Embed = MagicMock(return_value=mock_embed) + + # Mock discord.ui + mock_ui = MagicMock() + mock_ui.View = MagicMock + mock_ui.Button = MagicMock + mock_discord.ui = mock_ui + + # Mock discord.Message + mock_discord.Message = MagicMock + + # Mock discord.Interaction + mock_discord.Interaction = MagicMock + mock_discord.InteractionType = MagicMock() + mock_discord.InteractionType.application_command = 2 + mock_discord.InteractionType.component = 3 + + # Mock discord.File + mock_discord.File = MagicMock + + # Mock discord.SlashCommand + mock_discord.SlashCommand = MagicMock + + # Mock discord.Option + mock_discord.Option = MagicMock + + # Mock discord.SlashCommandOptionType + mock_discord.SlashCommandOptionType = MagicMock() + mock_discord.SlashCommandOptionType.string = 3 + + # Mock discord.errors + mock_discord.errors = MagicMock() + mock_discord.errors.LoginFailure = Exception + mock_discord.errors.ConnectionClosed = Exception + mock_discord.errors.NotFound = Exception + mock_discord.errors.Forbidden = Exception + + # Mock discord.abc + mock_discord.abc = MagicMock() + mock_discord.abc.GuildChannel = MagicMock + mock_discord.abc.Messageable = MagicMock + mock_discord.abc.PrivateChannel = MagicMock + + # Mock discord.channel + mock_channel = MagicMock() + mock_channel.DMChannel = MagicMock + mock_discord.channel = mock_channel + + # Mock discord.types + mock_discord.types = MagicMock() + mock_discord.types.interactions = MagicMock() + + # Mock discord.ApplicationContext + mock_discord.ApplicationContext = MagicMock + + # Mock discord.CustomActivity + mock_discord.CustomActivity = MagicMock + + return mock_discord + + +@pytest.fixture(scope="module", autouse=True) +def mock_discord_modules(): + """Mock Discord 相关模块的 fixture。 + + 自动应用于使用此 fixture 的测试模块。 + """ + mock_discord = create_mock_discord_modules() + monkeypatch = pytest.MonkeyPatch() + + monkeypatch.setitem(sys.modules, "discord", mock_discord) + monkeypatch.setitem(sys.modules, "discord.abc", mock_discord.abc) + monkeypatch.setitem(sys.modules, "discord.channel", mock_discord.channel) + monkeypatch.setitem(sys.modules, "discord.errors", mock_discord.errors) + monkeypatch.setitem(sys.modules, "discord.types", mock_discord.types) + monkeypatch.setitem( + sys.modules, + "discord.types.interactions", + mock_discord.types.interactions, + ) + monkeypatch.setitem(sys.modules, "discord.ui", mock_discord.ui) + yield + monkeypatch.undo() + + +class MockDiscordBuilder: + """构建 Discord 测试 mock 对象的工具类。""" + + @staticmethod + def create_client(): + """创建 mock Discord client 实例。""" + client = MagicMock() + client.user = MagicMock() + client.user.id = 123456789 + client.user.display_name = "TestBot" + client.user.name = "TestBot" + client.get_channel = MagicMock() + client.fetch_channel = AsyncMock() + client.get_message = MagicMock() + client.start = AsyncMock() + client.close = AsyncMock() + client.is_closed = MagicMock(return_value=False) + client.add_application_command = MagicMock() + client.sync_commands = AsyncMock() + client.change_presence = AsyncMock() + return client diff --git a/tests/fixtures/mocks/telegram.py b/tests/fixtures/mocks/telegram.py new file mode 100644 index 000000000..fbe4d0436 --- /dev/null +++ b/tests/fixtures/mocks/telegram.py @@ -0,0 +1,141 @@ +"""Telegram 模块 Mock 工具。 + +提供统一的 Telegram 相关模块 mock 设置,避免在测试文件中重复定义。 +""" + +import sys +from unittest.mock import AsyncMock, MagicMock + +import pytest + + +def create_mock_telegram_modules(): + """创建 Telegram 相关的 mock 模块。 + + Returns: + dict: 包含 telegram 和相关模块的 mock 对象 + """ + mock_telegram = MagicMock() + mock_telegram.BotCommand = MagicMock + mock_telegram.Update = MagicMock + mock_telegram.constants = MagicMock() + mock_telegram.constants.ChatType = MagicMock() + mock_telegram.constants.ChatType.PRIVATE = "private" + mock_telegram.constants.ChatAction = MagicMock() + mock_telegram.constants.ChatAction.TYPING = "typing" + mock_telegram.constants.ChatAction.UPLOAD_VOICE = "upload_voice" + mock_telegram.constants.ChatAction.UPLOAD_DOCUMENT = "upload_document" + mock_telegram.constants.ChatAction.UPLOAD_PHOTO = "upload_photo" + mock_telegram.error = MagicMock() + mock_telegram.error.BadRequest = Exception + mock_telegram.ReactionTypeCustomEmoji = MagicMock + mock_telegram.ReactionTypeEmoji = MagicMock + + mock_telegram_ext = MagicMock() + mock_telegram_ext.ApplicationBuilder = MagicMock + mock_telegram_ext.ContextTypes = MagicMock + mock_telegram_ext.ExtBot = MagicMock + mock_telegram_ext.filters = MagicMock() + mock_telegram_ext.filters.ALL = MagicMock() + mock_telegram_ext.MessageHandler = MagicMock + + # Mock telegramify_markdown + mock_telegramify = MagicMock() + mock_telegramify.markdownify = lambda text, **kwargs: text + + # Mock apscheduler + mock_apscheduler = MagicMock() + mock_apscheduler.schedulers = MagicMock() + mock_apscheduler.schedulers.asyncio = MagicMock() + mock_apscheduler.schedulers.asyncio.AsyncIOScheduler = MagicMock + mock_apscheduler.schedulers.background = MagicMock() + mock_apscheduler.schedulers.background.BackgroundScheduler = MagicMock + + return { + "telegram": mock_telegram, + "telegram.ext": mock_telegram_ext, + "telegramify_markdown": mock_telegramify, + "apscheduler": mock_apscheduler, + } + + +@pytest.fixture(scope="module", autouse=True) +def mock_telegram_modules(): + """Mock Telegram 相关模块的 fixture。 + + 自动应用于使用此 fixture 的测试模块。 + """ + mocks = create_mock_telegram_modules() + monkeypatch = pytest.MonkeyPatch() + + monkeypatch.setitem(sys.modules, "telegram", mocks["telegram"]) + monkeypatch.setitem(sys.modules, "telegram.constants", mocks["telegram"].constants) + monkeypatch.setitem(sys.modules, "telegram.error", mocks["telegram"].error) + monkeypatch.setitem(sys.modules, "telegram.ext", mocks["telegram.ext"]) + monkeypatch.setitem(sys.modules, "telegramify_markdown", mocks["telegramify_markdown"]) + monkeypatch.setitem(sys.modules, "apscheduler", mocks["apscheduler"]) + monkeypatch.setitem( + sys.modules, "apscheduler.schedulers", mocks["apscheduler"].schedulers + ) + monkeypatch.setitem( + sys.modules, + "apscheduler.schedulers.asyncio", + mocks["apscheduler"].schedulers.asyncio, + ) + monkeypatch.setitem( + sys.modules, + "apscheduler.schedulers.background", + mocks["apscheduler"].schedulers.background, + ) + yield + monkeypatch.undo() + + +class MockTelegramBuilder: + """构建 Telegram 测试 mock 对象的工具类。""" + + @staticmethod + def create_bot(): + """创建 mock Telegram bot 实例。""" + bot = MagicMock() + bot.username = "test_bot" + bot.id = 12345678 + bot.base_url = "https://api.telegram.org/bottest_token_123/" + bot.send_message = AsyncMock() + bot.send_photo = AsyncMock() + bot.send_document = AsyncMock() + bot.send_voice = AsyncMock() + bot.send_chat_action = AsyncMock() + bot.delete_my_commands = AsyncMock() + bot.set_my_commands = AsyncMock() + bot.set_message_reaction = AsyncMock() + bot.edit_message_text = AsyncMock() + return bot + + @staticmethod + def create_application(): + """创建 mock Telegram Application 实例。""" + from tests.fixtures.helpers import NoopAwaitable + + app = MagicMock() + app.bot = MagicMock() + app.bot.username = "test_bot" + app.bot.base_url = "https://api.telegram.org/bottest_token_123/" + app.initialize = AsyncMock() + app.start = AsyncMock() + app.stop = AsyncMock() + app.add_handler = MagicMock() + app.updater = MagicMock() + app.updater.start_polling = MagicMock(return_value=NoopAwaitable()) + app.updater.stop = AsyncMock() + return app + + @staticmethod + def create_scheduler(): + """创建 mock APScheduler 实例。""" + scheduler = MagicMock() + scheduler.add_job = MagicMock() + scheduler.start = MagicMock() + scheduler.running = True + scheduler.shutdown = MagicMock() + return scheduler diff --git a/tests/fixtures/plugins/fixture_plugin.py b/tests/fixtures/plugins/fixture_plugin.py new file mode 100644 index 000000000..455b5b759 --- /dev/null +++ b/tests/fixtures/plugins/fixture_plugin.py @@ -0,0 +1,40 @@ +""" +测试插件 - 用于插件系统测试 + +这是一个最小化的测试插件,用于验证插件系统的功能。 +""" + +from astrbot.api import llm_tool, star +from astrbot.api.event import AstrMessageEvent, MessageEventResult, filter + + +@star.register("test_plugin", "AstrBot Team", "测试插件 - 用于插件系统测试", "1.0.0") +class TestPlugin(star.Star): + """测试插件类""" + + def __init__(self, context: star.Context) -> None: + super().__init__(context) + self.initialized = True + + async def terminate(self) -> None: + """插件终止""" + self.initialized = False + + @filter.command("test_cmd") + async def test_command(self, event: AstrMessageEvent) -> None: + """测试命令处理器。""" + event.set_result(MessageEventResult().message("测试命令执行成功")) + + @llm_tool("test_tool") + async def test_llm_tool(self, query: str) -> str: + """测试 LLM 工具。 + + Args: + query(string): 查询内容。 + """ + return f"测试工具执行成功: {query}" + + @filter.regex(r"^test_regex_(.+)$") + async def test_regex_handler(self, event: AstrMessageEvent) -> None: + """测试正则处理器。""" + event.set_result(MessageEventResult().message("正则匹配成功")) diff --git a/tests/fixtures/plugins/metadata.yaml b/tests/fixtures/plugins/metadata.yaml new file mode 100644 index 000000000..2554fb15d --- /dev/null +++ b/tests/fixtures/plugins/metadata.yaml @@ -0,0 +1,5 @@ +name: test_plugin +description: 测试插件 - 用于插件系统测试 +version: 1.0.0 +author: AstrBot Team +repo: https://github.com/test/test_plugin diff --git a/tests/test_api_key_open_api.py b/tests/test_api_key_open_api.py new file mode 100644 index 000000000..3d1ea0a0f --- /dev/null +++ b/tests/test_api_key_open_api.py @@ -0,0 +1,334 @@ +import asyncio +import uuid + +import pytest +import pytest_asyncio +from quart import Quart, g, request + +from astrbot.core import LogBroker +from astrbot.core.core_lifecycle import AstrBotCoreLifecycle +from astrbot.core.db.sqlite import SQLiteDatabase +from astrbot.dashboard.routes.route import Response +from astrbot.dashboard.server import AstrBotDashboard + + +@pytest_asyncio.fixture(scope="module") +async def core_lifecycle_td(tmp_path_factory): + tmp_db_path = tmp_path_factory.mktemp("data") / "test_data_api_key.db" + db = SQLiteDatabase(str(tmp_db_path)) + log_broker = LogBroker() + core_lifecycle = AstrBotCoreLifecycle(log_broker, db) + await core_lifecycle.initialize() + try: + yield core_lifecycle + finally: + try: + stop_result = core_lifecycle.stop() + if asyncio.iscoroutine(stop_result): + await stop_result + except Exception: + pass + + +@pytest.fixture(scope="module") +def app(core_lifecycle_td: AstrBotCoreLifecycle): + shutdown_event = asyncio.Event() + server = AstrBotDashboard(core_lifecycle_td, core_lifecycle_td.db, shutdown_event) + return server.app + + +@pytest_asyncio.fixture(scope="module") +async def authenticated_header(app: Quart, core_lifecycle_td: AstrBotCoreLifecycle): + test_client = app.test_client() + response = await test_client.post( + "/api/auth/login", + json={ + "username": core_lifecycle_td.astrbot_config["dashboard"]["username"], + "password": core_lifecycle_td.astrbot_config["dashboard"]["password"], + }, + ) + data = await response.get_json() + token = data["data"]["token"] + return {"Authorization": f"Bearer {token}"} + + +@pytest.mark.asyncio +async def test_api_key_scope_and_revoke(app: Quart, authenticated_header: dict): + test_client = app.test_client() + + create_res = await test_client.post( + "/api/apikey/create", + json={"name": "im-scope-key", "scopes": ["im"]}, + headers=authenticated_header, + ) + assert create_res.status_code == 200 + create_data = await create_res.get_json() + assert create_data["status"] == "ok" + raw_key = create_data["data"]["api_key"] + key_id = create_data["data"]["key_id"] + + open_bot_res = await test_client.get( + "/api/v1/im/bots", + headers={"X-API-Key": raw_key}, + ) + assert open_bot_res.status_code == 200 + open_bot_data = await open_bot_res.get_json() + assert open_bot_data["status"] == "ok" + assert isinstance(open_bot_data["data"]["bot_ids"], list) + + denied_chat_sessions_res = await test_client.get( + "/api/v1/chat/sessions?page=1&page_size=10", + headers={"X-API-Key": raw_key}, + ) + assert denied_chat_sessions_res.status_code == 403 + + denied_chat_configs_res = await test_client.get( + "/api/v1/configs", + headers={"X-API-Key": raw_key}, + ) + assert denied_chat_configs_res.status_code == 403 + + denied_res = await test_client.post( + "/api/v1/file", + data={}, + headers={"X-API-Key": raw_key}, + ) + assert denied_res.status_code == 403 + + revoke_res = await test_client.post( + "/api/apikey/revoke", + json={"key_id": key_id}, + headers=authenticated_header, + ) + assert revoke_res.status_code == 200 + revoke_data = await revoke_res.get_json() + assert revoke_data["status"] == "ok" + + revoked_access_res = await test_client.get( + "/api/v1/im/bots", + headers={"X-API-Key": raw_key}, + ) + assert revoked_access_res.status_code == 401 + + +@pytest.mark.asyncio +async def test_open_send_message_with_api_key(app: Quart, authenticated_header: dict): + test_client = app.test_client() + + create_res = await test_client.post( + "/api/apikey/create", + json={"name": "send-message-key", "scopes": ["im"]}, + headers=authenticated_header, + ) + create_data = await create_res.get_json() + assert create_data["status"] == "ok" + raw_key = create_data["data"]["api_key"] + + send_res = await test_client.post( + "/api/v1/im/message", + json={ + "umo": "webchat:FriendMessage:open_api_test_session", + "message": "hello", + }, + headers={"X-API-Key": raw_key}, + ) + assert send_res.status_code == 200 + send_data = await send_res.get_json() + assert send_data["status"] == "ok" + + +@pytest.mark.asyncio +async def test_open_chat_send_auto_session_id_and_username( + app: Quart, + authenticated_header: dict, + core_lifecycle_td: AstrBotCoreLifecycle, +): + test_client = app.test_client() + + create_res = await test_client.post( + "/api/apikey/create", + json={"name": "chat-send-key", "scopes": ["chat"]}, + headers=authenticated_header, + ) + create_data = await create_res.get_json() + assert create_data["status"] == "ok" + raw_key = create_data["data"]["api_key"] + + rule = next( + ( + item + for item in app.url_map.iter_rules() + if item.rule == "/api/v1/chat" and "POST" in item.methods + ), + None, + ) + assert rule is not None + open_api_route = app.view_functions[rule.endpoint].__self__ + + original_chat = open_api_route.chat_route.chat + + async def fake_chat(post_data: dict | None = None): + payload = post_data or await request.get_json() + return ( + Response() + .ok( + data={ + "session_id": payload.get("session_id"), + "creator": g.get("username"), + } + ) + .__dict__ + ) + + open_api_route.chat_route.chat = fake_chat + try: + send_res = await test_client.post( + "/api/v1/chat", + json={ + "message": "hello", + "username": "alice", + "enable_streaming": False, + }, + headers={"X-API-Key": raw_key}, + ) + finally: + open_api_route.chat_route.chat = original_chat + + assert send_res.status_code == 200 + send_data = await send_res.get_json() + assert send_data["status"] == "ok" + created_session_id = send_data["data"]["session_id"] + assert isinstance(created_session_id, str) + uuid.UUID(created_session_id) + assert send_data["data"]["creator"] == "alice" + created_session = await core_lifecycle_td.db.get_platform_session_by_id( + created_session_id + ) + assert created_session is not None + assert created_session.creator == "alice" + assert created_session.platform_id == "webchat" + + await core_lifecycle_td.db.create_platform_session( + creator="bob", + platform_id="webchat", + session_id="open_api_existing_bob_session", + is_group=0, + ) + another_user_session_res = await test_client.post( + "/api/v1/chat", + json={ + "message": "hello", + "username": "alice", + "session_id": "open_api_existing_bob_session", + "enable_streaming": False, + }, + headers={"X-API-Key": raw_key}, + ) + another_user_session_data = await another_user_session_res.get_json() + assert another_user_session_data["status"] == "error" + assert ( + another_user_session_data["message"] + == "session_id belongs to another username" + ) + + missing_username_res = await test_client.post( + "/api/v1/chat", + json={"message": "hello"}, + headers={"X-API-Key": raw_key}, + ) + missing_username_data = await missing_username_res.get_json() + assert missing_username_data["status"] == "error" + assert missing_username_data["message"] == "Missing key: username" + + +@pytest.mark.asyncio +async def test_open_chat_sessions_pagination( + app: Quart, + authenticated_header: dict, + core_lifecycle_td: AstrBotCoreLifecycle, +): + test_client = app.test_client() + + create_res = await test_client.post( + "/api/apikey/create", + json={"name": "chat-scope-key", "scopes": ["chat"]}, + headers=authenticated_header, + ) + create_data = await create_res.get_json() + assert create_data["status"] == "ok" + raw_key = create_data["data"]["api_key"] + + creator = "alice" + for idx in range(3): + await core_lifecycle_td.db.create_platform_session( + creator=creator, + platform_id="webchat", + session_id=f"open_api_paginated_{idx}", + display_name=f"Open API Session {idx}", + is_group=0, + ) + await core_lifecycle_td.db.create_platform_session( + creator="bob", + platform_id="webchat", + session_id="open_api_paginated_bob", + display_name="Open API Session Bob", + is_group=0, + ) + + page_1_res = await test_client.get( + "/api/v1/chat/sessions?page=1&page_size=2&username=alice", + headers={"X-API-Key": raw_key}, + ) + assert page_1_res.status_code == 200 + page_1_data = await page_1_res.get_json() + assert page_1_data["status"] == "ok" + assert page_1_data["data"]["page"] == 1 + assert page_1_data["data"]["page_size"] == 2 + assert page_1_data["data"]["total"] == 3 + assert len(page_1_data["data"]["sessions"]) == 2 + assert all(item["creator"] == "alice" for item in page_1_data["data"]["sessions"]) + + page_2_res = await test_client.get( + "/api/v1/chat/sessions?page=2&page_size=2&username=alice", + headers={"X-API-Key": raw_key}, + ) + assert page_2_res.status_code == 200 + page_2_data = await page_2_res.get_json() + assert page_2_data["status"] == "ok" + assert page_2_data["data"]["page"] == 2 + assert len(page_2_data["data"]["sessions"]) == 1 + + missing_username_res = await test_client.get( + "/api/v1/chat/sessions?page=1&page_size=2", + headers={"X-API-Key": raw_key}, + ) + missing_username_data = await missing_username_res.get_json() + assert missing_username_data["status"] == "error" + assert missing_username_data["message"] == "Missing key: username" + + +@pytest.mark.asyncio +async def test_open_chat_configs_list( + app: Quart, + authenticated_header: dict, +): + test_client = app.test_client() + + create_res = await test_client.post( + "/api/apikey/create", + json={"name": "chat-config-key", "scopes": ["config"]}, + headers=authenticated_header, + ) + create_data = await create_res.get_json() + assert create_data["status"] == "ok" + raw_key = create_data["data"]["api_key"] + + configs_res = await test_client.get( + "/api/v1/configs", + headers={"X-API-Key": raw_key}, + ) + assert configs_res.status_code == 200 + configs_data = await configs_res.get_json() + assert configs_data["status"] == "ok" + assert isinstance(configs_data["data"]["configs"], list) + assert any(item["id"] == "default" for item in configs_data["data"]["configs"]) diff --git a/tests/test_smoke.py b/tests/test_smoke.py new file mode 100644 index 000000000..4474e1599 --- /dev/null +++ b/tests/test_smoke.py @@ -0,0 +1,115 @@ +"""Smoke tests for critical startup and import paths.""" + +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + +from astrbot.core.pipeline.bootstrap import ensure_builtin_stages_registered +from astrbot.core.pipeline.process_stage.method.agent_sub_stages.internal import ( + InternalAgentSubStage, +) +from astrbot.core.pipeline.process_stage.method.agent_sub_stages.third_party import ( + ThirdPartyAgentSubStage, +) +from astrbot.core.pipeline.stage import Stage, registered_stages +from astrbot.core.pipeline.stage_order import STAGES_ORDER + +REPO_ROOT = Path(__file__).resolve().parents[1] + + +def _run_code_in_fresh_interpreter(code: str, failure_message: str) -> None: + proc = subprocess.run( + [sys.executable, "-c", code], + cwd=REPO_ROOT, + capture_output=True, + text=True, + check=False, + ) + assert proc.returncode == 0, ( + f"{failure_message}\nstdout:\n{proc.stdout}\nstderr:\n{proc.stderr}\n" + ) + + +def test_smoke_critical_imports_in_fresh_interpreter() -> None: + code = ( + "import importlib;" + "mods=[" + "'astrbot.core.core_lifecycle'," + "'astrbot.core.astr_main_agent'," + "'astrbot.core.pipeline.scheduler'," + "'astrbot.core.pipeline.process_stage.method.agent_sub_stages.internal'," + "'astrbot.core.pipeline.process_stage.method.agent_sub_stages.third_party'" + "];" + "[importlib.import_module(m) for m in mods]" + ) + _run_code_in_fresh_interpreter(code, "Smoke import check failed.") + + +def test_smoke_pipeline_stage_registration_matches_order() -> None: + ensure_builtin_stages_registered() + stage_names = {cls.__name__ for cls in registered_stages} + + assert set(STAGES_ORDER).issubset(stage_names) + assert len(stage_names) == len(registered_stages) + + +def test_smoke_agent_sub_stages_are_stage_subclasses() -> None: + assert issubclass(InternalAgentSubStage, Stage) + assert issubclass(ThirdPartyAgentSubStage, Stage) + + +def test_pipeline_package_exports_remain_compatible() -> None: + import astrbot.core.pipeline as pipeline + + assert pipeline.ProcessStage is not None + assert pipeline.RespondStage is not None + assert isinstance(pipeline.STAGES_ORDER, list) + assert "ProcessStage" in pipeline.STAGES_ORDER + + +def test_builtin_stage_bootstrap_is_idempotent() -> None: + ensure_builtin_stages_registered() + before_count = len(registered_stages) + stage_names = {cls.__name__ for cls in registered_stages} + + expected_stage_names = { + "WakingCheckStage", + "WhitelistCheckStage", + "SessionStatusCheckStage", + "RateLimitStage", + "ContentSafetyCheckStage", + "PreProcessStage", + "ProcessStage", + "ResultDecorateStage", + "RespondStage", + } + + assert expected_stage_names.issubset(stage_names) + + ensure_builtin_stages_registered() + assert len(registered_stages) == before_count + + +def test_pipeline_import_is_stable_with_mocked_apscheduler() -> None: + """Regression: importing pipeline should not require cron/apscheduler modules.""" + code = ( + "import sys;" + "from unittest.mock import MagicMock;" + "mock_apscheduler = MagicMock();" + "mock_apscheduler.schedulers = MagicMock();" + "mock_apscheduler.schedulers.asyncio = MagicMock();" + "mock_apscheduler.schedulers.background = MagicMock();" + "sys.modules['apscheduler'] = mock_apscheduler;" + "sys.modules['apscheduler.schedulers'] = mock_apscheduler.schedulers;" + "sys.modules['apscheduler.schedulers.asyncio'] = mock_apscheduler.schedulers.asyncio;" + "sys.modules['apscheduler.schedulers.background'] = mock_apscheduler.schedulers.background;" + "import astrbot.core.pipeline as pipeline;" + "assert pipeline.ProcessStage is not None;" + "assert pipeline.RespondStage is not None" + ) + _run_code_in_fresh_interpreter( + code, + "Pipeline import should not depend on real apscheduler package.", + ) diff --git a/tests/test_tool_loop_agent_runner.py b/tests/test_tool_loop_agent_runner.py index f0e90002d..0b5190407 100644 --- a/tests/test_tool_loop_agent_runner.py +++ b/tests/test_tool_loop_agent_runner.py @@ -90,6 +90,43 @@ class MockToolExecutor: return generator() +class MockFailingProvider(MockProvider): + async def text_chat(self, **kwargs) -> LLMResponse: + self.call_count += 1 + raise RuntimeError("primary provider failed") + + +class MockErrProvider(MockProvider): + async def text_chat(self, **kwargs) -> LLMResponse: + self.call_count += 1 + return LLMResponse( + role="err", + completion_text="primary provider returned error", + ) + + +class MockAbortableStreamProvider(MockProvider): + async def text_chat_stream(self, **kwargs): + abort_signal = kwargs.get("abort_signal") + yield LLMResponse( + role="assistant", + completion_text="partial ", + is_chunk=True, + ) + if abort_signal and abort_signal.is_set(): + yield LLMResponse( + role="assistant", + completion_text="partial ", + is_chunk=False, + ) + return + yield LLMResponse( + role="assistant", + completion_text="partial final", + is_chunk=False, + ) + + class MockHooks(BaseAgentRunHooks): """模拟钩子函数""" @@ -321,6 +358,99 @@ async def test_hooks_called_with_max_step( assert mock_hooks.tool_end_called, "on_tool_end应该被调用" +@pytest.mark.asyncio +async def test_fallback_provider_used_when_primary_raises( + runner, provider_request, mock_tool_executor, mock_hooks +): + primary_provider = MockFailingProvider() + fallback_provider = MockProvider() + fallback_provider.should_call_tools = False + + await runner.reset( + provider=primary_provider, + request=provider_request, + run_context=ContextWrapper(context=None), + tool_executor=mock_tool_executor, + agent_hooks=mock_hooks, + streaming=False, + fallback_providers=[fallback_provider], + ) + + async for _ in runner.step_until_done(5): + pass + + final_resp = runner.get_final_llm_resp() + assert final_resp is not None + assert final_resp.role == "assistant" + assert final_resp.completion_text == "这是我的最终回答" + assert primary_provider.call_count == 1 + assert fallback_provider.call_count == 1 + + +@pytest.mark.asyncio +async def test_fallback_provider_used_when_primary_returns_err( + runner, provider_request, mock_tool_executor, mock_hooks +): + primary_provider = MockErrProvider() + fallback_provider = MockProvider() + fallback_provider.should_call_tools = False + + await runner.reset( + provider=primary_provider, + request=provider_request, + run_context=ContextWrapper(context=None), + tool_executor=mock_tool_executor, + agent_hooks=mock_hooks, + streaming=False, + fallback_providers=[fallback_provider], + ) + + async for _ in runner.step_until_done(5): + pass + + final_resp = runner.get_final_llm_resp() + assert final_resp is not None + assert final_resp.role == "assistant" + assert final_resp.completion_text == "这是我的最终回答" + assert primary_provider.call_count == 1 + assert fallback_provider.call_count == 1 + + +@pytest.mark.asyncio +async def test_stop_signal_returns_aborted_and_persists_partial_message( + runner, provider_request, mock_tool_executor, mock_hooks +): + provider = MockAbortableStreamProvider() + + await runner.reset( + provider=provider, + request=provider_request, + run_context=ContextWrapper(context=None), + tool_executor=mock_tool_executor, + agent_hooks=mock_hooks, + streaming=True, + ) + + step_iter = runner.step() + first_resp = await step_iter.__anext__() + assert first_resp.type == "streaming_delta" + + runner.request_stop() + + rest_responses = [] + async for response in step_iter: + rest_responses.append(response) + + assert any(resp.type == "aborted" for resp in rest_responses) + assert runner.was_aborted() is True + + final_resp = runner.get_final_llm_resp() + assert final_resp is not None + assert final_resp.role == "assistant" + assert final_resp.completion_text == "partial " + assert runner.run_context.messages[-1].role == "assistant" + + if __name__ == "__main__": # 运行测试 pytest.main([__file__, "-v"]) diff --git a/tests/unit/test_astr_message_event.py b/tests/unit/test_astr_message_event.py new file mode 100644 index 000000000..ac529318f --- /dev/null +++ b/tests/unit/test_astr_message_event.py @@ -0,0 +1,781 @@ +"""Tests for AstrMessageEvent class.""" + +import re +from unittest.mock import AsyncMock, patch + +import pytest + +from astrbot.core.message.components import ( + At, + AtAll, + Face, + Forward, + Image, + Plain, + Reply, +) +from astrbot.core.message.message_event_result import MessageEventResult +from astrbot.core.platform.astr_message_event import AstrMessageEvent +from astrbot.core.platform.astrbot_message import AstrBotMessage, MessageMember +from astrbot.core.platform.message_type import MessageType +from astrbot.core.platform.platform_metadata import PlatformMetadata + + +class ConcreteAstrMessageEvent(AstrMessageEvent): + """Concrete implementation of AstrMessageEvent for testing purposes.""" + + async def send(self, message): + """Send message implementation.""" + await super().send(message) + + +@pytest.fixture +def platform_meta(): + """Create platform metadata for testing.""" + return PlatformMetadata( + name="test_platform", + description="Test platform", + id="test_platform_id", + ) + + +@pytest.fixture +def message_member(): + """Create a message member for testing.""" + return MessageMember(user_id="user123", nickname="TestUser") + + +@pytest.fixture +def astrbot_message(message_member): + """Create an AstrBotMessage for testing.""" + message = AstrBotMessage() + message.type = MessageType.FRIEND_MESSAGE + message.self_id = "bot123" + message.session_id = "session123" + message.message_id = "msg123" + message.sender = message_member + message.message = [Plain(text="Hello world")] + message.message_str = "Hello world" + message.raw_message = None + return message + + +@pytest.fixture +def astr_message_event(platform_meta, astrbot_message): + """Create an AstrMessageEvent instance for testing.""" + return ConcreteAstrMessageEvent( + message_str="Hello world", + message_obj=astrbot_message, + platform_meta=platform_meta, + session_id="session123", + ) + + +class TestAstrMessageEventInit: + """Tests for AstrMessageEvent initialization.""" + + def test_init_basic(self, astr_message_event): + """Test basic AstrMessageEvent initialization.""" + assert astr_message_event.message_str == "Hello world" + assert astr_message_event.role == "member" + assert astr_message_event.is_wake is False + assert astr_message_event.is_at_or_wake_command is False + assert astr_message_event._extras == {} + assert astr_message_event._result is None + assert astr_message_event.call_llm is False + + def test_init_session(self, astr_message_event): + """Test session initialization.""" + assert astr_message_event.session_id == "session123" + assert astr_message_event.session.platform_name == "test_platform_id" + + def test_init_platform_reference(self, astr_message_event, platform_meta): + """Test platform reference initialization.""" + assert astr_message_event.platform_meta == platform_meta + assert astr_message_event.platform == platform_meta # back compatibility + + def test_init_created_at(self, astr_message_event): + """Test created_at timestamp is set.""" + assert astr_message_event.created_at is not None + assert isinstance(astr_message_event.created_at, float) + + def test_init_trace(self, astr_message_event): + """Test trace/span initialization.""" + assert astr_message_event.trace is not None + assert astr_message_event.span is not None + assert astr_message_event.trace == astr_message_event.span + + +class TestUnifiedMsgOrigin: + """Tests for unified_msg_origin property.""" + + def test_unified_msg_origin_getter(self, astr_message_event): + """Test unified_msg_origin getter.""" + expected = "test_platform_id:FriendMessage:session123" + assert astr_message_event.unified_msg_origin == expected + + def test_unified_msg_origin_setter(self, astr_message_event): + """Test unified_msg_origin setter.""" + astr_message_event.unified_msg_origin = "new_platform:GroupMessage:new_session" + + assert astr_message_event.session.platform_name == "new_platform" + assert astr_message_event.session.session_id == "new_session" + + +class TestSessionId: + """Tests for session_id property.""" + + def test_session_id_getter(self, astr_message_event): + """Test session_id getter.""" + assert astr_message_event.session_id == "session123" + + def test_session_id_setter(self, astr_message_event): + """Test session_id setter.""" + astr_message_event.session_id = "new_session_id" + + assert astr_message_event.session_id == "new_session_id" + + +class TestGetPlatformInfo: + """Tests for platform info methods.""" + + def test_get_platform_name(self, astr_message_event): + """Test get_platform_name method.""" + assert astr_message_event.get_platform_name() == "test_platform" + + def test_get_platform_id(self, astr_message_event): + """Test get_platform_id method.""" + assert astr_message_event.get_platform_id() == "test_platform_id" + + +class TestGetMessageInfo: + """Tests for message info methods.""" + + def test_get_message_str(self, astr_message_event): + """Test get_message_str method.""" + assert astr_message_event.get_message_str() == "Hello world" + + def test_get_message_str_none(self, platform_meta, astrbot_message): + """Test get_message_str keeps None when source message_str is None.""" + astrbot_message.message_str = None + event = ConcreteAstrMessageEvent( + message_str=None, + message_obj=astrbot_message, + platform_meta=platform_meta, + session_id="session123", + ) + assert event.get_message_str() is None + + def test_get_messages(self, astr_message_event): + """Test get_messages method.""" + messages = astr_message_event.get_messages() + assert len(messages) == 1 + assert isinstance(messages[0], Plain) + assert messages[0].text == "Hello world" + + def test_get_message_type(self, astr_message_event): + """Test get_message_type method.""" + assert astr_message_event.get_message_type() == MessageType.FRIEND_MESSAGE + + def test_get_session_id(self, astr_message_event): + """Test get_session_id method.""" + assert astr_message_event.get_session_id() == "session123" + + def test_get_group_id_empty_for_private(self, astr_message_event): + """Test get_group_id returns empty for private messages.""" + assert astr_message_event.get_group_id() == "" + + def test_get_self_id(self, astr_message_event): + """Test get_self_id method.""" + assert astr_message_event.get_self_id() == "bot123" + + def test_get_sender_id(self, astr_message_event): + """Test get_sender_id method.""" + assert astr_message_event.get_sender_id() == "user123" + + def test_get_sender_name(self, astr_message_event): + """Test get_sender_name method.""" + assert astr_message_event.get_sender_name() == "TestUser" + + def test_get_sender_name_empty_when_none(self, platform_meta, astrbot_message): + """Test get_sender_name returns empty string when nickname is None.""" + astrbot_message.sender = MessageMember(user_id="user123", nickname=None) + event = ConcreteAstrMessageEvent( + message_str="test", + message_obj=astrbot_message, + platform_meta=platform_meta, + session_id="session123", + ) + assert event.get_sender_name() == "" + + def test_get_sender_name_coerces_non_string(self, platform_meta, astrbot_message): + """Test get_sender_name stringifies non-string nickname values.""" + astrbot_message.sender = MessageMember(user_id="user123", nickname=None) + astrbot_message.sender.nickname = 12345 + event = ConcreteAstrMessageEvent( + message_str="test", + message_obj=astrbot_message, + platform_meta=platform_meta, + session_id="session123", + ) + assert event.get_sender_name() == "12345" + + +class TestGetMessageOutline: + """Tests for get_message_outline method.""" + + def test_outline_plain_text(self, astr_message_event): + """Test outline with plain text message.""" + outline = astr_message_event.get_message_outline() + assert "Hello world" in outline + + def test_outline_with_image(self, platform_meta, astrbot_message): + """Test outline with image component.""" + astrbot_message.message = [ + Plain(text="Look at this"), + Image(file="http://example.com/img.jpg"), + ] + event = ConcreteAstrMessageEvent( + message_str="Look at this", + message_obj=astrbot_message, + platform_meta=platform_meta, + session_id="session123", + ) + outline = event.get_message_outline() + assert "Look at this" in outline + assert "[图片]" in outline + + def test_outline_with_at(self, platform_meta, astrbot_message): + """Test outline with At component.""" + astrbot_message.message = [At(qq="12345"), Plain(text=" hello")] + event = ConcreteAstrMessageEvent( + message_str=" hello", + message_obj=astrbot_message, + platform_meta=platform_meta, + session_id="session123", + ) + outline = event.get_message_outline() + assert "[At:12345]" in outline + + def test_outline_with_at_all(self, platform_meta, astrbot_message): + """Test outline with AtAll component.""" + astrbot_message.message = [AtAll()] + event = ConcreteAstrMessageEvent( + message_str="", + message_obj=astrbot_message, + platform_meta=platform_meta, + session_id="session123", + ) + outline = event.get_message_outline() + # AtAll format is "[At:all]" in the actual implementation + assert "[At:" in outline and "all" in outline.lower() + + def test_outline_with_face(self, platform_meta, astrbot_message): + """Test outline with Face component.""" + astrbot_message.message = [Face(id="123")] + event = ConcreteAstrMessageEvent( + message_str="", + message_obj=astrbot_message, + platform_meta=platform_meta, + session_id="session123", + ) + outline = event.get_message_outline() + assert "[表情:123]" in outline + + def test_outline_with_forward(self, platform_meta, astrbot_message): + """Test outline with Forward component.""" + # Forward requires an id parameter + astrbot_message.message = [Forward(id="test_forward_id")] + event = ConcreteAstrMessageEvent( + message_str="", + message_obj=astrbot_message, + platform_meta=platform_meta, + session_id="session123", + ) + outline = event.get_message_outline() + assert "[转发消息]" in outline + + def test_outline_with_reply(self, platform_meta, astrbot_message): + """Test outline with Reply component.""" + # Reply requires an id parameter + reply = Reply(id="test_reply_id") + reply.message_str = "Original message" + reply.sender_nickname = "Sender" + astrbot_message.message = [reply, Plain(text=" reply")] + event = ConcreteAstrMessageEvent( + message_str=" reply", + message_obj=astrbot_message, + platform_meta=platform_meta, + session_id="session123", + ) + outline = event.get_message_outline() + assert "[引用消息(Sender: Original message)]" in outline + + def test_outline_with_reply_no_message(self, platform_meta, astrbot_message): + """Test outline with Reply component without message_str.""" + # Reply requires an id parameter + reply = Reply(id="test_reply_id") + reply.message_str = None + astrbot_message.message = [reply] + event = ConcreteAstrMessageEvent( + message_str="", + message_obj=astrbot_message, + platform_meta=platform_meta, + session_id="session123", + ) + outline = event.get_message_outline() + assert "[引用消息]" in outline + + def test_outline_empty_chain(self, platform_meta, astrbot_message): + """Test outline with empty message chain.""" + astrbot_message.message = [] + event = ConcreteAstrMessageEvent( + message_str="", + message_obj=astrbot_message, + platform_meta=platform_meta, + session_id="session123", + ) + outline = event.get_message_outline() + assert outline == "" + + def test_outline_very_long_plain_text(self, platform_meta, astrbot_message): + """Test outline generation for very long plain text content.""" + long_text = "A" * 20000 + astrbot_message.message = [Plain(text=long_text)] + event = ConcreteAstrMessageEvent( + message_str=long_text, + message_obj=astrbot_message, + platform_meta=platform_meta, + session_id="session123", + ) + outline = event.get_message_outline() + assert outline.startswith("A") + assert len(outline) >= 20000 + + +class TestExtras: + """Tests for extra information methods.""" + + def test_set_extra(self, astr_message_event): + """Test set_extra method.""" + astr_message_event.set_extra("key1", "value1") + assert astr_message_event._extras["key1"] == "value1" + + def test_get_extra_with_key(self, astr_message_event): + """Test get_extra with specific key.""" + astr_message_event.set_extra("key1", "value1") + assert astr_message_event.get_extra("key1") == "value1" + + def test_get_extra_with_default(self, astr_message_event): + """Test get_extra with default value.""" + result = astr_message_event.get_extra("nonexistent", "default_value") + assert result == "default_value" + + def test_get_extra_all(self, astr_message_event): + """Test get_extra without key returns all extras.""" + astr_message_event.set_extra("key1", "value1") + astr_message_event.set_extra("key2", "value2") + all_extras = astr_message_event.get_extra() + assert all_extras == {"key1": "value1", "key2": "value2"} + + def test_clear_extra(self, astr_message_event): + """Test clear_extra method.""" + astr_message_event.set_extra("key1", "value1") + astr_message_event.clear_extra() + assert astr_message_event._extras == {} + + +class TestSetResult: + """Tests for set_result method.""" + + def test_set_result_with_message_event_result(self, astr_message_event): + """Test set_result with MessageEventResult object.""" + result = MessageEventResult().message("Test message") + astr_message_event.set_result(result) + + assert astr_message_event._result == result + + def test_set_result_with_string(self, astr_message_event): + """Test set_result with string creates MessageEventResult.""" + astr_message_event.set_result("Test message") + + assert astr_message_event._result is not None + assert len(astr_message_event._result.chain) == 1 + assert isinstance(astr_message_event._result.chain[0], Plain) + + def test_set_result_with_empty_chain(self, astr_message_event): + """Test set_result handles empty chain correctly.""" + result = MessageEventResult() + # chain is already an empty list by default + astr_message_event.set_result(result) + + assert astr_message_event._result.chain == [] + + +class TestStopContinueEvent: + """Tests for stop_event and continue_event methods.""" + + def test_stop_event_creates_result_if_none(self, astr_message_event): + """Test stop_event creates result if none exists.""" + astr_message_event.stop_event() + + assert astr_message_event._result is not None + assert astr_message_event.is_stopped() is True + + def test_stop_event_with_existing_result(self, astr_message_event): + """Test stop_event with existing result.""" + astr_message_event.set_result(MessageEventResult().message("Test")) + astr_message_event.stop_event() + + assert astr_message_event.is_stopped() is True + + def test_continue_event_creates_result_if_none(self, astr_message_event): + """Test continue_event creates result if none exists.""" + astr_message_event.continue_event() + + assert astr_message_event._result is not None + assert astr_message_event.is_stopped() is False + + def test_continue_event_with_existing_result(self, astr_message_event): + """Test continue_event with existing result.""" + astr_message_event.set_result(MessageEventResult().message("Test")) + astr_message_event.stop_event() + astr_message_event.continue_event() + + assert astr_message_event.is_stopped() is False + + def test_is_stopped_default_false(self, astr_message_event): + """Test is_stopped returns False by default.""" + assert astr_message_event.is_stopped() is False + + +class TestIsPrivateChat: + """Tests for is_private_chat method.""" + + def test_is_private_chat_true(self, astr_message_event): + """Test is_private_chat returns True for friend message.""" + assert astr_message_event.is_private_chat() is True + + def test_is_private_chat_false(self, platform_meta, astrbot_message): + """Test is_private_chat returns False for group message.""" + astrbot_message.type = MessageType.GROUP_MESSAGE + event = ConcreteAstrMessageEvent( + message_str="test", + message_obj=astrbot_message, + platform_meta=platform_meta, + session_id="session123", + ) + assert event.is_private_chat() is False + + +class TestIsWakeUp: + """Tests for is_wake_up method.""" + + def test_is_wake_up_default_false(self, astr_message_event): + """Test is_wake_up returns False by default.""" + assert astr_message_event.is_wake_up() is False + + def test_is_wake_up_when_set(self, astr_message_event): + """Test is_wake_up returns True when is_wake is set.""" + astr_message_event.is_wake = True + assert astr_message_event.is_wake_up() is True + + +class TestIsAdmin: + """Tests for is_admin method.""" + + def test_is_admin_default_false(self, astr_message_event): + """Test is_admin returns False by default.""" + assert astr_message_event.is_admin() is False + + def test_is_admin_when_admin(self, astr_message_event): + """Test is_admin returns True when role is admin.""" + astr_message_event.role = "admin" + assert astr_message_event.is_admin() is True + + +class TestProcessBuffer: + """Tests for process_buffer method.""" + + @pytest.mark.asyncio + async def test_process_buffer_splits_by_pattern(self, astr_message_event): + """Test process_buffer splits buffer by pattern.""" + buffer = "Line 1\nLine 2\nLine 3\nRemaining" + pattern = re.compile(r".*\n") + + with patch.object( + astr_message_event, "send", new_callable=AsyncMock + ) as mock_send: + result = await astr_message_event.process_buffer(buffer, pattern) + + # Should have sent 3 lines and remaining should be "Remaining" + assert mock_send.call_count == 3 + assert result == "Remaining" + + @pytest.mark.asyncio + async def test_process_buffer_no_match(self, astr_message_event): + """Test process_buffer returns original when no match.""" + buffer = "No newlines here" + pattern = re.compile(r"\n") + + result = await astr_message_event.process_buffer(buffer, pattern) + + assert result == "No newlines here" + + +class TestResultHelpers: + """Tests for result helper methods.""" + + def test_make_result(self, astr_message_event): + """Test make_result creates empty MessageEventResult.""" + result = astr_message_event.make_result() + assert isinstance(result, MessageEventResult) + + def test_plain_result(self, astr_message_event): + """Test plain_result creates result with text.""" + result = astr_message_event.plain_result("Hello") + + assert isinstance(result, MessageEventResult) + assert len(result.chain) == 1 + assert isinstance(result.chain[0], Plain) + assert result.chain[0].text == "Hello" + + def test_image_result_url(self, astr_message_event): + """Test image_result with URL.""" + result = astr_message_event.image_result("http://example.com/image.jpg") + + assert isinstance(result, MessageEventResult) + assert len(result.chain) == 1 + assert isinstance(result.chain[0], Image) + + def test_image_result_path(self, astr_message_event): + """Test image_result with file path.""" + result = astr_message_event.image_result("/path/to/image.jpg") + + assert isinstance(result, MessageEventResult) + assert len(result.chain) == 1 + assert isinstance(result.chain[0], Image) + + +class TestGetResult: + """Tests for get_result and clear_result methods.""" + + def test_get_result_returns_none_by_default(self, astr_message_event): + """Test get_result returns None by default.""" + assert astr_message_event.get_result() is None + + def test_get_result_returns_set_result(self, astr_message_event): + """Test get_result returns set result.""" + result = MessageEventResult().message("Test") + astr_message_event.set_result(result) + + assert astr_message_event.get_result() == result + + def test_clear_result(self, astr_message_event): + """Test clear_result clears the result.""" + astr_message_event.set_result(MessageEventResult().message("Test")) + astr_message_event.clear_result() + + assert astr_message_event.get_result() is None + + +class TestShouldCallLlm: + """Tests for should_call_llm method.""" + + def test_should_call_llm_default(self, astr_message_event): + """Test call_llm default is False.""" + assert astr_message_event.call_llm is False + + def test_should_call_llm_when_set(self, astr_message_event): + """Test should_call_llm sets call_llm.""" + astr_message_event.should_call_llm(True) + assert astr_message_event.call_llm is True + + +class TestRequestLlm: + """Tests for request_llm method.""" + + def test_request_llm_basic(self, astr_message_event): + """Test request_llm creates ProviderRequest.""" + request = astr_message_event.request_llm(prompt="Hello") + + assert request.prompt == "Hello" + assert request.session_id == "" + assert request.image_urls == [] + assert request.contexts == [] + + def test_request_llm_with_all_params(self, astr_message_event): + """Test request_llm with all parameters.""" + request = astr_message_event.request_llm( + prompt="Hello", + session_id="session123", + image_urls=["http://example.com/img.jpg"], + contexts=[{"role": "user", "content": "Hi"}], + system_prompt="You are helpful", + ) + + assert request.prompt == "Hello" + assert request.session_id == "session123" + assert request.image_urls == ["http://example.com/img.jpg"] + assert request.contexts == [{"role": "user", "content": "Hi"}] + assert request.system_prompt == "You are helpful" + + +class TestSendStreaming: + """Tests for send_streaming method.""" + + @pytest.mark.asyncio + async def test_send_streaming_sets_has_send_oper(self, astr_message_event): + """Test send_streaming sets _has_send_oper flag.""" + assert astr_message_event._has_send_oper is False + + async def generator(): + yield MessageEventResult().message("Test") + + with patch( + "astrbot.core.platform.astr_message_event.Metric.upload", + new_callable=AsyncMock, + ): + await astr_message_event.send_streaming(generator()) + + assert astr_message_event._has_send_oper is True + + +class TestSendTyping: + """Tests for send_typing method.""" + + @pytest.mark.asyncio + async def test_send_typing_default_empty(self, astr_message_event): + """Test send_typing default implementation is empty.""" + # Should not raise any exception + await astr_message_event.send_typing() + + +class TestReact: + """Tests for react method.""" + + @pytest.mark.asyncio + async def test_react_sends_emoji(self, astr_message_event): + """Test react sends emoji as message.""" + with patch.object( + astr_message_event, "send", new_callable=AsyncMock + ) as mock_send: + await astr_message_event.react("👍") + + mock_send.assert_called_once() + call_arg = mock_send.call_args[0][0] + # MessageChain is a dataclass with chain attribute + assert len(call_arg.chain) == 1 + assert isinstance(call_arg.chain[0], Plain) + assert call_arg.chain[0].text == "👍" + + +class TestGetGroup: + """Tests for get_group method.""" + + @pytest.mark.asyncio + async def test_get_group_returns_none_for_private(self, astr_message_event): + """Test get_group returns None for private chat.""" + result = await astr_message_event.get_group() + assert result is None + + @pytest.mark.asyncio + async def test_get_group_with_group_id_param(self, astr_message_event): + """Test get_group with group_id parameter.""" + # Default implementation returns None + result = await astr_message_event.get_group(group_id="group123") + assert result is None + + +class TestMessageTypeHandling: + """Tests for message type handling edge cases.""" + + def test_message_type_from_valid_string(self, platform_meta): + """Valid MessageType string should be converted correctly.""" + message = AstrBotMessage() + message.type = "FRIEND_MESSAGE" + message.message = [] + event = ConcreteAstrMessageEvent( + message_str="test", + message_obj=message, + platform_meta=platform_meta, + session_id="session123", + ) + assert event.session.message_type == MessageType.FRIEND_MESSAGE + assert event.get_message_type() == MessageType.FRIEND_MESSAGE + + def test_message_type_from_invalid_string_defaults_to_friend(self, platform_meta): + """Invalid message type should default to FRIEND_MESSAGE.""" + message = AstrBotMessage() + message.type = "InvalidMessageType" + message.message = [] + event = ConcreteAstrMessageEvent( + message_str="test", + message_obj=message, + platform_meta=platform_meta, + session_id="session123", + ) + assert event.session.message_type == MessageType.FRIEND_MESSAGE + assert event.get_message_type() == MessageType.FRIEND_MESSAGE + + def test_message_type_from_none_defaults_to_friend(self, platform_meta): + """None message type should default to FRIEND_MESSAGE.""" + message = AstrBotMessage() + message.type = None + message.message = [] + event = ConcreteAstrMessageEvent( + message_str="test", + message_obj=message, + platform_meta=platform_meta, + session_id="session123", + ) + assert event.session.message_type == MessageType.FRIEND_MESSAGE + assert event.get_message_type() == MessageType.FRIEND_MESSAGE + + def test_message_type_from_integer_defaults_to_friend(self, platform_meta): + """Integer message type should default to FRIEND_MESSAGE.""" + message = AstrBotMessage() + message.type = 123 + message.message = [] + event = ConcreteAstrMessageEvent( + message_str="test", + message_obj=message, + platform_meta=platform_meta, + session_id="session123", + ) + assert event.session.message_type == MessageType.FRIEND_MESSAGE + assert event.get_message_type() == MessageType.FRIEND_MESSAGE + + +class TestDefensiveGetattr: + """Tests for defensive getattr behavior in AstrMessageEvent.""" + + def test_get_messages_without_message_attr(self, astr_message_event): + """get_messages should handle message_obj without 'message' attribute.""" + astr_message_event.message_obj = type("DummyMessage", (), {})() + messages = astr_message_event.get_messages() + assert isinstance(messages, list) + + def test_get_message_type_without_type_attr(self, astr_message_event): + """get_message_type should handle message_obj without 'type' attribute.""" + astr_message_event.message_obj = type("DummyMessage", (), {})() + message_type = astr_message_event.get_message_type() + assert isinstance(message_type, MessageType) + + def test_get_sender_fields_without_sender_attr(self, astr_message_event): + """get_sender_id and get_sender_name should handle missing 'sender'.""" + astr_message_event.message_obj = type("DummyMessage", (), {})() + sender_id = astr_message_event.get_sender_id() + sender_name = astr_message_event.get_sender_name() + assert isinstance(sender_id, str) + assert isinstance(sender_name, str) + + def test_get_message_type_with_non_enum_type(self, astr_message_event): + """get_message_type should handle message_obj.type that is not a MessageType.""" + class DummyMessage: + def __init__(self): + self.type = "not_an_enum" + self.message = [] + astr_message_event.message_obj = DummyMessage() + message_type = astr_message_event.get_message_type() + assert isinstance(message_type, MessageType) diff --git a/tests/unit/test_astrbot_message.py b/tests/unit/test_astrbot_message.py new file mode 100644 index 000000000..508a2727b --- /dev/null +++ b/tests/unit/test_astrbot_message.py @@ -0,0 +1,268 @@ +"""Tests for AstrBotMessage and MessageMember classes.""" + +import time +from unittest.mock import patch + +from astrbot.core.message.components import Image, Plain +from astrbot.core.platform.astrbot_message import AstrBotMessage, Group, MessageMember +from astrbot.core.platform.message_type import MessageType + + +class TestMessageMember: + """Tests for MessageMember dataclass.""" + + def test_message_member_creation_basic(self): + """Test creating a MessageMember with required fields.""" + member = MessageMember(user_id="user123") + + assert member.user_id == "user123" + assert member.nickname is None + + def test_message_member_creation_with_nickname(self): + """Test creating a MessageMember with nickname.""" + member = MessageMember(user_id="user123", nickname="TestUser") + + assert member.user_id == "user123" + assert member.nickname == "TestUser" + + def test_message_member_str_with_nickname(self): + """Test __str__ method with nickname.""" + member = MessageMember(user_id="user123", nickname="TestUser") + result = str(member) + + assert "User ID: user123" in result + assert "Nickname: TestUser" in result + + def test_message_member_str_without_nickname(self): + """Test __str__ method without nickname.""" + member = MessageMember(user_id="user123") + result = str(member) + + assert "User ID: user123" in result + assert "Nickname: N/A" in result + + +class TestGroup: + """Tests for Group dataclass.""" + + def test_group_creation_basic(self): + """Test creating a Group with required fields.""" + group = Group(group_id="group123") + + assert group.group_id == "group123" + assert group.group_name is None + assert group.group_avatar is None + assert group.group_owner is None + assert group.group_admins is None + assert group.members is None + + def test_group_creation_with_all_fields(self): + """Test creating a Group with all fields.""" + members = [MessageMember(user_id="user1"), MessageMember(user_id="user2")] + group = Group( + group_id="group123", + group_name="Test Group", + group_avatar="http://example.com/avatar.jpg", + group_owner="owner123", + group_admins=["admin1", "admin2"], + members=members, + ) + + assert group.group_id == "group123" + assert group.group_name == "Test Group" + assert group.group_avatar == "http://example.com/avatar.jpg" + assert group.group_owner == "owner123" + assert group.group_admins == ["admin1", "admin2"] + assert group.members == members + + def test_group_str_with_all_fields(self): + """Test __str__ method with all fields.""" + members = [MessageMember(user_id="user1", nickname="User One")] + group = Group( + group_id="group123", + group_name="Test Group", + group_avatar="http://example.com/avatar.jpg", + group_owner="owner123", + group_admins=["admin1"], + members=members, + ) + result = str(group) + + assert "Group ID: group123" in result + assert "Name: Test Group" in result + assert "Avatar: http://example.com/avatar.jpg" in result + assert "Owner ID: owner123" in result + assert "Admin IDs: ['admin1']" in result + assert "Members Len: 1" in result + + def test_group_str_with_minimal_fields(self): + """Test __str__ method with minimal fields.""" + group = Group(group_id="group123") + result = str(group) + + assert "Group ID: group123" in result + assert "Name: N/A" in result + assert "Avatar: N/A" in result + assert "Owner ID: N/A" in result + assert "Admin IDs: N/A" in result + assert "Members Len: 0" in result + assert "First Member: N/A" in result + + +class TestAstrBotMessage: + """Tests for AstrBotMessage class.""" + + def test_astrbot_message_creation(self): + """Test creating an AstrBotMessage.""" + message = AstrBotMessage() + + assert message.group is None + assert message.timestamp is not None + assert isinstance(message.timestamp, int) + + def test_astrbot_message_timestamp(self): + """Test timestamp is set on creation.""" + with patch.object(time, "time", return_value=1234567890): + message = AstrBotMessage() + assert message.timestamp == 1234567890 + + def test_astrbot_message_all_attributes(self): + """Test setting all attributes on AstrBotMessage.""" + message = AstrBotMessage() + message.type = MessageType.FRIEND_MESSAGE + message.self_id = "bot123" + message.session_id = "session123" + message.message_id = "msg123" + message.sender = MessageMember(user_id="user123", nickname="TestUser") + message.message = [Plain(text="Hello")] + message.message_str = "Hello" + message.raw_message = {"raw": "data"} + + assert message.type == MessageType.FRIEND_MESSAGE + assert message.self_id == "bot123" + assert message.session_id == "session123" + assert message.message_id == "msg123" + assert message.sender.user_id == "user123" + assert len(message.message) == 1 + assert message.message_str == "Hello" + assert message.raw_message == {"raw": "data"} + + def test_astrbot_message_str(self): + """Test __str__ method.""" + message = AstrBotMessage() + message.type = MessageType.FRIEND_MESSAGE + message.self_id = "bot123" + + result = str(message) + assert "'type'" in result + assert "'self_id'" in result + + +class TestAstrBotMessageGroupId: + """Tests for AstrBotMessage group_id property.""" + + def test_group_id_returns_empty_when_no_group(self): + """Test group_id returns empty string when group is None.""" + message = AstrBotMessage() + assert message.group_id == "" + + def test_group_id_returns_group_id_when_group_exists(self): + """Test group_id returns the group's id when group exists.""" + message = AstrBotMessage() + message.group = Group(group_id="group123") + + assert message.group_id == "group123" + + def test_group_id_setter_creates_new_group(self): + """Test group_id setter creates a new group if none exists.""" + message = AstrBotMessage() + message.group_id = "new_group123" + + assert message.group is not None + assert message.group.group_id == "new_group123" + + def test_group_id_setter_updates_existing_group(self): + """Test group_id setter updates existing group's id.""" + message = AstrBotMessage() + message.group = Group(group_id="old_group") + message.group_id = "new_group" + + assert message.group.group_id == "new_group" + + def test_group_id_setter_with_none_removes_group(self): + """Test group_id setter with None removes the group.""" + message = AstrBotMessage() + message.group = Group(group_id="group123") + message.group_id = None + + assert message.group is None + + def test_group_id_setter_with_empty_string_removes_group(self): + """Test group_id setter with empty string removes the group.""" + message = AstrBotMessage() + message.group = Group(group_id="group123") + message.group_id = "" + + assert message.group is None + + +class TestAstrBotMessageTypes: + """Tests for AstrBotMessage with different message types.""" + + def test_friend_message_type(self): + """Test AstrBotMessage with FRIEND_MESSAGE type.""" + message = AstrBotMessage() + message.type = MessageType.FRIEND_MESSAGE + + assert message.type == MessageType.FRIEND_MESSAGE + assert message.type.value == "FriendMessage" + + def test_group_message_type(self): + """Test AstrBotMessage with GROUP_MESSAGE type.""" + message = AstrBotMessage() + message.type = MessageType.GROUP_MESSAGE + + assert message.type == MessageType.GROUP_MESSAGE + assert message.type.value == "GroupMessage" + + def test_other_message_type(self): + """Test AstrBotMessage with OTHER_MESSAGE type.""" + message = AstrBotMessage() + message.type = MessageType.OTHER_MESSAGE + + assert message.type == MessageType.OTHER_MESSAGE + assert message.type.value == "OtherMessage" + + +class TestAstrBotMessageChain: + """Tests for AstrBotMessage message chain.""" + + def test_message_chain_with_plain_text(self): + """Test message chain with plain text.""" + message = AstrBotMessage() + message.message = [Plain(text="Hello world")] + + assert len(message.message) == 1 + assert isinstance(message.message[0], Plain) + assert message.message[0].text == "Hello world" + + def test_message_chain_with_multiple_components(self): + """Test message chain with multiple components.""" + message = AstrBotMessage() + message.message = [ + Plain(text="Hello "), + Plain(text="world"), + Image(file="http://example.com/img.jpg"), + ] + + assert len(message.message) == 3 + assert isinstance(message.message[0], Plain) + assert isinstance(message.message[1], Plain) + assert isinstance(message.message[2], Image) + + def test_message_chain_empty(self): + """Test empty message chain.""" + message = AstrBotMessage() + message.message = [] + + assert len(message.message) == 0
💙 Ролевые игры & Эмоциональная поддержка✨ Проактивный Агент(Agent)🚀 Универсальные Агентные возможности🧩 Универсальные Агентные (Agentic) возможности✨ Проактивный Агент (Agent)🚀 Универсальные возможности Агента🧩 1000+ плагинов сообщества

99b587c5d35eea09d84f33e6cf6cfd4f

💙 角色扮演 & 情感陪伴 ✨ 主動式 Agent 🚀 通用 Agentic 能力🧩 900+ 社區外掛程式🧩 1000+ 社區外掛程式

99b587c5d35eea09d84f33e6cf6cfd4f

{{ tm('apiKey.table.name') }}{{ tm('apiKey.table.prefix') }}{{ tm('apiKey.table.scopes') }}{{ tm('apiKey.table.status') }}{{ tm('apiKey.table.lastUsed') }}{{ tm('apiKey.table.createdAt') }}{{ tm('apiKey.table.actions') }}
{{ item.name }}{{ item.key_prefix }}{{ (item.scopes || []).join(', ') }} + + {{ item.is_revoked || item.is_expired ? tm('apiKey.status.inactive') : tm('apiKey.status.active') }} + + {{ formatDate(item.last_used_at) }}{{ formatDate(item.created_at) }} + + {{ tm('apiKey.revoke') }} + + + {{ tm('apiKey.delete') }} + +
+ {{ tm('apiKey.empty') }} +