Compare commits
120 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bb1b6a3814 | |||
| 28bfb3b8b2 | |||
| 351895ae66 | |||
| c1009adf52 | |||
| ecaec41208 | |||
| 997b51102b | |||
| c5bd074c28 | |||
| 4c09ed3c09 | |||
| a56e43d17e | |||
| e357d9de74 | |||
| 94736ff199 | |||
| aff92a48bf | |||
| d0998a9dfb | |||
| 3678688433 | |||
| 0c03177840 | |||
| 20ff719c00 | |||
| 8a8ec492d7 | |||
| 02c1443dd1 | |||
| 79301f192c | |||
| 4b2c854c42 | |||
| d02ee7be8b | |||
| dbeadb6833 | |||
| 478cc32de1 | |||
| 7b302445c2 | |||
| ae839ef6d8 | |||
| 144a53f4b3 | |||
| fa1d1e6034 | |||
| a404436f2c | |||
| bcb12a0717 | |||
| 5d0fc8ac7a | |||
| a4d37e2c20 | |||
| c599fb75ed | |||
| e7e0f84edf | |||
| e19a282c59 | |||
| fbc8667968 | |||
| cda49c3a9a | |||
| 4be1027444 | |||
| 46152d3faf | |||
| ed4cacfffb | |||
| 52d1979937 | |||
| b30cb12133 | |||
| 31d4e304fc | |||
| 9a7a594cb5 | |||
| e469178a6b | |||
| 0a517980b7 | |||
| 9c691b2266 | |||
| 3597726aad | |||
| a4a37c268d | |||
| 651a0645c5 | |||
| bf3fa3e918 | |||
| 3b2ce9f500 | |||
| 20d6ff4620 | |||
| a2b61e2ab8 | |||
| c6289d8f75 | |||
| 567390e27c | |||
| 0c0f8bf484 | |||
| ae0a9cb591 | |||
| 3f4d7255a0 | |||
| b8d2499475 | |||
| 8cb26d886f | |||
| 3ca8dd204f | |||
| 3476afce41 | |||
| 9b0e24ec49 | |||
| 92d71fffe9 | |||
| 80c22f4f72 | |||
| 6e22d266dd | |||
| 4c285fb521 | |||
| 51c3521aaa | |||
| 32112a3326 | |||
| f22221f781 | |||
| 4250d997b3 | |||
| 153d8cef6b | |||
| c9cdf47603 | |||
| 55ac878648 | |||
| 60abddada3 | |||
| bbc583cc8d | |||
| 7906030037 | |||
| 06b385697d | |||
| 059008a903 | |||
| 97c9e95211 | |||
| a4be369e43 | |||
| bdaca78750 | |||
| 6326d7e4ba | |||
| a809a09e55 | |||
| 52c4ef2d87 | |||
| 52c31fabe2 | |||
| 79e239ad97 | |||
| 8abaf1015d | |||
| 9a0c814fd4 | |||
| c64e1b42a4 | |||
| 2d23c36067 | |||
| 754144ad99 | |||
| 0faf109c2a | |||
| 7d1eff3ec4 | |||
| e295c470a5 | |||
| 935168c024 | |||
| f44961d065 | |||
| 0c7a95ccd8 | |||
| 09215bad57 | |||
| 4ff07e3c74 | |||
| 473e01aadd | |||
| cd5312ba77 | |||
| d87bfb0d5d | |||
| d2de0ea5ad | |||
| 4af064fd17 | |||
| 8ab2b515f6 | |||
| 51a1c0e375 | |||
| 30a0098b2a | |||
| e3cb9eb8af | |||
| b0de33c801 | |||
| bcdd8c463c | |||
| 336e2a2c40 | |||
| 338d8a6610 | |||
| 9d93bda3fe | |||
| a8dda20a30 | |||
| cd7755fe07 | |||
| dc995af34b | |||
| 331ada02fd | |||
| 80e1231e9a | |||
| e61b29ec6a |
@@ -17,7 +17,6 @@ ENV/
|
||||
.conda/
|
||||
dashboard/
|
||||
data/
|
||||
changelogs/
|
||||
tests/
|
||||
.ruff_cache/
|
||||
.astrbot
|
||||
|
||||
@@ -1,42 +1,40 @@
|
||||
|
||||
name: '🎉 功能建议'
|
||||
name: '🎉 Feature Request / 功能建议'
|
||||
title: "[Feature]"
|
||||
description: 提交建议帮助我们改进。
|
||||
description: Submit a suggestion to help us improve. / 提交建议帮助我们改进。
|
||||
labels: [ "enhancement" ]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
感谢您抽出时间提出新功能建议,请准确解释您的想法。
|
||||
Thank you for taking the time to suggest a new feature! Please explain your idea clearly and accurately. / 感谢您抽出时间提出新功能建议,请准确解释您的想法。
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: 描述
|
||||
description: 简短描述您的功能建议。
|
||||
label: Description / 描述
|
||||
description: Please describe the feature you want to be added in detail. / 请详细描述您希望添加的功能。
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: 使用场景
|
||||
description: 你想要发生什么?
|
||||
placeholder: >
|
||||
一个清晰且具体的描述这个功能的使用场景。
|
||||
label: Use Case / 使用场景
|
||||
description: Please describe the use case for this feature. / 请描述这个功能的使用场景。
|
||||
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: 你愿意提交PR吗?
|
||||
label: Willing to Submit PR? / 是否愿意提交PR?
|
||||
description: >
|
||||
这不是必须的,但我们欢迎您的贡献。
|
||||
This is not required, but if you are willing to submit a PR to implement this feature, it would be greatly appreciated! / 这不是必需的,但如果您愿意提交 PR 来实现这个功能,我们将不胜感激!
|
||||
options:
|
||||
- label: 是的, 我愿意提交PR!
|
||||
- label: Yes, I am willing to submit a PR. / 是的,我愿意提交 PR。
|
||||
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Code of Conduct
|
||||
options:
|
||||
- label: >
|
||||
我已阅读并同意遵守该项目的 [行为准则](https://docs.github.com/zh/site-policy/github-terms/github-community-code-of-conduct)。
|
||||
I have read and agree to abide by the project's [Code of Conduct](https://docs.github.com/zh/site-policy/github-terms/github-community-code-of-conduct). /
|
||||
required: true
|
||||
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: "感谢您填写我们的表单!"
|
||||
value: "Thank you for filling out our form!"
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.10'
|
||||
python-version: '3.12'
|
||||
|
||||
- name: Install UV
|
||||
run: pip install uv
|
||||
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 'latest'
|
||||
node-version: '24.13.0'
|
||||
|
||||
- name: npm install, build
|
||||
run: |
|
||||
@@ -52,4 +52,4 @@ jobs:
|
||||
repo: astrbot-release-harbour
|
||||
body: "Automated release from commit ${{ github.sha }}"
|
||||
token: ${{ secrets.ASTRBOT_HARBOUR_TOKEN }}
|
||||
artifacts: "dashboard/dist.zip"
|
||||
artifacts: "dashboard/dist.zip"
|
||||
|
||||
@@ -57,7 +57,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: '24.13.0'
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: dashboard/pnpm-lock.yaml
|
||||
|
||||
@@ -102,170 +102,11 @@ jobs:
|
||||
cp "dashboard/AstrBot-${VERSION_TAG}-dashboard.zip" "dashboard/astrbot-webui-${VERSION_TAG}.zip"
|
||||
rclone copy "dashboard/astrbot-webui-${VERSION_TAG}.zip" "r2:${R2_BUCKET_NAME}" --progress
|
||||
|
||||
build-desktop:
|
||||
name: Build ${{ matrix.name }}
|
||||
runs-on: ${{ matrix.runner }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- name: linux-x64
|
||||
runner: ubuntu-24.04
|
||||
os: linux
|
||||
arch: amd64
|
||||
- name: linux-arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
os: linux
|
||||
arch: arm64
|
||||
- name: windows-x64
|
||||
runner: windows-2022
|
||||
os: win
|
||||
arch: amd64
|
||||
- name: windows-arm64
|
||||
runner: windows-11-arm
|
||||
os: win
|
||||
arch: arm64
|
||||
- name: macos-x64
|
||||
runner: macos-15-intel
|
||||
os: mac
|
||||
arch: amd64
|
||||
- name: macos-arm64
|
||||
runner: macos-15
|
||||
os: mac
|
||||
arch: arm64
|
||||
env:
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: "false"
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
|
||||
- name: Resolve tag
|
||||
id: tag
|
||||
shell: bash
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "push" ]; then
|
||||
tag="${GITHUB_REF_NAME}"
|
||||
elif [ -n "${{ inputs.tag }}" ]; then
|
||||
tag="${{ inputs.tag }}"
|
||||
else
|
||||
tag="$(git describe --tags --abbrev=0)"
|
||||
fi
|
||||
if [ -z "$tag" ]; then
|
||||
echo "Failed to resolve tag." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "tag=$tag" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Setup uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10.28.2
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 20
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: |
|
||||
dashboard/pnpm-lock.yaml
|
||||
desktop/pnpm-lock.yaml
|
||||
|
||||
- name: Prepare OpenSSL for Windows ARM64
|
||||
if: ${{ matrix.os == 'win' && matrix.arch == 'arm64' }}
|
||||
shell: pwsh
|
||||
run: |
|
||||
git clone https://github.com/microsoft/vcpkg.git C:\vcpkg
|
||||
& C:\vcpkg\bootstrap-vcpkg.bat -disableMetrics
|
||||
& C:\vcpkg\vcpkg.exe install openssl:arm64-windows
|
||||
|
||||
"VCPKG_ROOT=C:\vcpkg" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
|
||||
"VCPKGRS_TRIPLET=arm64-windows" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
|
||||
"OPENSSL_DIR=C:\vcpkg\installed\arm64-windows" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
|
||||
"OPENSSL_ROOT_DIR=C:\vcpkg\installed\arm64-windows" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
|
||||
"OPENSSL_LIB_DIR=C:\vcpkg\installed\arm64-windows\lib" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
|
||||
"OPENSSL_INCLUDE_DIR=C:\vcpkg\installed\arm64-windows\include" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
|
||||
|
||||
- name: Install dependencies
|
||||
shell: bash
|
||||
run: |
|
||||
uv sync
|
||||
pnpm --dir dashboard install --frozen-lockfile
|
||||
pnpm --dir desktop install --frozen-lockfile
|
||||
|
||||
- name: Build desktop package
|
||||
shell: bash
|
||||
run: |
|
||||
pnpm --dir dashboard run build
|
||||
pnpm --dir desktop run build:webui
|
||||
pnpm --dir desktop run build:backend
|
||||
pnpm --dir desktop run sync:version
|
||||
pnpm --dir desktop exec electron-builder --publish never
|
||||
|
||||
- name: Normalize artifact names
|
||||
shell: bash
|
||||
env:
|
||||
NAME_PREFIX: AstrBot-${{ steps.tag.outputs.tag }}-${{ matrix.arch }}-${{ matrix.os }}
|
||||
run: |
|
||||
shopt -s nullglob
|
||||
out_dir="desktop/dist/release"
|
||||
mkdir -p "$out_dir"
|
||||
files=(
|
||||
desktop/dist/*.AppImage
|
||||
desktop/dist/*.dmg
|
||||
desktop/dist/*.zip
|
||||
desktop/dist/*.exe
|
||||
)
|
||||
if [ ${#files[@]} -eq 0 ]; then
|
||||
echo "No desktop artifacts found to rename." >&2
|
||||
exit 1
|
||||
fi
|
||||
for src in "${files[@]}"; do
|
||||
file="$(basename "$src")"
|
||||
case "$file" in
|
||||
*.AppImage)
|
||||
dest="$out_dir/${NAME_PREFIX}.AppImage"
|
||||
;;
|
||||
*.dmg)
|
||||
dest="$out_dir/${NAME_PREFIX}.dmg"
|
||||
;;
|
||||
*.exe)
|
||||
dest="$out_dir/${NAME_PREFIX}.exe"
|
||||
;;
|
||||
*.zip)
|
||||
dest="$out_dir/${NAME_PREFIX}.zip"
|
||||
;;
|
||||
*)
|
||||
continue
|
||||
;;
|
||||
esac
|
||||
cp "$src" "$dest"
|
||||
done
|
||||
ls -la "$out_dir"
|
||||
|
||||
- name: Upload desktop artifacts
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: AstrBot-${{ steps.tag.outputs.tag }}-${{ matrix.arch }}-${{ matrix.os }}
|
||||
if-no-files-found: error
|
||||
path: desktop/dist/release/*
|
||||
|
||||
publish-release:
|
||||
name: Publish GitHub Release
|
||||
runs-on: ubuntu-24.04
|
||||
needs:
|
||||
- build-dashboard
|
||||
- build-desktop
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
@@ -296,12 +137,6 @@ jobs:
|
||||
name: Dashboard-${{ steps.tag.outputs.tag }}
|
||||
path: release-assets
|
||||
|
||||
- name: Download desktop artifacts
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
pattern: AstrBot-${{ steps.tag.outputs.tag }}-*
|
||||
path: release-assets
|
||||
merge-multiple: true
|
||||
|
||||
- name: Resolve release notes
|
||||
id: notes
|
||||
|
||||
@@ -33,13 +33,6 @@ tests/astrbot_plugin_openai
|
||||
dashboard/node_modules/
|
||||
dashboard/dist/
|
||||
.pnpm-store/
|
||||
desktop/node_modules/
|
||||
desktop/dist/
|
||||
desktop/out/
|
||||
desktop/resources/backend/astrbot-backend*
|
||||
desktop/resources/backend/*.exe
|
||||
desktop/resources/webui/*
|
||||
desktop/resources/.pyinstaller/
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
|
||||
|
||||
+7
-7
@@ -15,17 +15,17 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
curl \
|
||||
gnupg \
|
||||
git \
|
||||
&& curl -fsSL https://deb.nodesource.com/setup_lts.x | bash - \
|
||||
&& apt-get install -y --no-install-recommends nodejs \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
|
||||
|
||||
RUN apt-get update && apt-get install -y curl gnupg \
|
||||
&& curl -fsSL https://deb.nodesource.com/setup_lts.x | bash - \
|
||||
&& apt-get install -y nodejs
|
||||
|
||||
RUN python -m pip install uv \
|
||||
&& echo "3.12" > .python-version
|
||||
RUN uv pip install -r requirements.txt --no-cache-dir --system
|
||||
RUN uv pip install socksio uv pilk --no-cache-dir --system
|
||||
&& echo "3.12" > .python-version \
|
||||
&& uv lock \
|
||||
&& uv export --format requirements.txt --output-file requirements.txt --frozen \
|
||||
&& uv pip install -r requirements.txt --no-cache-dir --system \
|
||||
&& uv pip install socksio uv pilk --no-cache-dir --system
|
||||
|
||||
EXPOSE 6185
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
## Welcome to AstrBot
|
||||
|
||||
🌟 Thank you for using AstrBot!
|
||||
|
||||
AstrBot is an Agentic AI assistant for personal and group chats, with support for multiple IM platforms and a wide range of built-in features. We hope it brings you an efficient and enjoyable experience. ❤️
|
||||
|
||||
Important notice:
|
||||
|
||||
AstrBot is a **free and open-source software project** protected by the AGPLv3 license. You can find the full source code and related resources on our [**official website**](https://astrbot.app) and [**GitHub**](https://github.com/astrbotdevs/astrbot).
|
||||
As of now, AstrBot has **no commercial services of any kind**, and the official team **will never charge users any fees** under any name.
|
||||
|
||||
If anyone asks you to pay while using AstrBot, **you are likely being scammed**. Please request a refund immediately and report it to us by email.
|
||||
|
||||
📮 Official email: [community@astrbot.app](mailto:community@astrbot.app)
|
||||
@@ -0,0 +1,14 @@
|
||||
## 欢迎使用 AstrBot
|
||||
|
||||
🌟 感谢您使用 AstrBot!
|
||||
|
||||
AstrBot 是一款可接入多种 IM 平台的 Agentic AI 个人 / 群聊助手,内置多项强大功能,希望能为您带来高效、愉快的使用体验。❤️
|
||||
|
||||
我们想特别说明:
|
||||
|
||||
AstrBot 是受 AGPLv3 开源协议保护的**免费开源软件项目**,您可以在[**官方网站**](https://astrbot.app)、[**GitHub**](https://github.com/astrbotdevs/astrbot) 上找到 AstrBot 的全部源代码及相关资源。
|
||||
截至目前,AstrBot 项目**未开展任何形式的商业化服务**,官方**不会以任何名义向用户收取费用**。
|
||||
|
||||
如果您在使用 AstrBot 的过程中被要求付费,**表明您已经遭遇诈骗行为**。请立即向相关方申请退款,并及时通过邮件向我们反馈。
|
||||
|
||||
📮 官方邮箱:[community@astrbot.app](mailto:community@astrbot.app)
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
<div align="center">
|
||||
|
||||
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_en.md">English</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_ja.md">日本語</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_zh-TW.md">繁體中文</a> |
|
||||
@@ -41,14 +40,14 @@ AstrBot 是一个开源的一站式 Agentic 个人和群聊助手,可在 QQ、
|
||||
## 主要功能
|
||||
|
||||
1. 💯 免费 & 开源。
|
||||
1. ✨ AI 大模型对话,多模态,Agent,MCP,Skills,知识库,人格设定,自动压缩对话。
|
||||
2. 🤖 支持接入 Dify、阿里云百炼、Coze 等智能体平台。
|
||||
2. 🌐 多平台,支持 QQ、企业微信、飞书、钉钉、微信公众号、Telegram、Slack 以及[更多](#支持的消息平台)。
|
||||
3. 📦 插件扩展,已有近 800 个插件可一键安装。
|
||||
5. 🛡️ [Agent Sandbox](https://docs.astrbot.app/use/astrbot-agent-sandbox.html) 隔离化环境,安全地执行任何代码、调用 Shell、会话级资源复用。
|
||||
6. 💻 WebUI 支持。
|
||||
7. 🌈 Web ChatUI 支持,ChatUI 内置代理沙盒、网页搜索等。
|
||||
8. 🌐 国际化(i18n)支持。
|
||||
2. ✨ AI 大模型对话,多模态,Agent,MCP,Skills,知识库,人格设定,自动压缩对话。
|
||||
3. 🤖 支持接入 Dify、阿里云百炼、Coze 等智能体平台。
|
||||
4. 🌐 多平台,支持 QQ、企业微信、飞书、钉钉、微信公众号、Telegram、Slack 以及[更多](#支持的消息平台)。
|
||||
5. 📦 插件扩展,已有 1000+ 个插件可一键安装。
|
||||
6. 🛡️ [Agent Sandbox](https://docs.astrbot.app/use/astrbot-agent-sandbox.html) 隔离化环境,安全地执行任何代码、调用 Shell、会话级资源复用。
|
||||
7. 💻 WebUI 支持。
|
||||
8. 🌈 Web ChatUI 支持,ChatUI 内置代理沙盒、网页搜索等。
|
||||
9. 🌐 国际化(i18n)支持。
|
||||
|
||||
<br>
|
||||
|
||||
@@ -57,7 +56,7 @@ AstrBot 是一个开源的一站式 Agentic 个人和群聊助手,可在 QQ、
|
||||
<th>💙 角色扮演 & 情感陪伴</th>
|
||||
<th>✨ 主动式 Agent</th>
|
||||
<th>🚀 通用 Agentic 能力</th>
|
||||
<th>🧩 900+ 社区插件</th>
|
||||
<th>🧩 1000+ 社区插件</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><p align="center"><img width="984" height="1746" alt="99b587c5d35eea09d84f33e6cf6cfd4f" src="https://github.com/user-attachments/assets/89196061-3290-458d-b51f-afa178049f84" /></p></td>
|
||||
@@ -78,9 +77,20 @@ AstrBot 是一个开源的一站式 Agentic 个人和群聊助手,可在 QQ、
|
||||
#### uv 部署
|
||||
|
||||
```bash
|
||||
uvx astrbot
|
||||
uv tool install astrbot
|
||||
astrbot
|
||||
```
|
||||
|
||||
#### 桌面应用部署(Tauri)
|
||||
|
||||
桌面应用仓库 [AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop)。
|
||||
|
||||
支持多系统架构,安装包直接安装,开箱即用,最适合新手和懒人的一键桌面部署方案,不推荐服务器场景。
|
||||
|
||||
#### 启动器一键部署(AstrBot Launcher)
|
||||
|
||||
快速部署和多开方案,实现环境隔离,进入 [AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher) 仓库,在 Releases 页最新版本下找到对应的系统安装包安装即可。
|
||||
|
||||
#### 宝塔面板部署
|
||||
|
||||
AstrBot 与宝塔面板合作,已上架至宝塔面板。
|
||||
@@ -132,15 +142,22 @@ uv run main.py
|
||||
|
||||
或者请参阅官方文档 [通过源码部署 AstrBot](https://astrbot.app/deploy/astrbot/cli.html) 。
|
||||
|
||||
#### 桌面端 Electron 打包
|
||||
#### 系统包管理器安装
|
||||
|
||||
桌面端(Electron 打包,`pnpm` 工作流)构建流程请参阅:[`desktop/README.md`](desktop/README.md)。
|
||||
##### Arch Linux
|
||||
|
||||
```bash
|
||||
yay -S astrbot-git
|
||||
# 或者使用 paru
|
||||
paru -S astrbot-git
|
||||
```
|
||||
|
||||
## 支持的消息平台
|
||||
|
||||
**官方维护**
|
||||
|
||||
- QQ (官方平台 & OneBot)
|
||||
- QQ
|
||||
- OneBot v11 协议实现
|
||||
- Telegram
|
||||
- 企微应用 & 企微智能机器人
|
||||
- 微信客服 & 微信公众号
|
||||
@@ -148,10 +165,10 @@ uv run main.py
|
||||
- 钉钉
|
||||
- Slack
|
||||
- Discord
|
||||
- LINE
|
||||
- Satori
|
||||
- Misskey
|
||||
- Whatsapp (将支持)
|
||||
- LINE (将支持)
|
||||
|
||||
**社区维护**
|
||||
|
||||
@@ -171,6 +188,7 @@ uv run main.py
|
||||
- DeepSeek
|
||||
- Ollama (本地部署)
|
||||
- LM Studio (本地部署)
|
||||
- [AIHubMix](https://aihubmix.com/?aff=4bfH)
|
||||
- [优云智算](https://www.compshare.cn/?ytag=GPU_YY-gh_astrbot&referral_code=FV7DcGowN4hB5UuXKgpE74)
|
||||
- [302.AI](https://share.302.ai/rr1M3l)
|
||||
- [小马算力](https://www.tokenpony.cn/3YPyf)
|
||||
@@ -246,13 +264,23 @@ pre-commit install
|
||||
特别感谢所有 Contributors 和插件开发者对 AstrBot 的贡献 ❤️
|
||||
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot" />
|
||||
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot&max=200&columns=14" />
|
||||
</a>
|
||||
|
||||
此外,本项目的诞生离不开以下开源项目的帮助:
|
||||
|
||||
- [NapNeko/NapCatQQ](https://github.com/NapNeko/NapCatQQ) - 伟大的猫猫框架
|
||||
|
||||
开源项目友情链接:
|
||||
|
||||
- [NoneBot2](https://github.com/nonebot/nonebot2) - 优秀的 Python 异步 ChatBot 框架
|
||||
- [Koishi](https://github.com/koishijs/koishi) - 优秀的 Node.js ChatBot 框架
|
||||
- [MaiBot](https://github.com/Mai-with-u/MaiBot) - 优秀的拟人化 AI ChatBot
|
||||
- [nekro-agent](https://github.com/KroMiose/nekro-agent) - 优秀的 Agent ChatBot
|
||||
- [LangBot](https://github.com/langbot-app/LangBot) - 优秀的多平台 AI ChatBot
|
||||
- [ChatLuna](https://github.com/ChatLunaLab/chatluna) - 优秀的多平台 AI ChatBot Koishi 插件
|
||||
- [Operit AI](https://github.com/AAswordman/Operit) - 优秀的 AI 智能助手 Android APP
|
||||
|
||||
## ⭐ Star History
|
||||
|
||||
> [!TIP]
|
||||
@@ -264,8 +292,6 @@ pre-commit install
|
||||
|
||||
</div>
|
||||
|
||||
</details>
|
||||
|
||||
<div align="center">
|
||||
|
||||
_陪伴与能力从来不应该是对立面。我们希望创造的是一个既能理解情绪、给予陪伴,也能可靠完成工作的机器人。_
|
||||
@@ -273,3 +299,5 @@ _陪伴与能力从来不应该是对立面。我们希望创造的是一个既
|
||||
_私は、高性能ですから!_
|
||||
|
||||
<img src="https://files.astrbot.app/watashiwa-koseino-desukara.gif" width="100"/>
|
||||
|
||||
</div>
|
||||
|
||||
+46
-13
@@ -3,7 +3,6 @@
|
||||
<div align="center">
|
||||
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README.md">中文</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_en.md">English</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_ja.md">日本語</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_zh-TW.md">繁體中文</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_fr.md">Français</a> |
|
||||
@@ -38,7 +37,7 @@
|
||||
|
||||
AstrBot is an open-source all-in-one Agent chatbot platform that integrates with mainstream instant messaging apps. It provides reliable and scalable conversational AI infrastructure for individuals, developers, and teams. Whether you're building a personal AI companion, intelligent customer service, automation assistant, or enterprise knowledge base, AstrBot enables you to quickly build production-ready AI applications within your IM platform workflows.
|
||||
|
||||

|
||||

|
||||
|
||||
## Key Features
|
||||
|
||||
@@ -46,12 +45,29 @@ AstrBot is an open-source all-in-one Agent chatbot platform that integrates with
|
||||
2. ✨ AI LLM Conversations, Multimodal, Agent, MCP, Skills, Knowledge Base, Persona Settings, Auto Context Compression.
|
||||
3. 🤖 Supports integration with Dify, Alibaba Cloud Bailian, Coze, and other agent platforms.
|
||||
4. 🌐 Multi-Platform: QQ, WeChat Work, Feishu, DingTalk, WeChat Official Accounts, Telegram, Slack, and [more](#supported-messaging-platforms).
|
||||
5. 📦 Plugin Extensions with nearly 800 plugins available for one-click installation.
|
||||
5. 📦 Plugin Extensions with 1000+ plugins available for one-click installation.
|
||||
6. 🛡️ [Agent Sandbox](https://docs.astrbot.app/use/astrbot-agent-sandbox.html) for isolated, safe execution of code, shell calls, and session-level resource reuse.
|
||||
7. 💻 WebUI Support.
|
||||
8. 🌈 Web ChatUI Support with built-in agent sandbox and web search.
|
||||
9. 🌐 Internationalization (i18n) Support.
|
||||
|
||||
<br>
|
||||
|
||||
<table align="center">
|
||||
<tr align="center">
|
||||
<th>💙 Role-playing & Emotional Companionship</th>
|
||||
<th>✨ Proactive Agent</th>
|
||||
<th>🚀 General Agentic Capabilities</th>
|
||||
<th>🧩 1000+ Community Plugins</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><p align="center"><img width="984" height="1746" alt="99b587c5d35eea09d84f33e6cf6cfd4f" src="https://github.com/user-attachments/assets/89196061-3290-458d-b51f-afa178049f84" /></p></td>
|
||||
<td align="center"><p align="center"><img width="976" height="1612" alt="c449acd838c41d0915cc08a3824025b1" src="https://github.com/user-attachments/assets/f75368b4-e022-41dc-a9e0-131c3e73e32e" /></p></td>
|
||||
<td align="center"><p align="center"><img width="974" height="1732" alt="image" src="https://github.com/user-attachments/assets/e22a3968-87d7-4708-a7cd-e7f198c7c32e" /></p></td>
|
||||
<td align="center"><p align="center"><img width="976" height="1734" alt="image" src="https://github.com/user-attachments/assets/0952b395-6b4a-432a-8a50-c294b7f89750" /></p></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## Quick Start
|
||||
|
||||
#### Docker Deployment (Recommended 🥳)
|
||||
@@ -63,9 +79,30 @@ Please refer to the official documentation: [Deploy AstrBot with Docker](https:/
|
||||
#### uv Deployment
|
||||
|
||||
```bash
|
||||
uvx astrbot
|
||||
uv tool install astrbot
|
||||
astrbot
|
||||
```
|
||||
|
||||
#### System Package Manager Installation
|
||||
|
||||
##### Arch Linux
|
||||
|
||||
```bash
|
||||
yay -S astrbot-git
|
||||
# or use paru
|
||||
paru -S astrbot-git
|
||||
```
|
||||
|
||||
#### Desktop Application (Tauri)
|
||||
|
||||
Desktop repository: [AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop).
|
||||
|
||||
Supports multiple system architectures, direct installation, out-of-the-box experience. Ideal for beginners.
|
||||
|
||||
#### AstrBot Launcher
|
||||
|
||||
Quick deployment and multi-instance solution. Visit the [AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher) repository and find the latest release for your system.
|
||||
|
||||
#### BT-Panel Deployment
|
||||
|
||||
AstrBot has partnered with BT-Panel and is now available in their marketplace.
|
||||
@@ -117,10 +154,6 @@ uv run main.py
|
||||
|
||||
Or refer to the official documentation: [Deploy AstrBot from Source](https://astrbot.app/deploy/astrbot/cli.html).
|
||||
|
||||
#### Desktop Electron Build
|
||||
|
||||
For desktop build steps (Electron packaging, `pnpm` workflow), see [`desktop/README.md`](desktop/README.md).
|
||||
|
||||
## Supported Messaging Platforms
|
||||
|
||||
**Officially Maintained**
|
||||
@@ -135,8 +168,8 @@ For desktop build steps (Electron packaging, `pnpm` workflow), see [`desktop/REA
|
||||
- Discord
|
||||
- Satori
|
||||
- Misskey
|
||||
- LINE
|
||||
- WhatsApp (Coming Soon)
|
||||
- LINE (Coming Soon)
|
||||
|
||||
**Community Maintained**
|
||||
|
||||
@@ -159,7 +192,7 @@ For desktop build steps (Electron packaging, `pnpm` workflow), see [`desktop/REA
|
||||
- [CompShare](https://www.compshare.cn/?ytag=GPU_YY-gh_astrbot&referral_code=FV7DcGowN4hB5UuXKgpE74)
|
||||
- [302.AI](https://share.302.ai/rr1M3l)
|
||||
- [TokenPony](https://www.tokenpony.cn/3YPyf)
|
||||
- [SiliconFlow](https://docs.siliconflow.cn/cn/usecases/use-siliconcloud-in-astrbot)
|
||||
- [SiliconFlow](https://docs.siliconflow.cn/cn/usercases/use-siliconcloud-in-astrbot)
|
||||
- [PPIO Cloud](https://ppio.com/user/register?invited_by=AIOONE)
|
||||
- ModelScope
|
||||
- OneAPI
|
||||
@@ -231,7 +264,7 @@ pre-commit install
|
||||
Special thanks to all Contributors and plugin developers for their contributions to AstrBot ❤️
|
||||
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot" />
|
||||
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot&max=200&columns=14" />
|
||||
</a>
|
||||
|
||||
Additionally, the birth of this project would not have been possible without the help of the following open-source projects:
|
||||
@@ -249,10 +282,10 @@ Additionally, the birth of this project would not have been possible without the
|
||||
|
||||
</div>
|
||||
|
||||
</details>
|
||||
|
||||
<div align="center">
|
||||
|
||||
_Companionship and capability should never be at odds. What we aim to create is a robot that can understand emotions, provide genuine companionship, and reliably accomplish tasks._
|
||||
|
||||
_私は、高性能ですから!_
|
||||
|
||||
<img src="https://files.astrbot.app/watashiwa-koseino-desukara.gif" width="100"/>
|
||||
|
||||
+70
-26
@@ -1,9 +1,13 @@
|
||||

|
||||
|
||||
</p>
|
||||
|
||||
<div align="center">
|
||||
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README.md">中文</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_en.md">English</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_ja.md">日本語</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_zh-TW.md">繁體中文</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_ru.md">Русский</a>
|
||||
|
||||
<br>
|
||||
|
||||
<div>
|
||||
@@ -14,22 +18,17 @@
|
||||
<br>
|
||||
|
||||
<div>
|
||||
<img src="https://img.shields.io/github/v/release/AstrBotDevs/AstrBot?style=for-the-badge&color=76bad9" href="https://github.com/AstrBotDevs/AstrBot/releases/latest">
|
||||
<img src="https://img.shields.io/badge/python-3.10+-blue.svg?style=for-the-badge&color=76bad9" alt="python">
|
||||
<a href="https://hub.docker.com/r/soulter/astrbot"><img alt="Docker pull" src="https://img.shields.io/docker/pulls/soulter/astrbot.svg?style=for-the-badge&color=76bad9"/></a>
|
||||
<a href="https://qm.qq.com/cgi-bin/qm/qr?k=wtbaNx7EioxeaqS9z7RQWVXPIxg2zYr7&jump_from=webapi&authKey=vlqnv/AV2DbJEvGIcxdlNSpfxVy+8vVqijgreRdnVKOaydpc+YSw4MctmEbr0k5"><img alt="QQ_community" src="https://img.shields.io/badge/QQ群-775869627-purple?style=for-the-badge&color=76bad9"></a>
|
||||
<a href="https://t.me/+hAsD2Ebl5as3NmY1"><img alt="Telegram_community" src="https://img.shields.io/badge/Telegram-AstrBot-purple?style=for-the-badge&color=76bad9"></a>
|
||||
<img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fapi.soulter.top%2Fastrbot%2Fplugin-num&query=%24.result&suffix=%20plugins&style=for-the-badge&label=Marketplace&cacheSeconds=3600">
|
||||
<img src="https://img.shields.io/github/v/release/AstrBotDevs/AstrBot?color=76bad9" href="https://github.com/AstrBotDevs/AstrBot/releases/latest">
|
||||
<img src="https://img.shields.io/badge/python-3.10+-blue.svg" alt="python">
|
||||
<img src="https://deepwiki.com/badge.svg" href="https://deepwiki.com/AstrBotDevs/AstrBot">
|
||||
<a href="https://zread.ai/AstrBotDevs/AstrBot" target="_blank"><img src="https://img.shields.io/badge/Ask_Zread-_.svg?style=flat&color=00b0aa&labelColor=000000&logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9IjAgMCAxNiAxNiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTQuOTYxNTYgMS42MDAxSDIuMjQxNTZDMS44ODgxIDEuNjAwMSAxLjYwMTU2IDEuODg2NjQgMS42MDE1NiAyLjI0MDFWNC45NjAxQzEuNjAxNTYgNS4zMTM1NiAxLjg4ODEgNS42MDAxIDIuMjQxNTYgNS42MDAxSDQuOTYxNTZDNS4zMTUwMiA1LjYwMDEgNS42MDE1NiA1LjMxMzU2IDUuNjAxNTYgNC45NjAxVjIuMjQwMUM1LjYwMTU2IDEuODg2NjQgNS4zMTUwMiAxLjYwMDEgNC45NjE1NiAxLjYwMDFZIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik00Ljk2MTU2IDEwLjM5OTlIMi4yNDE1NkMxLjg4ODEgMTAuMzk5OSAxLjYwMTU2IDEwLjY4NjQgMS42MDE1NiAxMS4wMzk5VjEzLjc1OTlDMS42MDE1NiAxNC4xMTM0IDEuODg4MSAxNC4zOTk5IDIuMjQxNTYgMTQuMzk5OUg0Ljk2MTU2QzUuMzE1MDIgMTQuMzk5OSA1LjYwMTU2IDE0LjExMzQgNS42MDE1NiAxMy43NTk5VjExLjAzOTlDNS42MDE1NiAxMC42ODY0IDUuMzE1MDIgMTAuMzk5OSA0Ljk2MTU2IDEwLjM5OTlaIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik0xMy43NTg0IDEuNjAwMUgxMS4wMzg0QzEwLjY4NSAxLjYwMDEgMTAuMzk4NCAxLjg4NjY0IDEwLjM5ODQgMi4yNDAxVjQuOTYwMUMxMC4zOTg0IDUuMzEzNTYgMTAuNjg1IDUuNjAwMSAxMS4wMzg0IDUuNjAwMUgxMy43NTg0QzE0LjExMTkgNS42MDAxIDE0LjM5ODQgNS4zMTM1NiAxNC4zOTg0IDQuOTYwMVYyLjI0MDFDMTQuMzk4NCAxLjg4NjY0IDE0LjExMTkgMS42MDAxIDEzLjc1ODQgMS42MDAxWiIgZmlsbD0iI2ZmZiIvPgo8cGF0aCBkPSJNNCAxMkwxMiA0TDQgMTJaIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik00IDEyTDEyIDQiIHN0cm9rZT0iI2ZmZiIgc3Ryb2tlLXdpZHRoPSIxLjUiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIvPgo8L3N2Zz4K&logoColor=ffffff" alt="zread"/></a>
|
||||
<a href="https://hub.docker.com/r/soulter/astrbot"><img alt="Docker pull" src="https://img.shields.io/docker/pulls/soulter/astrbot.svg?color=76bad9"/></a>
|
||||
<img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fapi.soulter.top%2Fastrbot%2Fplugin-num&query=%24.result&suffix=%20&label=Marketplace&cacheSeconds=3600">
|
||||
<img src="https://gitcode.com/Soulter/AstrBot/star/badge.svg" href="https://gitcode.com/Soulter/AstrBot">
|
||||
</div>
|
||||
|
||||
<br>
|
||||
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README.md">中文</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_en.md">English</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_ja.md">日本語</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_zh-TW.md">繁體中文</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_ru.md">Русский</a>
|
||||
|
||||
<a href="https://astrbot.app/">Documentation</a> |
|
||||
<a href="https://blog.astrbot.app/">Blog</a> |
|
||||
<a href="https://astrbot.featurebase.app/roadmap">Feuille de route</a> |
|
||||
@@ -38,17 +37,36 @@
|
||||
|
||||
AstrBot est une plateforme de chatbot Agent tout-en-un open source qui s'intègre aux principales applications de messagerie instantanée. Elle fournit une infrastructure d'IA conversationnelle fiable et évolutive pour les particuliers, les développeurs et les équipes. Que vous construisiez un compagnon IA personnel, un service client intelligent, un assistant d'automatisation ou une base de connaissances d'entreprise, AstrBot vous permet de créer rapidement des applications d'IA prêtes pour la production dans les flux de travail de votre plateforme de messagerie.
|
||||
|
||||
<img width="1776" height="1080" alt="image" src="https://github.com/user-attachments/assets/00782c4c-4437-4d97-aabc-605e3738da5c" />
|
||||

|
||||
|
||||
## Fonctionnalités principales
|
||||
|
||||
1. 💯 Gratuit & Open Source.
|
||||
2. ✨ Conversations avec LLM IA, Multimodal, Agent, MCP, Base de connaissances, Paramètres de personnalité.
|
||||
3. 🤖 Prise en charge de l'intégration avec Dify, Alibaba Cloud Bailian, Coze et autres plateformes d'agents.
|
||||
4. 🌐 Multi-plateforme : QQ, WeChat Work, Feishu, DingTalk, Comptes officiels WeChat, Telegram, Slack, et [plus encore](#plateformes-de-messagerie-prises-en-charge).
|
||||
5. 📦 Extensions de plugins avec près de 800 plugins disponibles pour une installation en un clic.
|
||||
6. 💻 Support WebUI.
|
||||
7. 🌐 Support de l'internationalisation (i18n).
|
||||
2. ✨ Dialogue avec de grands modèles d'IA, multimodal, Agent, MCP, Skills, Base de connaissances, Paramétrage de personnalité, compression automatique des dialogues.
|
||||
3. 🤖 Prise en charge de l'accès aux plateformes d'Agents telles que Dify, Alibaba Cloud Bailian, Coze, etc.
|
||||
4. 🌐 Multiplateforme : supporte QQ, WeChat Enterprise, Feishu, DingTalk, Comptes officiels WeChat, Telegram, Slack et [plus encore](#plateformes-de-messagerie-prises-en-charge).
|
||||
5. 📦 Extension par plugins, avec plus de 1000 plugins déjà disponibles pour une installation en un clic.
|
||||
6. 🛡️ Environnement isolé [Agent Sandbox](https://docs.astrbot.app/use/astrbot-agent-sandbox.html) : exécution sécurisée de code, appels Shell et réutilisation des ressources au niveau de la session.
|
||||
7. 💻 Support WebUI.
|
||||
8. 🌈 Support Web ChatUI, avec sandbox d'agent intégrée, recherche web, etc.
|
||||
9. 🌐 Support de l'internationalisation (i18n).
|
||||
|
||||
<br>
|
||||
|
||||
<table align="center">
|
||||
<tr align="center">
|
||||
<th>💙 Jeux de rôle & Accompagnement émotionnel</th>
|
||||
<th>✨ Agent proactif</th>
|
||||
<th>🚀 Capacités agentiques générales</th>
|
||||
<th>🧩 1000+ Plugins de communauté</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><p align="center"><img width="984" height="1746" alt="99b587c5d35eea09d84f33e6cf6cfd4f" src="https://github.com/user-attachments/assets/89196061-3290-458d-b51f-afa178049f84" /></p></td>
|
||||
<td align="center"><p align="center"><img width="976" height="1612" alt="c449acd838c41d0915cc08a3824025b1" src="https://github.com/user-attachments/assets/f75368b4-e022-41dc-a9e0-131c3e73e32e" /></p></td>
|
||||
<td align="center"><p align="center"><img width="974" height="1732" alt="image" src="https://github.com/user-attachments/assets/e22a3968-87d7-4708-a7cd-e7f198c7c32e" /></p></td>
|
||||
<td align="center"><p align="center"><img width="976" height="1734" alt="image" src="https://github.com/user-attachments/assets/0952b395-6b4a-432a-8a50-c294b7f89750" /></p></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## Démarrage rapide
|
||||
|
||||
@@ -61,9 +79,20 @@ Veuillez consulter la documentation officielle : [Déployer AstrBot avec Docker]
|
||||
#### Déploiement uv
|
||||
|
||||
```bash
|
||||
uvx astrbot
|
||||
uv tool install astrbot
|
||||
astrbot
|
||||
```
|
||||
|
||||
#### Application de bureau (Tauri)
|
||||
|
||||
Dépôt de l'application de bureau : [AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop).
|
||||
|
||||
Prend en charge plusieurs architectures système, installation directe, prête à l'emploi. La solution de déploiement de bureau en un clic la plus adaptée aux débutants. Non recommandée pour les serveurs.
|
||||
|
||||
#### Déploiement en un clic avec le lanceur (AstrBot Launcher)
|
||||
|
||||
Déploiement rapide et solution multi-instances, isolation de l'environnement. Accédez au dépôt [AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher), trouvez le package d'installation correspondant à votre système sous la dernière version sur la page Releases.
|
||||
|
||||
#### Déploiement BT-Panel
|
||||
|
||||
AstrBot s'est associé à BT-Panel et est maintenant disponible sur leur marketplace.
|
||||
@@ -115,6 +144,16 @@ uv run main.py
|
||||
|
||||
Ou consultez la documentation officielle : [Déployer AstrBot depuis les sources](https://astrbot.app/deploy/astrbot/cli.html).
|
||||
|
||||
#### Installation via le gestionnaire de paquets du système
|
||||
|
||||
##### Arch Linux
|
||||
|
||||
```bash
|
||||
yay -S astrbot-git
|
||||
# ou utiliser paru
|
||||
paru -S astrbot-git
|
||||
```
|
||||
|
||||
## Plateformes de messagerie prises en charge
|
||||
|
||||
**Maintenues officiellement**
|
||||
@@ -129,8 +168,8 @@ Ou consultez la documentation officielle : [Déployer AstrBot depuis les sources
|
||||
- Discord
|
||||
- Satori
|
||||
- Misskey
|
||||
- LINE
|
||||
- WhatsApp (Bientôt disponible)
|
||||
- LINE (Bientôt disponible)
|
||||
|
||||
**Maintenues par la communauté**
|
||||
|
||||
@@ -153,7 +192,7 @@ Ou consultez la documentation officielle : [Déployer AstrBot depuis les sources
|
||||
- [CompShare](https://www.compshare.cn/?ytag=GPU_YY-gh_astrbot&referral_code=FV7DcGowN4hB5UuXKgpE74)
|
||||
- [302.AI](https://share.302.ai/rr1M3l)
|
||||
- [TokenPony](https://www.tokenpony.cn/3YPyf)
|
||||
- [SiliconFlow](https://docs.siliconflow.cn/cn/usecases/use-siliconcloud-in-astrbot)
|
||||
- [SiliconFlow](https://docs.siliconflow.cn/cn/usercases/use-siliconcloud-in-astrbot)
|
||||
- [PPIO Cloud](https://ppio.com/user/register?invited_by=AIOONE)
|
||||
- ModelScope
|
||||
- OneAPI
|
||||
@@ -223,7 +262,7 @@ pre-commit install
|
||||
Un grand merci à tous les contributeurs et développeurs de plugins pour leurs contributions à AstrBot ❤️
|
||||
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot" />
|
||||
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot&max=200&columns=14" />
|
||||
</a>
|
||||
|
||||
De plus, la naissance de ce projet n'aurait pas été possible sans l'aide des projets open source suivants :
|
||||
@@ -241,7 +280,12 @@ De plus, la naissance de ce projet n'aurait pas été possible sans l'aide des p
|
||||
|
||||
</div>
|
||||
|
||||
</details>
|
||||
<div align="center">
|
||||
|
||||
_La compagnie et la capacité ne devraient jamais être des opposés. Nous souhaitons créer un robot capable à la fois de comprendre les émotions, d'offrir de la présence, et d'accomplir des tâches de manière fiable._
|
||||
|
||||
_私は、高性能ですから!_
|
||||
|
||||
<img src="https://files.astrbot.app/watashiwa-koseino-desukara.gif" width="100"/>
|
||||
|
||||
</div>
|
||||
|
||||
+70
-25
@@ -1,9 +1,13 @@
|
||||

|
||||
|
||||
</p>
|
||||
|
||||
<div align="center">
|
||||
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README.md">中文</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_en.md">English</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_zh-TW.md">繁體中文</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_fr.md">Français</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_ru.md">Русский</a>
|
||||
|
||||
<br>
|
||||
|
||||
<div>
|
||||
@@ -14,22 +18,17 @@
|
||||
<br>
|
||||
|
||||
<div>
|
||||
<img src="https://img.shields.io/github/v/release/AstrBotDevs/AstrBot?style=for-the-badge&color=76bad9" href="https://github.com/AstrBotDevs/AstrBot/releases/latest">
|
||||
<img src="https://img.shields.io/badge/python-3.10+-blue.svg?style=for-the-badge&color=76bad9" alt="python">
|
||||
<a href="https://hub.docker.com/r/soulter/astrbot"><img alt="Docker pull" src="https://img.shields.io/docker/pulls/soulter/astrbot.svg?style=for-the-badge&color=76bad9"/></a>
|
||||
<a href="https://qm.qq.com/cgi-bin/qm/qr?k=wtbaNx7EioxeaqS9z7RQWVXPIxg2zYr7&jump_from=webapi&authKey=vlqnv/AV2DbJEvGIcxdlNSpfxVy+8vVqijgreRdnVKOaydpc+YSw4MctmEbr0k5"><img alt="QQ_community" src="https://img.shields.io/badge/QQ群-775869627-purple?style=for-the-badge&color=76bad9"></a>
|
||||
<a href="https://t.me/+hAsD2Ebl5as3NmY1"><img alt="Telegram_community" src="https://img.shields.io/badge/Telegram-AstrBot-purple?style=for-the-badge&color=76bad9"></a>
|
||||
<img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fapi.soulter.top%2Fastrbot%2Fplugin-num&query=%24.result&suffix=%E5%80%8B&style=for-the-badge&label=%E3%83%97%E3%83%A9%E3%82%B0%E3%82%A4%E3%83%B3&cacheSeconds=3600">
|
||||
<img src="https://img.shields.io/github/v/release/AstrBotDevs/AstrBot?color=76bad9" href="https://github.com/AstrBotDevs/AstrBot/releases/latest">
|
||||
<img src="https://img.shields.io/badge/python-3.10+-blue.svg" alt="python">
|
||||
<img src="https://deepwiki.com/badge.svg" href="https://deepwiki.com/AstrBotDevs/AstrBot">
|
||||
<a href="https://zread.ai/AstrBotDevs/AstrBot" target="_blank"><img src="https://img.shields.io/badge/Ask_Zread-_.svg?style=flat&color=00b0aa&labelColor=000000&logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9IjAgMCAxNiAxNiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTQuOTYxNTYgMS42MDAxSDIuMjQxNTZDMS44ODgxIDEuNjAwMSAxLjYwMTU2IDEuODg2NjQgMS42MDE1NiAyLjI0MDFWNC45NjAxQzEuNjAxNTYgNS4zMTM1NiAxLjg4ODEgNS42MDAxIDIuMjQxNTYgNS42MDAxSDQuOTYxNTZDNS4zMTUwMiA1LjYwMDEgNS42MDE1NiA1LjMxMzU2IDUuNjAxNTYgNC45NjAxVjIuMjQwMUM1LjYwMTU2IDEuODg2NjQgNS4zMTUwMiAxLjYwMDEgNC45NjE1NiAxLjYwMDFZIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik00Ljk2MTU2IDEwLjM5OTlIMi4yNDE1NkMxLjg4ODEgMTAuMzk5OSAxLjYwMTU2IDEwLjY4NjQgMS42MDE1NiAxMS4wMzk5VjEzLjc1OTlDMS42MDE1NiAxNC4xMTM0IDEuODg4MSAxNC4zOTk5IDIuMjQxNTYgMTQuMzk5OUg0LjYxNTZDNS4zMTUwMiAxNC4zOTk5IDUuNjAxNTYgMTQuMTEzNCA1LjYwMTU2IDEzLjc1OTlWMTEuMDM5OUM1LjYwMTU2IDEwLjY4NjQgNS4zMTUwMiAxMC4zOTk5IDQuOTYxNTYgMTAuMzk5OVoiIGZpbGw9IiNmZmYiLz4KPHBhdGggZD0iTTEzLjc1ODQgMS42MDAxSDExLjAzODRDMTAuNjg1IDEuNjAwMSAxMC4zOTg0IDEuODg2NjQgMTAuMzk4NCAyLjI0MDFWNC45NjAxQzEwLjM5ODQgNS4zMTM1NiAxMC42ODUgNS42MDAxIDExLjAzODQgNS42MDAxSDEzLjc1ODRDMTQuMTExOSA1LjYwMDEgMTQuMzk4NCA1LjMxMzU2IDE0LjM5ODQgNC45NjAxVjIuMjQwMUMxNC4zOTg0IDEuODg2NjQgMTQuMTExOSAxLjYwMDEgMTMuNzU4NCAxLjYwMDFZIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik00IDEyTDEyIDRMNCAxMlpFIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik00IDEyTDEyIDQiIHN0cm9rZT0iI2ZmZiIgc3Ryb2tlLXdpZHRoPSIxLjUiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIvPgo8L3N2Zz4K&logoColor=ffffff" alt="zread"/></a>
|
||||
<a href="https://hub.docker.com/r/soulter/astrbot"><img alt="Docker pull" src="https://img.shields.io/docker/pulls/soulter/astrbot.svg?color=76bad9"/></a>
|
||||
<img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fapi.soulter.top%2Fastrbot%2Fplugin-num&query=%24.result&suffix=%20&label=%E3%83%97%E3%83%A9%E3%82%B0%E3%82%A4%E3%83%B3%E3%83%9E%E3%83%BC%E3%82%B1%E3%83%83%E3%83%88&cacheSeconds=3600">
|
||||
<img src="https://gitcode.com/Soulter/AstrBot/star/badge.svg" href="https://gitcode.com/Soulter/AstrBot">
|
||||
</div>
|
||||
|
||||
<br>
|
||||
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README.md">中文</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_en.md">English</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_zh-TW.md">繁體中文</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_fr.md">Français</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_ru.md">Русский</a>
|
||||
|
||||
<a href="https://astrbot.app/">ドキュメント</a> |
|
||||
<a href="https://blog.astrbot.app/">Blog</a> |
|
||||
<a href="https://astrbot.featurebase.app/roadmap">ロードマップ</a> |
|
||||
@@ -38,17 +37,36 @@
|
||||
|
||||
AstrBot は、主要なインスタントメッセージングアプリと統合できるオープンソースのオールインワン Agent チャットボットプラットフォームです。個人、開発者、チームに信頼性が高くスケーラブルな会話型 AI インフラストラクチャを提供します。パーソナル AI コンパニオン、インテリジェントカスタマーサービス、オートメーションアシスタント、エンタープライズナレッジベースなど、AstrBot を使用すると、IM プラットフォームのワークフロー内で本番環境対応の AI アプリケーションを迅速に構築できます。
|
||||
|
||||
<img width="1776" height="1080" alt="image" src="https://github.com/user-attachments/assets/00782c4c-4437-4d97-aabc-605e3738da5c" />
|
||||

|
||||
|
||||
## 主な機能
|
||||
|
||||
1. 💯 無料 & オープンソース。
|
||||
2. ✨ AI 大規模言語モデル対話、マルチモーダル、Agent、MCP、ナレッジベース、ペルソナ設定。
|
||||
3. 🤖 Dify、Alibaba Cloud 百炼、Coze などの Agent プラットフォームとの統合をサポート。
|
||||
4. 🌐 マルチプラットフォーム:QQ、WeChat Work、Feishu、DingTalk、WeChat 公式アカウント、Telegram、Slack、[その他](#サポートされているメッセージプラットフォーム)。
|
||||
5. 📦 約800個のプラグインをワンクリックでインストール可能なプラグイン拡張機能。
|
||||
6. 💻 WebUI サポート。
|
||||
7. 🌐 国際化(i18n)サポート。
|
||||
2. ✨ AI大規模言語モデル対話、マルチモーダル、Agent、MCP、Skills、ナレッジベース、ペルソナ設定、対話の自動圧縮。
|
||||
3. 🤖 Dify、Alibaba Cloud Bailian(百煉)、Coze などのAgentプラットフォームへの接続をサポート。
|
||||
4. 🌐 マルチプラットフォーム:QQ、企業微信(WeCom)、飛書(Lark)、釘釘(DingTalk)、WeChat公式アカウント、Telegram、Slack、[その他](#サポートされているメッセージプラットフォーム)に対応。
|
||||
5. 📦 プラグイン拡張:1000を超える既存プラグインをワンクリックでインストール可能。
|
||||
6. 🛡️ 隔離環境[Agent Sandbox](https://docs.astrbot.app/use/astrbot-agent-sandbox.html):コードの安全な実行、Shell呼び出し、セッションレベルのリソース再利用。
|
||||
7. 💻 WebUI 対応。
|
||||
8. 🌈 Web ChatUI 対応:ChatUI内にAgent Sandboxやウェブ検索などを内蔵。
|
||||
9. 🌐 多言語対応(i18n)。
|
||||
|
||||
<br>
|
||||
|
||||
<table align="center">
|
||||
<tr align="center">
|
||||
<th>💙 ロールプレイ & 感情的な対話</th>
|
||||
<th>✨ プロアクティブ・エージェント (Proactive Agent)</th>
|
||||
<th>🚀 汎用 エージェント的能力</th>
|
||||
<th>🧩 1000+ コミュニティプラグイン</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><p align="center"><img width="984" height="1746" alt="99b587c5d35eea09d84f33e6cf6cfd4f" src="https://github.com/user-attachments/assets/89196061-3290-458d-b51f-afa178049f84" /></p></td>
|
||||
<td align="center"><p align="center"><img width="976" height="1612" alt="c449acd838c41d0915cc08a3824025b1" src="https://github.com/user-attachments/assets/f75368b4-e022-41dc-a9e0-131c3e73e32e" /></p></td>
|
||||
<td align="center"><p align="center"><img width="974" height="1732" alt="image" src="https://github.com/user-attachments/assets/e22a3968-87d7-4708-a7cd-e7f198c7c32e" /></p></td>
|
||||
<td align="center"><p align="center"><img width="976" height="1734" alt="image" src="https://github.com/user-attachments/assets/0952b395-6b4a-432a-8a50-c294b7f89750" /></p></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## クイックスタート
|
||||
|
||||
@@ -61,9 +79,20 @@ Docker / Docker Compose を使用した AstrBot のデプロイを推奨しま
|
||||
#### uv デプロイ
|
||||
|
||||
```bash
|
||||
uvx astrbot
|
||||
uv tool install astrbot
|
||||
astrbot
|
||||
```
|
||||
|
||||
#### デスクトップアプリのデプロイ(Tauri)
|
||||
|
||||
デスクトップアプリのリポジトリ [AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop)。
|
||||
|
||||
マルチシステムアーキテクチャをサポートし、インストールしてすぐに使用可能。初心者や手軽さを求める人に最適なワンクリックデスクトップデプロイソリューションです。サーバー環境での使用は推奨されません。
|
||||
|
||||
#### ランチャーによるワンクリックデプロイ(AstrBot Launcher)
|
||||
|
||||
迅速なデプロイとマルチインスタンス対応、環境の隔離が可能。[AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher) リポジトリにアクセスし、Releases ページから最新バージョンのシステム対応パッケージをダウンロードしてインストールしてください。
|
||||
|
||||
#### 宝塔パネルデプロイ
|
||||
|
||||
AstrBot は宝塔パネルと提携し、宝塔パネルに公開されています。
|
||||
@@ -115,6 +144,16 @@ uv run main.py
|
||||
|
||||
または、公式ドキュメント [ソースコードから AstrBot をデプロイ](https://astrbot.app/deploy/astrbot/cli.html) をご参照ください。
|
||||
|
||||
#### システムパッケージマネージャーでのインストール
|
||||
|
||||
##### Arch Linux
|
||||
|
||||
```bash
|
||||
yay -S astrbot-git
|
||||
# または paru を使用
|
||||
paru -S astrbot-git
|
||||
```
|
||||
|
||||
## サポートされているメッセージプラットフォーム
|
||||
|
||||
**公式メンテナンス**
|
||||
@@ -129,8 +168,8 @@ uv run main.py
|
||||
- Discord
|
||||
- Satori
|
||||
- Misskey
|
||||
- LINE
|
||||
- WhatsApp (近日対応予定)
|
||||
- LINE (近日対応予定)
|
||||
|
||||
**コミュニティメンテナンス**
|
||||
|
||||
@@ -224,7 +263,7 @@ pre-commit install
|
||||
AstrBot への貢献をしていただいたすべてのコントリビューターとプラグイン開発者に特別な感謝を ❤️
|
||||
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot" />
|
||||
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot&max=200&columns=14" />
|
||||
</a>
|
||||
|
||||
また、このプロジェクトの誕生は以下のオープンソースプロジェクトの助けなしには実現できませんでした:
|
||||
@@ -242,6 +281,12 @@ AstrBot への貢献をしていただいたすべてのコントリビュータ
|
||||
|
||||
</div>
|
||||
|
||||
</details>
|
||||
<div align="center">
|
||||
|
||||
_共感力と能力は決して対立するものではありません。私たちが目指すのは、感情を理解し、心の支えとなるだけでなく、確実に仕事をこなせるロボットの創造です。_
|
||||
|
||||
_私は、高性能ですから!_
|
||||
|
||||
<img src="https://files.astrbot.app/watashiwa-koseino-desukara.gif" width="100"/>
|
||||
|
||||
</div>
|
||||
|
||||
+73
-27
@@ -1,9 +1,13 @@
|
||||

|
||||
|
||||
</p>
|
||||
|
||||
<div align="center">
|
||||
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README.md">中文</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_en.md">English</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_ja.md">日本語</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_zh-TW.md">繁體中文</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_fr.md">Français</a>
|
||||
|
||||
<br>
|
||||
|
||||
<div>
|
||||
@@ -14,22 +18,17 @@
|
||||
<br>
|
||||
|
||||
<div>
|
||||
<img src="https://img.shields.io/github/v/release/AstrBotDevs/AstrBot?style=for-the-badge&color=76bad9" href="https://github.com/AstrBotDevs/AstrBot/releases/latest">
|
||||
<img src="https://img.shields.io/badge/python-3.10+-blue.svg?style=for-the-badge&color=76bad9" alt="python">
|
||||
<a href="https://hub.docker.com/r/soulter/astrbot"><img alt="Docker pull" src="https://img.shields.io/docker/pulls/soulter/astrbot.svg?style=for-the-badge&color=76bad9"/></a>
|
||||
<a href="https://qm.qq.com/cgi-bin/qm/qr?k=wtbaNx7EioxeaqS9z7RQWVXPIxg2zYr7&jump_from=webapi&authKey=vlqnv/AV2DbJEvGIcxdlNSpfxVy+8vVqijgreRdnVKOaydpc+YSw4MctmEbr0k5"><img alt="QQ_community" src="https://img.shields.io/badge/QQ群-775869627-purple?style=for-the-badge&color=76bad9"></a>
|
||||
<a href="https://t.me/+hAsD2Ebl5as3NmY1"><img alt="Telegram_community" src="https://img.shields.io/badge/Telegram-AstrBot-purple?style=for-the-badge&color=76bad9"></a>
|
||||
<img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fapi.soulter.top%2Fastrbot%2Fplugin-num&query=%24.result&suffix=%20%D0%BF%D0%BB%D0%B0%D0%B3%D0%B8%D0%BD%D0%BE%D0%B2&style=for-the-badge&label=%D0%9C%D0%B0%D0%B3%D0%B0%D0%B7%D0%B8%D0%BD&cacheSeconds=3600">
|
||||
<img src="https://img.shields.io/github/v/release/AstrBotDevs/AstrBot?color=76bad9" href="https://github.com/AstrBotDevs/AstrBot/releases/latest">
|
||||
<img src="https://img.shields.io/badge/python-3.10+-blue.svg" alt="python">
|
||||
<img src="https://deepwiki.com/badge.svg" href="https://deepwiki.com/AstrBotDevs/AstrBot">
|
||||
<a href="https://zread.ai/AstrBotDevs/AstrBot" target="_blank"><img src="https://img.shields.io/badge/Ask_Zread-_.svg?style=flat&color=00b0aa&labelColor=000000&logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9IjAgMCAxNiAxNiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTQuOTYxNTYgMS42MDAxSDIuMjQxNTZDMS44ODgxIDEuNjAwMSAxLjYwMTU2IDEuODg2NjQgMS42MDE1NiAyLjI0MDFWNC45NjAxQzEuNjAxNTYgNS4zMTM1NiAxLjg4ODEgNS42MDAxIDIuMjQxNTYgNS42MDAxSDQuOTYxNTZDNS4zMTUwMiA1LjYwMDEgNS42MDE1NiA1LjMxMzU2IDUuNjAxNTYgNC45NjAxVjIuMjQwMUM1LjYwMTU2IDEuODg2NjQgNS4zMTUwMiAxLjYwMDEgNC45NjE1NiAxLjYwMDFZIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik00Ljk2MTU2IDEwLjM5OTlIMi4yNDE1NkMxLjg4ODEgMTAuMzk5OSAxLjYwMTU2IDEwLjY4NjQgMS42MDE1NiAxMS4wMzk5VjEzLjc1OTlDMS42MDE1NiAxNC4xMTM0IDEuODg4MSAxNC4zOTk5IDIuMjQxNTYgMTQuMzk5OUg0Ljk2MTU2QzUuMzE1MDIgMTQuMzk5OSA1LjYwMTU2IDE0LjExMzQgNS42MDE1NiAxMy43NTk5VjExLjAzOTlDNS42MDE1NiAxMC42ODY0IDUuMzE1MDIgMTAuMzk5OSA0Ljk2MTU2IDEwLjM5OTlaIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik0xMy43NTg0IDEuNjAwMUgxMS4wMzg0QzEwLjY4NSAxLjYwMDEgMTAuMzk4NCAxLjg4NjY0IDEwLjM5ODQgMi4yNDAxVjQuOTYwMUMxMC4zOTg0IDUuMzEzNTYgMTAuNjg1IDUuNjAwMSAxMS4wMzg0IDUuNjAwMUgxMy43NTg0QzE0LjExMTkgNS42MDAxIDE0LjM5ODQgNS4zMTM1NiAxNC4zOTg0IDQuOTYwMVYyLjI0MDFDMTQuMzk4NCAxLjg4NjY0IDE0LjExMTkgMS42MDAxIDEzLjczODQgMS42MDAxWiIgZmlsbD0iI2ZmZiIvPgo8cGF0aCBkPSJNNCAxMkwxMiA0TDQgMTJaIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik00IDEyTDEyIDQiIHN0cm9rZT0iI2ZmZiIgc3Ryb2tlLXdpZHRoPSIxLjUiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIvPgo8L3N2Zz4K&logoColor=ffffff" alt="zread"/></a>
|
||||
<a href="https://hub.docker.com/r/soulter/astrbot"><img alt="Docker pull" src="https://img.shields.io/docker/pulls/soulter/astrbot.svg?color=76bad9"/></a>
|
||||
<img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fapi.soulter.top%2Fastrbot%2Fplugin-num&query=%24.result&suffix=%20&label=%D0%9C%D0%B0%D1%80%D0%BA%D0%B5%D1%82%D0%BF%D0%BB%D0%B5%D0%B9%D1%81&cacheSeconds=3600">
|
||||
<img src="https://gitcode.com/Soulter/AstrBot/star/badge.svg" href="https://gitcode.com/Soulter/AstrBot">
|
||||
</div>
|
||||
|
||||
<br>
|
||||
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README.md">中文</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_en.md">English</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_ja.md">日本語</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_zh-TW.md">繁體中文</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_fr.md">Français</a>
|
||||
|
||||
<a href="https://astrbot.app/">Документация</a> |
|
||||
<a href="https://blog.astrbot.app/">Блог</a> |
|
||||
<a href="https://astrbot.featurebase.app/roadmap">Дорожная карта</a> |
|
||||
@@ -38,17 +37,36 @@
|
||||
|
||||
AstrBot — это универсальная платформа Agent-чатботов с открытым исходным кодом, которая интегрируется с основными приложениями для обмена мгновенными сообщениями. Она предоставляет надёжную и масштабируемую инфраструктуру разговорного ИИ для частных лиц, разработчиков и команд. Будь то персональный ИИ-компаньон, интеллектуальная служба поддержки, автоматизированный помощник или корпоративная база знаний — AstrBot позволяет быстро создавать готовые к использованию ИИ-приложения в рабочих процессах вашей платформы обмена сообщениями.
|
||||
|
||||
<img width="1776" height="1080" alt="image" src="https://github.com/user-attachments/assets/00782c4c-4437-4d97-aabc-605e3738da5c" />
|
||||

|
||||
|
||||
## Основные возможности
|
||||
|
||||
1. 💯 Бесплатно и с открытым исходным кодом.
|
||||
2. ✨ ИИ-диалоги с LLM, мультимодальность, Agent, MCP, база знаний, настройки личности.
|
||||
3. 🤖 Поддержка интеграции с Dify, Alibaba Cloud Bailian, Coze и другими платформами агентов.
|
||||
4. 🌐 Мультиплатформенность: QQ, WeChat Work, Feishu, DingTalk, официальные аккаунты WeChat, Telegram, Slack и [другие](#поддерживаемые-платформы-обмена-сообщениями).
|
||||
5. 📦 Расширения плагинов с почти 800 плагинами, доступными для установки в один клик.
|
||||
6. 💻 Поддержка WebUI.
|
||||
7. 🌐 Поддержка интернационализации (i18n).
|
||||
1. 💯 Бесплатно & Открытый исходный код.
|
||||
2. ✨ Диалоги с ИИ-моделями, мультимодальность, Agent, MCP, Skills, База знаний, Настройка личности, автоматическое сжатие диалогов.
|
||||
3. 🤖 Поддержка интеграции с платформами Agents, такими как Dify, Alibaba Cloud Bailian, Coze и др.
|
||||
4. 🌐 Мультиплатформенность: поддержка QQ, WeChat для предприятий, Feishu, DingTalk, публичных аккаунтов WeChat, Telegram, Slack и [других](#Поддерживаемые-платформы-обмена-сообщениями).
|
||||
5. 📦 Расширение плагинами: доступно более 1000 плагинов для установки в один клик.
|
||||
6. 🛡️ Изолированная среда[Agent Sandbox](https://docs.astrbot.app/use/astrbot-agent-sandbox.html): безопасное выполнение любого кода, вызов Shell, повторное использование ресурсов на уровне сессии.
|
||||
7. 💻 Поддержка WebUI.
|
||||
8. 🌈 Поддержка Web ChatUI: встроенная песочница агента, веб-поиск и др.
|
||||
9. 🌐 Поддержка интернационализации (i18n).
|
||||
|
||||
<br>
|
||||
|
||||
<table align="center">
|
||||
<tr align="center">
|
||||
<th>💙 Ролевые игры & Эмоциональная поддержка</th>
|
||||
<th>✨ Проактивный Агент (Agent)</th>
|
||||
<th>🚀 Универсальные возможности Агента</th>
|
||||
<th>🧩 1000+ плагинов сообщества</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><p align="center"><img width="984" height="1746" alt="99b587c5d35eea09d84f33e6cf6cfd4f" src="https://github.com/user-attachments/assets/89196061-3290-458d-b51f-afa178049f84" /></p></td>
|
||||
<td align="center"><p align="center"><img width="976" height="1612" alt="c449acd838c41d0915cc08a3824025b1" src="https://github.com/user-attachments/assets/f75368b4-e022-41dc-a9e0-131c3e73e32e" /></p></td>
|
||||
<td align="center"><p align="center"><img width="974" height="1732" alt="image" src="https://github.com/user-attachments/assets/e22a3968-87d7-4708-a7cd-e7f198c7c32e" /></p></td>
|
||||
<td align="center"><p align="center"><img width="976" height="1734" alt="image" src="https://github.com/user-attachments/assets/0952b395-6b4a-432a-8a50-c294b7f89750" /></p></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## Быстрый старт
|
||||
|
||||
@@ -61,9 +79,20 @@ AstrBot — это универсальная платформа Agent-чатб
|
||||
#### Развёртывание uv
|
||||
|
||||
```bash
|
||||
uvx astrbot
|
||||
uv tool install astrbot
|
||||
astrbot
|
||||
```
|
||||
|
||||
#### Десктопное приложение (Tauri)
|
||||
|
||||
Репозиторий десктопного приложения: [AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop).
|
||||
|
||||
Поддерживает различные системные архитектуры, устанавливается напрямую, "из коробки", лучшее настольное решение в один клик для новичков и тех, кто ценит простоту. Не рекомендуется для серверных сценариев.
|
||||
|
||||
#### Установка в один клик через лаунчер (AstrBot Launcher)
|
||||
|
||||
Быстрое развёртывание и поддержка нескольких экземпляров, изоляция среды. Перейдите в репозиторий [AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher), найдите последнюю версию на странице Releases и установите соответствующий пакет для вашей системы.
|
||||
|
||||
#### Развёртывание BT-Panel
|
||||
|
||||
AstrBot в партнёрстве с BT-Panel теперь доступен на их маркетплейсе.
|
||||
@@ -115,6 +144,16 @@ uv run main.py
|
||||
|
||||
Или см. официальную документацию: [Развёртывание AstrBot из исходного кода](https://astrbot.app/deploy/astrbot/cli.html).
|
||||
|
||||
#### Установка через системный пакетный менеджер
|
||||
|
||||
##### Arch Linux
|
||||
|
||||
```bash
|
||||
yay -S astrbot-git
|
||||
# или используйте paru
|
||||
paru -S astrbot-git
|
||||
```
|
||||
|
||||
## Поддерживаемые платформы обмена сообщениями
|
||||
|
||||
**Официально поддерживаемые**
|
||||
@@ -129,8 +168,9 @@ uv run main.py
|
||||
- Discord
|
||||
- Satori
|
||||
- Misskey
|
||||
- LINE
|
||||
- WhatsApp (Скоро)
|
||||
- LINE (Скоро)
|
||||
|
||||
|
||||
**Поддерживаемые сообществом**
|
||||
|
||||
@@ -153,7 +193,7 @@ uv run main.py
|
||||
- [CompShare](https://www.compshare.cn/?ytag=GPU_YY-gh_astrbot&referral_code=FV7DcGowN4hB5UuXKgpE74)
|
||||
- [302.AI](https://share.302.ai/rr1M3l)
|
||||
- [TokenPony](https://www.tokenpony.cn/3YPyf)
|
||||
- [SiliconFlow](https://docs.siliconflow.cn/cn/usecases/use-siliconcloud-in-astrbot)
|
||||
- [SiliconFlow](https://docs.siliconflow.cn/cn/usercases/use-siliconcloud-in-astrbot)
|
||||
- [PPIO Cloud](https://ppio.com/user/register?invited_by=AIOONE)
|
||||
- ModelScope
|
||||
- OneAPI
|
||||
@@ -223,7 +263,7 @@ pre-commit install
|
||||
Особая благодарность всем контрибьюторам и разработчикам плагинов за их вклад в AstrBot ❤️
|
||||
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot" />
|
||||
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot&max=200&columns=14" />
|
||||
</a>
|
||||
|
||||
Кроме того, рождение этого проекта было бы невозможно без помощи следующих проектов с открытым исходным кодом:
|
||||
@@ -235,13 +275,19 @@ pre-commit install
|
||||
> [!TIP]
|
||||
> Если этот проект помог вам в жизни или работе, или если вас интересует его будущее развитие, пожалуйста, поставьте проекту звезду. Это движущая сила поддержки этого проекта с открытым исходным кодом <3
|
||||
|
||||
|
||||
<div align="center">
|
||||
|
||||
[](https://star-history.com/#astrbotdevs/astrbot&Date)
|
||||
|
||||
</div>
|
||||
|
||||
</details>
|
||||
<div align="center">
|
||||
|
||||
_Сопровождение и способности никогда не должны быть противоположностями. Мы стремимся создать робота, который сможет как понимать эмоции, оказывать душевную поддержку, так и надёжно выполнять работу._
|
||||
|
||||
_私は、高性能ですから!_
|
||||
|
||||
<img src="https://files.astrbot.app/watashiwa-koseino-desukara.gif" width="100"/>
|
||||
|
||||
</div>
|
||||
|
||||
+70
-25
@@ -1,9 +1,13 @@
|
||||

|
||||
|
||||
</p>
|
||||
|
||||
<div align="center">
|
||||
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README.md">简体中文</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_en.md">English</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_ja.md">日本語</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_fr.md">Français</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_ru.md">Русский</a>
|
||||
|
||||
<br>
|
||||
|
||||
<div>
|
||||
@@ -14,22 +18,17 @@
|
||||
<br>
|
||||
|
||||
<div>
|
||||
<img src="https://img.shields.io/github/v/release/AstrBotDevs/AstrBot?style=for-the-badge&color=76bad9" href="https://github.com/AstrBotDevs/AstrBot/releases/latest">
|
||||
<img src="https://img.shields.io/badge/python-3.10+-blue.svg?style=for-the-badge&color=76bad9" alt="python">
|
||||
<a href="https://hub.docker.com/r/soulter/astrbot"><img alt="Docker pull" src="https://img.shields.io/docker/pulls/soulter/astrbot.svg?style=for-the-badge&color=76bad9"/></a>
|
||||
<a href="https://qm.qq.com/cgi-bin/qm/qr?k=wtbaNx7EioxeaqS9z7RQWVXPIxg2zYr7&jump_from=webapi&authKey=vlqnv/AV2DbJEvGIcxdlNSpfxVy+8vVqijgreRdnVKOaydpc+YSw4MctmEbr0k5"><img alt="QQ_community" src="https://img.shields.io/badge/QQ群-775869627-purple?style=for-the-badge&color=76bad9"></a>
|
||||
<a href="https://t.me/+hAsD2Ebl5as3NmY1"><img alt="Telegram_community" src="https://img.shields.io/badge/Telegram-AstrBot-purple?style=for-the-badge&color=76bad9"></a>
|
||||
<img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fapi.soulter.top%2Fastrbot%2Fplugin-num&query=%24.result&suffix=%E5%80%8B&style=for-the-badge&label=%E6%8F%92%E4%BB%B6%E5%B8%82%E5%A0%B4&cacheSeconds=3600">
|
||||
<img src="https://img.shields.io/github/v/release/AstrBotDevs/AstrBot?color=76bad9" href="https://github.com/AstrBotDevs/AstrBot/releases/latest">
|
||||
<img src="https://img.shields.io/badge/python-3.10+-blue.svg" alt="python">
|
||||
<img src="https://deepwiki.com/badge.svg" href="https://deepwiki.com/AstrBotDevs/AstrBot">
|
||||
<a href="https://zread.ai/AstrBotDevs/AstrBot" target="_blank"><img src="https://img.shields.io/badge/Ask_Zread-_.svg?style=flat&color=00b0aa&labelColor=000000&logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9IjAgMCAxNiAxNiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTQuOTYxNTYgMS42MDAxSDIuMjQxNTZDMS44ODgxIDEuNjAwMSAxLjYwMTU2IDEuODg2NjQgMS42MDE1NiAyLjI0MDFWNC45NjAxQzEuNjAxNTYgNS4zMTM1NiAxLjg4ODEgNS42MDAxIDIuMjQxNTYgNS42MDAxSDQuOTYxNTZDNS4zMTUwMiA1LjYwMDEgNS42MDE1NiA1LjMxMzU2IDUuNjAxNTYgNC45NjAxVjIuMjQwMUM1LjYwMTU2IDEuODg2NjQgNS4zMTUwMiAxLjYwMDEgNC45NjE1NiAxLjYwMDFaIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik00Ljk2MTU2IDEwLjM5OTlIMi4yNDE1NkMxLjg4ODEgMTAuMzk5OSAxLjYwMTU2IDEwLjY4NjQgMS42MDE1NiAxMS4wMzk5VjEzLjc1OTlDMS42MDE1NiAxNC4xMTM0IDEuODg4MSAxNC4zOTk5IDIuMjQxNTYgMTQuMzk5OUg0Ljk2MTU2QzUuMzE1MDIgMTQuMzk5OSA1LjYwMTU2IDE0LjExMzQgNS42MDE1NiAxMy43NTk5VjExLjAzOTlDNS42MDE1NiAxMC42ODY0IDUuMzE1MDIgMTAuMzk5OSA0Ljk2MTU2IDEwLjM5OTlaIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik0xMy43NTg0IDEuNjAwMUgxMS4wMzg0QzEwLjY4NSAxLjYwMDEgMTAuMzk4NCAxLjg4NjY0IDEwLjM5ODQgMi4yNDAxVjQuOTYwMUMxMC4zOTg0IDUuMzEzNTYgMTAuNjg1IDUuNjAwMSAxMS4wMzg0IDUuNjAwMUgxMy43NTg0QzE0LjExMTkgNS42MDAxIDE0LjM5ODQgNS4zMTM1NiAxNC4zOTg0IDQuOTYwMVYyLjI0MDFDMTQuMzk4NCAxLjg4NjY0IDE0LjExMTkgMS42MDAxIDEzLjc1ODQgMS42MDAxWiIgZmlsbD0iI2ZmZiIvPgo8cGF0aCBkPSJNNCAxMkwxMiA0TDQgMTJaIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik00IDEyTDEyIDQiIHN0cm9rZT0iI2ZmZiIgc3Ryb2tlLXdpZHRoPSIxLjUiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIvPgo8L3N2Zz4K&logoColor=ffffff" alt="zread"/></a>
|
||||
<a href="https://hub.docker.com/r/soulter/astrbot"><img alt="Docker pull" src="https://img.shields.io/docker/pulls/soulter/astrbot.svg?color=76bad9"/></a>
|
||||
<img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fapi.soulter.top%2Fastrbot%2Fplugin-num&query=%24.result&suffix=%E5%80%8B&label=%E6%8F%92%E4%BB%B6%E5%B8%82%E5%A0%B4&cacheSeconds=3600">
|
||||
<img src="https://gitcode.com/Soulter/AstrBot/star/badge.svg" href="https://gitcode.com/Soulter/AstrBot">
|
||||
</div>
|
||||
|
||||
<br>
|
||||
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README.md">简体中文</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_en.md">English</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_ja.md">日本語</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_fr.md">Français</a> |
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_ru.md">Русский</a>
|
||||
|
||||
<a href="https://astrbot.app/">文件</a> |
|
||||
<a href="https://blog.astrbot.app/">Blog</a> |
|
||||
<a href="https://astrbot.featurebase.app/roadmap">路線圖</a> |
|
||||
@@ -38,17 +37,36 @@
|
||||
|
||||
AstrBot 是一個開源的一站式 Agent 聊天機器人平台,可接入主流即時通訊軟體,為個人、開發者和團隊打造可靠、可擴展的對話式智慧基礎設施。無論是個人 AI 夥伴、智慧客服、自動化助手,還是企業知識庫,AstrBot 都能在您的即時通訊軟體平台的工作流程中快速構建生產可用的 AI 應用程式。
|
||||
|
||||
<img width="1776" height="1080" alt="image" src="https://github.com/user-attachments/assets/00782c4c-4437-4d97-aabc-605e3738da5c" />
|
||||

|
||||
|
||||
## 主要功能
|
||||
|
||||
1. 💯 免費 & 開源。
|
||||
2. ✨ AI 大型模型對話,多模態,Agent,MCP,知識庫,人格設定。
|
||||
3. 🤖 支援接入 Dify、阿里雲百煉、Coze 等智慧體平台。
|
||||
4. 🌐 多平台:QQ、企業微信、飛書、釘釘、微信公眾號、Telegram、Slack 以及[更多](#支援的訊息平台)。
|
||||
5. 📦 外掛擴充,已有近 800 個外掛可一鍵安裝。
|
||||
6. 💻 WebUI 支援。
|
||||
7. 🌐 國際化(i18n)支援。
|
||||
2. ✨ AI 大模型對話,多模態,Agent,MCP,Skills,知識庫,人格設定,自動壓縮對話。
|
||||
3. 🤖 支援接入 Dify、阿里雲百煉、Coze 等智慧體 (Agent) 平台。
|
||||
4. 🌐 多平台,支援 QQ、企業微信、飛書、釘釘、微信公眾號、Telegram、Slack 以及[更多](#支援的訊息平台)。
|
||||
5. 📦 插件擴展,已有 1000+ 個插件可一鍵安裝。
|
||||
6. 🛡️ [Agent Sandbox](https://docs.astrbot.app/use/astrbot-agent-sandbox.html) 隔離化環境,安全地執行任何代碼、調用 Shell、會話級資源複用。
|
||||
7. 💻 WebUI 支援。
|
||||
8. 🌈 Web ChatUI 支援,ChatUI 內置代理沙盒 (Agent Sandbox)、網頁搜尋等。
|
||||
9. 🌐 國際化(i18n)支援。
|
||||
|
||||
<br>
|
||||
|
||||
<table align="center">
|
||||
<tr align="center">
|
||||
<th>💙 角色扮演 & 情感陪伴</th>
|
||||
<th>✨ 主動式 Agent</th>
|
||||
<th>🚀 通用 Agentic 能力</th>
|
||||
<th>🧩 1000+ 社區外掛程式</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><p align="center"><img width="984" height="1746" alt="99b587c5d35eea09d84f33e6cf6cfd4f" src="https://github.com/user-attachments/assets/89196061-3290-458d-b51f-afa178049f84" /></p></td>
|
||||
<td align="center"><p align="center"><img width="976" height="1612" alt="c449acd838c41d0915cc08a3824025b1" src="https://github.com/user-attachments/assets/f75368b4-e022-41dc-a9e0-131c3e73e32e" /></p></td>
|
||||
<td align="center"><p align="center"><img width="974" height="1732" alt="image" src="https://github.com/user-attachments/assets/e22a3968-87d7-4708-a7cd-e7f198c7c32e" /></p></td>
|
||||
<td align="center"><p align="center"><img width="976" height="1734" alt="image" src="https://github.com/user-attachments/assets/0952b395-6b4a-432a-8a50-c294b7f89750" /></p></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## 快速開始
|
||||
|
||||
@@ -61,9 +79,20 @@ AstrBot 是一個開源的一站式 Agent 聊天機器人平台,可接入主
|
||||
#### uv 部署
|
||||
|
||||
```bash
|
||||
uvx astrbot
|
||||
uv tool install astrbot
|
||||
astrbot
|
||||
```
|
||||
|
||||
#### 桌面應用部署(Tauri)
|
||||
|
||||
桌面應用倉庫 [AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop)。
|
||||
|
||||
支援多系統架構,安裝包直接安裝,開箱即用,最適合新手和懶人的一鍵桌面部署方案,不推薦伺服器場景。
|
||||
|
||||
#### 啟動器一鍵部署(AstrBot Launcher)
|
||||
|
||||
快速部署和多開方案,實現環境隔離,進入 [AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher) 倉庫,在 Releases 頁最新版本下找到對應的系統安裝包安裝即可。
|
||||
|
||||
#### 寶塔面板部署
|
||||
|
||||
AstrBot 與寶塔面板合作,已上架至寶塔面板。
|
||||
@@ -115,6 +144,16 @@ uv run main.py
|
||||
|
||||
或者請參閱官方文件 [透過原始碼部署 AstrBot](https://astrbot.app/deploy/astrbot/cli.html)。
|
||||
|
||||
#### 系統套件管理員安裝
|
||||
|
||||
##### Arch Linux
|
||||
|
||||
```bash
|
||||
yay -S astrbot-git
|
||||
# 或者使用 paru
|
||||
paru -S astrbot-git
|
||||
```
|
||||
|
||||
## 支援的訊息平台
|
||||
|
||||
**官方維護**
|
||||
@@ -129,8 +168,9 @@ uv run main.py
|
||||
- Discord
|
||||
- Satori
|
||||
- Misskey
|
||||
- LINE
|
||||
- Whatsapp(即將支援)
|
||||
- LINE(即將支援)
|
||||
|
||||
|
||||
**社群維護**
|
||||
|
||||
@@ -223,7 +263,7 @@ pre-commit install
|
||||
特別感謝所有 Contributors 和外掛開發者對 AstrBot 的貢獻 ❤️
|
||||
|
||||
<a href="https://github.com/AstrBotDevs/AstrBot/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot" />
|
||||
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot&max=200&columns=14" />
|
||||
</a>
|
||||
|
||||
此外,本專案的誕生離不開以下開源專案的幫助:
|
||||
@@ -241,7 +281,12 @@ pre-commit install
|
||||
|
||||
</div>
|
||||
|
||||
</details>
|
||||
<div align="center">
|
||||
|
||||
_陪伴與能力從來不應該是對立面。我們希望創造的是一個既能理解情緒、給予陪伴,也能可靠完成工作的機器人。_
|
||||
|
||||
_私は、高性能ですから!_
|
||||
|
||||
<img src="https://files.astrbot.app/watashiwa-koseino-desukara.gif" width="100"/>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -24,6 +24,9 @@ from astrbot.core.star.register import (
|
||||
register_on_llm_tool_respond as on_llm_tool_respond,
|
||||
)
|
||||
from astrbot.core.star.register import register_on_platform_loaded as on_platform_loaded
|
||||
from astrbot.core.star.register import register_on_plugin_error as on_plugin_error
|
||||
from astrbot.core.star.register import register_on_plugin_loaded as on_plugin_loaded
|
||||
from astrbot.core.star.register import register_on_plugin_unloaded as on_plugin_unloaded
|
||||
from astrbot.core.star.register import register_on_using_llm_tool as on_using_llm_tool
|
||||
from astrbot.core.star.register import (
|
||||
register_on_waiting_llm_request as on_waiting_llm_request,
|
||||
@@ -52,6 +55,9 @@ __all__ = [
|
||||
"on_decorating_result",
|
||||
"on_llm_request",
|
||||
"on_llm_response",
|
||||
"on_plugin_error",
|
||||
"on_plugin_loaded",
|
||||
"on_plugin_unloaded",
|
||||
"on_platform_loaded",
|
||||
"on_waiting_llm_request",
|
||||
"permission_type",
|
||||
|
||||
@@ -4,6 +4,7 @@ from astrbot.api import sp, star
|
||||
from astrbot.api.event import AstrMessageEvent, MessageEventResult
|
||||
from astrbot.core.platform.astr_message_event import MessageSession
|
||||
from astrbot.core.platform.message_type import MessageType
|
||||
from astrbot.core.utils.active_event_registry import active_event_registry
|
||||
|
||||
from .utils.rst_scene import RstScene
|
||||
|
||||
@@ -62,6 +63,7 @@ class ConversationCommands:
|
||||
|
||||
agent_runner_type = cfg["provider_settings"]["agent_runner_type"]
|
||||
if agent_runner_type in THIRD_PARTY_AGENT_RUNNER_KEY:
|
||||
active_event_registry.stop_all(umo, exclude=message)
|
||||
await sp.remove_async(
|
||||
scope="umo",
|
||||
scope_id=umo,
|
||||
@@ -86,6 +88,8 @@ class ConversationCommands:
|
||||
)
|
||||
return
|
||||
|
||||
active_event_registry.stop_all(umo, exclude=message)
|
||||
|
||||
await self.context.conversation_manager.update_conversation(
|
||||
umo,
|
||||
cid,
|
||||
@@ -98,6 +102,30 @@ class ConversationCommands:
|
||||
|
||||
message.set_result(MessageEventResult().message(ret))
|
||||
|
||||
async def stop(self, message: AstrMessageEvent) -> None:
|
||||
"""停止当前会话正在运行的 Agent"""
|
||||
cfg = self.context.get_config(umo=message.unified_msg_origin)
|
||||
agent_runner_type = cfg["provider_settings"]["agent_runner_type"]
|
||||
umo = message.unified_msg_origin
|
||||
|
||||
if agent_runner_type in THIRD_PARTY_AGENT_RUNNER_KEY:
|
||||
stopped_count = active_event_registry.stop_all(umo, exclude=message)
|
||||
else:
|
||||
stopped_count = active_event_registry.request_agent_stop_all(
|
||||
umo,
|
||||
exclude=message,
|
||||
)
|
||||
|
||||
if stopped_count > 0:
|
||||
message.set_result(
|
||||
MessageEventResult().message(
|
||||
f"已请求停止 {stopped_count} 个运行中的任务。"
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
message.set_result(MessageEventResult().message("当前会话没有运行中的任务。"))
|
||||
|
||||
async def his(self, message: AstrMessageEvent, page: int = 1) -> None:
|
||||
"""查看对话记录"""
|
||||
if not self.context.get_using_provider(message.unified_msg_origin):
|
||||
@@ -221,6 +249,7 @@ class ConversationCommands:
|
||||
cfg = self.context.get_config(umo=message.unified_msg_origin)
|
||||
agent_runner_type = cfg["provider_settings"]["agent_runner_type"]
|
||||
if agent_runner_type in THIRD_PARTY_AGENT_RUNNER_KEY:
|
||||
active_event_registry.stop_all(message.unified_msg_origin, exclude=message)
|
||||
await sp.remove_async(
|
||||
scope="umo",
|
||||
scope_id=message.unified_msg_origin,
|
||||
@@ -229,6 +258,7 @@ class ConversationCommands:
|
||||
message.set_result(MessageEventResult().message("已创建新对话。"))
|
||||
return
|
||||
|
||||
active_event_registry.stop_all(message.unified_msg_origin, exclude=message)
|
||||
cpersona = await self._get_current_persona_id(message.unified_msg_origin)
|
||||
cid = await self.context.conversation_manager.new_conversation(
|
||||
message.unified_msg_origin,
|
||||
@@ -321,7 +351,8 @@ class ConversationCommands:
|
||||
|
||||
async def del_conv(self, message: AstrMessageEvent) -> None:
|
||||
"""删除当前对话"""
|
||||
cfg = self.context.get_config(umo=message.unified_msg_origin)
|
||||
umo = message.unified_msg_origin
|
||||
cfg = self.context.get_config(umo=umo)
|
||||
is_unique_session = cfg["platform_settings"]["unique_session"]
|
||||
if message.get_group_id() and not is_unique_session and message.role != "admin":
|
||||
# 群聊,没开独立会话,发送人不是管理员
|
||||
@@ -334,18 +365,17 @@ class ConversationCommands:
|
||||
|
||||
agent_runner_type = cfg["provider_settings"]["agent_runner_type"]
|
||||
if agent_runner_type in THIRD_PARTY_AGENT_RUNNER_KEY:
|
||||
active_event_registry.stop_all(umo, exclude=message)
|
||||
await sp.remove_async(
|
||||
scope="umo",
|
||||
scope_id=message.unified_msg_origin,
|
||||
scope_id=umo,
|
||||
key=THIRD_PARTY_AGENT_RUNNER_KEY[agent_runner_type],
|
||||
)
|
||||
message.set_result(MessageEventResult().message("重置对话成功。"))
|
||||
return
|
||||
|
||||
session_curr_cid = (
|
||||
await self.context.conversation_manager.get_curr_conversation_id(
|
||||
message.unified_msg_origin,
|
||||
)
|
||||
await self.context.conversation_manager.get_curr_conversation_id(umo)
|
||||
)
|
||||
|
||||
if not session_curr_cid:
|
||||
@@ -356,8 +386,10 @@ class ConversationCommands:
|
||||
)
|
||||
return
|
||||
|
||||
active_event_registry.stop_all(umo, exclude=message)
|
||||
|
||||
await self.context.conversation_manager.delete_conversation(
|
||||
message.unified_msg_origin,
|
||||
umo,
|
||||
session_curr_cid,
|
||||
)
|
||||
|
||||
|
||||
@@ -132,6 +132,11 @@ class Main(star.Star):
|
||||
"""重置 LLM 会话"""
|
||||
await self.conversation_c.reset(message)
|
||||
|
||||
@filter.command("stop")
|
||||
async def stop(self, message: AstrMessageEvent) -> None:
|
||||
"""停止当前会话中正在运行的 Agent"""
|
||||
await self.conversation_c.stop(message)
|
||||
|
||||
@filter.permission_type(filter.PermissionType.ADMIN)
|
||||
@filter.command("model")
|
||||
async def model_ls(
|
||||
|
||||
@@ -70,7 +70,7 @@ class Main(star.Star):
|
||||
header = HEADERS
|
||||
header.update({"User-Agent": random.choice(USER_AGENTS)})
|
||||
async with aiohttp.ClientSession(trust_env=True) as session:
|
||||
async with session.get(url, headers=header, timeout=6) as response:
|
||||
async with session.get(url, headers=header) as response:
|
||||
html = await response.text(encoding="utf-8")
|
||||
doc = Document(html)
|
||||
ret = doc.summary(html_partial=True)
|
||||
@@ -151,7 +151,6 @@ class Main(star.Star):
|
||||
url,
|
||||
json=payload,
|
||||
headers=header,
|
||||
timeout=6,
|
||||
) as response:
|
||||
if response.status != 200:
|
||||
reason = await response.text()
|
||||
@@ -183,7 +182,6 @@ class Main(star.Star):
|
||||
url,
|
||||
json=payload,
|
||||
headers=header,
|
||||
timeout=6,
|
||||
) as response:
|
||||
if response.status != 200:
|
||||
reason = await response.text()
|
||||
@@ -265,7 +263,7 @@ class Main(star.Star):
|
||||
"transport": "sse",
|
||||
"url": f"http://appbuilder.baidu.com/v2/ai_search/mcp/sse?api_key={key}",
|
||||
"headers": {},
|
||||
"timeout": 30,
|
||||
"timeout": 600,
|
||||
},
|
||||
)
|
||||
self.baidu_initialized = True
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "4.15.0"
|
||||
__version__ = "4.18.1"
|
||||
|
||||
@@ -44,6 +44,19 @@ class HandoffTool(FunctionTool, Generic[TContext]):
|
||||
"type": "string",
|
||||
"description": "The input to be handed off to another agent. This should be a clear and concise request or task.",
|
||||
},
|
||||
"image_urls": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Optional: An array of image sources (public HTTP URLs or local file paths) used as references in multimodal tasks such as video generation.",
|
||||
},
|
||||
"background_task": {
|
||||
"type": "boolean",
|
||||
"description": (
|
||||
"Defaults to false. "
|
||||
"Set to true if the task may take noticeable time, involves external tools, or the user does not need to wait. "
|
||||
"Use false only for quick, immediate tasks."
|
||||
),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,8 @@ class ContextWrapper(Generic[TContext]):
|
||||
messages: list[Message] = Field(default_factory=list)
|
||||
"""This field stores the llm message context for the agent run, agent runners will maintain this field automatically."""
|
||||
tool_call_timeout: int = 60 # Default tool call timeout in seconds
|
||||
tool_call_approval: dict[str, Any] = Field(default_factory=dict)
|
||||
"""Tool call approval runtime configuration."""
|
||||
|
||||
|
||||
NoContext = ContextWrapper[None]
|
||||
|
||||
@@ -10,7 +10,7 @@ from astrbot.core.provider.entities import (
|
||||
LLMResponse,
|
||||
ProviderRequest,
|
||||
)
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
|
||||
from astrbot.core.utils.io import download_file
|
||||
|
||||
from ...hooks import BaseAgentRunHooks
|
||||
@@ -291,8 +291,8 @@ class DifyAgentRunner(BaseAgentRunner[TContext]):
|
||||
return Comp.Image(file=item["url"], url=item["url"])
|
||||
case "audio":
|
||||
# 仅支持 wav
|
||||
temp_dir = os.path.join(get_astrbot_data_path(), "temp")
|
||||
path = os.path.join(temp_dir, f"{item['filename']}.wav")
|
||||
temp_dir = get_astrbot_temp_path()
|
||||
path = os.path.join(temp_dir, f"dify_{item['filename']}.wav")
|
||||
await download_file(item["url"], path)
|
||||
return Comp.Image(file=item["url"], url=item["url"])
|
||||
case "video":
|
||||
|
||||
@@ -37,6 +37,10 @@ from ..hooks import BaseAgentRunHooks
|
||||
from ..message import AssistantMessageSegment, Message, ToolCallMessageSegment
|
||||
from ..response import AgentResponseData, AgentStats
|
||||
from ..run_context import ContextWrapper, TContext
|
||||
from ..tool_call_approval import (
|
||||
ToolCallApprovalContext,
|
||||
request_tool_call_approval,
|
||||
)
|
||||
from ..tool_executor import BaseFunctionToolExecutor
|
||||
from .base import AgentResponse, AgentState, BaseAgentRunner
|
||||
|
||||
@@ -91,6 +95,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
custom_token_counter: TokenCounter | None = None,
|
||||
custom_compressor: ContextCompressor | None = None,
|
||||
tool_schema_mode: str | None = "full",
|
||||
fallback_providers: list[Provider] | None = None,
|
||||
**kwargs: T.Any,
|
||||
) -> None:
|
||||
self.req = request
|
||||
@@ -120,11 +125,24 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
self.context_manager = ContextManager(self.context_config)
|
||||
|
||||
self.provider = provider
|
||||
self.fallback_providers: list[Provider] = []
|
||||
seen_provider_ids: set[str] = {str(provider.provider_config.get("id", ""))}
|
||||
for fallback_provider in fallback_providers or []:
|
||||
fallback_id = str(fallback_provider.provider_config.get("id", ""))
|
||||
if fallback_provider is provider:
|
||||
continue
|
||||
if fallback_id and fallback_id in seen_provider_ids:
|
||||
continue
|
||||
self.fallback_providers.append(fallback_provider)
|
||||
if fallback_id:
|
||||
seen_provider_ids.add(fallback_id)
|
||||
self.final_llm_resp = None
|
||||
self._state = AgentState.IDLE
|
||||
self.tool_executor = tool_executor
|
||||
self.agent_hooks = agent_hooks
|
||||
self.run_context = run_context
|
||||
self._stop_requested = False
|
||||
self._aborted = False
|
||||
|
||||
# These two are used for tool schema mode handling
|
||||
# We now have two modes:
|
||||
@@ -166,16 +184,19 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
self.stats = AgentStats()
|
||||
self.stats.start_time = time.time()
|
||||
|
||||
async def _iter_llm_responses(self) -> T.AsyncGenerator[LLMResponse, None]:
|
||||
async def _iter_llm_responses(
|
||||
self, *, include_model: bool = True
|
||||
) -> T.AsyncGenerator[LLMResponse, None]:
|
||||
"""Yields chunks *and* a final LLMResponse."""
|
||||
payload = {
|
||||
"contexts": self.run_context.messages, # list[Message]
|
||||
"func_tool": self.req.func_tool,
|
||||
"model": self.req.model, # NOTE: in fact, this arg is None in most cases
|
||||
"session_id": self.req.session_id,
|
||||
"extra_user_content_parts": self.req.extra_user_content_parts, # list[ContentPart]
|
||||
}
|
||||
|
||||
if include_model:
|
||||
# For primary provider we keep explicit model selection if provided.
|
||||
payload["model"] = self.req.model
|
||||
if self.streaming:
|
||||
stream = self.provider.text_chat_stream(**payload)
|
||||
async for resp in stream: # type: ignore
|
||||
@@ -183,6 +204,83 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
else:
|
||||
yield await self.provider.text_chat(**payload)
|
||||
|
||||
async def _iter_llm_responses_with_fallback(
|
||||
self,
|
||||
) -> T.AsyncGenerator[LLMResponse, None]:
|
||||
"""Wrap _iter_llm_responses with provider fallback handling."""
|
||||
candidates = [self.provider, *self.fallback_providers]
|
||||
total_candidates = len(candidates)
|
||||
last_exception: Exception | None = None
|
||||
last_err_response: LLMResponse | None = None
|
||||
|
||||
for idx, candidate in enumerate(candidates):
|
||||
candidate_id = candidate.provider_config.get("id", "<unknown>")
|
||||
is_last_candidate = idx == total_candidates - 1
|
||||
if idx > 0:
|
||||
logger.warning(
|
||||
"Switched from %s to fallback chat provider: %s",
|
||||
self.provider.provider_config.get("id", "<unknown>"),
|
||||
candidate_id,
|
||||
)
|
||||
self.provider = candidate
|
||||
has_stream_output = False
|
||||
try:
|
||||
async for resp in self._iter_llm_responses(include_model=idx == 0):
|
||||
if resp.is_chunk:
|
||||
has_stream_output = True
|
||||
yield resp
|
||||
continue
|
||||
|
||||
if (
|
||||
resp.role == "err"
|
||||
and not has_stream_output
|
||||
and (not is_last_candidate)
|
||||
):
|
||||
last_err_response = resp
|
||||
logger.warning(
|
||||
"Chat Model %s returns error response, trying fallback to next provider.",
|
||||
candidate_id,
|
||||
)
|
||||
break
|
||||
|
||||
yield resp
|
||||
return
|
||||
|
||||
if has_stream_output:
|
||||
return
|
||||
except Exception as exc: # noqa: BLE001
|
||||
last_exception = exc
|
||||
logger.warning(
|
||||
"Chat Model %s request error: %s",
|
||||
candidate_id,
|
||||
exc,
|
||||
exc_info=True,
|
||||
)
|
||||
continue
|
||||
|
||||
if last_err_response:
|
||||
yield last_err_response
|
||||
return
|
||||
if last_exception:
|
||||
yield LLMResponse(
|
||||
role="err",
|
||||
completion_text=(
|
||||
"All chat models failed: "
|
||||
f"{type(last_exception).__name__}: {last_exception}"
|
||||
),
|
||||
)
|
||||
return
|
||||
yield LLMResponse(
|
||||
role="err",
|
||||
completion_text="All available chat models are unavailable.",
|
||||
)
|
||||
|
||||
def _simple_print_message_role(self, tag: str = ""):
|
||||
roles = []
|
||||
for message in self.run_context.messages:
|
||||
roles.append(message.role)
|
||||
logger.debug(f"{tag} RunCtx.messages -> [{len(roles)}] {','.join(roles)}")
|
||||
|
||||
@override
|
||||
async def step(self):
|
||||
"""Process a single step of the agent.
|
||||
@@ -203,11 +301,13 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
|
||||
# do truncate and compress
|
||||
token_usage = self.req.conversation.token_usage if self.req.conversation else 0
|
||||
self._simple_print_message_role("[BefCompact]")
|
||||
self.run_context.messages = await self.context_manager.process(
|
||||
self.run_context.messages, trusted_token_usage=token_usage
|
||||
)
|
||||
self._simple_print_message_role("[AftCompact]")
|
||||
|
||||
async for llm_response in self._iter_llm_responses():
|
||||
async for llm_response in self._iter_llm_responses_with_fallback():
|
||||
if llm_response.is_chunk:
|
||||
# update ttft
|
||||
if self.stats.time_to_first_token == 0:
|
||||
@@ -234,6 +334,14 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
),
|
||||
),
|
||||
)
|
||||
if self._stop_requested:
|
||||
llm_resp_result = LLMResponse(
|
||||
role="assistant",
|
||||
completion_text="[SYSTEM: User actively interrupted the response generation. Partial output before interruption is preserved.]",
|
||||
reasoning_content=llm_response.reasoning_content,
|
||||
reasoning_signature=llm_response.reasoning_signature,
|
||||
)
|
||||
break
|
||||
continue
|
||||
llm_resp_result = llm_response
|
||||
|
||||
@@ -245,6 +353,48 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
break # got final response
|
||||
|
||||
if not llm_resp_result:
|
||||
if self._stop_requested:
|
||||
llm_resp_result = LLMResponse(role="assistant", completion_text="")
|
||||
else:
|
||||
return
|
||||
|
||||
if self._stop_requested:
|
||||
logger.info("Agent execution was requested to stop by user.")
|
||||
llm_resp = llm_resp_result
|
||||
if llm_resp.role != "assistant":
|
||||
llm_resp = LLMResponse(
|
||||
role="assistant",
|
||||
completion_text="[SYSTEM: User actively interrupted the response generation. Partial output before interruption is preserved.]",
|
||||
)
|
||||
self.final_llm_resp = llm_resp
|
||||
self._aborted = True
|
||||
self._transition_state(AgentState.DONE)
|
||||
self.stats.end_time = time.time()
|
||||
|
||||
parts = []
|
||||
if llm_resp.reasoning_content or llm_resp.reasoning_signature:
|
||||
parts.append(
|
||||
ThinkPart(
|
||||
think=llm_resp.reasoning_content,
|
||||
encrypted=llm_resp.reasoning_signature,
|
||||
)
|
||||
)
|
||||
if llm_resp.completion_text:
|
||||
parts.append(TextPart(text=llm_resp.completion_text))
|
||||
if parts:
|
||||
self.run_context.messages.append(
|
||||
Message(role="assistant", content=parts)
|
||||
)
|
||||
|
||||
try:
|
||||
await self.agent_hooks.on_agent_done(self.run_context, llm_resp)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in on_agent_done hook: {e}", exc_info=True)
|
||||
|
||||
yield AgentResponse(
|
||||
type="aborted",
|
||||
data=AgentResponseData(chain=MessageChain(type="aborted")),
|
||||
)
|
||||
return
|
||||
|
||||
# 处理 LLM 响应
|
||||
@@ -263,6 +413,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
),
|
||||
),
|
||||
)
|
||||
return
|
||||
|
||||
if not llm_resp.tools_call_name:
|
||||
# 如果没有工具调用,转换到完成状态
|
||||
@@ -512,6 +663,41 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
# 如果没有 handler(如 MCP 工具),使用所有参数
|
||||
valid_params = func_tool_args
|
||||
|
||||
approval_cfg = self.run_context.tool_call_approval
|
||||
if approval_cfg.get("enable", False):
|
||||
event = getattr(self.run_context.context, "event", None)
|
||||
if event is None:
|
||||
tool_call_result_blocks.append(
|
||||
ToolCallMessageSegment(
|
||||
role="tool",
|
||||
tool_call_id=func_tool_id,
|
||||
content=(
|
||||
f"error: tool call approval is enabled, but event context is unavailable for `{func_tool_name}`."
|
||||
),
|
||||
),
|
||||
)
|
||||
continue
|
||||
approval_result = await request_tool_call_approval(
|
||||
config=approval_cfg,
|
||||
ctx=ToolCallApprovalContext(
|
||||
event=event,
|
||||
tool_name=func_tool_name,
|
||||
tool_args=valid_params,
|
||||
tool_call_id=func_tool_id,
|
||||
),
|
||||
)
|
||||
if not approval_result.approved:
|
||||
tool_call_result_blocks.append(
|
||||
ToolCallMessageSegment(
|
||||
role="tool",
|
||||
tool_call_id=func_tool_id,
|
||||
content=approval_result.to_tool_result_text(
|
||||
func_tool_name
|
||||
),
|
||||
),
|
||||
)
|
||||
continue
|
||||
|
||||
try:
|
||||
await self.agent_hooks.on_tool_start(
|
||||
self.run_context,
|
||||
@@ -753,5 +939,11 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
"""检查 Agent 是否已完成工作"""
|
||||
return self._state in (AgentState.DONE, AgentState.ERROR)
|
||||
|
||||
def request_stop(self) -> None:
|
||||
self._stop_requested = True
|
||||
|
||||
def was_aborted(self) -> bool:
|
||||
return self._aborted
|
||||
|
||||
def get_final_llm_resp(self) -> LLMResponse | None:
|
||||
return self.final_llm_resp
|
||||
|
||||
@@ -285,6 +285,9 @@ class ToolSet:
|
||||
prop_value = convert_schema(value)
|
||||
if "default" in prop_value:
|
||||
del prop_value["default"]
|
||||
# see #5217
|
||||
if "additionalProperties" in prop_value:
|
||||
del prop_value["additionalProperties"]
|
||||
properties[key] = prop_value
|
||||
|
||||
if properties:
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
import string
|
||||
import typing as T
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
|
||||
from astrbot import logger
|
||||
from astrbot.core.message.message_event_result import MessageChain
|
||||
from astrbot.core.platform.astr_message_event import AstrMessageEvent
|
||||
from astrbot.core.utils.session_waiter import (
|
||||
FILTERS,
|
||||
DefaultSessionFilter,
|
||||
SessionController,
|
||||
SessionWaiter,
|
||||
)
|
||||
|
||||
ApprovalReason = T.Literal[
|
||||
"approved",
|
||||
"rejected",
|
||||
"timeout",
|
||||
"unsupported_strategy",
|
||||
"error",
|
||||
]
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ToolCallApprovalContext:
|
||||
event: AstrMessageEvent
|
||||
tool_name: str
|
||||
tool_args: dict[str, T.Any]
|
||||
tool_call_id: str
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ToolCallApprovalResult:
|
||||
approved: bool
|
||||
reason: ApprovalReason
|
||||
detail: str = ""
|
||||
|
||||
def to_tool_result_text(self, tool_name: str) -> str:
|
||||
if self.approved:
|
||||
return f"tool call approval passed: {tool_name}"
|
||||
if self.reason == "timeout":
|
||||
return (
|
||||
f"error: tool call approval timed out for `{tool_name}`. "
|
||||
"The tool call was cancelled."
|
||||
)
|
||||
if self.reason == "unsupported_strategy":
|
||||
return (
|
||||
f"error: tool call approval strategy is unsupported for `{tool_name}`. "
|
||||
"The tool call was cancelled."
|
||||
)
|
||||
if self.reason == "error":
|
||||
return (
|
||||
f"error: tool call approval failed for `{tool_name}` ({self.detail}). "
|
||||
"The tool call was cancelled."
|
||||
)
|
||||
return (
|
||||
f"error: user rejected tool call approval for `{tool_name}`. "
|
||||
"The tool call was cancelled."
|
||||
)
|
||||
|
||||
|
||||
class BaseToolCallApprovalStrategy(ABC):
|
||||
@property
|
||||
@abstractmethod
|
||||
def name(self) -> str: ...
|
||||
|
||||
@abstractmethod
|
||||
async def request(
|
||||
self,
|
||||
ctx: ToolCallApprovalContext,
|
||||
config: dict[str, T.Any],
|
||||
) -> ToolCallApprovalResult: ...
|
||||
|
||||
|
||||
class DynamicCodeApprovalStrategy(BaseToolCallApprovalStrategy):
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "dynamic_code"
|
||||
|
||||
async def request(
|
||||
self,
|
||||
ctx: ToolCallApprovalContext,
|
||||
config: dict[str, T.Any],
|
||||
) -> ToolCallApprovalResult:
|
||||
timeout_seconds = _safe_int(config.get("timeout", 60), default=60, minimum=1)
|
||||
dynamic_cfg = config.get("dynamic_code", {})
|
||||
if not isinstance(dynamic_cfg, dict):
|
||||
dynamic_cfg = {}
|
||||
code_length = _safe_int(dynamic_cfg.get("code_length", 6), default=6, minimum=4)
|
||||
case_sensitive = bool(dynamic_cfg.get("case_sensitive", False))
|
||||
|
||||
code = "".join(secrets.choice(string.digits) for _ in range(code_length))
|
||||
|
||||
await ctx.event.send(
|
||||
MessageChain().message(
|
||||
"Tool call needs your approval before execution.\n"
|
||||
f"Tool: `{ctx.tool_name}`\n"
|
||||
f"Approval code: `{code}`\n"
|
||||
"Please send this code to continue. "
|
||||
"Any other message will cancel this tool call."
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
result = await _wait_for_code_input(
|
||||
event=ctx.event,
|
||||
expected_code=code,
|
||||
timeout=timeout_seconds,
|
||||
case_sensitive=case_sensitive,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error(
|
||||
"Tool call approval failed unexpectedly for %s: %s",
|
||||
ctx.tool_name,
|
||||
exc,
|
||||
exc_info=True,
|
||||
)
|
||||
return ToolCallApprovalResult(
|
||||
approved=False,
|
||||
reason="error",
|
||||
detail=str(exc),
|
||||
)
|
||||
|
||||
if not result.approved:
|
||||
if result.reason == "timeout":
|
||||
await ctx.event.send(
|
||||
MessageChain().message(
|
||||
f"Tool call `{ctx.tool_name}` approval timed out. This call was cancelled."
|
||||
)
|
||||
)
|
||||
else:
|
||||
await ctx.event.send(
|
||||
MessageChain().message(
|
||||
f"Tool call `{ctx.tool_name}` was cancelled."
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
_STRATEGY_REGISTRY: dict[str, BaseToolCallApprovalStrategy] = {}
|
||||
|
||||
|
||||
def register_tool_call_approval_strategy(
|
||||
strategy: BaseToolCallApprovalStrategy,
|
||||
) -> None:
|
||||
_STRATEGY_REGISTRY[strategy.name] = strategy
|
||||
|
||||
|
||||
def _register_builtin_strategies() -> None:
|
||||
register_tool_call_approval_strategy(DynamicCodeApprovalStrategy())
|
||||
|
||||
|
||||
_register_builtin_strategies()
|
||||
|
||||
|
||||
async def request_tool_call_approval(
|
||||
*,
|
||||
config: dict[str, T.Any] | None,
|
||||
ctx: ToolCallApprovalContext,
|
||||
) -> ToolCallApprovalResult:
|
||||
if not config or not bool(config.get("enable", False)):
|
||||
return ToolCallApprovalResult(approved=True, reason="approved")
|
||||
|
||||
strategy_name = (
|
||||
str(config.get("strategy", "dynamic_code")).strip() or "dynamic_code"
|
||||
)
|
||||
strategy = _STRATEGY_REGISTRY.get(strategy_name)
|
||||
if not strategy:
|
||||
logger.warning("Unsupported tool call approval strategy: %s", strategy_name)
|
||||
return ToolCallApprovalResult(
|
||||
approved=False,
|
||||
reason="unsupported_strategy",
|
||||
detail=strategy_name,
|
||||
)
|
||||
return await strategy.request(ctx, config)
|
||||
|
||||
|
||||
async def _wait_for_code_input(
|
||||
*,
|
||||
event: AstrMessageEvent,
|
||||
expected_code: str,
|
||||
timeout: int,
|
||||
case_sensitive: bool,
|
||||
) -> ToolCallApprovalResult:
|
||||
session_filter = DefaultSessionFilter()
|
||||
FILTERS.append(session_filter)
|
||||
waiter = SessionWaiter(
|
||||
session_filter=session_filter,
|
||||
session_id=event.unified_msg_origin,
|
||||
record_history_chains=False,
|
||||
)
|
||||
|
||||
async def _handler(
|
||||
controller: SessionController, incoming: AstrMessageEvent
|
||||
) -> None:
|
||||
raw_input = (incoming.message_str or "").strip()
|
||||
if _is_code_match(
|
||||
expected=expected_code,
|
||||
actual=raw_input,
|
||||
case_sensitive=case_sensitive,
|
||||
):
|
||||
if not controller.future.done():
|
||||
controller.future.set_result(
|
||||
ToolCallApprovalResult(approved=True, reason="approved"),
|
||||
)
|
||||
else:
|
||||
if not controller.future.done():
|
||||
controller.future.set_result(
|
||||
ToolCallApprovalResult(
|
||||
approved=False,
|
||||
reason="rejected",
|
||||
detail=raw_input,
|
||||
)
|
||||
)
|
||||
controller.stop()
|
||||
|
||||
try:
|
||||
result = await waiter.register_wait(handler=_handler, timeout=timeout)
|
||||
except TimeoutError:
|
||||
return ToolCallApprovalResult(approved=False, reason="timeout")
|
||||
|
||||
if isinstance(result, ToolCallApprovalResult):
|
||||
return result
|
||||
return ToolCallApprovalResult(
|
||||
approved=False,
|
||||
reason="error",
|
||||
detail=f"Invalid approval result type: {type(result).__name__}",
|
||||
)
|
||||
|
||||
|
||||
def _is_code_match(*, expected: str, actual: str, case_sensitive: bool) -> bool:
|
||||
if case_sensitive:
|
||||
return actual == expected
|
||||
return actual.casefold() == expected.casefold()
|
||||
|
||||
|
||||
def _safe_int(value: T.Any, *, default: int, minimum: int) -> int:
|
||||
try:
|
||||
parsed = int(value)
|
||||
if parsed < minimum:
|
||||
return minimum
|
||||
return parsed
|
||||
except Exception: # noqa: BLE001
|
||||
return default
|
||||
@@ -20,6 +20,10 @@ from astrbot.core.provider.provider import TTSProvider
|
||||
AgentRunner = ToolLoopAgentRunner[AstrAgentContext]
|
||||
|
||||
|
||||
def _should_stop_agent(astr_event) -> bool:
|
||||
return astr_event.is_stopped() or bool(astr_event.get_extra("agent_stop_requested"))
|
||||
|
||||
|
||||
async def run_agent(
|
||||
agent_runner: AgentRunner,
|
||||
max_step: int = 30,
|
||||
@@ -48,10 +52,28 @@ async def run_agent(
|
||||
)
|
||||
)
|
||||
|
||||
stop_watcher = asyncio.create_task(
|
||||
_watch_agent_stop_signal(agent_runner, astr_event),
|
||||
)
|
||||
try:
|
||||
async for resp in agent_runner.step():
|
||||
if astr_event.is_stopped():
|
||||
if _should_stop_agent(astr_event):
|
||||
agent_runner.request_stop()
|
||||
|
||||
if resp.type == "aborted":
|
||||
if not stop_watcher.done():
|
||||
stop_watcher.cancel()
|
||||
try:
|
||||
await stop_watcher
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
astr_event.set_extra("agent_user_aborted", True)
|
||||
astr_event.set_extra("agent_stop_requested", False)
|
||||
return
|
||||
|
||||
if _should_stop_agent(astr_event):
|
||||
continue
|
||||
|
||||
if resp.type == "tool_call_result":
|
||||
msg_chain = resp.data["chain"]
|
||||
|
||||
@@ -120,6 +142,12 @@ async def run_agent(
|
||||
# display the reasoning content only when configured
|
||||
continue
|
||||
yield resp.data["chain"] # MessageChain
|
||||
if not stop_watcher.done():
|
||||
stop_watcher.cancel()
|
||||
try:
|
||||
await stop_watcher
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
if agent_runner.done():
|
||||
# send agent stats to webchat
|
||||
if astr_event.get_platform_name() == "webchat":
|
||||
@@ -133,6 +161,12 @@ async def run_agent(
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
if "stop_watcher" in locals() and not stop_watcher.done():
|
||||
stop_watcher.cancel()
|
||||
try:
|
||||
await stop_watcher
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
logger.error(traceback.format_exc())
|
||||
|
||||
err_msg = f"\n\nAstrBot 请求失败。\n错误类型: {type(e).__name__}\n错误信息: {e!s}\n\n请在平台日志查看和分享错误详情。\n"
|
||||
@@ -155,6 +189,14 @@ async def run_agent(
|
||||
return
|
||||
|
||||
|
||||
async def _watch_agent_stop_signal(agent_runner: AgentRunner, astr_event) -> None:
|
||||
while not agent_runner.done():
|
||||
if _should_stop_agent(astr_event):
|
||||
agent_runner.request_stop()
|
||||
return
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
|
||||
async def run_live_agent(
|
||||
agent_runner: AgentRunner,
|
||||
tts_provider: TTSProvider | None = None,
|
||||
|
||||
@@ -45,6 +45,13 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
|
||||
|
||||
"""
|
||||
if isinstance(tool, HandoffTool):
|
||||
is_bg = tool_args.pop("background_task", False)
|
||||
if is_bg:
|
||||
async for r in cls._execute_handoff_background(
|
||||
tool, run_context, **tool_args
|
||||
):
|
||||
yield r
|
||||
return
|
||||
async for r in cls._execute_handoff(tool, run_context, **tool_args):
|
||||
yield r
|
||||
return
|
||||
@@ -92,6 +99,7 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
|
||||
**tool_args,
|
||||
):
|
||||
input_ = tool_args.get("input")
|
||||
image_urls = tool_args.get("image_urls")
|
||||
|
||||
# make toolset for the agent
|
||||
tools = tool.agent.tools
|
||||
@@ -136,16 +144,98 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
|
||||
event=event,
|
||||
chat_provider_id=prov_id,
|
||||
prompt=input_,
|
||||
image_urls=image_urls,
|
||||
system_prompt=tool.agent.instructions,
|
||||
tools=toolset,
|
||||
contexts=contexts,
|
||||
max_steps=30,
|
||||
run_hooks=tool.agent.run_hooks,
|
||||
stream=ctx.get_config().get("provider_settings", {}).get("stream", False),
|
||||
)
|
||||
yield mcp.types.CallToolResult(
|
||||
content=[mcp.types.TextContent(type="text", text=llm_resp.completion_text)]
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def _execute_handoff_background(
|
||||
cls,
|
||||
tool: HandoffTool,
|
||||
run_context: ContextWrapper[AstrAgentContext],
|
||||
**tool_args,
|
||||
):
|
||||
"""Execute a handoff as a background task.
|
||||
|
||||
Immediately yields a success response with a task_id, then runs
|
||||
the subagent asynchronously. When the subagent finishes, a
|
||||
``CronMessageEvent`` is created so the main LLM can inform the
|
||||
user of the result – the same pattern used by
|
||||
``_execute_background`` for regular background tasks.
|
||||
"""
|
||||
task_id = uuid.uuid4().hex
|
||||
|
||||
async def _run_handoff_in_background() -> None:
|
||||
try:
|
||||
await cls._do_handoff_background(
|
||||
tool=tool,
|
||||
run_context=run_context,
|
||||
task_id=task_id,
|
||||
**tool_args,
|
||||
)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.error(
|
||||
f"Background handoff {task_id} ({tool.name}) failed: {e!s}",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
asyncio.create_task(_run_handoff_in_background())
|
||||
|
||||
text_content = mcp.types.TextContent(
|
||||
type="text",
|
||||
text=(
|
||||
f"Background task dedicated to subagent '{tool.agent.name}' submitted. task_id={task_id}. "
|
||||
f"The subagent '{tool.agent.name}' is working on the task on hehalf you. "
|
||||
f"You will be notified when it finishes."
|
||||
),
|
||||
)
|
||||
yield mcp.types.CallToolResult(content=[text_content])
|
||||
|
||||
@classmethod
|
||||
async def _do_handoff_background(
|
||||
cls,
|
||||
tool: HandoffTool,
|
||||
run_context: ContextWrapper[AstrAgentContext],
|
||||
task_id: str,
|
||||
**tool_args,
|
||||
) -> None:
|
||||
"""Run the subagent handoff and, on completion, wake the main agent."""
|
||||
result_text = ""
|
||||
try:
|
||||
async for r in cls._execute_handoff(tool, run_context, **tool_args):
|
||||
if isinstance(r, mcp.types.CallToolResult):
|
||||
for content in r.content:
|
||||
if isinstance(content, mcp.types.TextContent):
|
||||
result_text += content.text + "\n"
|
||||
except Exception as e:
|
||||
result_text = (
|
||||
f"error: Background task execution failed, internal error: {e!s}"
|
||||
)
|
||||
|
||||
event = run_context.context.event
|
||||
|
||||
await cls._wake_main_agent_for_background_result(
|
||||
run_context=run_context,
|
||||
task_id=task_id,
|
||||
tool_name=tool.name,
|
||||
result_text=result_text,
|
||||
tool_args=tool_args,
|
||||
note=(
|
||||
event.get_extra("background_note")
|
||||
or f"Background task for subagent '{tool.agent.name}' finished."
|
||||
),
|
||||
summary_name=f"Dedicated to subagent `{tool.agent.name}`",
|
||||
extra_result_fields={"subagent_name": tool.agent.name},
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def _execute_background(
|
||||
cls,
|
||||
@@ -154,12 +244,6 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
|
||||
task_id: str,
|
||||
**tool_args,
|
||||
) -> None:
|
||||
from astrbot.core.astr_main_agent import (
|
||||
MainAgentBuildConfig,
|
||||
_get_session_conv,
|
||||
build_main_agent,
|
||||
)
|
||||
|
||||
# run the tool
|
||||
result_text = ""
|
||||
try:
|
||||
@@ -177,21 +261,53 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
|
||||
f"error: Background task execution failed, internal error: {e!s}"
|
||||
)
|
||||
|
||||
event = run_context.context.event
|
||||
|
||||
await cls._wake_main_agent_for_background_result(
|
||||
run_context=run_context,
|
||||
task_id=task_id,
|
||||
tool_name=tool.name,
|
||||
result_text=result_text,
|
||||
tool_args=tool_args,
|
||||
note=(
|
||||
event.get_extra("background_note")
|
||||
or f"Background task {tool.name} finished."
|
||||
),
|
||||
summary_name=tool.name,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def _wake_main_agent_for_background_result(
|
||||
cls,
|
||||
run_context: ContextWrapper[AstrAgentContext],
|
||||
*,
|
||||
task_id: str,
|
||||
tool_name: str,
|
||||
result_text: str,
|
||||
tool_args: dict[str, T.Any],
|
||||
note: str,
|
||||
summary_name: str,
|
||||
extra_result_fields: dict[str, T.Any] | None = None,
|
||||
) -> None:
|
||||
from astrbot.core.astr_main_agent import (
|
||||
MainAgentBuildConfig,
|
||||
_get_session_conv,
|
||||
build_main_agent,
|
||||
)
|
||||
|
||||
event = run_context.context.event
|
||||
ctx = run_context.context.context
|
||||
|
||||
note = (
|
||||
event.get_extra("background_note")
|
||||
or f"Background task {tool.name} finished."
|
||||
)
|
||||
extras = {
|
||||
"background_task_result": {
|
||||
"task_id": task_id,
|
||||
"tool_name": tool.name,
|
||||
"result": result_text or "",
|
||||
"tool_args": tool_args,
|
||||
}
|
||||
task_result = {
|
||||
"task_id": task_id,
|
||||
"tool_name": tool_name,
|
||||
"result": result_text or "",
|
||||
"tool_args": tool_args,
|
||||
}
|
||||
if extra_result_fields:
|
||||
task_result.update(extra_result_fields)
|
||||
extras = {"background_task_result": task_result}
|
||||
|
||||
session = MessageSession.from_str(event.unified_msg_origin)
|
||||
cron_event = CronMessageEvent(
|
||||
context=ctx,
|
||||
@@ -201,7 +317,12 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
|
||||
message_type=session.message_type,
|
||||
)
|
||||
cron_event.role = event.role
|
||||
config = MainAgentBuildConfig(tool_call_timeout=3600)
|
||||
config = MainAgentBuildConfig(
|
||||
tool_call_timeout=3600,
|
||||
streaming_response=ctx.get_config()
|
||||
.get("provider_settings", {})
|
||||
.get("stream", False),
|
||||
)
|
||||
|
||||
req = ProviderRequest()
|
||||
conv = await _get_session_conv(event=cron_event, plugin_context=ctx)
|
||||
@@ -222,8 +343,11 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
|
||||
)
|
||||
req.prompt = (
|
||||
"Proceed according to your system instructions. "
|
||||
"Output using same language as previous conversation."
|
||||
" After completing your task, summarize and output your actions and results."
|
||||
"Output using same language as previous conversation. "
|
||||
"If you need to deliver the result to the user immediately, "
|
||||
"you MUST use `send_message_to_user` tool to send the message directly to the user, "
|
||||
"otherwise the user will not see the result. "
|
||||
"After completing your task, summarize and output your actions and results. "
|
||||
)
|
||||
if not req.func_tool:
|
||||
req.func_tool = ToolSet()
|
||||
@@ -233,7 +357,7 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
|
||||
event=cron_event, plugin_context=ctx, config=config, req=req
|
||||
)
|
||||
if not result:
|
||||
logger.error("Failed to build main agent for background task job.")
|
||||
logger.error(f"Failed to build main agent for background task {tool_name}.")
|
||||
return
|
||||
|
||||
runner = result.agent_runner
|
||||
@@ -243,7 +367,7 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
|
||||
llm_resp = runner.get_final_llm_resp()
|
||||
task_meta = extras.get("background_task_result", {})
|
||||
summary_note = (
|
||||
f"[BackgroundTask] {task_meta.get('tool_name', tool.name)} "
|
||||
f"[BackgroundTask] {summary_name} "
|
||||
f"(task_id={task_meta.get('task_id', task_id)}) finished. "
|
||||
f"Result: {task_meta.get('result') or result_text or 'no content'}"
|
||||
)
|
||||
|
||||
@@ -52,6 +52,17 @@ from astrbot.core.tools.cron_tools import (
|
||||
)
|
||||
from astrbot.core.utils.file_extract import extract_file_moonshotai
|
||||
from astrbot.core.utils.llm_metadata import LLM_METADATAS
|
||||
from astrbot.core.utils.quoted_message.settings import (
|
||||
SETTINGS as DEFAULT_QUOTED_MESSAGE_SETTINGS,
|
||||
)
|
||||
from astrbot.core.utils.quoted_message.settings import (
|
||||
QuotedMessageParserSettings,
|
||||
)
|
||||
from astrbot.core.utils.quoted_message_parser import (
|
||||
extract_quoted_message_images,
|
||||
extract_quoted_message_text,
|
||||
)
|
||||
from astrbot.core.utils.string_utils import normalize_and_dedupe_strings
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
@@ -108,6 +119,10 @@ class MainAgentBuildConfig:
|
||||
provider_settings: dict = field(default_factory=dict)
|
||||
subagent_orchestrator: dict = field(default_factory=dict)
|
||||
timezone: str | None = None
|
||||
max_quoted_fallback_images: int = 20
|
||||
"""Maximum number of images injected from quoted-message fallback extraction."""
|
||||
tool_call_approval: dict = field(default_factory=dict)
|
||||
"""Tool call approval configuration."""
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
@@ -470,11 +485,29 @@ async def _ensure_img_caption(
|
||||
logger.error("处理图片描述失败: %s", exc)
|
||||
|
||||
|
||||
def _append_quoted_image_attachment(req: ProviderRequest, image_path: str) -> None:
|
||||
req.extra_user_content_parts.append(
|
||||
TextPart(text=f"[Image Attachment in quoted message: path {image_path}]")
|
||||
)
|
||||
|
||||
|
||||
def _get_quoted_message_parser_settings(
|
||||
provider_settings: dict[str, object] | None,
|
||||
) -> QuotedMessageParserSettings:
|
||||
if not isinstance(provider_settings, dict):
|
||||
return DEFAULT_QUOTED_MESSAGE_SETTINGS
|
||||
overrides = provider_settings.get("quoted_message_parser")
|
||||
if not isinstance(overrides, dict):
|
||||
return DEFAULT_QUOTED_MESSAGE_SETTINGS
|
||||
return DEFAULT_QUOTED_MESSAGE_SETTINGS.with_overrides(overrides)
|
||||
|
||||
|
||||
async def _process_quote_message(
|
||||
event: AstrMessageEvent,
|
||||
req: ProviderRequest,
|
||||
img_cap_prov_id: str,
|
||||
plugin_context: Context,
|
||||
quoted_message_settings: QuotedMessageParserSettings = DEFAULT_QUOTED_MESSAGE_SETTINGS,
|
||||
) -> None:
|
||||
quote = None
|
||||
for comp in event.message_obj.message:
|
||||
@@ -486,7 +519,15 @@ async def _process_quote_message(
|
||||
|
||||
content_parts = []
|
||||
sender_info = f"({quote.sender_nickname}): " if quote.sender_nickname else ""
|
||||
message_str = quote.message_str or "[Empty Text]"
|
||||
message_str = (
|
||||
await extract_quoted_message_text(
|
||||
event,
|
||||
quote,
|
||||
settings=quoted_message_settings,
|
||||
)
|
||||
or quote.message_str
|
||||
or "[Empty Text]"
|
||||
)
|
||||
content_parts.append(f"{sender_info}{message_str}")
|
||||
|
||||
image_seg = None
|
||||
@@ -592,11 +633,13 @@ async def _decorate_llm_request(
|
||||
)
|
||||
|
||||
img_cap_prov_id = cfg.get("default_image_caption_provider_id") or ""
|
||||
quoted_message_settings = _get_quoted_message_parser_settings(cfg)
|
||||
await _process_quote_message(
|
||||
event,
|
||||
req,
|
||||
img_cap_prov_id,
|
||||
plugin_context,
|
||||
quoted_message_settings,
|
||||
)
|
||||
|
||||
tz = config.timezone
|
||||
@@ -829,6 +872,41 @@ def _get_compress_provider(
|
||||
return provider
|
||||
|
||||
|
||||
def _get_fallback_chat_providers(
|
||||
provider: Provider, plugin_context: Context, provider_settings: dict
|
||||
) -> list[Provider]:
|
||||
fallback_ids = provider_settings.get("fallback_chat_models", [])
|
||||
if not isinstance(fallback_ids, list):
|
||||
logger.warning(
|
||||
"fallback_chat_models setting is not a list, skip fallback providers."
|
||||
)
|
||||
return []
|
||||
|
||||
provider_id = str(provider.provider_config.get("id", ""))
|
||||
seen_provider_ids: set[str] = {provider_id} if provider_id else set()
|
||||
fallbacks: list[Provider] = []
|
||||
|
||||
for fallback_id in fallback_ids:
|
||||
if not isinstance(fallback_id, str) or not fallback_id:
|
||||
continue
|
||||
if fallback_id in seen_provider_ids:
|
||||
continue
|
||||
fallback_provider = plugin_context.get_provider_by_id(fallback_id)
|
||||
if fallback_provider is None:
|
||||
logger.warning("Fallback chat provider `%s` not found, skip.", fallback_id)
|
||||
continue
|
||||
if not isinstance(fallback_provider, Provider):
|
||||
logger.warning(
|
||||
"Fallback chat provider `%s` is invalid type: %s, skip.",
|
||||
fallback_id,
|
||||
type(fallback_provider),
|
||||
)
|
||||
continue
|
||||
fallbacks.append(fallback_provider)
|
||||
seen_provider_ids.add(fallback_id)
|
||||
return fallbacks
|
||||
|
||||
|
||||
async def build_main_agent(
|
||||
*,
|
||||
event: AstrMessageEvent,
|
||||
@@ -867,6 +945,8 @@ async def build_main_agent(
|
||||
return None
|
||||
|
||||
req.prompt = event.message_str[len(config.provider_wake_prefix) :]
|
||||
|
||||
# media files attachments
|
||||
for comp in event.message_obj.message:
|
||||
if isinstance(comp, Image):
|
||||
image_path = await comp.convert_to_file_path()
|
||||
@@ -882,6 +962,81 @@ async def build_main_agent(
|
||||
text=f"[File Attachment: name {file_name}, path {file_path}]"
|
||||
)
|
||||
)
|
||||
# quoted message attachments
|
||||
reply_comps = [
|
||||
comp for comp in event.message_obj.message if isinstance(comp, Reply)
|
||||
]
|
||||
quoted_message_settings = _get_quoted_message_parser_settings(
|
||||
config.provider_settings
|
||||
)
|
||||
fallback_quoted_image_count = 0
|
||||
for comp in reply_comps:
|
||||
has_embedded_image = False
|
||||
if comp.chain:
|
||||
for reply_comp in comp.chain:
|
||||
if isinstance(reply_comp, Image):
|
||||
has_embedded_image = True
|
||||
image_path = await reply_comp.convert_to_file_path()
|
||||
req.image_urls.append(image_path)
|
||||
_append_quoted_image_attachment(req, image_path)
|
||||
elif isinstance(reply_comp, File):
|
||||
file_path = await reply_comp.get_file()
|
||||
file_name = reply_comp.name or os.path.basename(file_path)
|
||||
req.extra_user_content_parts.append(
|
||||
TextPart(
|
||||
text=(
|
||||
f"[File Attachment in quoted message: "
|
||||
f"name {file_name}, path {file_path}]"
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
# Fallback quoted image extraction for reply-id-only payloads, or when
|
||||
# embedded reply chain only contains placeholders (e.g. [Forward Message], [Image]).
|
||||
if not has_embedded_image:
|
||||
try:
|
||||
fallback_images = normalize_and_dedupe_strings(
|
||||
await extract_quoted_message_images(
|
||||
event,
|
||||
comp,
|
||||
settings=quoted_message_settings,
|
||||
)
|
||||
)
|
||||
remaining_limit = max(
|
||||
config.max_quoted_fallback_images
|
||||
- fallback_quoted_image_count,
|
||||
0,
|
||||
)
|
||||
if remaining_limit <= 0 and fallback_images:
|
||||
logger.warning(
|
||||
"Skip quoted fallback images due to limit=%d for umo=%s",
|
||||
config.max_quoted_fallback_images,
|
||||
event.unified_msg_origin,
|
||||
)
|
||||
continue
|
||||
if len(fallback_images) > remaining_limit:
|
||||
logger.warning(
|
||||
"Truncate quoted fallback images for umo=%s, reply_id=%s from %d to %d",
|
||||
event.unified_msg_origin,
|
||||
getattr(comp, "id", None),
|
||||
len(fallback_images),
|
||||
remaining_limit,
|
||||
)
|
||||
fallback_images = fallback_images[:remaining_limit]
|
||||
for image_ref in fallback_images:
|
||||
if image_ref in req.image_urls:
|
||||
continue
|
||||
req.image_urls.append(image_ref)
|
||||
fallback_quoted_image_count += 1
|
||||
_append_quoted_image_attachment(req, image_ref)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning(
|
||||
"Failed to resolve fallback quoted images for umo=%s, reply_id=%s: %s",
|
||||
event.unified_msg_origin,
|
||||
getattr(comp, "id", None),
|
||||
exc,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
conversation = await _get_session_conv(event, plugin_context)
|
||||
req.conversation = conversation
|
||||
@@ -890,6 +1045,7 @@ 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)
|
||||
|
||||
if config.file_extract_enabled:
|
||||
try:
|
||||
@@ -964,6 +1120,7 @@ async def build_main_agent(
|
||||
run_context=AgentContextWrapper(
|
||||
context=astr_agent_ctx,
|
||||
tool_call_timeout=config.tool_call_timeout,
|
||||
tool_call_approval=config.tool_call_approval,
|
||||
),
|
||||
tool_executor=FunctionToolExecutor(),
|
||||
agent_hooks=MAIN_AGENT_HOOKS,
|
||||
@@ -974,6 +1131,9 @@ async def build_main_agent(
|
||||
truncate_turns=config.dequeue_context_length,
|
||||
enforce_max_turns=config.max_context_length,
|
||||
tool_schema_mode=config.tool_schema_mode,
|
||||
fallback_providers=_get_fallback_chat_providers(
|
||||
provider, plugin_context, config.provider_settings
|
||||
),
|
||||
)
|
||||
|
||||
if apply_reset:
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import uuid
|
||||
|
||||
from pydantic import Field
|
||||
from pydantic.dataclasses import dataclass
|
||||
@@ -240,7 +241,9 @@ class SendMessageToUserTool(FunctionTool[AstrAgentContext]):
|
||||
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(), name)
|
||||
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
|
||||
@@ -352,11 +355,11 @@ class SendMessageToUserTool(FunctionTool[AstrAgentContext]):
|
||||
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}")
|
||||
# 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}"
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ from astrbot.core.db.po import (
|
||||
CommandConflict,
|
||||
ConversationV2,
|
||||
Persona,
|
||||
PersonaFolder,
|
||||
PlatformMessageHistory,
|
||||
PlatformSession,
|
||||
PlatformStat,
|
||||
@@ -39,6 +40,7 @@ MAIN_DB_MODELS: dict[str, type[SQLModel]] = {
|
||||
"platform_stats": PlatformStat,
|
||||
"conversations": ConversationV2,
|
||||
"personas": Persona,
|
||||
"persona_folders": PersonaFolder,
|
||||
"preferences": Preference,
|
||||
"platform_message_history": PlatformMessageHistory,
|
||||
"platform_sessions": PlatformSession,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import os
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from astrbot.api import FunctionTool, logger
|
||||
@@ -167,7 +168,9 @@ class FileDownloadTool(FunctionTool):
|
||||
try:
|
||||
name = os.path.basename(remote_path)
|
||||
|
||||
local_path = os.path.join(get_astrbot_temp_path(), name)
|
||||
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)
|
||||
@@ -183,12 +186,12 @@ class FileDownloadTool(FunctionTool):
|
||||
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}")
|
||||
# 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. The file has been removed from local storage."
|
||||
return f"File downloaded successfully to {local_path} and sent to user."
|
||||
|
||||
return f"File downloaded successfully to {local_path}"
|
||||
except Exception as e:
|
||||
|
||||
@@ -5,8 +5,9 @@ import mcp
|
||||
from astrbot.api import FunctionTool
|
||||
from astrbot.core.agent.run_context import ContextWrapper
|
||||
from astrbot.core.agent.tool import ToolExecResult
|
||||
from astrbot.core.astr_agent_context import AstrAgentContext
|
||||
from astrbot.core.astr_agent_context import AstrAgentContext, AstrMessageEvent
|
||||
from astrbot.core.computer.computer_client import get_booter, get_local_booter
|
||||
from astrbot.core.message.message_event_result import MessageChain
|
||||
|
||||
param_schema = {
|
||||
"type": "object",
|
||||
@@ -25,7 +26,22 @@ param_schema = {
|
||||
}
|
||||
|
||||
|
||||
def handle_result(result: dict) -> ToolExecResult:
|
||||
def _check_admin_permission(context: ContextWrapper[AstrAgentContext]) -> str | None:
|
||||
cfg = context.context.context.get_config(
|
||||
umo=context.context.event.unified_msg_origin
|
||||
)
|
||||
provider_settings = cfg.get("provider_settings", {})
|
||||
require_admin = provider_settings.get("computer_use_require_admin", True)
|
||||
if require_admin and context.context.event.role != "admin":
|
||||
return (
|
||||
"error: Permission denied. Python execution is only allowed for admin users. "
|
||||
"Tell user to set admins in `AstrBot WebUI -> Config -> General Config` by adding their user ID to the admins list if they need this feature."
|
||||
f"User's ID is: {context.context.event.get_sender_id()}. User's ID can be found by using /sid command."
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
async def handle_result(result: dict, event: AstrMessageEvent) -> ToolExecResult:
|
||||
data = result.get("data", {})
|
||||
output = data.get("output", {})
|
||||
error = data.get("error", "")
|
||||
@@ -44,6 +60,9 @@ def handle_result(result: dict) -> ToolExecResult:
|
||||
type="image", data=img["image/png"], mimeType="image/png"
|
||||
)
|
||||
)
|
||||
|
||||
if event.get_platform_name() == "webchat":
|
||||
await event.send(message=MessageChain().base64_image(img["image/png"]))
|
||||
if text:
|
||||
resp.content.append(mcp.types.TextContent(type="text", text=text))
|
||||
|
||||
@@ -62,13 +81,15 @@ class PythonTool(FunctionTool):
|
||||
async def call(
|
||||
self, context: ContextWrapper[AstrAgentContext], code: str, silent: bool = False
|
||||
) -> ToolExecResult:
|
||||
if permission_error := _check_admin_permission(context):
|
||||
return permission_error
|
||||
sb = await get_booter(
|
||||
context.context.context,
|
||||
context.context.event.unified_msg_origin,
|
||||
)
|
||||
try:
|
||||
result = await sb.python.exec(code, silent=silent)
|
||||
return handle_result(result)
|
||||
return await handle_result(result, context.context.event)
|
||||
except Exception as e:
|
||||
return f"Error executing code: {str(e)}"
|
||||
|
||||
@@ -83,12 +104,11 @@ class LocalPythonTool(FunctionTool):
|
||||
async def call(
|
||||
self, context: ContextWrapper[AstrAgentContext], code: str, silent: bool = False
|
||||
) -> ToolExecResult:
|
||||
if context.context.event.role != "admin":
|
||||
return "error: Permission denied. Local Python execution is only allowed for admin users. Tell user to set admins in AstrBot WebUI."
|
||||
|
||||
if permission_error := _check_admin_permission(context):
|
||||
return permission_error
|
||||
sb = get_local_booter()
|
||||
try:
|
||||
result = await sb.python.exec(code, silent=silent)
|
||||
return handle_result(result)
|
||||
return await handle_result(result, context.context.event)
|
||||
except Exception as e:
|
||||
return f"Error executing code: {str(e)}"
|
||||
|
||||
@@ -9,6 +9,21 @@ from astrbot.core.astr_agent_context import AstrAgentContext
|
||||
from ..computer_client import get_booter, get_local_booter
|
||||
|
||||
|
||||
def _check_admin_permission(context: ContextWrapper[AstrAgentContext]) -> str | None:
|
||||
cfg = context.context.context.get_config(
|
||||
umo=context.context.event.unified_msg_origin
|
||||
)
|
||||
provider_settings = cfg.get("provider_settings", {})
|
||||
require_admin = provider_settings.get("computer_use_require_admin", True)
|
||||
if require_admin and context.context.event.role != "admin":
|
||||
return (
|
||||
"error: Permission denied. Shell execution is only allowed for admin users. "
|
||||
"Tell user to set admins in `AstrBot WebUI -> Config -> General Config` by adding their user ID to the admins list if they need this feature."
|
||||
f"User's ID is: {context.context.event.get_sender_id()}. User's ID can be found by using /sid command."
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExecuteShellTool(FunctionTool):
|
||||
name: str = "astrbot_execute_shell"
|
||||
@@ -46,8 +61,8 @@ class ExecuteShellTool(FunctionTool):
|
||||
background: bool = False,
|
||||
env: dict = {},
|
||||
) -> ToolExecResult:
|
||||
if context.context.event.role != "admin":
|
||||
return "error: Permission denied. Shell execution is only allowed for admin users. Tell user to Set admins in AstrBot WebUI."
|
||||
if permission_error := _check_admin_permission(context):
|
||||
return permission_error
|
||||
|
||||
if self.is_local:
|
||||
sb = get_local_booter()
|
||||
|
||||
@@ -52,6 +52,9 @@ class AstrBotConfig(dict):
|
||||
|
||||
with open(config_path, encoding="utf-8-sig") as f:
|
||||
conf_str = f.read()
|
||||
# Handle UTF-8 BOM if present
|
||||
if conf_str.startswith("\ufeff"):
|
||||
conf_str = conf_str[1:]
|
||||
conf = json.loads(conf_str)
|
||||
|
||||
# 检查配置完整性,并插入
|
||||
|
||||
@@ -5,7 +5,7 @@ from typing import Any, TypedDict
|
||||
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
||||
|
||||
VERSION = "4.15.0"
|
||||
VERSION = "4.18.1"
|
||||
DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db")
|
||||
|
||||
WEBHOOK_SUPPORTED_PLATFORMS = [
|
||||
@@ -15,6 +15,7 @@ WEBHOOK_SUPPORTED_PLATFORMS = [
|
||||
"wecom_ai_bot",
|
||||
"slack",
|
||||
"lark",
|
||||
"line",
|
||||
]
|
||||
|
||||
# 默认配置
|
||||
@@ -67,6 +68,7 @@ DEFAULT_CONFIG = {
|
||||
"provider_settings": {
|
||||
"enable": True,
|
||||
"default_provider_id": "",
|
||||
"fallback_chat_models": [],
|
||||
"default_image_caption_provider_id": "",
|
||||
"image_caption_prompt": "Please describe the image using Chinese.",
|
||||
"provider_pool": ["*"], # "*" 表示使用所有可用的提供者
|
||||
@@ -99,6 +101,13 @@ DEFAULT_CONFIG = {
|
||||
"streaming_response": False,
|
||||
"show_tool_use_status": False,
|
||||
"sanitize_context_by_modalities": False,
|
||||
"max_quoted_fallback_images": 20,
|
||||
"quoted_message_parser": {
|
||||
"max_component_chain_depth": 4,
|
||||
"max_forward_node_depth": 6,
|
||||
"max_forward_fetch": 32,
|
||||
"warn_on_action_failure": False,
|
||||
},
|
||||
"agent_runner_type": "local",
|
||||
"dify_agent_runner_provider_id": "",
|
||||
"coze_agent_runner_provider_id": "",
|
||||
@@ -108,6 +117,15 @@ DEFAULT_CONFIG = {
|
||||
"max_agent_step": 30,
|
||||
"tool_call_timeout": 60,
|
||||
"tool_schema_mode": "full",
|
||||
"tool_call_approval": {
|
||||
"enable": False,
|
||||
"strategy": "dynamic_code",
|
||||
"timeout": 60,
|
||||
"dynamic_code": {
|
||||
"code_length": 6,
|
||||
"case_sensitive": False,
|
||||
},
|
||||
},
|
||||
"llm_safety_mode": True,
|
||||
"safety_mode_strategy": "system_prompt", # TODO: llm judge
|
||||
"file_extract": {
|
||||
@@ -119,6 +137,7 @@ DEFAULT_CONFIG = {
|
||||
"add_cron_tools": True,
|
||||
},
|
||||
"computer_use_runtime": "local",
|
||||
"computer_use_require_admin": True,
|
||||
"sandbox": {
|
||||
"booter": "shipyard",
|
||||
"shipyard_endpoint": "",
|
||||
@@ -187,6 +206,12 @@ DEFAULT_CONFIG = {
|
||||
"host": "0.0.0.0",
|
||||
"port": 6185,
|
||||
"disable_access_log": True,
|
||||
"ssl": {
|
||||
"enable": False,
|
||||
"cert_file": "",
|
||||
"key_file": "",
|
||||
"ca_certs": "",
|
||||
},
|
||||
},
|
||||
"platform": [],
|
||||
"platform_specific": {
|
||||
@@ -203,6 +228,7 @@ DEFAULT_CONFIG = {
|
||||
"log_file_enable": False,
|
||||
"log_file_path": "logs/astrbot.log",
|
||||
"log_file_max_mb": 20,
|
||||
"temp_dir_max_size": 1024,
|
||||
"trace_enable": False,
|
||||
"trace_log_enable": False,
|
||||
"trace_log_path": "logs/astrbot.trace.log",
|
||||
@@ -407,6 +433,7 @@ CONFIG_METADATA_2 = {
|
||||
"slack_webhook_port": 6197,
|
||||
"slack_webhook_path": "/astrbot-slack-webhook/callback",
|
||||
},
|
||||
# LINE's config is located in line_adapter.py
|
||||
"Satori": {
|
||||
"id": "satori",
|
||||
"type": "satori",
|
||||
@@ -961,7 +988,7 @@ CONFIG_METADATA_2 = {
|
||||
"api_base": "https://api.anthropic.com/v1",
|
||||
"timeout": 120,
|
||||
"proxy": "",
|
||||
"anth_thinking_config": {"budget": 0},
|
||||
"anth_thinking_config": {"type": "", "budget": 0, "effort": ""},
|
||||
},
|
||||
"Moonshot": {
|
||||
"id": "moonshot",
|
||||
@@ -1012,6 +1039,42 @@ CONFIG_METADATA_2 = {
|
||||
"proxy": "",
|
||||
"custom_headers": {},
|
||||
},
|
||||
"AIHubMix": {
|
||||
"id": "aihubmix",
|
||||
"provider": "aihubmix",
|
||||
"type": "aihubmix_chat_completion",
|
||||
"provider_type": "chat_completion",
|
||||
"enable": True,
|
||||
"key": [],
|
||||
"timeout": 120,
|
||||
"api_base": "https://aihubmix.com/v1",
|
||||
"proxy": "",
|
||||
"custom_headers": {},
|
||||
},
|
||||
"OpenRouter": {
|
||||
"id": "openrouter",
|
||||
"provider": "openrouter",
|
||||
"type": "openrouter_chat_completion",
|
||||
"provider_type": "chat_completion",
|
||||
"enable": True,
|
||||
"key": [],
|
||||
"timeout": 120,
|
||||
"api_base": "https://openrouter.ai/v1",
|
||||
"proxy": "",
|
||||
"custom_headers": {},
|
||||
},
|
||||
"NVIDIA": {
|
||||
"id": "nvidia",
|
||||
"provider": "nvidia",
|
||||
"type": "openai_chat_completion",
|
||||
"provider_type": "chat_completion",
|
||||
"enable": True,
|
||||
"key": [],
|
||||
"api_base": "https://integrate.api.nvidia.com/v1",
|
||||
"timeout": 120,
|
||||
"proxy": "",
|
||||
"custom_headers": {},
|
||||
},
|
||||
"Azure OpenAI": {
|
||||
"id": "azure_openai",
|
||||
"provider": "azure",
|
||||
@@ -1910,13 +1973,25 @@ CONFIG_METADATA_2 = {
|
||||
},
|
||||
},
|
||||
"anth_thinking_config": {
|
||||
"description": "Thinking Config",
|
||||
"description": "思考配置",
|
||||
"type": "object",
|
||||
"items": {
|
||||
"type": {
|
||||
"description": "思考类型",
|
||||
"type": "string",
|
||||
"options": ["", "adaptive"],
|
||||
"hint": "Opus 4.6+ / Sonnet 4.6+ 推荐设为 'adaptive'。留空则使用手动 budget 模式。参见: https://platform.claude.com/docs/en/build-with-claude/adaptive-thinking",
|
||||
},
|
||||
"budget": {
|
||||
"description": "Thinking Budget",
|
||||
"description": "思考预算",
|
||||
"type": "int",
|
||||
"hint": "Anthropic thinking.budget_tokens param. Must >= 1024. See: https://platform.claude.com/docs/en/build-with-claude/extended-thinking",
|
||||
"hint": "手动 budget_tokens,需 >= 1024。仅在 type 为空时生效。Opus 4.6 / Sonnet 4.6 上已弃用。参见: https://platform.claude.com/docs/en/build-with-claude/extended-thinking",
|
||||
},
|
||||
"effort": {
|
||||
"description": "思考深度",
|
||||
"type": "string",
|
||||
"options": ["", "low", "medium", "high", "max"],
|
||||
"hint": "type 为 'adaptive' 时控制思考深度。默认 'high'。'max' 仅限 Opus 4.6。参见: https://platform.claude.com/docs/en/build-with-claude/effort",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -2197,6 +2272,10 @@ CONFIG_METADATA_2 = {
|
||||
"default_provider_id": {
|
||||
"type": "string",
|
||||
},
|
||||
"fallback_chat_models": {
|
||||
"type": "list",
|
||||
"items": {"type": "string"},
|
||||
},
|
||||
"wake_prefix": {
|
||||
"type": "string",
|
||||
},
|
||||
@@ -2260,6 +2339,31 @@ CONFIG_METADATA_2 = {
|
||||
"tool_schema_mode": {
|
||||
"type": "string",
|
||||
},
|
||||
"tool_call_approval": {
|
||||
"type": "object",
|
||||
"items": {
|
||||
"enable": {
|
||||
"type": "bool",
|
||||
},
|
||||
"strategy": {
|
||||
"type": "string",
|
||||
},
|
||||
"timeout": {
|
||||
"type": "int",
|
||||
},
|
||||
"dynamic_code": {
|
||||
"type": "object",
|
||||
"items": {
|
||||
"code_length": {
|
||||
"type": "int",
|
||||
},
|
||||
"case_sensitive": {
|
||||
"type": "bool",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"file_extract": {
|
||||
"type": "object",
|
||||
"items": {
|
||||
@@ -2391,9 +2495,23 @@ CONFIG_METADATA_2 = {
|
||||
"type": "string",
|
||||
"options": ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
|
||||
},
|
||||
"dashboard.ssl.enable": {"type": "bool"},
|
||||
"dashboard.ssl.cert_file": {
|
||||
"type": "string",
|
||||
"condition": {"dashboard.ssl.enable": True},
|
||||
},
|
||||
"dashboard.ssl.key_file": {
|
||||
"type": "string",
|
||||
"condition": {"dashboard.ssl.enable": True},
|
||||
},
|
||||
"dashboard.ssl.ca_certs": {
|
||||
"type": "string",
|
||||
"condition": {"dashboard.ssl.enable": True},
|
||||
},
|
||||
"log_file_enable": {"type": "bool"},
|
||||
"log_file_path": {"type": "string", "condition": {"log_file_enable": True}},
|
||||
"log_file_max_mb": {"type": "int", "condition": {"log_file_enable": True}},
|
||||
"temp_dir_max_size": {"type": "int"},
|
||||
"trace_log_enable": {"type": "bool"},
|
||||
"trace_log_path": {
|
||||
"type": "string",
|
||||
@@ -2493,15 +2611,22 @@ CONFIG_METADATA_3 = {
|
||||
},
|
||||
"ai": {
|
||||
"description": "模型",
|
||||
"hint": "当使用非内置 Agent 执行器时,默认聊天模型和默认图片转述模型可能会无效,但某些插件会依赖此配置项来调用 AI 能力。",
|
||||
"hint": "当使用非内置 Agent 执行器时,默认对话模型和默认图片转述模型可能会无效,但某些插件会依赖此配置项来调用 AI 能力。",
|
||||
"type": "object",
|
||||
"items": {
|
||||
"provider_settings.default_provider_id": {
|
||||
"description": "默认聊天模型",
|
||||
"description": "默认对话模型",
|
||||
"type": "string",
|
||||
"_special": "select_provider",
|
||||
"hint": "留空时使用第一个模型",
|
||||
},
|
||||
"provider_settings.fallback_chat_models": {
|
||||
"description": "回退对话模型列表",
|
||||
"type": "list",
|
||||
"items": {"type": "string"},
|
||||
"_special": "select_providers",
|
||||
"hint": "主聊天模型请求失败时,按顺序切换到这些模型。",
|
||||
},
|
||||
"provider_settings.default_image_caption_provider_id": {
|
||||
"description": "默认图片转述模型",
|
||||
"type": "string",
|
||||
@@ -2671,6 +2796,11 @@ CONFIG_METADATA_3 = {
|
||||
"labels": ["无", "本地", "沙箱"],
|
||||
"hint": "选择 Computer Use 运行环境。",
|
||||
},
|
||||
"provider_settings.computer_use_require_admin": {
|
||||
"description": "需要 AstrBot 管理员权限",
|
||||
"type": "bool",
|
||||
"hint": "开启后,需要 AstrBot 管理员权限才能调用使用电脑能力。在平台配置->管理员中可添加管理员。使用 /sid 指令查看管理员 ID。",
|
||||
},
|
||||
"provider_settings.sandbox.booter": {
|
||||
"description": "沙箱环境驱动器",
|
||||
"type": "string",
|
||||
@@ -2906,6 +3036,46 @@ CONFIG_METADATA_3 = {
|
||||
"provider_settings.agent_runner_type": "local",
|
||||
},
|
||||
},
|
||||
"provider_settings.max_quoted_fallback_images": {
|
||||
"description": "引用图片回退解析上限",
|
||||
"type": "int",
|
||||
"hint": "引用/转发消息回退解析图片时的最大注入数量,超出会截断。",
|
||||
"condition": {
|
||||
"provider_settings.agent_runner_type": "local",
|
||||
},
|
||||
},
|
||||
"provider_settings.quoted_message_parser.max_component_chain_depth": {
|
||||
"description": "引用解析组件链深度",
|
||||
"type": "int",
|
||||
"hint": "解析 Reply 组件链时允许的最大递归深度。",
|
||||
"condition": {
|
||||
"provider_settings.agent_runner_type": "local",
|
||||
},
|
||||
},
|
||||
"provider_settings.quoted_message_parser.max_forward_node_depth": {
|
||||
"description": "引用解析转发节点深度",
|
||||
"type": "int",
|
||||
"hint": "解析合并转发节点时允许的最大递归深度。",
|
||||
"condition": {
|
||||
"provider_settings.agent_runner_type": "local",
|
||||
},
|
||||
},
|
||||
"provider_settings.quoted_message_parser.max_forward_fetch": {
|
||||
"description": "引用解析转发拉取上限",
|
||||
"type": "int",
|
||||
"hint": "递归拉取 get_forward_msg 的最大次数。",
|
||||
"condition": {
|
||||
"provider_settings.agent_runner_type": "local",
|
||||
},
|
||||
},
|
||||
"provider_settings.quoted_message_parser.warn_on_action_failure": {
|
||||
"description": "引用解析 action 失败告警",
|
||||
"type": "bool",
|
||||
"hint": "开启后,get_msg/get_forward_msg 全部尝试失败时输出 warning 日志。",
|
||||
"condition": {
|
||||
"provider_settings.agent_runner_type": "local",
|
||||
},
|
||||
},
|
||||
"provider_settings.max_agent_step": {
|
||||
"description": "工具调用轮数上限",
|
||||
"type": "int",
|
||||
@@ -2930,6 +3100,50 @@ CONFIG_METADATA_3 = {
|
||||
"provider_settings.agent_runner_type": "local",
|
||||
},
|
||||
},
|
||||
"provider_settings.tool_call_approval.enable": {
|
||||
"description": "启用工具调用确认",
|
||||
"type": "bool",
|
||||
"hint": "开启后,工具调用需要用户确认后才会执行。",
|
||||
"condition": {
|
||||
"provider_settings.agent_runner_type": "local",
|
||||
},
|
||||
},
|
||||
"provider_settings.tool_call_approval.strategy": {
|
||||
"description": "工具调用确认策略",
|
||||
"type": "string",
|
||||
"options": ["dynamic_code"],
|
||||
"labels": ["Dynamic Code(动态码)"],
|
||||
"condition": {
|
||||
"provider_settings.agent_runner_type": "local",
|
||||
"provider_settings.tool_call_approval.enable": True,
|
||||
},
|
||||
},
|
||||
"provider_settings.tool_call_approval.timeout": {
|
||||
"description": "工具调用确认超时(秒)",
|
||||
"type": "int",
|
||||
"condition": {
|
||||
"provider_settings.agent_runner_type": "local",
|
||||
"provider_settings.tool_call_approval.enable": True,
|
||||
},
|
||||
},
|
||||
"provider_settings.tool_call_approval.dynamic_code.code_length": {
|
||||
"description": "动态确认码长度",
|
||||
"type": "int",
|
||||
"condition": {
|
||||
"provider_settings.agent_runner_type": "local",
|
||||
"provider_settings.tool_call_approval.enable": True,
|
||||
"provider_settings.tool_call_approval.strategy": "dynamic_code",
|
||||
},
|
||||
},
|
||||
"provider_settings.tool_call_approval.dynamic_code.case_sensitive": {
|
||||
"description": "动态确认码区分大小写",
|
||||
"type": "bool",
|
||||
"condition": {
|
||||
"provider_settings.agent_runner_type": "local",
|
||||
"provider_settings.tool_call_approval.enable": True,
|
||||
"provider_settings.tool_call_approval.strategy": "dynamic_code",
|
||||
},
|
||||
},
|
||||
"provider_settings.wake_prefix": {
|
||||
"description": "LLM 聊天额外唤醒前缀 ",
|
||||
"type": "string",
|
||||
@@ -3357,6 +3571,29 @@ CONFIG_METADATA_3_SYSTEM = {
|
||||
"hint": "控制台输出日志的级别。",
|
||||
"options": ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
|
||||
},
|
||||
"dashboard.ssl.enable": {
|
||||
"description": "启用 WebUI HTTPS",
|
||||
"type": "bool",
|
||||
"hint": "启用后,WebUI 将直接使用 HTTPS 提供服务。",
|
||||
},
|
||||
"dashboard.ssl.cert_file": {
|
||||
"description": "SSL 证书文件路径",
|
||||
"type": "string",
|
||||
"hint": "证书文件路径(PEM)。支持绝对路径和相对路径(相对于当前工作目录)。",
|
||||
"condition": {"dashboard.ssl.enable": True},
|
||||
},
|
||||
"dashboard.ssl.key_file": {
|
||||
"description": "SSL 私钥文件路径",
|
||||
"type": "string",
|
||||
"hint": "私钥文件路径(PEM)。支持绝对路径和相对路径(相对于当前工作目录)。",
|
||||
"condition": {"dashboard.ssl.enable": True},
|
||||
},
|
||||
"dashboard.ssl.ca_certs": {
|
||||
"description": "SSL CA 证书文件路径",
|
||||
"type": "string",
|
||||
"hint": "可选。用于指定 CA 证书文件路径。",
|
||||
"condition": {"dashboard.ssl.enable": True},
|
||||
},
|
||||
"log_file_enable": {
|
||||
"description": "启用文件日志",
|
||||
"type": "bool",
|
||||
@@ -3372,6 +3609,11 @@ CONFIG_METADATA_3_SYSTEM = {
|
||||
"type": "int",
|
||||
"hint": "超过大小后自动轮转,默认 20MB。",
|
||||
},
|
||||
"temp_dir_max_size": {
|
||||
"description": "临时目录大小上限 (MB)",
|
||||
"type": "int",
|
||||
"hint": "用于限制 data/temp 目录总大小,单位为 MB。系统每 10 分钟检查一次,超限时按文件修改时间从旧到新删除,释放约 30% 当前体积。",
|
||||
},
|
||||
"trace_log_enable": {
|
||||
"description": "启用 Trace 文件日志",
|
||||
"type": "bool",
|
||||
|
||||
@@ -37,6 +37,7 @@ from astrbot.core.umop_config_router import UmopConfigRouter
|
||||
from astrbot.core.updator import AstrBotUpdator
|
||||
from astrbot.core.utils.llm_metadata import update_llm_metadata
|
||||
from astrbot.core.utils.migra_helper import migra
|
||||
from astrbot.core.utils.temp_dir_cleaner import TempDirCleaner
|
||||
|
||||
from . import astrbot_config, html_renderer
|
||||
from .event_bus import EventBus
|
||||
@@ -57,6 +58,7 @@ class AstrBotCoreLifecycle:
|
||||
|
||||
self.subagent_orchestrator: SubAgentOrchestrator | None = None
|
||||
self.cron_manager: CronJobManager | None = None
|
||||
self.temp_dir_cleaner: TempDirCleaner | None = None
|
||||
|
||||
# 设置代理
|
||||
proxy_config = self.astrbot_config.get("http_proxy", "")
|
||||
@@ -125,6 +127,12 @@ class AstrBotCoreLifecycle:
|
||||
ucr=self.umop_config_router,
|
||||
sp=sp,
|
||||
)
|
||||
self.temp_dir_cleaner = TempDirCleaner(
|
||||
max_size_getter=lambda: self.astrbot_config_mgr.default_conf.get(
|
||||
TempDirCleaner.CONFIG_KEY,
|
||||
TempDirCleaner.DEFAULT_MAX_SIZE,
|
||||
),
|
||||
)
|
||||
|
||||
# apply migration
|
||||
try:
|
||||
@@ -238,6 +246,12 @@ class AstrBotCoreLifecycle:
|
||||
self.cron_manager.start(self.star_context),
|
||||
name="cron_manager",
|
||||
)
|
||||
temp_dir_cleaner_task = None
|
||||
if self.temp_dir_cleaner:
|
||||
temp_dir_cleaner_task = asyncio.create_task(
|
||||
self.temp_dir_cleaner.run(),
|
||||
name="temp_dir_cleaner",
|
||||
)
|
||||
|
||||
# 把插件中注册的所有协程函数注册到事件总线中并执行
|
||||
extra_tasks = []
|
||||
@@ -247,6 +261,8 @@ class AstrBotCoreLifecycle:
|
||||
tasks_ = [event_bus_task, *(extra_tasks if extra_tasks else [])]
|
||||
if cron_task:
|
||||
tasks_.append(cron_task)
|
||||
if temp_dir_cleaner_task:
|
||||
tasks_.append(temp_dir_cleaner_task)
|
||||
for task in tasks_:
|
||||
self.curr_tasks.append(
|
||||
asyncio.create_task(self._task_wrapper(task), name=task.get_name()),
|
||||
@@ -298,6 +314,9 @@ class AstrBotCoreLifecycle:
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""停止 AstrBot 核心生命周期管理类, 取消所有当前任务并终止各个管理器."""
|
||||
if self.temp_dir_cleaner:
|
||||
await self.temp_dir_cleaner.stop()
|
||||
|
||||
# 请求停止所有正在运行的异步任务
|
||||
for task in self.curr_tasks:
|
||||
task.cancel()
|
||||
|
||||
@@ -8,6 +8,7 @@ from deprecated import deprecated
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
|
||||
from astrbot.core.db.po import (
|
||||
ApiKey,
|
||||
Attachment,
|
||||
ChatUIProject,
|
||||
CommandConfig,
|
||||
@@ -248,6 +249,55 @@ class BaseDatabase(abc.ABC):
|
||||
"""
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
async def create_api_key(
|
||||
self,
|
||||
name: str,
|
||||
key_hash: str,
|
||||
key_prefix: str,
|
||||
scopes: list[str] | None,
|
||||
created_by: str,
|
||||
expires_at: datetime.datetime | None = None,
|
||||
) -> ApiKey:
|
||||
"""Create a new API key record."""
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
async def list_api_keys(self) -> list[ApiKey]:
|
||||
"""List all API keys."""
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
async def get_api_key_by_id(self, key_id: str) -> ApiKey | None:
|
||||
"""Get an API key by key_id."""
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
async def get_active_api_key_by_hash(self, key_hash: str) -> ApiKey | None:
|
||||
"""Get an active API key by hash (not revoked, not expired)."""
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
async def touch_api_key(self, key_id: str) -> None:
|
||||
"""Update last_used_at of an API key."""
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
async def revoke_api_key(self, key_id: str) -> bool:
|
||||
"""Revoke an API key.
|
||||
|
||||
Returns True when the key exists and is updated.
|
||||
"""
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
async def delete_api_key(self, key_id: str) -> bool:
|
||||
"""Delete an API key.
|
||||
|
||||
Returns True when the key exists and is deleted.
|
||||
"""
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
async def insert_persona(
|
||||
self,
|
||||
@@ -608,6 +658,22 @@ class BaseDatabase(abc.ABC):
|
||||
"""
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
async def get_platform_sessions_by_creator_paginated(
|
||||
self,
|
||||
creator: str,
|
||||
platform_id: str | None = None,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
exclude_project_sessions: bool = False,
|
||||
) -> tuple[list[dict], int]:
|
||||
"""Get paginated platform sessions and total count for a creator.
|
||||
|
||||
Returns:
|
||||
tuple[list[dict], int]: (sessions_with_project_info, total_count)
|
||||
"""
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
async def update_platform_session(
|
||||
self,
|
||||
|
||||
@@ -288,6 +288,43 @@ class Attachment(TimestampMixin, SQLModel, table=True):
|
||||
)
|
||||
|
||||
|
||||
class ApiKey(TimestampMixin, SQLModel, table=True):
|
||||
"""API keys used by external developers to access Open APIs."""
|
||||
|
||||
__tablename__: str = "api_keys"
|
||||
|
||||
inner_id: int | None = Field(
|
||||
primary_key=True,
|
||||
sa_column_kwargs={"autoincrement": True},
|
||||
default=None,
|
||||
)
|
||||
key_id: str = Field(
|
||||
max_length=36,
|
||||
nullable=False,
|
||||
unique=True,
|
||||
default_factory=lambda: str(uuid.uuid4()),
|
||||
)
|
||||
name: str = Field(max_length=255, nullable=False)
|
||||
key_hash: str = Field(max_length=128, nullable=False, unique=True)
|
||||
key_prefix: str = Field(max_length=24, nullable=False)
|
||||
scopes: list | None = Field(default=None, sa_type=JSON)
|
||||
created_by: str = Field(max_length=255, nullable=False)
|
||||
last_used_at: datetime | None = Field(default=None)
|
||||
expires_at: datetime | None = Field(default=None)
|
||||
revoked_at: datetime | None = Field(default=None)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"key_id",
|
||||
name="uix_api_key_id",
|
||||
),
|
||||
UniqueConstraint(
|
||||
"key_hash",
|
||||
name="uix_api_key_hash",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class ChatUIProject(TimestampMixin, SQLModel, table=True):
|
||||
"""This class represents projects for organizing ChatUI conversations.
|
||||
|
||||
|
||||
+180
-41
@@ -10,6 +10,7 @@ from sqlmodel import col, delete, desc, func, or_, select, text, update
|
||||
|
||||
from astrbot.core.db import BaseDatabase
|
||||
from astrbot.core.db.po import (
|
||||
ApiKey,
|
||||
Attachment,
|
||||
ChatUIProject,
|
||||
CommandConfig,
|
||||
@@ -573,6 +574,100 @@ class SQLiteDatabase(BaseDatabase):
|
||||
result = T.cast(CursorResult, await session.execute(query))
|
||||
return result.rowcount
|
||||
|
||||
async def create_api_key(
|
||||
self,
|
||||
name: str,
|
||||
key_hash: str,
|
||||
key_prefix: str,
|
||||
scopes: list[str] | None,
|
||||
created_by: str,
|
||||
expires_at: datetime | None = None,
|
||||
) -> ApiKey:
|
||||
"""Create a new API key record."""
|
||||
async with self.get_db() as session:
|
||||
session: AsyncSession
|
||||
async with session.begin():
|
||||
api_key = ApiKey(
|
||||
name=name,
|
||||
key_hash=key_hash,
|
||||
key_prefix=key_prefix,
|
||||
scopes=scopes,
|
||||
created_by=created_by,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
session.add(api_key)
|
||||
await session.flush()
|
||||
await session.refresh(api_key)
|
||||
return api_key
|
||||
|
||||
async def list_api_keys(self) -> list[ApiKey]:
|
||||
"""List all API keys."""
|
||||
async with self.get_db() as session:
|
||||
session: AsyncSession
|
||||
result = await session.execute(
|
||||
select(ApiKey).order_by(desc(ApiKey.created_at))
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def get_api_key_by_id(self, key_id: str) -> ApiKey | None:
|
||||
"""Get an API key by key_id."""
|
||||
async with self.get_db() as session:
|
||||
session: AsyncSession
|
||||
result = await session.execute(
|
||||
select(ApiKey).where(ApiKey.key_id == key_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_active_api_key_by_hash(self, key_hash: str) -> ApiKey | None:
|
||||
"""Get an active API key by hash (not revoked, not expired)."""
|
||||
async with self.get_db() as session:
|
||||
session: AsyncSession
|
||||
now = datetime.now(timezone.utc)
|
||||
query = select(ApiKey).where(
|
||||
ApiKey.key_hash == key_hash,
|
||||
col(ApiKey.revoked_at).is_(None),
|
||||
or_(col(ApiKey.expires_at).is_(None), ApiKey.expires_at > now),
|
||||
)
|
||||
result = await session.execute(query)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def touch_api_key(self, key_id: str) -> None:
|
||||
"""Update last_used_at of an API key."""
|
||||
async with self.get_db() as session:
|
||||
session: AsyncSession
|
||||
async with session.begin():
|
||||
await session.execute(
|
||||
update(ApiKey)
|
||||
.where(ApiKey.key_id == key_id)
|
||||
.values(last_used_at=datetime.now(timezone.utc)),
|
||||
)
|
||||
|
||||
async def revoke_api_key(self, key_id: str) -> bool:
|
||||
"""Revoke an API key."""
|
||||
async with self.get_db() as session:
|
||||
session: AsyncSession
|
||||
async with session.begin():
|
||||
query = (
|
||||
update(ApiKey)
|
||||
.where(ApiKey.key_id == key_id)
|
||||
.values(revoked_at=datetime.now(timezone.utc))
|
||||
)
|
||||
result = T.cast(CursorResult, await session.execute(query))
|
||||
return result.rowcount > 0
|
||||
|
||||
async def delete_api_key(self, key_id: str) -> bool:
|
||||
"""Delete an API key."""
|
||||
async with self.get_db() as session:
|
||||
session: AsyncSession
|
||||
async with session.begin():
|
||||
result = T.cast(
|
||||
CursorResult,
|
||||
await session.execute(
|
||||
delete(ApiKey).where(ApiKey.key_id == key_id)
|
||||
),
|
||||
)
|
||||
return result.rowcount > 0
|
||||
|
||||
async def insert_persona(
|
||||
self,
|
||||
persona_id,
|
||||
@@ -1317,58 +1412,102 @@ class SQLiteDatabase(BaseDatabase):
|
||||
|
||||
Returns a list of dicts containing session info and project info (if session belongs to a project).
|
||||
"""
|
||||
(
|
||||
sessions_with_projects,
|
||||
_,
|
||||
) = await self.get_platform_sessions_by_creator_paginated(
|
||||
creator=creator,
|
||||
platform_id=platform_id,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
exclude_project_sessions=False,
|
||||
)
|
||||
return sessions_with_projects
|
||||
|
||||
@staticmethod
|
||||
def _build_platform_sessions_query(
|
||||
creator: str,
|
||||
platform_id: str | None = None,
|
||||
exclude_project_sessions: bool = False,
|
||||
):
|
||||
query = (
|
||||
select(
|
||||
PlatformSession,
|
||||
col(ChatUIProject.project_id),
|
||||
col(ChatUIProject.title).label("project_title"),
|
||||
col(ChatUIProject.emoji).label("project_emoji"),
|
||||
)
|
||||
.outerjoin(
|
||||
SessionProjectRelation,
|
||||
col(PlatformSession.session_id)
|
||||
== col(SessionProjectRelation.session_id),
|
||||
)
|
||||
.outerjoin(
|
||||
ChatUIProject,
|
||||
col(SessionProjectRelation.project_id) == col(ChatUIProject.project_id),
|
||||
)
|
||||
.where(col(PlatformSession.creator) == creator)
|
||||
)
|
||||
|
||||
if platform_id:
|
||||
query = query.where(PlatformSession.platform_id == platform_id)
|
||||
if exclude_project_sessions:
|
||||
query = query.where(col(ChatUIProject.project_id).is_(None))
|
||||
|
||||
return query
|
||||
|
||||
@staticmethod
|
||||
def _rows_to_session_dicts(rows: list[tuple]) -> list[dict]:
|
||||
sessions_with_projects = []
|
||||
for row in rows:
|
||||
platform_session = row[0]
|
||||
project_id = row[1]
|
||||
project_title = row[2]
|
||||
project_emoji = row[3]
|
||||
|
||||
session_dict = {
|
||||
"session": platform_session,
|
||||
"project_id": project_id,
|
||||
"project_title": project_title,
|
||||
"project_emoji": project_emoji,
|
||||
}
|
||||
sessions_with_projects.append(session_dict)
|
||||
|
||||
return sessions_with_projects
|
||||
|
||||
async def get_platform_sessions_by_creator_paginated(
|
||||
self,
|
||||
creator: str,
|
||||
platform_id: str | None = None,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
exclude_project_sessions: bool = False,
|
||||
) -> tuple[list[dict], int]:
|
||||
"""Get paginated Platform sessions for a creator with total count."""
|
||||
async with self.get_db() as session:
|
||||
session: AsyncSession
|
||||
offset = (page - 1) * page_size
|
||||
|
||||
# LEFT JOIN with SessionProjectRelation and ChatUIProject to get project info
|
||||
query = (
|
||||
select(
|
||||
PlatformSession,
|
||||
col(ChatUIProject.project_id),
|
||||
col(ChatUIProject.title).label("project_title"),
|
||||
col(ChatUIProject.emoji).label("project_emoji"),
|
||||
)
|
||||
.outerjoin(
|
||||
SessionProjectRelation,
|
||||
col(PlatformSession.session_id)
|
||||
== col(SessionProjectRelation.session_id),
|
||||
)
|
||||
.outerjoin(
|
||||
ChatUIProject,
|
||||
col(SessionProjectRelation.project_id)
|
||||
== col(ChatUIProject.project_id),
|
||||
)
|
||||
.where(col(PlatformSession.creator) == creator)
|
||||
base_query = self._build_platform_sessions_query(
|
||||
creator=creator,
|
||||
platform_id=platform_id,
|
||||
exclude_project_sessions=exclude_project_sessions,
|
||||
)
|
||||
|
||||
if platform_id:
|
||||
query = query.where(PlatformSession.platform_id == platform_id)
|
||||
total_result = await session.execute(
|
||||
select(func.count()).select_from(base_query.subquery())
|
||||
)
|
||||
total = int(total_result.scalar_one() or 0)
|
||||
|
||||
query = (
|
||||
query.order_by(desc(PlatformSession.updated_at))
|
||||
result_query = (
|
||||
base_query.order_by(desc(PlatformSession.updated_at))
|
||||
.offset(offset)
|
||||
.limit(page_size)
|
||||
)
|
||||
result = await session.execute(query)
|
||||
result = await session.execute(result_query)
|
||||
|
||||
# Convert to list of dicts with session and project info
|
||||
sessions_with_projects = []
|
||||
for row in result.all():
|
||||
platform_session = row[0]
|
||||
project_id = row[1]
|
||||
project_title = row[2]
|
||||
project_emoji = row[3]
|
||||
|
||||
session_dict = {
|
||||
"session": platform_session,
|
||||
"project_id": project_id,
|
||||
"project_title": project_title,
|
||||
"project_emoji": project_emoji,
|
||||
}
|
||||
sessions_with_projects.append(session_dict)
|
||||
|
||||
return sessions_with_projects
|
||||
sessions_with_projects = self._rows_to_session_dicts(result.all())
|
||||
return sessions_with_projects, total
|
||||
|
||||
async def update_platform_session(
|
||||
self,
|
||||
|
||||
@@ -13,16 +13,19 @@ from astrbot.core.knowledge_base.models import (
|
||||
KBMedia,
|
||||
KnowledgeBase,
|
||||
)
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_knowledge_base_path
|
||||
|
||||
|
||||
class KBSQLiteDatabase:
|
||||
def __init__(self, db_path: str = "data/knowledge_base/kb.db") -> None:
|
||||
def __init__(self, db_path: str | None = None) -> None:
|
||||
"""初始化知识库数据库
|
||||
|
||||
Args:
|
||||
db_path: 数据库文件路径, 默认为 data/knowledge_base/kb.db
|
||||
db_path: 数据库文件路径, 默认位于 AstrBot 数据目录下的 knowledge_base/kb.db
|
||||
|
||||
"""
|
||||
if db_path is None:
|
||||
db_path = str(Path(get_astrbot_knowledge_base_path()) / "kb.db")
|
||||
self.db_path = db_path
|
||||
self.DATABASE_URL = f"sqlite+aiosqlite:///{db_path}"
|
||||
self.inited = False
|
||||
|
||||
@@ -3,6 +3,7 @@ from pathlib import Path
|
||||
|
||||
from astrbot.core import logger
|
||||
from astrbot.core.provider.manager import ProviderManager
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_knowledge_base_path
|
||||
|
||||
# from .chunking.fixed_size import FixedSizeChunker
|
||||
from .chunking.recursive import RecursiveCharacterChunker
|
||||
@@ -13,7 +14,7 @@ from .retrieval.manager import RetrievalManager, RetrievalResult
|
||||
from .retrieval.rank_fusion import RankFusion
|
||||
from .retrieval.sparse_retriever import SparseRetriever
|
||||
|
||||
FILES_PATH = "data/knowledge_base"
|
||||
FILES_PATH = get_astrbot_knowledge_base_path()
|
||||
DB_PATH = Path(FILES_PATH) / "kb.db"
|
||||
"""Knowledge Base storage root directory"""
|
||||
CHUNKER = RecursiveCharacterChunker()
|
||||
@@ -27,7 +28,7 @@ class KnowledgeBaseManager:
|
||||
self,
|
||||
provider_manager: ProviderManager,
|
||||
) -> None:
|
||||
Path(DB_PATH).parent.mkdir(parents=True, exist_ok=True)
|
||||
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
self.provider_manager = provider_manager
|
||||
self._session_deleted_callback_registered = False
|
||||
|
||||
|
||||
+266
-309
@@ -1,24 +1,4 @@
|
||||
"""日志系统, 用于支持核心组件和插件的日志记录, 提供了日志订阅功能
|
||||
|
||||
const:
|
||||
CACHED_SIZE: 日志缓存大小, 用于限制缓存的日志数量
|
||||
log_color_config: 日志颜色配置, 定义了不同日志级别的颜色
|
||||
|
||||
class:
|
||||
LogBroker: 日志代理类, 用于缓存和分发日志消息
|
||||
LogQueueHandler: 日志处理器, 用于将日志消息发送到 LogBroker
|
||||
LogManager: 日志管理器, 用于创建和配置日志记录器
|
||||
|
||||
function:
|
||||
is_plugin_path: 检查文件路径是否来自插件目录
|
||||
get_short_level_name: 将日志级别名称转换为四个字母的缩写
|
||||
|
||||
工作流程:
|
||||
1. 通过 LogManager.GetLogger() 获取日志器, 配置了控制台输出和多个格式化过滤器
|
||||
2. 通过 set_queue_handler() 设置日志处理器, 将日志消息发送到 LogBroker
|
||||
3. logBroker 维护一个订阅者列表, 负责将日志分发给所有订阅者
|
||||
4. 订阅者可以使用 register() 方法注册到 LogBroker, 订阅日志流
|
||||
"""
|
||||
"""日志系统,统一将标准 logging 输出转发到 loguru。"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
@@ -27,54 +7,59 @@ import sys
|
||||
import time
|
||||
from asyncio import Queue
|
||||
from collections import deque
|
||||
from logging.handlers import RotatingFileHandler
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import colorlog
|
||||
from loguru import logger as _raw_loguru_logger
|
||||
|
||||
from astrbot.core.config.default import VERSION
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
||||
|
||||
# 日志缓存大小
|
||||
CACHED_SIZE = 500
|
||||
# 日志颜色配置
|
||||
log_color_config = {
|
||||
"DEBUG": "green",
|
||||
"INFO": "bold_cyan",
|
||||
"WARNING": "bold_yellow",
|
||||
"ERROR": "red",
|
||||
"CRITICAL": "bold_red",
|
||||
"RESET": "reset",
|
||||
"asctime": "green",
|
||||
}
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from loguru import Record
|
||||
|
||||
|
||||
def is_plugin_path(pathname):
|
||||
"""检查文件路径是否来自插件目录
|
||||
class _RecordEnricherFilter(logging.Filter):
|
||||
"""为 logging.LogRecord 注入 AstrBot 日志字段。"""
|
||||
|
||||
Args:
|
||||
pathname (str): 文件路径
|
||||
def filter(self, record: logging.LogRecord) -> bool:
|
||||
record.plugin_tag = "[Plug]" if _is_plugin_path(record.pathname) else "[Core]"
|
||||
record.short_levelname = _get_short_level_name(record.levelname)
|
||||
record.astrbot_version_tag = (
|
||||
f" [v{VERSION}]" if record.levelno >= logging.WARNING else ""
|
||||
)
|
||||
record.source_file = _build_source_file(record.pathname)
|
||||
record.source_line = record.lineno
|
||||
record.is_trace = record.name == "astrbot.trace"
|
||||
return True
|
||||
|
||||
Returns:
|
||||
bool: 如果路径来自插件目录,则返回 True,否则返回 False
|
||||
|
||||
"""
|
||||
class _QueueAnsiColorFilter(logging.Filter):
|
||||
"""Attach ANSI color prefix for WebUI console rendering."""
|
||||
|
||||
_LEVEL_COLOR = {
|
||||
"DEBUG": "\u001b[1;34m",
|
||||
"INFO": "\u001b[1;36m",
|
||||
"WARNING": "\u001b[1;33m",
|
||||
"ERROR": "\u001b[31m",
|
||||
"CRITICAL": "\u001b[1;31m",
|
||||
}
|
||||
|
||||
def filter(self, record: logging.LogRecord) -> bool:
|
||||
record.ansi_prefix = self._LEVEL_COLOR.get(record.levelname, "\u001b[0m")
|
||||
record.ansi_reset = "\u001b[0m"
|
||||
return True
|
||||
|
||||
|
||||
def _is_plugin_path(pathname: str | None) -> bool:
|
||||
if not pathname:
|
||||
return False
|
||||
|
||||
norm_path = os.path.normpath(pathname)
|
||||
return ("data/plugins" in norm_path) or ("astrbot/builtin_stars/" in norm_path)
|
||||
|
||||
|
||||
def get_short_level_name(level_name):
|
||||
"""将日志级别名称转换为四个字母的缩写
|
||||
|
||||
Args:
|
||||
level_name (str): 日志级别名称, 如 "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"
|
||||
|
||||
Returns:
|
||||
str: 四个字母的日志级别缩写
|
||||
|
||||
"""
|
||||
def _get_short_level_name(level_name: str) -> str:
|
||||
level_map = {
|
||||
"DEBUG": "DBUG",
|
||||
"INFO": "INFO",
|
||||
@@ -85,44 +70,75 @@ def get_short_level_name(level_name):
|
||||
return level_map.get(level_name, level_name[:4].upper())
|
||||
|
||||
|
||||
class LogBroker:
|
||||
"""日志代理类, 用于缓存和分发日志消息
|
||||
def _build_source_file(pathname: str | None) -> str:
|
||||
if not pathname:
|
||||
return "unknown"
|
||||
dirname = os.path.dirname(pathname)
|
||||
return (
|
||||
os.path.basename(dirname) + "." + os.path.basename(pathname).replace(".py", "")
|
||||
)
|
||||
|
||||
发布-订阅模式
|
||||
"""
|
||||
|
||||
def _patch_record(record: "Record") -> None:
|
||||
extra = record["extra"]
|
||||
extra.setdefault("plugin_tag", "[Core]")
|
||||
extra.setdefault("short_levelname", _get_short_level_name(record["level"].name))
|
||||
level_no = record["level"].no
|
||||
extra.setdefault("astrbot_version_tag", f" [v{VERSION}]" if level_no >= 30 else "")
|
||||
extra.setdefault("source_file", _build_source_file(record["file"].path))
|
||||
extra.setdefault("source_line", record["line"])
|
||||
extra.setdefault("is_trace", False)
|
||||
|
||||
|
||||
_loguru = _raw_loguru_logger.patch(_patch_record)
|
||||
|
||||
|
||||
class _LoguruInterceptHandler(logging.Handler):
|
||||
"""将 logging 记录转发到 loguru。"""
|
||||
|
||||
def emit(self, record: logging.LogRecord) -> None:
|
||||
try:
|
||||
level: str | int = _loguru.level(record.levelname).name
|
||||
except ValueError:
|
||||
level = record.levelno
|
||||
|
||||
payload = {
|
||||
"plugin_tag": getattr(record, "plugin_tag", "[Core]"),
|
||||
"short_levelname": getattr(
|
||||
record,
|
||||
"short_levelname",
|
||||
_get_short_level_name(record.levelname),
|
||||
),
|
||||
"astrbot_version_tag": getattr(record, "astrbot_version_tag", ""),
|
||||
"source_file": getattr(
|
||||
record, "source_file", _build_source_file(record.pathname)
|
||||
),
|
||||
"source_line": getattr(record, "source_line", record.lineno),
|
||||
"is_trace": getattr(record, "is_trace", record.name == "astrbot.trace"),
|
||||
}
|
||||
|
||||
_loguru.bind(**payload).opt(exception=record.exc_info).log(
|
||||
level,
|
||||
record.getMessage(),
|
||||
)
|
||||
|
||||
|
||||
class LogBroker:
|
||||
"""日志代理类,用于缓存和分发日志消息。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.log_cache = deque(maxlen=CACHED_SIZE) # 环形缓冲区, 保存最近的日志
|
||||
self.subscribers: list[Queue] = [] # 订阅者列表
|
||||
self.log_cache = deque(maxlen=CACHED_SIZE)
|
||||
self.subscribers: list[Queue] = []
|
||||
|
||||
def register(self) -> Queue:
|
||||
"""注册新的订阅者, 并给每个订阅者返回一个带有日志缓存的队列
|
||||
|
||||
Returns:
|
||||
Queue: 订阅者的队列, 可用于接收日志消息
|
||||
|
||||
"""
|
||||
q = Queue(maxsize=CACHED_SIZE + 10)
|
||||
self.subscribers.append(q)
|
||||
return q
|
||||
|
||||
def unregister(self, q: Queue) -> None:
|
||||
"""取消订阅
|
||||
|
||||
Args:
|
||||
q (Queue): 需要取消订阅的队列
|
||||
|
||||
"""
|
||||
self.subscribers.remove(q)
|
||||
|
||||
def publish(self, log_entry: dict) -> None:
|
||||
"""发布新日志到所有订阅者, 使用非阻塞方式投递, 避免一个订阅者阻塞整个系统
|
||||
|
||||
Args:
|
||||
log_entry (dict): 日志消息, 包含日志级别和日志内容.
|
||||
example: {"level": "INFO", "data": "This is a log message.", "time": "2023-10-01 12:00:00"}
|
||||
|
||||
"""
|
||||
self.log_cache.append(log_entry)
|
||||
for q in self.subscribers:
|
||||
try:
|
||||
@@ -132,23 +148,13 @@ class LogBroker:
|
||||
|
||||
|
||||
class LogQueueHandler(logging.Handler):
|
||||
"""日志处理器, 用于将日志消息发送到 LogBroker
|
||||
|
||||
继承自 logging.Handler
|
||||
"""
|
||||
"""日志处理器,用于将日志消息发送到 LogBroker。"""
|
||||
|
||||
def __init__(self, log_broker: LogBroker) -> None:
|
||||
super().__init__()
|
||||
self.log_broker = log_broker
|
||||
|
||||
def emit(self, record) -> None:
|
||||
"""日志处理的入口方法, 接受一个日志记录, 转换为字符串后由 LogBroker 发布
|
||||
这个方法会在每次日志记录时被调用
|
||||
|
||||
Args:
|
||||
record (logging.LogRecord): 日志记录对象, 包含日志信息
|
||||
|
||||
"""
|
||||
def emit(self, record: logging.LogRecord) -> None:
|
||||
log_entry = self.format(record)
|
||||
self.log_broker.publish(
|
||||
{
|
||||
@@ -160,117 +166,16 @@ class LogQueueHandler(logging.Handler):
|
||||
|
||||
|
||||
class LogManager:
|
||||
"""日志管理器, 用于创建和配置日志记录器
|
||||
_LOGGER_HANDLER_FLAG = "_astrbot_loguru_handler"
|
||||
_ENRICH_FILTER_FLAG = "_astrbot_enrich_filter"
|
||||
|
||||
提供了获取默认日志记录器logger和设置队列处理器的方法
|
||||
"""
|
||||
|
||||
_FILE_HANDLER_FLAG = "_astrbot_file_handler"
|
||||
_TRACE_FILE_HANDLER_FLAG = "_astrbot_trace_file_handler"
|
||||
|
||||
@classmethod
|
||||
def GetLogger(cls, log_name: str = "default"):
|
||||
"""获取指定名称的日志记录器logger
|
||||
|
||||
Args:
|
||||
log_name (str): 日志记录器的名称, 默认为 "default"
|
||||
|
||||
Returns:
|
||||
logging.Logger: 返回配置好的日志记录器
|
||||
|
||||
"""
|
||||
logger = logging.getLogger(log_name)
|
||||
# 检查该logger或父级logger是否已经有处理器, 如果已经有处理器, 直接返回该logger, 避免重复配置
|
||||
if logger.hasHandlers():
|
||||
return logger
|
||||
# 如果logger没有处理器
|
||||
console_handler = logging.StreamHandler(
|
||||
sys.stdout,
|
||||
) # 创建一个StreamHandler用于控制台输出
|
||||
console_handler.setLevel(
|
||||
logging.DEBUG,
|
||||
) # 将日志级别设置为DEBUG(最低级别, 显示所有日志), *如果插件没有设置级别, 默认为DEBUG
|
||||
|
||||
# 创建彩色日志格式化器, 输出日志格式为: [时间] [插件标签] [日志级别] [文件名:行号]: 日志消息
|
||||
console_formatter = colorlog.ColoredFormatter(
|
||||
fmt="%(log_color)s [%(asctime)s] %(plugin_tag)s [%(short_levelname)-4s]%(astrbot_version_tag)s [%(filename)s:%(lineno)d]: %(message)s %(reset)s",
|
||||
datefmt="%H:%M:%S",
|
||||
log_colors=log_color_config,
|
||||
)
|
||||
|
||||
class PluginFilter(logging.Filter):
|
||||
"""插件过滤器类, 用于标记日志来源是插件还是核心组件"""
|
||||
|
||||
def filter(self, record) -> bool:
|
||||
record.plugin_tag = (
|
||||
"[Plug]" if is_plugin_path(record.pathname) else "[Core]"
|
||||
)
|
||||
return True
|
||||
|
||||
class FileNameFilter(logging.Filter):
|
||||
"""文件名过滤器类, 用于修改日志记录的文件名格式
|
||||
例如: 将文件路径 /path/to/file.py 转换为 file.<file> 格式
|
||||
"""
|
||||
|
||||
# 获取这个文件和父文件夹的名字:<folder>.<file> 并且去除 .py
|
||||
def filter(self, record) -> bool:
|
||||
dirname = os.path.dirname(record.pathname)
|
||||
record.filename = (
|
||||
os.path.basename(dirname)
|
||||
+ "."
|
||||
+ os.path.basename(record.pathname).replace(".py", "")
|
||||
)
|
||||
return True
|
||||
|
||||
class LevelNameFilter(logging.Filter):
|
||||
"""短日志级别名称过滤器类, 用于将日志级别名称转换为四个字母的缩写"""
|
||||
|
||||
# 添加短日志级别名称
|
||||
def filter(self, record) -> bool:
|
||||
record.short_levelname = get_short_level_name(record.levelname)
|
||||
return True
|
||||
|
||||
class AstrBotVersionTagFilter(logging.Filter):
|
||||
"""在 WARNING 及以上级别日志后追加当前 AstrBot 版本号。"""
|
||||
|
||||
def filter(self, record) -> bool:
|
||||
if record.levelno >= logging.WARNING:
|
||||
record.astrbot_version_tag = f" [v{VERSION}]"
|
||||
else:
|
||||
record.astrbot_version_tag = ""
|
||||
return True
|
||||
|
||||
console_handler.setFormatter(console_formatter) # 设置处理器的格式化器
|
||||
logger.addFilter(PluginFilter()) # 添加插件过滤器
|
||||
logger.addFilter(FileNameFilter()) # 添加文件名过滤器
|
||||
logger.addFilter(LevelNameFilter()) # 添加级别名称过滤器
|
||||
logger.addFilter(AstrBotVersionTagFilter()) # 追加版本号(WARNING 及以上)
|
||||
logger.setLevel(logging.DEBUG) # 设置日志级别为DEBUG
|
||||
logger.addHandler(console_handler) # 添加处理器到logger
|
||||
|
||||
return logger
|
||||
|
||||
@classmethod
|
||||
def set_queue_handler(cls, logger: logging.Logger, log_broker: LogBroker) -> None:
|
||||
"""设置队列处理器, 用于将日志消息发送到 LogBroker
|
||||
|
||||
Args:
|
||||
logger (logging.Logger): 日志记录器
|
||||
log_broker (LogBroker): 日志代理类, 用于缓存和分发日志消息
|
||||
|
||||
"""
|
||||
handler = LogQueueHandler(log_broker)
|
||||
handler.setLevel(logging.DEBUG)
|
||||
if logger.handlers:
|
||||
handler.setFormatter(logger.handlers[0].formatter)
|
||||
else:
|
||||
# 为队列处理器设置相同格式的formatter
|
||||
handler.setFormatter(
|
||||
logging.Formatter(
|
||||
"[%(asctime)s] [%(short_levelname)s] %(plugin_tag)s[%(filename)s:%(lineno)d]: %(message)s",
|
||||
),
|
||||
)
|
||||
logger.addHandler(handler)
|
||||
_configured = False
|
||||
_console_sink_id: int | None = None
|
||||
_file_sink_id: int | None = None
|
||||
_trace_sink_id: int | None = None
|
||||
_NOISY_LOGGER_LEVELS: dict[str, int] = {
|
||||
"aiosqlite": logging.WARNING,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def _default_log_path(cls) -> str:
|
||||
@@ -285,79 +190,147 @@ class LogManager:
|
||||
return os.path.join(get_astrbot_data_path(), configured_path)
|
||||
|
||||
@classmethod
|
||||
def _get_file_handlers(cls, logger: logging.Logger) -> list[logging.Handler]:
|
||||
return [
|
||||
handler
|
||||
for handler in logger.handlers
|
||||
if getattr(handler, cls._FILE_HANDLER_FLAG, False)
|
||||
]
|
||||
def _setup_loguru(cls) -> None:
|
||||
if cls._configured:
|
||||
return
|
||||
|
||||
@classmethod
|
||||
def _get_trace_file_handlers(cls, logger: logging.Logger) -> list[logging.Handler]:
|
||||
return [
|
||||
handler
|
||||
for handler in logger.handlers
|
||||
if getattr(handler, cls._TRACE_FILE_HANDLER_FLAG, False)
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def _remove_file_handlers(cls, logger: logging.Logger) -> None:
|
||||
for handler in cls._get_file_handlers(logger):
|
||||
logger.removeHandler(handler)
|
||||
try:
|
||||
handler.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def _remove_trace_file_handlers(cls, logger: logging.Logger) -> None:
|
||||
for handler in cls._get_trace_file_handlers(logger):
|
||||
logger.removeHandler(handler)
|
||||
try:
|
||||
handler.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def _add_file_handler(
|
||||
cls,
|
||||
logger: logging.Logger,
|
||||
file_path: str,
|
||||
max_mb: int | None = None,
|
||||
backup_count: int = 3,
|
||||
trace: bool = False,
|
||||
) -> None:
|
||||
os.makedirs(os.path.dirname(file_path) or ".", exist_ok=True)
|
||||
max_bytes = 0
|
||||
if max_mb and max_mb > 0:
|
||||
max_bytes = max_mb * 1024 * 1024
|
||||
if max_bytes > 0:
|
||||
file_handler = RotatingFileHandler(
|
||||
file_path,
|
||||
maxBytes=max_bytes,
|
||||
backupCount=backup_count,
|
||||
encoding="utf-8",
|
||||
)
|
||||
else:
|
||||
file_handler = logging.FileHandler(file_path, encoding="utf-8")
|
||||
file_handler.setLevel(logger.level)
|
||||
if trace:
|
||||
formatter = logging.Formatter(
|
||||
"[%(asctime)s] %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
else:
|
||||
formatter = logging.Formatter(
|
||||
"[%(asctime)s] %(plugin_tag)s [%(short_levelname)s]%(astrbot_version_tag)s [%(filename)s:%(lineno)d]: %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
file_handler.setFormatter(formatter)
|
||||
setattr(
|
||||
file_handler,
|
||||
cls._TRACE_FILE_HANDLER_FLAG if trace else cls._FILE_HANDLER_FLAG,
|
||||
True,
|
||||
_loguru.remove()
|
||||
cls._console_sink_id = _loguru.add(
|
||||
sys.stdout,
|
||||
level="DEBUG",
|
||||
colorize=True,
|
||||
filter=lambda record: not record["extra"].get("is_trace", False),
|
||||
format=(
|
||||
"<green>[{time:HH:mm:ss.SSS}]</green> {extra[plugin_tag]} "
|
||||
"<level>[{extra[short_levelname]}]</level>{extra[astrbot_version_tag]} "
|
||||
"[{extra[source_file]}:{extra[source_line]}]: <level>{message}</level>"
|
||||
),
|
||||
)
|
||||
cls._configured = True
|
||||
|
||||
@classmethod
|
||||
def _setup_root_bridge(cls) -> None:
|
||||
root_logger = logging.getLogger()
|
||||
|
||||
has_handler = any(
|
||||
getattr(handler, cls._LOGGER_HANDLER_FLAG, False)
|
||||
for handler in root_logger.handlers
|
||||
)
|
||||
if not has_handler:
|
||||
handler = _LoguruInterceptHandler()
|
||||
setattr(handler, cls._LOGGER_HANDLER_FLAG, True)
|
||||
root_logger.addHandler(handler)
|
||||
root_logger.setLevel(logging.DEBUG)
|
||||
for name, level in cls._NOISY_LOGGER_LEVELS.items():
|
||||
logging.getLogger(name).setLevel(level)
|
||||
|
||||
@classmethod
|
||||
def _ensure_logger_enricher_filter(cls, logger: logging.Logger) -> None:
|
||||
has_filter = any(
|
||||
getattr(existing_filter, cls._ENRICH_FILTER_FLAG, False)
|
||||
for existing_filter in logger.filters
|
||||
)
|
||||
if not has_filter:
|
||||
enrich_filter = _RecordEnricherFilter()
|
||||
setattr(enrich_filter, cls._ENRICH_FILTER_FLAG, True)
|
||||
logger.addFilter(enrich_filter)
|
||||
|
||||
@classmethod
|
||||
def _ensure_logger_intercept_handler(cls, logger: logging.Logger) -> None:
|
||||
has_handler = any(
|
||||
getattr(handler, cls._LOGGER_HANDLER_FLAG, False)
|
||||
for handler in logger.handlers
|
||||
)
|
||||
if not has_handler:
|
||||
handler = _LoguruInterceptHandler()
|
||||
setattr(handler, cls._LOGGER_HANDLER_FLAG, True)
|
||||
logger.addHandler(handler)
|
||||
|
||||
@classmethod
|
||||
def GetLogger(cls, log_name: str = "default") -> logging.Logger:
|
||||
cls._setup_loguru()
|
||||
cls._setup_root_bridge()
|
||||
|
||||
logger = logging.getLogger(log_name)
|
||||
cls._ensure_logger_enricher_filter(logger)
|
||||
cls._ensure_logger_intercept_handler(logger)
|
||||
logger.setLevel(logging.DEBUG)
|
||||
logger.propagate = False
|
||||
return logger
|
||||
|
||||
@classmethod
|
||||
def set_queue_handler(cls, logger: logging.Logger, log_broker: LogBroker) -> None:
|
||||
cls._ensure_logger_enricher_filter(logger)
|
||||
|
||||
for handler in logger.handlers:
|
||||
if isinstance(handler, LogQueueHandler):
|
||||
return
|
||||
|
||||
handler = LogQueueHandler(log_broker)
|
||||
handler.setLevel(logging.DEBUG)
|
||||
handler.addFilter(_QueueAnsiColorFilter())
|
||||
handler.setFormatter(
|
||||
logging.Formatter(
|
||||
"%(ansi_prefix)s[%(asctime)s.%(msecs)03d] %(plugin_tag)s [%(short_levelname)s]%(astrbot_version_tag)s "
|
||||
"[%(source_file)s:%(source_line)d]: %(message)s%(ansi_reset)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
),
|
||||
)
|
||||
logger.addHandler(handler)
|
||||
|
||||
@classmethod
|
||||
def _remove_sink(cls, sink_id: int | None) -> None:
|
||||
if sink_id is None:
|
||||
return
|
||||
try:
|
||||
_loguru.remove(sink_id)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def _add_file_sink(
|
||||
cls,
|
||||
*,
|
||||
file_path: str,
|
||||
level: int,
|
||||
max_mb: int | None,
|
||||
backup_count: int,
|
||||
trace: bool,
|
||||
) -> int:
|
||||
os.makedirs(os.path.dirname(file_path) or ".", exist_ok=True)
|
||||
rotation = f"{max_mb} MB" if max_mb and max_mb > 0 else None
|
||||
retention = (
|
||||
backup_count if rotation and backup_count and backup_count > 0 else None
|
||||
)
|
||||
if trace:
|
||||
return _loguru.add(
|
||||
file_path,
|
||||
level="INFO",
|
||||
format="[{time:YYYY-MM-DD HH:mm:ss.SSS}] {message}",
|
||||
encoding="utf-8",
|
||||
rotation=rotation,
|
||||
retention=retention,
|
||||
enqueue=True,
|
||||
filter=lambda record: record["extra"].get("is_trace", False),
|
||||
)
|
||||
|
||||
logging_level_name = logging.getLevelName(level)
|
||||
if isinstance(logging_level_name, int):
|
||||
logging_level_name = "INFO"
|
||||
return _loguru.add(
|
||||
file_path,
|
||||
level=logging_level_name,
|
||||
format=(
|
||||
"[{time:YYYY-MM-DD HH:mm:ss.SSS}] {extra[plugin_tag]} "
|
||||
"[{extra[short_levelname]}]{extra[astrbot_version_tag]} "
|
||||
"[{extra[source_file]}:{extra[source_line]}]: {message}"
|
||||
),
|
||||
encoding="utf-8",
|
||||
rotation=rotation,
|
||||
retention=retention,
|
||||
enqueue=True,
|
||||
filter=lambda record: not record["extra"].get("is_trace", False),
|
||||
)
|
||||
logger.addHandler(file_handler)
|
||||
|
||||
@classmethod
|
||||
def configure_logger(
|
||||
@@ -366,13 +339,6 @@ class LogManager:
|
||||
config: dict | None,
|
||||
override_level: str | None = None,
|
||||
) -> None:
|
||||
"""根据配置设置日志级别和文件日志。
|
||||
|
||||
Args:
|
||||
logger: 需要配置的 logger
|
||||
config: 配置字典
|
||||
override_level: 若提供,将覆盖配置中的日志级别
|
||||
"""
|
||||
if not config:
|
||||
return
|
||||
|
||||
@@ -383,7 +349,6 @@ class LogManager:
|
||||
except Exception:
|
||||
logger.setLevel(logging.INFO)
|
||||
|
||||
# 兼容旧版嵌套配置
|
||||
if "log_file" in config:
|
||||
file_conf = config.get("log_file") or {}
|
||||
enable_file = bool(file_conf.get("enable", False))
|
||||
@@ -394,27 +359,25 @@ class LogManager:
|
||||
file_path = config.get("log_file_path")
|
||||
max_mb = config.get("log_file_max_mb")
|
||||
|
||||
file_path = cls._resolve_log_path(file_path)
|
||||
cls._remove_sink(cls._file_sink_id)
|
||||
cls._file_sink_id = None
|
||||
|
||||
existing = cls._get_file_handlers(logger)
|
||||
if not enable_file:
|
||||
cls._remove_file_handlers(logger)
|
||||
return
|
||||
|
||||
# 如果已有文件处理器且路径一致,则仅同步级别
|
||||
if existing:
|
||||
handler = existing[0]
|
||||
base = getattr(handler, "baseFilename", "")
|
||||
if base and os.path.abspath(base) == os.path.abspath(file_path):
|
||||
handler.setLevel(logger.level)
|
||||
return
|
||||
cls._remove_file_handlers(logger)
|
||||
|
||||
cls._add_file_handler(logger, file_path, max_mb=max_mb)
|
||||
try:
|
||||
cls._file_sink_id = cls._add_file_sink(
|
||||
file_path=cls._resolve_log_path(file_path),
|
||||
level=logger.level,
|
||||
max_mb=max_mb,
|
||||
backup_count=3,
|
||||
trace=False,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to add file sink: {e}")
|
||||
|
||||
@classmethod
|
||||
def configure_trace_logger(cls, config: dict | None) -> None:
|
||||
"""为 trace 事件配置独立的文件日志,不向控制台输出。"""
|
||||
if not config:
|
||||
return
|
||||
|
||||
@@ -429,28 +392,22 @@ class LogManager:
|
||||
path = path or legacy.get("trace_path")
|
||||
max_mb = max_mb or legacy.get("trace_max_mb")
|
||||
|
||||
if not enable:
|
||||
trace_logger = logging.getLogger("astrbot.trace")
|
||||
cls._remove_trace_file_handlers(trace_logger)
|
||||
return
|
||||
|
||||
file_path = cls._resolve_log_path(path or "logs/astrbot.trace.log")
|
||||
trace_logger = logging.getLogger("astrbot.trace")
|
||||
cls._ensure_logger_enricher_filter(trace_logger)
|
||||
cls._ensure_logger_intercept_handler(trace_logger)
|
||||
trace_logger.setLevel(logging.INFO)
|
||||
trace_logger.propagate = False
|
||||
|
||||
existing = cls._get_trace_file_handlers(trace_logger)
|
||||
if existing:
|
||||
handler = existing[0]
|
||||
base = getattr(handler, "baseFilename", "")
|
||||
if base and os.path.abspath(base) == os.path.abspath(file_path):
|
||||
handler.setLevel(trace_logger.level)
|
||||
return
|
||||
cls._remove_trace_file_handlers(trace_logger)
|
||||
cls._remove_sink(cls._trace_sink_id)
|
||||
cls._trace_sink_id = None
|
||||
|
||||
cls._add_file_handler(
|
||||
trace_logger,
|
||||
file_path,
|
||||
if not enable:
|
||||
return
|
||||
|
||||
cls._trace_sink_id = cls._add_file_sink(
|
||||
file_path=cls._resolve_log_path(path or "logs/astrbot.trace.log"),
|
||||
level=logging.INFO,
|
||||
max_mb=max_mb,
|
||||
backup_count=3,
|
||||
trace=True,
|
||||
)
|
||||
|
||||
@@ -25,13 +25,17 @@ import asyncio
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import uuid
|
||||
from enum import Enum
|
||||
|
||||
from pydantic.v1 import BaseModel
|
||||
if sys.version_info >= (3, 14):
|
||||
from pydantic import BaseModel
|
||||
else:
|
||||
from pydantic.v1 import BaseModel
|
||||
|
||||
from astrbot.core import astrbot_config, file_token_service, logger
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
|
||||
from astrbot.core.utils.io import download_file, download_image_by_url, file_to_base64
|
||||
|
||||
|
||||
@@ -85,7 +89,7 @@ class BaseMessageComponent(BaseModel):
|
||||
|
||||
|
||||
class Plain(BaseMessageComponent):
|
||||
type = ComponentType.Plain
|
||||
type: ComponentType = ComponentType.Plain
|
||||
text: str
|
||||
convert: bool | None = True
|
||||
|
||||
@@ -100,7 +104,7 @@ class Plain(BaseMessageComponent):
|
||||
|
||||
|
||||
class Face(BaseMessageComponent):
|
||||
type = ComponentType.Face
|
||||
type: ComponentType = ComponentType.Face
|
||||
id: int
|
||||
|
||||
def __init__(self, **_) -> None:
|
||||
@@ -108,13 +112,15 @@ class Face(BaseMessageComponent):
|
||||
|
||||
|
||||
class Record(BaseMessageComponent):
|
||||
type = ComponentType.Record
|
||||
type: ComponentType = ComponentType.Record
|
||||
file: str | None = ""
|
||||
magic: bool | None = False
|
||||
url: str | None = ""
|
||||
cache: bool | None = True
|
||||
proxy: bool | None = True
|
||||
timeout: int | None = 0
|
||||
# Original text content (e.g. TTS source text), used as caption in fallback scenarios
|
||||
text: str | None = None
|
||||
# 额外
|
||||
path: str | None
|
||||
|
||||
@@ -156,8 +162,9 @@ class Record(BaseMessageComponent):
|
||||
if self.file.startswith("base64://"):
|
||||
bs64_data = self.file.removeprefix("base64://")
|
||||
image_bytes = base64.b64decode(bs64_data)
|
||||
temp_dir = os.path.join(get_astrbot_data_path(), "temp")
|
||||
file_path = os.path.join(temp_dir, f"{uuid.uuid4()}.jpg")
|
||||
file_path = os.path.join(
|
||||
get_astrbot_temp_path(), f"recordseg_{uuid.uuid4()}.jpg"
|
||||
)
|
||||
with open(file_path, "wb") as f:
|
||||
f.write(image_bytes)
|
||||
return os.path.abspath(file_path)
|
||||
@@ -214,7 +221,7 @@ class Record(BaseMessageComponent):
|
||||
|
||||
|
||||
class Video(BaseMessageComponent):
|
||||
type = ComponentType.Video
|
||||
type: ComponentType = ComponentType.Video
|
||||
file: str
|
||||
cover: str | None = ""
|
||||
c: int | None = 2
|
||||
@@ -245,8 +252,9 @@ class Video(BaseMessageComponent):
|
||||
if url and url.startswith("file:///"):
|
||||
return url[8:]
|
||||
if url and url.startswith("http"):
|
||||
download_dir = os.path.join(get_astrbot_data_path(), "temp")
|
||||
video_file_path = os.path.join(download_dir, f"{uuid.uuid4().hex}")
|
||||
video_file_path = os.path.join(
|
||||
get_astrbot_temp_path(), f"videoseg_{uuid.uuid4().hex}"
|
||||
)
|
||||
await download_file(url, video_file_path)
|
||||
if os.path.exists(video_file_path):
|
||||
return os.path.abspath(video_file_path)
|
||||
@@ -299,7 +307,7 @@ class Video(BaseMessageComponent):
|
||||
|
||||
|
||||
class At(BaseMessageComponent):
|
||||
type = ComponentType.At
|
||||
type: ComponentType = ComponentType.At
|
||||
qq: int | str # 此处str为all时代表所有人
|
||||
name: str | None = ""
|
||||
|
||||
@@ -321,28 +329,28 @@ class AtAll(At):
|
||||
|
||||
|
||||
class RPS(BaseMessageComponent): # TODO
|
||||
type = ComponentType.RPS
|
||||
type: ComponentType = ComponentType.RPS
|
||||
|
||||
def __init__(self, **_) -> None:
|
||||
super().__init__(**_)
|
||||
|
||||
|
||||
class Dice(BaseMessageComponent): # TODO
|
||||
type = ComponentType.Dice
|
||||
type: ComponentType = ComponentType.Dice
|
||||
|
||||
def __init__(self, **_) -> None:
|
||||
super().__init__(**_)
|
||||
|
||||
|
||||
class Shake(BaseMessageComponent): # TODO
|
||||
type = ComponentType.Shake
|
||||
type: ComponentType = ComponentType.Shake
|
||||
|
||||
def __init__(self, **_) -> None:
|
||||
super().__init__(**_)
|
||||
|
||||
|
||||
class Share(BaseMessageComponent):
|
||||
type = ComponentType.Share
|
||||
type: ComponentType = ComponentType.Share
|
||||
url: str
|
||||
title: str
|
||||
content: str | None = ""
|
||||
@@ -353,7 +361,7 @@ class Share(BaseMessageComponent):
|
||||
|
||||
|
||||
class Contact(BaseMessageComponent): # TODO
|
||||
type = ComponentType.Contact
|
||||
type: ComponentType = ComponentType.Contact
|
||||
_type: str # type 字段冲突
|
||||
id: int | None = 0
|
||||
|
||||
@@ -362,7 +370,7 @@ class Contact(BaseMessageComponent): # TODO
|
||||
|
||||
|
||||
class Location(BaseMessageComponent): # TODO
|
||||
type = ComponentType.Location
|
||||
type: ComponentType = ComponentType.Location
|
||||
lat: float
|
||||
lon: float
|
||||
title: str | None = ""
|
||||
@@ -373,7 +381,7 @@ class Location(BaseMessageComponent): # TODO
|
||||
|
||||
|
||||
class Music(BaseMessageComponent):
|
||||
type = ComponentType.Music
|
||||
type: ComponentType = ComponentType.Music
|
||||
_type: str
|
||||
id: int | None = 0
|
||||
url: str | None = ""
|
||||
@@ -390,7 +398,7 @@ class Music(BaseMessageComponent):
|
||||
|
||||
|
||||
class Image(BaseMessageComponent):
|
||||
type = ComponentType.Image
|
||||
type: ComponentType = ComponentType.Image
|
||||
file: str | None = ""
|
||||
_type: str | None = ""
|
||||
subType: int | None = 0
|
||||
@@ -445,8 +453,9 @@ class Image(BaseMessageComponent):
|
||||
if url.startswith("base64://"):
|
||||
bs64_data = url.removeprefix("base64://")
|
||||
image_bytes = base64.b64decode(bs64_data)
|
||||
temp_dir = os.path.join(get_astrbot_data_path(), "temp")
|
||||
image_file_path = os.path.join(temp_dir, f"{uuid.uuid4()}.jpg")
|
||||
image_file_path = os.path.join(
|
||||
get_astrbot_temp_path(), f"imgseg_{uuid.uuid4()}.jpg"
|
||||
)
|
||||
with open(image_file_path, "wb") as f:
|
||||
f.write(image_bytes)
|
||||
return os.path.abspath(image_file_path)
|
||||
@@ -504,7 +513,7 @@ class Image(BaseMessageComponent):
|
||||
|
||||
|
||||
class Reply(BaseMessageComponent):
|
||||
type = ComponentType.Reply
|
||||
type: ComponentType = ComponentType.Reply
|
||||
id: str | int
|
||||
"""所引用的消息 ID"""
|
||||
chain: list["BaseMessageComponent"] | None = []
|
||||
@@ -540,7 +549,7 @@ class Poke(BaseMessageComponent):
|
||||
|
||||
|
||||
class Forward(BaseMessageComponent):
|
||||
type = ComponentType.Forward
|
||||
type: ComponentType = ComponentType.Forward
|
||||
id: str
|
||||
|
||||
def __init__(self, **_) -> None:
|
||||
@@ -550,7 +559,7 @@ class Forward(BaseMessageComponent):
|
||||
class Node(BaseMessageComponent):
|
||||
"""群合并转发消息"""
|
||||
|
||||
type = ComponentType.Node
|
||||
type: ComponentType = ComponentType.Node
|
||||
id: int | None = 0 # 忽略
|
||||
name: str | None = "" # qq昵称
|
||||
uin: str | None = "0" # qq号
|
||||
@@ -602,7 +611,7 @@ class Node(BaseMessageComponent):
|
||||
|
||||
|
||||
class Nodes(BaseMessageComponent):
|
||||
type = ComponentType.Nodes
|
||||
type: ComponentType = ComponentType.Nodes
|
||||
nodes: list[Node]
|
||||
|
||||
def __init__(self, nodes: list[Node], **_) -> None:
|
||||
@@ -628,7 +637,7 @@ class Nodes(BaseMessageComponent):
|
||||
|
||||
|
||||
class Json(BaseMessageComponent):
|
||||
type = ComponentType.Json
|
||||
type: ComponentType = ComponentType.Json
|
||||
data: dict
|
||||
|
||||
def __init__(self, data: str | dict, **_) -> None:
|
||||
@@ -638,14 +647,14 @@ class Json(BaseMessageComponent):
|
||||
|
||||
|
||||
class Unknown(BaseMessageComponent):
|
||||
type = ComponentType.Unknown
|
||||
type: ComponentType = ComponentType.Unknown
|
||||
text: str
|
||||
|
||||
|
||||
class File(BaseMessageComponent):
|
||||
"""文件消息段"""
|
||||
|
||||
type = ComponentType.File
|
||||
type: ComponentType = ComponentType.File
|
||||
name: str | None = "" # 名字
|
||||
file_: str | None = "" # 本地路径
|
||||
url: str | None = "" # url
|
||||
@@ -725,13 +734,12 @@ class File(BaseMessageComponent):
|
||||
"""下载文件"""
|
||||
if not self.url:
|
||||
raise ValueError("Download failed: No URL provided in File component.")
|
||||
download_dir = os.path.join(get_astrbot_data_path(), "temp")
|
||||
os.makedirs(download_dir, exist_ok=True)
|
||||
download_dir = get_astrbot_temp_path()
|
||||
if self.name:
|
||||
name, ext = os.path.splitext(self.name)
|
||||
filename = f"{name}_{uuid.uuid4().hex[:8]}{ext}"
|
||||
filename = f"fileseg_{name}_{uuid.uuid4().hex[:8]}{ext}"
|
||||
else:
|
||||
filename = f"{uuid.uuid4().hex}"
|
||||
filename = f"fileseg_{uuid.uuid4().hex}"
|
||||
file_path = os.path.join(download_dir, filename)
|
||||
await download_file(self.url, file_path)
|
||||
self.file_ = os.path.abspath(file_path)
|
||||
@@ -781,7 +789,7 @@ class File(BaseMessageComponent):
|
||||
|
||||
|
||||
class WechatEmoji(BaseMessageComponent):
|
||||
type = ComponentType.WechatEmoji
|
||||
type: ComponentType = ComponentType.WechatEmoji
|
||||
md5: str | None = ""
|
||||
md5_len: int | None = 0
|
||||
cdnurl: str | None = ""
|
||||
|
||||
@@ -44,6 +44,7 @@ class InternalAgentSubStage(Stage):
|
||||
]
|
||||
self.max_step: int = settings.get("max_agent_step", 30)
|
||||
self.tool_call_timeout: int = settings.get("tool_call_timeout", 60)
|
||||
self.tool_call_approval: dict = settings.get("tool_call_approval", {})
|
||||
self.tool_schema_mode: str = settings.get("tool_schema_mode", "full")
|
||||
if self.tool_schema_mode not in ("skills_like", "full"):
|
||||
logger.warning(
|
||||
@@ -123,6 +124,8 @@ class InternalAgentSubStage(Stage):
|
||||
provider_settings=settings,
|
||||
subagent_orchestrator=conf.get("subagent_orchestrator", {}),
|
||||
timezone=self.ctx.plugin_manager.context.get_config().get("timezone"),
|
||||
max_quoted_fallback_images=settings.get("max_quoted_fallback_images", 20),
|
||||
tool_call_approval=self.tool_call_approval,
|
||||
)
|
||||
|
||||
async def process(
|
||||
@@ -149,6 +152,7 @@ class InternalAgentSubStage(Stage):
|
||||
|
||||
logger.debug("ready to request llm provider")
|
||||
|
||||
await event.send_typing()
|
||||
await call_event_hook(event, EventType.OnWaitingLLMRequestEvent)
|
||||
|
||||
async with session_lock_manager.acquire_lock(event.unified_msg_origin):
|
||||
@@ -190,6 +194,8 @@ class InternalAgentSubStage(Stage):
|
||||
)
|
||||
|
||||
if await call_event_hook(event, EventType.OnLLMRequestEvent, req):
|
||||
if reset_coro:
|
||||
reset_coro.close()
|
||||
return
|
||||
|
||||
# apply reset
|
||||
@@ -243,13 +249,16 @@ class InternalAgentSubStage(Stage):
|
||||
yield
|
||||
|
||||
# 保存历史记录
|
||||
if not event.is_stopped() and agent_runner.done():
|
||||
if agent_runner.done() and (
|
||||
not event.is_stopped() or agent_runner.was_aborted()
|
||||
):
|
||||
await self._save_to_history(
|
||||
event,
|
||||
req,
|
||||
agent_runner.get_final_llm_resp(),
|
||||
agent_runner.run_context.messages,
|
||||
agent_runner.stats,
|
||||
user_aborted=agent_runner.was_aborted(),
|
||||
)
|
||||
|
||||
elif streaming_response and not stream_to_general:
|
||||
@@ -304,13 +313,14 @@ class InternalAgentSubStage(Stage):
|
||||
)
|
||||
|
||||
# 检查事件是否被停止,如果被停止则不保存历史记录
|
||||
if not event.is_stopped():
|
||||
if not event.is_stopped() or agent_runner.was_aborted():
|
||||
await self._save_to_history(
|
||||
event,
|
||||
req,
|
||||
final_resp,
|
||||
agent_runner.run_context.messages,
|
||||
agent_runner.stats,
|
||||
user_aborted=agent_runner.was_aborted(),
|
||||
)
|
||||
|
||||
asyncio.create_task(
|
||||
@@ -336,16 +346,29 @@ class InternalAgentSubStage(Stage):
|
||||
llm_response: LLMResponse | None,
|
||||
all_messages: list[Message],
|
||||
runner_stats: AgentStats | None,
|
||||
user_aborted: bool = False,
|
||||
) -> None:
|
||||
if (
|
||||
not req
|
||||
or not req.conversation
|
||||
or not llm_response
|
||||
or llm_response.role != "assistant"
|
||||
):
|
||||
if not req or not req.conversation:
|
||||
return
|
||||
|
||||
if not llm_response.completion_text and not req.tool_calls_result:
|
||||
if not llm_response and not user_aborted:
|
||||
return
|
||||
|
||||
if llm_response and llm_response.role != "assistant":
|
||||
if not user_aborted:
|
||||
return
|
||||
llm_response = LLMResponse(
|
||||
role="assistant",
|
||||
completion_text=llm_response.completion_text or "",
|
||||
)
|
||||
elif llm_response is None:
|
||||
llm_response = LLMResponse(role="assistant", completion_text="")
|
||||
|
||||
if (
|
||||
not llm_response.completion_text
|
||||
and not req.tool_calls_result
|
||||
and not user_aborted
|
||||
):
|
||||
logger.debug("LLM 响应为空,不保存记录。")
|
||||
return
|
||||
|
||||
@@ -359,6 +382,14 @@ class InternalAgentSubStage(Stage):
|
||||
continue
|
||||
message_to_save.append(message.model_dump())
|
||||
|
||||
# if user_aborted:
|
||||
# message_to_save.append(
|
||||
# Message(
|
||||
# role="assistant",
|
||||
# content="[User aborted this request. Partial output before abort was preserved.]",
|
||||
# ).model_dump()
|
||||
# )
|
||||
|
||||
token_usage = None
|
||||
if runner_stats:
|
||||
# token_usage = runner_stats.token_usage.total
|
||||
|
||||
@@ -8,9 +8,9 @@ from astrbot.core import logger
|
||||
from astrbot.core.message.message_event_result import MessageEventResult
|
||||
from astrbot.core.platform.astr_message_event import AstrMessageEvent
|
||||
from astrbot.core.star.star import star_map
|
||||
from astrbot.core.star.star_handler import StarHandlerMetadata
|
||||
from astrbot.core.star.star_handler import EventType, StarHandlerMetadata
|
||||
|
||||
from ...context import PipelineContext, call_handler
|
||||
from ...context import PipelineContext, call_event_hook, call_handler
|
||||
from ..stage import Stage
|
||||
|
||||
|
||||
@@ -48,10 +48,20 @@ class StarRequestSubStage(Stage):
|
||||
yield ret
|
||||
event.clear_result() # 清除上一个 handler 的结果
|
||||
except Exception as e:
|
||||
logger.error(traceback.format_exc())
|
||||
traceback_text = traceback.format_exc()
|
||||
logger.error(traceback_text)
|
||||
logger.error(f"Star {handler.handler_full_name} handle error: {e}")
|
||||
|
||||
if event.is_at_or_wake_command:
|
||||
await call_event_hook(
|
||||
event,
|
||||
EventType.OnPluginErrorEvent,
|
||||
md.name,
|
||||
handler.handler_name,
|
||||
e,
|
||||
traceback_text,
|
||||
)
|
||||
|
||||
if not event.is_stopped() and event.is_at_or_wake_command:
|
||||
ret = f":(\n\n在调用插件 {md.name} 的处理函数 {handler.handler_name} 时出现异常:{e}"
|
||||
event.set_result(MessageEventResult().message(ret))
|
||||
yield
|
||||
|
||||
@@ -33,6 +33,21 @@ class RespondStage(Stage):
|
||||
Comp.Nodes: lambda comp: bool(comp.nodes), # 多个转发节点
|
||||
Comp.File: lambda comp: bool(comp.file_ or comp.url),
|
||||
Comp.WechatEmoji: lambda comp: comp.md5 is not None, # 微信表情
|
||||
Comp.Json: lambda comp: bool(comp.data), # Json 卡片
|
||||
Comp.Share: lambda comp: bool(comp.url) or bool(comp.title),
|
||||
Comp.Music: lambda comp: (
|
||||
(comp.id and comp._type and comp._type != "custom")
|
||||
or (comp._type == "custom" and comp.url and comp.audio and comp.title)
|
||||
), # 音乐分享
|
||||
Comp.Forward: lambda comp: bool(comp.id), # 合并转发
|
||||
Comp.Location: lambda comp: bool(
|
||||
comp.lat is not None and comp.lon is not None
|
||||
), # 位置
|
||||
Comp.Contact: lambda comp: bool(comp._type and comp.id), # 推荐好友 or 群
|
||||
Comp.Shake: lambda _: True, # 窗口抖动(戳一戳)
|
||||
Comp.Dice: lambda _: True, # 掷骰子魔法表情
|
||||
Comp.RPS: lambda _: True, # 猜拳魔法表情
|
||||
Comp.Unknown: lambda comp: bool(comp.text and comp.text.strip()),
|
||||
}
|
||||
|
||||
async def initialize(self, ctx: PipelineContext) -> None:
|
||||
@@ -61,16 +76,17 @@ class RespondStage(Stage):
|
||||
self.log_base = float(
|
||||
ctx.astrbot_config["platform_settings"]["segmented_reply"]["log_base"],
|
||||
)
|
||||
interval_str: str = ctx.astrbot_config["platform_settings"]["segmented_reply"][
|
||||
"interval"
|
||||
]
|
||||
interval_str_ls = interval_str.replace(" ", "").split(",")
|
||||
try:
|
||||
self.interval = [float(t) for t in interval_str_ls]
|
||||
except BaseException as e:
|
||||
logger.error(f"解析分段回复的间隔时间失败。{e}")
|
||||
self.interval = [1.5, 3.5]
|
||||
logger.info(f"分段回复间隔时间:{self.interval}")
|
||||
self.interval = [1.5, 3.5]
|
||||
if self.enable_seg:
|
||||
interval_str: str = ctx.astrbot_config["platform_settings"][
|
||||
"segmented_reply"
|
||||
]["interval"]
|
||||
interval_str_ls = interval_str.replace(" ", "").split(",")
|
||||
try:
|
||||
self.interval = [float(t) for t in interval_str_ls]
|
||||
except BaseException as e:
|
||||
logger.error(f"解析分段回复的间隔时间失败。{e}")
|
||||
logger.info(f"分段回复间隔时间:{self.interval}")
|
||||
|
||||
async def _word_cnt(self, text: str) -> int:
|
||||
"""分段回复 统计字数"""
|
||||
|
||||
@@ -315,6 +315,7 @@ class ResultDecorateStage(Stage):
|
||||
Record(
|
||||
file=url or audio_path,
|
||||
url=url or audio_path,
|
||||
text=comp.text,
|
||||
),
|
||||
)
|
||||
if dual_output:
|
||||
|
||||
@@ -6,6 +6,7 @@ from astrbot.core.platform.sources.webchat.webchat_event import WebChatMessageEv
|
||||
from astrbot.core.platform.sources.wecom_ai_bot.wecomai_event import (
|
||||
WecomAIBotMessageEvent,
|
||||
)
|
||||
from astrbot.core.utils.active_event_registry import active_event_registry
|
||||
|
||||
from . import STAGES_ORDER
|
||||
from .context import PipelineContext
|
||||
@@ -79,10 +80,14 @@ class PipelineScheduler:
|
||||
event (AstrMessageEvent): 事件对象
|
||||
|
||||
"""
|
||||
await self._process_stages(event)
|
||||
active_event_registry.register(event)
|
||||
try:
|
||||
await self._process_stages(event)
|
||||
|
||||
# 如果没有发送操作, 则发送一个空消息, 以便于后续的处理
|
||||
if isinstance(event, WebChatMessageEvent | WecomAIBotMessageEvent):
|
||||
await event.send(None)
|
||||
# 如果没有发送操作, 则发送一个空消息, 以便于后续的处理
|
||||
if isinstance(event, WebChatMessageEvent | WecomAIBotMessageEvent):
|
||||
await event.send(None)
|
||||
|
||||
logger.debug("pipeline 执行完毕。")
|
||||
logger.debug("pipeline 执行完毕。")
|
||||
finally:
|
||||
active_event_registry.unregister(event)
|
||||
|
||||
@@ -244,6 +244,12 @@ class AstrMessageEvent(abc.ABC):
|
||||
)
|
||||
self._has_send_oper = True
|
||||
|
||||
async def send_typing(self) -> None:
|
||||
"""发送输入中状态。
|
||||
|
||||
默认实现为空,由具体平台按需重写。
|
||||
"""
|
||||
|
||||
async def _pre_send(self) -> None:
|
||||
"""调度器会在执行 send() 前调用该方法 deprecated in v3.5.18"""
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import asyncio
|
||||
import traceback
|
||||
from asyncio import Queue
|
||||
from dataclasses import dataclass
|
||||
|
||||
from astrbot.core import logger
|
||||
from astrbot.core.config.astrbot_config import AstrBotConfig
|
||||
@@ -12,12 +13,19 @@ from .register import platform_cls_map
|
||||
from .sources.webchat.webchat_adapter import WebChatAdapter
|
||||
|
||||
|
||||
@dataclass
|
||||
class PlatformTasks:
|
||||
run: asyncio.Task
|
||||
wrapper: asyncio.Task
|
||||
|
||||
|
||||
class PlatformManager:
|
||||
def __init__(self, config: AstrBotConfig, event_queue: Queue) -> None:
|
||||
self.platform_insts: list[Platform] = []
|
||||
"""加载的 Platform 的实例"""
|
||||
|
||||
self._inst_map: dict[str, dict] = {}
|
||||
self._platform_tasks: dict[str, PlatformTasks] = {}
|
||||
|
||||
self.astrbot_config = config
|
||||
self.platforms_config = config["platform"]
|
||||
@@ -38,6 +46,44 @@ class PlatformManager:
|
||||
sanitized = platform_id.replace(":", "_").replace("!", "_")
|
||||
return sanitized, sanitized != platform_id
|
||||
|
||||
def _start_platform_task(self, task_name: str, inst: Platform) -> None:
|
||||
run_task = asyncio.create_task(inst.run(), name=task_name)
|
||||
wrapper_task = asyncio.create_task(
|
||||
self._task_wrapper(run_task, platform=inst),
|
||||
name=f"{task_name}_wrapper",
|
||||
)
|
||||
self._platform_tasks[inst.client_self_id] = PlatformTasks(
|
||||
run=run_task,
|
||||
wrapper=wrapper_task,
|
||||
)
|
||||
|
||||
async def _stop_platform_task(self, client_id: str) -> None:
|
||||
tasks = self._platform_tasks.pop(client_id, None)
|
||||
if not tasks:
|
||||
return
|
||||
for task in (tasks.run, tasks.wrapper):
|
||||
if not task.done():
|
||||
task.cancel()
|
||||
await asyncio.gather(tasks.run, tasks.wrapper, return_exceptions=True)
|
||||
|
||||
async def _terminate_inst_and_tasks(self, inst: Platform) -> None:
|
||||
client_id = inst.client_self_id
|
||||
try:
|
||||
if getattr(inst, "terminate", None):
|
||||
try:
|
||||
await inst.terminate()
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"终止平台适配器失败: client_id=%s, error=%s",
|
||||
client_id,
|
||||
e,
|
||||
)
|
||||
logger.error(traceback.format_exc())
|
||||
finally:
|
||||
await self._stop_platform_task(client_id)
|
||||
|
||||
async def initialize(self) -> None:
|
||||
"""初始化所有平台适配器"""
|
||||
for platform in self.platforms_config:
|
||||
@@ -51,12 +97,7 @@ class PlatformManager:
|
||||
# 网页聊天
|
||||
webchat_inst = WebChatAdapter({}, self.settings, self.event_queue)
|
||||
self.platform_insts.append(webchat_inst)
|
||||
asyncio.create_task(
|
||||
self._task_wrapper(
|
||||
asyncio.create_task(webchat_inst.run(), name="webchat"),
|
||||
platform=webchat_inst,
|
||||
),
|
||||
)
|
||||
self._start_platform_task("webchat", webchat_inst)
|
||||
|
||||
async def load_platform(self, platform_config: dict) -> None:
|
||||
"""实例化一个平台"""
|
||||
@@ -135,6 +176,10 @@ class PlatformManager:
|
||||
from .sources.satori.satori_adapter import (
|
||||
SatoriPlatformAdapter, # noqa: F401
|
||||
)
|
||||
case "line":
|
||||
from .sources.line.line_adapter import (
|
||||
LinePlatformAdapter, # noqa: F401
|
||||
)
|
||||
except (ImportError, ModuleNotFoundError) as e:
|
||||
logger.error(
|
||||
f"加载平台适配器 {platform_config['type']} 失败,原因:{e}。请检查依赖库是否安装。提示:可以在 管理面板->平台日志->安装Pip库 中安装依赖库。",
|
||||
@@ -154,15 +199,9 @@ class PlatformManager:
|
||||
"client_id": inst.client_self_id,
|
||||
}
|
||||
self.platform_insts.append(inst)
|
||||
|
||||
asyncio.create_task(
|
||||
self._task_wrapper(
|
||||
asyncio.create_task(
|
||||
inst.run(),
|
||||
name=f"platform_{platform_config['type']}_{platform_config['id']}",
|
||||
),
|
||||
platform=inst,
|
||||
),
|
||||
self._start_platform_task(
|
||||
f"platform_{platform_config['type']}_{platform_config['id']}",
|
||||
inst,
|
||||
)
|
||||
handlers = star_handlers_registry.get_handlers_by_event_type(
|
||||
EventType.OnPlatformLoadedEvent,
|
||||
@@ -230,13 +269,25 @@ class PlatformManager:
|
||||
except Exception:
|
||||
logger.warning(f"可能未完全移除 {platform_id} 平台适配器")
|
||||
|
||||
if getattr(inst, "terminate", None):
|
||||
await inst.terminate()
|
||||
await self._terminate_inst_and_tasks(inst)
|
||||
|
||||
async def terminate(self) -> None:
|
||||
for inst in self.platform_insts:
|
||||
if getattr(inst, "terminate", None):
|
||||
await inst.terminate()
|
||||
terminated_client_ids: set[str] = set()
|
||||
for platform_id in list(self._inst_map.keys()):
|
||||
info = self._inst_map.get(platform_id)
|
||||
if info:
|
||||
terminated_client_ids.add(info["client_id"])
|
||||
await self.terminate_platform(platform_id)
|
||||
|
||||
for inst in list(self.platform_insts):
|
||||
client_id = inst.client_self_id
|
||||
if client_id in terminated_client_ids:
|
||||
continue
|
||||
await self._terminate_inst_and_tasks(inst)
|
||||
|
||||
self.platform_insts.clear()
|
||||
self._inst_map.clear()
|
||||
self._platform_tasks.clear()
|
||||
|
||||
def get_insts(self):
|
||||
return self.platform_insts
|
||||
|
||||
@@ -24,3 +24,14 @@ class PlatformMetadata:
|
||||
|
||||
module_path: str | None = None
|
||||
"""注册该适配器的模块路径,用于插件热重载时清理"""
|
||||
i18n_resources: dict[str, dict] | None = None
|
||||
"""国际化资源数据,如 {"zh-CN": {...}, "en-US": {...}}
|
||||
|
||||
参考 https://github.com/AstrBotDevs/AstrBot/pull/5045
|
||||
"""
|
||||
|
||||
config_metadata: dict | None = None
|
||||
"""配置项元数据,用于 WebUI 生成表单。对应 config_metadata.json 的内容
|
||||
|
||||
参考 https://github.com/AstrBotDevs/AstrBot/pull/5045
|
||||
"""
|
||||
|
||||
@@ -15,11 +15,14 @@ def register_platform_adapter(
|
||||
adapter_display_name: str | None = None,
|
||||
logo_path: str | None = None,
|
||||
support_streaming_message: bool = True,
|
||||
i18n_resources: dict[str, dict] | None = None,
|
||||
config_metadata: dict | None = None,
|
||||
):
|
||||
"""用于注册平台适配器的带参装饰器。
|
||||
|
||||
default_config_tmpl 指定了平台适配器的默认配置模板。用户填写好后将会作为 platform_config 传入你的 Platform 类的实现类。
|
||||
logo_path 指定了平台适配器的 logo 文件路径,是相对于插件目录的路径。
|
||||
config_metadata 指定了配置项的元数据,用于 WebUI 生成表单。如果不指定,WebUI 将会把配置项渲染为原始的键值对编辑框。
|
||||
"""
|
||||
|
||||
def decorator(cls):
|
||||
@@ -49,6 +52,8 @@ def register_platform_adapter(
|
||||
logo_path=logo_path,
|
||||
support_streaming_message=support_streaming_message,
|
||||
module_path=module_path,
|
||||
i18n_resources=i18n_resources,
|
||||
config_metadata=config_metadata,
|
||||
)
|
||||
platform_registry.append(pm)
|
||||
platform_cls_map[adapter_name] = cls
|
||||
|
||||
@@ -21,7 +21,7 @@ from astrbot.api.platform import (
|
||||
)
|
||||
from astrbot.core import sp
|
||||
from astrbot.core.platform.astr_message_event import MessageSesion
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
|
||||
from astrbot.core.utils.io import download_file
|
||||
from astrbot.core.utils.media_utils import (
|
||||
convert_audio_format,
|
||||
@@ -253,9 +253,9 @@ class DingtalkPlatformAdapter(Platform):
|
||||
"downloadCode": download_code,
|
||||
"robotCode": robot_code,
|
||||
}
|
||||
temp_dir = Path(get_astrbot_data_path()) / "temp"
|
||||
temp_dir = Path(get_astrbot_temp_path())
|
||||
temp_dir.mkdir(parents=True, exist_ok=True)
|
||||
f_path = temp_dir / f"dingtalk_file_{uuid.uuid4()}.{ext}"
|
||||
f_path = temp_dir / f"dingtalk_{uuid.uuid4()}.{ext}"
|
||||
async with (
|
||||
aiohttp.ClientSession() as session,
|
||||
session.post(
|
||||
|
||||
@@ -3,10 +3,13 @@ import base64
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
from uuid import uuid4
|
||||
|
||||
import lark_oapi as lark
|
||||
from lark_oapi.api.im.v1 import (
|
||||
GetMessageRequest,
|
||||
GetMessageResourceRequest,
|
||||
)
|
||||
from lark_oapi.api.im.v1.processor import P2ImMessageReceiveV1Processor
|
||||
@@ -22,6 +25,7 @@ from astrbot.api.platform import (
|
||||
PlatformMetadata,
|
||||
)
|
||||
from astrbot.core.platform.astr_message_event import MessageSesion
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
|
||||
from astrbot.core.utils.webhook_utils import log_webhook_info
|
||||
|
||||
from ...register import register_platform_adapter
|
||||
@@ -91,6 +95,347 @@ class LarkPlatformAdapter(Platform):
|
||||
|
||||
self.event_id_timestamps: dict[str, float] = {}
|
||||
|
||||
async def _download_message_resource(
|
||||
self,
|
||||
*,
|
||||
message_id: str,
|
||||
file_key: str,
|
||||
resource_type: str,
|
||||
) -> bytes | None:
|
||||
if self.lark_api.im is None:
|
||||
logger.error("[Lark] API Client im 模块未初始化")
|
||||
return None
|
||||
|
||||
request = (
|
||||
GetMessageResourceRequest.builder()
|
||||
.message_id(message_id)
|
||||
.file_key(file_key)
|
||||
.type(resource_type)
|
||||
.build()
|
||||
)
|
||||
response = await self.lark_api.im.v1.message_resource.aget(request)
|
||||
if not response.success():
|
||||
logger.error(
|
||||
f"[Lark] 下载消息资源失败 type={resource_type}, key={file_key}, "
|
||||
f"code={response.code}, msg={response.msg}",
|
||||
)
|
||||
return None
|
||||
|
||||
if response.file is None:
|
||||
logger.error(f"[Lark] 消息资源响应中不包含文件流: {file_key}")
|
||||
return None
|
||||
|
||||
return response.file.read()
|
||||
|
||||
@staticmethod
|
||||
def _build_message_str_from_components(
|
||||
components: list[Comp.BaseMessageComponent],
|
||||
) -> str:
|
||||
parts: list[str] = []
|
||||
for comp in components:
|
||||
if isinstance(comp, Comp.Plain):
|
||||
text = comp.text.strip()
|
||||
if text:
|
||||
parts.append(text)
|
||||
elif isinstance(comp, Comp.At):
|
||||
name = str(comp.name or comp.qq or "").strip()
|
||||
if name:
|
||||
parts.append(f"@{name}")
|
||||
elif isinstance(comp, Comp.Image):
|
||||
parts.append("[image]")
|
||||
elif isinstance(comp, Comp.File):
|
||||
parts.append(str(comp.name or "[file]"))
|
||||
elif isinstance(comp, Comp.Record):
|
||||
parts.append("[audio]")
|
||||
elif isinstance(comp, Comp.Video):
|
||||
parts.append("[video]")
|
||||
|
||||
return " ".join(parts).strip()
|
||||
|
||||
@staticmethod
|
||||
def _parse_post_content(content: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
result: list[dict[str, Any]] = []
|
||||
for item in content.get("content", []):
|
||||
if isinstance(item, list):
|
||||
for comp in item:
|
||||
if isinstance(comp, dict):
|
||||
result.append(comp)
|
||||
elif isinstance(item, dict):
|
||||
result.append(item)
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _build_at_map(mentions: list[Any] | None) -> dict[str, Comp.At]:
|
||||
at_map: dict[str, Comp.At] = {}
|
||||
if not mentions:
|
||||
return at_map
|
||||
|
||||
for mention in mentions:
|
||||
key = getattr(mention, "key", None)
|
||||
if not key:
|
||||
continue
|
||||
|
||||
mention_id = getattr(mention, "id", None)
|
||||
open_id = ""
|
||||
if mention_id is not None:
|
||||
if hasattr(mention_id, "open_id"):
|
||||
open_id = getattr(mention_id, "open_id", "") or ""
|
||||
else:
|
||||
open_id = str(mention_id)
|
||||
|
||||
mention_name = str(getattr(mention, "name", "") or "")
|
||||
at_map[key] = Comp.At(qq=open_id, name=mention_name)
|
||||
|
||||
return at_map
|
||||
|
||||
async def _parse_message_components(
|
||||
self,
|
||||
*,
|
||||
message_id: str | None,
|
||||
message_type: str,
|
||||
content: dict[str, Any],
|
||||
at_map: dict[str, Comp.At],
|
||||
) -> list[Comp.BaseMessageComponent]:
|
||||
components: list[Comp.BaseMessageComponent] = []
|
||||
|
||||
if message_type == "text":
|
||||
message_str_raw = str(content.get("text", ""))
|
||||
at_pattern = r"(@_user_\d+)"
|
||||
parts = re.split(at_pattern, message_str_raw)
|
||||
for part in parts:
|
||||
segment = part.strip()
|
||||
if not segment:
|
||||
continue
|
||||
if segment in at_map:
|
||||
components.append(at_map[segment])
|
||||
else:
|
||||
components.append(Comp.Plain(segment))
|
||||
return components
|
||||
|
||||
if message_type in ("post", "image"):
|
||||
if message_type == "image":
|
||||
comp_list = [
|
||||
{
|
||||
"tag": "img",
|
||||
"image_key": content.get("image_key"),
|
||||
},
|
||||
]
|
||||
else:
|
||||
comp_list = self._parse_post_content(content)
|
||||
|
||||
for comp in comp_list:
|
||||
tag = comp.get("tag")
|
||||
if tag == "at":
|
||||
user_key = str(comp.get("user_id", ""))
|
||||
if user_key in at_map:
|
||||
components.append(at_map[user_key])
|
||||
elif tag == "text":
|
||||
text = str(comp.get("text", "")).strip()
|
||||
if text:
|
||||
components.append(Comp.Plain(text))
|
||||
elif tag == "a":
|
||||
text = str(comp.get("text", "")).strip()
|
||||
href = str(comp.get("href", "")).strip()
|
||||
if text and href:
|
||||
components.append(Comp.Plain(f"{text}({href})"))
|
||||
elif text:
|
||||
components.append(Comp.Plain(text))
|
||||
elif tag == "img":
|
||||
image_key = str(comp.get("image_key", "")).strip()
|
||||
if not image_key:
|
||||
continue
|
||||
if not message_id:
|
||||
logger.error("[Lark] 图片消息缺少 message_id")
|
||||
continue
|
||||
image_bytes = await self._download_message_resource(
|
||||
message_id=message_id,
|
||||
file_key=image_key,
|
||||
resource_type="image",
|
||||
)
|
||||
if image_bytes is None:
|
||||
continue
|
||||
image_base64 = base64.b64encode(image_bytes).decode()
|
||||
components.append(Comp.Image.fromBase64(image_base64))
|
||||
elif tag == "media":
|
||||
file_key = str(comp.get("file_key", "")).strip()
|
||||
file_name = (
|
||||
str(comp.get("file_name", "")).strip() or "lark_media.mp4"
|
||||
)
|
||||
if not file_key:
|
||||
continue
|
||||
if not message_id:
|
||||
logger.error("[Lark] 富文本视频消息缺少 message_id")
|
||||
continue
|
||||
file_path = await self._download_file_resource_to_temp(
|
||||
message_id=message_id,
|
||||
file_key=file_key,
|
||||
message_type="post_media",
|
||||
file_name=file_name,
|
||||
default_suffix=".mp4",
|
||||
)
|
||||
if file_path:
|
||||
components.append(Comp.Video(file=file_path, path=file_path))
|
||||
|
||||
return components
|
||||
|
||||
if message_type == "file":
|
||||
file_key = str(content.get("file_key", "")).strip()
|
||||
file_name = str(content.get("file_name", "")).strip() or "lark_file"
|
||||
if not message_id:
|
||||
logger.error("[Lark] 文件消息缺少 message_id")
|
||||
return components
|
||||
if not file_key:
|
||||
logger.error("[Lark] 文件消息缺少 file_key")
|
||||
return components
|
||||
file_path = await self._download_file_resource_to_temp(
|
||||
message_id=message_id,
|
||||
file_key=file_key,
|
||||
message_type="file",
|
||||
file_name=file_name,
|
||||
)
|
||||
if file_path:
|
||||
components.append(Comp.File(name=file_name, file=file_path))
|
||||
return components
|
||||
|
||||
if message_type == "audio":
|
||||
file_key = str(content.get("file_key", "")).strip()
|
||||
if not message_id:
|
||||
logger.error("[Lark] 音频消息缺少 message_id")
|
||||
return components
|
||||
if not file_key:
|
||||
logger.error("[Lark] 音频消息缺少 file_key")
|
||||
return components
|
||||
file_path = await self._download_file_resource_to_temp(
|
||||
message_id=message_id,
|
||||
file_key=file_key,
|
||||
message_type="audio",
|
||||
default_suffix=".opus",
|
||||
)
|
||||
if file_path:
|
||||
components.append(Comp.Record(file=file_path, url=file_path))
|
||||
return components
|
||||
|
||||
if message_type == "media":
|
||||
file_key = str(content.get("file_key", "")).strip()
|
||||
file_name = str(content.get("file_name", "")).strip() or "lark_media.mp4"
|
||||
if not message_id:
|
||||
logger.error("[Lark] 视频消息缺少 message_id")
|
||||
return components
|
||||
if not file_key:
|
||||
logger.error("[Lark] 视频消息缺少 file_key")
|
||||
return components
|
||||
file_path = await self._download_file_resource_to_temp(
|
||||
message_id=message_id,
|
||||
file_key=file_key,
|
||||
message_type="media",
|
||||
file_name=file_name,
|
||||
default_suffix=".mp4",
|
||||
)
|
||||
if file_path:
|
||||
components.append(Comp.Video(file=file_path, path=file_path))
|
||||
return components
|
||||
|
||||
return components
|
||||
|
||||
async def _build_reply_from_parent_id(
|
||||
self,
|
||||
parent_message_id: str,
|
||||
) -> Comp.Reply | None:
|
||||
if self.lark_api.im is None:
|
||||
logger.error("[Lark] API Client im 模块未初始化")
|
||||
return None
|
||||
|
||||
request = GetMessageRequest.builder().message_id(parent_message_id).build()
|
||||
response = await self.lark_api.im.v1.message.aget(request)
|
||||
if not response.success():
|
||||
logger.error(
|
||||
f"[Lark] 获取引用消息失败 id={parent_message_id}, "
|
||||
f"code={response.code}, msg={response.msg}",
|
||||
)
|
||||
return None
|
||||
|
||||
if response.data is None or not response.data.items:
|
||||
logger.error(
|
||||
f"[Lark] 引用消息响应为空 id={parent_message_id}",
|
||||
)
|
||||
return None
|
||||
|
||||
parent_message = response.data.items[0]
|
||||
quoted_message_id = parent_message.message_id or parent_message_id
|
||||
quoted_sender_id = (
|
||||
parent_message.sender.id
|
||||
if parent_message.sender and parent_message.sender.id
|
||||
else "unknown"
|
||||
)
|
||||
quoted_time_raw = parent_message.create_time or 0
|
||||
quoted_time = (
|
||||
quoted_time_raw // 1000
|
||||
if isinstance(quoted_time_raw, int) and quoted_time_raw > 10**11
|
||||
else quoted_time_raw
|
||||
)
|
||||
quoted_content = (
|
||||
parent_message.body.content if parent_message.body else ""
|
||||
) or ""
|
||||
quoted_type = parent_message.msg_type or ""
|
||||
quoted_content_json: dict[str, Any] = {}
|
||||
if quoted_content:
|
||||
try:
|
||||
parsed = json.loads(quoted_content)
|
||||
if isinstance(parsed, dict):
|
||||
quoted_content_json = parsed
|
||||
except json.JSONDecodeError:
|
||||
logger.warning(
|
||||
f"[Lark] 解析引用消息内容失败 id={quoted_message_id}",
|
||||
)
|
||||
|
||||
quoted_at_map = self._build_at_map(parent_message.mentions)
|
||||
quoted_chain = await self._parse_message_components(
|
||||
message_id=quoted_message_id,
|
||||
message_type=quoted_type,
|
||||
content=quoted_content_json,
|
||||
at_map=quoted_at_map,
|
||||
)
|
||||
quoted_text = self._build_message_str_from_components(quoted_chain)
|
||||
sender_nickname = (
|
||||
quoted_sender_id[:8] if quoted_sender_id != "unknown" else "unknown"
|
||||
)
|
||||
|
||||
return Comp.Reply(
|
||||
id=quoted_message_id,
|
||||
chain=quoted_chain,
|
||||
sender_id=quoted_sender_id,
|
||||
sender_nickname=sender_nickname,
|
||||
time=quoted_time,
|
||||
message_str=quoted_text,
|
||||
text=quoted_text,
|
||||
)
|
||||
|
||||
async def _download_file_resource_to_temp(
|
||||
self,
|
||||
*,
|
||||
message_id: str,
|
||||
file_key: str,
|
||||
message_type: str,
|
||||
file_name: str = "",
|
||||
default_suffix: str = ".bin",
|
||||
) -> str | None:
|
||||
file_bytes = await self._download_message_resource(
|
||||
message_id=message_id,
|
||||
file_key=file_key,
|
||||
resource_type="file",
|
||||
)
|
||||
if file_bytes is None:
|
||||
return None
|
||||
|
||||
suffix = Path(file_name).suffix if file_name else default_suffix
|
||||
temp_dir = Path(get_astrbot_temp_path())
|
||||
temp_dir.mkdir(parents=True, exist_ok=True)
|
||||
temp_path = (
|
||||
temp_dir / f"lark_{message_type}_{file_name}_{uuid4().hex[:4]}{suffix}"
|
||||
)
|
||||
temp_path.write_bytes(file_bytes)
|
||||
return str(temp_path.resolve())
|
||||
|
||||
def _clean_expired_events(self) -> None:
|
||||
"""清理超过 30 分钟的事件记录"""
|
||||
current_time = time.time()
|
||||
@@ -176,6 +521,11 @@ class LarkPlatformAdapter(Platform):
|
||||
abm.message_str = ""
|
||||
|
||||
at_list = {}
|
||||
if message.parent_id:
|
||||
reply_seg = await self._build_reply_from_parent_id(message.parent_id)
|
||||
if reply_seg:
|
||||
abm.message.append(reply_seg)
|
||||
|
||||
if message.mentions:
|
||||
for m in message.mentions:
|
||||
if m.id is None:
|
||||
@@ -198,80 +548,19 @@ class LarkPlatformAdapter(Platform):
|
||||
logger.error(f"[Lark] 解析消息内容失败: {message.content}")
|
||||
return
|
||||
|
||||
if message.message_type == "text":
|
||||
message_str_raw = content_json_b.get("text", "") # 带有 @ 的消息
|
||||
at_pattern = r"(@_user_\d+)" # 可以根据需求修改正则
|
||||
# at_users = re.findall(at_pattern, message_str_raw)
|
||||
# 拆分文本,去掉AT符号部分
|
||||
parts = re.split(at_pattern, message_str_raw)
|
||||
for i in range(len(parts)):
|
||||
s = parts[i].strip()
|
||||
if not s:
|
||||
continue
|
||||
if s in at_list:
|
||||
abm.message.append(at_list[s])
|
||||
else:
|
||||
abm.message.append(Comp.Plain(parts[i].strip()))
|
||||
elif message.message_type == "post":
|
||||
_ls = []
|
||||
if not isinstance(content_json_b, dict):
|
||||
logger.error(f"[Lark] 消息内容不是 JSON Object: {message.content}")
|
||||
return
|
||||
|
||||
content_ls = content_json_b.get("content", [])
|
||||
for comp in content_ls:
|
||||
if isinstance(comp, list):
|
||||
_ls.extend(comp)
|
||||
elif isinstance(comp, dict):
|
||||
_ls.append(comp)
|
||||
content_json_b = _ls
|
||||
elif message.message_type == "image":
|
||||
content_json_b = [
|
||||
{
|
||||
"tag": "img",
|
||||
"image_key": content_json_b.get("image_key"),
|
||||
"style": [],
|
||||
},
|
||||
]
|
||||
|
||||
if message.message_type in ("post", "image"):
|
||||
for comp in content_json_b:
|
||||
if comp.get("tag") == "at":
|
||||
user_id = comp.get("user_id")
|
||||
if user_id in at_list:
|
||||
abm.message.append(at_list[user_id])
|
||||
elif comp.get("tag") == "text" and comp.get("text", "").strip():
|
||||
abm.message.append(Comp.Plain(comp["text"].strip()))
|
||||
elif comp.get("tag") == "img":
|
||||
image_key = comp.get("image_key")
|
||||
if not image_key:
|
||||
continue
|
||||
|
||||
request = (
|
||||
GetMessageResourceRequest.builder()
|
||||
.message_id(cast(str, message.message_id))
|
||||
.file_key(image_key)
|
||||
.type("image")
|
||||
.build()
|
||||
)
|
||||
|
||||
if self.lark_api.im is None:
|
||||
logger.error("[Lark] API Client im 模块未初始化")
|
||||
continue
|
||||
|
||||
response = await self.lark_api.im.v1.message_resource.aget(request)
|
||||
if not response.success():
|
||||
logger.error(f"无法下载飞书图片: {image_key}")
|
||||
continue
|
||||
|
||||
if response.file is None:
|
||||
logger.error(f"飞书图片响应中不包含文件流: {image_key}")
|
||||
continue
|
||||
|
||||
image_bytes = response.file.read()
|
||||
image_base64 = base64.b64encode(image_bytes).decode()
|
||||
abm.message.append(Comp.Image.fromBase64(image_base64))
|
||||
|
||||
for comp in abm.message:
|
||||
if isinstance(comp, Comp.Plain):
|
||||
abm.message_str += comp.text
|
||||
logger.debug(f"[Lark] 解析消息内容: {content_json_b}")
|
||||
parsed_components = await self._parse_message_components(
|
||||
message_id=message.message_id,
|
||||
message_type=message.message_type or "unknown",
|
||||
content=content_json_b,
|
||||
at_map=at_list,
|
||||
)
|
||||
abm.message.extend(parsed_components)
|
||||
abm.message_str = self._build_message_str_from_components(parsed_components)
|
||||
|
||||
if message.message_id is None:
|
||||
logger.error("[Lark] 消息缺少 message_id")
|
||||
@@ -296,7 +585,6 @@ class LarkPlatformAdapter(Platform):
|
||||
else:
|
||||
abm.session_id = abm.sender.user_id
|
||||
|
||||
logger.debug(abm)
|
||||
await self.handle_msg(abm)
|
||||
|
||||
async def handle_msg(self, abm: AstrBotMessage) -> None:
|
||||
|
||||
@@ -21,7 +21,7 @@ from astrbot import logger
|
||||
from astrbot.api.event import AstrMessageEvent, MessageChain
|
||||
from astrbot.api.message_components import At, File, Plain, Record, Video
|
||||
from astrbot.api.message_components import Image as AstrBotImage
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
|
||||
from astrbot.core.utils.io import download_image_by_url
|
||||
from astrbot.core.utils.media_utils import (
|
||||
convert_audio_to_opus,
|
||||
@@ -202,8 +202,11 @@ class LarkMessageEvent(AstrMessageEvent):
|
||||
base64_str = comp.file.removeprefix("base64://")
|
||||
image_data = base64.b64decode(base64_str)
|
||||
# save as temp file
|
||||
temp_dir = os.path.join(get_astrbot_data_path(), "temp")
|
||||
file_path = os.path.join(temp_dir, f"{uuid.uuid4()}_test.jpg")
|
||||
temp_dir = get_astrbot_temp_path()
|
||||
file_path = os.path.join(
|
||||
temp_dir,
|
||||
f"lark_image_{uuid.uuid4().hex[:8]}.jpg",
|
||||
)
|
||||
with open(file_path, "wb") as f:
|
||||
f.write(BytesIO(image_data).getvalue())
|
||||
else:
|
||||
|
||||
@@ -0,0 +1,474 @@
|
||||
import asyncio
|
||||
import mimetypes
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
|
||||
from astrbot.api import logger
|
||||
from astrbot.api.event import MessageChain
|
||||
from astrbot.api.message_components import At, File, Image, Plain, Record, Video
|
||||
from astrbot.api.platform import (
|
||||
AstrBotMessage,
|
||||
Group,
|
||||
MessageMember,
|
||||
MessageType,
|
||||
Platform,
|
||||
PlatformMetadata,
|
||||
)
|
||||
from astrbot.core.platform.astr_message_event import MessageSesion
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
|
||||
from astrbot.core.utils.webhook_utils import log_webhook_info
|
||||
|
||||
from ...register import register_platform_adapter
|
||||
from .line_api import LineAPIClient
|
||||
from .line_event import LineMessageEvent
|
||||
|
||||
LINE_CONFIG_METADATA = {
|
||||
"channel_access_token": {
|
||||
"description": "LINE Channel Access Token",
|
||||
"type": "string",
|
||||
"hint": "LINE Messaging API 的 channel access token。",
|
||||
},
|
||||
"channel_secret": {
|
||||
"description": "LINE Channel Secret",
|
||||
"type": "string",
|
||||
"hint": "用于校验 LINE Webhook 签名。",
|
||||
},
|
||||
}
|
||||
|
||||
LINE_I18N_RESOURCES = {
|
||||
"zh-CN": {
|
||||
"channel_access_token": {
|
||||
"description": "LINE Channel Access Token",
|
||||
"hint": "LINE Messaging API 的 channel access token。",
|
||||
},
|
||||
"channel_secret": {
|
||||
"description": "LINE Channel Secret",
|
||||
"hint": "用于校验 LINE Webhook 签名。",
|
||||
},
|
||||
},
|
||||
"en-US": {
|
||||
"channel_access_token": {
|
||||
"description": "LINE Channel Access Token",
|
||||
"hint": "Channel access token for LINE Messaging API.",
|
||||
},
|
||||
"channel_secret": {
|
||||
"description": "LINE Channel Secret",
|
||||
"hint": "Used to verify LINE webhook signatures.",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@register_platform_adapter(
|
||||
"line",
|
||||
"LINE Messaging API 适配器",
|
||||
support_streaming_message=False,
|
||||
default_config_tmpl={
|
||||
"id": "line",
|
||||
"type": "line",
|
||||
"enable": False,
|
||||
"channel_access_token": "",
|
||||
"channel_secret": "",
|
||||
"unified_webhook_mode": True,
|
||||
"webhook_uuid": "",
|
||||
},
|
||||
config_metadata=LINE_CONFIG_METADATA,
|
||||
i18n_resources=LINE_I18N_RESOURCES,
|
||||
)
|
||||
class LinePlatformAdapter(Platform):
|
||||
def __init__(
|
||||
self,
|
||||
platform_config: dict,
|
||||
platform_settings: dict,
|
||||
event_queue: asyncio.Queue,
|
||||
) -> None:
|
||||
super().__init__(platform_config, event_queue)
|
||||
self.config["unified_webhook_mode"] = True
|
||||
self.destination = "unknown"
|
||||
self.settings = platform_settings
|
||||
self._event_id_timestamps: dict[str, float] = {}
|
||||
self.shutdown_event = asyncio.Event()
|
||||
|
||||
channel_access_token = str(platform_config.get("channel_access_token", ""))
|
||||
channel_secret = str(platform_config.get("channel_secret", ""))
|
||||
if not channel_access_token or not channel_secret:
|
||||
raise ValueError(
|
||||
"LINE 适配器需要 channel_access_token 和 channel_secret。",
|
||||
)
|
||||
|
||||
self.line_api = LineAPIClient(
|
||||
channel_access_token=channel_access_token,
|
||||
channel_secret=channel_secret,
|
||||
)
|
||||
|
||||
async def send_by_session(
|
||||
self,
|
||||
session: MessageSesion,
|
||||
message_chain: MessageChain,
|
||||
) -> None:
|
||||
messages = await LineMessageEvent.build_line_messages(message_chain)
|
||||
if messages:
|
||||
await self.line_api.push_message(session.session_id, messages)
|
||||
await super().send_by_session(session, message_chain)
|
||||
|
||||
def meta(self) -> PlatformMetadata:
|
||||
return PlatformMetadata(
|
||||
name="line",
|
||||
description="LINE Messaging API 适配器",
|
||||
id=cast(str, self.config.get("id", "line")),
|
||||
support_streaming_message=False,
|
||||
)
|
||||
|
||||
async def run(self) -> None:
|
||||
webhook_uuid = self.config.get("webhook_uuid")
|
||||
if webhook_uuid:
|
||||
log_webhook_info(f"{self.meta().id}(LINE)", webhook_uuid)
|
||||
else:
|
||||
logger.warning("[LINE] webhook_uuid 为空,统一 Webhook 可能无法接收消息。")
|
||||
await self.shutdown_event.wait()
|
||||
|
||||
async def terminate(self) -> None:
|
||||
self.shutdown_event.set()
|
||||
await self.line_api.close()
|
||||
|
||||
async def webhook_callback(self, request: Any) -> Any:
|
||||
raw_body = await request.get_data()
|
||||
signature = request.headers.get("x-line-signature")
|
||||
if not self.line_api.verify_signature(raw_body, signature):
|
||||
logger.warning("[LINE] invalid webhook signature")
|
||||
return "invalid signature", 400
|
||||
|
||||
try:
|
||||
payload = await request.get_json(force=True, silent=False)
|
||||
except Exception as e:
|
||||
logger.warning("[LINE] invalid webhook body: %s", e)
|
||||
return "bad request", 400
|
||||
|
||||
if not isinstance(payload, dict):
|
||||
return "bad request", 400
|
||||
|
||||
await self.handle_webhook_event(payload)
|
||||
return "ok", 200
|
||||
|
||||
async def handle_webhook_event(self, payload: dict[str, Any]) -> None:
|
||||
destination = str(payload.get("destination", "")).strip()
|
||||
if destination:
|
||||
self.destination = destination
|
||||
|
||||
events = payload.get("events")
|
||||
if not isinstance(events, list):
|
||||
return
|
||||
|
||||
for event in events:
|
||||
if not isinstance(event, dict):
|
||||
continue
|
||||
|
||||
event_id = str(event.get("webhookEventId", ""))
|
||||
if event_id and self._is_duplicate_event(event_id):
|
||||
logger.debug("[LINE] duplicate event skipped: %s", event_id)
|
||||
continue
|
||||
|
||||
abm = await self.convert_message(event)
|
||||
if abm is None:
|
||||
continue
|
||||
await self.handle_msg(abm)
|
||||
|
||||
async def convert_message(self, event: dict[str, Any]) -> AstrBotMessage | None:
|
||||
if str(event.get("type", "")) != "message":
|
||||
return None
|
||||
if str(event.get("mode", "active")) == "standby":
|
||||
return None
|
||||
|
||||
source = event.get("source", {})
|
||||
if not isinstance(source, dict):
|
||||
return None
|
||||
|
||||
message = event.get("message", {})
|
||||
if not isinstance(message, dict):
|
||||
return None
|
||||
|
||||
source_type = str(source.get("type", ""))
|
||||
user_id = str(source.get("userId", "")).strip()
|
||||
group_id = str(source.get("groupId", "")).strip()
|
||||
room_id = str(source.get("roomId", "")).strip()
|
||||
|
||||
abm = AstrBotMessage()
|
||||
abm.self_id = self.destination or self.meta().id
|
||||
abm.message = []
|
||||
abm.raw_message = event
|
||||
abm.message_id = str(
|
||||
message.get("id")
|
||||
or event.get("webhookEventId")
|
||||
or event.get("deliveryContext", {}).get("deliveryId", "")
|
||||
or uuid.uuid4().hex
|
||||
)
|
||||
|
||||
event_timestamp = event.get("timestamp")
|
||||
if isinstance(event_timestamp, int):
|
||||
abm.timestamp = (
|
||||
event_timestamp // 1000
|
||||
if event_timestamp > 1_000_000_000_000
|
||||
else event_timestamp
|
||||
)
|
||||
else:
|
||||
abm.timestamp = int(time.time())
|
||||
|
||||
if source_type in {"group", "room"}:
|
||||
abm.type = MessageType.GROUP_MESSAGE
|
||||
container_id = group_id or room_id
|
||||
abm.group = Group(group_id=container_id, group_name=container_id)
|
||||
abm.session_id = container_id
|
||||
sender_id = user_id or container_id
|
||||
elif source_type == "user":
|
||||
abm.type = MessageType.FRIEND_MESSAGE
|
||||
abm.session_id = user_id
|
||||
sender_id = user_id
|
||||
else:
|
||||
abm.type = MessageType.OTHER_MESSAGE
|
||||
abm.session_id = user_id or group_id or room_id or "unknown"
|
||||
sender_id = abm.session_id
|
||||
|
||||
abm.sender = MessageMember(user_id=sender_id, nickname=sender_id[:8])
|
||||
|
||||
components = await self._parse_line_message_components(message)
|
||||
if not components:
|
||||
return None
|
||||
abm.message = components
|
||||
abm.message_str = self._build_message_str(components)
|
||||
return abm
|
||||
|
||||
async def _parse_line_message_components(
|
||||
self,
|
||||
message: dict[str, Any],
|
||||
) -> list:
|
||||
msg_type = str(message.get("type", ""))
|
||||
message_id = str(message.get("id", "")).strip()
|
||||
|
||||
if msg_type == "text":
|
||||
text = str(message.get("text", ""))
|
||||
mention = message.get("mention")
|
||||
if isinstance(mention, dict):
|
||||
return self._parse_text_with_mentions(text, mention)
|
||||
return [Plain(text=text)] if text else []
|
||||
|
||||
if msg_type == "image":
|
||||
image_component = await self._build_image_component(message_id, message)
|
||||
return [image_component] if image_component else [Plain(text="[image]")]
|
||||
|
||||
if msg_type == "video":
|
||||
video_component = await self._build_video_component(message_id, message)
|
||||
return [video_component] if video_component else [Plain(text="[video]")]
|
||||
|
||||
if msg_type == "audio":
|
||||
audio_component = await self._build_audio_component(message_id, message)
|
||||
return [audio_component] if audio_component else [Plain(text="[audio]")]
|
||||
|
||||
if msg_type == "file":
|
||||
file_component = await self._build_file_component(message_id, message)
|
||||
return [file_component] if file_component else [Plain(text="[file]")]
|
||||
|
||||
if msg_type == "sticker":
|
||||
return [Plain(text="[sticker]")]
|
||||
|
||||
return [Plain(text=f"[{msg_type}]")]
|
||||
|
||||
def _parse_text_with_mentions(self, text: str, mention_obj: dict[str, Any]) -> list:
|
||||
mentions = mention_obj.get("mentionees", [])
|
||||
if not isinstance(mentions, list) or not mentions:
|
||||
return [Plain(text=text)] if text else []
|
||||
|
||||
normalized = []
|
||||
for item in mentions:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
start = item.get("index")
|
||||
length = item.get("length")
|
||||
if not isinstance(start, int) or not isinstance(length, int):
|
||||
continue
|
||||
normalized.append((start, length, item))
|
||||
normalized.sort(key=lambda x: x[0])
|
||||
|
||||
ret = []
|
||||
cursor = 0
|
||||
for start, length, item in normalized:
|
||||
if start > cursor:
|
||||
part = text[cursor:start]
|
||||
if part:
|
||||
ret.append(Plain(text=part))
|
||||
|
||||
label = text[start : start + length] or "@user"
|
||||
mention_type = str(item.get("type", ""))
|
||||
if mention_type == "user":
|
||||
target_id = str(item.get("userId", "")).strip()
|
||||
ret.append(At(qq=target_id, name=label.lstrip("@")))
|
||||
else:
|
||||
ret.append(Plain(text=label))
|
||||
cursor = max(cursor, start + length)
|
||||
|
||||
if cursor < len(text):
|
||||
tail = text[cursor:]
|
||||
if tail:
|
||||
ret.append(Plain(text=tail))
|
||||
return ret
|
||||
|
||||
async def _build_image_component(
|
||||
self,
|
||||
message_id: str,
|
||||
message: dict[str, Any],
|
||||
) -> Image | None:
|
||||
external_url = self._get_external_content_url(message)
|
||||
if external_url:
|
||||
return Image.fromURL(external_url)
|
||||
|
||||
content = await self.line_api.get_message_content(message_id)
|
||||
if not content:
|
||||
return None
|
||||
content_bytes, _, _ = content
|
||||
return Image.fromBytes(content_bytes)
|
||||
|
||||
async def _build_video_component(
|
||||
self,
|
||||
message_id: str,
|
||||
message: dict[str, Any],
|
||||
) -> Video | None:
|
||||
external_url = self._get_external_content_url(message)
|
||||
if external_url:
|
||||
return Video.fromURL(external_url)
|
||||
|
||||
content = await self.line_api.get_message_content(message_id)
|
||||
if not content:
|
||||
return None
|
||||
content_bytes, content_type, _ = content
|
||||
suffix = self._guess_suffix(content_type, ".mp4")
|
||||
file_path = self._store_temp_content("video", message_id, content_bytes, suffix)
|
||||
return Video(file=file_path, path=file_path)
|
||||
|
||||
async def _build_audio_component(
|
||||
self,
|
||||
message_id: str,
|
||||
message: dict[str, Any],
|
||||
) -> Record | None:
|
||||
external_url = self._get_external_content_url(message)
|
||||
if external_url:
|
||||
return Record.fromURL(external_url)
|
||||
|
||||
content = await self.line_api.get_message_content(message_id)
|
||||
if not content:
|
||||
return None
|
||||
content_bytes, content_type, _ = content
|
||||
suffix = self._guess_suffix(content_type, ".m4a")
|
||||
file_path = self._store_temp_content("audio", message_id, content_bytes, suffix)
|
||||
return Record(file=file_path, url=file_path)
|
||||
|
||||
async def _build_file_component(
|
||||
self,
|
||||
message_id: str,
|
||||
message: dict[str, Any],
|
||||
) -> File | None:
|
||||
content = await self.line_api.get_message_content(message_id)
|
||||
if not content:
|
||||
return None
|
||||
content_bytes, content_type, filename = content
|
||||
default_name = str(message.get("fileName", "")).strip() or f"{message_id}.bin"
|
||||
suffix = Path(default_name).suffix or self._guess_suffix(content_type, ".bin")
|
||||
final_name = filename or default_name
|
||||
file_path = self._store_temp_content(
|
||||
"file",
|
||||
message_id,
|
||||
content_bytes,
|
||||
suffix,
|
||||
original_name=final_name,
|
||||
)
|
||||
return File(name=final_name, file=file_path, url=file_path)
|
||||
|
||||
@staticmethod
|
||||
def _get_external_content_url(message: dict[str, Any]) -> str:
|
||||
provider = message.get("contentProvider")
|
||||
if not isinstance(provider, dict):
|
||||
return ""
|
||||
if str(provider.get("type", "")) != "external":
|
||||
return ""
|
||||
return str(provider.get("originalContentUrl", "")).strip()
|
||||
|
||||
@staticmethod
|
||||
def _guess_suffix(content_type: str | None, fallback: str) -> str:
|
||||
if not content_type:
|
||||
return fallback
|
||||
base_type = content_type.split(";", 1)[0].strip().lower()
|
||||
guessed = mimetypes.guess_extension(base_type)
|
||||
if guessed:
|
||||
return guessed
|
||||
return fallback
|
||||
|
||||
@staticmethod
|
||||
def _store_temp_content(
|
||||
content_type: str,
|
||||
message_id: str,
|
||||
content: bytes,
|
||||
suffix: str,
|
||||
original_name: str = "",
|
||||
) -> str:
|
||||
temp_dir = Path(get_astrbot_temp_path())
|
||||
temp_dir.mkdir(parents=True, exist_ok=True)
|
||||
name_prefix = f"line_{content_type}"
|
||||
if original_name:
|
||||
safe_stem = Path(original_name).stem.strip()
|
||||
safe_stem = "".join(
|
||||
ch if ch.isalnum() or ch in ("-", "_", ".") else "_" for ch in safe_stem
|
||||
)
|
||||
safe_stem = safe_stem.strip("._")
|
||||
if safe_stem:
|
||||
name_prefix = safe_stem[:64]
|
||||
file_path = temp_dir / f"{name_prefix}_{message_id}_{uuid.uuid4().hex[:6]}"
|
||||
file_path = file_path.with_suffix(suffix)
|
||||
file_path.write_bytes(content)
|
||||
return str(file_path.resolve())
|
||||
|
||||
@staticmethod
|
||||
def _build_message_str(components: list) -> str:
|
||||
parts: list[str] = []
|
||||
for comp in components:
|
||||
if isinstance(comp, Plain):
|
||||
parts.append(comp.text)
|
||||
elif isinstance(comp, At):
|
||||
parts.append(f"@{comp.name or comp.qq}")
|
||||
elif isinstance(comp, Image):
|
||||
parts.append("[image]")
|
||||
elif isinstance(comp, Video):
|
||||
parts.append("[video]")
|
||||
elif isinstance(comp, Record):
|
||||
parts.append("[audio]")
|
||||
elif isinstance(comp, File):
|
||||
parts.append(str(comp.name or "[file]"))
|
||||
else:
|
||||
parts.append(f"[{comp.type}]")
|
||||
return " ".join(i for i in parts if i).strip()
|
||||
|
||||
def _clean_expired_events(self) -> None:
|
||||
current = time.time()
|
||||
expired = [
|
||||
event_id
|
||||
for event_id, ts in self._event_id_timestamps.items()
|
||||
if current - ts > 1800
|
||||
]
|
||||
for event_id in expired:
|
||||
del self._event_id_timestamps[event_id]
|
||||
|
||||
def _is_duplicate_event(self, event_id: str) -> bool:
|
||||
self._clean_expired_events()
|
||||
if event_id in self._event_id_timestamps:
|
||||
return True
|
||||
self._event_id_timestamps[event_id] = time.time()
|
||||
return False
|
||||
|
||||
async def handle_msg(self, abm: AstrBotMessage) -> None:
|
||||
event = LineMessageEvent(
|
||||
message_str=abm.message_str,
|
||||
message_obj=abm,
|
||||
platform_meta=self.meta(),
|
||||
session_id=abm.session_id,
|
||||
line_api=self.line_api,
|
||||
)
|
||||
self._event_queue.put_nowait(event)
|
||||
@@ -0,0 +1,203 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import hmac
|
||||
import json
|
||||
from hashlib import sha256
|
||||
from typing import Any
|
||||
from urllib.parse import unquote
|
||||
|
||||
import aiohttp
|
||||
|
||||
from astrbot.api import logger
|
||||
|
||||
|
||||
class LineAPIClient:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
channel_access_token: str,
|
||||
channel_secret: str,
|
||||
timeout_seconds: int = 30,
|
||||
) -> None:
|
||||
self.channel_access_token = channel_access_token.strip()
|
||||
self.channel_secret = channel_secret.strip()
|
||||
self.timeout = aiohttp.ClientTimeout(total=timeout_seconds)
|
||||
self._session: aiohttp.ClientSession | None = None
|
||||
|
||||
async def _get_session(self) -> aiohttp.ClientSession:
|
||||
if self._session is None or self._session.closed:
|
||||
self._session = aiohttp.ClientSession(timeout=self.timeout)
|
||||
return self._session
|
||||
|
||||
async def close(self) -> None:
|
||||
if self._session and not self._session.closed:
|
||||
await self._session.close()
|
||||
|
||||
def verify_signature(self, raw_body: bytes, signature: str | None) -> bool:
|
||||
if not signature:
|
||||
return False
|
||||
digest = hmac.new(
|
||||
self.channel_secret.encode("utf-8"),
|
||||
raw_body,
|
||||
sha256,
|
||||
).digest()
|
||||
expected = base64.b64encode(digest).decode("utf-8")
|
||||
return hmac.compare_digest(expected, signature.strip())
|
||||
|
||||
@property
|
||||
def _auth_headers(self) -> dict[str, str]:
|
||||
return {"Authorization": f"Bearer {self.channel_access_token}"}
|
||||
|
||||
async def reply_message(
|
||||
self,
|
||||
reply_token: str,
|
||||
messages: list[dict[str, Any]],
|
||||
*,
|
||||
notification_disabled: bool = False,
|
||||
) -> bool:
|
||||
payload = {
|
||||
"replyToken": reply_token,
|
||||
"messages": messages[:5],
|
||||
"notificationDisabled": notification_disabled,
|
||||
}
|
||||
return await self._post_json(
|
||||
"https://api.line.me/v2/bot/message/reply",
|
||||
payload=payload,
|
||||
op_name="reply",
|
||||
)
|
||||
|
||||
async def push_message(
|
||||
self,
|
||||
to: str,
|
||||
messages: list[dict[str, Any]],
|
||||
*,
|
||||
notification_disabled: bool = False,
|
||||
) -> bool:
|
||||
payload = {
|
||||
"to": to,
|
||||
"messages": messages[:5],
|
||||
"notificationDisabled": notification_disabled,
|
||||
}
|
||||
return await self._post_json(
|
||||
"https://api.line.me/v2/bot/message/push",
|
||||
payload=payload,
|
||||
op_name="push",
|
||||
)
|
||||
|
||||
async def _post_json(
|
||||
self,
|
||||
url: str,
|
||||
*,
|
||||
payload: dict[str, Any],
|
||||
op_name: str,
|
||||
) -> bool:
|
||||
session = await self._get_session()
|
||||
headers = {
|
||||
**self._auth_headers,
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
try:
|
||||
async with session.post(url, json=payload, headers=headers) as resp:
|
||||
if resp.status < 400:
|
||||
return True
|
||||
body = await resp.text()
|
||||
logger.error(
|
||||
"[LINE] %s message failed: status=%s body=%s",
|
||||
op_name,
|
||||
resp.status,
|
||||
body,
|
||||
)
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error("[LINE] %s message request failed: %s", op_name, e)
|
||||
return False
|
||||
|
||||
async def get_message_content(
|
||||
self,
|
||||
message_id: str,
|
||||
) -> tuple[bytes, str | None, str | None] | None:
|
||||
session = await self._get_session()
|
||||
url = f"https://api-data.line.me/v2/bot/message/{message_id}/content"
|
||||
headers = self._auth_headers
|
||||
|
||||
async with session.get(url, headers=headers) as resp:
|
||||
if resp.status == 202:
|
||||
if not await self._wait_for_transcoding(message_id):
|
||||
return None
|
||||
async with session.get(url, headers=headers) as retry_resp:
|
||||
if retry_resp.status != 200:
|
||||
body = await retry_resp.text()
|
||||
logger.warning(
|
||||
"[LINE] get content retry failed: message_id=%s status=%s body=%s",
|
||||
message_id,
|
||||
retry_resp.status,
|
||||
body,
|
||||
)
|
||||
return None
|
||||
return await self._read_content_response(retry_resp)
|
||||
|
||||
if resp.status != 200:
|
||||
body = await resp.text()
|
||||
logger.warning(
|
||||
"[LINE] get content failed: message_id=%s status=%s body=%s",
|
||||
message_id,
|
||||
resp.status,
|
||||
body,
|
||||
)
|
||||
return None
|
||||
return await self._read_content_response(resp)
|
||||
|
||||
async def _read_content_response(
|
||||
self,
|
||||
resp: aiohttp.ClientResponse,
|
||||
) -> tuple[bytes, str | None, str | None]:
|
||||
content = await resp.read()
|
||||
content_type = resp.headers.get("Content-Type")
|
||||
disposition = resp.headers.get("Content-Disposition")
|
||||
filename = self._extract_filename_from_disposition(disposition)
|
||||
return content, content_type, filename
|
||||
|
||||
def _extract_filename_from_disposition(self, disposition: str | None) -> str | None:
|
||||
if not disposition:
|
||||
return None
|
||||
for part in disposition.split(";"):
|
||||
token = part.strip()
|
||||
if token.startswith("filename*="):
|
||||
val = token.split("=", 1)[1].strip().strip('"')
|
||||
if val.lower().startswith("utf-8''"):
|
||||
val = val[7:]
|
||||
return unquote(val)
|
||||
if token.startswith("filename="):
|
||||
return token.split("=", 1)[1].strip().strip('"')
|
||||
return None
|
||||
|
||||
async def _wait_for_transcoding(
|
||||
self,
|
||||
message_id: str,
|
||||
*,
|
||||
max_attempts: int = 10,
|
||||
interval_seconds: float = 1.0,
|
||||
) -> bool:
|
||||
session = await self._get_session()
|
||||
url = (
|
||||
f"https://api-data.line.me/v2/bot/message/{message_id}/content/transcoding"
|
||||
)
|
||||
headers = self._auth_headers
|
||||
|
||||
for _ in range(max_attempts):
|
||||
try:
|
||||
async with session.get(url, headers=headers) as resp:
|
||||
if resp.status != 200:
|
||||
await asyncio.sleep(interval_seconds)
|
||||
continue
|
||||
body = await resp.text()
|
||||
data = json.loads(body)
|
||||
status = str(data.get("status", "")).lower()
|
||||
if status == "succeeded":
|
||||
return True
|
||||
if status == "failed":
|
||||
return False
|
||||
except Exception:
|
||||
pass
|
||||
await asyncio.sleep(interval_seconds)
|
||||
return False
|
||||
@@ -0,0 +1,285 @@
|
||||
import asyncio
|
||||
import os
|
||||
import re
|
||||
import uuid
|
||||
from collections.abc import AsyncGenerator
|
||||
from pathlib import Path
|
||||
|
||||
from astrbot.api import logger
|
||||
from astrbot.api.event import AstrMessageEvent, MessageChain
|
||||
from astrbot.api.message_components import (
|
||||
At,
|
||||
BaseMessageComponent,
|
||||
File,
|
||||
Image,
|
||||
Plain,
|
||||
Record,
|
||||
Video,
|
||||
)
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
|
||||
from astrbot.core.utils.media_utils import get_media_duration
|
||||
|
||||
from .line_api import LineAPIClient
|
||||
|
||||
|
||||
class LineMessageEvent(AstrMessageEvent):
|
||||
def __init__(
|
||||
self,
|
||||
message_str,
|
||||
message_obj,
|
||||
platform_meta,
|
||||
session_id,
|
||||
line_api: LineAPIClient,
|
||||
) -> None:
|
||||
super().__init__(message_str, message_obj, platform_meta, session_id)
|
||||
self.line_api = line_api
|
||||
|
||||
@staticmethod
|
||||
async def _component_to_message_object(
|
||||
segment: BaseMessageComponent,
|
||||
) -> dict | None:
|
||||
if isinstance(segment, Plain):
|
||||
text = segment.text.strip()
|
||||
if not text:
|
||||
return None
|
||||
return {"type": "text", "text": text[:5000]}
|
||||
|
||||
if isinstance(segment, At):
|
||||
name = str(segment.name or segment.qq or "").strip()
|
||||
if not name:
|
||||
return None
|
||||
return {"type": "text", "text": f"@{name}"[:5000]}
|
||||
|
||||
if isinstance(segment, Image):
|
||||
image_url = await LineMessageEvent._resolve_image_url(segment)
|
||||
if not image_url:
|
||||
return None
|
||||
return {
|
||||
"type": "image",
|
||||
"originalContentUrl": image_url,
|
||||
"previewImageUrl": image_url,
|
||||
}
|
||||
|
||||
if isinstance(segment, Record):
|
||||
audio_url = await LineMessageEvent._resolve_record_url(segment)
|
||||
if not audio_url:
|
||||
return None
|
||||
duration = await LineMessageEvent._resolve_record_duration(segment)
|
||||
return {
|
||||
"type": "audio",
|
||||
"originalContentUrl": audio_url,
|
||||
"duration": duration,
|
||||
}
|
||||
|
||||
if isinstance(segment, Video):
|
||||
video_url = await LineMessageEvent._resolve_video_url(segment)
|
||||
if not video_url:
|
||||
return None
|
||||
preview_url = await LineMessageEvent._resolve_video_preview_url(segment)
|
||||
if not preview_url:
|
||||
return None
|
||||
return {
|
||||
"type": "video",
|
||||
"originalContentUrl": video_url,
|
||||
"previewImageUrl": preview_url,
|
||||
}
|
||||
|
||||
if isinstance(segment, File):
|
||||
file_url = await LineMessageEvent._resolve_file_url(segment)
|
||||
if not file_url:
|
||||
return None
|
||||
file_name = str(segment.name or "").strip() or "file.bin"
|
||||
file_size = await LineMessageEvent._resolve_file_size(segment)
|
||||
if file_size <= 0:
|
||||
return None
|
||||
return {
|
||||
"type": "file",
|
||||
"fileName": file_name,
|
||||
"fileSize": file_size,
|
||||
"originalContentUrl": file_url,
|
||||
}
|
||||
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
async def _resolve_image_url(segment: Image) -> str:
|
||||
candidate = (segment.url or segment.file or "").strip()
|
||||
if candidate.startswith("http://") or candidate.startswith("https://"):
|
||||
return candidate
|
||||
try:
|
||||
return await segment.register_to_file_service()
|
||||
except Exception as e:
|
||||
logger.debug("[LINE] resolve image url failed: %s", e)
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
async def _resolve_record_url(segment: Record) -> str:
|
||||
candidate = (segment.url or segment.file or "").strip()
|
||||
if candidate.startswith("http://") or candidate.startswith("https://"):
|
||||
return candidate
|
||||
try:
|
||||
return await segment.register_to_file_service()
|
||||
except Exception as e:
|
||||
logger.debug("[LINE] resolve record url failed: %s", e)
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
async def _resolve_record_duration(segment: Record) -> int:
|
||||
try:
|
||||
file_path = await segment.convert_to_file_path()
|
||||
duration_ms = await get_media_duration(file_path)
|
||||
if isinstance(duration_ms, int) and duration_ms > 0:
|
||||
return duration_ms
|
||||
except Exception as e:
|
||||
logger.debug("[LINE] resolve record duration failed: %s", e)
|
||||
return 1000
|
||||
|
||||
@staticmethod
|
||||
async def _resolve_video_url(segment: Video) -> str:
|
||||
candidate = (segment.file or "").strip()
|
||||
if candidate.startswith("http://") or candidate.startswith("https://"):
|
||||
return candidate
|
||||
try:
|
||||
return await segment.register_to_file_service()
|
||||
except Exception as e:
|
||||
logger.debug("[LINE] resolve video url failed: %s", e)
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
async def _resolve_video_preview_url(segment: Video) -> str:
|
||||
cover_candidate = (segment.cover or "").strip()
|
||||
if cover_candidate.startswith("http://") or cover_candidate.startswith(
|
||||
"https://"
|
||||
):
|
||||
return cover_candidate
|
||||
|
||||
if cover_candidate:
|
||||
try:
|
||||
cover_seg = Image(file=cover_candidate)
|
||||
return await cover_seg.register_to_file_service()
|
||||
except Exception as e:
|
||||
logger.debug("[LINE] resolve video cover failed: %s", e)
|
||||
|
||||
try:
|
||||
video_path = await segment.convert_to_file_path()
|
||||
temp_dir = Path(get_astrbot_temp_path())
|
||||
temp_dir.mkdir(parents=True, exist_ok=True)
|
||||
thumb_path = temp_dir / f"line_video_preview_{uuid.uuid4().hex}.jpg"
|
||||
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-ss",
|
||||
"00:00:01",
|
||||
"-i",
|
||||
video_path,
|
||||
"-frames:v",
|
||||
"1",
|
||||
str(thumb_path),
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
await process.communicate()
|
||||
if process.returncode != 0 or not thumb_path.exists():
|
||||
return ""
|
||||
|
||||
cover_seg = Image.fromFileSystem(str(thumb_path))
|
||||
return await cover_seg.register_to_file_service()
|
||||
except Exception as e:
|
||||
logger.debug("[LINE] generate video preview failed: %s", e)
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
async def _resolve_file_url(segment: File) -> str:
|
||||
if segment.url and segment.url.startswith(("http://", "https://")):
|
||||
return segment.url
|
||||
try:
|
||||
return await segment.register_to_file_service()
|
||||
except Exception as e:
|
||||
logger.debug("[LINE] resolve file url failed: %s", e)
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
async def _resolve_file_size(segment: File) -> int:
|
||||
try:
|
||||
file_path = await segment.get_file(allow_return_url=False)
|
||||
if file_path and os.path.exists(file_path):
|
||||
return int(os.path.getsize(file_path))
|
||||
except Exception as e:
|
||||
logger.debug("[LINE] resolve file size failed: %s", e)
|
||||
return 0
|
||||
|
||||
@classmethod
|
||||
async def build_line_messages(cls, message_chain: MessageChain) -> list[dict]:
|
||||
messages: list[dict] = []
|
||||
for segment in message_chain.chain:
|
||||
obj = await cls._component_to_message_object(segment)
|
||||
if obj:
|
||||
messages.append(obj)
|
||||
|
||||
if not messages:
|
||||
return []
|
||||
|
||||
if len(messages) > 5:
|
||||
logger.warning(
|
||||
"[LINE] message count exceeds 5, extra segments will be dropped."
|
||||
)
|
||||
messages = messages[:5]
|
||||
return messages
|
||||
|
||||
async def send(self, message: MessageChain) -> None:
|
||||
messages = await self.build_line_messages(message)
|
||||
if not messages:
|
||||
return
|
||||
|
||||
raw = self.message_obj.raw_message
|
||||
reply_token = ""
|
||||
if isinstance(raw, dict):
|
||||
reply_token = str(raw.get("replyToken") or "")
|
||||
|
||||
sent = False
|
||||
if reply_token:
|
||||
sent = await self.line_api.reply_message(reply_token, messages)
|
||||
|
||||
if not sent:
|
||||
target_id = self.get_group_id() or self.get_sender_id()
|
||||
if target_id:
|
||||
await self.line_api.push_message(target_id, messages)
|
||||
|
||||
await super().send(message)
|
||||
|
||||
async def send_streaming(
|
||||
self,
|
||||
generator: AsyncGenerator,
|
||||
use_fallback: bool = False,
|
||||
):
|
||||
if not use_fallback:
|
||||
buffer = None
|
||||
async for chain in generator:
|
||||
if not buffer:
|
||||
buffer = chain
|
||||
else:
|
||||
buffer.chain.extend(chain.chain)
|
||||
if not buffer:
|
||||
return None
|
||||
buffer.squash_plain()
|
||||
await self.send(buffer)
|
||||
return await super().send_streaming(generator, use_fallback)
|
||||
|
||||
buffer = ""
|
||||
pattern = re.compile(r"[^。?!~…]+[。?!~…]+")
|
||||
|
||||
async for chain in generator:
|
||||
if isinstance(chain, MessageChain):
|
||||
for comp in chain.chain:
|
||||
if isinstance(comp, Plain):
|
||||
buffer += comp.text
|
||||
if any(p in buffer for p in "。?!~…"):
|
||||
buffer = await self.process_buffer(buffer, pattern)
|
||||
else:
|
||||
await self.send(MessageChain(chain=[comp]))
|
||||
await asyncio.sleep(1.5)
|
||||
|
||||
if buffer.strip():
|
||||
await self.send(MessageChain([Plain(buffer)]))
|
||||
return await super().send_streaming(generator, use_fallback)
|
||||
@@ -21,7 +21,7 @@ try:
|
||||
except Exception:
|
||||
magic = None
|
||||
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
|
||||
|
||||
from .misskey_event import MisskeyPlatformEvent
|
||||
from .misskey_utils import (
|
||||
@@ -498,7 +498,7 @@ class MisskeyPlatformAdapter(Platform):
|
||||
finally:
|
||||
# 清理临时文件
|
||||
if local_path and isinstance(local_path, str):
|
||||
data_temp = os.path.join(get_astrbot_data_path(), "temp")
|
||||
data_temp = get_astrbot_temp_path()
|
||||
if local_path.startswith(data_temp) and os.path.exists(
|
||||
local_path,
|
||||
):
|
||||
|
||||
@@ -7,24 +7,47 @@ from typing import cast
|
||||
|
||||
import aiofiles
|
||||
import botpy
|
||||
import botpy.errors
|
||||
import botpy.message
|
||||
import botpy.types
|
||||
import botpy.types.message
|
||||
from botpy import Client
|
||||
from botpy.http import Route
|
||||
from botpy.types import message
|
||||
from botpy.types.message import Media
|
||||
from botpy.types.message import MarkdownPayload, Media
|
||||
|
||||
from astrbot.api import logger
|
||||
from astrbot.api.event import AstrMessageEvent, MessageChain
|
||||
from astrbot.api.message_components import Image, Plain, Record
|
||||
from astrbot.api.platform import AstrBotMessage, PlatformMetadata
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
|
||||
from astrbot.core.utils.io import download_image_by_url, file_to_base64
|
||||
from astrbot.core.utils.tencent_record_helper import wav_to_tencent_silk
|
||||
|
||||
|
||||
def _patch_qq_botpy_formdata() -> None:
|
||||
"""Patch qq-botpy for aiohttp>=3.12 compatibility.
|
||||
|
||||
qq-botpy 1.2.1 defines botpy.http._FormData._gen_form_data() and expects
|
||||
aiohttp.FormData to have a private flag named _is_processed, which is no
|
||||
longer present in newer aiohttp versions.
|
||||
"""
|
||||
|
||||
try:
|
||||
from botpy.http import _FormData # type: ignore
|
||||
|
||||
if not hasattr(_FormData, "_is_processed"):
|
||||
setattr(_FormData, "_is_processed", False)
|
||||
except Exception:
|
||||
logger.debug("[QQOfficial] Skip botpy FormData patch.")
|
||||
|
||||
|
||||
_patch_qq_botpy_formdata()
|
||||
|
||||
|
||||
class QQOfficialMessageEvent(AstrMessageEvent):
|
||||
MARKDOWN_NOT_ALLOWED_ERROR = "不允许发送原生 markdown"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message_str: str,
|
||||
@@ -114,7 +137,9 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
||||
return None
|
||||
|
||||
payload: dict = {
|
||||
"content": plain_text,
|
||||
# "content": plain_text,
|
||||
"markdown": MarkdownPayload(content=plain_text) if plain_text else None,
|
||||
"msg_type": 2,
|
||||
"msg_id": self.message_obj.message_id,
|
||||
}
|
||||
|
||||
@@ -145,9 +170,13 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
||||
)
|
||||
payload["media"] = media
|
||||
payload["msg_type"] = 7
|
||||
ret = await self.bot.api.post_group_message(
|
||||
group_openid=source.group_openid,
|
||||
**payload,
|
||||
ret = await self._send_with_markdown_fallback(
|
||||
send_func=lambda retry_payload: self.bot.api.post_group_message(
|
||||
group_openid=source.group_openid, # type: ignore
|
||||
**retry_payload,
|
||||
),
|
||||
payload=payload,
|
||||
plain_text=plain_text,
|
||||
)
|
||||
|
||||
case botpy.message.C2CMessage():
|
||||
@@ -168,30 +197,53 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
||||
payload["media"] = media
|
||||
payload["msg_type"] = 7
|
||||
if stream:
|
||||
ret = await self.post_c2c_message(
|
||||
openid=source.author.user_openid,
|
||||
**payload,
|
||||
stream=stream,
|
||||
ret = await self._send_with_markdown_fallback(
|
||||
send_func=lambda retry_payload: self.post_c2c_message(
|
||||
openid=source.author.user_openid,
|
||||
**retry_payload,
|
||||
stream=stream,
|
||||
),
|
||||
payload=payload,
|
||||
plain_text=plain_text,
|
||||
)
|
||||
else:
|
||||
ret = await self.post_c2c_message(
|
||||
openid=source.author.user_openid,
|
||||
**payload,
|
||||
ret = await self._send_with_markdown_fallback(
|
||||
send_func=lambda retry_payload: self.post_c2c_message(
|
||||
openid=source.author.user_openid,
|
||||
**retry_payload,
|
||||
),
|
||||
payload=payload,
|
||||
plain_text=plain_text,
|
||||
)
|
||||
logger.debug(f"Message sent to C2C: {ret}")
|
||||
|
||||
case botpy.message.Message():
|
||||
if image_path:
|
||||
payload["file_image"] = image_path
|
||||
ret = await self.bot.api.post_message(
|
||||
channel_id=source.channel_id,
|
||||
**payload,
|
||||
# Guild text-channel send API (/channels/{channel_id}/messages) does not use v2 msg_type.
|
||||
payload.pop("msg_type", None)
|
||||
ret = await self._send_with_markdown_fallback(
|
||||
send_func=lambda retry_payload: self.bot.api.post_message(
|
||||
channel_id=source.channel_id,
|
||||
**retry_payload,
|
||||
),
|
||||
payload=payload,
|
||||
plain_text=plain_text,
|
||||
)
|
||||
|
||||
case botpy.message.DirectMessage():
|
||||
if image_path:
|
||||
payload["file_image"] = image_path
|
||||
ret = await self.bot.api.post_dms(guild_id=source.guild_id, **payload)
|
||||
# Guild DM send API (/dms/{guild_id}/messages) does not use v2 msg_type.
|
||||
payload.pop("msg_type", None)
|
||||
ret = await self._send_with_markdown_fallback(
|
||||
send_func=lambda retry_payload: self.bot.api.post_dms(
|
||||
guild_id=source.guild_id,
|
||||
**retry_payload,
|
||||
),
|
||||
payload=payload,
|
||||
plain_text=plain_text,
|
||||
)
|
||||
|
||||
case _:
|
||||
pass
|
||||
@@ -202,6 +254,32 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
||||
|
||||
return ret
|
||||
|
||||
async def _send_with_markdown_fallback(
|
||||
self,
|
||||
send_func,
|
||||
payload: dict,
|
||||
plain_text: str,
|
||||
):
|
||||
try:
|
||||
return await send_func(payload)
|
||||
except botpy.errors.ServerError as err:
|
||||
if (
|
||||
self.MARKDOWN_NOT_ALLOWED_ERROR not in str(err)
|
||||
or not payload.get("markdown")
|
||||
or not plain_text
|
||||
):
|
||||
raise
|
||||
|
||||
logger.warning(
|
||||
"[QQOfficial] markdown 发送被拒绝,回退到 content 模式重试。"
|
||||
)
|
||||
fallback_payload = payload.copy()
|
||||
fallback_payload["markdown"] = None
|
||||
fallback_payload["content"] = plain_text
|
||||
if fallback_payload.get("msg_type") == 2:
|
||||
fallback_payload["msg_type"] = 0
|
||||
return await send_func(fallback_payload)
|
||||
|
||||
async def upload_group_and_c2c_image(
|
||||
self,
|
||||
image_base64: str,
|
||||
@@ -350,10 +428,10 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
||||
elif isinstance(i, Record):
|
||||
if i.file:
|
||||
record_wav_path = await i.convert_to_file_path() # wav 路径
|
||||
temp_dir = os.path.join(get_astrbot_data_path(), "temp")
|
||||
temp_dir = get_astrbot_temp_path()
|
||||
record_tecent_silk_path = os.path.join(
|
||||
temp_dir,
|
||||
f"{uuid.uuid4()}.silk",
|
||||
f"qqofficial_{uuid.uuid4()}.silk",
|
||||
)
|
||||
try:
|
||||
duration = await wav_to_tencent_silk(
|
||||
|
||||
@@ -8,13 +8,11 @@ from typing import cast
|
||||
|
||||
import botpy
|
||||
import botpy.message
|
||||
import botpy.types
|
||||
import botpy.types.message
|
||||
from botpy import Client
|
||||
|
||||
from astrbot import logger
|
||||
from astrbot.api.event import MessageChain
|
||||
from astrbot.api.message_components import At, Image, Plain
|
||||
from astrbot.api.message_components import At, File, Image, Plain
|
||||
from astrbot.api.platform import (
|
||||
AstrBotMessage,
|
||||
MessageMember,
|
||||
@@ -143,6 +141,41 @@ class QQOfficialPlatformAdapter(Platform):
|
||||
support_proactive_message=False,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_attachment_url(url: str | None) -> str:
|
||||
if not url:
|
||||
return ""
|
||||
if url.startswith("http://") or url.startswith("https://"):
|
||||
return url
|
||||
return f"https://{url}"
|
||||
|
||||
@staticmethod
|
||||
def _append_attachments(
|
||||
msg: list[BaseMessageComponent],
|
||||
attachments: list | None,
|
||||
) -> None:
|
||||
if not attachments:
|
||||
return
|
||||
|
||||
for attachment in attachments:
|
||||
content_type = cast(str, getattr(attachment, "content_type", "") or "")
|
||||
url = QQOfficialPlatformAdapter._normalize_attachment_url(
|
||||
cast(str | None, getattr(attachment, "url", None))
|
||||
)
|
||||
if not url:
|
||||
continue
|
||||
|
||||
if content_type.startswith("image"):
|
||||
msg.append(Image.fromURL(url))
|
||||
else:
|
||||
filename = cast(
|
||||
str,
|
||||
getattr(attachment, "filename", None)
|
||||
or getattr(attachment, "name", None)
|
||||
or "attachment",
|
||||
)
|
||||
msg.append(File(name=filename, file=url, url=url))
|
||||
|
||||
@staticmethod
|
||||
def _parse_from_qqofficial(
|
||||
message: botpy.message.Message
|
||||
@@ -172,14 +205,7 @@ class QQOfficialPlatformAdapter(Platform):
|
||||
abm.self_id = "unknown_selfid"
|
||||
msg.append(At(qq="qq_official"))
|
||||
msg.append(Plain(abm.message_str))
|
||||
if message.attachments:
|
||||
for i in message.attachments:
|
||||
if i.content_type.startswith("image"):
|
||||
url = i.url
|
||||
if not url.startswith("http"):
|
||||
url = "https://" + url
|
||||
img = Image.fromURL(url)
|
||||
msg.append(img)
|
||||
QQOfficialPlatformAdapter._append_attachments(msg, message.attachments)
|
||||
abm.message = msg
|
||||
|
||||
elif isinstance(message, botpy.message.Message) or isinstance(
|
||||
@@ -196,14 +222,7 @@ class QQOfficialPlatformAdapter(Platform):
|
||||
"",
|
||||
).strip()
|
||||
|
||||
if message.attachments:
|
||||
for i in message.attachments:
|
||||
if i.content_type.startswith("image"):
|
||||
url = i.url
|
||||
if not url.startswith("http"):
|
||||
url = "https://" + url
|
||||
img = Image.fromURL(url)
|
||||
msg.append(img)
|
||||
QQOfficialPlatformAdapter._append_attachments(msg, message.attachments)
|
||||
abm.message = msg
|
||||
abm.message_str = plain_content
|
||||
abm.sender = MessageMember(
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import random
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, cast
|
||||
|
||||
import botpy
|
||||
import botpy.message
|
||||
import botpy.types
|
||||
import botpy.types.message
|
||||
from botpy import Client
|
||||
|
||||
from astrbot import logger
|
||||
@@ -15,6 +15,7 @@ from astrbot.core.platform.astr_message_event import MessageSesion
|
||||
from astrbot.core.utils.webhook_utils import log_webhook_info
|
||||
|
||||
from ...register import register_platform_adapter
|
||||
from ..qqofficial.qqofficial_message_event import QQOfficialMessageEvent
|
||||
from ..qqofficial.qqofficial_platform_adapter import QQOfficialPlatformAdapter
|
||||
from .qo_webhook_event import QQOfficialWebhookMessageEvent
|
||||
from .qo_webhook_server import QQOfficialWebhook
|
||||
@@ -39,6 +40,7 @@ class botClient(Client):
|
||||
)
|
||||
abm.group_id = cast(str, message.group_openid)
|
||||
abm.session_id = abm.group_id
|
||||
self.platform.remember_session_scene(abm.session_id, "group")
|
||||
self._commit(abm)
|
||||
|
||||
# 收到频道消息
|
||||
@@ -49,6 +51,7 @@ class botClient(Client):
|
||||
)
|
||||
abm.group_id = message.channel_id
|
||||
abm.session_id = abm.group_id
|
||||
self.platform.remember_session_scene(abm.session_id, "channel")
|
||||
self._commit(abm)
|
||||
|
||||
# 收到私聊消息
|
||||
@@ -60,6 +63,7 @@ class botClient(Client):
|
||||
MessageType.FRIEND_MESSAGE,
|
||||
)
|
||||
abm.session_id = abm.sender.user_id
|
||||
self.platform.remember_session_scene(abm.session_id, "friend")
|
||||
self._commit(abm)
|
||||
|
||||
# 收到 C2C 消息
|
||||
@@ -69,9 +73,11 @@ class botClient(Client):
|
||||
MessageType.FRIEND_MESSAGE,
|
||||
)
|
||||
abm.session_id = abm.sender.user_id
|
||||
self.platform.remember_session_scene(abm.session_id, "friend")
|
||||
self._commit(abm)
|
||||
|
||||
def _commit(self, abm: AstrBotMessage) -> None:
|
||||
self.platform.remember_session_message_id(abm.session_id, abm.message_id)
|
||||
self.platform.commit_event(
|
||||
QQOfficialWebhookMessageEvent(
|
||||
abm.message_str,
|
||||
@@ -109,20 +115,129 @@ class QQOfficialWebhookPlatformAdapter(Platform):
|
||||
)
|
||||
self.client.set_platform(self)
|
||||
self.webhook_helper = None
|
||||
self._session_last_message_id: dict[str, str] = {}
|
||||
self._session_scene: dict[str, str] = {}
|
||||
|
||||
async def send_by_session(
|
||||
self,
|
||||
session: MessageSesion,
|
||||
message_chain: MessageChain,
|
||||
) -> None:
|
||||
raise NotImplementedError("QQ 机器人官方 API 适配器不支持 send_by_session")
|
||||
(
|
||||
plain_text,
|
||||
image_base64,
|
||||
image_path,
|
||||
record_file_path,
|
||||
) = await QQOfficialMessageEvent._parse_to_qqofficial(message_chain)
|
||||
if not plain_text and not image_path:
|
||||
return
|
||||
|
||||
msg_id = self._session_last_message_id.get(session.session_id)
|
||||
if not msg_id:
|
||||
logger.warning(
|
||||
"[QQOfficialWebhook] No cached msg_id for session: %s, skip send_by_session",
|
||||
session.session_id,
|
||||
)
|
||||
return
|
||||
|
||||
payload: dict[str, Any] = {"content": plain_text, "msg_id": msg_id}
|
||||
ret: Any = None
|
||||
send_helper = SimpleNamespace(bot=self.client)
|
||||
if session.message_type == MessageType.GROUP_MESSAGE:
|
||||
scene = self._session_scene.get(session.session_id)
|
||||
if scene == "group":
|
||||
payload["msg_seq"] = random.randint(1, 10000)
|
||||
if image_base64:
|
||||
media = await QQOfficialMessageEvent.upload_group_and_c2c_image(
|
||||
send_helper, # type: ignore
|
||||
image_base64,
|
||||
1,
|
||||
group_openid=session.session_id,
|
||||
)
|
||||
payload["media"] = media
|
||||
payload["msg_type"] = 7
|
||||
if record_file_path:
|
||||
media = await QQOfficialMessageEvent.upload_group_and_c2c_record(
|
||||
send_helper, # type: ignore
|
||||
record_file_path,
|
||||
3,
|
||||
group_openid=session.session_id,
|
||||
)
|
||||
payload["media"] = media
|
||||
payload["msg_type"] = 7
|
||||
ret = await self.client.api.post_group_message(
|
||||
group_openid=session.session_id,
|
||||
**payload,
|
||||
)
|
||||
else:
|
||||
if image_path:
|
||||
payload["file_image"] = image_path
|
||||
ret = await self.client.api.post_message(
|
||||
channel_id=session.session_id,
|
||||
**payload,
|
||||
)
|
||||
elif session.message_type == MessageType.FRIEND_MESSAGE:
|
||||
payload["msg_seq"] = random.randint(1, 10000)
|
||||
if image_base64:
|
||||
media = await QQOfficialMessageEvent.upload_group_and_c2c_image(
|
||||
send_helper, # type: ignore
|
||||
image_base64,
|
||||
1,
|
||||
openid=session.session_id,
|
||||
)
|
||||
payload["media"] = media
|
||||
payload["msg_type"] = 7
|
||||
if record_file_path:
|
||||
media = await QQOfficialMessageEvent.upload_group_and_c2c_record(
|
||||
send_helper, # type: ignore
|
||||
record_file_path,
|
||||
3,
|
||||
openid=session.session_id,
|
||||
)
|
||||
payload["media"] = media
|
||||
payload["msg_type"] = 7
|
||||
ret = await QQOfficialMessageEvent.post_c2c_message(
|
||||
send_helper, # type: ignore
|
||||
openid=session.session_id,
|
||||
**payload,
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"[QQOfficialWebhook] Unsupported message type for send_by_session: %s",
|
||||
session.message_type,
|
||||
)
|
||||
return
|
||||
|
||||
sent_message_id = self._extract_message_id(ret)
|
||||
if sent_message_id:
|
||||
self.remember_session_message_id(session.session_id, sent_message_id)
|
||||
await super().send_by_session(session, message_chain)
|
||||
|
||||
def remember_session_message_id(self, session_id: str, message_id: str) -> None:
|
||||
if not session_id or not message_id:
|
||||
return
|
||||
self._session_last_message_id[session_id] = message_id
|
||||
|
||||
def remember_session_scene(self, session_id: str, scene: str) -> None:
|
||||
if not session_id or not scene:
|
||||
return
|
||||
self._session_scene[session_id] = scene
|
||||
|
||||
def _extract_message_id(self, ret: Any) -> str | None:
|
||||
if isinstance(ret, dict):
|
||||
message_id = ret.get("id")
|
||||
return str(message_id) if message_id else None
|
||||
message_id = getattr(ret, "id", None)
|
||||
if message_id:
|
||||
return str(message_id)
|
||||
return None
|
||||
|
||||
def meta(self) -> PlatformMetadata:
|
||||
return PlatformMetadata(
|
||||
name="qq_official_webhook",
|
||||
description="QQ 机器人官方 API 适配器",
|
||||
id=cast(str, self.config.get("id")),
|
||||
support_proactive_message=False,
|
||||
support_proactive_message=True,
|
||||
)
|
||||
|
||||
async def run(self) -> None:
|
||||
|
||||
@@ -174,14 +174,19 @@ class TelegramPlatformAdapter(Platform):
|
||||
if not handler_metadata.enabled:
|
||||
continue
|
||||
for event_filter in handler_metadata.event_filters:
|
||||
cmd_info = self._extract_command_info(
|
||||
cmd_info_list = self._extract_command_info(
|
||||
event_filter,
|
||||
handler_metadata,
|
||||
skip_commands,
|
||||
)
|
||||
if cmd_info:
|
||||
cmd_name, description = cmd_info
|
||||
command_dict.setdefault(cmd_name, description)
|
||||
if cmd_info_list:
|
||||
for cmd_name, description in cmd_info_list:
|
||||
if cmd_name in command_dict:
|
||||
logger.warning(
|
||||
f"命令名 '{cmd_name}' 重复注册,将使用首次注册的定义: "
|
||||
f"'{command_dict[cmd_name]}'"
|
||||
)
|
||||
command_dict.setdefault(cmd_name, description)
|
||||
|
||||
commands_a = sorted(command_dict.keys())
|
||||
return [BotCommand(cmd, command_dict[cmd]) for cmd in commands_a]
|
||||
@@ -191,9 +196,9 @@ class TelegramPlatformAdapter(Platform):
|
||||
event_filter,
|
||||
handler_metadata,
|
||||
skip_commands: set,
|
||||
) -> tuple[str, str] | None:
|
||||
"""从事件过滤器中提取指令信息"""
|
||||
cmd_name = None
|
||||
) -> list[tuple[str, str]] | None:
|
||||
"""从事件过滤器中提取指令信息,包括所有别名"""
|
||||
cmd_names = []
|
||||
is_group = False
|
||||
if isinstance(event_filter, CommandFilter) and event_filter.command_name:
|
||||
if (
|
||||
@@ -201,26 +206,32 @@ class TelegramPlatformAdapter(Platform):
|
||||
and event_filter.parent_command_names != [""]
|
||||
):
|
||||
return None
|
||||
cmd_name = event_filter.command_name
|
||||
# 收集主命令名和所有别名
|
||||
cmd_names = [event_filter.command_name]
|
||||
if event_filter.alias:
|
||||
cmd_names.extend(event_filter.alias)
|
||||
elif isinstance(event_filter, CommandGroupFilter):
|
||||
if event_filter.parent_group:
|
||||
return None
|
||||
cmd_name = event_filter.group_name
|
||||
cmd_names = [event_filter.group_name]
|
||||
is_group = True
|
||||
|
||||
if not cmd_name or cmd_name in skip_commands:
|
||||
return None
|
||||
result = []
|
||||
for cmd_name in cmd_names:
|
||||
if not cmd_name or cmd_name in skip_commands:
|
||||
continue
|
||||
if not re.match(r"^[a-z0-9_]+$", cmd_name) or len(cmd_name) > 32:
|
||||
continue
|
||||
|
||||
if not re.match(r"^[a-z0-9_]+$", cmd_name) or len(cmd_name) > 32:
|
||||
return None
|
||||
# Build description.
|
||||
description = handler_metadata.desc or (
|
||||
f"Command group: {cmd_name}" if is_group else f"Command: {cmd_name}"
|
||||
)
|
||||
if len(description) > 30:
|
||||
description = description[:30] + "..."
|
||||
result.append((cmd_name, description))
|
||||
|
||||
# Build description.
|
||||
description = handler_metadata.desc or (
|
||||
f"指令组: {cmd_name} (包含多个子指令)" if is_group else f"指令: {cmd_name}"
|
||||
)
|
||||
if len(description) > 30:
|
||||
description = description[:30] + "..."
|
||||
return cmd_name, description
|
||||
return result if result else None
|
||||
|
||||
async def start(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
if not update.effective_chat:
|
||||
|
||||
@@ -5,6 +5,8 @@ from typing import Any, cast
|
||||
|
||||
import telegramify_markdown
|
||||
from telegram import ReactionTypeCustomEmoji, ReactionTypeEmoji
|
||||
from telegram.constants import ChatAction
|
||||
from telegram.error import BadRequest
|
||||
from telegram.ext import ExtBot
|
||||
|
||||
from astrbot import logger
|
||||
@@ -31,6 +33,14 @@ class TelegramPlatformEvent(AstrMessageEvent):
|
||||
"word": re.compile(r"\s"),
|
||||
}
|
||||
|
||||
# 消息类型到 chat action 的映射,用于优先级判断
|
||||
ACTION_BY_TYPE: dict[type, str] = {
|
||||
Record: ChatAction.UPLOAD_VOICE,
|
||||
File: ChatAction.UPLOAD_DOCUMENT,
|
||||
Image: ChatAction.UPLOAD_PHOTO,
|
||||
Plain: ChatAction.TYPING,
|
||||
}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message_str: str,
|
||||
@@ -67,6 +77,130 @@ class TelegramPlatformEvent(AstrMessageEvent):
|
||||
|
||||
return chunks
|
||||
|
||||
@classmethod
|
||||
async def _send_chat_action(
|
||||
cls,
|
||||
client: ExtBot,
|
||||
chat_id: str,
|
||||
action: ChatAction | str,
|
||||
message_thread_id: str | None = None,
|
||||
) -> None:
|
||||
"""发送聊天状态动作"""
|
||||
try:
|
||||
payload: dict[str, Any] = {"chat_id": chat_id, "action": action}
|
||||
if message_thread_id:
|
||||
payload["message_thread_id"] = message_thread_id
|
||||
await client.send_chat_action(**payload)
|
||||
except Exception as e:
|
||||
logger.warning(f"[Telegram] 发送 chat action 失败: {e}")
|
||||
|
||||
@classmethod
|
||||
def _get_chat_action_for_chain(cls, chain: list[Any]) -> ChatAction | str:
|
||||
"""根据消息链中的组件类型确定合适的 chat action(按优先级)"""
|
||||
for seg_type, action in cls.ACTION_BY_TYPE.items():
|
||||
if any(isinstance(seg, seg_type) for seg in chain):
|
||||
return action
|
||||
return ChatAction.TYPING
|
||||
|
||||
@classmethod
|
||||
async def _send_media_with_action(
|
||||
cls,
|
||||
client: ExtBot,
|
||||
upload_action: ChatAction | str,
|
||||
send_coro,
|
||||
*,
|
||||
user_name: str,
|
||||
message_thread_id: str | None = None,
|
||||
**payload: Any,
|
||||
) -> None:
|
||||
"""发送媒体时显示 upload action,发送完成后恢复 typing"""
|
||||
await cls._send_chat_action(client, user_name, upload_action, message_thread_id)
|
||||
await send_coro(**payload)
|
||||
await cls._send_chat_action(
|
||||
client, user_name, ChatAction.TYPING, message_thread_id
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def _send_voice_with_fallback(
|
||||
cls,
|
||||
client: ExtBot,
|
||||
path: str,
|
||||
payload: dict[str, Any],
|
||||
*,
|
||||
caption: str | None = None,
|
||||
user_name: str = "",
|
||||
message_thread_id: str | None = None,
|
||||
use_media_action: bool = False,
|
||||
) -> None:
|
||||
"""Send a voice message, falling back to a document if the user's
|
||||
privacy settings forbid voice messages (``BadRequest`` with
|
||||
``Voice_messages_forbidden``).
|
||||
|
||||
When *use_media_action* is ``True`` the helper wraps the send calls
|
||||
with ``_send_media_with_action`` (used by the streaming path).
|
||||
"""
|
||||
try:
|
||||
if use_media_action:
|
||||
await cls._send_media_with_action(
|
||||
client,
|
||||
ChatAction.UPLOAD_VOICE,
|
||||
client.send_voice,
|
||||
user_name=user_name,
|
||||
message_thread_id=message_thread_id,
|
||||
voice=path,
|
||||
**cast(Any, payload),
|
||||
)
|
||||
else:
|
||||
await client.send_voice(voice=path, **cast(Any, payload))
|
||||
except BadRequest as e:
|
||||
# python-telegram-bot raises BadRequest for Voice_messages_forbidden;
|
||||
# distinguish the voice-privacy case via the API error message.
|
||||
if "Voice_messages_forbidden" not in e.message:
|
||||
raise
|
||||
logger.warning(
|
||||
"User privacy settings prevent receiving voice messages, falling back to sending an audio file. "
|
||||
"To enable voice messages, go to Telegram Settings → Privacy and Security → Voice Messages → set to 'Everyone'."
|
||||
)
|
||||
if use_media_action:
|
||||
await cls._send_media_with_action(
|
||||
client,
|
||||
ChatAction.UPLOAD_DOCUMENT,
|
||||
client.send_document,
|
||||
user_name=user_name,
|
||||
message_thread_id=message_thread_id,
|
||||
document=path,
|
||||
caption=caption,
|
||||
**cast(Any, payload),
|
||||
)
|
||||
else:
|
||||
await client.send_document(
|
||||
document=path,
|
||||
caption=caption,
|
||||
**cast(Any, payload),
|
||||
)
|
||||
|
||||
async def _ensure_typing(
|
||||
self,
|
||||
user_name: str,
|
||||
message_thread_id: str | None = None,
|
||||
) -> None:
|
||||
"""确保显示 typing 状态"""
|
||||
await self._send_chat_action(
|
||||
self.client, user_name, ChatAction.TYPING, message_thread_id
|
||||
)
|
||||
|
||||
async def send_typing(self) -> None:
|
||||
message_thread_id = None
|
||||
if self.get_message_type() == MessageType.GROUP_MESSAGE:
|
||||
user_name = self.message_obj.group_id
|
||||
else:
|
||||
user_name = self.get_sender_id()
|
||||
|
||||
if "#" in user_name:
|
||||
user_name, message_thread_id = user_name.split("#")
|
||||
|
||||
await self._ensure_typing(user_name, message_thread_id)
|
||||
|
||||
@classmethod
|
||||
async def send_with_client(
|
||||
cls,
|
||||
@@ -91,6 +225,11 @@ class TelegramPlatformEvent(AstrMessageEvent):
|
||||
if "#" in user_name:
|
||||
# it's a supergroup chat with message_thread_id
|
||||
user_name, message_thread_id = user_name.split("#")
|
||||
|
||||
# 根据消息链确定合适的 chat action 并发送
|
||||
action = cls._get_chat_action_for_chain(message.chain)
|
||||
await cls._send_chat_action(client, user_name, action, message_thread_id)
|
||||
|
||||
for i in message.chain:
|
||||
payload = {
|
||||
"chat_id": user_name,
|
||||
@@ -132,7 +271,13 @@ class TelegramPlatformEvent(AstrMessageEvent):
|
||||
)
|
||||
elif isinstance(i, Record):
|
||||
path = await i.convert_to_file_path()
|
||||
await client.send_voice(voice=path, **cast(Any, payload))
|
||||
await cls._send_voice_with_fallback(
|
||||
client,
|
||||
path,
|
||||
payload,
|
||||
caption=i.text or None,
|
||||
use_media_action=False,
|
||||
)
|
||||
|
||||
async def send(self, message: MessageChain) -> None:
|
||||
if self.get_message_type() == MessageType.GROUP_MESSAGE:
|
||||
@@ -195,6 +340,12 @@ class TelegramPlatformEvent(AstrMessageEvent):
|
||||
message_id = None
|
||||
last_edit_time = 0 # 上次编辑消息的时间
|
||||
throttle_interval = 0.6 # 编辑消息的间隔时间 (秒)
|
||||
last_chat_action_time = 0 # 上次发送 chat action 的时间
|
||||
chat_action_interval = 0.5 # chat action 的节流间隔 (秒)
|
||||
|
||||
# 发送初始 typing 状态
|
||||
await self._ensure_typing(user_name, message_thread_id)
|
||||
last_chat_action_time = asyncio.get_event_loop().time()
|
||||
|
||||
async for chain in generator:
|
||||
if isinstance(chain, MessageChain):
|
||||
@@ -219,15 +370,25 @@ class TelegramPlatformEvent(AstrMessageEvent):
|
||||
delta += i.text
|
||||
elif isinstance(i, Image):
|
||||
image_path = await i.convert_to_file_path()
|
||||
await self.client.send_photo(
|
||||
photo=image_path, **cast(Any, payload)
|
||||
await self._send_media_with_action(
|
||||
self.client,
|
||||
ChatAction.UPLOAD_PHOTO,
|
||||
self.client.send_photo,
|
||||
user_name=user_name,
|
||||
message_thread_id=message_thread_id,
|
||||
photo=image_path,
|
||||
**cast(Any, payload),
|
||||
)
|
||||
continue
|
||||
elif isinstance(i, File):
|
||||
path = await i.get_file()
|
||||
name = i.name or os.path.basename(path)
|
||||
|
||||
await self.client.send_document(
|
||||
await self._send_media_with_action(
|
||||
self.client,
|
||||
ChatAction.UPLOAD_DOCUMENT,
|
||||
self.client.send_document,
|
||||
user_name=user_name,
|
||||
message_thread_id=message_thread_id,
|
||||
document=path,
|
||||
filename=name,
|
||||
**cast(Any, payload),
|
||||
@@ -235,7 +396,15 @@ class TelegramPlatformEvent(AstrMessageEvent):
|
||||
continue
|
||||
elif isinstance(i, Record):
|
||||
path = await i.convert_to_file_path()
|
||||
await self.client.send_voice(voice=path, **cast(Any, payload))
|
||||
await self._send_voice_with_fallback(
|
||||
self.client,
|
||||
path,
|
||||
payload,
|
||||
caption=i.text or delta or None,
|
||||
user_name=user_name,
|
||||
message_thread_id=message_thread_id,
|
||||
use_media_action=True,
|
||||
)
|
||||
continue
|
||||
else:
|
||||
logger.warning(f"不支持的消息类型: {type(i)}")
|
||||
@@ -248,6 +417,11 @@ class TelegramPlatformEvent(AstrMessageEvent):
|
||||
|
||||
# 如果距离上次编辑的时间 >= 设定的间隔,等待一段时间
|
||||
if time_since_last_edit >= throttle_interval:
|
||||
# 发送 typing 状态(带节流)
|
||||
current_time = asyncio.get_event_loop().time()
|
||||
if current_time - last_chat_action_time >= chat_action_interval:
|
||||
await self._ensure_typing(user_name, message_thread_id)
|
||||
last_chat_action_time = current_time
|
||||
# 编辑消息
|
||||
try:
|
||||
await self.client.edit_message_text(
|
||||
@@ -263,6 +437,11 @@ class TelegramPlatformEvent(AstrMessageEvent):
|
||||
) # 更新上次编辑的时间
|
||||
else:
|
||||
# delta 长度一般不会大于 4096,因此这里直接发送
|
||||
# 发送 typing 状态(带节流)
|
||||
current_time = asyncio.get_event_loop().time()
|
||||
if current_time - last_chat_action_time >= chat_action_interval:
|
||||
await self._ensure_typing(user_name, message_thread_id)
|
||||
last_chat_action_time = current_time
|
||||
try:
|
||||
msg = await self.client.send_message(
|
||||
text=delta, **cast(Any, payload)
|
||||
|
||||
@@ -26,14 +26,23 @@ from .webchat_queue_mgr import WebChatQueueMgr, webchat_queue_mgr
|
||||
|
||||
|
||||
class QueueListener:
|
||||
def __init__(self, webchat_queue_mgr: WebChatQueueMgr, callback: Callable) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
webchat_queue_mgr: WebChatQueueMgr,
|
||||
callback: Callable,
|
||||
stop_event: asyncio.Event,
|
||||
) -> None:
|
||||
self.webchat_queue_mgr = webchat_queue_mgr
|
||||
self.callback = callback
|
||||
self.stop_event = stop_event
|
||||
|
||||
async def run(self) -> None:
|
||||
"""Register callback and keep adapter task alive."""
|
||||
self.webchat_queue_mgr.set_listener(self.callback)
|
||||
await asyncio.Event().wait()
|
||||
try:
|
||||
await self.stop_event.wait()
|
||||
finally:
|
||||
await self.webchat_queue_mgr.clear_listener()
|
||||
|
||||
|
||||
@register_platform_adapter("webchat", "webchat")
|
||||
@@ -56,6 +65,8 @@ class WebChatAdapter(Platform):
|
||||
id="webchat",
|
||||
support_proactive_message=False,
|
||||
)
|
||||
self._shutdown_event = asyncio.Event()
|
||||
self._webchat_queue_mgr = webchat_queue_mgr
|
||||
|
||||
async def send_by_session(
|
||||
self,
|
||||
@@ -184,7 +195,7 @@ class WebChatAdapter(Platform):
|
||||
abm = await self.convert_message(data)
|
||||
await self.handle_msg(abm)
|
||||
|
||||
bot = QueueListener(webchat_queue_mgr, callback)
|
||||
bot = QueueListener(self._webchat_queue_mgr, callback, self._shutdown_event)
|
||||
return bot.run()
|
||||
|
||||
def meta(self) -> PlatformMetadata:
|
||||
@@ -209,5 +220,4 @@ class WebChatAdapter(Platform):
|
||||
self.commit_event(message_event)
|
||||
|
||||
async def terminate(self) -> None:
|
||||
# Do nothing
|
||||
pass
|
||||
self._shutdown_event.set()
|
||||
|
||||
@@ -11,13 +11,13 @@ from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
||||
|
||||
from .webchat_queue_mgr import webchat_queue_mgr
|
||||
|
||||
imgs_dir = os.path.join(get_astrbot_data_path(), "webchat", "imgs")
|
||||
attachments_dir = os.path.join(get_astrbot_data_path(), "attachments")
|
||||
|
||||
|
||||
class WebChatMessageEvent(AstrMessageEvent):
|
||||
def __init__(self, message_str, message_obj, platform_meta, session_id) -> None:
|
||||
super().__init__(message_str, message_obj, platform_meta, session_id)
|
||||
os.makedirs(imgs_dir, exist_ok=True)
|
||||
os.makedirs(attachments_dir, exist_ok=True)
|
||||
|
||||
@staticmethod
|
||||
async def _send(
|
||||
@@ -69,7 +69,7 @@ class WebChatMessageEvent(AstrMessageEvent):
|
||||
elif isinstance(comp, Image):
|
||||
# save image to local
|
||||
filename = f"{str(uuid.uuid4())}.jpg"
|
||||
path = os.path.join(imgs_dir, filename)
|
||||
path = os.path.join(attachments_dir, filename)
|
||||
image_base64 = await comp.convert_to_base64()
|
||||
with open(path, "wb") as f:
|
||||
f.write(base64.b64decode(image_base64))
|
||||
@@ -85,7 +85,7 @@ class WebChatMessageEvent(AstrMessageEvent):
|
||||
elif isinstance(comp, Record):
|
||||
# save record to local
|
||||
filename = f"{str(uuid.uuid4())}.wav"
|
||||
path = os.path.join(imgs_dir, filename)
|
||||
path = os.path.join(attachments_dir, filename)
|
||||
record_base64 = await comp.convert_to_base64()
|
||||
with open(path, "wb") as f:
|
||||
f.write(base64.b64decode(record_base64))
|
||||
@@ -104,7 +104,7 @@ class WebChatMessageEvent(AstrMessageEvent):
|
||||
original_name = comp.name or os.path.basename(file_path)
|
||||
ext = os.path.splitext(original_name)[1] or ""
|
||||
filename = f"{uuid.uuid4()!s}{ext}"
|
||||
dest_path = os.path.join(imgs_dir, filename)
|
||||
dest_path = os.path.join(attachments_dir, filename)
|
||||
shutil.copy2(file_path, dest_path)
|
||||
data = f"[FILE]{filename}"
|
||||
await web_chat_back_queue.put(
|
||||
|
||||
@@ -87,6 +87,19 @@ class WebChatQueueMgr:
|
||||
for conversation_id in list(self.queues.keys()):
|
||||
self._start_listener_if_needed(conversation_id)
|
||||
|
||||
async def clear_listener(self) -> None:
|
||||
self._listener_callback = None
|
||||
for close_event in list(self._queue_close_events.values()):
|
||||
close_event.set()
|
||||
self._queue_close_events.clear()
|
||||
|
||||
listener_tasks = list(self._listener_tasks.values())
|
||||
for task in listener_tasks:
|
||||
task.cancel()
|
||||
if listener_tasks:
|
||||
await asyncio.gather(*listener_tasks, return_exceptions=True)
|
||||
self._listener_tasks.clear()
|
||||
|
||||
def _start_listener_if_needed(self, conversation_id: str):
|
||||
if self._listener_callback is None:
|
||||
return
|
||||
|
||||
@@ -25,7 +25,7 @@ from astrbot.api.platform import (
|
||||
)
|
||||
from astrbot.core import logger
|
||||
from astrbot.core.platform.astr_message_event import MessageSesion
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
|
||||
from astrbot.core.utils.media_utils import convert_audio_to_wav
|
||||
from astrbot.core.utils.webhook_utils import log_webhook_info
|
||||
|
||||
@@ -344,7 +344,7 @@ class WecomPlatformAdapter(Platform):
|
||||
self.client.media.download,
|
||||
msg.media_id,
|
||||
)
|
||||
temp_dir = os.path.join(get_astrbot_data_path(), "temp")
|
||||
temp_dir = get_astrbot_temp_path()
|
||||
path = os.path.join(temp_dir, f"wecom_{msg.media_id}.amr")
|
||||
with open(path, "wb") as f:
|
||||
f.write(resp.content)
|
||||
@@ -400,7 +400,8 @@ class WecomPlatformAdapter(Platform):
|
||||
self.client.media.download,
|
||||
media_id,
|
||||
)
|
||||
path = f"data/temp/wechat_kf_{media_id}.jpg"
|
||||
temp_dir = get_astrbot_temp_path()
|
||||
path = os.path.join(temp_dir, f"weixinkefu_{media_id}.jpg")
|
||||
with open(path, "wb") as f:
|
||||
f.write(resp.content)
|
||||
abm.message = [Image(file=path, url=path)]
|
||||
@@ -412,7 +413,7 @@ class WecomPlatformAdapter(Platform):
|
||||
media_id,
|
||||
)
|
||||
|
||||
temp_dir = os.path.join(get_astrbot_data_path(), "temp")
|
||||
temp_dir = get_astrbot_temp_path()
|
||||
path = os.path.join(temp_dir, f"weixinkefu_{media_id}.amr")
|
||||
with open(path, "wb") as f:
|
||||
f.write(resp.content)
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any, cast
|
||||
|
||||
import quart
|
||||
from requests import Response
|
||||
from wechatpy import WeChatClient, parse_message
|
||||
from wechatpy import WeChatClient, create_reply, parse_message
|
||||
from wechatpy.crypto import WeChatCrypto
|
||||
from wechatpy.exceptions import InvalidSignatureException
|
||||
from wechatpy.messages import BaseMessage, ImageMessage, TextMessage, VoiceMessage
|
||||
@@ -24,6 +26,7 @@ from astrbot.api.platform import (
|
||||
)
|
||||
from astrbot.core import logger
|
||||
from astrbot.core.platform.astr_message_event import MessageSesion
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
|
||||
from astrbot.core.utils.media_utils import convert_audio_to_wav
|
||||
from astrbot.core.utils.webhook_utils import log_webhook_info
|
||||
|
||||
@@ -36,7 +39,12 @@ else:
|
||||
|
||||
|
||||
class WeixinOfficialAccountServer:
|
||||
def __init__(self, event_queue: asyncio.Queue, config: dict) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
event_queue: asyncio.Queue,
|
||||
config: dict,
|
||||
user_buffer: dict[Any, dict[str, Any]],
|
||||
) -> None:
|
||||
self.server = quart.Quart(__name__)
|
||||
self.port = int(cast(int | str, config.get("port")))
|
||||
self.callback_server_host = config.get("callback_server_host", "0.0.0.0")
|
||||
@@ -60,6 +68,10 @@ class WeixinOfficialAccountServer:
|
||||
self.callback: Callable[[BaseMessage], Awaitable[None]] | None = None
|
||||
self.shutdown_event = asyncio.Event()
|
||||
|
||||
self._wx_msg_time_out = 4.0 # 微信服务器要求 5 秒内回复
|
||||
self.user_buffer: dict[str, dict[str, Any]] = user_buffer # from_user -> state
|
||||
self.active_send_mode = False # 是否启用主动发送模式,启用后 callback 将直接返回回复内容,无需等待微信回调
|
||||
|
||||
async def verify(self):
|
||||
"""内部服务器的 GET 验证入口"""
|
||||
return await self.handle_verify(quart.request)
|
||||
@@ -96,6 +108,22 @@ class WeixinOfficialAccountServer:
|
||||
"""内部服务器的 POST 回调入口"""
|
||||
return await self.handle_callback(quart.request)
|
||||
|
||||
def _maybe_encrypt(self, xml: str, nonce: str | None, timestamp: str | None) -> str:
|
||||
if xml and "<Encrypt>" not in xml and nonce and timestamp:
|
||||
return self.crypto.encrypt_message(xml, nonce, timestamp)
|
||||
return xml or "success"
|
||||
|
||||
def _preview(self, msg: BaseMessage, limit: int = 24) -> str:
|
||||
"""生成消息预览文本,供占位符使用"""
|
||||
if isinstance(msg, TextMessage):
|
||||
t = cast(str, msg.content).strip()
|
||||
return (t[:limit] + "...") if len(t) > limit else (t or "空消息")
|
||||
if isinstance(msg, ImageMessage):
|
||||
return "图片"
|
||||
if isinstance(msg, VoiceMessage):
|
||||
return "语音"
|
||||
return getattr(msg, "type", "未知消息")
|
||||
|
||||
async def handle_callback(self, request) -> str:
|
||||
"""处理回调请求,可被统一 webhook 入口复用
|
||||
|
||||
@@ -121,14 +149,152 @@ class WeixinOfficialAccountServer:
|
||||
raise
|
||||
logger.info(f"解析成功: {msg}")
|
||||
|
||||
if self.callback:
|
||||
if not self.callback:
|
||||
return "success"
|
||||
|
||||
# by pass passive reply logic and return active reply directly.
|
||||
if self.active_send_mode:
|
||||
result_xml = await self.callback(msg)
|
||||
if not result_xml:
|
||||
return "success"
|
||||
if isinstance(result_xml, str):
|
||||
return result_xml
|
||||
|
||||
return "success"
|
||||
# passive reply
|
||||
from_user = str(getattr(msg, "source", ""))
|
||||
msg_id = str(cast(str | int, getattr(msg, "id", "")))
|
||||
state = self.user_buffer.get(from_user)
|
||||
|
||||
def _reply_text(text: str) -> str:
|
||||
reply_obj = create_reply(text, msg)
|
||||
reply_xml = reply_obj if isinstance(reply_obj, str) else str(reply_obj)
|
||||
return self._maybe_encrypt(reply_xml, nonce, timestamp)
|
||||
|
||||
# if in cached state, return cached result or placeholder
|
||||
if state:
|
||||
logger.debug(f"用户消息缓冲状态: user={from_user} state={state}")
|
||||
cached = state.get("cached_xml")
|
||||
# send one cached each time, if cached is empty after pop, remove the buffer
|
||||
if cached and len(cached) > 0:
|
||||
logger.info(f"wx buffer hit on trigger: user={from_user}")
|
||||
cached_xml = cached.pop(0)
|
||||
if len(cached) == 0:
|
||||
self.user_buffer.pop(from_user, None)
|
||||
return _reply_text(cached_xml)
|
||||
else:
|
||||
return _reply_text(
|
||||
cached_xml
|
||||
+ "\n【后续消息还在缓冲中,回复任意文字继续获取】"
|
||||
)
|
||||
|
||||
task: asyncio.Task | None = cast(asyncio.Task | None, state.get("task"))
|
||||
placeholder = (
|
||||
f"【正在思考'{state.get('preview', '...')}'中,已思考"
|
||||
f"{int(time.monotonic() - state.get('started_at', time.monotonic()))}s,回复任意文字尝试获取回复】"
|
||||
)
|
||||
|
||||
# same msgid => WeChat retry: wait a little; new msgid => user trigger: just placeholder
|
||||
if task and state.get("msg_id") == msg_id:
|
||||
done, _ = await asyncio.wait(
|
||||
{task},
|
||||
timeout=self._wx_msg_time_out,
|
||||
return_when=asyncio.FIRST_COMPLETED,
|
||||
)
|
||||
if done:
|
||||
try:
|
||||
cached = state.get("cached_xml")
|
||||
# send one cached each time, if cached is empty after pop, remove the buffer
|
||||
if cached and len(cached) > 0:
|
||||
logger.info(
|
||||
f"wx buffer hit on retry window: user={from_user}"
|
||||
)
|
||||
cached_xml = cached.pop(0)
|
||||
if len(cached) == 0:
|
||||
self.user_buffer.pop(from_user, None)
|
||||
logger.debug(
|
||||
f"wx finished message sending in passive window: user={from_user} msg_id={msg_id} "
|
||||
)
|
||||
return _reply_text(cached_xml)
|
||||
else:
|
||||
logger.debug(
|
||||
f"wx finished message sending in passive window but not final: user={from_user} msg_id={msg_id} "
|
||||
)
|
||||
return _reply_text(
|
||||
cached_xml
|
||||
+ "\n【后续消息还在缓冲中,回复任意文字继续获取】"
|
||||
)
|
||||
logger.info(
|
||||
f"wx finished in window but not final; return placeholder: user={from_user} msg_id={msg_id} "
|
||||
)
|
||||
return _reply_text(placeholder)
|
||||
except Exception:
|
||||
logger.critical(
|
||||
"wx task failed in passive window", exc_info=True
|
||||
)
|
||||
self.user_buffer.pop(from_user, None)
|
||||
return _reply_text("处理消息失败,请稍后再试。")
|
||||
|
||||
logger.info(
|
||||
f"wx passive window timeout: user={from_user} msg_id={msg_id}"
|
||||
)
|
||||
return _reply_text(placeholder)
|
||||
|
||||
logger.debug(f"wx trigger while thinking: user={from_user}")
|
||||
return _reply_text(placeholder)
|
||||
|
||||
# create new trigger when state is empty, and store state in buffer
|
||||
logger.debug(f"wx new trigger: user={from_user} msg_id={msg_id}")
|
||||
preview = self._preview(msg)
|
||||
placeholder = (
|
||||
f"【正在思考'{preview}'中,已思考0s,回复任意文字尝试获取回复】"
|
||||
)
|
||||
logger.info(
|
||||
f"wx start task: user={from_user} msg_id={msg_id} preview={preview}"
|
||||
)
|
||||
|
||||
self.user_buffer[from_user] = state = {
|
||||
"msg_id": msg_id,
|
||||
"preview": preview,
|
||||
"task": None, # set later after task created
|
||||
"cached_xml": [], # for passive reply
|
||||
"started_at": time.monotonic(),
|
||||
}
|
||||
self.user_buffer[from_user]["task"] = task = asyncio.create_task(
|
||||
self.callback(msg)
|
||||
)
|
||||
|
||||
# immediate return if done
|
||||
done, _ = await asyncio.wait(
|
||||
{task},
|
||||
timeout=self._wx_msg_time_out,
|
||||
return_when=asyncio.FIRST_COMPLETED,
|
||||
)
|
||||
if done:
|
||||
try:
|
||||
cached = state.get("cached_xml", None)
|
||||
# send one cached each time, if cached is empty after pop, remove the buffer
|
||||
if cached and len(cached) > 0:
|
||||
logger.info(f"wx buffer hit immediately: user={from_user}")
|
||||
cached_xml = cached.pop(0)
|
||||
if len(cached) == 0:
|
||||
self.user_buffer.pop(from_user, None)
|
||||
return _reply_text(cached_xml)
|
||||
else:
|
||||
return _reply_text(
|
||||
cached_xml
|
||||
+ "\n【后续消息还在缓冲中,回复任意文字继续获取】"
|
||||
)
|
||||
logger.info(
|
||||
f"wx not finished in first window; return placeholder: user={from_user} msg_id={msg_id} "
|
||||
)
|
||||
return _reply_text(placeholder)
|
||||
except Exception:
|
||||
logger.critical("wx task failed in first window", exc_info=True)
|
||||
self.user_buffer.pop(from_user, None)
|
||||
return _reply_text("处理消息失败,请稍后再试。")
|
||||
|
||||
logger.info(f"wx first window timeout: user={from_user} msg_id={msg_id}")
|
||||
return _reply_text(placeholder)
|
||||
|
||||
async def start_polling(self) -> None:
|
||||
logger.info(
|
||||
@@ -174,7 +340,10 @@ class WeixinOfficialAccountPlatformAdapter(Platform):
|
||||
if not self.api_base_url.endswith("/"):
|
||||
self.api_base_url += "/"
|
||||
|
||||
self.server = WeixinOfficialAccountServer(self._event_queue, self.config)
|
||||
self.user_buffer: dict[str, dict[str, Any]] = {} # from_user -> state
|
||||
self.server = WeixinOfficialAccountServer(
|
||||
self._event_queue, self.config, self.user_buffer
|
||||
)
|
||||
|
||||
self.client = WeChatClient(
|
||||
self.config["appid"].strip(),
|
||||
@@ -191,28 +360,33 @@ class WeixinOfficialAccountPlatformAdapter(Platform):
|
||||
try:
|
||||
if self.active_send_mode:
|
||||
await self.convert_message(msg, None)
|
||||
return None
|
||||
|
||||
msg_id = str(cast(str | int, msg.id))
|
||||
future = self.wexin_event_workers.get(msg_id)
|
||||
if future:
|
||||
logger.debug(f"duplicate message id checked: {msg.id}")
|
||||
else:
|
||||
if str(msg.id) in self.wexin_event_workers:
|
||||
future = self.wexin_event_workers[str(cast(str | int, msg.id))]
|
||||
logger.debug(f"duplicate message id checked: {msg.id}")
|
||||
else:
|
||||
future = asyncio.get_event_loop().create_future()
|
||||
self.wexin_event_workers[str(cast(str | int, msg.id))] = future
|
||||
await self.convert_message(msg, future)
|
||||
future = asyncio.get_event_loop().create_future()
|
||||
self.wexin_event_workers[msg_id] = future
|
||||
await self.convert_message(msg, future)
|
||||
# I love shield so much!
|
||||
result = await asyncio.wait_for(
|
||||
asyncio.shield(future),
|
||||
60,
|
||||
) # wait for 60s
|
||||
logger.debug(f"Got future result: {result}")
|
||||
self.wexin_event_workers.pop(str(cast(str | int, msg.id)), None)
|
||||
return result # xml. see weixin_offacc_event.py
|
||||
180,
|
||||
) # wait for 180s
|
||||
logger.debug(f"Got future result: {result}")
|
||||
return result
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
logger.info(f"callback 处理消息超时: message_id={msg.id}")
|
||||
return create_reply("处理消息超时,请稍后再试。", msg)
|
||||
except Exception as e:
|
||||
logger.error(f"转换消息时出现异常: {e}")
|
||||
finally:
|
||||
self.wexin_event_workers.pop(str(cast(str | int, msg.id)), None)
|
||||
|
||||
self.server.callback = callback
|
||||
self.server.active_send_mode = self.active_send_mode
|
||||
|
||||
@override
|
||||
async def send_by_session(
|
||||
@@ -290,12 +464,16 @@ class WeixinOfficialAccountPlatformAdapter(Platform):
|
||||
self.client.media.download,
|
||||
msg.media_id,
|
||||
)
|
||||
path = f"data/temp/wecom_{msg.media_id}.amr"
|
||||
temp_dir = get_astrbot_temp_path()
|
||||
path = os.path.join(temp_dir, f"weixin_offacc_{msg.media_id}.amr")
|
||||
with open(path, "wb") as f:
|
||||
f.write(resp.content)
|
||||
|
||||
try:
|
||||
path_wav = f"data/temp/wecom_{msg.media_id}.wav"
|
||||
path_wav = os.path.join(
|
||||
temp_dir,
|
||||
f"weixin_offacc_{msg.media_id}.wav",
|
||||
)
|
||||
path_wav = await convert_audio_to_wav(path, path_wav)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
@@ -330,12 +508,19 @@ class WeixinOfficialAccountPlatformAdapter(Platform):
|
||||
await self.handle_msg(abm)
|
||||
|
||||
async def handle_msg(self, message: AstrBotMessage) -> None:
|
||||
buffer = self.user_buffer.get(message.sender.user_id, None)
|
||||
if buffer is None:
|
||||
logger.critical(
|
||||
f"用户消息未找到缓冲状态,无法处理消息: user={message.sender.user_id} message_id={message.message_id}"
|
||||
)
|
||||
return
|
||||
message_event = WeixinOfficialAccountPlatformEvent(
|
||||
message_str=message.message_str,
|
||||
message_obj=message,
|
||||
platform_meta=self.meta(),
|
||||
session_id=message.session_id,
|
||||
client=self.client,
|
||||
message_out=buffer,
|
||||
)
|
||||
self.commit_event(message_event)
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import asyncio
|
||||
import os
|
||||
from typing import cast
|
||||
from typing import Any, cast
|
||||
|
||||
from wechatpy import WeChatClient
|
||||
from wechatpy.replies import ImageReply, TextReply, VoiceReply
|
||||
from wechatpy.replies import ImageReply, VoiceReply
|
||||
|
||||
from astrbot.api import logger
|
||||
from astrbot.api.event import AstrMessageEvent, MessageChain
|
||||
@@ -20,9 +20,11 @@ class WeixinOfficialAccountPlatformEvent(AstrMessageEvent):
|
||||
platform_meta: PlatformMetadata,
|
||||
session_id: str,
|
||||
client: WeChatClient,
|
||||
message_out: dict[Any, Any],
|
||||
) -> None:
|
||||
super().__init__(message_str, message_obj, platform_meta, session_id)
|
||||
self.client = client
|
||||
self.message_out = message_out
|
||||
|
||||
@staticmethod
|
||||
async def send_with_client(
|
||||
@@ -32,8 +34,8 @@ class WeixinOfficialAccountPlatformEvent(AstrMessageEvent):
|
||||
) -> None:
|
||||
pass
|
||||
|
||||
async def split_plain(self, plain: str) -> list[str]:
|
||||
"""将长文本分割成多个小文本, 每个小文本长度不超过 2048 字符
|
||||
async def split_plain(self, plain: str, max_length: int = 1024) -> list[str]:
|
||||
"""将长文本分割成多个小文本, 每个小文本长度不超过 max_length 字符
|
||||
|
||||
Args:
|
||||
plain (str): 要分割的长文本
|
||||
@@ -41,18 +43,18 @@ class WeixinOfficialAccountPlatformEvent(AstrMessageEvent):
|
||||
list[str]: 分割后的文本列表
|
||||
|
||||
"""
|
||||
if len(plain) <= 2048:
|
||||
if len(plain) <= max_length:
|
||||
return [plain]
|
||||
result = []
|
||||
start = 0
|
||||
while start < len(plain):
|
||||
# 剩下的字符串长度<2048时结束
|
||||
if start + 2048 >= len(plain):
|
||||
# 剩下的字符串长度<max_length时结束
|
||||
if start + max_length >= len(plain):
|
||||
result.append(plain[start:])
|
||||
break
|
||||
|
||||
# 向前搜索分割标点符号
|
||||
end = min(start + 2048, len(plain))
|
||||
end = min(start + max_length, len(plain))
|
||||
cut_position = end
|
||||
for i in range(end, start, -1):
|
||||
if i < len(plain) and plain[i - 1] in [
|
||||
@@ -87,19 +89,15 @@ class WeixinOfficialAccountPlatformEvent(AstrMessageEvent):
|
||||
if isinstance(comp, Plain):
|
||||
# Split long text messages if needed
|
||||
plain_chunks = await self.split_plain(comp.text)
|
||||
for chunk in plain_chunks:
|
||||
if active_send_mode:
|
||||
if active_send_mode:
|
||||
for chunk in plain_chunks:
|
||||
self.client.message.send_text(message_obj.sender.user_id, chunk)
|
||||
else:
|
||||
reply = TextReply(
|
||||
content=chunk,
|
||||
message=cast(dict, self.message_obj.raw_message)["message"],
|
||||
)
|
||||
xml = reply.render()
|
||||
future = cast(dict, self.message_obj.raw_message)["future"]
|
||||
assert isinstance(future, asyncio.Future)
|
||||
future.set_result(xml)
|
||||
await asyncio.sleep(0.5) # Avoid sending too fast
|
||||
else:
|
||||
# disable passive sending, just store the chunks in
|
||||
logger.debug(
|
||||
f"split plain into {len(plain_chunks)} chunks for passive reply. Message not sent."
|
||||
)
|
||||
self.message_out["cached_xml"] = plain_chunks
|
||||
elif isinstance(comp, Image):
|
||||
img_path = await comp.convert_to_file_path()
|
||||
|
||||
|
||||
@@ -295,6 +295,16 @@ class ProviderManager:
|
||||
from .sources.zhipu_source import ProviderZhipu as ProviderZhipu
|
||||
case "groq_chat_completion":
|
||||
from .sources.groq_source import ProviderGroq as ProviderGroq
|
||||
case "xai_chat_completion":
|
||||
from .sources.xai_source import ProviderXAI as ProviderXAI
|
||||
case "aihubmix_chat_completion":
|
||||
from .sources.oai_aihubmix_source import (
|
||||
ProviderAIHubMix as ProviderAIHubMix,
|
||||
)
|
||||
case "openrouter_chat_completion":
|
||||
from .sources.openrouter_source import (
|
||||
ProviderOpenRouter as ProviderOpenRouter,
|
||||
)
|
||||
case "anthropic_chat_completion":
|
||||
from .sources.anthropic_source import (
|
||||
ProviderAnthropic as ProviderAnthropic,
|
||||
|
||||
@@ -33,20 +33,29 @@ class ProviderAnthropic(Provider):
|
||||
self,
|
||||
provider_config,
|
||||
provider_settings,
|
||||
*,
|
||||
use_api_key: bool = True,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
provider_config,
|
||||
provider_settings,
|
||||
)
|
||||
|
||||
self.chosen_api_key: str = ""
|
||||
self.api_keys: list = super().get_keys()
|
||||
self.chosen_api_key = self.api_keys[0] if len(self.api_keys) > 0 else ""
|
||||
self.base_url = provider_config.get("api_base", "https://api.anthropic.com")
|
||||
self.timeout = provider_config.get("timeout", 120)
|
||||
if isinstance(self.timeout, str):
|
||||
self.timeout = int(self.timeout)
|
||||
self.thinking_config = provider_config.get("anth_thinking_config", {})
|
||||
|
||||
if use_api_key:
|
||||
self._init_api_key(provider_config)
|
||||
|
||||
self.set_model(provider_config.get("model", "unknown"))
|
||||
|
||||
def _init_api_key(self, provider_config: dict) -> None:
|
||||
self.chosen_api_key: str = ""
|
||||
self.api_keys: list = super().get_keys()
|
||||
self.chosen_api_key = self.api_keys[0] if len(self.api_keys) > 0 else ""
|
||||
self.client = AsyncAnthropic(
|
||||
api_key=self.chosen_api_key,
|
||||
timeout=self.timeout,
|
||||
@@ -54,15 +63,27 @@ class ProviderAnthropic(Provider):
|
||||
http_client=self._create_http_client(provider_config),
|
||||
)
|
||||
|
||||
self.thinking_config = provider_config.get("anth_thinking_config", {})
|
||||
|
||||
self.set_model(provider_config.get("model", "unknown"))
|
||||
|
||||
def _create_http_client(self, provider_config: dict) -> httpx.AsyncClient | None:
|
||||
"""创建带代理的 HTTP 客户端"""
|
||||
proxy = provider_config.get("proxy", "")
|
||||
return create_proxy_client("Anthropic", proxy)
|
||||
|
||||
def _apply_thinking_config(self, payloads: dict) -> None:
|
||||
thinking_type = self.thinking_config.get("type", "")
|
||||
if thinking_type == "adaptive":
|
||||
payloads["thinking"] = {"type": "adaptive"}
|
||||
effort = self.thinking_config.get("effort", "")
|
||||
output_cfg = dict(payloads.get("output_config", {}))
|
||||
if effort:
|
||||
output_cfg["effort"] = effort
|
||||
if output_cfg:
|
||||
payloads["output_config"] = output_cfg
|
||||
elif not thinking_type and self.thinking_config.get("budget"):
|
||||
payloads["thinking"] = {
|
||||
"budget_tokens": self.thinking_config.get("budget"),
|
||||
"type": "enabled",
|
||||
}
|
||||
|
||||
def _prepare_payload(self, messages: list[dict]):
|
||||
"""准备 Anthropic API 的请求 payload
|
||||
|
||||
@@ -213,11 +234,7 @@ class ProviderAnthropic(Provider):
|
||||
|
||||
if "max_tokens" not in payloads:
|
||||
payloads["max_tokens"] = 1024
|
||||
if self.thinking_config.get("budget"):
|
||||
payloads["thinking"] = {
|
||||
"budget_tokens": self.thinking_config.get("budget"),
|
||||
"type": "enabled",
|
||||
}
|
||||
self._apply_thinking_config(payloads)
|
||||
|
||||
try:
|
||||
completion = await self.client.messages.create(
|
||||
@@ -287,11 +304,7 @@ class ProviderAnthropic(Provider):
|
||||
|
||||
if "max_tokens" not in payloads:
|
||||
payloads["max_tokens"] = 1024
|
||||
if self.thinking_config.get("budget"):
|
||||
payloads["thinking"] = {
|
||||
"budget_tokens": self.thinking_config.get("budget"),
|
||||
"type": "enabled",
|
||||
}
|
||||
self._apply_thinking_config(payloads)
|
||||
|
||||
async with self.client.messages.stream(
|
||||
**payloads, extra_body=extra_body
|
||||
|
||||
@@ -12,12 +12,13 @@ from httpx import AsyncClient, Timeout
|
||||
|
||||
from astrbot import logger
|
||||
from astrbot.core.config.default import VERSION
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
|
||||
|
||||
from ..entities import ProviderType
|
||||
from ..provider import TTSProvider
|
||||
from ..register import register_provider_adapter
|
||||
|
||||
TEMP_DIR = Path("data/temp/azure_tts")
|
||||
TEMP_DIR = Path(get_astrbot_temp_path()) / "azure_tts"
|
||||
TEMP_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ except (
|
||||
): # pragma: no cover - older dashscope versions without Qwen TTS support
|
||||
MultiModalConversation = None
|
||||
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
|
||||
|
||||
from ..entities import ProviderType
|
||||
from ..provider import TTSProvider
|
||||
@@ -45,7 +45,7 @@ class ProviderDashscopeTTSAPI(TTSProvider):
|
||||
if not model:
|
||||
raise RuntimeError("Dashscope TTS model is not configured.")
|
||||
|
||||
temp_dir = os.path.join(get_astrbot_data_path(), "temp")
|
||||
temp_dir = get_astrbot_temp_path()
|
||||
os.makedirs(temp_dir, exist_ok=True)
|
||||
|
||||
if self._is_qwen_tts_model(model):
|
||||
|
||||
@@ -6,7 +6,7 @@ import uuid
|
||||
import edge_tts
|
||||
|
||||
from astrbot.core import logger
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
|
||||
|
||||
from ..entities import ProviderType
|
||||
from ..provider import TTSProvider
|
||||
@@ -46,7 +46,7 @@ class ProviderEdgeTTS(TTSProvider):
|
||||
self.set_model("edge_tts")
|
||||
|
||||
async def get_audio(self, text: str) -> str:
|
||||
temp_dir = os.path.join(get_astrbot_data_path(), "temp")
|
||||
temp_dir = get_astrbot_temp_path()
|
||||
mp3_path = os.path.join(temp_dir, f"edge_tts_temp_{uuid.uuid4()}.mp3")
|
||||
wav_path = os.path.join(temp_dir, f"edge_tts_{uuid.uuid4()}.wav")
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ from httpx import AsyncClient
|
||||
from pydantic import BaseModel, conint
|
||||
|
||||
from astrbot import logger
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
|
||||
|
||||
from ..entities import ProviderType
|
||||
from ..provider import TTSProvider
|
||||
@@ -142,7 +142,7 @@ class ProviderFishAudioTTSAPI(TTSProvider):
|
||||
)
|
||||
|
||||
async def get_audio(self, text: str) -> str:
|
||||
temp_dir = os.path.join(get_astrbot_data_path(), "temp")
|
||||
temp_dir = get_astrbot_temp_path()
|
||||
path = os.path.join(temp_dir, f"fishaudio_tts_api_{uuid.uuid4()}.wav")
|
||||
self.headers["content-type"] = "application/msgpack"
|
||||
request = await self._generate_request(text)
|
||||
|
||||
@@ -6,7 +6,7 @@ from google import genai
|
||||
from google.genai import types
|
||||
|
||||
from astrbot import logger
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
|
||||
|
||||
from ..entities import ProviderType
|
||||
from ..provider import TTSProvider
|
||||
@@ -49,7 +49,7 @@ class ProviderGeminiTTSAPI(TTSProvider):
|
||||
self.voice_name: str = provider_config.get("gemini_tts_voice_name", "Leda")
|
||||
|
||||
async def get_audio(self, text: str) -> str:
|
||||
temp_dir = os.path.join(get_astrbot_data_path(), "temp")
|
||||
temp_dir = get_astrbot_temp_path()
|
||||
path = os.path.join(temp_dir, f"gemini_tts_{uuid.uuid4()}.wav")
|
||||
prompt = f"{self.prefix}: {text}" if self.prefix else text
|
||||
response = await self.client.models.generate_content(
|
||||
|
||||
@@ -6,7 +6,7 @@ from astrbot.core import logger
|
||||
from astrbot.core.provider.entities import ProviderType
|
||||
from astrbot.core.provider.provider import TTSProvider
|
||||
from astrbot.core.provider.register import register_provider_adapter
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
|
||||
|
||||
try:
|
||||
import genie_tts as genie # type: ignore
|
||||
@@ -54,7 +54,7 @@ class GenieTTSProvider(TTSProvider):
|
||||
return True
|
||||
|
||||
async def get_audio(self, text: str) -> str:
|
||||
temp_dir = os.path.join(get_astrbot_data_path(), "temp")
|
||||
temp_dir = get_astrbot_temp_path()
|
||||
os.makedirs(temp_dir, exist_ok=True)
|
||||
filename = f"genie_tts_{uuid.uuid4()}.wav"
|
||||
path = os.path.join(temp_dir, filename)
|
||||
@@ -94,7 +94,7 @@ class GenieTTSProvider(TTSProvider):
|
||||
break
|
||||
|
||||
try:
|
||||
temp_dir = os.path.join(get_astrbot_data_path(), "temp")
|
||||
temp_dir = get_astrbot_temp_path()
|
||||
os.makedirs(temp_dir, exist_ok=True)
|
||||
filename = f"genie_tts_{uuid.uuid4()}.wav"
|
||||
path = os.path.join(temp_dir, filename)
|
||||
|
||||
@@ -5,7 +5,7 @@ import uuid
|
||||
import aiohttp
|
||||
|
||||
from astrbot import logger
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
|
||||
|
||||
from ..entities import ProviderType
|
||||
from ..provider import TTSProvider
|
||||
@@ -121,7 +121,7 @@ class ProviderGSVTTS(TTSProvider):
|
||||
|
||||
params = self.build_synthesis_params(text)
|
||||
|
||||
temp_dir = os.path.join(get_astrbot_data_path(), "temp")
|
||||
temp_dir = get_astrbot_temp_path()
|
||||
os.makedirs(temp_dir, exist_ok=True)
|
||||
path = os.path.join(temp_dir, f"gsv_tts_{uuid.uuid4().hex}.wav")
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import uuid
|
||||
|
||||
import aiohttp
|
||||
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
|
||||
|
||||
from ..entities import ProviderType
|
||||
from ..provider import TTSProvider
|
||||
@@ -29,7 +29,7 @@ class ProviderGSVITTS(TTSProvider):
|
||||
self.emotion = provider_config.get("emotion")
|
||||
|
||||
async def get_audio(self, text: str) -> str:
|
||||
temp_dir = os.path.join(get_astrbot_data_path(), "temp")
|
||||
temp_dir = get_astrbot_temp_path()
|
||||
path = os.path.join(temp_dir, f"gsvi_tts_{uuid.uuid4()}.wav")
|
||||
params = {"text": text}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ from collections.abc import AsyncIterator
|
||||
import aiohttp
|
||||
|
||||
from astrbot.api import logger
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
|
||||
|
||||
from ..entities import ProviderType
|
||||
from ..provider import TTSProvider
|
||||
@@ -145,7 +145,7 @@ class ProviderMiniMaxTTSAPI(TTSProvider):
|
||||
return b"".join(chunks)
|
||||
|
||||
async def get_audio(self, text: str) -> str:
|
||||
temp_dir = os.path.join(get_astrbot_data_path(), "temp")
|
||||
temp_dir = get_astrbot_temp_path()
|
||||
os.makedirs(temp_dir, exist_ok=True)
|
||||
path = os.path.join(temp_dir, f"minimax_tts_api_{uuid.uuid4()}.mp3")
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
from ..register import register_provider_adapter
|
||||
from .openai_source import ProviderOpenAIOfficial
|
||||
|
||||
|
||||
@register_provider_adapter(
|
||||
"aihubmix_chat_completion", "AIHubMix Chat Completion Provider Adapter"
|
||||
)
|
||||
class ProviderAIHubMix(ProviderOpenAIOfficial):
|
||||
def __init__(
|
||||
self,
|
||||
provider_config: dict,
|
||||
provider_settings: dict,
|
||||
) -> None:
|
||||
super().__init__(provider_config, provider_settings)
|
||||
# Reference to: https://aihubmix.com/appstore
|
||||
# Use this code can enjoy 10% off prices for AIHubMix API calls.
|
||||
self.client._custom_headers["APP-Code"] = "KRLC5702" # type: ignore
|
||||
@@ -5,6 +5,7 @@ import json
|
||||
import random
|
||||
import re
|
||||
from collections.abc import AsyncGenerator
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from openai import AsyncAzureOpenAI, AsyncOpenAI
|
||||
@@ -27,6 +28,7 @@ from astrbot.core.utils.network_utils import (
|
||||
is_connection_error,
|
||||
log_connection_failure,
|
||||
)
|
||||
from astrbot.core.utils.string_utils import normalize_and_dedupe_strings
|
||||
|
||||
from ..register import register_provider_adapter
|
||||
|
||||
@@ -36,6 +38,128 @@ from ..register import register_provider_adapter
|
||||
"OpenAI API Chat Completion 提供商适配器",
|
||||
)
|
||||
class ProviderOpenAIOfficial(Provider):
|
||||
_ERROR_TEXT_CANDIDATE_MAX_CHARS = 4096
|
||||
|
||||
@classmethod
|
||||
def _truncate_error_text_candidate(cls, text: str) -> str:
|
||||
if len(text) <= cls._ERROR_TEXT_CANDIDATE_MAX_CHARS:
|
||||
return text
|
||||
return text[: cls._ERROR_TEXT_CANDIDATE_MAX_CHARS]
|
||||
|
||||
@staticmethod
|
||||
def _safe_json_dump(value: Any) -> str | None:
|
||||
try:
|
||||
return json.dumps(value, ensure_ascii=False, default=str)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _get_image_moderation_error_patterns(self) -> list[str]:
|
||||
"""Return configured moderation patterns (case-insensitive substring match, not regex)."""
|
||||
configured = self.provider_config.get("image_moderation_error_patterns", [])
|
||||
patterns: list[str] = []
|
||||
if isinstance(configured, str):
|
||||
configured = [configured]
|
||||
if isinstance(configured, list):
|
||||
for pattern in configured:
|
||||
if not isinstance(pattern, str):
|
||||
continue
|
||||
pattern = pattern.strip()
|
||||
if pattern:
|
||||
patterns.append(pattern)
|
||||
return patterns
|
||||
|
||||
@staticmethod
|
||||
def _extract_error_text_candidates(error: Exception) -> list[str]:
|
||||
candidates: list[str] = []
|
||||
|
||||
def _append_candidate(candidate: Any):
|
||||
if candidate is None:
|
||||
return
|
||||
text = str(candidate).strip()
|
||||
if not text:
|
||||
return
|
||||
candidates.append(
|
||||
ProviderOpenAIOfficial._truncate_error_text_candidate(text)
|
||||
)
|
||||
|
||||
_append_candidate(str(error))
|
||||
|
||||
body = getattr(error, "body", None)
|
||||
if isinstance(body, dict):
|
||||
err_obj = body.get("error")
|
||||
body_text = ProviderOpenAIOfficial._safe_json_dump(
|
||||
{"error": err_obj} if isinstance(err_obj, dict) else body
|
||||
)
|
||||
_append_candidate(body_text)
|
||||
if isinstance(err_obj, dict):
|
||||
for field in ("message", "type", "code", "param"):
|
||||
value = err_obj.get(field)
|
||||
if value is not None:
|
||||
_append_candidate(value)
|
||||
elif isinstance(body, str):
|
||||
_append_candidate(body)
|
||||
|
||||
response = getattr(error, "response", None)
|
||||
if response is not None:
|
||||
response_text = getattr(response, "text", None)
|
||||
if isinstance(response_text, str):
|
||||
_append_candidate(response_text)
|
||||
|
||||
return normalize_and_dedupe_strings(candidates)
|
||||
|
||||
def _is_content_moderated_upload_error(self, error: Exception) -> bool:
|
||||
patterns = [
|
||||
pattern.lower() for pattern in self._get_image_moderation_error_patterns()
|
||||
]
|
||||
if not patterns:
|
||||
return False
|
||||
candidates = [
|
||||
candidate.lower()
|
||||
for candidate in self._extract_error_text_candidates(error)
|
||||
]
|
||||
for pattern in patterns:
|
||||
if any(pattern in candidate for candidate in candidates):
|
||||
return True
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _context_contains_image(contexts: list[dict]) -> bool:
|
||||
for context in contexts:
|
||||
content = context.get("content")
|
||||
if not isinstance(content, list):
|
||||
continue
|
||||
for item in content:
|
||||
if isinstance(item, dict) and item.get("type") == "image_url":
|
||||
return True
|
||||
return False
|
||||
|
||||
async def _fallback_to_text_only_and_retry(
|
||||
self,
|
||||
payloads: dict,
|
||||
context_query: list,
|
||||
chosen_key: str,
|
||||
available_api_keys: list[str],
|
||||
func_tool: ToolSet | None,
|
||||
reason: str,
|
||||
*,
|
||||
image_fallback_used: bool = False,
|
||||
) -> tuple:
|
||||
logger.warning(
|
||||
"检测到图片请求失败(%s),已移除图片并重试(保留文本内容)。",
|
||||
reason,
|
||||
)
|
||||
new_contexts = await self._remove_image_from_context(context_query)
|
||||
payloads["messages"] = new_contexts
|
||||
return (
|
||||
False,
|
||||
chosen_key,
|
||||
available_api_keys,
|
||||
payloads,
|
||||
new_contexts,
|
||||
func_tool,
|
||||
image_fallback_used,
|
||||
)
|
||||
|
||||
def _create_http_client(self, provider_config: dict) -> httpx.AsyncClient | None:
|
||||
"""创建带代理的 HTTP 客户端"""
|
||||
proxy = provider_config.get("proxy", "")
|
||||
@@ -199,7 +323,8 @@ class ProviderOpenAIOfficial(Provider):
|
||||
llm_response.reasoning_content = reasoning
|
||||
_y = True
|
||||
if delta.content:
|
||||
completion_text = delta.content
|
||||
# Don't strip streaming chunks to preserve spaces between words
|
||||
completion_text = self._normalize_content(delta.content, strip=False)
|
||||
llm_response.result_chain = MessageChain(
|
||||
chain=[Comp.Plain(completion_text)],
|
||||
)
|
||||
@@ -247,6 +372,96 @@ class ProviderOpenAIOfficial(Provider):
|
||||
output=completion_tokens,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_content(raw_content: Any, strip: bool = True) -> str:
|
||||
"""Normalize content from various formats to plain string.
|
||||
|
||||
Some LLM providers return content as list[dict] format
|
||||
like [{'type': 'text', 'text': '...'}] instead of
|
||||
plain string. This method handles both formats.
|
||||
|
||||
Args:
|
||||
raw_content: The raw content from LLM response, can be str, list, dict, or other.
|
||||
strip: Whether to strip whitespace from the result. Set to False for
|
||||
streaming chunks to preserve spaces between words.
|
||||
|
||||
Returns:
|
||||
Normalized plain text string.
|
||||
"""
|
||||
# Handle dict format (e.g., {"type": "text", "text": "..."})
|
||||
if isinstance(raw_content, dict):
|
||||
if "text" in raw_content:
|
||||
text_val = raw_content.get("text", "")
|
||||
return str(text_val) if text_val is not None else ""
|
||||
# For other dict formats, return empty string and log
|
||||
logger.warning(f"Unexpected dict format content: {raw_content}")
|
||||
return ""
|
||||
|
||||
if isinstance(raw_content, list):
|
||||
# Check if this looks like OpenAI content-part format
|
||||
# Only process if at least one item has {'type': 'text', 'text': ...} structure
|
||||
has_content_part = any(
|
||||
isinstance(part, dict) and part.get("type") == "text"
|
||||
for part in raw_content
|
||||
)
|
||||
if has_content_part:
|
||||
text_parts = []
|
||||
for part in raw_content:
|
||||
if isinstance(part, dict) and part.get("type") == "text":
|
||||
text_val = part.get("text", "")
|
||||
# Coerce to str in case text is null or non-string
|
||||
text_parts.append(str(text_val) if text_val is not None else "")
|
||||
return "".join(text_parts)
|
||||
# Not content-part format, return string representation
|
||||
return str(raw_content)
|
||||
|
||||
if isinstance(raw_content, str):
|
||||
content = raw_content.strip() if strip else raw_content
|
||||
# Check if the string is a JSON-encoded list (e.g., "[{'type': 'text', ...}]")
|
||||
# This can happen when streaming concatenates content that was originally list format
|
||||
# Only check if it looks like a complete JSON array (requires strip for check)
|
||||
check_content = raw_content.strip()
|
||||
if (
|
||||
check_content.startswith("[")
|
||||
and check_content.endswith("]")
|
||||
and len(check_content) < 8192
|
||||
):
|
||||
try:
|
||||
# First try standard JSON parsing
|
||||
parsed = json.loads(check_content)
|
||||
except json.JSONDecodeError:
|
||||
# If that fails, try parsing as Python literal (handles single quotes)
|
||||
# This is safer than blind replace("'", '"') which corrupts apostrophes
|
||||
try:
|
||||
import ast
|
||||
|
||||
parsed = ast.literal_eval(check_content)
|
||||
except (ValueError, SyntaxError):
|
||||
parsed = None
|
||||
|
||||
if isinstance(parsed, list):
|
||||
# Only convert if it matches OpenAI content-part schema
|
||||
# i.e., at least one item has {'type': 'text', 'text': ...}
|
||||
has_content_part = any(
|
||||
isinstance(part, dict) and part.get("type") == "text"
|
||||
for part in parsed
|
||||
)
|
||||
if has_content_part:
|
||||
text_parts = []
|
||||
for part in parsed:
|
||||
if isinstance(part, dict) and part.get("type") == "text":
|
||||
text_val = part.get("text", "")
|
||||
# Coerce to str in case text is null or non-string
|
||||
text_parts.append(
|
||||
str(text_val) if text_val is not None else ""
|
||||
)
|
||||
if text_parts:
|
||||
return "".join(text_parts)
|
||||
return content
|
||||
|
||||
# Fallback for other types (int, float, etc.)
|
||||
return str(raw_content) if raw_content is not None else ""
|
||||
|
||||
async def _parse_openai_completion(
|
||||
self, completion: ChatCompletion, tools: ToolSet | None
|
||||
) -> LLMResponse:
|
||||
@@ -259,8 +474,7 @@ class ProviderOpenAIOfficial(Provider):
|
||||
|
||||
# parse the text completion
|
||||
if choice.message.content is not None:
|
||||
# text completion
|
||||
completion_text = str(choice.message.content).strip()
|
||||
completion_text = self._normalize_content(choice.message.content)
|
||||
# specially, some providers may set <think> tags around reasoning content in the completion text,
|
||||
# we use regex to remove them, and store then in reasoning_content field
|
||||
reasoning_pattern = re.compile(r"<think>(.*?)</think>", re.DOTALL)
|
||||
@@ -270,6 +484,8 @@ class ProviderOpenAIOfficial(Provider):
|
||||
[match.strip() for match in matches],
|
||||
)
|
||||
completion_text = reasoning_pattern.sub("", completion_text).strip()
|
||||
# Also clean up orphan </think> tags that may leak from some models
|
||||
completion_text = re.sub(r"</think>\s*$", "", completion_text).strip()
|
||||
llm_response.result_chain = MessageChain().message(completion_text)
|
||||
|
||||
# parse the reasoning content if any
|
||||
@@ -403,6 +619,7 @@ class ProviderOpenAIOfficial(Provider):
|
||||
available_api_keys: list[str],
|
||||
retry_cnt: int,
|
||||
max_retries: int,
|
||||
image_fallback_used: bool = False,
|
||||
) -> tuple:
|
||||
"""处理API错误并尝试恢复"""
|
||||
if "429" in str(e):
|
||||
@@ -422,6 +639,7 @@ class ProviderOpenAIOfficial(Provider):
|
||||
payloads,
|
||||
context_query,
|
||||
func_tool,
|
||||
image_fallback_used,
|
||||
)
|
||||
raise e
|
||||
if "maximum context length" in str(e):
|
||||
@@ -437,20 +655,34 @@ class ProviderOpenAIOfficial(Provider):
|
||||
payloads,
|
||||
context_query,
|
||||
func_tool,
|
||||
image_fallback_used,
|
||||
)
|
||||
if "The model is not a VLM" in str(e): # siliconcloud
|
||||
if image_fallback_used or not self._context_contains_image(context_query):
|
||||
raise e
|
||||
# 尝试删除所有 image
|
||||
new_contexts = await self._remove_image_from_context(context_query)
|
||||
payloads["messages"] = new_contexts
|
||||
context_query = new_contexts
|
||||
return (
|
||||
False,
|
||||
chosen_key,
|
||||
available_api_keys,
|
||||
return await self._fallback_to_text_only_and_retry(
|
||||
payloads,
|
||||
context_query,
|
||||
chosen_key,
|
||||
available_api_keys,
|
||||
func_tool,
|
||||
"model_not_vlm",
|
||||
image_fallback_used=True,
|
||||
)
|
||||
if self._is_content_moderated_upload_error(e):
|
||||
if image_fallback_used or not self._context_contains_image(context_query):
|
||||
raise e
|
||||
return await self._fallback_to_text_only_and_retry(
|
||||
payloads,
|
||||
context_query,
|
||||
chosen_key,
|
||||
available_api_keys,
|
||||
func_tool,
|
||||
"image_content_moderated",
|
||||
image_fallback_used=True,
|
||||
)
|
||||
|
||||
if (
|
||||
"Function calling is not enabled" in str(e)
|
||||
or ("tool" in str(e).lower() and "support" in str(e).lower())
|
||||
@@ -461,7 +693,15 @@ class ProviderOpenAIOfficial(Provider):
|
||||
f"{self.get_model()} 不支持函数工具调用,已自动去除,不影响使用。",
|
||||
)
|
||||
payloads.pop("tools", None)
|
||||
return False, chosen_key, available_api_keys, payloads, context_query, None
|
||||
return (
|
||||
False,
|
||||
chosen_key,
|
||||
available_api_keys,
|
||||
payloads,
|
||||
context_query,
|
||||
None,
|
||||
image_fallback_used,
|
||||
)
|
||||
# logger.error(f"发生了错误。Provider 配置如下: {self.provider_config}")
|
||||
|
||||
if "tool" in str(e).lower() and "support" in str(e).lower():
|
||||
@@ -501,6 +741,7 @@ class ProviderOpenAIOfficial(Provider):
|
||||
max_retries = 10
|
||||
available_api_keys = self.api_keys.copy()
|
||||
chosen_key = random.choice(available_api_keys)
|
||||
image_fallback_used = False
|
||||
|
||||
last_exception = None
|
||||
retry_cnt = 0
|
||||
@@ -518,6 +759,7 @@ class ProviderOpenAIOfficial(Provider):
|
||||
payloads,
|
||||
context_query,
|
||||
func_tool,
|
||||
image_fallback_used,
|
||||
) = await self._handle_api_error(
|
||||
e,
|
||||
payloads,
|
||||
@@ -527,6 +769,7 @@ class ProviderOpenAIOfficial(Provider):
|
||||
available_api_keys,
|
||||
retry_cnt,
|
||||
max_retries,
|
||||
image_fallback_used=image_fallback_used,
|
||||
)
|
||||
if success:
|
||||
break
|
||||
@@ -564,6 +807,7 @@ class ProviderOpenAIOfficial(Provider):
|
||||
max_retries = 10
|
||||
available_api_keys = self.api_keys.copy()
|
||||
chosen_key = random.choice(available_api_keys)
|
||||
image_fallback_used = False
|
||||
|
||||
last_exception = None
|
||||
retry_cnt = 0
|
||||
@@ -582,6 +826,7 @@ class ProviderOpenAIOfficial(Provider):
|
||||
payloads,
|
||||
context_query,
|
||||
func_tool,
|
||||
image_fallback_used,
|
||||
) = await self._handle_api_error(
|
||||
e,
|
||||
payloads,
|
||||
@@ -591,6 +836,7 @@ class ProviderOpenAIOfficial(Provider):
|
||||
available_api_keys,
|
||||
retry_cnt,
|
||||
max_retries,
|
||||
image_fallback_used=image_fallback_used,
|
||||
)
|
||||
if success:
|
||||
break
|
||||
|
||||
@@ -5,7 +5,7 @@ import httpx
|
||||
from openai import NOT_GIVEN, AsyncOpenAI
|
||||
|
||||
from astrbot import logger
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
|
||||
|
||||
from ..entities import ProviderType
|
||||
from ..provider import TTSProvider
|
||||
@@ -46,7 +46,7 @@ class ProviderOpenAITTSAPI(TTSProvider):
|
||||
self.set_model(provider_config.get("model", ""))
|
||||
|
||||
async def get_audio(self, text: str) -> str:
|
||||
temp_dir = os.path.join(get_astrbot_data_path(), "temp")
|
||||
temp_dir = get_astrbot_temp_path()
|
||||
path = os.path.join(temp_dir, f"openai_tts_api_{uuid.uuid4()}.wav")
|
||||
async with self.client.audio.speech.with_streaming_response.create(
|
||||
model=self.model_name,
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
from ..register import register_provider_adapter
|
||||
from .openai_source import ProviderOpenAIOfficial
|
||||
|
||||
|
||||
@register_provider_adapter(
|
||||
"openrouter_chat_completion", "OpenRouter Chat Completion Provider Adapter"
|
||||
)
|
||||
class ProviderOpenRouter(ProviderOpenAIOfficial):
|
||||
def __init__(
|
||||
self,
|
||||
provider_config: dict,
|
||||
provider_settings: dict,
|
||||
) -> None:
|
||||
super().__init__(provider_config, provider_settings)
|
||||
# Reference to: https://openrouter.ai/docs/api/reference/overview#headers
|
||||
self.client._custom_headers["HTTP-Referer"] = ( # type: ignore
|
||||
"https://github.com/AstrBotDevs/AstrBot"
|
||||
)
|
||||
self.client._custom_headers["X-TITLE"] = "AstrBot" # type: ignore
|
||||
@@ -7,12 +7,14 @@ import asyncio
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import cast
|
||||
|
||||
from funasr_onnx import SenseVoiceSmall
|
||||
from funasr_onnx.utils.postprocess_utils import rich_transcription_postprocess
|
||||
|
||||
from astrbot.core import logger
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
|
||||
from astrbot.core.utils.io import download_file
|
||||
from astrbot.core.utils.tencent_record_helper import tencent_silk_to_wav
|
||||
|
||||
@@ -50,7 +52,9 @@ class ProviderSenseVoiceSTTSelfHost(STTProvider):
|
||||
|
||||
async def get_timestamped_path(self) -> str:
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
return os.path.join("data", "temp", f"{timestamp}")
|
||||
temp_dir = Path(get_astrbot_temp_path())
|
||||
temp_dir.mkdir(parents=True, exist_ok=True)
|
||||
return str(temp_dir / timestamp)
|
||||
|
||||
async def _is_silk_file(self, file_path) -> bool:
|
||||
silk_header = b"SILK"
|
||||
|
||||
@@ -8,6 +8,7 @@ import uuid
|
||||
import aiohttp
|
||||
|
||||
from astrbot import logger
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
|
||||
|
||||
from ..entities import ProviderType
|
||||
from ..provider import TTSProvider
|
||||
@@ -92,9 +93,12 @@ class ProviderVolcengineTTS(TTSProvider):
|
||||
if "data" in resp_data:
|
||||
audio_data = base64.b64decode(resp_data["data"])
|
||||
|
||||
os.makedirs("data/temp", exist_ok=True)
|
||||
|
||||
file_path = f"data/temp/volcengine_tts_{uuid.uuid4()}.mp3"
|
||||
temp_dir = get_astrbot_temp_path()
|
||||
os.makedirs(temp_dir, exist_ok=True)
|
||||
file_path = os.path.join(
|
||||
temp_dir,
|
||||
f"volcengine_tts_{uuid.uuid4()}.mp3",
|
||||
)
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
await loop.run_in_executor(
|
||||
|
||||
@@ -4,7 +4,7 @@ import uuid
|
||||
from openai import NOT_GIVEN, AsyncOpenAI
|
||||
|
||||
from astrbot.core import logger
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
|
||||
from astrbot.core.utils.io import download_file
|
||||
from astrbot.core.utils.tencent_record_helper import (
|
||||
convert_to_pcm_wav,
|
||||
@@ -65,9 +65,11 @@ class ProviderOpenAIWhisperAPI(STTProvider):
|
||||
if "multimedia.nt.qq.com.cn" in audio_url:
|
||||
is_tencent = True
|
||||
|
||||
name = str(uuid.uuid4())
|
||||
temp_dir = os.path.join(get_astrbot_data_path(), "temp")
|
||||
path = os.path.join(temp_dir, name)
|
||||
temp_dir = get_astrbot_temp_path()
|
||||
path = os.path.join(
|
||||
temp_dir,
|
||||
f"whisper_api_{uuid.uuid4().hex[:8]}.input",
|
||||
)
|
||||
await download_file(audio_url, path)
|
||||
audio_url = path
|
||||
|
||||
@@ -79,8 +81,11 @@ class ProviderOpenAIWhisperAPI(STTProvider):
|
||||
|
||||
# 判断是否需要转换
|
||||
if file_format in ["silk", "amr"]:
|
||||
temp_dir = os.path.join(get_astrbot_data_path(), "temp")
|
||||
output_path = os.path.join(temp_dir, str(uuid.uuid4()) + ".wav")
|
||||
temp_dir = get_astrbot_temp_path()
|
||||
output_path = os.path.join(
|
||||
temp_dir,
|
||||
f"whisper_api_{uuid.uuid4().hex[:8]}.wav",
|
||||
)
|
||||
|
||||
if file_format == "silk":
|
||||
logger.info(
|
||||
|
||||
@@ -6,7 +6,7 @@ from typing import cast
|
||||
import whisper
|
||||
|
||||
from astrbot.core import logger
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
|
||||
from astrbot.core.utils.io import download_file
|
||||
from astrbot.core.utils.tencent_record_helper import tencent_silk_to_wav
|
||||
|
||||
@@ -58,9 +58,11 @@ class ProviderOpenAIWhisperSelfHost(STTProvider):
|
||||
if "multimedia.nt.qq.com.cn" in audio_url:
|
||||
is_tencent = True
|
||||
|
||||
name = str(uuid.uuid4())
|
||||
temp_dir = os.path.join(get_astrbot_data_path(), "temp")
|
||||
path = os.path.join(temp_dir, name)
|
||||
temp_dir = get_astrbot_temp_path()
|
||||
path = os.path.join(
|
||||
temp_dir,
|
||||
f"whisper_selfhost_{uuid.uuid4().hex[:8]}.input",
|
||||
)
|
||||
await download_file(audio_url, path)
|
||||
audio_url = path
|
||||
|
||||
@@ -71,8 +73,11 @@ class ProviderOpenAIWhisperSelfHost(STTProvider):
|
||||
is_silk = await self._is_silk_file(audio_url)
|
||||
if is_silk:
|
||||
logger.info("Converting silk file to wav ...")
|
||||
temp_dir = os.path.join(get_astrbot_data_path(), "temp")
|
||||
output_path = os.path.join(temp_dir, str(uuid.uuid4()) + ".wav")
|
||||
temp_dir = get_astrbot_temp_path()
|
||||
output_path = os.path.join(
|
||||
temp_dir,
|
||||
f"whisper_selfhost_{uuid.uuid4().hex[:8]}.wav",
|
||||
)
|
||||
await tencent_silk_to_wav(audio_url, output_path)
|
||||
audio_url = output_path
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ from xinference_client.client.restful.async_restful_client import (
|
||||
)
|
||||
|
||||
from astrbot.core import logger
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
|
||||
from astrbot.core.utils.tencent_record_helper import (
|
||||
convert_to_pcm_wav,
|
||||
tencent_silk_to_wav,
|
||||
@@ -130,11 +130,17 @@ class ProviderXinferenceSTT(STTProvider):
|
||||
logger.info(
|
||||
f"Audio requires conversion ({conversion_type}), using temporary files..."
|
||||
)
|
||||
temp_dir = os.path.join(get_astrbot_data_path(), "temp")
|
||||
temp_dir = get_astrbot_temp_path()
|
||||
os.makedirs(temp_dir, exist_ok=True)
|
||||
|
||||
input_path = os.path.join(temp_dir, str(uuid.uuid4()))
|
||||
output_path = os.path.join(temp_dir, str(uuid.uuid4()) + ".wav")
|
||||
input_path = os.path.join(
|
||||
temp_dir,
|
||||
f"xinference_stt_{uuid.uuid4().hex[:8]}.input",
|
||||
)
|
||||
output_path = os.path.join(
|
||||
temp_dir,
|
||||
f"xinference_stt_{uuid.uuid4().hex[:8]}.wav",
|
||||
)
|
||||
temp_files.extend([input_path, output_path])
|
||||
|
||||
with open(input_path, "wb") as f:
|
||||
|
||||
@@ -93,7 +93,6 @@ class SkillManager:
|
||||
self.skills_root = skills_root or get_astrbot_skills_path()
|
||||
self.config_path = os.path.join(get_astrbot_data_path(), SKILLS_CONFIG_FILENAME)
|
||||
os.makedirs(self.skills_root, exist_ok=True)
|
||||
os.makedirs(get_astrbot_temp_path(), exist_ok=True)
|
||||
|
||||
def _load_config(self) -> dict:
|
||||
if not os.path.exists(self.config_path):
|
||||
|
||||
@@ -11,6 +11,7 @@ class PlatformAdapterType(enum.Flag):
|
||||
QQOFFICIAL = enum.auto()
|
||||
TELEGRAM = enum.auto()
|
||||
WECOM = enum.auto()
|
||||
WECOM_AI_BOT = enum.auto()
|
||||
LARK = enum.auto()
|
||||
DINGTALK = enum.auto()
|
||||
DISCORD = enum.auto()
|
||||
@@ -20,11 +21,13 @@ class PlatformAdapterType(enum.Flag):
|
||||
WEIXIN_OFFICIAL_ACCOUNT = enum.auto()
|
||||
SATORI = enum.auto()
|
||||
MISSKEY = enum.auto()
|
||||
LINE = enum.auto()
|
||||
ALL = (
|
||||
AIOCQHTTP
|
||||
| QQOFFICIAL
|
||||
| TELEGRAM
|
||||
| WECOM
|
||||
| WECOM_AI_BOT
|
||||
| LARK
|
||||
| DINGTALK
|
||||
| DISCORD
|
||||
@@ -34,6 +37,7 @@ class PlatformAdapterType(enum.Flag):
|
||||
| WEIXIN_OFFICIAL_ACCOUNT
|
||||
| SATORI
|
||||
| MISSKEY
|
||||
| LINE
|
||||
)
|
||||
|
||||
|
||||
@@ -42,6 +46,7 @@ ADAPTER_NAME_2_TYPE = {
|
||||
"qq_official": PlatformAdapterType.QQOFFICIAL,
|
||||
"telegram": PlatformAdapterType.TELEGRAM,
|
||||
"wecom": PlatformAdapterType.WECOM,
|
||||
"wecom_ai_bot": PlatformAdapterType.WECOM_AI_BOT,
|
||||
"lark": PlatformAdapterType.LARK,
|
||||
"dingtalk": PlatformAdapterType.DINGTALK,
|
||||
"discord": PlatformAdapterType.DISCORD,
|
||||
@@ -51,6 +56,7 @@ ADAPTER_NAME_2_TYPE = {
|
||||
"weixin_official_account": PlatformAdapterType.WEIXIN_OFFICIAL_ACCOUNT,
|
||||
"satori": PlatformAdapterType.SATORI,
|
||||
"misskey": PlatformAdapterType.MISSKEY,
|
||||
"line": PlatformAdapterType.LINE,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -13,6 +13,9 @@ from .star_handler import (
|
||||
register_on_llm_response,
|
||||
register_on_llm_tool_respond,
|
||||
register_on_platform_loaded,
|
||||
register_on_plugin_error,
|
||||
register_on_plugin_loaded,
|
||||
register_on_plugin_unloaded,
|
||||
register_on_using_llm_tool,
|
||||
register_on_waiting_llm_request,
|
||||
register_permission_type,
|
||||
@@ -32,6 +35,9 @@ __all__ = [
|
||||
"register_on_decorating_result",
|
||||
"register_on_llm_request",
|
||||
"register_on_llm_response",
|
||||
"register_on_plugin_error",
|
||||
"register_on_plugin_loaded",
|
||||
"register_on_plugin_unloaded",
|
||||
"register_on_platform_loaded",
|
||||
"register_on_waiting_llm_request",
|
||||
"register_permission_type",
|
||||
|
||||
@@ -339,6 +339,58 @@ def register_on_platform_loaded(**kwargs):
|
||||
return decorator
|
||||
|
||||
|
||||
def register_on_plugin_error(**kwargs):
|
||||
"""当插件处理消息异常时触发。
|
||||
|
||||
Hook 参数:
|
||||
event, plugin_name, handler_name, error, traceback_text
|
||||
|
||||
说明:
|
||||
在 hook 中调用 `event.stop_event()` 可屏蔽默认报错回显,
|
||||
并由插件自行决定是否转发到其他会话。
|
||||
"""
|
||||
|
||||
def decorator(awaitable):
|
||||
_ = get_handler_or_create(awaitable, EventType.OnPluginErrorEvent, **kwargs)
|
||||
return awaitable
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def register_on_plugin_loaded(**kwargs):
|
||||
"""当有插件加载完成时
|
||||
|
||||
Hook 参数:
|
||||
metadata
|
||||
|
||||
说明:
|
||||
当有插件加载完成时,触发该事件并获取到该插件的元数据
|
||||
"""
|
||||
|
||||
def decorator(awaitable):
|
||||
_ = get_handler_or_create(awaitable, EventType.OnPluginLoadedEvent, **kwargs)
|
||||
return awaitable
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def register_on_plugin_unloaded(**kwargs):
|
||||
"""当有插件卸载完成时
|
||||
|
||||
Hook 参数:
|
||||
metadata
|
||||
|
||||
说明:
|
||||
当有插件卸载完成时,触发该事件并获取到该插件的元数据
|
||||
"""
|
||||
|
||||
def decorator(awaitable):
|
||||
_ = get_handler_or_create(awaitable, EventType.OnPluginUnloadedEvent, **kwargs)
|
||||
return awaitable
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def register_on_waiting_llm_request(**kwargs):
|
||||
"""当等待调用 LLM 时的通知事件(在获取锁之前)
|
||||
|
||||
|
||||
@@ -61,6 +61,12 @@ class StarMetadata:
|
||||
logo_path: str | None = None
|
||||
"""插件 Logo 的路径"""
|
||||
|
||||
support_platforms: list[str] = field(default_factory=list)
|
||||
"""插件声明支持的平台适配器 ID 列表(对应 ADAPTER_NAME_2_TYPE 的 key)"""
|
||||
|
||||
astrbot_version: str | None = None
|
||||
"""插件要求的 AstrBot 版本范围(PEP 440 specifier,如 >=4.13.0,<4.17.0)"""
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"Plugin {self.name} ({self.version}) by {self.author}: {self.desc}"
|
||||
|
||||
|
||||
@@ -97,6 +97,14 @@ class StarHandlerRegistry(Generic[T]):
|
||||
plugins_name: list[str] | None = None,
|
||||
) -> list[StarHandlerMetadata[Callable[..., Awaitable[Any]]]]: ...
|
||||
|
||||
@overload
|
||||
def get_handlers_by_event_type(
|
||||
self,
|
||||
event_type: Literal[EventType.OnPluginErrorEvent],
|
||||
only_activated=True,
|
||||
plugins_name: list[str] | None = None,
|
||||
) -> list[StarHandlerMetadata[Callable[..., Awaitable[Any]]]]: ...
|
||||
|
||||
@overload
|
||||
def get_handlers_by_event_type(
|
||||
self,
|
||||
@@ -136,6 +144,8 @@ class StarHandlerRegistry(Generic[T]):
|
||||
not in (
|
||||
EventType.OnAstrBotLoadedEvent,
|
||||
EventType.OnPlatformLoadedEvent,
|
||||
EventType.OnPluginLoadedEvent,
|
||||
EventType.OnPluginUnloadedEvent,
|
||||
)
|
||||
and not plugin.reserved
|
||||
):
|
||||
@@ -192,6 +202,9 @@ class EventType(enum.Enum):
|
||||
OnUsingLLMToolEvent = enum.auto() # 使用 LLM 工具
|
||||
OnLLMToolRespondEvent = enum.auto() # 调用函数工具后
|
||||
OnAfterMessageSentEvent = enum.auto() # 发送消息后
|
||||
OnPluginErrorEvent = enum.auto() # 插件处理消息异常时
|
||||
OnPluginLoadedEvent = enum.auto() # 插件加载完成
|
||||
OnPluginUnloadedEvent = enum.auto() # 插件卸载完成
|
||||
|
||||
|
||||
H = TypeVar("H", bound=Callable[..., Any])
|
||||
|
||||
+407
-124
@@ -11,10 +11,13 @@ import traceback
|
||||
from types import ModuleType
|
||||
|
||||
import yaml
|
||||
from packaging.specifiers import InvalidSpecifier, SpecifierSet
|
||||
from packaging.version import InvalidVersion, Version
|
||||
|
||||
from astrbot.core import logger, pip_installer, sp
|
||||
from astrbot.core.agent.handoff import FunctionTool, HandoffTool
|
||||
from astrbot.core.config.astrbot_config import AstrBotConfig
|
||||
from astrbot.core.config.default import VERSION
|
||||
from astrbot.core.platform.register import unregister_platform_adapters_by_module
|
||||
from astrbot.core.provider.register import llm_tools
|
||||
from astrbot.core.utils.astrbot_path import (
|
||||
@@ -30,7 +33,7 @@ from .command_management import sync_command_configs
|
||||
from .context import Context
|
||||
from .filter.permission import PermissionType, PermissionTypeFilter
|
||||
from .star import star_map, star_registry
|
||||
from .star_handler import star_handlers_registry
|
||||
from .star_handler import EventType, star_handlers_registry
|
||||
from .updator import PluginUpdator
|
||||
|
||||
try:
|
||||
@@ -40,6 +43,10 @@ except ImportError:
|
||||
logger.warning("未安装 watchfiles,无法实现插件的热重载。")
|
||||
|
||||
|
||||
class PluginVersionIncompatibleError(Exception):
|
||||
"""Raised when plugin astrbot_version is incompatible with current AstrBot."""
|
||||
|
||||
|
||||
class PluginManager:
|
||||
def __init__(self, context: Context, config: AstrBotConfig) -> None:
|
||||
self.updator = PluginUpdator()
|
||||
@@ -62,6 +69,9 @@ class PluginManager:
|
||||
self._pm_lock = asyncio.Lock()
|
||||
"""StarManager操作互斥锁"""
|
||||
|
||||
self.failed_plugin_dict = {}
|
||||
"""加载失败插件的信息,用于后续可能的热重载"""
|
||||
|
||||
self.failed_plugin_info = ""
|
||||
if os.getenv("ASTRBOT_RELOAD", "0") == "1":
|
||||
asyncio.create_task(self._watch_plugins_changes())
|
||||
@@ -191,6 +201,38 @@ class PluginManager:
|
||||
await pip_installer.install(requirements_path=pth)
|
||||
except Exception as e:
|
||||
logger.error(f"更新插件 {p} 的依赖失败。Code: {e!s}")
|
||||
return True
|
||||
|
||||
async def _import_plugin_with_dependency_recovery(
|
||||
self,
|
||||
path: str,
|
||||
module_str: str,
|
||||
root_dir_name: str,
|
||||
requirements_path: str,
|
||||
) -> ModuleType:
|
||||
try:
|
||||
return __import__(path, fromlist=[module_str])
|
||||
except (ModuleNotFoundError, ImportError) as import_exc:
|
||||
if os.path.exists(requirements_path):
|
||||
try:
|
||||
logger.info(
|
||||
f"插件 {root_dir_name} 导入失败,尝试从已安装依赖恢复: {import_exc!s}"
|
||||
)
|
||||
pip_installer.prefer_installed_dependencies(
|
||||
requirements_path=requirements_path
|
||||
)
|
||||
module = __import__(path, fromlist=[module_str])
|
||||
logger.info(
|
||||
f"插件 {root_dir_name} 已从 site-packages 恢复依赖,跳过重新安装。"
|
||||
)
|
||||
return module
|
||||
except Exception as recover_exc:
|
||||
logger.info(
|
||||
f"插件 {root_dir_name} 已安装依赖恢复失败,将重新安装依赖: {recover_exc!s}"
|
||||
)
|
||||
|
||||
await self._check_plugin_dept_update(target_plugin=root_dir_name)
|
||||
return __import__(path, fromlist=[module_str])
|
||||
|
||||
@staticmethod
|
||||
def _load_plugin_metadata(plugin_path: str, plugin_obj=None) -> StarMetadata | None:
|
||||
@@ -233,10 +275,58 @@ class PluginManager:
|
||||
version=metadata["version"],
|
||||
repo=metadata["repo"] if "repo" in metadata else None,
|
||||
display_name=metadata.get("display_name", None),
|
||||
support_platforms=(
|
||||
[
|
||||
platform_id
|
||||
for platform_id in metadata["support_platforms"]
|
||||
if isinstance(platform_id, str)
|
||||
]
|
||||
if isinstance(metadata.get("support_platforms"), list)
|
||||
else []
|
||||
),
|
||||
astrbot_version=(
|
||||
metadata["astrbot_version"]
|
||||
if isinstance(metadata.get("astrbot_version"), str)
|
||||
else None
|
||||
),
|
||||
)
|
||||
|
||||
return metadata
|
||||
|
||||
@staticmethod
|
||||
def _validate_astrbot_version_specifier(
|
||||
version_spec: str | None,
|
||||
) -> tuple[bool, str | None]:
|
||||
if not version_spec:
|
||||
return True, None
|
||||
|
||||
normalized_spec = version_spec.strip()
|
||||
if not normalized_spec:
|
||||
return True, None
|
||||
|
||||
try:
|
||||
specifier = SpecifierSet(normalized_spec)
|
||||
except InvalidSpecifier:
|
||||
return (
|
||||
False,
|
||||
"astrbot_version 格式无效,请使用 PEP 440 版本范围格式,例如 >=4.16,<5。",
|
||||
)
|
||||
|
||||
try:
|
||||
current_version = Version(VERSION)
|
||||
except InvalidVersion:
|
||||
return (
|
||||
False,
|
||||
f"AstrBot 当前版本 {VERSION} 无法被解析,无法校验插件版本范围。",
|
||||
)
|
||||
|
||||
if current_version not in specifier:
|
||||
return (
|
||||
False,
|
||||
f"当前 AstrBot 版本为 {VERSION},不满足插件要求的 astrbot_version: {normalized_spec}",
|
||||
)
|
||||
return True, None
|
||||
|
||||
@staticmethod
|
||||
def _get_plugin_related_modules(
|
||||
plugin_root_dir: str,
|
||||
@@ -295,6 +385,28 @@ class PluginManager:
|
||||
except KeyError:
|
||||
logger.warning(f"模块 {module_name} 未载入")
|
||||
|
||||
async def reload_failed_plugin(self, dir_name):
|
||||
"""
|
||||
重新加载未注册(加载失败)的插件
|
||||
Args:
|
||||
dir_name (str): 要重载的特定插件名称。
|
||||
Returns:
|
||||
tuple: 返回 load() 方法的结果,包含 (success, error_message)
|
||||
- success (bool): 重载是否成功
|
||||
- error_message (str|None): 错误信息,成功时为 None
|
||||
"""
|
||||
async with self._pm_lock:
|
||||
if dir_name in self.failed_plugin_dict:
|
||||
success, error = await self.load(specified_dir_name=dir_name)
|
||||
if success:
|
||||
self.failed_plugin_dict.pop(dir_name, None)
|
||||
if not self.failed_plugin_dict:
|
||||
self.failed_plugin_info = ""
|
||||
return success, None
|
||||
else:
|
||||
return False, error
|
||||
return False, "插件不存在于失败列表中"
|
||||
|
||||
async def reload(self, specified_plugin_name=None):
|
||||
"""重新加载插件
|
||||
|
||||
@@ -351,7 +463,12 @@ class PluginManager:
|
||||
|
||||
return result
|
||||
|
||||
async def load(self, specified_module_path=None, specified_dir_name=None):
|
||||
async def load(
|
||||
self,
|
||||
specified_module_path=None,
|
||||
specified_dir_name=None,
|
||||
ignore_version_check: bool = False,
|
||||
):
|
||||
"""载入插件。
|
||||
当 specified_module_path 或者 specified_dir_name 不为 None 时,只载入指定的插件。
|
||||
|
||||
@@ -385,6 +502,12 @@ class PluginManager:
|
||||
"reserved",
|
||||
False,
|
||||
) # 是否是保留插件。目前在 astrbot/builtin_stars 目录下的都是保留插件。保留插件不可以卸载。
|
||||
plugin_dir_path = (
|
||||
os.path.join(self.plugin_store_path, root_dir_name)
|
||||
if not reserved
|
||||
else os.path.join(self.reserved_plugin_path, root_dir_name)
|
||||
)
|
||||
requirements_path = os.path.join(plugin_dir_path, "requirements.txt")
|
||||
|
||||
path = "data.plugins." if not reserved else "astrbot.builtin_stars."
|
||||
path += root_dir_name + "." + module_str
|
||||
@@ -399,23 +522,30 @@ class PluginManager:
|
||||
|
||||
# 尝试导入模块
|
||||
try:
|
||||
module = __import__(path, fromlist=[module_str])
|
||||
except (ModuleNotFoundError, ImportError):
|
||||
# 尝试安装依赖
|
||||
await self._check_plugin_dept_update(target_plugin=root_dir_name)
|
||||
module = __import__(path, fromlist=[module_str])
|
||||
module = await self._import_plugin_with_dependency_recovery(
|
||||
path=path,
|
||||
module_str=module_str,
|
||||
root_dir_name=root_dir_name,
|
||||
requirements_path=requirements_path,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(traceback.format_exc())
|
||||
error_trace = traceback.format_exc()
|
||||
logger.error(error_trace)
|
||||
logger.error(f"插件 {root_dir_name} 导入失败。原因:{e!s}")
|
||||
fail_rec += f"加载 {root_dir_name} 插件时出现问题,原因 {e!s}。\n"
|
||||
self.failed_plugin_dict[root_dir_name] = {
|
||||
"error": str(e),
|
||||
"traceback": error_trace,
|
||||
}
|
||||
if path in star_map:
|
||||
logger.info("失败插件依旧在插件列表中,正在清理...")
|
||||
metadata = star_map.pop(path)
|
||||
if metadata in star_registry:
|
||||
star_registry.remove(metadata)
|
||||
continue
|
||||
|
||||
# 检查 _conf_schema.json
|
||||
plugin_config = None
|
||||
plugin_dir_path = (
|
||||
os.path.join(self.plugin_store_path, root_dir_name)
|
||||
if not reserved
|
||||
else os.path.join(self.reserved_plugin_path, root_dir_name)
|
||||
)
|
||||
plugin_schema_path = os.path.join(
|
||||
plugin_dir_path,
|
||||
self.conf_schema_fname,
|
||||
@@ -448,12 +578,37 @@ class PluginManager:
|
||||
metadata.version = metadata_yaml.version
|
||||
metadata.repo = metadata_yaml.repo
|
||||
metadata.display_name = metadata_yaml.display_name
|
||||
metadata.support_platforms = metadata_yaml.support_platforms
|
||||
metadata.astrbot_version = metadata_yaml.astrbot_version
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"插件 {root_dir_name} 元数据载入失败: {e!s}。使用默认元数据。",
|
||||
)
|
||||
|
||||
if not ignore_version_check:
|
||||
is_valid, error_message = (
|
||||
self._validate_astrbot_version_specifier(
|
||||
metadata.astrbot_version,
|
||||
)
|
||||
)
|
||||
if not is_valid:
|
||||
raise PluginVersionIncompatibleError(
|
||||
error_message
|
||||
or "The plugin is not compatible with the current AstrBot version."
|
||||
)
|
||||
|
||||
logger.info(metadata)
|
||||
metadata.config = plugin_config
|
||||
p_name = (metadata.name or "unknown").lower().replace("/", "_")
|
||||
p_author = (metadata.author or "unknown").lower().replace("/", "_")
|
||||
plugin_id = f"{p_author}/{p_name}"
|
||||
|
||||
# 在实例化前注入类属性,保证插件 __init__ 可读取这些值
|
||||
if metadata.star_cls_type:
|
||||
setattr(metadata.star_cls_type, "name", p_name)
|
||||
setattr(metadata.star_cls_type, "author", p_author)
|
||||
setattr(metadata.star_cls_type, "plugin_id", plugin_id)
|
||||
|
||||
if path not in inactivated_plugins:
|
||||
# 只有没有禁用插件时才实例化插件类
|
||||
if plugin_config and metadata.star_cls_type:
|
||||
@@ -471,17 +626,10 @@ class PluginManager:
|
||||
context=self.context,
|
||||
)
|
||||
|
||||
p_name = (metadata.name or "unknown").lower().replace("/", "_")
|
||||
p_author = (
|
||||
(metadata.author or "unknown").lower().replace("/", "_")
|
||||
)
|
||||
setattr(metadata.star_cls, "name", p_name)
|
||||
setattr(metadata.star_cls, "author", p_author)
|
||||
setattr(
|
||||
metadata.star_cls,
|
||||
"plugin_id",
|
||||
f"{p_author}/{p_name}",
|
||||
)
|
||||
if metadata.star_cls:
|
||||
setattr(metadata.star_cls, "name", p_name)
|
||||
setattr(metadata.star_cls, "author", p_author)
|
||||
setattr(metadata.star_cls, "plugin_id", plugin_id)
|
||||
else:
|
||||
logger.info(f"插件 {metadata.name} 已被禁用。")
|
||||
|
||||
@@ -559,6 +707,19 @@ class PluginManager:
|
||||
)
|
||||
if not metadata:
|
||||
raise Exception(f"无法找到插件 {plugin_dir_path} 的元数据。")
|
||||
|
||||
if not ignore_version_check:
|
||||
is_valid, error_message = (
|
||||
self._validate_astrbot_version_specifier(
|
||||
metadata.astrbot_version,
|
||||
)
|
||||
)
|
||||
if not is_valid:
|
||||
raise PluginVersionIncompatibleError(
|
||||
error_message
|
||||
or "The plugin is not compatible with the current AstrBot version."
|
||||
)
|
||||
|
||||
metadata.star_cls = obj
|
||||
metadata.config = plugin_config
|
||||
metadata.module = module
|
||||
@@ -622,6 +783,19 @@ class PluginManager:
|
||||
if hasattr(metadata.star_cls, "initialize") and metadata.star_cls:
|
||||
await metadata.star_cls.initialize()
|
||||
|
||||
# 触发插件加载事件
|
||||
handlers = star_handlers_registry.get_handlers_by_event_type(
|
||||
EventType.OnPluginLoadedEvent,
|
||||
)
|
||||
for handler in handlers:
|
||||
try:
|
||||
logger.info(
|
||||
f"hook(on_plugin_loaded) -> {star_map[handler.handler_module_path].name} - {handler.handler_name}",
|
||||
)
|
||||
await handler.handler(metadata)
|
||||
except Exception:
|
||||
logger.error(traceback.format_exc())
|
||||
|
||||
except BaseException as e:
|
||||
logger.error(f"----- 插件 {root_dir_name} 载入失败 -----")
|
||||
errors = traceback.format_exc()
|
||||
@@ -629,6 +803,16 @@ class PluginManager:
|
||||
logger.error(f"| {line}")
|
||||
logger.error("----------------------------------")
|
||||
fail_rec += f"加载 {root_dir_name} 插件时出现问题,原因 {e!s}。\n"
|
||||
self.failed_plugin_dict[root_dir_name] = {
|
||||
"error": str(e),
|
||||
"traceback": errors,
|
||||
}
|
||||
# 记录注册失败的插件名称,以便后续重载插件
|
||||
if path in star_map:
|
||||
logger.info("失败插件依旧在插件列表中,正在清理...")
|
||||
metadata = star_map.pop(path)
|
||||
if metadata in star_registry:
|
||||
star_registry.remove(metadata)
|
||||
|
||||
# 清除 pip.main 导致的多余的 logging handlers
|
||||
for handler in logging.root.handlers[:]:
|
||||
@@ -644,7 +828,52 @@ class PluginManager:
|
||||
self.failed_plugin_info = fail_rec
|
||||
return False, fail_rec
|
||||
|
||||
async def install_plugin(self, repo_url: str, proxy=""):
|
||||
async def _cleanup_failed_plugin_install(
|
||||
self,
|
||||
dir_name: str,
|
||||
plugin_path: str,
|
||||
) -> None:
|
||||
plugin = None
|
||||
for star in self.context.get_all_stars():
|
||||
if star.root_dir_name == dir_name:
|
||||
plugin = star
|
||||
break
|
||||
|
||||
if plugin and plugin.name and plugin.module_path:
|
||||
try:
|
||||
await self._terminate_plugin(plugin)
|
||||
except Exception:
|
||||
logger.warning(traceback.format_exc())
|
||||
try:
|
||||
await self._unbind_plugin(plugin.name, plugin.module_path)
|
||||
except Exception:
|
||||
logger.warning(traceback.format_exc())
|
||||
|
||||
if os.path.exists(plugin_path):
|
||||
try:
|
||||
remove_dir(plugin_path)
|
||||
logger.warning(f"已清理安装失败的插件目录: {plugin_path}")
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"清理安装失败插件目录失败: {plugin_path},原因: {e!s}",
|
||||
)
|
||||
|
||||
plugin_config_path = os.path.join(
|
||||
self.plugin_config_path,
|
||||
f"{dir_name}_config.json",
|
||||
)
|
||||
if os.path.exists(plugin_config_path):
|
||||
try:
|
||||
os.remove(plugin_config_path)
|
||||
logger.warning(f"已清理安装失败插件配置: {plugin_config_path}")
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"清理安装失败插件配置失败: {plugin_config_path},原因: {e!s}",
|
||||
)
|
||||
|
||||
async def install_plugin(
|
||||
self, repo_url: str, proxy: str = "", ignore_version_check: bool = False
|
||||
):
|
||||
"""从仓库 URL 安装插件
|
||||
|
||||
从指定的仓库 URL 下载并安装插件,然后加载该插件到系统中
|
||||
@@ -669,44 +898,65 @@ class PluginManager:
|
||||
)
|
||||
|
||||
async with self._pm_lock:
|
||||
plugin_path = await self.updator.install(repo_url, proxy)
|
||||
# reload the plugin
|
||||
dir_name = os.path.basename(plugin_path)
|
||||
await self.load(specified_dir_name=dir_name)
|
||||
plugin_path = ""
|
||||
dir_name = ""
|
||||
cleanup_required = False
|
||||
try:
|
||||
plugin_path = await self.updator.install(repo_url, proxy)
|
||||
cleanup_required = True
|
||||
|
||||
# Get the plugin metadata to return repo info
|
||||
plugin = self.context.get_registered_star(dir_name)
|
||||
if not plugin:
|
||||
# Try to find by other name if directory name doesn't match plugin name
|
||||
for star in self.context.get_all_stars():
|
||||
if star.root_dir_name == dir_name:
|
||||
plugin = star
|
||||
break
|
||||
|
||||
# Extract README.md content if exists
|
||||
readme_content = None
|
||||
readme_path = os.path.join(plugin_path, "README.md")
|
||||
if not os.path.exists(readme_path):
|
||||
readme_path = os.path.join(plugin_path, "readme.md")
|
||||
|
||||
if os.path.exists(readme_path):
|
||||
try:
|
||||
with open(readme_path, encoding="utf-8") as f:
|
||||
readme_content = f.read()
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"读取插件 {dir_name} 的 README.md 文件失败: {e!s}",
|
||||
# reload the plugin
|
||||
dir_name = os.path.basename(plugin_path)
|
||||
success, error_message = await self.load(
|
||||
specified_dir_name=dir_name,
|
||||
ignore_version_check=ignore_version_check,
|
||||
)
|
||||
if not success:
|
||||
raise Exception(
|
||||
error_message
|
||||
or f"安装插件 {dir_name} 失败,请检查插件依赖或兼容性。"
|
||||
)
|
||||
|
||||
plugin_info = None
|
||||
if plugin:
|
||||
plugin_info = {
|
||||
"repo": plugin.repo,
|
||||
"readme": readme_content,
|
||||
"name": plugin.name,
|
||||
}
|
||||
# Get the plugin metadata to return repo info
|
||||
plugin = self.context.get_registered_star(dir_name)
|
||||
if not plugin:
|
||||
# Try to find by other name if directory name doesn't match plugin name
|
||||
for star in self.context.get_all_stars():
|
||||
if star.root_dir_name == dir_name:
|
||||
plugin = star
|
||||
break
|
||||
|
||||
return plugin_info
|
||||
# Extract README.md content if exists
|
||||
readme_content = None
|
||||
readme_path = os.path.join(plugin_path, "README.md")
|
||||
if not os.path.exists(readme_path):
|
||||
readme_path = os.path.join(plugin_path, "readme.md")
|
||||
|
||||
if os.path.exists(readme_path):
|
||||
try:
|
||||
with open(readme_path, encoding="utf-8") as f:
|
||||
readme_content = f.read()
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"读取插件 {dir_name} 的 README.md 文件失败: {e!s}",
|
||||
)
|
||||
|
||||
plugin_info = None
|
||||
if plugin:
|
||||
plugin_info = {
|
||||
"repo": plugin.repo,
|
||||
"readme": readme_content,
|
||||
"name": plugin.name,
|
||||
}
|
||||
|
||||
return plugin_info
|
||||
except Exception:
|
||||
if cleanup_required and dir_name and plugin_path:
|
||||
await self._cleanup_failed_plugin_install(
|
||||
dir_name=dir_name,
|
||||
plugin_path=plugin_path,
|
||||
)
|
||||
raise
|
||||
|
||||
async def uninstall_plugin(
|
||||
self,
|
||||
@@ -938,6 +1188,19 @@ class PluginManager:
|
||||
elif "terminate" in star_metadata.star_cls_type.__dict__:
|
||||
await star_metadata.star_cls.terminate()
|
||||
|
||||
# 触发插件卸载事件
|
||||
handlers = star_handlers_registry.get_handlers_by_event_type(
|
||||
EventType.OnPluginUnloadedEvent,
|
||||
)
|
||||
for handler in handlers:
|
||||
try:
|
||||
logger.info(
|
||||
f"hook(on_plugin_unloaded) -> {star_map[handler.handler_module_path].name} - {handler.handler_name}",
|
||||
)
|
||||
await handler.handler(star_metadata)
|
||||
except Exception:
|
||||
logger.error(traceback.format_exc())
|
||||
|
||||
async def turn_on_plugin(self, plugin_name: str) -> None:
|
||||
plugin = self.context.get_registered_star(plugin_name)
|
||||
if plugin is None:
|
||||
@@ -964,10 +1227,13 @@ class PluginManager:
|
||||
|
||||
await self.reload(plugin_name)
|
||||
|
||||
async def install_plugin_from_file(self, zip_file_path: str):
|
||||
async def install_plugin_from_file(
|
||||
self, zip_file_path: str, ignore_version_check: bool = False
|
||||
):
|
||||
dir_name = os.path.basename(zip_file_path).replace(".zip", "")
|
||||
dir_name = dir_name.removesuffix("-master").removesuffix("-main").lower()
|
||||
desti_dir = os.path.join(self.plugin_store_path, dir_name)
|
||||
cleanup_required = False
|
||||
|
||||
# 第一步:检查是否已安装同目录名的插件,先终止旧插件
|
||||
existing_plugin = None
|
||||
@@ -987,74 +1253,91 @@ class PluginManager:
|
||||
existing_plugin.name, existing_plugin.module_path
|
||||
)
|
||||
|
||||
self.updator.unzip_file(zip_file_path, desti_dir)
|
||||
|
||||
# 第二步:解压后,读取新插件的 metadata.yaml,检查是否存在同名但不同目录的插件
|
||||
try:
|
||||
new_metadata = self._load_plugin_metadata(desti_dir)
|
||||
if new_metadata and new_metadata.name:
|
||||
for star in self.context.get_all_stars():
|
||||
if (
|
||||
star.name == new_metadata.name
|
||||
and star.root_dir_name != dir_name
|
||||
):
|
||||
logger.warning(
|
||||
f"检测到同名插件 {star.name} 存在于不同目录 {star.root_dir_name},正在终止..."
|
||||
)
|
||||
try:
|
||||
await self._terminate_plugin(star)
|
||||
except Exception:
|
||||
logger.warning(traceback.format_exc())
|
||||
if star.name and star.module_path:
|
||||
await self._unbind_plugin(star.name, star.module_path)
|
||||
break # 只处理第一个匹配的
|
||||
except Exception as e:
|
||||
logger.debug(f"读取新插件 metadata.yaml 失败,跳过同名检查: {e!s}")
|
||||
self.updator.unzip_file(zip_file_path, desti_dir)
|
||||
cleanup_required = True
|
||||
|
||||
# remove the zip
|
||||
try:
|
||||
os.remove(zip_file_path)
|
||||
except BaseException as e:
|
||||
logger.warning(f"删除插件压缩包失败: {e!s}")
|
||||
# await self.reload()
|
||||
await self.load(specified_dir_name=dir_name)
|
||||
|
||||
# Get the plugin metadata to return repo info
|
||||
plugin = self.context.get_registered_star(dir_name)
|
||||
if not plugin:
|
||||
# Try to find by other name if directory name doesn't match plugin name
|
||||
for star in self.context.get_all_stars():
|
||||
if star.root_dir_name == dir_name:
|
||||
plugin = star
|
||||
break
|
||||
|
||||
# Extract README.md content if exists
|
||||
readme_content = None
|
||||
readme_path = os.path.join(desti_dir, "README.md")
|
||||
if not os.path.exists(readme_path):
|
||||
readme_path = os.path.join(desti_dir, "readme.md")
|
||||
|
||||
if os.path.exists(readme_path):
|
||||
# 第二步:解压后,读取新插件的 metadata.yaml,检查是否存在同名但不同目录的插件
|
||||
try:
|
||||
with open(readme_path, encoding="utf-8") as f:
|
||||
readme_content = f.read()
|
||||
new_metadata = self._load_plugin_metadata(desti_dir)
|
||||
if new_metadata and new_metadata.name:
|
||||
for star in self.context.get_all_stars():
|
||||
if (
|
||||
star.name == new_metadata.name
|
||||
and star.root_dir_name != dir_name
|
||||
):
|
||||
logger.warning(
|
||||
f"检测到同名插件 {star.name} 存在于不同目录 {star.root_dir_name},正在终止..."
|
||||
)
|
||||
try:
|
||||
await self._terminate_plugin(star)
|
||||
except Exception:
|
||||
logger.warning(traceback.format_exc())
|
||||
if star.name and star.module_path:
|
||||
await self._unbind_plugin(star.name, star.module_path)
|
||||
break # 只处理第一个匹配的
|
||||
except Exception as e:
|
||||
logger.warning(f"读取插件 {dir_name} 的 README.md 文件失败: {e!s}")
|
||||
logger.debug(f"读取新插件 metadata.yaml 失败,跳过同名检查: {e!s}")
|
||||
|
||||
plugin_info = None
|
||||
if plugin:
|
||||
plugin_info = {
|
||||
"repo": plugin.repo,
|
||||
"readme": readme_content,
|
||||
"name": plugin.name,
|
||||
}
|
||||
|
||||
if plugin.repo:
|
||||
asyncio.create_task(
|
||||
Metric.upload(
|
||||
et="install_star_f", # install star
|
||||
repo=plugin.repo,
|
||||
),
|
||||
# remove the zip
|
||||
try:
|
||||
os.remove(zip_file_path)
|
||||
except BaseException as e:
|
||||
logger.warning(f"删除插件压缩包失败: {e!s}")
|
||||
# await self.reload()
|
||||
success, error_message = await self.load(
|
||||
specified_dir_name=dir_name,
|
||||
ignore_version_check=ignore_version_check,
|
||||
)
|
||||
if not success:
|
||||
raise Exception(
|
||||
error_message
|
||||
or f"安装插件 {dir_name} 失败,请检查插件依赖或兼容性。"
|
||||
)
|
||||
|
||||
return plugin_info
|
||||
# Get the plugin metadata to return repo info
|
||||
plugin = self.context.get_registered_star(dir_name)
|
||||
if not plugin:
|
||||
# Try to find by other name if directory name doesn't match plugin name
|
||||
for star in self.context.get_all_stars():
|
||||
if star.root_dir_name == dir_name:
|
||||
plugin = star
|
||||
break
|
||||
|
||||
# Extract README.md content if exists
|
||||
readme_content = None
|
||||
readme_path = os.path.join(desti_dir, "README.md")
|
||||
if not os.path.exists(readme_path):
|
||||
readme_path = os.path.join(desti_dir, "readme.md")
|
||||
|
||||
if os.path.exists(readme_path):
|
||||
try:
|
||||
with open(readme_path, encoding="utf-8") as f:
|
||||
readme_content = f.read()
|
||||
except Exception as e:
|
||||
logger.warning(f"读取插件 {dir_name} 的 README.md 文件失败: {e!s}")
|
||||
|
||||
plugin_info = None
|
||||
if plugin:
|
||||
plugin_info = {
|
||||
"repo": plugin.repo,
|
||||
"readme": readme_content,
|
||||
"name": plugin.name,
|
||||
}
|
||||
|
||||
if plugin.repo:
|
||||
asyncio.create_task(
|
||||
Metric.upload(
|
||||
et="install_star_f", # install star
|
||||
repo=plugin.repo,
|
||||
),
|
||||
)
|
||||
|
||||
return plugin_info
|
||||
except Exception:
|
||||
if cleanup_required:
|
||||
await self._cleanup_failed_plugin_install(
|
||||
dir_name=dir_name,
|
||||
plugin_path=desti_dir,
|
||||
)
|
||||
raise
|
||||
|
||||
@@ -148,8 +148,8 @@ class AstrBotUpdator(RepoZipUpdator):
|
||||
update_data = await self.fetch_release_info(self.ASTRBOT_RELEASE_API, latest)
|
||||
file_url = None
|
||||
|
||||
if os.environ.get("ASTRBOT_CLI"):
|
||||
raise Exception("不支持更新CLI启动的AstrBot") # 避免版本管理混乱
|
||||
if os.environ.get("ASTRBOT_CLI") or os.environ.get("ASTRBOT_LAUNCHER"):
|
||||
raise Exception("不支持更新此方式启动的AstrBot") # 避免版本管理混乱
|
||||
|
||||
if latest:
|
||||
latest_version = update_data[0]["tag_name"]
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from astrbot.core.platform import AstrMessageEvent
|
||||
|
||||
|
||||
class ActiveEventRegistry:
|
||||
"""维护 unified_msg_origin 到活跃事件的映射。
|
||||
|
||||
用于在 reset 等场景下终止该会话正在处理的事件。
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._events: dict[str, set[AstrMessageEvent]] = defaultdict(set)
|
||||
|
||||
def register(self, event: AstrMessageEvent) -> None:
|
||||
self._events[event.unified_msg_origin].add(event)
|
||||
|
||||
def unregister(self, event: AstrMessageEvent) -> None:
|
||||
umo = event.unified_msg_origin
|
||||
self._events[umo].discard(event)
|
||||
if not self._events[umo]:
|
||||
del self._events[umo]
|
||||
|
||||
def stop_all(
|
||||
self,
|
||||
umo: str,
|
||||
exclude: AstrMessageEvent | None = None,
|
||||
) -> int:
|
||||
"""终止指定 UMO 的所有活跃事件。
|
||||
|
||||
Args:
|
||||
umo: 统一消息来源标识符。
|
||||
exclude: 需要排除的事件(通常是发起 reset 的事件本身)。
|
||||
|
||||
Returns:
|
||||
被终止的事件数量。
|
||||
"""
|
||||
count = 0
|
||||
for event in list(self._events.get(umo, [])):
|
||||
if event is not exclude:
|
||||
event.stop_event()
|
||||
count += 1
|
||||
return count
|
||||
|
||||
def request_agent_stop_all(
|
||||
self,
|
||||
umo: str,
|
||||
exclude: AstrMessageEvent | None = None,
|
||||
) -> int:
|
||||
"""请求停止指定 UMO 的所有活跃事件中的 Agent 运行。
|
||||
|
||||
与 stop_all 不同,这里不会调用 event.stop_event(),
|
||||
因此不会中断事件传播,后续流程(如历史记录保存)仍可继续。
|
||||
"""
|
||||
count = 0
|
||||
for event in list(self._events.get(umo, [])):
|
||||
if event is not exclude:
|
||||
event.set_extra("agent_stop_requested", True)
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
active_event_registry = ActiveEventRegistry()
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user