mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-14 17:00:17 +08:00
Compare commits
1 Commits
feat/runti
...
feat/opena
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2949572110 |
2
.github/workflows/coverage_test.yml
vendored
2
.github/workflows/coverage_test.yml
vendored
@@ -41,6 +41,6 @@ jobs:
|
||||
|
||||
- name: Upload results to Codecov
|
||||
if: github.repository == 'AstrBotDevs/AstrBot'
|
||||
uses: codecov/codecov-action@v6
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
8
.github/workflows/docker-image.yml
vendored
8
.github/workflows/docker-image.yml
vendored
@@ -70,14 +70,14 @@ jobs:
|
||||
uses: docker/setup-buildx-action@v4.0.0
|
||||
|
||||
- name: Log in to DockerHub
|
||||
uses: docker/login-action@v4.1.0
|
||||
uses: docker/login-action@v4.0.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
if: env.HAS_GHCR_TOKEN == 'true'
|
||||
uses: docker/login-action@v4.1.0
|
||||
uses: docker/login-action@v4.0.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ env.GHCR_OWNER }}
|
||||
@@ -169,14 +169,14 @@ jobs:
|
||||
uses: docker/setup-buildx-action@v4.0.0
|
||||
|
||||
- name: Log in to DockerHub
|
||||
uses: docker/login-action@v4.1.0
|
||||
uses: docker/login-action@v4.0.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
if: env.HAS_GHCR_TOKEN == 'true'
|
||||
uses: docker/login-action@v4.1.0
|
||||
uses: docker/login-action@v4.0.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ env.GHCR_OWNER }}
|
||||
|
||||
@@ -12,11 +12,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
bash \
|
||||
ffmpeg \
|
||||
libavcodec-extra \
|
||||
curl \
|
||||
gnupg \
|
||||
git \
|
||||
ripgrep \
|
||||
&& curl -fsSL https://deb.nodesource.com/setup_lts.x | bash - \
|
||||
&& apt-get install -y --no-install-recommends nodejs \
|
||||
&& apt-get clean \
|
||||
|
||||
28
README.md
28
README.md
@@ -1,5 +1,4 @@
|
||||

|
||||
|
||||

|
||||
|
||||
<div align="center">
|
||||
|
||||
@@ -93,9 +92,6 @@ Update `astrbot`:
|
||||
uv tool upgrade astrbot
|
||||
```
|
||||
|
||||
> [!WARNING]
|
||||
> AstrBot deployed via `uv` **does not support upgrading through the WebUI**. To update, please run the command above from the command line.
|
||||
|
||||
### Docker Deployment
|
||||
|
||||
For users familiar with containers and looking for a more stable, production-ready deployment method, we recommend deploying AstrBot with Docker / Docker Compose.
|
||||
@@ -158,7 +154,6 @@ Connect AstrBot to your favorite chat platform.
|
||||
| LINE | Official |
|
||||
| Satori | Official |
|
||||
| Misskey | Official |
|
||||
| Mattermost | Official |
|
||||
| WhatsApp (Coming Soon) | Official |
|
||||
| [Matrix](https://github.com/stevessr/astrbot_plugin_matrix_adapter) | Community |
|
||||
| [KOOK](https://github.com/wuyan1003/astrbot_plugin_kook_adapter) | Community |
|
||||
@@ -189,7 +184,6 @@ Connect AstrBot to your favorite chat platform.
|
||||
| Coze | LLMOps Platforms |
|
||||
| OpenAI Whisper | Speech-to-Text Services |
|
||||
| SenseVoice | Speech-to-Text Services |
|
||||
| Xiaomi MiMo Omni | Speech-to-Text Services |
|
||||
| OpenAI TTS | Text-to-Speech Services |
|
||||
| Gemini TTS | Text-to-Speech Services |
|
||||
| GPT-Sovits-Inference | Text-to-Speech Services |
|
||||
@@ -199,7 +193,6 @@ Connect AstrBot to your favorite chat platform.
|
||||
| Alibaba Cloud Bailian TTS | Text-to-Speech Services |
|
||||
| Azure TTS | Text-to-Speech Services |
|
||||
| Minimax TTS | Text-to-Speech Services |
|
||||
| Xiaomi MiMo TTS | Text-to-Speech Services |
|
||||
| Volcano Engine TTS | Text-to-Speech Services |
|
||||
|
||||
## ❤️ Sponsors
|
||||
@@ -232,17 +225,14 @@ pre-commit install
|
||||
|
||||
### QQ Groups
|
||||
|
||||
- Group 12: 916228568 (New)
|
||||
- Group 9: 1076659624 (Full)
|
||||
- Group 10: 1078079676 (Full)
|
||||
- Group 11: 704659519 (Full)
|
||||
- Group 1: 322154837 (Full)
|
||||
- Group 3: 630166526 (Full)
|
||||
- Group 4: 1077826412 (Full)
|
||||
- Group 5: 822130018 (Full)
|
||||
- Group 6: 753075035 (Full)
|
||||
- Group 7: 743746109 (Full)
|
||||
- Group 8: 1030353265 (Full)
|
||||
- Group 9: 1076659624 (New)
|
||||
- Group 10: 1078079676 (New)
|
||||
- Group 1: 322154837
|
||||
- Group 3: 630166526
|
||||
- Group 5: 822130018
|
||||
- Group 6: 753075035
|
||||
- Group 7: 743746109
|
||||
- Group 8: 1030353265
|
||||
|
||||
- Developer Group(Chit-chat): 975206796
|
||||
- Developer Group(Formal): 1039761811
|
||||
|
||||
20
README_fr.md
20
README_fr.md
@@ -92,9 +92,6 @@ Mettre à jour `astrbot` :
|
||||
uv tool upgrade astrbot
|
||||
```
|
||||
|
||||
> [!WARNING]
|
||||
> AstrBot déployé via `uv` **ne prend pas en charge la mise à jour via le WebUI**. Pour mettre à jour, exécutez la commande ci-dessus depuis le terminal.
|
||||
|
||||
### Déploiement Docker
|
||||
|
||||
Pour les utilisateurs familiers avec les conteneurs et qui souhaitent une méthode plus stable et adaptée à la production, nous recommandons de déployer AstrBot avec Docker / Docker Compose.
|
||||
@@ -187,7 +184,6 @@ Connectez AstrBot à vos plateformes de chat préférées.
|
||||
| Coze | Plateformes LLMOps |
|
||||
| OpenAI Whisper | Services de reconnaissance vocale |
|
||||
| SenseVoice | Services de reconnaissance vocale |
|
||||
| Xiaomi MiMo Omni | Services de reconnaissance vocale |
|
||||
| OpenAI TTS | Services de synthèse vocale |
|
||||
| Gemini TTS | Services de synthèse vocale |
|
||||
| GPT-Sovits-Inference | Services de synthèse vocale |
|
||||
@@ -197,7 +193,6 @@ Connectez AstrBot à vos plateformes de chat préférées.
|
||||
| Alibaba Cloud Bailian TTS | Services de synthèse vocale |
|
||||
| Azure TTS | Services de synthèse vocale |
|
||||
| Minimax TTS | Services de synthèse vocale |
|
||||
| Xiaomi MiMo TTS | Services de synthèse vocale |
|
||||
| Volcano Engine TTS | Services de synthèse vocale |
|
||||
|
||||
## ❤️ Contribuer
|
||||
@@ -222,17 +217,10 @@ pre-commit install
|
||||
|
||||
### Groupes QQ
|
||||
|
||||
- Groupe 12 : 916228568 (nouveau)
|
||||
- Groupe 9 : 1076659624 (complet)
|
||||
- Groupe 10 : 1078079676 (complet)
|
||||
- Groupe 11 : 704659519 (complet)
|
||||
- Groupe 1 : 322154837 (complet)
|
||||
- Groupe 3 : 630166526 (complet)
|
||||
- Groupe 4 : 1077826412 (complet)
|
||||
- Groupe 5 : 822130018 (complet)
|
||||
- Groupe 6 : 753075035 (complet)
|
||||
- Groupe 7 : 743746109 (complet)
|
||||
- Groupe 8 : 1030353265 (complet)
|
||||
- Groupe 1 : 322154837
|
||||
- Groupe 3 : 630166526
|
||||
- Groupe 5 : 822130018
|
||||
- Groupe 6 : 753075035
|
||||
- Groupe développeurs : 975206796
|
||||
- Groupe développeurs (officiel) : 1039761811
|
||||
|
||||
|
||||
20
README_ja.md
20
README_ja.md
@@ -92,9 +92,6 @@ astrbot run
|
||||
uv tool upgrade astrbot
|
||||
```
|
||||
|
||||
> [!WARNING]
|
||||
> `uv` 経由でデプロイした AstrBot は、**WebUI からのバージョンアップグレードに対応していません**。更新するには、上記のコマンドをコマンドラインで実行してください。
|
||||
|
||||
### Docker デプロイ
|
||||
|
||||
コンテナ運用に慣れており、より安定した本番向けのデプロイ方法を求めるユーザーには、Docker / Docker Compose での AstrBot デプロイをおすすめします。
|
||||
@@ -188,7 +185,6 @@ AstrBot をよく使うチャットプラットフォームに接続できます
|
||||
| Coze | LLMOps プラットフォーム |
|
||||
| OpenAI Whisper | 音声認識サービス |
|
||||
| SenseVoice | 音声認識サービス |
|
||||
| Xiaomi MiMo Omni | 音声認識サービス |
|
||||
| OpenAI TTS | 音声合成サービス |
|
||||
| Gemini TTS | 音声合成サービス |
|
||||
| GPT-Sovits-Inference | 音声合成サービス |
|
||||
@@ -198,7 +194,6 @@ AstrBot をよく使うチャットプラットフォームに接続できます
|
||||
| Alibaba Cloud 百炼 TTS | 音声合成サービス |
|
||||
| Azure TTS | 音声合成サービス |
|
||||
| Minimax TTS | 音声合成サービス |
|
||||
| Xiaomi MiMo TTS | 音声合成サービス |
|
||||
| Volcano Engine TTS | 音声合成サービス |
|
||||
|
||||
## ❤️ コントリビューション
|
||||
@@ -223,17 +218,10 @@ pre-commit install
|
||||
|
||||
### QQ グループ
|
||||
|
||||
- 12群: 916228568 (新)
|
||||
- 9群: 1076659624 (満員)
|
||||
- 10群: 1078079676 (満員)
|
||||
- 11群: 704659519 (満員)
|
||||
- 1群: 322154837 (満員)
|
||||
- 3群: 630166526 (満員)
|
||||
- 4群: 1077826412 (満員)
|
||||
- 5群: 822130018 (満員)
|
||||
- 6群: 753075035 (満員)
|
||||
- 7群: 743746109 (満員)
|
||||
- 8群: 1030353265 (満員)
|
||||
- 1群: 322154837
|
||||
- 3群: 630166526
|
||||
- 5群: 822130018
|
||||
- 6群: 753075035
|
||||
- 開発者群: 975206796
|
||||
- 開発者群(正式): 1039761811
|
||||
|
||||
|
||||
20
README_ru.md
20
README_ru.md
@@ -92,9 +92,6 @@ astrbot run
|
||||
uv tool upgrade astrbot
|
||||
```
|
||||
|
||||
> [!WARNING]
|
||||
> AstrBot, развёрнутый через `uv`, **не поддерживает обновление через WebUI**. Для обновления выполните указанную выше команду из командной строки.
|
||||
|
||||
### Развёртывание Docker
|
||||
|
||||
Для пользователей, знакомых с контейнерами и которым нужен более стабильный и подходящий для production способ, мы рекомендуем разворачивать AstrBot через Docker / Docker Compose.
|
||||
@@ -187,7 +184,6 @@ yay -S astrbot-git
|
||||
| Coze | Платформы LLMOps |
|
||||
| OpenAI Whisper | Сервисы распознавания речи |
|
||||
| SenseVoice | Сервисы распознавания речи |
|
||||
| Xiaomi MiMo Omni | Сервисы распознавания речи |
|
||||
| OpenAI TTS | Сервисы синтеза речи |
|
||||
| Gemini TTS | Сервисы синтеза речи |
|
||||
| GPT-Sovits-Inference | Сервисы синтеза речи |
|
||||
@@ -197,7 +193,6 @@ yay -S astrbot-git
|
||||
| Alibaba Cloud Bailian TTS | Сервисы синтеза речи |
|
||||
| Azure TTS | Сервисы синтеза речи |
|
||||
| Minimax TTS | Сервисы синтеза речи |
|
||||
| Xiaomi MiMo TTS | Сервисы синтеза речи |
|
||||
| Volcano Engine TTS | Сервисы синтеза речи |
|
||||
|
||||
## ❤️ Вклад в проект
|
||||
@@ -222,17 +217,10 @@ pre-commit install
|
||||
|
||||
### Группы QQ
|
||||
|
||||
- Группа 12: 916228568 (новая)
|
||||
- Группа 9: 1076659624 (полная)
|
||||
- Группа 10: 1078079676 (полная)
|
||||
- Группа 11: 704659519 (полная)
|
||||
- Группа 1: 322154837 (полная)
|
||||
- Группа 3: 630166526 (полная)
|
||||
- Группа 4: 1077826412 (полная)
|
||||
- Группа 5: 822130018 (полная)
|
||||
- Группа 6: 753075035 (полная)
|
||||
- Группа 7: 743746109 (полная)
|
||||
- Группа 8: 1030353265 (полная)
|
||||
- Группа 1: 322154837
|
||||
- Группа 3: 630166526
|
||||
- Группа 5: 822130018
|
||||
- Группа 6: 753075035
|
||||
- Группа разработчиков: 975206796
|
||||
- Группа разработчиков (официальная): 1039761811
|
||||
|
||||
|
||||
@@ -92,9 +92,6 @@ astrbot run
|
||||
uv tool upgrade astrbot
|
||||
```
|
||||
|
||||
> [!WARNING]
|
||||
> 透過 `uv` 部署的 AstrBot **不支援在 WebUI 中進行版本升級**。如需更新,請透過命令列執行上述命令。
|
||||
|
||||
### Docker 部署
|
||||
|
||||
對於熟悉容器、希望獲得更穩定且更適合正式環境部署方式的使用者,我們推薦使用 Docker / Docker Compose 部署 AstrBot。
|
||||
@@ -187,7 +184,6 @@ yay -S astrbot-git
|
||||
| Coze | LLMOps 平台 |
|
||||
| OpenAI Whisper | 語音轉文字服務 |
|
||||
| SenseVoice | 語音轉文字服務 |
|
||||
| Xiaomi MiMo Omni | 語音轉文字服務 |
|
||||
| OpenAI TTS | 文字轉語音服務 |
|
||||
| Gemini TTS | 文字轉語音服務 |
|
||||
| GPT-Sovits-Inference | 文字轉語音服務 |
|
||||
@@ -197,7 +193,6 @@ yay -S astrbot-git
|
||||
| 阿里雲百煉 TTS | 文字轉語音服務 |
|
||||
| Azure TTS | 文字轉語音服務 |
|
||||
| Minimax TTS | 文字轉語音服務 |
|
||||
| Xiaomi MiMo TTS | 文字轉語音服務 |
|
||||
| 火山引擎 TTS | 文字轉語音服務 |
|
||||
|
||||
## ❤️ 貢獻
|
||||
@@ -222,17 +217,14 @@ pre-commit install
|
||||
|
||||
### QQ 群組
|
||||
|
||||
- 12 群:916228568 (新)
|
||||
- 9 群:1076659624 (人滿)
|
||||
- 10 群:1078079676 (人滿)
|
||||
- 11 群:704659519 (人滿)
|
||||
- 1 群:322154837 (人滿)
|
||||
- 3 群:630166526 (人滿)
|
||||
- 4 群:1077826412 (人滿)
|
||||
- 5 群:822130018 (人滿)
|
||||
- 6 群:753075035 (人滿)
|
||||
- 7 群:743746109 (人滿)
|
||||
- 8 群:1030353265 (人滿)
|
||||
- 9 群: 1076659624 (新)
|
||||
- 10 群: 1078079676 (新)
|
||||
- 1 群:322154837
|
||||
- 3 群:630166526
|
||||
- 5 群:822130018
|
||||
- 6 群:753075035
|
||||
- 7 群:743746109
|
||||
- 8 群:1030353265
|
||||
- 開發者群(闲聊吹水):975206796
|
||||
- 開發者群(正式):1039761811
|
||||
|
||||
|
||||
24
README_zh.md
24
README_zh.md
@@ -92,9 +92,6 @@ astrbot run
|
||||
uv tool upgrade astrbot
|
||||
```
|
||||
|
||||
> [!WARNING]
|
||||
> 通过 `uv` 部署的 AstrBot **不支持在 WebUI 中进行版本升级**。如需更新,请通过命令行执行上述命令。
|
||||
|
||||
### Docker 部署
|
||||
|
||||
对于熟悉容器、希望获得更稳定且更适合生产环境部署方式的用户,我们推荐使用 Docker / Docker Compose 部署 AstrBot。
|
||||
@@ -188,7 +185,6 @@ yay -S astrbot-git
|
||||
| Coze | LLMOps 平台 |
|
||||
| OpenAI Whisper | 语音转文本 |
|
||||
| SenseVoice | 语音转文本 |
|
||||
| Xiaomi MiMo Omni | 语音转文本 |
|
||||
| OpenAI TTS | 文本转语音 |
|
||||
| Gemini TTS | 文本转语音 |
|
||||
| GPT-Sovits-Inference | 文本转语音 |
|
||||
@@ -198,7 +194,6 @@ yay -S astrbot-git
|
||||
| 阿里云百炼 TTS | 文本转语音 |
|
||||
| Azure TTS | 文本转语音 |
|
||||
| Minimax TTS | 文本转语音 |
|
||||
| Xiaomi MiMo TTS | 文本转语音 |
|
||||
| 火山引擎 TTS | 文本转语音 |
|
||||
|
||||
## ❤️ 贡献
|
||||
@@ -223,17 +218,14 @@ pre-commit install
|
||||
|
||||
### QQ 群组
|
||||
|
||||
- 12 群:916228568 (新)
|
||||
- 9 群:1076659624 (人满)
|
||||
- 10 群:1078079676 (人满)
|
||||
- 11 群:704659519 (人满)
|
||||
- 1 群:322154837 (人满)
|
||||
- 3 群:630166526 (人满)
|
||||
- 4 群:1077826412 (人满)
|
||||
- 5 群:822130018 (人满)
|
||||
- 6 群:753075035 (人满)
|
||||
- 7 群:743746109 (人满)
|
||||
- 8 群:1030353265 (人满)
|
||||
- 9 群: 1076659624 (新)
|
||||
- 10 群: 1078079676 (新)
|
||||
- 1 群:322154837
|
||||
- 3 群:630166526
|
||||
- 5 群:822130018
|
||||
- 6 群:753075035
|
||||
- 7 群:743746109
|
||||
- 8 群:1030353265
|
||||
- 开发者群(偏闲聊吹水):975206796
|
||||
- 开发者群(正式):1039761811
|
||||
|
||||
|
||||
@@ -36,9 +36,9 @@ class Main(star.Star):
|
||||
if self.ltm_enabled(event) and self.ltm and has_image_or_plain:
|
||||
need_active = await self.ltm.need_active_reply(event)
|
||||
|
||||
group_icl_enable = self.context.get_config(umo=event.unified_msg_origin)[
|
||||
"provider_ltm_settings"
|
||||
]["group_icl_enable"]
|
||||
group_icl_enable = self.context.get_config()["provider_ltm_settings"][
|
||||
"group_icl_enable"
|
||||
]
|
||||
if group_icl_enable:
|
||||
"""记录对话"""
|
||||
try:
|
||||
|
||||
@@ -1,15 +1,29 @@
|
||||
# Commands module
|
||||
|
||||
from .admin import AdminCommands
|
||||
from .alter_cmd import AlterCmdCommands
|
||||
from .conversation import ConversationCommands
|
||||
from .help import HelpCommand
|
||||
from .llm import LLMCommands
|
||||
from .persona import PersonaCommands
|
||||
from .plugin import PluginCommands
|
||||
from .provider import ProviderCommands
|
||||
from .setunset import SetUnsetCommands
|
||||
from .sid import SIDCommand
|
||||
from .t2i import T2ICommand
|
||||
from .tts import TTSCommand
|
||||
|
||||
__all__ = [
|
||||
"AdminCommands",
|
||||
"AlterCmdCommands",
|
||||
"ConversationCommands",
|
||||
"HelpCommand",
|
||||
"SetUnsetCommands",
|
||||
"LLMCommands",
|
||||
"PersonaCommands",
|
||||
"PluginCommands",
|
||||
"ProviderCommands",
|
||||
"SIDCommand",
|
||||
"SetUnsetCommands",
|
||||
"T2ICommand",
|
||||
"TTSCommand",
|
||||
]
|
||||
|
||||
@@ -1,17 +1,77 @@
|
||||
from astrbot.api import star
|
||||
from astrbot.api.event import AstrMessageEvent, MessageChain
|
||||
from astrbot.api.event import AstrMessageEvent, MessageChain, MessageEventResult
|
||||
from astrbot.core.config.default import VERSION
|
||||
from astrbot.core.utils.io import download_dashboard
|
||||
|
||||
from ..i18n import t
|
||||
|
||||
|
||||
class AdminCommands:
|
||||
def __init__(self, context: star.Context) -> None:
|
||||
self.context = context
|
||||
|
||||
async def op(self, event: AstrMessageEvent, admin_id: str = "") -> None:
|
||||
"""授权管理员。op <admin_id>"""
|
||||
if not admin_id:
|
||||
event.set_result(
|
||||
MessageEventResult().message(
|
||||
"使用方法: /op <id> 授权管理员;/deop <id> 取消管理员。可通过 /sid 获取 ID。",
|
||||
),
|
||||
)
|
||||
return
|
||||
self.context.get_config()["admins_id"].append(str(admin_id))
|
||||
self.context.get_config().save_config()
|
||||
event.set_result(MessageEventResult().message("授权成功。"))
|
||||
|
||||
async def deop(self, event: AstrMessageEvent, admin_id: str = "") -> None:
|
||||
"""取消授权管理员。deop <admin_id>"""
|
||||
if not admin_id:
|
||||
event.set_result(
|
||||
MessageEventResult().message(
|
||||
"使用方法: /deop <id> 取消管理员。可通过 /sid 获取 ID。",
|
||||
),
|
||||
)
|
||||
return
|
||||
try:
|
||||
self.context.get_config()["admins_id"].remove(str(admin_id))
|
||||
self.context.get_config().save_config()
|
||||
event.set_result(MessageEventResult().message("取消授权成功。"))
|
||||
except ValueError:
|
||||
event.set_result(
|
||||
MessageEventResult().message("此用户 ID 不在管理员名单内。"),
|
||||
)
|
||||
|
||||
async def wl(self, event: AstrMessageEvent, sid: str = "") -> None:
|
||||
"""添加白名单。wl <sid>"""
|
||||
if not sid:
|
||||
event.set_result(
|
||||
MessageEventResult().message(
|
||||
"使用方法: /wl <id> 添加白名单;/dwl <id> 删除白名单。可通过 /sid 获取 ID。",
|
||||
),
|
||||
)
|
||||
return
|
||||
cfg = self.context.get_config(umo=event.unified_msg_origin)
|
||||
cfg["platform_settings"]["id_whitelist"].append(str(sid))
|
||||
cfg.save_config()
|
||||
event.set_result(MessageEventResult().message("添加白名单成功。"))
|
||||
|
||||
async def dwl(self, event: AstrMessageEvent, sid: str = "") -> None:
|
||||
"""删除白名单。dwl <sid>"""
|
||||
if not sid:
|
||||
event.set_result(
|
||||
MessageEventResult().message(
|
||||
"使用方法: /dwl <id> 删除白名单。可通过 /sid 获取 ID。",
|
||||
),
|
||||
)
|
||||
return
|
||||
try:
|
||||
cfg = self.context.get_config(umo=event.unified_msg_origin)
|
||||
cfg["platform_settings"]["id_whitelist"].remove(str(sid))
|
||||
cfg.save_config()
|
||||
event.set_result(MessageEventResult().message("删除白名单成功。"))
|
||||
except ValueError:
|
||||
event.set_result(MessageEventResult().message("此 SID 不在白名单内。"))
|
||||
|
||||
async def update_dashboard(self, event: AstrMessageEvent) -> None:
|
||||
"""更新管理面板"""
|
||||
await event.send(MessageChain().message(t(self.context, "dashboard.updating")))
|
||||
await event.send(MessageChain().message("正在尝试更新管理面板..."))
|
||||
await download_dashboard(version=f"v{VERSION}", latest=False)
|
||||
await event.send(MessageChain().message(t(self.context, "dashboard.updated")))
|
||||
await event.send(MessageChain().message("管理面板更新完成。"))
|
||||
|
||||
173
astrbot/builtin_stars/builtin_commands/commands/alter_cmd.py
Normal file
173
astrbot/builtin_stars/builtin_commands/commands/alter_cmd.py
Normal file
@@ -0,0 +1,173 @@
|
||||
from astrbot.api import star
|
||||
from astrbot.api.event import AstrMessageEvent, MessageChain
|
||||
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.star import star_map
|
||||
from astrbot.core.star.star_handler import StarHandlerMetadata, star_handlers_registry
|
||||
from astrbot.core.utils.command_parser import CommandParserMixin
|
||||
|
||||
from .utils.rst_scene import RstScene
|
||||
|
||||
|
||||
class AlterCmdCommands(CommandParserMixin):
|
||||
def __init__(self, context: star.Context) -> None:
|
||||
self.context = context
|
||||
|
||||
async def update_reset_permission(self, scene_key: str, perm_type: str) -> None:
|
||||
"""更新reset命令在特定场景下的权限设置"""
|
||||
from astrbot.api import sp
|
||||
|
||||
alter_cmd_cfg = await sp.global_get("alter_cmd", {})
|
||||
plugin_cfg = alter_cmd_cfg.get("astrbot", {})
|
||||
reset_cfg = plugin_cfg.get("reset", {})
|
||||
reset_cfg[scene_key] = perm_type
|
||||
plugin_cfg["reset"] = reset_cfg
|
||||
alter_cmd_cfg["astrbot"] = plugin_cfg
|
||||
await sp.global_put("alter_cmd", alter_cmd_cfg)
|
||||
|
||||
async def alter_cmd(self, event: AstrMessageEvent) -> None:
|
||||
token = self.parse_commands(event.message_str)
|
||||
if token.len < 3:
|
||||
await event.send(
|
||||
MessageChain().message(
|
||||
"该指令用于设置指令或指令组的权限。\n"
|
||||
"格式: /alter_cmd <cmd_name> <admin/member>\n"
|
||||
"例1: /alter_cmd c1 admin 将 c1 设为管理员指令\n"
|
||||
"例2: /alter_cmd g1 c1 admin 将 g1 指令组的 c1 子指令设为管理员指令\n"
|
||||
"/alter_cmd reset config 打开 reset 权限配置",
|
||||
),
|
||||
)
|
||||
return
|
||||
|
||||
# 兼容 reset scene 的专门配置
|
||||
cmd_name = token.get(1)
|
||||
cmd_type = token.get(2)
|
||||
|
||||
if cmd_name == "reset" and cmd_type == "config":
|
||||
from astrbot.api import sp
|
||||
|
||||
alter_cmd_cfg = await sp.global_get("alter_cmd", {})
|
||||
plugin_ = alter_cmd_cfg.get("astrbot", {})
|
||||
reset_cfg = plugin_.get("reset", {})
|
||||
|
||||
group_unique_on = reset_cfg.get("group_unique_on", "admin")
|
||||
group_unique_off = reset_cfg.get("group_unique_off", "admin")
|
||||
private = reset_cfg.get("private", "member")
|
||||
|
||||
config_menu = f"""reset命令权限细粒度配置
|
||||
当前配置:
|
||||
1. 群聊+会话隔离开: {group_unique_on}
|
||||
2. 群聊+会话隔离关: {group_unique_off}
|
||||
3. 私聊: {private}
|
||||
修改指令格式:
|
||||
/alter_cmd reset scene <场景编号> <admin/member>
|
||||
例如: /alter_cmd reset scene 2 member"""
|
||||
await event.send(MessageChain().message(config_menu))
|
||||
return
|
||||
|
||||
if cmd_name == "reset" and cmd_type == "scene" and token.len >= 4:
|
||||
scene_num = token.get(3)
|
||||
perm_type = token.get(4)
|
||||
|
||||
if scene_num is None or perm_type is None:
|
||||
await event.send(MessageChain().message("场景编号和权限类型不能为空"))
|
||||
return
|
||||
|
||||
if not scene_num.isdigit() or int(scene_num) < 1 or int(scene_num) > 3:
|
||||
await event.send(
|
||||
MessageChain().message("场景编号必须是 1-3 之间的数字"),
|
||||
)
|
||||
return
|
||||
|
||||
if perm_type not in ["admin", "member"]:
|
||||
await event.send(
|
||||
MessageChain().message("权限类型错误,只能是 admin 或 member"),
|
||||
)
|
||||
return
|
||||
|
||||
scene_num = int(scene_num)
|
||||
scene = RstScene.from_index(scene_num)
|
||||
scene_key = scene.key
|
||||
|
||||
await self.update_reset_permission(scene_key, perm_type)
|
||||
|
||||
await event.send(
|
||||
MessageChain().message(
|
||||
f"已将 reset 命令在{scene.name}场景下的权限设为{perm_type}",
|
||||
),
|
||||
)
|
||||
return
|
||||
|
||||
if cmd_type not in ["admin", "member"]:
|
||||
await event.send(
|
||||
MessageChain().message("指令类型错误,可选类型有 admin, member"),
|
||||
)
|
||||
return
|
||||
|
||||
# 查找指令
|
||||
cmd_name = " ".join(token.tokens[1:-1])
|
||||
cmd_type = token.get(-1)
|
||||
found_command = None
|
||||
cmd_group = False
|
||||
for handler in star_handlers_registry:
|
||||
assert isinstance(handler, StarHandlerMetadata)
|
||||
for filter_ in handler.event_filters:
|
||||
if isinstance(filter_, CommandFilter):
|
||||
if filter_.equals(cmd_name):
|
||||
found_command = handler
|
||||
break
|
||||
elif isinstance(filter_, CommandGroupFilter):
|
||||
if filter_.equals(cmd_name):
|
||||
found_command = handler
|
||||
cmd_group = True
|
||||
break
|
||||
|
||||
if not found_command:
|
||||
await event.send(MessageChain().message("未找到该指令"))
|
||||
return
|
||||
|
||||
found_plugin = star_map[found_command.handler_module_path]
|
||||
|
||||
from astrbot.api import sp
|
||||
|
||||
alter_cmd_cfg = await sp.global_get("alter_cmd", {})
|
||||
plugin_ = alter_cmd_cfg.get(found_plugin.name, {})
|
||||
cfg = plugin_.get(found_command.handler_name, {})
|
||||
cfg["permission"] = cmd_type
|
||||
plugin_[found_command.handler_name] = cfg
|
||||
alter_cmd_cfg[found_plugin.name] = plugin_
|
||||
|
||||
await sp.global_put("alter_cmd", alter_cmd_cfg)
|
||||
|
||||
# 注入权限过滤器
|
||||
found_permission_filter = False
|
||||
for filter_ in found_command.event_filters:
|
||||
if isinstance(filter_, PermissionTypeFilter):
|
||||
if cmd_type == "admin":
|
||||
from astrbot.api.event import filter
|
||||
|
||||
filter_.permission_type = filter.PermissionType.ADMIN
|
||||
else:
|
||||
from astrbot.api.event import filter
|
||||
|
||||
filter_.permission_type = filter.PermissionType.MEMBER
|
||||
found_permission_filter = True
|
||||
break
|
||||
if not found_permission_filter:
|
||||
from astrbot.api.event import filter
|
||||
|
||||
found_command.event_filters.insert(
|
||||
0,
|
||||
PermissionTypeFilter(
|
||||
filter.PermissionType.ADMIN
|
||||
if cmd_type == "admin"
|
||||
else filter.PermissionType.MEMBER,
|
||||
),
|
||||
)
|
||||
cmd_group_str = "指令组" if cmd_group else "指令"
|
||||
await event.send(
|
||||
MessageChain().message(
|
||||
f"已将「{cmd_name}」{cmd_group_str} 的权限级别调整为 {cmd_type}。",
|
||||
),
|
||||
)
|
||||
@@ -1,15 +1,15 @@
|
||||
import datetime
|
||||
|
||||
from astrbot.api import sp, star
|
||||
from astrbot.api.event import AstrMessageEvent, MessageEventResult
|
||||
from astrbot.core import logger
|
||||
from astrbot.core.agent.runners.deerflow.constants import (
|
||||
DEERFLOW_AGENT_RUNNER_PROVIDER_ID_KEY,
|
||||
DEERFLOW_PROVIDER_TYPE,
|
||||
DEERFLOW_THREAD_ID_KEY,
|
||||
)
|
||||
from astrbot.core.agent.runners.deerflow.deerflow_api_client import DeerFlowAPIClient
|
||||
from astrbot.core.platform.astr_message_event import MessageSession
|
||||
from astrbot.core.platform.message_type import MessageType
|
||||
from astrbot.core.utils.active_event_registry import active_event_registry
|
||||
|
||||
from ..i18n import t
|
||||
from .utils.rst_scene import RstScene
|
||||
|
||||
THIRD_PARTY_AGENT_RUNNER_KEY = {
|
||||
@@ -21,85 +21,6 @@ THIRD_PARTY_AGENT_RUNNER_KEY = {
|
||||
THIRD_PARTY_AGENT_RUNNER_STR = ", ".join(THIRD_PARTY_AGENT_RUNNER_KEY.keys())
|
||||
|
||||
|
||||
async def _cleanup_deerflow_thread_if_present(
|
||||
context: star.Context,
|
||||
umo: str,
|
||||
) -> None:
|
||||
try:
|
||||
thread_id = await sp.get_async(
|
||||
scope="umo",
|
||||
scope_id=umo,
|
||||
key=DEERFLOW_THREAD_ID_KEY,
|
||||
default="",
|
||||
)
|
||||
if not thread_id:
|
||||
return
|
||||
|
||||
cfg = context.get_config(umo=umo)
|
||||
provider_id = cfg["provider_settings"].get(
|
||||
DEERFLOW_AGENT_RUNNER_PROVIDER_ID_KEY,
|
||||
"",
|
||||
)
|
||||
if not provider_id:
|
||||
return
|
||||
|
||||
merged_provider_config = context.provider_manager.get_provider_config_by_id(
|
||||
provider_id,
|
||||
merged=True,
|
||||
)
|
||||
if not merged_provider_config:
|
||||
logger.warning(
|
||||
"Failed to resolve DeerFlow provider config for remote thread cleanup: provider_id=%s",
|
||||
provider_id,
|
||||
)
|
||||
return
|
||||
|
||||
client = DeerFlowAPIClient(
|
||||
api_base=merged_provider_config.get(
|
||||
"deerflow_api_base",
|
||||
"http://127.0.0.1:2026",
|
||||
),
|
||||
api_key=merged_provider_config.get("deerflow_api_key", ""),
|
||||
auth_header=merged_provider_config.get("deerflow_auth_header", ""),
|
||||
proxy=merged_provider_config.get("proxy", ""),
|
||||
)
|
||||
try:
|
||||
await client.delete_thread(thread_id)
|
||||
finally:
|
||||
try:
|
||||
await client.close()
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Failed to close DeerFlow API client after thread cleanup: %s",
|
||||
e,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Failed to clean up DeerFlow thread for session %s: %s",
|
||||
umo,
|
||||
e,
|
||||
)
|
||||
|
||||
|
||||
async def _clear_third_party_agent_runner_state(
|
||||
context: star.Context,
|
||||
umo: str,
|
||||
agent_runner_type: str,
|
||||
) -> None:
|
||||
session_key = THIRD_PARTY_AGENT_RUNNER_KEY.get(agent_runner_type)
|
||||
if not session_key:
|
||||
return
|
||||
|
||||
if agent_runner_type == DEERFLOW_PROVIDER_TYPE:
|
||||
await _cleanup_deerflow_thread_if_present(context, umo)
|
||||
|
||||
await sp.remove_async(
|
||||
scope="umo",
|
||||
scope_id=umo,
|
||||
key=session_key,
|
||||
)
|
||||
|
||||
|
||||
class ConversationCommands:
|
||||
def __init__(self, context: star.Context) -> None:
|
||||
self.context = context
|
||||
@@ -139,12 +60,8 @@ class ConversationCommands:
|
||||
if required_perm == "admin" and message.role != "admin":
|
||||
message.set_result(
|
||||
MessageEventResult().message(
|
||||
t(
|
||||
self.context,
|
||||
"conversation.reset_admin_required",
|
||||
scene_name=t(self.context, f"scene.{scene.key}"),
|
||||
sender_id=message.get_sender_id(),
|
||||
),
|
||||
f"在{scene.name}场景下,reset命令需要管理员权限,"
|
||||
f"您 (ID {message.get_sender_id()}) 不是管理员,无法执行此操作。",
|
||||
),
|
||||
)
|
||||
return
|
||||
@@ -152,23 +69,17 @@ class ConversationCommands:
|
||||
agent_runner_type = cfg["provider_settings"]["agent_runner_type"]
|
||||
if agent_runner_type in THIRD_PARTY_AGENT_RUNNER_KEY:
|
||||
active_event_registry.stop_all(umo, exclude=message)
|
||||
await _clear_third_party_agent_runner_state(
|
||||
self.context,
|
||||
umo,
|
||||
agent_runner_type,
|
||||
)
|
||||
message.set_result(
|
||||
MessageEventResult().message(
|
||||
t(self.context, "conversation.reset_success"),
|
||||
)
|
||||
await sp.remove_async(
|
||||
scope="umo",
|
||||
scope_id=umo,
|
||||
key=THIRD_PARTY_AGENT_RUNNER_KEY[agent_runner_type],
|
||||
)
|
||||
message.set_result(MessageEventResult().message("重置对话成功。"))
|
||||
return
|
||||
|
||||
if not self.context.get_using_provider(umo):
|
||||
message.set_result(
|
||||
MessageEventResult().message(
|
||||
t(self.context, "conversation.no_provider"),
|
||||
),
|
||||
MessageEventResult().message("未找到任何 LLM 提供商。请先配置。"),
|
||||
)
|
||||
return
|
||||
|
||||
@@ -177,7 +88,7 @@ class ConversationCommands:
|
||||
if not cid:
|
||||
message.set_result(
|
||||
MessageEventResult().message(
|
||||
t(self.context, "conversation.no_conversation"),
|
||||
"当前未处于对话状态,请 /switch 切换或者 /new 创建。",
|
||||
),
|
||||
)
|
||||
return
|
||||
@@ -190,7 +101,7 @@ class ConversationCommands:
|
||||
[],
|
||||
)
|
||||
|
||||
ret = t(self.context, "conversation.reset_success")
|
||||
ret = "清除聊天历史成功!"
|
||||
|
||||
message.set_extra("_clean_ltm_session", True)
|
||||
|
||||
@@ -213,37 +124,160 @@ class ConversationCommands:
|
||||
if stopped_count > 0:
|
||||
message.set_result(
|
||||
MessageEventResult().message(
|
||||
t(
|
||||
self.context,
|
||||
"conversation.stop_requested",
|
||||
count=stopped_count,
|
||||
),
|
||||
f"已请求停止 {stopped_count} 个运行中的任务。"
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
message.set_result(
|
||||
MessageEventResult().message(
|
||||
t(self.context, "conversation.no_running_tasks"),
|
||||
message.set_result(MessageEventResult().message("当前会话没有运行中的任务。"))
|
||||
|
||||
async def his(self, message: AstrMessageEvent, page: int = 1) -> None:
|
||||
"""查看对话记录"""
|
||||
if not self.context.get_using_provider(message.unified_msg_origin):
|
||||
message.set_result(
|
||||
MessageEventResult().message("未找到任何 LLM 提供商。请先配置。"),
|
||||
)
|
||||
return
|
||||
|
||||
size_per_page = 6
|
||||
|
||||
conv_mgr = self.context.conversation_manager
|
||||
umo = message.unified_msg_origin
|
||||
session_curr_cid = await conv_mgr.get_curr_conversation_id(umo)
|
||||
|
||||
if not session_curr_cid:
|
||||
session_curr_cid = await conv_mgr.new_conversation(
|
||||
umo,
|
||||
message.get_platform_id(),
|
||||
)
|
||||
|
||||
contexts, total_pages = await conv_mgr.get_human_readable_context(
|
||||
umo,
|
||||
session_curr_cid,
|
||||
page,
|
||||
size_per_page,
|
||||
)
|
||||
|
||||
parts = []
|
||||
for context in contexts:
|
||||
if len(context) > 150:
|
||||
context = context[:150] + "..."
|
||||
parts.append(f"{context}\n")
|
||||
|
||||
history = "".join(parts)
|
||||
ret = (
|
||||
f"当前对话历史记录:"
|
||||
f"{history or '无历史记录'}\n\n"
|
||||
f"第 {page} 页 | 共 {total_pages} 页\n"
|
||||
f"*输入 /history 2 跳转到第 2 页"
|
||||
)
|
||||
|
||||
message.set_result(MessageEventResult().message(ret).use_t2i(False))
|
||||
|
||||
async def convs(self, message: AstrMessageEvent, page: int = 1) -> None:
|
||||
"""查看对话列表"""
|
||||
cfg = self.context.get_config(umo=message.unified_msg_origin)
|
||||
agent_runner_type = cfg["provider_settings"]["agent_runner_type"]
|
||||
if agent_runner_type in THIRD_PARTY_AGENT_RUNNER_KEY:
|
||||
message.set_result(
|
||||
MessageEventResult().message(
|
||||
f"{THIRD_PARTY_AGENT_RUNNER_STR} 对话列表功能暂不支持。",
|
||||
),
|
||||
)
|
||||
return
|
||||
|
||||
size_per_page = 6
|
||||
"""获取所有对话列表"""
|
||||
conversations_all = await self.context.conversation_manager.get_conversations(
|
||||
message.unified_msg_origin,
|
||||
)
|
||||
"""计算总页数"""
|
||||
total_pages = (len(conversations_all) + size_per_page - 1) // size_per_page
|
||||
"""确保页码有效"""
|
||||
page = max(1, min(page, total_pages))
|
||||
"""分页处理"""
|
||||
start_idx = (page - 1) * size_per_page
|
||||
end_idx = start_idx + size_per_page
|
||||
conversations_paged = conversations_all[start_idx:end_idx]
|
||||
|
||||
parts = ["对话列表:\n---\n"]
|
||||
"""全局序号从当前页的第一个开始"""
|
||||
global_index = start_idx + 1
|
||||
|
||||
"""生成所有对话的标题字典"""
|
||||
_titles = {}
|
||||
for conv in conversations_all:
|
||||
title = conv.title if conv.title else "新对话"
|
||||
_titles[conv.cid] = title
|
||||
|
||||
"""遍历分页后的对话生成列表显示"""
|
||||
provider_settings = cfg.get("provider_settings", {})
|
||||
platform_name = message.get_platform_name()
|
||||
for conv in conversations_paged:
|
||||
(
|
||||
persona_id,
|
||||
_,
|
||||
force_applied_persona_id,
|
||||
_,
|
||||
) = await self.context.persona_manager.resolve_selected_persona(
|
||||
umo=message.unified_msg_origin,
|
||||
conversation_persona_id=conv.persona_id,
|
||||
platform_name=platform_name,
|
||||
provider_settings=provider_settings,
|
||||
)
|
||||
if persona_id == "[%None]":
|
||||
persona_name = "无"
|
||||
elif persona_id:
|
||||
persona_name = persona_id
|
||||
else:
|
||||
persona_name = "无"
|
||||
|
||||
if force_applied_persona_id:
|
||||
persona_name = f"{persona_name} (自定义规则)"
|
||||
|
||||
title = _titles.get(conv.cid, "新对话")
|
||||
parts.append(
|
||||
f"{global_index}. {title}({conv.cid[:4]})\n 人格情景: {persona_name}\n 上次更新: {datetime.datetime.fromtimestamp(conv.updated_at).strftime('%m-%d %H:%M')}\n"
|
||||
)
|
||||
global_index += 1
|
||||
|
||||
parts.append("---\n")
|
||||
ret = "".join(parts)
|
||||
curr_cid = await self.context.conversation_manager.get_curr_conversation_id(
|
||||
message.unified_msg_origin,
|
||||
)
|
||||
if curr_cid:
|
||||
"""从所有对话的标题字典中获取标题"""
|
||||
title = _titles.get(curr_cid, "新对话")
|
||||
ret += f"\n当前对话: {title}({curr_cid[:4]})"
|
||||
else:
|
||||
ret += "\n当前对话: 无"
|
||||
|
||||
cfg = self.context.get_config(umo=message.unified_msg_origin)
|
||||
unique_session = cfg["platform_settings"]["unique_session"]
|
||||
if unique_session:
|
||||
ret += "\n会话隔离粒度: 个人"
|
||||
else:
|
||||
ret += "\n会话隔离粒度: 群聊"
|
||||
|
||||
ret += f"\n第 {page} 页 | 共 {total_pages} 页"
|
||||
ret += "\n*输入 /ls 2 跳转到第 2 页"
|
||||
|
||||
message.set_result(MessageEventResult().message(ret).use_t2i(False))
|
||||
return
|
||||
|
||||
async def new_conv(self, message: AstrMessageEvent) -> None:
|
||||
"""创建新对话"""
|
||||
cfg = self.context.get_config(umo=message.unified_msg_origin)
|
||||
agent_runner_type = cfg["provider_settings"]["agent_runner_type"]
|
||||
if agent_runner_type in THIRD_PARTY_AGENT_RUNNER_KEY:
|
||||
active_event_registry.stop_all(message.unified_msg_origin, exclude=message)
|
||||
await _clear_third_party_agent_runner_state(
|
||||
self.context,
|
||||
message.unified_msg_origin,
|
||||
agent_runner_type,
|
||||
)
|
||||
message.set_result(
|
||||
MessageEventResult().message(
|
||||
t(self.context, "conversation.new_created")
|
||||
)
|
||||
await sp.remove_async(
|
||||
scope="umo",
|
||||
scope_id=message.unified_msg_origin,
|
||||
key=THIRD_PARTY_AGENT_RUNNER_KEY[agent_runner_type],
|
||||
)
|
||||
message.set_result(MessageEventResult().message("已创建新对话。"))
|
||||
return
|
||||
|
||||
active_event_registry.stop_all(message.unified_msg_origin, exclude=message)
|
||||
@@ -257,11 +291,130 @@ class ConversationCommands:
|
||||
message.set_extra("_clean_ltm_session", True)
|
||||
|
||||
message.set_result(
|
||||
MessageEventResult().message(
|
||||
t(
|
||||
self.context,
|
||||
"conversation.switched_new",
|
||||
conversation_id=cid[:4],
|
||||
),
|
||||
),
|
||||
MessageEventResult().message(f"切换到新对话: 新对话({cid[:4]})。"),
|
||||
)
|
||||
|
||||
async def groupnew_conv(self, message: AstrMessageEvent, sid: str = "") -> None:
|
||||
"""创建新群聊对话"""
|
||||
if sid:
|
||||
session = str(
|
||||
MessageSession(
|
||||
platform_name=message.platform_meta.id,
|
||||
message_type=MessageType("GroupMessage"),
|
||||
session_id=sid,
|
||||
),
|
||||
)
|
||||
|
||||
cpersona = await self._get_current_persona_id(session)
|
||||
cid = await self.context.conversation_manager.new_conversation(
|
||||
session,
|
||||
message.get_platform_id(),
|
||||
persona_id=cpersona,
|
||||
)
|
||||
message.set_result(
|
||||
MessageEventResult().message(
|
||||
f"群聊 {session} 已切换到新对话: 新对话({cid[:4]})。",
|
||||
),
|
||||
)
|
||||
else:
|
||||
message.set_result(
|
||||
MessageEventResult().message("请输入群聊 ID。/groupnew 群聊ID。"),
|
||||
)
|
||||
|
||||
async def switch_conv(
|
||||
self,
|
||||
message: AstrMessageEvent,
|
||||
index: int | None = None,
|
||||
) -> None:
|
||||
"""通过 /ls 前面的序号切换对话"""
|
||||
if not isinstance(index, int):
|
||||
message.set_result(
|
||||
MessageEventResult().message("类型错误,请输入数字对话序号。"),
|
||||
)
|
||||
return
|
||||
|
||||
if index is None:
|
||||
message.set_result(
|
||||
MessageEventResult().message(
|
||||
"请输入对话序号。/switch 对话序号。/ls 查看对话 /new 新建对话",
|
||||
),
|
||||
)
|
||||
return
|
||||
conversations = await self.context.conversation_manager.get_conversations(
|
||||
message.unified_msg_origin,
|
||||
)
|
||||
if index > len(conversations) or index < 1:
|
||||
message.set_result(
|
||||
MessageEventResult().message("对话序号错误,请使用 /ls 查看"),
|
||||
)
|
||||
else:
|
||||
conversation = conversations[index - 1]
|
||||
title = conversation.title if conversation.title else "新对话"
|
||||
await self.context.conversation_manager.switch_conversation(
|
||||
message.unified_msg_origin,
|
||||
conversation.cid,
|
||||
)
|
||||
message.set_result(
|
||||
MessageEventResult().message(
|
||||
f"切换到对话: {title}({conversation.cid[:4]})。",
|
||||
),
|
||||
)
|
||||
|
||||
async def rename_conv(self, message: AstrMessageEvent, new_name: str = "") -> None:
|
||||
"""重命名对话"""
|
||||
if not new_name:
|
||||
message.set_result(MessageEventResult().message("请输入新的对话名称。"))
|
||||
return
|
||||
await self.context.conversation_manager.update_conversation_title(
|
||||
message.unified_msg_origin,
|
||||
new_name,
|
||||
)
|
||||
message.set_result(MessageEventResult().message("重命名对话成功。"))
|
||||
|
||||
async def del_conv(self, message: AstrMessageEvent) -> None:
|
||||
"""删除当前对话"""
|
||||
umo = message.unified_msg_origin
|
||||
cfg = self.context.get_config(umo=umo)
|
||||
is_unique_session = cfg["platform_settings"]["unique_session"]
|
||||
if message.get_group_id() and not is_unique_session and message.role != "admin":
|
||||
# 群聊,没开独立会话,发送人不是管理员
|
||||
message.set_result(
|
||||
MessageEventResult().message(
|
||||
f"会话处于群聊,并且未开启独立会话,并且您 (ID {message.get_sender_id()}) 不是管理员,因此没有权限删除当前对话。",
|
||||
),
|
||||
)
|
||||
return
|
||||
|
||||
agent_runner_type = cfg["provider_settings"]["agent_runner_type"]
|
||||
if agent_runner_type in THIRD_PARTY_AGENT_RUNNER_KEY:
|
||||
active_event_registry.stop_all(umo, exclude=message)
|
||||
await sp.remove_async(
|
||||
scope="umo",
|
||||
scope_id=umo,
|
||||
key=THIRD_PARTY_AGENT_RUNNER_KEY[agent_runner_type],
|
||||
)
|
||||
message.set_result(MessageEventResult().message("重置对话成功。"))
|
||||
return
|
||||
|
||||
session_curr_cid = (
|
||||
await self.context.conversation_manager.get_curr_conversation_id(umo)
|
||||
)
|
||||
|
||||
if not session_curr_cid:
|
||||
message.set_result(
|
||||
MessageEventResult().message(
|
||||
"当前未处于对话状态,请 /switch 序号 切换或 /new 创建。",
|
||||
),
|
||||
)
|
||||
return
|
||||
|
||||
active_event_registry.stop_all(umo, exclude=message)
|
||||
|
||||
await self.context.conversation_manager.delete_conversation(
|
||||
umo,
|
||||
session_curr_cid,
|
||||
)
|
||||
|
||||
ret = "删除当前对话成功。不再处于对话状态,使用 /switch 序号 切换到其他对话或 /new 创建。"
|
||||
message.set_extra("_clean_ltm_session", True)
|
||||
message.set_result(MessageEventResult().message(ret))
|
||||
|
||||
@@ -6,8 +6,6 @@ from astrbot.core.config.default import VERSION
|
||||
from astrbot.core.star import command_management
|
||||
from astrbot.core.utils.io import get_dashboard_version
|
||||
|
||||
from ..i18n import t
|
||||
|
||||
|
||||
class HelpCommand:
|
||||
def __init__(self, context: star.Context) -> None:
|
||||
@@ -34,6 +32,7 @@ class HelpCommand:
|
||||
return []
|
||||
|
||||
lines: list[str] = []
|
||||
hidden_commands = {"set", "unset", "websearch"}
|
||||
|
||||
def walk(items: list[dict], indent: int = 0) -> None:
|
||||
for item in items:
|
||||
@@ -50,12 +49,9 @@ class HelpCommand:
|
||||
or item.get("original_command")
|
||||
or item.get("handler_name")
|
||||
)
|
||||
if not effective or effective in [
|
||||
"set",
|
||||
"unset",
|
||||
"help",
|
||||
"dashboard_update",
|
||||
]:
|
||||
if not effective:
|
||||
continue
|
||||
if effective in hidden_commands:
|
||||
continue
|
||||
|
||||
description = item.get("description") or ""
|
||||
@@ -77,13 +73,12 @@ class HelpCommand:
|
||||
dashboard_version = await get_dashboard_version()
|
||||
command_lines = await self._build_reserved_command_lines()
|
||||
commands_section = (
|
||||
"\n".join(command_lines)
|
||||
if command_lines
|
||||
else t(self.context, "help.no_enabled_builtin_commands")
|
||||
"\n".join(command_lines) if command_lines else "暂无启用的内置指令"
|
||||
)
|
||||
|
||||
msg_parts = [
|
||||
f"AstrBot v{VERSION}(WebUI: {dashboard_version})",
|
||||
"内置指令:",
|
||||
commands_section,
|
||||
]
|
||||
if notice:
|
||||
|
||||
20
astrbot/builtin_stars/builtin_commands/commands/llm.py
Normal file
20
astrbot/builtin_stars/builtin_commands/commands/llm.py
Normal file
@@ -0,0 +1,20 @@
|
||||
from astrbot.api import star
|
||||
from astrbot.api.event import AstrMessageEvent, MessageChain
|
||||
|
||||
|
||||
class LLMCommands:
|
||||
def __init__(self, context: star.Context) -> None:
|
||||
self.context = context
|
||||
|
||||
async def llm(self, event: AstrMessageEvent) -> None:
|
||||
"""开启/关闭 LLM"""
|
||||
cfg = self.context.get_config(umo=event.unified_msg_origin)
|
||||
enable = cfg["provider_settings"].get("enable", True)
|
||||
if enable:
|
||||
cfg["provider_settings"]["enable"] = False
|
||||
status = "关闭"
|
||||
else:
|
||||
cfg["provider_settings"]["enable"] = True
|
||||
status = "开启"
|
||||
cfg.save_config()
|
||||
await event.send(MessageChain().message(f"{status} LLM 聊天功能。"))
|
||||
216
astrbot/builtin_stars/builtin_commands/commands/persona.py
Normal file
216
astrbot/builtin_stars/builtin_commands/commands/persona.py
Normal file
@@ -0,0 +1,216 @@
|
||||
import builtins
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from astrbot.api import star
|
||||
from astrbot.api.event import AstrMessageEvent, MessageEventResult
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from astrbot.core.db.po import Persona
|
||||
|
||||
|
||||
class PersonaCommands:
|
||||
def __init__(self, context: star.Context) -> None:
|
||||
self.context = context
|
||||
|
||||
def _build_tree_output(
|
||||
self,
|
||||
folder_tree: list[dict],
|
||||
all_personas: list["Persona"],
|
||||
depth: int = 0,
|
||||
) -> list[str]:
|
||||
"""递归构建树状输出,使用短线条表示层级"""
|
||||
lines: list[str] = []
|
||||
# 使用短线条作为缩进前缀,每层只用 "│" 加一个空格
|
||||
prefix = "│ " * depth
|
||||
|
||||
for folder in folder_tree:
|
||||
# 输出文件夹
|
||||
lines.append(f"{prefix}├ 📁 {folder['name']}/")
|
||||
|
||||
# 获取该文件夹下的人格
|
||||
folder_personas = [
|
||||
p for p in all_personas if p.folder_id == folder["folder_id"]
|
||||
]
|
||||
child_prefix = "│ " * (depth + 1)
|
||||
|
||||
# 输出该文件夹下的人格
|
||||
for persona in folder_personas:
|
||||
lines.append(f"{child_prefix}├ 👤 {persona.persona_id}")
|
||||
|
||||
# 递归处理子文件夹
|
||||
children = folder.get("children", [])
|
||||
if children:
|
||||
lines.extend(
|
||||
self._build_tree_output(
|
||||
children,
|
||||
all_personas,
|
||||
depth + 1,
|
||||
)
|
||||
)
|
||||
|
||||
return lines
|
||||
|
||||
async def persona(self, message: AstrMessageEvent) -> None:
|
||||
l = message.message_str.split(" ") # noqa: E741
|
||||
umo = message.unified_msg_origin
|
||||
|
||||
curr_persona_name = "无"
|
||||
cid = await self.context.conversation_manager.get_curr_conversation_id(umo)
|
||||
default_persona = await self.context.persona_manager.get_default_persona_v3(
|
||||
umo=umo,
|
||||
)
|
||||
force_applied_persona_id = None
|
||||
|
||||
curr_cid_title = "无"
|
||||
if cid:
|
||||
conv = await self.context.conversation_manager.get_conversation(
|
||||
unified_msg_origin=umo,
|
||||
conversation_id=cid,
|
||||
create_if_not_exists=True,
|
||||
)
|
||||
if conv is None:
|
||||
message.set_result(
|
||||
MessageEventResult().message(
|
||||
"当前对话不存在,请先使用 /new 新建一个对话。",
|
||||
),
|
||||
)
|
||||
return
|
||||
|
||||
provider_settings = self.context.get_config(umo=umo).get(
|
||||
"provider_settings",
|
||||
{},
|
||||
)
|
||||
(
|
||||
persona_id,
|
||||
_,
|
||||
force_applied_persona_id,
|
||||
_,
|
||||
) = await self.context.persona_manager.resolve_selected_persona(
|
||||
umo=umo,
|
||||
conversation_persona_id=conv.persona_id,
|
||||
platform_name=message.get_platform_name(),
|
||||
provider_settings=provider_settings,
|
||||
)
|
||||
|
||||
if persona_id == "[%None]":
|
||||
curr_persona_name = "无"
|
||||
elif persona_id:
|
||||
curr_persona_name = persona_id
|
||||
|
||||
if force_applied_persona_id:
|
||||
curr_persona_name = f"{curr_persona_name} (自定义规则)"
|
||||
|
||||
curr_cid_title = conv.title if conv.title else "新对话"
|
||||
curr_cid_title += f"({cid[:4]})"
|
||||
|
||||
if len(l) == 1:
|
||||
message.set_result(
|
||||
MessageEventResult()
|
||||
.message(
|
||||
f"""[Persona]
|
||||
|
||||
- 人格情景列表: `/persona list`
|
||||
- 设置人格情景: `/persona 人格`
|
||||
- 人格情景详细信息: `/persona view 人格`
|
||||
- 取消人格: `/persona unset`
|
||||
|
||||
默认人格情景: {default_persona["name"]}
|
||||
当前对话 {curr_cid_title} 的人格情景: {curr_persona_name}
|
||||
|
||||
配置人格情景请前往管理面板-配置页
|
||||
""",
|
||||
)
|
||||
.use_t2i(False),
|
||||
)
|
||||
elif l[1] == "list":
|
||||
# 获取文件夹树和所有人格
|
||||
folder_tree = await self.context.persona_manager.get_folder_tree()
|
||||
all_personas = self.context.persona_manager.personas
|
||||
|
||||
lines = ["📂 人格列表:\n"]
|
||||
|
||||
# 构建树状输出
|
||||
tree_lines = self._build_tree_output(folder_tree, all_personas)
|
||||
lines.extend(tree_lines)
|
||||
|
||||
# 输出根目录下的人格(没有文件夹的)
|
||||
root_personas = [p for p in all_personas if p.folder_id is None]
|
||||
if root_personas:
|
||||
if tree_lines: # 如果有文件夹内容,加个空行
|
||||
lines.append("")
|
||||
for persona in root_personas:
|
||||
lines.append(f"👤 {persona.persona_id}")
|
||||
|
||||
# 统计信息
|
||||
total_count = len(all_personas)
|
||||
lines.append(f"\n共 {total_count} 个人格")
|
||||
lines.append("\n*使用 `/persona <人格名>` 设置人格")
|
||||
lines.append("*使用 `/persona view <人格名>` 查看详细信息")
|
||||
|
||||
msg = "\n".join(lines)
|
||||
message.set_result(MessageEventResult().message(msg).use_t2i(False))
|
||||
elif l[1] == "view":
|
||||
if len(l) == 2:
|
||||
message.set_result(MessageEventResult().message("请输入人格情景名"))
|
||||
return
|
||||
ps = l[2].strip()
|
||||
if persona := next(
|
||||
builtins.filter(
|
||||
lambda persona: persona["name"] == ps,
|
||||
self.context.provider_manager.personas,
|
||||
),
|
||||
None,
|
||||
):
|
||||
msg = f"人格{ps}的详细信息:\n"
|
||||
msg += f"{persona['prompt']}\n"
|
||||
else:
|
||||
msg = f"人格{ps}不存在"
|
||||
message.set_result(MessageEventResult().message(msg))
|
||||
elif l[1] == "unset":
|
||||
if not cid:
|
||||
message.set_result(
|
||||
MessageEventResult().message("当前没有对话,无法取消人格。"),
|
||||
)
|
||||
return
|
||||
await self.context.conversation_manager.update_conversation_persona_id(
|
||||
message.unified_msg_origin,
|
||||
"[%None]",
|
||||
)
|
||||
message.set_result(MessageEventResult().message("取消人格成功。"))
|
||||
else:
|
||||
ps = "".join(l[1:]).strip()
|
||||
if not cid:
|
||||
message.set_result(
|
||||
MessageEventResult().message(
|
||||
"当前没有对话,请先开始对话或使用 /new 创建一个对话。",
|
||||
),
|
||||
)
|
||||
return
|
||||
if persona := next(
|
||||
builtins.filter(
|
||||
lambda persona: persona["name"] == ps,
|
||||
self.context.provider_manager.personas,
|
||||
),
|
||||
None,
|
||||
):
|
||||
await self.context.conversation_manager.update_conversation_persona_id(
|
||||
message.unified_msg_origin,
|
||||
ps,
|
||||
)
|
||||
force_warn_msg = ""
|
||||
if force_applied_persona_id:
|
||||
force_warn_msg = (
|
||||
"提醒:由于自定义规则,您现在切换的人格将不会生效。"
|
||||
)
|
||||
|
||||
message.set_result(
|
||||
MessageEventResult().message(
|
||||
f"设置成功。如果您正在切换到不同的人格,请注意使用 /reset 来清空上下文,防止原人格对话影响现人格。{force_warn_msg}",
|
||||
),
|
||||
)
|
||||
else:
|
||||
message.set_result(
|
||||
MessageEventResult().message(
|
||||
"不存在该人格情景。使用 /persona list 查看所有。",
|
||||
),
|
||||
)
|
||||
120
astrbot/builtin_stars/builtin_commands/commands/plugin.py
Normal file
120
astrbot/builtin_stars/builtin_commands/commands/plugin.py
Normal file
@@ -0,0 +1,120 @@
|
||||
from astrbot.api import star
|
||||
from astrbot.api.event import AstrMessageEvent, MessageEventResult
|
||||
from astrbot.core import DEMO_MODE, logger
|
||||
from astrbot.core.star.filter.command import CommandFilter
|
||||
from astrbot.core.star.filter.command_group import CommandGroupFilter
|
||||
from astrbot.core.star.star_handler import StarHandlerMetadata, star_handlers_registry
|
||||
from astrbot.core.star.star_manager import PluginManager
|
||||
|
||||
|
||||
class PluginCommands:
|
||||
def __init__(self, context: star.Context) -> None:
|
||||
self.context = context
|
||||
|
||||
async def plugin_ls(self, event: AstrMessageEvent) -> None:
|
||||
"""获取已经安装的插件列表。"""
|
||||
parts = ["已加载的插件:\n"]
|
||||
for plugin in self.context.get_all_stars():
|
||||
line = f"- `{plugin.name}` By {plugin.author}: {plugin.desc}"
|
||||
if not plugin.activated:
|
||||
line += " (未启用)"
|
||||
parts.append(line + "\n")
|
||||
|
||||
if len(parts) == 1:
|
||||
plugin_list_info = "没有加载任何插件。"
|
||||
else:
|
||||
plugin_list_info = "".join(parts)
|
||||
|
||||
plugin_list_info += "\n使用 /plugin help <插件名> 查看插件帮助和加载的指令。\n使用 /plugin on/off <插件名> 启用或者禁用插件。"
|
||||
event.set_result(
|
||||
MessageEventResult().message(f"{plugin_list_info}").use_t2i(False),
|
||||
)
|
||||
|
||||
async def plugin_off(self, event: AstrMessageEvent, plugin_name: str = "") -> None:
|
||||
"""禁用插件"""
|
||||
if DEMO_MODE:
|
||||
event.set_result(MessageEventResult().message("演示模式下无法禁用插件。"))
|
||||
return
|
||||
if not plugin_name:
|
||||
event.set_result(
|
||||
MessageEventResult().message("/plugin off <插件名> 禁用插件。"),
|
||||
)
|
||||
return
|
||||
await self.context._star_manager.turn_off_plugin(plugin_name) # type: ignore
|
||||
event.set_result(MessageEventResult().message(f"插件 {plugin_name} 已禁用。"))
|
||||
|
||||
async def plugin_on(self, event: AstrMessageEvent, plugin_name: str = "") -> None:
|
||||
"""启用插件"""
|
||||
if DEMO_MODE:
|
||||
event.set_result(MessageEventResult().message("演示模式下无法启用插件。"))
|
||||
return
|
||||
if not plugin_name:
|
||||
event.set_result(
|
||||
MessageEventResult().message("/plugin on <插件名> 启用插件。"),
|
||||
)
|
||||
return
|
||||
await self.context._star_manager.turn_on_plugin(plugin_name) # type: ignore
|
||||
event.set_result(MessageEventResult().message(f"插件 {plugin_name} 已启用。"))
|
||||
|
||||
async def plugin_get(self, event: AstrMessageEvent, plugin_repo: str = "") -> None:
|
||||
"""安装插件"""
|
||||
if DEMO_MODE:
|
||||
event.set_result(MessageEventResult().message("演示模式下无法安装插件。"))
|
||||
return
|
||||
if not plugin_repo:
|
||||
event.set_result(
|
||||
MessageEventResult().message("/plugin get <插件仓库地址> 安装插件"),
|
||||
)
|
||||
return
|
||||
logger.info(f"准备从 {plugin_repo} 安装插件。")
|
||||
if self.context._star_manager:
|
||||
star_mgr: PluginManager = self.context._star_manager
|
||||
try:
|
||||
await star_mgr.install_plugin(plugin_repo) # type: ignore
|
||||
event.set_result(MessageEventResult().message("安装插件成功。"))
|
||||
except Exception as e:
|
||||
logger.error(f"安装插件失败: {e}")
|
||||
event.set_result(MessageEventResult().message(f"安装插件失败: {e}"))
|
||||
return
|
||||
|
||||
async def plugin_help(self, event: AstrMessageEvent, plugin_name: str = "") -> None:
|
||||
"""获取插件帮助"""
|
||||
if not plugin_name:
|
||||
event.set_result(
|
||||
MessageEventResult().message("/plugin help <插件名> 查看插件信息。"),
|
||||
)
|
||||
return
|
||||
plugin = self.context.get_registered_star(plugin_name)
|
||||
if plugin is None:
|
||||
event.set_result(MessageEventResult().message("未找到此插件。"))
|
||||
return
|
||||
help_msg = ""
|
||||
help_msg += f"\n\n✨ 作者: {plugin.author}\n✨ 版本: {plugin.version}"
|
||||
command_handlers = []
|
||||
command_names = []
|
||||
for handler in star_handlers_registry:
|
||||
assert isinstance(handler, StarHandlerMetadata)
|
||||
if handler.handler_module_path != plugin.module_path:
|
||||
continue
|
||||
for filter_ in handler.event_filters:
|
||||
if isinstance(filter_, CommandFilter):
|
||||
command_handlers.append(handler)
|
||||
command_names.append(filter_.command_name)
|
||||
break
|
||||
if isinstance(filter_, CommandGroupFilter):
|
||||
command_handlers.append(handler)
|
||||
command_names.append(filter_.group_name)
|
||||
|
||||
if len(command_handlers) > 0:
|
||||
parts = ["\n\n🔧 指令列表:\n"]
|
||||
for i in range(len(command_handlers)):
|
||||
line = f"- {command_names[i]}"
|
||||
if command_handlers[i].desc:
|
||||
line += f": {command_handlers[i].desc}"
|
||||
parts.append(line + "\n")
|
||||
parts.append("\nTip: 指令的触发需要添加唤醒前缀,默认为 /。")
|
||||
help_msg += "".join(parts)
|
||||
|
||||
ret = f"🧩 插件 {plugin_name} 帮助信息:\n" + help_msg
|
||||
ret += "更多帮助信息请查看插件仓库 README。"
|
||||
event.set_result(MessageEventResult().message(ret).use_t2i(False))
|
||||
736
astrbot/builtin_stars/builtin_commands/commands/provider.py
Normal file
736
astrbot/builtin_stars/builtin_commands/commands/provider.py
Normal file
@@ -0,0 +1,736 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from astrbot import logger
|
||||
from astrbot.api import star
|
||||
from astrbot.api.event import AstrMessageEvent, MessageEventResult
|
||||
from astrbot.core.provider.entities import ProviderType
|
||||
from astrbot.core.utils.error_redaction import safe_error
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from astrbot.core.provider.provider import Provider
|
||||
|
||||
|
||||
MODEL_LIST_CACHE_TTL_SECONDS_DEFAULT = 30.0
|
||||
MODEL_LOOKUP_MAX_CONCURRENCY_DEFAULT = 4
|
||||
MODEL_LOOKUP_MAX_CONCURRENCY_UPPER_BOUND = 16
|
||||
MODEL_LIST_CACHE_TTL_KEY = "model_list_cache_ttl_seconds"
|
||||
MODEL_LOOKUP_MAX_CONCURRENCY_KEY = "model_lookup_max_concurrency"
|
||||
MODEL_CACHE_MAX_ENTRIES = 512
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _ModelLookupConfig:
|
||||
umo: str | None
|
||||
cache_ttl_seconds: float
|
||||
max_concurrency: int
|
||||
|
||||
|
||||
class _ModelCache:
|
||||
def __init__(self) -> None:
|
||||
self._store: dict[tuple[str, str | None], tuple[float, list[str]]] = {}
|
||||
|
||||
def get(self, provider_id: str, umo: str | None, ttl: float) -> list[str] | None:
|
||||
if ttl <= 0:
|
||||
return None
|
||||
entry = self._store.get((provider_id, umo))
|
||||
if not entry:
|
||||
return None
|
||||
timestamp, models = entry
|
||||
if time.monotonic() - timestamp > ttl:
|
||||
self._store.pop((provider_id, umo), None)
|
||||
return None
|
||||
return models
|
||||
|
||||
def set(
|
||||
self, provider_id: str, umo: str | None, models: list[str], ttl: float
|
||||
) -> None:
|
||||
if ttl <= 0:
|
||||
return
|
||||
self._store[(provider_id, umo)] = (time.monotonic(), list(models))
|
||||
self._evict_if_needed()
|
||||
|
||||
def _evict_if_needed(self) -> None:
|
||||
if len(self._store) <= MODEL_CACHE_MAX_ENTRIES:
|
||||
return
|
||||
# Drop oldest entries first when cache grows too large.
|
||||
overflow = len(self._store) - MODEL_CACHE_MAX_ENTRIES
|
||||
for key, _ in sorted(
|
||||
self._store.items(),
|
||||
key=lambda item: item[1][0],
|
||||
)[:overflow]:
|
||||
self._store.pop(key, None)
|
||||
|
||||
def invalidate(
|
||||
self, provider_id: str | None = None, *, umo: str | None = None
|
||||
) -> None:
|
||||
if provider_id is None:
|
||||
self._store.clear()
|
||||
return
|
||||
if umo is not None:
|
||||
self._store.pop((provider_id, umo), None)
|
||||
return
|
||||
stale_keys = [
|
||||
cache_key for cache_key in self._store if cache_key[0] == provider_id
|
||||
]
|
||||
for cache_key in stale_keys:
|
||||
self._store.pop(cache_key, None)
|
||||
|
||||
|
||||
class ProviderCommands:
|
||||
def __init__(self, context: star.Context) -> None:
|
||||
self.context = context
|
||||
self._model_cache = _ModelCache()
|
||||
self._register_provider_change_hook()
|
||||
|
||||
def _register_provider_change_hook(self) -> None:
|
||||
set_change_callback = getattr(
|
||||
self.context.provider_manager,
|
||||
"set_provider_change_callback",
|
||||
None,
|
||||
)
|
||||
if callable(set_change_callback):
|
||||
set_change_callback(self._on_provider_manager_changed)
|
||||
return
|
||||
register_change_hook = getattr(
|
||||
self.context.provider_manager,
|
||||
"register_provider_change_hook",
|
||||
None,
|
||||
)
|
||||
if callable(register_change_hook):
|
||||
register_change_hook(self._on_provider_manager_changed)
|
||||
|
||||
def invalidate_provider_models_cache(
|
||||
self, provider_id: str | None = None, *, umo: str | None = None
|
||||
) -> None:
|
||||
"""Public hook for cache invalidation on external provider config changes."""
|
||||
self._model_cache.invalidate(provider_id, umo=umo)
|
||||
|
||||
def _on_provider_manager_changed(
|
||||
self,
|
||||
provider_id: str,
|
||||
provider_type: ProviderType,
|
||||
umo: str | None,
|
||||
) -> None:
|
||||
if provider_type == ProviderType.CHAT_COMPLETION:
|
||||
self.invalidate_provider_models_cache(provider_id, umo=umo)
|
||||
|
||||
def _get_provider_settings(self, umo: str | None) -> dict:
|
||||
if not umo:
|
||||
return {}
|
||||
try:
|
||||
return self.context.get_config(umo).get("provider_settings", {}) or {}
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
"读取 provider_settings 失败,使用默认值: %s",
|
||||
safe_error("", e),
|
||||
)
|
||||
return {}
|
||||
|
||||
def _get_model_cache_ttl(self, umo: str | None) -> float:
|
||||
settings = self._get_provider_settings(umo)
|
||||
raw = settings.get(
|
||||
MODEL_LIST_CACHE_TTL_KEY,
|
||||
MODEL_LIST_CACHE_TTL_SECONDS_DEFAULT,
|
||||
)
|
||||
try:
|
||||
return max(float(raw), 0.0)
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
"读取 %s 失败,回退默认值 %r: %s",
|
||||
MODEL_LIST_CACHE_TTL_KEY,
|
||||
MODEL_LIST_CACHE_TTL_SECONDS_DEFAULT,
|
||||
safe_error("", e),
|
||||
)
|
||||
return MODEL_LIST_CACHE_TTL_SECONDS_DEFAULT
|
||||
|
||||
def _get_model_lookup_concurrency(self, umo: str | None) -> int:
|
||||
settings = self._get_provider_settings(umo)
|
||||
raw = settings.get(
|
||||
MODEL_LOOKUP_MAX_CONCURRENCY_KEY,
|
||||
MODEL_LOOKUP_MAX_CONCURRENCY_DEFAULT,
|
||||
)
|
||||
try:
|
||||
value = int(raw)
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
"读取 %s 失败,回退默认值 %r: %s",
|
||||
MODEL_LOOKUP_MAX_CONCURRENCY_KEY,
|
||||
MODEL_LOOKUP_MAX_CONCURRENCY_DEFAULT,
|
||||
safe_error("", e),
|
||||
)
|
||||
value = MODEL_LOOKUP_MAX_CONCURRENCY_DEFAULT
|
||||
return min(max(value, 1), MODEL_LOOKUP_MAX_CONCURRENCY_UPPER_BOUND)
|
||||
|
||||
def _get_model_lookup_config(self, umo: str | None) -> _ModelLookupConfig:
|
||||
return _ModelLookupConfig(
|
||||
umo=umo,
|
||||
cache_ttl_seconds=self._get_model_cache_ttl(umo),
|
||||
max_concurrency=self._get_model_lookup_concurrency(umo),
|
||||
)
|
||||
|
||||
def _resolve_model_name(
|
||||
self,
|
||||
model_name: str,
|
||||
models: Sequence[str],
|
||||
) -> str | None:
|
||||
"""Resolve model name with precedence:
|
||||
exact > case-insensitive > provider-qualified suffix.
|
||||
"""
|
||||
requested = model_name.strip()
|
||||
if not requested:
|
||||
return None
|
||||
|
||||
requested_norm = requested.casefold()
|
||||
|
||||
# exact / case-insensitive match
|
||||
for candidate in models:
|
||||
if candidate == requested or candidate.casefold() == requested_norm:
|
||||
return candidate
|
||||
|
||||
# provider-qualified suffix match:
|
||||
# e.g. candidate `openai/gpt-4o` should match requested `gpt-4o`.
|
||||
for candidate in models:
|
||||
cand_norm = candidate.casefold()
|
||||
if cand_norm.endswith(f"/{requested_norm}") or cand_norm.endswith(
|
||||
f":{requested_norm}"
|
||||
):
|
||||
return candidate
|
||||
|
||||
return None
|
||||
|
||||
def _apply_model(
|
||||
self, prov: Provider, model_name: str, *, umo: str | None = None
|
||||
) -> str:
|
||||
prov.set_model(model_name)
|
||||
self.invalidate_provider_models_cache(prov.meta().id, umo=umo)
|
||||
return f"切换模型成功。当前提供商: [{prov.meta().id}] 当前模型: [{prov.get_model()}]"
|
||||
|
||||
async def _get_provider_models(
|
||||
self,
|
||||
provider: Provider,
|
||||
*,
|
||||
config: _ModelLookupConfig,
|
||||
use_cache: bool = True,
|
||||
) -> list[str]:
|
||||
provider_id = provider.meta().id
|
||||
ttl_seconds = config.cache_ttl_seconds
|
||||
umo = config.umo
|
||||
if use_cache:
|
||||
cached = self._model_cache.get(provider_id, umo, ttl_seconds)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
models = list(await provider.get_models())
|
||||
if use_cache:
|
||||
self._model_cache.set(provider_id, umo, models, ttl_seconds)
|
||||
return models
|
||||
|
||||
async def _get_models_or_reply_error(
|
||||
self,
|
||||
message: AstrMessageEvent,
|
||||
prov: Provider,
|
||||
config: _ModelLookupConfig,
|
||||
*,
|
||||
error_prefix: str,
|
||||
disable_t2i: bool = False,
|
||||
warning_log: str | None = None,
|
||||
) -> list[str] | None:
|
||||
try:
|
||||
return await self._get_provider_models(prov, config=config)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as e:
|
||||
if warning_log is not None:
|
||||
logger.warning(
|
||||
warning_log,
|
||||
prov.meta().id,
|
||||
safe_error("", e),
|
||||
)
|
||||
result = MessageEventResult().message(safe_error(error_prefix, e))
|
||||
if disable_t2i:
|
||||
result = result.use_t2i(False)
|
||||
message.set_result(result)
|
||||
return None
|
||||
|
||||
def _log_reachability_failure(
|
||||
self,
|
||||
provider,
|
||||
provider_capability_type: ProviderType | None,
|
||||
err_code: str,
|
||||
err_reason: str,
|
||||
) -> None:
|
||||
"""记录不可达原因到日志。"""
|
||||
meta = provider.meta()
|
||||
logger.warning(
|
||||
"Provider reachability check failed: id=%s type=%s code=%s reason=%s",
|
||||
meta.id,
|
||||
provider_capability_type.name if provider_capability_type else "unknown",
|
||||
err_code,
|
||||
err_reason,
|
||||
)
|
||||
|
||||
async def _test_provider_capability(self, provider):
|
||||
"""测试单个 provider 的可用性"""
|
||||
meta = provider.meta()
|
||||
provider_capability_type = meta.provider_type
|
||||
|
||||
try:
|
||||
await provider.test()
|
||||
return True, None, None
|
||||
except Exception as e:
|
||||
err_code = "TEST_FAILED"
|
||||
err_reason = safe_error("", e)
|
||||
self._log_reachability_failure(
|
||||
provider, provider_capability_type, err_code, err_reason
|
||||
)
|
||||
return False, err_code, err_reason
|
||||
|
||||
async def _find_provider_for_model(
|
||||
self,
|
||||
model_name: str,
|
||||
*,
|
||||
exclude_provider_id: str | None = None,
|
||||
config: _ModelLookupConfig,
|
||||
use_cache: bool = True,
|
||||
) -> tuple[Provider | None, str | None]:
|
||||
all_providers = []
|
||||
for provider in self.context.get_all_providers():
|
||||
provider_meta = provider.meta()
|
||||
if provider_meta.provider_type != ProviderType.CHAT_COMPLETION:
|
||||
continue
|
||||
if (
|
||||
exclude_provider_id is not None
|
||||
and provider_meta.id == exclude_provider_id
|
||||
):
|
||||
continue
|
||||
all_providers.append(provider)
|
||||
if not all_providers:
|
||||
return None, None
|
||||
|
||||
semaphore = asyncio.Semaphore(config.max_concurrency)
|
||||
|
||||
async def fetch_models(
|
||||
provider: Provider,
|
||||
) -> tuple[Provider, list[str] | None, str | None]:
|
||||
async with semaphore:
|
||||
try:
|
||||
models = await self._get_provider_models(
|
||||
provider,
|
||||
config=config,
|
||||
use_cache=use_cache,
|
||||
)
|
||||
return provider, models, None
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as e:
|
||||
err = safe_error("", e)
|
||||
logger.debug(
|
||||
"跨提供商查找模型 %s 获取 %s 模型列表失败: %s",
|
||||
model_name,
|
||||
provider.meta().id,
|
||||
err,
|
||||
)
|
||||
return provider, None, err
|
||||
|
||||
results = await asyncio.gather(
|
||||
*(fetch_models(provider) for provider in all_providers)
|
||||
)
|
||||
failed_provider_errors: list[tuple[str, str]] = []
|
||||
for provider, models, err in results:
|
||||
if err is not None:
|
||||
failed_provider_errors.append((provider.meta().id, err))
|
||||
continue
|
||||
if models is None:
|
||||
continue
|
||||
|
||||
matched_model_name = self._resolve_model_name(model_name, models)
|
||||
if matched_model_name is not None:
|
||||
return provider, matched_model_name
|
||||
|
||||
if failed_provider_errors and len(failed_provider_errors) == len(all_providers):
|
||||
failed_ids = ",".join(
|
||||
provider_id for provider_id, _ in failed_provider_errors
|
||||
)
|
||||
logger.error(
|
||||
"跨提供商查找模型 %s 时,所有 %d 个提供商的 get_models() 均失败: %s。请检查配置或网络",
|
||||
model_name,
|
||||
len(all_providers),
|
||||
failed_ids,
|
||||
)
|
||||
elif failed_provider_errors:
|
||||
logger.debug(
|
||||
"跨提供商查找模型 %s 时有 %d 个提供商获取模型失败: %s",
|
||||
model_name,
|
||||
len(failed_provider_errors),
|
||||
",".join(
|
||||
f"{provider_id}({error})"
|
||||
for provider_id, error in failed_provider_errors
|
||||
),
|
||||
)
|
||||
return None, None
|
||||
|
||||
async def provider(
|
||||
self,
|
||||
event: AstrMessageEvent,
|
||||
idx: str | int | None = None,
|
||||
idx2: int | None = None,
|
||||
) -> None:
|
||||
"""查看或者切换 LLM Provider"""
|
||||
umo = event.unified_msg_origin
|
||||
cfg = self.context.get_config(umo).get("provider_settings", {})
|
||||
reachability_check_enabled = cfg.get("reachability_check", True)
|
||||
|
||||
if idx is None:
|
||||
parts = ["## 载入的 LLM 提供商\n"]
|
||||
|
||||
# 获取所有类型的提供商
|
||||
llms = list(self.context.get_all_providers())
|
||||
ttss = self.context.get_all_tts_providers()
|
||||
stts = self.context.get_all_stt_providers()
|
||||
|
||||
# 构造待检测列表: [(provider, type_label), ...]
|
||||
all_providers = []
|
||||
all_providers.extend([(p, "llm") for p in llms])
|
||||
all_providers.extend([(p, "tts") for p in ttss])
|
||||
all_providers.extend([(p, "stt") for p in stts])
|
||||
|
||||
# 并发测试连通性
|
||||
if reachability_check_enabled:
|
||||
if all_providers:
|
||||
await event.send(
|
||||
MessageEventResult().message(
|
||||
"正在进行提供商可达性测试,请稍候..."
|
||||
)
|
||||
)
|
||||
check_results = await asyncio.gather(
|
||||
*[self._test_provider_capability(p) for p, _ in all_providers],
|
||||
return_exceptions=True,
|
||||
)
|
||||
else:
|
||||
# 用 None 表示未检测
|
||||
check_results = [None for _ in all_providers]
|
||||
|
||||
# 整合结果
|
||||
display_data = []
|
||||
for (p, p_type), reachable in zip(all_providers, check_results):
|
||||
meta = p.meta()
|
||||
id_ = meta.id
|
||||
error_code = None
|
||||
|
||||
if isinstance(reachable, asyncio.CancelledError):
|
||||
raise reachable
|
||||
if isinstance(reachable, Exception):
|
||||
# 异常情况下兜底处理,避免单个 provider 导致列表失败
|
||||
self._log_reachability_failure(
|
||||
p,
|
||||
None,
|
||||
reachable.__class__.__name__,
|
||||
safe_error("", reachable),
|
||||
)
|
||||
reachable_flag = False
|
||||
error_code = reachable.__class__.__name__
|
||||
elif isinstance(reachable, tuple):
|
||||
reachable_flag, error_code, _ = reachable
|
||||
else:
|
||||
reachable_flag = reachable
|
||||
|
||||
# 根据类型构建显示名称
|
||||
if p_type == "llm":
|
||||
info = f"{id_} ({meta.model})"
|
||||
else:
|
||||
info = f"{id_}"
|
||||
|
||||
# 确定状态标记
|
||||
if reachable_flag is True:
|
||||
mark = " ✅"
|
||||
elif reachable_flag is False:
|
||||
if error_code:
|
||||
mark = f" ❌(错误码: {error_code})"
|
||||
else:
|
||||
mark = " ❌"
|
||||
else:
|
||||
mark = "" # 不支持检测时不显示标记
|
||||
|
||||
display_data.append(
|
||||
{
|
||||
"type": p_type,
|
||||
"info": info,
|
||||
"mark": mark,
|
||||
"provider": p,
|
||||
}
|
||||
)
|
||||
|
||||
# 分组输出
|
||||
# 1. LLM
|
||||
llm_data = [d for d in display_data if d["type"] == "llm"]
|
||||
for i, d in enumerate(llm_data):
|
||||
line = f"{i + 1}. {d['info']}{d['mark']}"
|
||||
provider_using = self.context.get_using_provider(umo=umo)
|
||||
if (
|
||||
provider_using
|
||||
and provider_using.meta().id == d["provider"].meta().id
|
||||
):
|
||||
line += " (当前使用)"
|
||||
parts.append(line + "\n")
|
||||
|
||||
# 2. TTS
|
||||
tts_data = [d for d in display_data if d["type"] == "tts"]
|
||||
if tts_data:
|
||||
parts.append("\n## 载入的 TTS 提供商\n")
|
||||
for i, d in enumerate(tts_data):
|
||||
line = f"{i + 1}. {d['info']}{d['mark']}"
|
||||
tts_using = self.context.get_using_tts_provider(umo=umo)
|
||||
if tts_using and tts_using.meta().id == d["provider"].meta().id:
|
||||
line += " (当前使用)"
|
||||
parts.append(line + "\n")
|
||||
|
||||
# 3. STT
|
||||
stt_data = [d for d in display_data if d["type"] == "stt"]
|
||||
if stt_data:
|
||||
parts.append("\n## 载入的 STT 提供商\n")
|
||||
for i, d in enumerate(stt_data):
|
||||
line = f"{i + 1}. {d['info']}{d['mark']}"
|
||||
stt_using = self.context.get_using_stt_provider(umo=umo)
|
||||
if stt_using and stt_using.meta().id == d["provider"].meta().id:
|
||||
line += " (当前使用)"
|
||||
parts.append(line + "\n")
|
||||
|
||||
parts.append("\n使用 /provider <序号> 切换 LLM 提供商。")
|
||||
ret = "".join(parts)
|
||||
|
||||
if ttss:
|
||||
ret += "\n使用 /provider tts <序号> 切换 TTS 提供商。"
|
||||
if stts:
|
||||
ret += "\n使用 /provider stt <序号> 切换 STT 提供商。"
|
||||
if not reachability_check_enabled:
|
||||
ret += "\n已跳过提供商可达性检测,如需检测请在配置文件中开启。"
|
||||
|
||||
event.set_result(MessageEventResult().message(ret))
|
||||
elif idx == "tts":
|
||||
if idx2 is None:
|
||||
event.set_result(MessageEventResult().message("请输入序号。"))
|
||||
return
|
||||
if idx2 > len(self.context.get_all_tts_providers()) or idx2 < 1:
|
||||
event.set_result(MessageEventResult().message("无效的提供商序号。"))
|
||||
return
|
||||
provider = self.context.get_all_tts_providers()[idx2 - 1]
|
||||
id_ = provider.meta().id
|
||||
await self.context.provider_manager.set_provider(
|
||||
provider_id=id_,
|
||||
provider_type=ProviderType.TEXT_TO_SPEECH,
|
||||
umo=umo,
|
||||
)
|
||||
event.set_result(MessageEventResult().message(f"成功切换到 {id_}。"))
|
||||
elif idx == "stt":
|
||||
if idx2 is None:
|
||||
event.set_result(MessageEventResult().message("请输入序号。"))
|
||||
return
|
||||
if idx2 > len(self.context.get_all_stt_providers()) or idx2 < 1:
|
||||
event.set_result(MessageEventResult().message("无效的提供商序号。"))
|
||||
return
|
||||
provider = self.context.get_all_stt_providers()[idx2 - 1]
|
||||
id_ = provider.meta().id
|
||||
await self.context.provider_manager.set_provider(
|
||||
provider_id=id_,
|
||||
provider_type=ProviderType.SPEECH_TO_TEXT,
|
||||
umo=umo,
|
||||
)
|
||||
event.set_result(MessageEventResult().message(f"成功切换到 {id_}。"))
|
||||
elif isinstance(idx, int):
|
||||
if idx > len(self.context.get_all_providers()) or idx < 1:
|
||||
event.set_result(MessageEventResult().message("无效的提供商序号。"))
|
||||
return
|
||||
provider = self.context.get_all_providers()[idx - 1]
|
||||
id_ = provider.meta().id
|
||||
await self.context.provider_manager.set_provider(
|
||||
provider_id=id_,
|
||||
provider_type=ProviderType.CHAT_COMPLETION,
|
||||
umo=umo,
|
||||
)
|
||||
event.set_result(MessageEventResult().message(f"成功切换到 {id_}。"))
|
||||
else:
|
||||
event.set_result(MessageEventResult().message("无效的参数。"))
|
||||
|
||||
async def _switch_model_by_name(
|
||||
self, message: AstrMessageEvent, model_name: str, prov: Provider
|
||||
) -> None:
|
||||
model_name = model_name.strip()
|
||||
if not model_name:
|
||||
message.set_result(MessageEventResult().message("模型名不能为空。"))
|
||||
return
|
||||
|
||||
umo = message.unified_msg_origin
|
||||
config = self._get_model_lookup_config(umo)
|
||||
curr_provider_id = prov.meta().id
|
||||
|
||||
models = await self._get_models_or_reply_error(
|
||||
message,
|
||||
prov,
|
||||
config,
|
||||
error_prefix="获取当前提供商模型列表失败: ",
|
||||
warning_log="获取当前提供商 %s 模型列表失败,停止跨提供商查找: %s",
|
||||
)
|
||||
if models is None:
|
||||
return
|
||||
|
||||
matched_model_name = self._resolve_model_name(model_name, models)
|
||||
if matched_model_name is not None:
|
||||
message.set_result(
|
||||
MessageEventResult().message(
|
||||
self._apply_model(prov, matched_model_name, umo=umo)
|
||||
),
|
||||
)
|
||||
return
|
||||
|
||||
target_prov, matched_target_model_name = await self._find_provider_for_model(
|
||||
model_name,
|
||||
exclude_provider_id=curr_provider_id,
|
||||
config=config,
|
||||
)
|
||||
|
||||
if target_prov is None or matched_target_model_name is None:
|
||||
message.set_result(
|
||||
MessageEventResult().message(
|
||||
f"模型 [{model_name}] 未在任何已配置的提供商中找到,或所有提供商模型列表获取失败,请检查配置或网络后重试。",
|
||||
),
|
||||
)
|
||||
return
|
||||
|
||||
target_id = target_prov.meta().id
|
||||
try:
|
||||
await self.context.provider_manager.set_provider(
|
||||
provider_id=target_id,
|
||||
provider_type=ProviderType.CHAT_COMPLETION,
|
||||
umo=umo,
|
||||
)
|
||||
self._apply_model(target_prov, matched_target_model_name, umo=umo)
|
||||
message.set_result(
|
||||
MessageEventResult().message(
|
||||
f"检测到模型 [{matched_target_model_name}] 属于提供商 [{target_id}],已自动切换提供商并设置模型。",
|
||||
),
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as e:
|
||||
message.set_result(
|
||||
MessageEventResult().message(
|
||||
safe_error("跨提供商切换并设置模型失败: ", e)
|
||||
),
|
||||
)
|
||||
|
||||
async def model_ls(
|
||||
self,
|
||||
message: AstrMessageEvent,
|
||||
idx_or_name: int | str | None = None,
|
||||
) -> None:
|
||||
"""查看或者切换模型"""
|
||||
prov = self.context.get_using_provider(message.unified_msg_origin)
|
||||
if not prov:
|
||||
message.set_result(
|
||||
MessageEventResult().message("未找到任何 LLM 提供商。请先配置。"),
|
||||
)
|
||||
return
|
||||
config = self._get_model_lookup_config(message.unified_msg_origin)
|
||||
|
||||
if idx_or_name is None:
|
||||
models = await self._get_models_or_reply_error(
|
||||
message,
|
||||
prov,
|
||||
config,
|
||||
error_prefix="获取模型列表失败: ",
|
||||
disable_t2i=True,
|
||||
)
|
||||
if models is None:
|
||||
return
|
||||
parts = ["下面列出了此模型提供商可用模型:"]
|
||||
for i, model in enumerate(models, 1):
|
||||
parts.append(f"\n{i}. {model}")
|
||||
|
||||
curr_model = prov.get_model() or "无"
|
||||
parts.append(f"\n当前模型: [{curr_model}]")
|
||||
parts.append(
|
||||
"\nTips: 使用 /model <模型名/编号> 切换模型。输入模型名时可自动跨提供商查找并切换;跨提供商也可使用 /provider 切换。"
|
||||
)
|
||||
|
||||
ret = "".join(parts)
|
||||
message.set_result(MessageEventResult().message(ret).use_t2i(False))
|
||||
elif isinstance(idx_or_name, int):
|
||||
models = await self._get_models_or_reply_error(
|
||||
message,
|
||||
prov,
|
||||
config,
|
||||
error_prefix="获取模型列表失败: ",
|
||||
)
|
||||
if models is None:
|
||||
return
|
||||
if idx_or_name > len(models) or idx_or_name < 1:
|
||||
message.set_result(MessageEventResult().message("模型序号错误。"))
|
||||
else:
|
||||
try:
|
||||
new_model = models[idx_or_name - 1]
|
||||
message.set_result(
|
||||
MessageEventResult().message(
|
||||
self._apply_model(
|
||||
prov,
|
||||
new_model,
|
||||
umo=message.unified_msg_origin,
|
||||
)
|
||||
),
|
||||
)
|
||||
except Exception as e:
|
||||
message.set_result(
|
||||
MessageEventResult().message(
|
||||
safe_error("切换模型未知错误: ", e)
|
||||
),
|
||||
)
|
||||
return
|
||||
else:
|
||||
await self._switch_model_by_name(message, idx_or_name, prov)
|
||||
|
||||
async def key(self, message: AstrMessageEvent, index: int | None = None) -> None:
|
||||
prov = self.context.get_using_provider(message.unified_msg_origin)
|
||||
if not prov:
|
||||
message.set_result(
|
||||
MessageEventResult().message("未找到任何 LLM 提供商。请先配置。"),
|
||||
)
|
||||
return
|
||||
|
||||
if index is None:
|
||||
keys_data = prov.get_keys()
|
||||
curr_key = prov.get_current_key()
|
||||
parts = ["Key:"]
|
||||
for i, k in enumerate(keys_data, 1):
|
||||
parts.append(f"\n{i}. {k[:8]}")
|
||||
|
||||
parts.append(f"\n当前 Key: {curr_key[:8]}")
|
||||
parts.append("\n当前模型: " + prov.get_model())
|
||||
parts.append("\n使用 /key <idx> 切换 Key。")
|
||||
|
||||
ret = "".join(parts)
|
||||
message.set_result(MessageEventResult().message(ret).use_t2i(False))
|
||||
else:
|
||||
keys_data = prov.get_keys()
|
||||
if index > len(keys_data) or index < 1:
|
||||
message.set_result(MessageEventResult().message("Key 序号错误。"))
|
||||
else:
|
||||
try:
|
||||
new_key = keys_data[index - 1]
|
||||
prov.set_key(new_key)
|
||||
self.invalidate_provider_models_cache(
|
||||
prov.meta().id,
|
||||
umo=message.unified_msg_origin,
|
||||
)
|
||||
message.set_result(MessageEventResult().message("切换 Key 成功。"))
|
||||
except Exception as e:
|
||||
message.set_result(
|
||||
MessageEventResult().message(
|
||||
safe_error("切换 Key 未知错误: ", e)
|
||||
),
|
||||
)
|
||||
return
|
||||
@@ -1,8 +1,6 @@
|
||||
from astrbot.api import sp, star
|
||||
from astrbot.api.event import AstrMessageEvent, MessageEventResult
|
||||
|
||||
from ..i18n import t
|
||||
|
||||
|
||||
class SetUnsetCommands:
|
||||
def __init__(self, context: star.Context) -> None:
|
||||
@@ -17,12 +15,7 @@ class SetUnsetCommands:
|
||||
|
||||
event.set_result(
|
||||
MessageEventResult().message(
|
||||
t(
|
||||
self.context,
|
||||
"setunset.set_success",
|
||||
session_id=uid,
|
||||
key=key,
|
||||
),
|
||||
f"会话 {uid} 变量 {key} 存储成功。使用 /unset 移除。",
|
||||
),
|
||||
)
|
||||
|
||||
@@ -33,20 +26,11 @@ class SetUnsetCommands:
|
||||
|
||||
if key not in session_var:
|
||||
event.set_result(
|
||||
MessageEventResult().message(
|
||||
t(self.context, "setunset.unset_not_found")
|
||||
),
|
||||
MessageEventResult().message("没有那个变量名。格式 /unset 变量名。"),
|
||||
)
|
||||
else:
|
||||
del session_var[key]
|
||||
await sp.session_put(uid, "session_variables", session_var)
|
||||
event.set_result(
|
||||
MessageEventResult().message(
|
||||
t(
|
||||
self.context,
|
||||
"setunset.unset_success",
|
||||
session_id=uid,
|
||||
key=key,
|
||||
),
|
||||
),
|
||||
MessageEventResult().message(f"会话 {uid} 变量 {key} 移除成功。"),
|
||||
)
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
from astrbot.api import star
|
||||
from astrbot.api.event import AstrMessageEvent, MessageEventResult
|
||||
|
||||
from ..i18n import t
|
||||
|
||||
|
||||
class SIDCommand:
|
||||
"""会话ID命令类"""
|
||||
@@ -19,24 +17,20 @@ class SIDCommand:
|
||||
umo_platform = event.session.platform_id
|
||||
umo_msg_type = event.session.message_type.value
|
||||
umo_session_id = event.session.session_id
|
||||
ret = t(
|
||||
self.context,
|
||||
"sid.info",
|
||||
sid=sid,
|
||||
user_id=user_id,
|
||||
platform=umo_platform,
|
||||
message_type=umo_msg_type,
|
||||
session_id=umo_session_id,
|
||||
ret = (
|
||||
f"UMO: 「{sid}」 此值可用于设置白名单。\n"
|
||||
f"UID: 「{user_id}」 此值可用于设置管理员。\n"
|
||||
f"消息会话来源信息:\n"
|
||||
f" 机器人 ID: 「{umo_platform}」\n"
|
||||
f" 消息类型: 「{umo_msg_type}」\n"
|
||||
f" 会话 ID: 「{umo_session_id}」\n"
|
||||
f"消息来源可用于配置机器人的配置文件路由。"
|
||||
)
|
||||
|
||||
if (
|
||||
self.context.get_config()["platform_settings"]["unique_session"]
|
||||
and event.get_group_id()
|
||||
):
|
||||
ret += t(
|
||||
self.context,
|
||||
"sid.group_whitelist",
|
||||
group_id=event.get_group_id(),
|
||||
)
|
||||
ret += f"\n\n当前处于独立会话模式, 此群 ID: 「{event.get_group_id()}」, 也可将此 ID 加入白名单来放行整个群聊。"
|
||||
|
||||
event.set_result(MessageEventResult().message(ret).use_t2i(False))
|
||||
|
||||
23
astrbot/builtin_stars/builtin_commands/commands/t2i.py
Normal file
23
astrbot/builtin_stars/builtin_commands/commands/t2i.py
Normal file
@@ -0,0 +1,23 @@
|
||||
"""文本转图片命令"""
|
||||
|
||||
from astrbot.api import star
|
||||
from astrbot.api.event import AstrMessageEvent, MessageEventResult
|
||||
|
||||
|
||||
class T2ICommand:
|
||||
"""文本转图片命令类"""
|
||||
|
||||
def __init__(self, context: star.Context) -> None:
|
||||
self.context = context
|
||||
|
||||
async def t2i(self, event: AstrMessageEvent) -> None:
|
||||
"""开关文本转图片"""
|
||||
config = self.context.get_config(umo=event.unified_msg_origin)
|
||||
if config["t2i"]:
|
||||
config["t2i"] = False
|
||||
config.save_config()
|
||||
event.set_result(MessageEventResult().message("已关闭文本转图片模式。"))
|
||||
return
|
||||
config["t2i"] = True
|
||||
config.save_config()
|
||||
event.set_result(MessageEventResult().message("已开启文本转图片模式。"))
|
||||
36
astrbot/builtin_stars/builtin_commands/commands/tts.py
Normal file
36
astrbot/builtin_stars/builtin_commands/commands/tts.py
Normal file
@@ -0,0 +1,36 @@
|
||||
"""文本转语音命令"""
|
||||
|
||||
from astrbot.api import star
|
||||
from astrbot.api.event import AstrMessageEvent, MessageEventResult
|
||||
from astrbot.core.star.session_llm_manager import SessionServiceManager
|
||||
|
||||
|
||||
class TTSCommand:
|
||||
"""文本转语音命令类"""
|
||||
|
||||
def __init__(self, context: star.Context) -> None:
|
||||
self.context = context
|
||||
|
||||
async def tts(self, event: AstrMessageEvent) -> None:
|
||||
"""开关文本转语音(会话级别)"""
|
||||
umo = event.unified_msg_origin
|
||||
ses_tts = await SessionServiceManager.is_tts_enabled_for_session(umo)
|
||||
cfg = self.context.get_config(umo=umo)
|
||||
tts_enable = cfg["provider_tts_settings"]["enable"]
|
||||
|
||||
# 切换状态
|
||||
new_status = not ses_tts
|
||||
await SessionServiceManager.set_tts_status_for_session(umo, new_status)
|
||||
|
||||
status_text = "已开启" if new_status else "已关闭"
|
||||
|
||||
if new_status and not tts_enable:
|
||||
event.set_result(
|
||||
MessageEventResult().message(
|
||||
f"{status_text}当前会话的文本转语音。但 TTS 功能在配置中未启用,请前往 WebUI 开启。",
|
||||
),
|
||||
)
|
||||
else:
|
||||
event.set_result(
|
||||
MessageEventResult().message(f"{status_text}当前会话的文本转语音。"),
|
||||
)
|
||||
@@ -1,30 +0,0 @@
|
||||
import json
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
LOCALE_DIR = Path(__file__).resolve().parent / "locales"
|
||||
|
||||
|
||||
@lru_cache(maxsize=2)
|
||||
def _load_locale(language: str) -> dict[str, Any]:
|
||||
with (LOCALE_DIR / f"{language}.json").open(encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def _resolve_key(data: dict[str, Any], translation_key: str) -> Any:
|
||||
value: Any = data
|
||||
for part in translation_key.split("."):
|
||||
if not isinstance(value, dict) or part not in value:
|
||||
return None
|
||||
value = value[part]
|
||||
return value
|
||||
|
||||
|
||||
def t(context: Any, translation_key: str, **kwargs: Any) -> str:
|
||||
text = _resolve_key(_load_locale(context.get_current_language()), translation_key)
|
||||
if not isinstance(text, str):
|
||||
return translation_key
|
||||
if not kwargs:
|
||||
return text
|
||||
return text.format(**kwargs)
|
||||
@@ -1,33 +0,0 @@
|
||||
{
|
||||
"help": {
|
||||
"no_enabled_builtin_commands": "No enabled built-in commands."
|
||||
},
|
||||
"sid": {
|
||||
"info": "UMO: 「{sid}」\nUID: 「{user_id}」\n*Use UMO to set whitelist and configure routing, use UID to set admin list.\n\nYour session information:\nBot ID: 「{platform}」\nMessage Type: 「{message_type}」\nSession ID: 「{session_id}」\n\n",
|
||||
"group_whitelist": "\n\nThe group's ID: 「{group_id}」. Set this ID to whitelist to allow the entire group."
|
||||
},
|
||||
"dashboard": {
|
||||
"updating": "⏳ Updating dashboard...",
|
||||
"updated": "✅ Dashboard updated successfully."
|
||||
},
|
||||
"scene": {
|
||||
"group_unique_on": "group chat with unique session enabled",
|
||||
"group_unique_off": "group chat with unique session disabled",
|
||||
"private": "private chat"
|
||||
},
|
||||
"conversation": {
|
||||
"reset_admin_required": "Reset command requires admin permission in {scene_name} scenario, you (ID {sender_id}) are not admin, cannot perform this action.",
|
||||
"reset_success": "✅ Conversation reset successfully.",
|
||||
"no_provider": "😕 Cannot find any LLM provider. Configure one first.",
|
||||
"no_conversation": "😕 You are not in a conversation. Use /new to create one.",
|
||||
"stop_requested": "✅ Requested to stop {count} running tasks.",
|
||||
"no_running_tasks": "✅ No running tasks in the current session.",
|
||||
"new_created": "✅ New conversation created.",
|
||||
"switched_new": "✅ Switched to new conversation: {conversation_id}."
|
||||
},
|
||||
"setunset": {
|
||||
"set_success": "Session {session_id} variable {key} saved. Use /unset to remove it.",
|
||||
"unset_not_found": "No variable with that name. Format: /unset variable_name.",
|
||||
"unset_success": "Session {session_id} variable {key} removed."
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
{
|
||||
"help": {
|
||||
"no_enabled_builtin_commands": "没有已启用的内置指令。"
|
||||
},
|
||||
"sid": {
|
||||
"info": "UMO: 「{sid}」\nUID: 「{user_id}」\n*使用 UMO 设置白名单和配置文件路由,使用 UID 设置管理员列表。\n\n当前会话信息:\n机器人 ID: 「{platform}」\n消息类型: 「{message_type}」\n会话 ID: 「{session_id}」\n\n",
|
||||
"group_whitelist": "\n\n当前群聊 ID: 「{group_id}」。将此 ID 加入白名单可允许整个群聊。"
|
||||
},
|
||||
"dashboard": {
|
||||
"updating": "⏳ 正在更新管理面板...",
|
||||
"updated": "✅ 管理面板更新成功。"
|
||||
},
|
||||
"scene": {
|
||||
"group_unique_on": "群聊+会话隔离开启",
|
||||
"group_unique_off": "群聊+会话隔离关闭",
|
||||
"private": "私聊"
|
||||
},
|
||||
"conversation": {
|
||||
"reset_admin_required": "在 {scene_name} 场景下,reset 指令需要管理员权限。你(ID {sender_id})不是管理员,无法执行此操作。",
|
||||
"reset_success": "✅ 会话已重置。",
|
||||
"no_provider": "😕 未找到可用的 LLM 提供商,请先配置。",
|
||||
"no_conversation": "😕 当前未处于对话状态。请使用 /new 创建一个对话。",
|
||||
"stop_requested": "✅ 已请求停止 {count} 个正在运行的任务。",
|
||||
"no_running_tasks": "✅ 当前会话没有正在运行的任务。",
|
||||
"new_created": "✅ 已创建新对话。",
|
||||
"switched_new": "✅ 已切换到新对话:{conversation_id}。"
|
||||
},
|
||||
"setunset": {
|
||||
"set_success": "会话 {session_id} 变量 {key} 存储成功。使用 /unset 移除。",
|
||||
"unset_not_found": "没有那个变量名。格式 /unset 变量名。",
|
||||
"unset_success": "会话 {session_id} 变量 {key} 移除成功。"
|
||||
}
|
||||
}
|
||||
@@ -3,10 +3,17 @@ from astrbot.api.event import AstrMessageEvent, filter
|
||||
|
||||
from .commands import (
|
||||
AdminCommands,
|
||||
AlterCmdCommands,
|
||||
ConversationCommands,
|
||||
HelpCommand,
|
||||
LLMCommands,
|
||||
PersonaCommands,
|
||||
PluginCommands,
|
||||
ProviderCommands,
|
||||
SetUnsetCommands,
|
||||
SIDCommand,
|
||||
T2ICommand,
|
||||
TTSCommand,
|
||||
)
|
||||
|
||||
|
||||
@@ -14,49 +21,198 @@ class Main(star.Star):
|
||||
def __init__(self, context: star.Context) -> None:
|
||||
self.context = context
|
||||
|
||||
self.help_c = HelpCommand(self.context)
|
||||
self.llm_c = LLMCommands(self.context)
|
||||
self.plugin_c = PluginCommands(self.context)
|
||||
self.admin_c = AdminCommands(self.context)
|
||||
self.conversation_c = ConversationCommands(self.context)
|
||||
self.help_c = HelpCommand(self.context)
|
||||
self.provider_c = ProviderCommands(self.context)
|
||||
self.persona_c = PersonaCommands(self.context)
|
||||
self.alter_cmd_c = AlterCmdCommands(self.context)
|
||||
self.setunset_c = SetUnsetCommands(self.context)
|
||||
self.t2i_c = T2ICommand(self.context)
|
||||
self.tts_c = TTSCommand(self.context)
|
||||
self.sid_c = SIDCommand(self.context)
|
||||
|
||||
@filter.command("help")
|
||||
async def help(self, event: AstrMessageEvent) -> None:
|
||||
"""Show help message"""
|
||||
"""查看帮助"""
|
||||
await self.help_c.help(event)
|
||||
|
||||
@filter.permission_type(filter.PermissionType.ADMIN)
|
||||
@filter.command("llm")
|
||||
async def llm(self, event: AstrMessageEvent) -> None:
|
||||
"""开启/关闭 LLM"""
|
||||
await self.llm_c.llm(event)
|
||||
|
||||
@filter.command_group("plugin")
|
||||
def plugin(self) -> None:
|
||||
"""插件管理"""
|
||||
|
||||
@plugin.command("ls")
|
||||
async def plugin_ls(self, event: AstrMessageEvent) -> None:
|
||||
"""获取已经安装的插件列表。"""
|
||||
await self.plugin_c.plugin_ls(event)
|
||||
|
||||
@filter.permission_type(filter.PermissionType.ADMIN)
|
||||
@plugin.command("off")
|
||||
async def plugin_off(self, event: AstrMessageEvent, plugin_name: str = "") -> None:
|
||||
"""禁用插件"""
|
||||
await self.plugin_c.plugin_off(event, plugin_name)
|
||||
|
||||
@filter.permission_type(filter.PermissionType.ADMIN)
|
||||
@plugin.command("on")
|
||||
async def plugin_on(self, event: AstrMessageEvent, plugin_name: str = "") -> None:
|
||||
"""启用插件"""
|
||||
await self.plugin_c.plugin_on(event, plugin_name)
|
||||
|
||||
@filter.permission_type(filter.PermissionType.ADMIN)
|
||||
@plugin.command("get")
|
||||
async def plugin_get(self, event: AstrMessageEvent, plugin_repo: str = "") -> None:
|
||||
"""安装插件"""
|
||||
await self.plugin_c.plugin_get(event, plugin_repo)
|
||||
|
||||
@plugin.command("help")
|
||||
async def plugin_help(self, event: AstrMessageEvent, plugin_name: str = "") -> None:
|
||||
"""获取插件帮助"""
|
||||
await self.plugin_c.plugin_help(event, plugin_name)
|
||||
|
||||
@filter.command("t2i")
|
||||
async def t2i(self, event: AstrMessageEvent) -> None:
|
||||
"""开关文本转图片"""
|
||||
await self.t2i_c.t2i(event)
|
||||
|
||||
@filter.command("tts")
|
||||
async def tts(self, event: AstrMessageEvent) -> None:
|
||||
"""开关文本转语音(会话级别)"""
|
||||
await self.tts_c.tts(event)
|
||||
|
||||
@filter.command("sid")
|
||||
async def sid(self, event: AstrMessageEvent) -> None:
|
||||
"""Get session ID and other related information"""
|
||||
"""获取会话 ID 和 管理员 ID"""
|
||||
await self.sid_c.sid(event)
|
||||
|
||||
@filter.permission_type(filter.PermissionType.ADMIN)
|
||||
@filter.command("op")
|
||||
async def op(self, event: AstrMessageEvent, admin_id: str = "") -> None:
|
||||
"""授权管理员。op <admin_id>"""
|
||||
await self.admin_c.op(event, admin_id)
|
||||
|
||||
@filter.permission_type(filter.PermissionType.ADMIN)
|
||||
@filter.command("deop")
|
||||
async def deop(self, event: AstrMessageEvent, admin_id: str) -> None:
|
||||
"""取消授权管理员。deop <admin_id>"""
|
||||
await self.admin_c.deop(event, admin_id)
|
||||
|
||||
@filter.permission_type(filter.PermissionType.ADMIN)
|
||||
@filter.command("wl")
|
||||
async def wl(self, event: AstrMessageEvent, sid: str = "") -> None:
|
||||
"""添加白名单。wl <sid>"""
|
||||
await self.admin_c.wl(event, sid)
|
||||
|
||||
@filter.permission_type(filter.PermissionType.ADMIN)
|
||||
@filter.command("dwl")
|
||||
async def dwl(self, event: AstrMessageEvent, sid: str) -> None:
|
||||
"""删除白名单。dwl <sid>"""
|
||||
await self.admin_c.dwl(event, sid)
|
||||
|
||||
@filter.permission_type(filter.PermissionType.ADMIN)
|
||||
@filter.command("provider")
|
||||
async def provider(
|
||||
self,
|
||||
event: AstrMessageEvent,
|
||||
idx: str | int | None = None,
|
||||
idx2: int | None = None,
|
||||
) -> None:
|
||||
"""查看或者切换 LLM Provider"""
|
||||
await self.provider_c.provider(event, idx, idx2)
|
||||
|
||||
@filter.command("reset")
|
||||
async def reset(self, message: AstrMessageEvent) -> None:
|
||||
"""Reset conversation history"""
|
||||
"""重置 LLM 会话"""
|
||||
await self.conversation_c.reset(message)
|
||||
|
||||
@filter.command("stop")
|
||||
async def stop(self, message: AstrMessageEvent) -> None:
|
||||
"""Stop agent execution"""
|
||||
"""停止当前会话中正在运行的 Agent"""
|
||||
await self.conversation_c.stop(message)
|
||||
|
||||
@filter.permission_type(filter.PermissionType.ADMIN)
|
||||
@filter.command("model")
|
||||
async def model_ls(
|
||||
self,
|
||||
message: AstrMessageEvent,
|
||||
idx_or_name: int | str | None = None,
|
||||
) -> None:
|
||||
"""查看或者切换模型"""
|
||||
await self.provider_c.model_ls(message, idx_or_name)
|
||||
|
||||
@filter.command("history")
|
||||
async def his(self, message: AstrMessageEvent, page: int = 1) -> None:
|
||||
"""查看对话记录"""
|
||||
await self.conversation_c.his(message, page)
|
||||
|
||||
@filter.command("ls")
|
||||
async def convs(self, message: AstrMessageEvent, page: int = 1) -> None:
|
||||
"""查看对话列表"""
|
||||
await self.conversation_c.convs(message, page)
|
||||
|
||||
@filter.command("new")
|
||||
async def new_conv(self, message: AstrMessageEvent) -> None:
|
||||
"""Create new conversation"""
|
||||
"""创建新对话"""
|
||||
await self.conversation_c.new_conv(message)
|
||||
|
||||
@filter.permission_type(filter.PermissionType.ADMIN)
|
||||
@filter.command("groupnew")
|
||||
async def groupnew_conv(self, message: AstrMessageEvent, sid: str) -> None:
|
||||
"""创建新群聊对话"""
|
||||
await self.conversation_c.groupnew_conv(message, sid)
|
||||
|
||||
@filter.command("switch")
|
||||
async def switch_conv(
|
||||
self, message: AstrMessageEvent, index: int | None = None
|
||||
) -> None:
|
||||
"""通过 /ls 前面的序号切换对话"""
|
||||
await self.conversation_c.switch_conv(message, index)
|
||||
|
||||
@filter.command("rename")
|
||||
async def rename_conv(self, message: AstrMessageEvent, new_name: str) -> None:
|
||||
"""重命名对话"""
|
||||
await self.conversation_c.rename_conv(message, new_name)
|
||||
|
||||
@filter.command("del")
|
||||
async def del_conv(self, message: AstrMessageEvent) -> None:
|
||||
"""删除当前对话"""
|
||||
await self.conversation_c.del_conv(message)
|
||||
|
||||
@filter.permission_type(filter.PermissionType.ADMIN)
|
||||
@filter.command("key")
|
||||
async def key(self, message: AstrMessageEvent, index: int | None = None) -> None:
|
||||
"""查看或者切换 Key"""
|
||||
await self.provider_c.key(message, index)
|
||||
|
||||
@filter.permission_type(filter.PermissionType.ADMIN)
|
||||
@filter.command("persona")
|
||||
async def persona(self, message: AstrMessageEvent) -> None:
|
||||
"""查看或者切换 Persona"""
|
||||
await self.persona_c.persona(message)
|
||||
|
||||
@filter.permission_type(filter.PermissionType.ADMIN)
|
||||
@filter.command("dashboard_update")
|
||||
async def update_dashboard(self, event: AstrMessageEvent) -> None:
|
||||
"""Update AstrBot WebUI"""
|
||||
"""更新管理面板"""
|
||||
await self.admin_c.update_dashboard(event)
|
||||
|
||||
@filter.command("set")
|
||||
async def set_variable(self, event: AstrMessageEvent, key: str, value: str) -> None:
|
||||
"""Set session variable"""
|
||||
await self.setunset_c.set_variable(event, key, value)
|
||||
|
||||
@filter.command("unset")
|
||||
async def unset_variable(self, event: AstrMessageEvent, key: str) -> None:
|
||||
"""Unset session variable"""
|
||||
await self.setunset_c.unset_variable(event, key)
|
||||
|
||||
@filter.permission_type(filter.PermissionType.ADMIN)
|
||||
@filter.command("alter_cmd", alias={"alter"})
|
||||
async def alter_cmd(self, event: AstrMessageEvent) -> None:
|
||||
"""修改命令权限"""
|
||||
await self.alter_cmd_c.alter_cmd(event)
|
||||
|
||||
@@ -91,8 +91,6 @@ class Main(Star):
|
||||
controller: SessionController,
|
||||
event: AstrMessageEvent,
|
||||
) -> None:
|
||||
if not event.message_str or not event.message_str.strip():
|
||||
return
|
||||
event.message_obj.message.insert(
|
||||
0,
|
||||
Comp.At(qq=event.get_self_id(), name=event.get_self_id()),
|
||||
|
||||
112
astrbot/builtin_stars/web_searcher/engines/__init__.py
Normal file
112
astrbot/builtin_stars/web_searcher/engines/__init__.py
Normal file
@@ -0,0 +1,112 @@
|
||||
import random
|
||||
import urllib.parse
|
||||
from dataclasses import dataclass
|
||||
|
||||
from aiohttp import ClientSession
|
||||
from bs4 import BeautifulSoup, Tag
|
||||
|
||||
HEADERS = {
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 6.1; rv:84.0) Gecko/20100101 Firefox/84.0",
|
||||
"Accept": "*/*",
|
||||
"Connection": "keep-alive",
|
||||
"Accept-Language": "en-GB,en;q=0.5",
|
||||
}
|
||||
|
||||
USER_AGENT_BING = "Mozilla/5.0 (Windows NT 6.1; rv:84.0) Gecko/20100101 Firefox/84.0"
|
||||
USER_AGENTS = [
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.131 Safari/537.36",
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36",
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:89.0) Gecko/20100101 Firefox/89.0",
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:88.0) Gecko/20100101 Firefox/88.0",
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.131 Safari/537.36",
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36",
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Version/14.1.2 Safari/537.36",
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Version/14.1 Safari/537.36",
|
||||
"Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:89.0) Gecko/20100101 Firefox/89.0",
|
||||
"Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:88.0) Gecko/20100101 Firefox/88.0",
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class SearchResult:
|
||||
title: str
|
||||
url: str
|
||||
snippet: str
|
||||
favicon: str | None = None
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.title} - {self.url}\n{self.snippet}"
|
||||
|
||||
|
||||
class SearchEngine:
|
||||
"""搜索引擎爬虫基类"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.TIMEOUT = 10
|
||||
self.page = 1
|
||||
self.headers = HEADERS
|
||||
|
||||
def _set_selector(self, selector: str) -> str:
|
||||
raise NotImplementedError
|
||||
|
||||
async def _get_next_page(self, query: str) -> str:
|
||||
raise NotImplementedError
|
||||
|
||||
async def _get_html(self, url: str, data: dict | None = None) -> str:
|
||||
headers = self.headers
|
||||
headers["Referer"] = url
|
||||
headers["User-Agent"] = random.choice(USER_AGENTS)
|
||||
if data:
|
||||
async with (
|
||||
ClientSession() as session,
|
||||
session.post(
|
||||
url,
|
||||
headers=headers,
|
||||
data=data,
|
||||
timeout=self.TIMEOUT,
|
||||
) as resp,
|
||||
):
|
||||
ret = await resp.text(encoding="utf-8")
|
||||
return ret
|
||||
else:
|
||||
async with (
|
||||
ClientSession() as session,
|
||||
session.get(
|
||||
url,
|
||||
headers=headers,
|
||||
timeout=self.TIMEOUT,
|
||||
) as resp,
|
||||
):
|
||||
ret = await resp.text(encoding="utf-8")
|
||||
return ret
|
||||
|
||||
def tidy_text(self, text: str) -> str:
|
||||
"""清理文本,去除空格、换行符等"""
|
||||
return text.strip().replace("\n", " ").replace("\r", " ").replace(" ", " ")
|
||||
|
||||
def _get_url(self, tag: Tag) -> str:
|
||||
return self.tidy_text(tag.get_text())
|
||||
|
||||
async def search(self, query: str, num_results: int) -> list[SearchResult]:
|
||||
query = urllib.parse.quote(query)
|
||||
|
||||
try:
|
||||
resp = await self._get_next_page(query)
|
||||
soup = BeautifulSoup(resp, "html.parser")
|
||||
links = soup.select(self._set_selector("links"))
|
||||
results = []
|
||||
for link in links:
|
||||
# Safely get the title text (select_one may return None)
|
||||
title_elem = link.select_one(self._set_selector("title"))
|
||||
title = ""
|
||||
if title_elem is not None:
|
||||
title = self.tidy_text(title_elem.get_text())
|
||||
|
||||
url_tag = link.select_one(self._set_selector("url"))
|
||||
snippet = ""
|
||||
if title and url_tag:
|
||||
url = self._get_url(url_tag)
|
||||
results.append(SearchResult(title=title, url=url, snippet=snippet))
|
||||
return results[:num_results] if len(results) > num_results else results
|
||||
except Exception as e:
|
||||
raise e
|
||||
30
astrbot/builtin_stars/web_searcher/engines/bing.py
Normal file
30
astrbot/builtin_stars/web_searcher/engines/bing.py
Normal file
@@ -0,0 +1,30 @@
|
||||
from . import USER_AGENT_BING, SearchEngine
|
||||
|
||||
|
||||
class Bing(SearchEngine):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.base_urls = ["https://cn.bing.com", "https://www.bing.com"]
|
||||
self.headers.update({"User-Agent": USER_AGENT_BING})
|
||||
|
||||
def _set_selector(self, selector: str):
|
||||
selectors = {
|
||||
"url": "div.b_attribution cite",
|
||||
"title": "h2",
|
||||
"text": "p",
|
||||
"links": "ol#b_results > li.b_algo",
|
||||
"next": 'div#b_content nav[role="navigation"] a.sb_pagN',
|
||||
}
|
||||
return selectors[selector]
|
||||
|
||||
async def _get_next_page(self, query) -> str:
|
||||
# if self.page == 1:
|
||||
# await self._get_html(self.base_url)
|
||||
for base_url in self.base_urls:
|
||||
try:
|
||||
url = f"{base_url}/search?q={query}"
|
||||
return await self._get_html(url, None)
|
||||
except Exception as _:
|
||||
self.base_url = base_url
|
||||
continue
|
||||
raise Exception("Bing search failed")
|
||||
52
astrbot/builtin_stars/web_searcher/engines/sogo.py
Normal file
52
astrbot/builtin_stars/web_searcher/engines/sogo.py
Normal file
@@ -0,0 +1,52 @@
|
||||
import random
|
||||
import re
|
||||
from typing import cast
|
||||
|
||||
from bs4 import BeautifulSoup, Tag
|
||||
|
||||
from . import USER_AGENTS, SearchEngine, SearchResult
|
||||
|
||||
|
||||
class Sogo(SearchEngine):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.base_url = "https://www.sogou.com"
|
||||
self.headers["User-Agent"] = random.choice(USER_AGENTS)
|
||||
|
||||
def _set_selector(self, selector: str):
|
||||
selectors = {
|
||||
"url": "h3 > a",
|
||||
"title": "h3",
|
||||
"text": "",
|
||||
"links": "div.results > div.vrwrap:not(.middle-better-hintBox)",
|
||||
"next": "",
|
||||
}
|
||||
return selectors[selector]
|
||||
|
||||
async def _get_next_page(self, query) -> str:
|
||||
url = f"{self.base_url}/web?query={query}"
|
||||
return await self._get_html(url, None)
|
||||
|
||||
def _get_url(self, tag: Tag) -> str:
|
||||
return cast(str, tag.get("href"))
|
||||
|
||||
async def search(self, query: str, num_results: int) -> list[SearchResult]:
|
||||
results = await super().search(query, num_results)
|
||||
for result in results:
|
||||
if result.url.startswith("/link?"):
|
||||
result.url = self.base_url + result.url
|
||||
result.url = await self._parse_url(result.url)
|
||||
return results
|
||||
|
||||
async def _parse_url(self, url) -> str:
|
||||
html = await self._get_html(url)
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
script = soup.find("script")
|
||||
if script:
|
||||
script_text = (
|
||||
script.string if script.string is not None else script.get_text()
|
||||
)
|
||||
match = re.search(r'window.location.replace\("(.+?)"\)', script_text)
|
||||
if match:
|
||||
url = match.group(1)
|
||||
return url
|
||||
610
astrbot/builtin_stars/web_searcher/main.py
Normal file
610
astrbot/builtin_stars/web_searcher/main.py
Normal file
@@ -0,0 +1,610 @@
|
||||
import asyncio
|
||||
import json
|
||||
import random
|
||||
import uuid
|
||||
|
||||
import aiohttp
|
||||
from bs4 import BeautifulSoup
|
||||
from readability import Document
|
||||
|
||||
from astrbot.api import AstrBotConfig, llm_tool, logger, sp, star
|
||||
from astrbot.api.event import AstrMessageEvent, filter
|
||||
from astrbot.api.provider import ProviderRequest
|
||||
from astrbot.core.provider.func_tool_manager import FunctionToolManager
|
||||
|
||||
from .engines import HEADERS, USER_AGENTS, SearchResult
|
||||
from .engines.bing import Bing
|
||||
from .engines.sogo import Sogo
|
||||
|
||||
|
||||
class Main(star.Star):
|
||||
TOOLS = [
|
||||
"web_search",
|
||||
"fetch_url",
|
||||
"web_search_tavily",
|
||||
"tavily_extract_web_page",
|
||||
"web_search_bocha",
|
||||
]
|
||||
|
||||
def __init__(self, context: star.Context) -> None:
|
||||
self.context = context
|
||||
self.tavily_key_index = 0
|
||||
self.tavily_key_lock = asyncio.Lock()
|
||||
|
||||
self.bocha_key_index = 0
|
||||
self.bocha_key_lock = asyncio.Lock()
|
||||
|
||||
# 将 str 类型的 key 迁移至 list[str],并保存
|
||||
cfg = self.context.get_config()
|
||||
provider_settings = cfg.get("provider_settings")
|
||||
if provider_settings:
|
||||
tavily_key = provider_settings.get("websearch_tavily_key")
|
||||
if isinstance(tavily_key, str):
|
||||
logger.info(
|
||||
"检测到旧版 websearch_tavily_key (字符串格式),自动迁移为列表格式并保存。",
|
||||
)
|
||||
if tavily_key:
|
||||
provider_settings["websearch_tavily_key"] = [tavily_key]
|
||||
else:
|
||||
provider_settings["websearch_tavily_key"] = []
|
||||
cfg.save_config()
|
||||
|
||||
bocha_key = provider_settings.get("websearch_bocha_key")
|
||||
if isinstance(bocha_key, str):
|
||||
if bocha_key:
|
||||
provider_settings["websearch_bocha_key"] = [bocha_key]
|
||||
else:
|
||||
provider_settings["websearch_bocha_key"] = []
|
||||
cfg.save_config()
|
||||
|
||||
self.bing_search = Bing()
|
||||
self.sogo_search = Sogo()
|
||||
self.baidu_initialized = False
|
||||
|
||||
async def _tidy_text(self, text: str) -> str:
|
||||
"""清理文本,去除空格、换行符等"""
|
||||
return text.strip().replace("\n", " ").replace("\r", " ").replace(" ", " ")
|
||||
|
||||
async def _get_from_url(self, url: str) -> str:
|
||||
"""获取网页内容"""
|
||||
header = HEADERS
|
||||
header.update({"User-Agent": random.choice(USER_AGENTS)})
|
||||
async with aiohttp.ClientSession(trust_env=True) as session:
|
||||
async with session.get(url, headers=header) as response:
|
||||
html = await response.text(encoding="utf-8")
|
||||
doc = Document(html)
|
||||
ret = doc.summary(html_partial=True)
|
||||
soup = BeautifulSoup(ret, "html.parser")
|
||||
ret = await self._tidy_text(soup.get_text())
|
||||
return ret
|
||||
|
||||
async def _process_search_result(
|
||||
self,
|
||||
result: SearchResult,
|
||||
idx: int,
|
||||
websearch_link: bool,
|
||||
) -> str:
|
||||
"""处理单个搜索结果"""
|
||||
logger.info(f"web_searcher - scraping web: {result.title} - {result.url}")
|
||||
try:
|
||||
site_result = await self._get_from_url(result.url)
|
||||
except BaseException:
|
||||
site_result = ""
|
||||
site_result = (
|
||||
f"{site_result[:700]}..." if len(site_result) > 700 else site_result
|
||||
)
|
||||
|
||||
header = f"{idx}. {result.title} "
|
||||
|
||||
if websearch_link and result.url:
|
||||
header += result.url
|
||||
|
||||
return f"{header}\n{result.snippet}\n{site_result}\n\n"
|
||||
|
||||
async def _web_search_default(
|
||||
self,
|
||||
query,
|
||||
num_results: int = 5,
|
||||
) -> list[SearchResult]:
|
||||
results = []
|
||||
try:
|
||||
results = await self.bing_search.search(query, num_results)
|
||||
except Exception as e:
|
||||
logger.error(f"bing search error: {e}, try the next one...")
|
||||
if len(results) == 0:
|
||||
logger.debug("search bing failed")
|
||||
try:
|
||||
results = await self.sogo_search.search(query, num_results)
|
||||
except Exception as e:
|
||||
logger.error(f"sogo search error: {e}")
|
||||
if len(results) == 0:
|
||||
logger.debug("search sogo failed")
|
||||
return []
|
||||
|
||||
return results
|
||||
|
||||
async def _get_tavily_key(self, cfg: AstrBotConfig) -> str:
|
||||
"""并发安全的从列表中获取并轮换Tavily API密钥。"""
|
||||
tavily_keys = cfg.get("provider_settings", {}).get("websearch_tavily_key", [])
|
||||
if not tavily_keys:
|
||||
raise ValueError("错误:Tavily API密钥未在AstrBot中配置。")
|
||||
|
||||
async with self.tavily_key_lock:
|
||||
key = tavily_keys[self.tavily_key_index]
|
||||
self.tavily_key_index = (self.tavily_key_index + 1) % len(tavily_keys)
|
||||
return key
|
||||
|
||||
async def _web_search_tavily(
|
||||
self,
|
||||
cfg: AstrBotConfig,
|
||||
payload: dict,
|
||||
) -> list[SearchResult]:
|
||||
"""使用 Tavily 搜索引擎进行搜索"""
|
||||
tavily_key = await self._get_tavily_key(cfg)
|
||||
url = "https://api.tavily.com/search"
|
||||
header = {
|
||||
"Authorization": f"Bearer {tavily_key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
async with aiohttp.ClientSession(trust_env=True) as session:
|
||||
async with session.post(
|
||||
url,
|
||||
json=payload,
|
||||
headers=header,
|
||||
) as response:
|
||||
if response.status != 200:
|
||||
reason = await response.text()
|
||||
raise Exception(
|
||||
f"Tavily web search failed: {reason}, status: {response.status}",
|
||||
)
|
||||
data = await response.json()
|
||||
results = []
|
||||
for item in data.get("results", []):
|
||||
result = SearchResult(
|
||||
title=item.get("title"),
|
||||
url=item.get("url"),
|
||||
snippet=item.get("content"),
|
||||
favicon=item.get("favicon"),
|
||||
)
|
||||
results.append(result)
|
||||
return results
|
||||
|
||||
async def _extract_tavily(self, cfg: AstrBotConfig, payload: dict) -> list[dict]:
|
||||
"""使用 Tavily 提取网页内容"""
|
||||
tavily_key = await self._get_tavily_key(cfg)
|
||||
url = "https://api.tavily.com/extract"
|
||||
header = {
|
||||
"Authorization": f"Bearer {tavily_key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
async with aiohttp.ClientSession(trust_env=True) as session:
|
||||
async with session.post(
|
||||
url,
|
||||
json=payload,
|
||||
headers=header,
|
||||
) as response:
|
||||
if response.status != 200:
|
||||
reason = await response.text()
|
||||
raise Exception(
|
||||
f"Tavily web search failed: {reason}, status: {response.status}",
|
||||
)
|
||||
data = await response.json()
|
||||
results: list[dict] = data.get("results", [])
|
||||
if not results:
|
||||
raise ValueError(
|
||||
"Error: Tavily web searcher does not return any results.",
|
||||
)
|
||||
return results
|
||||
|
||||
@llm_tool(name="web_search")
|
||||
async def search_from_search_engine(
|
||||
self,
|
||||
event: AstrMessageEvent,
|
||||
query: str,
|
||||
max_results: int = 5,
|
||||
) -> str:
|
||||
"""搜索网络以回答用户的问题。当用户需要搜索网络以获取即时性的信息时调用此工具。
|
||||
|
||||
Args:
|
||||
query(string): 和用户的问题最相关的搜索关键词,用于在 Google 上搜索。
|
||||
max_results(number): 返回的最大搜索结果数量,默认为 5。
|
||||
|
||||
"""
|
||||
logger.info(f"web_searcher - search_from_search_engine: {query}")
|
||||
cfg = self.context.get_config(umo=event.unified_msg_origin)
|
||||
websearch_link = cfg["provider_settings"].get("web_search_link", False)
|
||||
|
||||
results = await self._web_search_default(query, max_results)
|
||||
if not results:
|
||||
return "Error: web searcher does not return any results."
|
||||
|
||||
tasks = []
|
||||
for idx, result in enumerate(results, 1):
|
||||
task = self._process_search_result(result, idx, websearch_link)
|
||||
tasks.append(task)
|
||||
processed_results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
ret = ""
|
||||
for processed_result in processed_results:
|
||||
if isinstance(processed_result, BaseException):
|
||||
logger.error(f"Error processing search result: {processed_result}")
|
||||
continue
|
||||
ret += processed_result
|
||||
|
||||
if websearch_link:
|
||||
ret += "\n\n针对问题,请根据上面的结果分点总结,并且在结尾处附上对应内容的参考链接(如有)。"
|
||||
|
||||
return ret
|
||||
|
||||
async def ensure_baidu_ai_search_mcp(self, umo: str | None = None) -> None:
|
||||
if self.baidu_initialized:
|
||||
return
|
||||
cfg = self.context.get_config(umo=umo)
|
||||
key = cfg.get("provider_settings", {}).get(
|
||||
"websearch_baidu_app_builder_key",
|
||||
"",
|
||||
)
|
||||
if not key:
|
||||
raise ValueError(
|
||||
"Error: Baidu AI Search API key is not configured in AstrBot.",
|
||||
)
|
||||
func_tool_mgr = self.context.get_llm_tool_manager()
|
||||
await func_tool_mgr.enable_mcp_server(
|
||||
"baidu_ai_search",
|
||||
config={
|
||||
"transport": "sse",
|
||||
"url": f"http://appbuilder.baidu.com/v2/ai_search/mcp/sse?api_key={key}",
|
||||
"headers": {},
|
||||
"timeout": 600,
|
||||
},
|
||||
)
|
||||
self.baidu_initialized = True
|
||||
logger.info("Successfully initialized Baidu AI Search MCP server.")
|
||||
|
||||
@llm_tool(name="fetch_url")
|
||||
async def fetch_website_content(self, event: AstrMessageEvent, url: str) -> str:
|
||||
"""Fetch the content of a website with the given web url
|
||||
|
||||
Args:
|
||||
url(string): The url of the website to fetch content from
|
||||
|
||||
"""
|
||||
resp = await self._get_from_url(url)
|
||||
return resp
|
||||
|
||||
@llm_tool("web_search_tavily")
|
||||
async def search_from_tavily(
|
||||
self,
|
||||
event: AstrMessageEvent,
|
||||
query: str,
|
||||
max_results: int = 7,
|
||||
search_depth: str = "basic",
|
||||
topic: str = "general",
|
||||
days: int = 3,
|
||||
time_range: str = "",
|
||||
start_date: str = "",
|
||||
end_date: str = "",
|
||||
) -> str:
|
||||
"""A web search tool that uses Tavily to search the web for relevant content.
|
||||
Ideal for gathering current information, news, and detailed web content analysis.
|
||||
|
||||
Args:
|
||||
query(string): Required. Search query.
|
||||
max_results(number): Optional. The maximum number of results to return. Default is 7. Range is 5-20.
|
||||
search_depth(string): Optional. The depth of the search, must be one of 'basic', 'advanced'. Default is "basic".
|
||||
topic(string): Optional. The topic of the search, must be one of 'general', 'news'. Default is "general".
|
||||
days(number): Optional. The number of days back from the current date to include in the search results. Please note that this feature is only available when using the 'news' search topic.
|
||||
time_range(string): Optional. The time range back from the current date to include in the search results. This feature is available for both 'general' and 'news' search topics. Must be one of 'day', 'week', 'month', 'year'.
|
||||
start_date(string): Optional. The start date for the search results in the format 'YYYY-MM-DD'.
|
||||
end_date(string): Optional. The end date for the search results in the format 'YYYY-MM-DD'.
|
||||
|
||||
"""
|
||||
logger.info(f"web_searcher - search_from_tavily: {query}")
|
||||
cfg = self.context.get_config(umo=event.unified_msg_origin)
|
||||
# websearch_link = cfg["provider_settings"].get("web_search_link", False)
|
||||
if not cfg.get("provider_settings", {}).get("websearch_tavily_key", []):
|
||||
raise ValueError("Error: Tavily API key is not configured in AstrBot.")
|
||||
|
||||
# build payload
|
||||
payload = {"query": query, "max_results": max_results, "include_favicon": True}
|
||||
if search_depth not in ["basic", "advanced"]:
|
||||
search_depth = "basic"
|
||||
payload["search_depth"] = search_depth
|
||||
|
||||
if topic not in ["general", "news"]:
|
||||
topic = "general"
|
||||
payload["topic"] = topic
|
||||
|
||||
if topic == "news":
|
||||
payload["days"] = days
|
||||
|
||||
if time_range in ["day", "week", "month", "year"]:
|
||||
payload["time_range"] = time_range
|
||||
if start_date:
|
||||
payload["start_date"] = start_date
|
||||
if end_date:
|
||||
payload["end_date"] = end_date
|
||||
|
||||
results = await self._web_search_tavily(cfg, payload)
|
||||
if not results:
|
||||
return "Error: Tavily web searcher does not return any results."
|
||||
|
||||
ret_ls = []
|
||||
ref_uuid = str(uuid.uuid4())[:4]
|
||||
for idx, result in enumerate(results, 1):
|
||||
index = f"{ref_uuid}.{idx}"
|
||||
ret_ls.append(
|
||||
{
|
||||
"title": f"{result.title}",
|
||||
"url": f"{result.url}",
|
||||
"snippet": f"{result.snippet}",
|
||||
# TODO: do not need ref for non-webchat platform adapter
|
||||
"index": index,
|
||||
}
|
||||
)
|
||||
if result.favicon:
|
||||
sp.temporary_cache["_ws_favicon"][result.url] = result.favicon
|
||||
# ret = "\n".join(ret_ls)
|
||||
ret = json.dumps({"results": ret_ls}, ensure_ascii=False)
|
||||
return ret
|
||||
|
||||
@llm_tool("tavily_extract_web_page")
|
||||
async def tavily_extract_web_page(
|
||||
self,
|
||||
event: AstrMessageEvent,
|
||||
url: str = "",
|
||||
extract_depth: str = "basic",
|
||||
) -> str:
|
||||
"""Extract the content of a web page using Tavily.
|
||||
|
||||
Args:
|
||||
url(string): Required. An URl to extract content from.
|
||||
extract_depth(string): Optional. The depth of the extraction, must be one of 'basic', 'advanced'. Default is "basic".
|
||||
|
||||
"""
|
||||
cfg = self.context.get_config(umo=event.unified_msg_origin)
|
||||
if not cfg.get("provider_settings", {}).get("websearch_tavily_key", []):
|
||||
raise ValueError("Error: Tavily API key is not configured in AstrBot.")
|
||||
|
||||
if not url:
|
||||
raise ValueError("Error: url must be a non-empty string.")
|
||||
if extract_depth not in ["basic", "advanced"]:
|
||||
extract_depth = "basic"
|
||||
payload = {
|
||||
"urls": [url],
|
||||
"extract_depth": extract_depth,
|
||||
}
|
||||
results = await self._extract_tavily(cfg, payload)
|
||||
ret_ls = []
|
||||
for result in results:
|
||||
ret_ls.append(f"URL: {result.get('url', 'No URL')}")
|
||||
ret_ls.append(f"Content: {result.get('raw_content', 'No content')}")
|
||||
ret = "\n".join(ret_ls)
|
||||
if not ret:
|
||||
return "Error: Tavily web searcher does not return any results."
|
||||
return ret
|
||||
|
||||
async def _get_bocha_key(self, cfg: AstrBotConfig) -> str:
|
||||
"""并发安全的从列表中获取并轮换BoCha API密钥。"""
|
||||
bocha_keys = cfg.get("provider_settings", {}).get("websearch_bocha_key", [])
|
||||
if not bocha_keys:
|
||||
raise ValueError("错误:BoCha API密钥未在AstrBot中配置。")
|
||||
|
||||
async with self.bocha_key_lock:
|
||||
key = bocha_keys[self.bocha_key_index]
|
||||
self.bocha_key_index = (self.bocha_key_index + 1) % len(bocha_keys)
|
||||
return key
|
||||
|
||||
async def _web_search_bocha(
|
||||
self,
|
||||
cfg: AstrBotConfig,
|
||||
payload: dict,
|
||||
) -> list[SearchResult]:
|
||||
"""使用 BoCha 搜索引擎进行搜索"""
|
||||
bocha_key = await self._get_bocha_key(cfg)
|
||||
url = "https://api.bochaai.com/v1/web-search"
|
||||
header = {
|
||||
"Authorization": f"Bearer {bocha_key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
async with aiohttp.ClientSession(trust_env=True) as session:
|
||||
async with session.post(
|
||||
url,
|
||||
json=payload,
|
||||
headers=header,
|
||||
) as response:
|
||||
if response.status != 200:
|
||||
reason = await response.text()
|
||||
raise Exception(
|
||||
f"BoCha web search failed: {reason}, status: {response.status}",
|
||||
)
|
||||
data = await response.json()
|
||||
data = data["data"]["webPages"]["value"]
|
||||
results = []
|
||||
for item in data:
|
||||
result = SearchResult(
|
||||
title=item.get("name"),
|
||||
url=item.get("url"),
|
||||
snippet=item.get("snippet"),
|
||||
favicon=item.get("siteIcon"),
|
||||
)
|
||||
results.append(result)
|
||||
return results
|
||||
|
||||
@llm_tool("web_search_bocha")
|
||||
async def search_from_bocha(
|
||||
self,
|
||||
event: AstrMessageEvent,
|
||||
query: str,
|
||||
freshness: str = "noLimit",
|
||||
summary: bool = False,
|
||||
include: str = "",
|
||||
exclude: str = "",
|
||||
count: int = 10,
|
||||
) -> str:
|
||||
"""
|
||||
A web search tool based on Bocha Search API, used to retrieve web pages
|
||||
related to the user's query.
|
||||
|
||||
Args:
|
||||
query (string): Required. User's search query.
|
||||
|
||||
freshness (string): Optional. Specifies the time range of the search.
|
||||
Supported values:
|
||||
- "noLimit": No time limit (default, recommended).
|
||||
- "oneDay": Within one day.
|
||||
- "oneWeek": Within one week.
|
||||
- "oneMonth": Within one month.
|
||||
- "oneYear": Within one year.
|
||||
- "YYYY-MM-DD..YYYY-MM-DD": Search within a specific date range.
|
||||
Example: "2025-01-01..2025-04-06".
|
||||
- "YYYY-MM-DD": Search on a specific date.
|
||||
Example: "2025-04-06".
|
||||
It is recommended to use "noLimit", as the search algorithm will
|
||||
automatically optimize time relevance. Manually restricting the
|
||||
time range may result in no search results.
|
||||
|
||||
summary (boolean): Optional. Whether to include a text summary
|
||||
for each search result.
|
||||
- True: Include summary.
|
||||
- False: Do not include summary (default).
|
||||
|
||||
include (string): Optional. Specifies the domains to include in
|
||||
the search. Multiple domains can be separated by "|" or ",".
|
||||
A maximum of 100 domains is allowed.
|
||||
Examples:
|
||||
- "qq.com"
|
||||
- "qq.com|m.163.com"
|
||||
|
||||
exclude (string): Optional. Specifies the domains to exclude from
|
||||
the search. Multiple domains can be separated by "|" or ",".
|
||||
A maximum of 100 domains is allowed.
|
||||
Examples:
|
||||
- "qq.com"
|
||||
- "qq.com|m.163.com"
|
||||
|
||||
count (number): Optional. Number of search results to return.
|
||||
- Range: 1–50
|
||||
- Default: 10
|
||||
The actual number of returned results may be less than the
|
||||
specified count.
|
||||
"""
|
||||
logger.info(f"web_searcher - search_from_bocha: {query}")
|
||||
cfg = self.context.get_config(umo=event.unified_msg_origin)
|
||||
# websearch_link = cfg["provider_settings"].get("web_search_link", False)
|
||||
if not cfg.get("provider_settings", {}).get("websearch_bocha_key", []):
|
||||
raise ValueError("Error: BoCha API key is not configured in AstrBot.")
|
||||
|
||||
# build payload
|
||||
payload = {
|
||||
"query": query,
|
||||
"count": count,
|
||||
}
|
||||
|
||||
# freshness:时间范围
|
||||
if freshness:
|
||||
payload["freshness"] = freshness
|
||||
|
||||
# 是否返回摘要
|
||||
payload["summary"] = summary
|
||||
|
||||
# include:限制搜索域
|
||||
if include:
|
||||
payload["include"] = include
|
||||
|
||||
# exclude:排除搜索域
|
||||
if exclude:
|
||||
payload["exclude"] = exclude
|
||||
|
||||
results = await self._web_search_bocha(cfg, payload)
|
||||
if not results:
|
||||
return "Error: BoCha web searcher does not return any results."
|
||||
|
||||
ret_ls = []
|
||||
ref_uuid = str(uuid.uuid4())[:4]
|
||||
for idx, result in enumerate(results, 1):
|
||||
index = f"{ref_uuid}.{idx}"
|
||||
ret_ls.append(
|
||||
{
|
||||
"title": f"{result.title}",
|
||||
"url": f"{result.url}",
|
||||
"snippet": f"{result.snippet}",
|
||||
"index": index,
|
||||
}
|
||||
)
|
||||
if result.favicon:
|
||||
sp.temporary_cache["_ws_favicon"][result.url] = result.favicon
|
||||
# ret = "\n".join(ret_ls)
|
||||
ret = json.dumps({"results": ret_ls}, ensure_ascii=False)
|
||||
return ret
|
||||
|
||||
@filter.on_llm_request(priority=-10000)
|
||||
async def edit_web_search_tools(
|
||||
self,
|
||||
event: AstrMessageEvent,
|
||||
req: ProviderRequest,
|
||||
) -> None:
|
||||
"""Get the session conversation for the given event."""
|
||||
cfg = self.context.get_config(umo=event.unified_msg_origin)
|
||||
prov_settings = cfg.get("provider_settings", {})
|
||||
websearch_enable = prov_settings.get("web_search", False)
|
||||
provider = prov_settings.get("websearch_provider", "default")
|
||||
|
||||
tool_set = req.func_tool
|
||||
if isinstance(tool_set, FunctionToolManager):
|
||||
req.func_tool = tool_set.get_full_tool_set()
|
||||
tool_set = req.func_tool
|
||||
|
||||
if not tool_set:
|
||||
return
|
||||
|
||||
if not websearch_enable:
|
||||
# pop tools
|
||||
for tool_name in self.TOOLS:
|
||||
tool_set.remove_tool(tool_name)
|
||||
return
|
||||
|
||||
func_tool_mgr = self.context.get_llm_tool_manager()
|
||||
if provider == "default":
|
||||
web_search_t = func_tool_mgr.get_func("web_search")
|
||||
fetch_url_t = func_tool_mgr.get_func("fetch_url")
|
||||
if web_search_t and web_search_t.active:
|
||||
tool_set.add_tool(web_search_t)
|
||||
if fetch_url_t and fetch_url_t.active:
|
||||
tool_set.add_tool(fetch_url_t)
|
||||
tool_set.remove_tool("web_search_tavily")
|
||||
tool_set.remove_tool("tavily_extract_web_page")
|
||||
tool_set.remove_tool("AIsearch")
|
||||
tool_set.remove_tool("web_search_bocha")
|
||||
elif provider == "tavily":
|
||||
web_search_tavily = func_tool_mgr.get_func("web_search_tavily")
|
||||
tavily_extract_web_page = func_tool_mgr.get_func("tavily_extract_web_page")
|
||||
if web_search_tavily and web_search_tavily.active:
|
||||
tool_set.add_tool(web_search_tavily)
|
||||
if tavily_extract_web_page and tavily_extract_web_page.active:
|
||||
tool_set.add_tool(tavily_extract_web_page)
|
||||
tool_set.remove_tool("web_search")
|
||||
tool_set.remove_tool("fetch_url")
|
||||
tool_set.remove_tool("AIsearch")
|
||||
tool_set.remove_tool("web_search_bocha")
|
||||
elif provider == "baidu_ai_search":
|
||||
try:
|
||||
await self.ensure_baidu_ai_search_mcp(event.unified_msg_origin)
|
||||
aisearch_tool = func_tool_mgr.get_func("AIsearch")
|
||||
if aisearch_tool and aisearch_tool.active:
|
||||
tool_set.add_tool(aisearch_tool)
|
||||
tool_set.remove_tool("web_search")
|
||||
tool_set.remove_tool("fetch_url")
|
||||
tool_set.remove_tool("web_search_tavily")
|
||||
tool_set.remove_tool("tavily_extract_web_page")
|
||||
tool_set.remove_tool("web_search_bocha")
|
||||
except Exception as e:
|
||||
logger.error(f"Cannot Initialize Baidu AI Search MCP Server: {e}")
|
||||
elif provider == "bocha":
|
||||
web_search_bocha = func_tool_mgr.get_func("web_search_bocha")
|
||||
if web_search_bocha and web_search_bocha.active:
|
||||
tool_set.add_tool(web_search_bocha)
|
||||
tool_set.remove_tool("web_search")
|
||||
tool_set.remove_tool("fetch_url")
|
||||
tool_set.remove_tool("AIsearch")
|
||||
tool_set.remove_tool("web_search_tavily")
|
||||
tool_set.remove_tool("tavily_extract_web_page")
|
||||
4
astrbot/builtin_stars/web_searcher/metadata.yaml
Normal file
4
astrbot/builtin_stars/web_searcher/metadata.yaml
Normal file
@@ -0,0 +1,4 @@
|
||||
name: astrbot-web-searcher
|
||||
desc: 让 LLM 具有网页检索能力
|
||||
author: Soulter
|
||||
version: 1.14.514
|
||||
@@ -1 +1 @@
|
||||
__version__ = "4.23.0"
|
||||
__version__ = "4.22.0"
|
||||
|
||||
@@ -15,6 +15,7 @@ class HandoffTool(FunctionTool, Generic[TContext]):
|
||||
tool_description: str | None = None,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
|
||||
# Avoid passing duplicate `description` to the FunctionTool dataclass.
|
||||
# Some call sites (e.g. SubAgentOrchestrator) pass `description` via kwargs
|
||||
# to override what the main agent sees, while we also compute a default
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from contextlib import AsyncExitStack
|
||||
from datetime import timedelta
|
||||
from pathlib import Path, PureWindowsPath
|
||||
from typing import Generic
|
||||
|
||||
from tenacity import (
|
||||
@@ -23,75 +21,6 @@ from astrbot.core.utils.log_pipe import LogPipe
|
||||
from .run_context import TContext
|
||||
from .tool import FunctionTool
|
||||
|
||||
_DEFAULT_STDIO_COMMAND_ALLOWLIST = frozenset(
|
||||
{
|
||||
"python",
|
||||
"python3",
|
||||
"py",
|
||||
"node",
|
||||
"npx",
|
||||
"npm",
|
||||
"pnpm",
|
||||
"yarn",
|
||||
"bun",
|
||||
"bunx",
|
||||
"deno",
|
||||
"uv",
|
||||
"uvx",
|
||||
}
|
||||
)
|
||||
_DENIED_STDIO_COMMANDS = frozenset(
|
||||
{
|
||||
"bash",
|
||||
"sh",
|
||||
"zsh",
|
||||
"fish",
|
||||
"cmd",
|
||||
"cmd.exe",
|
||||
"powershell",
|
||||
"powershell.exe",
|
||||
"pwsh",
|
||||
"pwsh.exe",
|
||||
"osascript",
|
||||
"open",
|
||||
"curl",
|
||||
"wget",
|
||||
"nc",
|
||||
"netcat",
|
||||
"telnet",
|
||||
"ssh",
|
||||
"scp",
|
||||
"rm",
|
||||
"mv",
|
||||
"cp",
|
||||
"dd",
|
||||
"mkfs",
|
||||
"sudo",
|
||||
"su",
|
||||
"chmod",
|
||||
"chown",
|
||||
"kill",
|
||||
"killall",
|
||||
"shutdown",
|
||||
"reboot",
|
||||
"poweroff",
|
||||
"halt",
|
||||
}
|
||||
)
|
||||
_SHELL_META_RE = re.compile(r"[\r\n\x00;&|<>`$]")
|
||||
_PYTHON_INLINE_CODE_FLAGS = frozenset({"-c"})
|
||||
_JS_INLINE_CODE_FLAGS = frozenset({"-e", "--eval", "-p", "--print"})
|
||||
_DENIED_DOCKER_ARGS = frozenset(
|
||||
{
|
||||
"--privileged",
|
||||
"--pid=host",
|
||||
"--network=host",
|
||||
"--net=host",
|
||||
"--ipc=host",
|
||||
}
|
||||
)
|
||||
_STDIO_ALLOWLIST_ENV = "ASTRBOT_MCP_STDIO_ALLOWED_COMMANDS"
|
||||
|
||||
try:
|
||||
import anyio
|
||||
import mcp
|
||||
@@ -113,156 +42,27 @@ def _prepare_config(config: dict) -> dict:
|
||||
"""Prepare configuration, handle nested format"""
|
||||
if config.get("mcpServers"):
|
||||
first_key = next(iter(config["mcpServers"]))
|
||||
config = dict(config["mcpServers"][first_key])
|
||||
else:
|
||||
config = dict(config)
|
||||
config = config["mcpServers"][first_key]
|
||||
config.pop("active", None)
|
||||
return config
|
||||
|
||||
|
||||
def _normalize_stdio_command_name(command: str) -> str:
|
||||
command = command.strip()
|
||||
if "\\" in command:
|
||||
command_name = PureWindowsPath(command).name
|
||||
else:
|
||||
command_name = Path(command).name
|
||||
command_name = command_name.lower()
|
||||
for suffix in (".exe", ".cmd", ".bat"):
|
||||
if command_name.endswith(suffix):
|
||||
return command_name[: -len(suffix)]
|
||||
return command_name
|
||||
|
||||
|
||||
def _get_stdio_command_allowlist() -> set[str]:
|
||||
allowed = set(_DEFAULT_STDIO_COMMAND_ALLOWLIST)
|
||||
configured = os.environ.get(_STDIO_ALLOWLIST_ENV, "")
|
||||
if configured.strip():
|
||||
allowed = {
|
||||
_normalize_stdio_command_name(item)
|
||||
for item in configured.split(",")
|
||||
if item.strip()
|
||||
}
|
||||
return allowed
|
||||
|
||||
|
||||
def _is_stdio_config(config: dict) -> bool:
|
||||
cfg = _prepare_config(config.copy())
|
||||
return "url" not in cfg
|
||||
|
||||
|
||||
def _validate_stdio_args(command_name: str, args: object) -> None:
|
||||
if args is None:
|
||||
return
|
||||
if not isinstance(args, list) or not all(isinstance(arg, str) for arg in args):
|
||||
raise ValueError("MCP stdio args must be a list of strings.")
|
||||
|
||||
for arg in args:
|
||||
if "\x00" in arg or "\r" in arg or "\n" in arg:
|
||||
raise ValueError("MCP stdio args cannot contain control characters.")
|
||||
|
||||
if command_name.startswith("python") or command_name == "py":
|
||||
if any(
|
||||
arg == "-c"
|
||||
or (arg.startswith("-") and not arg.startswith("--") and "c" in arg)
|
||||
for arg in args
|
||||
):
|
||||
raise ValueError(
|
||||
"MCP stdio Python servers must be launched from a module or file; inline code flags such as -c are not allowed."
|
||||
)
|
||||
elif command_name in {"node", "deno", "bun"} or command_name.startswith("node"):
|
||||
if any(
|
||||
arg in _JS_INLINE_CODE_FLAGS
|
||||
or arg == "eval"
|
||||
or (
|
||||
arg.startswith("-")
|
||||
and not arg.startswith("--")
|
||||
and any(c in arg for c in "ep")
|
||||
)
|
||||
for arg in args
|
||||
):
|
||||
raise ValueError(
|
||||
"MCP stdio JavaScript servers must be launched from a package or file; inline eval flags are not allowed."
|
||||
)
|
||||
elif command_name == "docker":
|
||||
denied = []
|
||||
for i, arg in enumerate(args):
|
||||
if arg in _DENIED_DOCKER_ARGS:
|
||||
denied.append(arg)
|
||||
elif (
|
||||
arg in {"--network", "--net", "--pid", "--ipc"}
|
||||
and i + 1 < len(args)
|
||||
and args[i + 1] == "host"
|
||||
):
|
||||
denied.append(f"{arg} {args[i + 1]}")
|
||||
if denied:
|
||||
raise ValueError(
|
||||
f"MCP stdio Docker args are unsafe and not allowed: {', '.join(denied)}."
|
||||
)
|
||||
|
||||
|
||||
def validate_mcp_stdio_config(config: dict) -> None:
|
||||
"""Validate stdio MCP config before any subprocess can be spawned."""
|
||||
cfg = _prepare_config(config.copy())
|
||||
if "url" in cfg:
|
||||
return
|
||||
|
||||
command = cfg.get("command")
|
||||
if not isinstance(command, str) or not command.strip():
|
||||
raise ValueError("MCP stdio server requires a non-empty command.")
|
||||
if _SHELL_META_RE.search(command):
|
||||
raise ValueError("MCP stdio command contains unsafe shell metacharacters.")
|
||||
|
||||
command_name = _normalize_stdio_command_name(command)
|
||||
if command_name in _DENIED_STDIO_COMMANDS:
|
||||
raise ValueError(f"MCP stdio command `{command_name}` is not allowed.")
|
||||
|
||||
allowed = _get_stdio_command_allowlist()
|
||||
if command_name not in allowed:
|
||||
allowed_display = ", ".join(sorted(allowed))
|
||||
raise ValueError(
|
||||
f"MCP stdio command `{command_name}` is not allowed. "
|
||||
f"Allowed commands: {allowed_display}. "
|
||||
f"Set {_STDIO_ALLOWLIST_ENV} to override this list if you trust another launcher."
|
||||
)
|
||||
|
||||
_validate_stdio_args(command_name, cfg.get("args"))
|
||||
|
||||
env = cfg.get("env")
|
||||
if env is not None and not isinstance(env, dict):
|
||||
raise ValueError("MCP stdio env must be an object.")
|
||||
if isinstance(env, dict) and not all(
|
||||
isinstance(key, str) and isinstance(value, str) for key, value in env.items()
|
||||
):
|
||||
raise ValueError("MCP stdio env keys and values must be strings.")
|
||||
|
||||
|
||||
def _prepare_stdio_env(config: dict) -> dict:
|
||||
"""Preserve Windows executable resolution for stdio subprocesses."""
|
||||
if sys.platform != "win32":
|
||||
return config
|
||||
|
||||
pathext = os.environ.get("PATHEXT")
|
||||
if not pathext:
|
||||
return config
|
||||
|
||||
prepared = config.copy()
|
||||
env = dict(prepared.get("env") or {})
|
||||
env = _merge_environment_variables(env)
|
||||
env.setdefault("PATHEXT", pathext)
|
||||
prepared["env"] = env
|
||||
return prepared
|
||||
|
||||
|
||||
def _merge_environment_variables(env: dict) -> dict:
|
||||
"""合并环境变量,处理Windows不区分大小写的情况"""
|
||||
merged = env.copy()
|
||||
|
||||
# 将用户环境变量转换为统一的大小写形式便于比较
|
||||
user_keys_lower = {k.lower(): k for k in merged.keys()}
|
||||
|
||||
for sys_key, sys_value in os.environ.items():
|
||||
sys_key_lower = sys_key.lower()
|
||||
if sys_key_lower not in user_keys_lower:
|
||||
# 使用系统环境变量中的原始大小写
|
||||
merged[sys_key] = sys_value
|
||||
|
||||
return merged
|
||||
|
||||
|
||||
async def _quick_test_mcp_connection(config: dict) -> tuple[bool, str]:
|
||||
"""Quick test MCP server connectivity"""
|
||||
import aiohttp
|
||||
@@ -432,7 +232,6 @@ class MCPClient:
|
||||
)
|
||||
|
||||
else:
|
||||
validate_mcp_stdio_config(cfg)
|
||||
cfg = _prepare_stdio_env(cfg)
|
||||
server_params = mcp.StdioServerParameters(
|
||||
**cfg,
|
||||
|
||||
@@ -410,20 +410,18 @@ class DeerFlowAgentRunner(BaseAgentRunner[TContext]):
|
||||
)
|
||||
return messages
|
||||
|
||||
def _build_runtime_configurable(self, thread_id: str) -> dict[str, T.Any]:
|
||||
runtime_configurable: dict[str, T.Any] = {
|
||||
def _build_runtime_context(self, thread_id: str) -> dict[str, T.Any]:
|
||||
runtime_context: dict[str, T.Any] = {
|
||||
"thread_id": thread_id,
|
||||
"thinking_enabled": self.thinking_enabled,
|
||||
"is_plan_mode": self.plan_mode,
|
||||
"subagent_enabled": self.subagent_enabled,
|
||||
}
|
||||
if self.subagent_enabled:
|
||||
runtime_configurable["max_concurrent_subagents"] = (
|
||||
self.max_concurrent_subagents
|
||||
)
|
||||
runtime_context["max_concurrent_subagents"] = self.max_concurrent_subagents
|
||||
if self.model_name:
|
||||
runtime_configurable["model_name"] = self.model_name
|
||||
return runtime_configurable
|
||||
runtime_context["model_name"] = self.model_name
|
||||
return runtime_context
|
||||
|
||||
def _build_payload(
|
||||
self,
|
||||
@@ -432,19 +430,16 @@ class DeerFlowAgentRunner(BaseAgentRunner[TContext]):
|
||||
image_urls: list[str],
|
||||
system_prompt: str | None,
|
||||
) -> dict[str, T.Any]:
|
||||
runtime_configurable = self._build_runtime_configurable(thread_id)
|
||||
return {
|
||||
"assistant_id": self.assistant_id,
|
||||
"input": {
|
||||
"messages": self._build_messages(prompt, image_urls, system_prompt),
|
||||
},
|
||||
"stream_mode": ["values", "messages-tuple", "custom"],
|
||||
# DeerFlow 2.0 consumes runtime overrides from config.configurable.
|
||||
# Keep the legacy context mirror for older compat paths.
|
||||
"context": dict(runtime_configurable),
|
||||
# LangGraph 0.6+ prefers context instead of configurable.
|
||||
"context": self._build_runtime_context(thread_id),
|
||||
"config": {
|
||||
"recursion_limit": self.recursion_limit,
|
||||
"configurable": runtime_configurable,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -10,33 +10,6 @@ from astrbot.core import logger
|
||||
SSE_MAX_BUFFER_CHARS = 1_048_576
|
||||
|
||||
|
||||
class DeerFlowAPIError(Exception):
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
operation: str,
|
||||
status: int,
|
||||
body: str,
|
||||
url: str,
|
||||
thread_id: str | None = None,
|
||||
) -> None:
|
||||
self.operation = operation
|
||||
self.status = status
|
||||
self.body = body
|
||||
self.url = url
|
||||
self.thread_id = thread_id
|
||||
|
||||
message = (
|
||||
f"DeerFlow {operation} failed: status={status}, url={url}, body={body}"
|
||||
)
|
||||
if thread_id is not None:
|
||||
message = (
|
||||
f"DeerFlow {operation} failed: thread_id={thread_id}, "
|
||||
f"status={status}, url={url}, body={body}"
|
||||
)
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
def _normalize_sse_newlines(text: str) -> str:
|
||||
"""Normalize CRLF/CR to LF so SSE block splitting works reliably."""
|
||||
return text.replace("\r\n", "\n").replace("\r", "\n")
|
||||
@@ -179,33 +152,11 @@ class DeerFlowAPIClient:
|
||||
) as resp:
|
||||
if resp.status not in (200, 201):
|
||||
text = await resp.text()
|
||||
raise DeerFlowAPIError(
|
||||
operation="create thread",
|
||||
status=resp.status,
|
||||
body=text,
|
||||
url=url,
|
||||
raise Exception(
|
||||
f"DeerFlow create thread failed: {resp.status}. {text}",
|
||||
)
|
||||
return await resp.json()
|
||||
|
||||
async def delete_thread(self, thread_id: str, timeout: float = 20) -> None:
|
||||
session = self._get_session()
|
||||
url = f"{self.api_base}/api/threads/{thread_id}"
|
||||
async with session.delete(
|
||||
url,
|
||||
headers=self.headers,
|
||||
timeout=timeout,
|
||||
proxy=self.proxy,
|
||||
) as resp:
|
||||
if resp.status not in (200, 202, 204, 404):
|
||||
text = await resp.text()
|
||||
raise DeerFlowAPIError(
|
||||
operation="delete thread",
|
||||
status=resp.status,
|
||||
body=text,
|
||||
url=url,
|
||||
thread_id=thread_id,
|
||||
)
|
||||
|
||||
async def stream_run(
|
||||
self,
|
||||
thread_id: str,
|
||||
@@ -249,12 +200,8 @@ class DeerFlowAPIClient:
|
||||
) as resp:
|
||||
if resp.status != 200:
|
||||
text = await resp.text()
|
||||
raise DeerFlowAPIError(
|
||||
operation="runs/stream request",
|
||||
status=resp.status,
|
||||
body=text,
|
||||
url=url,
|
||||
thread_id=thread_id,
|
||||
raise Exception(
|
||||
f"DeerFlow runs/stream request failed: {resp.status}. {text}",
|
||||
)
|
||||
async for event in _stream_sse(resp):
|
||||
yield event
|
||||
|
||||
@@ -4,11 +4,9 @@ import sys
|
||||
import time
|
||||
import traceback
|
||||
import typing as T
|
||||
import uuid
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
from mcp.types import (
|
||||
BlobResourceContents,
|
||||
@@ -18,18 +16,11 @@ from mcp.types import (
|
||||
TextContent,
|
||||
TextResourceContents,
|
||||
)
|
||||
from tenacity import (
|
||||
AsyncRetrying,
|
||||
retry_if_exception_type,
|
||||
stop_after_attempt,
|
||||
wait_exponential,
|
||||
)
|
||||
|
||||
from astrbot import logger
|
||||
from astrbot.core.agent.message import ImageURLPart, TextPart, ThinkPart
|
||||
from astrbot.core.agent.tool import FunctionTool, ToolSet
|
||||
from astrbot.core.agent.tool import ToolSet
|
||||
from astrbot.core.agent.tool_image_cache import tool_image_cache
|
||||
from astrbot.core.exceptions import EmptyModelOutputError
|
||||
from astrbot.core.message.components import Json
|
||||
from astrbot.core.message.message_event_result import (
|
||||
MessageChain,
|
||||
@@ -47,7 +38,7 @@ from astrbot.core.provider.provider import Provider
|
||||
from ..context.compressor import ContextCompressor
|
||||
from ..context.config import ContextConfig
|
||||
from ..context.manager import ContextManager
|
||||
from ..context.token_counter import EstimateTokenCounter, TokenCounter
|
||||
from ..context.token_counter import TokenCounter
|
||||
from ..hooks import BaseAgentRunHooks
|
||||
from ..message import AssistantMessageSegment, Message, ToolCallMessageSegment
|
||||
from ..response import AgentResponseData, AgentStats
|
||||
@@ -97,102 +88,18 @@ class _ToolExecutionInterrupted(Exception):
|
||||
|
||||
ToolExecutorResultT = T.TypeVar("ToolExecutorResultT")
|
||||
|
||||
USER_INTERRUPTION_MESSAGE = (
|
||||
"[SYSTEM: User actively interrupted the response generation. "
|
||||
"Partial output before interruption is preserved.]"
|
||||
)
|
||||
|
||||
|
||||
class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
TOOL_RESULT_MAX_ESTIMATED_TOKENS = 27_500
|
||||
TOOL_RESULT_PREVIEW_MAX_ESTIMATED_TOKENS = 7000
|
||||
EMPTY_OUTPUT_RETRY_ATTEMPTS = 3
|
||||
EMPTY_OUTPUT_RETRY_WAIT_MIN_S = 1
|
||||
EMPTY_OUTPUT_RETRY_WAIT_MAX_S = 4
|
||||
USER_INTERRUPTION_MESSAGE = (
|
||||
"[SYSTEM: User actively interrupted the response generation. "
|
||||
"Partial output before interruption is preserved.]"
|
||||
)
|
||||
FOLLOW_UP_NOTICE_TEMPLATE = (
|
||||
"\n\n[SYSTEM NOTICE] User sent follow-up messages while tool execution "
|
||||
"was in progress. Prioritize these follow-up instructions in your next "
|
||||
"actions. In your very next action, briefly acknowledge to the user "
|
||||
"that their follow-up message(s) were received before continuing.\n"
|
||||
"{follow_up_lines}"
|
||||
)
|
||||
MAX_STEPS_REACHED_PROMPT = (
|
||||
"Maximum tool call limit reached. "
|
||||
"Stop calling tools, and based on the information you have gathered, "
|
||||
"summarize your task and findings, and reply to the user directly."
|
||||
)
|
||||
SKILLS_LIKE_REQUERY_INSTRUCTION_TEMPLATE = (
|
||||
"You have decided to call tool(s): {tool_names}. Now call the tool(s) "
|
||||
"with required arguments using the tool schema, and follow the existing "
|
||||
"tool-use rules."
|
||||
)
|
||||
SKILLS_LIKE_REQUERY_REPAIR_INSTRUCTION = (
|
||||
"This is the second-stage tool execution step. "
|
||||
"You must do exactly one of the following: "
|
||||
"1. Call one of the selected tools using the provided tool schema. "
|
||||
"2. If calling a tool is no longer possible or appropriate, reply to the user "
|
||||
"with a brief explanation of why. "
|
||||
"Do not return an empty response. "
|
||||
"Do not ignore the selected tools without explanation."
|
||||
)
|
||||
REPEATED_TOOL_NOTICE_L1_THRESHOLD = 3
|
||||
REPEATED_TOOL_NOTICE_L2_THRESHOLD = 4
|
||||
REPEATED_TOOL_NOTICE_L3_THRESHOLD = 5
|
||||
REPEATED_TOOL_NOTICE_L1_TEMPLATE = (
|
||||
"\n\n[SYSTEM NOTICE] By the way, you have executed the same tool "
|
||||
"`{tool_name}` {streak} times consecutively. Double-check whether another "
|
||||
"tool, different arguments, or a summary would move the task forward better."
|
||||
)
|
||||
REPEATED_TOOL_NOTICE_L2_TEMPLATE = (
|
||||
"\n\n[SYSTEM NOTICE] Important: you have executed the same tool "
|
||||
"`{tool_name}` {streak} times consecutively. Unless this repetition is "
|
||||
"clearly necessary, stop repeating the same action and either switch "
|
||||
"tools, refine parameters, or summarize what is still missing."
|
||||
)
|
||||
REPEATED_TOOL_NOTICE_L3_TEMPLATE = (
|
||||
"\n\n[SYSTEM NOTICE] Important: you have executed the same tool "
|
||||
"`{tool_name}` {streak} times consecutively. Repetition is now very "
|
||||
"high. Continue only if each call is clearly producing new information. "
|
||||
"Otherwise, change strategy, adjust arguments, or explain the limitation "
|
||||
"to the user."
|
||||
)
|
||||
TOOL_RESULT_OVERFLOW_NOTICE_TEMPLATE = (
|
||||
"Truncated tool output preview shown above. "
|
||||
"The tool output was too large to include directly and was written to "
|
||||
"`{overflow_path}`. Use {read_tool_hint} to inspect it. "
|
||||
"Use a narrower window when reading large files."
|
||||
)
|
||||
|
||||
def _get_persona_custom_error_message(self) -> str | None:
|
||||
"""Read persona-level custom error message from event extras when available."""
|
||||
event = getattr(self.run_context.context, "event", None)
|
||||
return extract_persona_custom_error_message_from_event(event)
|
||||
|
||||
async def _complete_with_assistant_response(self, llm_resp: LLMResponse) -> None:
|
||||
"""Finalize the current step as a plain assistant response with no tool calls."""
|
||||
self.final_llm_resp = llm_resp
|
||||
self._transition_state(AgentState.DONE)
|
||||
self.stats.end_time = time.time()
|
||||
|
||||
parts = []
|
||||
if llm_resp.reasoning_content or llm_resp.reasoning_signature:
|
||||
parts.append(
|
||||
ThinkPart(
|
||||
think=llm_resp.reasoning_content,
|
||||
encrypted=llm_resp.reasoning_signature,
|
||||
)
|
||||
)
|
||||
if llm_resp.completion_text:
|
||||
parts.append(TextPart(text=llm_resp.completion_text))
|
||||
if len(parts) == 0:
|
||||
logger.warning("LLM returned empty assistant message with no tool calls.")
|
||||
self.run_context.messages.append(Message(role="assistant", content=parts))
|
||||
|
||||
try:
|
||||
await self.agent_hooks.on_agent_done(self.run_context, llm_resp)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in on_agent_done hook: {e}", exc_info=True)
|
||||
self._resolve_unconsumed_follow_ups()
|
||||
|
||||
@override
|
||||
async def reset(
|
||||
self,
|
||||
@@ -216,8 +123,6 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
custom_compressor: ContextCompressor | None = None,
|
||||
tool_schema_mode: str | None = "full",
|
||||
fallback_providers: list[Provider] | None = None,
|
||||
tool_result_overflow_dir: str | None = None,
|
||||
read_tool: FunctionTool | None = None,
|
||||
**kwargs: T.Any,
|
||||
) -> None:
|
||||
self.req = request
|
||||
@@ -229,9 +134,6 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
self.truncate_turns = truncate_turns
|
||||
self.custom_token_counter = custom_token_counter
|
||||
self.custom_compressor = custom_compressor
|
||||
self.tool_result_overflow_dir = tool_result_overflow_dir
|
||||
self.read_tool = read_tool
|
||||
self._tool_result_token_counter = EstimateTokenCounter()
|
||||
# we will do compress when:
|
||||
# 1. before requesting LLM
|
||||
# TODO: 2. after LLM output a tool call
|
||||
@@ -270,8 +172,6 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
self._abort_signal = asyncio.Event()
|
||||
self._pending_follow_ups: list[FollowUpTicket] = []
|
||||
self._follow_up_seq = 0
|
||||
self._last_tool_name: str | None = None
|
||||
self._same_tool_streak = 0
|
||||
|
||||
# These two are used for tool schema mode handling
|
||||
# We now have two modes:
|
||||
@@ -313,103 +213,6 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
self.stats = AgentStats()
|
||||
self.stats.start_time = time.time()
|
||||
|
||||
def _read_tool_hint(self) -> str:
|
||||
if self.read_tool is not None:
|
||||
return f"`{self.read_tool.name}`"
|
||||
return "the available file-read tool"
|
||||
|
||||
async def _write_tool_result_overflow_file(
|
||||
self,
|
||||
*,
|
||||
tool_call_id: str,
|
||||
content: str,
|
||||
) -> str:
|
||||
if self.tool_result_overflow_dir is None:
|
||||
raise ValueError("tool_result_overflow_dir is not configured")
|
||||
|
||||
overflow_dir = Path(self.tool_result_overflow_dir).resolve(strict=False)
|
||||
safe_tool_call_id = (
|
||||
"".join(
|
||||
ch if ch.isalnum() or ch in {"-", "_", "."} else "_"
|
||||
for ch in tool_call_id
|
||||
).strip("._")
|
||||
or "tool_call"
|
||||
)
|
||||
file_name = f"{safe_tool_call_id}_{uuid.uuid4().hex[:8]}.txt"
|
||||
overflow_path = overflow_dir / file_name
|
||||
|
||||
def _run() -> str:
|
||||
overflow_dir.mkdir(parents=True, exist_ok=True)
|
||||
overflow_path.write_text(content, encoding="utf-8")
|
||||
return str(overflow_path)
|
||||
|
||||
return await asyncio.to_thread(_run)
|
||||
|
||||
async def _materialize_large_tool_result(
|
||||
self,
|
||||
*,
|
||||
tool_call_id: str,
|
||||
content: str,
|
||||
) -> str:
|
||||
if self.tool_result_overflow_dir is None or self.read_tool is None:
|
||||
return content
|
||||
|
||||
estimated_tokens = self._tool_result_token_counter.count_tokens(
|
||||
[Message(role="tool", content=content, tool_call_id=tool_call_id)]
|
||||
)
|
||||
if estimated_tokens <= self.TOOL_RESULT_MAX_ESTIMATED_TOKENS:
|
||||
return content
|
||||
|
||||
preview = self._truncate_tool_result_preview(content, tool_call_id=tool_call_id)
|
||||
try:
|
||||
overflow_path = await self._write_tool_result_overflow_file(
|
||||
tool_call_id=tool_call_id,
|
||||
content=content,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Failed to spill oversized tool result for %s: %s",
|
||||
tool_call_id,
|
||||
exc,
|
||||
exc_info=True,
|
||||
)
|
||||
error_notice = (
|
||||
"Tool output exceeded the inline result limit "
|
||||
f"({estimated_tokens} estimated tokens > "
|
||||
f"{self.TOOL_RESULT_MAX_ESTIMATED_TOKENS}) and could not be written "
|
||||
f"to `{self.tool_result_overflow_dir}`: {exc}"
|
||||
)
|
||||
if not preview:
|
||||
return error_notice
|
||||
return f"{preview}\n\n{error_notice}"
|
||||
|
||||
notice = self.TOOL_RESULT_OVERFLOW_NOTICE_TEMPLATE.format(
|
||||
overflow_path=overflow_path,
|
||||
read_tool_hint=self._read_tool_hint(),
|
||||
)
|
||||
if not preview:
|
||||
return notice
|
||||
return f"{preview}\n\n{notice}"
|
||||
|
||||
def _truncate_tool_result_preview(
|
||||
self,
|
||||
content: str,
|
||||
*,
|
||||
tool_call_id: str,
|
||||
) -> str:
|
||||
preview = content
|
||||
while preview:
|
||||
estimated_tokens = self._tool_result_token_counter.count_tokens(
|
||||
[Message(role="tool", content=preview, tool_call_id=tool_call_id)]
|
||||
)
|
||||
if estimated_tokens <= self.TOOL_RESULT_PREVIEW_MAX_ESTIMATED_TOKENS:
|
||||
return preview
|
||||
next_len = len(preview) // 2
|
||||
if next_len <= 0:
|
||||
break
|
||||
preview = preview[:next_len]
|
||||
return preview
|
||||
|
||||
async def _iter_llm_responses(
|
||||
self, *, include_model: bool = True
|
||||
) -> T.AsyncGenerator[LLMResponse, None]:
|
||||
@@ -450,61 +253,31 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
candidate_id,
|
||||
)
|
||||
self.provider = candidate
|
||||
has_stream_output = False
|
||||
try:
|
||||
retrying = AsyncRetrying(
|
||||
retry=retry_if_exception_type(EmptyModelOutputError),
|
||||
stop=stop_after_attempt(self.EMPTY_OUTPUT_RETRY_ATTEMPTS),
|
||||
wait=wait_exponential(
|
||||
multiplier=1,
|
||||
min=self.EMPTY_OUTPUT_RETRY_WAIT_MIN_S,
|
||||
max=self.EMPTY_OUTPUT_RETRY_WAIT_MAX_S,
|
||||
),
|
||||
reraise=True,
|
||||
)
|
||||
async for resp in self._iter_llm_responses(include_model=idx == 0):
|
||||
if resp.is_chunk:
|
||||
has_stream_output = True
|
||||
yield resp
|
||||
continue
|
||||
|
||||
async for attempt in retrying:
|
||||
has_stream_output = False
|
||||
with attempt:
|
||||
try:
|
||||
async for resp in self._iter_llm_responses(
|
||||
include_model=idx == 0
|
||||
):
|
||||
if resp.is_chunk:
|
||||
has_stream_output = True
|
||||
yield resp
|
||||
continue
|
||||
if (
|
||||
resp.role == "err"
|
||||
and not has_stream_output
|
||||
and (not is_last_candidate)
|
||||
):
|
||||
last_err_response = resp
|
||||
logger.warning(
|
||||
"Chat Model %s returns error response, trying fallback to next provider.",
|
||||
candidate_id,
|
||||
)
|
||||
break
|
||||
|
||||
if (
|
||||
resp.role == "err"
|
||||
and not has_stream_output
|
||||
and (not is_last_candidate)
|
||||
):
|
||||
last_err_response = resp
|
||||
logger.warning(
|
||||
"Chat Model %s returns error response, trying fallback to next provider.",
|
||||
candidate_id,
|
||||
)
|
||||
break
|
||||
yield resp
|
||||
return
|
||||
|
||||
yield resp
|
||||
return
|
||||
|
||||
if has_stream_output:
|
||||
return
|
||||
except EmptyModelOutputError:
|
||||
if has_stream_output:
|
||||
logger.warning(
|
||||
"Chat Model %s returned empty output after streaming started; skipping empty-output retry.",
|
||||
candidate_id,
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"Chat Model %s returned empty output on attempt %s/%s.",
|
||||
candidate_id,
|
||||
attempt.retry_state.attempt_number,
|
||||
self.EMPTY_OUTPUT_RETRY_ATTEMPTS,
|
||||
)
|
||||
raise
|
||||
if has_stream_output:
|
||||
return
|
||||
except Exception as exc: # noqa: BLE001
|
||||
last_exception = exc
|
||||
logger.warning(
|
||||
@@ -573,8 +346,12 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
follow_up_lines = "\n".join(
|
||||
f"{idx}. {ticket.text}" for idx, ticket in enumerate(follow_ups, start=1)
|
||||
)
|
||||
return self.FOLLOW_UP_NOTICE_TEMPLATE.format(
|
||||
follow_up_lines=follow_up_lines,
|
||||
return (
|
||||
"\n\n[SYSTEM NOTICE] User sent follow-up messages while tool execution "
|
||||
"was in progress. Prioritize these follow-up instructions in your next "
|
||||
"actions. In your very next action, briefly acknowledge to the user "
|
||||
"that their follow-up message(s) were received before continuing.\n"
|
||||
f"{follow_up_lines}"
|
||||
)
|
||||
|
||||
def _merge_follow_up_notice(self, content: str) -> str:
|
||||
@@ -583,35 +360,6 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
return content
|
||||
return f"{content}{notice}"
|
||||
|
||||
def _track_tool_call_streak(self, tool_name: str) -> int:
|
||||
if tool_name == self._last_tool_name:
|
||||
self._same_tool_streak += 1
|
||||
else:
|
||||
self._last_tool_name = tool_name
|
||||
self._same_tool_streak = 1
|
||||
return self._same_tool_streak
|
||||
|
||||
def _build_repeated_tool_call_guidance(self, tool_name: str, streak: int) -> str:
|
||||
if streak < self.REPEATED_TOOL_NOTICE_L1_THRESHOLD:
|
||||
return ""
|
||||
|
||||
if streak >= self.REPEATED_TOOL_NOTICE_L3_THRESHOLD:
|
||||
return self.REPEATED_TOOL_NOTICE_L3_TEMPLATE.format(
|
||||
tool_name=tool_name,
|
||||
streak=streak,
|
||||
)
|
||||
|
||||
if streak >= self.REPEATED_TOOL_NOTICE_L2_THRESHOLD:
|
||||
return self.REPEATED_TOOL_NOTICE_L2_TEMPLATE.format(
|
||||
tool_name=tool_name,
|
||||
streak=streak,
|
||||
)
|
||||
|
||||
return self.REPEATED_TOOL_NOTICE_L1_TEMPLATE.format(
|
||||
tool_name=tool_name,
|
||||
streak=streak,
|
||||
)
|
||||
|
||||
@override
|
||||
async def step(self):
|
||||
"""Process a single step of the agent.
|
||||
@@ -668,7 +416,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
if self._is_stop_requested():
|
||||
llm_resp_result = LLMResponse(
|
||||
role="assistant",
|
||||
completion_text=self.USER_INTERRUPTION_MESSAGE,
|
||||
completion_text=USER_INTERRUPTION_MESSAGE,
|
||||
reasoning_content=llm_response.reasoning_content,
|
||||
reasoning_signature=llm_response.reasoning_signature,
|
||||
)
|
||||
@@ -715,7 +463,34 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
return
|
||||
|
||||
if not llm_resp.tools_call_name:
|
||||
await self._complete_with_assistant_response(llm_resp)
|
||||
# 如果没有工具调用,转换到完成状态
|
||||
self.final_llm_resp = llm_resp
|
||||
self._transition_state(AgentState.DONE)
|
||||
self.stats.end_time = time.time()
|
||||
|
||||
# record the final assistant message
|
||||
parts = []
|
||||
if llm_resp.reasoning_content or llm_resp.reasoning_signature:
|
||||
parts.append(
|
||||
ThinkPart(
|
||||
think=llm_resp.reasoning_content,
|
||||
encrypted=llm_resp.reasoning_signature,
|
||||
)
|
||||
)
|
||||
if llm_resp.completion_text:
|
||||
parts.append(TextPart(text=llm_resp.completion_text))
|
||||
if len(parts) == 0:
|
||||
logger.warning(
|
||||
"LLM returned empty assistant message with no tool calls."
|
||||
)
|
||||
self.run_context.messages.append(Message(role="assistant", content=parts))
|
||||
|
||||
# call the on_agent_done hook
|
||||
try:
|
||||
await self.agent_hooks.on_agent_done(self.run_context, llm_resp)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in on_agent_done hook: {e}", exc_info=True)
|
||||
self._resolve_unconsumed_follow_ups()
|
||||
|
||||
# 返回 LLM 结果
|
||||
if llm_resp.result_chain:
|
||||
@@ -735,24 +510,6 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
if llm_resp.tools_call_name:
|
||||
if self.tool_schema_mode == "skills_like":
|
||||
llm_resp, _ = await self._resolve_tool_exec(llm_resp)
|
||||
if not llm_resp.tools_call_name:
|
||||
logger.warning(
|
||||
"skills_like tool re-query returned no tool calls; fallback to assistant response."
|
||||
)
|
||||
if llm_resp.result_chain:
|
||||
yield AgentResponse(
|
||||
type="llm_result",
|
||||
data=AgentResponseData(chain=llm_resp.result_chain),
|
||||
)
|
||||
elif llm_resp.completion_text:
|
||||
yield AgentResponse(
|
||||
type="llm_result",
|
||||
data=AgentResponseData(
|
||||
chain=MessageChain().message(llm_resp.completion_text),
|
||||
),
|
||||
)
|
||||
await self._complete_with_assistant_response(llm_resp)
|
||||
return
|
||||
|
||||
tool_call_result_blocks = []
|
||||
cached_images = [] # Collect cached images for LLM visibility
|
||||
@@ -866,7 +623,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
self.run_context.messages.append(
|
||||
Message(
|
||||
role="user",
|
||||
content=self.MAX_STEPS_REACHED_PROMPT,
|
||||
content="工具调用次数已达到上限,请停止使用工具,并根据已经收集到的信息,对你的任务和发现进行总结,然后直接回复用户。",
|
||||
)
|
||||
)
|
||||
# 再执行最后一步
|
||||
@@ -897,7 +654,6 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
llm_response.tools_call_args,
|
||||
llm_response.tools_call_ids,
|
||||
):
|
||||
tool_call_streak = self._track_tool_call_streak(func_tool_name)
|
||||
yield _HandleFunctionToolsResult.from_message_chain(
|
||||
MessageChain(
|
||||
type="tool_call",
|
||||
@@ -1045,17 +801,9 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
"The tool has returned a data type that is not supported."
|
||||
)
|
||||
if result_parts:
|
||||
inline_result = "\n\n".join(result_parts)
|
||||
inline_result = await self._materialize_large_tool_result(
|
||||
tool_call_id=func_tool_id,
|
||||
content=inline_result,
|
||||
)
|
||||
_append_tool_call_result(
|
||||
func_tool_id,
|
||||
inline_result
|
||||
+ self._build_repeated_tool_call_guidance(
|
||||
func_tool_name, tool_call_streak
|
||||
),
|
||||
"\n\n".join(result_parts),
|
||||
)
|
||||
|
||||
elif resp is None:
|
||||
@@ -1069,10 +817,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
self.stats.end_time = time.time()
|
||||
_append_tool_call_result(
|
||||
func_tool_id,
|
||||
"The tool has no return value, or has sent the result directly to the user."
|
||||
+ self._build_repeated_tool_call_guidance(
|
||||
func_tool_name, tool_call_streak
|
||||
),
|
||||
"The tool has no return value, or has sent the result directly to the user.",
|
||||
)
|
||||
else:
|
||||
# 不应该出现其他类型
|
||||
@@ -1081,10 +826,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
)
|
||||
_append_tool_call_result(
|
||||
func_tool_id,
|
||||
"*The tool has returned an unsupported type. Please tell the user to check the definition and implementation of this tool.*"
|
||||
+ self._build_repeated_tool_call_guidance(
|
||||
func_tool_name, tool_call_streak
|
||||
),
|
||||
"*The tool has returned an unsupported type. Please tell the user to check the definition and implementation of this tool.*",
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -1102,10 +844,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
logger.warning(traceback.format_exc())
|
||||
_append_tool_call_result(
|
||||
func_tool_id,
|
||||
f"error: {e!s}"
|
||||
+ self._build_repeated_tool_call_guidance(
|
||||
func_tool_name, tool_call_streak
|
||||
),
|
||||
f"error: {e!s}",
|
||||
)
|
||||
|
||||
# yield the last tool call result
|
||||
@@ -1134,9 +873,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
)
|
||||
|
||||
def _build_tool_requery_context(
|
||||
self,
|
||||
tool_names: list[str],
|
||||
extra_instruction: str | None = None,
|
||||
self, tool_names: list[str]
|
||||
) -> list[dict[str, T.Any]]:
|
||||
"""Build contexts for re-querying LLM with param-only tool schemas."""
|
||||
contexts: list[dict[str, T.Any]] = []
|
||||
@@ -1145,11 +882,12 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
contexts.append(msg.model_dump()) # type: ignore[call-arg]
|
||||
elif isinstance(msg, dict):
|
||||
contexts.append(copy.deepcopy(msg))
|
||||
instruction = self.SKILLS_LIKE_REQUERY_INSTRUCTION_TEMPLATE.format(
|
||||
tool_names=", ".join(tool_names)
|
||||
instruction = (
|
||||
"You have decided to call tool(s): "
|
||||
+ ", ".join(tool_names)
|
||||
+ ". Now call the tool(s) with required arguments using the tool schema, "
|
||||
"and follow the existing tool-use rules."
|
||||
)
|
||||
if extra_instruction:
|
||||
instruction = f"{instruction}\n{extra_instruction}"
|
||||
if contexts and contexts[0].get("role") == "system":
|
||||
content = contexts[0].get("content") or ""
|
||||
contexts[0]["content"] = f"{content}\n{instruction}"
|
||||
@@ -1157,11 +895,6 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
contexts.insert(0, {"role": "system", "content": instruction})
|
||||
return contexts
|
||||
|
||||
@staticmethod
|
||||
def _has_meaningful_assistant_reply(llm_resp: LLMResponse) -> bool:
|
||||
text = (llm_resp.completion_text or "").strip()
|
||||
return bool(text)
|
||||
|
||||
def _build_tool_subset(self, tool_set: ToolSet, tool_names: list[str]) -> ToolSet:
|
||||
"""Build a subset of tools from the given tool set based on tool names."""
|
||||
subset = ToolSet()
|
||||
@@ -1199,38 +932,11 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
model=self.req.model,
|
||||
session_id=self.req.session_id,
|
||||
extra_user_content_parts=self.req.extra_user_content_parts,
|
||||
tool_choice="required",
|
||||
abort_signal=self._abort_signal,
|
||||
)
|
||||
if requery_resp:
|
||||
llm_resp = requery_resp
|
||||
|
||||
# If the re-query still returns no tool calls, and also does not have a meaningful assistant reply,
|
||||
# we consider it as a failure of the LLM to follow the tool-use instruction,
|
||||
# and we will retry once with a stronger instruction that explicitly requires the LLM to either call the tool or give an explanation.
|
||||
if (
|
||||
not llm_resp.tools_call_name
|
||||
and not self._has_meaningful_assistant_reply(llm_resp)
|
||||
):
|
||||
logger.warning(
|
||||
"skills_like tool re-query returned no tool calls and no explanation; retrying with stronger instruction."
|
||||
)
|
||||
repair_contexts = self._build_tool_requery_context(
|
||||
tool_names,
|
||||
extra_instruction=self.SKILLS_LIKE_REQUERY_REPAIR_INSTRUCTION,
|
||||
)
|
||||
repair_resp = await self.provider.text_chat(
|
||||
contexts=repair_contexts,
|
||||
func_tool=param_subset,
|
||||
model=self.req.model,
|
||||
session_id=self.req.session_id,
|
||||
extra_user_content_parts=self.req.extra_user_content_parts,
|
||||
tool_choice="required",
|
||||
abort_signal=self._abort_signal,
|
||||
)
|
||||
if repair_resp:
|
||||
llm_resp = repair_resp
|
||||
|
||||
return llm_resp, subset
|
||||
|
||||
def done(self) -> bool:
|
||||
@@ -1259,7 +965,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
if llm_resp.role != "assistant":
|
||||
llm_resp = LLMResponse(
|
||||
role="assistant",
|
||||
completion_text=self.USER_INTERRUPTION_MESSAGE,
|
||||
completion_text=USER_INTERRUPTION_MESSAGE,
|
||||
)
|
||||
self.final_llm_resp = llm_resp
|
||||
self._aborted = True
|
||||
|
||||
@@ -59,13 +59,7 @@ class MainAgentHooks(BaseAgentRunHooks[AstrAgentContext]):
|
||||
platform_name = run_context.context.event.get_platform_name()
|
||||
if (
|
||||
platform_name == "webchat"
|
||||
and tool.name
|
||||
in [
|
||||
"web_search_baidu",
|
||||
"web_search_tavily",
|
||||
"web_search_bocha",
|
||||
"web_search_brave",
|
||||
]
|
||||
and tool.name in ["web_search_tavily", "web_search_bocha"]
|
||||
and len(run_context.messages) > 0
|
||||
and tool_result
|
||||
and len(tool_result.content)
|
||||
|
||||
@@ -19,6 +19,13 @@ from astrbot.core.agent.tool_executor import BaseFunctionToolExecutor
|
||||
from astrbot.core.astr_agent_context import AstrAgentContext
|
||||
from astrbot.core.astr_main_agent_resources import (
|
||||
BACKGROUND_TASK_RESULT_WOKE_SYSTEM_PROMPT,
|
||||
EXECUTE_SHELL_TOOL,
|
||||
FILE_DOWNLOAD_TOOL,
|
||||
FILE_UPLOAD_TOOL,
|
||||
LOCAL_EXECUTE_SHELL_TOOL,
|
||||
LOCAL_PYTHON_TOOL,
|
||||
PYTHON_TOOL,
|
||||
SEND_MESSAGE_TO_USER_TOOL,
|
||||
)
|
||||
from astrbot.core.cron.events import CronMessageEvent
|
||||
from astrbot.core.message.components import Image
|
||||
@@ -30,18 +37,6 @@ from astrbot.core.message.message_event_result import (
|
||||
from astrbot.core.platform.message_session import MessageSession
|
||||
from astrbot.core.provider.entites import ProviderRequest
|
||||
from astrbot.core.provider.register import llm_tools
|
||||
from astrbot.core.tools.computer_tools import (
|
||||
ExecuteShellTool,
|
||||
FileDownloadTool,
|
||||
FileEditTool,
|
||||
FileReadTool,
|
||||
FileUploadTool,
|
||||
FileWriteTool,
|
||||
GrepTool,
|
||||
LocalPythonTool,
|
||||
PythonTool,
|
||||
)
|
||||
from astrbot.core.tools.message_tools import SendMessageToUserTool
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
|
||||
from astrbot.core.utils.history_saver import persist_agent_history
|
||||
from astrbot.core.utils.image_ref_utils import is_supported_image_ref
|
||||
@@ -182,44 +177,18 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
|
||||
return
|
||||
|
||||
@classmethod
|
||||
def _get_runtime_computer_tools(
|
||||
cls,
|
||||
runtime: str,
|
||||
tool_mgr,
|
||||
) -> dict[str, FunctionTool]:
|
||||
def _get_runtime_computer_tools(cls, runtime: str) -> dict[str, FunctionTool]:
|
||||
if runtime == "sandbox":
|
||||
shell_tool = tool_mgr.get_builtin_tool(ExecuteShellTool)
|
||||
python_tool = tool_mgr.get_builtin_tool(PythonTool)
|
||||
upload_tool = tool_mgr.get_builtin_tool(FileUploadTool)
|
||||
download_tool = tool_mgr.get_builtin_tool(FileDownloadTool)
|
||||
read_tool = tool_mgr.get_builtin_tool(FileReadTool)
|
||||
write_tool = tool_mgr.get_builtin_tool(FileWriteTool)
|
||||
edit_tool = tool_mgr.get_builtin_tool(FileEditTool)
|
||||
grep_tool = tool_mgr.get_builtin_tool(GrepTool)
|
||||
return {
|
||||
shell_tool.name: shell_tool,
|
||||
python_tool.name: python_tool,
|
||||
upload_tool.name: upload_tool,
|
||||
download_tool.name: download_tool,
|
||||
read_tool.name: read_tool,
|
||||
write_tool.name: write_tool,
|
||||
edit_tool.name: edit_tool,
|
||||
grep_tool.name: grep_tool,
|
||||
EXECUTE_SHELL_TOOL.name: EXECUTE_SHELL_TOOL,
|
||||
PYTHON_TOOL.name: PYTHON_TOOL,
|
||||
FILE_UPLOAD_TOOL.name: FILE_UPLOAD_TOOL,
|
||||
FILE_DOWNLOAD_TOOL.name: FILE_DOWNLOAD_TOOL,
|
||||
}
|
||||
if runtime == "local":
|
||||
shell_tool = tool_mgr.get_builtin_tool(ExecuteShellTool)
|
||||
python_tool = tool_mgr.get_builtin_tool(LocalPythonTool)
|
||||
read_tool = tool_mgr.get_builtin_tool(FileReadTool)
|
||||
write_tool = tool_mgr.get_builtin_tool(FileWriteTool)
|
||||
edit_tool = tool_mgr.get_builtin_tool(FileEditTool)
|
||||
grep_tool = tool_mgr.get_builtin_tool(GrepTool)
|
||||
return {
|
||||
shell_tool.name: shell_tool,
|
||||
python_tool.name: python_tool,
|
||||
read_tool.name: read_tool,
|
||||
write_tool.name: write_tool,
|
||||
edit_tool.name: edit_tool,
|
||||
grep_tool.name: grep_tool,
|
||||
LOCAL_EXECUTE_SHELL_TOOL.name: LOCAL_EXECUTE_SHELL_TOOL,
|
||||
LOCAL_PYTHON_TOOL.name: LOCAL_PYTHON_TOOL,
|
||||
}
|
||||
return {}
|
||||
|
||||
@@ -234,15 +203,7 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
|
||||
cfg = ctx.get_config(umo=event.unified_msg_origin)
|
||||
provider_settings = cfg.get("provider_settings", {})
|
||||
runtime = str(provider_settings.get("computer_use_runtime", "local"))
|
||||
tool_mgr = (
|
||||
ctx.get_llm_tool_manager()
|
||||
if hasattr(ctx, "get_llm_tool_manager")
|
||||
else llm_tools
|
||||
)
|
||||
runtime_computer_tools = cls._get_runtime_computer_tools(
|
||||
runtime,
|
||||
tool_mgr,
|
||||
)
|
||||
runtime_computer_tools = cls._get_runtime_computer_tools(runtime)
|
||||
|
||||
# Keep persona semantics aligned with the main agent: tools=None means
|
||||
# "all tools", including runtime computer-use tools.
|
||||
@@ -554,9 +515,7 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
|
||||
)
|
||||
if not req.func_tool:
|
||||
req.func_tool = ToolSet()
|
||||
req.func_tool.add_tool(
|
||||
ctx.get_llm_tool_manager().get_builtin_tool(SendMessageToUserTool)
|
||||
)
|
||||
req.func_tool.add_tool(SEND_MESSAGE_TO_USER_TOOL)
|
||||
|
||||
result = await build_main_agent(
|
||||
event=cron_event, plugin_context=ctx, config=config, req=req
|
||||
|
||||
@@ -9,7 +9,6 @@ import platform
|
||||
import zoneinfo
|
||||
from collections.abc import Coroutine
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
from astrbot.core import logger
|
||||
from astrbot.core.agent.handoff import HandoffTool
|
||||
@@ -21,15 +20,38 @@ from astrbot.core.astr_agent_hooks import MAIN_AGENT_HOOKS
|
||||
from astrbot.core.astr_agent_run_util import AgentRunner
|
||||
from astrbot.core.astr_agent_tool_exec import FunctionToolExecutor
|
||||
from astrbot.core.astr_main_agent_resources import (
|
||||
ANNOTATE_EXECUTION_TOOL,
|
||||
BROWSER_BATCH_EXEC_TOOL,
|
||||
BROWSER_EXEC_TOOL,
|
||||
CHATUI_SPECIAL_DEFAULT_PERSONA_PROMPT,
|
||||
CREATE_SKILL_CANDIDATE_TOOL,
|
||||
CREATE_SKILL_PAYLOAD_TOOL,
|
||||
EVALUATE_SKILL_CANDIDATE_TOOL,
|
||||
EXECUTE_SHELL_TOOL,
|
||||
FILE_DOWNLOAD_TOOL,
|
||||
FILE_UPLOAD_TOOL,
|
||||
GET_EXECUTION_HISTORY_TOOL,
|
||||
GET_SKILL_PAYLOAD_TOOL,
|
||||
KNOWLEDGE_BASE_QUERY_TOOL,
|
||||
LIST_SKILL_CANDIDATES_TOOL,
|
||||
LIST_SKILL_RELEASES_TOOL,
|
||||
LIVE_MODE_SYSTEM_PROMPT,
|
||||
LLM_SAFETY_MODE_SYSTEM_PROMPT,
|
||||
LOCAL_EXECUTE_SHELL_TOOL,
|
||||
LOCAL_PYTHON_TOOL,
|
||||
PROMOTE_SKILL_CANDIDATE_TOOL,
|
||||
PYTHON_TOOL,
|
||||
ROLLBACK_SKILL_RELEASE_TOOL,
|
||||
RUN_BROWSER_SKILL_TOOL,
|
||||
SANDBOX_MODE_PROMPT,
|
||||
SEND_MESSAGE_TO_USER_TOOL,
|
||||
SYNC_SKILL_RELEASE_TOOL,
|
||||
TOOL_CALL_PROMPT,
|
||||
TOOL_CALL_PROMPT_SKILLS_LIKE_MODE,
|
||||
retrieve_knowledge_base,
|
||||
)
|
||||
from astrbot.core.conversation_mgr import Conversation
|
||||
from astrbot.core.message.components import File, Image, Record, Reply
|
||||
from astrbot.core.message.components import File, Image, Reply
|
||||
from astrbot.core.persona_error_reply import (
|
||||
extract_persona_custom_error_message_from_persona,
|
||||
set_persona_custom_error_message_on_event,
|
||||
@@ -37,53 +59,13 @@ from astrbot.core.persona_error_reply import (
|
||||
from astrbot.core.platform.astr_message_event import AstrMessageEvent
|
||||
from astrbot.core.provider import Provider
|
||||
from astrbot.core.provider.entities import ProviderRequest
|
||||
from astrbot.core.provider.register import llm_tools
|
||||
from astrbot.core.skills.skill_manager import SkillManager, build_skills_prompt
|
||||
from astrbot.core.star.context import Context
|
||||
from astrbot.core.star.star_handler import star_map
|
||||
from astrbot.core.tools.computer_tools import (
|
||||
AnnotateExecutionTool,
|
||||
BrowserBatchExecTool,
|
||||
BrowserExecTool,
|
||||
CreateSkillCandidateTool,
|
||||
CreateSkillPayloadTool,
|
||||
EvaluateSkillCandidateTool,
|
||||
ExecuteShellTool,
|
||||
FileDownloadTool,
|
||||
FileEditTool,
|
||||
FileReadTool,
|
||||
FileUploadTool,
|
||||
FileWriteTool,
|
||||
GetExecutionHistoryTool,
|
||||
GetSkillPayloadTool,
|
||||
GrepTool,
|
||||
ListSkillCandidatesTool,
|
||||
ListSkillReleasesTool,
|
||||
LocalPythonTool,
|
||||
PromoteSkillCandidateTool,
|
||||
PythonTool,
|
||||
RollbackSkillReleaseTool,
|
||||
RunBrowserSkillTool,
|
||||
SyncSkillReleaseTool,
|
||||
normalize_umo_for_workspace,
|
||||
)
|
||||
from astrbot.core.tools.cron_tools import FutureTaskTool
|
||||
from astrbot.core.tools.knowledge_base_tools import (
|
||||
KnowledgeBaseQueryTool,
|
||||
retrieve_knowledge_base,
|
||||
)
|
||||
from astrbot.core.tools.message_tools import SendMessageToUserTool
|
||||
from astrbot.core.tools.web_search_tools import (
|
||||
BaiduWebSearchTool,
|
||||
BochaWebSearchTool,
|
||||
BraveWebSearchTool,
|
||||
TavilyExtractWebPageTool,
|
||||
TavilyWebSearchTool,
|
||||
normalize_legacy_web_search_config,
|
||||
)
|
||||
from astrbot.core.utils.astrbot_path import (
|
||||
get_astrbot_system_tmp_path,
|
||||
get_astrbot_workspaces_path,
|
||||
from astrbot.core.tools.cron_tools import (
|
||||
CREATE_CRON_JOB_TOOL,
|
||||
DELETE_CRON_JOB_TOOL,
|
||||
LIST_CRON_JOBS_TOOL,
|
||||
)
|
||||
from astrbot.core.utils.file_extract import extract_file_moonshotai
|
||||
from astrbot.core.utils.llm_metadata import LLM_METADATAS
|
||||
@@ -236,11 +218,7 @@ async def _apply_kb(
|
||||
else:
|
||||
if req.func_tool is None:
|
||||
req.func_tool = ToolSet()
|
||||
req.func_tool.add_tool(
|
||||
plugin_context.get_llm_tool_manager().get_builtin_tool(
|
||||
KnowledgeBaseQueryTool
|
||||
)
|
||||
)
|
||||
req.func_tool.add_tool(KNOWLEDGE_BASE_QUERY_TOOL)
|
||||
|
||||
|
||||
async def _apply_file_extract(
|
||||
@@ -302,54 +280,11 @@ def _apply_prompt_prefix(req: ProviderRequest, cfg: dict) -> None:
|
||||
req.prompt = f"{prefix}{req.prompt}"
|
||||
|
||||
|
||||
def _get_workspace_path_for_umo(umo: str) -> Path:
|
||||
normalized_umo = normalize_umo_for_workspace(umo)
|
||||
return Path(get_astrbot_workspaces_path()) / normalized_umo
|
||||
|
||||
|
||||
def _apply_workspace_extra_prompt(
|
||||
event: AstrMessageEvent,
|
||||
req: ProviderRequest,
|
||||
) -> None:
|
||||
extra_prompt_path = _get_workspace_path_for_umo(event.unified_msg_origin) / (
|
||||
"EXTRA_PROMPT.md"
|
||||
)
|
||||
if not extra_prompt_path.is_file():
|
||||
return
|
||||
|
||||
try:
|
||||
extra_prompt = extra_prompt_path.read_text(encoding="utf-8").strip()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning(
|
||||
"Failed to read workspace extra prompt for umo=%s from %s: %s",
|
||||
event.unified_msg_origin,
|
||||
extra_prompt_path,
|
||||
exc,
|
||||
)
|
||||
return
|
||||
|
||||
if not extra_prompt:
|
||||
return
|
||||
|
||||
req.system_prompt = (
|
||||
f"{req.system_prompt or ''}\n"
|
||||
"[Workspace Extra Prompt]\n"
|
||||
"The following instructions are loaded from the current workspace "
|
||||
"`EXTRA_PROMPT.md` file.\n"
|
||||
f"{extra_prompt}\n"
|
||||
)
|
||||
|
||||
|
||||
def _apply_local_env_tools(req: ProviderRequest, plugin_context: Context) -> None:
|
||||
def _apply_local_env_tools(req: ProviderRequest) -> None:
|
||||
if req.func_tool is None:
|
||||
req.func_tool = ToolSet()
|
||||
tool_mgr = plugin_context.get_llm_tool_manager()
|
||||
req.func_tool.add_tool(tool_mgr.get_builtin_tool(ExecuteShellTool))
|
||||
req.func_tool.add_tool(tool_mgr.get_builtin_tool(LocalPythonTool))
|
||||
req.func_tool.add_tool(tool_mgr.get_builtin_tool(FileReadTool))
|
||||
req.func_tool.add_tool(tool_mgr.get_builtin_tool(FileWriteTool))
|
||||
req.func_tool.add_tool(tool_mgr.get_builtin_tool(FileEditTool))
|
||||
req.func_tool.add_tool(tool_mgr.get_builtin_tool(GrepTool))
|
||||
req.func_tool.add_tool(LOCAL_EXECUTE_SHELL_TOOL)
|
||||
req.func_tool.add_tool(LOCAL_PYTHON_TOOL)
|
||||
req.system_prompt = f"{req.system_prompt or ''}\n{_build_local_mode_prompt()}\n"
|
||||
|
||||
|
||||
@@ -580,18 +515,6 @@ def _append_quoted_image_attachment(req: ProviderRequest, image_path: str) -> No
|
||||
)
|
||||
|
||||
|
||||
def _append_audio_attachment(req: ProviderRequest, audio_path: str) -> None:
|
||||
req.extra_user_content_parts.append(
|
||||
TextPart(text=f"[Audio Attachment: path {audio_path}]")
|
||||
)
|
||||
|
||||
|
||||
def _append_quoted_audio_attachment(req: ProviderRequest, audio_path: str) -> None:
|
||||
req.extra_user_content_parts.append(
|
||||
TextPart(text=f"[Audio Attachment in quoted message: path {audio_path}]")
|
||||
)
|
||||
|
||||
|
||||
def _get_quoted_message_parser_settings(
|
||||
provider_settings: dict[str, object] | None,
|
||||
) -> QuotedMessageParserSettings:
|
||||
@@ -820,7 +743,6 @@ async def _decorate_llm_request(
|
||||
if tz is None:
|
||||
tz = plugin_context.get_config().get("timezone")
|
||||
_append_system_reminders(event, req, cfg, tz)
|
||||
_apply_workspace_extra_prompt(event, req)
|
||||
|
||||
|
||||
def _modalities_fix(provider: Provider, req: ProviderRequest) -> None:
|
||||
@@ -831,25 +753,12 @@ def _modalities_fix(provider: Provider, req: ProviderRequest) -> None:
|
||||
"Provider %s does not support image, using placeholder.", provider
|
||||
)
|
||||
image_count = len(req.image_urls)
|
||||
placeholder = " ".join(["[Image]"] * image_count)
|
||||
placeholder = " ".join(["[图片]"] * image_count)
|
||||
if req.prompt:
|
||||
req.prompt = f"{placeholder} {req.prompt}"
|
||||
else:
|
||||
req.prompt = placeholder
|
||||
req.image_urls = []
|
||||
if req.audio_urls:
|
||||
provider_cfg = provider.provider_config.get("modalities", ["audio"])
|
||||
if "audio" not in provider_cfg:
|
||||
logger.debug(
|
||||
"Provider %s does not support audio, using placeholder.", provider
|
||||
)
|
||||
audio_count = len(req.audio_urls)
|
||||
placeholder = " ".join(["[Audio]"] * audio_count)
|
||||
if req.prompt:
|
||||
req.prompt = f"{placeholder} {req.prompt}"
|
||||
else:
|
||||
req.prompt = placeholder
|
||||
req.audio_urls = []
|
||||
if req.func_tool:
|
||||
provider_cfg = provider.provider_config.get("modalities", ["tool_use"])
|
||||
if "tool_use" not in provider_cfg:
|
||||
@@ -872,14 +781,12 @@ def _sanitize_context_by_modalities(
|
||||
if not modalities or not isinstance(modalities, list):
|
||||
return
|
||||
supports_image = bool("image" in modalities)
|
||||
supports_audio = bool("audio" in modalities)
|
||||
supports_tool_use = bool("tool_use" in modalities)
|
||||
if supports_image and supports_audio and supports_tool_use:
|
||||
if supports_image and supports_tool_use:
|
||||
return
|
||||
|
||||
sanitized_contexts: list[dict] = []
|
||||
removed_image_blocks = 0
|
||||
removed_audio_blocks = 0
|
||||
removed_tool_messages = 0
|
||||
removed_tool_calls = 0
|
||||
|
||||
@@ -901,27 +808,20 @@ def _sanitize_context_by_modalities(
|
||||
new_msg.pop("tool_calls", None)
|
||||
new_msg.pop("tool_call_id", None)
|
||||
|
||||
if not supports_image or not supports_audio:
|
||||
if not supports_image:
|
||||
content = new_msg.get("content")
|
||||
if isinstance(content, list):
|
||||
filtered_parts: list = []
|
||||
removed_any_multimodal = False
|
||||
removed_any_image = False
|
||||
for part in content:
|
||||
if isinstance(part, dict):
|
||||
part_type = str(part.get("type", "")).lower()
|
||||
if not supports_image and part_type in {"image_url", "image"}:
|
||||
removed_any_multimodal = True
|
||||
if part_type in {"image_url", "image"}:
|
||||
removed_any_image = True
|
||||
removed_image_blocks += 1
|
||||
continue
|
||||
if not supports_audio and part_type in {
|
||||
"audio_url",
|
||||
"input_audio",
|
||||
}:
|
||||
removed_any_multimodal = True
|
||||
removed_audio_blocks += 1
|
||||
continue
|
||||
filtered_parts.append(part)
|
||||
if removed_any_multimodal:
|
||||
if removed_any_image:
|
||||
new_msg["content"] = filtered_parts
|
||||
|
||||
if role == "assistant":
|
||||
@@ -935,18 +835,11 @@ def _sanitize_context_by_modalities(
|
||||
|
||||
sanitized_contexts.append(new_msg)
|
||||
|
||||
if (
|
||||
removed_image_blocks
|
||||
or removed_audio_blocks
|
||||
or removed_tool_messages
|
||||
or removed_tool_calls
|
||||
):
|
||||
if removed_image_blocks or removed_tool_messages or removed_tool_calls:
|
||||
logger.debug(
|
||||
"sanitize_context_by_modalities applied: "
|
||||
"removed_image_blocks=%s, removed_audio_blocks=%s, "
|
||||
"removed_tool_messages=%s, removed_tool_calls=%s",
|
||||
"removed_image_blocks=%s, removed_tool_messages=%s, removed_tool_calls=%s",
|
||||
removed_image_blocks,
|
||||
removed_audio_blocks,
|
||||
removed_tool_messages,
|
||||
removed_tool_calls,
|
||||
)
|
||||
@@ -1037,9 +930,7 @@ def _apply_llm_safety_mode(config: MainAgentBuildConfig, req: ProviderRequest) -
|
||||
|
||||
|
||||
def _apply_sandbox_tools(
|
||||
config: MainAgentBuildConfig,
|
||||
req: ProviderRequest,
|
||||
session_id: str,
|
||||
config: MainAgentBuildConfig, req: ProviderRequest, session_id: str
|
||||
) -> None:
|
||||
if req.func_tool is None:
|
||||
req.func_tool = ToolSet()
|
||||
@@ -1055,15 +946,10 @@ def _apply_sandbox_tools(
|
||||
os.environ["SHIPYARD_ENDPOINT"] = ep
|
||||
os.environ["SHIPYARD_ACCESS_TOKEN"] = at
|
||||
|
||||
tool_mgr = llm_tools
|
||||
req.func_tool.add_tool(tool_mgr.get_builtin_tool(ExecuteShellTool))
|
||||
req.func_tool.add_tool(tool_mgr.get_builtin_tool(PythonTool))
|
||||
req.func_tool.add_tool(tool_mgr.get_builtin_tool(FileUploadTool))
|
||||
req.func_tool.add_tool(tool_mgr.get_builtin_tool(FileDownloadTool))
|
||||
req.func_tool.add_tool(tool_mgr.get_builtin_tool(FileReadTool))
|
||||
req.func_tool.add_tool(tool_mgr.get_builtin_tool(FileWriteTool))
|
||||
req.func_tool.add_tool(tool_mgr.get_builtin_tool(FileEditTool))
|
||||
req.func_tool.add_tool(tool_mgr.get_builtin_tool(GrepTool))
|
||||
req.func_tool.add_tool(EXECUTE_SHELL_TOOL)
|
||||
req.func_tool.add_tool(PYTHON_TOOL)
|
||||
req.func_tool.add_tool(FILE_UPLOAD_TOOL)
|
||||
req.func_tool.add_tool(FILE_DOWNLOAD_TOOL)
|
||||
if booter == "shipyard_neo":
|
||||
# Neo-specific path rule: filesystem tools operate relative to sandbox
|
||||
# workspace root. Do not prepend "/workspace".
|
||||
@@ -1099,59 +985,32 @@ def _apply_sandbox_tools(
|
||||
# Browser tools: only register if profile supports browser
|
||||
# (or if capabilities are unknown because sandbox hasn't booted yet)
|
||||
if sandbox_capabilities is None or "browser" in sandbox_capabilities:
|
||||
req.func_tool.add_tool(tool_mgr.get_builtin_tool(BrowserExecTool))
|
||||
req.func_tool.add_tool(tool_mgr.get_builtin_tool(BrowserBatchExecTool))
|
||||
req.func_tool.add_tool(tool_mgr.get_builtin_tool(RunBrowserSkillTool))
|
||||
req.func_tool.add_tool(BROWSER_EXEC_TOOL)
|
||||
req.func_tool.add_tool(BROWSER_BATCH_EXEC_TOOL)
|
||||
req.func_tool.add_tool(RUN_BROWSER_SKILL_TOOL)
|
||||
|
||||
# Neo-specific tools (always available for shipyard_neo)
|
||||
req.func_tool.add_tool(tool_mgr.get_builtin_tool(GetExecutionHistoryTool))
|
||||
req.func_tool.add_tool(tool_mgr.get_builtin_tool(AnnotateExecutionTool))
|
||||
req.func_tool.add_tool(tool_mgr.get_builtin_tool(CreateSkillPayloadTool))
|
||||
req.func_tool.add_tool(tool_mgr.get_builtin_tool(GetSkillPayloadTool))
|
||||
req.func_tool.add_tool(tool_mgr.get_builtin_tool(CreateSkillCandidateTool))
|
||||
req.func_tool.add_tool(tool_mgr.get_builtin_tool(ListSkillCandidatesTool))
|
||||
req.func_tool.add_tool(tool_mgr.get_builtin_tool(EvaluateSkillCandidateTool))
|
||||
req.func_tool.add_tool(tool_mgr.get_builtin_tool(PromoteSkillCandidateTool))
|
||||
req.func_tool.add_tool(tool_mgr.get_builtin_tool(ListSkillReleasesTool))
|
||||
req.func_tool.add_tool(tool_mgr.get_builtin_tool(RollbackSkillReleaseTool))
|
||||
req.func_tool.add_tool(tool_mgr.get_builtin_tool(SyncSkillReleaseTool))
|
||||
req.func_tool.add_tool(GET_EXECUTION_HISTORY_TOOL)
|
||||
req.func_tool.add_tool(ANNOTATE_EXECUTION_TOOL)
|
||||
req.func_tool.add_tool(CREATE_SKILL_PAYLOAD_TOOL)
|
||||
req.func_tool.add_tool(GET_SKILL_PAYLOAD_TOOL)
|
||||
req.func_tool.add_tool(CREATE_SKILL_CANDIDATE_TOOL)
|
||||
req.func_tool.add_tool(LIST_SKILL_CANDIDATES_TOOL)
|
||||
req.func_tool.add_tool(EVALUATE_SKILL_CANDIDATE_TOOL)
|
||||
req.func_tool.add_tool(PROMOTE_SKILL_CANDIDATE_TOOL)
|
||||
req.func_tool.add_tool(LIST_SKILL_RELEASES_TOOL)
|
||||
req.func_tool.add_tool(ROLLBACK_SKILL_RELEASE_TOOL)
|
||||
req.func_tool.add_tool(SYNC_SKILL_RELEASE_TOOL)
|
||||
|
||||
req.system_prompt = f"{req.system_prompt or ''}\n{SANDBOX_MODE_PROMPT}\n"
|
||||
|
||||
|
||||
def _proactive_cron_job_tools(req: ProviderRequest, plugin_context: Context) -> None:
|
||||
def _proactive_cron_job_tools(req: ProviderRequest) -> None:
|
||||
if req.func_tool is None:
|
||||
req.func_tool = ToolSet()
|
||||
tool_mgr = plugin_context.get_llm_tool_manager()
|
||||
req.func_tool.add_tool(tool_mgr.get_builtin_tool(FutureTaskTool))
|
||||
|
||||
|
||||
async def _apply_web_search_tools(
|
||||
event: AstrMessageEvent,
|
||||
req: ProviderRequest,
|
||||
plugin_context: Context,
|
||||
) -> None:
|
||||
cfg = plugin_context.get_config(umo=event.unified_msg_origin)
|
||||
normalize_legacy_web_search_config(cfg)
|
||||
prov_settings = cfg.get("provider_settings", {})
|
||||
|
||||
if not prov_settings.get("web_search", False):
|
||||
return
|
||||
|
||||
if req.func_tool is None:
|
||||
req.func_tool = ToolSet()
|
||||
|
||||
tool_mgr = plugin_context.get_llm_tool_manager()
|
||||
provider = prov_settings.get("websearch_provider", "tavily")
|
||||
if provider == "tavily":
|
||||
req.func_tool.add_tool(tool_mgr.get_builtin_tool(TavilyWebSearchTool))
|
||||
req.func_tool.add_tool(tool_mgr.get_builtin_tool(TavilyExtractWebPageTool))
|
||||
elif provider == "bocha":
|
||||
req.func_tool.add_tool(tool_mgr.get_builtin_tool(BochaWebSearchTool))
|
||||
elif provider == "brave":
|
||||
req.func_tool.add_tool(tool_mgr.get_builtin_tool(BraveWebSearchTool))
|
||||
elif provider == "baidu_ai_search":
|
||||
req.func_tool.add_tool(tool_mgr.get_builtin_tool(BaiduWebSearchTool))
|
||||
req.func_tool.add_tool(CREATE_CRON_JOB_TOOL)
|
||||
req.func_tool.add_tool(DELETE_CRON_JOB_TOOL)
|
||||
req.func_tool.add_tool(LIST_CRON_JOBS_TOOL)
|
||||
|
||||
|
||||
def _get_compress_provider(
|
||||
@@ -1242,7 +1101,6 @@ async def build_main_agent(
|
||||
req = ProviderRequest()
|
||||
req.prompt = ""
|
||||
req.image_urls = []
|
||||
req.audio_urls = []
|
||||
if sel_model := event.get_extra("selected_model"):
|
||||
req.model = sel_model
|
||||
if config.provider_wake_prefix and not event.message_str.startswith(
|
||||
@@ -1266,10 +1124,6 @@ async def build_main_agent(
|
||||
req.extra_user_content_parts.append(
|
||||
TextPart(text=f"[Image Attachment: path {image_path}]")
|
||||
)
|
||||
elif isinstance(comp, Record):
|
||||
audio_path = await comp.convert_to_file_path()
|
||||
req.audio_urls.append(audio_path)
|
||||
_append_audio_attachment(req, audio_path)
|
||||
elif isinstance(comp, File):
|
||||
file_path = await comp.get_file()
|
||||
file_name = comp.name or os.path.basename(file_path)
|
||||
@@ -1301,10 +1155,6 @@ async def build_main_agent(
|
||||
event.track_temporary_local_file(image_path)
|
||||
req.image_urls.append(image_path)
|
||||
_append_quoted_image_attachment(req, image_path)
|
||||
elif isinstance(reply_comp, Record):
|
||||
audio_path = await reply_comp.convert_to_file_path()
|
||||
req.audio_urls.append(audio_path)
|
||||
_append_quoted_audio_attachment(req, audio_path)
|
||||
elif isinstance(reply_comp, File):
|
||||
file_path = await reply_comp.get_file()
|
||||
file_name = reply_comp.name or os.path.basename(file_path)
|
||||
@@ -1372,7 +1222,6 @@ async def build_main_agent(
|
||||
if isinstance(req.contexts, str):
|
||||
req.contexts = json.loads(req.contexts)
|
||||
req.image_urls = normalize_and_dedupe_strings(req.image_urls)
|
||||
req.audio_urls = normalize_and_dedupe_strings(req.audio_urls)
|
||||
|
||||
if config.file_extract_enabled:
|
||||
try:
|
||||
@@ -1380,7 +1229,7 @@ async def build_main_agent(
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error("Error occurred while applying file extract: %s", exc)
|
||||
|
||||
if not req.prompt and not req.image_urls and not req.audio_urls:
|
||||
if not req.prompt and not req.image_urls:
|
||||
if not event.get_group_id() and req.extra_user_content_parts:
|
||||
req.prompt = "<attachment>"
|
||||
else:
|
||||
@@ -1395,7 +1244,6 @@ async def build_main_agent(
|
||||
|
||||
_modalities_fix(provider, req)
|
||||
_plugin_tool_fix(event, req)
|
||||
await _apply_web_search_tools(event, req, plugin_context)
|
||||
_sanitize_context_by_modalities(config, provider, req)
|
||||
|
||||
if config.llm_safety_mode:
|
||||
@@ -1404,7 +1252,7 @@ async def build_main_agent(
|
||||
if config.computer_use_runtime == "sandbox":
|
||||
_apply_sandbox_tools(config, req, req.session_id)
|
||||
elif config.computer_use_runtime == "local":
|
||||
_apply_local_env_tools(req, plugin_context)
|
||||
_apply_local_env_tools(req)
|
||||
|
||||
agent_runner = AgentRunner()
|
||||
astr_agent_ctx = AstrAgentContext(
|
||||
@@ -1413,16 +1261,12 @@ async def build_main_agent(
|
||||
)
|
||||
|
||||
if config.add_cron_tools:
|
||||
_proactive_cron_job_tools(req, plugin_context)
|
||||
_proactive_cron_job_tools(req)
|
||||
|
||||
if event.platform_meta.support_proactive_message:
|
||||
if req.func_tool is None:
|
||||
req.func_tool = ToolSet()
|
||||
req.func_tool.add_tool(
|
||||
plugin_context.get_llm_tool_manager().get_builtin_tool(
|
||||
SendMessageToUserTool
|
||||
)
|
||||
)
|
||||
req.func_tool.add_tool(SEND_MESSAGE_TO_USER_TOOL)
|
||||
|
||||
if provider.provider_config.get("max_context_tokens", 0) <= 0:
|
||||
model = provider.get_model()
|
||||
@@ -1440,15 +1284,6 @@ async def build_main_agent(
|
||||
if config.tool_schema_mode == "full"
|
||||
else TOOL_CALL_PROMPT_SKILLS_LIKE_MODE
|
||||
)
|
||||
|
||||
if config.computer_use_runtime == "local":
|
||||
tool_prompt += (
|
||||
f"\nCurrent workspace you can use: "
|
||||
f"`{_get_workspace_path_for_umo(event.unified_msg_origin)}`\n"
|
||||
"Unless the user explicitly specifies a different directory, "
|
||||
"perform all file-related operations in this workspace.\n"
|
||||
)
|
||||
|
||||
req.system_prompt += f"\n{tool_prompt}\n"
|
||||
|
||||
action_type = event.get_extra("action_type")
|
||||
@@ -1474,14 +1309,6 @@ async def build_main_agent(
|
||||
fallback_providers=_get_fallback_chat_providers(
|
||||
provider, plugin_context, config.provider_settings
|
||||
),
|
||||
tool_result_overflow_dir=(
|
||||
get_astrbot_system_tmp_path()
|
||||
if req.func_tool and req.func_tool.get_tool("astrbot_file_read_tool")
|
||||
else None
|
||||
),
|
||||
read_tool=(
|
||||
req.func_tool.get_tool("astrbot_file_read_tool") if req.func_tool else None
|
||||
),
|
||||
)
|
||||
|
||||
if apply_reset:
|
||||
|
||||
@@ -1,4 +1,43 @@
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import uuid
|
||||
|
||||
from pydantic import Field
|
||||
from pydantic.dataclasses import dataclass
|
||||
|
||||
import astrbot.core.message.components as Comp
|
||||
from astrbot.api import logger, sp
|
||||
from astrbot.core.agent.run_context import ContextWrapper
|
||||
from astrbot.core.agent.tool import FunctionTool, ToolExecResult
|
||||
from astrbot.core.astr_agent_context import AstrAgentContext
|
||||
from astrbot.core.computer.computer_client import get_booter
|
||||
from astrbot.core.computer.tools import (
|
||||
AnnotateExecutionTool,
|
||||
BrowserBatchExecTool,
|
||||
BrowserExecTool,
|
||||
CreateSkillCandidateTool,
|
||||
CreateSkillPayloadTool,
|
||||
EvaluateSkillCandidateTool,
|
||||
ExecuteShellTool,
|
||||
FileDownloadTool,
|
||||
FileUploadTool,
|
||||
GetExecutionHistoryTool,
|
||||
GetSkillPayloadTool,
|
||||
ListSkillCandidatesTool,
|
||||
ListSkillReleasesTool,
|
||||
LocalPythonTool,
|
||||
PromoteSkillCandidateTool,
|
||||
PythonTool,
|
||||
RollbackSkillReleaseTool,
|
||||
RunBrowserSkillTool,
|
||||
SyncSkillReleaseTool,
|
||||
)
|
||||
from astrbot.core.knowledge_base.kb_helper import KBHelper
|
||||
from astrbot.core.message.message_event_result import MessageChain
|
||||
from astrbot.core.platform.message_session import MessageSession
|
||||
from astrbot.core.star.context import Context
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
|
||||
|
||||
LLM_SAFETY_MODE_SYSTEM_PROMPT = """You are running in Safe Mode.
|
||||
|
||||
@@ -108,6 +147,374 @@ BACKGROUND_TASK_RESULT_WOKE_SYSTEM_PROMPT = (
|
||||
"{background_task_result}"
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class KnowledgeBaseQueryTool(FunctionTool[AstrAgentContext]):
|
||||
name: str = "astr_kb_search"
|
||||
description: str = (
|
||||
"Query the knowledge base for facts or relevant context. "
|
||||
"Use this tool when the user's question requires factual information, "
|
||||
"definitions, background knowledge, or previously indexed content. "
|
||||
"Only send short keywords or a concise question as the query."
|
||||
)
|
||||
parameters: dict = Field(
|
||||
default_factory=lambda: {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "A concise keyword query for the knowledge base.",
|
||||
},
|
||||
},
|
||||
"required": ["query"],
|
||||
}
|
||||
)
|
||||
|
||||
async def call(
|
||||
self, context: ContextWrapper[AstrAgentContext], **kwargs
|
||||
) -> ToolExecResult:
|
||||
query = kwargs.get("query", "")
|
||||
if not query:
|
||||
return "error: Query parameter is empty."
|
||||
result = await retrieve_knowledge_base(
|
||||
query=kwargs.get("query", ""),
|
||||
umo=context.context.event.unified_msg_origin,
|
||||
context=context.context.context,
|
||||
)
|
||||
if not result:
|
||||
return "No relevant knowledge found."
|
||||
return result
|
||||
|
||||
|
||||
@dataclass
|
||||
class SendMessageToUserTool(FunctionTool[AstrAgentContext]):
|
||||
name: str = "send_message_to_user"
|
||||
description: str = (
|
||||
"Send message to the user. "
|
||||
"Supports various message types including `plain`, `image`, `record`, `video`, `file`, and `mention_user`. "
|
||||
"Use this tool to send media files (`image`, `record`, `video`, `file`), "
|
||||
"or when you need to proactively message the user(such as cron job). For normal text replies, you can output directly."
|
||||
)
|
||||
|
||||
parameters: dict = Field(
|
||||
default_factory=lambda: {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"messages": {
|
||||
"type": "array",
|
||||
"description": "An ordered list of message components to send. `mention_user` type can be used to mention the user.",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Component type. One of: "
|
||||
"plain, image, record, video, file, mention_user. Record is voice message."
|
||||
),
|
||||
},
|
||||
"text": {
|
||||
"type": "string",
|
||||
"description": "Text content for `plain` type.",
|
||||
},
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "File path for `image`, `record`, or `file` types. Both local path and sandbox path are supported.",
|
||||
},
|
||||
"url": {
|
||||
"type": "string",
|
||||
"description": "URL for `image`, `record`, or `file` types.",
|
||||
},
|
||||
"mention_user_id": {
|
||||
"type": "string",
|
||||
"description": "User ID to mention for `mention_user` type.",
|
||||
},
|
||||
},
|
||||
"required": ["type"],
|
||||
},
|
||||
},
|
||||
},
|
||||
"required": ["messages"],
|
||||
}
|
||||
)
|
||||
|
||||
async def _resolve_path_from_sandbox(
|
||||
self, context: ContextWrapper[AstrAgentContext], path: str
|
||||
) -> tuple[str, bool]:
|
||||
"""
|
||||
If the path exists locally, return it directly.
|
||||
Otherwise, check if it exists in the sandbox and download it.
|
||||
|
||||
bool: indicates whether the file was downloaded from sandbox.
|
||||
"""
|
||||
if os.path.exists(path):
|
||||
return path, False
|
||||
|
||||
# Try to check if the file exists in the sandbox
|
||||
try:
|
||||
sb = await get_booter(
|
||||
context.context.context,
|
||||
context.context.event.unified_msg_origin,
|
||||
)
|
||||
# Use shell to check if the file exists in sandbox
|
||||
result = await sb.shell.exec(f"test -f {path} && echo '_&exists_'")
|
||||
if "_&exists_" in json.dumps(result):
|
||||
# Download the file from sandbox
|
||||
name = os.path.basename(path)
|
||||
local_path = os.path.join(
|
||||
get_astrbot_temp_path(), f"sandbox_{uuid.uuid4().hex[:4]}_{name}"
|
||||
)
|
||||
await sb.download_file(path, local_path)
|
||||
logger.info(f"Downloaded file from sandbox: {path} -> {local_path}")
|
||||
return local_path, True
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to check/download file from sandbox: {e}")
|
||||
|
||||
# Return the original path (will likely fail later, but that's expected)
|
||||
return path, False
|
||||
|
||||
async def call(
|
||||
self, context: ContextWrapper[AstrAgentContext], **kwargs
|
||||
) -> ToolExecResult:
|
||||
session = kwargs.get("session") or context.context.event.unified_msg_origin
|
||||
messages = kwargs.get("messages")
|
||||
|
||||
if not isinstance(messages, list) or not messages:
|
||||
return "error: messages parameter is empty or invalid."
|
||||
|
||||
components: list[Comp.BaseMessageComponent] = []
|
||||
|
||||
for idx, msg in enumerate(messages):
|
||||
if not isinstance(msg, dict):
|
||||
return f"error: messages[{idx}] should be an object."
|
||||
|
||||
msg_type = str(msg.get("type", "")).lower()
|
||||
if not msg_type:
|
||||
return f"error: messages[{idx}].type is required."
|
||||
|
||||
file_from_sandbox = False
|
||||
|
||||
try:
|
||||
if msg_type == "plain":
|
||||
text = str(msg.get("text", "")).strip()
|
||||
if not text:
|
||||
return f"error: messages[{idx}].text is required for plain component."
|
||||
components.append(Comp.Plain(text=text))
|
||||
elif msg_type == "image":
|
||||
path = msg.get("path")
|
||||
url = msg.get("url")
|
||||
if path:
|
||||
(
|
||||
local_path,
|
||||
file_from_sandbox,
|
||||
) = await self._resolve_path_from_sandbox(context, path)
|
||||
components.append(Comp.Image.fromFileSystem(path=local_path))
|
||||
elif url:
|
||||
components.append(Comp.Image.fromURL(url=url))
|
||||
else:
|
||||
return f"error: messages[{idx}] must include path or url for image component."
|
||||
elif msg_type == "record":
|
||||
path = msg.get("path")
|
||||
url = msg.get("url")
|
||||
if path:
|
||||
(
|
||||
local_path,
|
||||
file_from_sandbox,
|
||||
) = await self._resolve_path_from_sandbox(context, path)
|
||||
components.append(Comp.Record.fromFileSystem(path=local_path))
|
||||
elif url:
|
||||
components.append(Comp.Record.fromURL(url=url))
|
||||
else:
|
||||
return f"error: messages[{idx}] must include path or url for record component."
|
||||
elif msg_type == "video":
|
||||
path = msg.get("path")
|
||||
url = msg.get("url")
|
||||
if path:
|
||||
(
|
||||
local_path,
|
||||
file_from_sandbox,
|
||||
) = await self._resolve_path_from_sandbox(context, path)
|
||||
components.append(Comp.Video.fromFileSystem(path=local_path))
|
||||
elif url:
|
||||
components.append(Comp.Video.fromURL(url=url))
|
||||
else:
|
||||
return f"error: messages[{idx}] must include path or url for video component."
|
||||
elif msg_type == "file":
|
||||
path = msg.get("path")
|
||||
url = msg.get("url")
|
||||
name = (
|
||||
msg.get("text")
|
||||
or (os.path.basename(path) if path else "")
|
||||
or (os.path.basename(url) if url else "")
|
||||
or "file"
|
||||
)
|
||||
if path:
|
||||
(
|
||||
local_path,
|
||||
file_from_sandbox,
|
||||
) = await self._resolve_path_from_sandbox(context, path)
|
||||
components.append(Comp.File(name=name, file=local_path))
|
||||
elif url:
|
||||
components.append(Comp.File(name=name, url=url))
|
||||
else:
|
||||
return f"error: messages[{idx}] must include path or url for file component."
|
||||
elif msg_type == "mention_user":
|
||||
mention_user_id = msg.get("mention_user_id")
|
||||
if not mention_user_id:
|
||||
return f"error: messages[{idx}].mention_user_id is required for mention_user component."
|
||||
components.append(
|
||||
Comp.At(
|
||||
qq=mention_user_id,
|
||||
),
|
||||
)
|
||||
else:
|
||||
return (
|
||||
f"error: unsupported message type '{msg_type}' at index {idx}."
|
||||
)
|
||||
except Exception as exc: # 捕获组件构造异常,避免直接抛出
|
||||
return f"error: failed to build messages[{idx}] component: {exc}"
|
||||
|
||||
try:
|
||||
target_session = (
|
||||
MessageSession.from_str(session)
|
||||
if isinstance(session, str)
|
||||
else session
|
||||
)
|
||||
except Exception as e:
|
||||
return f"error: invalid session: {e}"
|
||||
|
||||
await context.context.context.send_message(
|
||||
target_session,
|
||||
MessageChain(chain=components),
|
||||
)
|
||||
|
||||
# if file_from_sandbox:
|
||||
# try:
|
||||
# os.remove(local_path)
|
||||
# except Exception as e:
|
||||
# logger.error(f"Error removing temp file {local_path}: {e}")
|
||||
|
||||
return f"Message sent to session {target_session}"
|
||||
|
||||
|
||||
def check_all_kb(kb_list: list[KBHelper | None]) -> bool:
|
||||
"""检查是否所有的知识库都为空
|
||||
Args:
|
||||
kb_list: 所选的知识库
|
||||
Returns:
|
||||
bool: 是否全为空
|
||||
"""
|
||||
return not any(
|
||||
kb and (kb.kb.doc_count != 0 or kb.kb.chunk_count != 0) for kb in kb_list
|
||||
)
|
||||
|
||||
|
||||
async def retrieve_knowledge_base(
|
||||
query: str,
|
||||
umo: str,
|
||||
context: Context,
|
||||
) -> str | None:
|
||||
"""Inject knowledge base context into the provider request
|
||||
|
||||
Args:
|
||||
umo: Unique message object (session ID)
|
||||
p_ctx: Pipeline context
|
||||
"""
|
||||
kb_mgr = context.kb_manager
|
||||
config = context.get_config(umo=umo)
|
||||
|
||||
# 1. 优先读取会话级配置
|
||||
session_config = await sp.session_get(umo, "kb_config", default={})
|
||||
|
||||
if session_config and "kb_ids" in session_config:
|
||||
# 会话级配置
|
||||
kb_ids = session_config.get("kb_ids", [])
|
||||
|
||||
# 如果配置为空列表,明确表示不使用知识库
|
||||
if not kb_ids:
|
||||
logger.info(f"[知识库] 会话 {umo} 已被配置为不使用知识库")
|
||||
return
|
||||
|
||||
top_k = session_config.get("top_k", 5)
|
||||
|
||||
# 将 kb_ids 转换为 kb_names
|
||||
kb_names = []
|
||||
invalid_kb_ids = []
|
||||
for kb_id in kb_ids:
|
||||
kb_helper = await kb_mgr.get_kb(kb_id)
|
||||
if kb_helper:
|
||||
kb_names.append(kb_helper.kb.kb_name)
|
||||
else:
|
||||
logger.warning(f"[知识库] 知识库不存在或未加载: {kb_id}")
|
||||
invalid_kb_ids.append(kb_id)
|
||||
|
||||
if invalid_kb_ids:
|
||||
logger.warning(
|
||||
f"[知识库] 会话 {umo} 配置的以下知识库无效: {invalid_kb_ids}",
|
||||
)
|
||||
|
||||
if not kb_names:
|
||||
return
|
||||
|
||||
logger.debug(f"[知识库] 使用会话级配置,知识库数量: {len(kb_names)}")
|
||||
else:
|
||||
kb_names = config.get("kb_names", [])
|
||||
top_k = config.get("kb_final_top_k", 5)
|
||||
logger.debug(f"[知识库] 使用全局配置,知识库数量: {len(kb_names)}")
|
||||
|
||||
top_k_fusion = config.get("kb_fusion_top_k", 20)
|
||||
|
||||
if not kb_names:
|
||||
return
|
||||
|
||||
all_kbs = [await kb_mgr.get_kb_by_name(kb) for kb in kb_names]
|
||||
|
||||
if check_all_kb(all_kbs):
|
||||
logger.debug("所配置的所有知识库全为空,跳过检索过程")
|
||||
return
|
||||
|
||||
logger.debug(f"[知识库] 开始检索知识库,数量: {len(kb_names)}, top_k={top_k}")
|
||||
kb_context = await kb_mgr.retrieve(
|
||||
query=query,
|
||||
kb_names=kb_names,
|
||||
top_k_fusion=top_k_fusion,
|
||||
top_m_final=top_k,
|
||||
)
|
||||
|
||||
if not kb_context:
|
||||
return
|
||||
|
||||
formatted = kb_context.get("context_text", "")
|
||||
if formatted:
|
||||
results = kb_context.get("results", [])
|
||||
logger.debug(f"[知识库] 为会话 {umo} 注入了 {len(results)} 条相关知识块")
|
||||
return formatted
|
||||
|
||||
|
||||
KNOWLEDGE_BASE_QUERY_TOOL = KnowledgeBaseQueryTool()
|
||||
SEND_MESSAGE_TO_USER_TOOL = SendMessageToUserTool()
|
||||
|
||||
EXECUTE_SHELL_TOOL = ExecuteShellTool()
|
||||
LOCAL_EXECUTE_SHELL_TOOL = ExecuteShellTool(is_local=True)
|
||||
PYTHON_TOOL = PythonTool()
|
||||
LOCAL_PYTHON_TOOL = LocalPythonTool()
|
||||
FILE_UPLOAD_TOOL = FileUploadTool()
|
||||
FILE_DOWNLOAD_TOOL = FileDownloadTool()
|
||||
BROWSER_EXEC_TOOL = BrowserExecTool()
|
||||
BROWSER_BATCH_EXEC_TOOL = BrowserBatchExecTool()
|
||||
RUN_BROWSER_SKILL_TOOL = RunBrowserSkillTool()
|
||||
GET_EXECUTION_HISTORY_TOOL = GetExecutionHistoryTool()
|
||||
ANNOTATE_EXECUTION_TOOL = AnnotateExecutionTool()
|
||||
CREATE_SKILL_PAYLOAD_TOOL = CreateSkillPayloadTool()
|
||||
GET_SKILL_PAYLOAD_TOOL = GetSkillPayloadTool()
|
||||
CREATE_SKILL_CANDIDATE_TOOL = CreateSkillCandidateTool()
|
||||
LIST_SKILL_CANDIDATES_TOOL = ListSkillCandidatesTool()
|
||||
EVALUATE_SKILL_CANDIDATE_TOOL = EvaluateSkillCandidateTool()
|
||||
PROMOTE_SKILL_CANDIDATE_TOOL = PromoteSkillCandidateTool()
|
||||
LIST_SKILL_RELEASES_TOOL = ListSkillReleasesTool()
|
||||
ROLLBACK_SKILL_RELEASE_TOOL = RollbackSkillReleaseTool()
|
||||
SYNC_SKILL_RELEASE_TOOL = SyncSkillReleaseTool()
|
||||
|
||||
# we prevent astrbot from connecting to known malicious hosts
|
||||
# these hosts are base64 encoded
|
||||
BLOCKED = {"dGZid2h2d3IuY2xvdWQuc2VhbG9zLmlv", "a291cmljaGF0"}
|
||||
|
||||
@@ -7,7 +7,6 @@ from sqlmodel import SQLModel
|
||||
|
||||
from astrbot.core.db.po import (
|
||||
Attachment,
|
||||
ChatUIProject,
|
||||
CommandConfig,
|
||||
CommandConflict,
|
||||
ConversationV2,
|
||||
@@ -17,7 +16,6 @@ from astrbot.core.db.po import (
|
||||
PlatformSession,
|
||||
PlatformStat,
|
||||
Preference,
|
||||
SessionProjectRelation,
|
||||
)
|
||||
from astrbot.core.knowledge_base.models import (
|
||||
KBDocument,
|
||||
@@ -46,8 +44,6 @@ MAIN_DB_MODELS: dict[str, type[SQLModel]] = {
|
||||
"preferences": Preference,
|
||||
"platform_message_history": PlatformMessageHistory,
|
||||
"platform_sessions": PlatformSession,
|
||||
"chatui_projects": ChatUIProject,
|
||||
"session_project_relations": SessionProjectRelation,
|
||||
"attachments": Attachment,
|
||||
"command_configs": CommandConfig,
|
||||
"command_conflicts": CommandConflict,
|
||||
|
||||
@@ -4,7 +4,7 @@ from typing import Any
|
||||
|
||||
import aiohttp
|
||||
import boxlite
|
||||
from shipyard import FileSystemComponent as ShipyardFileSystemComponent
|
||||
from shipyard.filesystem import FileSystemComponent as ShipyardFileSystemComponent
|
||||
from shipyard.python import PythonComponent as ShipyardPythonComponent
|
||||
from shipyard.shell import ShellComponent as ShipyardShellComponent
|
||||
|
||||
@@ -12,7 +12,6 @@ from astrbot.api import logger
|
||||
|
||||
from ..olayer import FileSystemComponent, PythonComponent, ShellComponent
|
||||
from .base import ComputerBooter
|
||||
from .shipyard import ShipyardFileSystemWrapper
|
||||
|
||||
|
||||
class MockShipyardSandboxClient:
|
||||
@@ -151,6 +150,11 @@ class BoxliteBooter(ComputerBooter):
|
||||
self.mocked = MockShipyardSandboxClient(
|
||||
sb_url=f"http://127.0.0.1:{random_port}"
|
||||
)
|
||||
self._fs = ShipyardFileSystemComponent(
|
||||
client=self.mocked, # type: ignore
|
||||
ship_id=self.box.id,
|
||||
session_id=session_id,
|
||||
)
|
||||
self._python = ShipyardPythonComponent(
|
||||
client=self.mocked, # type: ignore
|
||||
ship_id=self.box.id,
|
||||
@@ -161,14 +165,6 @@ class BoxliteBooter(ComputerBooter):
|
||||
ship_id=self.box.id,
|
||||
session_id=session_id,
|
||||
)
|
||||
self._ship_fs = ShipyardFileSystemComponent(
|
||||
client=self.mocked, # type: ignore
|
||||
ship_id=self.box.id,
|
||||
session_id=session_id,
|
||||
)
|
||||
self._fs = ShipyardFileSystemWrapper(
|
||||
_shipyard_fs=self._ship_fs, _shipyard_shell=self._shell
|
||||
)
|
||||
|
||||
await self.mocked.wait_healthy(self.box.id, session_id)
|
||||
|
||||
|
||||
@@ -9,18 +9,15 @@ import sys
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from python_ripgrep import search
|
||||
|
||||
from astrbot.api import logger
|
||||
from astrbot.core.computer.file_read_utils import (
|
||||
detect_text_encoding,
|
||||
read_local_text_range_sync,
|
||||
from astrbot.core.utils.astrbot_path import (
|
||||
get_astrbot_data_path,
|
||||
get_astrbot_root,
|
||||
get_astrbot_temp_path,
|
||||
)
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_root
|
||||
|
||||
from ..olayer import FileSystemComponent, PythonComponent, ShellComponent
|
||||
from .base import ComputerBooter
|
||||
from .shipyard_search_file_util import _truncate_long_lines
|
||||
|
||||
_BLOCKED_COMMAND_PATTERNS = [
|
||||
" rm -rf ",
|
||||
@@ -44,6 +41,18 @@ def _is_safe_command(command: str) -> bool:
|
||||
return not any(pat in cmd for pat in _BLOCKED_COMMAND_PATTERNS)
|
||||
|
||||
|
||||
def _ensure_safe_path(path: str) -> str:
|
||||
abs_path = os.path.abspath(path)
|
||||
allowed_roots = [
|
||||
os.path.abspath(get_astrbot_root()),
|
||||
os.path.abspath(get_astrbot_data_path()),
|
||||
os.path.abspath(get_astrbot_temp_path()),
|
||||
]
|
||||
if not any(abs_path.startswith(root) for root in allowed_roots):
|
||||
raise PermissionError("Path is outside the allowed computer roots.")
|
||||
return abs_path
|
||||
|
||||
|
||||
def _decode_bytes_with_fallback(
|
||||
output: bytes | None,
|
||||
*,
|
||||
@@ -101,7 +110,7 @@ class LocalShellComponent(ShellComponent):
|
||||
run_env = os.environ.copy()
|
||||
if env:
|
||||
run_env.update({str(k): str(v) for k, v in env.items()})
|
||||
working_dir = os.path.abspath(cwd) if cwd else get_astrbot_root()
|
||||
working_dir = _ensure_safe_path(cwd) if cwd else get_astrbot_root()
|
||||
if background:
|
||||
# `command` is intentionally executed through the current shell so
|
||||
# local computer-use behavior matches existing tool semantics.
|
||||
@@ -177,7 +186,7 @@ class LocalFileSystemComponent(FileSystemComponent):
|
||||
self, path: str, content: str = "", mode: int = 0o644
|
||||
) -> dict[str, Any]:
|
||||
def _run() -> dict[str, Any]:
|
||||
abs_path = os.path.abspath(path)
|
||||
abs_path = _ensure_safe_path(path)
|
||||
os.makedirs(os.path.dirname(abs_path), exist_ok=True)
|
||||
with open(abs_path, "w", encoding="utf-8") as f:
|
||||
f.write(content)
|
||||
@@ -186,85 +195,16 @@ class LocalFileSystemComponent(FileSystemComponent):
|
||||
|
||||
return await asyncio.to_thread(_run)
|
||||
|
||||
async def read_file(
|
||||
self,
|
||||
path: str,
|
||||
encoding: str = "utf-8",
|
||||
offset: int | None = None,
|
||||
limit: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
async def read_file(self, path: str, encoding: str = "utf-8") -> dict[str, Any]:
|
||||
def _run() -> dict[str, Any]:
|
||||
abs_path = os.path.abspath(path)
|
||||
detected_encoding = encoding
|
||||
if encoding == "utf-8":
|
||||
with open(abs_path, "rb") as f:
|
||||
raw_sample = f.read(8192)
|
||||
detected_encoding = detect_text_encoding(raw_sample) or encoding
|
||||
return {
|
||||
"success": True,
|
||||
"content": read_local_text_range_sync(
|
||||
abs_path,
|
||||
encoding=detected_encoding,
|
||||
offset=offset,
|
||||
limit=limit,
|
||||
),
|
||||
}
|
||||
|
||||
return await asyncio.to_thread(_run)
|
||||
|
||||
async def search_files(
|
||||
self,
|
||||
pattern: str,
|
||||
path: str | None = None,
|
||||
glob: str | None = None,
|
||||
after_context: int | None = None,
|
||||
before_context: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
def _run() -> dict[str, Any]:
|
||||
results = search(
|
||||
patterns=[pattern],
|
||||
paths=[path] if path else None,
|
||||
globs=[glob] if glob else None,
|
||||
after_context=after_context,
|
||||
before_context=before_context,
|
||||
line_number=True,
|
||||
abs_path = _ensure_safe_path(path)
|
||||
with open(abs_path, "rb") as f:
|
||||
raw_content = f.read()
|
||||
content = _decode_bytes_with_fallback(
|
||||
raw_content,
|
||||
preferred_encoding=encoding,
|
||||
)
|
||||
return {"success": True, "content": _truncate_long_lines("".join(results))}
|
||||
|
||||
return await asyncio.to_thread(_run)
|
||||
|
||||
async def edit_file(
|
||||
self,
|
||||
path: str,
|
||||
old_string: str,
|
||||
new_string: str,
|
||||
replace_all: bool = False,
|
||||
encoding: str = "utf-8",
|
||||
) -> dict[str, Any]:
|
||||
def _run() -> dict[str, Any]:
|
||||
abs_path = os.path.abspath(path)
|
||||
with open(abs_path, encoding=encoding) as f:
|
||||
content = f.read()
|
||||
occurrences = content.count(old_string)
|
||||
if occurrences == 0:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "old string not found in file",
|
||||
"replacements": 0,
|
||||
}
|
||||
if replace_all:
|
||||
updated = content.replace(old_string, new_string)
|
||||
replacements = occurrences
|
||||
else:
|
||||
updated = content.replace(old_string, new_string, 1)
|
||||
replacements = 1
|
||||
with open(abs_path, "w", encoding=encoding) as f:
|
||||
f.write(updated)
|
||||
return {
|
||||
"success": True,
|
||||
"path": abs_path,
|
||||
"replacements": replacements,
|
||||
}
|
||||
return {"success": True, "content": content}
|
||||
|
||||
return await asyncio.to_thread(_run)
|
||||
|
||||
@@ -272,7 +212,7 @@ class LocalFileSystemComponent(FileSystemComponent):
|
||||
self, path: str, content: str, mode: str = "w", encoding: str = "utf-8"
|
||||
) -> dict[str, Any]:
|
||||
def _run() -> dict[str, Any]:
|
||||
abs_path = os.path.abspath(path)
|
||||
abs_path = _ensure_safe_path(path)
|
||||
os.makedirs(os.path.dirname(abs_path), exist_ok=True)
|
||||
with open(abs_path, mode, encoding=encoding) as f:
|
||||
f.write(content)
|
||||
@@ -282,7 +222,7 @@ class LocalFileSystemComponent(FileSystemComponent):
|
||||
|
||||
async def delete_file(self, path: str) -> dict[str, Any]:
|
||||
def _run() -> dict[str, Any]:
|
||||
abs_path = os.path.abspath(path)
|
||||
abs_path = _ensure_safe_path(path)
|
||||
if os.path.isdir(abs_path):
|
||||
shutil.rmtree(abs_path)
|
||||
else:
|
||||
@@ -295,7 +235,7 @@ class LocalFileSystemComponent(FileSystemComponent):
|
||||
self, path: str = ".", show_hidden: bool = False
|
||||
) -> dict[str, Any]:
|
||||
def _run() -> dict[str, Any]:
|
||||
abs_path = os.path.abspath(path)
|
||||
abs_path = _ensure_safe_path(path)
|
||||
entries = os.listdir(abs_path)
|
||||
if not show_hidden:
|
||||
entries = [e for e in entries if not e.startswith(".")]
|
||||
|
||||
@@ -1,87 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from shipyard import FileSystemComponent as ShipyardFileSystemComponent
|
||||
from shipyard import ShipyardClient, Spec
|
||||
|
||||
from astrbot.api import logger
|
||||
|
||||
from ..olayer import FileSystemComponent, PythonComponent, ShellComponent
|
||||
from .base import ComputerBooter
|
||||
from .shipyard_search_file_util import search_files_via_shell
|
||||
|
||||
|
||||
class ShipyardFileSystemWrapper:
|
||||
def __init__(
|
||||
self, _shipyard_fs: ShipyardFileSystemComponent, _shipyard_shell: ShellComponent
|
||||
):
|
||||
self._fs = _shipyard_fs
|
||||
self._shell = _shipyard_shell
|
||||
|
||||
async def create_file(
|
||||
self, path: str, content: str = "", mode: int = 420
|
||||
) -> dict[str, Any]:
|
||||
return await self._fs.create_file(path=path, content=content, mode=mode)
|
||||
|
||||
async def read_file(
|
||||
self,
|
||||
path: str,
|
||||
encoding: str = "utf-8",
|
||||
offset: int | None = None,
|
||||
limit: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
return await self._fs.read_file(
|
||||
path=path, encoding=encoding, offset=offset, limit=limit
|
||||
)
|
||||
|
||||
async def write_file(
|
||||
self, path: str, content: str, mode: str = "w", encoding: str = "utf-8"
|
||||
) -> dict[str, Any]:
|
||||
return await self._fs.write_file(
|
||||
path=path, content=content, mode=mode, encoding=encoding
|
||||
)
|
||||
|
||||
async def list_dir(
|
||||
self, path: str = ".", show_hidden: bool = False
|
||||
) -> dict[str, Any]:
|
||||
return await self._fs.list_dir(path=path, show_hidden=show_hidden)
|
||||
|
||||
async def delete_file(self, path: str) -> dict[str, Any]:
|
||||
return await self._fs.delete_file(path=path)
|
||||
|
||||
async def search_files(
|
||||
self,
|
||||
pattern: str,
|
||||
path: str | None = None,
|
||||
glob: str | None = None,
|
||||
after_context: int | None = None,
|
||||
before_context: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
return await search_files_via_shell(
|
||||
self._shell,
|
||||
pattern=pattern,
|
||||
path=path,
|
||||
glob=glob,
|
||||
after_context=after_context,
|
||||
before_context=before_context,
|
||||
)
|
||||
|
||||
async def edit_file(
|
||||
self,
|
||||
path: str,
|
||||
old_string: str,
|
||||
new_string: str,
|
||||
replace_all: bool = False,
|
||||
encoding: str = "utf-8",
|
||||
) -> dict[str, Any]:
|
||||
return await self._fs.edit_file(
|
||||
path=path,
|
||||
old_string=old_string,
|
||||
new_string=new_string,
|
||||
replace_all=replace_all,
|
||||
encoding=encoding,
|
||||
)
|
||||
|
||||
|
||||
class ShipyardBooter(ComputerBooter):
|
||||
@@ -107,14 +29,13 @@ class ShipyardBooter(ComputerBooter):
|
||||
)
|
||||
logger.info(f"Got sandbox ship: {ship.id} for session: {session_id}")
|
||||
self._ship = ship
|
||||
self._fs = ShipyardFileSystemWrapper(self._ship.fs, self._ship.shell)
|
||||
|
||||
async def shutdown(self) -> None:
|
||||
logger.info("[Computer] Shipyard booter shutdown.")
|
||||
|
||||
@property
|
||||
def fs(self) -> FileSystemComponent:
|
||||
return self._fs
|
||||
return self._ship.fs
|
||||
|
||||
@property
|
||||
def python(self) -> PythonComponent:
|
||||
|
||||
@@ -13,15 +13,6 @@ from ..olayer import (
|
||||
ShellComponent,
|
||||
)
|
||||
from .base import ComputerBooter
|
||||
from .shipyard_search_file_util import search_files_via_shell
|
||||
|
||||
try:
|
||||
from shipyard_neo import BayClient
|
||||
from shipyard_neo.sandbox import Sandbox
|
||||
except ImportError:
|
||||
logger.warning(
|
||||
"shipyard_neo_sdk is not installed. ShipyardNeoBooter will not work without it."
|
||||
)
|
||||
|
||||
|
||||
def _maybe_model_dump(value: Any) -> dict[str, Any]:
|
||||
@@ -34,20 +25,8 @@ def _maybe_model_dump(value: Any) -> dict[str, Any]:
|
||||
return {}
|
||||
|
||||
|
||||
def _slice_content_by_lines(
|
||||
content: str,
|
||||
*,
|
||||
offset: int | None = None,
|
||||
limit: int | None = None,
|
||||
) -> str:
|
||||
lines = content.splitlines(keepends=True)
|
||||
start = 0 if offset is None else offset
|
||||
selected = lines[start:] if limit is None else lines[start : start + limit]
|
||||
return "".join(selected)
|
||||
|
||||
|
||||
class NeoPythonComponent(PythonComponent):
|
||||
def __init__(self, sandbox: Sandbox) -> None:
|
||||
def __init__(self, sandbox: Any) -> None:
|
||||
self._sandbox = sandbox
|
||||
|
||||
async def exec(
|
||||
@@ -88,7 +67,7 @@ class NeoPythonComponent(PythonComponent):
|
||||
|
||||
|
||||
class NeoShellComponent(ShellComponent):
|
||||
def __init__(self, sandbox: Sandbox) -> None:
|
||||
def __init__(self, sandbox: Any) -> None:
|
||||
self._sandbox = sandbox
|
||||
|
||||
async def exec(
|
||||
@@ -157,9 +136,8 @@ class NeoShellComponent(ShellComponent):
|
||||
|
||||
|
||||
class NeoFileSystemComponent(FileSystemComponent):
|
||||
def __init__(self, sandbox: Sandbox, shell: ShellComponent) -> None:
|
||||
def __init__(self, sandbox: Any) -> None:
|
||||
self._sandbox = sandbox
|
||||
self._shell = shell
|
||||
|
||||
async def create_file(
|
||||
self,
|
||||
@@ -171,71 +149,10 @@ class NeoFileSystemComponent(FileSystemComponent):
|
||||
await self._sandbox.filesystem.write_file(path, content)
|
||||
return {"success": True, "path": path}
|
||||
|
||||
async def read_file(
|
||||
self,
|
||||
path: str,
|
||||
encoding: str = "utf-8",
|
||||
offset: int | None = None,
|
||||
limit: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
async def read_file(self, path: str, encoding: str = "utf-8") -> dict[str, Any]:
|
||||
_ = encoding
|
||||
content = await self._sandbox.filesystem.read_file(path)
|
||||
return {
|
||||
"success": True,
|
||||
"path": path,
|
||||
"content": _slice_content_by_lines(
|
||||
content,
|
||||
offset=offset,
|
||||
limit=limit,
|
||||
),
|
||||
}
|
||||
|
||||
async def search_files(
|
||||
self,
|
||||
pattern: str,
|
||||
path: str | None = None,
|
||||
glob: str | None = None,
|
||||
after_context: int | None = None,
|
||||
before_context: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
return await search_files_via_shell(
|
||||
self._shell,
|
||||
pattern=pattern,
|
||||
path=path,
|
||||
glob=glob,
|
||||
after_context=after_context,
|
||||
before_context=before_context,
|
||||
)
|
||||
|
||||
async def edit_file(
|
||||
self,
|
||||
path: str,
|
||||
old_string: str,
|
||||
new_string: str,
|
||||
replace_all: bool = False,
|
||||
encoding: str = "utf-8",
|
||||
) -> dict[str, Any]:
|
||||
_ = encoding
|
||||
content = await self._sandbox.filesystem.read_file(path)
|
||||
occurrences = content.count(old_string)
|
||||
if occurrences == 0:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "old string not found in file",
|
||||
"replacements": 0,
|
||||
}
|
||||
if replace_all:
|
||||
updated = content.replace(old_string, new_string)
|
||||
replacements = occurrences
|
||||
else:
|
||||
updated = content.replace(old_string, new_string, 1)
|
||||
replacements = 1
|
||||
await self._sandbox.filesystem.write_file(path, updated)
|
||||
return {
|
||||
"success": True,
|
||||
"path": path,
|
||||
"replacements": replacements,
|
||||
}
|
||||
return {"success": True, "path": path, "content": content}
|
||||
|
||||
async def write_file(
|
||||
self,
|
||||
@@ -269,7 +186,7 @@ class NeoFileSystemComponent(FileSystemComponent):
|
||||
|
||||
|
||||
class NeoBrowserComponent(BrowserComponent):
|
||||
def __init__(self, sandbox: Sandbox) -> None:
|
||||
def __init__(self, sandbox: Any) -> None:
|
||||
self._sandbox = sandbox
|
||||
|
||||
async def exec(
|
||||
@@ -354,8 +271,8 @@ class ShipyardNeoBooter(ComputerBooter):
|
||||
self._access_token = access_token
|
||||
self._profile = profile
|
||||
self._ttl = ttl
|
||||
self._client: BayClient | None = None
|
||||
self._sandbox: Sandbox | None = None
|
||||
self._client: Any = None
|
||||
self._sandbox: Any = None
|
||||
self._bay_manager: Any = None # BayContainerManager when auto-started
|
||||
self._fs: FileSystemComponent | None = None
|
||||
self._python: PythonComponent | None = None
|
||||
@@ -419,6 +336,8 @@ class ShipyardNeoBooter(ComputerBooter):
|
||||
"or ensure Bay's credentials.json is accessible for auto-discovery."
|
||||
)
|
||||
|
||||
from shipyard_neo import BayClient
|
||||
|
||||
self._client = BayClient(
|
||||
endpoint_url=self._endpoint_url,
|
||||
access_token=self._access_token,
|
||||
@@ -433,9 +352,9 @@ class ShipyardNeoBooter(ComputerBooter):
|
||||
ttl=self._ttl,
|
||||
)
|
||||
|
||||
self._shell = NeoShellComponent(self._sandbox)
|
||||
self._fs = NeoFileSystemComponent(self._sandbox, self._shell)
|
||||
self._fs = NeoFileSystemComponent(self._sandbox)
|
||||
self._python = NeoPythonComponent(self._sandbox)
|
||||
self._shell = NeoShellComponent(self._sandbox)
|
||||
|
||||
caps = self.capabilities or ()
|
||||
self._browser = (
|
||||
|
||||
@@ -1,148 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import shlex
|
||||
from typing import Any
|
||||
|
||||
from ..olayer import ShellComponent
|
||||
|
||||
_MAX_SEARCH_LINE_COLUMNS = 1000
|
||||
|
||||
|
||||
def _truncate_long_lines(text: str) -> str:
|
||||
output_lines: list[str] = []
|
||||
for line in text.splitlines(keepends=True):
|
||||
line_ending = ""
|
||||
line_body = line
|
||||
if line.endswith("\r\n"):
|
||||
line_body = line[:-2]
|
||||
line_ending = "\r\n"
|
||||
elif line.endswith("\n") or line.endswith("\r"):
|
||||
line_body = line[:-1]
|
||||
line_ending = line[-1]
|
||||
|
||||
if len(line_body) > _MAX_SEARCH_LINE_COLUMNS:
|
||||
line_body = line_body[:_MAX_SEARCH_LINE_COLUMNS]
|
||||
|
||||
output_lines.append(f"{line_body}{line_ending}")
|
||||
return "".join(output_lines)
|
||||
|
||||
|
||||
def _build_rg_command(
|
||||
*,
|
||||
pattern: str,
|
||||
path: str,
|
||||
glob: str | None,
|
||||
after_context: int | None,
|
||||
before_context: int | None,
|
||||
) -> list[str]:
|
||||
command = [
|
||||
"rg",
|
||||
"--color=never",
|
||||
"-n",
|
||||
"--max-columns",
|
||||
str(_MAX_SEARCH_LINE_COLUMNS),
|
||||
"-e",
|
||||
pattern,
|
||||
]
|
||||
if glob:
|
||||
command.extend(["-g", glob])
|
||||
if after_context is not None:
|
||||
command.extend(["-A", str(after_context)])
|
||||
if before_context is not None:
|
||||
command.extend(["-B", str(before_context)])
|
||||
command.extend(["--", path])
|
||||
return command
|
||||
|
||||
|
||||
def _build_grep_command(
|
||||
*,
|
||||
pattern: str,
|
||||
path: str,
|
||||
glob: str | None,
|
||||
after_context: int | None,
|
||||
before_context: int | None,
|
||||
) -> list[str]:
|
||||
command = ["grep", "-R", "-H", "-n", "-e", pattern]
|
||||
if glob:
|
||||
command.append(f"--include={glob}")
|
||||
if after_context is not None:
|
||||
command.extend(["-A", str(after_context)])
|
||||
if before_context is not None:
|
||||
command.extend(["-B", str(before_context)])
|
||||
command.extend(["--", path])
|
||||
return command
|
||||
|
||||
|
||||
def _quote_command(command: list[str]) -> str:
|
||||
return " ".join(shlex.quote(part) for part in command)
|
||||
|
||||
|
||||
def build_search_command(
|
||||
*,
|
||||
pattern: str,
|
||||
path: str,
|
||||
glob: str | None,
|
||||
after_context: int | None,
|
||||
before_context: int | None,
|
||||
) -> str:
|
||||
rg_command = _quote_command(
|
||||
_build_rg_command(
|
||||
pattern=pattern,
|
||||
path=path,
|
||||
glob=glob,
|
||||
after_context=after_context,
|
||||
before_context=before_context,
|
||||
)
|
||||
)
|
||||
grep_command = _quote_command(
|
||||
_build_grep_command(
|
||||
pattern=pattern,
|
||||
path=path,
|
||||
glob=glob,
|
||||
after_context=after_context,
|
||||
before_context=before_context,
|
||||
)
|
||||
)
|
||||
return (
|
||||
"if command -v rg >/dev/null 2>&1; then "
|
||||
f"{rg_command}; "
|
||||
"elif command -v grep >/dev/null 2>&1; then "
|
||||
f"{grep_command}; "
|
||||
"else "
|
||||
"echo 'Neither rg nor grep is available in the sandbox.' >&2; "
|
||||
"exit 127; "
|
||||
"fi"
|
||||
)
|
||||
|
||||
|
||||
async def search_files_via_shell(
|
||||
shell: ShellComponent,
|
||||
*,
|
||||
pattern: str,
|
||||
path: str | None = None,
|
||||
glob: str | None = None,
|
||||
after_context: int | None = None,
|
||||
before_context: int | None = None,
|
||||
timeout: int = 30,
|
||||
) -> dict[str, Any]:
|
||||
command = build_search_command(
|
||||
pattern=pattern,
|
||||
path=path or ".",
|
||||
glob=glob,
|
||||
after_context=after_context,
|
||||
before_context=before_context,
|
||||
)
|
||||
result = await shell.exec(command, timeout=timeout)
|
||||
stdout = _truncate_long_lines(str(result.get("stdout", "") or ""))
|
||||
stderr = str(result.get("stderr", "") or "")
|
||||
exit_code = result.get("exit_code")
|
||||
if exit_code in (0, None):
|
||||
return {"success": True, "content": stdout}
|
||||
if exit_code == 1:
|
||||
return {"success": True, "content": ""}
|
||||
return {
|
||||
"success": False,
|
||||
"content": "",
|
||||
"error": stderr or f"command exited with code {exit_code}",
|
||||
"exit_code": exit_code,
|
||||
}
|
||||
@@ -1,707 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import zipfile
|
||||
from asyncio import to_thread
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
import mcp
|
||||
|
||||
from astrbot.core.agent.context.token_counter import EstimateTokenCounter
|
||||
from astrbot.core.agent.message import Message
|
||||
from astrbot.core.agent.tool import ToolExecResult
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
|
||||
from astrbot.core.utils.media_utils import (
|
||||
IMAGE_COMPRESS_DEFAULT_MAX_SIZE,
|
||||
IMAGE_COMPRESS_DEFAULT_OPTIMIZE,
|
||||
IMAGE_COMPRESS_DEFAULT_QUALITY,
|
||||
_compress_image_sync,
|
||||
)
|
||||
|
||||
from .booters.base import ComputerBooter
|
||||
|
||||
_MAX_FILE_READ_BYTES = 128 * 1024
|
||||
_MAX_FILE_READ_TOKENS = 25_000
|
||||
_MAX_TEXT_FILE_FULL_READ_BYTES = 256 * 1024
|
||||
_FILE_SNIFF_BYTES = 512
|
||||
_TOKEN_COUNTER = EstimateTokenCounter()
|
||||
_TEXT_ENCODINGS = (
|
||||
"utf-8-sig",
|
||||
"utf-8",
|
||||
"gb18030",
|
||||
"utf-16",
|
||||
"utf-16-le",
|
||||
"utf-16-be",
|
||||
"utf-32",
|
||||
"utf-32-le",
|
||||
"utf-32-be",
|
||||
)
|
||||
_UTF_BOMS = (
|
||||
b"\xef\xbb\xbf",
|
||||
b"\xff\xfe",
|
||||
b"\xfe\xff",
|
||||
b"\xff\xfe\x00\x00",
|
||||
b"\x00\x00\xfe\xff",
|
||||
)
|
||||
_ZIP_MAGIC_PREFIXES = (
|
||||
b"PK\x03\x04",
|
||||
b"PK\x05\x06",
|
||||
b"PK\x07\x08",
|
||||
)
|
||||
_BINARY_MAGIC_PREFIXES = (
|
||||
b"%PDF-",
|
||||
b"\x1f\x8b",
|
||||
b"7z\xbc\xaf\x27\x1c",
|
||||
b"Rar!\x1a\x07",
|
||||
b"\x7fELF",
|
||||
b"MZ",
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FileProbe:
|
||||
kind: Literal["text", "image", "binary"]
|
||||
encoding: str | None
|
||||
mime_type: str | None
|
||||
size_bytes: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ParsedDocument:
|
||||
kind: Literal["docx", "pdf"]
|
||||
file_bytes: bytes
|
||||
text: str
|
||||
|
||||
|
||||
def _build_probe_script(path: str) -> str:
|
||||
return f"""
|
||||
import base64
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
path = Path({path!r})
|
||||
with path.open("rb") as file_obj:
|
||||
sample = file_obj.read({_FILE_SNIFF_BYTES})
|
||||
print(
|
||||
json.dumps(
|
||||
{{
|
||||
"size_bytes": path.stat().st_size,
|
||||
"sample_b64": base64.b64encode(sample).decode("ascii"),
|
||||
}}
|
||||
)
|
||||
)
|
||||
""".strip()
|
||||
|
||||
|
||||
def _build_text_read_script(
|
||||
path: str,
|
||||
*,
|
||||
encoding: str,
|
||||
offset: int | None,
|
||||
limit: int | None,
|
||||
) -> str:
|
||||
start_expr = "0" if offset is None else str(offset)
|
||||
limit_expr = "None" if limit is None else str(limit)
|
||||
return f"""
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
path = Path({path!r})
|
||||
start = {start_expr}
|
||||
limit = {limit_expr}
|
||||
end = None if limit is None else start + limit
|
||||
lines = []
|
||||
with path.open("r", encoding={encoding!r}, newline="") as file_obj:
|
||||
for index, line in enumerate(file_obj):
|
||||
if index < start:
|
||||
continue
|
||||
if end is not None and index >= end:
|
||||
break
|
||||
lines.append(line)
|
||||
content = "".join(lines)
|
||||
print(json.dumps({{"content": content}}, ensure_ascii=False))
|
||||
""".strip()
|
||||
|
||||
|
||||
def _build_image_read_script(path: str) -> str:
|
||||
return f"""
|
||||
import base64
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
path = Path({path!r})
|
||||
data = path.read_bytes()
|
||||
print(
|
||||
json.dumps(
|
||||
{{
|
||||
"size_bytes": len(data),
|
||||
"base64": base64.b64encode(data).decode("ascii"),
|
||||
}}
|
||||
)
|
||||
)
|
||||
""".strip()
|
||||
|
||||
|
||||
def _looks_like_text(decoded: str) -> bool:
|
||||
if not decoded:
|
||||
return True
|
||||
|
||||
disallowed = 0
|
||||
printable = 0
|
||||
for char in decoded:
|
||||
if char in "\n\r\t\f\b":
|
||||
printable += 1
|
||||
continue
|
||||
if char.isprintable():
|
||||
printable += 1
|
||||
code = ord(char)
|
||||
if (0 <= code < 32) or (127 <= code < 160):
|
||||
disallowed += 1
|
||||
|
||||
total = max(len(decoded), 1)
|
||||
return disallowed / total <= 0.02 and printable / total >= 0.85
|
||||
|
||||
|
||||
def detect_text_encoding(sample: bytes) -> str | None:
|
||||
if not sample:
|
||||
return "utf-8"
|
||||
|
||||
if b"\x00" in sample and not sample.startswith(_UTF_BOMS):
|
||||
odd_bytes = sample[1::2]
|
||||
even_bytes = sample[0::2]
|
||||
odd_zero_ratio = odd_bytes.count(0) / max(len(odd_bytes), 1)
|
||||
even_zero_ratio = even_bytes.count(0) / max(len(even_bytes), 1)
|
||||
if odd_zero_ratio < 0.8 and even_zero_ratio < 0.8:
|
||||
return None
|
||||
|
||||
for encoding in _TEXT_ENCODINGS:
|
||||
try:
|
||||
decoded = sample.decode(encoding)
|
||||
except UnicodeDecodeError as exc:
|
||||
# Probe samples can end in the middle of a multibyte sequence.
|
||||
# When the decode failure only happens at the sample tail, trim a few
|
||||
# bytes and retry so UTF-8 text is not misclassified as binary.
|
||||
if exc.start >= len(sample) - 4:
|
||||
decoded = ""
|
||||
for trim_bytes in range(1, min(4, len(sample)) + 1):
|
||||
try:
|
||||
decoded = sample[:-trim_bytes].decode(encoding)
|
||||
break
|
||||
except UnicodeDecodeError:
|
||||
continue
|
||||
if not decoded:
|
||||
continue
|
||||
else:
|
||||
continue
|
||||
if _looks_like_text(decoded):
|
||||
return encoding
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def read_local_text_range_sync(
|
||||
path: str,
|
||||
*,
|
||||
encoding: str,
|
||||
offset: int | None,
|
||||
limit: int | None,
|
||||
) -> str:
|
||||
lines: list[str] = []
|
||||
start = 0 if offset is None else offset
|
||||
end = None if limit is None else start + limit
|
||||
with open(path, encoding=encoding, newline="") as file_obj:
|
||||
for index, line in enumerate(file_obj):
|
||||
if index < start:
|
||||
continue
|
||||
if end is not None and index >= end:
|
||||
break
|
||||
lines.append(line)
|
||||
return "".join(lines)
|
||||
|
||||
|
||||
async def read_local_text_range(
|
||||
path: str,
|
||||
*,
|
||||
encoding: str,
|
||||
offset: int | None,
|
||||
limit: int | None,
|
||||
) -> str:
|
||||
return await to_thread(
|
||||
read_local_text_range_sync,
|
||||
path,
|
||||
encoding=encoding,
|
||||
offset=offset,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
|
||||
async def _exec_python_json(
|
||||
booter: ComputerBooter,
|
||||
script: str,
|
||||
*,
|
||||
action: str,
|
||||
) -> dict:
|
||||
result = await booter.python.exec(script)
|
||||
data = result.get("data") if isinstance(result.get("data"), dict) else {}
|
||||
if not isinstance(data, dict):
|
||||
raise RuntimeError(f"{action} failed: invalid result format")
|
||||
output = data.get("output") if isinstance(data.get("output"), dict) else {}
|
||||
if not isinstance(output, dict):
|
||||
raise RuntimeError(f"{action} failed: invalid output format")
|
||||
error_text = str(data.get("error", "") or result.get("error", "") or "").strip()
|
||||
if error_text:
|
||||
raise RuntimeError(f"{action} failed: {error_text}")
|
||||
|
||||
text = str(output.get("text", "") or "").strip()
|
||||
if not text:
|
||||
raise RuntimeError(f"{action} failed: empty output")
|
||||
|
||||
try:
|
||||
payload = json.loads(text)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise RuntimeError(f"{action} failed: invalid JSON output") from exc
|
||||
|
||||
if not isinstance(payload, dict):
|
||||
raise RuntimeError(f"{action} failed: invalid JSON payload")
|
||||
return payload
|
||||
|
||||
|
||||
async def _probe_local_file(path: str) -> dict[str, str | int]:
|
||||
def _run() -> dict[str, str | int]:
|
||||
file_path = Path(path)
|
||||
with file_path.open("rb") as file_obj:
|
||||
sample = file_obj.read(_FILE_SNIFF_BYTES)
|
||||
return {
|
||||
"size_bytes": file_path.stat().st_size,
|
||||
"sample_b64": base64.b64encode(sample).decode("ascii"),
|
||||
}
|
||||
|
||||
return await to_thread(_run)
|
||||
|
||||
|
||||
async def _read_local_image_base64(path: str) -> dict[str, str | int]:
|
||||
def _run() -> dict[str, str | int]:
|
||||
data = Path(path).read_bytes()
|
||||
return {
|
||||
"size_bytes": len(data),
|
||||
"base64": base64.b64encode(data).decode("ascii"),
|
||||
}
|
||||
|
||||
return await to_thread(_run)
|
||||
|
||||
|
||||
async def _read_local_file_bytes(path: str) -> bytes:
|
||||
return await to_thread(Path(path).read_bytes)
|
||||
|
||||
|
||||
async def _compress_image_bytes_to_base64(data: bytes) -> dict[str, str | int]:
|
||||
def _run() -> dict[str, str | int]:
|
||||
temp_dir = Path(get_astrbot_temp_path())
|
||||
temp_dir.mkdir(parents=True, exist_ok=True)
|
||||
compressed_path = Path(
|
||||
_compress_image_sync(
|
||||
data,
|
||||
temp_dir,
|
||||
IMAGE_COMPRESS_DEFAULT_MAX_SIZE,
|
||||
IMAGE_COMPRESS_DEFAULT_QUALITY,
|
||||
IMAGE_COMPRESS_DEFAULT_OPTIMIZE,
|
||||
)
|
||||
)
|
||||
try:
|
||||
compressed_bytes = compressed_path.read_bytes()
|
||||
finally:
|
||||
compressed_path.unlink(missing_ok=True)
|
||||
|
||||
return {
|
||||
"size_bytes": len(compressed_bytes),
|
||||
"base64": base64.b64encode(compressed_bytes).decode("ascii"),
|
||||
"mime_type": "image/jpeg",
|
||||
}
|
||||
|
||||
return await to_thread(_run)
|
||||
|
||||
|
||||
def _detect_image_mime(sample: bytes) -> str | None:
|
||||
if sample.startswith(b"\x89PNG\r\n\x1a\n"):
|
||||
return "image/png"
|
||||
if sample.startswith(b"\xff\xd8\xff"):
|
||||
return "image/jpeg"
|
||||
if sample.startswith((b"GIF87a", b"GIF89a")):
|
||||
return "image/gif"
|
||||
if sample.startswith(b"BM"):
|
||||
return "image/bmp"
|
||||
if sample.startswith((b"II*\x00", b"MM\x00*")):
|
||||
return "image/tiff"
|
||||
if sample.startswith(b"\x00\x00\x01\x00"):
|
||||
return "image/x-icon"
|
||||
if len(sample) >= 12 and sample[:4] == b"RIFF" and sample[8:12] == b"WEBP":
|
||||
return "image/webp"
|
||||
if len(sample) >= 12 and sample[4:12] in (b"ftypavif", b"ftypavis"):
|
||||
return "image/avif"
|
||||
return None
|
||||
|
||||
|
||||
def _looks_like_known_binary(sample: bytes) -> bool:
|
||||
return any(sample.startswith(prefix) for prefix in _BINARY_MAGIC_PREFIXES)
|
||||
|
||||
|
||||
def _looks_like_pdf(path: str, sample: bytes) -> bool:
|
||||
return Path(path).suffix.lower() == ".pdf" or sample.startswith(b"%PDF-")
|
||||
|
||||
|
||||
def _looks_like_zip_container(sample: bytes) -> bool:
|
||||
return any(sample.startswith(prefix) for prefix in _ZIP_MAGIC_PREFIXES)
|
||||
|
||||
|
||||
def _is_docx_bytes(file_bytes: bytes) -> bool:
|
||||
try:
|
||||
with zipfile.ZipFile(io.BytesIO(file_bytes)) as archive:
|
||||
names = set(archive.namelist())
|
||||
except (OSError, zipfile.BadZipFile):
|
||||
return False
|
||||
|
||||
if "[Content_Types].xml" not in names:
|
||||
return False
|
||||
|
||||
return any(name.startswith("word/") for name in names)
|
||||
|
||||
|
||||
async def _parse_local_docx_text(file_bytes: bytes, file_name: str) -> str:
|
||||
from astrbot.core.knowledge_base.parsers.markitdown_parser import (
|
||||
MarkitdownParser,
|
||||
)
|
||||
|
||||
result = await MarkitdownParser().parse(file_bytes, file_name)
|
||||
return result.text
|
||||
|
||||
|
||||
async def _parse_local_pdf_text(file_bytes: bytes, file_name: str) -> str:
|
||||
from astrbot.core.knowledge_base.parsers.pdf_parser import PDFParser
|
||||
|
||||
result = await PDFParser().parse(file_bytes, file_name)
|
||||
return result.text
|
||||
|
||||
|
||||
async def _parse_local_supported_document(
|
||||
path: str,
|
||||
sample: bytes,
|
||||
) -> ParsedDocument | None:
|
||||
file_name = Path(path).name
|
||||
if _looks_like_pdf(path, sample):
|
||||
file_bytes = await _read_local_file_bytes(path)
|
||||
text = await _parse_local_pdf_text(file_bytes, file_name)
|
||||
return ParsedDocument(kind="pdf", file_bytes=file_bytes, text=text)
|
||||
|
||||
if Path(path).suffix.lower() == ".docx" or _looks_like_zip_container(sample):
|
||||
file_bytes = await _read_local_file_bytes(path)
|
||||
if not _is_docx_bytes(file_bytes):
|
||||
return None
|
||||
text = await _parse_local_docx_text(file_bytes, file_name)
|
||||
return ParsedDocument(kind="docx", file_bytes=file_bytes, text=text)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _probe_file(sample: bytes, *, size_bytes: int) -> FileProbe:
|
||||
if image_mime := _detect_image_mime(sample):
|
||||
return FileProbe(
|
||||
kind="image",
|
||||
encoding=None,
|
||||
mime_type=image_mime,
|
||||
size_bytes=size_bytes,
|
||||
)
|
||||
|
||||
if _looks_like_known_binary(sample):
|
||||
return FileProbe(
|
||||
kind="binary",
|
||||
encoding=None,
|
||||
mime_type=None,
|
||||
size_bytes=size_bytes,
|
||||
)
|
||||
|
||||
if encoding := detect_text_encoding(sample):
|
||||
return FileProbe(
|
||||
kind="text",
|
||||
encoding=encoding,
|
||||
mime_type="text/plain",
|
||||
size_bytes=size_bytes,
|
||||
)
|
||||
|
||||
return FileProbe(
|
||||
kind="binary",
|
||||
encoding=None,
|
||||
mime_type=None,
|
||||
size_bytes=size_bytes,
|
||||
)
|
||||
|
||||
|
||||
def _validate_text_output(content: str) -> str | None:
|
||||
content_bytes = len(content.encode("utf-8"))
|
||||
if content_bytes > _MAX_FILE_READ_BYTES:
|
||||
return (
|
||||
"Error reading file: "
|
||||
f"output exceeds {_MAX_FILE_READ_BYTES} bytes "
|
||||
f"({content_bytes} bytes). Use `offset`, `limit` to narrow the read window."
|
||||
)
|
||||
|
||||
content_tokens = _TOKEN_COUNTER.count_tokens(
|
||||
[Message(role="user", content=content)]
|
||||
)
|
||||
if content_tokens > _MAX_FILE_READ_TOKENS:
|
||||
return (
|
||||
"Error reading file: "
|
||||
f"output exceeds {_MAX_FILE_READ_TOKENS} tokens "
|
||||
f"({content_tokens} tokens). Use `offset`, `limit` to narrow the read window."
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _text_exceeds_read_thresholds(content: str) -> bool:
|
||||
return _validate_text_output(content) is not None
|
||||
|
||||
|
||||
def _validate_full_text_read_request(probe: FileProbe) -> str | None:
|
||||
if probe.size_bytes > _MAX_TEXT_FILE_FULL_READ_BYTES:
|
||||
return (
|
||||
"Error reading file: "
|
||||
f"text file exceeds {_MAX_TEXT_FILE_FULL_READ_BYTES} bytes "
|
||||
f"({probe.size_bytes} bytes). Use `offset` and `limit` to narrow the read window."
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _slice_text_by_lines(
|
||||
content: str,
|
||||
*,
|
||||
offset: int | None,
|
||||
limit: int | None,
|
||||
) -> str:
|
||||
if offset is None and limit is None:
|
||||
return content
|
||||
|
||||
lines = content.splitlines(keepends=True)
|
||||
start = 0 if offset is None else offset
|
||||
end = None if limit is None else start + limit
|
||||
return "".join(lines[start:end])
|
||||
|
||||
|
||||
async def _store_converted_text_for_workspace(
|
||||
*,
|
||||
workspace_dir: str,
|
||||
original_path: str,
|
||||
original_bytes: bytes,
|
||||
content: str,
|
||||
) -> str:
|
||||
def _run() -> str:
|
||||
original_name = Path(original_path).name
|
||||
digest_suffix = hashlib.md5(original_bytes).hexdigest()[-6:]
|
||||
target_dir = (
|
||||
Path(workspace_dir) / "converted_files" / f"{original_name}_{digest_suffix}"
|
||||
)
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
target_path = target_dir / "text.txt"
|
||||
target_path.write_text(content, encoding="utf-8")
|
||||
return str(target_path)
|
||||
|
||||
return await to_thread(_run)
|
||||
|
||||
|
||||
def _build_converted_text_notice(
|
||||
converted_text_path: str,
|
||||
*,
|
||||
selection_returned: bool,
|
||||
selection_too_large: bool = False,
|
||||
) -> str:
|
||||
if selection_too_large:
|
||||
return (
|
||||
"Converted text was saved to "
|
||||
f"`{converted_text_path}`. The requested output is still too large to "
|
||||
"return directly. Read or grep that file with a narrower window."
|
||||
)
|
||||
|
||||
if selection_returned:
|
||||
return (
|
||||
"Full converted text is also available at "
|
||||
f"`{converted_text_path}`. Read or grep that file with a narrow "
|
||||
"window for additional reads."
|
||||
)
|
||||
|
||||
return (
|
||||
"Converted text was saved to "
|
||||
f"`{converted_text_path}` because the parsed document is too large to "
|
||||
"return directly. Read or grep that file with a narrow window."
|
||||
)
|
||||
|
||||
|
||||
async def _read_local_supported_document_result(
|
||||
*,
|
||||
path: str,
|
||||
parsed_document: ParsedDocument,
|
||||
workspace_dir: str | None,
|
||||
offset: int | None,
|
||||
limit: int | None,
|
||||
) -> ToolExecResult:
|
||||
content = parsed_document.text
|
||||
if not content:
|
||||
return "No content found at the requested line offset."
|
||||
|
||||
if not _text_exceeds_read_thresholds(content):
|
||||
selected_content = _slice_text_by_lines(content, offset=offset, limit=limit)
|
||||
if not selected_content:
|
||||
return "No content found at the requested line offset."
|
||||
if validation_error := _validate_text_output(selected_content):
|
||||
return validation_error
|
||||
return selected_content
|
||||
|
||||
if not workspace_dir:
|
||||
return (
|
||||
"Error reading file: parsed document exceeds the read output limit and "
|
||||
"no workspace is available for storing converted text."
|
||||
)
|
||||
|
||||
converted_text_path = await _store_converted_text_for_workspace(
|
||||
workspace_dir=workspace_dir,
|
||||
original_path=path,
|
||||
original_bytes=parsed_document.file_bytes,
|
||||
content=content,
|
||||
)
|
||||
|
||||
if offset is None and limit is None:
|
||||
return _build_converted_text_notice(
|
||||
converted_text_path,
|
||||
selection_returned=False,
|
||||
)
|
||||
|
||||
selected_content = _slice_text_by_lines(content, offset=offset, limit=limit)
|
||||
if not selected_content:
|
||||
return (
|
||||
"No content found at the requested line offset. "
|
||||
+ _build_converted_text_notice(
|
||||
converted_text_path,
|
||||
selection_returned=False,
|
||||
)
|
||||
)
|
||||
|
||||
notice = _build_converted_text_notice(
|
||||
converted_text_path,
|
||||
selection_returned=True,
|
||||
)
|
||||
combined_output = f"{selected_content}\n\n[{notice}]"
|
||||
if _validate_text_output(combined_output):
|
||||
if _validate_text_output(selected_content):
|
||||
return _build_converted_text_notice(
|
||||
converted_text_path,
|
||||
selection_returned=False,
|
||||
selection_too_large=True,
|
||||
)
|
||||
return selected_content
|
||||
|
||||
return combined_output
|
||||
|
||||
|
||||
async def read_file_tool_result(
|
||||
booter: ComputerBooter,
|
||||
*,
|
||||
local_mode: bool,
|
||||
path: str,
|
||||
offset: int | None,
|
||||
limit: int | None,
|
||||
workspace_dir: str | None = None,
|
||||
) -> ToolExecResult:
|
||||
if local_mode:
|
||||
probe_payload = await _probe_local_file(path)
|
||||
else:
|
||||
probe_payload = await _exec_python_json(
|
||||
booter,
|
||||
_build_probe_script(path),
|
||||
action="file probe",
|
||||
)
|
||||
sample_b64 = str(probe_payload.get("sample_b64", "") or "")
|
||||
sample = base64.b64decode(sample_b64) if sample_b64 else b""
|
||||
size_bytes = int(probe_payload.get("size_bytes", 0) or 0)
|
||||
probe = _probe_file(sample, size_bytes=size_bytes)
|
||||
|
||||
if local_mode:
|
||||
try:
|
||||
parsed_document = await _parse_local_supported_document(path, sample)
|
||||
except Exception as exc:
|
||||
return f"Error reading file: failed to parse document: {exc}"
|
||||
|
||||
if parsed_document is not None:
|
||||
return await _read_local_supported_document_result(
|
||||
path=path,
|
||||
parsed_document=parsed_document,
|
||||
workspace_dir=workspace_dir,
|
||||
offset=offset,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
if probe.kind == "binary":
|
||||
return "Error reading file: binary files are not supported by this tool."
|
||||
|
||||
if probe.kind == "image":
|
||||
if local_mode:
|
||||
image_payload = await _read_local_image_base64(path)
|
||||
else:
|
||||
image_payload = await _exec_python_json(
|
||||
booter,
|
||||
_build_image_read_script(path),
|
||||
action="image read",
|
||||
)
|
||||
raw_base64_data = str(image_payload.get("base64", "") or "")
|
||||
if not raw_base64_data:
|
||||
return "Error reading file: image payload is empty."
|
||||
raw_bytes = base64.b64decode(raw_base64_data)
|
||||
compressed_payload = await _compress_image_bytes_to_base64(raw_bytes)
|
||||
base64_data = str(compressed_payload.get("base64", "") or "")
|
||||
if not base64_data:
|
||||
return "Error reading file: compressed image payload is empty."
|
||||
return mcp.types.CallToolResult(
|
||||
content=[
|
||||
mcp.types.ImageContent(
|
||||
type="image",
|
||||
data=base64_data,
|
||||
mimeType=str(
|
||||
compressed_payload.get("mime_type", "") or "image/jpeg"
|
||||
),
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
if offset is None and limit is None:
|
||||
if validation_error := _validate_full_text_read_request(probe):
|
||||
return validation_error
|
||||
|
||||
if local_mode:
|
||||
content = await read_local_text_range(
|
||||
path,
|
||||
encoding=probe.encoding or "utf-8",
|
||||
offset=offset,
|
||||
limit=limit,
|
||||
)
|
||||
else:
|
||||
text_payload = await _exec_python_json(
|
||||
booter,
|
||||
_build_text_read_script(
|
||||
path,
|
||||
encoding=probe.encoding or "utf-8",
|
||||
offset=offset,
|
||||
limit=limit,
|
||||
),
|
||||
action="text read",
|
||||
)
|
||||
content = str(text_payload.get("content", "") or "")
|
||||
|
||||
if not content:
|
||||
return "No content found at the requested line offset."
|
||||
|
||||
if validation_error := _validate_text_output(content):
|
||||
return validation_error
|
||||
|
||||
return content
|
||||
@@ -12,36 +12,8 @@ class FileSystemComponent(Protocol):
|
||||
"""Create a file with the specified content"""
|
||||
...
|
||||
|
||||
async def read_file(
|
||||
self,
|
||||
path: str,
|
||||
encoding: str = "utf-8",
|
||||
offset: int | None = None,
|
||||
limit: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Read file content by line window"""
|
||||
...
|
||||
|
||||
async def search_files(
|
||||
self,
|
||||
pattern: str,
|
||||
path: str | None = None,
|
||||
glob: str | None = None,
|
||||
after_context: int | None = None,
|
||||
before_context: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Search file contents"""
|
||||
...
|
||||
|
||||
async def edit_file(
|
||||
self,
|
||||
path: str,
|
||||
old_string: str,
|
||||
new_string: str,
|
||||
replace_all: bool = False,
|
||||
encoding: str = "utf-8",
|
||||
) -> dict[str, Any]:
|
||||
"""Edit file content by string replacement"""
|
||||
async def read_file(self, path: str, encoding: str = "utf-8") -> dict[str, Any]:
|
||||
"""Read file content"""
|
||||
...
|
||||
|
||||
async def write_file(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from .browser import BrowserBatchExecTool, BrowserExecTool, RunBrowserSkillTool
|
||||
from .fs import FileDownloadTool, FileUploadTool
|
||||
from .neo_skills import (
|
||||
AnnotateExecutionTool,
|
||||
CreateSkillCandidateTool,
|
||||
@@ -12,20 +13,27 @@ from .neo_skills import (
|
||||
RollbackSkillReleaseTool,
|
||||
SyncSkillReleaseTool,
|
||||
)
|
||||
from .python import LocalPythonTool, PythonTool
|
||||
from .shell import ExecuteShellTool
|
||||
|
||||
__all__ = [
|
||||
"AnnotateExecutionTool",
|
||||
"BrowserBatchExecTool",
|
||||
"BrowserExecTool",
|
||||
"CreateSkillCandidateTool",
|
||||
"CreateSkillPayloadTool",
|
||||
"EvaluateSkillCandidateTool",
|
||||
"GetExecutionHistoryTool",
|
||||
"GetSkillPayloadTool",
|
||||
"ListSkillCandidatesTool",
|
||||
"ListSkillReleasesTool",
|
||||
"PromoteSkillCandidateTool",
|
||||
"RollbackSkillReleaseTool",
|
||||
"BrowserBatchExecTool",
|
||||
"RunBrowserSkillTool",
|
||||
"GetExecutionHistoryTool",
|
||||
"AnnotateExecutionTool",
|
||||
"CreateSkillPayloadTool",
|
||||
"GetSkillPayloadTool",
|
||||
"CreateSkillCandidateTool",
|
||||
"ListSkillCandidatesTool",
|
||||
"EvaluateSkillCandidateTool",
|
||||
"PromoteSkillCandidateTool",
|
||||
"ListSkillReleasesTool",
|
||||
"RollbackSkillReleaseTool",
|
||||
"SyncSkillReleaseTool",
|
||||
"FileUploadTool",
|
||||
"PythonTool",
|
||||
"LocalPythonTool",
|
||||
"ExecuteShellTool",
|
||||
"FileDownloadTool",
|
||||
]
|
||||
@@ -6,20 +6,23 @@ from astrbot.api import FunctionTool
|
||||
from astrbot.core.agent.run_context import ContextWrapper
|
||||
from astrbot.core.agent.tool import ToolExecResult
|
||||
from astrbot.core.astr_agent_context import AstrAgentContext
|
||||
from astrbot.core.computer.computer_client import get_booter
|
||||
from astrbot.core.tools.computer_tools.util import check_admin_permission
|
||||
from astrbot.core.tools.registry import builtin_tool
|
||||
|
||||
_SHIPYARD_NEO_TOOL_CONFIG = {
|
||||
"provider_settings.computer_use_runtime": "sandbox",
|
||||
"provider_settings.sandbox.booter": "shipyard_neo",
|
||||
}
|
||||
from ..computer_client import get_booter
|
||||
|
||||
|
||||
def _to_json(data: Any) -> str:
|
||||
return json.dumps(data, ensure_ascii=False, default=str)
|
||||
|
||||
|
||||
def _ensure_admin(context: ContextWrapper[AstrAgentContext]) -> str | None:
|
||||
if context.context.event.role != "admin":
|
||||
return (
|
||||
"error: Permission denied. Browser and skill lifecycle tools are only allowed "
|
||||
"for admin users."
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
async def _get_browser_component(context: ContextWrapper[AstrAgentContext]) -> Any:
|
||||
booter = await get_booter(
|
||||
context.context.context,
|
||||
@@ -34,7 +37,6 @@ async def _get_browser_component(context: ContextWrapper[AstrAgentContext]) -> A
|
||||
return browser
|
||||
|
||||
|
||||
@builtin_tool(config=_SHIPYARD_NEO_TOOL_CONFIG)
|
||||
@dataclass
|
||||
class BrowserExecTool(FunctionTool):
|
||||
name: str = "astrbot_execute_browser"
|
||||
@@ -75,7 +77,7 @@ class BrowserExecTool(FunctionTool):
|
||||
learn: bool = False,
|
||||
include_trace: bool = False,
|
||||
) -> ToolExecResult:
|
||||
if err := check_admin_permission(context, "Using browser tools"):
|
||||
if err := _ensure_admin(context):
|
||||
return err
|
||||
try:
|
||||
browser = await _get_browser_component(context)
|
||||
@@ -92,7 +94,6 @@ class BrowserExecTool(FunctionTool):
|
||||
return f"Error executing browser command: {str(e)}"
|
||||
|
||||
|
||||
@builtin_tool(config=_SHIPYARD_NEO_TOOL_CONFIG)
|
||||
@dataclass
|
||||
class BrowserBatchExecTool(FunctionTool):
|
||||
name: str = "astrbot_execute_browser_batch"
|
||||
@@ -139,7 +140,7 @@ class BrowserBatchExecTool(FunctionTool):
|
||||
learn: bool = False,
|
||||
include_trace: bool = False,
|
||||
) -> ToolExecResult:
|
||||
if err := check_admin_permission(context, "Using browser tools"):
|
||||
if err := _ensure_admin(context):
|
||||
return err
|
||||
try:
|
||||
browser = await _get_browser_component(context)
|
||||
@@ -157,7 +158,6 @@ class BrowserBatchExecTool(FunctionTool):
|
||||
return f"Error executing browser batch command: {str(e)}"
|
||||
|
||||
|
||||
@builtin_tool(config=_SHIPYARD_NEO_TOOL_CONFIG)
|
||||
@dataclass
|
||||
class RunBrowserSkillTool(FunctionTool):
|
||||
name: str = "astrbot_run_browser_skill"
|
||||
@@ -187,7 +187,7 @@ class RunBrowserSkillTool(FunctionTool):
|
||||
description: str | None = None,
|
||||
tags: str | None = None,
|
||||
) -> ToolExecResult:
|
||||
if err := check_admin_permission(context, "Using browser tools"):
|
||||
if err := _ensure_admin(context):
|
||||
return err
|
||||
try:
|
||||
browser = await _get_browser_component(context)
|
||||
213
astrbot/core/computer/tools/fs.py
Normal file
213
astrbot/core/computer/tools/fs.py
Normal file
@@ -0,0 +1,213 @@
|
||||
import os
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from astrbot.api import FunctionTool, logger
|
||||
from astrbot.api.event import MessageChain
|
||||
from astrbot.core.agent.run_context import ContextWrapper
|
||||
from astrbot.core.agent.tool import ToolExecResult
|
||||
from astrbot.core.astr_agent_context import AstrAgentContext
|
||||
from astrbot.core.message.components import File
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
|
||||
|
||||
from ..computer_client import get_booter
|
||||
from .permissions import check_admin_permission
|
||||
|
||||
# @dataclass
|
||||
# class CreateFileTool(FunctionTool):
|
||||
# name: str = "astrbot_create_file"
|
||||
# description: str = "Create a new file in the sandbox."
|
||||
# parameters: dict = field(
|
||||
# default_factory=lambda: {
|
||||
# "type": "object",
|
||||
# "properties": {
|
||||
# "path": {
|
||||
# "path": "string",
|
||||
# "description": "The path where the file should be created, relative to the sandbox root. Must not use absolute paths or traverse outside the sandbox.",
|
||||
# },
|
||||
# "content": {
|
||||
# "type": "string",
|
||||
# "description": "The content to write into the file.",
|
||||
# },
|
||||
# },
|
||||
# "required": ["path", "content"],
|
||||
# }
|
||||
# )
|
||||
|
||||
# async def call(
|
||||
# self, context: ContextWrapper[AstrAgentContext], path: str, content: str
|
||||
# ) -> ToolExecResult:
|
||||
# sb = await get_booter(
|
||||
# context.context.context,
|
||||
# context.context.event.unified_msg_origin,
|
||||
# )
|
||||
# try:
|
||||
# result = await sb.fs.create_file(path, content)
|
||||
# return json.dumps(result)
|
||||
# except Exception as e:
|
||||
# return f"Error creating file: {str(e)}"
|
||||
|
||||
|
||||
# @dataclass
|
||||
# class ReadFileTool(FunctionTool):
|
||||
# name: str = "astrbot_read_file"
|
||||
# description: str = "Read the content of a file in the sandbox."
|
||||
# parameters: dict = field(
|
||||
# default_factory=lambda: {
|
||||
# "type": "object",
|
||||
# "properties": {
|
||||
# "path": {
|
||||
# "type": "string",
|
||||
# "description": "The path of the file to read, relative to the sandbox root. Must not use absolute paths or traverse outside the sandbox.",
|
||||
# },
|
||||
# },
|
||||
# "required": ["path"],
|
||||
# }
|
||||
# )
|
||||
|
||||
# async def call(self, context: ContextWrapper[AstrAgentContext], path: str):
|
||||
# sb = await get_booter(
|
||||
# context.context.context,
|
||||
# context.context.event.unified_msg_origin,
|
||||
# )
|
||||
# try:
|
||||
# result = await sb.fs.read_file(path)
|
||||
# return result
|
||||
# except Exception as e:
|
||||
# return f"Error reading file: {str(e)}"
|
||||
|
||||
|
||||
@dataclass
|
||||
class FileUploadTool(FunctionTool):
|
||||
name: str = "astrbot_upload_file"
|
||||
description: str = (
|
||||
"Transfer a file FROM the host machine INTO the sandbox so that sandbox "
|
||||
"code can access it. Use this when the user sends/attaches a file and you "
|
||||
"need to process it inside the sandbox. The local_path must point to an "
|
||||
"existing file on the host filesystem."
|
||||
)
|
||||
parameters: dict = field(
|
||||
default_factory=lambda: {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"local_path": {
|
||||
"type": "string",
|
||||
"description": "Absolute path to the file on the host filesystem that will be copied into the sandbox.",
|
||||
},
|
||||
# "remote_path": {
|
||||
# "type": "string",
|
||||
# "description": "The filename to use in the sandbox. If not provided, file will be saved to the working directory with the same name as the local file.",
|
||||
# },
|
||||
},
|
||||
"required": ["local_path"],
|
||||
}
|
||||
)
|
||||
|
||||
async def call(
|
||||
self,
|
||||
context: ContextWrapper[AstrAgentContext],
|
||||
local_path: str,
|
||||
) -> str | None:
|
||||
if permission_error := check_admin_permission(context, "File upload/download"):
|
||||
return permission_error
|
||||
sb = await get_booter(
|
||||
context.context.context,
|
||||
context.context.event.unified_msg_origin,
|
||||
)
|
||||
try:
|
||||
# Check if file exists
|
||||
if not os.path.exists(local_path):
|
||||
return f"Error: File does not exist: {local_path}"
|
||||
|
||||
if not os.path.isfile(local_path):
|
||||
return f"Error: Path is not a file: {local_path}"
|
||||
|
||||
# Use basename if sandbox_filename is not provided
|
||||
remote_path = os.path.basename(local_path)
|
||||
|
||||
# Upload file to sandbox
|
||||
result = await sb.upload_file(local_path, remote_path)
|
||||
logger.debug(f"Upload result: {result}")
|
||||
success = result.get("success", False)
|
||||
|
||||
if not success:
|
||||
return f"Error uploading file: {result.get('message', 'Unknown error')}"
|
||||
|
||||
file_path = result.get("file_path", "")
|
||||
logger.info(f"File {local_path} uploaded to sandbox at {file_path}")
|
||||
|
||||
return f"File uploaded successfully to {file_path}"
|
||||
except Exception as e:
|
||||
logger.error(f"Error uploading file {local_path}: {e}")
|
||||
return f"Error uploading file: {str(e)}"
|
||||
|
||||
|
||||
@dataclass
|
||||
class FileDownloadTool(FunctionTool):
|
||||
name: str = "astrbot_download_file"
|
||||
description: str = (
|
||||
"Transfer a file FROM the sandbox OUT to the host and optionally send it "
|
||||
"to the user. Use this ONLY when the user asks to retrieve/export a file "
|
||||
"that was created or modified inside the sandbox."
|
||||
)
|
||||
parameters: dict = field(
|
||||
default_factory=lambda: {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"remote_path": {
|
||||
"type": "string",
|
||||
"description": "Path of the file inside the sandbox to copy out to the host.",
|
||||
},
|
||||
"also_send_to_user": {
|
||||
"type": "boolean",
|
||||
"description": "Whether to also send the downloaded file to the user via message. Defaults to true.",
|
||||
},
|
||||
},
|
||||
"required": ["remote_path"],
|
||||
}
|
||||
)
|
||||
|
||||
async def call(
|
||||
self,
|
||||
context: ContextWrapper[AstrAgentContext],
|
||||
remote_path: str,
|
||||
also_send_to_user: bool = True,
|
||||
) -> ToolExecResult:
|
||||
if permission_error := check_admin_permission(context, "File upload/download"):
|
||||
return permission_error
|
||||
sb = await get_booter(
|
||||
context.context.context,
|
||||
context.context.event.unified_msg_origin,
|
||||
)
|
||||
try:
|
||||
name = os.path.basename(remote_path)
|
||||
|
||||
local_path = os.path.join(
|
||||
get_astrbot_temp_path(), f"sandbox_{uuid.uuid4().hex[:4]}_{name}"
|
||||
)
|
||||
|
||||
# Download file from sandbox
|
||||
await sb.download_file(remote_path, local_path)
|
||||
logger.info(f"File {remote_path} downloaded from sandbox to {local_path}")
|
||||
|
||||
if also_send_to_user:
|
||||
try:
|
||||
name = os.path.basename(local_path)
|
||||
await context.context.event.send(
|
||||
MessageChain(chain=[File(name=name, file=local_path)])
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error sending file message: {e}")
|
||||
|
||||
# remove
|
||||
# try:
|
||||
# os.remove(local_path)
|
||||
# except Exception as e:
|
||||
# logger.error(f"Error removing temp file {local_path}: {e}")
|
||||
|
||||
return f"File downloaded successfully to {local_path} and sent to user."
|
||||
|
||||
return f"File downloaded successfully to {local_path}"
|
||||
except Exception as e:
|
||||
logger.error(f"Error downloading file {remote_path}: {e}")
|
||||
return f"Error downloading file: {str(e)}"
|
||||
@@ -7,15 +7,9 @@ from astrbot.api import FunctionTool
|
||||
from astrbot.core.agent.run_context import ContextWrapper
|
||||
from astrbot.core.agent.tool import ToolExecResult
|
||||
from astrbot.core.astr_agent_context import AstrAgentContext
|
||||
from astrbot.core.computer.computer_client import get_booter
|
||||
from astrbot.core.skills.neo_skill_sync import NeoSkillSyncManager
|
||||
from astrbot.core.tools.computer_tools.util import check_admin_permission
|
||||
from astrbot.core.tools.registry import builtin_tool
|
||||
|
||||
_SHIPYARD_NEO_TOOL_CONFIG = {
|
||||
"provider_settings.computer_use_runtime": "sandbox",
|
||||
"provider_settings.sandbox.booter": "shipyard_neo",
|
||||
}
|
||||
from ..computer_client import get_booter
|
||||
|
||||
|
||||
def _to_jsonable(model_like: Any) -> Any:
|
||||
@@ -32,6 +26,12 @@ def _to_json_text(data: Any) -> str:
|
||||
return json.dumps(_to_jsonable(data), ensure_ascii=False, default=str)
|
||||
|
||||
|
||||
def _ensure_admin(context: ContextWrapper[AstrAgentContext]) -> str | None:
|
||||
if context.context.event.role != "admin":
|
||||
return "error: Permission denied. Skill lifecycle tools are only allowed for admin users."
|
||||
return None
|
||||
|
||||
|
||||
async def _get_neo_context(
|
||||
context: ContextWrapper[AstrAgentContext],
|
||||
) -> tuple[Any, Any]:
|
||||
@@ -59,7 +59,7 @@ class NeoSkillToolBase(FunctionTool):
|
||||
neo_call: Callable[[Any, Any], Awaitable[Any]],
|
||||
error_action: str,
|
||||
) -> ToolExecResult:
|
||||
if err := check_admin_permission(context, "Using skill lifecycle tools"):
|
||||
if err := _ensure_admin(context):
|
||||
return err
|
||||
try:
|
||||
client, sandbox = await _get_neo_context(context)
|
||||
@@ -69,7 +69,6 @@ class NeoSkillToolBase(FunctionTool):
|
||||
return f"{self.error_prefix} {error_action}: {str(e)}"
|
||||
|
||||
|
||||
@builtin_tool(config=_SHIPYARD_NEO_TOOL_CONFIG)
|
||||
@dataclass
|
||||
class GetExecutionHistoryTool(NeoSkillToolBase):
|
||||
name: str = "astrbot_get_execution_history"
|
||||
@@ -116,7 +115,6 @@ class GetExecutionHistoryTool(NeoSkillToolBase):
|
||||
)
|
||||
|
||||
|
||||
@builtin_tool(config=_SHIPYARD_NEO_TOOL_CONFIG)
|
||||
@dataclass
|
||||
class AnnotateExecutionTool(NeoSkillToolBase):
|
||||
name: str = "astrbot_annotate_execution"
|
||||
@@ -154,7 +152,6 @@ class AnnotateExecutionTool(NeoSkillToolBase):
|
||||
)
|
||||
|
||||
|
||||
@builtin_tool(config=_SHIPYARD_NEO_TOOL_CONFIG)
|
||||
@dataclass
|
||||
class CreateSkillPayloadTool(NeoSkillToolBase):
|
||||
name: str = "astrbot_create_skill_payload"
|
||||
@@ -202,7 +199,6 @@ class CreateSkillPayloadTool(NeoSkillToolBase):
|
||||
)
|
||||
|
||||
|
||||
@builtin_tool(config=_SHIPYARD_NEO_TOOL_CONFIG)
|
||||
@dataclass
|
||||
class GetSkillPayloadTool(NeoSkillToolBase):
|
||||
name: str = "astrbot_get_skill_payload"
|
||||
@@ -229,7 +225,6 @@ class GetSkillPayloadTool(NeoSkillToolBase):
|
||||
)
|
||||
|
||||
|
||||
@builtin_tool(config=_SHIPYARD_NEO_TOOL_CONFIG)
|
||||
@dataclass
|
||||
class CreateSkillCandidateTool(NeoSkillToolBase):
|
||||
name: str = "astrbot_create_skill_candidate"
|
||||
@@ -283,7 +278,6 @@ class CreateSkillCandidateTool(NeoSkillToolBase):
|
||||
)
|
||||
|
||||
|
||||
@builtin_tool(config=_SHIPYARD_NEO_TOOL_CONFIG)
|
||||
@dataclass
|
||||
class ListSkillCandidatesTool(NeoSkillToolBase):
|
||||
name: str = "astrbot_list_skill_candidates"
|
||||
@@ -321,7 +315,6 @@ class ListSkillCandidatesTool(NeoSkillToolBase):
|
||||
)
|
||||
|
||||
|
||||
@builtin_tool(config=_SHIPYARD_NEO_TOOL_CONFIG)
|
||||
@dataclass
|
||||
class EvaluateSkillCandidateTool(NeoSkillToolBase):
|
||||
name: str = "astrbot_evaluate_skill_candidate"
|
||||
@@ -362,7 +355,6 @@ class EvaluateSkillCandidateTool(NeoSkillToolBase):
|
||||
)
|
||||
|
||||
|
||||
@builtin_tool(config=_SHIPYARD_NEO_TOOL_CONFIG)
|
||||
@dataclass
|
||||
class PromoteSkillCandidateTool(NeoSkillToolBase):
|
||||
name: str = "astrbot_promote_skill_candidate"
|
||||
@@ -400,7 +392,7 @@ class PromoteSkillCandidateTool(NeoSkillToolBase):
|
||||
stage: str = "canary",
|
||||
sync_to_local: bool = True,
|
||||
) -> ToolExecResult:
|
||||
if err := check_admin_permission(context, "Using skill lifecycle tools"):
|
||||
if err := _ensure_admin(context):
|
||||
return err
|
||||
if stage not in {"canary", "stable"}:
|
||||
return "Error promoting skill candidate: stage must be canary or stable."
|
||||
@@ -433,7 +425,6 @@ class PromoteSkillCandidateTool(NeoSkillToolBase):
|
||||
return f"Error promoting skill candidate: {str(e)}"
|
||||
|
||||
|
||||
@builtin_tool(config=_SHIPYARD_NEO_TOOL_CONFIG)
|
||||
@dataclass
|
||||
class ListSkillReleasesTool(NeoSkillToolBase):
|
||||
name: str = "astrbot_list_skill_releases"
|
||||
@@ -474,7 +465,6 @@ class ListSkillReleasesTool(NeoSkillToolBase):
|
||||
)
|
||||
|
||||
|
||||
@builtin_tool(config=_SHIPYARD_NEO_TOOL_CONFIG)
|
||||
@dataclass
|
||||
class RollbackSkillReleaseTool(NeoSkillToolBase):
|
||||
name: str = "astrbot_rollback_skill_release"
|
||||
@@ -501,7 +491,6 @@ class RollbackSkillReleaseTool(NeoSkillToolBase):
|
||||
)
|
||||
|
||||
|
||||
@builtin_tool(config=_SHIPYARD_NEO_TOOL_CONFIG)
|
||||
@dataclass
|
||||
class SyncSkillReleaseTool(NeoSkillToolBase):
|
||||
name: str = "astrbot_sync_skill_release"
|
||||
@@ -1,29 +1,5 @@
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from astrbot.core.agent.run_context import ContextWrapper
|
||||
from astrbot.core.astr_agent_context import AstrAgentContext
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_workspaces_path
|
||||
|
||||
|
||||
def normalize_umo_for_workspace(umo: str) -> str:
|
||||
normalized = re.sub(r"[^A-Za-z0-9._-]+", "_", umo.strip())
|
||||
return normalized or "unknown"
|
||||
|
||||
|
||||
def workspace_root(umo: str) -> Path:
|
||||
"""Root directory for relative paths in local runtime"""
|
||||
normalized_umo = normalize_umo_for_workspace(umo)
|
||||
return (Path(get_astrbot_workspaces_path()) / normalized_umo).resolve(strict=False)
|
||||
|
||||
|
||||
def is_local_runtime(context: ContextWrapper[AstrAgentContext]) -> bool:
|
||||
cfg = context.context.context.get_config(
|
||||
umo=context.context.event.unified_msg_origin
|
||||
)
|
||||
provider_settings = cfg.get("provider_settings", {})
|
||||
runtime = str(provider_settings.get("computer_use_runtime", "local"))
|
||||
return runtime == "local"
|
||||
|
||||
|
||||
def check_admin_permission(
|
||||
@@ -8,18 +8,10 @@ from astrbot.core.agent.run_context import ContextWrapper
|
||||
from astrbot.core.agent.tool import ToolExecResult
|
||||
from astrbot.core.astr_agent_context import AstrAgentContext, AstrMessageEvent
|
||||
from astrbot.core.computer.computer_client import get_booter, get_local_booter
|
||||
from astrbot.core.computer.tools.permissions import check_admin_permission
|
||||
from astrbot.core.message.message_event_result import MessageChain
|
||||
|
||||
from ..registry import builtin_tool
|
||||
from .util import check_admin_permission
|
||||
|
||||
_OS_NAME = platform.system()
|
||||
_SANDBOX_PYTHON_TOOL_CONFIG = {
|
||||
"provider_settings.computer_use_runtime": "sandbox",
|
||||
}
|
||||
_LOCAL_PYTHON_TOOL_CONFIG = {
|
||||
"provider_settings.computer_use_runtime": "local",
|
||||
}
|
||||
|
||||
param_schema = {
|
||||
"type": "object",
|
||||
@@ -69,7 +61,6 @@ async def handle_result(result: dict, event: AstrMessageEvent) -> ToolExecResult
|
||||
return resp
|
||||
|
||||
|
||||
@builtin_tool(config=_SANDBOX_PYTHON_TOOL_CONFIG)
|
||||
@dataclass
|
||||
class PythonTool(FunctionTool):
|
||||
name: str = "astrbot_execute_ipython"
|
||||
@@ -92,7 +83,6 @@ class PythonTool(FunctionTool):
|
||||
return f"Error executing code: {str(e)}"
|
||||
|
||||
|
||||
@builtin_tool(config=_LOCAL_PYTHON_TOOL_CONFIG)
|
||||
@dataclass
|
||||
class LocalPythonTool(FunctionTool):
|
||||
name: str = "astrbot_execute_python"
|
||||
@@ -5,17 +5,11 @@ from astrbot.api import FunctionTool
|
||||
from astrbot.core.agent.run_context import ContextWrapper
|
||||
from astrbot.core.agent.tool import ToolExecResult
|
||||
from astrbot.core.astr_agent_context import AstrAgentContext
|
||||
from astrbot.core.computer.computer_client import get_booter
|
||||
|
||||
from ..registry import builtin_tool
|
||||
from .util import check_admin_permission, is_local_runtime, workspace_root
|
||||
|
||||
_COMPUTER_RUNTIME_TOOL_CONFIG = {
|
||||
"provider_settings.computer_use_runtime": ("local", "sandbox"),
|
||||
}
|
||||
from ..computer_client import get_booter, get_local_booter
|
||||
from .permissions import check_admin_permission
|
||||
|
||||
|
||||
@builtin_tool(config=_COMPUTER_RUNTIME_TOOL_CONFIG)
|
||||
@dataclass
|
||||
class ExecuteShellTool(FunctionTool):
|
||||
name: str = "astrbot_execute_shell"
|
||||
@@ -44,6 +38,8 @@ class ExecuteShellTool(FunctionTool):
|
||||
}
|
||||
)
|
||||
|
||||
is_local: bool = False
|
||||
|
||||
async def call(
|
||||
self,
|
||||
context: ContextWrapper[AstrAgentContext],
|
||||
@@ -54,25 +50,15 @@ class ExecuteShellTool(FunctionTool):
|
||||
if permission_error := check_admin_permission(context, "Shell execution"):
|
||||
return permission_error
|
||||
|
||||
sb = await get_booter(
|
||||
context.context.context,
|
||||
context.context.event.unified_msg_origin,
|
||||
)
|
||||
try:
|
||||
cwd: str | None = None
|
||||
if is_local_runtime(context):
|
||||
current_workspace_root = workspace_root(
|
||||
context.context.event.unified_msg_origin
|
||||
)
|
||||
current_workspace_root.mkdir(parents=True, exist_ok=True)
|
||||
cwd = str(current_workspace_root)
|
||||
|
||||
result = await sb.shell.exec(
|
||||
command,
|
||||
cwd=cwd,
|
||||
background=background,
|
||||
env=env,
|
||||
if self.is_local:
|
||||
sb = get_local_booter()
|
||||
else:
|
||||
sb = await get_booter(
|
||||
context.context.context,
|
||||
context.context.event.unified_msg_origin,
|
||||
)
|
||||
return json.dumps(result, ensure_ascii=False)
|
||||
try:
|
||||
result = await sb.shell.exec(command, background=background, env=env)
|
||||
return json.dumps(result)
|
||||
except Exception as e:
|
||||
return f"Error executing command: {str(e)}"
|
||||
@@ -3,10 +3,9 @@
|
||||
import os
|
||||
from typing import Any, TypedDict
|
||||
|
||||
from astrbot.core.i18n import Language
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
||||
|
||||
VERSION = "4.23.0"
|
||||
VERSION = "4.22.0"
|
||||
DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db")
|
||||
PERSONAL_WECHAT_CONFIG_METADATA = {
|
||||
"weixin_oc_base_url": {
|
||||
@@ -54,7 +53,6 @@ WEBHOOK_SUPPORTED_PLATFORMS = [
|
||||
# 默认配置
|
||||
DEFAULT_CONFIG = {
|
||||
"config_version": 2,
|
||||
"language": Language.ZH_CN.value,
|
||||
"platform_settings": {
|
||||
"unique_session": False,
|
||||
"rate_limit": {
|
||||
@@ -108,10 +106,9 @@ DEFAULT_CONFIG = {
|
||||
"provider_pool": ["*"], # "*" 表示使用所有可用的提供者
|
||||
"wake_prefix": "",
|
||||
"web_search": False,
|
||||
"websearch_provider": "tavily",
|
||||
"websearch_provider": "default",
|
||||
"websearch_tavily_key": [],
|
||||
"websearch_bocha_key": [],
|
||||
"websearch_brave_key": [],
|
||||
"websearch_baidu_app_builder_key": "",
|
||||
"web_search_link": False,
|
||||
"display_reasoning_text": False,
|
||||
@@ -456,7 +453,6 @@ CONFIG_METADATA_2 = {
|
||||
"discord_proxy": "",
|
||||
"discord_command_register": True,
|
||||
"discord_activity_name": "",
|
||||
"discord_allow_bot_messages": False,
|
||||
},
|
||||
"Misskey": {
|
||||
"id": "misskey",
|
||||
@@ -510,7 +506,7 @@ CONFIG_METADATA_2 = {
|
||||
"satori_heartbeat_interval": 10,
|
||||
"satori_reconnect_delay": 5,
|
||||
},
|
||||
"KOOK": {
|
||||
"kook": {
|
||||
"id": "kook",
|
||||
"type": "kook",
|
||||
"enable": False,
|
||||
@@ -523,14 +519,6 @@ CONFIG_METADATA_2 = {
|
||||
"kook_max_heartbeat_failures": 3,
|
||||
"kook_max_consecutive_failures": 5,
|
||||
},
|
||||
"Mattermost": {
|
||||
"id": "mattermost",
|
||||
"type": "mattermost",
|
||||
"enable": False,
|
||||
"mattermost_url": "https://chat.example.com",
|
||||
"mattermost_bot_token": "",
|
||||
"mattermost_reconnect_delay": 5.0,
|
||||
},
|
||||
# "WebChat": {
|
||||
# "id": "webchat",
|
||||
# "type": "webchat",
|
||||
@@ -665,21 +653,6 @@ CONFIG_METADATA_2 = {
|
||||
"type": "string",
|
||||
"hint": "如果你的网络环境为中国大陆,请在 `其他配置` 处设置代理或更改 api_base。",
|
||||
},
|
||||
"mattermost_url": {
|
||||
"description": "Mattermost URL",
|
||||
"type": "string",
|
||||
"hint": "Mattermost 服务地址,例如 https://chat.example.com。",
|
||||
},
|
||||
"mattermost_bot_token": {
|
||||
"description": "Mattermost Bot Token",
|
||||
"type": "string",
|
||||
"hint": "在 Mattermost 中创建 Bot 账户后生成的访问令牌。",
|
||||
},
|
||||
"mattermost_reconnect_delay": {
|
||||
"description": "Mattermost 重连延迟",
|
||||
"type": "float",
|
||||
"hint": "WebSocket 断开后的重连等待时间,单位为秒。默认 5 秒。",
|
||||
},
|
||||
"misskey_instance_url": {
|
||||
"description": "Misskey 实例 URL",
|
||||
"type": "string",
|
||||
@@ -922,11 +895,6 @@ CONFIG_METADATA_2 = {
|
||||
"type": "string",
|
||||
"hint": "可选的 Discord 活动名称。留空则不设置活动。",
|
||||
},
|
||||
"discord_allow_bot_messages": {
|
||||
"description": "允许接收机器人消息",
|
||||
"type": "bool",
|
||||
"hint": "启用后,AstrBot 将接收来自其他 Discord 机器人的消息。适用于机器人间通信场景(如消息转发)。默认关闭。",
|
||||
},
|
||||
"port": {
|
||||
"description": "回调服务器端口",
|
||||
"type": "int",
|
||||
@@ -1275,18 +1243,6 @@ CONFIG_METADATA_2 = {
|
||||
"proxy": "",
|
||||
"custom_headers": {},
|
||||
},
|
||||
"LongCat": {
|
||||
"id": "longcat",
|
||||
"provider": "longcat",
|
||||
"type": "longcat_chat_completion",
|
||||
"provider_type": "chat_completion",
|
||||
"enable": True,
|
||||
"key": [],
|
||||
"api_base": "https://api.longcat.chat/openai",
|
||||
"timeout": 120,
|
||||
"proxy": "",
|
||||
"custom_headers": {},
|
||||
},
|
||||
"AIHubMix": {
|
||||
"id": "aihubmix",
|
||||
"provider": "aihubmix",
|
||||
@@ -1565,7 +1521,6 @@ CONFIG_METADATA_2 = {
|
||||
"enable": False,
|
||||
"id": "whisper_selfhost",
|
||||
"model": "tiny",
|
||||
"whisper_device": "cpu",
|
||||
},
|
||||
"SenseVoice(Local)": {
|
||||
"type": "sensevoice_stt_selfhost",
|
||||
@@ -1668,14 +1623,10 @@ CONFIG_METADATA_2 = {
|
||||
"type": "gsvi_tts_api",
|
||||
"provider": "gpt_sovits_inference",
|
||||
"provider_type": "text_to_speech",
|
||||
"enable": False,
|
||||
"api_key": "",
|
||||
"api_base": "http://127.0.0.1:8000",
|
||||
"version": "v4",
|
||||
"api_base": "http://127.0.0.1:5000",
|
||||
"character": "",
|
||||
"prompt_text_lang": "中文",
|
||||
"emotion": "默认",
|
||||
"text_lang": "中文",
|
||||
"emotion": "default",
|
||||
"enable": False,
|
||||
"timeout": 20,
|
||||
},
|
||||
"FishAudio TTS(API)": {
|
||||
@@ -1806,7 +1757,6 @@ CONFIG_METADATA_2 = {
|
||||
"enable": True,
|
||||
"rerank_api_key": "",
|
||||
"rerank_api_base": "http://127.0.0.1:8000",
|
||||
"rerank_api_suffix": "/v1/rerank",
|
||||
"rerank_model": "BAAI/bge-reranker-base",
|
||||
"timeout": 20,
|
||||
},
|
||||
@@ -1835,19 +1785,6 @@ CONFIG_METADATA_2 = {
|
||||
"return_documents": False,
|
||||
"instruct": "",
|
||||
},
|
||||
"NVIDIA Rerank": {
|
||||
"id": "nvidia_rerank",
|
||||
"type": "nvidia_rerank",
|
||||
"provider": "nvidia",
|
||||
"provider_type": "rerank",
|
||||
"enable": True,
|
||||
"nvidia_rerank_api_key": "",
|
||||
"nvidia_rerank_api_base": "https://ai.api.nvidia.com/v1/retrieval",
|
||||
"nvidia_rerank_model": "nv-rerank-qa-mistral-4b:1",
|
||||
"nvidia_rerank_model_endpoint": "/reranking",
|
||||
"timeout": 20,
|
||||
"nvidia_rerank_truncate": "",
|
||||
},
|
||||
"Xinference STT": {
|
||||
"id": "xinference_stt",
|
||||
"type": "xinference_stt",
|
||||
@@ -1885,12 +1822,7 @@ CONFIG_METADATA_2 = {
|
||||
"rerank_api_base": {
|
||||
"description": "重排序模型 API Base URL",
|
||||
"type": "string",
|
||||
"hint": "最终请求路径由 Base URL 和路径后缀拼接而成(默认为 /v1/rerank)。",
|
||||
},
|
||||
"rerank_api_suffix": {
|
||||
"description": "API URL 路径后缀",
|
||||
"type": "string",
|
||||
"hint": "追加到 base_url 后的路径,如 /v1/rerank。留空则不追加。",
|
||||
"hint": "AstrBot 会在请求时在末尾加上 /v1/rerank。",
|
||||
},
|
||||
"rerank_api_key": {
|
||||
"description": "API Key",
|
||||
@@ -1916,40 +1848,12 @@ CONFIG_METADATA_2 = {
|
||||
"type": "bool",
|
||||
"hint": "如果模型当前未在 Xinference 服务中运行,是否尝试自动启动它。在生产环境中建议关闭。",
|
||||
},
|
||||
"nvidia_rerank_api_base": {
|
||||
"description": "API Base URL",
|
||||
"type": "string",
|
||||
},
|
||||
"nvidia_rerank_api_key": {
|
||||
"description": "API Key",
|
||||
"type": "string",
|
||||
},
|
||||
"nvidia_rerank_model": {
|
||||
"description": "重排序模型名称",
|
||||
"type": "string",
|
||||
"hint": "请参照NVIDIA Docs中模型名称填写。",
|
||||
},
|
||||
"nvidia_rerank_model_endpoint": {
|
||||
"description": "自定义模型端点",
|
||||
"type": "string",
|
||||
"hint": "自定义URL末尾端点,默认为 /reranking",
|
||||
},
|
||||
"nvidia_rerank_truncate": {
|
||||
"description": "文本截断策略",
|
||||
"type": "string",
|
||||
"hint": "当输入文本过长时,是否截断输入以适应模型的最大上下文长度。",
|
||||
"options": [
|
||||
"",
|
||||
"NONE",
|
||||
"END",
|
||||
],
|
||||
},
|
||||
"modalities": {
|
||||
"description": "模型能力",
|
||||
"type": "list",
|
||||
"items": {"type": "string"},
|
||||
"options": ["text", "image", "audio", "tool_use"],
|
||||
"labels": ["文本", "图像", "音频", "工具使用"],
|
||||
"options": ["text", "image", "tool_use"],
|
||||
"labels": ["文本", "图像", "工具使用"],
|
||||
"render_type": "checkbox",
|
||||
"hint": "模型支持的模态。如所填写的模型不支持图像,请取消勾选图像。",
|
||||
},
|
||||
@@ -2565,12 +2469,6 @@ CONFIG_METADATA_2 = {
|
||||
"type": "string",
|
||||
"hint": "启用前请 pip 安装 openai-whisper 库(N卡用户大约下载 2GB,主要是 torch 和 cuda,CPU 用户大约下载 1 GB),并且安装 ffmpeg。否则将无法正常转文字。",
|
||||
},
|
||||
"whisper_device": {
|
||||
"description": "推理设备",
|
||||
"type": "string",
|
||||
"hint": "Whisper 推理设备。Apple Silicon 可选 mps;其他环境建议使用 cpu。若指定 mps 但当前环境不可用,将自动回退到 cpu。",
|
||||
"options": ["cpu", "mps"],
|
||||
},
|
||||
"id": {
|
||||
"description": "ID",
|
||||
"type": "string",
|
||||
@@ -2673,12 +2571,12 @@ CONFIG_METADATA_2 = {
|
||||
"deerflow_assistant_id": {
|
||||
"description": "Assistant ID",
|
||||
"type": "string",
|
||||
"hint": "DeerFlow 2.0 LangGraph assistant_id,默认为 lead_agent。",
|
||||
"hint": "LangGraph assistant_id,默认为 lead_agent。",
|
||||
},
|
||||
"deerflow_model_name": {
|
||||
"description": "模型名称覆盖",
|
||||
"type": "string",
|
||||
"hint": "可选。覆盖 DeerFlow 默认模型(对应运行时 configurable 的 model_name)。",
|
||||
"hint": "可选。覆盖 DeerFlow 默认模型(对应 runtime context 的 model_name)。",
|
||||
},
|
||||
"deerflow_thinking_enabled": {
|
||||
"description": "启用思考模式",
|
||||
@@ -2687,17 +2585,17 @@ CONFIG_METADATA_2 = {
|
||||
"deerflow_plan_mode": {
|
||||
"description": "启用计划模式",
|
||||
"type": "bool",
|
||||
"hint": "对应 DeerFlow 2.0 运行时 configurable 的 is_plan_mode。",
|
||||
"hint": "对应 DeerFlow 的 is_plan_mode。",
|
||||
},
|
||||
"deerflow_subagent_enabled": {
|
||||
"description": "启用子智能体",
|
||||
"type": "bool",
|
||||
"hint": "对应 DeerFlow 2.0 运行时 configurable 的 subagent_enabled。",
|
||||
"hint": "对应 DeerFlow 的 subagent_enabled。",
|
||||
},
|
||||
"deerflow_max_concurrent_subagents": {
|
||||
"description": "子智能体最大并发数",
|
||||
"type": "int",
|
||||
"hint": "对应 DeerFlow 2.0 运行时 configurable 的 max_concurrent_subagents。仅在启用子智能体时生效,默认 3。",
|
||||
"hint": "对应 DeerFlow 的 max_concurrent_subagents。仅在启用子智能体时生效,默认 3。",
|
||||
},
|
||||
"deerflow_recursion_limit": {
|
||||
"description": "递归深度上限",
|
||||
@@ -3182,12 +3080,7 @@ CONFIG_METADATA_3 = {
|
||||
"provider_settings.websearch_provider": {
|
||||
"description": "网页搜索提供商",
|
||||
"type": "string",
|
||||
"options": [
|
||||
"tavily",
|
||||
"baidu_ai_search",
|
||||
"bocha",
|
||||
"brave",
|
||||
],
|
||||
"options": ["default", "tavily", "baidu_ai_search", "bocha"],
|
||||
"condition": {
|
||||
"provider_settings.web_search": True,
|
||||
},
|
||||
@@ -3212,16 +3105,6 @@ CONFIG_METADATA_3 = {
|
||||
"provider_settings.web_search": True,
|
||||
},
|
||||
},
|
||||
"provider_settings.websearch_brave_key": {
|
||||
"description": "Brave Search API Key",
|
||||
"type": "list",
|
||||
"items": {"type": "string"},
|
||||
"hint": "可添加多个 Key 进行轮询。",
|
||||
"condition": {
|
||||
"provider_settings.websearch_provider": "brave",
|
||||
"provider_settings.web_search": True,
|
||||
},
|
||||
},
|
||||
"provider_settings.websearch_baidu_app_builder_key": {
|
||||
"description": "百度千帆智能云 APP Builder API Key",
|
||||
"type": "string",
|
||||
@@ -3600,13 +3483,11 @@ CONFIG_METADATA_3 = {
|
||||
"provider_tts_settings.dual_output": {
|
||||
"description": "开启 TTS 时同时输出语音和文字内容",
|
||||
"type": "bool",
|
||||
"collapsed": True,
|
||||
},
|
||||
"provider_settings.reachability_check": {
|
||||
"description": "提供商可达性检测",
|
||||
"type": "bool",
|
||||
"hint": "/provider 命令列出模型时是否并发检测连通性。开启后会主动调用模型测试连通性,可能产生额外 token 消耗。",
|
||||
"collapsed": True,
|
||||
},
|
||||
"provider_settings.max_quoted_fallback_images": {
|
||||
"description": "引用图片回退解析上限",
|
||||
@@ -3615,7 +3496,6 @@ CONFIG_METADATA_3 = {
|
||||
"condition": {
|
||||
"provider_settings.agent_runner_type": "local",
|
||||
},
|
||||
"collapsed": True,
|
||||
},
|
||||
"provider_settings.quoted_message_parser.max_component_chain_depth": {
|
||||
"description": "引用解析组件链深度",
|
||||
@@ -3624,7 +3504,6 @@ CONFIG_METADATA_3 = {
|
||||
"condition": {
|
||||
"provider_settings.agent_runner_type": "local",
|
||||
},
|
||||
"collapsed": True,
|
||||
},
|
||||
"provider_settings.quoted_message_parser.max_forward_node_depth": {
|
||||
"description": "引用解析转发节点深度",
|
||||
@@ -3633,7 +3512,6 @@ CONFIG_METADATA_3 = {
|
||||
"condition": {
|
||||
"provider_settings.agent_runner_type": "local",
|
||||
},
|
||||
"collapsed": True,
|
||||
},
|
||||
"provider_settings.quoted_message_parser.max_forward_fetch": {
|
||||
"description": "引用解析转发拉取上限",
|
||||
@@ -3642,7 +3520,6 @@ CONFIG_METADATA_3 = {
|
||||
"condition": {
|
||||
"provider_settings.agent_runner_type": "local",
|
||||
},
|
||||
"collapsed": True,
|
||||
},
|
||||
"provider_settings.quoted_message_parser.warn_on_action_failure": {
|
||||
"description": "引用解析 action 失败告警",
|
||||
@@ -3651,7 +3528,6 @@ CONFIG_METADATA_3 = {
|
||||
"condition": {
|
||||
"provider_settings.agent_runner_type": "local",
|
||||
},
|
||||
"collapsed": True,
|
||||
},
|
||||
},
|
||||
"condition": {
|
||||
@@ -4039,13 +3915,6 @@ CONFIG_METADATA_3_SYSTEM = {
|
||||
"description": "系统配置",
|
||||
"type": "object",
|
||||
"items": {
|
||||
"language": {
|
||||
"description": "系统语言",
|
||||
"type": "string",
|
||||
"hint": "用于 AstrBot 运行时回复的语言。目前支持简体中文和英文。",
|
||||
"options": [Language.ZH_CN.value, Language.EN_US.value],
|
||||
"labels": ["简体中文", "English"],
|
||||
},
|
||||
"t2i_strategy": {
|
||||
"description": "文本转图像策略",
|
||||
"type": "string",
|
||||
@@ -4160,9 +4029,9 @@ CONFIG_METADATA_3_SYSTEM = {
|
||||
"hint": "时区设置。请填写 IANA 时区名称, 如 Asia/Shanghai, 为空时使用系统默认时区。所有时区请查看: https://data.iana.org/time-zones/tzdb-2021a/zone1970.tab",
|
||||
},
|
||||
"http_proxy": {
|
||||
"description": "代理",
|
||||
"description": "HTTP 代理",
|
||||
"type": "string",
|
||||
"hint": "启用后,会以添加环境变量的方式设置代理。支持 http://、https://、socks5:// 格式,例如:http://127.0.0.1:7890 或 socks5://127.0.0.1:7891",
|
||||
"hint": "启用后,会以添加环境变量的方式设置代理。格式为 `http://ip:port`",
|
||||
},
|
||||
"no_proxy": {
|
||||
"description": "直连地址列表",
|
||||
|
||||
@@ -275,8 +275,8 @@ class CronJobManager:
|
||||
)
|
||||
from astrbot.core.astr_main_agent_resources import (
|
||||
PROACTIVE_AGENT_CRON_WOKE_SYSTEM_PROMPT,
|
||||
SEND_MESSAGE_TO_USER_TOOL,
|
||||
)
|
||||
from astrbot.core.tools.message_tools import SendMessageToUserTool
|
||||
|
||||
try:
|
||||
session = (
|
||||
@@ -342,9 +342,7 @@ class CronJobManager:
|
||||
)
|
||||
if not req.func_tool:
|
||||
req.func_tool = ToolSet()
|
||||
req.func_tool.add_tool(
|
||||
self.ctx.get_llm_tool_manager().get_builtin_tool(SendMessageToUserTool)
|
||||
)
|
||||
req.func_tool.add_tool(SEND_MESSAGE_TO_USER_TOOL)
|
||||
|
||||
result = await build_main_agent(
|
||||
event=cron_event, plugin_context=self.ctx, config=config, req=req
|
||||
|
||||
@@ -21,7 +21,6 @@ from astrbot.core.db.po import (
|
||||
PlatformSession,
|
||||
PlatformStat,
|
||||
Preference,
|
||||
ProviderStat,
|
||||
SessionProjectRelation,
|
||||
Stats,
|
||||
)
|
||||
@@ -106,21 +105,6 @@ class BaseDatabase(abc.ABC):
|
||||
"""Get platform statistics within the specified offset in seconds and group by platform_id."""
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
async def insert_provider_stat(
|
||||
self,
|
||||
*,
|
||||
umo: str,
|
||||
provider_id: str,
|
||||
provider_model: str | None = None,
|
||||
conversation_id: str | None = None,
|
||||
status: str = "completed",
|
||||
stats: dict | None = None,
|
||||
agent_type: str = "internal",
|
||||
) -> ProviderStat:
|
||||
"""Insert a per-response provider stat record."""
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
async def get_conversations(
|
||||
self,
|
||||
|
||||
@@ -38,30 +38,6 @@ class PlatformStat(SQLModel, table=True):
|
||||
)
|
||||
|
||||
|
||||
class ProviderStat(TimestampMixin, SQLModel, table=True):
|
||||
"""Per-response provider stats for internal agent runs."""
|
||||
|
||||
__tablename__: str = "provider_stats"
|
||||
|
||||
id: int | None = Field(
|
||||
default=None,
|
||||
primary_key=True,
|
||||
sa_column_kwargs={"autoincrement": True},
|
||||
)
|
||||
agent_type: str = Field(default="internal", nullable=False, index=True)
|
||||
status: str = Field(default="completed", nullable=False, index=True)
|
||||
umo: str = Field(nullable=False, index=True)
|
||||
conversation_id: str | None = Field(default=None, index=True)
|
||||
provider_id: str = Field(nullable=False, index=True)
|
||||
provider_model: str | None = Field(default=None, index=True)
|
||||
token_input_other: int = Field(default=0, nullable=False)
|
||||
token_input_cached: int = Field(default=0, nullable=False)
|
||||
token_output: int = Field(default=0, nullable=False)
|
||||
start_time: float = Field(default=0.0, nullable=False)
|
||||
end_time: float = Field(default=0.0, nullable=False)
|
||||
time_to_first_token: float = Field(default=0.0, nullable=False)
|
||||
|
||||
|
||||
class ConversationV2(TimestampMixin, SQLModel, table=True):
|
||||
__tablename__: str = "conversations"
|
||||
|
||||
|
||||
@@ -23,7 +23,6 @@ from astrbot.core.db.po import (
|
||||
PlatformSession,
|
||||
PlatformStat,
|
||||
Preference,
|
||||
ProviderStat,
|
||||
SessionProjectRelation,
|
||||
SQLModel,
|
||||
)
|
||||
@@ -170,51 +169,6 @@ class SQLiteDatabase(BaseDatabase):
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def insert_provider_stat(
|
||||
self,
|
||||
*,
|
||||
umo: str,
|
||||
provider_id: str,
|
||||
provider_model: str | None = None,
|
||||
conversation_id: str | None = None,
|
||||
status: str = "completed",
|
||||
stats: dict | None = None,
|
||||
agent_type: str = "internal",
|
||||
) -> ProviderStat:
|
||||
"""Insert a provider stat record for a single agent response."""
|
||||
stats = stats or {}
|
||||
token_usage = stats.get("token_usage", {})
|
||||
|
||||
token_input_other = int(token_usage.get("input_other", 0) or 0)
|
||||
token_input_cached = int(token_usage.get("input_cached", 0) or 0)
|
||||
token_output = int(token_usage.get("output", 0) or 0)
|
||||
|
||||
start_time = float(stats.get("start_time", 0.0) or 0.0)
|
||||
end_time = float(stats.get("end_time", 0.0) or 0.0)
|
||||
time_to_first_token = float(stats.get("time_to_first_token", 0.0) or 0.0)
|
||||
|
||||
async with self.get_db() as session:
|
||||
session: AsyncSession
|
||||
async with session.begin():
|
||||
record = ProviderStat(
|
||||
agent_type=agent_type,
|
||||
status=status,
|
||||
umo=umo,
|
||||
conversation_id=conversation_id,
|
||||
provider_id=provider_id,
|
||||
provider_model=provider_model,
|
||||
token_input_other=token_input_other,
|
||||
token_input_cached=token_input_cached,
|
||||
token_output=token_output,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
time_to_first_token=time_to_first_token,
|
||||
)
|
||||
session.add(record)
|
||||
await session.flush()
|
||||
await session.refresh(record)
|
||||
return record
|
||||
|
||||
# ====
|
||||
# Conversation Management
|
||||
# ====
|
||||
|
||||
@@ -7,7 +7,3 @@ class AstrBotError(Exception):
|
||||
|
||||
class ProviderNotFoundError(AstrBotError):
|
||||
"""Raised when a specified provider is not found."""
|
||||
|
||||
|
||||
class EmptyModelOutputError(AstrBotError):
|
||||
"""Raised when the model response contains no usable assistant output."""
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
import json
|
||||
from enum import Enum
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
CORE_LOCALE_DIR = Path(__file__).resolve().parent / "locales"
|
||||
|
||||
|
||||
class Language(str, Enum):
|
||||
ZH_CN = "zh-CN"
|
||||
EN_US = "en-US"
|
||||
|
||||
|
||||
DEFAULT_LANGUAGE = Language.ZH_CN.value
|
||||
|
||||
|
||||
def normalize_language(language: str | Language | None) -> str:
|
||||
if isinstance(language, Language):
|
||||
return language.value
|
||||
if language == Language.EN_US.value:
|
||||
return Language.EN_US.value
|
||||
return Language.ZH_CN.value
|
||||
|
||||
|
||||
@lru_cache(maxsize=64)
|
||||
def _load_locale(locale_dir: str, language: str) -> dict[str, Any]:
|
||||
locale_path = Path(locale_dir) / f"{language}.json"
|
||||
with locale_path.open(encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def _resolve_key(data: dict[str, Any], key: str) -> Any:
|
||||
value: Any = data
|
||||
for part in key.split("."):
|
||||
if not isinstance(value, dict) or part not in value:
|
||||
return None
|
||||
value = value[part]
|
||||
return value
|
||||
|
||||
|
||||
def t(
|
||||
translation_key: str,
|
||||
*,
|
||||
locale: str | None = None,
|
||||
locale_dir: str | Path | None = None,
|
||||
**kwargs: Any,
|
||||
) -> str:
|
||||
language = normalize_language(locale)
|
||||
resolved_locale_dir = str(locale_dir or CORE_LOCALE_DIR)
|
||||
text = _resolve_key(_load_locale(resolved_locale_dir, language), translation_key)
|
||||
|
||||
if text is None and language != DEFAULT_LANGUAGE:
|
||||
text = _resolve_key(
|
||||
_load_locale(resolved_locale_dir, DEFAULT_LANGUAGE),
|
||||
translation_key,
|
||||
)
|
||||
if not isinstance(text, str):
|
||||
return translation_key
|
||||
if not kwargs:
|
||||
return text
|
||||
return text.format(**kwargs)
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"pipeline": {
|
||||
"filter_error": "Plugin {plugin_name}: {error}",
|
||||
"no_permission": "You (ID: {sender_id}) do not have permission to use this command. Use /sid to get your ID and ask an administrator to add it.",
|
||||
"content_blocked": "Your message or the model response contains inappropriate content and has been blocked.",
|
||||
"keyword_blocked_reason": "Content safety check failed because a sensitive keyword was matched.",
|
||||
"baidu_aip_violation_header": "Baidu content moderation found {count} violations:\n",
|
||||
"baidu_aip_conclusion": "\nConclusion: {conclusion}",
|
||||
"plugin_handler_error": ":(\n\nAn exception occurred while calling plugin {plugin_name}'s handler {handler_name}: {error}",
|
||||
"reasoning_prefix": "🤔 Thinking: {reasoning_content}\n"
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"pipeline": {
|
||||
"filter_error": "插件 {plugin_name}: {error}",
|
||||
"no_permission": "您(ID: {sender_id})的权限不足以使用此指令。通过 /sid 获取 ID 并请管理员添加。",
|
||||
"content_blocked": "你的消息或者大模型的响应中包含不适当的内容,已被屏蔽。",
|
||||
"keyword_blocked_reason": "内容安全检查不通过,匹配到敏感词。",
|
||||
"baidu_aip_violation_header": "百度审核服务发现 {count} 处违规:\n",
|
||||
"baidu_aip_conclusion": "\n判断结果:{conclusion}",
|
||||
"plugin_handler_error": ":(\n\n在调用插件 {plugin_name} 的处理函数 {handler_name} 时出现异常:{error}",
|
||||
"reasoning_prefix": "🤔 思考: {reasoning_content}\n"
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import delete, func, select, text, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlmodel import col, desc
|
||||
|
||||
from astrbot.core import logger
|
||||
from astrbot.core.db.vec_db.faiss_impl import FaissVecDB
|
||||
from astrbot.core.knowledge_base.models import (
|
||||
BaseKBModel,
|
||||
KBDocument,
|
||||
@@ -15,9 +15,6 @@ from astrbot.core.knowledge_base.models import (
|
||||
)
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_knowledge_base_path
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from astrbot.core.db.vec_db.faiss_impl import FaissVecDB
|
||||
|
||||
|
||||
class KBSQLiteDatabase:
|
||||
def __init__(self, db_path: str | None = None) -> None:
|
||||
@@ -299,7 +296,7 @@ class KBSQLiteDatabase:
|
||||
|
||||
return metadata_map
|
||||
|
||||
async def delete_document_by_id(self, doc_id: str, vec_db: "FaissVecDB") -> None:
|
||||
async def delete_document_by_id(self, doc_id: str, vec_db: FaissVecDB) -> None:
|
||||
"""删除单个文档及其相关数据"""
|
||||
# 在知识库表中删除
|
||||
async with self.get_db() as session, session.begin():
|
||||
@@ -327,7 +324,7 @@ class KBSQLiteDatabase:
|
||||
result = await session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def update_kb_stats(self, kb_id: str, vec_db: "FaissVecDB") -> None:
|
||||
async def update_kb_stats(self, kb_id: str, vec_db: FaissVecDB) -> None:
|
||||
"""更新知识库统计信息"""
|
||||
chunk_cnt = await vec_db.count_documents()
|
||||
|
||||
|
||||
@@ -4,12 +4,12 @@ import re
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import aiofiles
|
||||
|
||||
from astrbot.core import logger
|
||||
from astrbot.core.db.vec_db.base import BaseVecDB
|
||||
from astrbot.core.db.vec_db.faiss_impl.vec_db import FaissVecDB
|
||||
from astrbot.core.provider.manager import ProviderManager
|
||||
from astrbot.core.provider.provider import (
|
||||
EmbeddingProvider,
|
||||
@@ -27,9 +27,6 @@ from .parsers.url_parser import extract_text_from_url
|
||||
from .parsers.util import select_parser
|
||||
from .prompts import TEXT_REPAIR_SYSTEM_PROMPT
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from astrbot.core.db.vec_db.faiss_impl.vec_db import FaissVecDB
|
||||
|
||||
|
||||
class RateLimiter:
|
||||
"""一个简单的速率限制器"""
|
||||
@@ -111,7 +108,6 @@ Text chunk to process:
|
||||
class KBHelper:
|
||||
vec_db: BaseVecDB
|
||||
kb: KnowledgeBase
|
||||
init_error: str | None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -126,7 +122,6 @@ class KBHelper:
|
||||
self.prov_mgr = provider_manager
|
||||
self.kb_root_dir = kb_root_dir
|
||||
self.chunker = chunker
|
||||
self.init_error = None
|
||||
|
||||
self.kb_dir = Path(self.kb_root_dir) / self.kb.kb_id
|
||||
self.kb_medias_dir = Path(self.kb_dir) / "medias" / self.kb.kb_id
|
||||
@@ -153,30 +148,21 @@ class KBHelper:
|
||||
async def get_rp(self) -> RerankProvider | None:
|
||||
if not self.kb.rerank_provider_id:
|
||||
return None
|
||||
rp: RerankProvider | None = await self.prov_mgr.get_provider_by_id(
|
||||
rp: RerankProvider = await self.prov_mgr.get_provider_by_id(
|
||||
self.kb.rerank_provider_id,
|
||||
) # type: ignore
|
||||
if not rp:
|
||||
logger.warning(
|
||||
f"知识库 {self.kb.kb_name}({self.kb.kb_id}) 的 Rerank Provider({self.kb.rerank_provider_id}) 不可用,将跳过重排序。",
|
||||
raise ValueError(
|
||||
f"无法找到 ID 为 {self.kb.rerank_provider_id} 的 Rerank Provider",
|
||||
)
|
||||
return None
|
||||
return rp
|
||||
|
||||
async def _ensure_vec_db(self) -> "FaissVecDB":
|
||||
async def _ensure_vec_db(self) -> FaissVecDB:
|
||||
if not self.kb.embedding_provider_id:
|
||||
raise ValueError(f"知识库 {self.kb.kb_name} 未配置 Embedding Provider")
|
||||
|
||||
ep = await self.get_ep()
|
||||
rp: RerankProvider | None = None
|
||||
try:
|
||||
rp = await self.get_rp()
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"知识库 {self.kb.kb_name}({self.kb.kb_id}) 初始化重排序能力失败,将跳过重排序: {e}",
|
||||
)
|
||||
|
||||
from astrbot.core.db.vec_db.faiss_impl.vec_db import FaissVecDB
|
||||
rp = await self.get_rp()
|
||||
|
||||
vec_db = FaissVecDB(
|
||||
doc_store_path=str(self.kb_dir / "doc.db"),
|
||||
@@ -186,8 +172,6 @@ class KBHelper:
|
||||
)
|
||||
await vec_db.initialize()
|
||||
self.vec_db = vec_db
|
||||
# Clear stale init_error once initialization succeeds.
|
||||
self.init_error = None
|
||||
return vec_db
|
||||
|
||||
async def delete_vec_db(self) -> None:
|
||||
@@ -199,7 +183,7 @@ class KBHelper:
|
||||
shutil.rmtree(self.kb_dir)
|
||||
|
||||
async def terminate(self) -> None:
|
||||
if hasattr(self, "vec_db") and self.vec_db:
|
||||
if self.vec_db:
|
||||
await self.vec_db.close()
|
||||
|
||||
async def upload_document(
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import traceback
|
||||
from pathlib import Path
|
||||
|
||||
from astrbot.core import logger
|
||||
@@ -55,7 +56,8 @@ class KnowledgeBaseManager:
|
||||
logger.error(f"知识库模块导入失败: {e}")
|
||||
logger.warning("请确保已安装所需依赖: pypdf, aiofiles, Pillow, rank-bm25")
|
||||
except Exception as e:
|
||||
logger.error(f"知识库模块初始化失败: {e}", exc_info=True)
|
||||
logger.error(f"知识库模块初始化失败: {e}")
|
||||
logger.error(traceback.format_exc())
|
||||
|
||||
async def _init_kb_database(self) -> None:
|
||||
self.kb_db = KBSQLiteDatabase(DB_PATH.as_posix())
|
||||
@@ -74,14 +76,7 @@ class KnowledgeBaseManager:
|
||||
kb_root_dir=FILES_PATH,
|
||||
chunker=CHUNKER,
|
||||
)
|
||||
try:
|
||||
await kb_helper.initialize()
|
||||
except Exception as e:
|
||||
kb_helper.init_error = str(e)
|
||||
logger.error(
|
||||
f"知识库 {record.kb_name}({record.kb_id}) 初始化失败: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
await kb_helper.initialize()
|
||||
self.kb_insts[record.kb_id] = kb_helper
|
||||
|
||||
async def create_kb(
|
||||
@@ -184,20 +179,6 @@ class KnowledgeBaseManager:
|
||||
return None
|
||||
|
||||
kb = kb_helper.kb
|
||||
previous_state = {
|
||||
"kb_name": kb.kb_name,
|
||||
"description": kb.description,
|
||||
"emoji": kb.emoji,
|
||||
"embedding_provider_id": kb.embedding_provider_id,
|
||||
"rerank_provider_id": kb.rerank_provider_id,
|
||||
"chunk_size": kb.chunk_size,
|
||||
"chunk_overlap": kb.chunk_overlap,
|
||||
"top_k_dense": kb.top_k_dense,
|
||||
"top_k_sparse": kb.top_k_sparse,
|
||||
"top_m_final": kb.top_m_final,
|
||||
}
|
||||
previous_init_error = kb_helper.init_error
|
||||
|
||||
if kb_name is not None:
|
||||
kb.kb_name = kb_name
|
||||
if description is not None:
|
||||
@@ -217,47 +198,12 @@ class KnowledgeBaseManager:
|
||||
kb.top_k_sparse = top_k_sparse
|
||||
if top_m_final is not None:
|
||||
kb.top_m_final = top_m_final
|
||||
|
||||
# Build a new helper first. Keep current vec_db alive until new init succeeds.
|
||||
new_helper = KBHelper(
|
||||
kb_db=self.kb_db,
|
||||
kb=kb,
|
||||
provider_manager=self.provider_manager,
|
||||
kb_root_dir=FILES_PATH,
|
||||
chunker=CHUNKER,
|
||||
)
|
||||
|
||||
try:
|
||||
await new_helper.initialize()
|
||||
except Exception as e:
|
||||
# Roll back in-memory settings and keep current helper available.
|
||||
kb.kb_name = previous_state["kb_name"]
|
||||
kb.description = previous_state["description"]
|
||||
kb.emoji = previous_state["emoji"]
|
||||
kb.embedding_provider_id = previous_state["embedding_provider_id"]
|
||||
kb.rerank_provider_id = previous_state["rerank_provider_id"]
|
||||
kb.chunk_size = previous_state["chunk_size"]
|
||||
kb.chunk_overlap = previous_state["chunk_overlap"]
|
||||
kb.top_k_dense = previous_state["top_k_dense"]
|
||||
kb.top_k_sparse = previous_state["top_k_sparse"]
|
||||
kb.top_m_final = previous_state["top_m_final"]
|
||||
kb_helper.init_error = previous_init_error
|
||||
logger.error(
|
||||
f"知识库 {kb.kb_name}({kb.kb_id}) 重新初始化失败,继续使用旧实例: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
return kb_helper
|
||||
|
||||
async with self.kb_db.get_db() as session:
|
||||
session.add(kb)
|
||||
await session.commit()
|
||||
await session.refresh(kb)
|
||||
|
||||
old_helper = kb_helper
|
||||
self.kb_insts[kb_id] = new_helper
|
||||
await old_helper.terminate()
|
||||
new_helper.init_error = None
|
||||
return new_helper
|
||||
return kb_helper
|
||||
|
||||
async def retrieve(
|
||||
self,
|
||||
@@ -269,21 +215,11 @@ class KnowledgeBaseManager:
|
||||
"""从指定知识库中检索相关内容"""
|
||||
kb_ids = []
|
||||
kb_id_helper_map = {}
|
||||
unavailable_kbs = []
|
||||
for kb_name in kb_names:
|
||||
if kb_helper := await self.get_kb_by_name(kb_name):
|
||||
if kb_helper.init_error:
|
||||
unavailable_kbs.append((kb_name, kb_helper.init_error))
|
||||
logger.warning(f"知识库 {kb_name} 不可用: {kb_helper.init_error}")
|
||||
continue
|
||||
kb_ids.append(kb_helper.kb.kb_id)
|
||||
kb_id_helper_map[kb_helper.kb.kb_id] = kb_helper
|
||||
|
||||
# all requested KBs are unavailable
|
||||
if not kb_ids and unavailable_kbs:
|
||||
errors = "; ".join(f"{n}: {e}" for n, e in unavailable_kbs)
|
||||
raise ValueError(f"所有请求的知识库均不可用: {errors}")
|
||||
|
||||
if not kb_ids:
|
||||
return {}
|
||||
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from astrbot import logger
|
||||
from astrbot.core.db.vec_db.base import Result
|
||||
from astrbot.core.db.vec_db.faiss_impl import FaissVecDB
|
||||
from astrbot.core.knowledge_base.kb_db_sqlite import KBSQLiteDatabase
|
||||
from astrbot.core.knowledge_base.retrieval.rank_fusion import RankFusion
|
||||
from astrbot.core.knowledge_base.retrieval.sparse_retriever import SparseRetriever
|
||||
@@ -16,9 +16,6 @@ from astrbot.core.provider.provider import RerankProvider
|
||||
|
||||
from ..kb_helper import KBHelper
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from astrbot.core.db.vec_db.faiss_impl import FaissVecDB
|
||||
|
||||
|
||||
@dataclass
|
||||
class RetrievalResult:
|
||||
@@ -173,31 +170,26 @@ class RetrievalManager:
|
||||
first_rerank = None
|
||||
for kb_id in kb_ids:
|
||||
vec_db = kb_options[kb_id]["vec_db"]
|
||||
rerank_provider = (
|
||||
getattr(vec_db, "rerank_provider", None) if vec_db else None
|
||||
)
|
||||
if rerank_provider is None:
|
||||
if not isinstance(vec_db, FaissVecDB):
|
||||
logger.warning(f"vec_db for kb_id {kb_id} is not FaissVecDB")
|
||||
continue
|
||||
|
||||
rerank_pi = kb_options[kb_id]["rerank_provider_id"]
|
||||
if (
|
||||
vec_db
|
||||
and rerank_provider
|
||||
and vec_db.rerank_provider
|
||||
and rerank_pi
|
||||
and rerank_pi == rerank_provider.meta().id
|
||||
and rerank_pi == vec_db.rerank_provider.meta().id
|
||||
):
|
||||
first_rerank = rerank_provider
|
||||
first_rerank = vec_db.rerank_provider
|
||||
break
|
||||
if first_rerank and retrieval_results:
|
||||
try:
|
||||
retrieval_results = await self._rerank(
|
||||
query=query,
|
||||
results=retrieval_results,
|
||||
top_k=top_m_final,
|
||||
rerank_provider=first_rerank,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Rerank 执行失败,已跳过重排序并使用融合结果: {e}")
|
||||
retrieval_results = await self._rerank(
|
||||
query=query,
|
||||
results=retrieval_results,
|
||||
top_k=top_m_final,
|
||||
rerank_provider=first_rerank,
|
||||
)
|
||||
|
||||
return retrieval_results[:top_m_final]
|
||||
|
||||
@@ -237,10 +229,10 @@ class RetrievalManager:
|
||||
|
||||
all_results.extend(vec_results)
|
||||
except Exception as e:
|
||||
logger.error(f"知识库 {kb_id} 稠密检索失败: {e}", exc_info=True)
|
||||
if len(kb_ids) == 1:
|
||||
raise RuntimeError(f"知识库 {kb_id} 稠密检索失败: {e}") from e
|
||||
# multi-KB: skip the faulty KB and continue
|
||||
from astrbot.core import logger
|
||||
|
||||
logger.warning(f"知识库 {kb_id} 稠密检索失败: {e}")
|
||||
continue
|
||||
|
||||
# 按相似度排序并返回 top_k
|
||||
all_results.sort(key=lambda x: x.similarity, reverse=True)
|
||||
|
||||
@@ -6,16 +6,13 @@
|
||||
import json
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import jieba
|
||||
from rank_bm25 import BM25Okapi
|
||||
|
||||
from astrbot.core.db.vec_db.faiss_impl import FaissVecDB
|
||||
from astrbot.core.knowledge_base.kb_db_sqlite import KBSQLiteDatabase
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from astrbot.core.db.vec_db.faiss_impl import FaissVecDB
|
||||
|
||||
|
||||
@dataclass
|
||||
class SparseResult:
|
||||
@@ -76,7 +73,7 @@ class SparseRetriever:
|
||||
top_k_sparse = 0
|
||||
chunks = []
|
||||
for kb_id in kb_ids:
|
||||
vec_db: FaissVecDB | None = kb_options.get(kb_id, {}).get("vec_db")
|
||||
vec_db: FaissVecDB = kb_options.get(kb_id, {}).get("vec_db")
|
||||
if not vec_db:
|
||||
continue
|
||||
result = await vec_db.document_storage.get_documents(
|
||||
|
||||
@@ -64,6 +64,7 @@ class ComponentType(str, Enum):
|
||||
Music = "Music"
|
||||
Json = "Json"
|
||||
Unknown = "Unknown"
|
||||
WechatEmoji = "WechatEmoji" # Wechat 下的 emoji 表情包
|
||||
|
||||
|
||||
class BaseMessageComponent(BaseModel):
|
||||
@@ -90,6 +91,7 @@ class BaseMessageComponent(BaseModel):
|
||||
class Plain(BaseMessageComponent):
|
||||
type: ComponentType = ComponentType.Plain
|
||||
text: str
|
||||
convert: bool | None = True
|
||||
|
||||
def __init__(self, text: str, convert: bool = True, **_) -> None:
|
||||
super().__init__(text=text, convert=convert, **_)
|
||||
@@ -112,11 +114,15 @@ class Face(BaseMessageComponent):
|
||||
class Record(BaseMessageComponent):
|
||||
type: ComponentType = ComponentType.Record
|
||||
file: str | None = ""
|
||||
magic: bool | None = False
|
||||
url: str | None = ""
|
||||
cache: bool | None = True
|
||||
proxy: bool | None = True
|
||||
timeout: int | None = 0
|
||||
# Original text content (e.g. TTS source text), used as caption in fallback scenarios
|
||||
text: str | None = None
|
||||
# 额外
|
||||
path: str | None = None
|
||||
path: str | None
|
||||
|
||||
def __init__(self, file: str | None, **_) -> None:
|
||||
for k in _:
|
||||
@@ -218,6 +224,7 @@ class Video(BaseMessageComponent):
|
||||
type: ComponentType = ComponentType.Video
|
||||
file: str
|
||||
cover: str | None = ""
|
||||
c: int | None = 2
|
||||
# 额外
|
||||
path: str | None = ""
|
||||
|
||||
@@ -394,9 +401,14 @@ class Image(BaseMessageComponent):
|
||||
type: ComponentType = ComponentType.Image
|
||||
file: str | None = ""
|
||||
_type: str | None = ""
|
||||
subType: int | None = 0
|
||||
url: str | None = ""
|
||||
cache: bool | None = True
|
||||
id: int | None = 40000
|
||||
c: int | None = 2
|
||||
# 额外
|
||||
path: str | None = ""
|
||||
file_unique: str | None = "" # 某些平台可能有图片缓存的唯一标识
|
||||
|
||||
def __init__(self, file: str | None, **_) -> None:
|
||||
super().__init__(file=file, **_)
|
||||
@@ -827,6 +839,16 @@ class File(BaseMessageComponent):
|
||||
}
|
||||
|
||||
|
||||
class WechatEmoji(BaseMessageComponent):
|
||||
type: ComponentType = ComponentType.WechatEmoji
|
||||
md5: str | None = ""
|
||||
md5_len: int | None = 0
|
||||
cdnurl: str | None = ""
|
||||
|
||||
def __init__(self, **_) -> None:
|
||||
super().__init__(**_)
|
||||
|
||||
|
||||
ComponentTypes = {
|
||||
# Basic Message Segments
|
||||
"plain": Plain,
|
||||
@@ -852,4 +874,5 @@ ComponentTypes = {
|
||||
"nodes": Nodes,
|
||||
"json": Json,
|
||||
"unknown": Unknown,
|
||||
"WechatEmoji": WechatEmoji,
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
from astrbot.core import logger
|
||||
from astrbot.core.i18n import t
|
||||
from astrbot.core.message.message_event_result import MessageEventResult
|
||||
from astrbot.core.platform.astr_message_event import AstrMessageEvent
|
||||
|
||||
@@ -18,7 +17,6 @@ class ContentSafetyCheckStage(Stage):
|
||||
"""
|
||||
|
||||
async def initialize(self, ctx: PipelineContext) -> None:
|
||||
self.ctx = ctx
|
||||
config = ctx.astrbot_config["content_safety"]
|
||||
self.strategy_selector = StrategySelector(config)
|
||||
|
||||
@@ -29,13 +27,12 @@ class ContentSafetyCheckStage(Stage):
|
||||
) -> AsyncGenerator[None, None]:
|
||||
"""检查内容安全"""
|
||||
text = check_text if check_text else event.get_message_str()
|
||||
locale = self.ctx.get_current_language()
|
||||
ok, info = self.strategy_selector.check(text, locale=locale)
|
||||
ok, info = self.strategy_selector.check(text)
|
||||
if not ok:
|
||||
if event.is_at_or_wake_command:
|
||||
event.set_result(
|
||||
MessageEventResult().message(
|
||||
t("pipeline.content_blocked", locale=locale),
|
||||
"你的消息或者大模型的响应中包含不适当的内容,已被屏蔽。",
|
||||
),
|
||||
)
|
||||
yield
|
||||
|
||||
@@ -3,5 +3,5 @@ import abc
|
||||
|
||||
class ContentSafetyStrategy(abc.ABC):
|
||||
@abc.abstractmethod
|
||||
def check(self, content: str, locale: str | None = None) -> tuple[bool, str]:
|
||||
def check(self, content: str) -> tuple[bool, str]:
|
||||
raise NotImplementedError
|
||||
|
||||
@@ -4,8 +4,6 @@ from typing import Any, cast
|
||||
|
||||
from aip import AipContentCensor
|
||||
|
||||
from astrbot.core.i18n import t
|
||||
|
||||
from . import ContentSafetyStrategy
|
||||
|
||||
|
||||
@@ -16,7 +14,7 @@ class BaiduAipStrategy(ContentSafetyStrategy):
|
||||
self.secret_key = sk
|
||||
self.client = AipContentCensor(self.app_id, self.api_key, self.secret_key)
|
||||
|
||||
def check(self, content: str, locale: str | None = None) -> tuple[bool, str]:
|
||||
def check(self, content: str) -> tuple[bool, str]:
|
||||
res = self.client.textCensorUserDefined(content)
|
||||
if "conclusionType" not in res:
|
||||
return False, ""
|
||||
@@ -25,18 +23,10 @@ class BaiduAipStrategy(ContentSafetyStrategy):
|
||||
if "data" not in res:
|
||||
return False, ""
|
||||
count = len(res["data"])
|
||||
parts = [
|
||||
t("pipeline.baidu_aip_violation_header", locale=locale, count=count),
|
||||
]
|
||||
parts = [f"百度审核服务发现 {count} 处违规:\n"]
|
||||
for i in res["data"]:
|
||||
# 百度 AIP 返回结构是动态 dict;类型检查时 i 可能被推断为序列,转成 dict 后用 get 取字段
|
||||
parts.append(f"{cast(dict[str, Any], i).get('msg', '')};\n")
|
||||
parts.append(
|
||||
t(
|
||||
"pipeline.baidu_aip_conclusion",
|
||||
locale=locale,
|
||||
conclusion=res["conclusion"],
|
||||
),
|
||||
)
|
||||
parts.append("\n判断结果:" + res["conclusion"])
|
||||
info = "".join(parts)
|
||||
return False, info
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import re
|
||||
|
||||
from astrbot.core.i18n import t
|
||||
|
||||
from . import ContentSafetyStrategy
|
||||
|
||||
|
||||
@@ -19,8 +17,8 @@ class KeywordsStrategy(ContentSafetyStrategy):
|
||||
# json.loads(base64.b64decode(f.read()).decode("utf-8"))["keywords"]
|
||||
# )
|
||||
|
||||
def check(self, content: str, locale: str | None = None) -> tuple[bool, str]:
|
||||
def check(self, content: str) -> tuple[bool, str]:
|
||||
for keyword in self.keywords:
|
||||
if re.search(keyword, content):
|
||||
return False, t("pipeline.keyword_blocked_reason", locale=locale)
|
||||
return False, "内容安全检查不通过,匹配到敏感词。"
|
||||
return True, ""
|
||||
|
||||
@@ -26,9 +26,9 @@ class StrategySelector:
|
||||
),
|
||||
)
|
||||
|
||||
def check(self, content: str, locale: str | None = None) -> tuple[bool, str]:
|
||||
def check(self, content: str) -> tuple[bool, str]:
|
||||
for strategy in self.enabled_strategies:
|
||||
ok, info = strategy.check(content, locale=locale)
|
||||
ok, info = strategy.check(content)
|
||||
if not ok:
|
||||
return False, info
|
||||
return True, ""
|
||||
|
||||
@@ -20,6 +20,3 @@ class PipelineContext:
|
||||
astrbot_config_id: str
|
||||
call_handler = call_handler
|
||||
call_event_hook = call_event_hook
|
||||
|
||||
def get_current_language(self) -> str:
|
||||
return self.plugin_manager.context.get_current_language()
|
||||
|
||||
@@ -6,7 +6,6 @@ from collections.abc import AsyncGenerator
|
||||
from astrbot.core import logger
|
||||
from astrbot.core.message.components import Image, Plain, Record
|
||||
from astrbot.core.platform.astr_message_event import AstrMessageEvent
|
||||
from astrbot.core.utils.media_utils import ensure_wav
|
||||
|
||||
from ..context import PipelineContext
|
||||
from ..stage import Stage, register_stage
|
||||
@@ -65,21 +64,6 @@ class PreProcessStage(Stage):
|
||||
logger.debug(f"路径映射: {url} -> {component.url}")
|
||||
message_chain[idx] = component
|
||||
|
||||
# In here, we convert all Record components to wav format and update the file path.
|
||||
message_chain = event.get_messages()
|
||||
for idx, component in enumerate(message_chain):
|
||||
if isinstance(component, Record):
|
||||
try:
|
||||
original_path = await component.convert_to_file_path()
|
||||
record_path = await ensure_wav(original_path)
|
||||
if record_path != original_path:
|
||||
event.track_temporary_local_file(record_path)
|
||||
component.file = record_path
|
||||
component.path = record_path
|
||||
message_chain[idx] = component
|
||||
except Exception as e:
|
||||
logger.warning(f"Voice processing failed: {e}")
|
||||
|
||||
# STT
|
||||
if self.stt_settings.get("enable", False):
|
||||
# TODO: 独立
|
||||
@@ -92,8 +76,8 @@ class PreProcessStage(Stage):
|
||||
return
|
||||
message_chain = event.get_messages()
|
||||
for idx, component in enumerate(message_chain):
|
||||
if isinstance(component, Record):
|
||||
path = await component.convert_to_file_path()
|
||||
if isinstance(component, Record) and component.url:
|
||||
path = component.url.removeprefix("file://")
|
||||
retry = 5
|
||||
for i in range(retry):
|
||||
try:
|
||||
|
||||
@@ -5,7 +5,7 @@ import base64
|
||||
from collections.abc import AsyncGenerator
|
||||
from dataclasses import replace
|
||||
|
||||
from astrbot.core import db_helper, logger
|
||||
from astrbot.core import logger
|
||||
from astrbot.core.agent.message import Message
|
||||
from astrbot.core.agent.response import AgentStats
|
||||
from astrbot.core.astr_main_agent import (
|
||||
@@ -13,7 +13,7 @@ from astrbot.core.astr_main_agent import (
|
||||
MainAgentBuildResult,
|
||||
build_main_agent,
|
||||
)
|
||||
from astrbot.core.message.components import File, Image, Record, Video
|
||||
from astrbot.core.message.components import File, Image
|
||||
from astrbot.core.message.message_event_result import (
|
||||
MessageChain,
|
||||
MessageEventResult,
|
||||
@@ -144,7 +144,6 @@ class InternalAgentSubStage(Stage):
|
||||
follow_up_capture: FollowUpCapture | None = None
|
||||
follow_up_consumed_marked = False
|
||||
follow_up_activated = False
|
||||
typing_requested = False
|
||||
try:
|
||||
streaming_response = self.streaming_response
|
||||
if (enable_streaming := event.get_extra("enable_streaming")) is not None:
|
||||
@@ -153,8 +152,7 @@ class InternalAgentSubStage(Stage):
|
||||
has_provider_request = event.get_extra("provider_request") is not None
|
||||
has_valid_message = bool(event.message_str and event.message_str.strip())
|
||||
has_media_content = any(
|
||||
isinstance(comp, (Image, File, Record, Video))
|
||||
for comp in event.message_obj.message
|
||||
isinstance(comp, Image | File) for comp in event.message_obj.message
|
||||
)
|
||||
|
||||
if (
|
||||
@@ -180,11 +178,7 @@ class InternalAgentSubStage(Stage):
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
typing_requested = True
|
||||
await event.send_typing()
|
||||
except Exception:
|
||||
logger.warning("send_typing failed", exc_info=True)
|
||||
await event.send_typing()
|
||||
await call_event_hook(event, EventType.OnWaitingLLMRequestEvent)
|
||||
|
||||
async with session_lock_manager.acquire_lock(event.unified_msg_origin):
|
||||
@@ -351,15 +345,6 @@ class InternalAgentSubStage(Stage):
|
||||
resp=final_resp.completion_text if final_resp else None,
|
||||
)
|
||||
|
||||
asyncio.create_task(
|
||||
_record_internal_agent_stats(
|
||||
event,
|
||||
req,
|
||||
agent_runner,
|
||||
final_resp,
|
||||
)
|
||||
)
|
||||
|
||||
# 检查事件是否被停止,如果被停止则不保存历史记录
|
||||
if not event.is_stopped() or agent_runner.was_aborted():
|
||||
await self._save_to_history(
|
||||
@@ -392,11 +377,6 @@ class InternalAgentSubStage(Stage):
|
||||
)
|
||||
await event.send(MessageChain().message(error_text))
|
||||
finally:
|
||||
if typing_requested:
|
||||
try:
|
||||
await event.stop_typing()
|
||||
except Exception:
|
||||
logger.warning("stop_typing failed", exc_info=True)
|
||||
if follow_up_capture:
|
||||
await finalize_follow_up_capture(
|
||||
follow_up_capture,
|
||||
@@ -472,46 +452,3 @@ class InternalAgentSubStage(Stage):
|
||||
# these hosts are base64 encoded
|
||||
BLOCKED = {"dGZid2h2d3IuY2xvdWQuc2VhbG9zLmlv", "a291cmljaGF0"}
|
||||
decoded_blocked = [base64.b64decode(b).decode("utf-8") for b in BLOCKED]
|
||||
|
||||
|
||||
async def _record_internal_agent_stats(
|
||||
event: AstrMessageEvent,
|
||||
req: ProviderRequest | None,
|
||||
agent_runner: AgentRunner | None,
|
||||
final_resp: LLMResponse | None,
|
||||
) -> None:
|
||||
"""Persist internal agent stats without affecting the user response flow."""
|
||||
if agent_runner is None:
|
||||
return
|
||||
|
||||
provider = agent_runner.provider
|
||||
stats = agent_runner.stats
|
||||
if provider is None or stats is None:
|
||||
return
|
||||
|
||||
try:
|
||||
provider_config = getattr(provider, "provider_config", {}) or {}
|
||||
conversation_id = (
|
||||
req.conversation.cid
|
||||
if req is not None and req.conversation is not None
|
||||
else None
|
||||
)
|
||||
|
||||
if agent_runner.was_aborted():
|
||||
status = "aborted"
|
||||
elif final_resp is not None and final_resp.role == "err":
|
||||
status = "error"
|
||||
else:
|
||||
status = "completed"
|
||||
|
||||
await db_helper.insert_provider_stat(
|
||||
umo=event.unified_msg_origin,
|
||||
conversation_id=conversation_id,
|
||||
provider_id=provider_config.get("id", "") or provider.meta().id,
|
||||
provider_model=provider.get_model(),
|
||||
status=status,
|
||||
stats=stats.to_dict(),
|
||||
agent_type="internal",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("Persist provider stats failed: %s", e, exc_info=True)
|
||||
|
||||
@@ -17,7 +17,7 @@ from astrbot.core.agent.runners.deerflow.deerflow_agent_runner import (
|
||||
)
|
||||
from astrbot.core.agent.runners.dify.dify_agent_runner import DifyAgentRunner
|
||||
from astrbot.core.astr_agent_hooks import MAIN_AGENT_HOOKS
|
||||
from astrbot.core.message.components import Image, Record
|
||||
from astrbot.core.message.components import Image
|
||||
from astrbot.core.message.message_event_result import (
|
||||
MessageChain,
|
||||
MessageEventResult,
|
||||
@@ -317,11 +317,8 @@ class ThirdPartyAgentSubStage(Stage):
|
||||
if isinstance(comp, Image):
|
||||
image_path = await comp.convert_to_base64()
|
||||
req.image_urls.append(image_path)
|
||||
elif isinstance(comp, Record):
|
||||
audio_path = await comp.convert_to_file_path()
|
||||
req.audio_urls.append(audio_path)
|
||||
|
||||
if not req.prompt and not req.image_urls and not req.audio_urls:
|
||||
if not req.prompt and not req.image_urls:
|
||||
return
|
||||
|
||||
custom_error_message = await self._resolve_persona_custom_error_message(event)
|
||||
|
||||
@@ -5,7 +5,6 @@ from collections.abc import AsyncGenerator
|
||||
from typing import Any
|
||||
|
||||
from astrbot.core import logger
|
||||
from astrbot.core.i18n import t
|
||||
from astrbot.core.message.message_event_result import MessageEventResult
|
||||
from astrbot.core.platform.astr_message_event import AstrMessageEvent
|
||||
from astrbot.core.star.star import star_map
|
||||
@@ -63,13 +62,7 @@ class StarRequestSubStage(Stage):
|
||||
)
|
||||
|
||||
if not event.is_stopped() and event.is_at_or_wake_command:
|
||||
ret = t(
|
||||
"pipeline.plugin_handler_error",
|
||||
locale=self.ctx.get_current_language(),
|
||||
plugin_name=md.name,
|
||||
handler_name=handler.handler_name,
|
||||
error=e,
|
||||
)
|
||||
ret = f":(\n\n在调用插件 {md.name} 的处理函数 {handler.handler_name} 时出现异常:{e}"
|
||||
event.set_result(MessageEventResult().message(ret))
|
||||
yield
|
||||
event.clear_result()
|
||||
|
||||
@@ -32,6 +32,7 @@ class RespondStage(Stage):
|
||||
Comp.Node: lambda comp: bool(comp.content), # 转发节点
|
||||
Comp.Nodes: lambda comp: bool(comp.nodes), # 多个转发节点
|
||||
Comp.File: lambda comp: bool(comp.file_ or comp.url),
|
||||
Comp.WechatEmoji: lambda comp: comp.md5 is not None, # 微信表情
|
||||
Comp.Json: lambda comp: bool(comp.data), # Json 卡片
|
||||
Comp.Share: lambda comp: bool(comp.url) or bool(comp.title),
|
||||
Comp.Music: lambda comp: (
|
||||
|
||||
@@ -5,7 +5,6 @@ import traceback
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
from astrbot.core import file_token_service, html_renderer, logger
|
||||
from astrbot.core.i18n import t
|
||||
from astrbot.core.message.components import At, Image, Json, Node, Plain, Record, Reply
|
||||
from astrbot.core.message.message_event_result import ResultContentType
|
||||
from astrbot.core.pipeline.content_safety_check.stage import ContentSafetyCheckStage
|
||||
@@ -290,16 +289,7 @@ class ResultDecorateStage(Stage):
|
||||
),
|
||||
)
|
||||
else:
|
||||
result.chain.insert(
|
||||
0,
|
||||
Plain(
|
||||
t(
|
||||
"pipeline.reasoning_prefix",
|
||||
locale=self.ctx.get_current_language(),
|
||||
reasoning_content=reasoning_content,
|
||||
),
|
||||
),
|
||||
)
|
||||
result.chain.insert(0, Plain(f"🤔 思考: {reasoning_content}\n"))
|
||||
|
||||
if should_tts and tts_provider:
|
||||
new_chain = []
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
from collections.abc import AsyncGenerator, Callable
|
||||
|
||||
from astrbot import logger
|
||||
from astrbot.core.i18n import t
|
||||
from astrbot.core.message.components import At, AtAll, Reply
|
||||
from astrbot.core.message.message_event_result import MessageChain, MessageEventResult
|
||||
from astrbot.core.platform.astr_message_event import AstrMessageEvent
|
||||
@@ -23,7 +22,6 @@ UNIQUE_SESSION_ID_BUILDERS: dict[str, Callable[[AstrMessageEvent], str | None]]
|
||||
"qq_official_webhook": lambda e: e.get_sender_id(),
|
||||
"lark": lambda e: f"{e.get_sender_id()}%{e.get_group_id()}",
|
||||
"misskey": lambda e: f"{e.get_session_id()}_{e.get_sender_id()}",
|
||||
"matrix": lambda e: f"{e.get_sender_id()}_{e.get_group_id() or e.get_session_id()}",
|
||||
}
|
||||
|
||||
|
||||
@@ -188,12 +186,7 @@ class WakingCheckStage(Stage):
|
||||
except Exception as e:
|
||||
await event.send(
|
||||
MessageEventResult().message(
|
||||
t(
|
||||
"pipeline.filter_error",
|
||||
locale=self.ctx.get_current_language(),
|
||||
plugin_name=star_map[handler.handler_module_path].name,
|
||||
error=e,
|
||||
),
|
||||
f"插件 {star_map[handler.handler_module_path].name}: {e}",
|
||||
),
|
||||
)
|
||||
event.stop_event()
|
||||
@@ -207,11 +200,7 @@ class WakingCheckStage(Stage):
|
||||
if self.no_permission_reply:
|
||||
await event.send(
|
||||
MessageChain().message(
|
||||
t(
|
||||
"pipeline.no_permission",
|
||||
locale=self.ctx.get_current_language(),
|
||||
sender_id=event.get_sender_id(),
|
||||
),
|
||||
f"您(ID: {event.get_sender_id()})的权限不足以使用此指令。通过 /sid 获取 ID 并请管理员添加。",
|
||||
),
|
||||
)
|
||||
logger.info(
|
||||
|
||||
@@ -293,12 +293,6 @@ class AstrMessageEvent(abc.ABC):
|
||||
默认实现为空,由具体平台按需重写。
|
||||
"""
|
||||
|
||||
async def stop_typing(self) -> None:
|
||||
"""停止输入中状态。
|
||||
|
||||
默认实现为空,由具体平台按需重写。
|
||||
"""
|
||||
|
||||
async def _pre_send(self) -> None:
|
||||
"""调度器会在执行 send() 前调用该方法 deprecated in v3.5.18"""
|
||||
|
||||
@@ -414,7 +408,6 @@ class AstrMessageEvent(abc.ABC):
|
||||
tool_set: ToolSet | None = None,
|
||||
session_id: str = "",
|
||||
image_urls: list[str] | None = None,
|
||||
audio_urls: list[str] | None = None,
|
||||
contexts: list | None = None,
|
||||
system_prompt: str = "",
|
||||
conversation: Conversation | None = None,
|
||||
@@ -433,8 +426,6 @@ class AstrMessageEvent(abc.ABC):
|
||||
|
||||
image_urls: 可以是 base64:// 或者 http:// 开头的图片链接,也可以是本地图片路径。
|
||||
|
||||
audio_urls: 音频 URL 列表,也支持本地路径。
|
||||
|
||||
contexts: 当指定 contexts 时,将会使用 contexts 作为上下文。如果同时传入了 conversation,将会忽略 conversation。
|
||||
|
||||
func_tool_manager: [Deprecated] 函数工具管理器,用于调用函数工具。用 self.context.get_llm_tool_manager() 获取。已过时,请使用 tool_set 参数代替。
|
||||
@@ -444,8 +435,6 @@ class AstrMessageEvent(abc.ABC):
|
||||
"""
|
||||
if image_urls is None:
|
||||
image_urls = []
|
||||
if audio_urls is None:
|
||||
audio_urls = []
|
||||
if contexts is None:
|
||||
contexts = []
|
||||
if len(contexts) > 0 and conversation:
|
||||
@@ -455,7 +444,6 @@ class AstrMessageEvent(abc.ABC):
|
||||
prompt=prompt,
|
||||
session_id=session_id,
|
||||
image_urls=image_urls,
|
||||
audio_urls=audio_urls,
|
||||
# func_tool=func_tool_manager,
|
||||
func_tool=tool_set,
|
||||
contexts=contexts,
|
||||
|
||||
@@ -188,10 +188,6 @@ class PlatformManager:
|
||||
from .sources.kook.kook_adapter import (
|
||||
KookPlatformAdapter, # noqa: F401
|
||||
)
|
||||
case "mattermost":
|
||||
from .sources.mattermost.mattermost_adapter import (
|
||||
MattermostPlatformAdapter, # noqa: F401
|
||||
)
|
||||
except (ImportError, ModuleNotFoundError) as e:
|
||||
logger.error(
|
||||
f"加载平台适配器 {platform_config['type']} 失败,原因:{e}。请检查依赖库是否安装。提示:可以在 管理面板->平台日志->安装Pip库 中安装依赖库。",
|
||||
|
||||
@@ -15,12 +15,9 @@ else:
|
||||
class DiscordBotClient(discord.Bot):
|
||||
"""Discord客户端封装"""
|
||||
|
||||
def __init__(
|
||||
self, token: str, proxy: str | None = None, allow_bot_messages: bool = False
|
||||
) -> None:
|
||||
def __init__(self, token: str, proxy: str | None = None) -> None:
|
||||
self.token = token
|
||||
self.proxy = proxy
|
||||
self.allow_bot_messages = allow_bot_messages
|
||||
|
||||
# 设置Intent权限,遵循权限最小化原则
|
||||
intents = discord.Intents.default()
|
||||
@@ -38,11 +35,11 @@ class DiscordBotClient(discord.Bot):
|
||||
async def on_ready(self) -> None:
|
||||
"""当机器人成功连接并准备就绪时触发"""
|
||||
if self.user is None:
|
||||
logger.error("[Discord] Bot user not loaded correctly (self.user is None)")
|
||||
logger.error("[Discord] 客户端未正确加载用户信息 (self.user is None)")
|
||||
return
|
||||
|
||||
logger.info(f"[Discord] Logged in as {self.user} (ID: {self.user.id})")
|
||||
logger.info("[Discord] Client is ready.")
|
||||
logger.info(f"[Discord] 已作为 {self.user} (ID: {self.user.id}) 登录")
|
||||
logger.info("[Discord] 客户端已准备就绪。")
|
||||
|
||||
if self.on_ready_once_callback and not self._ready_once_fired:
|
||||
self._ready_once_fired = True
|
||||
@@ -50,7 +47,7 @@ class DiscordBotClient(discord.Bot):
|
||||
await self.on_ready_once_callback()
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"[Discord] Failed to execute on_ready_once_callback: {e}",
|
||||
f"[Discord] on_ready_once_callback 执行失败: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
@@ -98,11 +95,11 @@ class DiscordBotClient(discord.Bot):
|
||||
|
||||
async def on_message(self, message: discord.Message) -> None:
|
||||
"""当接收到消息时触发"""
|
||||
if message.author.bot and not self.allow_bot_messages:
|
||||
if message.author.bot:
|
||||
return
|
||||
|
||||
logger.debug(
|
||||
f"[Discord] Received raw message from {message.author.name}: {message.content}",
|
||||
f"[Discord] 收到原始消息 from {message.author.name}: {message.content}",
|
||||
)
|
||||
|
||||
if self.on_message_received:
|
||||
|
||||
@@ -46,7 +46,7 @@ class DiscordPlatformAdapter(Platform):
|
||||
) -> None:
|
||||
super().__init__(platform_config, event_queue)
|
||||
self.settings = platform_settings
|
||||
self.bot_self_id: str | None = None
|
||||
self.client_self_id: str | None = None
|
||||
self.registered_handlers = []
|
||||
# 指令注册相关
|
||||
self.enable_command_register = self.config.get("discord_command_register", True)
|
||||
@@ -64,7 +64,7 @@ class DiscordPlatformAdapter(Platform):
|
||||
"""通过会话发送消息"""
|
||||
if self.client.user is None:
|
||||
logger.error(
|
||||
"[Discord] Client is not ready (self.client.user is None); message send skipped"
|
||||
"[Discord] 客户端未就绪 (self.client.user is None),无法发送消息"
|
||||
)
|
||||
return
|
||||
|
||||
@@ -92,10 +92,10 @@ class DiscordPlatformAdapter(Platform):
|
||||
|
||||
message_obj.message_str = message_chain.get_plain_text()
|
||||
message_obj.sender = MessageMember(
|
||||
user_id=str(self.bot_self_id),
|
||||
user_id=str(self.client_self_id),
|
||||
nickname=self.client.user.display_name,
|
||||
)
|
||||
message_obj.self_id = cast(str, self.bot_self_id)
|
||||
message_obj.self_id = cast(str, self.client_self_id)
|
||||
message_obj.session_id = session.session_id
|
||||
message_obj.message = message_chain.chain
|
||||
|
||||
@@ -115,7 +115,7 @@ class DiscordPlatformAdapter(Platform):
|
||||
"""返回平台元数据"""
|
||||
return PlatformMetadata(
|
||||
"discord",
|
||||
"Discord Adapter",
|
||||
"Discord 适配器",
|
||||
id=cast(str, self.config.get("id")),
|
||||
default_config_tmpl=self.config,
|
||||
support_streaming_message=False,
|
||||
@@ -127,37 +127,29 @@ class DiscordPlatformAdapter(Platform):
|
||||
|
||||
# 初始化回调函数
|
||||
async def on_received(message_data) -> None:
|
||||
logger.debug(f"[Discord] Message received: {message_data}")
|
||||
if self.bot_self_id is None:
|
||||
self.bot_self_id = message_data.get("bot_id")
|
||||
logger.debug(f"[Discord] 收到消息: {message_data}")
|
||||
if self.client_self_id is None:
|
||||
self.client_self_id = message_data.get("bot_id")
|
||||
abm = await self.convert_message(data=message_data)
|
||||
await self.handle_msg(abm)
|
||||
|
||||
# 初始化 Discord 客户端
|
||||
token = str(self.config.get("discord_token"))
|
||||
if not token:
|
||||
logger.error(
|
||||
"[Discord] Bot token is not configured. Please set a valid token in the config file."
|
||||
)
|
||||
logger.error("[Discord] Bot Token 未配置。请在配置文件中正确设置 token。")
|
||||
return
|
||||
|
||||
proxy = self.config.get("discord_proxy") or None
|
||||
allow_bot_messages = bool(self.config.get("discord_allow_bot_messages"))
|
||||
self.client = DiscordBotClient(token, proxy, allow_bot_messages)
|
||||
self.client = DiscordBotClient(token, proxy)
|
||||
self.client.on_message_received = on_received
|
||||
|
||||
async def callback() -> None:
|
||||
try:
|
||||
if self.enable_command_register:
|
||||
await self._collect_and_register_commands()
|
||||
if self.activity_name:
|
||||
await self.client.change_presence(
|
||||
status=discord.Status.online,
|
||||
activity=discord.CustomActivity(name=self.activity_name),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"[Discord] on_ready_once_callback err: {e}", exc_info=True
|
||||
if self.enable_command_register:
|
||||
await self._collect_and_register_commands()
|
||||
if self.activity_name:
|
||||
await self.client.change_presence(
|
||||
status=discord.Status.online,
|
||||
activity=discord.CustomActivity(name=self.activity_name),
|
||||
)
|
||||
|
||||
self.client.on_ready_once_callback = callback
|
||||
@@ -166,16 +158,11 @@ class DiscordPlatformAdapter(Platform):
|
||||
self._polling_task = asyncio.create_task(self.client.start_polling())
|
||||
await self.shutdown_event.wait()
|
||||
except discord.errors.LoginFailure:
|
||||
logger.error(
|
||||
"[Discord] Login failed. Please check whether the bot token is correct."
|
||||
)
|
||||
logger.error("[Discord] 登录失败。请检查你的 Bot Token 是否正确。")
|
||||
except discord.errors.ConnectionClosed:
|
||||
logger.warning("[Discord] Connection with Discord has been closed.")
|
||||
logger.warning("[Discord] 与 Discord 的连接已关闭。")
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"[Discord] Unexpected error while adapter is running: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
logger.error(f"[Discord] 适配器运行时发生意外错误: {e}", exc_info=True)
|
||||
|
||||
def _get_message_type(
|
||||
self,
|
||||
@@ -254,7 +241,7 @@ class DiscordPlatformAdapter(Platform):
|
||||
)
|
||||
abm.message = message_chain
|
||||
abm.raw_message = message
|
||||
abm.self_id = cast(str, self.bot_self_id)
|
||||
abm.self_id = cast(str, self.client_self_id)
|
||||
abm.session_id = str(message.channel.id)
|
||||
abm.message_id = str(message.id)
|
||||
return abm
|
||||
@@ -277,7 +264,7 @@ class DiscordPlatformAdapter(Platform):
|
||||
|
||||
if self.client.user is None:
|
||||
logger.error(
|
||||
"[Discord] Client is not ready (self.client.user is None); message handling skipped"
|
||||
"[Discord] 客户端未就绪 (self.client.user is None),无法处理消息"
|
||||
)
|
||||
return
|
||||
|
||||
@@ -296,7 +283,7 @@ class DiscordPlatformAdapter(Platform):
|
||||
raw_message = message.raw_message
|
||||
if not isinstance(raw_message, discord.Message):
|
||||
logger.warning(
|
||||
f"[Discord] Non-Message type received and ignored: {type(raw_message)}"
|
||||
f"[Discord] 收到非 Message 类型的消息: {type(raw_message)},已忽略。"
|
||||
)
|
||||
return
|
||||
|
||||
@@ -337,9 +324,20 @@ class DiscordPlatformAdapter(Platform):
|
||||
|
||||
@override
|
||||
async def terminate(self) -> None:
|
||||
logger.info("[Discord] Shutting down adapter...")
|
||||
"""终止适配器"""
|
||||
logger.info("[Discord] 正在终止适配器... (step 1: cancel polling task)")
|
||||
self.shutdown_event.set()
|
||||
logger.info("[Discord] Cleaning up commands...")
|
||||
# 优先 cancel polling_task
|
||||
if self._polling_task:
|
||||
self._polling_task.cancel()
|
||||
try:
|
||||
await asyncio.wait_for(self._polling_task, timeout=10)
|
||||
except asyncio.CancelledError:
|
||||
logger.info("[Discord] polling_task 已取消。")
|
||||
except Exception as e:
|
||||
logger.warning(f"[Discord] polling_task 取消异常: {e}")
|
||||
logger.info("[Discord] 正在清理已注册的斜杠指令... (step 2)")
|
||||
# 清理指令
|
||||
if self.enable_command_register and self.client:
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
@@ -349,29 +347,16 @@ class DiscordPlatformAdapter(Platform):
|
||||
),
|
||||
timeout=10,
|
||||
)
|
||||
logger.info("[Discord] Commands cleaned up successfully.")
|
||||
logger.info("[Discord] 指令清理完成。")
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"[Discord] Error occurred while cleaning up commands: {e}"
|
||||
)
|
||||
|
||||
if self._polling_task:
|
||||
self._polling_task.cancel()
|
||||
try:
|
||||
await asyncio.wait_for(self._polling_task, timeout=10)
|
||||
except asyncio.CancelledError:
|
||||
logger.info("[Discord] Polling task cancelled successfully.")
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"[Discord] Error occurred while cancelling polling task: {e}"
|
||||
)
|
||||
logger.info("[Discord] Closing client connection...")
|
||||
logger.error(f"[Discord] 清理指令时发生错误: {e}", exc_info=True)
|
||||
logger.info("[Discord] 正在关闭 Discord 客户端... (step 3)")
|
||||
if self.client and hasattr(self.client, "close"):
|
||||
try:
|
||||
await asyncio.wait_for(self.client.close(), timeout=10)
|
||||
except Exception as e:
|
||||
logger.warning(f"[Discord] Error occurred while closing client: {e}")
|
||||
logger.info("[Discord] Adapter shutdown complete.")
|
||||
logger.warning(f"[Discord] 客户端关闭异常: {e}")
|
||||
logger.info("[Discord] 适配器已终止。")
|
||||
|
||||
def register_handler(self, handler_info) -> None:
|
||||
"""注册处理器信息"""
|
||||
@@ -379,7 +364,7 @@ class DiscordPlatformAdapter(Platform):
|
||||
|
||||
async def _collect_and_register_commands(self) -> None:
|
||||
"""收集所有指令并注册到Discord"""
|
||||
logger.info("[Discord] Collecting and registering slash commands...")
|
||||
logger.info("[Discord] 开始收集并注册斜杠指令...")
|
||||
registered_commands = []
|
||||
|
||||
for handler_md in star_handlers_registry:
|
||||
@@ -420,15 +405,15 @@ class DiscordPlatformAdapter(Platform):
|
||||
|
||||
if registered_commands:
|
||||
logger.info(
|
||||
f"[Discord] Ready to sync {len(registered_commands)} commands: {', '.join(registered_commands)}",
|
||||
f"[Discord] 准备同步 {len(registered_commands)} 个指令: {', '.join(registered_commands)}",
|
||||
)
|
||||
else:
|
||||
logger.info("[Discord] No commands found for registration.")
|
||||
logger.info("[Discord] 没有发现可注册的指令。")
|
||||
|
||||
# 使用 Pycord 的方法同步指令
|
||||
# 注意:这可能需要一些时间,并且有频率限制
|
||||
await self.client.sync_commands()
|
||||
logger.info("[Discord] Command synchronization completed.")
|
||||
logger.info("[Discord] 指令同步完成。")
|
||||
|
||||
def _create_dynamic_callback(self, cmd_name: str):
|
||||
"""为每个指令动态创建一个异步回调函数"""
|
||||
@@ -436,35 +421,28 @@ class DiscordPlatformAdapter(Platform):
|
||||
async def dynamic_callback(
|
||||
ctx: discord.ApplicationContext, params: str | None = None
|
||||
) -> None:
|
||||
# 1. 嘗試立即响应,防止超时 (移到最前面)
|
||||
followup_webhook = None
|
||||
try:
|
||||
# 設定 2.5 秒超時,避免卡死整個 event loop
|
||||
await asyncio.wait_for(ctx.defer(), timeout=2.5)
|
||||
followup_webhook = ctx.followup
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning(
|
||||
f"[Discord] Defer command '{cmd_name}' timeout. Network might be too slow."
|
||||
)
|
||||
return
|
||||
except Exception as e:
|
||||
logger.warning(f"[Discord] Failed to defer command '{cmd_name}': {e}")
|
||||
return
|
||||
|
||||
# 将平台特定的前缀'/'剥离,以适配通用的CommandFilter
|
||||
logger.debug(f"[Discord] Callback triggered: {cmd_name}")
|
||||
logger.debug(f"[Discord] Callback context: {ctx}")
|
||||
logger.debug(f"[Discord] Callback params: {params}")
|
||||
logger.debug(f"[Discord] 回调函数触发: {cmd_name}")
|
||||
logger.debug(f"[Discord] 回调函数参数: {ctx}")
|
||||
logger.debug(f"[Discord] 回调函数参数: {params}")
|
||||
message_str_for_filter = cmd_name
|
||||
if params:
|
||||
message_str_for_filter += f" {params}"
|
||||
|
||||
logger.debug(
|
||||
f"[Discord] Slash command '{cmd_name}' triggered. "
|
||||
f"Raw params: '{params}'. "
|
||||
f"Built command string: '{message_str_for_filter}'",
|
||||
f"[Discord] 斜杠指令 '{cmd_name}' 被触发。 "
|
||||
f"原始参数: '{params}'. "
|
||||
f"构建的指令字符串: '{message_str_for_filter}'",
|
||||
)
|
||||
|
||||
# 尝试立即响应,防止超时
|
||||
followup_webhook = None
|
||||
try:
|
||||
await ctx.defer()
|
||||
followup_webhook = ctx.followup
|
||||
except Exception as e:
|
||||
logger.warning(f"[Discord] 指令 '{cmd_name}' defer 失败: {e}")
|
||||
|
||||
# 2. 构建 AstrBotMessage
|
||||
channel = ctx.channel
|
||||
abm = AstrBotMessage()
|
||||
@@ -487,7 +465,7 @@ class DiscordPlatformAdapter(Platform):
|
||||
)
|
||||
abm.message = [Plain(text=message_str_for_filter)]
|
||||
abm.raw_message = ctx.interaction
|
||||
abm.self_id = cast(str, self.bot_self_id)
|
||||
abm.self_id = cast(str, self.client_self_id)
|
||||
abm.session_id = str(ctx.channel_id)
|
||||
abm.message_id = str(ctx.interaction.id)
|
||||
|
||||
@@ -525,10 +503,10 @@ class DiscordPlatformAdapter(Platform):
|
||||
|
||||
# Discord 斜杠指令名称规范
|
||||
if not re.match(r"^[a-z0-9_-]{1,32}$", cmd_name):
|
||||
logger.debug(f"[Discord] Skipping invalid slash command format: {cmd_name}")
|
||||
logger.debug(f"[Discord] 跳过不符合规范的指令: {cmd_name}")
|
||||
return None
|
||||
|
||||
description = handler_metadata.desc or f"Command: {cmd_name}"
|
||||
description = handler_metadata.desc or f"指令: {cmd_name}"
|
||||
if len(description) > 100:
|
||||
description = f"{description[:97]}..."
|
||||
|
||||
|
||||
@@ -1,260 +0,0 @@
|
||||
import asyncio
|
||||
import json
|
||||
import mimetypes
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
|
||||
from astrbot.api import logger
|
||||
from astrbot.api.event import MessageChain
|
||||
from astrbot.api.message_components import At, File, Image, Plain, Record, Reply, Video
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
|
||||
|
||||
|
||||
class MattermostClient:
|
||||
def __init__(self, base_url: str, token: str) -> None:
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.token = token
|
||||
self._session: aiohttp.ClientSession | None = None
|
||||
|
||||
async def ensure_session(self) -> aiohttp.ClientSession:
|
||||
if self._session is None or self._session.closed:
|
||||
self._session = aiohttp.ClientSession(
|
||||
timeout=aiohttp.ClientTimeout(total=30),
|
||||
)
|
||||
return self._session
|
||||
|
||||
async def close(self) -> None:
|
||||
if self._session and not self._session.closed:
|
||||
await self._session.close()
|
||||
|
||||
def _headers(self) -> dict[str, str]:
|
||||
return {
|
||||
"Authorization": f"Bearer {self.token}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
def _auth_headers(self) -> dict[str, str]:
|
||||
return {"Authorization": f"Bearer {self.token}"}
|
||||
|
||||
async def get_json(self, path: str) -> dict[str, Any]:
|
||||
session = await self.ensure_session()
|
||||
url = f"{self.base_url}/api/v4/{path.lstrip('/')}"
|
||||
async with session.get(url, headers=self._headers()) as resp:
|
||||
if resp.status >= 400:
|
||||
body = await resp.text()
|
||||
raise RuntimeError(
|
||||
f"Mattermost GET {path} failed: {resp.status} {body}"
|
||||
)
|
||||
data = await resp.json()
|
||||
if not isinstance(data, dict):
|
||||
raise RuntimeError(f"Mattermost GET {path} returned non-object JSON")
|
||||
return data
|
||||
|
||||
async def post_json(self, path: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
session = await self.ensure_session()
|
||||
url = f"{self.base_url}/api/v4/{path.lstrip('/')}"
|
||||
async with session.post(url, headers=self._headers(), json=payload) as resp:
|
||||
if resp.status >= 400:
|
||||
body = await resp.text()
|
||||
raise RuntimeError(
|
||||
f"Mattermost POST {path} failed: {resp.status} {body}"
|
||||
)
|
||||
data = await resp.json()
|
||||
if not isinstance(data, dict):
|
||||
raise RuntimeError(f"Mattermost POST {path} returned non-object JSON")
|
||||
return data
|
||||
|
||||
async def get_me(self) -> dict[str, Any]:
|
||||
return await self.get_json("users/me")
|
||||
|
||||
async def get_channel(self, channel_id: str) -> dict[str, Any]:
|
||||
return await self.get_json(f"channels/{channel_id}")
|
||||
|
||||
async def get_file_info(self, file_id: str) -> dict[str, Any]:
|
||||
return await self.get_json(f"files/{file_id}/info")
|
||||
|
||||
async def download_file(self, file_id: str) -> bytes:
|
||||
session = await self.ensure_session()
|
||||
url = f"{self.base_url}/api/v4/files/{file_id}"
|
||||
async with session.get(url, headers=self._auth_headers()) as resp:
|
||||
if resp.status >= 400:
|
||||
body = await resp.text()
|
||||
raise RuntimeError(
|
||||
f"Mattermost download file {file_id} failed: {resp.status} {body}"
|
||||
)
|
||||
return await resp.read()
|
||||
|
||||
async def upload_file(
|
||||
self,
|
||||
channel_id: str,
|
||||
file_bytes: bytes,
|
||||
filename: str,
|
||||
content_type: str,
|
||||
) -> str:
|
||||
session = await self.ensure_session()
|
||||
url = f"{self.base_url}/api/v4/files"
|
||||
form = aiohttp.FormData()
|
||||
form.add_field("channel_id", channel_id)
|
||||
form.add_field(
|
||||
"files",
|
||||
file_bytes,
|
||||
filename=filename,
|
||||
content_type=content_type,
|
||||
)
|
||||
async with session.post(url, headers=self._auth_headers(), data=form) as resp:
|
||||
if resp.status >= 400:
|
||||
body = await resp.text()
|
||||
raise RuntimeError(
|
||||
f"Mattermost upload file failed: {resp.status} {body}"
|
||||
)
|
||||
data = await resp.json()
|
||||
file_infos = data.get("file_infos", [])
|
||||
if not file_infos:
|
||||
raise RuntimeError("Mattermost upload file returned no file_infos")
|
||||
file_id = file_infos[0].get("id", "")
|
||||
if not file_id:
|
||||
raise RuntimeError("Mattermost upload file returned empty file id")
|
||||
return str(file_id)
|
||||
|
||||
async def create_post(
|
||||
self,
|
||||
channel_id: str,
|
||||
message: str,
|
||||
*,
|
||||
file_ids: list[str] | None = None,
|
||||
root_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
payload: dict[str, Any] = {
|
||||
"channel_id": channel_id,
|
||||
"message": message,
|
||||
}
|
||||
if file_ids:
|
||||
payload["file_ids"] = file_ids
|
||||
if root_id:
|
||||
payload["root_id"] = root_id
|
||||
return await self.post_json("posts", payload)
|
||||
|
||||
async def ws_connect(self) -> aiohttp.ClientWebSocketResponse:
|
||||
session = await self.ensure_session()
|
||||
ws_url = self.base_url.replace("https://", "wss://", 1).replace(
|
||||
"http://", "ws://", 1
|
||||
)
|
||||
ws_url = f"{ws_url}/api/v4/websocket"
|
||||
return await session.ws_connect(ws_url, heartbeat=30.0)
|
||||
|
||||
async def send_message_chain(
|
||||
self,
|
||||
channel_id: str,
|
||||
message_chain: MessageChain,
|
||||
) -> dict[str, Any]:
|
||||
text_parts: list[str] = []
|
||||
file_ids: list[str] = []
|
||||
root_id: str | None = None
|
||||
|
||||
for segment in message_chain.chain:
|
||||
if isinstance(segment, Plain):
|
||||
text_parts.append(segment.text)
|
||||
elif isinstance(segment, At):
|
||||
mention_name = str(segment.name or segment.qq or "").strip()
|
||||
if mention_name:
|
||||
text_parts.append(f"@{mention_name}")
|
||||
elif isinstance(segment, Reply):
|
||||
if segment.id:
|
||||
root_id = str(segment.id)
|
||||
elif isinstance(segment, Image):
|
||||
path = await segment.convert_to_file_path()
|
||||
file_path = Path(path)
|
||||
file_bytes = await asyncio.to_thread(file_path.read_bytes)
|
||||
file_ids.append(
|
||||
await self.upload_file(
|
||||
channel_id,
|
||||
file_bytes,
|
||||
file_path.name,
|
||||
mimetypes.guess_type(file_path.name)[0] or "image/jpeg",
|
||||
)
|
||||
)
|
||||
elif isinstance(segment, (File, Record, Video)):
|
||||
if isinstance(segment, File):
|
||||
path = await segment.get_file()
|
||||
filename = segment.name or Path(path).name
|
||||
else:
|
||||
path = await segment.convert_to_file_path()
|
||||
filename = Path(path).name
|
||||
file_path = Path(path)
|
||||
file_bytes = await asyncio.to_thread(file_path.read_bytes)
|
||||
file_ids.append(
|
||||
await self.upload_file(
|
||||
channel_id,
|
||||
file_bytes,
|
||||
filename,
|
||||
mimetypes.guess_type(filename)[0] or "application/octet-stream",
|
||||
)
|
||||
)
|
||||
else:
|
||||
logger.debug(
|
||||
"Mattermost send_message_chain skipped unsupported segment: %s",
|
||||
segment.type,
|
||||
)
|
||||
|
||||
return await self.create_post(
|
||||
channel_id,
|
||||
"".join(text_parts).strip(),
|
||||
file_ids=file_ids or None,
|
||||
root_id=root_id,
|
||||
)
|
||||
|
||||
async def parse_post_attachments(
|
||||
self,
|
||||
file_ids: list[str],
|
||||
) -> tuple[list[Any], list[str]]:
|
||||
components: list[Any] = []
|
||||
temp_paths: list[str] = []
|
||||
|
||||
for file_id in file_ids:
|
||||
try:
|
||||
info = await self.get_file_info(file_id)
|
||||
file_bytes = await self.download_file(file_id)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Mattermost fetch attachment failed %s: %s", file_id, exc
|
||||
)
|
||||
continue
|
||||
|
||||
filename = str(info.get("name") or f"file_{file_id}")
|
||||
mime_type = str(info.get("mime_type") or "application/octet-stream")
|
||||
suffix = Path(filename).suffix
|
||||
file_path = Path(get_astrbot_temp_path()) / f"mattermost_{file_id}{suffix}"
|
||||
try:
|
||||
await asyncio.to_thread(file_path.write_bytes, file_bytes)
|
||||
except OSError as exc:
|
||||
logger.warning(
|
||||
"Mattermost write attachment failed %s -> %s: %s",
|
||||
file_id,
|
||||
file_path,
|
||||
exc,
|
||||
)
|
||||
continue
|
||||
temp_paths.append(str(file_path))
|
||||
|
||||
if mime_type.startswith("image/"):
|
||||
components.append(Image.fromFileSystem(str(file_path)))
|
||||
elif mime_type.startswith("audio/"):
|
||||
components.append(Record.fromFileSystem(str(file_path)))
|
||||
elif mime_type.startswith("video/"):
|
||||
components.append(Video.fromFileSystem(str(file_path)))
|
||||
else:
|
||||
components.append(File(name=filename, file=str(file_path)))
|
||||
|
||||
return components, temp_paths
|
||||
|
||||
@staticmethod
|
||||
def parse_websocket_post(raw_post: str) -> dict[str, Any] | None:
|
||||
try:
|
||||
parsed = json.loads(raw_post)
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
if not isinstance(parsed, dict):
|
||||
return None
|
||||
return parsed
|
||||
@@ -1,323 +0,0 @@
|
||||
import asyncio
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
from collections import deque
|
||||
from typing import Any, cast
|
||||
|
||||
import aiohttp
|
||||
|
||||
from astrbot.api import logger
|
||||
from astrbot.api.event import MessageChain
|
||||
from astrbot.api.message_components import At, Plain
|
||||
from astrbot.api.platform import (
|
||||
AstrBotMessage,
|
||||
MessageMember,
|
||||
MessageType,
|
||||
Platform,
|
||||
PlatformMetadata,
|
||||
)
|
||||
from astrbot.core.platform.astr_message_event import MessageSesion
|
||||
|
||||
from ...register import register_platform_adapter
|
||||
from .client import MattermostClient
|
||||
from .mattermost_event import MattermostMessageEvent
|
||||
|
||||
|
||||
@register_platform_adapter(
|
||||
"mattermost",
|
||||
"Mattermost 平台适配器",
|
||||
support_streaming_message=False,
|
||||
)
|
||||
class MattermostPlatformAdapter(Platform):
|
||||
def __init__(
|
||||
self,
|
||||
platform_config: dict,
|
||||
platform_settings: dict,
|
||||
event_queue: asyncio.Queue,
|
||||
) -> None:
|
||||
super().__init__(platform_config, event_queue)
|
||||
self.settings = platform_settings
|
||||
self.base_url = str(platform_config.get("mattermost_url", "")).rstrip("/")
|
||||
self.bot_token = str(platform_config.get("mattermost_bot_token", "")).strip()
|
||||
self.reconnect_delay = float(
|
||||
platform_config.get("mattermost_reconnect_delay", 5.0)
|
||||
)
|
||||
|
||||
if not self.base_url:
|
||||
raise ValueError("Mattermost URL 是必需的")
|
||||
if not self.bot_token:
|
||||
raise ValueError("Mattermost bot token 是必需的")
|
||||
|
||||
self.client = MattermostClient(self.base_url, self.bot_token)
|
||||
self.metadata = PlatformMetadata(
|
||||
name="mattermost",
|
||||
description="Mattermost 平台适配器",
|
||||
id=cast(str, self.config.get("id", "mattermost")),
|
||||
support_streaming_message=False,
|
||||
)
|
||||
self.bot_self_id = ""
|
||||
self.bot_username = ""
|
||||
self._mention_pattern: re.Pattern[str] | None = None
|
||||
self._running = True
|
||||
self._seen_post_ids: dict[str, float] = {}
|
||||
self._seen_post_queue: deque[tuple[str, float]] = deque()
|
||||
self._dedup_ttl = 300.0
|
||||
|
||||
async def send_by_session(
|
||||
self,
|
||||
session: MessageSesion,
|
||||
message_chain: MessageChain,
|
||||
) -> None:
|
||||
await self.client.send_message_chain(session.session_id, message_chain)
|
||||
await super().send_by_session(session, message_chain)
|
||||
|
||||
def meta(self) -> PlatformMetadata:
|
||||
return self.metadata
|
||||
|
||||
async def run(self) -> None:
|
||||
me = await self.client.get_me()
|
||||
self.bot_self_id = str(me.get("id", ""))
|
||||
self.bot_username = str(me.get("username", ""))
|
||||
self._mention_pattern = self._build_mention_pattern(self.bot_username)
|
||||
if not self.bot_self_id:
|
||||
raise RuntimeError("Mattermost auth succeeded but returned empty user id")
|
||||
|
||||
logger.info(
|
||||
"Mattermost auth test OK. Bot: @%s (%s)",
|
||||
self.bot_username,
|
||||
self.bot_self_id,
|
||||
)
|
||||
|
||||
while self._running:
|
||||
try:
|
||||
await self._ws_connect_and_listen()
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
if not self._running:
|
||||
break
|
||||
logger.warning(
|
||||
"Mattermost websocket disconnected: %s. Retrying in %.1fs.",
|
||||
exc,
|
||||
self.reconnect_delay,
|
||||
)
|
||||
await asyncio.sleep(self.reconnect_delay)
|
||||
|
||||
async def _ws_connect_and_listen(self) -> None:
|
||||
ws = await self.client.ws_connect()
|
||||
try:
|
||||
await ws.send_json(
|
||||
{
|
||||
"seq": 1,
|
||||
"action": "authentication_challenge",
|
||||
"data": {"token": self.bot_token},
|
||||
}
|
||||
)
|
||||
|
||||
async for message in ws:
|
||||
if message.type != aiohttp.WSMsgType.TEXT:
|
||||
if message.type in {
|
||||
aiohttp.WSMsgType.CLOSE,
|
||||
aiohttp.WSMsgType.CLOSED,
|
||||
aiohttp.WSMsgType.CLOSING,
|
||||
aiohttp.WSMsgType.ERROR,
|
||||
}:
|
||||
break
|
||||
continue
|
||||
|
||||
try:
|
||||
payload = json.loads(message.data)
|
||||
except json.JSONDecodeError:
|
||||
logger.debug(
|
||||
"Mattermost websocket received non-JSON text frame: %r",
|
||||
message.data,
|
||||
)
|
||||
continue
|
||||
if isinstance(payload, dict):
|
||||
await self._handle_ws_event(payload)
|
||||
finally:
|
||||
await ws.close()
|
||||
|
||||
async def _handle_ws_event(self, payload: dict[str, Any]) -> None:
|
||||
if payload.get("event") != "posted":
|
||||
return
|
||||
|
||||
data = payload.get("data")
|
||||
if not isinstance(data, dict):
|
||||
return
|
||||
|
||||
raw_post = data.get("post")
|
||||
if not isinstance(raw_post, str):
|
||||
return
|
||||
|
||||
post = self.client.parse_websocket_post(raw_post)
|
||||
if not post:
|
||||
return
|
||||
|
||||
user_id = str(post.get("user_id", ""))
|
||||
if not user_id or user_id == self.bot_self_id:
|
||||
return
|
||||
if post.get("type"):
|
||||
return
|
||||
|
||||
post_id = str(post.get("id", ""))
|
||||
if post_id and self._is_duplicate_post(post_id):
|
||||
return
|
||||
|
||||
abm = await self.convert_message(post=post, data=data)
|
||||
if abm is not None:
|
||||
await self.handle_msg(abm)
|
||||
|
||||
def _is_duplicate_post(self, post_id: str) -> bool:
|
||||
now = time.monotonic()
|
||||
self._prune_seen_posts(now)
|
||||
if post_id in self._seen_post_ids:
|
||||
return True
|
||||
self._seen_post_ids[post_id] = now
|
||||
self._seen_post_queue.append((post_id, now))
|
||||
return False
|
||||
|
||||
def _prune_seen_posts(self, now: float) -> None:
|
||||
while self._seen_post_queue:
|
||||
queued_post_id, seen_at = self._seen_post_queue[0]
|
||||
if now - seen_at <= self._dedup_ttl:
|
||||
break
|
||||
self._seen_post_queue.popleft()
|
||||
current_seen_at = self._seen_post_ids.get(queued_post_id)
|
||||
if current_seen_at == seen_at:
|
||||
del self._seen_post_ids[queued_post_id]
|
||||
|
||||
async def convert_message(
|
||||
self,
|
||||
*,
|
||||
post: dict[str, Any],
|
||||
data: dict[str, Any],
|
||||
) -> AstrBotMessage | None:
|
||||
channel_id = str(post.get("channel_id", "") or "")
|
||||
if not channel_id:
|
||||
return None
|
||||
|
||||
channel_type = str(data.get("channel_type", "O") or "O")
|
||||
sender_id = str(post.get("user_id", "") or "")
|
||||
sender_name = str(data.get("sender_name", "") or sender_id).lstrip("@")
|
||||
message_text = str(post.get("message", "") or "")
|
||||
file_ids = [
|
||||
str(file_id)
|
||||
for file_id in (post.get("file_ids") or [])
|
||||
if str(file_id).strip()
|
||||
]
|
||||
|
||||
abm = AstrBotMessage()
|
||||
abm.self_id = self.bot_self_id
|
||||
abm.sender = MessageMember(user_id=sender_id, nickname=sender_name)
|
||||
abm.session_id = channel_id
|
||||
abm.message_id = str(post.get("id") or channel_id)
|
||||
abm.raw_message = post
|
||||
abm.timestamp = self._parse_timestamp(post.get("create_at"))
|
||||
abm.message = self._parse_text_components(message_text)
|
||||
|
||||
if channel_type == "D":
|
||||
abm.type = MessageType.FRIEND_MESSAGE
|
||||
else:
|
||||
abm.type = MessageType.GROUP_MESSAGE
|
||||
abm.group_id = channel_id
|
||||
|
||||
if file_ids:
|
||||
(
|
||||
attachment_components,
|
||||
temp_paths,
|
||||
) = await self.client.parse_post_attachments(file_ids)
|
||||
abm.message.extend(attachment_components)
|
||||
setattr(abm, "temporary_file_paths", temp_paths)
|
||||
|
||||
abm.message_str = self._build_message_str(
|
||||
abm.message,
|
||||
message_text,
|
||||
self.bot_self_id,
|
||||
)
|
||||
return abm
|
||||
|
||||
def _parse_text_components(self, message_text: str) -> list[Any]:
|
||||
if not message_text:
|
||||
return []
|
||||
|
||||
components: list[Any] = []
|
||||
if not self.bot_username:
|
||||
return [Plain(message_text)]
|
||||
|
||||
mention_pattern = self._mention_pattern
|
||||
if mention_pattern is None:
|
||||
mention_pattern = self._build_mention_pattern(self.bot_username)
|
||||
if mention_pattern is None:
|
||||
return [Plain(message_text)]
|
||||
last_end = 0
|
||||
|
||||
for match in mention_pattern.finditer(message_text):
|
||||
if match.start() > last_end:
|
||||
components.append(Plain(message_text[last_end : match.start()]))
|
||||
components.append(At(qq=self.bot_self_id, name=self.bot_username))
|
||||
last_end = match.end()
|
||||
|
||||
if last_end < len(message_text):
|
||||
components.append(Plain(message_text[last_end:]))
|
||||
|
||||
if not components:
|
||||
components.append(Plain(message_text))
|
||||
return components
|
||||
|
||||
@staticmethod
|
||||
def _build_mention_pattern(bot_username: str) -> re.Pattern[str] | None:
|
||||
if not bot_username:
|
||||
return None
|
||||
return re.compile(
|
||||
rf"(?<![A-Za-z0-9_.-])@{re.escape(bot_username)}(?![A-Za-z0-9_.-])",
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _build_message_str(
|
||||
components: list[Any],
|
||||
fallback: str,
|
||||
self_id: str,
|
||||
) -> str:
|
||||
text_parts: list[str] = []
|
||||
leading_self_mention_skipped = False
|
||||
|
||||
for component in components:
|
||||
if isinstance(component, Plain):
|
||||
text_parts.append(component.text)
|
||||
elif isinstance(component, At):
|
||||
is_self_mention = str(component.qq) == self_id
|
||||
if not leading_self_mention_skipped and is_self_mention:
|
||||
leading_self_mention_skipped = True
|
||||
if not text_parts or not "".join(text_parts).strip():
|
||||
continue
|
||||
mention_name = str(component.name or component.qq or "").strip()
|
||||
if mention_name:
|
||||
text_parts.append(f"@{mention_name}")
|
||||
message_str = "".join(text_parts).strip()
|
||||
return message_str or fallback.strip()
|
||||
|
||||
@staticmethod
|
||||
def _parse_timestamp(raw_value: Any) -> int:
|
||||
if isinstance(raw_value, int):
|
||||
return raw_value // 1000 if raw_value > 1_000_000_000_000 else raw_value
|
||||
return int(time.time())
|
||||
|
||||
async def handle_msg(self, message: AstrBotMessage) -> None:
|
||||
message_event = MattermostMessageEvent(
|
||||
message_str=message.message_str,
|
||||
message_obj=message,
|
||||
platform_meta=self.meta(),
|
||||
session_id=message.session_id,
|
||||
client=self.client,
|
||||
)
|
||||
self.commit_event(message_event)
|
||||
|
||||
async def terminate(self) -> None:
|
||||
self._running = False
|
||||
await self.client.close()
|
||||
|
||||
def get_client(self) -> MattermostClient:
|
||||
return self.client
|
||||
@@ -1,88 +0,0 @@
|
||||
import asyncio
|
||||
import re
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
from astrbot.api.event import AstrMessageEvent, MessageChain
|
||||
from astrbot.api.message_components import Plain
|
||||
from astrbot.api.platform import Group, MessageMember
|
||||
|
||||
from .client import MattermostClient
|
||||
|
||||
|
||||
class MattermostMessageEvent(AstrMessageEvent):
|
||||
_FALLBACK_SENTENCE_PATTERN = re.compile(r"[^。?!~…]+[。?!~…]+")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message_str,
|
||||
message_obj,
|
||||
platform_meta,
|
||||
session_id,
|
||||
client: MattermostClient,
|
||||
) -> None:
|
||||
super().__init__(message_str, message_obj, platform_meta, session_id)
|
||||
self.client = client
|
||||
for path in getattr(message_obj, "temporary_file_paths", []):
|
||||
self.track_temporary_local_file(path)
|
||||
|
||||
async def send(self, message: MessageChain) -> None:
|
||||
await self.client.send_message_chain(self.get_session_id(), message)
|
||||
await super().send(message)
|
||||
|
||||
async def send_streaming(
|
||||
self,
|
||||
generator: AsyncGenerator,
|
||||
use_fallback: bool = False,
|
||||
) -> None:
|
||||
await super().send_streaming(generator, use_fallback)
|
||||
|
||||
if not use_fallback:
|
||||
message_buffer: MessageChain | None = None
|
||||
async for chain in generator:
|
||||
if not message_buffer:
|
||||
message_buffer = chain
|
||||
else:
|
||||
message_buffer.chain.extend(chain.chain)
|
||||
if not message_buffer:
|
||||
return None
|
||||
message_buffer.squash_plain()
|
||||
await self.send(message_buffer)
|
||||
return None
|
||||
|
||||
text_buffer = ""
|
||||
|
||||
async for chain in generator:
|
||||
if isinstance(chain, MessageChain):
|
||||
for comp in chain.chain:
|
||||
if isinstance(comp, Plain):
|
||||
text_buffer += comp.text
|
||||
if any(p in text_buffer for p in "。?!~…"):
|
||||
text_buffer = await self.process_buffer(
|
||||
text_buffer,
|
||||
self._FALLBACK_SENTENCE_PATTERN,
|
||||
)
|
||||
else:
|
||||
await self.send(MessageChain(chain=[comp]))
|
||||
await asyncio.sleep(1.5)
|
||||
|
||||
if text_buffer.strip():
|
||||
await self.send(MessageChain([Plain(text_buffer)]))
|
||||
return None
|
||||
|
||||
async def get_group(self, group_id=None, **kwargs):
|
||||
channel_id = group_id or self.get_group_id()
|
||||
if not channel_id:
|
||||
return None
|
||||
channel = await self.client.get_channel(channel_id)
|
||||
return Group(
|
||||
group_id=channel_id,
|
||||
group_name=channel.get("display_name") or channel.get("name") or channel_id,
|
||||
group_owner="",
|
||||
group_admins=[],
|
||||
members=[
|
||||
MessageMember(
|
||||
user_id=self.get_sender_id(),
|
||||
nickname=self.get_sender_name(),
|
||||
)
|
||||
],
|
||||
)
|
||||
@@ -93,7 +93,7 @@ class MisskeyPlatformAdapter(Platform):
|
||||
|
||||
self.api: MisskeyAPI | None = None
|
||||
self._running = False
|
||||
self.bot_self_id = ""
|
||||
self.client_self_id = ""
|
||||
self._bot_username = ""
|
||||
self._user_cache = {}
|
||||
|
||||
@@ -138,10 +138,10 @@ class MisskeyPlatformAdapter(Platform):
|
||||
|
||||
try:
|
||||
user_info = await self.api.get_current_user()
|
||||
self.bot_self_id = str(user_info.get("id", ""))
|
||||
self.client_self_id = str(user_info.get("id", ""))
|
||||
self._bot_username = user_info.get("username", "")
|
||||
logger.info(
|
||||
f"[Misskey] 已连接用户: {self._bot_username} (ID: {self.bot_self_id})",
|
||||
f"[Misskey] 已连接用户: {self._bot_username} (ID: {self.client_self_id})",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"[Misskey] 获取用户信息失败: {e}")
|
||||
@@ -312,9 +312,9 @@ class MisskeyPlatformAdapter(Platform):
|
||||
)
|
||||
room_id = data.get("toRoomId")
|
||||
logger.debug(
|
||||
f"[Misskey] 收到聊天事件: sender_id={sender_id}, room_id={room_id}, is_self={sender_id == self.bot_self_id}",
|
||||
f"[Misskey] 收到聊天事件: sender_id={sender_id}, room_id={room_id}, is_self={sender_id == self.client_self_id}",
|
||||
)
|
||||
if sender_id == self.bot_self_id:
|
||||
if sender_id == self.client_self_id:
|
||||
return
|
||||
|
||||
if room_id:
|
||||
@@ -354,13 +354,13 @@ class MisskeyPlatformAdapter(Platform):
|
||||
mentions = note.get("mentions", [])
|
||||
if self._bot_username and f"@{self._bot_username}" in text:
|
||||
return True
|
||||
if self.bot_self_id in [str(uid) for uid in mentions]:
|
||||
if self.client_self_id in [str(uid) for uid in mentions]:
|
||||
return True
|
||||
|
||||
reply = note.get("reply")
|
||||
if reply and isinstance(reply, dict):
|
||||
reply_user_id = str(reply.get("user", {}).get("id", ""))
|
||||
if reply_user_id == self.bot_self_id:
|
||||
if reply_user_id == self.client_self_id:
|
||||
return bool(self._bot_username and f"@{self._bot_username}" in text)
|
||||
|
||||
return False
|
||||
@@ -598,7 +598,7 @@ class MisskeyPlatformAdapter(Platform):
|
||||
visibility, visible_user_ids = resolve_message_visibility(
|
||||
user_id=user_id_for_cache,
|
||||
user_cache=self._user_cache,
|
||||
self_id=self.bot_self_id,
|
||||
self_id=self.client_self_id,
|
||||
default_visibility=self.default_visibility,
|
||||
)
|
||||
logger.debug(
|
||||
@@ -637,14 +637,14 @@ class MisskeyPlatformAdapter(Platform):
|
||||
message = create_base_message(
|
||||
raw_data,
|
||||
sender_info,
|
||||
self.bot_self_id,
|
||||
self.client_self_id,
|
||||
is_chat=False,
|
||||
)
|
||||
cache_user_info(
|
||||
self._user_cache,
|
||||
sender_info,
|
||||
raw_data,
|
||||
self.bot_self_id,
|
||||
self.client_self_id,
|
||||
is_chat=False,
|
||||
)
|
||||
|
||||
@@ -656,7 +656,7 @@ class MisskeyPlatformAdapter(Platform):
|
||||
message,
|
||||
raw_text,
|
||||
self._bot_username,
|
||||
self.bot_self_id,
|
||||
self.client_self_id,
|
||||
)
|
||||
message_parts.extend(text_parts)
|
||||
|
||||
@@ -685,14 +685,14 @@ class MisskeyPlatformAdapter(Platform):
|
||||
message = create_base_message(
|
||||
raw_data,
|
||||
sender_info,
|
||||
self.bot_self_id,
|
||||
self.client_self_id,
|
||||
is_chat=True,
|
||||
)
|
||||
cache_user_info(
|
||||
self._user_cache,
|
||||
sender_info,
|
||||
raw_data,
|
||||
self.bot_self_id,
|
||||
self.client_self_id,
|
||||
is_chat=True,
|
||||
)
|
||||
|
||||
@@ -713,7 +713,7 @@ class MisskeyPlatformAdapter(Platform):
|
||||
message = create_base_message(
|
||||
raw_data,
|
||||
sender_info,
|
||||
self.bot_self_id,
|
||||
self.client_self_id,
|
||||
is_chat=False,
|
||||
room_id=room_id,
|
||||
)
|
||||
@@ -722,10 +722,10 @@ class MisskeyPlatformAdapter(Platform):
|
||||
self._user_cache,
|
||||
sender_info,
|
||||
raw_data,
|
||||
self.bot_self_id,
|
||||
self.client_self_id,
|
||||
is_chat=False,
|
||||
)
|
||||
cache_room_info(self._user_cache, raw_data, self.bot_self_id)
|
||||
cache_room_info(self._user_cache, raw_data, self.client_self_id)
|
||||
|
||||
raw_text = raw_data.get("text", "")
|
||||
message_parts = []
|
||||
@@ -736,7 +736,7 @@ class MisskeyPlatformAdapter(Platform):
|
||||
message,
|
||||
raw_text,
|
||||
self._bot_username,
|
||||
self.bot_self_id,
|
||||
self.client_self_id,
|
||||
)
|
||||
message_parts.extend(text_parts)
|
||||
else:
|
||||
|
||||
@@ -335,7 +335,7 @@ def extract_sender_info(
|
||||
def create_base_message(
|
||||
raw_data: dict[str, Any],
|
||||
sender_info: dict[str, Any],
|
||||
bot_self_id: str,
|
||||
client_self_id: str,
|
||||
is_chat: bool = False,
|
||||
room_id: str | None = None,
|
||||
) -> AstrBotMessage:
|
||||
@@ -367,7 +367,7 @@ def create_base_message(
|
||||
session_id if sender_info["sender_id"] else f"{session_prefix}%unknown"
|
||||
)
|
||||
message.message_id = str(raw_data.get("id", ""))
|
||||
message.self_id = bot_self_id
|
||||
message.self_id = client_self_id
|
||||
|
||||
return message
|
||||
|
||||
@@ -376,7 +376,7 @@ def process_at_mention(
|
||||
message: AstrBotMessage,
|
||||
raw_text: str,
|
||||
bot_username: str,
|
||||
bot_self_id: str,
|
||||
client_self_id: str,
|
||||
) -> tuple[list[str], str]:
|
||||
"""处理@提及逻辑,返回消息部分列表和处理后的文本"""
|
||||
message_parts = []
|
||||
@@ -386,7 +386,7 @@ def process_at_mention(
|
||||
|
||||
if bot_username and raw_text.startswith(f"@{bot_username}"):
|
||||
at_mention = f"@{bot_username}"
|
||||
message.message.append(Comp.At(qq=bot_self_id))
|
||||
message.message.append(Comp.At(qq=client_self_id))
|
||||
remaining_text = raw_text[len(at_mention) :].strip()
|
||||
if remaining_text:
|
||||
message.message.append(Comp.Plain(remaining_text))
|
||||
@@ -401,7 +401,7 @@ def cache_user_info(
|
||||
user_cache: dict[str, Any],
|
||||
sender_info: dict[str, Any],
|
||||
raw_data: dict[str, Any],
|
||||
bot_self_id: str,
|
||||
client_self_id: str,
|
||||
is_chat: bool = False,
|
||||
) -> None:
|
||||
"""缓存用户信息"""
|
||||
@@ -410,7 +410,7 @@ def cache_user_info(
|
||||
"username": sender_info["username"],
|
||||
"nickname": sender_info["nickname"],
|
||||
"visibility": "specified",
|
||||
"visible_user_ids": [bot_self_id, sender_info["sender_id"]],
|
||||
"visible_user_ids": [client_self_id, sender_info["sender_id"]],
|
||||
}
|
||||
else:
|
||||
user_cache_data = {
|
||||
@@ -428,7 +428,7 @@ def cache_user_info(
|
||||
def cache_room_info(
|
||||
user_cache: dict[str, Any],
|
||||
raw_data: dict[str, Any],
|
||||
bot_self_id: str,
|
||||
client_self_id: str,
|
||||
) -> None:
|
||||
"""缓存房间信息"""
|
||||
room_data = raw_data.get("toRoom")
|
||||
@@ -442,7 +442,7 @@ def cache_room_info(
|
||||
"room_description": room_data.get("description", ""),
|
||||
"owner_id": room_data.get("ownerId", ""),
|
||||
"visibility": "specified",
|
||||
"visible_user_ids": [bot_self_id],
|
||||
"visible_user_ids": [client_self_id],
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
import uuid
|
||||
@@ -16,13 +15,6 @@ from botpy import Client
|
||||
from botpy.http import Route
|
||||
from botpy.types import message
|
||||
from botpy.types.message import MarkdownPayload, Media
|
||||
from tenacity import (
|
||||
before_sleep_log,
|
||||
retry,
|
||||
retry_if_exception_type,
|
||||
stop_after_attempt,
|
||||
wait_exponential,
|
||||
)
|
||||
|
||||
from astrbot.api import logger
|
||||
from astrbot.api.event import AstrMessageEvent, MessageChain
|
||||
@@ -52,20 +44,6 @@ def _patch_qq_botpy_formdata() -> None:
|
||||
|
||||
_patch_qq_botpy_formdata()
|
||||
|
||||
# Retry decorator for QQ Official API transient errors (HTTP 500/504)
|
||||
_qqofficial_retry = retry(
|
||||
retry=retry_if_exception_type(
|
||||
(
|
||||
botpy.errors.ServerError,
|
||||
botpy.errors.SequenceNumberError,
|
||||
)
|
||||
),
|
||||
stop=stop_after_attempt(3),
|
||||
wait=wait_exponential(multiplier=1, min=1, max=10),
|
||||
before_sleep=before_sleep_log(logger, logging.WARNING),
|
||||
reraise=True,
|
||||
)
|
||||
|
||||
|
||||
class QQOfficialMessageEvent(AstrMessageEvent):
|
||||
MARKDOWN_NOT_ALLOWED_ERROR = "不允许发送原生 markdown"
|
||||
@@ -475,26 +453,21 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
||||
"srv_send_msg": False,
|
||||
}
|
||||
|
||||
@_qqofficial_retry
|
||||
async def _do_upload():
|
||||
if "openid" in kwargs:
|
||||
payload["openid"] = kwargs["openid"]
|
||||
route = Route(
|
||||
"POST", "/v2/users/{openid}/files", openid=kwargs["openid"]
|
||||
)
|
||||
return await self.bot.api._http.request(route, json=payload)
|
||||
elif "group_openid" in kwargs:
|
||||
payload["group_openid"] = kwargs["group_openid"]
|
||||
route = Route(
|
||||
"POST",
|
||||
"/v2/groups/{group_openid}/files",
|
||||
group_openid=kwargs["group_openid"],
|
||||
)
|
||||
return await self.bot.api._http.request(route, json=payload)
|
||||
else:
|
||||
raise ValueError("Invalid upload parameters")
|
||||
|
||||
result = await _do_upload()
|
||||
result = None
|
||||
if "openid" in kwargs:
|
||||
payload["openid"] = kwargs["openid"]
|
||||
route = Route("POST", "/v2/users/{openid}/files", openid=kwargs["openid"])
|
||||
result = await self.bot.api._http.request(route, json=payload)
|
||||
elif "group_openid" in kwargs:
|
||||
payload["group_openid"] = kwargs["group_openid"]
|
||||
route = Route(
|
||||
"POST",
|
||||
"/v2/groups/{group_openid}/files",
|
||||
group_openid=kwargs["group_openid"],
|
||||
)
|
||||
result = await self.bot.api._http.request(route, json=payload)
|
||||
else:
|
||||
raise ValueError("Invalid upload parameters")
|
||||
|
||||
if not isinstance(result, dict):
|
||||
raise RuntimeError(
|
||||
@@ -517,7 +490,7 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
||||
) -> Media | None:
|
||||
"""上传媒体文件"""
|
||||
# 构建基础payload
|
||||
payload: dict = {"file_type": file_type, "srv_send_msg": srv_send_msg}
|
||||
payload = {"file_type": file_type, "srv_send_msg": srv_send_msg}
|
||||
if file_name:
|
||||
payload["file_name"] = file_name
|
||||
|
||||
@@ -546,12 +519,9 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
||||
else:
|
||||
return None
|
||||
|
||||
@_qqofficial_retry
|
||||
async def _do_upload():
|
||||
return await self.bot.api._http.request(route, json=payload)
|
||||
|
||||
try:
|
||||
result = await _do_upload()
|
||||
# 使用底层HTTP请求
|
||||
result = await self.bot.api._http.request(route, json=payload)
|
||||
|
||||
if result:
|
||||
if not isinstance(result, dict):
|
||||
@@ -563,8 +533,6 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
||||
file_info=result["file_info"],
|
||||
ttl=result.get("ttl", 0),
|
||||
)
|
||||
except (botpy.errors.ServerError, botpy.errors.SequenceNumberError):
|
||||
logger.error(f"上传媒体文件失败,共尝试3次后放弃: {file_source}")
|
||||
except Exception as e:
|
||||
logger.error(f"上传请求错误: {e}")
|
||||
|
||||
|
||||
@@ -5,15 +5,12 @@ import logging
|
||||
import os
|
||||
import random
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, cast
|
||||
|
||||
import botpy
|
||||
import botpy.message
|
||||
from botpy import Client
|
||||
from botpy.gateway import BotWebSocket
|
||||
|
||||
from astrbot import logger
|
||||
from astrbot.api.event import MessageChain
|
||||
@@ -27,8 +24,6 @@ from astrbot.api.platform import (
|
||||
)
|
||||
from astrbot.core.message.components import BaseMessageComponent
|
||||
from astrbot.core.platform.astr_message_event import MessageSesion
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
|
||||
from astrbot.core.utils.io import download_file
|
||||
|
||||
from ...register import register_platform_adapter
|
||||
from .qqofficial_message_event import QQOfficialMessageEvent
|
||||
@@ -38,42 +33,16 @@ for handler in logging.root.handlers[:]:
|
||||
logging.root.removeHandler(handler)
|
||||
|
||||
|
||||
class ManagedBotWebSocket(BotWebSocket):
|
||||
def __init__(self, session, connection: Any, client: botClient):
|
||||
super().__init__(session, connection)
|
||||
self._client = client
|
||||
|
||||
async def on_closed(self, close_status_code, close_msg):
|
||||
if self._client.is_shutting_down:
|
||||
logger.debug("[QQOfficial] Ignore websocket reconnect during shutdown.")
|
||||
return
|
||||
await super().on_closed(close_status_code, close_msg)
|
||||
|
||||
async def close(self) -> None:
|
||||
self._can_reconnect = False
|
||||
if self._conn is not None and not self._conn.closed:
|
||||
await self._conn.close()
|
||||
|
||||
|
||||
# QQ 机器人官方框架
|
||||
class botClient(Client):
|
||||
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
self._shutting_down = False
|
||||
self._active_websockets: set[ManagedBotWebSocket] = set()
|
||||
|
||||
def set_platform(self, platform: QQOfficialPlatformAdapter) -> None:
|
||||
self.platform = platform
|
||||
|
||||
@property
|
||||
def is_shutting_down(self) -> bool:
|
||||
return self._shutting_down or self.is_closed()
|
||||
|
||||
# 收到群消息
|
||||
async def on_group_at_message_create(
|
||||
self, message: botpy.message.GroupMessage
|
||||
) -> None:
|
||||
abm = await QQOfficialPlatformAdapter._parse_from_qqofficial(
|
||||
abm = QQOfficialPlatformAdapter._parse_from_qqofficial(
|
||||
message,
|
||||
MessageType.GROUP_MESSAGE,
|
||||
)
|
||||
@@ -84,7 +53,7 @@ class botClient(Client):
|
||||
|
||||
# 收到频道消息
|
||||
async def on_at_message_create(self, message: botpy.message.Message) -> None:
|
||||
abm = await QQOfficialPlatformAdapter._parse_from_qqofficial(
|
||||
abm = QQOfficialPlatformAdapter._parse_from_qqofficial(
|
||||
message,
|
||||
MessageType.GROUP_MESSAGE,
|
||||
)
|
||||
@@ -97,7 +66,7 @@ class botClient(Client):
|
||||
async def on_direct_message_create(
|
||||
self, message: botpy.message.DirectMessage
|
||||
) -> None:
|
||||
abm = await QQOfficialPlatformAdapter._parse_from_qqofficial(
|
||||
abm = QQOfficialPlatformAdapter._parse_from_qqofficial(
|
||||
message,
|
||||
MessageType.FRIEND_MESSAGE,
|
||||
)
|
||||
@@ -107,7 +76,7 @@ class botClient(Client):
|
||||
|
||||
# 收到 C2C 消息
|
||||
async def on_c2c_message_create(self, message: botpy.message.C2CMessage) -> None:
|
||||
abm = await QQOfficialPlatformAdapter._parse_from_qqofficial(
|
||||
abm = QQOfficialPlatformAdapter._parse_from_qqofficial(
|
||||
message,
|
||||
MessageType.FRIEND_MESSAGE,
|
||||
)
|
||||
@@ -127,30 +96,6 @@ class botClient(Client):
|
||||
),
|
||||
)
|
||||
|
||||
async def bot_connect(self, session) -> None:
|
||||
logger.info("[QQOfficial] Websocket session starting.")
|
||||
|
||||
websocket = ManagedBotWebSocket(session, self._connection, self)
|
||||
self._active_websockets.add(websocket)
|
||||
try:
|
||||
await websocket.ws_connect()
|
||||
except Exception as e:
|
||||
if not self.is_shutting_down:
|
||||
await websocket.on_error(e)
|
||||
finally:
|
||||
self._active_websockets.discard(websocket)
|
||||
|
||||
async def shutdown(self) -> None:
|
||||
if self.is_shutting_down:
|
||||
return
|
||||
|
||||
self._shutting_down = True
|
||||
await asyncio.gather(
|
||||
*(websocket.close() for websocket in list(self._active_websockets)),
|
||||
return_exceptions=True,
|
||||
)
|
||||
await self.close()
|
||||
|
||||
|
||||
@register_platform_adapter("qq_official", "QQ 机器人官方 API 适配器")
|
||||
class QQOfficialPlatformAdapter(Platform):
|
||||
@@ -293,9 +238,6 @@ class QQOfficialPlatformAdapter(Platform):
|
||||
)
|
||||
|
||||
elif session.message_type == MessageType.FRIEND_MESSAGE:
|
||||
# 参考 https://bot.q.qq.com/wiki/develop/pythonsdk/api/message/post_message.html
|
||||
# msg_id 缺失时认为是主动推送,而似乎至少在私聊上主动推送是没有被限制的,这里直接移除 msg_id 可以避免越权或 msg_id 不可用的bug
|
||||
payload.pop("msg_id", None)
|
||||
payload["msg_seq"] = random.randint(1, 10000)
|
||||
if image_base64:
|
||||
media = await QQOfficialMessageEvent.upload_group_and_c2c_image(
|
||||
@@ -326,6 +268,9 @@ class QQOfficialPlatformAdapter(Platform):
|
||||
if media:
|
||||
payload["media"] = media
|
||||
payload["msg_type"] = 7
|
||||
# QQ API rejects msg_id for media (video/file) messages sent
|
||||
# via the proactive tool-call path; remove it to avoid 越权 error.
|
||||
payload.pop("msg_id", None)
|
||||
if file_source:
|
||||
media = await QQOfficialMessageEvent.upload_group_and_c2c_media(
|
||||
send_helper, # type: ignore
|
||||
@@ -337,6 +282,7 @@ class QQOfficialPlatformAdapter(Platform):
|
||||
if media:
|
||||
payload["media"] = media
|
||||
payload["msg_type"] = 7
|
||||
payload.pop("msg_id", None)
|
||||
|
||||
ret = await QQOfficialMessageEvent.post_c2c_message(
|
||||
send_helper, # type: ignore
|
||||
@@ -391,22 +337,7 @@ class QQOfficialPlatformAdapter(Platform):
|
||||
return f"https://{url}"
|
||||
|
||||
@staticmethod
|
||||
async def _prepare_audio_attachment(
|
||||
url: str,
|
||||
filename: str,
|
||||
) -> Record:
|
||||
temp_dir = Path(get_astrbot_temp_path())
|
||||
temp_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
ext = Path(filename).suffix.lower()
|
||||
source_ext = ext or ".audio"
|
||||
source_path = temp_dir / f"qqofficial_{uuid.uuid4().hex}{source_ext}"
|
||||
await download_file(url, str(source_path))
|
||||
|
||||
return Record(file=str(source_path), url=str(source_path))
|
||||
|
||||
@staticmethod
|
||||
async def _append_attachments(
|
||||
def _append_attachments(
|
||||
msg: list[BaseMessageComponent],
|
||||
attachments: list | None,
|
||||
) -> None:
|
||||
@@ -433,7 +364,7 @@ class QQOfficialPlatformAdapter(Platform):
|
||||
or getattr(attachment, "name", None)
|
||||
or "attachment",
|
||||
)
|
||||
ext = Path(filename).suffix.lower()
|
||||
ext = os.path.splitext(filename)[1].lower()
|
||||
image_exts = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"}
|
||||
audio_exts = {
|
||||
".mp3",
|
||||
@@ -451,21 +382,8 @@ class QQOfficialPlatformAdapter(Platform):
|
||||
".webm",
|
||||
}
|
||||
|
||||
if content_type.startswith("voice") or ext in audio_exts:
|
||||
try:
|
||||
msg.append(
|
||||
await QQOfficialPlatformAdapter._prepare_audio_attachment(
|
||||
url,
|
||||
filename,
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"[QQOfficial] Failed to prepare audio attachment %s: %s",
|
||||
url,
|
||||
e,
|
||||
)
|
||||
msg.append(Record.fromURL(url))
|
||||
if content_type.startswith("audio") or ext in audio_exts:
|
||||
msg.append(Record.fromURL(url))
|
||||
elif content_type.startswith("video") or ext in video_exts:
|
||||
msg.append(Video.fromURL(url))
|
||||
elif content_type.startswith("image") or ext in image_exts:
|
||||
@@ -515,13 +433,13 @@ class QQOfficialPlatformAdapter(Platform):
|
||||
return re.sub(r"<faceType=\d+[^>]*>", replace_face, content)
|
||||
|
||||
@staticmethod
|
||||
async def _parse_from_qqofficial(
|
||||
def _parse_from_qqofficial(
|
||||
message: botpy.message.Message
|
||||
| botpy.message.GroupMessage
|
||||
| botpy.message.DirectMessage
|
||||
| botpy.message.C2CMessage,
|
||||
message_type: MessageType,
|
||||
) -> AstrBotMessage:
|
||||
):
|
||||
abm = AstrBotMessage()
|
||||
abm.type = message_type
|
||||
abm.timestamp = int(time.time())
|
||||
@@ -546,9 +464,7 @@ class QQOfficialPlatformAdapter(Platform):
|
||||
abm.self_id = "unknown_selfid"
|
||||
msg.append(At(qq="qq_official"))
|
||||
msg.append(Plain(abm.message_str))
|
||||
await QQOfficialPlatformAdapter._append_attachments(
|
||||
msg, message.attachments
|
||||
)
|
||||
QQOfficialPlatformAdapter._append_attachments(msg, message.attachments)
|
||||
abm.message = msg
|
||||
|
||||
elif isinstance(message, botpy.message.Message) or isinstance(
|
||||
@@ -567,9 +483,7 @@ class QQOfficialPlatformAdapter(Platform):
|
||||
).strip()
|
||||
)
|
||||
|
||||
await QQOfficialPlatformAdapter._append_attachments(
|
||||
msg, message.attachments
|
||||
)
|
||||
QQOfficialPlatformAdapter._append_attachments(msg, message.attachments)
|
||||
abm.message = msg
|
||||
abm.message_str = plain_content
|
||||
abm.sender = MessageMember(
|
||||
@@ -593,5 +507,5 @@ class QQOfficialPlatformAdapter(Platform):
|
||||
return self.client
|
||||
|
||||
async def terminate(self) -> None:
|
||||
await self.client.shutdown()
|
||||
logger.info("QQ 官方机器人接口 适配器已被关闭")
|
||||
await self.client.close()
|
||||
logger.info("QQ 官方机器人接口 适配器已被优雅地关闭")
|
||||
|
||||
@@ -31,7 +31,7 @@ class botClient(Client):
|
||||
async def on_group_at_message_create(
|
||||
self, message: botpy.message.GroupMessage
|
||||
) -> None:
|
||||
abm = await QQOfficialPlatformAdapter._parse_from_qqofficial(
|
||||
abm = QQOfficialPlatformAdapter._parse_from_qqofficial(
|
||||
message,
|
||||
MessageType.GROUP_MESSAGE,
|
||||
)
|
||||
@@ -42,7 +42,7 @@ class botClient(Client):
|
||||
|
||||
# 收到频道消息
|
||||
async def on_at_message_create(self, message: botpy.message.Message) -> None:
|
||||
abm = await QQOfficialPlatformAdapter._parse_from_qqofficial(
|
||||
abm = QQOfficialPlatformAdapter._parse_from_qqofficial(
|
||||
message,
|
||||
MessageType.GROUP_MESSAGE,
|
||||
)
|
||||
@@ -55,7 +55,7 @@ class botClient(Client):
|
||||
async def on_direct_message_create(
|
||||
self, message: botpy.message.DirectMessage
|
||||
) -> None:
|
||||
abm = await QQOfficialPlatformAdapter._parse_from_qqofficial(
|
||||
abm = QQOfficialPlatformAdapter._parse_from_qqofficial(
|
||||
message,
|
||||
MessageType.FRIEND_MESSAGE,
|
||||
)
|
||||
@@ -65,7 +65,7 @@ class botClient(Client):
|
||||
|
||||
# 收到 C2C 消息
|
||||
async def on_c2c_message_create(self, message: botpy.message.C2CMessage) -> None:
|
||||
abm = await QQOfficialPlatformAdapter._parse_from_qqofficial(
|
||||
abm = QQOfficialPlatformAdapter._parse_from_qqofficial(
|
||||
message,
|
||||
MessageType.FRIEND_MESSAGE,
|
||||
)
|
||||
@@ -193,4 +193,4 @@ class QQOfficialWebhookPlatformAdapter(Platform):
|
||||
f"Exception occurred during QQOfficialWebhook server shutdown: {exc}",
|
||||
exc_info=True,
|
||||
)
|
||||
logger.info("QQ 机器人官方 API 适配器已经被关闭")
|
||||
logger.info("QQ 机器人官方 API 适配器已经被优雅地关闭")
|
||||
|
||||
@@ -3,13 +3,12 @@ import os
|
||||
import re
|
||||
import sys
|
||||
import uuid
|
||||
from contextlib import suppress
|
||||
from typing import cast
|
||||
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from telegram import BotCommand, Update
|
||||
from telegram.constants import ChatType
|
||||
from telegram.error import Forbidden, InvalidToken, NetworkError
|
||||
from telegram.error import Forbidden, InvalidToken
|
||||
from telegram.ext import ApplicationBuilder, ContextTypes, ExtBot, filters
|
||||
from telegram.ext import MessageHandler as TelegramMessageHandler
|
||||
|
||||
@@ -51,6 +50,7 @@ class TelegramPlatformAdapter(Platform):
|
||||
) -> None:
|
||||
super().__init__(platform_config, event_queue)
|
||||
self.settings = platform_settings
|
||||
self.client_self_id = uuid.uuid4().hex[:8]
|
||||
|
||||
base_url = self.config.get(
|
||||
"telegram_api_base_url",
|
||||
@@ -67,7 +67,6 @@ class TelegramPlatformAdapter(Platform):
|
||||
file_base_url = "https://api.telegram.org/file/bot"
|
||||
|
||||
self.base_url = base_url
|
||||
self.file_base_url = file_base_url
|
||||
|
||||
self.enable_command_register = self.config.get(
|
||||
"telegram_command_register",
|
||||
@@ -79,12 +78,23 @@ class TelegramPlatformAdapter(Platform):
|
||||
)
|
||||
self.last_command_hash = None
|
||||
|
||||
self.application = (
|
||||
ApplicationBuilder()
|
||||
.token(self.config["telegram_token"])
|
||||
.base_url(base_url)
|
||||
.base_file_url(file_base_url)
|
||||
.build()
|
||||
)
|
||||
message_handler = TelegramMessageHandler(
|
||||
filters=filters.ALL, # receive all messages
|
||||
callback=self.message_handler,
|
||||
)
|
||||
self.application.add_handler(message_handler)
|
||||
self.client = self.application.bot
|
||||
logger.debug(f"Telegram base url: {self.client.base_url}")
|
||||
|
||||
self.scheduler = AsyncIOScheduler()
|
||||
self._terminating = False
|
||||
self._loop: asyncio.AbstractEventLoop | None = None
|
||||
self._polling_recovery_requested = asyncio.Event()
|
||||
self._consecutive_polling_failures = 0
|
||||
self._last_polling_failure_at = 0.0
|
||||
raw_delay = self.config.get("telegram_polling_restart_delay", 5.0)
|
||||
try:
|
||||
delay = float(raw_delay)
|
||||
@@ -104,10 +114,6 @@ class TelegramPlatformAdapter(Platform):
|
||||
)
|
||||
delay = 0.1
|
||||
self._polling_restart_delay = delay
|
||||
self._polling_recovery_threshold = 3
|
||||
self._polling_failure_window = 60.0
|
||||
self._application_started = False
|
||||
self._build_application()
|
||||
|
||||
# Media group handling
|
||||
# Cache structure: {media_group_id: {"created_at": datetime, "items": [(update, context), ...]}}
|
||||
@@ -119,85 +125,6 @@ class TelegramPlatformAdapter(Platform):
|
||||
"telegram_media_group_max_wait", 10.0
|
||||
) # max seconds - hard cap to prevent indefinite delay
|
||||
|
||||
def _build_application(self) -> None:
|
||||
self.application = (
|
||||
ApplicationBuilder()
|
||||
.token(self.config["telegram_token"])
|
||||
.base_url(self.base_url)
|
||||
.base_file_url(self.file_base_url)
|
||||
.build()
|
||||
)
|
||||
message_handler = TelegramMessageHandler(
|
||||
filters=filters.ALL,
|
||||
callback=self.message_handler,
|
||||
)
|
||||
self.application.add_handler(message_handler)
|
||||
self.client = self.application.bot
|
||||
logger.debug(f"Telegram base url: {self.client.base_url}")
|
||||
|
||||
async def _start_application(self) -> None:
|
||||
await self.application.initialize()
|
||||
await self.application.start()
|
||||
|
||||
if self.enable_command_register:
|
||||
await self.register_commands()
|
||||
|
||||
self._application_started = True
|
||||
|
||||
async def _shutdown_application(
|
||||
self,
|
||||
*,
|
||||
delete_commands: bool,
|
||||
) -> None:
|
||||
self._application_started = False
|
||||
|
||||
updater = self.application.updater
|
||||
if updater is not None:
|
||||
with suppress(Exception):
|
||||
await updater.stop()
|
||||
|
||||
if delete_commands and self.enable_command_register:
|
||||
with suppress(Exception):
|
||||
await self.client.delete_my_commands()
|
||||
|
||||
with suppress(Exception):
|
||||
await self.application.stop()
|
||||
|
||||
shutdown = getattr(self.application, "shutdown", None)
|
||||
if shutdown is not None:
|
||||
with suppress(Exception):
|
||||
await shutdown()
|
||||
|
||||
async def _recreate_application(self) -> None:
|
||||
if self._terminating:
|
||||
self._polling_recovery_requested.clear()
|
||||
return
|
||||
|
||||
logger.warning(
|
||||
"Telegram polling hit repeated network errors; rebuilding the "
|
||||
"Telegram application and HTTP client.",
|
||||
)
|
||||
await self._shutdown_application(delete_commands=False)
|
||||
self._build_application()
|
||||
self._consecutive_polling_failures = 0
|
||||
self._last_polling_failure_at = 0.0
|
||||
self._polling_recovery_requested.clear()
|
||||
|
||||
def _start_command_scheduler(self) -> None:
|
||||
if not self.enable_command_refresh or not self.enable_command_register:
|
||||
return
|
||||
if self.scheduler.running:
|
||||
return
|
||||
|
||||
self.scheduler.add_job(
|
||||
self.register_commands,
|
||||
"interval",
|
||||
seconds=self.config.get("telegram_command_register_interval", 300),
|
||||
id="telegram_command_register",
|
||||
misfire_grace_time=60,
|
||||
)
|
||||
self.scheduler.start()
|
||||
|
||||
@override
|
||||
async def send_by_session(
|
||||
self,
|
||||
@@ -219,42 +146,41 @@ class TelegramPlatformAdapter(Platform):
|
||||
|
||||
@override
|
||||
async def run(self) -> None:
|
||||
self._loop = asyncio.get_running_loop()
|
||||
self._start_command_scheduler()
|
||||
await self.application.initialize()
|
||||
await self.application.start()
|
||||
|
||||
if self.enable_command_register:
|
||||
await self.register_commands()
|
||||
|
||||
if self.enable_command_refresh and self.enable_command_register:
|
||||
self.scheduler.add_job(
|
||||
self.register_commands,
|
||||
"interval",
|
||||
seconds=self.config.get("telegram_command_register_interval", 300),
|
||||
id="telegram_command_register",
|
||||
misfire_grace_time=60,
|
||||
)
|
||||
self.scheduler.start()
|
||||
|
||||
if not self.application.updater:
|
||||
logger.error("Telegram Updater is not initialized. Cannot start polling.")
|
||||
return
|
||||
|
||||
while not self._terminating:
|
||||
try:
|
||||
if not self._application_started:
|
||||
await self._start_application()
|
||||
|
||||
self._polling_recovery_requested.clear()
|
||||
updater = self.application.updater
|
||||
if updater is None:
|
||||
logger.error(
|
||||
"Telegram Updater is not initialized. Cannot start polling."
|
||||
)
|
||||
self._application_started = False
|
||||
await asyncio.sleep(self._polling_restart_delay)
|
||||
continue
|
||||
logger.info("Starting Telegram polling...")
|
||||
await updater.start_polling(error_callback=self._on_polling_error)
|
||||
await self.application.updater.start_polling(
|
||||
error_callback=self._on_polling_error
|
||||
)
|
||||
logger.info("Telegram Platform Adapter is running.")
|
||||
while updater.running and not self._terminating: # noqa: ASYNC110
|
||||
if self._polling_recovery_requested.is_set():
|
||||
await self._recreate_application()
|
||||
break
|
||||
while self.application.updater.running and not self._terminating: # noqa: ASYNC110
|
||||
await asyncio.sleep(1)
|
||||
else:
|
||||
if not self._terminating:
|
||||
logger.warning(
|
||||
"Telegram polling loop exited unexpectedly, "
|
||||
f"retrying in {self._polling_restart_delay}s."
|
||||
)
|
||||
continue
|
||||
|
||||
if not self._terminating:
|
||||
logger.info("Telegram polling restarted with a fresh client.")
|
||||
continue
|
||||
logger.warning(
|
||||
"Telegram polling loop exited unexpectedly, "
|
||||
f"retrying in {self._polling_restart_delay}s."
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except (Forbidden, InvalidToken) as e:
|
||||
@@ -268,9 +194,6 @@ class TelegramPlatformAdapter(Platform):
|
||||
f"{type(e).__name__}: {e!s}. "
|
||||
f"Retrying in {self._polling_restart_delay}s.",
|
||||
)
|
||||
with suppress(Exception):
|
||||
await self._shutdown_application(delete_commands=False)
|
||||
self._build_application()
|
||||
|
||||
if not self._terminating:
|
||||
await asyncio.sleep(self._polling_restart_delay)
|
||||
@@ -280,33 +203,6 @@ class TelegramPlatformAdapter(Platform):
|
||||
f"Telegram polling request failed: {type(error).__name__}: {error!s}",
|
||||
exc_info=error,
|
||||
)
|
||||
if not isinstance(error, NetworkError):
|
||||
return
|
||||
|
||||
if self._loop is None:
|
||||
return
|
||||
|
||||
now = self._loop.time()
|
||||
if now - self._last_polling_failure_at > self._polling_failure_window:
|
||||
self._consecutive_polling_failures = 0
|
||||
self._last_polling_failure_at = now
|
||||
self._consecutive_polling_failures += 1
|
||||
|
||||
if self._consecutive_polling_failures < self._polling_recovery_threshold:
|
||||
return
|
||||
|
||||
logger.warning(
|
||||
"Telegram polling encountered %s network failures within %.1fs; "
|
||||
"scheduling client rebuild.",
|
||||
self._consecutive_polling_failures,
|
||||
self._polling_failure_window,
|
||||
)
|
||||
if self._loop.is_closed():
|
||||
return
|
||||
try:
|
||||
self._loop.call_soon_threadsafe(self._polling_recovery_requested.set)
|
||||
except RuntimeError:
|
||||
return
|
||||
|
||||
async def register_commands(self) -> None:
|
||||
"""收集所有注册的指令并注册到 Telegram"""
|
||||
@@ -333,10 +229,7 @@ class TelegramPlatformAdapter(Platform):
|
||||
|
||||
for handler_md in star_handlers_registry:
|
||||
handler_metadata = handler_md
|
||||
if (
|
||||
handler_metadata.handler_module_path not in star_map
|
||||
or not star_map[handler_metadata.handler_module_path].activated
|
||||
):
|
||||
if not star_map[handler_metadata.handler_module_path].activated:
|
||||
continue
|
||||
if not handler_metadata.enabled:
|
||||
continue
|
||||
@@ -442,20 +335,6 @@ class TelegramPlatformAdapter(Platform):
|
||||
logger.warning("Received an update without a message.")
|
||||
return None
|
||||
|
||||
def _apply_caption() -> None:
|
||||
if not update.message:
|
||||
return
|
||||
if update.message.caption:
|
||||
message.message_str = update.message.caption
|
||||
message.message.append(Comp.Plain(message.message_str))
|
||||
if update.message.caption and update.message.caption_entities:
|
||||
for entity in update.message.caption_entities:
|
||||
if entity.type == "mention":
|
||||
name = update.message.caption[
|
||||
entity.offset + 1 : entity.offset + entity.length
|
||||
]
|
||||
message.message.append(Comp.At(qq=name, name=name))
|
||||
|
||||
message = AstrBotMessage()
|
||||
message.session_id = str(update.message.chat.id)
|
||||
|
||||
@@ -567,15 +446,24 @@ class TelegramPlatformAdapter(Platform):
|
||||
)
|
||||
path_wav = await convert_audio_to_wav(temp_path, path_wav)
|
||||
|
||||
record = Comp.Record(file=path_wav, url=path_wav)
|
||||
record.path = path_wav
|
||||
message.message = [record]
|
||||
message.message = [
|
||||
Comp.Record(file=path_wav, url=path_wav),
|
||||
]
|
||||
|
||||
elif update.message.photo:
|
||||
photo = update.message.photo[-1] # get the largest photo
|
||||
file = await photo.get_file()
|
||||
message.message.append(Comp.Image(file=file.file_path, url=file.file_path))
|
||||
_apply_caption()
|
||||
if update.message.caption:
|
||||
message.message_str = update.message.caption
|
||||
message.message.append(Comp.Plain(message.message_str))
|
||||
if update.message.caption_entities:
|
||||
for entity in update.message.caption_entities:
|
||||
if entity.type == "mention":
|
||||
name = message.message_str[
|
||||
entity.offset + 1 : entity.offset + entity.length
|
||||
]
|
||||
message.message.append(Comp.At(qq=name, name=name))
|
||||
|
||||
elif update.message.sticker:
|
||||
# 将sticker当作图片处理
|
||||
@@ -598,7 +486,6 @@ class TelegramPlatformAdapter(Platform):
|
||||
message.message.append(
|
||||
Comp.File(file=file_path, name=file_name, url=file_path)
|
||||
)
|
||||
_apply_caption()
|
||||
|
||||
elif update.message.video:
|
||||
file = await update.message.video.get_file()
|
||||
@@ -610,7 +497,6 @@ class TelegramPlatformAdapter(Platform):
|
||||
)
|
||||
else:
|
||||
message.message.append(Comp.Video(file=file_path, path=file.file_path))
|
||||
_apply_caption()
|
||||
|
||||
return message
|
||||
|
||||
@@ -739,8 +625,15 @@ class TelegramPlatformAdapter(Platform):
|
||||
self._terminating = True
|
||||
if self.scheduler.running:
|
||||
self.scheduler.shutdown()
|
||||
self._polling_recovery_requested.set()
|
||||
await self._shutdown_application(delete_commands=True)
|
||||
|
||||
await self.application.stop()
|
||||
|
||||
if self.enable_command_register:
|
||||
await self.client.delete_my_commands()
|
||||
|
||||
# 保险起见先判断是否存在updater对象
|
||||
if self.application.updater is not None:
|
||||
await self.application.updater.stop()
|
||||
|
||||
logger.info("Telegram adapter has been closed.")
|
||||
except Exception as e:
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user