From 0021cfc4bc297a73857cdec8c7c49baeef5e7ce9 Mon Sep 17 00:00:00 2001 From: Xu Void <55913486+Nothingness-Void@users.noreply.github.com> Date: Thu, 6 Feb 2025 15:28:28 +0800 Subject: [PATCH 1/3] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E8=BF=87=E6=BB=A4?= =?UTF-8?q?=E6=8E=89=E6=AD=A3=E5=88=99=E8=A1=A8=E8=BE=BE=E5=BC=8F=E5=86=85?= =?UTF-8?q?=E5=AE=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #338 新增过滤掉正则表达式内容 --- For more details, open the [Copilot Workspace session](https://copilot-workspace.githubnext.com/Soulter/AstrBot/issues/338?shareId=XXXX-XXXX-XXXX-XXXX). --- astrbot/core/config/default.py | 8 +++++++- astrbot/core/pipeline/result_decorate/stage.py | 5 ++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index b687adbde..56a7e5c1e 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -30,7 +30,8 @@ DEFAULT_CONFIG = { "only_llm_result": True, "interval": "1.5,3.5", "seg_prompt": "", - "regex": ".*?[。?!~…]+|.+$" + "regex": ".*?[。?!~…]+|.+$", + "filter_regex_content": False }, "no_permission_reply": True, }, @@ -229,6 +230,11 @@ CONFIG_METADATA_2 = { "obvious_hint": True, "hint": "用于分隔一段消息。默认情况下会根据句号、问号等标点符号分隔。re.findall(r'', text)", }, + "filter_regex_content": { + "description": "过滤正则表达式内容", + "type": "bool", + "hint": "启用后,分段回复时会过滤掉正则表达式匹配的内容。", + }, }, }, "reply_prefix": { diff --git a/astrbot/core/pipeline/result_decorate/stage.py b/astrbot/core/pipeline/result_decorate/stage.py index 332e1cef4..7c501247a 100644 --- a/astrbot/core/pipeline/result_decorate/stage.py +++ b/astrbot/core/pipeline/result_decorate/stage.py @@ -25,6 +25,7 @@ class ResultDecorateStage: self.only_llm_result = ctx.astrbot_config['platform_settings']['segmented_reply']['only_llm_result'] self.seg_prompt = ctx.astrbot_config['platform_settings']['segmented_reply']['seg_prompt'] self.regex = ctx.astrbot_config['platform_settings']['segmented_reply']['regex'] + self.filter_regex_content = ctx.astrbot_config['platform_settings']['segmented_reply'].get('filter_regex_content', False) async def process(self, event: AstrMessageEvent) -> Union[None, AsyncGenerator[None, None]]: result = event.get_result() @@ -69,6 +70,8 @@ class ResultDecorateStage: continue for seg in split_response: if seg: + if self.filter_regex_content: + seg = re.sub(self.regex, '', seg) new_chain.append(Plain(seg)) else: # 非 Plain 类型的消息段不分段 @@ -126,4 +129,4 @@ class ResultDecorateStage: # 引用回复 if self.reply_with_quote: - result.chain.insert(0, Reply(id=event.message_obj.message_id)) \ No newline at end of file + result.chain.insert(0, Reply(id=event.message_obj.message_id)) From 7155b4f0ac1177744e483f4628fbf7ed1de476a1 Mon Sep 17 00:00:00 2001 From: Xu Void <55913486+Nothingness-Void@users.noreply.github.com> Date: Sat, 8 Feb 2025 10:16:31 +0800 Subject: [PATCH 2/3] Update default.py --- astrbot/core/config/default.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index 56a7e5c1e..718131385 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -2,7 +2,7 @@ 如需修改配置,请在 `data/cmd_config.json` 中修改或者在管理面板中可视化修改。 """ -VERSION = "3.4.20" +VERSION = "3.4.24" DB_PATH = "data/data_v3.db" # 默认配置 From 8e9fd270582732a9cb2b5f9b37f82d0b3563f0dd Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Sun, 16 Feb 2025 15:17:44 +0800 Subject: [PATCH 3/3] merge branch `master` --- .gitignore | 4 +- Dockerfile | 4 +- README.md | 10 +- README_ja.md | 170 ++++++++++ astrbot/core/__init__.py | 3 +- astrbot/core/config/default.py | 151 +++++++-- astrbot/core/conversation_mgr.py | 1 + astrbot/core/core_lifecycle.py | 3 +- astrbot/core/db/sqlite.py | 4 + astrbot/core/message/components.py | 24 +- .../pipeline/content_safety_check/stage.py | 9 +- .../process_stage/method/dify_request.py | 2 + .../process_stage/method/llm_request.py | 7 +- .../process_stage/method/star_request.py | 2 +- astrbot/core/pipeline/process_stage/stage.py | 1 - astrbot/core/pipeline/respond/stage.py | 50 ++- .../core/pipeline/result_decorate/stage.py | 80 +++-- astrbot/core/pipeline/scheduler.py | 4 +- astrbot/core/pipeline/waking_check/stage.py | 8 +- astrbot/core/platform/astr_message_event.py | 18 +- astrbot/core/platform/manager.py | 37 ++- .../aiocqhttp/aiocqhttp_message_event.py | 30 +- .../aiocqhttp/aiocqhttp_platform_adapter.py | 91 +++++- .../core/platform/sources/gewechat/client.py | 52 ++- .../sources/gewechat/gewechat_event.py | 69 +++- .../gewechat/gewechat_platform_adapter.py | 4 - .../platform/sources/lark/lark_adapter.py | 175 ++++++++++ .../core/platform/sources/lark/lark_event.py | 96 ++++++ .../qqofficial/qqofficial_message_event.py | 27 +- .../qqofficial/qqofficial_platform_adapter.py | 13 +- .../qqofficial_webhook/qo_webhook_adapter.py | 99 ++++++ .../qqofficial_webhook/qo_webhook_event.py | 18 ++ .../qqofficial_webhook/qo_webhook_server.py | 108 +++++++ .../sources/vchat/vchat_message_event.py | 44 --- .../sources/vchat/vchat_platform_adapter.py | 120 ------- .../platform/sources/webchat/webchat_event.py | 5 +- astrbot/core/provider/entites.py | 2 +- astrbot/core/provider/manager.py | 20 +- astrbot/core/provider/sources/dify_source.py | 23 +- .../sources/fishaudio_tts_api_source.py | 105 ++++++ .../core/provider/sources/gemini_source.py | 30 +- .../core/provider/sources/openai_source.py | 50 ++- astrbot/core/star/context.py | 24 +- astrbot/core/star/filter/command.py | 2 +- astrbot/core/star/filter/command_group.py | 2 +- astrbot/core/star/filter/regex.py | 1 + astrbot/core/star/register/star_handler.py | 75 +++-- astrbot/core/star/star.py | 5 +- astrbot/core/star/star_handler.py | 69 +++- astrbot/core/star/star_manager.py | 108 +++++-- astrbot/core/star/updator.py | 10 +- astrbot/core/utils/dify_api_client.py | 125 ++++++-- astrbot/core/utils/io.py | 21 +- astrbot/core/utils/t2i/network_strategy.py | 11 + astrbot/core/utils/t2i/renderer.py | 2 +- astrbot/core/utils/tencent_record_helper.py | 36 ++- astrbot/core/zip_updator.py | 22 +- astrbot/dashboard/routes/config.py | 9 +- astrbot/dashboard/routes/plugin.py | 101 +++++- astrbot/dashboard/server.py | 29 +- changelogs/v3.4.21.md | 19 ++ changelogs/v3.4.22.md | 12 + changelogs/v3.4.23.md | 11 + changelogs/v3.4.24.md | 11 + changelogs/v3.4.25.md | 9 + changelogs/v3.4.26.md | 12 + changelogs/v3.4.27.md | 14 + dashboard/package.json | 2 +- .../src/components/shared/AstrBotConfig.vue | 105 +++--- .../src/components/shared/ExtensionCard.vue | 14 +- .../src/components/shared/ListConfigItem.vue | 93 ++++++ .../full/vertical-sidebar/VerticalSidebar.vue | 6 +- .../full/vertical-sidebar/sidebarItem.ts | 9 +- dashboard/src/router/MainRoutes.ts | 5 + dashboard/src/views/ConfigPage.vue | 18 +- dashboard/src/views/ExtensionPage.vue | 298 +++++++++++++++--- dashboard/src/views/Settings.vue | 52 +++ .../default/components/MessageStat.vue | 5 + packages/astrbot/long_term_memory.py | 22 +- packages/astrbot/main.py | 282 +++++++++++++---- packages/reminder/main.py | 4 +- .../engines/{engine.py => __init__.py} | 31 +- packages/web_searcher/engines/bing.py | 22 +- packages/web_searcher/engines/config.py | 20 -- packages/web_searcher/engines/google.py | 3 +- packages/web_searcher/engines/sogo.py | 4 +- packages/web_searcher/main.py | 12 +- requirements.txt | 6 +- tests/test_plugin_manager.py | 1 - 89 files changed, 2755 insertions(+), 772 deletions(-) create mode 100644 README_ja.md create mode 100644 astrbot/core/platform/sources/lark/lark_adapter.py create mode 100644 astrbot/core/platform/sources/lark/lark_event.py create mode 100644 astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_adapter.py create mode 100644 astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_event.py create mode 100644 astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_server.py delete mode 100644 astrbot/core/platform/sources/vchat/vchat_message_event.py delete mode 100644 astrbot/core/platform/sources/vchat/vchat_platform_adapter.py create mode 100644 astrbot/core/provider/sources/fishaudio_tts_api_source.py create mode 100644 changelogs/v3.4.21.md create mode 100644 changelogs/v3.4.22.md create mode 100644 changelogs/v3.4.23.md create mode 100644 changelogs/v3.4.24.md create mode 100644 changelogs/v3.4.25.md create mode 100644 changelogs/v3.4.26.md create mode 100644 changelogs/v3.4.27.md create mode 100644 dashboard/src/components/shared/ListConfigItem.vue create mode 100644 dashboard/src/views/Settings.vue rename packages/web_searcher/engines/{engine.py => __init__.py} (56%) delete mode 100644 packages/web_searcher/engines/config.py diff --git a/.gitignore b/.gitignore index 8d004e5c3..7745d18ba 100644 --- a/.gitignore +++ b/.gitignore @@ -21,4 +21,6 @@ node_modules/ .DS_Store package-lock.json package.json -venv/* \ No newline at end of file +venv/* +packages/python_interpreter/workplace +.venv/* diff --git a/Dockerfile b/Dockerfile index 055d37bae..0b46d5d48 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,7 +12,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* -RUN python -m pip install -r requirements.txt +RUN python -m pip install -r requirements.txt --no-cache-dir + +RUN python -m pip install socksio wechatpy cryptography --no-cache-dir EXPOSE 6185 EXPOSE 6186 diff --git a/README.md b/README.md index 30f625be5..bf4748809 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,6 @@

-![logo](https://github.com/user-attachments/assets/07649e07-3b8e-4feb-9aa9-bf13af4f3476) - +![6e1279651f16d7fdf4727558b72bbaf1](https://github.com/user-attachments/assets/ead4c551-fc3c-48f7-a6f7-afbfdb820512)

@@ -14,11 +13,12 @@ _✨ 易上手的多平台 LLM 聊天机器人及开发框架 ✨_ [![GitHub release (latest by date)](https://img.shields.io/github/v/release/Soulter/AstrBot)](https://github.com/Soulter/AstrBot/releases/latest) python Docker pull -Static Badge +Static Badge [![wakatime](https://wakatime.com/badge/user/915e5316-99c6-4563-a483-ef186cf000c9/project/018e705a-a1a7-409a-a849-3013485e6c8e.svg)](https://wakatime.com/badge/user/915e5316-99c6-4563-a483-ef186cf000c9/project/018e705a-a1a7-409a-a849-3013485e6c8e) ![Dynamic JSON Badge](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fapi.soulter.top%2Fastrbot%2Fstats&query=v&label=7%E6%97%A5%E6%B6%88%E6%81%AF%E4%B8%8A%E8%A1%8C%E9%87%8F&cacheSeconds=3600) [![codecov](https://codecov.io/gh/Soulter/AstrBot/graph/badge.svg?token=FF3P5967B8)](https://codecov.io/gh/Soulter/AstrBot) +日本語查看文档问题提交 @@ -28,7 +28,7 @@ AstrBot 是一个松耦合、异步、支持多消息平台部署、具有易用 ## ✨ 主要功能 1. **大语言模型对话**。支持各种大语言模型,包括 OpenAI API、Google Gemini、Llama、Deepseek、ChatGLM 等,支持接入本地部署的大模型,通过 Ollama、LLMTuner。具有多轮对话、人格情境、多模态能力,支持图片理解、语音转文字(Whisper)。 -2. **多消息平台接入**。支持接入 QQ(OneBot)、QQ 频道、微信(Gewechat、VChat)、Telegram。后续将支持钉钉、飞书、Discord、WhatsApp、小爱音响。支持速率限制、白名单、关键词过滤、百度内容审核。 +2. **多消息平台接入**。支持接入 QQ(OneBot)、QQ 频道、微信(Gewechat)、飞书、Telegram。后续将支持钉钉、Discord、WhatsApp、小爱音响。支持速率限制、白名单、关键词过滤、百度内容审核。 3. **Agent**。原生支持部分 Agent 能力,如代码执行器、自然语言待办、网页搜索。对接 [Dify 平台](https://astrbot.app/others/dify.html),便捷接入 Dify 智能助手、知识库和 Dify 工作流。 4. **插件扩展**。深度优化的插件机制,支持[开发插件](https://astrbot.app/dev/plugin.html)扩展功能,极简开发。已支持安装多个插件。 5. **可视化管理面板**。支持可视化修改配置、插件管理、日志查看等功能,降低配置难度。集成 WebChat,可在面板上与大模型对话。 @@ -73,8 +73,8 @@ AstrBot 是一个松耦合、异步、支持多消息平台部署、具有易用 | 微信(个人号) | ✔ | 微信个人号私聊、群聊 | 文字、图片、语音 | | [Telegram](https://github.com/Soulter/astrbot_plugin_telegram) | ✔ | 私聊、群聊 | 文字、图片 | | [微信(企业微信)](https://github.com/Soulter/astrbot_plugin_wecom) | ✔ | 私聊 | 文字、图片、语音 | +| 飞书 | ✔ | 群聊 | 文字、图片 | | 微信对话开放平台 | 🚧 | 计划内 | - | -| 飞书 | 🚧 | 计划内 | - | | Discord | 🚧 | 计划内 | - | | WhatsApp | 🚧 | 计划内 | - | | 小爱音响 | 🚧 | 计划内 | - | diff --git a/README_ja.md b/README_ja.md new file mode 100644 index 000000000..03092818d --- /dev/null +++ b/README_ja.md @@ -0,0 +1,170 @@ +

+ +![6e1279651f16d7fdf4727558b72bbaf1](https://github.com/user-attachments/assets/ead4c551-fc3c-48f7-a6f7-afbfdb820512) + +

+ +
+ +_✨ 簡単に使えるマルチプラットフォーム LLM チャットボットおよび開発フレームワーク ✨_ + +Soulter%2FAstrBot | Trendshift + +[![GitHub release (latest by date)](https://img.shields.io/github/v/release/Soulter/AstrBot)](https://github.com/Soulter/AstrBot/releases/latest) +python +Docker pull +Static Badge +[![wakatime](https://wakatime.com/badge/user/915e5316-99c6-4563-a483-ef186cf000c9/project/018e705a-a1a7-409a-a849-3013485e6c8e.svg)](https://wakatime.com/badge/user/915e5316-99c6-4563-a483-ef186cf000c9/project/018e705a-a1a7-409a-a849-3013485e6c8e) +![Dynamic JSON Badge](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fapi.soulter.top%2Fastrbot%2Fstats&query=v&label=7%E6%97%A5%E6%B6%88%E6%81%AF%E4%B8%8A%E8%A1%8C%E9%87%8F&cacheSeconds=3600) +[![codecov](https://codecov.io/gh/Soulter/AstrBot/graph/badge.svg?token=FF3P5967B8)](https://codecov.io/gh/Soulter/AstrBot) + +ドキュメントを見る | +問題を報告する +
+ +AstrBot は、疎結合、非同期、複数のメッセージプラットフォームに対応したデプロイ、使いやすいプラグインシステム、および包括的な大規模言語モデル(LLM)接続機能を備えたチャットボットおよび開発フレームワークです。 + +## ✨ 主な機能 + +1. **大規模言語モデルの対話**。OpenAI API、Google Gemini、Llama、Deepseek、ChatGLM など、さまざまな大規模言語モデルをサポートし、Ollama、LLMTuner を介してローカルにデプロイされた大規模モデルをサポートします。多輪対話、人格シナリオ、多モーダル機能を備え、画像理解、音声からテキストへの変換(Whisper)をサポートします。 +2. **複数のメッセージプラットフォームの接続**。QQ(OneBot)、QQ チャンネル、WeChat(Gewechat)、Feishu、Telegram への接続をサポートします。今後、DingTalk、Discord、WhatsApp、Xiaoai 音響をサポートする予定です。レート制限、ホワイトリスト、キーワードフィルタリング、Baidu コンテンツ監査をサポートします。 +3. **エージェント**。一部のエージェント機能をネイティブにサポートし、コードエグゼキューター、自然言語タスク、ウェブ検索などを提供します。[Dify プラットフォーム](https://astrbot.app/others/dify.html)と連携し、Dify スマートアシスタント、ナレッジベース、Dify ワークフローを簡単に接続できます。 +4. **プラグインの拡張**。深く最適化されたプラグインメカニズムを備え、[プラグインの開発](https://astrbot.app/dev/plugin.html)をサポートし、機能を拡張できます。複数のプラグインのインストールをサポートします。 +5. **ビジュアル管理パネル**。設定の視覚的な変更、プラグイン管理、ログの表示などをサポートし、設定の難易度を低減します。WebChat を統合し、パネル上で大規模モデルと対話できます。 +6. **高い安定性と高いモジュール性**。イベントバスとパイプラインに基づくアーキテクチャ設計により、高度にモジュール化され、低結合です。 + +> [!TIP] +> 管理パネルのオンラインデモを体験する: [https://demo.astrbot.app/](https://demo.astrbot.app/) +> +> ユーザー名: `astrbot`, パスワード: `astrbot`。LLM が設定されていないため、チャットページで大規模モデルを使用することはできません。(デモのログインパスワードを変更しないでください 😭) + +## ✨ 使用方法 + +#### Docker デプロイ + +公式ドキュメント [Docker を使用して AstrBot をデプロイする](https://astrbot.app/deploy/astrbot/docker.html#%E4%BD%BF%E7%94%A8-docker-%E9%83%A8%E7%BD%B2-astrbot) を参照してください。 + +#### Windows ワンクリックインストーラーのデプロイ + +コンピュータに Python(>3.10)がインストールされている必要があります。公式ドキュメント [Windows ワンクリックインストーラーを使用して AstrBot をデプロイする](https://astrbot.app/deploy/astrbot/windows.html) を参照してください。 + +#### Replit デプロイ + +[![Run on Repl.it](https://repl.it/badge/github/Soulter/AstrBot)](https://repl.it/github/Soulter/AstrBot) + +#### CasaOS デプロイ + +コミュニティが提供するデプロイ方法です。 + +公式ドキュメント [ソースコードを使用して AstrBot をデプロイする](https://astrbot.app/deploy/astrbot/casaos.html) を参照してください。 + +#### 手動デプロイ + +公式ドキュメント [ソースコードを使用して AstrBot をデプロイする](https://astrbot.app/deploy/astrbot/cli.html) を参照してください。 + +## ⚡ メッセージプラットフォームのサポート状況 + +| プラットフォーム | サポート状況 | 詳細 | メッセージタイプ | +| -------- | ------- | ------- | ------ | +| QQ(公式ロボットインターフェース) | ✔ | プライベートチャット、グループチャット、QQ チャンネルプライベートチャット、グループチャット | テキスト、画像 | +| QQ(OneBot) | ✔ | プライベートチャット、グループチャット | テキスト、画像、音声 | +| WeChat(個人アカウント) | ✔ | WeChat 個人アカウントのプライベートチャット、グループチャット | テキスト、画像、音声 | +| [Telegram](https://github.com/Soulter/astrbot_plugin_telegram) | ✔ | プライベートチャット、グループチャット | テキスト、画像 | +| [WeChat(企業 WeChat)](https://github.com/Soulter/astrbot_plugin_wecom) | ✔ | プライベートチャット | テキスト、画像、音声 | +| Feishu | ✔ | グループチャット | テキスト、画像 | +| WeChat 対話オープンプラットフォーム | 🚧 | 計画中 | - | +| Discord | 🚧 | 計画中 | - | +| WhatsApp | 🚧 | 計画中 | - | +| Xiaoai 音響 | 🚧 | 計画中 | - | + +# 🦌 今後のロードマップ + +> [!TIP] +> Issue でさらに多くの提案を歓迎します <3 + +- [ ] 現在のすべてのプラットフォームアダプターの機能の一貫性を確保し、改善する +- [ ] プラグインインターフェースの最適化 +- [ ] GPT-Sovits などの TTS サービスをデフォルトでサポート +- [ ] "チャット強化" 部分を完成させ、永続的な記憶をサポート +- [ ] i18n の計画 + +## ❤️ 貢献 + +Issue や Pull Request を歓迎します!このプロジェクトに変更を加えるだけです :) + +新機能の追加については、まず Issue で議論してください。 + +## 🌟 サポート + +- このプロジェクトに Star を付けてください! +- [愛発電](https://afdian.com/a/soulter)で私をサポートしてください! +- [WeChat](https://drive.soulter.top/f/pYfA/d903f4fa49a496fda3f16d2be9e023b5.png)で私をサポートしてください~ + +## ✨ デモ + +> [!NOTE] +> コードエグゼキューターのファイル入力/出力は現在 Napcat(QQ)、Lagrange(QQ) でのみテストされています + +
+ + + +_✨ Docker ベースのサンドボックス化されたコードエグゼキューター(ベータテスト中)✨_ + + + +_✨ 多モーダル、ウェブ検索、長文の画像変換(設定可能)✨_ + + + +_✨ 自然言語タスク ✨_ + + + + +_✨ プラグインシステム - 一部のプラグインの展示 ✨_ + + + +_✨ 管理パネル ✨_ + +![webchat](https://drive.soulter.top/f/vlsA/ezgif-5-fb044b2542.gif) + +_✨ 内蔵 Web Chat、オンラインでボットと対話 ✨_ + +
+ +## ⭐ Star History + +> [!TIP] +> このプロジェクトがあなたの生活や仕事に役立った場合、またはこのプロジェクトの将来の発展に関心がある場合は、プロジェクトに Star を付けてください。これはこのオープンソースプロジェクトを維持するためのモチベーションです <3 + +
+ +[![Star History Chart](https://api.star-history.com/svg?repos=soulter/astrbot&type=Date)](https://star-history.com/#soulter/astrbot&Date) + +
+ +## スポンサー + +[](https://api.gitsponsors.com/api/badge/link?p=XEpbdGxlitw/RbcwiTX93UMzNK/jgDYC8NiSzamIPMoKvG2lBFmyXhSS/b0hFoWlBBMX2L5X5CxTDsUdyvcIEHTOfnkXz47UNOZvMwyt5CzbYpq0SEzsSV1OJF1cCo90qC/ZyYKYOWedal3MhZ3ikw==) + +## 免責事項 + +1. このプロジェクトは `AGPL-v3` オープンソースライセンスの下で保護されています。 +2. WeChat(個人アカウント)のデプロイメントには [Gewechat](https://github.com/Devo919/Gewechat) サービスを利用しています。AstrBot は Gewechat との接続を保証するだけであり、アカウントのリスク管理に関しては、このプロジェクトの著者は一切の責任を負いません。 +3. このプロジェクトを使用する際は、現地の法律および規制を遵守してください。 + + + + +_私は、高性能ですから!_ + diff --git a/astrbot/core/__init__.py b/astrbot/core/__init__.py index aac8fc117..6e22ba94d 100644 --- a/astrbot/core/__init__.py +++ b/astrbot/core/__init__.py @@ -11,7 +11,8 @@ from astrbot.core.config import AstrBotConfig os.makedirs("data", exist_ok=True) astrbot_config = AstrBotConfig() -html_renderer = HtmlRenderer() +t2i_base_url = astrbot_config.get('t2i_endpoint', 'https://t2i.soulter.top/text2img') +html_renderer = HtmlRenderer(t2i_base_url) logger = LogManager.GetLogger(log_name='astrbot') if os.environ.get('TESTING', ""): diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index 718131385..a70831bab 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -2,7 +2,7 @@ 如需修改配置,请在 `data/cmd_config.json` 中修改或者在管理面板中可视化修改。 """ -VERSION = "3.4.24" +VERSION = "3.4.27" DB_PATH = "data/data_v3.db" # 默认配置 @@ -28,10 +28,12 @@ DEFAULT_CONFIG = { "segmented_reply": { "enable": False, "only_llm_result": True, + "interval_method": "random", "interval": "1.5,3.5", - "seg_prompt": "", + "log_base": 2.6, + "words_count_threshold": 150, "regex": ".*?[。?!~…]+|.+$", - "filter_regex_content": False + "content_cleanup_rule": "", }, "no_permission_reply": True, }, @@ -58,26 +60,32 @@ DEFAULT_CONFIG = { "group_icl_enable": False, "group_message_max_cnt": 300, "image_caption": False, + "image_caption_provider_id": "", "image_caption_prompt": "Please describe the image using Chinese.", "active_reply": { "enable": False, "method": "possibility_reply", "possibility_reply": 0.1, "prompt": "", - }, - "put_history_to_prompt": True, + "whitelist": [] + } }, "content_safety": { + "also_use_in_response": False, "internal_keywords": {"enable": True, "extra_keywords": []}, "baidu_aip": {"enable": False, "app_id": "", "api_key": "", "secret_key": ""}, }, - "admins_id": [], + "admins_id": [ + "astrbot" + ], "t2i": False, + "t2i_word_threshold": 150, "http_proxy": "", "dashboard": { "enable": True, "username": "astrbot", "password": "77b90590a8945a7d36c963981a307dc9", + "port": 6185 }, "platform": [], "wake_prefix": ["/"], @@ -108,6 +116,14 @@ CONFIG_METADATA_2 = { "enable_group_c2c": True, "enable_guild_direct_message": True, }, + "qq_official_webhook(QQ)": { + "id": "default", + "type": "qq_official_webhook", + "enable": False, + "appid": "", + "secret": "", + "port": 6196 + }, "aiocqhtp(QQ)": { "id": "default", "type": "aiocqhttp", @@ -124,12 +140,21 @@ CONFIG_METADATA_2 = { "host": "这里填写你的局域网IP或者公网服务器IP", "port": 11451, }, + "lark(飞书)": { + "id": "lark", + "type": "lark", + "enable": False, + "lark_bot_name": "", + "app_id": "", + "app_secret": "", + "domain": "https://open.feishu.cn" + }, }, "items": { "id": { "description": "ID", "type": "string", - "hint": "提供商 ID 名,用于在多实例下方便管理和识别。自定义,ID 不能重复。", + "hint": "用于在多实例下方便管理和识别。自定义,ID 不能重复。", }, "type": { "description": "适配器类型", @@ -171,6 +196,12 @@ CONFIG_METADATA_2 = { "type": "int", "hint": "aiocqhttp 适配器的反向 Websocket 端口。", }, + "lark_bot_name": { + "description": "飞书机器人的名字", + "type": "string", + "hint": "请务必填对,否则 @ 机器人将无法唤醒,只能通过前缀唤醒。", + "obvious_hint": True + } }, }, "platform_settings": { @@ -214,15 +245,26 @@ CONFIG_METADATA_2 = { "description": "仅对 LLM 结果分段", "type": "bool", }, + "interval_method": { + "description": "间隔时间计算方法", + "type": "string", + "options": ["random", "log"], + "hint": "分段回复的间隔时间计算方法。random 为随机时间,log 为根据消息长度计算,$y=log_{log\_base}(x)$,x为字数,y的单位为秒。", + }, "interval": { "description": "随机间隔时间(秒)", "type": "string", - "hint": "每一段回复的间隔时间,格式为 `最小时间,最大时间`。如 `0.75,2.5`", + "hint": "`random` 方法用。每一段回复的间隔时间,格式为 `最小时间,最大时间`。如 `0.75,2.5`", }, - "seg_prompt": { - "description": "分段提示词辅助", - "type": "string", - "hint": "此项为空时表达不启用这个方法。此方法会调用一次LLM请求。让 LLM 在某一句话中插入一个可以用正则表达式分隔的标记,来实现LLM基于情感分段。如: `请基于情感对以下文本进行分段, 并在两段之间添加``以便我用正则匹配。` 然后将下面的正则表达式更换为`.+?`。", + "log_base": { + "description": "对数函数底数", + "type": "float", + "hint": "`log` 方法用。对数函数的底数。默认为 2.6", + }, + "words_count_threshold": { + "description": "字数阈值", + "type": "int", + "hint": "超过这个字数的消息不会被分段回复。默认为 150", }, "regex": { "description": "正则表达式", @@ -230,10 +272,11 @@ CONFIG_METADATA_2 = { "obvious_hint": True, "hint": "用于分隔一段消息。默认情况下会根据句号、问号等标点符号分隔。re.findall(r'', text)", }, - "filter_regex_content": { - "description": "过滤正则表达式内容", - "type": "bool", - "hint": "启用后,分段回复时会过滤掉正则表达式匹配的内容。", + "content_cleanup_rule": { + "description": "过滤分段后的内容", + "type": "string", + "obvious_hint": True, + "hint": "移除分段后的内容中的指定的内容。支持正则表达式。如填写 `[。?!]` 将移除所有的句号、问号、感叹号。re.sub(r'', '', text)", }, }, }, @@ -256,7 +299,7 @@ CONFIG_METADATA_2 = { "type": "list", "items": {"type": "string"}, "obvious_hint": True, - "hint": "AstrBot 只处理所填写的 ID 发来的消息事件。为空时不启用白名单过滤。可以使用 /sid 指令获取在某个平台上的会话 ID。也可在 AstrBot 日志内获取会话 ID,当一条消息没通过白名单时,会输出 INFO 级别的日志。会话 ID 类似 aiocqhttp:GroupMessage:547540978", + "hint": "AstrBot 只处理所填写的 ID 发来的消息事件。为空时不启用白名单过滤。可以使用 /sid 指令获取在某个平台上的会话 ID。也可在 AstrBot 日志内获取会话 ID,当一条消息没通过白名单时,会输出 INFO 级别的日志。会话 ID 类似 aiocqhttp:GroupMessage:547540978。管理员可使用 /wl 添加白名单", }, "id_whitelist_log": { "description": "打印白名单日志", @@ -287,13 +330,18 @@ CONFIG_METADATA_2 = { "items": {"type": "string"}, "obvious_hint": True, "hint": "此功能解决由于文件系统不一致导致路径不存在的问题。格式为 <原路径>:<映射路径>。如 `/app/.config/QQ:/var/lib/docker/volumes/xxxx/_data`。这样,当消息平台下发的事件中图片和语音路径以 `/app/.config/QQ` 开头时,开头被替换为 `/var/lib/docker/volumes/xxxx/_data`。这在 AstrBot 或者平台协议端使用 Docker 部署时特别有用。", - } + }, }, }, "content_safety": { "description": "内容安全", "type": "object", "items": { + "also_use_in_response": { + "description": "对大模型响应安全审核", + "type": "bool", + "hint": "启用后,大模型的响应也会通过内容安全审核。", + }, "baidu_aip": { "description": "百度内容审核配置", "type": "object", @@ -415,7 +463,7 @@ CONFIG_METADATA_2 = { "model": "glm-4-flash", }, }, - "硅基流动": { + "siliconflow": { "id": "siliconflow", "type": "openai_chat_completion", "enable": True, @@ -426,6 +474,17 @@ CONFIG_METADATA_2 = { "model": "deepseek-ai/DeepSeek-V3", }, }, + "moonshot(kimi)": { + "id": "moonshot", + "type": "openai_chat_completion", + "enable": True, + "key": [], + "timeout": 120, + "api_base": "https://api.moonshot.cn/v1", + "model_config": { + "model": "moonshot-v1-8k", + }, + }, "llmtuner": { "id": "llmtuner_default", "type": "llm_tuner", @@ -444,6 +503,8 @@ CONFIG_METADATA_2 = { "dify_api_key": "", "dify_api_base": "https://api.dify.ai/v1", "dify_workflow_output_key": "", + "dify_query_input_key": "astrbot_text_query", + "timeout": 60, }, "whisper(API)": { "id": "whisper", @@ -470,6 +531,15 @@ CONFIG_METADATA_2 = { "openai-tts-voice": "alloy", "timeout": "20", }, + "fishaudio_tts(API)": { + "id": "fishaudio_tts", + "type": "fishaudio_tts_api", + "enable": False, + "api_key": "", + "api_base": "https://api.fish-audio.cn/v1", + "fishaudio-tts-character": "可莉", + "timeout": "20", + }, }, "items": { "timeout": { @@ -483,6 +553,12 @@ CONFIG_METADATA_2 = { "obvious_hint": True, "hint": "OpenAI TTS 的声音。OpenAI 默认支持:'alloy', 'echo', 'fable', 'onyx', 'nova', 'shimmer'", }, + "fishaudio-tts-character": { + "description": "character", + "type": "string", + "obvious_hint": True, + "hint": "fishaudio TTS 的角色。默认为可莉。更多角色请访问:https://fish.audio/zh-CN/discovery", + }, "whisper_hint": { "description": "本地部署 Whisper 模型须知", "type": "string", @@ -579,6 +655,12 @@ CONFIG_METADATA_2 = { "type": "string", "hint": "Dify Workflow 输出变量名。当应用类型为 workflow 时才使用。默认为 astrbot_wf_output。", }, + "dify_query_input_key": { + "description": "Prompt 输入变量名", + "type": "string", + "hint": "发送的消息文本内容对应的输入变量名。默认为 astrbot_text_query。", + "obvious": True, + } }, }, "provider_settings": { @@ -728,9 +810,15 @@ CONFIG_METADATA_2 = { "obvious_hint": True, "hint": "启用后,当接收到图片消息时,会使用模型先将图片转述为文字再进行后续处理。推荐使用 gpt-4o-mini 模型。", }, + "image_caption_provider_id": { + "description": "图像转述提供商 ID", + "type": "string", + "obvious_hint": True, + "hint": "可选。图像转述提供商 ID。如为空将选择聊天使用的提供商。", + }, "image_caption_prompt": { "description": "图像转述提示词", - "type": "string" + "type": "string", }, "active_reply": { "description": "主动回复", @@ -742,6 +830,13 @@ CONFIG_METADATA_2 = { "obvious_hint": True, "hint": "启用后,会根据触发概率主动回复群聊内的对话。QQ官方API(qq_official)不可用", }, + "whitelist": { + "description": "主动回复白名单", + "type": "list", + "items": {"type": "string"}, + "obvious_hint": True, + "hint": "启用后,只有在白名单内的群聊会被主动回复。为空时不启用白名单过滤。需要通过 /sid 获取 SID 添加到这里。", + }, "method": { "description": "回复方法", "type": "string", @@ -758,16 +853,10 @@ CONFIG_METADATA_2 = { "description": "提示词", "type": "string", "obvious_hint": True, - "hint": "提示词。当提示词为空时,如果触发回复,prompt是触发的消息的内容;否则是提示词。此项可以和定时回复(暂未实现)配合使用。", + "hint": "提示词。当提示词为空时,如果触发回复,则向 LLM 请求的是触发的消息的内容;否则是提示词。此项可以和定时回复(暂未实现)配合使用。", }, }, }, - "put_history_to_prompt": { - "description": "将群聊历史记录作为 prompt", - "type": "bool", - "obvious_hint": True, - "hint": "需要先启用 group_icl_enable。此功能会将群聊历史记录放到 prompt 再请求。如果关闭,则是放在 system_prompt。如果开启了主动回复,建议启用,模型能够更好地完成回复任务。", - } }, }, }, @@ -787,6 +876,11 @@ CONFIG_METADATA_2 = { "type": "bool", "hint": "启用后,超出一定长度的文本将会通过 AstrBot API 渲染成 Markdown 图片发送。可以缓解审核和消息过长刷屏的问题,并提高 Markdown 文本的可读性。", }, + "t2i_word_threshold": { + "description": "文本转图像字数阈值", + "type": "int", + "hint": "超出此字符长度的文本将会被转换成图片。字数不能低于 50。", + }, "admins_id": { "description": "管理员 ID", "type": "list", @@ -817,7 +911,8 @@ CONFIG_METADATA_2 = { "plugin_repo_mirror": { "description": "插件仓库镜像", "type": "string", - "hint": "插件仓库的镜像地址,用于加速插件的下载。", + "hint": "已废弃,请使用管理面板->设置页的代理地址选择", + "obvious_hint": True, "options": [ "default", "https://ghp.ci/", diff --git a/astrbot/core/conversation_mgr.py b/astrbot/core/conversation_mgr.py index 7aca80296..d905219a4 100644 --- a/astrbot/core/conversation_mgr.py +++ b/astrbot/core/conversation_mgr.py @@ -50,6 +50,7 @@ class ConversationManager(): cid=conversation_id ) del self.session_conversations[unified_msg_origin] + sp.put("session_conversation", self.session_conversations) async def get_curr_conversation_id(self, unified_msg_origin: str) -> str: '''获取会话当前的对话 ID''' diff --git a/astrbot/core/core_lifecycle.py b/astrbot/core/core_lifecycle.py index e5630d017..0289c4d1a 100644 --- a/astrbot/core/core_lifecycle.py +++ b/astrbot/core/core_lifecycle.py @@ -27,7 +27,8 @@ class AstrBotCoreLifecycle: os.environ['https_proxy'] = self.astrbot_config['http_proxy'] os.environ['http_proxy'] = self.astrbot_config['http_proxy'] - + os.environ['no_proxy'] = 'localhost,127.0.0.1' + async def initialize(self): logger.info("AstrBot v"+ VERSION) if os.environ.get("TESTING", ""): diff --git a/astrbot/core/db/sqlite.py b/astrbot/core/db/sqlite.py index b81aef773..fecd251df 100644 --- a/astrbot/core/db/sqlite.py +++ b/astrbot/core/db/sqlite.py @@ -247,6 +247,10 @@ class SQLiteDatabase(BaseDatabase): res = c.fetchone() c.close() + + if not res: + return + return Conversation(*res) def new_conversation(self, user_id: str, cid: str): diff --git a/astrbot/core/message/components.py b/astrbot/core/message/components.py index 45fa82901..16ba0c1e4 100644 --- a/astrbot/core/message/components.py +++ b/astrbot/core/message/components.py @@ -325,11 +325,13 @@ class RedBag(BaseMessageComponent): class Poke(BaseMessageComponent): - type: ComponentType = "Poke" - qq: int + type: str = "" + id: T.Optional[int] = 0 + qq: T.Optional[int] = 0 - def __init__(self, **_): - super().__init__(**_) + def __init__(self, type: str, **_): + type = f"Poke:{type}" + super().__init__(type=type, **_) class Forward(BaseMessageComponent): @@ -339,14 +341,14 @@ class Forward(BaseMessageComponent): def __init__(self, **_): super().__init__(**_) - -class Node(BaseMessageComponent): # 该 component 仅支持使用 sendGroupForwardMessage 发送 +class Node(BaseMessageComponent): + '''群合并转发消息''' type: ComponentType = "Node" - id: T.Optional[int] = 0 - name: T.Optional[str] = "" - uin: T.Optional[int] = 0 - content: T.Optional[T.Union[str, list]] = "" - seq: T.Optional[T.Union[str, list]] = "" # 不清楚是什么 + id: T.Optional[int] = 0 # 忽略 + name: T.Optional[str] = "" # qq昵称 + uin: T.Optional[int] = 0 # qq号 + content: T.Optional[T.Union[str, list]] = "" # 子消息段列表 + seq: T.Optional[T.Union[str, list]] = "" # 忽略 time: T.Optional[int] = 0 def __init__(self, content: T.Union[str, list], **_): diff --git a/astrbot/core/pipeline/content_safety_check/stage.py b/astrbot/core/pipeline/content_safety_check/stage.py index 8e9a0ad25..ffd7689a7 100644 --- a/astrbot/core/pipeline/content_safety_check/stage.py +++ b/astrbot/core/pipeline/content_safety_check/stage.py @@ -17,11 +17,14 @@ class ContentSafetyCheckStage(Stage): config = ctx.astrbot_config['content_safety'] self.strategy_selector = StrategySelector(config) - async def process(self, event: AstrMessageEvent) -> Union[None, AsyncGenerator[None, None]]: + async def process(self, event: AstrMessageEvent, check_text: str = None) -> Union[None, AsyncGenerator[None, None]]: '''检查内容安全''' - ok, info = self.strategy_selector.check(event.get_message_str()) + text = check_text if check_text else event.get_message_str() + ok, info = self.strategy_selector.check(text) if not ok: - event.set_result(MessageEventResult().message("你的消息中包含不适当的内容,已被屏蔽。")) + if event.is_at_or_wake_command: + event.set_result(MessageEventResult().message("你的消息或者大模型的响应中包含不适当的内容,已被屏蔽。")) + yield event.stop_event() logger.info(f"内容安全检查不通过,原因:{info}") return diff --git a/astrbot/core/pipeline/process_stage/method/dify_request.py b/astrbot/core/pipeline/process_stage/method/dify_request.py index acdb22010..663a9f8bf 100644 --- a/astrbot/core/pipeline/process_stage/method/dify_request.py +++ b/astrbot/core/pipeline/process_stage/method/dify_request.py @@ -46,6 +46,8 @@ class DifyRequestSubStage(Stage): if not req.prompt: return + + req.session_id = event.unified_msg_origin try: logger.debug(f"Dify 请求 Payload: {req.__dict__}") diff --git a/astrbot/core/pipeline/process_stage/method/llm_request.py b/astrbot/core/pipeline/process_stage/method/llm_request.py index b2ee8d508..852c2c331 100644 --- a/astrbot/core/pipeline/process_stage/method/llm_request.py +++ b/astrbot/core/pipeline/process_stage/method/llm_request.py @@ -111,10 +111,10 @@ class LLMRequestSubStage(Stage): # 尝试调用工具函数 wrapper = self._call_handler(self.ctx, event, func_tool.handler, **func_tool_args) async for resp in wrapper: - if resp is not None: + if resp is not None: # 有 return 返回 function_calling_result[func_tool_name] = resp else: - yield + yield # 有生成器返回 event.clear_result() # 清除上一个 handler 的结果 except BaseException as e: logger.warning(traceback.format_exc()) @@ -129,6 +129,9 @@ class LLMRequestSubStage(Stage): req.prompt += extra_prompt async for _ in self.process(event, _nested=True): yield + else: + if llm_response.completion_text: + event.set_result(MessageEventResult().message(llm_response.completion_text)) except BaseException as e: logger.error(traceback.format_exc()) diff --git a/astrbot/core/pipeline/process_stage/method/star_request.py b/astrbot/core/pipeline/process_stage/method/star_request.py index 109e1ea81..d2a4c8382 100644 --- a/astrbot/core/pipeline/process_stage/method/star_request.py +++ b/astrbot/core/pipeline/process_stage/method/star_request.py @@ -31,7 +31,7 @@ class StarRequestSubStage(Stage): # 孤立无援的 star handler continue - logger.debug(f"执行 Star Handler {handler.handler_full_name}") + logger.debug(f"执行插件 handler {handler.handler_full_name}") wrapper = self._call_handler(self.ctx, event, handler.handler, **params) async for ret in wrapper: yield ret diff --git a/astrbot/core/pipeline/process_stage/stage.py b/astrbot/core/pipeline/process_stage/stage.py index 431885f59..7efe2c130 100644 --- a/astrbot/core/pipeline/process_stage/stage.py +++ b/astrbot/core/pipeline/process_stage/stage.py @@ -35,7 +35,6 @@ class ProcessStage(Stage): # 生成器返回值处理 if isinstance(resp, ProviderRequest): # Handler 的 LLM 请求 - logger.debug(f"llm request -> {resp.prompt}") event.set_extra("provider_request", resp) _t = False async for _ in self.llm_request_sub_stage.process(event): diff --git a/astrbot/core/pipeline/respond/stage.py b/astrbot/core/pipeline/respond/stage.py index 330417d29..95810130f 100644 --- a/astrbot/core/pipeline/respond/stage.py +++ b/astrbot/core/pipeline/respond/stage.py @@ -1,21 +1,29 @@ import random import asyncio +import math from typing import Union, AsyncGenerator from ..stage import register_stage, Stage from ..context import PipelineContext from astrbot.core.platform.astr_message_event import AstrMessageEvent from astrbot.core.message.message_event_result import MessageChain from astrbot.core import logger +from astrbot.core.message.message_event_result import BaseMessageComponent from astrbot.core.star.star_handler import star_handlers_registry, EventType - +from astrbot.core.message.components import Plain, Reply, At @register_stage class RespondStage(Stage): async def initialize(self, ctx: PipelineContext): self.ctx = ctx + self.reply_with_mention = ctx.astrbot_config['platform_settings']['reply_with_mention'] + self.reply_with_quote = ctx.astrbot_config['platform_settings']['reply_with_quote'] + # 分段回复 self.enable_seg: bool = ctx.astrbot_config['platform_settings']['segmented_reply']['enable'] self.only_llm_result = ctx.astrbot_config['platform_settings']['segmented_reply']['only_llm_result'] + + self.interval_method = ctx.astrbot_config['platform_settings']['segmented_reply']['interval_method'] + self.log_base = float(ctx.astrbot_config['platform_settings']['segmented_reply']['log_base']) interval_str: str = ctx.astrbot_config['platform_settings']['segmented_reply']['interval'] interval_str_ls = interval_str.replace(" ", "").split(",") try: @@ -24,7 +32,27 @@ class RespondStage(Stage): logger.error(f'解析分段回复的间隔时间失败。{e}') self.interval = [1.5, 3.5] logger.info(f"分段回复间隔时间:{self.interval}") - + + async def _word_cnt(self, text: str) -> int: + '''分段回复 统计字数''' + if all(ord(c) < 128 for c in text): + word_count = len(text.split()) + else: + word_count = len([c for c in text if c.isalnum()]) + return word_count + + async def _calc_comp_interval(self, comp: BaseMessageComponent) -> float: + '''分段回复 计算间隔时间''' + if self.interval_method == 'log': + if isinstance(comp, Plain): + wc = await self._word_cnt(comp.text) + i = math.log(wc + 1, self.log_base) + return random.uniform(i, i + 0.5) + else: + return random.uniform(1, 1.75) + else: + # random + return random.uniform(self.interval[0], self.interval[1]) async def process(self, event: AstrMessageEvent) -> Union[None, AsyncGenerator[None, None]]: result = event.get_result() @@ -35,10 +63,24 @@ class RespondStage(Stage): await event._pre_send() if self.enable_seg and ((self.only_llm_result and result.is_llm_result()) or not self.only_llm_result): + decorated_comps = [] + if self.reply_with_mention: + for comp in result.chain: + if isinstance(comp, At): + decorated_comps.append(comp) + result.chain.remove(comp) + break + if self.reply_with_quote: + for comp in result.chain: + if isinstance(comp, Reply): + decorated_comps.append(comp) + result.chain.remove(comp) + break # 分段回复 for comp in result.chain: - await event.send(MessageChain([comp])) - await asyncio.sleep(random.uniform(self.interval[0], self.interval[1])) + i = await self._calc_comp_interval(comp) + await asyncio.sleep(i) + await event.send(MessageChain([*decorated_comps, comp])) else: await event.send(result) await event._post_send() diff --git a/astrbot/core/pipeline/result_decorate/stage.py b/astrbot/core/pipeline/result_decorate/stage.py index 7c501247a..c12025ef2 100644 --- a/astrbot/core/pipeline/result_decorate/stage.py +++ b/astrbot/core/pipeline/result_decorate/stage.py @@ -2,41 +2,69 @@ import time import re import traceback from typing import Union, AsyncGenerator -from ..stage import register_stage +from ..stage import Stage, register_stage, registered_stages from ..context import PipelineContext from astrbot.core.platform.astr_message_event import AstrMessageEvent from astrbot.core.platform.message_type import MessageType from astrbot.core import logger -from astrbot.core.message.components import Plain, Image, At, Reply, Record +from astrbot.core.message.components import Plain, Image, At, Reply, Record, File from astrbot.core import html_renderer from astrbot.core.star.star_handler import star_handlers_registry, EventType @register_stage -class ResultDecorateStage: +class ResultDecorateStage(Stage): async def initialize(self, ctx: PipelineContext): self.ctx = ctx self.reply_prefix = ctx.astrbot_config['platform_settings']['reply_prefix'] self.reply_with_mention = ctx.astrbot_config['platform_settings']['reply_with_mention'] self.reply_with_quote = ctx.astrbot_config['platform_settings']['reply_with_quote'] - self.use_tts = ctx.astrbot_config['provider_tts_settings']['enable'] + self.t2i_word_threshold = ctx.astrbot_config['t2i_word_threshold'] + try: + self.t2i_word_threshold = int(self.t2i_word_threshold) + if self.t2i_word_threshold < 50: + self.t2i_word_threshold = 50 + except BaseException: + self.t2i_word_threshold = 150 # 分段回复 + self.words_count_threshold = int(ctx.astrbot_config['platform_settings']['segmented_reply']['words_count_threshold']) self.enable_segmented_reply = ctx.astrbot_config['platform_settings']['segmented_reply']['enable'] self.only_llm_result = ctx.astrbot_config['platform_settings']['segmented_reply']['only_llm_result'] - self.seg_prompt = ctx.astrbot_config['platform_settings']['segmented_reply']['seg_prompt'] self.regex = ctx.astrbot_config['platform_settings']['segmented_reply']['regex'] - self.filter_regex_content = ctx.astrbot_config['platform_settings']['segmented_reply'].get('filter_regex_content', False) - + self.content_cleanup_rule = ctx.astrbot_config['platform_settings']['segmented_reply']['content_cleanup_rule'] + + # exception + self.content_safe_check_reply = ctx.astrbot_config['content_safety']['also_use_in_response'] + self.content_safe_check_stage = None + if self.content_safe_check_reply: + for stage in registered_stages: + if stage.__class__.__name__ == "ContentSafetyCheckStage": + self.content_safe_check_stage = stage + + async def process(self, event: AstrMessageEvent) -> Union[None, AsyncGenerator[None, None]]: result = event.get_result() if result is None: return + # 回复时检查内容安全 + if self.content_safe_check_reply and self.content_safe_check_stage and result.is_llm_result(): + text = "" + for comp in result.chain: + if isinstance(comp, Plain): + text += comp.text + async for _ in self.content_safe_check_stage.process(event, check_text=text): + yield + handlers = star_handlers_registry.get_handlers_by_event_type(EventType.OnDecoratingResultEvent) for handler in handlers: - # TODO: 如何让这里的 handler 也能使用 LLM 能力。也许需要将 LLMRequestSubStage 提取出来。 await handler.handler(event) + # 需要再获取一次。插件可能直接对 chain 进行了替换。 + result = event.get_result() + if result is None: + return + if len(result.chain) > 0: # 回复前缀 if self.reply_prefix: @@ -51,27 +79,20 @@ class ResultDecorateStage: new_chain = [] for comp in result.chain: if isinstance(comp, Plain): - - if self.seg_prompt: - try: - llm_resp = await self.ctx.plugin_manager.context.get_using_provider().text_chat( - prompt=f"{self.seg_prompt}\n{comp.text}", - ) - comp.text = llm_resp.completion_text - except BaseException as e: - traceback.print_exc() - logger.error("使用 LLM 分段回复失败: " + str(e)) - new_chain.append(comp) - continue - - split_response = re.findall(self.regex, comp.text) + if len(comp.text) > self.words_count_threshold: + # 不分段回复 + new_chain.append(comp) + continue + split_response = [] + for line in comp.text.split("\n"): + split_response.extend(re.findall(self.regex, line)) if not split_response: new_chain.append(comp) continue for seg in split_response: - if seg: - if self.filter_regex_content: - seg = re.sub(self.regex, '', seg) + if self.content_cleanup_rule: + seg = re.sub(self.content_cleanup_rule, "", seg) + if seg.strip(): new_chain.append(Plain(seg)) else: # 非 Plain 类型的消息段不分段 @@ -79,7 +100,7 @@ class ResultDecorateStage: result.chain = new_chain # TTS - if self.use_tts and result.is_llm_result(): + if self.ctx.astrbot_config['provider_tts_settings']['enable'] and result.is_llm_result(): tts_provider = self.ctx.plugin_manager.context.provider_manager.curr_tts_provider_inst new_chain = [] for comp in result.chain: @@ -94,7 +115,7 @@ class ResultDecorateStage: logger.error(f"由于 TTS 音频文件没找到,消息段转语音失败: {comp.text}") new_chain.append(comp) except BaseException: - traceback.print_exc() + logger.error(traceback.format_exc()) logger.error("TTS 失败,使用文本发送。") new_chain.append(comp) else: @@ -109,7 +130,7 @@ class ResultDecorateStage: plain_str += "\n\n" + comp.text else: break - if plain_str and len(plain_str) > 150: + if plain_str and len(plain_str) > self.t2i_word_threshold: render_start = time.time() try: url = await html_renderer.render_t2i(plain_str, return_url=True) @@ -129,4 +150,5 @@ class ResultDecorateStage: # 引用回复 if self.reply_with_quote: - result.chain.insert(0, Reply(id=event.message_obj.message_id)) + if not any(isinstance(item, File) for item in result.chain): + result.chain.insert(0, Reply(id=event.message_obj.message_id)) diff --git a/astrbot/core/pipeline/scheduler.py b/astrbot/core/pipeline/scheduler.py index e18ee92be..fcb200787 100644 --- a/astrbot/core/pipeline/scheduler.py +++ b/astrbot/core/pipeline/scheduler.py @@ -12,14 +12,14 @@ class PipelineScheduler(): async def initialize(self): for stage in registered_stages: - logger.debug(f"初始化阶段 {stage.__class__ .__name__}") + # logger.debug(f"初始化阶段 {stage.__class__ .__name__}") await stage.initialize(self.ctx) async def _process_stages(self, event: AstrMessageEvent, from_stage=0): for i in range(from_stage, len(registered_stages)): stage = registered_stages[i] - logger.debug(f"执行阶段 {stage.__class__ .__name__}") + # logger.debug(f"执行阶段 {stage.__class__ .__name__}") coro = stage.process(event) if isinstance(coro, AsyncGenerator): async for _ in coro: diff --git a/astrbot/core/pipeline/waking_check/stage.py b/astrbot/core/pipeline/waking_check/stage.py index 6d8b9c8b6..b48637c14 100644 --- a/astrbot/core/pipeline/waking_check/stage.py +++ b/astrbot/core/pipeline/waking_check/stage.py @@ -3,7 +3,7 @@ from ..context import PipelineContext from typing import Union, AsyncGenerator from astrbot.core.platform.astr_message_event import AstrMessageEvent from astrbot.core.message.message_event_result import MessageEventResult, MessageChain -from astrbot.core.message.components import At +from astrbot.core.message.components import At, Reply from astrbot.core.star.star_handler import star_handlers_registry, EventType from astrbot.core.star.filter.command_group import CommandGroupFilter from astrbot.core.star.filter.permission import PermissionTypeFilter @@ -86,6 +86,10 @@ class WakingCheckStage(Stage): if len(handler.event_filters) == 0: # 不可能有这种情况, 也不允许有这种情况 continue + + if 'sub_command' in handler.extras_configs: + # 如果是子指令 + continue for filter in handler.event_filters: try: @@ -122,7 +126,7 @@ class WakingCheckStage(Stage): if permission_not_pass: if self.no_permission_reply: - await event.send(MessageChain().message(f"ID {event.get_sender_id()} 权限不足")) + await event.send(MessageChain().message(f"ID {event.get_sender_id()} 权限不足。通过 /sid 获取 ID 并请管理员添加。")) event.stop_event() return diff --git a/astrbot/core/platform/astr_message_event.py b/astrbot/core/platform/astr_message_event.py index 7e3df73e2..d650ba840 100644 --- a/astrbot/core/platform/astr_message_event.py +++ b/astrbot/core/platform/astr_message_event.py @@ -8,7 +8,7 @@ from typing import List, Union from astrbot.core.message.components import Plain, Image, BaseMessageComponent, Face, At, AtAll, Forward from astrbot.core.utils.metrics import Metric from astrbot.core.provider.entites import ProviderRequest - +from astrbot.core.db.po import Conversation @dataclass class MessageSesion: @@ -305,9 +305,10 @@ class AstrMessageEvent(abc.ABC): prompt: str, func_tool_manager = None, session_id: str = None, - image_urls: List[str] = None, - contexts: List = None, - system_prompt: str = "" + image_urls: List[str] = [], + contexts: List = [], + system_prompt: str = "", + conversation: Conversation = None ) -> ProviderRequest: ''' 创建一个 LLM 请求。 @@ -316,10 +317,12 @@ class AstrMessageEvent(abc.ABC): ```py yield event.request_llm(prompt="hi") ``` - + prompt: 提示词 + session_id: 已经过时,留空即可 image_urls: 可以是 base64:// 或者 http:// 开头的图片链接,也可以是本地图片路径。 - contexts: 当指定 contexts 时,将会**只**使用 contexts 作为上下文。 + contexts: 当指定 contexts 时,将会使用 contexts 作为上下文。 func_tool_manager: 函数工具管理器,用于调用函数工具。用 self.context.get_llm_tool_manager() 获取。 + conversation: 可选。如果指定,将在指定的对话中进行 LLM 请求。对话的人格会被用于 LLM 请求,并且结果将会被记录到对话中。 ''' return ProviderRequest( prompt = prompt, @@ -327,5 +330,6 @@ class AstrMessageEvent(abc.ABC): image_urls = image_urls, func_tool = func_tool_manager, contexts = contexts, - system_prompt = system_prompt + system_prompt = system_prompt, + conversation=conversation ) \ No newline at end of file diff --git a/astrbot/core/platform/manager.py b/astrbot/core/platform/manager.py index a12d39af6..46118b554 100644 --- a/astrbot/core/platform/manager.py +++ b/astrbot/core/platform/manager.py @@ -15,22 +15,25 @@ class PlatformManager(): self.settings = config['platform_settings'] self.event_queue = event_queue - for platform in self.platforms_config: - if not platform['enable']: - continue - match platform['type']: - case "aiocqhttp": - from .sources.aiocqhttp.aiocqhttp_platform_adapter import AiocqhttpAdapter # noqa: F401 - case "qq_official": - from .sources.qqofficial.qqofficial_platform_adapter import QQOfficialPlatformAdapter # noqa: F401 - case "vchat": - try: - from .sources.vchat.vchat_platform_adapter import VChatPlatformAdapter # noqa: F401 - except BaseException: - logger.warning("当前 astrbot 已不维护 vchat 的接入,如有需要请 pip 安装 vchat 然后重启") - case "gewechat": - from .sources.gewechat.gewechat_platform_adapter import GewechatPlatformAdapter # noqa: F401 - + try: + for platform in self.platforms_config: + if not platform['enable']: + continue + match platform['type']: + case "aiocqhttp": + from .sources.aiocqhttp.aiocqhttp_platform_adapter import AiocqhttpAdapter # noqa: F401 + case "qq_official": + from .sources.qqofficial.qqofficial_platform_adapter import QQOfficialPlatformAdapter # noqa: F401 + case "qq_official_webhook": + from .sources.qqofficial_webhook.qo_webhook_adapter import QQOfficialWebhookPlatformAdapter # noqa: F401 + case "gewechat": + from .sources.gewechat.gewechat_platform_adapter import GewechatPlatformAdapter # noqa: F401 + case "lark": + from .sources.lark.lark_adapter import LarkPlatformAdapter # noqa: F401 + except (ImportError, ModuleNotFoundError) as e: + logger.error(f"加载平台适配器 {platform['type']} 失败,原因:{e}。请检查依赖库是否安装。提示:可以在 管理面板->控制台->安装Pip库 中安装依赖库。") + except Exception as e: + logger.error(f"加载平台适配器 {platform['type']} 失败,原因:{e}。") async def initialize(self): for platform in self.platforms_config: @@ -40,7 +43,7 @@ class PlatformManager(): logger.error(f"未找到适用于 {platform['type']}({platform['id']}) 平台适配器,请检查是否已经安装或者名称填写错误。已跳过。") continue cls_type = platform_cls_map[platform['type']] - logger.info(f"尝试实例化 {platform['type']}({platform['id']}) 平台适配器 ...") + logger.debug(f"尝试实例化 {platform['type']}({platform['id']}) 平台适配器 ...") inst = cls_type(platform, self.settings, self.event_queue) self.platform_insts.append(inst) diff --git a/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_message_event.py b/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_message_event.py index 482f676cf..b91e44227 100644 --- a/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_message_event.py +++ b/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_message_event.py @@ -1,9 +1,7 @@ -import os -import random import asyncio from astrbot.api.event import AstrMessageEvent, MessageChain -from astrbot.api.message_components import Plain, Image, Record +from astrbot.api.message_components import Plain, Image, Record, At, Node, Music, Video from aiocqhttp import CQHttp from astrbot.core.utils.io import file_to_base64, download_image_by_url @@ -20,7 +18,7 @@ class AiocqhttpMessageEvent(AstrMessageEvent): d = segment.toDict() if isinstance(segment, Plain): d['type'] = 'text' - if isinstance(segment, (Image, Record)): + elif isinstance(segment, (Image, Record)): # convert to base64 if segment.file and segment.file.startswith("file:///"): bs64_data = file_to_base64(segment.file[8:]) @@ -28,17 +26,35 @@ class AiocqhttpMessageEvent(AstrMessageEvent): elif segment.file and segment.file.startswith("http"): image_file_path = await download_image_by_url(segment.file) bs64_data = file_to_base64(image_file_path) + elif segment.file and segment.file.startswith("base64://"): + bs64_data = segment.file else: bs64_data = file_to_base64(segment.file) d['data'] = { 'file': bs64_data, } + elif isinstance(segment, At): + d['data'] = { + 'qq': str(segment.qq) # 转换为字符串 + } ret.append(d) return ret async def send(self, message: MessageChain): ret = await AiocqhttpMessageEvent._parse_onebot_json(message) - if os.environ.get('TEST_MODE', 'off') == 'on': - return - await self.bot.send(self.message_obj.raw_message, ret) + + send_one_by_one = False + for seg in message.chain: + if isinstance(seg, (Node, Music)): + # 转发消息不能和普通消息混在一起发送 + send_one_by_one = True + break + + if send_one_by_one: + for seg in message.chain: + await self.bot.send(self.message_obj.raw_message, await AiocqhttpMessageEvent._parse_onebot_json(MessageChain([seg]))) + await asyncio.sleep(0.5) + else: + await self.bot.send(self.message_obj.raw_message, ret) + await super().send(message) \ No newline at end of file diff --git a/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_platform_adapter.py b/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_platform_adapter.py index 52957e8b1..ce4b5700d 100644 --- a/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_platform_adapter.py +++ b/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_platform_adapter.py @@ -2,6 +2,7 @@ import os import time import asyncio import logging +import uuid from typing import Awaitable, Any from aiocqhttp import CQHttp, Event from astrbot.api.platform import Platform, AstrBotMessage, MessageMember, MessageType, PlatformMetadata @@ -46,19 +47,82 @@ class AiocqhttpAdapter(Platform): await super().send_by_session(session, message_chain) async def convert_message(self, event: Event) -> AstrBotMessage: + logger.debug(f"[aiocqhttp] RawMessage {event}") + + if event['post_type'] == 'message': + abm = await self._convert_handle_message_event(event) + elif event['post_type'] == 'notice': + abm = await self._convert_handle_notice_event(event) + elif event['post_type'] == 'request': + abm = await self._convert_handle_request_event(event) + + return abm + + async def _convert_handle_request_event(self, event: Event) -> AstrBotMessage: + '''OneBot V11 请求类事件''' abm = AstrBotMessage() abm.self_id = str(event.self_id) - abm.tag = "aiocqhttp" + abm.sender = MessageMember( + user_id=event.user_id, + nickname=event.user_id + ) + abm.type = MessageType.OTHER_MESSAGE + if 'group_id' in event and event['group_id']: + abm.type = MessageType.GROUP_MESSAGE + abm.group_id = str(event.group_id) + else: + abm.type = MessageType.FRIEND_MESSAGE + if self.unique_session and abm.type == MessageType.GROUP_MESSAGE: + abm.session_id = abm.sender.user_id + "_" + str(event.group_id) + abm.message_str = '' + abm.message = [] + abm.timestamp = int(time.time()) + abm.message_id = uuid.uuid4().hex + abm.raw_message = event + return abm + + async def _convert_handle_notice_event(self, event: Event) -> AstrBotMessage: + '''OneBot V11 通知类事件''' + abm = AstrBotMessage() + abm.self_id = str(event.self_id) + abm.sender = MessageMember( + user_id=event.user_id, + nickname=event.user_id + ) + abm.type = MessageType.OTHER_MESSAGE + if 'group_id' in event and event['group_id']: + abm.group_id = str(event.group_id) + abm.type = MessageType.GROUP_MESSAGE + else: + abm.type = MessageType.FRIEND_MESSAGE + if self.unique_session and abm.type == MessageType.GROUP_MESSAGE: + abm.session_id = abm.sender.user_id + "_" + str(event.group_id) # 也保留群组 id + else: + abm.session_id = str(event.group_id) if abm.type == MessageType.GROUP_MESSAGE else abm.sender.user_id + abm.message_str = "" + abm.message = [] + abm.raw_message = event + abm.timestamp = int(time.time()) + abm.message_id = uuid.uuid4().hex - abm.sender = MessageMember(str(event.sender['user_id']), event.sender['nickname']) - + if 'sub_type' in event: + if event['sub_type'] == 'poke' and 'target_id' in event: + abm.message.append(Poke(qq=str(event['target_id']), type='poke')) # noqa: F405 + + return abm + + + async def _convert_handle_message_event(self, event: Event) -> AstrBotMessage: + '''OneBot V11 消息类事件''' + abm = AstrBotMessage() + abm.self_id = str(event.self_id) + abm.sender = MessageMember(str(event.sender['user_id']), event.sender['nickname']) if event['message_type'] == 'group': abm.type = MessageType.GROUP_MESSAGE abm.group_id = str(event.group_id) elif event['message_type'] == 'private': abm.type = MessageType.FRIEND_MESSAGE - - if self.unique_session: + if self.unique_session and abm.type == MessageType.GROUP_MESSAGE: abm.session_id = abm.sender.user_id + "_" + str(event.group_id) # 也保留群组 id else: abm.session_id = str(event.group_id) if abm.type == MessageType.GROUP_MESSAGE else abm.sender.user_id @@ -75,7 +139,8 @@ class AiocqhttpAdapter(Platform): except BaseException as e: logger.error(f"回复消息失败: {e}") return - logger.debug(f"aiocqhttp: 收到消息: {event.message}") + + # 按消息段类型类型适配 for m in event.message: t = m['type'] a = None @@ -118,6 +183,7 @@ class AiocqhttpAdapter(Platform): abm.timestamp = int(time.time()) abm.message_str = message_str abm.raw_message = event + return abm def run(self) -> Awaitable[Any]: @@ -127,6 +193,19 @@ class AiocqhttpAdapter(Platform): self.port = 6199 self.bot = CQHttp(use_ws_reverse=True, import_name='aiocqhttp', api_timeout_sec=180) + + @self.bot.on_request() + async def request(event: Event): + abm = await self.convert_message(event) + if abm: + await self.handle_msg(abm) + + @self.bot.on_notice() + async def notice(event: Event): + abm = await self.convert_message(event) + if abm: + await self.handle_msg(abm) + @self.bot.on_message('group') async def group(event: Event): abm = await self.convert_message(event) diff --git a/astrbot/core/platform/sources/gewechat/client.py b/astrbot/core/platform/sources/gewechat/client.py index e96145bbf..56f02bd2a 100644 --- a/astrbot/core/platform/sources/gewechat/client.py +++ b/astrbot/core/platform/sources/gewechat/client.py @@ -3,7 +3,8 @@ import asyncio import aiohttp import quart import base64 - +import datetime +import re from astrbot.api.platform import AstrBotMessage, MessageMember, MessageType from astrbot.api.message_components import Plain, Image, At, Record from astrbot.api import logger, sp @@ -67,6 +68,17 @@ class SimpleGewechatClient(): logger.critical("收到 gewechat 下线通知。") return + if 'Data' in data and 'CreateTime' in data['Data']: + # 得到系统 UTF+8 的 ts + tz_offset = datetime.timedelta(hours=8) + tz = datetime.timezone(tz_offset) + ts = datetime.datetime.now(tz).timestamp() + create_time = data['Data']['CreateTime'] + if create_time < ts - 30: + logger.warning(f"消息时间戳过旧: {create_time},当前时间戳: {ts}") + return + + abm = AstrBotMessage() d = data['Data'] @@ -88,7 +100,8 @@ class SimpleGewechatClient(): content = _t[1] if '\u2005' in content: # at - content = content.split('\u2005')[1] + # content = content.split('\u2005')[1] + content = re.sub(r'@[^\u2005]*\u2005', '', content) abm.group_id = from_user_name # at msg_source = d['MsgSource'] @@ -107,7 +120,8 @@ class SimpleGewechatClient(): user_real_name = d.get('PushContent', 'unknown : ').split(' : ')[0] \ .replace('在群聊中@了你', '') \ - .replace('在群聊中发了一段语音', '') # 真实昵称 + .replace('在群聊中发了一段语音', '') \ + .replace('在群聊中发了一张图片', '') # 真实昵称 abm.sender = MessageMember(user_id, user_real_name) abm.raw_message = d abm.message_str = "" @@ -141,12 +155,11 @@ class SimpleGewechatClient(): with open(file_path, "wb") as f: f.write(voice_data) abm.message.append(Record(file=file_path, url=file_path)) - case _: - logger.error(f"未实现的消息类型: {d['MsgType']}") - return + logger.info(f"未实现的消息类型: {d['MsgType']}") + abm.raw_message = d - logger.info(f"abm: {abm}") + logger.debug(f"abm: {abm}") return abm async def callback(self): @@ -189,7 +202,7 @@ class SimpleGewechatClient(): logger.info(f"设置回调结果: {json_blob}") if json_blob['ret'] != 200: raise Exception(f"设置回调失败: {json_blob}") - logger.info(f"将在 {self.callback_url} 上接收 gewechat 下发的消息。如果一直没收到消息请先尝试重启 AstrBot。") + logger.info(f"将在 {self.callback_url} 上接收 gewechat 下发的消息。如果一直没收到消息请先尝试重启 AstrBot。如果仍没收到请到管理面板聊天页输入 /gewe_logout 重新登录。") async def start_polling(self): threading.Thread(target=asyncio.run, args=(self._set_callback_url(),)).start() @@ -300,12 +313,14 @@ class SimpleGewechatClient(): self.appid = appid logger.info(f"已保存 APPID: {appid}") - async def post_text(self, to_wxid, content: str): + async def post_text(self, to_wxid, content: str, ats: str = ""): payload = { "appId": self.appid, "toWxid": to_wxid, "content": content, } + if ats: + payload['ats'] = ats async with aiohttp.ClientSession() as session: async with session.post( @@ -349,4 +364,21 @@ class SimpleGewechatClient(): json=payload ) as resp: json_blob = await resp.json() - logger.debug(f"发送语音结果: {json_blob}") \ No newline at end of file + logger.debug(f"发送语音结果: {json_blob}") + + async def post_file(self, to_wxid, file_url: str, file_name: str): + payload = { + "appId": self.appid, + "toWxid": to_wxid, + "fileUrl": file_url, + "fileName": file_name + } + + async with aiohttp.ClientSession() as session: + async with session.post( + f"{self.base_url}/message/postFile", + headers=self.headers, + json=payload + ) as resp: + json_blob = await resp.json() + logger.debug(f"发送文件结果: {json_blob}") \ No newline at end of file diff --git a/astrbot/core/platform/sources/gewechat/gewechat_event.py b/astrbot/core/platform/sources/gewechat/gewechat_event.py index 27048b8e5..e329780a4 100644 --- a/astrbot/core/platform/sources/gewechat/gewechat_event.py +++ b/astrbot/core/platform/sources/gewechat/gewechat_event.py @@ -1,12 +1,13 @@ import wave import uuid +import traceback import os from astrbot.core.utils.io import save_temp_img, download_image_by_url, download_file from astrbot.core.utils.tencent_record_helper import wav_to_tencent_silk from astrbot.api import logger from astrbot.api.event import AstrMessageEvent, MessageChain from astrbot.api.platform import AstrBotMessage, PlatformMetadata -from astrbot.api.message_components import Plain, Image, Record +from astrbot.api.message_components import Plain, Image, Record, At, File from .client import SimpleGewechatClient def get_wav_duration(file_path): @@ -15,6 +16,8 @@ def get_wav_duration(file_path): n_channels, sampwidth, framerate, n_frames = wav_file.getparams()[:4] if n_frames == 2147483647: duration = (file_size - 44) / (n_channels * sampwidth * framerate) + elif n_frames == 0: + duration = (file_size - 44) / (n_channels * sampwidth * framerate) else: duration = n_frames / float(framerate) return duration @@ -43,9 +46,31 @@ class GewechatPlatformEvent(AstrMessageEvent): logger.error("无法获取到 to_wxid。") return + # 检查@ + ats = [] + ats_names = [] + for comp in message.chain: + if isinstance(comp, At): + ats.append(comp.qq) + ats_names.append(comp.name) + has_at = False + for comp in message.chain: if isinstance(comp, Plain): - await self.client.post_text(to_wxid, comp.text) + text = comp.text + payload = { + "to_wxid": to_wxid, + "content": text, + } + if not has_at and ats: + ats = f"{','.join(ats)}" + ats_names = f"@{' @'.join(ats_names)}" + text = f"{ats_names} {text}" + payload["content"] = text + payload["ats"] = ats + has_at = True + await self.client.post_text(**payload) + elif isinstance(comp, Image): img_url = comp.file img_path = "" @@ -80,23 +105,35 @@ class GewechatPlatformEvent(AstrMessageEvent): record_path = record_url silk_path = f"data/temp/{uuid.uuid4()}.silk" - duration = await wav_to_tencent_silk(record_path, silk_path) - - print(f"duration: {duration}, {silk_path}") - - # 检查 record_path 是否在 data/temp 目录中, record_path 可能是绝对路径 - # temp_directory = os.path.abspath('data/temp') - # record_path = os.path.abspath(record_path) - # if os.path.commonpath([temp_directory, record_path]) != temp_directory: - # with open(record_path, "rb") as f: - # record_path = f"data/temp/{uuid.uuid4()}.wav" - # with open(record_path, "wb") as f2: - # f2.write(f.read()) - + try: + duration = await wav_to_tencent_silk(record_path, silk_path) + except Exception as e: + logger.error(traceback.format_exc()) + await self.send(MessageChain().message(f"语音文件转换失败。{str(e)}")) + logger.info("Silk 语音文件格式转换至: " + record_path) if duration == 0: duration = get_wav_duration(record_path) - file_id = os.path.basename(silk_path) record_url = f"{self.client.file_server_url}/{file_id}" + logger.debug(f"gewe callback record url: {record_url}") await self.client.post_voice(to_wxid, record_url, duration*1000) + elif isinstance(comp, File): + file_path = comp.file + file_name = comp.name + if file_path.startswith("file:///"): + file_path = file_path[8:] + elif file_path.startswith("http"): + await download_file(file_path, f"data/temp/{file_name}") + else: + file_path = file_path + + file_id = os.path.basename(file_path) + file_url = f"{self.client.file_server_url}/{file_id}" + logger.debug(f"gewe callback file url: {file_url}") + await self.client.post_file(to_wxid, file_url, file_id) + elif isinstance(comp, At): + pass + else: + logger.error(f"gewechat 暂不支持发送消息类型: {comp.type}") + await super().send(message) \ No newline at end of file diff --git a/astrbot/core/platform/sources/gewechat/gewechat_platform_adapter.py b/astrbot/core/platform/sources/gewechat/gewechat_platform_adapter.py index 2862b817c..1ca47391e 100644 --- a/astrbot/core/platform/sources/gewechat/gewechat_platform_adapter.py +++ b/astrbot/core/platform/sources/gewechat/gewechat_platform_adapter.py @@ -30,10 +30,6 @@ class GewechatPlatformAdapter(Platform): @override async def send_by_session(self, session: MessageSesion, message_chain: MessageChain): to_wxid = session.session_id - if "_" in to_wxid: - # 群聊,开启了独立会话 - _, to_wxid = to_wxid.split("_") - if not to_wxid: logger.error("无法获取到 to_wxid。") return diff --git a/astrbot/core/platform/sources/lark/lark_adapter.py b/astrbot/core/platform/sources/lark/lark_adapter.py new file mode 100644 index 000000000..6a9dc578d --- /dev/null +++ b/astrbot/core/platform/sources/lark/lark_adapter.py @@ -0,0 +1,175 @@ +import base64 +import time +import asyncio +import json +import re + +from astrbot.api.platform import Platform, AstrBotMessage, MessageMember, MessageType, PlatformMetadata +from astrbot.api.event import MessageChain +from typing import Union, List +from astrbot.api.message_components import Image, Plain, At +from astrbot.core.platform.astr_message_event import MessageSesion +from .lark_event import LarkMessageEvent +from ...register import register_platform_adapter +from astrbot.core.message.components import BaseMessageComponent +from astrbot import logger +import lark_oapi as lark +from lark_oapi.api.im.v1 import * + +@register_platform_adapter("lark", "飞书机器人官方 API 适配器") +class LarkPlatformAdapter(Platform): + + def __init__(self, platform_config: dict, platform_settings: dict, event_queue: asyncio.Queue) -> None: + super().__init__(event_queue) + + self.config = platform_config + + self.unique_session = platform_settings['unique_session'] + + self.appid = platform_config['app_id'] + self.appsecret = platform_config['app_secret'] + self.domain = platform_config.get('domain', lark.FEISHU_DOMAIN) + self.bot_name = platform_config.get('lark_bot_name', "astrbot") + + if not self.bot_name: + logger.warning("未设置飞书机器人名称,@ 机器人可能得不到回复。") + + async def on_msg_event_recv(event: lark.im.v1.P2ImMessageReceiveV1): + await self.convert_msg(event) + + def do_v2_msg_event(event: lark.im.v1.P2ImMessageReceiveV1): + asyncio.create_task(on_msg_event_recv(event)) + + self.event_handler = lark.EventDispatcherHandler.builder("", "") \ + .register_p2_im_message_receive_v1(do_v2_msg_event) \ + .build() + + self.client = lark.ws.Client( + app_id=self.appid, + app_secret=self.appsecret, + log_level=lark.LogLevel.ERROR, + domain=self.domain, + event_handler=self.event_handler + ) + + self.lark_api = ( + lark.Client.builder() + .app_id(self.appid) + .app_secret(self.appsecret) + .build() + ) + + async def send_by_session(self, session: MessageSesion, message_chain: MessageChain): + raise NotImplementedError("QQ 机器人官方 API 适配器不支持 send_by_session") + + def meta(self) -> PlatformMetadata: + return PlatformMetadata( + "lark", + "飞书机器人官方 API 适配器", + ) + + async def convert_msg(self, event: lark.im.v1.P2ImMessageReceiveV1): + message = event.event.message + abm = AstrBotMessage() + abm.timestamp = int(message.create_time) / 1000 + abm.message = [] + abm.type = MessageType.GROUP_MESSAGE if message.chat_type == 'group' else MessageType.FRIEND_MESSAGE + if message.chat_type == 'group': + abm.group_id = message.chat_id + abm.self_id = self.bot_name + abm.message_str = "" + + at_list = {} + if message.mentions: + for m in message.mentions: + at_list[m.key] = At(qq=m.id.open_id, name=m.name) + if m.name == self.bot_name: + abm.self_id = m.id.open_id + + content_json_b = json.loads(message.content) + + if message.message_type == 'text': + message_str_raw = content_json_b['text'] # 带有 @ 的消息 + at_pattern = r"(@_user_\d+)" # 可以根据需求修改正则 + at_users = re.findall(at_pattern, message_str_raw) + # 拆分文本,去掉AT符号部分 + parts = re.split(at_pattern, message_str_raw) + for i in range(len(parts)): + s = parts[i].strip() + if not s: + continue + if s in at_list: + abm.message.append(at_list[s]) + else: + abm.message.append(Plain(parts[i].strip())) + elif message.message_type == 'post': + _ls = [] + + content_ls = content_json_b.get('content', []) + for comp in content_ls: + if isinstance(comp, list): + _ls.extend(comp) + elif isinstance(comp, dict): + _ls.append(comp) + content_json_b = _ls + elif message.message_type == 'image': + content_json_b = [ + {"tag": "img", "image_key": content_json_b["image_key"], "style": []} + ] + + if message.message_type in ('post', 'image'): + for comp in content_json_b: + if comp['tag'] == 'at': + abm.message.append(at_list[comp['user_id']]) + elif comp['tag'] == 'text' and comp['text'].strip(): + abm.message.append(Plain(comp['text'].strip())) + elif comp['tag'] == 'img': + image_key = comp['image_key'] + request = GetMessageResourceRequest.builder() \ + .message_id(message.message_id) \ + .file_key(image_key) \ + .type("image") \ + .build() + response = await self.lark_api.im.v1.message_resource.aget(request) + if not response.success(): + logger.error(f"无法下载飞书图片: {image_key}") + image_bytes = response.file.read() + image_base64 = base64.b64encode(image_bytes).decode() + abm.message.append(Image.fromBase64(image_base64)) + + for comp in abm.message: + if isinstance(comp, Plain): + abm.message_str += comp.text + abm.message_id = message.message_id + abm.raw_message = message + abm.sender = MessageMember( + user_id=event.event.sender.sender_id.open_id, + nickname=event.event.sender.sender_id.open_id[:8] + ) + # 独立会话 + if not self.unique_session: + if abm.type == MessageType.GROUP_MESSAGE: + abm.session_id = abm.group_id + else: + abm.session_id = abm.sender.user_id + else: + abm.session_id = abm.sender.user_id + + logger.debug(abm) + await self.handle_msg(abm) + + async def handle_msg(self, abm: AstrBotMessage): + event = LarkMessageEvent( + message_str=abm.message_str, + message_obj=abm, + platform_meta=self.meta(), + session_id=abm.session_id, + bot=self.lark_api + ) + + self._event_queue.put_nowait(event) + + async def run(self): + # self.client.start() + await self.client._connect() + \ No newline at end of file diff --git a/astrbot/core/platform/sources/lark/lark_event.py b/astrbot/core/platform/sources/lark/lark_event.py new file mode 100644 index 000000000..f3b1529ec --- /dev/null +++ b/astrbot/core/platform/sources/lark/lark_event.py @@ -0,0 +1,96 @@ +import json +import uuid +import lark_oapi as lark +from typing import List +from astrbot.api.event import AstrMessageEvent, MessageChain +from astrbot.api.message_components import Plain, Image as AstrBotImage, Record, At, Node, Music, Video +from astrbot.core.utils.io import file_to_base64, download_image_by_url +from lark_oapi.api.im.v1 import * +from astrbot import logger + +class LarkMessageEvent(AstrMessageEvent): + def __init__(self, message_str, message_obj, platform_meta, session_id, bot: lark.Client): + super().__init__(message_str, message_obj, platform_meta, session_id) + self.bot = bot + + @staticmethod + async def _convert_to_lark(message: MessageChain, lark_client: lark.Client) -> List: + ret = [] + _stage = [] + for comp in message.chain: + if isinstance(comp, Plain): + _stage.append({ + "tag": "md", + "text": comp.text + }) + elif isinstance(comp, At): + _stage.append({ + "tag": "at", + "user_id": comp.qq, + "style": [] + }) + elif isinstance(comp, AstrBotImage): + file_path = "" + if comp.file and comp.file.startswith("file:///"): + file_path = comp.file.replace('file:///', '') + elif comp.file and comp.file.startswith("http"): + image_file_path = await download_image_by_url(comp.file) + file_path = image_file_path + elif comp.file and comp.file.startswith("base64://"): + pass + else: + file_path = comp.file + + request = CreateImageRequest.builder() \ + .request_body( \ + CreateImageRequestBody.builder() \ + .image_type("message") \ + .image(open(file_path, 'rb')) \ + .build() \ + ) \ + .build() + response = await lark_client.im.v1.image.acreate(request) + if not response.success(): + logger.error(f"无法上传飞书图片({response.code}): {response.msg}") + image_key = response.data.image_key + print(image_key) + ret.append(_stage) + ret.append([{ + "tag": "img", + "image_key": image_key + }]) + _stage.clear() + else: + logger.warning(f"飞书 暂时不支持消息段: {comp.type}") + + if _stage: + ret.append(_stage) + return ret + + async def send(self, message: MessageChain): + res = await LarkMessageEvent._convert_to_lark(message, self.bot) + wrapped = { + "zh_cn": { + "title": "", + "content": res, + } + } + + request = ReplyMessageRequest.builder() \ + .message_id(self.message_obj.message_id) \ + .request_body( \ + ReplyMessageRequestBody.builder() \ + .content(json.dumps(wrapped)) \ + .msg_type("post") \ + .uuid(str(uuid.uuid4())) \ + .reply_in_thread(False) \ + .build() \ + ) \ + .build() + + response = await self.bot.im.v1.message.areply(request) + + if not response.success(): + logger.error(f"回复飞书消息失败({response.code}): {response.msg}") + + await super().send(message) \ No newline at end of file diff --git a/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py b/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py index 9cadb1447..fec3ed062 100644 --- a/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py +++ b/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py @@ -5,9 +5,10 @@ import botpy.types.message from astrbot.core.utils.io import file_to_base64, download_image_by_url from astrbot.api.event import AstrMessageEvent, MessageChain from astrbot.api.platform import AstrBotMessage, PlatformMetadata -from astrbot.api.message_components import Plain, Image, Reply +from astrbot.api.message_components import Plain, Image from botpy import Client from botpy.http import Route +from astrbot.api import logger class QQOfficialMessageEvent(AstrMessageEvent): @@ -29,18 +30,8 @@ class QQOfficialMessageEvent(AstrMessageEvent): plain_text, image_base64, image_path = await QQOfficialMessageEvent._parse_to_qqofficial(self.send_buffer) - ref = None - for i in self.send_buffer.chain: - if isinstance(i, Reply): - try: - ref = self.message_obj.raw_message.message_reference - ref = botpy.types.message.Reference( - message_id=ref.message_id, - ignore_get_message_error=False - ) - except BaseException as _: - pass - break + if not plain_text and not image_base64 and not image_path: + return payload = { 'content': plain_text, @@ -49,30 +40,22 @@ class QQOfficialMessageEvent(AstrMessageEvent): match type(source): case botpy.message.GroupMessage: - if ref: - payload['message_reference'] = ref if image_base64: media = await self.upload_group_and_c2c_image(image_base64, 1, group_openid=source.group_openid) payload['media'] = media payload['msg_type'] = 7 await self.bot.api.post_group_message(group_openid=source.group_openid, **payload) case botpy.message.C2CMessage: - if ref: - payload['message_reference'] = ref if image_base64: media = await self.upload_group_and_c2c_image(image_base64, 1, openid=source.author.user_openid) payload['media'] = media payload['msg_type'] = 7 await self.bot.api.post_c2c_message(openid=source.author.user_openid, **payload) case botpy.message.Message: - if ref: - payload['message_reference'] = ref if image_path: payload['file_image'] = image_path await self.bot.api.post_message(channel_id=source.channel_id, **payload) case botpy.message.DirectMessage: - if ref: - payload['message_reference'] = ref if image_path: payload['file_image'] = image_path await self.bot.api.post_dms(guild_id=source.guild_id, **payload) @@ -114,4 +97,6 @@ class QQOfficialMessageEvent(AstrMessageEvent): else: image_base64 = file_to_base64(i.file).replace("base64://", "") image_file_path = i.file + else: + logger.error(f"qq_official 暂不支持发送消息类型 {i.type}") return plain_text, image_base64, image_file_path \ No newline at end of file diff --git a/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py b/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py index de94ab6a4..4548bbf3c 100644 --- a/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py +++ b/astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import botpy import logging import time @@ -28,25 +30,25 @@ class botClient(Client): # 收到群消息 async def on_group_at_message_create(self, message: botpy.message.GroupMessage): - abm = self.platform._parse_from_qqofficial(message, MessageType.GROUP_MESSAGE) + abm = QQOfficialPlatformAdapter._parse_from_qqofficial(message, MessageType.GROUP_MESSAGE) abm.session_id = abm.sender.user_id if self.platform.unique_session else message.group_openid self._commit(abm) # 收到频道消息 async def on_at_message_create(self, message: botpy.message.Message): - abm = self.platform._parse_from_qqofficial(message, MessageType.GROUP_MESSAGE) + abm = QQOfficialPlatformAdapter._parse_from_qqofficial(message, MessageType.GROUP_MESSAGE) abm.session_id = abm.sender.user_id if self.platform.unique_session else message.channel_id self._commit(abm) # 收到私聊消息 async def on_direct_message_create(self, message: botpy.message.DirectMessage): - abm = self.platform._parse_from_qqofficial(message, MessageType.FRIEND_MESSAGE) + abm = QQOfficialPlatformAdapter._parse_from_qqofficial(message, MessageType.FRIEND_MESSAGE) abm.session_id = abm.sender.user_id self._commit(abm) # 收到 C2C 消息 async def on_c2c_message_create(self, message: botpy.message.C2CMessage): - abm = self.platform._parse_from_qqofficial(message, MessageType.FRIEND_MESSAGE) + abm = QQOfficialPlatformAdapter._parse_from_qqofficial(message, MessageType.FRIEND_MESSAGE) abm.session_id = abm.sender.user_id self._commit(abm) @@ -102,7 +104,8 @@ class QQOfficialPlatformAdapter(Platform): "QQ 机器人官方 API 适配器", ) - def _parse_from_qqofficial(self, message: Union[botpy.message.Message, botpy.message.GroupMessage], + @staticmethod + def _parse_from_qqofficial(message: Union[botpy.message.Message, botpy.message.GroupMessage], message_type: MessageType): abm = AstrBotMessage() abm.type = message_type diff --git a/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_adapter.py b/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_adapter.py new file mode 100644 index 000000000..90e6f7e5b --- /dev/null +++ b/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_adapter.py @@ -0,0 +1,99 @@ +import botpy +import logging +import asyncio +import botpy.message +import botpy.types +import botpy.types.message + +from botpy import Client +from astrbot.api.platform import Platform, AstrBotMessage, MessageType, PlatformMetadata +from astrbot.api.event import MessageChain +from astrbot.core.platform.astr_message_event import MessageSesion +from .qo_webhook_event import QQOfficialWebhookMessageEvent +from ...register import register_platform_adapter +from .qo_webhook_server import QQOfficialWebhook +from ..qqofficial.qqofficial_platform_adapter import QQOfficialPlatformAdapter + +# remove logger handler +for handler in logging.root.handlers[:]: + logging.root.removeHandler(handler) + +# QQ 机器人官方框架 +class botClient(Client): + def set_platform(self, platform: 'QQOfficialWebhookPlatformAdapter'): + self.platform = platform + + # 收到群消息 + async def on_group_at_message_create(self, message: botpy.message.GroupMessage): + abm = QQOfficialPlatformAdapter._parse_from_qqofficial(message, MessageType.GROUP_MESSAGE) + abm.session_id = abm.sender.user_id if self.platform.unique_session else message.group_openid + self._commit(abm) + + # 收到频道消息 + async def on_at_message_create(self, message: botpy.message.Message): + abm = QQOfficialPlatformAdapter._parse_from_qqofficial(message, MessageType.GROUP_MESSAGE) + abm.session_id = abm.sender.user_id if self.platform.unique_session else message.channel_id + self._commit(abm) + + # 收到私聊消息 + async def on_direct_message_create(self, message: botpy.message.DirectMessage): + abm = QQOfficialPlatformAdapter._parse_from_qqofficial(message, MessageType.FRIEND_MESSAGE) + abm.session_id = abm.sender.user_id + self._commit(abm) + + # 收到 C2C 消息 + async def on_c2c_message_create(self, message: botpy.message.C2CMessage): + abm = QQOfficialPlatformAdapter._parse_from_qqofficial(message, MessageType.FRIEND_MESSAGE) + abm.session_id = abm.sender.user_id + self._commit(abm) + + def _commit(self, abm: AstrBotMessage): + self.platform.commit_event(QQOfficialWebhookMessageEvent( + abm.message_str, + abm, + self.platform.meta(), + abm.session_id, + self + )) + +@register_platform_adapter("qq_official_webhook", "QQ 机器人官方 API 适配器(Webhook)") +class QQOfficialWebhookPlatformAdapter(Platform): + + def __init__(self, platform_config: dict, platform_settings: dict, event_queue: asyncio.Queue) -> None: + super().__init__(event_queue) + + self.config = platform_config + + self.appid = platform_config['appid'] + self.secret = platform_config['secret'] + self.unique_session = platform_settings['unique_session'] + + intents = botpy.Intents( + public_messages=True, + public_guild_messages=True, + direct_message=True + ) + self.client = botClient( + intents=intents, # 已经无用 + bot_log=False, + timeout=20, + ) + self.client.set_platform(self) + + async def send_by_session(self, session: MessageSesion, message_chain: MessageChain): + raise NotImplementedError("QQ 机器人官方 API 适配器不支持 send_by_session") + + def meta(self) -> PlatformMetadata: + return PlatformMetadata( + "qq_official_webhook", + "QQ 机器人官方 API 适配器", + ) + + async def run(self): + self.webhook_helper = QQOfficialWebhook( + self.config, + self._event_queue, + self.client + ) + await self.webhook_helper.initialize() + await self.webhook_helper.start_polling() \ No newline at end of file diff --git a/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_event.py b/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_event.py new file mode 100644 index 000000000..2056ab56e --- /dev/null +++ b/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_event.py @@ -0,0 +1,18 @@ +import botpy +import botpy.message +import botpy.types +import botpy.types.message +from astrbot.core.utils.io import file_to_base64, download_image_by_url +from astrbot.api.event import AstrMessageEvent, MessageChain +from astrbot.api.platform import AstrBotMessage, PlatformMetadata +from astrbot.api.message_components import Plain, Image, Reply +from botpy import Client +from botpy.http import Route +from astrbot.api import logger +from ..qqofficial.qqofficial_message_event import QQOfficialMessageEvent + + +class QQOfficialWebhookMessageEvent(QQOfficialMessageEvent): + def __init__(self, message_str: str, message_obj: AstrBotMessage, platform_meta: PlatformMetadata, session_id: str, bot: Client): + super().__init__(message_str, message_obj, platform_meta, session_id, bot) + \ No newline at end of file diff --git a/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_server.py b/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_server.py new file mode 100644 index 000000000..81ce8469e --- /dev/null +++ b/astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_server.py @@ -0,0 +1,108 @@ +import aiohttp +import quart +import json +import logging +import asyncio +import typing +from botpy import BotAPI, BotHttp, Client, Token, BotWebSocket, ConnectionSession +from astrbot.api import logger +import traceback +from cryptography.hazmat.primitives.asymmetric import ed25519 + +# remove logger handler +for handler in logging.root.handlers[:]: + logging.root.removeHandler(handler) + +class QQOfficialWebhook(): + def __init__(self, config: dict, event_queue: asyncio.Queue, botpy_client: Client): + self.appid = config['appid'] + self.secret = config['secret'] + self.port = config.get("port", 6196) + + if isinstance(self.port, str): + self.port = int(self.port) + + self.http: BotHttp = BotHttp(timeout=300) + self.api: BotAPI = BotAPI(http=self.http) + self.token = Token(self.appid, self.secret) + + self.server = quart.Quart(__name__) + self.server.add_url_rule('/astrbot-qo-webhook/callback', view_func=self.callback, methods=['POST']) + self.client = botpy_client + self.event_queue = event_queue + + async def initialize(self): + logger.info(f"正在登录到 QQ 官方机器人...") + self.user = await self.http.login(self.token) + logger.info(f"已登录 QQ 官方机器人账号: {self.user}") + # 直接注入到 botpy 的 Client,移花接木! + self.client.api = self.api + self.client.http = self.http + + async def bot_connect(): + pass + + self._connection = ConnectionSession( + max_async=1, + connect=bot_connect, + dispatch=self.client.ws_dispatch, + loop=asyncio.get_event_loop(), + api=self.api, + ) + + async def repeat_seed(self, bot_secret: str, target_size: int = 32) -> bytes: + seed = bot_secret + while len(seed) < target_size: + seed *= 2 + return seed[:target_size].encode('utf-8') + + + async def webhook_validation(self, validation_payload: dict): + seed = await self.repeat_seed(self.secret) + private_key = ed25519.Ed25519PrivateKey.from_private_bytes(seed) + msg = validation_payload.get("event_ts", "") + validation_payload.get("plain_token", "") + # sign + signature = private_key.sign(msg.encode()).hex() + response = { + "plain_token": validation_payload.get("plain_token"), + "signature": signature + } + return response + + async def callback(self): + msg: dict = await quart.request.json + logger.debug(f"收到 qq_official_webhook 回调: {msg}") + + event = msg.get("t") + opcode = msg.get("op") + data = msg.get("d") + + if opcode == 13: + # validation + signed = await self.webhook_validation(data) + print(signed) + return signed + + if event and opcode == BotWebSocket.WS_DISPATCH_EVENT: + event = msg["t"].lower() + try: + func = self._connection.parser[event] + except KeyError: + logger.error("_parser unknown event %s.", event) + else: + func(msg) + + return {"opcode": 12} + + async def start_polling(self): + await self.server.run_task( + host='0.0.0.0', + port=self.port, + shutdown_trigger=self.shutdown_trigger_placeholder + ) + + async def shutdown_trigger_placeholder(self): + while not self.event_queue.closed: + await asyncio.sleep(1) + logger.info("qq_official_webhook 适配器已关闭。") + \ No newline at end of file diff --git a/astrbot/core/platform/sources/vchat/vchat_message_event.py b/astrbot/core/platform/sources/vchat/vchat_message_event.py deleted file mode 100644 index 13242138f..000000000 --- a/astrbot/core/platform/sources/vchat/vchat_message_event.py +++ /dev/null @@ -1,44 +0,0 @@ -import random -import asyncio -from astrbot.core.utils.io import download_image_by_url -from astrbot.api import logger -from astrbot.api.event import AstrMessageEvent, MessageChain -from astrbot.api.platform import AstrBotMessage, PlatformMetadata -from astrbot.api.message_components import Plain, Image -from vchat import Core - -class VChatPlatformEvent(AstrMessageEvent): - def __init__(self, message_str: str, message_obj: AstrBotMessage, platform_meta: PlatformMetadata, session_id: str, client: Core): - super().__init__(message_str, message_obj, platform_meta, session_id) - self.client = client - - @staticmethod - async def send_with_client(client: Core, message: MessageChain, user_name: str): - plain = "" - for comp in message.chain: - if isinstance(comp, Plain): - if message.is_split_: - await client.send_msg(comp.text, user_name) - else: - plain += comp.text - elif isinstance(comp, Image): - if comp.file and comp.file.startswith("file:///"): - file_path = comp.file.replace("file:///", "") - with open(file_path, "rb") as f: - await client.send_image(user_name, fd=f) - elif comp.file and comp.file.startswith("http"): - image_path = await download_image_by_url(comp.file) - with open(image_path, "rb") as f: - await client.send_image(user_name, fd=f) - else: - logger.error(f"不支持的 vchat(微信适配器) 消息类型: {comp}") - await asyncio.sleep(random.uniform(0.5, 1.5)) # 🤓 - - if plain: - await client.send_msg(plain, user_name) - - - async def send(self, message: MessageChain): - await VChatPlatformEvent.send_with_client(self.client, message, self.message_obj.raw_message.from_.username) - await super().send(message) - \ No newline at end of file diff --git a/astrbot/core/platform/sources/vchat/vchat_platform_adapter.py b/astrbot/core/platform/sources/vchat/vchat_platform_adapter.py deleted file mode 100644 index f224ecf8f..000000000 --- a/astrbot/core/platform/sources/vchat/vchat_platform_adapter.py +++ /dev/null @@ -1,120 +0,0 @@ -import sys -import time -import uuid -import asyncio -import os - -from astrbot.api.platform import Platform, AstrBotMessage, MessageMember, MessageType, PlatformMetadata -from astrbot.api.event import MessageChain -from astrbot.api.message_components import * -from astrbot.api import logger -from astrbot.core.platform.astr_message_event import MessageSesion -from .vchat_message_event import VChatPlatformEvent -from ...register import register_platform_adapter - -from vchat import Core -from vchat import model - -if sys.version_info >= (3, 12): - from typing import override -else: - from typing_extensions import override - -@register_platform_adapter("vchat", "基于 VChat 的 Wechat 适配器") -class VChatPlatformAdapter(Platform): - - def __init__(self, platform_config: dict, platform_settings: dict, event_queue: asyncio.Queue) -> None: - super().__init__(event_queue) - self.config = platform_config - self.settingss = platform_settings - self.test_mode = os.environ.get('TEST_MODE', 'off') == 'on' - self.client_self_id = uuid.uuid4().hex[:8] - - @override - async def send_by_session(self, session: MessageSesion, message_chain: MessageChain): - from_username = session.session_id.split('$$')[0] - await VChatPlatformEvent.send_with_client(self.client, message_chain, from_username) - await super().send_by_session(session, message_chain) - - @override - def meta(self) -> PlatformMetadata: - return PlatformMetadata( - "vchat", - "基于 VChat 的 Wechat 适配器", - ) - - @override - def run(self): - self.client = Core() - @self.client.msg_register(msg_types=model.ContentTypes.TEXT, - contact_type=model.ContactTypes.CHATROOM | model.ContactTypes.USER) - async def _(msg: model.Message): - if isinstance(msg.content, model.UselessContent): - return - if msg.create_time < self.start_time: - logger.debug(f"忽略旧消息: {msg}") - return - logger.debug(f"收到消息: {msg.todict()}") - abmsg = self.convert_message(msg) - # await self.handle_msg(abmsg) # 不能直接调用,否则会阻塞 - asyncio.create_task(self.handle_msg(abmsg)) - - # TODO: 对齐微信服务器时间 - self.start_time = int(time.time()) - return self._run() - - - async def _run(self): - await self.client.init() - await self.client.auto_login(hot_reload=True, enable_cmd_qr=True) - await self.client.run() - - def convert_message(self, msg: model.Message) -> AstrBotMessage: - # credits: https://github.com/z2z63/astrbot_plugin_vchat/blob/master/main.py#L49 - assert isinstance(msg.content, model.TextContent) - amsg = AstrBotMessage() - amsg.message = [Plain(msg.content.content)] - amsg.self_id = self.client_self_id - if msg.content.is_at_me: - amsg.message.insert(0, At(qq=amsg.self_id)) - - sender = msg.chatroom_sender or msg.from_ - amsg.sender = MessageMember(sender.username, sender.nickname) - - if msg.content.is_at_me: - amsg.message_str = msg.content.content.split("\u2005")[1].strip() - else: - amsg.message_str = msg.content.content - amsg.message_id = msg.message_id - if isinstance(msg.from_, model.User): - amsg.type = MessageType.FRIEND_MESSAGE - elif isinstance(msg.from_, model.Chatroom): - amsg.type = MessageType.GROUP_MESSAGE - amsg.group_id = msg.from_.username - else: - logger.error(f"不支持的 Wechat 消息类型: {msg.from_}") - - amsg.raw_message = msg - - if self.settingss['unique_session']: - session_id = msg.from_.username + "$$" + msg.to.username - if msg.chatroom_sender is not None: - session_id += '$$' + msg.chatroom_sender.username - else: - session_id = msg.from_.username - - amsg.session_id = session_id - return amsg - - async def handle_msg(self, message: AstrBotMessage): - message_event = VChatPlatformEvent( - message_str=message.message_str, - message_obj=message, - platform_meta=self.meta(), - session_id=message.session_id, - client=self.client - ) - - logger.info(f"处理消息: {message_event}") - - self.commit_event(message_event) \ No newline at end of file diff --git a/astrbot/core/platform/sources/webchat/webchat_event.py b/astrbot/core/platform/sources/webchat/webchat_event.py index f447a616c..8345b5c51 100644 --- a/astrbot/core/platform/sources/webchat/webchat_event.py +++ b/astrbot/core/platform/sources/webchat/webchat_event.py @@ -1,8 +1,9 @@ import os import uuid +from astrbot.api import logger from astrbot.api.event import AstrMessageEvent, MessageChain from astrbot.api.message_components import Plain, Image -from astrbot.core.utils.io import file_to_base64, download_image_by_url +from astrbot.core.utils.io import download_image_by_url from astrbot.core import web_chat_back_queue class WebChatMessageEvent(AstrMessageEvent): @@ -37,5 +38,7 @@ class WebChatMessageEvent(AstrMessageEvent): with open(comp.file, "rb") as f2: f.write(f2.read()) web_chat_back_queue.put_nowait((f"[IMAGE]{filename}", cid)) + else: + logger.error(f"webchat 暂不支持发送消息类型: {comp.type}") web_chat_back_queue.put_nowait(None) await super().send(message) \ No newline at end of file diff --git a/astrbot/core/provider/entites.py b/astrbot/core/provider/entites.py index 804cb5b1c..87b9d006b 100644 --- a/astrbot/core/provider/entites.py +++ b/astrbot/core/provider/entites.py @@ -52,7 +52,7 @@ class ProviderRequest(): @dataclass class LLMResponse: role: str - '''角色''' + '''角色, assistant, tool, err''' completion_text: str = "" '''LLM 返回的文本''' tools_call_args: List[Dict[str, any]] = field(default_factory=list) diff --git a/astrbot/core/provider/manager.py b/astrbot/core/provider/manager.py index 811b02e15..2ba108e29 100644 --- a/astrbot/core/provider/manager.py +++ b/astrbot/core/provider/manager.py @@ -114,22 +114,24 @@ class ProviderManager(): try: match provider_cfg['type']: case "openai_chat_completion": - from .sources.openai_source import ProviderOpenAIOfficial # noqa: F401 + from .sources.openai_source import ProviderOpenAIOfficial as ProviderOpenAIOfficial case "zhipu_chat_completion": - from .sources.zhipu_source import ProviderZhipu # noqa: F401 + from .sources.zhipu_source import ProviderZhipu as ProviderZhipu case "llm_tuner": logger.info("加载 LLM Tuner 工具 ...") - from .sources.llmtuner_source import LLMTunerModelLoader # noqa: F401 + from .sources.llmtuner_source import LLMTunerModelLoader as LLMTunerModelLoader case "dify": - from .sources.dify_source import ProviderDify # noqa: F401 + from .sources.dify_source import ProviderDify as ProviderDify case "googlegenai_chat_completion": - from .sources.gemini_source import ProviderGoogleGenAI # noqa: F401 + from .sources.gemini_source import ProviderGoogleGenAI as ProviderGoogleGenAI case "openai_whisper_api": - from .sources.whisper_api_source import ProviderOpenAIWhisperAPI # noqa: F401 + from .sources.whisper_api_source import ProviderOpenAIWhisperAPI as ProviderOpenAIWhisperAPI case "openai_whisper_selfhost": - from .sources.whisper_selfhosted_source import ProviderOpenAIWhisperSelfHost # noqa: F401 + from .sources.whisper_selfhosted_source import ProviderOpenAIWhisperSelfHost as ProviderOpenAIWhisperSelfHost case "openai_tts_api": - from .sources.openai_tts_api_source import ProviderOpenAITTSAPI # noqa: F401 + from .sources.openai_tts_api_source import ProviderOpenAITTSAPI as ProviderOpenAITTSAPI + case "fishaudio_tts_api": + from .sources.fishaudio_tts_api_source import ProviderFishAudioTTSAPI as ProviderFishAudioTTSAPI except (ImportError, ModuleNotFoundError) as e: logger.critical(f"加载 {provider_cfg['type']}({provider_cfg['id']}) 提供商适配器失败:{e}。可能是因为有未安装的依赖。") continue @@ -160,7 +162,7 @@ class ProviderManager(): continue provider_metadata = provider_cls_map[provider_config['type']] - logger.info(f"尝试实例化 {provider_config['type']}({provider_config['id']}) 提供商适配器 ...") + logger.debug(f"尝试实例化 {provider_config['type']}({provider_config['id']}) 提供商适配器 ...") try: # 按任务实例化提供商 diff --git a/astrbot/core/provider/sources/dify_source.py b/astrbot/core/provider/sources/dify_source.py index d59e9c62b..3b171bda5 100644 --- a/astrbot/core/provider/sources/dify_source.py +++ b/astrbot/core/provider/sources/dify_source.py @@ -31,15 +31,21 @@ class ProviderDify(Provider): raise Exception("Dify API 类型不能为空。") self.model_name = "dify" self.workflow_output_key = provider_config.get("dify_workflow_output_key", "astrbot_wf_output") - + self.dify_query_input_key = provider_config.get("dify_query_input_key", "astrbot_text_query") + if not self.dify_query_input_key: + self.dify_query_input_key = "astrbot_text_query" + self.timeout = provider_config.get("timeout", 120) + if isinstance(self.timeout, str): + self.timeout = int(self.timeout) self.conversation_ids = {} + '''记录当前 session id 的对话 ID''' async def text_chat( self, prompt: str, session_id: str = None, - image_urls: List[str] = None, + image_urls: List[str] = [], func_tool: FuncCall = None, contexts: List = None, system_prompt: str = None, @@ -64,8 +70,6 @@ class ProviderDify(Provider): else: # TODO: 处理更多情况 logger.warning(f"未知的图片链接:{image_url},图片将忽略。") - - logger.debug(files_payload) # 获得会话变量 session_vars = sp.get("session_variables", {}) @@ -80,7 +84,8 @@ class ProviderDify(Provider): query=prompt, user=session_id, conversation_id=conversation_id, - files=files_payload + files=files_payload, + timeout=self.timeout ): logger.debug(f"dify resp chunk: {chunk}") if chunk['event'] == "message" or \ @@ -93,12 +98,13 @@ class ProviderDify(Provider): case "workflow": async for chunk in self.api_client.workflow_run( inputs={ - "astrbot_text_query": prompt, + self.dify_query_input_key: prompt, "astrbot_session_id": session_id, **session_var }, user=session_id, - files=files_payload + files=files_payload, + timeout=self.timeout ): match chunk['event']: case "workflow_started": @@ -115,11 +121,10 @@ class ProviderDify(Provider): result = chunk['data']['outputs'][self.workflow_output_key] case _: raise Exception(f"未知的 Dify API 类型:{self.api_type}") - return LLMResponse(role="assistant", completion_text=result) async def forget(self, session_id): - self.conversation_ids.pop(session_id, None) + self.conversation_ids[session_id] = "" return True async def get_current_key(self): diff --git a/astrbot/core/provider/sources/fishaudio_tts_api_source.py b/astrbot/core/provider/sources/fishaudio_tts_api_source.py new file mode 100644 index 000000000..84b4b677e --- /dev/null +++ b/astrbot/core/provider/sources/fishaudio_tts_api_source.py @@ -0,0 +1,105 @@ +import uuid +import ormsgpack +from pydantic import BaseModel, conint +from httpx import AsyncClient +from typing import Annotated, Literal +from ..provider import TTSProvider +from ..entites import ProviderType +from ..register import register_provider_adapter + + +class ServeReferenceAudio(BaseModel): + audio: bytes + text: str + + +class ServeTTSRequest(BaseModel): + text: str + chunk_length: Annotated[int, conint(ge=100, le=300, strict=True)] = 200 + # 音频格式 + format: Literal["wav", "pcm", "mp3"] = "mp3" + mp3_bitrate: Literal[64, 128, 192] = 128 + # 参考音频 + references: list[ServeReferenceAudio] = [] + # 参考模型 ID + # 例如 https://fish.audio/m/7f92f8afb8ec43bf81429cc1c9199cb1/ + # 其中reference_id为 7f92f8afb8ec43bf81429cc1c9199cb1 + reference_id: str | None = None + # 对中英文文本进行标准化,这可以提高数字的稳定性 + normalize: bool = True + # 平衡模式将延迟减少到300毫秒,但可能会降低稳定性 + latency: Literal["normal", "balanced"] = "normal" + + +@register_provider_adapter( + "fishaudio_tts_api", "FishAudio TTS API", provider_type=ProviderType.TEXT_TO_SPEECH +) +class ProviderFishAudioTTSAPI(TTSProvider): + def __init__( + self, + provider_config: dict, + provider_settings: dict, + ) -> None: + super().__init__(provider_config, provider_settings) + self.chosen_api_key: str = provider_config.get("api_key", "") + self.character: str = provider_config.get("fishaudio-tts-character", "可莉") + self.api_base: str = provider_config.get( + "api_base", "https://api.fish-audio.cn/v1" + ) + self.headers = { + "Authorization": f"Bearer {self.chosen_api_key}", + } + self.set_model(provider_config.get("model", None)) + + async def _get_reference_id_by_character(self, character: str) -> str: + """ + 获取角色的reference_id + + Args: + character: 角色名称 + + Returns: + reference_id: 角色的reference_id + + exception: + APIException: 获取语音角色列表为空 + """ + sort_options = ["score", "task_count", "created_at"] + async with AsyncClient(base_url=self.api_base.replace("/v1", "")) as client: + for sort_by in sort_options: + params = {"title": character, "sort_by": sort_by} + response = await client.get( + "/model", params=params, headers=self.headers + ) + resp_data = response.json() + if resp_data["total"] == 0: + continue + for item in resp_data["items"]: + if character in item["title"]: + return item["_id"] + return None + + async def _generate_request(self, text: str) -> dict: + return ServeTTSRequest( + text=text, + format="wav", + reference_id=await self._get_reference_id_by_character(self.character), + ) + + async def get_audio(self, text: str) -> str: + path = f"data/temp/fishaudio_tts_api_{uuid.uuid4()}.wav" + self.headers["content-type"] = "application/msgpack" + request = await self._generate_request(text) + async with AsyncClient(base_url=self.api_base).stream( + "POST", + "/tts", + headers=self.headers, + content=ormsgpack.packb(request, option=ormsgpack.OPT_SERIALIZE_PYDANTIC), + ) as response: + if response.headers["content-type"] == "audio/wav": + with open(path, "wb") as f: + async for chunk in response.aiter_bytes(): + f.write(chunk) + return path + text = await response.aread() + raise Exception(f"Fish Audio API请求失败: {text}") diff --git a/astrbot/core/provider/sources/gemini_source.py b/astrbot/core/provider/sources/gemini_source.py index 63cf7f9a2..3ce08968d 100644 --- a/astrbot/core/provider/sources/gemini_source.py +++ b/astrbot/core/provider/sources/gemini_source.py @@ -48,8 +48,18 @@ class SimpleGoogleGenAIClient(): logger.debug(f"payload: {payload}") request_url = f"{self.api_base}/v1beta/models/{model}:generateContent?key={self.api_key}" async with self.client.post(request_url, json=payload, timeout=self.timeout) as resp: - response = await resp.json() - return response + if "application/json" in resp.headers.get("Content-Type"): + try: + response = await resp.json() + except Exception as e: + text = await resp.text() + logger.error(f"Gemini 返回了非 json 数据: {text}") + raise e + return response + else: + text = await resp.text() + logger.error(f"Gemini 返回了非 json 数据: {text}") + raise Exception("Gemini 返回了非 json 数据: ") @register_provider_adapter("googlegenai_chat_completion", "Google Gemini Chat Completion 提供商适配器") @@ -96,6 +106,9 @@ class ProviderGoogleGenAI(Provider): for message in payloads["messages"]: if message["role"] == "user": if isinstance(message["content"], str): + if not message['content']: + message['content'] = "" + google_genai_conversation.append({ "role": "user", "parts": [{"text": message["content"]}] @@ -105,6 +118,8 @@ class ProviderGoogleGenAI(Provider): parts = [] for part in message["content"]: if part["type"] == "text": + if not part["text"]: + part["text"] = "" parts.append({"text": part["text"]}) elif part["type"] == "image_url": parts.append({"inline_data": { @@ -117,6 +132,8 @@ class ProviderGoogleGenAI(Provider): }) elif message["role"] == "assistant": + if not message["content"]: + message["content"] = "" google_genai_conversation.append({ "role": "model", "parts": [{"text": message["content"]}] @@ -181,9 +198,9 @@ class ProviderGoogleGenAI(Provider): llm_response = await self._query(payloads, func_tool) except Exception as e: if "maximum context length" in str(e): - retry_cnt = 10 + retry_cnt = 20 while retry_cnt > 0: - logger.warning(f"请求失败:{e}。上下文长度超过限制。尝试弹出最早的记录然后重试。") + logger.warning(f"请求失败:{e}。上下文长度超过限制。尝试弹出最早的记录然后重试。当前记录条数: {len(context_query)}") try: await self.pop_record(context_query) llm_response = await self._query(payloads, func_tool) @@ -196,7 +213,7 @@ class ProviderGoogleGenAI(Provider): if retry_cnt == 0: llm_response = LLMResponse("err", "err: 请尝试 /reset 重置会话") elif "Function calling is not enabled" in str(e): - logger.info(f"{self.get_model()} 不支持函数调用工具调用,已经自动去除") + logger.info(f"{self.get_model()} 不支持函数工具调用,已自动去除,不影响使用。") if 'tools' in payloads: del payloads['tools'] llm_response = await self._query(payloads, None) @@ -231,6 +248,9 @@ class ProviderGoogleGenAI(Provider): image_data = await self.encode_image_bs64(image_path) else: image_data = await self.encode_image_bs64(image_url) + if not image_data: + logger.warning(f"图片 {image_url} 得到的结果为空,将忽略。") + continue user_content["content"].append({"type": "image_url", "image_url": {"url": image_data}}) return user_content else: diff --git a/astrbot/core/provider/sources/openai_source.py b/astrbot/core/provider/sources/openai_source.py index 4b4b678ec..a3114a6c1 100644 --- a/astrbot/core/provider/sources/openai_source.py +++ b/astrbot/core/provider/sources/openai_source.py @@ -2,7 +2,7 @@ import base64 import json import os -from openai import AsyncOpenAI, AsyncAzureOpenAI, NOT_GIVEN +from openai import AsyncOpenAI, AsyncAzureOpenAI from openai.types.chat.chat_completion import ChatCompletion from openai._exceptions import NotFoundError, UnprocessableEntityError from astrbot.core.utils.io import download_image_by_url @@ -55,7 +55,7 @@ class ProviderOpenAIOfficial(Provider): try: models_str = [] models = await self.client.models.list() - models = models.data + models = sorted(models.data, key=lambda x: x.id) for model in models: models_str.append(model.id) return models_str @@ -80,12 +80,14 @@ class ProviderOpenAIOfficial(Provider): raise Exception("API 返回的 completion 为空。") choice = completion.choices[0] + llm_response = LLMResponse("assistant") + if choice.message.content: # text completion completion_text = str(choice.message.content).strip() - - return LLMResponse("assistant", completion_text, raw_completion=completion) - elif choice.message.tool_calls: + llm_response.completion_text = completion_text + + if choice.message.tool_calls: # tools call (function calling) args_ls = [] func_name_ls = [] @@ -95,16 +97,26 @@ class ProviderOpenAIOfficial(Provider): args = json.loads(tool_call.function.arguments) args_ls.append(args) func_name_ls.append(tool_call.function.name) - return LLMResponse(role="tool", tools_call_args=args_ls, tools_call_name=func_name_ls, raw_completion=completion) - else: + llm_response.role = "tool" + llm_response.tools_call_args = args_ls + llm_response.tools_call_name = func_name_ls + + if choice.finish_reason == 'content_filter': + raise Exception("API 返回的 completion 由于内容安全过滤被拒绝(非 AstrBot)。") + + if not llm_response.completion_text and not llm_response.tools_call_args: logger.error(f"API 返回的 completion 无法解析:{completion}。") - raise Exception("Internal Error") + raise Exception(f"API 返回的 completion 无法解析:{completion}。") + + llm_response.raw_completion = completion + + return llm_response async def text_chat( self, prompt: str, session_id: str=None, - image_urls: List[str]=None, + image_urls: List[str]=[], func_tool: FuncCall=None, contexts=[], system_prompt=None, @@ -135,15 +147,16 @@ class ProviderOpenAIOfficial(Provider): # 尝试删除所有 image new_contexts = await self._remove_image_from_context(context_query) payloads['messages'] = new_contexts + context_query = new_contexts llm_response = await self._query(payloads, func_tool) except Exception as e: if "maximum context length" in str(e): # 重试 10 次 - retry_cnt = 10 + retry_cnt = 20 while retry_cnt > 0: - logger.warning("上下文长度超过限制。尝试弹出最早的记录然后重试。") + logger.warning(f"上下文长度超过限制。尝试弹出最早的记录然后重试。当前记录条数: {len(context_query)}") try: - await self.pop_record(session_id) + await self.pop_record(context_query) llm_response = await self._query(payloads, func_tool) break except Exception as e: @@ -164,8 +177,12 @@ class ProviderOpenAIOfficial(Provider): or 'does not support tools' in str(e) \ or 'Function call is not supported' in str(e) \ or 'Function calling is not enabled' in str(e) \ - or 'Tool calling is not supported' in str(e): # siliconcloud - logger.info(f"{self.get_model()} 不支持函数调用工具调用,已经自动去除") + or 'Tool calling is not supported' in str(e) \ + or 'No endpoints found that support tool use' in str(e) \ + or 'model does not support function calling' in str(e) \ + or ('tool' in str(e) and 'support' in str(e).lower()) \ + or ('function' in str(e) and 'support' in str(e).lower()): + logger.info(f"{self.get_model()} 不支持函数工具调用,已自动去除,不影响使用。") if 'tools' in payloads: del payloads['tools'] llm_response = await self._query(payloads, None) @@ -173,7 +190,7 @@ class ProviderOpenAIOfficial(Provider): logger.error(f"发生了错误。Provider 配置如下: {self.provider_config}") if 'tool' in str(e).lower() and 'support' in str(e).lower(): - logger.error(f"疑似该模型不支持函数调用工具调用。请输入 /tool off_all") + logger.error("疑似该模型不支持函数调用工具调用。请输入 /tool off_all") if 'Connection error.' in str(e): proxy = os.environ.get("http_proxy", None) @@ -234,6 +251,9 @@ class ProviderOpenAIOfficial(Provider): image_data = await self.encode_image_bs64(image_path) else: image_data = await self.encode_image_bs64(image_url) + if not image_data: + logger.warning(f"图片 {image_url} 得到的结果为空,将忽略。") + continue user_content["content"].append({"type": "image_url", "image_url": {"url": image_data}}) return user_content else: diff --git a/astrbot/core/star/context.py b/astrbot/core/star/context.py index 472ed0e96..18b5200fe 100644 --- a/astrbot/core/star/context.py +++ b/astrbot/core/star/context.py @@ -1,8 +1,8 @@ from asyncio import Queue -from typing import List, TypedDict, Union +from typing import List, Union from astrbot.core import sp -from astrbot.core.provider.provider import Provider +from astrbot.core.provider.provider import Provider, TTSProvider, STTProvider from astrbot.core.db import BaseDatabase from astrbot.core.config.astrbot_config import AstrBotConfig from astrbot.core.provider.func_tool_manager import FuncCall @@ -127,6 +127,14 @@ class Context: '''获取所有用于文本生成任务的 LLM Provider(Chat_Completion 类型)。''' return self.provider_manager.provider_insts + def get_all_tts_providers(self) -> List[TTSProvider]: + '''获取所有用于 TTS 任务的 Provider。''' + return self.provider_manager.tts_provider_insts + + def get_all_stt_providers(self) -> List[STTProvider]: + '''获取所有用于 STT 任务的 Provider。''' + return self.provider_manager.stt_provider_insts + def get_using_provider(self) -> Provider: ''' 获取当前使用的用于文本生成任务的 LLM Provider(Chat_Completion 类型)。 @@ -135,6 +143,18 @@ class Context: ''' return self.provider_manager.curr_provider_inst + def get_using_tts_provider(self) -> TTSProvider: + ''' + 获取当前使用的用于 TTS 任务的 Provider。 + ''' + return self.provider_manager.curr_tts_provider_inst + + def get_using_stt_provider(self) -> STTProvider: + ''' + 获取当前使用的用于 STT 任务的 Provider。 + ''' + return self.provider_manager.curr_stt_provider_inst + def get_config(self) -> AstrBotConfig: '''获取 AstrBot 的配置。''' return self._config diff --git a/astrbot/core/star/filter/command.py b/astrbot/core/star/filter/command.py index 03466bb73..3e1394f38 100644 --- a/astrbot/core/star/filter/command.py +++ b/astrbot/core/star/filter/command.py @@ -43,7 +43,7 @@ class CommandFilter(HandlerFilter, ParameterValidationMixin): return self.handler_md def filter(self, event: AstrMessageEvent, cfg: AstrBotConfig) -> bool: - if not event.is_wake_up(): + if not event.is_at_or_wake_command: return False if event.get_extra("parsing_command"): diff --git a/astrbot/core/star/filter/command_group.py b/astrbot/core/star/filter/command_group.py index b3a66d22e..0dca9916f 100644 --- a/astrbot/core/star/filter/command_group.py +++ b/astrbot/core/star/filter/command_group.py @@ -37,7 +37,7 @@ class CommandGroupFilter(HandlerFilter): return result def filter(self, event: AstrMessageEvent, cfg: AstrBotConfig) -> Tuple[bool, StarHandlerMetadata]: - if not event.is_wake_up(): + if not event.is_at_or_wake_command: return False, None if event.get_extra("parsing_command"): diff --git a/astrbot/core/star/filter/regex.py b/astrbot/core/star/filter/regex.py index c5f919ee3..816b14109 100644 --- a/astrbot/core/star/filter/regex.py +++ b/astrbot/core/star/filter/regex.py @@ -8,6 +8,7 @@ from astrbot.core.config import AstrBotConfig class RegexFilter(HandlerFilter): '''正则表达式过滤器''' def __init__(self, regex: str): + self.regex_str = regex self.regex = re.compile(regex) def filter(self, event: AstrMessageEvent, cfg: AstrBotConfig) -> bool: diff --git a/astrbot/core/star/register/star_handler.py b/astrbot/core/star/register/star_handler.py index 6b8c6b878..c5e1239dd 100644 --- a/astrbot/core/star/register/star_handler.py +++ b/astrbot/core/star/register/star_handler.py @@ -17,7 +17,12 @@ def get_handler_full_name(awaitable: Awaitable) -> str: '''获取 Handler 的全名''' return f"{awaitable.__module__}_{awaitable.__name__}" -def get_handler_or_create(handler: Awaitable, event_type: EventType, dont_add = False, **kwargs) -> StarHandlerMetadata: +def get_handler_or_create( + handler: Awaitable, + event_type: EventType, + dont_add = False, + **kwargs +) -> StarHandlerMetadata: '''获取 Handler 或者创建一个新的 Handler''' handler_full_name = get_handler_full_name(handler) md = star_handlers_registry.get_handler_by_full_name(handler_full_name) @@ -30,18 +35,27 @@ def get_handler_or_create(handler: Awaitable, event_type: EventType, dont_add = handler_name=handler.__name__, handler_module_path=handler.__module__, handler=handler, - event_filters=[], + event_filters=[] ) + + # 插件handler的附加额外信息 if handler.__doc__: md.desc = handler.__doc__.strip() + if 'desc' in kwargs: + md.desc = kwargs['desc'] + del kwargs['desc'] + md.extras_configs = kwargs + if not dont_add: star_handlers_registry.append(md) return md -def register_command(command_name: str = None, *args): +def register_command(command_name: str = None, *args, **kwargs): '''注册一个 Command. ''' + # print("command: ", command_name, args, kwargs) + new_command = None add_to_event_filters = False if isinstance(command_name, RegisteringCommandable): @@ -54,20 +68,24 @@ def register_command(command_name: str = None, *args): add_to_event_filters = True def decorator(awaitable): - handler_md = get_handler_or_create(awaitable, EventType.AdapterMessageEvent) + if not add_to_event_filters: + kwargs['sub_command'] = True # 打一个标记,表示这是一个子指令,再 wakingstage 阶段这个 handler 将会直接被跳过(其父指令会接管) + handler_md = get_handler_or_create(awaitable, EventType.AdapterMessageEvent, **kwargs) new_command.init_handler_md(handler_md) if add_to_event_filters: # 裸指令 handler_md.event_filters.append(new_command) - + return awaitable return decorator -def register_command_group(command_group_name: str = None, *args): +def register_command_group(command_group_name: str = None, *args, **kwargs): '''注册一个 CommandGroup ''' + # print("commandgroup: ", command_group_name,args, kwargs) + new_group = None add_to_event_filters = False if isinstance(command_group_name, RegisteringCommandable): @@ -82,7 +100,7 @@ def register_command_group(command_group_name: str = None, *args): def decorator(obj): if add_to_event_filters: # 根指令组 - handler_md = get_handler_or_create(obj, EventType.AdapterMessageEvent) + handler_md = get_handler_or_create(obj, EventType.AdapterMessageEvent, **kwargs) handler_md.event_filters.append(new_group) return RegisteringCommandable(new_group) @@ -97,16 +115,16 @@ class RegisteringCommandable(): def __init__(self, parent_group: CommandGroupFilter): self.parent_group = parent_group -def register_event_message_type(event_message_type: EventMessageType): +def register_event_message_type(event_message_type: EventMessageType, **kwargs): '''注册一个 EventMessageType''' def decorator(awaitable): - handler_md = get_handler_or_create(awaitable, EventType.AdapterMessageEvent) + handler_md = get_handler_or_create(awaitable, EventType.AdapterMessageEvent, **kwargs) handler_md.event_filters.append(EventMessageTypeFilter(event_message_type)) return awaitable return decorator -def register_platform_adapter_type(platform_adapter_type: PlatformAdapterType): +def register_platform_adapter_type(platform_adapter_type: PlatformAdapterType, **kwargs): '''注册一个 PlatformAdapterType''' def decorator(awaitable): handler_md = get_handler_or_create(awaitable, EventType.AdapterMessageEvent) @@ -115,10 +133,10 @@ def register_platform_adapter_type(platform_adapter_type: PlatformAdapterType): return decorator -def register_regex(regex: str): +def register_regex(regex: str, **kwargs): '''注册一个 Regex''' def decorator(awaitable): - handler_md = get_handler_or_create(awaitable, EventType.AdapterMessageEvent) + handler_md = get_handler_or_create(awaitable, EventType.AdapterMessageEvent, **kwargs) handler_md.event_filters.append(RegexFilter(regex)) return awaitable @@ -138,7 +156,7 @@ def register_permission_type(permission_type: PermissionType, raise_error: bool return decorator -def register_on_llm_request(): +def register_on_llm_request(**kwargs): '''当有 LLM 请求时的事件 Examples: @@ -153,12 +171,12 @@ def register_on_llm_request(): 请务必接收两个参数:event, request ''' def decorator(awaitable): - _ = get_handler_or_create(awaitable, EventType.OnLLMRequestEvent) + _ = get_handler_or_create(awaitable, EventType.OnLLMRequestEvent, **kwargs) return awaitable return decorator -def register_on_llm_response(): +def register_on_llm_response(**kwargs): '''当有 LLM 请求后的事件 Examples: @@ -173,7 +191,7 @@ def register_on_llm_response(): 请务必接收两个参数:event, request ''' def decorator(awaitable): - _ = get_handler_or_create(awaitable, EventType.OnLLMResponseEvent) + _ = get_handler_or_create(awaitable, EventType.OnLLMResponseEvent, **kwargs) return awaitable return decorator @@ -186,7 +204,7 @@ def register_llm_tool(name: str = None): ``` @llm_tool(name="get_weather") # 如果 name 不填,将使用函数名 - async def get_weather(event: AstrMessageEvent, location: str) -> MessageEventResult: + async def get_weather(event: AstrMessageEvent, location: str): \'\'\'获取天气信息。 Args: @@ -196,7 +214,22 @@ def register_llm_tool(name: str = None): ``` 可接受的参数类型有:string, number, object, array, boolean。 + + 返回值: + - 返回 str:结果会被加入下一次 LLM 请求的 prompt 中,用于让 LLM 总结工具返回的结果 + - 返回 None:结果不会被加入下一次 LLM 请求的 prompt 中。 + + 可以使用 yield 发送消息、终止事件。 + + 发送消息:请参考文档。 + + 终止事件: + ``` + event.stop_event() + yield + ``` ''' + name_ = name def decorator(awaitable: Awaitable): @@ -219,18 +252,18 @@ def register_llm_tool(name: str = None): return decorator -def register_on_decorating_result(): +def register_on_decorating_result(**kwargs): '''在发送消息前的事件''' def decorator(awaitable): - _ = get_handler_or_create(awaitable, EventType.OnDecoratingResultEvent) + _ = get_handler_or_create(awaitable, EventType.OnDecoratingResultEvent, **kwargs) return awaitable return decorator -def register_after_message_sent(): +def register_after_message_sent(**kwargs): '''在消息发送后的事件''' def decorator(awaitable): - _ = get_handler_or_create(awaitable, EventType.OnAfterMessageSentEvent) + _ = get_handler_or_create(awaitable, EventType.OnAfterMessageSentEvent, **kwargs) return awaitable return decorator \ No newline at end of file diff --git a/astrbot/core/star/star.py b/astrbot/core/star/star.py index c2abb6e34..eb940b405 100644 --- a/astrbot/core/star/star.py +++ b/astrbot/core/star/star.py @@ -2,7 +2,7 @@ from __future__ import annotations from types import ModuleType from typing import List, Dict -from dataclasses import dataclass +from dataclasses import dataclass, field from astrbot.core.config import AstrBotConfig star_registry: List[StarMetadata] = [] @@ -39,6 +39,9 @@ class StarMetadata: config: AstrBotConfig = None '''插件配置''' + + star_handler_full_names: List[str] = field(default_factory=list) + '''注册的 Handler 的全名列表''' def __str__(self) -> str: return f"StarMetadata({self.name}, {self.desc}, {self.version}, {self.repo})" \ No newline at end of file diff --git a/astrbot/core/star/star_handler.py b/astrbot/core/star/star_handler.py index 378038327..7fee47d2b 100644 --- a/astrbot/core/star/star_handler.py +++ b/astrbot/core/star/star_handler.py @@ -1,34 +1,41 @@ from __future__ import annotations import enum -from dataclasses import dataclass +import heapq +from dataclasses import dataclass, field from typing import Awaitable, List, Dict, TypeVar, Generic from .filter import HandlerFilter from .star import star_map T = TypeVar('T', bound='StarHandlerMetadata') -class StarHandlerRegistry(Generic[T], List[T]): +class StarHandlerRegistry(Generic[T]): '''用于存储所有的 Star Handler''' star_handlers_map: Dict[str, StarHandlerMetadata] = {} '''用于快速查找。key 是 handler_full_name''' + _handlers = [] def append(self, handler: StarHandlerMetadata): '''添加一个 Handler''' - super().append(handler) + if 'priority' not in handler.extras_configs: + handler.extras_configs['priority'] = 0 + + heapq.heappush(self._handlers, (-handler.extras_configs['priority'], handler)) self.star_handlers_map[handler.handler_full_name] = handler - def get_handlers_by_event_type(self, event_type: EventType, only_activated = True) -> List[StarHandlerMetadata]: + def _print_handlers(self): + '''打印所有的 Handler''' + for _, handler in self._handlers: + print(handler.handler_full_name) + + def get_handlers_by_event_type(self, event_type: EventType, only_activated=True) -> List[StarHandlerMetadata]: '''通过事件类型获取 Handler''' - if only_activated: - return [ - handler - for handler in self - if handler.event_type == event_type and - star_map[handler.handler_module_path] and - star_map[handler.handler_module_path].activated - ] - else: - return [handler for handler in self if handler.event_type == event_type] + handlers = [ + handler + for _, handler in self._handlers + if handler.event_type == event_type and + (not only_activated or (star_map[handler.handler_module_path] and star_map[handler.handler_module_path].activated)) + ] + return handlers def get_handler_by_full_name(self, full_name: str) -> StarHandlerMetadata: '''通过 Handler 的全名获取 Handler''' @@ -36,7 +43,32 @@ class StarHandlerRegistry(Generic[T], List[T]): def get_handlers_by_module_name(self, module_name: str) -> List[StarHandlerMetadata]: '''通过模块名获取 Handler''' - return [handler for handler in self if handler.handler_module_path == module_name] + return [handler for _, handler in self._handlers if handler.handler_module_path == module_name] + + def clear(self): + '''清空所有的 Handler''' + self.star_handlers_map.clear() + self._handlers.clear() + + def remove(self, handler: StarHandlerMetadata): + '''删除一个 Handler''' + # self._handlers.remove(handler) + for i, h in enumerate(self._handlers): + if h[1] == handler: + self._handlers.pop(i) + break + try: + del self.star_handlers_map[handler.handler_full_name] + except KeyError: + pass + + def __iter__(self): + '''使 StarHandlerRegistry 支持迭代''' + return (handler for _, handler in self._handlers) + + def __len__(self): + '''返回 Handler 的数量''' + return len(self._handlers) star_handlers_registry = StarHandlerRegistry() @@ -76,3 +108,10 @@ class StarHandlerMetadata(): desc: str = "" '''Handler 的描述信息''' + + extras_configs: dict = field(default_factory=dict) + '''插件注册的一些其他的信息, 如 priority 等''' + + def __lt__(self, other: StarHandlerMetadata): + '''定义小于运算符以支持优先队列''' + return self.extras_configs.get('priority', 0) < other.extras_configs.get('priority', 0) \ No newline at end of file diff --git a/astrbot/core/star/star_manager.py b/astrbot/core/star/star_manager.py index a7a694fce..44e8aa565 100644 --- a/astrbot/core/star/star_manager.py +++ b/astrbot/core/star/star_manager.py @@ -9,7 +9,6 @@ import logging from types import ModuleType from typing import List from astrbot.core.config.astrbot_config import AstrBotConfig -from astrbot.core.config.default import DEFAULT_VALUE_MAP from astrbot.core import logger, sp, pip_installer from .context import Context from . import StarMetadata @@ -41,6 +40,8 @@ class PluginManager: '''保留插件的路径。在 packages 目录下''' self.conf_schema_fname = "_conf_schema.json" '''插件配置 Schema 文件名''' + + self.failed_plugin_info = "" def _get_classes(self, arg: ModuleType): '''获取指定模块(可以理解为一个 python 文件)下所有的类''' @@ -127,7 +128,7 @@ class PluginManager: if isinstance(metadata, dict): if 'name' not in metadata or 'desc' not in metadata or 'version' not in metadata or 'author' not in metadata: - raise Exception("插件元数据信息不完整。") + raise Exception("插件元数据信息不完整。name, desc, version, author 是必须的字段。") metadata = StarMetadata( name=metadata['name'], author=metadata['author'], @@ -137,25 +138,45 @@ class PluginManager: ) return metadata - - async def reload(self): - '''扫描并加载所有的插件''' - for smd in star_registry: - logger.debug(f"尝试终止插件 {smd.name} ...") - if hasattr(smd.star_cls, "__del__"): - smd.star_cls.__del__() - - star_handlers_registry.clear() - star_handlers_registry.star_handlers_map.clear() - star_map.clear() - star_registry.clear() - for key in list(sys.modules.keys()): - if key.startswith("data.plugins") or key.startswith("packages"): - del sys.modules[key] + + async def reload(self, specified_plugin_name=None): + '''扫描并加载所有的插件 当 specified_module_path 指定时,重载指定插件''' + + specified_module_path = None + if specified_plugin_name: + for smd in star_registry: + if smd.name == specified_plugin_name: + specified_module_path = smd.module_path + break + + # 终止插件 + if not specified_module_path: + for smd in star_registry: + logger.debug(f"尝试终止插件 {smd.name} ...") + if hasattr(smd.star_cls, "__del__"): + smd.star_cls.__del__() + + star_handlers_registry.clear() + star_map.clear() + star_registry.clear() + for key in list(sys.modules.keys()): + if key.startswith("data.plugins") or key.startswith("packages"): + del sys.modules[key] + else: + # 只重载指定插件 + smd = star_map.get(specified_module_path) + if smd: + await self._unbind_plugin(smd.name, specified_module_path) + try: + del sys.modules[specified_module_path] + except KeyError: + logger.warning(f"模块 {specified_module_path} 未载入") + plugin_modules = self._get_plugin_modules() if plugin_modules is None: return False, "未找到任何插件模块" + fail_rec = "" inactivated_plugins: list = sp.get("inactivated_plugins", []) @@ -171,11 +192,15 @@ class PluginManager: root_dir_name = plugin_module['pname'] # 插件的目录名 reserved = plugin_module.get('reserved', False) # 是否是保留插件。目前在 packages/ 目录下的都是保留插件。保留插件不可以卸载。 + path = "data.plugins." if not reserved else "packages." + path += root_dir_name + "." + module_str + + if specified_module_path and path != specified_module_path: + continue + logger.info(f"正在载入插件 {root_dir_name} ...") # 尝试导入模块 - path = "data.plugins." if not reserved else "packages." - path += root_dir_name + "." + module_str try: module = __import__(path, fromlist=[module_str]) except (ModuleNotFoundError, ImportError): @@ -204,6 +229,18 @@ class PluginManager: # 通过装饰器的方式注册插件 metadata = star_map[path] + try: + # yaml 文件的元数据优先 + metadata_yaml = self._load_plugin_metadata(plugin_path=plugin_dir_path) + if metadata_yaml: + metadata.name = metadata_yaml.name + metadata.author = metadata_yaml.author + metadata.desc = metadata_yaml.desc + metadata.version = metadata_yaml.version + metadata.repo = metadata_yaml.repo + except Exception: + pass + if plugin_config: metadata.config = plugin_config try: @@ -220,7 +257,6 @@ class PluginManager: # 绑定 handler related_handlers = star_handlers_registry.get_handlers_by_module_name(metadata.module_path) for handler in related_handlers: - logger.debug(f"bind handler {handler.handler_name} to {metadata.name}") handler.handler = functools.partial(handler.handler, metadata.star_cls) # 绑定 llm_tool handler for func_tool in llm_tools.func_list: @@ -244,8 +280,7 @@ class PluginManager: obj = getattr(module, classes[0])(context=self.context) # 实例化插件类 metadata = None - plugin_path = os.path.join(self.plugin_store_path, root_dir_name) if not reserved else os.path.join(self.reserved_plugin_path, root_dir_name) - metadata = self._load_plugin_metadata(plugin_path=plugin_path, plugin_obj=obj) + metadata = self._load_plugin_metadata(plugin_path=plugin_dir_path, plugin_obj=obj) metadata.star_cls = obj metadata.config = plugin_config metadata.module = module @@ -255,16 +290,17 @@ class PluginManager: metadata.module_path = path star_map[path] = metadata star_registry.append(metadata) - logger.debug(f"插件 {root_dir_name} 载入成功。") # 禁用/启用插件 if metadata.module_path in inactivated_plugins: metadata.activated = False - # 检查并且植入自定义的权限过滤器(alter_cmd) + full_names = [] for handler in star_handlers_registry.get_handlers_by_module_name(metadata.module_path): + full_names.append(handler.handler_full_name) + + # 检查并且植入自定义的权限过滤器(alter_cmd) if metadata.name in alter_cmd and handler.handler_name in alter_cmd[metadata.name]: - # 注入权限过滤器 cmd_type = alter_cmd[metadata.name][handler.handler_name].get("permission", "member") found_permission_filter = False for filter_ in handler.event_filters: @@ -280,25 +316,32 @@ class PluginManager: logger.debug(f"插入权限过滤器 {cmd_type} 到 {metadata.name} 的 {handler.handler_name} 方法。") + metadata.star_handler_full_names = full_names + # 执行 initialize() 方法 if hasattr(metadata.star_cls, "initialize"): await metadata.star_cls.initialize() except BaseException as e: - traceback.print_exc() - fail_rec += f"加载 {path} 插件时出现问题,原因 {str(e)}\n" + logger.error(f"----- 插件 {root_dir_name} 载入失败 -----") + errors = traceback.format_exc() + for line in errors.split('\n'): + logger.error(f"| {line}") + logger.error("----------------------------------") + fail_rec += f"加载 {root_dir_name} 插件时出现问题,原因 {str(e)}。\n" # 清除 pip.main 导致的多余的 logging handlers for handler in logging.root.handlers[:]: logging.root.removeHandler(handler) - + if not fail_rec: return True, None else: + self.failed_plugin_info = fail_rec return False, fail_rec - async def install_plugin(self, repo_url: str): - plugin_path = await self.updator.install(repo_url) + async def install_plugin(self, repo_url: str, proxy=""): + plugin_path = await self.updator.install(repo_url, proxy) # reload the plugin await self.reload() return plugin_path @@ -333,14 +376,14 @@ class PluginManager: logger.debug(f"unbind handler {v.handler_name} from {plugin_name} (map)") del star_handlers_registry.star_handlers_map[k] - async def update_plugin(self, plugin_name: str): + async def update_plugin(self, plugin_name: str, proxy = ""): plugin = self.context.get_registered_star(plugin_name) if not plugin: raise Exception("插件不存在。") if plugin.reserved: raise Exception("该插件是 AstrBot 保留插件,无法更新。") - await self.updator.update(plugin) + await self.updator.update(plugin, proxy=proxy) await self.reload() async def turn_off_plugin(self, plugin_name: str): @@ -385,6 +428,7 @@ class PluginManager: async def install_plugin_from_file(self, zip_file_path: str): 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) self.updator.unzip_file(zip_file_path, desti_dir) diff --git a/astrbot/core/star/updator.py b/astrbot/core/star/updator.py index 02b9dc2da..640fb4abb 100644 --- a/astrbot/core/star/updator.py +++ b/astrbot/core/star/updator.py @@ -15,20 +15,24 @@ class PluginUpdator(RepoZipUpdator): def get_plugin_store_path(self) -> str: return self.plugin_store_path - async def install(self, repo_url: str) -> str: + async def install(self, repo_url: str, proxy="") -> str: repo_name = self.format_repo_name(repo_url) plugin_path = os.path.join(self.plugin_store_path, repo_name) - await self.download_from_repo_url(plugin_path, repo_url) + await self.download_from_repo_url(plugin_path, repo_url, proxy) self.unzip_file(plugin_path + ".zip", plugin_path) return plugin_path - async def update(self, plugin: StarMetadata) -> str: + async def update(self, plugin: StarMetadata, proxy="") -> str: repo_url = plugin.repo if not repo_url: raise Exception(f"插件 {plugin.name} 没有指定仓库地址。") + if proxy: + proxy = proxy.removesuffix("/") + repo_url = f"{proxy}/{repo_url}" + plugin_path = os.path.join(self.plugin_store_path, plugin.root_dir_name) logger.info(f"正在更新插件,路径: {plugin_path},仓库地址: {repo_url}") diff --git a/astrbot/core/utils/dify_api_client.py b/astrbot/core/utils/dify_api_client.py index 5d41e2911..b5326e4b6 100644 --- a/astrbot/core/utils/dify_api_client.py +++ b/astrbot/core/utils/dify_api_client.py @@ -27,22 +27,38 @@ class DifyAPIClient: payload = locals() payload.pop("self") payload.pop("timeout") + logger.info(f"chat_messages payload: {payload}") async with self.session.post( url, json=payload, headers=self.headers, timeout=timeout ) as resp: + if resp.status != 200: + text = await resp.text() + raise Exception(f"chat_messages 请求失败:{resp.status}. {text}") + + buffer = "" while True: - data = await resp.content.read(8192) # 防止数据过大导致高水位报错 - if not data: + # 保持原有的8192字节限制,防止数据过大导致高水位报错 + chunk = await resp.content.read(8192) + if not chunk: break - if not data.strip(): - continue - elif data.startswith(b"data:"): - try: - json_ = json.loads(data[5:]) - yield json_ - except BaseException: - pass + buffer += chunk.decode('utf-8') + blocks = buffer.split('\n\n') + + # 处理完整的数据块 + for block in blocks[:-1]: + if block.strip() and block.startswith('data:'): + try: + json_str = block[5:] # 移除 "data:" 前缀 + json_obj = json.loads(json_str) + yield json_obj + except json.JSONDecodeError as e: + logger.error(f"JSON解析错误: {str(e)}") + logger.error(f"原始数据块: {json_str}") + + # 保留最后一个可能不完整的块 + buffer = blocks[-1] if blocks else "" + async def workflow_run( self, inputs: Dict, @@ -55,22 +71,38 @@ class DifyAPIClient: payload = locals() payload.pop("self") payload.pop("timeout") + logger.info(f"workflow_run payload: {payload}") async with self.session.post( url, json=payload, headers=self.headers, timeout=timeout ) as resp: + if resp.status != 200: + text = await resp.text() + raise Exception(f"workflow_run 请求失败:{resp.status}. {text}") + + buffer = "" while True: - data = await resp.content.read(8192) # 防止数据过大导致高水位报错 - if not data: + # 保持原有的8192字节限制,防止数据过大导致高水位报错 + chunk = await resp.content.read(8192) + if not chunk: break - if not data.strip(): - continue - elif data.startswith(b"data:"): - try: - json_ = json.loads(data[5:]) - yield json_ - except BaseException: - pass + buffer += chunk.decode('utf-8') + blocks = buffer.split('\n\n') + + # 处理完整的数据块 + for block in blocks[:-1]: + if block.strip() and block.startswith('data:'): + try: + json_str = block[5:] # 移除 "data:" 前缀 + json_obj = json.loads(json_str) + yield json_obj + except json.JSONDecodeError as e: + logger.error(f"JSON解析错误: {str(e)}") + logger.error(f"原始数据块: {json_str}") + + # 保留最后一个可能不完整的块 + buffer = blocks[-1] if blocks else "" + async def file_upload( self, file_path: str, @@ -87,4 +119,55 @@ class DifyAPIClient: return await resp.json() # {"id": "xxx", ...} async def close(self): - await self.session.close() \ No newline at end of file + await self.session.close() + + async def get_chat_convs( + self, + user: str, + limit: int = 20 + ): + # conversations. GET + url = f"{self.api_base}/conversations" + payload = { + "user": user, + "limit": limit, + } + async with self.session.get( + url, params=payload, headers=self.headers + ) as resp: + return await resp.json() + + async def delete_chat_conv( + self, + user: str, + conversation_id: str + ): + # conversation. DELETE + url = f"{self.api_base}/conversations/{conversation_id}" + payload = { + "user": user, + } + async with self.session.delete( + url, json=payload, headers=self.headers + ) as resp: + return await resp.json() + + async def rename( + self, + conversation_id: str, + name: str, + user: str, + auto_generate: bool = False + ): + # /conversations/:conversation_id/name + url = f"{self.api_base}/conversations/{conversation_id}/name" + payload = { + "user": user, + "name": name, + "auto_generate": auto_generate, + } + async with self.session.post( + url, json=payload, headers=self.headers + ) as resp: + return await resp.json() + \ No newline at end of file diff --git a/astrbot/core/utils/io.py b/astrbot/core/utils/io.py index 521af665c..99b7176dd 100644 --- a/astrbot/core/utils/io.py +++ b/astrbot/core/utils/io.py @@ -7,6 +7,7 @@ import aiohttp import base64 import zipfile import uuid +import psutil from typing import Union from PIL import Image @@ -160,17 +161,17 @@ def file_to_base64(file_path: str) -> str: base64_str = base64.b64encode(data_bytes).decode() return "base64://" + base64_str + def get_local_ip_addresses(): - ip = '' - try: - s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - s.connect(('8.8.8.8', 80)) - ip = s.getsockname()[0] - except BaseException: - pass - finally: - s.close() - return ip + net_interfaces = psutil.net_if_addrs() + network_ips = [] + + for interface, addrs in net_interfaces.items(): + for addr in addrs: + if addr.family == socket.AF_INET: # 使用 socket.AF_INET 代替 psutil.AF_INET + network_ips.append(addr.address) + + return network_ips async def get_dashboard_version(): if os.path.exists("data/dist"): diff --git a/astrbot/core/utils/t2i/network_strategy.py b/astrbot/core/utils/t2i/network_strategy.py index a8254e088..63004bec7 100644 --- a/astrbot/core/utils/t2i/network_strategy.py +++ b/astrbot/core/utils/t2i/network_strategy.py @@ -14,11 +14,22 @@ class NetworkRenderStrategy(RenderStrategy): base_url = ASTRBOT_T2I_DEFAULT_ENDPOINT self.BASE_RENDER_URL = base_url self.TEMPLATE_PATH = os.path.join(os.path.dirname(__file__), "template") + + if self.BASE_RENDER_URL.endswith("/"): + self.BASE_RENDER_URL = self.BASE_RENDER_URL[:-1] + if not self.BASE_RENDER_URL.endswith("text2img"): + self.BASE_RENDER_URL += "/text2img" def set_endpoint(self, base_url: str): if not base_url: base_url = ASTRBOT_T2I_DEFAULT_ENDPOINT self.BASE_RENDER_URL = base_url + + if self.BASE_RENDER_URL.endswith("/"): + self.BASE_RENDER_URL = self.BASE_RENDER_URL[:-1] + if not self.BASE_RENDER_URL.endswith("text2img"): + self.BASE_RENDER_URL += "/text2img" + async def render_custom_template(self, tmpl_str: str, tmpl_data: dict, return_url: bool=True) -> str: '''使用自定义文转图模板''' diff --git a/astrbot/core/utils/t2i/renderer.py b/astrbot/core/utils/t2i/renderer.py index f3298d3cb..5b3e27628 100644 --- a/astrbot/core/utils/t2i/renderer.py +++ b/astrbot/core/utils/t2i/renderer.py @@ -22,7 +22,7 @@ class HtmlRenderer: @return: 图片 URL 或者文件路径,取决于 return_url 参数。 - @example: 参见 https://astrbot.soulter.top 插件开发部分。 + @example: 参见 https://astrbot.app 插件开发部分。 ''' local = locals() local.pop('self') diff --git a/astrbot/core/utils/tencent_record_helper.py b/astrbot/core/utils/tencent_record_helper.py index 897e57906..6ecd2698f 100644 --- a/astrbot/core/utils/tencent_record_helper.py +++ b/astrbot/core/utils/tencent_record_helper.py @@ -22,21 +22,27 @@ async def tencent_silk_to_wav(silk_path: str, output_path: str) -> str: async def wav_to_tencent_silk(wav_path: str, output_path: str) -> int: '''返回 duration''' - import pysilk - - with wave.open(wav_path, 'rb') as wav: - wav_data = wav.readframes(wav.getnframes()) - wav_data = BytesIO(wav_data) - output_io = BytesIO() - pysilk.encode(wav_data, output_io, 24000, 24000) - output_io.seek(0) + try: + import pilk + except (ImportError, ModuleNotFoundError) as _: + raise Exception("pilk 模块未安装,请前往管理面板->控制台->安装pip库 安装 pilk 这个库") + # with wave.open(wav_path, 'rb') as wav: + # wav_data = wav.readframes(wav.getnframes()) + # wav_data = BytesIO(wav_data) + # output_io = BytesIO() + # pysilk.encode(wav_data, output_io, 24000, 24000) + # output_io.seek(0) - # 在首字节添加 \x02,去除结尾的\xff\xff - silk_data = output_io.read() - silk_data_with_prefix = b'\x02' + silk_data[:-2] + # # 在首字节添加 \x02,去除结尾的\xff\xff + # silk_data = output_io.read() + # silk_data_with_prefix = b'\x02' + silk_data[:-2] - # return BytesIO(silk_data_with_prefix) - with open(output_path, "wb") as f: - f.write(silk_data_with_prefix) + # # return BytesIO(silk_data_with_prefix) + # with open(output_path, "wb") as f: + # f.write(silk_data_with_prefix) - return 0 \ No newline at end of file + # return 0 + with wave.open(wav_path, 'rb') as wav: + rate = wav.getframerate() + duration = pilk.encode(wav_path, output_path, pcm_rate=rate, tencent=True) + return duration \ No newline at end of file diff --git a/astrbot/core/zip_updator.py b/astrbot/core/zip_updator.py index beb4dbb6d..67003a1bf 100644 --- a/astrbot/core/zip_updator.py +++ b/astrbot/core/zip_updator.py @@ -100,7 +100,7 @@ class RepoZipUpdator(): body=update_data[0]['body'] ) - async def download_from_repo_url(self, target_path: str, repo_url: str): + async def download_from_repo_url(self, target_path: str, repo_url: str, proxy=""): repo_namespace = repo_url.split("/")[-2:] author = repo_namespace[0] repo = repo_namespace[1] @@ -110,19 +110,23 @@ class RepoZipUpdator(): releases = await self.fetch_release_info(url=release_url) if not releases: # download from the default branch directly. - logger.info(f"未在仓库 {author}/{repo} 中找到任何发布版本,正在从默认分支下载。") + logger.info(f"正在从默认分支下载 {author}/{repo} ") release_url = f"https://github.com/{author}/{repo}/archive/refs/heads/master.zip" else: release_url = releases[0]['zipball_url'] # 镜像站点 - match self.repo_mirror: - case 'https://github-mirror.us.kg/': - release_url = self.repo_mirror + release_url - case "https://ghp.ci/": - release_url = self.repo_mirror + release_url - case _: - pass + # match self.repo_mirror: + # case 'https://github-mirror.us.kg/': + # release_url = self.repo_mirror + release_url + # case "https://ghp.ci/": + # release_url = self.repo_mirror + release_url + # case _: + # pass + + if proxy: + release_url = f"{proxy}/{release_url}" + logger.info(f"使用代理下载: {release_url}") await download_file(release_url, target_path + ".zip") diff --git a/astrbot/dashboard/routes/config.py b/astrbot/dashboard/routes/config.py index a26d67159..61810705c 100644 --- a/astrbot/dashboard/routes/config.py +++ b/astrbot/dashboard/routes/config.py @@ -1,3 +1,4 @@ +import typing from .route import Route, Response, RouteContext from quart import request from astrbot.core.config.default import CONFIG_METADATA_2, DEFAULT_VALUE_MAP @@ -17,7 +18,7 @@ def try_cast(value: str, type_: str): elif type_ == "float" and isinstance(value, int): return float(value) -def validate_config(data, schema: dict, is_core: bool): +def validate_config(data, schema: dict, is_core: bool) -> typing.Tuple[typing.List[str], typing.Dict]: errors = [] def validate(data, metadata=schema, path=""): for key, meta in metadata.items(): @@ -65,16 +66,16 @@ def validate_config(data, schema: dict, is_core: bool): else: validate(data, schema) - return errors + return errors, data def save_config(post_config: dict, config: AstrBotConfig, is_core: bool = False): '''验证并保存配置''' errors = None try: if is_core: - errors = validate_config(post_config, CONFIG_METADATA_2, is_core) + errors, post_config = validate_config(post_config, CONFIG_METADATA_2, is_core) else: - errors = validate_config(post_config, config.schema, is_core) + errors, post_config = validate_config(post_config, config.schema, is_core) except BaseException as e: logger.warning(f"验证配置时出现异常: {e}") if errors: diff --git a/astrbot/dashboard/routes/plugin.py b/astrbot/dashboard/routes/plugin.py index 56600224f..a4fc3eea3 100644 --- a/astrbot/dashboard/routes/plugin.py +++ b/astrbot/dashboard/routes/plugin.py @@ -1,11 +1,16 @@ import traceback import aiohttp -import uuid from .route import Route, Response, RouteContext from astrbot.core import logger from quart import request from astrbot.core.star.star_manager import PluginManager from astrbot.core.core_lifecycle import AstrBotCoreLifecycle +from astrbot.core.star.star_handler import star_handlers_registry +from astrbot.core.star.filter.command import CommandFilter +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 class PluginRoute(Route): def __init__(self, context: RouteContext, core_lifecycle: AstrBotCoreLifecycle, plugin_manager: PluginManager) -> None: @@ -18,11 +23,33 @@ class PluginRoute(Route): '/plugin/uninstall': ('POST', self.uninstall_plugin), '/plugin/market_list': ('GET', self.get_online_plugins), '/plugin/off': ('POST', self.off_plugin), - '/plugin/on': ('POST', self.on_plugin) + '/plugin/on': ('POST', self.on_plugin), + '/plugin/reload': ('POST', self.reload_plugins), } self.core_lifecycle = core_lifecycle self.plugin_manager = plugin_manager self.register_routes() + + self.translated_event_type = { + EventType.AdapterMessageEvent: "平台消息下发时", + EventType.OnLLMRequestEvent: "LLM 请求时", + EventType.OnLLMResponseEvent: "LLM 响应后", + EventType.OnDecoratingResultEvent: "回复消息前", + EventType.OnCallingFuncToolEvent: "函数工具", + EventType.OnAfterMessageSentEvent: "发送消息后" + } + + async def reload_plugins(self): + data = await request.json + plugin_name = data.get("name", None) + try: + success, message = await self.plugin_manager.reload(plugin_name) + if not success: + return Response().error(message).__dict__ + return Response().ok(None, "重载成功。").__dict__ + except Exception as e: + logger.error(f"/api/plugin/reload: {traceback.format_exc()}") + return Response().error(str(e)).__dict__ async def get_online_plugins(self): custom = request.args.get("custom_registry") @@ -31,7 +58,6 @@ class PluginRoute(Route): urls = [custom] else: urls = [ - "https://soulter.github.io/AstrBot_Plugins_Collection/plugins.json", "https://api.soulter.top/astrbot/plugins" ] @@ -59,17 +85,71 @@ class PluginRoute(Route): "desc": plugin.desc, "version": plugin.version, "reserved": plugin.reserved, - "activated": plugin.activated + "activated": plugin.activated, + "online_vesion": "", + "handlers": await self.get_plugin_handlers_info(plugin.star_handler_full_names), } _plugin_resp.append(_t) - return Response().ok(_plugin_resp).__dict__ + return Response().ok(_plugin_resp, message=self.plugin_manager.failed_plugin_info).__dict__ + + async def get_plugin_handlers_info(self, handler_full_names: list[str]): + '''解析插件行为''' + handlers = [] + + for handler_full_name in handler_full_names: + info = {} + handler = star_handlers_registry.star_handlers_map.get(handler_full_name, None) + if handler is None: + continue + info["event_type"] = handler.event_type.name + info["event_type_h"] = self.translated_event_type.get(handler.event_type, handler.event_type.name) + info["handler_full_name"] = handler.handler_full_name + info["desc"] = handler.desc + info["handler_name"] = handler.handler_name + + if handler.event_type == EventType.AdapterMessageEvent: + # 处理平台适配器消息事件 + has_admin = False + for filter in handler.event_filters: # 正常handler就只有 1~2 个 filter,因此这里时间复杂度不会太高 + if isinstance(filter, CommandFilter): + info["type"] = "指令" + info["cmd"] = filter.command_name + elif isinstance(filter, CommandGroupFilter): + info["type"] = "指令组" + info["cmd"] = filter.group_name + info["sub_command"] = filter.print_cmd_tree(filter.sub_command_filters) + elif isinstance(filter, RegexFilter): + info["type"] = "正则匹配" + info["cmd"] = filter.regex_str + elif isinstance(filter, PermissionTypeFilter): + has_admin = True + info["has_admin"] = has_admin + if "cmd" not in info: + info["cmd"] = "未知" + if "type" not in info: + info["type"] = "事件监听器" + else: + info["cmd"] = "自动触发" + info["type"] = "无" + + if not info["desc"]: + info["desc"] = "无描述" + + handlers.append(info) + + return handlers async def install_plugin(self): post_data = await request.json repo_url = post_data["url"] + + proxy: str = post_data.get("proxy", None) + if proxy: + proxy = proxy.removesuffix("/") + try: logger.info(f"正在安装插件 {repo_url}") - await self.plugin_manager.install_plugin(repo_url) + await self.plugin_manager.install_plugin(repo_url, proxy) self.core_lifecycle.restart() logger.info(f"安装插件 {repo_url} 成功。") return Response().ok(None, "安装成功。").__dict__ @@ -107,14 +187,15 @@ class PluginRoute(Route): async def update_plugin(self): post_data = await request.json plugin_name = post_data["name"] + proxy: str = post_data.get("proxy", None) try: logger.info(f"正在更新插件 {plugin_name}") - await self.plugin_manager.update_plugin(plugin_name) + await self.plugin_manager.update_plugin(plugin_name, proxy) self.core_lifecycle.restart() logger.info(f"更新插件 {plugin_name} 成功。") return Response().ok(None, "更新成功。").__dict__ except Exception as e: - logger.error(f"/api/extensions/update: {traceback.format_exc()}") + logger.error(f"/api/plugin/update: {traceback.format_exc()}") return Response().error(str(e)).__dict__ async def off_plugin(self): @@ -125,7 +206,7 @@ class PluginRoute(Route): logger.info(f"停用插件 {plugin_name} 。") return Response().ok(None, "停用成功。").__dict__ except Exception as e: - logger.error(f"/api/extensions/off: {traceback.format_exc()}") + logger.error(f"/api/plugin/off: {traceback.format_exc()}") return Response().error(str(e)).__dict__ async def on_plugin(self): @@ -136,5 +217,5 @@ class PluginRoute(Route): logger.info(f"启用插件 {plugin_name} 。") return Response().ok(None, "启用成功。").__dict__ except Exception as e: - logger.error(f"/api/extensions/on: {traceback.format_exc()}") + logger.error(f"/api/plugin/on: {traceback.format_exc()}") return Response().error(str(e)).__dict__ \ No newline at end of file diff --git a/astrbot/dashboard/server.py b/astrbot/dashboard/server.py index c46bd3028..2656ba2a9 100644 --- a/astrbot/dashboard/server.py +++ b/astrbot/dashboard/server.py @@ -2,6 +2,7 @@ import logging import jwt import asyncio import os +from astrbot.core.config.default import VERSION from quart import Quart, request, jsonify, g from quart.logging import default_handler from astrbot.core.core_lifecycle import AstrBotCoreLifecycle @@ -67,15 +68,21 @@ class AstrBotDashboard(): logger.info("管理面板已关闭。") def run(self): - ip_addr = get_local_ip_addresses() - logger.info(f""" -✨✨✨ -AstrBot 管理面板已启动,可访问 + try: + ip_addr = get_local_ip_addresses() + except Exception as e: + ip_addr = [] + + port = self.core_lifecycle.astrbot_config['dashboard'].get("port", 6185) + if isinstance(port, str): + port = int(port) + + display = f"\n ✨✨✨\n AstrBot v{VERSION} 管理面板已启动,可访问\n\n" + display += f" ➜ 本地: http://localhost:{port}\n" + for ip in ip_addr: + display += f" ➜ 网络: http://{ip}:{port}\n" + display += " ➜ 默认用户名和密码: astrbot\n ✨✨✨\n" + logger.info(display) + -1. http://{ip_addr}:6185 -2. http://localhost:6185 - -默认用户名和密码是 astrbot。 -✨✨✨ -""") - return self.app.run_task(host="0.0.0.0", port=6185, shutdown_trigger=self.shutdown_trigger_placeholder) \ No newline at end of file + return self.app.run_task(host="0.0.0.0", port=port, shutdown_trigger=self.shutdown_trigger_placeholder) \ No newline at end of file diff --git a/changelogs/v3.4.21.md b/changelogs/v3.4.21.md new file mode 100644 index 000000000..99ac49bab --- /dev/null +++ b/changelogs/v3.4.21.md @@ -0,0 +1,19 @@ +# What's Changed + +> 由于重写了会话记录部分,更新此版本后,将会造成之前的对话记录清空(但没有被删除)。 +> 关于更好的对话管理,如果有任何报错或者优化建议,请直接提交 issue~ + +1. 修复 reminder 时区问题 +2. 面板支持重载单个插件 #297 +3. 面板支持列表展示插件市场 +4. 文字转图片支持自定义字数阈值(配置->其他配置) +5. 面板更好的列表可视化 #274 +6. 面板支持查看插件行为 +7. 支持设置 timeout 超时时间参数,防止思考模型太长达到超时时间。(需要重新配置服务提供商或者在服务提供商 config 中配置 timeout 参数) #378 +8. openrouter 报错 no endpoints found that support tool use #371 +9. 修复插件 metadata 不生效的问题 +10. 修复不支持图片的模型请求异常 +11. 修复 reminder 无法删除的问题 +12. 修复 /model 切换不了模型的问题 +13. 插件支持设置优先级 +14. 聊天增强图像转述支持自定义 provider id。#274 diff --git a/changelogs/v3.4.22.md b/changelogs/v3.4.22.md new file mode 100644 index 000000000..c3b3fa3ca --- /dev/null +++ b/changelogs/v3.4.22.md @@ -0,0 +1,12 @@ +# What's Changed + +1. fix: 400 Bad Request: The browser (or proxy) sent a request that this server could not understand. #396 +2. remove: 移除了 put_history_to_prompt。当主动回复时,将群聊记录将自动放入prompt,当未主动回复但是开启群聊增强时,群聊记录将放入system prompt +3. fix: 插件错误信息点击关闭没反应 #394 +4. fix: 自部署文转图不生效 #352 +5. fix: Google Search 报 429 错误时,放宽 Exception 至其他搜索引擎 #405 +6. fix: 使用 Google Gemini (OpenAI 兼容)的部分情况下联网搜索等函数调用工具没被调用 #342 +7. fix: 修复尝试弹出最早的记录失效的问题 +8. fix: 移除了分段回复llm提示词辅助 +9. perf: 当图片数据为空时不加入上下文 #379 +10. 修复 dify 返回的结果带有多行数据时的 json 解析异常导致返回值为空的问题 #298 by @zhaolj \ No newline at end of file diff --git a/changelogs/v3.4.23.md b/changelogs/v3.4.23.md new file mode 100644 index 000000000..5ac025672 --- /dev/null +++ b/changelogs/v3.4.23.md @@ -0,0 +1,11 @@ +# What's Changed + +0. ✨ 新增: 支持 海豚 AI(FishAudio) TTS API #433 by @Cvandia +1. 🐛 修复: 当群聊主动回复时,不会带上人格的Prompt #419 +2. ✨ 新增: 支持展示插件是否有更新 +3. 👌 优化: 增加DIFY超时时间 #422 +4. 🐛 修复: 自部署文转图不生效 #352 +5. 🐛 修复: 修复 qq 回复别人的时候也会触发机器人, Onebot at 使用 string #330 +6. 👌 优化: 增加DIFY超时时间 #422 +7. 🐛 修复: 重启gewe的时候机器人会疯狂发消息 #421 +8. 🐛 修复: 修复子指令设置permission之后会导致其一定会被执行 #427 \ No newline at end of file diff --git a/changelogs/v3.4.24.md b/changelogs/v3.4.24.md new file mode 100644 index 000000000..2f44f0d4e --- /dev/null +++ b/changelogs/v3.4.24.md @@ -0,0 +1,11 @@ +# What's Changed + +0. ✨ 新增: 支持正则表达式匹配触发机器人,机器人在某一段时间内持续唤醒(不用输唤醒词)。(安装 astrbot_plugin_wake_enhance 插件) +2. ✨ 新增: 可以通过 /tts 开关TTS,通过 /provider 更换 TTS #436 +3. ✨ 新增: 管理面板支持设置 GitHub 反向代理地址以优化中国大陆地区下载 AstrBot 插件的速度。(在管理面板-设置页) +4. 🐛 修复: 修复指令不经过唤醒前缀也能生效的问题。在引用消息的时候无法使用前缀唤醒机器人 #444 +5. 🐛 修复: 修复 Napcat 下戳一戳消息报错 +6. 👌 优化: 从压缩包上传插件时,去除仓库 -branch 尾缀 +7. 🐛 修复: gemini 报错时显示 apikey +8. 🐛 修复: drun 不支持函数调用的报错 +9. 🐛 修复: raw_completion 没有正确传递导致部分插件无法正常运作 #439 \ No newline at end of file diff --git a/changelogs/v3.4.25.md b/changelogs/v3.4.25.md new file mode 100644 index 000000000..971f31f4a --- /dev/null +++ b/changelogs/v3.4.25.md @@ -0,0 +1,9 @@ +# What's Changed + +1. ✨ 新增: 支持接入飞书(Lark)。支持飞书文字、图片。 +2. ✨ 新增: 添加月之暗面配置模板 #446 +3. ✨ 新增: Gewechat 支持文件输出 +4. 🐛 修复: 修复gewechat无法at人和发语音失败的问题 #447 #438 +5. 🐛 修复: 修复qq在@和回复开启的情况下转发消息异常的问题 +6. 🐛 修复: GitHub 加速镜像没有正确被应用 +7. 🐛 优化: 平台将显示不受支持的消息段 \ No newline at end of file diff --git a/changelogs/v3.4.26.md b/changelogs/v3.4.26.md new file mode 100644 index 000000000..43d5ff6de --- /dev/null +++ b/changelogs/v3.4.26.md @@ -0,0 +1,12 @@ +# What's Changed + +1. ✨ 新增: 支持 Webhook 方式接入 QQ 官方机器人接口 +2. ✨ 新增: 支持完善的 Dify Chat 模式对话管理,包括 /new /switch /del /ls /reset 均已适配 Dify Chat 模式。 +3. ✨ 新增: 支持基于对数函数的分段回复延时时间计算 #414 +4. ✨ 新增: 支持设置管理面板的端口号 +5. ✨ 新增: 支持对大模型的响应进行内容审查 #474 +6. 🐛 修复: gewechat 不能发送主动消息 #402 +7. 🐛 修复: dify Chat 模式无法重置会话 #469 +8. 🐛 修复: ensure result is retrieved again to handle potential plugin chain replacements +9. 🐛 优化: 将 Gewechat 所有事件下发到流水线供插件开发 +10. 🐛 修复: correct dashboard update tooltip typo by @Akuma-real diff --git a/changelogs/v3.4.27.md b/changelogs/v3.4.27.md new file mode 100644 index 000000000..5bd709cde --- /dev/null +++ b/changelogs/v3.4.27.md @@ -0,0 +1,14 @@ +# What's Changed + +1. ✨ 新增: 支持日语版本的 Readme by @eltociear +2. ✨ 新增: 主动回复支持白名单 #488 +3. ⚡ 优化: 面板数据展示图表的时区问题 #460 +4. ⚡ 优化: 针对 id 对模型号进行排序以适配 OneAPI 乱序情况 #384 +5. ✨ 新增: 支持对大模型的响应进行内容审查 #474 +6. 🐛 修复: 修复保存插件配置时没有检查类型合法性的问题 +7. 🐛 修复: 尝试修复 Gemini empty text 相关报错 +8. 🐛 修复: dify 不能正常使用 set/unset 指令定义动态变量 #482 +9. 🐛 修复: 不能在 Webhook 模式下的 QQ 官方 API 私聊 #484 +10. 🐛 修复: 在没有触发并且没通过安全审查的情况下仍然发送了未通过消息 +11. 🐛 修复: /del 指令导致的相关异常 +12. 🐛 修复: 在 Gewechat 中不能先写内容后 @ 机器人 #492 \ No newline at end of file diff --git a/dashboard/package.json b/dashboard/package.json index 9b59cfa92..5d76b72d5 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -32,7 +32,7 @@ "vue-router": "4.2.4", "vue3-apexcharts": "1.4.4", "vue3-print-nb": "0.1.4", - "vuetify": "3.3.14", + "vuetify": "3.7.11", "yup": "1.2.0" }, "devDependencies": { diff --git a/dashboard/src/components/shared/AstrBotConfig.vue b/dashboard/src/components/shared/AstrBotConfig.vue index a94f8bddb..37efe7f6a 100644 --- a/dashboard/src/components/shared/AstrBotConfig.vue +++ b/dashboard/src/components/shared/AstrBotConfig.vue @@ -1,22 +1,26 @@