Compare commits
137 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 565c371e5c | |||
| a1c9dc5d01 | |||
| d3d4e1db7b | |||
| 78b3e12c66 | |||
| c42ac87ee1 | |||
| 3fbd16b211 | |||
| e77500ff69 | |||
| 2c49ac0dcf | |||
| 65decfbe87 | |||
| 92c31192de | |||
| b795f804a7 | |||
| bc3b5e58a4 | |||
| 7e3c32b828 | |||
| ceb32dce9f | |||
| 84e880af5f | |||
| 9909d774ed | |||
| 6b3868b4be | |||
| 11c840953a | |||
| 2bbca887ce | |||
| dd89a4b334 | |||
| a3fa8a5a7c | |||
| aa60467782 | |||
| d936bb0a10 | |||
| 64e0183b55 | |||
| 420d82df11 | |||
| d87cf897da | |||
| 2f51916a73 | |||
| b0e10cf479 | |||
| 20efaa5320 | |||
| 3ccd70cd4e | |||
| da520e573a | |||
| 6d055e81e9 | |||
| d41ccb70c5 | |||
| 18a99a25c2 | |||
| 96cafe001d | |||
| 29d100dd83 | |||
| 14f3701c4a | |||
| 1044fc48ca | |||
| 693c2ca818 | |||
| b1c486ba98 | |||
| 9363fb824a | |||
| 044b361ac5 | |||
| 06fd2d2428 | |||
| dd6bc1dcdb | |||
| 52d5258b10 | |||
| 91933bbd19 | |||
| f8d075b5d3 | |||
| 86ef758a9a | |||
| 1a03180643 | |||
| 326183a3fd | |||
| 08fc657755 | |||
| 0ff9539599 | |||
| 38f5e077ee | |||
| 89fbd75e7a | |||
| 493662524a | |||
| 1afbb357db | |||
| 8d2140f607 | |||
| 97732987d9 | |||
| a60a40bca3 | |||
| a8ff2b3d9c | |||
| a21bb5b234 | |||
| 994d39241e | |||
| e6c1164755 | |||
| 89cc8a1a65 | |||
| c0e4f1e114 | |||
| 7b43448ce4 | |||
| bdac0b65f4 | |||
| cf9ee6f20c | |||
| 01eae72a64 | |||
| bca1476eab | |||
| fbcbde0a4b | |||
| 3914d766db | |||
| 3e2cb6a2ab | |||
| 25830524f3 | |||
| 304094630c | |||
| 5c3643c54c | |||
| 589cce18af | |||
| e254caf82d | |||
| 7efcd242d6 | |||
| 5d811d3949 | |||
| 8e6aaee10c | |||
| 6da59cfb07 | |||
| 10ceacfbb1 | |||
| 66f5ccd902 | |||
| 3379587223 | |||
| e25a1a42cf | |||
| 0c771e4a77 | |||
| ec21cb13d3 | |||
| 1d26b96d90 | |||
| be017c87f4 | |||
| 23fffa95c8 | |||
| 5b303e2e6d | |||
| fc33b3eb68 | |||
| 795aec9578 | |||
| 7d31140c14 | |||
| 654112ca86 | |||
| 5dd30f9a45 | |||
| a53a1ca49b | |||
| 3fd6c4c8a6 | |||
| 5808784f07 | |||
| 537849c1e7 | |||
| 7f3c0fdeb2 | |||
| 8e431e2076 | |||
| 89c11fd683 | |||
| 7cfe2aca99 | |||
| 3a938d2a13 | |||
| 812834bc9f | |||
| 51ff4f6e46 | |||
| 7ac169c5e8 | |||
| 61648ebe3e | |||
| 0610f0db0a | |||
| 8c935981bb | |||
| 3f3b4e4924 | |||
| af581e7f21 | |||
| 9e371ee10b | |||
| 7cf77adbc8 | |||
| 31673ee521 | |||
| ff22030dde | |||
| 101580fd77 | |||
| 418f05f6e4 | |||
| df421e5554 | |||
| ed84074a60 | |||
| bbf61239ad | |||
| 92ee534a2c | |||
| fa4df0b5f3 | |||
| e5ac31efe7 | |||
| 2a7745c767 | |||
| 82e7502f74 | |||
| 866e546b59 | |||
| 6b642d7674 | |||
| 0711ec346f | |||
| 0dbe32e2dc | |||
| 4e855a17bc | |||
| f2fc724e0f | |||
| 460acf40c0 | |||
| cf29d9390f | |||
| ac44d1fdef |
@@ -21,7 +21,23 @@
|
||||
<!--If merged, your code will serve tens of thousands of users! Please double-check the following items before submitting.-->
|
||||
<!--如果分支被合并,您的代码将服务于数万名用户!在提交前,请核查一下几点内容。-->
|
||||
|
||||
- [ ] 😊 如果 PR 中有新加入的功能,已经通过 Issue / 邮件等方式和作者讨论过。/ If there are new features added in the PR, I have discussed it with the authors through issues/emails, etc.
|
||||
- [ ] 👀 我的更改经过了良好的测试,**并已在上方提供了“验证步骤”和“运行截图”**。/ My changes have been well-tested, **and "Verification Steps" and "Screenshots" have been provided above**.
|
||||
- [ ] 🤓 我确保没有引入新依赖库,或者引入了新依赖库的同时将其添加到了 `requirements.txt` 和 `pyproject.toml` 文件相应位置。/ I have ensured that no new dependencies are introduced, OR if new dependencies are introduced, they have been added to the appropriate locations in `requirements.txt` and `pyproject.toml`.
|
||||
- [ ] 😮 我的更改没有引入恶意代码。/ My changes do not introduce malicious code.
|
||||
- [ ] 😊 如果 PR 中有新加入的功能,已经通过 Issue / 邮件等方式和作者讨论过。
|
||||
/ If there are new features added in the PR, I have discussed it with the authors through issues/emails, etc.
|
||||
|
||||
- [ ] 👀 我的更改经过了良好的测试,**并已在上方提供了“验证步骤”和“运行截图”**。
|
||||
/ My changes have been well-tested, **and "Verification Steps" and "Screenshots" have been provided above**.
|
||||
|
||||
- [ ] 🤓 我确保没有引入新依赖库,或者引入了新依赖库的同时将其添加到 `requirements.txt` 和 `pyproject.toml` 文件相应位置。
|
||||
/ I have ensured that no new dependencies are introduced, OR if new dependencies are introduced, they have been added to the appropriate locations in `requirements.txt` and `pyproject.toml`.
|
||||
|
||||
- [ ] 😮 我的更改没有引入恶意代码。
|
||||
/ My changes do not introduce malicious code.
|
||||
|
||||
- [ ] ⚠️ 我已认真阅读并理解以上所有内容,确保本次提交符合规范。
|
||||
/ I have read and understood all the above and confirm this PR follows the rules.
|
||||
|
||||
- [ ] 🚀 我确保本次开发**基于 dev 分支**,并将代码合并至**开发分支**(除非极其紧急,才允许合并到主分支)。
|
||||
/ I confirm that this development is **based on the dev branch** and will be merged into the **development branch**, unless it is extremely urgent to merge into the main branch.
|
||||
|
||||
- [ ] ⚠️ 我**没有**认真阅读以上内容,直接提交。
|
||||
/ I **did not** read the above carefully before submitting.
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
name: release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest # 运行环境
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@v6
|
||||
- name: nodejs installation
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "18"
|
||||
- name: npm install
|
||||
run: npm add -D vitepress
|
||||
working-directory: './docs' # working-directory 指定 shell 命令运行目录
|
||||
- name: npm run build
|
||||
run: npm run docs:build
|
||||
working-directory: './docs'
|
||||
- name: scp
|
||||
uses: appleboy/scp-action@v1.0.0
|
||||
with:
|
||||
host: ${{ secrets.HOST_NEKO }}
|
||||
username: ${{ secrets.USERNAME }}
|
||||
password: ${{ secrets.PASSWORDNEKO }}
|
||||
source: 'docs/.vitepress/dist/*'
|
||||
target: '/tmp/'
|
||||
- name: script
|
||||
uses: appleboy/ssh-action@v1.2.5
|
||||
with:
|
||||
host: ${{ secrets.HOST_NEKO }}
|
||||
username: ${{ secrets.USERNAME }}
|
||||
password: ${{ secrets.PASSWORDNEKO }}
|
||||
script: |
|
||||
mkdir -p /root/docker_data/caddy/caddy_data/static_site/abv4/
|
||||
rm -rf /root/docker_data/caddy/caddy_data/static_site/abv4/*
|
||||
mv /tmp/docs/.vitepress/dist/* /root/docker_data/caddy/caddy_data/static_site/abv4/
|
||||
rm -rf /tmp/docs/
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
zip -r dist.zip dist
|
||||
|
||||
- name: Archive production artifacts
|
||||
uses: actions/upload-artifact@v6
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: dist-without-markdown
|
||||
path: |
|
||||
@@ -45,7 +45,7 @@ jobs:
|
||||
|
||||
- name: Create GitHub Release
|
||||
if: github.event_name == 'push'
|
||||
uses: ncipollo/release-action@v1
|
||||
uses: ncipollo/release-action@v1.20.0
|
||||
with:
|
||||
tag: release-${{ github.sha }}
|
||||
owner: AstrBotDevs
|
||||
|
||||
@@ -64,20 +64,20 @@ jobs:
|
||||
echo "build_date=$build_date" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Set QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
uses: docker/setup-qemu-action@v4.0.0
|
||||
|
||||
- name: Set Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4.0.0
|
||||
|
||||
- name: Log in to DockerHub
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4.0.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
if: env.HAS_GHCR_TOKEN == 'true'
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4.0.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ env.GHCR_OWNER }}
|
||||
@@ -98,7 +98,7 @@ jobs:
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Build and Push Nightly Image
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7.0.0
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
@@ -163,27 +163,27 @@ jobs:
|
||||
cp -r dashboard/dist data/
|
||||
|
||||
- name: Set QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
uses: docker/setup-qemu-action@v4.0.0
|
||||
|
||||
- name: Set Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4.0.0
|
||||
|
||||
- name: Log in to DockerHub
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4.0.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
if: env.HAS_GHCR_TOKEN == 'true'
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4.0.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ env.GHCR_OWNER }}
|
||||
password: ${{ secrets.GHCR_GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and Push Release Image
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7.0.0
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
name: PR Checklist Check
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, edited, reopened, synchronize]
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
issues: write
|
||||
|
||||
steps:
|
||||
- name: Check checklist
|
||||
id: check
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const body = context.payload.pull_request.body || "";
|
||||
const regex = /-\s*\[\s*x\s*\].*没有.*认真阅读/i;
|
||||
const bad = regex.test(body);
|
||||
core.setOutput("bad", bad);
|
||||
|
||||
- name: Close PR
|
||||
if: steps.check.outputs.bad == 'true'
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const pr = context.payload.pull_request;
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pr.number,
|
||||
body: `检测到你勾选了“我没有认真阅读”,PR 已关闭。`
|
||||
});
|
||||
|
||||
await github.rest.pulls.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: pr.number,
|
||||
state: "closed"
|
||||
});
|
||||
@@ -50,7 +50,7 @@ jobs:
|
||||
echo "tag=$tag" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
uses: pnpm/action-setup@v4.4.0
|
||||
with:
|
||||
version: 10.28.2
|
||||
|
||||
@@ -71,7 +71,7 @@ jobs:
|
||||
zip -r "AstrBot-${{ steps.tag.outputs.tag }}-dashboard.zip" dist
|
||||
|
||||
- name: Upload dashboard artifact
|
||||
uses: actions/upload-artifact@v6
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: Dashboard-${{ steps.tag.outputs.tag }}
|
||||
if-no-files-found: error
|
||||
@@ -132,7 +132,7 @@ jobs:
|
||||
echo "tag=$tag" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Download dashboard artifact
|
||||
uses: actions/download-artifact@v7
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: Dashboard-${{ steps.tag.outputs.tag }}
|
||||
path: release-assets
|
||||
@@ -184,7 +184,8 @@ jobs:
|
||||
publish-pypi:
|
||||
name: Publish PyPI
|
||||
runs-on: ubuntu-24.04
|
||||
needs: publish-release
|
||||
needs:
|
||||
- publish-release
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
@@ -192,6 +193,36 @@ jobs:
|
||||
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: Download dashboard artifact
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: Dashboard-${{ steps.tag.outputs.tag }}
|
||||
path: dashboard-artifact
|
||||
|
||||
- name: Unpack dashboard dist into package tree
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p astrbot/dashboard/dist
|
||||
unzip -q "dashboard-artifact/AstrBot-${{ steps.tag.outputs.tag }}-dashboard.zip" -d dashboard-artifact/unpacked
|
||||
cp -r dashboard-artifact/unpacked/dist/. astrbot/dashboard/dist/
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
@@ -203,6 +234,8 @@ jobs:
|
||||
|
||||
- name: Build package
|
||||
shell: bash
|
||||
# Dashboard assets are already in astrbot/dashboard/dist/;
|
||||
# ASTRBOT_BUILD_DASHBOARD is intentionally unset so the hatch hook skips npm.
|
||||
run: uv build
|
||||
|
||||
- name: Publish to PyPI
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
name: sync wiki
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
paths:
|
||||
- '.github/workflows/sync-wiki.yml'
|
||||
- 'docs/scripts/sync_docs_to_wiki.py'
|
||||
- 'docs/tests/test_sync_docs_to_wiki.py'
|
||||
- 'docs/zh/**'
|
||||
- 'docs/en/**'
|
||||
|
||||
concurrency:
|
||||
group: sync-wiki-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Validate manual ref
|
||||
if: github.event_name == 'workflow_dispatch' && github.ref != 'refs/heads/master'
|
||||
run: |
|
||||
echo "This workflow only publishes from refs/heads/master. Re-run it from the master branch."
|
||||
exit 1
|
||||
|
||||
- name: Check out docs repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Run sync unit tests
|
||||
working-directory: docs
|
||||
run: python -m unittest discover -s tests -p 'test_sync_docs_to_wiki.py' -v
|
||||
|
||||
- name: Validate internal doc links
|
||||
run: python docs/scripts/sync_docs_to_wiki.py --source-root docs --check-links-only
|
||||
|
||||
- name: Clone AstrBot wiki
|
||||
env:
|
||||
WIKI_TOKEN: ${{ secrets.ASTRBOT_WIKI_TOKEN }}
|
||||
run: |
|
||||
test -n "$WIKI_TOKEN"
|
||||
git clone "https://x-access-token:${WIKI_TOKEN}@github.com/AstrBotDevs/AstrBot.wiki.git" wiki
|
||||
|
||||
- name: Generate wiki pages
|
||||
run: python docs/scripts/sync_docs_to_wiki.py --source-root docs --wiki-root wiki
|
||||
|
||||
- name: Commit and push wiki changes
|
||||
working-directory: wiki
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git add .
|
||||
if git diff --cached --quiet; then
|
||||
echo "No wiki changes to push"
|
||||
exit 0
|
||||
fi
|
||||
git commit -m "docs: sync wiki from AstrBot-1/docs"
|
||||
git push
|
||||
@@ -36,6 +36,9 @@ dashboard/dist/
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
|
||||
# Bundled dashboard dist (generated by hatch_build.py during pip wheel build)
|
||||
astrbot/dashboard/dist/
|
||||
|
||||
# Operating System
|
||||
**/.DS_Store
|
||||
.DS_Store
|
||||
@@ -58,3 +61,5 @@ GenieData/
|
||||
.codex/
|
||||
.opencode/
|
||||
.kilocode/
|
||||
.worktrees/
|
||||
|
||||
|
||||
@@ -73,57 +73,68 @@ AstrBot is an open-source all-in-one Agent chatbot platform that integrates with
|
||||
|
||||
### One-Click Deployment
|
||||
|
||||
For users who want to quickly experience AstrBot, we recommend using the one-click deployment method with `uv` ⚡️:
|
||||
For users who want to quickly experience AstrBot, are familiar with command-line usage, and can install a `uv` environment on their own, we recommend the `uv` one-click deployment method ⚡️:
|
||||
|
||||
```bash
|
||||
uv tool install astrbot
|
||||
astrbot init # Only execute this command for the first time to initialize the environment
|
||||
astrbot
|
||||
astrbot run
|
||||
```
|
||||
|
||||
> Requires [uv](https://docs.astral.sh/uv/) to be installed.
|
||||
|
||||
> [!NOTE]
|
||||
> For macOS user: due to macOS security checks, the first run of the `astrbot` command may take longer (about 10-20s).
|
||||
|
||||
Update `astrbot`:
|
||||
|
||||
```bash
|
||||
uv tool upgrade astrbot
|
||||
```
|
||||
|
||||
### Docker Deployment
|
||||
|
||||
For users who want a more stable and production-ready deployment, we recommend using Docker / Docker Compose to deploy AstrBot.
|
||||
For users familiar with containers and looking for a more stable, production-ready deployment method, we recommend deploying AstrBot with Docker / Docker Compose.
|
||||
|
||||
Please refer to the official documentation: [Deploy AstrBot with Docker](https://astrbot.app/deploy/astrbot/docker.html#%E4%BD%BF%E7%94%A8-docker-%E9%83%A8%E7%BD%B2-astrbot).
|
||||
|
||||
### Deploy on RainYun
|
||||
|
||||
For users who want to deploy AstrBot with one-click and don't want to manage the server, we recommend using RainYun's one-click cloud deployment service ☁️:
|
||||
For users who want one-click deployment and do not want to manage servers themselves, we recommend RainYun's one-click cloud deployment service ☁️:
|
||||
|
||||
[](https://app.rainyun.com/apps/rca/store/5994?ref=NjU1ODg0)
|
||||
|
||||
### Desktop Application (Tauri)
|
||||
### Desktop Application Deployment
|
||||
|
||||
For users who want to deploy AstrBot on their desktop, primarily using AstrBot ChatUI, rarely use AstrBot plugins, we recommend using the AstrBot App:
|
||||
For users who want to use AstrBot on desktop and mainly use ChatUI, we recommend AstrBot App.
|
||||
|
||||
Desktop repository: [AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop).
|
||||
Visit [AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop) to download and install; this method is designed for desktop usage and is not recommended for server scenarios.
|
||||
|
||||
Supports multiple system architectures, direct package installation, and out-of-the-box usage. A convenient one-click desktop deployment option for beginners.
|
||||
### Launcher Deployment
|
||||
|
||||
### One-Click Launcher Deployment (AstrBot Launcher)
|
||||
For desktop users who also want fast deployment and isolated multi-instance usage, we recommend AstrBot Launcher.
|
||||
|
||||
For users who want a quick deployment and multi-instance solution with environment isolation, we recommend using the AstrBot Launcher:
|
||||
|
||||
Visit the [AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher) repository and install the package for your OS from the latest release.
|
||||
|
||||
A quick deployment and multi-instance solution with environment isolation.
|
||||
Visit [AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher) to download and install.
|
||||
|
||||
### Deploy on Replit
|
||||
|
||||
Community-contributed deployment method.
|
||||
Replit deployment is maintained by the community and is suitable for online demos and lightweight trials.
|
||||
|
||||
[](https://repl.it/github/AstrBotDevs/AstrBot)
|
||||
|
||||
### AUR
|
||||
|
||||
AUR deployment targets Arch Linux users who prefer installing AstrBot through the system package workflow.
|
||||
|
||||
Run the command below to install `astrbot-git`, then start AstrBot in your local environment.
|
||||
|
||||
```bash
|
||||
yay -S astrbot-git
|
||||
```
|
||||
|
||||
**More deployment methods**: [BT-Panel Deployment](https://astrbot.app/deploy/astrbot/btpanel.html) | [1Panel Deployment](https://astrbot.app/deploy/astrbot/1panel.html) | [CasaOS Deployment](https://astrbot.app/deploy/astrbot/casaos.html) | [Manual Deployment](https://astrbot.app/deploy/astrbot/cli.html)
|
||||
**More deployment methods**
|
||||
|
||||
If you need panel-based management or deeper customization, see [BT-Panel Deployment](https://astrbot.app/deploy/astrbot/btpanel.html) for BT Panel app-store setup, [1Panel Deployment](https://astrbot.app/deploy/astrbot/1panel.html) for 1Panel app-market deployment, [CasaOS Deployment](https://astrbot.app/deploy/astrbot/casaos.html) for NAS/home-server visual deployment, and [Manual Deployment](https://astrbot.app/deploy/astrbot/cli.html) for fully custom source-based installation with `uv`.
|
||||
|
||||
## Supported Messaging Platforms
|
||||
|
||||
@@ -184,6 +195,13 @@ Connect AstrBot to your favorite chat platform.
|
||||
| Minimax TTS | Text-to-Speech Services |
|
||||
| Volcano Engine TTS | Text-to-Speech Services |
|
||||
|
||||
## ❤️ Sponsors
|
||||
|
||||
<p align="center">
|
||||
<img alt="sponsors" src="https://sponsors.astrbot.app/?v=1">
|
||||
</p>
|
||||
|
||||
|
||||
## ❤️ Contributing
|
||||
|
||||
Issues and Pull Requests are always welcome! Feel free to submit your changes to this project :)
|
||||
@@ -202,17 +220,22 @@ pip install pre-commit
|
||||
pre-commit install
|
||||
```
|
||||
|
||||
|
||||
## 🌍 Community
|
||||
|
||||
### QQ Groups
|
||||
|
||||
- Group 9: 1076659624 (New)
|
||||
- Group 10: 1078079676 (New)
|
||||
- Group 1: 322154837
|
||||
- Group 3: 630166526
|
||||
- Group 5: 822130018
|
||||
- Group 6: 753075035
|
||||
- Group 7: 743746109
|
||||
- Group 8: 1030353265
|
||||
- Developer Group: 975206796
|
||||
|
||||
- Developer Group(Chit-chat): 975206796
|
||||
- Developer Group(Formal): 1039761811
|
||||
|
||||
### Discord Server
|
||||
|
||||
|
||||
+29
-17
@@ -73,57 +73,68 @@ AstrBot est une plateforme de chatbot Agent tout-en-un open source qui s'intègr
|
||||
|
||||
### Déploiement en un clic
|
||||
|
||||
Pour les utilisateurs qui souhaitent découvrir AstrBot rapidement, nous recommandons la méthode de déploiement en un clic avec `uv` ⚡️ :
|
||||
Pour les utilisateurs qui veulent découvrir AstrBot rapidement, qui sont familiers avec la ligne de commande et peuvent installer eux-mêmes l'environnement `uv`, nous recommandons la méthode de déploiement en un clic avec `uv` ⚡️ :
|
||||
|
||||
```bash
|
||||
uv tool install astrbot
|
||||
astrbot init # Exécutez cette commande uniquement la première fois pour initialiser l'environnement
|
||||
astrbot
|
||||
astrbot run
|
||||
```
|
||||
|
||||
> [uv](https://docs.astral.sh/uv/) doit être installé.
|
||||
|
||||
> [!NOTE]
|
||||
> Pour les utilisateurs macOS : en raison des vérifications de sécurité de macOS, la première exécution de la commande `astrbot` peut prendre plus de temps (environ 10-20s).
|
||||
|
||||
Mettre à jour `astrbot` :
|
||||
|
||||
```bash
|
||||
uv tool upgrade astrbot
|
||||
```
|
||||
|
||||
### Déploiement Docker
|
||||
|
||||
Pour les utilisateurs qui veulent un déploiement plus stable et prêt pour la production, nous recommandons d'utiliser Docker / Docker Compose pour déployer AstrBot.
|
||||
Pour les utilisateurs familiers avec les conteneurs et qui souhaitent une méthode plus stable et adaptée à la production, nous recommandons de déployer AstrBot avec Docker / Docker Compose.
|
||||
|
||||
Veuillez consulter la documentation officielle : [Déployer AstrBot avec Docker](https://astrbot.app/deploy/astrbot/docker.html#%E4%BD%BF%E7%94%A8-docker-%E9%83%A8%E7%BD%B2-astrbot).
|
||||
Veuillez consulter la documentation officielle [Déployer AstrBot avec Docker](https://astrbot.app/deploy/astrbot/docker.html#%E4%BD%BF%E7%94%A8-docker-%E9%83%A8%E7%BD%B2-astrbot).
|
||||
|
||||
### Déployer sur RainYun
|
||||
|
||||
Pour les utilisateurs qui souhaitent déployer AstrBot en un clic sans gérer le serveur, nous recommandons le service de déploiement cloud en un clic de RainYun ☁️ :
|
||||
Pour les utilisateurs qui souhaitent déployer AstrBot en un clic sans gérer le serveur eux-mêmes, nous recommandons le service de déploiement cloud en un clic de RainYun ☁️ :
|
||||
|
||||
[](https://app.rainyun.com/apps/rca/store/5994?ref=NjU1ODg0)
|
||||
|
||||
### Application de bureau (Tauri)
|
||||
### Déploiement de l'application de bureau
|
||||
|
||||
Pour les utilisateurs qui veulent déployer AstrBot sur desktop, utilisent principalement AstrBot ChatUI et utilisent rarement les plugins AstrBot, nous recommandons AstrBot App :
|
||||
Pour les utilisateurs qui veulent utiliser AstrBot sur desktop et passer principalement par ChatUI, nous recommandons AstrBot App.
|
||||
|
||||
Dépôt de l'application de bureau : [AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop).
|
||||
Accédez à [AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop) pour télécharger et installer l'application ; cette méthode est conçue pour un usage desktop et n'est pas recommandée pour les scénarios serveur.
|
||||
|
||||
Prend en charge plusieurs architectures système, installation directe, prête à l'emploi. Solution de déploiement bureau en un clic, particulièrement adaptée aux débutants. Non recommandée pour les serveurs.
|
||||
### Déploiement avec le lanceur
|
||||
|
||||
### Déploiement en un clic avec le lanceur (AstrBot Launcher)
|
||||
Également sur desktop, pour les utilisateurs qui souhaitent un déploiement rapide avec isolation d'environnement et multi-instances, nous recommandons AstrBot Launcher.
|
||||
|
||||
Pour les utilisateurs qui veulent une solution de déploiement rapide et multi-instances avec isolation d'environnement, nous recommandons d'utiliser AstrBot Launcher :
|
||||
|
||||
Accédez au dépôt [AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher) et installez le package correspondant à votre système depuis la dernière release.
|
||||
|
||||
Une solution de déploiement rapide et multi-instances avec isolation d'environnement.
|
||||
Accédez à [AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher) pour télécharger et installer.
|
||||
|
||||
### Déployer sur Replit
|
||||
|
||||
Méthode de déploiement contribuée par la communauté.
|
||||
Le déploiement sur Replit est maintenu par la communauté et convient aux démonstrations en ligne et aux essais légers.
|
||||
|
||||
[](https://repl.it/github/AstrBotDevs/AstrBot)
|
||||
|
||||
### AUR
|
||||
|
||||
Le mode AUR s'adresse aux utilisateurs Arch Linux qui préfèrent installer AstrBot via le gestionnaire de paquets système.
|
||||
|
||||
Exécutez la commande ci-dessous pour installer `astrbot-git`, puis lancez AstrBot localement.
|
||||
|
||||
```bash
|
||||
yay -S astrbot-git
|
||||
```
|
||||
|
||||
**Autres méthodes de déploiement** : [Déploiement BT-Panel](https://astrbot.app/deploy/astrbot/btpanel.html) | [Déploiement 1Panel](https://astrbot.app/deploy/astrbot/1panel.html) | [Déploiement CasaOS](https://astrbot.app/deploy/astrbot/casaos.html) | [Déploiement manuel](https://astrbot.app/deploy/astrbot/cli.html)
|
||||
**Autres méthodes de déploiement**
|
||||
|
||||
Si vous avez besoin d'une gestion par panneau ou d'une personnalisation plus poussée, consultez [Déploiement BT-Panel](https://astrbot.app/deploy/astrbot/btpanel.html) pour une installation via BT Panel, [Déploiement 1Panel](https://astrbot.app/deploy/astrbot/1panel.html) pour le marketplace 1Panel, [Déploiement CasaOS](https://astrbot.app/deploy/astrbot/casaos.html) pour un déploiement visuel sur NAS/serveur domestique, et [Déploiement manuel](https://astrbot.app/deploy/astrbot/cli.html) pour une installation complète depuis les sources avec `uv`.
|
||||
|
||||
## Plateformes de messagerie prises en charge
|
||||
|
||||
@@ -211,6 +222,7 @@ pre-commit install
|
||||
- Groupe 5 : 822130018
|
||||
- Groupe 6 : 753075035
|
||||
- Groupe développeurs : 975206796
|
||||
- Groupe développeurs (officiel) : 1039761811
|
||||
|
||||
### Serveur Discord
|
||||
|
||||
|
||||
+28
-16
@@ -73,57 +73,68 @@ AstrBot は、主要なインスタントメッセージングアプリと統合
|
||||
|
||||
### ワンクリックデプロイ
|
||||
|
||||
AstrBot を素早く試したいユーザーには、`uv` を使ったワンクリックデプロイをおすすめします ⚡️:
|
||||
AstrBot を素早く試したいユーザーで、コマンドラインに慣れており `uv` 環境を自分でインストールできる場合は、`uv` のワンクリックデプロイをおすすめします ⚡️:
|
||||
|
||||
```bash
|
||||
uv tool install astrbot
|
||||
astrbot init # 初回のみ実行して環境を初期化します
|
||||
astrbot
|
||||
astrbot run
|
||||
```
|
||||
|
||||
> [uv](https://docs.astral.sh/uv/) のインストールが必要です。
|
||||
|
||||
> [!NOTE]
|
||||
> macOS ユーザーの場合:macOS のセキュリティチェックにより、`astrbot` コマンドの初回実行に時間がかかる場合があります(約 10〜20 秒)。
|
||||
|
||||
`astrbot` の更新:
|
||||
|
||||
```bash
|
||||
uv tool upgrade astrbot
|
||||
```
|
||||
|
||||
### Docker デプロイ
|
||||
|
||||
より安定した本番向けのデプロイを求めるユーザーには、Docker / Docker Compose で AstrBot をデプロイすることをおすすめします。
|
||||
コンテナ運用に慣れており、より安定した本番向けのデプロイ方法を求めるユーザーには、Docker / Docker Compose での AstrBot デプロイをおすすめします。
|
||||
|
||||
公式ドキュメント [Docker を使用した AstrBot のデプロイ](https://astrbot.app/deploy/astrbot/docker.html#%E4%BD%BF%E7%94%A8-docker-%E9%83%A8%E7%BD%B2-astrbot) をご参照ください。
|
||||
|
||||
### 雨云でのデプロイ
|
||||
|
||||
サーバー管理をせずに AstrBot をワンクリックでデプロイしたいユーザーには、雨云のワンクリッククラウドデプロイサービスをおすすめします ☁️:
|
||||
AstrBot をワンクリックでデプロイしたく、サーバーを自分で管理したくないユーザーには、雨云のワンクリッククラウドデプロイサービスをおすすめします ☁️:
|
||||
|
||||
[](https://app.rainyun.com/apps/rca/store/5994?ref=NjU1ODg0)
|
||||
|
||||
### デスクトップクライアント(Tauri)
|
||||
### デスクトップアプリのデプロイ
|
||||
|
||||
デスクトップで AstrBot を使いたいユーザーで、主に AstrBot ChatUI を利用し、AstrBot プラグインの利用頻度が低い場合は、AstrBot App の利用をおすすめします:
|
||||
デスクトップで AstrBot を使い、主に ChatUI を入口として利用するユーザーには、AstrBot App をおすすめします。
|
||||
|
||||
デスクトップアプリのリポジトリ [AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop)。
|
||||
[AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop) からダウンロードしてインストールしてください。この方式はデスクトップ向けであり、サーバー用途には推奨されません。
|
||||
|
||||
マルチシステムアーキテクチャに対応し、インストーラーですぐ利用可能。初心者にも使いやすいワンクリックのデスクトップデプロイ方式です。サーバー用途には推奨されません。
|
||||
### ランチャーのデプロイ
|
||||
|
||||
### ランチャーによるワンクリックデプロイ(AstrBot Launcher)
|
||||
同じくデスクトップで、素早くデプロイしつつ環境を分離して多重起動したいユーザーには、AstrBot Launcher をおすすめします。
|
||||
|
||||
高速デプロイと環境分離されたマルチインスタンス運用を求めるユーザーには、AstrBot Launcher の利用をおすすめします:
|
||||
|
||||
[AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher) リポジトリにアクセスし、最新リリースからお使いの OS 向けパッケージをインストールしてください。
|
||||
|
||||
高速デプロイと環境分離されたマルチインスタンス運用を実現できます。
|
||||
[AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher) からダウンロードしてインストールしてください。
|
||||
|
||||
### Replit でのデプロイ
|
||||
|
||||
コミュニティ貢献によるデプロイ方法。
|
||||
Replit デプロイはコミュニティ提供の方式で、オンラインデモや軽量な試用に向いています。
|
||||
|
||||
[](https://repl.it/github/AstrBotDevs/AstrBot)
|
||||
|
||||
### AUR
|
||||
|
||||
AUR 方式は Arch Linux ユーザー向けで、システムのパッケージ運用に合わせて AstrBot を導入したい場合に適しています。
|
||||
|
||||
次のコマンドで `astrbot-git` をインストールし、ローカル環境で AstrBot を起動してください。
|
||||
|
||||
```bash
|
||||
yay -S astrbot-git
|
||||
```
|
||||
|
||||
**その他のデプロイ方法**:[宝塔パネルデプロイ](https://astrbot.app/deploy/astrbot/btpanel.html) | [1Panel デプロイ](https://astrbot.app/deploy/astrbot/1panel.html) | [CasaOS デプロイ](https://astrbot.app/deploy/astrbot/casaos.html) | [手動デプロイ](https://astrbot.app/deploy/astrbot/cli.html)
|
||||
**その他のデプロイ方法**
|
||||
|
||||
パネル操作での導入やより高度なカスタマイズが必要な場合は、[宝塔パネルデプロイ](https://astrbot.app/deploy/astrbot/btpanel.html)(BT Panel 経由の導入)、[1Panel デプロイ](https://astrbot.app/deploy/astrbot/1panel.html)(1Panel アプリマーケット経由)、[CasaOS デプロイ](https://astrbot.app/deploy/astrbot/casaos.html)(NAS / ホームサーバー向け可視化導入)、[手動デプロイ](https://astrbot.app/deploy/astrbot/cli.html)(`uv` とソースベースのフルカスタム導入)を参照してください。
|
||||
|
||||
## サポートされているメッセージプラットフォーム
|
||||
|
||||
@@ -212,6 +223,7 @@ pre-commit install
|
||||
- 5群: 822130018
|
||||
- 6群: 753075035
|
||||
- 開発者群: 975206796
|
||||
- 開発者群(正式): 1039761811
|
||||
|
||||
### Discord サーバー
|
||||
|
||||
|
||||
+29
-17
@@ -73,57 +73,68 @@ AstrBot — это универсальная платформа Agent-чатб
|
||||
|
||||
### Развёртывание в один клик
|
||||
|
||||
Для пользователей, которые хотят быстро попробовать AstrBot, мы рекомендуем использовать развёртывание в один клик через `uv` ⚡️:
|
||||
Для пользователей, которые хотят быстро попробовать AstrBot, знакомы с командной строкой и могут самостоятельно установить окружение `uv`, мы рекомендуем использовать развёртывание в один клик через `uv` ⚡️:
|
||||
|
||||
```bash
|
||||
uv tool install astrbot
|
||||
astrbot init # Выполните эту команду только при первом запуске для инициализации окружения
|
||||
astrbot
|
||||
astrbot run
|
||||
```
|
||||
|
||||
> Требуется установленный [uv](https://docs.astral.sh/uv/).
|
||||
|
||||
> [!NOTE]
|
||||
> Для пользователей macOS: из-за проверок безопасности macOS первый запуск команды `astrbot` может занять больше времени (около 10-20 секунд).
|
||||
|
||||
Обновить `astrbot`:
|
||||
|
||||
```bash
|
||||
uv tool upgrade astrbot
|
||||
```
|
||||
|
||||
### Развёртывание Docker
|
||||
|
||||
Для пользователей, которым нужен более стабильный и готовый к production вариант, мы рекомендуем развёртывать AstrBot через Docker / Docker Compose.
|
||||
Для пользователей, знакомых с контейнерами и которым нужен более стабильный и подходящий для production способ, мы рекомендуем разворачивать AstrBot через Docker / Docker Compose.
|
||||
|
||||
См. официальную документацию: [Развёртывание AstrBot с Docker](https://astrbot.app/deploy/astrbot/docker.html#%E4%BD%BF%E7%94%A8-docker-%E9%83%A8%E7%BD%B2-astrbot).
|
||||
См. официальную документацию [Развёртывание AstrBot с Docker](https://astrbot.app/deploy/astrbot/docker.html#%E4%BD%BF%E7%94%A8-docker-%E9%83%A8%E7%BD%B2-astrbot).
|
||||
|
||||
### Развёртывание на RainYun
|
||||
|
||||
Для пользователей, которые хотят развернуть AstrBot в один клик и не управлять сервером самостоятельно, мы рекомендуем облачный сервис развёртывания в один клик от RainYun ☁️:
|
||||
Для пользователей, которые хотят развернуть AstrBot в один клик и не хотят самостоятельно управлять сервером, мы рекомендуем облачный сервис развёртывания в один клик от RainYun ☁️:
|
||||
|
||||
[](https://app.rainyun.com/apps/rca/store/5994?ref=NjU1ODg0)
|
||||
|
||||
### Десктопное приложение (Tauri)
|
||||
### Развёртывание десктопного приложения
|
||||
|
||||
Для пользователей, которые хотят использовать AstrBot на десктопе, в основном работают с AstrBot ChatUI и редко используют плагины AstrBot, мы рекомендуем AstrBot App:
|
||||
Для пользователей, которые хотят использовать AstrBot на десктопе и в основном работают через ChatUI, мы рекомендуем AstrBot App.
|
||||
|
||||
Репозиторий десктопного приложения: [AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop).
|
||||
Перейдите в [AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop), скачайте и установите приложение; этот вариант предназначен для десктопа и не рекомендуется для серверных сценариев.
|
||||
|
||||
Поддерживает разные архитектуры систем, устанавливается напрямую и работает сразу после установки. Удобное настольное развёртывание в один клик для новичков. Не рекомендуется для серверных сценариев.
|
||||
### Развёртывание через лаунчер
|
||||
|
||||
### Установка в один клик через лаунчер (AstrBot Launcher)
|
||||
Также на десктопе, для пользователей, которым нужен быстрый запуск и мультиинстанс с изоляцией окружений, мы рекомендуем AstrBot Launcher.
|
||||
|
||||
Для пользователей, которым нужно быстрое развёртывание и мультиинстанс с изоляцией окружений, мы рекомендуем использовать AstrBot Launcher:
|
||||
|
||||
Перейдите в репозиторий [AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher), откройте Releases и установите пакет для вашей системы из последней версии.
|
||||
|
||||
Быстрое развёртывание и мультиинстанс-решение с изоляцией окружений.
|
||||
Перейдите в [AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher), чтобы скачать и установить.
|
||||
|
||||
### Развёртывание на Replit
|
||||
|
||||
Метод развёртывания от сообщества.
|
||||
Развёртывание через Replit поддерживается сообществом и подходит для онлайн-демо и лёгких тестовых запусков.
|
||||
|
||||
[](https://repl.it/github/AstrBotDevs/AstrBot)
|
||||
|
||||
### AUR
|
||||
|
||||
AUR-вариант предназначен для пользователей Arch Linux, которым удобна установка через системный менеджер пакетов.
|
||||
|
||||
Выполните команду ниже для установки `astrbot-git`, затем запустите AstrBot локально.
|
||||
|
||||
```bash
|
||||
yay -S astrbot-git
|
||||
```
|
||||
|
||||
**Другие способы развёртывания**: [Развёртывание BT-Panel](https://astrbot.app/deploy/astrbot/btpanel.html) | [Развёртывание 1Panel](https://astrbot.app/deploy/astrbot/1panel.html) | [Развёртывание CasaOS](https://astrbot.app/deploy/astrbot/casaos.html) | [Ручное развёртывание](https://astrbot.app/deploy/astrbot/cli.html)
|
||||
**Другие способы развёртывания**
|
||||
|
||||
Если вам нужна панельная установка или более глубокая кастомизация, смотрите [Развёртывание BT-Panel](https://astrbot.app/deploy/astrbot/btpanel.html) (установка через BT Panel), [Развёртывание 1Panel](https://astrbot.app/deploy/astrbot/1panel.html) (развёртывание через маркетплейс 1Panel), [Развёртывание CasaOS](https://astrbot.app/deploy/astrbot/casaos.html) (визуальный вариант для NAS и домашних серверов) и [Ручное развёртывание](https://astrbot.app/deploy/astrbot/cli.html) (полностью настраиваемая установка из исходников через `uv`).
|
||||
|
||||
## Поддерживаемые платформы обмена сообщениями
|
||||
|
||||
@@ -211,6 +222,7 @@ pre-commit install
|
||||
- Группа 5: 822130018
|
||||
- Группа 6: 753075035
|
||||
- Группа разработчиков: 975206796
|
||||
- Группа разработчиков (официальная): 1039761811
|
||||
|
||||
### Сервер Discord
|
||||
|
||||
|
||||
+33
-17
@@ -73,21 +73,30 @@ AstrBot 是一個開源的一站式 Agent 聊天機器人平台,可接入主
|
||||
|
||||
### 一鍵部署
|
||||
|
||||
對於想快速體驗 AstrBot 的使用者,我們推薦使用 `uv` 一鍵部署方式 ⚡️:
|
||||
對於想快速體驗 AstrBot、且熟悉命令列並能自行安裝 `uv` 環境的使用者,我們推薦使用 `uv` 一鍵部署方式 ⚡️。
|
||||
|
||||
```bash
|
||||
uv tool install astrbot
|
||||
astrbot init # 僅首次執行此命令以初始化環境
|
||||
astrbot
|
||||
astrbot run
|
||||
```
|
||||
|
||||
> 需要安裝 [uv](https://docs.astral.sh/uv/)。
|
||||
|
||||
> [!NOTE]
|
||||
> 對於 macOS 使用者:由於 macOS 安全性檢查,首次執行 `astrbot` 指令可能需要較長時間(約 10-20 秒)。
|
||||
|
||||
更新 `astrbot`:
|
||||
|
||||
```bash
|
||||
uv tool upgrade astrbot
|
||||
```
|
||||
|
||||
### Docker 部署
|
||||
|
||||
對於希望獲得更穩定、更適合正式環境部署方式的使用者,我們推薦使用 Docker / Docker Compose 部署 AstrBot。
|
||||
對於熟悉容器、希望獲得更穩定且更適合正式環境部署方式的使用者,我們推薦使用 Docker / Docker Compose 部署 AstrBot。
|
||||
|
||||
請參閱官方文件 [使用 Docker 部署 AstrBot](https://astrbot.app/deploy/astrbot/docker.html#%E4%BD%BF%E7%94%A8-docker-%E9%83%A8%E7%BD%B2-astrbot)。
|
||||
請參考官方文件 [使用 Docker 部署 AstrBot](https://astrbot.app/deploy/astrbot/docker.html#%E4%BD%BF%E7%94%A8-docker-%E9%83%A8%E7%BD%B2-astrbot)。
|
||||
|
||||
### 在雨雲上部署
|
||||
|
||||
@@ -95,35 +104,37 @@ astrbot
|
||||
|
||||
[](https://app.rainyun.com/apps/rca/store/5994?ref=NjU1ODg0)
|
||||
|
||||
### 桌面客戶端(Tauri)
|
||||
### 桌面客戶端部署
|
||||
|
||||
對於希望在桌面部署 AstrBot、以 AstrBot ChatUI 為主要使用方式、較少使用 AstrBot 外掛的使用者,我們推薦使用 AstrBot App:
|
||||
對於希望在桌面端使用 AstrBot、並以 ChatUI 為主要入口的使用者,我們推薦使用 AstrBot App。
|
||||
|
||||
桌面應用倉庫 [AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop)。
|
||||
前往 [AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop) 下載並安裝;此方式面向桌面使用,不建議伺服器場景。
|
||||
|
||||
支援多系統架構,安裝包直接安裝,開箱即用,最適合新手和懶人的一鍵桌面部署方案,不推薦伺服器場景。
|
||||
### 啟動器部署
|
||||
|
||||
### 啟動器一鍵部署(AstrBot Launcher)
|
||||
同樣在桌面端,對於希望快速部署並實現環境隔離多開的使用者,我們推薦使用 AstrBot Launcher。
|
||||
|
||||
對於希望快速部署並實現環境隔離多開的使用者,我們推薦使用 AstrBot Launcher:
|
||||
|
||||
進入 [AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher) 倉庫,在 Releases 頁最新版本下找到對應的系統安裝包安裝即可。
|
||||
|
||||
一個快速部署和多開方案,實現環境隔離。
|
||||
前往 [AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher) 下載並安裝。
|
||||
|
||||
### 在 Replit 上部署
|
||||
|
||||
社群貢獻的部署方式。
|
||||
Replit 部署由社群維護,適合線上示範與輕量試用情境。
|
||||
|
||||
[](https://repl.it/github/AstrBotDevs/AstrBot)
|
||||
|
||||
### AUR
|
||||
|
||||
AUR 方式面向 Arch Linux 使用者,適合希望透過系統套件管理器安裝 AstrBot 的場景。
|
||||
|
||||
在終端執行下方命令安裝 `astrbot-git` 套件,安裝完成後即可啟動使用。
|
||||
|
||||
```bash
|
||||
yay -S astrbot-git
|
||||
```
|
||||
|
||||
**更多部署方式**:[寶塔面板](https://astrbot.app/deploy/astrbot/btpanel.html) | [1Panel](https://astrbot.app/deploy/astrbot/1panel.html) | [CasaOS](https://astrbot.app/deploy/astrbot/casaos.html) | [手動部署](https://astrbot.app/deploy/astrbot/cli.html)
|
||||
**更多部署方式**
|
||||
|
||||
若你需要面板化或更高自訂程度的部署,可參考 [寶塔面板](https://astrbot.app/deploy/astrbot/btpanel.html)(BT Panel 應用商店安裝)、[1Panel](https://astrbot.app/deploy/astrbot/1panel.html)(1Panel 應用商店安裝)、[CasaOS](https://astrbot.app/deploy/astrbot/casaos.html)(NAS / 家用伺服器可視化部署)與 [手動部署](https://astrbot.app/deploy/astrbot/cli.html)(基於原始碼與 `uv` 的完整自訂安裝)。
|
||||
|
||||
## 支援的訊息平台
|
||||
|
||||
@@ -206,11 +217,16 @@ pre-commit install
|
||||
|
||||
### QQ 群組
|
||||
|
||||
- 9 群: 1076659624 (新)
|
||||
- 10 群: 1078079676 (新)
|
||||
- 1 群:322154837
|
||||
- 3 群:630166526
|
||||
- 5 群:822130018
|
||||
- 6 群:753075035
|
||||
- 開發者群:975206796
|
||||
- 7 群:743746109
|
||||
- 8 群:1030353265
|
||||
- 開發者群(闲聊吹水):975206796
|
||||
- 開發者群(正式):1039761811
|
||||
|
||||
### Discord 群組
|
||||
|
||||
|
||||
+31
-17
@@ -73,21 +73,30 @@ AstrBot 是一个开源的一站式 Agentic 个人和群聊助手,可在 QQ、
|
||||
|
||||
### 一键部署
|
||||
|
||||
对于想快速体验 AstrBot 的用户,我们推荐使用 `uv` 一键部署方式 ⚡️:
|
||||
对于想快速体验 AstrBot、且熟悉命令行并能够自行安装 `uv` 环境的用户,我们推荐使用 `uv` 一键部署方式 ⚡️。
|
||||
|
||||
```bash
|
||||
uv tool install astrbot
|
||||
astrbot init # 仅首次执行此命令以初始化环境
|
||||
astrbot
|
||||
astrbot run
|
||||
```
|
||||
|
||||
> 需要安装 [uv](https://docs.astral.sh/uv/)。
|
||||
|
||||
> [!NOTE]
|
||||
> 对于 macOS 用户:由于 macOS 安全检查,首次运行 `astrbot` 命令可能需要较长时间(约 10-20 秒)。
|
||||
|
||||
更新 `astrbot`:
|
||||
|
||||
```bash
|
||||
uv tool upgrade astrbot
|
||||
```
|
||||
|
||||
### Docker 部署
|
||||
|
||||
对于希望获得更稳定、更适合生产环境部署方式的用户,我们推荐使用 Docker / Docker Compose 部署 AstrBot。
|
||||
对于熟悉容器、希望获得更稳定且更适合生产环境部署方式的用户,我们推荐使用 Docker / Docker Compose 部署 AstrBot。
|
||||
|
||||
请参阅官方文档 [使用 Docker 部署 AstrBot](https://astrbot.app/deploy/astrbot/docker.html#%E4%BD%BF%E7%94%A8-docker-%E9%83%A8%E7%BD%B2-astrbot) 。
|
||||
请参考官方文档 [使用 Docker 部署 AstrBot](https://astrbot.app/deploy/astrbot/docker.html#%E4%BD%BF%E7%94%A8-docker-%E9%83%A8%E7%BD%B2-astrbot)。
|
||||
|
||||
### 在 雨云 上部署
|
||||
|
||||
@@ -95,35 +104,37 @@ astrbot
|
||||
|
||||
[](https://app.rainyun.com/apps/rca/store/5994?ref=NjU1ODg0)
|
||||
|
||||
### 桌面客户端(Tauri)
|
||||
### 桌面客户端部署
|
||||
|
||||
对于希望在桌面部署 AstrBot、以 AstrBot ChatUI 为主要使用方式、较少使用 AstrBot 插件的用户,我们推荐使用 AstrBot App:
|
||||
对于希望在桌面端使用 AstrBot、并以 ChatUI 为主要入口的用户,我们推荐使用 AstrBot App。
|
||||
|
||||
桌面应用仓库 [AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop)。
|
||||
前往 [AstrBot-desktop](https://github.com/AstrBotDevs/AstrBot-desktop) 下载并安装;该方式面向桌面使用,不推荐服务器场景。
|
||||
|
||||
支持多系统架构,安装包直接安装,开箱即用,最适合新手和懒人的一键桌面部署方案,不推荐服务器场景。
|
||||
### 启动器部署
|
||||
|
||||
### 启动器一键部署(AstrBot Launcher)
|
||||
同样在桌面端,希望快速部署并实现环境隔离多开的用户,我们推荐使用 AstrBot Launcher。
|
||||
|
||||
对于希望快速部署并实现环境隔离多开的用户,我们推荐使用 AstrBot Launcher:
|
||||
|
||||
进入 [AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher) 仓库,在 Releases 页最新版本下找到对应的系统安装包安装即可。
|
||||
|
||||
一个快速部署和多开方案,实现环境隔离。
|
||||
前往 [AstrBot Launcher](https://github.com/Raven95676/astrbot-launcher) 下载并安装。
|
||||
|
||||
### 在 Replit 上部署
|
||||
|
||||
社区贡献的部署方式。
|
||||
Replit 部署由社区维护,适合在线演示和轻量试用场景。
|
||||
|
||||
[](https://repl.it/github/AstrBotDevs/AstrBot)
|
||||
|
||||
### AUR
|
||||
|
||||
AUR 方式面向 Arch Linux 用户,适合希望通过系统包管理器安装 AstrBot 的场景。
|
||||
|
||||
在终端执行下方命令安装 `astrbot-git` 包,安装完成后即可启动使用。
|
||||
|
||||
```bash
|
||||
yay -S astrbot-git
|
||||
```
|
||||
|
||||
**更多部署方式**:[宝塔面板](https://astrbot.app/deploy/astrbot/btpanel.html) | [1Panel](https://astrbot.app/deploy/astrbot/1panel.html) | [CasaOS](https://astrbot.app/deploy/astrbot/casaos.html) | [手动部署](https://astrbot.app/deploy/astrbot/cli.html)
|
||||
**更多部署方式**
|
||||
|
||||
若你需要面板化或更高自定义部署,可参考 [宝塔面板](https://astrbot.app/deploy/astrbot/btpanel.html)(BT Panel 应用商店安装)、[1Panel](https://astrbot.app/deploy/astrbot/1panel.html)(1Panel 应用商店安装)、[CasaOS](https://astrbot.app/deploy/astrbot/casaos.html)(NAS / 家庭服务器可视化部署)和 [手动部署](https://astrbot.app/deploy/astrbot/cli.html)(基于源码与 `uv` 的完整自定义安装)。
|
||||
|
||||
## 支持的消息平台
|
||||
|
||||
@@ -207,13 +218,16 @@ pre-commit install
|
||||
|
||||
### QQ 群组
|
||||
|
||||
- 9 群: 1076659624 (新)
|
||||
- 10 群: 1078079676 (新)
|
||||
- 1 群:322154837
|
||||
- 3 群:630166526
|
||||
- 5 群:822130018
|
||||
- 6 群:753075035
|
||||
- 7 群:743746109
|
||||
- 8 群:1030353265
|
||||
- 开发者群:975206796
|
||||
- 开发者群(偏闲聊吹水):975206796
|
||||
- 开发者群(正式):1039761811
|
||||
|
||||
### Discord 频道
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "4.18.3"
|
||||
__version__ = "4.20.0"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""AstrBot CLI入口"""
|
||||
"""AstrBot CLI entry point"""
|
||||
|
||||
import sys
|
||||
|
||||
@@ -29,23 +29,23 @@ def cli() -> None:
|
||||
@click.command()
|
||||
@click.argument("command_name", required=False, type=str)
|
||||
def help(command_name: str | None) -> None:
|
||||
"""显示命令的帮助信息
|
||||
"""Display help information for commands
|
||||
|
||||
如果提供了 COMMAND_NAME,则显示该命令的详细帮助信息。
|
||||
否则,显示通用帮助信息。
|
||||
If COMMAND_NAME is provided, display detailed help for that command.
|
||||
Otherwise, display general help information.
|
||||
"""
|
||||
ctx = click.get_current_context()
|
||||
if command_name:
|
||||
# 查找指定命令
|
||||
# Find the specified command
|
||||
command = cli.get_command(ctx, command_name)
|
||||
if command:
|
||||
# 显示特定命令的帮助信息
|
||||
# Display help for the specific command
|
||||
click.echo(command.get_help(ctx))
|
||||
else:
|
||||
click.echo(f"Unknown command: {command_name}")
|
||||
sys.exit(1)
|
||||
else:
|
||||
# 显示通用帮助信息
|
||||
# Display general help information
|
||||
click.echo(cli.get_help(ctx))
|
||||
|
||||
|
||||
|
||||
@@ -10,57 +10,61 @@ from ..utils import check_astrbot_root, get_astrbot_root
|
||||
|
||||
|
||||
def _validate_log_level(value: str) -> str:
|
||||
"""验证日志级别"""
|
||||
"""Validate log level"""
|
||||
value = value.upper()
|
||||
if value not in ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]:
|
||||
raise click.ClickException(
|
||||
"日志级别必须是 DEBUG/INFO/WARNING/ERROR/CRITICAL 之一",
|
||||
"Log level must be one of DEBUG/INFO/WARNING/ERROR/CRITICAL",
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def _validate_dashboard_port(value: str) -> int:
|
||||
"""验证 Dashboard 端口"""
|
||||
"""Validate Dashboard port"""
|
||||
try:
|
||||
port = int(value)
|
||||
if port < 1 or port > 65535:
|
||||
raise click.ClickException("端口必须在 1-65535 范围内")
|
||||
raise click.ClickException("Port must be in range 1-65535")
|
||||
return port
|
||||
except ValueError:
|
||||
raise click.ClickException("端口必须是数字")
|
||||
raise click.ClickException("Port must be a number")
|
||||
|
||||
|
||||
def _validate_dashboard_username(value: str) -> str:
|
||||
"""验证 Dashboard 用户名"""
|
||||
"""Validate Dashboard username"""
|
||||
if not value:
|
||||
raise click.ClickException("用户名不能为空")
|
||||
raise click.ClickException("Username cannot be empty")
|
||||
return value
|
||||
|
||||
|
||||
def _validate_dashboard_password(value: str) -> str:
|
||||
"""验证 Dashboard 密码"""
|
||||
"""Validate Dashboard password"""
|
||||
if not value:
|
||||
raise click.ClickException("密码不能为空")
|
||||
raise click.ClickException("Password cannot be empty")
|
||||
return hashlib.md5(value.encode()).hexdigest()
|
||||
|
||||
|
||||
def _validate_timezone(value: str) -> str:
|
||||
"""验证时区"""
|
||||
"""Validate timezone"""
|
||||
try:
|
||||
zoneinfo.ZoneInfo(value)
|
||||
except Exception:
|
||||
raise click.ClickException(f"无效的时区: {value},请使用有效的IANA时区名称")
|
||||
raise click.ClickException(
|
||||
f"Invalid timezone: {value}. Please use a valid IANA timezone name"
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def _validate_callback_api_base(value: str) -> str:
|
||||
"""验证回调接口基址"""
|
||||
"""Validate callback API base URL"""
|
||||
if not value.startswith("http://") and not value.startswith("https://"):
|
||||
raise click.ClickException("回调接口基址必须以 http:// 或 https:// 开头")
|
||||
raise click.ClickException(
|
||||
"Callback API base must start with http:// or https://"
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
# 可通过CLI设置的配置项,配置键到验证器函数的映射
|
||||
# Configuration items settable via CLI, mapping config keys to validator functions
|
||||
CONFIG_VALIDATORS: dict[str, Callable[[str], Any]] = {
|
||||
"timezone": _validate_timezone,
|
||||
"log_level": _validate_log_level,
|
||||
@@ -72,11 +76,11 @@ CONFIG_VALIDATORS: dict[str, Callable[[str], Any]] = {
|
||||
|
||||
|
||||
def _load_config() -> dict[str, Any]:
|
||||
"""加载或初始化配置文件"""
|
||||
"""Load or initialize config file"""
|
||||
root = get_astrbot_root()
|
||||
if not check_astrbot_root(root):
|
||||
raise click.ClickException(
|
||||
f"{root}不是有效的 AstrBot 根目录,如需初始化请使用 astrbot init",
|
||||
f"{root} is not a valid AstrBot root directory. Use 'astrbot init' to initialize",
|
||||
)
|
||||
|
||||
config_path = root / "data" / "cmd_config.json"
|
||||
@@ -91,11 +95,11 @@ def _load_config() -> dict[str, Any]:
|
||||
try:
|
||||
return json.loads(config_path.read_text(encoding="utf-8-sig"))
|
||||
except json.JSONDecodeError as e:
|
||||
raise click.ClickException(f"配置文件解析失败: {e!s}")
|
||||
raise click.ClickException(f"Failed to parse config file: {e!s}")
|
||||
|
||||
|
||||
def _save_config(config: dict[str, Any]) -> None:
|
||||
"""保存配置文件"""
|
||||
"""Save config file"""
|
||||
config_path = get_astrbot_root() / "data" / "cmd_config.json"
|
||||
|
||||
config_path.write_text(
|
||||
@@ -105,21 +109,21 @@ def _save_config(config: dict[str, Any]) -> None:
|
||||
|
||||
|
||||
def _set_nested_item(obj: dict[str, Any], path: str, value: Any) -> None:
|
||||
"""设置嵌套字典中的值"""
|
||||
"""Set a value in a nested dictionary"""
|
||||
parts = path.split(".")
|
||||
for part in parts[:-1]:
|
||||
if part not in obj:
|
||||
obj[part] = {}
|
||||
elif not isinstance(obj[part], dict):
|
||||
raise click.ClickException(
|
||||
f"配置路径冲突: {'.'.join(parts[: parts.index(part) + 1])} 不是字典",
|
||||
f"Config path conflict: {'.'.join(parts[: parts.index(part) + 1])} is not a dict",
|
||||
)
|
||||
obj = obj[part]
|
||||
obj[parts[-1]] = value
|
||||
|
||||
|
||||
def _get_nested_item(obj: dict[str, Any], path: str) -> Any:
|
||||
"""获取嵌套字典中的值"""
|
||||
"""Get a value from a nested dictionary"""
|
||||
parts = path.split(".")
|
||||
for part in parts:
|
||||
obj = obj[part]
|
||||
@@ -128,21 +132,21 @@ def _get_nested_item(obj: dict[str, Any], path: str) -> Any:
|
||||
|
||||
@click.group(name="conf")
|
||||
def conf() -> None:
|
||||
"""配置管理命令
|
||||
"""Configuration management commands
|
||||
|
||||
支持的配置项:
|
||||
Supported config keys:
|
||||
|
||||
- timezone: 时区设置 (例如: Asia/Shanghai)
|
||||
- timezone: Timezone setting (e.g. Asia/Shanghai)
|
||||
|
||||
- log_level: 日志级别 (DEBUG/INFO/WARNING/ERROR/CRITICAL)
|
||||
- log_level: Log level (DEBUG/INFO/WARNING/ERROR/CRITICAL)
|
||||
|
||||
- dashboard.port: Dashboard 端口
|
||||
- dashboard.port: Dashboard port
|
||||
|
||||
- dashboard.username: Dashboard 用户名
|
||||
- dashboard.username: Dashboard username
|
||||
|
||||
- dashboard.password: Dashboard 密码
|
||||
- dashboard.password: Dashboard password
|
||||
|
||||
- callback_api_base: 回调接口基址
|
||||
- callback_api_base: Callback API base URL
|
||||
"""
|
||||
|
||||
|
||||
@@ -150,9 +154,9 @@ def conf() -> None:
|
||||
@click.argument("key")
|
||||
@click.argument("value")
|
||||
def set_config(key: str, value: str) -> None:
|
||||
"""设置配置项的值"""
|
||||
"""Set the value of a config item"""
|
||||
if key not in CONFIG_VALIDATORS:
|
||||
raise click.ClickException(f"不支持的配置项: {key}")
|
||||
raise click.ClickException(f"Unsupported config key: {key}")
|
||||
|
||||
config = _load_config()
|
||||
|
||||
@@ -162,29 +166,29 @@ def set_config(key: str, value: str) -> None:
|
||||
_set_nested_item(config, key, validated_value)
|
||||
_save_config(config)
|
||||
|
||||
click.echo(f"配置已更新: {key}")
|
||||
click.echo(f"Config updated: {key}")
|
||||
if key == "dashboard.password":
|
||||
click.echo(" 原值: ********")
|
||||
click.echo(" 新值: ********")
|
||||
click.echo(" Old value: ********")
|
||||
click.echo(" New value: ********")
|
||||
else:
|
||||
click.echo(f" 原值: {old_value}")
|
||||
click.echo(f" 新值: {validated_value}")
|
||||
click.echo(f" Old value: {old_value}")
|
||||
click.echo(f" New value: {validated_value}")
|
||||
|
||||
except KeyError:
|
||||
raise click.ClickException(f"未知的配置项: {key}")
|
||||
raise click.ClickException(f"Unknown config key: {key}")
|
||||
except Exception as e:
|
||||
raise click.UsageError(f"设置配置失败: {e!s}")
|
||||
raise click.UsageError(f"Failed to set config: {e!s}")
|
||||
|
||||
|
||||
@conf.command(name="get")
|
||||
@click.argument("key", required=False)
|
||||
def get_config(key: str | None = None) -> None:
|
||||
"""获取配置项的值,不提供key则显示所有可配置项"""
|
||||
"""Get the value of a config item. If no key is provided, show all configurable items"""
|
||||
config = _load_config()
|
||||
|
||||
if key:
|
||||
if key not in CONFIG_VALIDATORS:
|
||||
raise click.ClickException(f"不支持的配置项: {key}")
|
||||
raise click.ClickException(f"Unsupported config key: {key}")
|
||||
|
||||
try:
|
||||
value = _get_nested_item(config, key)
|
||||
@@ -192,11 +196,11 @@ def get_config(key: str | None = None) -> None:
|
||||
value = "********"
|
||||
click.echo(f"{key}: {value}")
|
||||
except KeyError:
|
||||
raise click.ClickException(f"未知的配置项: {key}")
|
||||
raise click.ClickException(f"Unknown config key: {key}")
|
||||
except Exception as e:
|
||||
raise click.UsageError(f"获取配置失败: {e!s}")
|
||||
raise click.UsageError(f"Failed to get config: {e!s}")
|
||||
else:
|
||||
click.echo("当前配置:")
|
||||
click.echo("Current config:")
|
||||
for key in CONFIG_VALIDATORS:
|
||||
try:
|
||||
value = (
|
||||
|
||||
@@ -8,16 +8,12 @@ from ..utils import check_dashboard, get_astrbot_root
|
||||
|
||||
|
||||
async def initialize_astrbot(astrbot_root: Path) -> None:
|
||||
"""执行 AstrBot 初始化逻辑"""
|
||||
"""Execute AstrBot initialization logic"""
|
||||
dot_astrbot = astrbot_root / ".astrbot"
|
||||
|
||||
if not dot_astrbot.exists():
|
||||
click.echo(f"Current Directory: {astrbot_root}")
|
||||
click.echo(
|
||||
"如果你确认这是 Astrbot root directory, 你需要在当前目录下创建一个 .astrbot 文件标记该目录为 AstrBot 的数据目录。",
|
||||
)
|
||||
if click.confirm(
|
||||
f"请检查当前目录是否正确,确认正确请回车: {astrbot_root}",
|
||||
f"Install AstrBot to this directory? {astrbot_root}",
|
||||
default=True,
|
||||
abort=True,
|
||||
):
|
||||
@@ -40,7 +36,7 @@ async def initialize_astrbot(astrbot_root: Path) -> None:
|
||||
|
||||
@click.command()
|
||||
def init() -> None:
|
||||
"""初始化 AstrBot"""
|
||||
"""Initialize AstrBot"""
|
||||
click.echo("Initializing AstrBot...")
|
||||
astrbot_root = get_astrbot_root()
|
||||
lock_file = astrbot_root / "astrbot.lock"
|
||||
@@ -49,8 +45,11 @@ def init() -> None:
|
||||
try:
|
||||
with lock.acquire():
|
||||
asyncio.run(initialize_astrbot(astrbot_root))
|
||||
click.echo("Done! You can now run 'astrbot run' to start AstrBot")
|
||||
except Timeout:
|
||||
raise click.ClickException("无法获取锁文件,请检查是否有其他实例正在运行")
|
||||
raise click.ClickException(
|
||||
"Cannot acquire lock file. Please check if another instance is running"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
raise click.ClickException(f"初始化失败: {e!s}")
|
||||
raise click.ClickException(f"Initialization failed: {e!s}")
|
||||
|
||||
@@ -16,14 +16,14 @@ from ..utils import (
|
||||
|
||||
@click.group()
|
||||
def plug() -> None:
|
||||
"""插件管理"""
|
||||
"""Plugin management"""
|
||||
|
||||
|
||||
def _get_data_path() -> Path:
|
||||
base = get_astrbot_root()
|
||||
if not check_astrbot_root(base):
|
||||
raise click.ClickException(
|
||||
f"{base}不是有效的 AstrBot 根目录,如需初始化请使用 astrbot init",
|
||||
f"{base} is not a valid AstrBot root directory. Use 'astrbot init' to initialize",
|
||||
)
|
||||
return (base / "data").resolve()
|
||||
|
||||
@@ -32,7 +32,9 @@ def display_plugins(plugins, title=None, color=None) -> None:
|
||||
if title:
|
||||
click.echo(click.style(title, fg=color, bold=True))
|
||||
|
||||
click.echo(f"{'名称':<20} {'版本':<10} {'状态':<10} {'作者':<15} {'描述':<30}")
|
||||
click.echo(
|
||||
f"{'Name':<20} {'Version':<10} {'Status':<10} {'Author':<15} {'Description':<30}"
|
||||
)
|
||||
click.echo("-" * 85)
|
||||
|
||||
for p in plugins:
|
||||
@@ -46,30 +48,30 @@ def display_plugins(plugins, title=None, color=None) -> None:
|
||||
@plug.command()
|
||||
@click.argument("name")
|
||||
def new(name: str) -> None:
|
||||
"""创建新插件"""
|
||||
"""Create a new plugin"""
|
||||
base_path = _get_data_path()
|
||||
plug_path = base_path / "plugins" / name
|
||||
|
||||
if plug_path.exists():
|
||||
raise click.ClickException(f"插件 {name} 已存在")
|
||||
raise click.ClickException(f"Plugin {name} already exists")
|
||||
|
||||
author = click.prompt("请输入插件作者", type=str)
|
||||
desc = click.prompt("请输入插件描述", type=str)
|
||||
version = click.prompt("请输入插件版本", type=str)
|
||||
author = click.prompt("Enter plugin author", type=str)
|
||||
desc = click.prompt("Enter plugin description", type=str)
|
||||
version = click.prompt("Enter plugin version", type=str)
|
||||
if not re.match(r"^\d+\.\d+(\.\d+)?$", version.lower().lstrip("v")):
|
||||
raise click.ClickException("版本号必须为 x.y 或 x.y.z 格式")
|
||||
repo = click.prompt("请输入插件仓库:", type=str)
|
||||
raise click.ClickException("Version must be in x.y or x.y.z format")
|
||||
repo = click.prompt("Enter plugin repository URL:", type=str)
|
||||
if not repo.startswith("http"):
|
||||
raise click.ClickException("仓库地址必须以 http 开头")
|
||||
raise click.ClickException("Repository URL must start with http")
|
||||
|
||||
click.echo("下载插件模板...")
|
||||
click.echo("Downloading plugin template...")
|
||||
get_git_repo(
|
||||
"https://github.com/Soulter/helloworld",
|
||||
plug_path,
|
||||
)
|
||||
|
||||
click.echo("重写插件信息...")
|
||||
# 重写 metadata.yaml
|
||||
click.echo("Rewriting plugin metadata...")
|
||||
# Rewrite metadata.yaml
|
||||
with open(plug_path / "metadata.yaml", "w", encoding="utf-8") as f:
|
||||
f.write(
|
||||
f"name: {name}\n"
|
||||
@@ -79,11 +81,13 @@ def new(name: str) -> None:
|
||||
f"repo: {repo}\n",
|
||||
)
|
||||
|
||||
# 重写 README.md
|
||||
# Rewrite README.md
|
||||
with open(plug_path / "README.md", "w", encoding="utf-8") as f:
|
||||
f.write(f"# {name}\n\n{desc}\n\n# 支持\n\n[帮助文档](https://astrbot.app)\n")
|
||||
f.write(
|
||||
f"# {name}\n\n{desc}\n\n# Support\n\n[Documentation](https://astrbot.app)\n"
|
||||
)
|
||||
|
||||
# 重写 main.py
|
||||
# Rewrite main.py
|
||||
with open(plug_path / "main.py", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
|
||||
@@ -95,54 +99,54 @@ def new(name: str) -> None:
|
||||
with open(plug_path / "main.py", "w", encoding="utf-8") as f:
|
||||
f.write(new_content)
|
||||
|
||||
click.echo(f"插件 {name} 创建成功")
|
||||
click.echo(f"Plugin {name} created successfully")
|
||||
|
||||
|
||||
@plug.command()
|
||||
@click.option("--all", "-a", is_flag=True, help="列出未安装的插件")
|
||||
@click.option("--all", "-a", is_flag=True, help="List uninstalled plugins")
|
||||
def list(all: bool) -> None:
|
||||
"""列出插件"""
|
||||
"""List plugins"""
|
||||
base_path = _get_data_path()
|
||||
plugins = build_plug_list(base_path / "plugins")
|
||||
|
||||
# 未发布的插件
|
||||
# Unpublished plugins
|
||||
not_published_plugins = [
|
||||
p for p in plugins if p["status"] == PluginStatus.NOT_PUBLISHED
|
||||
]
|
||||
if not_published_plugins:
|
||||
display_plugins(not_published_plugins, "未发布的插件", "red")
|
||||
display_plugins(not_published_plugins, "Unpublished Plugins", "red")
|
||||
|
||||
# 需要更新的插件
|
||||
# Plugins needing update
|
||||
need_update_plugins = [
|
||||
p for p in plugins if p["status"] == PluginStatus.NEED_UPDATE
|
||||
]
|
||||
if need_update_plugins:
|
||||
display_plugins(need_update_plugins, "需要更新的插件", "yellow")
|
||||
display_plugins(need_update_plugins, "Plugins Needing Update", "yellow")
|
||||
|
||||
# 已安装的插件
|
||||
# Installed plugins
|
||||
installed_plugins = [p for p in plugins if p["status"] == PluginStatus.INSTALLED]
|
||||
if installed_plugins:
|
||||
display_plugins(installed_plugins, "已安装的插件", "green")
|
||||
display_plugins(installed_plugins, "Installed Plugins", "green")
|
||||
|
||||
# 未安装的插件
|
||||
# Uninstalled plugins
|
||||
not_installed_plugins = [
|
||||
p for p in plugins if p["status"] == PluginStatus.NOT_INSTALLED
|
||||
]
|
||||
if not_installed_plugins and all:
|
||||
display_plugins(not_installed_plugins, "未安装的插件", "blue")
|
||||
display_plugins(not_installed_plugins, "Uninstalled Plugins", "blue")
|
||||
|
||||
if (
|
||||
not any([not_published_plugins, need_update_plugins, installed_plugins])
|
||||
and not all
|
||||
):
|
||||
click.echo("未安装任何插件")
|
||||
click.echo("No plugins installed")
|
||||
|
||||
|
||||
@plug.command()
|
||||
@click.argument("name")
|
||||
@click.option("--proxy", help="代理服务器地址")
|
||||
@click.option("--proxy", help="Proxy server address")
|
||||
def install(name: str, proxy: str | None) -> None:
|
||||
"""安装插件"""
|
||||
"""Install a plugin"""
|
||||
base_path = _get_data_path()
|
||||
plug_path = base_path / "plugins"
|
||||
plugins = build_plug_list(base_path / "plugins")
|
||||
@@ -157,7 +161,7 @@ def install(name: str, proxy: str | None) -> None:
|
||||
)
|
||||
|
||||
if not plugin:
|
||||
raise click.ClickException(f"未找到可安装的插件 {name},可能是不存在或已安装")
|
||||
raise click.ClickException(f"Plugin {name} not found or already installed")
|
||||
|
||||
manage_plugin(plugin, plug_path, is_update=False, proxy=proxy)
|
||||
|
||||
@@ -165,30 +169,32 @@ def install(name: str, proxy: str | None) -> None:
|
||||
@plug.command()
|
||||
@click.argument("name")
|
||||
def remove(name: str) -> None:
|
||||
"""卸载插件"""
|
||||
"""Uninstall a plugin"""
|
||||
base_path = _get_data_path()
|
||||
plugins = build_plug_list(base_path / "plugins")
|
||||
plugin = next((p for p in plugins if p["name"] == name), None)
|
||||
|
||||
if not plugin or not plugin.get("local_path"):
|
||||
raise click.ClickException(f"插件 {name} 不存在或未安装")
|
||||
raise click.ClickException(f"Plugin {name} does not exist or is not installed")
|
||||
|
||||
plugin_path = plugin["local_path"]
|
||||
|
||||
click.confirm(f"确定要卸载插件 {name} 吗?", default=False, abort=True)
|
||||
click.confirm(
|
||||
f"Are you sure you want to uninstall plugin {name}?", default=False, abort=True
|
||||
)
|
||||
|
||||
try:
|
||||
shutil.rmtree(plugin_path)
|
||||
click.echo(f"插件 {name} 已卸载")
|
||||
click.echo(f"Plugin {name} has been uninstalled")
|
||||
except Exception as e:
|
||||
raise click.ClickException(f"卸载插件 {name} 失败: {e}")
|
||||
raise click.ClickException(f"Failed to uninstall plugin {name}: {e}")
|
||||
|
||||
|
||||
@plug.command()
|
||||
@click.argument("name", required=False)
|
||||
@click.option("--proxy", help="Github代理地址")
|
||||
@click.option("--proxy", help="GitHub proxy address")
|
||||
def update(name: str, proxy: str | None) -> None:
|
||||
"""更新插件"""
|
||||
"""Update plugins"""
|
||||
base_path = _get_data_path()
|
||||
plug_path = base_path / "plugins"
|
||||
plugins = build_plug_list(base_path / "plugins")
|
||||
@@ -204,7 +210,9 @@ def update(name: str, proxy: str | None) -> None:
|
||||
)
|
||||
|
||||
if not plugin:
|
||||
raise click.ClickException(f"插件 {name} 不需要更新或无法更新")
|
||||
raise click.ClickException(
|
||||
f"Plugin {name} does not need updating or cannot be updated"
|
||||
)
|
||||
|
||||
manage_plugin(plugin, plug_path, is_update=True, proxy=proxy)
|
||||
else:
|
||||
@@ -213,20 +221,20 @@ def update(name: str, proxy: str | None) -> None:
|
||||
]
|
||||
|
||||
if not need_update_plugins:
|
||||
click.echo("没有需要更新的插件")
|
||||
click.echo("No plugins need updating")
|
||||
return
|
||||
|
||||
click.echo(f"发现 {len(need_update_plugins)} 个插件需要更新")
|
||||
click.echo(f"Found {len(need_update_plugins)} plugin(s) needing update")
|
||||
for plugin in need_update_plugins:
|
||||
plugin_name = plugin["name"]
|
||||
click.echo(f"正在更新插件 {plugin_name}...")
|
||||
click.echo(f"Updating plugin {plugin_name}...")
|
||||
manage_plugin(plugin, plug_path, is_update=True, proxy=proxy)
|
||||
|
||||
|
||||
@plug.command()
|
||||
@click.argument("query")
|
||||
def search(query: str) -> None:
|
||||
"""搜索插件"""
|
||||
"""Search for plugins"""
|
||||
base_path = _get_data_path()
|
||||
plugins = build_plug_list(base_path / "plugins")
|
||||
|
||||
@@ -239,7 +247,7 @@ def search(query: str) -> None:
|
||||
]
|
||||
|
||||
if not matched_plugins:
|
||||
click.echo(f"未找到匹配 '{query}' 的插件")
|
||||
click.echo(f"No plugins matching '{query}' found")
|
||||
return
|
||||
|
||||
display_plugins(matched_plugins, f"搜索结果: '{query}'", "cyan")
|
||||
display_plugins(matched_plugins, f"Search results: '{query}'", "cyan")
|
||||
|
||||
@@ -11,7 +11,7 @@ from ..utils import check_astrbot_root, check_dashboard, get_astrbot_root
|
||||
|
||||
|
||||
async def run_astrbot(astrbot_root: Path) -> None:
|
||||
"""运行 AstrBot"""
|
||||
"""Run AstrBot"""
|
||||
from astrbot.core import LogBroker, LogManager, db_helper, logger
|
||||
from astrbot.core.initial_loader import InitialLoader
|
||||
|
||||
@@ -26,18 +26,18 @@ async def run_astrbot(astrbot_root: Path) -> None:
|
||||
await core_lifecycle.start()
|
||||
|
||||
|
||||
@click.option("--reload", "-r", is_flag=True, help="插件自动重载")
|
||||
@click.option("--port", "-p", help="Astrbot Dashboard端口", required=False, type=str)
|
||||
@click.option("--reload", "-r", is_flag=True, help="Auto-reload plugins")
|
||||
@click.option("--port", "-p", help="AstrBot Dashboard port", required=False, type=str)
|
||||
@click.command()
|
||||
def run(reload: bool, port: str) -> None:
|
||||
"""运行 AstrBot"""
|
||||
"""Run AstrBot"""
|
||||
try:
|
||||
os.environ["ASTRBOT_CLI"] = "1"
|
||||
astrbot_root = get_astrbot_root()
|
||||
|
||||
if not check_astrbot_root(astrbot_root):
|
||||
raise click.ClickException(
|
||||
f"{astrbot_root}不是有效的 AstrBot 根目录,如需初始化请使用 astrbot init",
|
||||
f"{astrbot_root} is not a valid AstrBot root directory. Use 'astrbot init' to initialize",
|
||||
)
|
||||
|
||||
os.environ["ASTRBOT_ROOT"] = str(astrbot_root)
|
||||
@@ -47,7 +47,7 @@ def run(reload: bool, port: str) -> None:
|
||||
os.environ["DASHBOARD_PORT"] = port
|
||||
|
||||
if reload:
|
||||
click.echo("启用插件自动重载")
|
||||
click.echo("Plugin auto-reload enabled")
|
||||
os.environ["ASTRBOT_RELOAD"] = "1"
|
||||
|
||||
lock_file = astrbot_root / "astrbot.lock"
|
||||
@@ -55,8 +55,10 @@ def run(reload: bool, port: str) -> None:
|
||||
with lock.acquire():
|
||||
asyncio.run(run_astrbot(astrbot_root))
|
||||
except KeyboardInterrupt:
|
||||
click.echo("AstrBot 已关闭...")
|
||||
click.echo("AstrBot has been shut down.")
|
||||
except Timeout:
|
||||
raise click.ClickException("无法获取锁文件,请检查是否有其他实例正在运行")
|
||||
raise click.ClickException(
|
||||
"Cannot acquire lock file. Please check if another instance is running"
|
||||
)
|
||||
except Exception as e:
|
||||
raise click.ClickException(f"运行时出现错误: {e}\n{traceback.format_exc()}")
|
||||
raise click.ClickException(f"Runtime error: {e}\n{traceback.format_exc()}")
|
||||
|
||||
+21
-13
@@ -2,9 +2,12 @@ from pathlib import Path
|
||||
|
||||
import click
|
||||
|
||||
# Static assets bundled inside the installed wheel (built by hatch_build.py).
|
||||
_BUNDLED_DIST = Path(__file__).parent.parent.parent / "dashboard" / "dist"
|
||||
|
||||
|
||||
def check_astrbot_root(path: str | Path) -> bool:
|
||||
"""检查路径是否为 AstrBot 根目录"""
|
||||
"""Check if the path is an AstrBot root directory"""
|
||||
if not isinstance(path, Path):
|
||||
path = Path(path)
|
||||
if not path.exists() or not path.is_dir():
|
||||
@@ -15,43 +18,48 @@ def check_astrbot_root(path: str | Path) -> bool:
|
||||
|
||||
|
||||
def get_astrbot_root() -> Path:
|
||||
"""获取Astrbot根目录路径"""
|
||||
"""Get the AstrBot root directory path"""
|
||||
return Path.cwd()
|
||||
|
||||
|
||||
async def check_dashboard(astrbot_root: Path) -> None:
|
||||
"""检查是否安装了dashboard"""
|
||||
"""Check if the dashboard is installed"""
|
||||
from astrbot.core.config.default import VERSION
|
||||
from astrbot.core.utils.io import download_dashboard, get_dashboard_version
|
||||
|
||||
from .version_comparator import VersionComparator
|
||||
|
||||
# If the wheel ships bundled dashboard assets, no network download is needed.
|
||||
if _BUNDLED_DIST.exists():
|
||||
click.echo("Dashboard is bundled with the package – skipping download.")
|
||||
return
|
||||
|
||||
try:
|
||||
dashboard_version = await get_dashboard_version()
|
||||
match dashboard_version:
|
||||
case None:
|
||||
click.echo("未安装管理面板")
|
||||
click.echo("Dashboard is not installed")
|
||||
if click.confirm(
|
||||
"是否安装管理面板?",
|
||||
"Install dashboard?",
|
||||
default=True,
|
||||
abort=True,
|
||||
):
|
||||
click.echo("正在安装管理面板...")
|
||||
click.echo("Installing dashboard...")
|
||||
await download_dashboard(
|
||||
path="data/dashboard.zip",
|
||||
extract_path=str(astrbot_root),
|
||||
version=f"v{VERSION}",
|
||||
latest=False,
|
||||
)
|
||||
click.echo("管理面板安装完成")
|
||||
click.echo("Dashboard installed successfully")
|
||||
|
||||
case str():
|
||||
if VersionComparator.compare_version(VERSION, dashboard_version) <= 0:
|
||||
click.echo("管理面板已是最新版本")
|
||||
click.echo("Dashboard is already up to date")
|
||||
return
|
||||
try:
|
||||
version = dashboard_version.split("v")[1]
|
||||
click.echo(f"管理面板版本: {version}")
|
||||
click.echo(f"Dashboard version: {version}")
|
||||
await download_dashboard(
|
||||
path="data/dashboard.zip",
|
||||
extract_path=str(astrbot_root),
|
||||
@@ -59,10 +67,10 @@ async def check_dashboard(astrbot_root: Path) -> None:
|
||||
latest=False,
|
||||
)
|
||||
except Exception as e:
|
||||
click.echo(f"下载管理面板失败: {e}")
|
||||
click.echo(f"Failed to download dashboard: {e}")
|
||||
return
|
||||
except FileNotFoundError:
|
||||
click.echo("初始化管理面板目录...")
|
||||
click.echo("Initializing dashboard directory...")
|
||||
try:
|
||||
await download_dashboard(
|
||||
path=str(astrbot_root / "dashboard.zip"),
|
||||
@@ -70,7 +78,7 @@ async def check_dashboard(astrbot_root: Path) -> None:
|
||||
version=f"v{VERSION}",
|
||||
latest=False,
|
||||
)
|
||||
click.echo("管理面板初始化完成")
|
||||
click.echo("Dashboard initialized successfully")
|
||||
except Exception as e:
|
||||
click.echo(f"下载管理面板失败: {e}")
|
||||
click.echo(f"Failed to download dashboard: {e}")
|
||||
return
|
||||
|
||||
+47
-43
@@ -13,22 +13,22 @@ from .version_comparator import VersionComparator
|
||||
|
||||
|
||||
class PluginStatus(str, Enum):
|
||||
INSTALLED = "已安装"
|
||||
NEED_UPDATE = "需更新"
|
||||
NOT_INSTALLED = "未安装"
|
||||
NOT_PUBLISHED = "未发布"
|
||||
INSTALLED = "installed"
|
||||
NEED_UPDATE = "needs-update"
|
||||
NOT_INSTALLED = "not-installed"
|
||||
NOT_PUBLISHED = "unpublished"
|
||||
|
||||
|
||||
def get_git_repo(url: str, target_path: Path, proxy: str | None = None) -> None:
|
||||
"""从 Git 仓库下载代码并解压到指定路径"""
|
||||
"""Download code from a Git repository and extract to the specified path"""
|
||||
temp_dir = Path(tempfile.mkdtemp())
|
||||
try:
|
||||
# 解析仓库信息
|
||||
# Parse repository info
|
||||
repo_namespace = url.split("/")[-2:]
|
||||
author = repo_namespace[0]
|
||||
repo = repo_namespace[1]
|
||||
|
||||
# 尝试获取最新的 release
|
||||
# Try to get the latest release
|
||||
release_url = f"https://api.github.com/repos/{author}/{repo}/releases"
|
||||
try:
|
||||
with httpx.Client(
|
||||
@@ -40,21 +40,21 @@ def get_git_repo(url: str, target_path: Path, proxy: str | None = None) -> None:
|
||||
releases = resp.json()
|
||||
|
||||
if releases:
|
||||
# 使用最新的 release
|
||||
# Use the latest release
|
||||
download_url = releases[0]["zipball_url"]
|
||||
else:
|
||||
# 没有 release,使用默认分支
|
||||
click.echo(f"正在从默认分支下载 {author}/{repo}")
|
||||
# No release found, use default branch
|
||||
click.echo(f"Downloading {author}/{repo} from default branch")
|
||||
download_url = f"https://github.com/{author}/{repo}/archive/refs/heads/master.zip"
|
||||
except Exception as e:
|
||||
click.echo(f"获取 release 信息失败: {e},将直接使用提供的 URL")
|
||||
click.echo(f"Failed to get release info: {e}. Using provided URL directly")
|
||||
download_url = url
|
||||
|
||||
# 应用代理
|
||||
# Apply proxy
|
||||
if proxy:
|
||||
download_url = f"{proxy}/{download_url}"
|
||||
|
||||
# 下载并解压
|
||||
# Download and extract
|
||||
with httpx.Client(
|
||||
proxy=proxy if proxy else None,
|
||||
follow_redirects=True,
|
||||
@@ -65,7 +65,7 @@ def get_git_repo(url: str, target_path: Path, proxy: str | None = None) -> None:
|
||||
and "archive/refs/heads/master.zip" in download_url
|
||||
):
|
||||
alt_url = download_url.replace("master.zip", "main.zip")
|
||||
click.echo("master 分支不存在,尝试下载 main 分支")
|
||||
click.echo("Branch 'master' not found, trying 'main' branch")
|
||||
resp = client.get(alt_url)
|
||||
resp.raise_for_status()
|
||||
else:
|
||||
@@ -84,13 +84,13 @@ def get_git_repo(url: str, target_path: Path, proxy: str | None = None) -> None:
|
||||
|
||||
|
||||
def load_yaml_metadata(plugin_dir: Path) -> dict:
|
||||
"""从 metadata.yaml 文件加载插件元数据
|
||||
"""Load plugin metadata from metadata.yaml file
|
||||
|
||||
Args:
|
||||
plugin_dir: 插件目录路径
|
||||
plugin_dir: Plugin directory path
|
||||
|
||||
Returns:
|
||||
dict: 包含元数据的字典,如果读取失败则返回空字典
|
||||
dict: Dictionary containing metadata, or empty dict if loading fails
|
||||
|
||||
"""
|
||||
yaml_path = plugin_dir / "metadata.yaml"
|
||||
@@ -98,33 +98,33 @@ def load_yaml_metadata(plugin_dir: Path) -> dict:
|
||||
try:
|
||||
return yaml.safe_load(yaml_path.read_text(encoding="utf-8")) or {}
|
||||
except Exception as e:
|
||||
click.echo(f"读取 {yaml_path} 失败: {e}", err=True)
|
||||
click.echo(f"Failed to read {yaml_path}: {e}", err=True)
|
||||
return {}
|
||||
|
||||
|
||||
def build_plug_list(plugins_dir: Path) -> list:
|
||||
"""构建插件列表,包含本地和在线插件信息
|
||||
"""Build plugin list containing local and online plugin information
|
||||
|
||||
Args:
|
||||
plugins_dir (Path): 插件目录路径
|
||||
plugins_dir (Path): Plugin directory path
|
||||
|
||||
Returns:
|
||||
list: 包含插件信息的字典列表
|
||||
list: List of dicts containing plugin information
|
||||
|
||||
"""
|
||||
# 获取本地插件信息
|
||||
# Get local plugin info
|
||||
result = []
|
||||
if plugins_dir.exists():
|
||||
for plugin_name in [d.name for d in plugins_dir.glob("*") if d.is_dir()]:
|
||||
plugin_dir = plugins_dir / plugin_name
|
||||
|
||||
# 从 metadata.yaml 加载元数据
|
||||
# Load metadata from metadata.yaml
|
||||
metadata = load_yaml_metadata(plugin_dir)
|
||||
|
||||
if "desc" not in metadata and "description" in metadata:
|
||||
metadata["desc"] = metadata["description"]
|
||||
|
||||
# 如果成功加载元数据,添加到结果列表
|
||||
# If metadata loaded successfully, add to result list
|
||||
if metadata and all(
|
||||
k in metadata for k in ["name", "desc", "version", "author", "repo"]
|
||||
):
|
||||
@@ -140,7 +140,7 @@ def build_plug_list(plugins_dir: Path) -> list:
|
||||
},
|
||||
)
|
||||
|
||||
# 获取在线插件列表
|
||||
# Get online plugin list
|
||||
online_plugins = []
|
||||
try:
|
||||
with httpx.Client() as client:
|
||||
@@ -160,13 +160,13 @@ def build_plug_list(plugins_dir: Path) -> list:
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
click.echo(f"获取在线插件列表失败: {e}", err=True)
|
||||
click.echo(f"Failed to get online plugin list: {e}", err=True)
|
||||
|
||||
# 与在线插件比对,更新状态
|
||||
# Compare with online plugins and update status
|
||||
online_plugin_names = {plugin["name"] for plugin in online_plugins}
|
||||
for local_plugin in result:
|
||||
if local_plugin["name"] in online_plugin_names:
|
||||
# 查找对应的在线插件
|
||||
# Find the corresponding online plugin
|
||||
online_plugin = next(
|
||||
p for p in online_plugins if p["name"] == local_plugin["name"]
|
||||
)
|
||||
@@ -179,10 +179,10 @@ def build_plug_list(plugins_dir: Path) -> list:
|
||||
):
|
||||
local_plugin["status"] = PluginStatus.NEED_UPDATE
|
||||
else:
|
||||
# 本地插件未在线上发布
|
||||
# Local plugin is not published online
|
||||
local_plugin["status"] = PluginStatus.NOT_PUBLISHED
|
||||
|
||||
# 添加未安装的在线插件
|
||||
# Add uninstalled online plugins
|
||||
for online_plugin in online_plugins:
|
||||
if not any(plugin["name"] == online_plugin["name"] for plugin in result):
|
||||
result.append(online_plugin)
|
||||
@@ -196,19 +196,19 @@ def manage_plugin(
|
||||
is_update: bool = False,
|
||||
proxy: str | None = None,
|
||||
) -> None:
|
||||
"""安装或更新插件
|
||||
"""Install or update a plugin
|
||||
|
||||
Args:
|
||||
plugin (dict): 插件信息字典
|
||||
plugins_dir (Path): 插件目录
|
||||
is_update (bool, optional): 是否为更新操作. 默认为 False
|
||||
proxy (str, optional): 代理服务器地址
|
||||
plugin (dict): Plugin info dict
|
||||
plugins_dir (Path): Plugins directory
|
||||
is_update (bool, optional): Whether this is an update operation. Defaults to False
|
||||
proxy (str, optional): Proxy server address
|
||||
|
||||
"""
|
||||
plugin_name = plugin["name"]
|
||||
repo_url = plugin["repo"]
|
||||
|
||||
# 如果是更新且有本地路径,直接使用本地路径
|
||||
# If updating and local path exists, use it directly
|
||||
if is_update and plugin.get("local_path"):
|
||||
target_path = Path(plugin["local_path"])
|
||||
else:
|
||||
@@ -216,11 +216,13 @@ def manage_plugin(
|
||||
|
||||
backup_path = Path(f"{target_path}_backup") if is_update else None
|
||||
|
||||
# 检查插件是否存在
|
||||
# Check if plugin exists
|
||||
if is_update and not target_path.exists():
|
||||
raise click.ClickException(f"插件 {plugin_name} 未安装,无法更新")
|
||||
raise click.ClickException(
|
||||
f"Plugin {plugin_name} is not installed and cannot be updated"
|
||||
)
|
||||
|
||||
# 备份现有插件
|
||||
# Backup existing plugin
|
||||
if is_update and backup_path is not None and backup_path.exists():
|
||||
shutil.rmtree(backup_path)
|
||||
if is_update and backup_path is not None:
|
||||
@@ -228,19 +230,21 @@ def manage_plugin(
|
||||
|
||||
try:
|
||||
click.echo(
|
||||
f"正在从 {repo_url} {'更新' if is_update else '下载'}插件 {plugin_name}...",
|
||||
f"{'Updating' if is_update else 'Downloading'} plugin {plugin_name} from {repo_url}...",
|
||||
)
|
||||
get_git_repo(repo_url, target_path, proxy)
|
||||
|
||||
# 更新成功,删除备份
|
||||
# Update succeeded, delete backup
|
||||
if is_update and backup_path is not None and backup_path.exists():
|
||||
shutil.rmtree(backup_path)
|
||||
click.echo(f"插件 {plugin_name} {'更新' if is_update else '安装'}成功")
|
||||
click.echo(
|
||||
f"Plugin {plugin_name} {'updated' if is_update else 'installed'} successfully"
|
||||
)
|
||||
except Exception as e:
|
||||
if target_path.exists():
|
||||
shutil.rmtree(target_path, ignore_errors=True)
|
||||
if is_update and backup_path is not None and backup_path.exists():
|
||||
shutil.move(backup_path, target_path)
|
||||
raise click.ClickException(
|
||||
f"{'更新' if is_update else '安装'}插件 {plugin_name} 时出错: {e}",
|
||||
f"Error {'updating' if is_update else 'installing'} plugin {plugin_name}: {e}",
|
||||
)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""拷贝自 astrbot.core.utils.version_comparator"""
|
||||
"""Copied from astrbot.core.utils.version_comparator"""
|
||||
|
||||
import re
|
||||
|
||||
@@ -6,11 +6,11 @@ import re
|
||||
class VersionComparator:
|
||||
@staticmethod
|
||||
def compare_version(v1: str, v2: str) -> int:
|
||||
"""根据 Semver 语义版本规范来比较版本号的大小。支持不仅局限于 3 个数字的版本号,并处理预发布标签。
|
||||
"""Compare version numbers according to Semver semantics. Supports version numbers with more than 3 digits and handles pre-release tags.
|
||||
|
||||
参考: https://semver.org/lang/zh-CN/
|
||||
Reference: https://semver.org/
|
||||
|
||||
返回 1 表示 v1 > v2,返回 -1 表示 v1 < v2,返回 0 表示 v1 = v2。
|
||||
Returns 1 if v1 > v2, -1 if v1 < v2, 0 if v1 == v2.
|
||||
"""
|
||||
v1 = v1.lower().replace("v", "")
|
||||
v2 = v2.lower().replace("v", "")
|
||||
@@ -24,7 +24,7 @@ class VersionComparator:
|
||||
return [], None
|
||||
major_minor_patch = match.group(1).split(".")
|
||||
prerelease = match.group(2)
|
||||
# buildmetadata = match.group(3) # 构建元数据在比较时忽略
|
||||
# buildmetadata = match.group(3) # Build metadata is ignored in comparison
|
||||
parts = [int(x) for x in major_minor_patch]
|
||||
prerelease = VersionComparator._split_prerelease(prerelease)
|
||||
return parts, prerelease
|
||||
@@ -32,7 +32,7 @@ class VersionComparator:
|
||||
v1_parts, v1_prerelease = split_version(v1)
|
||||
v2_parts, v2_prerelease = split_version(v2)
|
||||
|
||||
# 比较数字部分
|
||||
# Compare numeric parts
|
||||
length = max(len(v1_parts), len(v2_parts))
|
||||
v1_parts.extend([0] * (length - len(v1_parts)))
|
||||
v2_parts.extend([0] * (length - len(v2_parts)))
|
||||
@@ -43,11 +43,11 @@ class VersionComparator:
|
||||
if v1_parts[i] < v2_parts[i]:
|
||||
return -1
|
||||
|
||||
# 比较预发布标签
|
||||
# Compare pre-release tags
|
||||
if v1_prerelease is None and v2_prerelease is not None:
|
||||
return 1 # 没有预发布标签的版本高于有预发布标签的版本
|
||||
return 1 # Version without pre-release tag is higher than one with it
|
||||
if v1_prerelease is not None and v2_prerelease is None:
|
||||
return -1 # 有预发布标签的版本低于没有预发布标签的版本
|
||||
return -1 # Version with pre-release tag is lower than one without it
|
||||
if v1_prerelease is not None and v2_prerelease is not None:
|
||||
len_pre = max(len(v1_prerelease), len(v2_prerelease))
|
||||
for i in range(len_pre):
|
||||
@@ -72,9 +72,9 @@ class VersionComparator:
|
||||
return 1
|
||||
if p1 < p2:
|
||||
return -1
|
||||
return 0 # 预发布标签完全相同
|
||||
return 0 # Pre-release tags are identical
|
||||
|
||||
return 0 # 数字部分和预发布标签都相同
|
||||
return 0 # Both numeric parts and pre-release tags are equal
|
||||
|
||||
@staticmethod
|
||||
def _split_prerelease(prerelease):
|
||||
|
||||
@@ -4,7 +4,21 @@ from astrbot.core.config import AstrBotConfig
|
||||
from astrbot.core.config.default import DB_PATH
|
||||
from astrbot.core.db.sqlite import SQLiteDatabase
|
||||
from astrbot.core.file_token_service import FileTokenService
|
||||
from astrbot.core.utils.pip_installer import PipInstaller
|
||||
from astrbot.core.utils.pip_installer import (
|
||||
DependencyConflictError as DependencyConflictError,
|
||||
)
|
||||
from astrbot.core.utils.pip_installer import (
|
||||
PipInstaller,
|
||||
)
|
||||
from astrbot.core.utils.requirements_utils import (
|
||||
RequirementsPrecheckFailed as RequirementsPrecheckFailed,
|
||||
)
|
||||
from astrbot.core.utils.requirements_utils import (
|
||||
find_missing_requirements as find_missing_requirements,
|
||||
)
|
||||
from astrbot.core.utils.requirements_utils import (
|
||||
find_missing_requirements_or_raise as find_missing_requirements_or_raise,
|
||||
)
|
||||
from astrbot.core.utils.shared_preferences import SharedPreferences
|
||||
from astrbot.core.utils.t2i.renderer import HtmlRenderer
|
||||
|
||||
@@ -14,7 +28,7 @@ from .utils.astrbot_path import get_astrbot_data_path
|
||||
# 初始化数据存储文件夹
|
||||
os.makedirs(get_astrbot_data_path(), exist_ok=True)
|
||||
|
||||
DEMO_MODE = os.getenv("DEMO_MODE", False)
|
||||
DEMO_MODE = os.getenv("DEMO_MODE", "False").strip().lower() in ("true", "1", "t")
|
||||
|
||||
astrbot_config = AstrBotConfig()
|
||||
t2i_base_url = astrbot_config.get("t2i_endpoint", "https://t2i.soulter.top/text2img")
|
||||
|
||||
@@ -144,10 +144,14 @@ class MCPClient:
|
||||
|
||||
cfg = _prepare_config(mcp_server_config.copy())
|
||||
|
||||
def logging_callback(msg: str) -> None:
|
||||
def logging_callback(
|
||||
msg: str | mcp.types.LoggingMessageNotificationParams,
|
||||
) -> None:
|
||||
# Handle MCP service error logs
|
||||
print(f"MCP Server {name} Error: {msg}")
|
||||
self.server_errlogs.append(msg)
|
||||
if isinstance(msg, mcp.types.LoggingMessageNotificationParams):
|
||||
if msg.level in ("warning", "error", "critical", "alert", "emergency"):
|
||||
log_msg = f"[{msg.level.upper()}] {str(msg.data)}"
|
||||
self.server_errlogs.append(log_msg)
|
||||
|
||||
if "url" in cfg:
|
||||
success, error_msg = await _quick_test_mcp_connection(cfg)
|
||||
@@ -214,15 +218,24 @@ class MCPClient:
|
||||
**cfg,
|
||||
)
|
||||
|
||||
def callback(msg: str) -> None:
|
||||
def callback(msg: str | mcp.types.LoggingMessageNotificationParams) -> None:
|
||||
# Handle MCP service error logs
|
||||
self.server_errlogs.append(msg)
|
||||
if isinstance(msg, mcp.types.LoggingMessageNotificationParams):
|
||||
if msg.level in (
|
||||
"warning",
|
||||
"error",
|
||||
"critical",
|
||||
"alert",
|
||||
"emergency",
|
||||
):
|
||||
log_msg = f"[{msg.level.upper()}] {str(msg.data)}"
|
||||
self.server_errlogs.append(log_msg)
|
||||
|
||||
stdio_transport = await self.exit_stack.enter_async_context(
|
||||
mcp.stdio_client(
|
||||
server_params,
|
||||
errlog=LogPipe(
|
||||
level=logging.ERROR,
|
||||
level=logging.INFO,
|
||||
logger=logger,
|
||||
identifier=f"MCPServer-{name}",
|
||||
callback=callback,
|
||||
|
||||
@@ -302,7 +302,7 @@ class DashscopeAgentRunner(BaseAgentRunner[TContext]):
|
||||
|
||||
while True:
|
||||
try:
|
||||
item_type, item_data = await asyncio.get_event_loop().run_in_executor(
|
||||
item_type, item_data = await asyncio.get_running_loop().run_in_executor(
|
||||
None, response_queue.get, True, 1
|
||||
)
|
||||
except queue.Empty:
|
||||
@@ -388,7 +388,7 @@ class DashscopeAgentRunner(BaseAgentRunner[TContext]):
|
||||
|
||||
# 发起请求
|
||||
partial = functools.partial(Application.call, **payload)
|
||||
response = await asyncio.get_event_loop().run_in_executor(None, partial)
|
||||
response = await asyncio.get_running_loop().run_in_executor(None, partial)
|
||||
|
||||
async for resp in self._handle_streaming_response(response, session_id):
|
||||
yield resp
|
||||
|
||||
@@ -326,6 +326,7 @@ async def run_live_agent(
|
||||
|
||||
# 创建队列
|
||||
text_queue: asyncio.Queue[str | None] = asyncio.Queue()
|
||||
delta_queue: asyncio.Queue[str | None] = asyncio.Queue()
|
||||
# audio_queue stored bytes or (text, bytes)
|
||||
audio_queue: asyncio.Queue[bytes | tuple[str, bytes] | None] = asyncio.Queue()
|
||||
|
||||
@@ -334,6 +335,7 @@ async def run_live_agent(
|
||||
_run_agent_feeder(
|
||||
agent_runner,
|
||||
text_queue,
|
||||
delta_queue,
|
||||
max_step,
|
||||
show_tool_use,
|
||||
show_tool_call_result,
|
||||
@@ -353,32 +355,63 @@ async def run_live_agent(
|
||||
|
||||
# 3. 主循环:从 audio_queue 读取音频并 yield
|
||||
try:
|
||||
while True:
|
||||
queue_item = await audio_queue.get()
|
||||
delta_done = False
|
||||
audio_done = False
|
||||
while not (delta_done and audio_done):
|
||||
task_sources: dict[asyncio.Task, str] = {}
|
||||
if not delta_done:
|
||||
task = asyncio.create_task(delta_queue.get())
|
||||
task_sources[task] = "delta"
|
||||
if not audio_done:
|
||||
task = asyncio.create_task(audio_queue.get())
|
||||
task_sources[task] = "audio"
|
||||
|
||||
if queue_item is None:
|
||||
break
|
||||
done, pending = await asyncio.wait(
|
||||
list(task_sources),
|
||||
return_when=asyncio.FIRST_COMPLETED,
|
||||
)
|
||||
|
||||
text = None
|
||||
if isinstance(queue_item, tuple):
|
||||
text, audio_data = queue_item
|
||||
else:
|
||||
audio_data = queue_item
|
||||
for task in pending:
|
||||
task.cancel()
|
||||
if pending:
|
||||
await asyncio.gather(*pending, return_exceptions=True)
|
||||
|
||||
if not first_chunk_received:
|
||||
# 记录首帧延迟(从开始处理到收到第一个音频块)
|
||||
tts_first_frame_time = time.time() - tts_start_time
|
||||
first_chunk_received = True
|
||||
for task in done:
|
||||
source = task_sources[task]
|
||||
queue_item = task.result()
|
||||
if source == "delta":
|
||||
if queue_item is None:
|
||||
delta_done = True
|
||||
continue
|
||||
yield MessageChain(
|
||||
chain=[Plain(queue_item)], type="live_text_delta"
|
||||
)
|
||||
continue
|
||||
|
||||
# 将音频数据封装为 MessageChain
|
||||
import base64
|
||||
if queue_item is None:
|
||||
audio_done = True
|
||||
continue
|
||||
|
||||
audio_b64 = base64.b64encode(audio_data).decode("utf-8")
|
||||
comps: list[BaseMessageComponent] = [Plain(audio_b64)]
|
||||
if text:
|
||||
comps.append(Json(data={"text": text}))
|
||||
chain = MessageChain(chain=comps, type="audio_chunk")
|
||||
yield chain
|
||||
text = None
|
||||
if isinstance(queue_item, tuple):
|
||||
text, audio_data = queue_item
|
||||
else:
|
||||
audio_data = queue_item
|
||||
|
||||
if not first_chunk_received:
|
||||
# 记录首帧延迟(从开始处理到收到第一个音频块)
|
||||
tts_first_frame_time = time.time() - tts_start_time
|
||||
first_chunk_received = True
|
||||
|
||||
# 将音频数据封装为 MessageChain
|
||||
import base64
|
||||
|
||||
audio_b64 = base64.b64encode(audio_data).decode("utf-8")
|
||||
comps: list[BaseMessageComponent] = [Plain(audio_b64)]
|
||||
if text:
|
||||
comps.append(Json(data={"text": text}))
|
||||
chain = MessageChain(chain=comps, type="audio_chunk")
|
||||
yield chain
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Live Agent] 运行时发生错误: {e}", exc_info=True)
|
||||
@@ -421,6 +454,7 @@ async def run_live_agent(
|
||||
async def _run_agent_feeder(
|
||||
agent_runner: AgentRunner,
|
||||
text_queue: asyncio.Queue,
|
||||
delta_queue: asyncio.Queue,
|
||||
max_step: int,
|
||||
show_tool_use: bool,
|
||||
show_tool_call_result: bool,
|
||||
@@ -440,9 +474,13 @@ async def _run_agent_feeder(
|
||||
if chain is None:
|
||||
continue
|
||||
|
||||
if chain.type == "reasoning":
|
||||
continue
|
||||
|
||||
# 提取文本
|
||||
text = chain.get_plain_text()
|
||||
if text:
|
||||
await delta_queue.put(text)
|
||||
buffer += text
|
||||
|
||||
# 分句逻辑:匹配标点符号
|
||||
@@ -477,6 +515,7 @@ async def _run_agent_feeder(
|
||||
finally:
|
||||
# 发送结束信号
|
||||
await text_queue.put(None)
|
||||
await delta_queue.put(None)
|
||||
|
||||
|
||||
async def _safe_tts_stream_wrapper(
|
||||
|
||||
@@ -291,6 +291,9 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
prov_settings: dict = ctx.get_config(umo=umo).get("provider_settings", {})
|
||||
agent_max_step = int(prov_settings.get("max_agent_step", 30))
|
||||
stream = prov_settings.get("streaming_response", False)
|
||||
llm_resp = await ctx.tool_loop_agent(
|
||||
event=event,
|
||||
chat_provider_id=prov_id,
|
||||
@@ -299,9 +302,8 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
|
||||
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),
|
||||
max_steps=agent_max_step,
|
||||
stream=stream,
|
||||
)
|
||||
yield mcp.types.CallToolResult(
|
||||
content=[mcp.types.TextContent(type="text", text=llm_resp.completion_text)]
|
||||
|
||||
@@ -778,9 +778,14 @@ def _plugin_tool_fix(event: AstrMessageEvent, req: ProviderRequest) -> None:
|
||||
continue
|
||||
mp = tool.handler_module_path
|
||||
if not mp:
|
||||
# 没有 plugin 归属信息的工具(如 subagent transfer_to_*)
|
||||
# 不应受到会话插件过滤影响。
|
||||
new_tool_set.add_tool(tool)
|
||||
continue
|
||||
plugin = star_map.get(mp)
|
||||
if not plugin:
|
||||
# 无法解析插件归属时,保守保留工具,避免误过滤。
|
||||
new_tool_set.add_tool(tool)
|
||||
continue
|
||||
if plugin.name in event.plugins_name or plugin.reserved:
|
||||
new_tool_set.add_tool(tool)
|
||||
@@ -846,6 +851,8 @@ def _apply_sandbox_tools(
|
||||
) -> None:
|
||||
if req.func_tool is None:
|
||||
req.func_tool = ToolSet()
|
||||
if req.system_prompt is None:
|
||||
req.system_prompt = ""
|
||||
booter = config.sandbox_cfg.get("booter", "shipyard_neo")
|
||||
if booter == "shipyard":
|
||||
ep = config.sandbox_cfg.get("shipyard_endpoint", "")
|
||||
|
||||
@@ -188,7 +188,12 @@ class KnowledgeBaseQueryTool(FunctionTool[AstrAgentContext]):
|
||||
@dataclass
|
||||
class SendMessageToUserTool(FunctionTool[AstrAgentContext]):
|
||||
name: str = "send_message_to_user"
|
||||
description: str = "Directly send message to the user. Only use this tool when you need to proactively message the user. Otherwise you can directly output the reply in the conversation."
|
||||
description: str = (
|
||||
"Send message to the user. "
|
||||
"Supports various message types including `plain`, `image`, `record`, `video`, `file`, and `mention_user`. "
|
||||
"Use this tool to send media files (`image`, `record`, `video`, `file`), "
|
||||
"or when you need to proactively message the user(such as cron job). For normal text replies, you can output directly."
|
||||
)
|
||||
|
||||
parameters: dict = Field(
|
||||
default_factory=lambda: {
|
||||
@@ -204,7 +209,7 @@ class SendMessageToUserTool(FunctionTool[AstrAgentContext]):
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Component type. One of: "
|
||||
"plain, image, record, file, mention_user"
|
||||
"plain, image, record, video, file, mention_user. Record is voice message."
|
||||
),
|
||||
},
|
||||
"text": {
|
||||
@@ -320,6 +325,19 @@ class SendMessageToUserTool(FunctionTool[AstrAgentContext]):
|
||||
components.append(Comp.Record.fromURL(url=url))
|
||||
else:
|
||||
return f"error: messages[{idx}] must include path or url for record component."
|
||||
elif msg_type == "video":
|
||||
path = msg.get("path")
|
||||
url = msg.get("url")
|
||||
if path:
|
||||
(
|
||||
local_path,
|
||||
file_from_sandbox,
|
||||
) = await self._resolve_path_from_sandbox(context, path)
|
||||
components.append(Comp.Video.fromFileSystem(path=local_path))
|
||||
elif url:
|
||||
components.append(Comp.Video.fromURL(url=url))
|
||||
else:
|
||||
return f"error: messages[{idx}] must include path or url for video component."
|
||||
elif msg_type == "file":
|
||||
path = msg.get("path")
|
||||
url = msg.get("url")
|
||||
|
||||
@@ -121,11 +121,12 @@ class BayContainerManager:
|
||||
async def wait_healthy(self, timeout: int = HEALTH_TIMEOUT_S) -> None:
|
||||
"""Block until Bay's ``/health`` endpoint returns 200."""
|
||||
url = f"http://127.0.0.1:{self._host_port}/health"
|
||||
deadline = asyncio.get_event_loop().time() + timeout
|
||||
loop = asyncio.get_running_loop()
|
||||
deadline = loop.time() + timeout
|
||||
last_error: str = ""
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
while asyncio.get_event_loop().time() < deadline:
|
||||
while loop.time() < deadline:
|
||||
try:
|
||||
async with session.get(
|
||||
url, timeout=aiohttp.ClientTimeout(total=3)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import locale
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
@@ -52,6 +53,31 @@ def _ensure_safe_path(path: str) -> str:
|
||||
return abs_path
|
||||
|
||||
|
||||
def _decode_shell_output(output: bytes | None) -> str:
|
||||
if output is None:
|
||||
return ""
|
||||
|
||||
preferred = locale.getpreferredencoding(False) or "utf-8"
|
||||
try:
|
||||
return output.decode("utf-8")
|
||||
except (LookupError, UnicodeDecodeError):
|
||||
pass
|
||||
|
||||
if os.name == "nt":
|
||||
for encoding in ("mbcs", "cp936", "gbk", "gb18030"):
|
||||
try:
|
||||
return output.decode(encoding)
|
||||
except (LookupError, UnicodeDecodeError):
|
||||
continue
|
||||
|
||||
try:
|
||||
return output.decode(preferred)
|
||||
except (LookupError, UnicodeDecodeError):
|
||||
pass
|
||||
|
||||
return output.decode("utf-8", errors="replace")
|
||||
|
||||
|
||||
@dataclass
|
||||
class LocalShellComponent(ShellComponent):
|
||||
async def exec(
|
||||
@@ -72,28 +98,32 @@ class LocalShellComponent(ShellComponent):
|
||||
run_env.update({str(k): str(v) for k, v in env.items()})
|
||||
working_dir = _ensure_safe_path(cwd) if cwd else get_astrbot_root()
|
||||
if background:
|
||||
proc = subprocess.Popen(
|
||||
# `command` is intentionally executed through the current shell so
|
||||
# local computer-use behavior matches existing tool semantics.
|
||||
# Safety relies on `_is_safe_command()` and the allowed-root checks.
|
||||
proc = subprocess.Popen( # noqa: S602 # nosemgrep: python.lang.security.audit.dangerous-subprocess-use-audit
|
||||
command,
|
||||
shell=shell,
|
||||
cwd=working_dir,
|
||||
env=run_env,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
return {"pid": proc.pid, "stdout": "", "stderr": "", "exit_code": None}
|
||||
result = subprocess.run(
|
||||
# `command` is intentionally executed through the current shell so
|
||||
# local computer-use behavior matches existing tool semantics.
|
||||
# Safety relies on `_is_safe_command()` and the allowed-root checks.
|
||||
result = subprocess.run( # noqa: S602 # nosemgrep: python.lang.security.audit.dangerous-subprocess-use-audit
|
||||
command,
|
||||
shell=shell,
|
||||
cwd=working_dir,
|
||||
env=run_env,
|
||||
timeout=timeout,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return {
|
||||
"stdout": result.stdout,
|
||||
"stderr": result.stderr,
|
||||
"stdout": _decode_shell_output(result.stdout),
|
||||
"stderr": _decode_shell_output(result.stderr),
|
||||
"exit_code": result.returncode,
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
@@ -41,8 +42,6 @@ def _discover_bay_credentials(endpoint: str) -> str:
|
||||
Returns:
|
||||
API key string, or empty string if not found.
|
||||
"""
|
||||
import os
|
||||
|
||||
candidates: list[Path] = []
|
||||
|
||||
# 1. BAY_DATA_DIR env var
|
||||
@@ -423,6 +422,12 @@ async def get_booter(
|
||||
) -> ComputerBooter:
|
||||
config = context.get_config(umo=session_id)
|
||||
|
||||
runtime = config.get("provider_settings", {}).get("computer_use_runtime", "local")
|
||||
if runtime == "local":
|
||||
return get_local_booter()
|
||||
elif runtime == "none":
|
||||
raise RuntimeError("Sandbox runtime is disabled by configuration.")
|
||||
|
||||
sandbox_cfg = config.get("provider_settings", {}).get("sandbox", {})
|
||||
booter_type = sandbox_cfg.get("booter", "shipyard_neo")
|
||||
|
||||
|
||||
@@ -164,7 +164,10 @@ class CreateSkillPayloadTool(NeoSkillToolBase):
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"payload": {
|
||||
"anyOf": [{"type": "object"}, {"type": "array"}],
|
||||
"anyOf": [
|
||||
{"type": "object"},
|
||||
{"type": "array", "items": {"type": "object"}},
|
||||
],
|
||||
"description": (
|
||||
"Skill payload JSON. Typical schema: {skill_markdown, inputs, outputs, meta}. "
|
||||
"This only stores content and returns payload_ref; it does not create a candidate or release."
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import platform
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
import mcp
|
||||
@@ -10,6 +11,8 @@ from astrbot.core.computer.computer_client import get_booter, get_local_booter
|
||||
from astrbot.core.computer.tools.permissions import check_admin_permission
|
||||
from astrbot.core.message.message_event_result import MessageChain
|
||||
|
||||
_OS_NAME = platform.system()
|
||||
|
||||
param_schema = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -61,7 +64,7 @@ async def handle_result(result: dict, event: AstrMessageEvent) -> ToolExecResult
|
||||
@dataclass
|
||||
class PythonTool(FunctionTool):
|
||||
name: str = "astrbot_execute_ipython"
|
||||
description: str = "Run codes in an IPython shell."
|
||||
description: str = f"Run codes in an IPython shell. Current OS: {_OS_NAME}."
|
||||
parameters: dict = field(default_factory=lambda: param_schema)
|
||||
|
||||
async def call(
|
||||
@@ -83,7 +86,10 @@ class PythonTool(FunctionTool):
|
||||
@dataclass
|
||||
class LocalPythonTool(FunctionTool):
|
||||
name: str = "astrbot_execute_python"
|
||||
description: str = "Execute codes in a Python environment."
|
||||
description: str = (
|
||||
f"Execute codes in a Python environment. Current OS: {_OS_NAME}. "
|
||||
"Use system-compatible commands."
|
||||
)
|
||||
|
||||
parameters: dict = field(default_factory=lambda: param_schema)
|
||||
|
||||
|
||||
+198
-48
@@ -5,7 +5,7 @@ from typing import Any, TypedDict
|
||||
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
||||
|
||||
VERSION = "4.18.3"
|
||||
VERSION = "4.20.0"
|
||||
DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db")
|
||||
|
||||
WEBHOOK_SUPPORTED_PLATFORMS = [
|
||||
@@ -219,6 +219,9 @@ DEFAULT_CONFIG = {
|
||||
"telegram": {
|
||||
"pre_ack_emoji": {"enable": False, "emojis": ["✍️"]},
|
||||
},
|
||||
"discord": {
|
||||
"pre_ack_emoji": {"enable": False, "emojis": ["🤔"]},
|
||||
},
|
||||
},
|
||||
"wake_prefix": ["/"],
|
||||
"log_level": "INFO",
|
||||
@@ -342,14 +345,20 @@ CONFIG_METADATA_2 = {
|
||||
"企业微信智能机器人": {
|
||||
"id": "wecom_ai_bot",
|
||||
"type": "wecom_ai_bot",
|
||||
"hint": "如果发现字段有异常,请重新创建",
|
||||
"enable": True,
|
||||
"wecom_ai_bot_connection_mode": "long_connection", # long_connection, webhook
|
||||
"wecom_ai_bot_name": "",
|
||||
"wecomaibot_ws_bot_id": "",
|
||||
"wecomaibot_ws_secret": "",
|
||||
"wecomaibot_token": "",
|
||||
"wecomaibot_encoding_aes_key": "",
|
||||
"wecomaibot_init_respond_text": "",
|
||||
"wecomaibot_friend_message_welcome_text": "",
|
||||
"wecom_ai_bot_name": "",
|
||||
"msg_push_webhook_url": "",
|
||||
"only_use_webhook_url_to_send": False,
|
||||
"token": "",
|
||||
"encoding_aes_key": "",
|
||||
"wecomaibot_ws_url": "wss://openws.work.weixin.qq.com",
|
||||
"wecomaibot_heartbeat_interval": 30,
|
||||
"unified_webhook_mode": True,
|
||||
"webhook_uuid": "",
|
||||
"callback_server_host": "0.0.0.0",
|
||||
@@ -395,7 +404,6 @@ CONFIG_METADATA_2 = {
|
||||
"discord_token": "",
|
||||
"discord_proxy": "",
|
||||
"discord_command_register": True,
|
||||
"discord_guild_id_for_debug": "",
|
||||
"discord_activity_name": "",
|
||||
},
|
||||
"Misskey": {
|
||||
@@ -450,6 +458,20 @@ CONFIG_METADATA_2 = {
|
||||
"satori_heartbeat_interval": 10,
|
||||
"satori_reconnect_delay": 5,
|
||||
},
|
||||
"kook": {
|
||||
"id": "kook",
|
||||
"type": "kook",
|
||||
"enable": False,
|
||||
"kook_bot_token": "",
|
||||
"kook_bot_nickname": "",
|
||||
"kook_reconnect_delay": 1,
|
||||
"kook_max_reconnect_delay": 60,
|
||||
"kook_max_retry_delay": 60,
|
||||
"kook_heartbeat_interval": 30,
|
||||
"kook_heartbeat_timeout": 6,
|
||||
"kook_max_heartbeat_failures": 3,
|
||||
"kook_max_consecutive_failures": 5,
|
||||
},
|
||||
# "WebChat": {
|
||||
# "id": "webchat",
|
||||
# "type": "webchat",
|
||||
@@ -719,6 +741,13 @@ CONFIG_METADATA_2 = {
|
||||
"type": "string",
|
||||
"hint": "请务必填写正确,否则无法使用一些指令。",
|
||||
},
|
||||
"wecom_ai_bot_connection_mode": {
|
||||
"description": "企业微信智能机器人连接模式",
|
||||
"type": "string",
|
||||
"options": ["webhook", "long_connection"],
|
||||
"labels": ["Webhook 回调", "长连接"],
|
||||
"hint": "Webhook 回调模式需要配置 Token/EncodingAESKey。长连接模式需要配置 BotID/Secret。",
|
||||
},
|
||||
"wecomaibot_init_respond_text": {
|
||||
"description": "企业微信智能机器人初始响应文本",
|
||||
"type": "string",
|
||||
@@ -729,6 +758,22 @@ CONFIG_METADATA_2 = {
|
||||
"type": "string",
|
||||
"hint": "当用户当天进入智能机器人单聊会话,回复欢迎语,留空则不回复。",
|
||||
},
|
||||
"wecomaibot_token": {
|
||||
"description": "企业微信智能机器人 Token",
|
||||
"type": "string",
|
||||
"hint": "用于 Webhook 回调模式的身份验证。",
|
||||
"condition": {
|
||||
"wecom_ai_bot_connection_mode": "webhook",
|
||||
},
|
||||
},
|
||||
"wecomaibot_encoding_aes_key": {
|
||||
"description": "企业微信智能机器人 EncodingAESKey",
|
||||
"type": "string",
|
||||
"hint": "用于 Webhook 回调模式的消息加密解密。",
|
||||
"condition": {
|
||||
"wecom_ai_bot_connection_mode": "webhook",
|
||||
},
|
||||
},
|
||||
"msg_push_webhook_url": {
|
||||
"description": "企业微信消息推送 Webhook URL",
|
||||
"type": "string",
|
||||
@@ -739,6 +784,40 @@ CONFIG_METADATA_2 = {
|
||||
"type": "bool",
|
||||
"hint": "启用后,企业微信智能机器人的所有回复都改为通过消息推送 Webhook 发送。消息推送 Webhook 支持更多的消息类型(如图片、文件等)。",
|
||||
},
|
||||
"wecomaibot_ws_bot_id": {
|
||||
"description": "长连接 BotID",
|
||||
"type": "string",
|
||||
"hint": "企业微信智能机器人长连接模式凭证 BotID。",
|
||||
"condition": {
|
||||
"wecom_ai_bot_connection_mode": "long_connection",
|
||||
},
|
||||
},
|
||||
"wecomaibot_ws_secret": {
|
||||
"description": "长连接 Secret",
|
||||
"type": "string",
|
||||
"hint": "企业微信智能机器人长连接模式凭证 Secret。",
|
||||
"condition": {
|
||||
"wecom_ai_bot_connection_mode": "long_connection",
|
||||
},
|
||||
},
|
||||
"wecomaibot_ws_url": {
|
||||
"description": "长连接 WebSocket 地址",
|
||||
"type": "string",
|
||||
"invisible": True,
|
||||
"hint": "默认值为 wss://openws.work.weixin.qq.com,一般无需修改。",
|
||||
"condition": {
|
||||
"wecom_ai_bot_connection_mode": "long_connection",
|
||||
},
|
||||
},
|
||||
"wecomaibot_heartbeat_interval": {
|
||||
"description": "长连接心跳间隔",
|
||||
"type": "int",
|
||||
"invisible": True,
|
||||
"hint": "长连接模式心跳间隔(秒),建议 30 秒。",
|
||||
"condition": {
|
||||
"wecom_ai_bot_connection_mode": "long_connection",
|
||||
},
|
||||
},
|
||||
"lark_bot_name": {
|
||||
"description": "飞书机器人的名字",
|
||||
"type": "string",
|
||||
@@ -755,7 +834,8 @@ CONFIG_METADATA_2 = {
|
||||
"hint": "可选的代理地址:http://ip:port",
|
||||
},
|
||||
"discord_command_register": {
|
||||
"description": "是否自动将插件指令注册为 Discord 斜杠指令",
|
||||
"description": "注册 Discord 指令",
|
||||
"hint": "启用后,自动将插件指令注册为 Discord 斜杠指令",
|
||||
"type": "bool",
|
||||
},
|
||||
"discord_activity_name": {
|
||||
@@ -782,7 +862,7 @@ CONFIG_METADATA_2 = {
|
||||
"unified_webhook_mode": {
|
||||
"description": "统一 Webhook 模式",
|
||||
"type": "bool",
|
||||
"hint": "启用后,将使用 AstrBot 统一 Webhook 入口,无需单独开启端口。回调地址为 /api/platform/webhook/{webhook_uuid}。",
|
||||
"hint": "Webhook 模式下使用 AstrBot 统一 Webhook 入口,无需单独开启端口。回调地址为 /api/platform/webhook/{webhook_uuid}。",
|
||||
},
|
||||
"webhook_uuid": {
|
||||
"invisible": True,
|
||||
@@ -790,6 +870,51 @@ CONFIG_METADATA_2 = {
|
||||
"type": "string",
|
||||
"hint": "统一 Webhook 模式下的唯一标识符,创建平台时自动生成。",
|
||||
},
|
||||
"kook_bot_token": {
|
||||
"description": "机器人 Token",
|
||||
"type": "string",
|
||||
"hint": "必填项。从 KOOK 开发者平台获取的机器人 Token。",
|
||||
},
|
||||
"kook_bot_nickname": {
|
||||
"description": "Bot Nickname",
|
||||
"type": "string",
|
||||
"hint": "可选项。若发送者昵称与此值一致,将忽略该消息以避免广播风暴。",
|
||||
},
|
||||
"kook_reconnect_delay": {
|
||||
"description": "重连延迟",
|
||||
"type": "int",
|
||||
"hint": "重连延迟时间(秒),使用指数退避策略。",
|
||||
},
|
||||
"kook_max_reconnect_delay": {
|
||||
"description": "最大重连延迟",
|
||||
"type": "int",
|
||||
"hint": "重连延迟的最大值(秒)。",
|
||||
},
|
||||
"kook_max_retry_delay": {
|
||||
"description": "最大重试延迟",
|
||||
"type": "int",
|
||||
"hint": "重试的最大延迟时间(秒)。",
|
||||
},
|
||||
"kook_heartbeat_interval": {
|
||||
"description": "心跳间隔",
|
||||
"type": "int",
|
||||
"hint": "心跳检测间隔时间(秒)。",
|
||||
},
|
||||
"kook_heartbeat_timeout": {
|
||||
"description": "心跳超时时间",
|
||||
"type": "int",
|
||||
"hint": "心跳检测超时时间(秒)。",
|
||||
},
|
||||
"kook_max_heartbeat_failures": {
|
||||
"description": "最大心跳失败次数",
|
||||
"type": "int",
|
||||
"hint": "允许的最大心跳失败次数,超过后断开连接。",
|
||||
},
|
||||
"kook_max_consecutive_failures": {
|
||||
"description": "最大连续失败次数",
|
||||
"type": "int",
|
||||
"hint": "允许的最大连续失败次数,超过后停止重试。",
|
||||
},
|
||||
},
|
||||
},
|
||||
"platform_settings": {
|
||||
@@ -1007,6 +1132,18 @@ CONFIG_METADATA_2 = {
|
||||
"proxy": "",
|
||||
"custom_headers": {},
|
||||
},
|
||||
"MiniMax": {
|
||||
"id": "minimax",
|
||||
"provider": "minimax",
|
||||
"type": "openai_chat_completion",
|
||||
"provider_type": "chat_completion",
|
||||
"enable": True,
|
||||
"key": [],
|
||||
"api_base": "https://api.minimaxi.com/v1",
|
||||
"timeout": 120,
|
||||
"proxy": "",
|
||||
"custom_headers": {},
|
||||
},
|
||||
"xAI": {
|
||||
"id": "xai",
|
||||
"provider": "xai",
|
||||
@@ -1064,7 +1201,7 @@ CONFIG_METADATA_2 = {
|
||||
"enable": True,
|
||||
"key": [],
|
||||
"timeout": 120,
|
||||
"api_base": "https://openrouter.ai/v1",
|
||||
"api_base": "https://openrouter.ai/api/v1",
|
||||
"proxy": "",
|
||||
"custom_headers": {},
|
||||
},
|
||||
@@ -3152,46 +3289,6 @@ 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",
|
||||
@@ -3235,6 +3332,46 @@ CONFIG_METADATA_3 = {
|
||||
"type": "bool",
|
||||
"hint": "/provider 命令列出模型时是否并发检测连通性。开启后会主动调用模型测试连通性,可能产生额外 token 消耗。",
|
||||
},
|
||||
"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",
|
||||
},
|
||||
},
|
||||
},
|
||||
"condition": {
|
||||
"provider_settings.enable": True,
|
||||
@@ -3446,6 +3583,19 @@ CONFIG_METADATA_3 = {
|
||||
"platform_specific.telegram.pre_ack_emoji.enable": True,
|
||||
},
|
||||
},
|
||||
"platform_specific.discord.pre_ack_emoji.enable": {
|
||||
"description": "[Discord] 启用预回应表情",
|
||||
"type": "bool",
|
||||
},
|
||||
"platform_specific.discord.pre_ack_emoji.emojis": {
|
||||
"description": "表情列表(Unicode 或自定义表情名)",
|
||||
"type": "list",
|
||||
"items": {"type": "string"},
|
||||
"hint": "填写 Unicode 表情符号,例如:👍、🤔、⏳",
|
||||
"condition": {
|
||||
"platform_specific.discord.pre_ack_emoji.enable": True,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -332,9 +332,9 @@ class CronJobManager:
|
||||
cron_job=cron_job_str
|
||||
)
|
||||
req.prompt = (
|
||||
"You are now responding to a scheduled task"
|
||||
"You are now responding to a scheduled task. "
|
||||
"Proceed according to your system instructions. "
|
||||
"Output using same language as previous conversation."
|
||||
"Output using same language as previous conversation. "
|
||||
"After completing your task, summarize and output your actions and results."
|
||||
)
|
||||
if not req.func_tool:
|
||||
|
||||
@@ -647,6 +647,13 @@ class BaseDatabase(abc.ABC):
|
||||
"""Get a Platform session by its ID."""
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
async def get_platform_sessions_by_ids(
|
||||
self, session_ids: list[str]
|
||||
) -> list[PlatformSession]:
|
||||
"""Get platform sessions by IDs."""
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
async def get_platform_sessions_by_creator(
|
||||
self,
|
||||
|
||||
@@ -1417,6 +1417,21 @@ class SQLiteDatabase(BaseDatabase):
|
||||
result = await session.execute(query)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_platform_sessions_by_ids(
|
||||
self, session_ids: list[str]
|
||||
) -> list[PlatformSession]:
|
||||
"""Get platform sessions by IDs."""
|
||||
if not session_ids:
|
||||
return []
|
||||
|
||||
async with self.get_db() as session:
|
||||
session: AsyncSession
|
||||
query = select(PlatformSession).where(
|
||||
col(PlatformSession.session_id).in_(session_ids)
|
||||
)
|
||||
result = await session.execute(query)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def get_platform_sessions_by_creator(
|
||||
self,
|
||||
creator: str,
|
||||
|
||||
@@ -175,6 +175,10 @@ class LogManager:
|
||||
_trace_sink_id: int | None = None
|
||||
_NOISY_LOGGER_LEVELS: dict[str, int] = {
|
||||
"aiosqlite": logging.WARNING,
|
||||
"filelock": logging.WARNING,
|
||||
"asyncio": logging.WARNING,
|
||||
"tzlocal": logging.WARNING,
|
||||
"apscheduler": logging.WARNING,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -96,10 +96,10 @@ class Plain(BaseMessageComponent):
|
||||
def __init__(self, text: str, convert: bool = True, **_) -> None:
|
||||
super().__init__(text=text, convert=convert, **_)
|
||||
|
||||
def toDict(self):
|
||||
return {"type": "text", "data": {"text": self.text.strip()}}
|
||||
def toDict(self) -> dict:
|
||||
return {"type": "text", "data": {"text": self.text}}
|
||||
|
||||
async def to_dict(self):
|
||||
async def to_dict(self) -> dict:
|
||||
return {"type": "text", "data": {"text": self.text}}
|
||||
|
||||
|
||||
@@ -539,13 +539,36 @@ class Reply(BaseMessageComponent):
|
||||
|
||||
|
||||
class Poke(BaseMessageComponent):
|
||||
type: str = ComponentType.Poke
|
||||
id: int | None = 0
|
||||
qq: int | None = 0
|
||||
type: ComponentType = ComponentType.Poke
|
||||
_type: str | int = "126"
|
||||
id: int | str | None = 0
|
||||
qq: int | str | None = 0 # deprecated: legacy field, kept for compatibility
|
||||
|
||||
def __init__(self, type: str, **_) -> None:
|
||||
type = f"Poke:{type}"
|
||||
super().__init__(type=type, **_)
|
||||
def __init__(self, poke_type: str | int | None = None, **_) -> None:
|
||||
# Backward compatible with old signature: Poke(type="poke", ...)
|
||||
legacy_type = _.pop("type", None)
|
||||
if poke_type is None:
|
||||
poke_type = legacy_type
|
||||
if poke_type in (None, "", "poke", "Poke"):
|
||||
poke_type = "126"
|
||||
super().__init__(_type=str(poke_type), **_)
|
||||
|
||||
def target_id(self) -> str | None:
|
||||
"""Return normalized target id, compatible with old `qq` field."""
|
||||
for value in (self.id, self.qq):
|
||||
if value is None:
|
||||
continue
|
||||
text = str(value).strip()
|
||||
if text and text != "0":
|
||||
return text
|
||||
return None
|
||||
|
||||
def toDict(self):
|
||||
target_id = self.target_id()
|
||||
data = {"type": str(self._type or "126")}
|
||||
if target_id:
|
||||
data["id"] = target_id
|
||||
return {"type": "poke", "data": data}
|
||||
|
||||
|
||||
class Forward(BaseMessageComponent):
|
||||
@@ -676,21 +699,24 @@ class File(BaseMessageComponent):
|
||||
|
||||
if self.url:
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
if loop.is_running():
|
||||
logger.warning(
|
||||
"不可以在异步上下文中同步等待下载! "
|
||||
"这个警告通常发生于某些逻辑试图通过 <File>.file 获取文件消息段的文件内容。"
|
||||
"请使用 await get_file() 代替直接获取 <File>.file 字段",
|
||||
)
|
||||
return ""
|
||||
# 等待下载完成
|
||||
loop.run_until_complete(self._download_file())
|
||||
# 检查是否有正在运行的 event loop
|
||||
asyncio.get_running_loop()
|
||||
logger.warning(
|
||||
"不可以在异步上下文中同步等待下载! "
|
||||
"这个警告通常发生于某些逻辑试图通过 <File>.file 获取文件消息段的文件内容。"
|
||||
"请使用 await get_file() 代替直接获取 <File>.file 字段",
|
||||
)
|
||||
return ""
|
||||
except RuntimeError:
|
||||
# 没有运行中的 event loop,可以同步执行
|
||||
try:
|
||||
# 使用 asyncio.run 安全地创建和关闭事件循环
|
||||
asyncio.run(self._download_file())
|
||||
except Exception:
|
||||
logger.exception("文件下载失败")
|
||||
|
||||
if self.file_ and os.path.exists(self.file_):
|
||||
return os.path.abspath(self.file_)
|
||||
except Exception as e:
|
||||
logger.error(f"文件下载失败: {e}")
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ class PreProcessStage(Stage):
|
||||
) -> None | AsyncGenerator[None, None]:
|
||||
"""在处理事件之前的预处理"""
|
||||
# 平台特异配置:platform_specific.<platform>.pre_ack_emoji
|
||||
supported = {"telegram", "lark"}
|
||||
supported = {"telegram", "lark", "discord"}
|
||||
platform = event.get_platform_name()
|
||||
cfg = (
|
||||
self.config.get("platform_specific", {})
|
||||
|
||||
@@ -28,7 +28,7 @@ class RespondStage(Stage):
|
||||
Comp.At: lambda comp: bool(comp.qq) or bool(comp.name), # @
|
||||
Comp.Image: lambda comp: bool(comp.file), # 图片
|
||||
Comp.Reply: lambda comp: bool(comp.id) and comp.sender_id is not None, # 回复
|
||||
Comp.Poke: lambda comp: comp.id != 0 and comp.qq != 0, # 戳一戳
|
||||
Comp.Poke: lambda comp: comp.target_id() is not None, # 戳一戳
|
||||
Comp.Node: lambda comp: bool(comp.content), # 转发节点
|
||||
Comp.Nodes: lambda comp: bool(comp.nodes), # 多个转发节点
|
||||
Comp.File: lambda comp: bool(comp.file_ or comp.url),
|
||||
|
||||
@@ -5,7 +5,7 @@ import traceback
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
from astrbot.core import file_token_service, html_renderer, logger
|
||||
from astrbot.core.message.components import At, File, Image, Node, Plain, Record, Reply
|
||||
from astrbot.core.message.components import At, Image, Node, Plain, Record, Reply
|
||||
from astrbot.core.message.message_event_result import ResultContentType
|
||||
from astrbot.core.pipeline.content_safety_check.stage import ContentSafetyCheckStage
|
||||
from astrbot.core.platform.astr_message_event import AstrMessageEvent
|
||||
@@ -383,8 +383,11 @@ class ResultDecorateStage(Stage):
|
||||
)
|
||||
result.chain = [node]
|
||||
|
||||
has_plain = any(isinstance(item, Plain) for item in result.chain)
|
||||
if has_plain:
|
||||
# at 回复 / 引用回复仅适用于纯文本或图文消息
|
||||
can_decorate = all(
|
||||
isinstance(item, (Plain, Image)) for item in result.chain
|
||||
)
|
||||
if can_decorate:
|
||||
# at 回复
|
||||
if (
|
||||
self.reply_with_mention
|
||||
@@ -399,5 +402,4 @@ class ResultDecorateStage(Stage):
|
||||
|
||||
# 引用回复
|
||||
if self.reply_with_quote:
|
||||
if not any(isinstance(item, File) for item in result.chain):
|
||||
result.chain.insert(0, Reply(id=event.message_obj.message_id))
|
||||
result.chain.insert(0, Reply(id=event.message_obj.message_id))
|
||||
|
||||
@@ -180,6 +180,10 @@ class PlatformManager:
|
||||
from .sources.line.line_adapter import (
|
||||
LinePlatformAdapter, # noqa: F401
|
||||
)
|
||||
case "kook":
|
||||
from .sources.kook.kook_adapter import (
|
||||
KookPlatformAdapter, # noqa: F401
|
||||
)
|
||||
except (ImportError, ModuleNotFoundError) as e:
|
||||
logger.error(
|
||||
f"加载平台适配器 {platform_config['type']} 失败,原因:{e}。请检查依赖库是否安装。提示:可以在 管理面板->平台日志->安装Pip库 中安装依赖库。",
|
||||
|
||||
@@ -6,6 +6,7 @@ from aiocqhttp import CQHttp, Event
|
||||
|
||||
from astrbot.api.event import AstrMessageEvent, MessageChain
|
||||
from astrbot.api.message_components import (
|
||||
At,
|
||||
BaseMessageComponent,
|
||||
File,
|
||||
Image,
|
||||
@@ -70,11 +71,19 @@ class AiocqhttpMessageEvent(AstrMessageEvent):
|
||||
"""解析成 OneBot json 格式"""
|
||||
ret = []
|
||||
for segment in message_chain.chain:
|
||||
if isinstance(segment, Plain):
|
||||
if isinstance(segment, At):
|
||||
# At 组件后插入一个空格,避免与后续文本粘连
|
||||
d = await AiocqhttpMessageEvent._from_segment_to_dict(segment)
|
||||
ret.append(d)
|
||||
ret.append({"type": "text", "data": {"text": " "}})
|
||||
elif isinstance(segment, Plain):
|
||||
if not segment.text.strip():
|
||||
continue
|
||||
d = await AiocqhttpMessageEvent._from_segment_to_dict(segment)
|
||||
ret.append(d)
|
||||
d = await AiocqhttpMessageEvent._from_segment_to_dict(segment)
|
||||
ret.append(d)
|
||||
else:
|
||||
d = await AiocqhttpMessageEvent._from_segment_to_dict(segment)
|
||||
ret.append(d)
|
||||
return ret
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -191,7 +191,7 @@ class AiocqhttpAdapter(Platform):
|
||||
|
||||
if "sub_type" in event:
|
||||
if event["sub_type"] == "poke" and "target_id" in event:
|
||||
abm.message.append(Poke(qq=str(event["target_id"]), type="poke"))
|
||||
abm.message.append(Poke(id=str(event["target_id"])))
|
||||
|
||||
return abm
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ from dingtalk_stream import AckMessage
|
||||
|
||||
from astrbot import logger
|
||||
from astrbot.api.event import MessageChain
|
||||
from astrbot.api.message_components import At, Image, Plain, Record, Video
|
||||
from astrbot.api.message_components import At, File, Image, Plain, Record, Video
|
||||
from astrbot.api.platform import (
|
||||
AstrBotMessage,
|
||||
MessageMember,
|
||||
@@ -178,29 +178,110 @@ class DingtalkPlatformAdapter(Platform):
|
||||
abm.session_id = abm.sender.user_id
|
||||
|
||||
message_type: str = cast(str, message.message_type)
|
||||
robot_code = cast(str, message.robot_code or "")
|
||||
raw_content = cast(dict, message.extensions.get("content") or {})
|
||||
if not isinstance(raw_content, dict):
|
||||
raw_content = {}
|
||||
match message_type:
|
||||
case "text":
|
||||
abm.message_str = message.text.content.strip()
|
||||
abm.message.append(Plain(abm.message_str))
|
||||
case "picture":
|
||||
if not robot_code:
|
||||
logger.error("钉钉图片消息解析失败: 回调中缺少 robotCode")
|
||||
await self._remember_sender_binding(message, abm)
|
||||
return abm
|
||||
image_content = cast(
|
||||
dingtalk_stream.ImageContent | None,
|
||||
message.image_content,
|
||||
)
|
||||
download_code = cast(
|
||||
str, (image_content.download_code if image_content else "") or ""
|
||||
)
|
||||
if not download_code:
|
||||
logger.warning("钉钉图片消息缺少 downloadCode,已跳过")
|
||||
else:
|
||||
f_path = await self.download_ding_file(
|
||||
download_code,
|
||||
robot_code,
|
||||
"jpg",
|
||||
)
|
||||
if f_path:
|
||||
abm.message.append(Image.fromFileSystem(f_path))
|
||||
else:
|
||||
logger.warning("钉钉图片消息下载失败,无法解析为图片")
|
||||
case "richText":
|
||||
rtc: dingtalk_stream.RichTextContent = cast(
|
||||
dingtalk_stream.RichTextContent, message.rich_text_content
|
||||
)
|
||||
contents: list[dict] = cast(list[dict], rtc.rich_text_list)
|
||||
plain_parts: list[str] = []
|
||||
for content in contents:
|
||||
plains = ""
|
||||
if "text" in content:
|
||||
plains += content["text"]
|
||||
abm.message.append(Plain(plains))
|
||||
plain_text = cast(str, content.get("text") or "")
|
||||
if plain_text:
|
||||
plain_parts.append(plain_text)
|
||||
abm.message.append(Plain(plain_text))
|
||||
elif "type" in content and content["type"] == "picture":
|
||||
download_code = cast(str, content.get("downloadCode") or "")
|
||||
if not download_code:
|
||||
logger.warning(
|
||||
"钉钉富文本图片消息缺少 downloadCode,已跳过"
|
||||
)
|
||||
continue
|
||||
if not robot_code:
|
||||
logger.error(
|
||||
"钉钉富文本图片消息解析失败: 回调中缺少 robotCode"
|
||||
)
|
||||
continue
|
||||
f_path = await self.download_ding_file(
|
||||
content["downloadCode"],
|
||||
cast(str, message.robot_code),
|
||||
download_code,
|
||||
robot_code,
|
||||
"jpg",
|
||||
)
|
||||
abm.message.append(Image.fromFileSystem(f_path))
|
||||
case "audio":
|
||||
pass
|
||||
if f_path:
|
||||
abm.message.append(Image.fromFileSystem(f_path))
|
||||
abm.message_str = "".join(plain_parts).strip()
|
||||
case "audio" | "voice":
|
||||
download_code = cast(str, raw_content.get("downloadCode") or "")
|
||||
if not download_code:
|
||||
logger.warning("钉钉语音消息缺少 downloadCode,已跳过")
|
||||
elif not robot_code:
|
||||
logger.error("钉钉语音消息解析失败: 回调中缺少 robotCode")
|
||||
else:
|
||||
voice_ext = cast(str, raw_content.get("fileExtension") or "")
|
||||
if not voice_ext:
|
||||
voice_ext = "amr"
|
||||
voice_ext = voice_ext.lstrip(".")
|
||||
f_path = await self.download_ding_file(
|
||||
download_code,
|
||||
robot_code,
|
||||
voice_ext,
|
||||
)
|
||||
if f_path:
|
||||
abm.message.append(Record.fromFileSystem(f_path))
|
||||
case "file":
|
||||
download_code = cast(str, raw_content.get("downloadCode") or "")
|
||||
if not download_code:
|
||||
logger.warning("钉钉文件消息缺少 downloadCode,已跳过")
|
||||
elif not robot_code:
|
||||
logger.error("钉钉文件消息解析失败: 回调中缺少 robotCode")
|
||||
else:
|
||||
file_name = cast(str, raw_content.get("fileName") or "")
|
||||
file_ext = Path(file_name).suffix.lstrip(".") if file_name else ""
|
||||
if not file_ext:
|
||||
file_ext = cast(str, raw_content.get("fileExtension") or "")
|
||||
if not file_ext:
|
||||
file_ext = "file"
|
||||
f_path = await self.download_ding_file(
|
||||
download_code,
|
||||
robot_code,
|
||||
file_ext,
|
||||
)
|
||||
if f_path:
|
||||
if not file_name:
|
||||
file_name = Path(f_path).name
|
||||
abm.message.append(File(name=file_name, file=f_path))
|
||||
|
||||
await self._remember_sender_binding(message, abm)
|
||||
return abm # 别忘了返回转换后的消息对象
|
||||
@@ -270,13 +351,23 @@ class DingtalkPlatformAdapter(Platform):
|
||||
)
|
||||
return ""
|
||||
resp_data = await resp.json()
|
||||
download_url = resp_data["data"]["downloadUrl"]
|
||||
download_url = cast(
|
||||
str,
|
||||
(
|
||||
resp_data.get("downloadUrl")
|
||||
or resp_data.get("data", {}).get("downloadUrl")
|
||||
or ""
|
||||
),
|
||||
)
|
||||
if not download_url:
|
||||
logger.error(f"下载钉钉文件失败: 未找到 downloadUrl, 响应: {resp_data}")
|
||||
return ""
|
||||
await download_file(download_url, str(f_path))
|
||||
return str(f_path)
|
||||
|
||||
async def get_access_token(self) -> str:
|
||||
try:
|
||||
access_token = await asyncio.get_event_loop().run_in_executor(
|
||||
access_token = await asyncio.get_running_loop().run_in_executor(
|
||||
None,
|
||||
self.client_.get_access_token,
|
||||
)
|
||||
@@ -541,6 +632,28 @@ class DingtalkPlatformAdapter(Platform):
|
||||
self._safe_remove_file(cover_path)
|
||||
if converted_video:
|
||||
self._safe_remove_file(video_path)
|
||||
elif isinstance(segment, File):
|
||||
try:
|
||||
file_path = await segment.get_file()
|
||||
if not file_path:
|
||||
logger.warning("钉钉文件发送失败: 无法解析文件路径")
|
||||
continue
|
||||
media_id = await self.upload_media(file_path, "file")
|
||||
if not media_id:
|
||||
continue
|
||||
file_name = segment.name or Path(file_path).name
|
||||
file_type = Path(file_name).suffix.lstrip(".")
|
||||
await send_message(
|
||||
msg_key="sampleFile",
|
||||
msg_param={
|
||||
"mediaId": media_id,
|
||||
"fileName": file_name,
|
||||
"fileType": file_type,
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"钉钉文件发送失败: {e}")
|
||||
continue
|
||||
|
||||
async def send_message_chain_to_group(
|
||||
self,
|
||||
@@ -647,7 +760,7 @@ class DingtalkPlatformAdapter(Platform):
|
||||
return
|
||||
logger.error(f"钉钉机器人启动失败: {e}")
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
loop = asyncio.get_running_loop()
|
||||
await loop.run_in_executor(None, start_client, loop)
|
||||
|
||||
async def terminate(self) -> None:
|
||||
|
||||
@@ -0,0 +1,371 @@
|
||||
import asyncio
|
||||
import json
|
||||
import re
|
||||
|
||||
from astrbot import logger
|
||||
from astrbot.api.event import MessageChain
|
||||
from astrbot.api.message_components import At, AtAll, Image, Plain
|
||||
from astrbot.api.platform import (
|
||||
AstrBotMessage,
|
||||
MessageMember,
|
||||
MessageType,
|
||||
Platform,
|
||||
PlatformMetadata,
|
||||
register_platform_adapter,
|
||||
)
|
||||
from astrbot.core.platform.astr_message_event import MessageSesion
|
||||
|
||||
from .kook_client import KookClient
|
||||
from .kook_config import KookConfig
|
||||
from .kook_event import KookEvent
|
||||
|
||||
|
||||
@register_platform_adapter(
|
||||
"kook",
|
||||
"KOOK 适配器",
|
||||
)
|
||||
class KookPlatformAdapter(Platform):
|
||||
def __init__(
|
||||
self, platform_config: dict, platform_settings: dict, event_queue: asyncio.Queue
|
||||
) -> None:
|
||||
super().__init__(platform_config, event_queue)
|
||||
self.kook_config = KookConfig.from_dict(platform_config)
|
||||
logger.debug(f"[KOOK] 配置: {self.kook_config.pretty_jsons()}")
|
||||
self.settings = platform_settings
|
||||
self.client = KookClient(self.kook_config, self._on_received)
|
||||
self._reconnect_task = None
|
||||
self.running = False
|
||||
self._main_task = None
|
||||
|
||||
async def send_by_session(
|
||||
self, session: MessageSesion, message_chain: MessageChain
|
||||
):
|
||||
inner_message = AstrBotMessage()
|
||||
inner_message.session_id = session.session_id
|
||||
inner_message.type = session.message_type
|
||||
message_event = KookEvent(
|
||||
message_str=message_chain.get_plain_text(),
|
||||
message_obj=inner_message,
|
||||
platform_meta=self.meta(),
|
||||
session_id=session.session_id,
|
||||
client=self.client,
|
||||
)
|
||||
await message_event.send(message_chain)
|
||||
|
||||
def meta(self) -> PlatformMetadata:
|
||||
return PlatformMetadata(
|
||||
name="kook", description="KOOK 适配器", id=self.kook_config.id
|
||||
)
|
||||
|
||||
def _should_ignore_event_by_bot_nickname(self, payload: dict) -> bool:
|
||||
bot_nickname = self.kook_config.bot_nickname.strip()
|
||||
if not bot_nickname:
|
||||
return False
|
||||
|
||||
author = payload.get("extra", {}).get("author", {})
|
||||
if not isinstance(author, dict):
|
||||
return False
|
||||
|
||||
author_nickname = author.get("nickname") or author.get("username") or ""
|
||||
if not isinstance(author_nickname, str):
|
||||
author_nickname = str(author_nickname)
|
||||
|
||||
return author_nickname.strip().casefold() == bot_nickname.casefold()
|
||||
|
||||
async def _on_received(self, data: dict):
|
||||
logger.debug(f"KOOK 收到数据: {data}")
|
||||
if "d" in data and data["s"] == 0:
|
||||
payload = data["d"]
|
||||
event_type = payload.get("type")
|
||||
# 支持type=9(文本)和type=10(卡片)
|
||||
if event_type in (9, 10):
|
||||
if self._should_ignore_event_by_bot_nickname(payload):
|
||||
return
|
||||
try:
|
||||
abm = await self.convert_message(payload)
|
||||
await self.handle_msg(abm)
|
||||
except Exception as e:
|
||||
logger.error(f"[KOOK] 消息处理异常: {e}")
|
||||
|
||||
async def run(self):
|
||||
"""主运行循环"""
|
||||
self.running = True
|
||||
logger.info("[KOOK] 启动KOOK适配器")
|
||||
|
||||
# 启动主循环
|
||||
self._main_task = asyncio.create_task(self._main_loop())
|
||||
|
||||
try:
|
||||
await self._main_task
|
||||
except asyncio.CancelledError:
|
||||
logger.info("[KOOK] 适配器被取消")
|
||||
except Exception as e:
|
||||
logger.error(f"[KOOK] 适配器运行异常: {e}")
|
||||
finally:
|
||||
self.running = False
|
||||
await self._cleanup()
|
||||
|
||||
async def _main_loop(self):
|
||||
"""主循环,处理连接和重连"""
|
||||
consecutive_failures = 0
|
||||
max_consecutive_failures = self.kook_config.max_consecutive_failures
|
||||
max_retry_delay = self.kook_config.max_retry_delay
|
||||
|
||||
while self.running:
|
||||
try:
|
||||
logger.info("[KOOK] 尝试连接KOOK服务器...")
|
||||
|
||||
# 尝试连接
|
||||
success = await self.client.connect()
|
||||
|
||||
if success:
|
||||
logger.info("[KOOK] 连接成功,开始监听消息")
|
||||
consecutive_failures = 0 # 重置失败计数
|
||||
|
||||
# 等待连接结束(可能是正常关闭或异常)
|
||||
while self.client.running and self.running:
|
||||
try:
|
||||
# 等待 client 内部触发 _stop_event,或者超时 1 秒后重试
|
||||
# 使用 wait_for 配合 timeout 是为了防止极端情况下 self.running 变化没被察觉
|
||||
await asyncio.wait_for(
|
||||
self.client.wait_until_closed(), timeout=1.0
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
# 正常超时,继续下一轮 while 检查
|
||||
continue
|
||||
|
||||
if self.running:
|
||||
logger.warning("[KOOK] 连接断开,准备重连")
|
||||
|
||||
else:
|
||||
consecutive_failures += 1
|
||||
logger.error(
|
||||
f"[KOOK] 连接失败,连续失败次数: {consecutive_failures}"
|
||||
)
|
||||
|
||||
if consecutive_failures >= max_consecutive_failures:
|
||||
logger.error("[KOOK] 连续失败次数过多,停止重连")
|
||||
break
|
||||
|
||||
# 等待一段时间后重试
|
||||
wait_time = min(
|
||||
2**consecutive_failures, max_retry_delay
|
||||
) # 指数退避
|
||||
logger.info(f"[KOOK] 等待 {wait_time} 秒后重试...")
|
||||
await asyncio.sleep(wait_time)
|
||||
|
||||
except Exception as e:
|
||||
consecutive_failures += 1
|
||||
logger.error(f"[KOOK] 主循环异常: {e}")
|
||||
|
||||
if consecutive_failures >= max_consecutive_failures:
|
||||
logger.error("[KOOK] 连续异常次数过多,停止重连")
|
||||
break
|
||||
|
||||
await asyncio.sleep(5)
|
||||
|
||||
async def _cleanup(self):
|
||||
"""清理资源"""
|
||||
logger.info("[KOOK] 开始清理资源")
|
||||
|
||||
if self.client:
|
||||
try:
|
||||
await self.client.close()
|
||||
except Exception as e:
|
||||
logger.error(f"[KOOK] 关闭客户端异常: {e}")
|
||||
|
||||
if self._main_task and not self._main_task.done():
|
||||
self._main_task.cancel()
|
||||
try:
|
||||
await self._main_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
logger.info("[KOOK] 资源清理完成")
|
||||
|
||||
def _parse_kmarkdown_text_message(
|
||||
self, data: dict, self_id: str
|
||||
) -> tuple[list, str]:
|
||||
kmarkdown = data.get("extra", {}).get("kmarkdown", {})
|
||||
content = data.get("content") or ""
|
||||
raw_content = kmarkdown.get("raw_content") or content
|
||||
if not isinstance(content, str):
|
||||
content = str(content)
|
||||
if not isinstance(raw_content, str):
|
||||
raw_content = str(raw_content)
|
||||
|
||||
mention_name_map: dict[str, str] = {}
|
||||
mention_part = kmarkdown.get("mention_part", [])
|
||||
if isinstance(mention_part, list):
|
||||
for item in mention_part:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
mention_id = item.get("id")
|
||||
if mention_id is None:
|
||||
continue
|
||||
mention_name_map[str(mention_id)] = str(item.get("username", ""))
|
||||
|
||||
components = []
|
||||
cursor = 0
|
||||
for match in re.finditer(r"\(met\)([^()]+)\(met\)", content):
|
||||
if match.start() > cursor:
|
||||
plain_text = content[cursor : match.start()]
|
||||
if plain_text:
|
||||
components.append(Plain(text=plain_text))
|
||||
|
||||
mention_target = match.group(1).strip()
|
||||
if mention_target == "all":
|
||||
components.append(AtAll())
|
||||
elif mention_target:
|
||||
components.append(
|
||||
At(
|
||||
qq=mention_target,
|
||||
name=mention_name_map.get(mention_target, ""),
|
||||
)
|
||||
)
|
||||
cursor = match.end()
|
||||
|
||||
if cursor < len(content):
|
||||
tail_text = content[cursor:]
|
||||
if tail_text:
|
||||
components.append(Plain(text=tail_text))
|
||||
|
||||
message_str = raw_content
|
||||
if components:
|
||||
for comp in components:
|
||||
if isinstance(comp, Plain):
|
||||
if not comp.text.strip():
|
||||
continue
|
||||
break
|
||||
if isinstance(comp, At):
|
||||
if str(comp.qq) == str(self_id):
|
||||
message_str = re.sub(
|
||||
r"^@[^\s]+(\s*-\s*[^\s]+)?\s*",
|
||||
"",
|
||||
message_str,
|
||||
count=1,
|
||||
).strip()
|
||||
break
|
||||
if not components:
|
||||
if message_str:
|
||||
components = [Plain(text=message_str)]
|
||||
else:
|
||||
components = []
|
||||
|
||||
return components, message_str
|
||||
|
||||
def _parse_card_message(self, data: dict) -> tuple[list, str]:
|
||||
content = data.get("content", "[]")
|
||||
if not isinstance(content, str):
|
||||
content = str(content)
|
||||
card_list = json.loads(content)
|
||||
|
||||
text_parts: list[str] = []
|
||||
images: list[str] = []
|
||||
|
||||
for card in card_list:
|
||||
if not isinstance(card, dict):
|
||||
continue
|
||||
for module in card.get("modules", []):
|
||||
if not isinstance(module, dict):
|
||||
continue
|
||||
|
||||
module_type = module.get("type")
|
||||
if module_type == "section":
|
||||
section_text = module.get("text", {}).get("content", "")
|
||||
if section_text:
|
||||
text_parts.append(str(section_text))
|
||||
continue
|
||||
|
||||
if module_type != "container":
|
||||
continue
|
||||
|
||||
for element in module.get("elements", []):
|
||||
if not isinstance(element, dict):
|
||||
continue
|
||||
if element.get("type") != "image":
|
||||
continue
|
||||
|
||||
image_src = element.get("src")
|
||||
if not isinstance(image_src, str):
|
||||
logger.warning(
|
||||
f'[KOOK] 处理卡片中的图片时发生错误,图片url "{image_src}" 应该为str类型, 而不是 "{type(image_src)}" '
|
||||
)
|
||||
continue
|
||||
if not image_src.startswith(("http://", "https://")):
|
||||
logger.warning(f"[KOOK] 屏蔽非http图片url: {image_src}")
|
||||
continue
|
||||
images.append(image_src)
|
||||
|
||||
text = "".join(text_parts)
|
||||
message = []
|
||||
if text:
|
||||
message.append(Plain(text=text))
|
||||
for img_url in images:
|
||||
message.append(Image(file=img_url))
|
||||
return message, text
|
||||
|
||||
async def convert_message(self, data: dict) -> AstrBotMessage:
|
||||
abm = AstrBotMessage()
|
||||
abm.raw_message = data
|
||||
abm.self_id = self.client.bot_id
|
||||
|
||||
channel_type = data.get("channel_type")
|
||||
author_id = data.get("author_id", "unknown")
|
||||
# channel_type定义: https://developer.kookapp.cn/doc/event/event-introduction
|
||||
match channel_type:
|
||||
case "GROUP":
|
||||
session_id = data.get("target_id") or "unknown"
|
||||
abm.type = MessageType.GROUP_MESSAGE
|
||||
abm.group_id = session_id
|
||||
abm.session_id = session_id
|
||||
case "PERSON":
|
||||
abm.type = MessageType.FRIEND_MESSAGE
|
||||
abm.group_id = ""
|
||||
abm.session_id = data.get("author_id", "unknown")
|
||||
case "BROADCAST":
|
||||
session_id = data.get("target_id") or "unknown"
|
||||
abm.type = MessageType.OTHER_MESSAGE
|
||||
abm.group_id = session_id
|
||||
abm.session_id = session_id
|
||||
case _:
|
||||
raise ValueError(f"不支持的频道类型: {channel_type}")
|
||||
|
||||
abm.sender = MessageMember(
|
||||
user_id=author_id,
|
||||
nickname=data.get("extra", {}).get("author", {}).get("username", ""),
|
||||
)
|
||||
|
||||
abm.message_id = data.get("msg_id", "unknown")
|
||||
|
||||
# 普通文本消息
|
||||
if data.get("type") == 9:
|
||||
message, message_str = self._parse_kmarkdown_text_message(
|
||||
data, str(abm.self_id)
|
||||
)
|
||||
abm.message = message
|
||||
abm.message_str = message_str
|
||||
# 卡片消息
|
||||
elif data.get("type") == 10:
|
||||
try:
|
||||
abm.message, abm.message_str = self._parse_card_message(data)
|
||||
except Exception as exp:
|
||||
logger.error(f"[KOOK] 卡片消息解析失败: {exp}")
|
||||
abm.message_str = "[卡片消息解析失败]"
|
||||
abm.message = [Plain(text="[卡片消息解析失败]")]
|
||||
else:
|
||||
logger.warning(f'[KOOK] 不支持的kook消息类型: "{data.get("type")}"')
|
||||
abm.message_str = "[不支持的消息类型]"
|
||||
abm.message = [Plain(text="[不支持的消息类型]")]
|
||||
|
||||
return abm
|
||||
|
||||
async def handle_msg(self, message: AstrBotMessage):
|
||||
message_event = KookEvent(
|
||||
message_str=message.message_str,
|
||||
message_obj=message,
|
||||
platform_meta=self.meta(),
|
||||
session_id=message.session_id,
|
||||
client=self.client,
|
||||
)
|
||||
self.commit_event(message_event)
|
||||
@@ -0,0 +1,437 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import time
|
||||
import zlib
|
||||
from pathlib import Path
|
||||
|
||||
import aiofiles
|
||||
import aiohttp
|
||||
import websockets
|
||||
|
||||
from astrbot import logger
|
||||
from astrbot.core.platform.message_type import MessageType
|
||||
|
||||
from .kook_config import KookConfig
|
||||
from .kook_types import KookApiPaths, KookMessageType
|
||||
|
||||
|
||||
class KookClient:
|
||||
def __init__(self, config: KookConfig, event_callback):
|
||||
# 数据字段
|
||||
self.config = config
|
||||
self._bot_id = ""
|
||||
self._bot_name = ""
|
||||
|
||||
# 资源字段
|
||||
self._http_client = aiohttp.ClientSession(
|
||||
headers={
|
||||
"Authorization": f"Bot {self.config.token}",
|
||||
}
|
||||
)
|
||||
self.event_callback = event_callback # 回调函数,用于处理接收到的事件
|
||||
self.ws = None
|
||||
self.heartbeat_task = None
|
||||
self._stop_event = asyncio.Event() # 用于通知连接结束
|
||||
|
||||
# 状态/计算字段
|
||||
self.running = False
|
||||
self.session_id = None
|
||||
self.last_sn = 0 # 记录最后处理的消息序号
|
||||
self.last_heartbeat_time = 0
|
||||
self.heartbeat_failed_count = 0
|
||||
|
||||
@property
|
||||
def bot_id(self):
|
||||
return self._bot_id
|
||||
|
||||
@property
|
||||
def bot_name(self):
|
||||
return self._bot_name
|
||||
|
||||
async def get_bot_info(self) -> str:
|
||||
"""获取机器人账号ID"""
|
||||
url = KookApiPaths.USER_ME
|
||||
|
||||
try:
|
||||
async with self._http_client.get(url) as resp:
|
||||
if resp.status != 200:
|
||||
logger.error(f"[KOOK] 获取机器人账号ID失败,状态码: {resp.status}")
|
||||
return ""
|
||||
|
||||
data = await resp.json()
|
||||
if data.get("code") != 0:
|
||||
logger.error(f"[KOOK] 获取机器人账号ID失败: {data}")
|
||||
return ""
|
||||
|
||||
bot_id: str = data["data"]["id"]
|
||||
self._bot_id = bot_id
|
||||
logger.info(f"[KOOK] 获取机器人账号ID成功: {bot_id}")
|
||||
bot_name: str = data["data"]["nickname"] or data["data"]["username"]
|
||||
self._bot_name = bot_name
|
||||
logger.info(f"[KOOK] 获取机器人名称成功: {self._bot_name}")
|
||||
|
||||
return bot_id
|
||||
except Exception as e:
|
||||
logger.error(f"[KOOK] 获取机器人账号ID异常: {e}")
|
||||
return ""
|
||||
|
||||
async def get_gateway_url(self, resume=False, sn=0, session_id=None):
|
||||
"""获取网关连接地址"""
|
||||
url = KookApiPaths.GATEWAY_INDEX
|
||||
|
||||
# 构建连接参数
|
||||
params = {}
|
||||
if resume:
|
||||
params["resume"] = 1
|
||||
params["sn"] = sn
|
||||
if session_id:
|
||||
params["session_id"] = session_id
|
||||
|
||||
try:
|
||||
async with self._http_client.get(url, params=params) as resp:
|
||||
if resp.status != 200:
|
||||
logger.error(f"[KOOK] 获取gateway失败,状态码: {resp.status}")
|
||||
return None
|
||||
|
||||
data = await resp.json()
|
||||
if data.get("code") != 0:
|
||||
logger.error(f"[KOOK] 获取gateway失败: {data}")
|
||||
return None
|
||||
|
||||
gateway_url: str = data["data"]["url"]
|
||||
logger.info(f"[KOOK] 获取gateway成功: {gateway_url.split('?')[0]}")
|
||||
return gateway_url
|
||||
except Exception as e:
|
||||
logger.error(f"[KOOK] 获取gateway异常: {e}")
|
||||
return None
|
||||
|
||||
async def connect(self, resume=False):
|
||||
"""连接WebSocket"""
|
||||
if self.ws:
|
||||
try:
|
||||
await self.ws.close()
|
||||
except Exception:
|
||||
pass
|
||||
self.ws = None
|
||||
self._stop_event.clear()
|
||||
try:
|
||||
# 获取gateway地址
|
||||
gateway_url = await self.get_gateway_url(
|
||||
resume=resume, sn=self.last_sn, session_id=self.session_id
|
||||
)
|
||||
await self.get_bot_info()
|
||||
|
||||
if not gateway_url:
|
||||
return False
|
||||
|
||||
# 连接WebSocket
|
||||
self.ws = await websockets.connect(gateway_url)
|
||||
self.running = True
|
||||
logger.info("[KOOK] WebSocket 连接成功")
|
||||
|
||||
# 启动心跳任务
|
||||
if self.heartbeat_task:
|
||||
self.heartbeat_task.cancel()
|
||||
self.heartbeat_task = asyncio.create_task(self._heartbeat_loop())
|
||||
|
||||
# 开始监听消息
|
||||
await self.listen()
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[KOOK] WebSocket 连接失败: {e}")
|
||||
if self.ws:
|
||||
try:
|
||||
await self.ws.close()
|
||||
except Exception:
|
||||
pass
|
||||
self.ws = None
|
||||
return False
|
||||
|
||||
async def listen(self):
|
||||
"""监听WebSocket消息"""
|
||||
try:
|
||||
while self.running:
|
||||
try:
|
||||
msg = await asyncio.wait_for(self.ws.recv(), timeout=10) # type: ignore
|
||||
|
||||
if isinstance(msg, bytes):
|
||||
try:
|
||||
msg = zlib.decompress(msg)
|
||||
except Exception as e:
|
||||
logger.error(f"[KOOK] 解压消息失败: {e}")
|
||||
continue
|
||||
msg = msg.decode("utf-8")
|
||||
|
||||
data = json.loads(msg)
|
||||
|
||||
# 处理不同类型的信令
|
||||
await self._handle_signal(data)
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
# 超时检查,继续循环
|
||||
continue
|
||||
except websockets.exceptions.ConnectionClosed:
|
||||
logger.warning("[KOOK] WebSocket连接已关闭")
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"[KOOK] 消息处理异常: {e}")
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[KOOK] WebSocket 监听异常: {e}")
|
||||
finally:
|
||||
self.running = False
|
||||
self._stop_event.set()
|
||||
|
||||
async def _handle_signal(self, data):
|
||||
"""处理不同类型的信令"""
|
||||
signal_type = data.get("s")
|
||||
|
||||
if signal_type == 0: # 事件消息
|
||||
# 更新消息序号
|
||||
if "sn" in data:
|
||||
self.last_sn = data["sn"]
|
||||
await self.event_callback(data)
|
||||
|
||||
elif signal_type == 1: # HELLO握手
|
||||
await self._handle_hello(data)
|
||||
|
||||
elif signal_type == 3: # PONG心跳响应
|
||||
await self._handle_pong(data)
|
||||
|
||||
elif signal_type == 5: # RECONNECT重连指令
|
||||
await self._handle_reconnect(data)
|
||||
|
||||
elif signal_type == 6: # RESUME ACK
|
||||
await self._handle_resume_ack(data)
|
||||
|
||||
else:
|
||||
logger.debug(f"[KOOK] 未处理的信令类型: {signal_type}")
|
||||
|
||||
async def _handle_hello(self, data):
|
||||
"""处理HELLO握手"""
|
||||
hello_data = data.get("d", {})
|
||||
code = hello_data.get("code", 0)
|
||||
|
||||
if code == 0:
|
||||
self.session_id = hello_data.get("session_id")
|
||||
logger.info(f"[KOOK] 握手成功,session_id: {self.session_id}")
|
||||
# TODO 重置重连延迟
|
||||
# self.reconnect_delay = 1
|
||||
else:
|
||||
logger.error(f"[KOOK] 握手失败,错误码: {code}")
|
||||
if code == 40103: # token过期
|
||||
logger.error("[KOOK] Token已过期,需要重新获取")
|
||||
self.running = False
|
||||
|
||||
async def _handle_pong(self, data):
|
||||
"""处理PONG心跳响应"""
|
||||
self.last_heartbeat_time = time.time()
|
||||
self.heartbeat_failed_count = 0
|
||||
|
||||
async def _handle_reconnect(self, data):
|
||||
"""处理重连指令"""
|
||||
logger.warning("[KOOK] 收到重连指令")
|
||||
# 清空本地状态
|
||||
self.last_sn = 0
|
||||
self.session_id = None
|
||||
self.running = False
|
||||
|
||||
async def _handle_resume_ack(self, data):
|
||||
"""处理RESUME确认"""
|
||||
resume_data = data.get("d", {})
|
||||
self.session_id = resume_data.get("session_id")
|
||||
logger.info(f"[KOOK] Resume成功,session_id: {self.session_id}")
|
||||
|
||||
async def _heartbeat_loop(self):
|
||||
"""心跳循环"""
|
||||
while self.running:
|
||||
try:
|
||||
# 随机化心跳间隔 (±5秒)
|
||||
interval = max(
|
||||
1, self.config.heartbeat_interval + random.randint(-5, 5)
|
||||
)
|
||||
await asyncio.sleep(interval)
|
||||
|
||||
if not self.running:
|
||||
break
|
||||
|
||||
# 发送心跳
|
||||
await self._send_ping()
|
||||
|
||||
# 等待PONG响应
|
||||
await asyncio.sleep(self.config.heartbeat_timeout)
|
||||
|
||||
# 检查是否收到PONG响应
|
||||
if (
|
||||
time.time() - self.last_heartbeat_time
|
||||
> self.config.heartbeat_timeout
|
||||
):
|
||||
self.heartbeat_failed_count += 1
|
||||
logger.warning(
|
||||
f"[KOOK] 心跳超时,失败次数: {self.heartbeat_failed_count}"
|
||||
)
|
||||
|
||||
if (
|
||||
self.heartbeat_failed_count
|
||||
>= self.config.max_heartbeat_failures
|
||||
):
|
||||
logger.error("[KOOK] 心跳失败次数过多,准备重连")
|
||||
self.running = False
|
||||
break
|
||||
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"[KOOK] 心跳异常: {e}")
|
||||
self.heartbeat_failed_count += 1
|
||||
|
||||
async def _send_ping(self):
|
||||
"""发送心跳PING"""
|
||||
try:
|
||||
ping_data = {"s": 2, "sn": self.last_sn}
|
||||
await self.ws.send(json.dumps(ping_data)) # type: ignore
|
||||
except Exception as e:
|
||||
logger.error(f"[KOOK] 发送心跳失败: {e}")
|
||||
|
||||
async def send_text(
|
||||
self,
|
||||
target_id: str,
|
||||
content: str,
|
||||
astrbot_message_type: MessageType,
|
||||
kook_message_type: KookMessageType,
|
||||
reply_message_id: str | int = "",
|
||||
):
|
||||
"""发送文本消息
|
||||
消息发送接口文档参见: https://developer.kookapp.cn/doc/http/message#%E5%8F%91%E9%80%81%E9%A2%91%E9%81%93%E8%81%8A%E5%A4%A9%E6%B6%88%E6%81%AF
|
||||
KMarkdown格式参见: https://developer.kookapp.cn/doc/kmarkdown-desc
|
||||
"""
|
||||
url = KookApiPaths.CHANNEL_MESSAGE_CREATE
|
||||
if astrbot_message_type == MessageType.FRIEND_MESSAGE:
|
||||
url = KookApiPaths.DIRECT_MESSAGE_CREATE
|
||||
|
||||
payload = {
|
||||
"target_id": target_id,
|
||||
"content": content,
|
||||
"type": kook_message_type,
|
||||
}
|
||||
if reply_message_id:
|
||||
payload["quote"] = reply_message_id
|
||||
payload["reply_msg_id"] = reply_message_id
|
||||
|
||||
try:
|
||||
async with self._http_client.post(url, json=payload) as resp:
|
||||
if resp.status == 200:
|
||||
result = await resp.json()
|
||||
if result.get("code") != 0:
|
||||
raise RuntimeError(
|
||||
f'发送kook消息类型 "{kook_message_type.name}" 失败: {result}'
|
||||
)
|
||||
# else:
|
||||
# logger.info("[KOOK] 发送消息成功")
|
||||
else:
|
||||
raise RuntimeError(
|
||||
f'发送kook消息类型 "{kook_message_type.name}" HTTP错误: {resp.status} , 响应内容 : {await resp.text()}'
|
||||
)
|
||||
except RuntimeError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f'[KOOK] 发送kook消息类型 "{kook_message_type.name}" 异常: {e}'
|
||||
)
|
||||
|
||||
async def upload_asset(self, file_url: str | None) -> str:
|
||||
"""上传文件到kook,获得远端资源url
|
||||
接口定义参见: https://developer.kookapp.cn/doc/http/asset
|
||||
"""
|
||||
if not file_url:
|
||||
return ""
|
||||
|
||||
bytes_data: bytes | None = None
|
||||
filename = "unknown"
|
||||
if file_url.startswith(("http://", "https://")):
|
||||
filename = file_url.split("/")[-1]
|
||||
return file_url
|
||||
|
||||
if file_url.startswith("base64:///"):
|
||||
# b64decode的时候得开头留一个'/'的, 不然会报错
|
||||
b64_str = file_url.removeprefix("base64://")
|
||||
bytes_data = base64.b64decode(b64_str)
|
||||
|
||||
elif file_url.startswith("file://") or os.path.exists(file_url):
|
||||
file_url = file_url.removeprefix("file:///")
|
||||
file_url = file_url.removeprefix("file://")
|
||||
|
||||
try:
|
||||
target_path = Path(file_url).resolve()
|
||||
except Exception as exp:
|
||||
logger.error(f'[KOOK] 获取文件 "{file_url}" 绝对路径失败: "{exp}"')
|
||||
raise FileNotFoundError(
|
||||
f'获取文件 "{file_url}" 绝对路径失败: "{exp}"'
|
||||
) from exp
|
||||
|
||||
if not target_path.is_file():
|
||||
raise FileNotFoundError(f"文件不存在: {target_path.name}")
|
||||
|
||||
filename = target_path.name
|
||||
async with aiofiles.open(target_path, "rb") as f:
|
||||
bytes_data = await f.read()
|
||||
|
||||
else:
|
||||
raise ValueError(f'[KOOK] 不支持的文件资源类型: "{file_url}"')
|
||||
|
||||
data = aiohttp.FormData()
|
||||
data.add_field("file", bytes_data, filename=filename)
|
||||
|
||||
url = KookApiPaths.ASSET_CREATE
|
||||
try:
|
||||
async with self._http_client.post(url, data=data) as resp:
|
||||
if resp.status == 200:
|
||||
result: dict = await resp.json()
|
||||
logger.debug(f"[KOOK] 上传文件响应: {result}")
|
||||
if result.get("code") == 0:
|
||||
logger.info("[KOOK] 上传文件到kook服务器成功")
|
||||
remote_url = result["data"]["url"]
|
||||
logger.debug(f"[KOOK] 文件远端URL: {remote_url}")
|
||||
return remote_url
|
||||
else:
|
||||
raise RuntimeError(f"上传文件到kook服务器失败: {result}")
|
||||
else:
|
||||
raise RuntimeError(
|
||||
f"上传文件到kook服务器 HTTP错误: {resp.status} , {await resp.text()}"
|
||||
)
|
||||
except RuntimeError:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"上传文件到kook服务器异常: {e}") from e
|
||||
|
||||
async def wait_until_closed(self):
|
||||
"""提供给外部调用的等待方法"""
|
||||
await self._stop_event.wait()
|
||||
|
||||
async def close(self):
|
||||
"""关闭连接"""
|
||||
self.running = False
|
||||
self._stop_event.set()
|
||||
|
||||
if self.heartbeat_task:
|
||||
self.heartbeat_task.cancel()
|
||||
try:
|
||||
await self.heartbeat_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
if self.ws:
|
||||
try:
|
||||
await self.ws.close()
|
||||
except Exception as e:
|
||||
logger.error(f"[KOOK] 关闭WebSocket异常: {e}")
|
||||
|
||||
if self._http_client:
|
||||
await self._http_client.close()
|
||||
|
||||
logger.info("[KOOK] 连接已关闭")
|
||||
@@ -0,0 +1,133 @@
|
||||
import json
|
||||
from dataclasses import asdict, dataclass
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass
|
||||
class KookConfig:
|
||||
"""KOOK 适配器配置类"""
|
||||
|
||||
# 基础配置
|
||||
token: str
|
||||
bot_nickname: str = ""
|
||||
enable: bool = False
|
||||
id: str = "kook"
|
||||
|
||||
# 重连配置
|
||||
reconnect_delay: int = 1
|
||||
"""重连延迟基数(秒),指数退避"""
|
||||
max_reconnect_delay: int = 60
|
||||
"""最大重连延迟(秒)"""
|
||||
max_retry_delay: int = 60
|
||||
"""最大重试延迟(秒)"""
|
||||
|
||||
# 心跳配置
|
||||
heartbeat_interval: int = 30
|
||||
"""心跳间隔(秒)"""
|
||||
heartbeat_timeout: int = 6
|
||||
"""心跳超时时间(秒)"""
|
||||
max_heartbeat_failures: int = 3
|
||||
"""最大心跳失败次数"""
|
||||
|
||||
# 失败处理
|
||||
max_consecutive_failures: int = 5
|
||||
"""最大连续失败次数"""
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, config_dict: dict) -> "KookConfig":
|
||||
"""从字典创建配置对象"""
|
||||
return cls(
|
||||
# 适配器id 应该是不能改的
|
||||
# id=config_dict.get("id", "kook"),
|
||||
enable=config_dict.get("enable", False),
|
||||
token=config_dict.get("kook_bot_token", ""),
|
||||
bot_nickname=config_dict.get("kook_bot_nickname", ""),
|
||||
reconnect_delay=config_dict.get(
|
||||
"kook_reconnect_delay",
|
||||
KookConfig.reconnect_delay,
|
||||
),
|
||||
max_reconnect_delay=config_dict.get(
|
||||
"kook_max_reconnect_delay",
|
||||
KookConfig.max_reconnect_delay,
|
||||
),
|
||||
max_retry_delay=config_dict.get(
|
||||
"kook_max_retry_delay",
|
||||
KookConfig.max_retry_delay,
|
||||
),
|
||||
heartbeat_interval=config_dict.get(
|
||||
"kook_heartbeat_interval",
|
||||
KookConfig.heartbeat_interval,
|
||||
),
|
||||
heartbeat_timeout=config_dict.get(
|
||||
"kook_heartbeat_timeout",
|
||||
KookConfig.heartbeat_timeout,
|
||||
),
|
||||
max_heartbeat_failures=config_dict.get(
|
||||
"kook_max_heartbeat_failures",
|
||||
KookConfig.max_heartbeat_failures,
|
||||
),
|
||||
max_consecutive_failures=config_dict.get(
|
||||
"kook_max_consecutive_failures",
|
||||
KookConfig.max_consecutive_failures,
|
||||
),
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
def pretty_jsons(self, indent=2) -> str:
|
||||
dict_config = self.to_dict()
|
||||
dict_config["token"] = "*" * len(self.token) if self.token else "MISSING"
|
||||
return json.dumps(dict_config, indent=indent, ensure_ascii=False)
|
||||
|
||||
|
||||
# TODO 没用上的config配置,未来有空会实现这些配置描述的功能?
|
||||
# # 连接配置
|
||||
# CONNECTION_CONFIG = {
|
||||
# # 心跳配置
|
||||
# "heartbeat_interval": 30, # 心跳间隔(秒)
|
||||
# "heartbeat_timeout": 6, # 心跳超时时间(秒)
|
||||
# "max_heartbeat_failures": 3, # 最大心跳失败次数
|
||||
# # 重连配置
|
||||
# "initial_reconnect_delay": 1, # 初始重连延迟(秒)
|
||||
# "max_reconnect_delay": 60, # 最大重连延迟(秒)
|
||||
# "max_consecutive_failures": 5, # 最大连续失败次数
|
||||
# # WebSocket配置
|
||||
# "websocket_timeout": 10, # WebSocket接收超时(秒)
|
||||
# "connection_timeout": 30, # 连接超时(秒)
|
||||
# # 消息处理配置
|
||||
# "enable_compression": True, # 是否启用消息压缩
|
||||
# "max_message_size": 1024 * 1024, # 最大消息大小(字节)
|
||||
# }
|
||||
|
||||
# # 日志配置
|
||||
# LOGGING_CONFIG = {
|
||||
# "level": "INFO", # 日志级别:DEBUG, INFO, WARNING, ERROR
|
||||
# "format": "[KOOK] %(message)s",
|
||||
# "enable_heartbeat_logs": False, # 是否启用心跳日志
|
||||
# "enable_message_logs": False, # 是否启用消息日志
|
||||
# }
|
||||
|
||||
# # 错误处理配置
|
||||
# ERROR_HANDLING_CONFIG = {
|
||||
# "retry_on_network_error": True, # 网络错误时是否重试
|
||||
# "retry_on_token_expired": True, # Token过期时是否重试
|
||||
# "max_retry_attempts": 3, # 最大重试次数
|
||||
# "retry_delay_base": 2, # 重试延迟基数(秒)
|
||||
# }
|
||||
|
||||
# # 性能配置
|
||||
# PERFORMANCE_CONFIG = {
|
||||
# "enable_message_buffering": True, # 是否启用消息缓冲
|
||||
# "buffer_size": 100, # 缓冲区大小
|
||||
# "enable_connection_pooling": True, # 是否启用连接池
|
||||
# "max_concurrent_requests": 10, # 最大并发请求数
|
||||
# }
|
||||
|
||||
# # 安全配置
|
||||
# SECURITY_CONFIG = {
|
||||
# "verify_ssl": True, # 是否验证SSL证书
|
||||
# "enable_rate_limiting": True, # 是否启用速率限制
|
||||
# "rate_limit_requests": 100, # 速率限制请求数
|
||||
# "rate_limit_window": 60, # 速率限制窗口(秒)
|
||||
# }
|
||||
@@ -0,0 +1,209 @@
|
||||
import asyncio
|
||||
import json
|
||||
from collections.abc import Coroutine
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from astrbot import logger
|
||||
from astrbot.api.event import AstrMessageEvent, MessageChain
|
||||
from astrbot.api.platform import AstrBotMessage, PlatformMetadata
|
||||
from astrbot.core.message.components import (
|
||||
At,
|
||||
AtAll,
|
||||
BaseMessageComponent,
|
||||
File,
|
||||
Image,
|
||||
Json,
|
||||
Plain,
|
||||
Record,
|
||||
Reply,
|
||||
Video,
|
||||
)
|
||||
from astrbot.core.platform import MessageType
|
||||
|
||||
from .kook_client import KookClient
|
||||
from .kook_types import (
|
||||
FileModule,
|
||||
KookCardMessage,
|
||||
KookCardMessageContainer,
|
||||
KookMessageType,
|
||||
OrderMessage,
|
||||
)
|
||||
|
||||
|
||||
class KookEvent(AstrMessageEvent):
|
||||
def __init__(
|
||||
self,
|
||||
message_str: str,
|
||||
message_obj: AstrBotMessage,
|
||||
platform_meta: PlatformMetadata,
|
||||
session_id: str,
|
||||
client: KookClient,
|
||||
):
|
||||
super().__init__(message_str, message_obj, platform_meta, session_id)
|
||||
self.client = client
|
||||
self.channel_id = message_obj.group_id or message_obj.session_id
|
||||
self.astrbot_message_type: MessageType = message_obj.type
|
||||
self._file_message_counter = 0
|
||||
|
||||
def _wrap_message(
|
||||
self, index: int, message_component: BaseMessageComponent
|
||||
) -> Coroutine[Any, Any, OrderMessage]:
|
||||
async def wrap_upload(
|
||||
index: int, message_type: KookMessageType, upload_coro
|
||||
) -> OrderMessage:
|
||||
url = await upload_coro
|
||||
return OrderMessage(index=index, text=url, type=message_type)
|
||||
|
||||
async def handle_plain(
|
||||
index: int,
|
||||
text: str | None,
|
||||
reply_id: str | int = "",
|
||||
type: KookMessageType = KookMessageType.KMARKDOWN,
|
||||
):
|
||||
if not text:
|
||||
text = ""
|
||||
return OrderMessage(
|
||||
index=index,
|
||||
text=text,
|
||||
type=type,
|
||||
reply_id=reply_id,
|
||||
)
|
||||
|
||||
match message_component:
|
||||
case Image():
|
||||
self._file_message_counter += 1
|
||||
return wrap_upload(
|
||||
index,
|
||||
KookMessageType.IMAGE,
|
||||
self.client.upload_asset(message_component.file),
|
||||
)
|
||||
|
||||
case Video():
|
||||
self._file_message_counter += 1
|
||||
return wrap_upload(
|
||||
index,
|
||||
KookMessageType.VIDEO,
|
||||
self.client.upload_asset(message_component.file),
|
||||
)
|
||||
case File():
|
||||
|
||||
async def handle_file(index: int, f_item: File):
|
||||
f_data = await f_item.get_file()
|
||||
url = await self.client.upload_asset(f_data)
|
||||
return OrderMessage(
|
||||
index=index, text=url, type=KookMessageType.FILE
|
||||
)
|
||||
|
||||
self._file_message_counter += 1
|
||||
return handle_file(index, message_component)
|
||||
|
||||
case Record():
|
||||
|
||||
async def handle_audio(index: int, f_item: Record):
|
||||
file_path = await f_item.convert_to_file_path()
|
||||
url = await self.client.upload_asset(file_path)
|
||||
title = f_item.text or Path(file_path).name
|
||||
return OrderMessage(
|
||||
index=index,
|
||||
text=KookCardMessageContainer(
|
||||
[
|
||||
KookCardMessage(
|
||||
modules=[
|
||||
FileModule(
|
||||
type="audio",
|
||||
title=title,
|
||||
src=url,
|
||||
)
|
||||
]
|
||||
)
|
||||
]
|
||||
).to_json(),
|
||||
type=KookMessageType.CARD,
|
||||
)
|
||||
|
||||
return handle_audio(index, message_component)
|
||||
case Plain():
|
||||
return handle_plain(index, message_component.text)
|
||||
case At():
|
||||
return handle_plain(index, f"(met){message_component.qq}(met)")
|
||||
case AtAll():
|
||||
return handle_plain(index, "(met)all(met)")
|
||||
case Reply():
|
||||
return handle_plain(index, "", reply_id=message_component.id)
|
||||
case Json():
|
||||
json_data = message_component.data
|
||||
# kook卡片json外层得是一个列表
|
||||
if isinstance(json_data, dict):
|
||||
json_data = [json_data]
|
||||
return handle_plain(
|
||||
index,
|
||||
# 考虑到kook可能会更改消息结构,为了能让插件开发者
|
||||
# 自行根据kook文档描述填卡片json内容,故不做模型校验
|
||||
# KookCardMessage().model_validate(message_component.data).to_json(),
|
||||
text=json.dumps(json_data),
|
||||
type=KookMessageType.CARD,
|
||||
)
|
||||
case _:
|
||||
raise NotImplementedError(
|
||||
f'kook适配器尚未实现对 "{message_component.type}" 消息类型的支持'
|
||||
)
|
||||
|
||||
async def send(self, message: MessageChain):
|
||||
file_upload_tasks: list[Coroutine[Any, Any, OrderMessage]] = []
|
||||
for index, item in enumerate(message.chain):
|
||||
file_upload_tasks.append(self._wrap_message(index, item))
|
||||
|
||||
if self._file_message_counter > 0:
|
||||
logger.debug("[Kook] 正在向kook服务器上传文件")
|
||||
|
||||
tasks_result = await asyncio.gather(*file_upload_tasks, return_exceptions=True)
|
||||
order_messages: list[OrderMessage] = []
|
||||
|
||||
for index, result in enumerate(tasks_result):
|
||||
if isinstance(result, BaseException):
|
||||
logger.error(f"[Kook] {result}")
|
||||
# 构造一个虚假的 OrderMessage,让用户知道这里本来有张图但坏了
|
||||
# 这样后面的 for 循环就能把它当成普通文本发出去
|
||||
err_node = OrderMessage(
|
||||
index=index,
|
||||
text=str(result),
|
||||
type=KookMessageType.TEXT,
|
||||
)
|
||||
order_messages.append(err_node)
|
||||
else:
|
||||
order_messages.append(result)
|
||||
|
||||
order_messages.sort(key=lambda x: x.index)
|
||||
|
||||
reply_id: str | int = ""
|
||||
errors: list[Exception] = []
|
||||
for item in order_messages:
|
||||
if item.reply_id:
|
||||
reply_id = item.reply_id
|
||||
if not item.text:
|
||||
logger.debug(f'[Kook] 跳过空消息,类型为"{item.type}"')
|
||||
continue
|
||||
try:
|
||||
await self.client.send_text(
|
||||
self.channel_id,
|
||||
item.text,
|
||||
self.astrbot_message_type,
|
||||
item.type,
|
||||
reply_id,
|
||||
)
|
||||
except RuntimeError as exp:
|
||||
await self.client.send_text(
|
||||
self.channel_id,
|
||||
str(exp),
|
||||
self.astrbot_message_type,
|
||||
KookMessageType.TEXT,
|
||||
reply_id,
|
||||
)
|
||||
errors.append(exp)
|
||||
|
||||
if errors:
|
||||
err_msg = "\n".join([str(err) for err in errors])
|
||||
logger.error(f"[kook] {err_msg}")
|
||||
|
||||
await super().send(message)
|
||||
@@ -0,0 +1,241 @@
|
||||
import json
|
||||
from dataclasses import field
|
||||
from enum import IntEnum
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from pydantic.dataclasses import dataclass
|
||||
|
||||
|
||||
class KookApiPaths:
|
||||
"""Kook Api 路径"""
|
||||
|
||||
BASE_URL = "https://www.kookapp.cn"
|
||||
API_VERSION_PATH = "/api/v3"
|
||||
|
||||
# 初始化相关
|
||||
USER_ME = f"{BASE_URL}{API_VERSION_PATH}/user/me"
|
||||
GATEWAY_INDEX = f"{BASE_URL}{API_VERSION_PATH}/gateway/index"
|
||||
|
||||
# 消息相关
|
||||
ASSET_CREATE = f"{BASE_URL}{API_VERSION_PATH}/asset/create"
|
||||
## 频道消息
|
||||
CHANNEL_MESSAGE_CREATE = f"{BASE_URL}{API_VERSION_PATH}/message/create"
|
||||
## 私聊消息
|
||||
DIRECT_MESSAGE_CREATE = f"{BASE_URL}{API_VERSION_PATH}/direct-message/create"
|
||||
|
||||
|
||||
# 定义参见kook事件结构文档: https://developer.kookapp.cn/doc/event/event-introduction
|
||||
class KookMessageType(IntEnum):
|
||||
TEXT = 1
|
||||
IMAGE = 2
|
||||
VIDEO = 3
|
||||
FILE = 4
|
||||
AUDIO = 8
|
||||
KMARKDOWN = 9
|
||||
CARD = 10
|
||||
SYSTEM = 255
|
||||
|
||||
|
||||
ThemeType = Literal[
|
||||
"primary", "success", "danger", "warning", "info", "secondary", "none", "invisible"
|
||||
]
|
||||
"""主题,可选的值为:primary, success, danger, warning, info, secondary, none.默认为 primary,为 none 时不显示侧边框。"""
|
||||
SizeType = Literal["xs", "sm", "md", "lg"]
|
||||
"""大小,可选值为:xs, sm, md, lg, 一般默认为 lg"""
|
||||
|
||||
SectionMode = Literal["left", "right"]
|
||||
CountdownMode = Literal["day", "hour", "second"]
|
||||
|
||||
|
||||
class KookCardColor(str):
|
||||
"""16 进制色值"""
|
||||
|
||||
|
||||
class KookCardModelBase:
|
||||
"""卡片模块基类"""
|
||||
|
||||
type: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class PlainTextElement(KookCardModelBase):
|
||||
content: str
|
||||
type: str = "plain-text"
|
||||
emoji: bool = True
|
||||
|
||||
|
||||
@dataclass
|
||||
class KmarkdownElement(KookCardModelBase):
|
||||
content: str
|
||||
type: str = "kmarkdown"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ImageElement(KookCardModelBase):
|
||||
src: str
|
||||
type: str = "image"
|
||||
alt: str = ""
|
||||
size: SizeType = "lg"
|
||||
circle: bool = False
|
||||
fallbackUrl: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ButtonElement(KookCardModelBase):
|
||||
text: str
|
||||
type: str = "button"
|
||||
theme: ThemeType = "primary"
|
||||
value: str = ""
|
||||
"""当为 link 时,会跳转到 value 代表的链接;
|
||||
当为 return-val 时,系统会通过系统消息将消息 id,点击用户 id 和 value 发回给发送者,发送者可以根据自己的需求进行处理,消息事件参见button 点击事件。私聊和频道内均可使用按钮点击事件。"""
|
||||
click: Literal["", "link", "return-val"] = ""
|
||||
"""click 代表用户点击的事件,默认为"",代表无任何事件。"""
|
||||
|
||||
|
||||
AnyElement = PlainTextElement | KmarkdownElement | ImageElement | ButtonElement | str
|
||||
|
||||
|
||||
@dataclass
|
||||
class ParagraphStructure(KookCardModelBase):
|
||||
fields: list[PlainTextElement | KmarkdownElement]
|
||||
type: str = "paragraph"
|
||||
cols: int = 1
|
||||
"""范围是 1-3 , 移动端忽略此参数"""
|
||||
|
||||
|
||||
@dataclass
|
||||
class HeaderModule(KookCardModelBase):
|
||||
text: PlainTextElement
|
||||
type: str = "header"
|
||||
|
||||
|
||||
@dataclass
|
||||
class SectionModule(KookCardModelBase):
|
||||
text: PlainTextElement | KmarkdownElement | ParagraphStructure
|
||||
type: str = "section"
|
||||
mode: SectionMode = "left"
|
||||
accessory: ImageElement | ButtonElement | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ImageGroupModule(KookCardModelBase):
|
||||
"""1 到多张图片的组合"""
|
||||
|
||||
elements: list[ImageElement]
|
||||
type: str = "image-group"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ContainerModule(KookCardModelBase):
|
||||
"""1 到多张图片的组合,与图片组模块(ImageGroupModule)不同,图片并不会裁切为正方形。多张图片会纵向排列。"""
|
||||
|
||||
elements: list[ImageElement]
|
||||
type: str = "container"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ActionGroupModule(KookCardModelBase):
|
||||
elements: list[ButtonElement]
|
||||
type: str = "action-group"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ContextModule(KookCardModelBase):
|
||||
elements: list[PlainTextElement | KmarkdownElement | ImageElement]
|
||||
"""最多包含10个元素"""
|
||||
type: str = "context"
|
||||
|
||||
|
||||
@dataclass
|
||||
class DividerModule(KookCardModelBase):
|
||||
type: str = "divider"
|
||||
|
||||
|
||||
@dataclass
|
||||
class FileModule(KookCardModelBase):
|
||||
src: str
|
||||
title: str = ""
|
||||
type: Literal["file", "audio", "video"] = "file"
|
||||
cover: str | None = None
|
||||
"""cover 仅音频有效, 是音频的封面图"""
|
||||
|
||||
|
||||
@dataclass
|
||||
class CountdownModule(KookCardModelBase):
|
||||
"""startTime 和 endTime 为毫秒时间戳,startTime 和 endTime 不能小于服务器当前时间戳。"""
|
||||
|
||||
endTime: int
|
||||
"""毫秒时间戳"""
|
||||
type: str = "countdown"
|
||||
startTime: int | None = None
|
||||
"""毫秒时间戳, 仅当mode为second才有这个字段"""
|
||||
mode: CountdownMode = "day"
|
||||
"""mode 主要是倒计时的样式"""
|
||||
|
||||
|
||||
@dataclass
|
||||
class InviteModule(KookCardModelBase):
|
||||
code: str
|
||||
"""邀请链接或者邀请码"""
|
||||
type: str = "invite"
|
||||
|
||||
|
||||
# 所有模块的联合类型
|
||||
AnyModule = (
|
||||
HeaderModule
|
||||
| SectionModule
|
||||
| ImageGroupModule
|
||||
| ContainerModule
|
||||
| ActionGroupModule
|
||||
| ContextModule
|
||||
| DividerModule
|
||||
| FileModule
|
||||
| CountdownModule
|
||||
| InviteModule
|
||||
)
|
||||
|
||||
|
||||
class KookCardMessage(BaseModel):
|
||||
"""卡片定义文档详见 : https://developer.kookapp.cn/doc/cardmessage
|
||||
此类型不能直接to_json后发送,因为kook要求卡片容器json顶层必须是**列表**
|
||||
若要发送卡片消息,请使用KookCardMessageContainer
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(arbitrary_types_allowed=True)
|
||||
type: str = "card"
|
||||
theme: ThemeType | None = None
|
||||
size: SizeType | None = None
|
||||
color: KookCardColor | None = None
|
||||
modules: list[AnyModule] = field(default_factory=list)
|
||||
"""单个 card 模块数量不限制,但是一条消息中所有卡片的模块数量之和最多是 50"""
|
||||
|
||||
def add_module(self, module: AnyModule):
|
||||
self.modules.append(module)
|
||||
|
||||
def to_dict(self, exclude_none: bool = True):
|
||||
"""exclude_none:去掉值为 None 字段,保留结构"""
|
||||
return self.model_dump(exclude_none=exclude_none)
|
||||
|
||||
def to_json(self, indent: int | None = None, ensure_ascii: bool = True):
|
||||
return json.dumps(self.to_dict(), indent=indent, ensure_ascii=ensure_ascii)
|
||||
|
||||
|
||||
class KookCardMessageContainer(list[KookCardMessage]):
|
||||
"""卡片消息容器(列表),此类型可以直接to_json后发送出去"""
|
||||
|
||||
def append(self, object: KookCardMessage) -> None:
|
||||
return super().append(object)
|
||||
|
||||
def to_json(self, indent: int | None = None, ensure_ascii: bool = True) -> str:
|
||||
return json.dumps(
|
||||
[i.to_dict() for i in self], indent=indent, ensure_ascii=ensure_ascii
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class OrderMessage:
|
||||
index: int
|
||||
text: str
|
||||
type: KookMessageType
|
||||
reply_id: str | int = ""
|
||||
@@ -34,7 +34,7 @@ from .server import LarkWebhookServer
|
||||
|
||||
|
||||
@register_platform_adapter(
|
||||
"lark", "飞书机器人官方 API 适配器", support_streaming_message=False
|
||||
"lark", "飞书机器人官方 API 适配器", support_streaming_message=True
|
||||
)
|
||||
class LarkPlatformAdapter(Platform):
|
||||
def __init__(
|
||||
@@ -491,7 +491,7 @@ class LarkPlatformAdapter(Platform):
|
||||
name="lark",
|
||||
description="飞书机器人官方 API 适配器",
|
||||
id=cast(str, self.config.get("id")),
|
||||
support_streaming_message=False,
|
||||
support_streaming_message=True,
|
||||
)
|
||||
|
||||
async def convert_msg(self, event: lark.im.v1.P2ImMessageReceiveV1) -> None:
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
@@ -5,6 +6,14 @@ import uuid
|
||||
from io import BytesIO
|
||||
|
||||
import lark_oapi as lark
|
||||
from lark_oapi.api.cardkit.v1 import (
|
||||
ContentCardElementRequest,
|
||||
ContentCardElementRequestBody,
|
||||
CreateCardRequest,
|
||||
CreateCardRequestBody,
|
||||
SettingsCardRequest,
|
||||
SettingsCardRequestBody,
|
||||
)
|
||||
from lark_oapi.api.im.v1 import (
|
||||
CreateFileRequest,
|
||||
CreateFileRequestBody,
|
||||
@@ -28,6 +37,7 @@ from astrbot.core.utils.media_utils import (
|
||||
convert_video_format,
|
||||
get_media_duration,
|
||||
)
|
||||
from astrbot.core.utils.metrics import Metric
|
||||
|
||||
|
||||
class LarkMessageEvent(AstrMessageEvent):
|
||||
@@ -555,15 +565,257 @@ class LarkMessageEvent(AstrMessageEvent):
|
||||
logger.error(f"发送飞书表情回应失败({response.code}): {response.msg}")
|
||||
return
|
||||
|
||||
async def send_streaming(self, generator, use_fallback: bool = False):
|
||||
async def _create_streaming_card(self) -> str | None:
|
||||
"""创建一个开启流式更新模式的卡片实体,返回 card_id。"""
|
||||
if self.bot.cardkit is None:
|
||||
logger.error("[Lark] API Client cardkit 模块未初始化")
|
||||
return None
|
||||
|
||||
card_json = {
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"title": {"content": "", "tag": "plain_text"},
|
||||
},
|
||||
"config": {
|
||||
"streaming_mode": True,
|
||||
"summary": {"content": ""},
|
||||
"streaming_config": {
|
||||
"print_frequency_ms": {"default": 50},
|
||||
"print_step": {"default": 2},
|
||||
"print_strategy": "fast",
|
||||
},
|
||||
},
|
||||
"body": {
|
||||
"elements": [
|
||||
{
|
||||
"tag": "markdown",
|
||||
"content": "",
|
||||
"element_id": "markdown_1",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
request = (
|
||||
CreateCardRequest.builder()
|
||||
.request_body(
|
||||
CreateCardRequestBody.builder()
|
||||
.type("card_json")
|
||||
.data(json.dumps(card_json, ensure_ascii=False))
|
||||
.build()
|
||||
)
|
||||
.build()
|
||||
)
|
||||
|
||||
try:
|
||||
response = await self.bot.cardkit.v1.card.acreate(request)
|
||||
except Exception as e:
|
||||
logger.error(f"[Lark] 创建流式卡片实体失败: {e}")
|
||||
return None
|
||||
|
||||
if not response.success():
|
||||
logger.error(
|
||||
f"[Lark] 创建流式卡片实体失败({response.code}): {response.msg}"
|
||||
)
|
||||
return None
|
||||
|
||||
if response.data is None or not response.data.card_id:
|
||||
logger.error("[Lark] 创建流式卡片实体成功但未返回 card_id")
|
||||
return None
|
||||
|
||||
card_id = response.data.card_id
|
||||
logger.debug(f"[Lark] 创建流式卡片实体成功: {card_id}")
|
||||
return card_id
|
||||
|
||||
async def _send_card_message(
|
||||
self,
|
||||
card_id: str,
|
||||
reply_message_id: str | None = None,
|
||||
receive_id: str | None = None,
|
||||
receive_id_type: str | None = None,
|
||||
) -> bool:
|
||||
"""将卡片实体作为 interactive 消息发送。"""
|
||||
content = json.dumps(
|
||||
{"type": "card", "data": {"card_id": card_id}},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
return await self._send_im_message(
|
||||
self.bot,
|
||||
content=content,
|
||||
msg_type="interactive",
|
||||
reply_message_id=reply_message_id,
|
||||
receive_id=receive_id,
|
||||
receive_id_type=receive_id_type,
|
||||
)
|
||||
|
||||
async def _update_streaming_text(
|
||||
self,
|
||||
card_id: str,
|
||||
content: str,
|
||||
sequence: int,
|
||||
) -> bool:
|
||||
"""调用 CardKit 流式更新文本接口,向 markdown_1 组件推送全量文本。"""
|
||||
if self.bot.cardkit is None:
|
||||
logger.error("[Lark] API Client cardkit 模块未初始化")
|
||||
return False
|
||||
|
||||
request = (
|
||||
ContentCardElementRequest.builder()
|
||||
.card_id(card_id)
|
||||
.element_id("markdown_1")
|
||||
.request_body(
|
||||
ContentCardElementRequestBody.builder()
|
||||
.content(content)
|
||||
.sequence(sequence)
|
||||
.uuid(str(uuid.uuid4()))
|
||||
.build()
|
||||
)
|
||||
.build()
|
||||
)
|
||||
|
||||
try:
|
||||
response = await self.bot.cardkit.v1.card_element.acontent(request)
|
||||
except Exception as e:
|
||||
logger.debug(f"[Lark] 流式更新文本失败 (ignored): {e}")
|
||||
return False
|
||||
|
||||
if not response.success():
|
||||
logger.debug(f"[Lark] 流式更新文本失败({response.code}): {response.msg}")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
async def _close_streaming_mode(
|
||||
self,
|
||||
card_id: str,
|
||||
sequence: int,
|
||||
) -> None:
|
||||
"""关闭卡片的流式更新模式,使其可正常转发、摘要恢复。"""
|
||||
if self.bot.cardkit is None:
|
||||
logger.error("[Lark] API Client cardkit 模块未初始化")
|
||||
return
|
||||
|
||||
settings_json = json.dumps(
|
||||
{"config": {"streaming_mode": False}},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
request = (
|
||||
SettingsCardRequest.builder()
|
||||
.card_id(card_id)
|
||||
.request_body(
|
||||
SettingsCardRequestBody.builder()
|
||||
.settings(settings_json)
|
||||
.sequence(sequence)
|
||||
.uuid(str(uuid.uuid4()))
|
||||
.build()
|
||||
)
|
||||
.build()
|
||||
)
|
||||
|
||||
try:
|
||||
response = await self.bot.cardkit.v1.card.asettings(request)
|
||||
except Exception as e:
|
||||
logger.error(f"[Lark] 关闭流式模式失败: {e}")
|
||||
return
|
||||
|
||||
if not response.success():
|
||||
logger.error(f"[Lark] 关闭流式模式失败({response.code}): {response.msg}")
|
||||
else:
|
||||
logger.debug(f"[Lark] 流式模式已关闭: {card_id}")
|
||||
|
||||
async def _fallback_send_streaming(self, generator, use_fallback: bool = False):
|
||||
"""回退到非流式发送:缓冲全部文本后一次性发送,并保留父类副作用。"""
|
||||
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)
|
||||
|
||||
if buffer:
|
||||
buffer.squash_plain()
|
||||
await self.send(buffer)
|
||||
|
||||
await Metric.upload(msg_event_tick=1, adapter_name=self.platform_meta.name)
|
||||
self._has_send_oper = True
|
||||
|
||||
async def send_streaming(self, generator, use_fallback: bool = False):
|
||||
"""使用 CardKit 流式卡片实现打字机效果。
|
||||
|
||||
流程:创建卡片实体 → 发送消息 → 流式更新文本 → 关闭流式模式。
|
||||
使用解耦发送循环,LLM token 到达时只更新 buffer 并唤醒发送协程,
|
||||
发送频率由网络 RTT 自然限流。
|
||||
"""
|
||||
# Step 1: 创建流式卡片实体
|
||||
card_id = await self._create_streaming_card()
|
||||
if not card_id:
|
||||
logger.warning("[Lark] 无法创建流式卡片,回退到非流式发送")
|
||||
await self._fallback_send_streaming(generator, use_fallback)
|
||||
return
|
||||
|
||||
# Step 2: 发送卡片消息
|
||||
sent = await self._send_card_message(
|
||||
card_id,
|
||||
reply_message_id=self.message_obj.message_id,
|
||||
)
|
||||
if not sent:
|
||||
logger.error("[Lark] 发送流式卡片消息失败,回退到非流式发送")
|
||||
await self._fallback_send_streaming(generator, use_fallback)
|
||||
return
|
||||
|
||||
logger.info("[Lark] 流式输出: 使用 CardKit 流式卡片")
|
||||
|
||||
# Step 3: 解耦发送循环 (Event-driven, 参考 Telegram Draft 路径)
|
||||
sequence = 0
|
||||
delta = ""
|
||||
last_sent = ""
|
||||
done = False
|
||||
text_changed = asyncio.Event()
|
||||
|
||||
async def _sender_loop() -> None:
|
||||
"""信号驱动的文本发送循环,有新内容就发,RTT 自然限流。"""
|
||||
nonlocal sequence, last_sent
|
||||
while not done:
|
||||
await text_changed.wait()
|
||||
text_changed.clear()
|
||||
snapshot = delta
|
||||
if snapshot and snapshot != last_sent:
|
||||
sequence += 1
|
||||
ok = await self._update_streaming_text(card_id, snapshot, sequence)
|
||||
if ok:
|
||||
last_sent = snapshot
|
||||
if delta != snapshot:
|
||||
text_changed.set()
|
||||
|
||||
sender_task = asyncio.create_task(_sender_loop())
|
||||
|
||||
try:
|
||||
async for chain in generator:
|
||||
if not isinstance(chain, MessageChain):
|
||||
continue
|
||||
|
||||
if chain.type == "break":
|
||||
# 飞书卡片不支持分段,忽略 break
|
||||
continue
|
||||
|
||||
for comp in chain.chain:
|
||||
if isinstance(comp, Plain):
|
||||
delta += comp.text
|
||||
text_changed.set()
|
||||
finally:
|
||||
done = True
|
||||
text_changed.set()
|
||||
await sender_task
|
||||
|
||||
# Step 4: 必要时补发最终文本 + 关闭流式模式
|
||||
if delta and delta != last_sent:
|
||||
sequence += 1
|
||||
await self._update_streaming_text(card_id, delta, sequence)
|
||||
|
||||
sequence += 1
|
||||
await self._close_streaming_mode(card_id, sequence)
|
||||
|
||||
# Step 5: 内联父类 send_streaming 的副作用
|
||||
await Metric.upload(msg_event_tick=1, adapter_name=self.platform_meta.name)
|
||||
self._has_send_oper = True
|
||||
|
||||
@@ -104,7 +104,7 @@ class LineMessageEvent(AstrMessageEvent):
|
||||
@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://"):
|
||||
if candidate.startswith("https://"):
|
||||
return candidate
|
||||
try:
|
||||
return await segment.register_to_file_service()
|
||||
@@ -115,7 +115,7 @@ class LineMessageEvent(AstrMessageEvent):
|
||||
@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://"):
|
||||
if candidate.startswith("https://"):
|
||||
return candidate
|
||||
try:
|
||||
return await segment.register_to_file_service()
|
||||
@@ -137,7 +137,7 @@ class LineMessageEvent(AstrMessageEvent):
|
||||
@staticmethod
|
||||
async def _resolve_video_url(segment: Video) -> str:
|
||||
candidate = (segment.file or "").strip()
|
||||
if candidate.startswith("http://") or candidate.startswith("https://"):
|
||||
if candidate.startswith("https://"):
|
||||
return candidate
|
||||
try:
|
||||
return await segment.register_to_file_service()
|
||||
@@ -148,9 +148,7 @@ class LineMessageEvent(AstrMessageEvent):
|
||||
@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://"
|
||||
):
|
||||
if cover_candidate.startswith("https://"):
|
||||
return cover_candidate
|
||||
|
||||
if cover_candidate:
|
||||
@@ -191,7 +189,7 @@ class LineMessageEvent(AstrMessageEvent):
|
||||
|
||||
@staticmethod
|
||||
async def _resolve_file_url(segment: File) -> str:
|
||||
if segment.url and segment.url.startswith(("http://", "https://")):
|
||||
if segment.url and segment.url.startswith("https://"):
|
||||
return segment.url
|
||||
try:
|
||||
return await segment.register_to_file_service()
|
||||
|
||||
@@ -18,7 +18,7 @@ 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.message_components import File, Image, Plain, Record, Video
|
||||
from astrbot.api.platform import AstrBotMessage, PlatformMetadata
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
|
||||
from astrbot.core.utils.io import download_image_by_url, file_to_base64
|
||||
@@ -47,6 +47,11 @@ _patch_qq_botpy_formdata()
|
||||
|
||||
class QQOfficialMessageEvent(AstrMessageEvent):
|
||||
MARKDOWN_NOT_ALLOWED_ERROR = "不允许发送原生 markdown"
|
||||
IMAGE_FILE_TYPE = 1
|
||||
VIDEO_FILE_TYPE = 2
|
||||
VOICE_FILE_TYPE = 3
|
||||
FILE_FILE_TYPE = 4
|
||||
STREAM_MARKDOWN_NEWLINE_ERROR = "流式消息md分片需要\\n结束"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -65,35 +70,71 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
||||
await self._post_send()
|
||||
|
||||
async def send_streaming(self, generator, use_fallback: bool = False):
|
||||
"""流式输出仅支持消息列表私聊"""
|
||||
"""流式输出仅支持消息列表私聊(C2C),其他消息源退化为普通发送"""
|
||||
# 先标记事件层“已执行发送操作”,避免异常路径遗漏
|
||||
await super().send_streaming(generator, use_fallback)
|
||||
# QQ C2C 流式协议:开始/中间分片使用 state=1,结束分片使用 state=10
|
||||
stream_payload = {"state": 1, "id": None, "index": 0, "reset": False}
|
||||
last_edit_time = 0 # 上次编辑消息的时间
|
||||
throttle_interval = 1 # 编辑消息的间隔时间 (秒)
|
||||
last_edit_time = 0 # 上次发送分片的时间
|
||||
throttle_interval = 1 # 分片间最短间隔 (秒)
|
||||
ret = None
|
||||
source = (
|
||||
self.message_obj.raw_message
|
||||
) # 提前获取,避免 generator 为空时 NameError
|
||||
try:
|
||||
async for chain in generator:
|
||||
source = self.message_obj.raw_message
|
||||
|
||||
if not isinstance(source, botpy.message.C2CMessage):
|
||||
# 非 C2C 场景:直接累积,最后统一发
|
||||
if not self.send_buffer:
|
||||
self.send_buffer = chain
|
||||
else:
|
||||
self.send_buffer.chain.extend(chain.chain)
|
||||
continue
|
||||
|
||||
# ---- C2C 流式场景 ----
|
||||
|
||||
# tool_call break 信号:工具开始执行,先把已有 buffer 以 state=10 结束当前流式段
|
||||
if chain.type == "break":
|
||||
if self.send_buffer:
|
||||
stream_payload["state"] = 10
|
||||
ret = await self._post_send(stream=stream_payload)
|
||||
ret_id = self._extract_response_message_id(ret)
|
||||
if ret_id is not None:
|
||||
stream_payload["id"] = ret_id
|
||||
# 重置 stream_payload,为下一段流式做准备
|
||||
stream_payload = {
|
||||
"state": 1,
|
||||
"id": None,
|
||||
"index": 0,
|
||||
"reset": False,
|
||||
}
|
||||
last_edit_time = 0
|
||||
continue
|
||||
|
||||
# 累积内容
|
||||
if not self.send_buffer:
|
||||
self.send_buffer = chain
|
||||
else:
|
||||
self.send_buffer.chain.extend(chain.chain)
|
||||
|
||||
if isinstance(source, botpy.message.C2CMessage):
|
||||
# 真流式传输
|
||||
current_time = asyncio.get_event_loop().time()
|
||||
time_since_last_edit = current_time - last_edit_time
|
||||
|
||||
if time_since_last_edit >= throttle_interval:
|
||||
ret = cast(
|
||||
message.Message,
|
||||
await self._post_send(stream=stream_payload),
|
||||
)
|
||||
stream_payload["index"] += 1
|
||||
stream_payload["id"] = ret["id"]
|
||||
last_edit_time = asyncio.get_event_loop().time()
|
||||
# 节流:按时间间隔发送中间分片
|
||||
current_time = asyncio.get_running_loop().time()
|
||||
if current_time - last_edit_time >= throttle_interval:
|
||||
ret = cast(
|
||||
message.Message,
|
||||
await self._post_send(stream=stream_payload),
|
||||
)
|
||||
stream_payload["index"] += 1
|
||||
ret_id = self._extract_response_message_id(ret)
|
||||
if ret_id is not None:
|
||||
stream_payload["id"] = ret_id
|
||||
last_edit_time = asyncio.get_running_loop().time()
|
||||
self.send_buffer = None # 清空已发送的分片,避免下次重复发送旧内容
|
||||
|
||||
if isinstance(source, botpy.message.C2CMessage):
|
||||
# 结束流式对话,并且传输 buffer 中剩余的消息
|
||||
# 结束流式对话,发送 buffer 中剩余内容
|
||||
stream_payload["state"] = 10
|
||||
ret = await self._post_send(stream=stream_payload)
|
||||
else:
|
||||
@@ -101,9 +142,22 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"发送流式消息时出错: {e}", exc_info=True)
|
||||
# 避免累计内容在异常后被整包重复发送:仅清理缓存,不做非流式整包兜底
|
||||
# 如需兜底,应该只发送未发送 delta(后续可继续优化)
|
||||
self.send_buffer = None
|
||||
|
||||
return await super().send_streaming(generator, use_fallback)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _extract_response_message_id(ret) -> str | None:
|
||||
"""兼容 qq-botpy 返回 Message 对象或 dict 两种形态。"""
|
||||
if ret is None:
|
||||
return None
|
||||
if isinstance(ret, dict):
|
||||
ret_id = ret.get("id")
|
||||
return str(ret_id) if ret_id is not None else None
|
||||
ret_id = getattr(ret, "id", None)
|
||||
return str(ret_id) if ret_id is not None else None
|
||||
|
||||
async def _post_send(self, stream: dict | None = None):
|
||||
if not self.send_buffer:
|
||||
@@ -126,16 +180,37 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
||||
image_base64,
|
||||
image_path,
|
||||
record_file_path,
|
||||
video_file_source,
|
||||
file_source,
|
||||
file_name,
|
||||
) = await QQOfficialMessageEvent._parse_to_qqofficial(self.send_buffer)
|
||||
|
||||
# C2C 流式仅用于文本分片,富媒体时降级为普通发送,避免平台侧流式校验报错。
|
||||
if stream and (image_base64 or record_file_path):
|
||||
logger.debug("[QQOfficial] 检测到富媒体,降级为非流式发送。")
|
||||
stream = None
|
||||
|
||||
if (
|
||||
not plain_text
|
||||
and not image_base64
|
||||
and not image_path
|
||||
and not record_file_path
|
||||
and not video_file_source
|
||||
and not file_source
|
||||
):
|
||||
return None
|
||||
|
||||
# QQ C2C 流式 API 说明:
|
||||
# - 开始/中间分片(state=1):增量追加内容,不需要 \n(加了会导致强制换行)
|
||||
# - 最终分片(state=10):结束流,content 必须以 \n 结尾(QQ API 要求)
|
||||
if (
|
||||
stream
|
||||
and stream.get("state") == 10
|
||||
and plain_text
|
||||
and not plain_text.endswith("\n")
|
||||
):
|
||||
plain_text = plain_text + "\n"
|
||||
|
||||
payload: dict = {
|
||||
# "content": plain_text,
|
||||
"markdown": MarkdownPayload(content=plain_text) if plain_text else None,
|
||||
@@ -157,7 +232,7 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
||||
if image_base64:
|
||||
media = await self.upload_group_and_c2c_image(
|
||||
image_base64,
|
||||
1,
|
||||
self.IMAGE_FILE_TYPE,
|
||||
group_openid=source.group_openid,
|
||||
)
|
||||
payload["media"] = media
|
||||
@@ -165,15 +240,39 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
||||
payload.pop("markdown", None)
|
||||
payload["content"] = plain_text or None
|
||||
if record_file_path: # group record msg
|
||||
media = await self.upload_group_and_c2c_record(
|
||||
media = await self.upload_group_and_c2c_media(
|
||||
record_file_path,
|
||||
3,
|
||||
self.VOICE_FILE_TYPE,
|
||||
group_openid=source.group_openid,
|
||||
)
|
||||
payload["media"] = media
|
||||
payload["msg_type"] = 7
|
||||
payload.pop("markdown", None)
|
||||
payload["content"] = plain_text or None
|
||||
if media:
|
||||
payload["media"] = media
|
||||
payload["msg_type"] = 7
|
||||
payload.pop("markdown", None)
|
||||
payload["content"] = plain_text or None
|
||||
if video_file_source:
|
||||
media = await self.upload_group_and_c2c_media(
|
||||
video_file_source,
|
||||
self.VIDEO_FILE_TYPE,
|
||||
group_openid=source.group_openid,
|
||||
)
|
||||
if media:
|
||||
payload["media"] = media
|
||||
payload["msg_type"] = 7
|
||||
payload.pop("markdown", None)
|
||||
payload["content"] = plain_text or None
|
||||
if file_source:
|
||||
media = await self.upload_group_and_c2c_media(
|
||||
file_source,
|
||||
self.FILE_FILE_TYPE,
|
||||
file_name=file_name,
|
||||
group_openid=source.group_openid,
|
||||
)
|
||||
if media:
|
||||
payload["media"] = media
|
||||
payload["msg_type"] = 7
|
||||
payload.pop("markdown", None)
|
||||
payload["content"] = plain_text or None
|
||||
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
|
||||
@@ -181,13 +280,14 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
||||
),
|
||||
payload=payload,
|
||||
plain_text=plain_text,
|
||||
stream=stream,
|
||||
)
|
||||
|
||||
case botpy.message.C2CMessage():
|
||||
if image_base64:
|
||||
media = await self.upload_group_and_c2c_image(
|
||||
image_base64,
|
||||
1,
|
||||
self.IMAGE_FILE_TYPE,
|
||||
openid=source.author.user_openid,
|
||||
)
|
||||
payload["media"] = media
|
||||
@@ -195,15 +295,39 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
||||
payload.pop("markdown", None)
|
||||
payload["content"] = plain_text or None
|
||||
if record_file_path: # c2c record
|
||||
media = await self.upload_group_and_c2c_record(
|
||||
media = await self.upload_group_and_c2c_media(
|
||||
record_file_path,
|
||||
3,
|
||||
self.VOICE_FILE_TYPE,
|
||||
openid=source.author.user_openid,
|
||||
)
|
||||
payload["media"] = media
|
||||
payload["msg_type"] = 7
|
||||
payload.pop("markdown", None)
|
||||
payload["content"] = plain_text or None
|
||||
if media:
|
||||
payload["media"] = media
|
||||
payload["msg_type"] = 7
|
||||
payload.pop("markdown", None)
|
||||
payload["content"] = plain_text or None
|
||||
if video_file_source:
|
||||
media = await self.upload_group_and_c2c_media(
|
||||
video_file_source,
|
||||
self.VIDEO_FILE_TYPE,
|
||||
openid=source.author.user_openid,
|
||||
)
|
||||
if media:
|
||||
payload["media"] = media
|
||||
payload["msg_type"] = 7
|
||||
payload.pop("markdown", None)
|
||||
payload["content"] = plain_text or None
|
||||
if file_source:
|
||||
media = await self.upload_group_and_c2c_media(
|
||||
file_source,
|
||||
self.FILE_FILE_TYPE,
|
||||
file_name=file_name,
|
||||
openid=source.author.user_openid,
|
||||
)
|
||||
if media:
|
||||
payload["media"] = media
|
||||
payload["msg_type"] = 7
|
||||
payload.pop("markdown", None)
|
||||
payload["content"] = plain_text or None
|
||||
if stream:
|
||||
ret = await self._send_with_markdown_fallback(
|
||||
send_func=lambda retry_payload: self.post_c2c_message(
|
||||
@@ -213,6 +337,7 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
||||
),
|
||||
payload=payload,
|
||||
plain_text=plain_text,
|
||||
stream=stream,
|
||||
)
|
||||
else:
|
||||
ret = await self._send_with_markdown_fallback(
|
||||
@@ -222,6 +347,7 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
||||
),
|
||||
payload=payload,
|
||||
plain_text=plain_text,
|
||||
stream=stream,
|
||||
)
|
||||
logger.debug(f"Message sent to C2C: {ret}")
|
||||
|
||||
@@ -237,6 +363,7 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
||||
),
|
||||
payload=payload,
|
||||
plain_text=plain_text,
|
||||
stream=stream,
|
||||
)
|
||||
|
||||
case botpy.message.DirectMessage():
|
||||
@@ -251,6 +378,7 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
||||
),
|
||||
payload=payload,
|
||||
plain_text=plain_text,
|
||||
stream=stream,
|
||||
)
|
||||
|
||||
case _:
|
||||
@@ -267,10 +395,31 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
||||
send_func,
|
||||
payload: dict,
|
||||
plain_text: str,
|
||||
stream: dict | None = None,
|
||||
):
|
||||
try:
|
||||
return await send_func(payload)
|
||||
except botpy.errors.ServerError as err:
|
||||
# QQ 流式 markdown 分片校验:内容必须以换行结尾。
|
||||
# 某些边界场景服务端仍可能判定失败,这里做一次修正重试。
|
||||
if stream and self.STREAM_MARKDOWN_NEWLINE_ERROR in str(err):
|
||||
retry_payload = payload.copy()
|
||||
|
||||
markdown_payload = retry_payload.get("markdown")
|
||||
if isinstance(markdown_payload, dict):
|
||||
md_content = cast(str, markdown_payload.get("content", "") or "")
|
||||
if md_content and not md_content.endswith("\n"):
|
||||
retry_payload["markdown"] = {"content": md_content + "\n"}
|
||||
|
||||
content = cast(str | None, retry_payload.get("content"))
|
||||
if content and not content.endswith("\n"):
|
||||
retry_payload["content"] = content + "\n"
|
||||
|
||||
logger.warning(
|
||||
"[QQOfficial] 流式 markdown 分片换行校验失败,已修正后重试一次。"
|
||||
)
|
||||
return await send_func(retry_payload)
|
||||
|
||||
if (
|
||||
self.MARKDOWN_NOT_ALLOWED_ERROR not in str(err)
|
||||
or not payload.get("markdown")
|
||||
@@ -282,10 +431,14 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
||||
"[QQOfficial] markdown 发送被拒绝,回退到 content 模式重试。"
|
||||
)
|
||||
fallback_payload = payload.copy()
|
||||
fallback_payload["markdown"] = None
|
||||
fallback_payload.pop("markdown", None)
|
||||
fallback_payload["content"] = plain_text
|
||||
if fallback_payload.get("msg_type") == 2:
|
||||
fallback_payload["msg_type"] = 0
|
||||
if stream:
|
||||
fallback_content = cast(str, fallback_payload.get("content") or "")
|
||||
if fallback_content and not fallback_content.endswith("\n"):
|
||||
fallback_payload["content"] = fallback_content + "\n"
|
||||
return await send_func(fallback_payload)
|
||||
|
||||
async def upload_group_and_c2c_image(
|
||||
@@ -327,16 +480,19 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
||||
ttl=result.get("ttl", 0),
|
||||
)
|
||||
|
||||
async def upload_group_and_c2c_record(
|
||||
async def upload_group_and_c2c_media(
|
||||
self,
|
||||
file_source: str,
|
||||
file_type: int,
|
||||
srv_send_msg: bool = False,
|
||||
file_name: str | None = None,
|
||||
**kwargs,
|
||||
) -> Media | None:
|
||||
"""上传媒体文件"""
|
||||
# 构建基础payload
|
||||
payload = {"file_type": file_type, "srv_send_msg": srv_send_msg}
|
||||
if file_name:
|
||||
payload["file_name"] = file_name
|
||||
|
||||
# 处理文件数据
|
||||
if os.path.exists(file_source):
|
||||
@@ -400,13 +556,21 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
||||
) -> message.Message:
|
||||
payload = locals()
|
||||
payload.pop("self", None)
|
||||
# QQ API does not accept stream.id=None; remove it when not yet assigned
|
||||
if "stream" in payload and payload["stream"] is not None:
|
||||
stream_data = dict(payload["stream"])
|
||||
if stream_data.get("id") is None:
|
||||
stream_data.pop("id", None)
|
||||
payload["stream"] = stream_data
|
||||
route = Route("POST", "/v2/users/{openid}/messages", openid=openid)
|
||||
result = await self.bot.api._http.request(route, json=payload)
|
||||
|
||||
if result is None:
|
||||
logger.warning("[QQOfficial] post_c2c_message: API 返回 None,跳过本次发送")
|
||||
return None
|
||||
if not isinstance(result, dict):
|
||||
raise RuntimeError(
|
||||
f"Failed to post c2c message, response is not dict: {result}"
|
||||
)
|
||||
logger.error(f"[QQOfficial] post_c2c_message: 响应不是 dict: {result}")
|
||||
return None
|
||||
|
||||
return message.Message(**result)
|
||||
|
||||
@@ -416,6 +580,9 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
||||
image_base64 = None # only one img supported
|
||||
image_file_path = None
|
||||
record_file_path = None
|
||||
video_file_source = None
|
||||
file_source = None
|
||||
file_name = None
|
||||
for i in message.chain:
|
||||
if isinstance(i, Plain):
|
||||
plain_text += i.text
|
||||
@@ -454,6 +621,30 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
||||
except Exception as e:
|
||||
logger.error(f"处理语音时出错: {e}")
|
||||
record_file_path = None
|
||||
elif isinstance(i, Video) and not video_file_source:
|
||||
if i.file.startswith("file:///"):
|
||||
video_file_source = i.file[8:]
|
||||
else:
|
||||
video_file_source = i.file
|
||||
elif isinstance(i, File) and not file_source:
|
||||
file_name = i.name
|
||||
if i.file_:
|
||||
file_path = i.file_
|
||||
if file_path.startswith("file:///"):
|
||||
file_path = file_path[8:]
|
||||
elif file_path.startswith("file://"):
|
||||
file_path = file_path[7:]
|
||||
file_source = file_path
|
||||
elif i.url:
|
||||
file_source = i.url
|
||||
else:
|
||||
logger.debug(f"qq_official 忽略 {i.type}")
|
||||
return plain_text, image_base64, image_file_path, record_file_path
|
||||
return (
|
||||
plain_text,
|
||||
image_base64,
|
||||
image_file_path,
|
||||
record_file_path,
|
||||
video_file_source,
|
||||
file_source,
|
||||
file_name,
|
||||
)
|
||||
|
||||
@@ -3,8 +3,10 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
import time
|
||||
from typing import cast
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, cast
|
||||
|
||||
import botpy
|
||||
import botpy.message
|
||||
@@ -12,7 +14,7 @@ from botpy import Client
|
||||
|
||||
from astrbot import logger
|
||||
from astrbot.api.event import MessageChain
|
||||
from astrbot.api.message_components import At, File, Image, Plain
|
||||
from astrbot.api.message_components import At, File, Image, Plain, Record, Video
|
||||
from astrbot.api.platform import (
|
||||
AstrBotMessage,
|
||||
MessageMember,
|
||||
@@ -46,6 +48,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)
|
||||
|
||||
# 收到频道消息
|
||||
@@ -56,6 +59,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)
|
||||
|
||||
# 收到私聊消息
|
||||
@@ -67,6 +71,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 消息
|
||||
@@ -76,9 +81,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(
|
||||
QQOfficialMessageEvent(
|
||||
abm.message_str,
|
||||
@@ -124,6 +131,9 @@ class QQOfficialPlatformAdapter(Platform):
|
||||
|
||||
self.client.set_platform(self)
|
||||
|
||||
self._session_last_message_id: dict[str, str] = {}
|
||||
self._session_scene: dict[str, str] = {}
|
||||
|
||||
self.test_mode = os.environ.get("TEST_MODE", "off") == "on"
|
||||
|
||||
async def send_by_session(
|
||||
@@ -131,14 +141,191 @@ class QQOfficialPlatformAdapter(Platform):
|
||||
session: MessageSesion,
|
||||
message_chain: MessageChain,
|
||||
) -> None:
|
||||
raise NotImplementedError("QQ 机器人官方 API 适配器不支持 send_by_session")
|
||||
await self._send_by_session_common(session, message_chain)
|
||||
|
||||
async def _send_by_session_common(
|
||||
self,
|
||||
session: MessageSesion,
|
||||
message_chain: MessageChain,
|
||||
) -> None:
|
||||
(
|
||||
plain_text,
|
||||
image_base64,
|
||||
image_path,
|
||||
record_file_path,
|
||||
video_file_source,
|
||||
file_source,
|
||||
file_name,
|
||||
) = await QQOfficialMessageEvent._parse_to_qqofficial(message_chain)
|
||||
if (
|
||||
not plain_text
|
||||
and not image_path
|
||||
and not image_base64
|
||||
and not record_file_path
|
||||
and not video_file_source
|
||||
and not file_source
|
||||
):
|
||||
return
|
||||
|
||||
msg_id = self._session_last_message_id.get(session.session_id)
|
||||
if not msg_id:
|
||||
logger.warning(
|
||||
"[QQOfficial] 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,
|
||||
QQOfficialMessageEvent.IMAGE_FILE_TYPE,
|
||||
group_openid=session.session_id,
|
||||
)
|
||||
payload["media"] = media
|
||||
payload["msg_type"] = 7
|
||||
if record_file_path:
|
||||
media = await QQOfficialMessageEvent.upload_group_and_c2c_media(
|
||||
send_helper, # type: ignore
|
||||
record_file_path,
|
||||
QQOfficialMessageEvent.VOICE_FILE_TYPE,
|
||||
group_openid=session.session_id,
|
||||
)
|
||||
if media:
|
||||
payload["media"] = media
|
||||
payload["msg_type"] = 7
|
||||
if video_file_source:
|
||||
media = await QQOfficialMessageEvent.upload_group_and_c2c_media(
|
||||
send_helper, # type: ignore
|
||||
video_file_source,
|
||||
QQOfficialMessageEvent.VIDEO_FILE_TYPE,
|
||||
group_openid=session.session_id,
|
||||
)
|
||||
if media:
|
||||
payload["media"] = media
|
||||
payload["msg_type"] = 7
|
||||
payload.pop("msg_id", None)
|
||||
if file_source:
|
||||
media = await QQOfficialMessageEvent.upload_group_and_c2c_media(
|
||||
send_helper, # type: ignore
|
||||
file_source,
|
||||
QQOfficialMessageEvent.FILE_FILE_TYPE,
|
||||
file_name=file_name,
|
||||
group_openid=session.session_id,
|
||||
)
|
||||
if media:
|
||||
payload["media"] = media
|
||||
payload["msg_type"] = 7
|
||||
payload.pop("msg_id", None)
|
||||
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,
|
||||
QQOfficialMessageEvent.IMAGE_FILE_TYPE,
|
||||
openid=session.session_id,
|
||||
)
|
||||
payload["media"] = media
|
||||
payload["msg_type"] = 7
|
||||
if record_file_path:
|
||||
media = await QQOfficialMessageEvent.upload_group_and_c2c_media(
|
||||
send_helper, # type: ignore
|
||||
record_file_path,
|
||||
QQOfficialMessageEvent.VOICE_FILE_TYPE,
|
||||
openid=session.session_id,
|
||||
)
|
||||
if media:
|
||||
payload["media"] = media
|
||||
payload["msg_type"] = 7
|
||||
if video_file_source:
|
||||
media = await QQOfficialMessageEvent.upload_group_and_c2c_media(
|
||||
send_helper, # type: ignore
|
||||
video_file_source,
|
||||
QQOfficialMessageEvent.VIDEO_FILE_TYPE,
|
||||
openid=session.session_id,
|
||||
)
|
||||
if media:
|
||||
payload["media"] = media
|
||||
payload["msg_type"] = 7
|
||||
# QQ API rejects msg_id for media (video/file) messages sent
|
||||
# via the proactive tool-call path; remove it to avoid 越权 error.
|
||||
payload.pop("msg_id", None)
|
||||
if file_source:
|
||||
media = await QQOfficialMessageEvent.upload_group_and_c2c_media(
|
||||
send_helper, # type: ignore
|
||||
file_source,
|
||||
QQOfficialMessageEvent.FILE_FILE_TYPE,
|
||||
file_name=file_name,
|
||||
openid=session.session_id,
|
||||
)
|
||||
if media:
|
||||
payload["media"] = media
|
||||
payload["msg_type"] = 7
|
||||
payload.pop("msg_id", None)
|
||||
|
||||
ret = await QQOfficialMessageEvent.post_c2c_message(
|
||||
send_helper, # type: ignore
|
||||
openid=session.session_id,
|
||||
**payload,
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"[QQOfficial] 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",
|
||||
description="QQ 机器人官方 API 适配器",
|
||||
id=cast(str, self.config.get("id")),
|
||||
support_proactive_message=False,
|
||||
support_proactive_message=True,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@@ -158,7 +345,10 @@ class QQOfficialPlatformAdapter(Platform):
|
||||
return
|
||||
|
||||
for attachment in attachments:
|
||||
content_type = cast(str, getattr(attachment, "content_type", "") or "")
|
||||
content_type = cast(
|
||||
str,
|
||||
getattr(attachment, "content_type", "") or "",
|
||||
).lower()
|
||||
url = QQOfficialPlatformAdapter._normalize_attachment_url(
|
||||
cast(str | None, getattr(attachment, "url", None))
|
||||
)
|
||||
@@ -174,7 +364,73 @@ class QQOfficialPlatformAdapter(Platform):
|
||||
or getattr(attachment, "name", None)
|
||||
or "attachment",
|
||||
)
|
||||
msg.append(File(name=filename, file=url, url=url))
|
||||
ext = os.path.splitext(filename)[1].lower()
|
||||
image_exts = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"}
|
||||
audio_exts = {
|
||||
".mp3",
|
||||
".wav",
|
||||
".ogg",
|
||||
".m4a",
|
||||
".amr",
|
||||
".silk",
|
||||
}
|
||||
video_exts = {
|
||||
".mp4",
|
||||
".mov",
|
||||
".avi",
|
||||
".mkv",
|
||||
".webm",
|
||||
}
|
||||
|
||||
if content_type.startswith("audio") or ext in audio_exts:
|
||||
msg.append(Record.fromURL(url))
|
||||
elif content_type.startswith("video") or ext in video_exts:
|
||||
msg.append(Video.fromURL(url))
|
||||
elif content_type.startswith("image") or ext in image_exts:
|
||||
msg.append(Image.fromURL(url))
|
||||
else:
|
||||
msg.append(File(name=filename, file=url, url=url))
|
||||
|
||||
@staticmethod
|
||||
def _parse_face_message(content: str) -> str:
|
||||
"""Parse QQ official face message format and convert to readable text.
|
||||
|
||||
QQ official face message format:
|
||||
<faceType=4,faceId="",ext="eyJ0ZXh0IjoiW+a7oeWktOmXruWPt10ifQ==">
|
||||
|
||||
The ext field contains base64-encoded JSON with a 'text' field
|
||||
describing the emoji (e.g., '[满头问号]').
|
||||
|
||||
Args:
|
||||
content: The message content that may contain face tags.
|
||||
|
||||
Returns:
|
||||
Content with face tags replaced by readable emoji descriptions.
|
||||
"""
|
||||
import base64
|
||||
import json
|
||||
import re
|
||||
|
||||
def replace_face(match):
|
||||
face_tag = match.group(0)
|
||||
# Extract ext field from the face tag
|
||||
ext_match = re.search(r'ext="([^"]*)"', face_tag)
|
||||
if ext_match:
|
||||
try:
|
||||
ext_encoded = ext_match.group(1)
|
||||
# Decode base64 and parse JSON
|
||||
ext_decoded = base64.b64decode(ext_encoded).decode("utf-8")
|
||||
ext_data = json.loads(ext_decoded)
|
||||
emoji_text = ext_data.get("text", "")
|
||||
if emoji_text:
|
||||
return f"[表情:{emoji_text}]"
|
||||
except Exception:
|
||||
pass
|
||||
# Fallback if parsing fails
|
||||
return "[表情]"
|
||||
|
||||
# Match face tags: <faceType=...>
|
||||
return re.sub(r"<faceType=\d+[^>]*>", replace_face, content)
|
||||
|
||||
@staticmethod
|
||||
def _parse_from_qqofficial(
|
||||
@@ -201,7 +457,10 @@ class QQOfficialPlatformAdapter(Platform):
|
||||
abm.group_id = message.group_openid
|
||||
else:
|
||||
abm.sender = MessageMember(message.author.user_openid, "")
|
||||
abm.message_str = message.content.strip()
|
||||
# Parse face messages to readable text
|
||||
abm.message_str = QQOfficialPlatformAdapter._parse_face_message(
|
||||
message.content.strip()
|
||||
)
|
||||
abm.self_id = "unknown_selfid"
|
||||
msg.append(At(qq="qq_official"))
|
||||
msg.append(Plain(abm.message_str))
|
||||
@@ -217,10 +476,12 @@ class QQOfficialPlatformAdapter(Platform):
|
||||
else:
|
||||
abm.self_id = ""
|
||||
|
||||
plain_content = message.content.replace(
|
||||
"<@!" + str(abm.self_id) + ">",
|
||||
"",
|
||||
).strip()
|
||||
plain_content = QQOfficialPlatformAdapter._parse_face_message(
|
||||
message.content.replace(
|
||||
"<@!" + str(abm.self_id) + ">",
|
||||
"",
|
||||
).strip()
|
||||
)
|
||||
|
||||
QQOfficialPlatformAdapter._append_attachments(msg, message.attachments)
|
||||
abm.message = msg
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import random
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, cast
|
||||
|
||||
import botpy
|
||||
@@ -15,7 +13,6 @@ 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
|
||||
@@ -123,95 +120,11 @@ class QQOfficialWebhookPlatformAdapter(Platform):
|
||||
session: MessageSesion,
|
||||
message_chain: MessageChain,
|
||||
) -> None:
|
||||
(
|
||||
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)
|
||||
await QQOfficialPlatformAdapter._send_by_session_common(
|
||||
cast(Any, self),
|
||||
session,
|
||||
message_chain,
|
||||
)
|
||||
|
||||
def remember_session_message_id(self, session_id: str, message_id: str) -> None:
|
||||
if not session_id or not message_id:
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from typing import cast
|
||||
|
||||
import quart
|
||||
@@ -39,6 +40,9 @@ class QQOfficialWebhook:
|
||||
self.client = botpy_client
|
||||
self.event_queue = event_queue
|
||||
self.shutdown_event = asyncio.Event()
|
||||
# Deduplication cache for webhook retry callbacks.
|
||||
self._seen_event_ids: dict[str, float] = {}
|
||||
self._dedup_ttl: int = 60 # seconds
|
||||
|
||||
async def initialize(self) -> None:
|
||||
logger.info("正在登录到 QQ 官方机器人...")
|
||||
@@ -55,7 +59,7 @@ class QQOfficialWebhook:
|
||||
max_async=1,
|
||||
connect=bot_connect,
|
||||
dispatch=self.client.ws_dispatch,
|
||||
loop=asyncio.get_event_loop(),
|
||||
loop=asyncio.get_running_loop(),
|
||||
api=self.api,
|
||||
)
|
||||
|
||||
@@ -106,6 +110,22 @@ class QQOfficialWebhook:
|
||||
print(signed)
|
||||
return signed
|
||||
|
||||
event_id = msg.get("id")
|
||||
if event_id:
|
||||
now = time.monotonic()
|
||||
# Lazily evict expired entries to prevent unbounded growth.
|
||||
expired = [
|
||||
k
|
||||
for k, ts in self._seen_event_ids.items()
|
||||
if now - ts > self._dedup_ttl
|
||||
]
|
||||
for k in expired:
|
||||
del self._seen_event_ids[k]
|
||||
if event_id in self._seen_event_ids:
|
||||
logger.debug(f"Duplicate webhook event {event_id!r}, skipping.")
|
||||
return {"opcode": 12}
|
||||
self._seen_event_ids[event_id] = now
|
||||
|
||||
if event and opcode == BotWebSocket.WS_DISPATCH_EVENT:
|
||||
event = msg["t"].lower()
|
||||
try:
|
||||
|
||||
@@ -289,8 +289,8 @@ class TelegramPlatformAdapter(Platform):
|
||||
else:
|
||||
message.type = MessageType.GROUP_MESSAGE
|
||||
message.group_id = str(update.message.chat.id)
|
||||
if update.message.message_thread_id:
|
||||
# Topic Group
|
||||
if update.message.is_topic_message and update.message.message_thread_id:
|
||||
# Telegram Topic Group: include thread id to isolate per-topic sessions.
|
||||
message.group_id += "#" + str(update.message.message_thread_id)
|
||||
message.session_id = message.group_id
|
||||
message.message_id = str(update.message.message_id)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import asyncio
|
||||
import os
|
||||
import re
|
||||
from collections.abc import Callable
|
||||
from typing import Any, cast
|
||||
|
||||
import telegramify_markdown
|
||||
@@ -21,6 +22,17 @@ from astrbot.api.message_components import (
|
||||
Video,
|
||||
)
|
||||
from astrbot.api.platform import AstrBotMessage, MessageType, PlatformMetadata
|
||||
from astrbot.core.utils.metrics import Metric
|
||||
|
||||
|
||||
def _is_gif(path: str) -> bool:
|
||||
if path.lower().endswith(".gif"):
|
||||
return True
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
return f.read(6) in (b"GIF87a", b"GIF89a")
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
class TelegramPlatformEvent(AstrMessageEvent):
|
||||
@@ -34,6 +46,20 @@ class TelegramPlatformEvent(AstrMessageEvent):
|
||||
"word": re.compile(r"\s"),
|
||||
}
|
||||
|
||||
# sendMessageDraft 的 draft_id 类级递增计数器
|
||||
_TELEGRAM_DRAFT_ID_MAX = 2_147_483_647
|
||||
_next_draft_id: int = 0
|
||||
|
||||
@classmethod
|
||||
def _allocate_draft_id(cls) -> int:
|
||||
"""分配一个递增的 draft_id,溢出时归 1。"""
|
||||
cls._next_draft_id = (
|
||||
1
|
||||
if cls._next_draft_id >= cls._TELEGRAM_DRAFT_ID_MAX
|
||||
else cls._next_draft_id + 1
|
||||
)
|
||||
return cls._next_draft_id
|
||||
|
||||
# 消息类型到 chat action 的映射,用于优先级判断
|
||||
ACTION_BY_TYPE: dict[type, str] = {
|
||||
Record: ChatAction.UPLOAD_VOICE,
|
||||
@@ -262,7 +288,6 @@ class TelegramPlatformEvent(AstrMessageEvent):
|
||||
try:
|
||||
md_text = telegramify_markdown.markdownify(
|
||||
chunk,
|
||||
normalize_whitespace=False,
|
||||
)
|
||||
await client.send_message(
|
||||
text=md_text,
|
||||
@@ -276,7 +301,13 @@ class TelegramPlatformEvent(AstrMessageEvent):
|
||||
await client.send_message(text=chunk, **cast(Any, payload))
|
||||
elif isinstance(i, Image):
|
||||
image_path = await i.convert_to_file_path()
|
||||
await client.send_photo(photo=image_path, **cast(Any, payload))
|
||||
if _is_gif(image_path):
|
||||
send_coro = client.send_animation
|
||||
media_kwarg = {"animation": image_path}
|
||||
else:
|
||||
send_coro = client.send_photo
|
||||
media_kwarg = {"photo": image_path}
|
||||
await send_coro(**media_kwarg, **cast(Any, payload))
|
||||
elif isinstance(i, File):
|
||||
path = await i.get_file()
|
||||
name = i.name or os.path.basename(path)
|
||||
@@ -339,6 +370,125 @@ class TelegramPlatformEvent(AstrMessageEvent):
|
||||
except Exception as e:
|
||||
logger.error(f"[Telegram] 添加反应失败: {e}")
|
||||
|
||||
async def _send_message_draft(
|
||||
self,
|
||||
chat_id: str,
|
||||
draft_id: int,
|
||||
text: str,
|
||||
message_thread_id: str | None = None,
|
||||
parse_mode: str | None = None,
|
||||
) -> None:
|
||||
"""通过 Bot.send_message_draft 发送草稿消息(流式推送部分消息)。
|
||||
|
||||
该 API 仅支持私聊。
|
||||
|
||||
Args:
|
||||
chat_id: 目标私聊的 chat_id
|
||||
draft_id: 草稿唯一标识,非零整数;相同 draft_id 的变更会以动画展示
|
||||
text: 消息文本,1-4096 字符
|
||||
message_thread_id: 可选,目标消息线程 ID
|
||||
parse_mode: 可选,消息文本的解析模式
|
||||
"""
|
||||
kwargs: dict[str, Any] = {}
|
||||
if message_thread_id:
|
||||
kwargs["message_thread_id"] = int(message_thread_id)
|
||||
if parse_mode:
|
||||
kwargs["parse_mode"] = parse_mode
|
||||
|
||||
try:
|
||||
logger.debug(
|
||||
f"[Telegram] sendMessageDraft: chat_id={chat_id}, draft_id={draft_id}, text_len={len(text)}"
|
||||
)
|
||||
await self.client.send_message_draft(
|
||||
chat_id=int(chat_id),
|
||||
draft_id=draft_id,
|
||||
text=text,
|
||||
**kwargs,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"[Telegram] sendMessageDraft 失败: {e!s}")
|
||||
|
||||
async def _process_chain_items(
|
||||
self,
|
||||
chain: MessageChain,
|
||||
payload: dict[str, Any],
|
||||
user_name: str,
|
||||
message_thread_id: str | None,
|
||||
on_text: Callable[[str], None],
|
||||
) -> None:
|
||||
"""处理 MessageChain 中的各类组件,文本通过 on_text 回调追加,媒体直接发送。"""
|
||||
for i in chain.chain:
|
||||
if isinstance(i, Plain):
|
||||
on_text(i.text)
|
||||
elif isinstance(i, Image):
|
||||
image_path = await i.convert_to_file_path()
|
||||
if _is_gif(image_path):
|
||||
action = ChatAction.UPLOAD_VIDEO
|
||||
send_coro = self.client.send_animation
|
||||
media_kwarg = {"animation": image_path}
|
||||
else:
|
||||
action = ChatAction.UPLOAD_PHOTO
|
||||
send_coro = self.client.send_photo
|
||||
media_kwarg = {"photo": image_path}
|
||||
await self._send_media_with_action(
|
||||
self.client,
|
||||
action,
|
||||
send_coro,
|
||||
user_name=user_name,
|
||||
**media_kwarg,
|
||||
**cast(Any, payload),
|
||||
)
|
||||
elif isinstance(i, File):
|
||||
path = await i.get_file()
|
||||
name = i.name or os.path.basename(path)
|
||||
await self._send_media_with_action(
|
||||
self.client,
|
||||
ChatAction.UPLOAD_DOCUMENT,
|
||||
self.client.send_document,
|
||||
user_name=user_name,
|
||||
document=path,
|
||||
filename=name,
|
||||
**cast(Any, payload),
|
||||
)
|
||||
elif isinstance(i, Record):
|
||||
path = await i.convert_to_file_path()
|
||||
await self._send_voice_with_fallback(
|
||||
self.client,
|
||||
path,
|
||||
payload,
|
||||
caption=i.text or None,
|
||||
user_name=user_name,
|
||||
message_thread_id=message_thread_id,
|
||||
use_media_action=True,
|
||||
)
|
||||
elif isinstance(i, Video):
|
||||
path = await i.convert_to_file_path()
|
||||
await self._send_media_with_action(
|
||||
self.client,
|
||||
ChatAction.UPLOAD_VIDEO,
|
||||
self.client.send_video,
|
||||
user_name=user_name,
|
||||
video=path,
|
||||
**cast(Any, payload),
|
||||
)
|
||||
else:
|
||||
logger.warning(f"不支持的消息类型: {type(i)}")
|
||||
|
||||
async def _send_final_segment(self, delta: str, payload: dict[str, Any]) -> None:
|
||||
"""将累积文本作为 MarkdownV2 真实消息发送,失败时回退到纯文本。"""
|
||||
try:
|
||||
markdown_text = telegramify_markdown.markdownify(
|
||||
delta,
|
||||
)
|
||||
await self.client.send_message(
|
||||
text=markdown_text,
|
||||
parse_mode="MarkdownV2",
|
||||
**cast(Any, payload),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Markdown转换失败,使用普通文本: {e!s}")
|
||||
await self.client.send_message(text=delta, **cast(Any, payload))
|
||||
|
||||
async def send_streaming(self, generator, use_fallback: bool = False):
|
||||
message_thread_id = None
|
||||
|
||||
@@ -356,6 +506,137 @@ class TelegramPlatformEvent(AstrMessageEvent):
|
||||
if message_thread_id:
|
||||
payload["message_thread_id"] = message_thread_id
|
||||
|
||||
# sendMessageDraft 仅支持私聊(显式检查 FRIEND_MESSAGE)
|
||||
is_private = self.get_message_type() == MessageType.FRIEND_MESSAGE
|
||||
|
||||
if is_private:
|
||||
logger.info("[Telegram] 流式输出: 使用 sendMessageDraft (私聊)")
|
||||
await self._send_streaming_draft(
|
||||
user_name, message_thread_id, payload, generator
|
||||
)
|
||||
else:
|
||||
logger.info("[Telegram] 流式输出: 使用 edit_message_text fallback (群聊)")
|
||||
await self._send_streaming_edit(
|
||||
user_name, message_thread_id, payload, generator
|
||||
)
|
||||
|
||||
# 内联父类 send_streaming 的副作用(避免传入已消费的 generator)
|
||||
asyncio.create_task(
|
||||
Metric.upload(msg_event_tick=1, adapter_name=self.platform_meta.name),
|
||||
)
|
||||
self._has_send_oper = True
|
||||
|
||||
async def _send_streaming_draft(
|
||||
self,
|
||||
user_name: str,
|
||||
message_thread_id: str | None,
|
||||
payload: dict[str, Any],
|
||||
generator,
|
||||
) -> None:
|
||||
"""使用 sendMessageDraft API 进行流式推送(私聊专用)。
|
||||
|
||||
流式过程中使用 sendMessageDraft 推送草稿动画,
|
||||
流式结束后发送一条真实消息保留最终内容(draft 是临时的,会消失)。
|
||||
使用信号驱动的发送循环:每次有新 token 到达时唤醒发送,
|
||||
发送频率由网络 RTT 自然限制(最多一个请求 in-flight)。
|
||||
"""
|
||||
draft_id = self._allocate_draft_id()
|
||||
delta = ""
|
||||
last_sent_text = ""
|
||||
done = False # 信号:生成器已结束
|
||||
text_changed = asyncio.Event() # 有新 token 到达时触发
|
||||
|
||||
async def _draft_sender_loop() -> None:
|
||||
"""信号驱动的草稿发送循环,有新内容就发,RTT 自然限流。"""
|
||||
nonlocal last_sent_text
|
||||
while not done:
|
||||
await text_changed.wait()
|
||||
text_changed.clear()
|
||||
# 发送最新的缓冲区内容(MarkdownV2 渲染,与真实消息一致)
|
||||
if delta and delta != last_sent_text:
|
||||
draft_text = delta[: self.MAX_MESSAGE_LENGTH]
|
||||
if draft_text != last_sent_text:
|
||||
try:
|
||||
md = telegramify_markdown.markdownify(
|
||||
draft_text,
|
||||
)
|
||||
await self._send_message_draft(
|
||||
user_name,
|
||||
draft_id,
|
||||
md,
|
||||
message_thread_id,
|
||||
parse_mode="MarkdownV2",
|
||||
)
|
||||
last_sent_text = draft_text
|
||||
except Exception:
|
||||
# markdownify 对未闭合语法可能失败,回退纯文本
|
||||
try:
|
||||
await self._send_message_draft(
|
||||
user_name,
|
||||
draft_id,
|
||||
draft_text,
|
||||
message_thread_id,
|
||||
)
|
||||
last_sent_text = draft_text
|
||||
except Exception as e2:
|
||||
logger.debug(
|
||||
f"[Telegram] sendMessageDraft failed (ignored): {e2!s}"
|
||||
)
|
||||
|
||||
sender_task = asyncio.create_task(_draft_sender_loop())
|
||||
|
||||
def _append_text(t: str) -> None:
|
||||
nonlocal delta
|
||||
delta += t
|
||||
text_changed.set() # 唤醒发送循环
|
||||
|
||||
try:
|
||||
async for chain in generator:
|
||||
if not isinstance(chain, MessageChain):
|
||||
continue
|
||||
|
||||
if chain.type == "break":
|
||||
# 分割符:发送真实消息保留内容,重置缓冲区
|
||||
if delta:
|
||||
# 用 emoji 清空 draft 显示,避免 draft 和真实消息同时可见
|
||||
await self._send_message_draft(
|
||||
user_name,
|
||||
draft_id,
|
||||
"\u23f3",
|
||||
message_thread_id,
|
||||
)
|
||||
await self._send_final_segment(delta, payload)
|
||||
delta = ""
|
||||
last_sent_text = ""
|
||||
draft_id = self._allocate_draft_id()
|
||||
continue
|
||||
|
||||
await self._process_chain_items(
|
||||
chain, payload, user_name, message_thread_id, _append_text
|
||||
)
|
||||
finally:
|
||||
done = True
|
||||
text_changed.set() # 唤醒循环使其退出
|
||||
await sender_task
|
||||
|
||||
# 流式结束:用 emoji 清空 draft,然后发真实消息持久化
|
||||
if delta:
|
||||
await self._send_message_draft(
|
||||
user_name,
|
||||
draft_id,
|
||||
"\u23f3",
|
||||
message_thread_id,
|
||||
)
|
||||
await self._send_final_segment(delta, payload)
|
||||
|
||||
async def _send_streaming_edit(
|
||||
self,
|
||||
user_name: str,
|
||||
message_thread_id: str | None,
|
||||
payload: dict[str, Any],
|
||||
generator,
|
||||
) -> None:
|
||||
"""使用 send_message + edit_message_text 进行流式推送(群聊 fallback)。"""
|
||||
delta = ""
|
||||
current_content = ""
|
||||
message_id = None
|
||||
@@ -366,130 +647,75 @@ class TelegramPlatformEvent(AstrMessageEvent):
|
||||
|
||||
# 发送初始 typing 状态
|
||||
await self._ensure_typing(user_name, message_thread_id)
|
||||
last_chat_action_time = asyncio.get_event_loop().time()
|
||||
last_chat_action_time = asyncio.get_running_loop().time()
|
||||
|
||||
def _append_text(t: str) -> None:
|
||||
nonlocal delta
|
||||
delta += t
|
||||
|
||||
async for chain in generator:
|
||||
if isinstance(chain, MessageChain):
|
||||
if chain.type == "break":
|
||||
# 分割符
|
||||
if message_id:
|
||||
try:
|
||||
await self.client.edit_message_text(
|
||||
text=delta,
|
||||
chat_id=payload["chat_id"],
|
||||
message_id=message_id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"编辑消息失败(streaming-break): {e!s}")
|
||||
message_id = None # 重置消息 ID
|
||||
delta = "" # 重置 delta
|
||||
continue
|
||||
if not isinstance(chain, MessageChain):
|
||||
continue
|
||||
|
||||
# 处理消息链中的每个组件
|
||||
for i in chain.chain:
|
||||
if isinstance(i, Plain):
|
||||
delta += i.text
|
||||
elif isinstance(i, Image):
|
||||
image_path = await i.convert_to_file_path()
|
||||
await self._send_media_with_action(
|
||||
self.client,
|
||||
ChatAction.UPLOAD_PHOTO,
|
||||
self.client.send_photo,
|
||||
user_name=user_name,
|
||||
photo=image_path,
|
||||
**cast(Any, payload),
|
||||
if chain.type == "break":
|
||||
# 分割符
|
||||
if message_id:
|
||||
try:
|
||||
await self.client.edit_message_text(
|
||||
text=delta,
|
||||
chat_id=payload["chat_id"],
|
||||
message_id=message_id,
|
||||
)
|
||||
continue
|
||||
elif isinstance(i, File):
|
||||
path = await i.get_file()
|
||||
name = i.name or os.path.basename(path)
|
||||
await self._send_media_with_action(
|
||||
self.client,
|
||||
ChatAction.UPLOAD_DOCUMENT,
|
||||
self.client.send_document,
|
||||
user_name=user_name,
|
||||
document=path,
|
||||
filename=name,
|
||||
**cast(Any, payload),
|
||||
)
|
||||
continue
|
||||
elif isinstance(i, Record):
|
||||
path = await i.convert_to_file_path()
|
||||
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
|
||||
elif isinstance(i, Video):
|
||||
path = await i.convert_to_file_path()
|
||||
await self._send_media_with_action(
|
||||
self.client,
|
||||
ChatAction.UPLOAD_VIDEO,
|
||||
self.client.send_video,
|
||||
user_name=user_name,
|
||||
video=path,
|
||||
**cast(Any, payload),
|
||||
)
|
||||
continue
|
||||
else:
|
||||
logger.warning(f"不支持的消息类型: {type(i)}")
|
||||
continue
|
||||
except Exception as e:
|
||||
logger.warning(f"编辑消息失败(streaming-break): {e!s}")
|
||||
message_id = None
|
||||
delta = ""
|
||||
continue
|
||||
|
||||
# Plain
|
||||
if message_id and len(delta) <= self.MAX_MESSAGE_LENGTH:
|
||||
current_time = asyncio.get_event_loop().time()
|
||||
time_since_last_edit = current_time - last_edit_time
|
||||
await self._process_chain_items(
|
||||
chain, payload, user_name, message_thread_id, _append_text
|
||||
)
|
||||
|
||||
# 如果距离上次编辑的时间 >= 设定的间隔,等待一段时间
|
||||
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(
|
||||
text=delta,
|
||||
chat_id=payload["chat_id"],
|
||||
message_id=message_id,
|
||||
)
|
||||
current_content = delta
|
||||
except Exception as e:
|
||||
logger.warning(f"编辑消息失败(streaming): {e!s}")
|
||||
last_edit_time = (
|
||||
asyncio.get_event_loop().time()
|
||||
) # 更新上次编辑的时间
|
||||
else:
|
||||
# delta 长度一般不会大于 4096,因此这里直接发送
|
||||
# 发送 typing 状态(带节流)
|
||||
current_time = asyncio.get_event_loop().time()
|
||||
# 编辑或发送消息
|
||||
if message_id and len(delta) <= self.MAX_MESSAGE_LENGTH:
|
||||
current_time = asyncio.get_running_loop().time()
|
||||
time_since_last_edit = current_time - last_edit_time
|
||||
|
||||
if time_since_last_edit >= throttle_interval:
|
||||
current_time = asyncio.get_running_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)
|
||||
await self.client.edit_message_text(
|
||||
text=delta,
|
||||
chat_id=payload["chat_id"],
|
||||
message_id=message_id,
|
||||
)
|
||||
current_content = delta
|
||||
except Exception as e:
|
||||
logger.warning(f"发送消息失败(streaming): {e!s}")
|
||||
message_id = msg.message_id
|
||||
last_edit_time = (
|
||||
asyncio.get_event_loop().time()
|
||||
) # 记录初始消息发送时间
|
||||
logger.warning(f"编辑消息失败(streaming): {e!s}")
|
||||
last_edit_time = asyncio.get_running_loop().time()
|
||||
else:
|
||||
current_time = asyncio.get_running_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)
|
||||
)
|
||||
current_content = delta
|
||||
except Exception as e:
|
||||
logger.warning(f"发送消息失败(streaming): {e!s}")
|
||||
message_id = msg.message_id
|
||||
last_edit_time = asyncio.get_running_loop().time()
|
||||
|
||||
try:
|
||||
if delta and current_content != delta:
|
||||
try:
|
||||
markdown_text = telegramify_markdown.markdownify(
|
||||
delta,
|
||||
normalize_whitespace=False,
|
||||
)
|
||||
await self.client.edit_message_text(
|
||||
text=markdown_text,
|
||||
@@ -506,5 +732,3 @@ class TelegramPlatformEvent(AstrMessageEvent):
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"编辑消息失败(streaming): {e!s}")
|
||||
|
||||
return await super().send_streaming(generator, use_fallback)
|
||||
|
||||
@@ -200,7 +200,7 @@ class WecomPlatformAdapter(Platform):
|
||||
return msg_list[-1]
|
||||
return None
|
||||
|
||||
msg_new = await asyncio.get_event_loop().run_in_executor(
|
||||
msg_new = await asyncio.get_running_loop().run_in_executor(
|
||||
None,
|
||||
get_latest_msg_item,
|
||||
)
|
||||
@@ -261,7 +261,7 @@ class WecomPlatformAdapter(Platform):
|
||||
|
||||
@override
|
||||
async def run(self) -> None:
|
||||
loop = asyncio.get_event_loop()
|
||||
loop = asyncio.get_running_loop()
|
||||
if self.kf_name:
|
||||
try:
|
||||
acc_list = (
|
||||
@@ -339,7 +339,7 @@ class WecomPlatformAdapter(Platform):
|
||||
abm.session_id = abm.sender.user_id
|
||||
abm.raw_message = msg
|
||||
elif isinstance(msg, VoiceMessage):
|
||||
resp: Response = await asyncio.get_event_loop().run_in_executor(
|
||||
resp: Response = await asyncio.get_running_loop().run_in_executor(
|
||||
None,
|
||||
self.client.media.download,
|
||||
msg.media_id,
|
||||
@@ -395,7 +395,7 @@ class WecomPlatformAdapter(Platform):
|
||||
abm.message_str = text
|
||||
elif msgtype == "image":
|
||||
media_id = msg.get("image", {}).get("media_id", "")
|
||||
resp: Response = await asyncio.get_event_loop().run_in_executor(
|
||||
resp: Response = await asyncio.get_running_loop().run_in_executor(
|
||||
None,
|
||||
self.client.media.download,
|
||||
media_id,
|
||||
@@ -407,7 +407,7 @@ class WecomPlatformAdapter(Platform):
|
||||
abm.message = [Image(file=path, url=path)]
|
||||
elif msgtype == "voice":
|
||||
media_id = msg.get("voice", {}).get("media_id", "")
|
||||
resp: Response = await asyncio.get_event_loop().run_in_executor(
|
||||
resp: Response = await asyncio.get_running_loop().run_in_executor(
|
||||
None,
|
||||
self.client.media.download,
|
||||
media_id,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""企业微信智能机器人平台适配器
|
||||
基于企业微信智能机器人 API 的消息平台适配器,支持 HTTP 回调
|
||||
基于企业微信智能机器人 API 的消息平台适配器,支持 HTTP 回调与长连接
|
||||
参考webchat_adapter.py的队列机制,实现异步消息处理和流式响应
|
||||
"""
|
||||
|
||||
@@ -31,6 +31,7 @@ from .wecomai_api import (
|
||||
WecomAIBotStreamMessageBuilder,
|
||||
)
|
||||
from .wecomai_event import WecomAIBotMessageEvent
|
||||
from .wecomai_long_connection import WecomAIBotLongConnectionClient
|
||||
from .wecomai_queue_mgr import WecomAIQueueMgr
|
||||
from .wecomai_server import WecomAIBotServer
|
||||
from .wecomai_utils import (
|
||||
@@ -78,8 +79,13 @@ class WecomAIBotAdapter(Platform):
|
||||
self.settings = platform_settings
|
||||
|
||||
# 初始化配置参数
|
||||
self.token = self.config["token"]
|
||||
self.encoding_aes_key = self.config["encoding_aes_key"]
|
||||
self.connection_mode = self.config.get(
|
||||
"wecom_ai_bot_connection_mode", "webhook"
|
||||
)
|
||||
self.token = self.config.get("token", self.config.get("wecomaibot_token", ""))
|
||||
self.encoding_aes_key = self.config.get(
|
||||
"encoding_aes_key", self.config.get("wecomaibot_encoding_aes_key", "")
|
||||
)
|
||||
self.port = int(self.config["port"])
|
||||
self.host = self.config.get("callback_server_host", "0.0.0.0")
|
||||
self.bot_name = self.config.get("wecom_ai_bot_name", "")
|
||||
@@ -96,25 +102,52 @@ class WecomAIBotAdapter(Platform):
|
||||
self.only_use_webhook_url_to_send = bool(
|
||||
self.config.get("only_use_webhook_url_to_send", False),
|
||||
)
|
||||
self.long_connection_bot_id = self.config.get(
|
||||
"wecomaibot_ws_bot_id", self.config.get("long_connection_bot_id", "")
|
||||
)
|
||||
self.long_connection_secret = self.config.get(
|
||||
"wecomaibot_ws_secret", self.config.get("long_connection_secret", "")
|
||||
)
|
||||
self.long_connection_ws_url = self.config.get(
|
||||
"wecomaibot_ws_url",
|
||||
"wss://openws.work.weixin.qq.com",
|
||||
)
|
||||
self.long_connection_heartbeat_interval = int(
|
||||
self.config.get("wecomaibot_heartbeat_interval", 30),
|
||||
)
|
||||
|
||||
# 平台元数据
|
||||
self.metadata = PlatformMetadata(
|
||||
name="wecom_ai_bot",
|
||||
description="企业微信智能机器人适配器,支持 HTTP 回调接收消息",
|
||||
description="企业微信智能机器人适配器,支持 HTTP 回调和长连接模式",
|
||||
id=self.config.get("id", "wecom_ai_bot"),
|
||||
support_proactive_message=bool(self.msg_push_webhook_url),
|
||||
)
|
||||
|
||||
# 初始化 API 客户端
|
||||
self.api_client = WecomAIBotAPIClient(self.token, self.encoding_aes_key)
|
||||
self.api_client: WecomAIBotAPIClient | None = None
|
||||
self.server: WecomAIBotServer | None = None
|
||||
self.long_connection_client: WecomAIBotLongConnectionClient | None = None
|
||||
|
||||
# 初始化 HTTP 服务器
|
||||
self.server = WecomAIBotServer(
|
||||
host=self.host,
|
||||
port=self.port,
|
||||
api_client=self.api_client,
|
||||
message_handler=self._process_message,
|
||||
)
|
||||
if self.connection_mode == "long_connection":
|
||||
if not self.long_connection_bot_id or not self.long_connection_secret:
|
||||
logger.warning(
|
||||
"企业微信智能机器人长连接模式缺少 BotID 或 Secret,连接可能失败"
|
||||
)
|
||||
self.long_connection_client = WecomAIBotLongConnectionClient(
|
||||
bot_id=self.long_connection_bot_id,
|
||||
secret=self.long_connection_secret,
|
||||
ws_url=self.long_connection_ws_url,
|
||||
heartbeat_interval=self.long_connection_heartbeat_interval,
|
||||
message_handler=self._process_long_connection_payload,
|
||||
)
|
||||
else:
|
||||
self.api_client = WecomAIBotAPIClient(self.token, self.encoding_aes_key)
|
||||
self.server = WecomAIBotServer(
|
||||
host=self.host,
|
||||
port=self.port,
|
||||
api_client=self.api_client,
|
||||
message_handler=self._process_message,
|
||||
)
|
||||
|
||||
# 事件循环和关闭信号
|
||||
self.shutdown_event = asyncio.Event()
|
||||
@@ -161,6 +194,9 @@ class WecomAIBotAdapter(Platform):
|
||||
加密后的响应消息,无需响应时返回 None
|
||||
|
||||
"""
|
||||
if not self.api_client:
|
||||
logger.error("Webhook 消息处理失败: API 客户端未初始化")
|
||||
return None
|
||||
msgtype = message_data.get("msgtype")
|
||||
if not msgtype:
|
||||
logger.warning(f"消息类型未知,忽略: {message_data}")
|
||||
@@ -320,10 +356,100 @@ class WecomAIBotAdapter(Platform):
|
||||
logger.error("处理欢迎消息时发生异常: %s", e)
|
||||
return None
|
||||
|
||||
async def _process_long_connection_payload(
|
||||
self,
|
||||
payload: dict[str, Any],
|
||||
) -> None:
|
||||
"""处理长连接回调消息。"""
|
||||
cmd = payload.get("cmd")
|
||||
headers = payload.get("headers") or {}
|
||||
body = payload.get("body") or {}
|
||||
req_id = headers.get("req_id")
|
||||
if not isinstance(body, dict):
|
||||
return
|
||||
|
||||
if cmd == "aibot_msg_callback":
|
||||
session_id = self._extract_session_id(body)
|
||||
stream_id = f"{session_id}_{generate_random_string(10)}"
|
||||
await self._enqueue_message(
|
||||
body, {"req_id": req_id or ""}, stream_id, session_id
|
||||
)
|
||||
self.queue_mgr.set_pending_response(
|
||||
stream_id,
|
||||
{
|
||||
"req_id": req_id or "",
|
||||
"connection_mode": "long_connection",
|
||||
},
|
||||
)
|
||||
|
||||
if self.initial_respond_text and req_id:
|
||||
await self._send_long_connection_respond_msg(
|
||||
req_id=req_id,
|
||||
body={
|
||||
"msgtype": "stream",
|
||||
"stream": {
|
||||
"id": stream_id,
|
||||
"finish": False,
|
||||
"content": self.initial_respond_text,
|
||||
},
|
||||
},
|
||||
)
|
||||
return
|
||||
|
||||
if cmd == "aibot_event_callback":
|
||||
event = body.get("event") or {}
|
||||
event_type = event.get("eventtype")
|
||||
if (
|
||||
event_type == "enter_chat"
|
||||
and self.friend_message_welcome_text
|
||||
and req_id
|
||||
):
|
||||
await self._send_long_connection_respond_welcome(req_id)
|
||||
elif event_type == "disconnected_event":
|
||||
logger.warning(
|
||||
"[WecomAI][LongConn] 收到 disconnected_event,旧连接将被关闭"
|
||||
)
|
||||
|
||||
async def _send_long_connection_respond_welcome(self, req_id: str) -> bool:
|
||||
client = self.long_connection_client
|
||||
if not client:
|
||||
return False
|
||||
return await client.send_command(
|
||||
cmd="aibot_respond_welcome_msg",
|
||||
req_id=req_id,
|
||||
body={
|
||||
"msgtype": "text",
|
||||
"text": {
|
||||
"content": self.friend_message_welcome_text,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
async def _send_long_connection_respond_msg(
|
||||
self,
|
||||
req_id: str,
|
||||
body: dict[str, Any],
|
||||
) -> bool:
|
||||
client = self.long_connection_client
|
||||
if not client:
|
||||
return False
|
||||
return await client.send_command(
|
||||
cmd="aibot_respond_msg",
|
||||
req_id=req_id,
|
||||
body=body,
|
||||
)
|
||||
|
||||
def _extract_session_id(self, message_data: dict[str, Any]) -> str:
|
||||
"""从消息数据中提取会话ID"""
|
||||
user_id = message_data.get("from", {}).get("userid", "default_user")
|
||||
return format_session_id("wecomai", user_id)
|
||||
"""从消息数据中提取会话ID
|
||||
群聊使用 chatid,单聊使用 userid
|
||||
"""
|
||||
chattype = message_data.get("chattype", "single")
|
||||
if chattype == "group":
|
||||
chat_id = message_data.get("chatid", "default_group")
|
||||
return format_session_id("wecomai", chat_id)
|
||||
else:
|
||||
user_id = message_data.get("from", {}).get("userid", "default_user")
|
||||
return format_session_id("wecomai", user_id)
|
||||
|
||||
async def _enqueue_message(
|
||||
self,
|
||||
@@ -355,15 +481,16 @@ class WecomAIBotAdapter(Platform):
|
||||
content = ""
|
||||
image_base64 = []
|
||||
|
||||
_img_url_to_process = []
|
||||
_img_url_to_process: list[tuple[str, str | None]] = []
|
||||
msg_items = []
|
||||
|
||||
if msgtype == WecomAIBotConstants.MSG_TYPE_TEXT:
|
||||
content = WecomAIBotMessageParser.parse_text_message(message_data)
|
||||
elif msgtype == WecomAIBotConstants.MSG_TYPE_IMAGE:
|
||||
_img_url_to_process.append(
|
||||
WecomAIBotMessageParser.parse_image_message(message_data),
|
||||
)
|
||||
image_payload = message_data.get("image", {})
|
||||
image_url = image_payload.get("url", "")
|
||||
if image_url:
|
||||
_img_url_to_process.append((image_url, image_payload.get("aeskey")))
|
||||
elif msgtype == WecomAIBotConstants.MSG_TYPE_MIXED:
|
||||
# 提取混合消息中的文本内容
|
||||
msg_items = WecomAIBotMessageParser.parse_mixed_message(message_data)
|
||||
@@ -374,9 +501,12 @@ class WecomAIBotAdapter(Platform):
|
||||
if text_content:
|
||||
text_parts.append(text_content)
|
||||
elif item.get("msgtype") == WecomAIBotConstants.MSG_TYPE_IMAGE:
|
||||
image_url = item.get("image", {}).get("url", "")
|
||||
image_payload = item.get("image", {})
|
||||
image_url = image_payload.get("url", "")
|
||||
if image_url:
|
||||
_img_url_to_process.append(image_url)
|
||||
_img_url_to_process.append(
|
||||
(image_url, image_payload.get("aeskey"))
|
||||
)
|
||||
content = " ".join(text_parts) if text_parts else ""
|
||||
else:
|
||||
content = f"[{msgtype}消息]"
|
||||
@@ -384,8 +514,8 @@ class WecomAIBotAdapter(Platform):
|
||||
# 并行处理图片下载和解密
|
||||
if _img_url_to_process:
|
||||
tasks = [
|
||||
process_encrypted_image(url, self.encoding_aes_key)
|
||||
for url in _img_url_to_process
|
||||
process_encrypted_image(url, aes_key or self.encoding_aes_key)
|
||||
for url, aes_key in _img_url_to_process
|
||||
]
|
||||
results = await asyncio.gather(*tasks)
|
||||
for success, result in results:
|
||||
@@ -459,26 +589,43 @@ class WecomAIBotAdapter(Platform):
|
||||
"""运行适配器,同时启动HTTP服务器和队列监听器"""
|
||||
|
||||
async def run_both() -> None:
|
||||
# 如果启用统一 webhook 模式,则不启动独立服务器
|
||||
webhook_uuid = self.config.get("webhook_uuid")
|
||||
if self.unified_webhook_mode and webhook_uuid:
|
||||
log_webhook_info(f"{self.meta().id}(企业微信智能机器人)", webhook_uuid)
|
||||
# 只运行队列监听器
|
||||
await self.queue_listener.run()
|
||||
else:
|
||||
if self.connection_mode == "long_connection":
|
||||
if not self.long_connection_client:
|
||||
raise RuntimeError("长连接客户端未初始化")
|
||||
logger.info(
|
||||
"启动企业微信智能机器人适配器,监听 %s:%d", self.host, self.port
|
||||
"启动企业微信智能机器人长连接模式: %s", self.long_connection_ws_url
|
||||
)
|
||||
# 同时运行HTTP服务器和队列监听器
|
||||
await asyncio.gather(
|
||||
self.server.start_server(),
|
||||
self.long_connection_client.start(),
|
||||
self.queue_listener.run(),
|
||||
)
|
||||
else:
|
||||
# 如果启用统一 webhook 模式,则不启动独立服务器
|
||||
webhook_uuid = self.config.get("webhook_uuid")
|
||||
if self.unified_webhook_mode and webhook_uuid:
|
||||
log_webhook_info(
|
||||
f"{self.meta().id}(企业微信智能机器人)", webhook_uuid
|
||||
)
|
||||
# 只运行队列监听器
|
||||
await self.queue_listener.run()
|
||||
else:
|
||||
if not self.server:
|
||||
raise RuntimeError("Webhook 服务器未初始化")
|
||||
logger.info(
|
||||
"启动企业微信智能机器人适配器,监听 %s:%d", self.host, self.port
|
||||
)
|
||||
# 同时运行HTTP服务器和队列监听器
|
||||
await asyncio.gather(
|
||||
self.server.start_server(),
|
||||
self.queue_listener.run(),
|
||||
)
|
||||
|
||||
return run_both()
|
||||
|
||||
async def webhook_callback(self, request: Any) -> Any:
|
||||
"""统一 Webhook 回调入口"""
|
||||
if self.connection_mode == "long_connection" or not self.server:
|
||||
return "long_connection mode does not accept webhook callbacks", 400
|
||||
# 根据请求方法分发到不同的处理函数
|
||||
if request.method == "GET":
|
||||
return await self.server.handle_verify(request)
|
||||
@@ -489,7 +636,10 @@ class WecomAIBotAdapter(Platform):
|
||||
"""终止适配器"""
|
||||
logger.info("企业微信智能机器人适配器正在关闭...")
|
||||
self.shutdown_event.set()
|
||||
await self.server.shutdown()
|
||||
if self.long_connection_client:
|
||||
await self.long_connection_client.shutdown()
|
||||
if self.server:
|
||||
await self.server.shutdown()
|
||||
|
||||
def meta(self) -> PlatformMetadata:
|
||||
"""获取平台元数据"""
|
||||
@@ -507,17 +657,22 @@ class WecomAIBotAdapter(Platform):
|
||||
queue_mgr=self.queue_mgr,
|
||||
webhook_client=self.webhook_client,
|
||||
only_use_webhook_url_to_send=self.only_use_webhook_url_to_send,
|
||||
long_connection_sender=self._send_long_connection_respond_msg,
|
||||
)
|
||||
message_event.is_at_or_wake_command = (
|
||||
True # 企业微信智能机器人默认消息都是 at 或唤醒命令
|
||||
)
|
||||
message_event.is_wake = True # 企业微信智能机器人消息默认当做唤醒命令处理
|
||||
|
||||
self.commit_event(message_event)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("处理消息时发生异常: %s", e)
|
||||
|
||||
def get_client(self) -> WecomAIBotAPIClient:
|
||||
def get_client(self) -> WecomAIBotAPIClient | None:
|
||||
"""获取 API 客户端"""
|
||||
return self.api_client
|
||||
|
||||
def get_server(self) -> WecomAIBotServer:
|
||||
def get_server(self) -> WecomAIBotServer | None:
|
||||
"""获取 HTTP 服务器实例"""
|
||||
return self.server
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""企业微信智能机器人事件处理模块,处理消息事件的发送和接收"""
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
|
||||
from astrbot.api import logger
|
||||
from astrbot.api.event import AstrMessageEvent, MessageChain
|
||||
from astrbot.api.message_components import At, Image, Plain
|
||||
@@ -18,10 +20,11 @@ class WecomAIBotMessageEvent(AstrMessageEvent):
|
||||
message_obj,
|
||||
platform_meta,
|
||||
session_id: str,
|
||||
api_client: WecomAIBotAPIClient,
|
||||
api_client: WecomAIBotAPIClient | None,
|
||||
queue_mgr: WecomAIQueueMgr,
|
||||
webhook_client: WecomAIBotWebhookClient | None = None,
|
||||
only_use_webhook_url_to_send: bool = False,
|
||||
long_connection_sender: (Callable[[str, dict], Awaitable[bool]] | None) = None,
|
||||
) -> None:
|
||||
"""初始化消息事件
|
||||
|
||||
@@ -38,6 +41,7 @@ class WecomAIBotMessageEvent(AstrMessageEvent):
|
||||
self.queue_mgr = queue_mgr
|
||||
self.webhook_client = webhook_client
|
||||
self.only_use_webhook_url_to_send = only_use_webhook_url_to_send
|
||||
self.long_connection_sender = long_connection_sender
|
||||
|
||||
async def _mark_stream_complete(self, stream_id: str) -> None:
|
||||
back_queue = self.queue_mgr.get_or_create_back_queue(stream_id)
|
||||
@@ -117,6 +121,18 @@ class WecomAIBotMessageEvent(AstrMessageEvent):
|
||||
|
||||
return data
|
||||
|
||||
@staticmethod
|
||||
def _extract_plain_text_from_chain(message_chain: MessageChain | None) -> str:
|
||||
if not message_chain:
|
||||
return ""
|
||||
plain_parts: list[str] = []
|
||||
for comp in message_chain.chain:
|
||||
if isinstance(comp, At):
|
||||
plain_parts.append(f"@{comp.name} ")
|
||||
elif isinstance(comp, Plain):
|
||||
plain_parts.append(comp.text)
|
||||
return "".join(plain_parts).strip()
|
||||
|
||||
async def send(self, message: MessageChain | None) -> None:
|
||||
"""发送消息"""
|
||||
raw = self.message_obj.raw_message
|
||||
@@ -124,6 +140,44 @@ class WecomAIBotMessageEvent(AstrMessageEvent):
|
||||
"wecom_ai_bot platform event raw_message should be a dict"
|
||||
)
|
||||
stream_id = raw.get("stream_id", self.session_id)
|
||||
pending_response = self.queue_mgr.get_pending_response(stream_id) or {}
|
||||
connection_mode = pending_response.get("callback_params", {}).get(
|
||||
"connection_mode"
|
||||
)
|
||||
req_id = pending_response.get("callback_params", {}).get("req_id")
|
||||
|
||||
if (
|
||||
connection_mode == "long_connection"
|
||||
and self.long_connection_sender
|
||||
and isinstance(req_id, str)
|
||||
and req_id
|
||||
):
|
||||
if self.only_use_webhook_url_to_send and self.webhook_client and message:
|
||||
await self.webhook_client.send_message_chain(message)
|
||||
await super().send(MessageChain([]))
|
||||
return
|
||||
|
||||
if self.webhook_client and message:
|
||||
await self.webhook_client.send_message_chain(
|
||||
message,
|
||||
unsupported_only=True,
|
||||
)
|
||||
|
||||
content = self._extract_plain_text_from_chain(message)
|
||||
await self.long_connection_sender(
|
||||
req_id,
|
||||
{
|
||||
"msgtype": "stream",
|
||||
"stream": {
|
||||
"id": stream_id,
|
||||
"finish": True,
|
||||
"content": content,
|
||||
},
|
||||
},
|
||||
)
|
||||
await super().send(MessageChain([]))
|
||||
return
|
||||
|
||||
if self.only_use_webhook_url_to_send and self.webhook_client and message:
|
||||
await self.webhook_client.send_message_chain(message)
|
||||
await self._mark_stream_complete(stream_id)
|
||||
@@ -152,8 +206,77 @@ class WecomAIBotMessageEvent(AstrMessageEvent):
|
||||
"wecom_ai_bot platform event raw_message should be a dict"
|
||||
)
|
||||
stream_id = raw.get("stream_id", self.session_id)
|
||||
pending_response = self.queue_mgr.get_pending_response(stream_id) or {}
|
||||
connection_mode = pending_response.get("callback_params", {}).get(
|
||||
"connection_mode"
|
||||
)
|
||||
req_id = pending_response.get("callback_params", {}).get("req_id")
|
||||
back_queue = self.queue_mgr.get_or_create_back_queue(stream_id)
|
||||
|
||||
if (
|
||||
connection_mode == "long_connection"
|
||||
and self.long_connection_sender
|
||||
and isinstance(req_id, str)
|
||||
and req_id
|
||||
):
|
||||
if self.only_use_webhook_url_to_send and self.webhook_client:
|
||||
merged_chain = MessageChain([])
|
||||
async for chain in generator:
|
||||
merged_chain.chain.extend(chain.chain)
|
||||
merged_chain.squash_plain()
|
||||
await self.webhook_client.send_message_chain(merged_chain)
|
||||
await self.long_connection_sender(
|
||||
req_id,
|
||||
{
|
||||
"msgtype": "stream",
|
||||
"stream": {
|
||||
"id": stream_id,
|
||||
"finish": True,
|
||||
"content": "",
|
||||
},
|
||||
},
|
||||
)
|
||||
await super().send_streaming(generator, use_fallback)
|
||||
return
|
||||
|
||||
increment_plain = ""
|
||||
async for chain in generator:
|
||||
if self.webhook_client:
|
||||
await self.webhook_client.send_message_chain(
|
||||
chain,
|
||||
unsupported_only=True,
|
||||
)
|
||||
|
||||
chain.squash_plain()
|
||||
chunk_text = self._extract_plain_text_from_chain(chain)
|
||||
if chunk_text:
|
||||
increment_plain += chunk_text
|
||||
await self.long_connection_sender(
|
||||
req_id,
|
||||
{
|
||||
"msgtype": "stream",
|
||||
"stream": {
|
||||
"id": stream_id,
|
||||
"finish": False,
|
||||
"content": increment_plain,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
await self.long_connection_sender(
|
||||
req_id,
|
||||
{
|
||||
"msgtype": "stream",
|
||||
"stream": {
|
||||
"id": stream_id,
|
||||
"finish": True,
|
||||
"content": increment_plain,
|
||||
},
|
||||
},
|
||||
)
|
||||
await super().send_streaming(generator, use_fallback)
|
||||
return
|
||||
|
||||
if self.only_use_webhook_url_to_send and self.webhook_client:
|
||||
merged_chain = MessageChain([])
|
||||
async for chain in generator:
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
"""企业微信智能机器人长连接客户端。"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import uuid
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
|
||||
from astrbot.api import logger
|
||||
|
||||
|
||||
class WecomAIBotLongConnectionClient:
|
||||
"""企业微信智能机器人 WebSocket 长连接客户端。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
bot_id: str,
|
||||
secret: str,
|
||||
ws_url: str,
|
||||
heartbeat_interval: int,
|
||||
message_handler: Callable[[dict[str, Any]], Awaitable[None]],
|
||||
) -> None:
|
||||
self.bot_id = bot_id
|
||||
self.secret = secret
|
||||
self.ws_url = ws_url
|
||||
self.heartbeat_interval = max(5, int(heartbeat_interval))
|
||||
self.message_handler = message_handler
|
||||
|
||||
self._session: aiohttp.ClientSession | None = None
|
||||
self._ws: aiohttp.ClientWebSocketResponse | None = None
|
||||
self._shutdown_event = asyncio.Event()
|
||||
self._send_lock = asyncio.Lock()
|
||||
self._command_lock = asyncio.Lock()
|
||||
self._response_waiters: dict[str, asyncio.Future[dict[str, Any]]] = {}
|
||||
|
||||
@staticmethod
|
||||
def gen_req_id() -> str:
|
||||
return uuid.uuid4().hex
|
||||
|
||||
async def start(self) -> None:
|
||||
"""启动长连接并自动重连。"""
|
||||
reconnect_delay = 1
|
||||
while not self._shutdown_event.is_set():
|
||||
try:
|
||||
await self._run_once()
|
||||
reconnect_delay = 1
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("[WecomAI][LongConn] 长连接异常: %s", e)
|
||||
if self._shutdown_event.is_set():
|
||||
break
|
||||
await asyncio.sleep(reconnect_delay)
|
||||
reconnect_delay = min(reconnect_delay * 2, 30)
|
||||
|
||||
async def _run_once(self) -> None:
|
||||
timeout = aiohttp.ClientTimeout(total=None, sock_connect=15, sock_read=None)
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||
self._session = session
|
||||
logger.info("[WecomAI][LongConn] 正在连接: %s", self.ws_url)
|
||||
async with session.ws_connect(
|
||||
self.ws_url, heartbeat=None, autoping=True
|
||||
) as ws:
|
||||
self._ws = ws
|
||||
await self._subscribe()
|
||||
logger.info("[WecomAI][LongConn] 订阅成功,已建立长连接")
|
||||
|
||||
heartbeat_task = asyncio.create_task(self._heartbeat_loop())
|
||||
try:
|
||||
while not self._shutdown_event.is_set():
|
||||
message = await ws.receive()
|
||||
if message.type == aiohttp.WSMsgType.TEXT:
|
||||
await self._handle_text_message(message.data)
|
||||
elif message.type in {
|
||||
aiohttp.WSMsgType.CLOSED,
|
||||
aiohttp.WSMsgType.CLOSE,
|
||||
aiohttp.WSMsgType.ERROR,
|
||||
}:
|
||||
break
|
||||
finally:
|
||||
heartbeat_task.cancel()
|
||||
try:
|
||||
await heartbeat_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self._ws = None
|
||||
|
||||
async def _subscribe(self) -> None:
|
||||
"""发送 aibot_subscribe,并等待响应。"""
|
||||
req_id = self.gen_req_id()
|
||||
payload = {
|
||||
"cmd": "aibot_subscribe",
|
||||
"headers": {"req_id": req_id},
|
||||
"body": {"bot_id": self.bot_id, "secret": self.secret},
|
||||
}
|
||||
await self._send_json(payload)
|
||||
|
||||
if not self._ws:
|
||||
raise RuntimeError("WebSocket 未建立")
|
||||
|
||||
reply = await self._ws.receive(timeout=10)
|
||||
if reply.type != aiohttp.WSMsgType.TEXT:
|
||||
raise RuntimeError(f"订阅失败: 非文本响应 {reply.type}")
|
||||
|
||||
data = json.loads(reply.data)
|
||||
if data.get("errcode") != 0:
|
||||
raise RuntimeError(
|
||||
f"订阅失败 errcode={data.get('errcode')} errmsg={data.get('errmsg')}"
|
||||
)
|
||||
|
||||
async def _heartbeat_loop(self) -> None:
|
||||
while not self._shutdown_event.is_set():
|
||||
await asyncio.sleep(self.heartbeat_interval)
|
||||
if self._shutdown_event.is_set():
|
||||
break
|
||||
try:
|
||||
await self.send_command("ping", self.gen_req_id(), None)
|
||||
except Exception as e:
|
||||
logger.warning("[WecomAI][LongConn] 发送心跳失败: %s", e)
|
||||
return
|
||||
|
||||
async def _handle_text_message(self, text: str) -> None:
|
||||
try:
|
||||
payload = json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
logger.warning("[WecomAI][LongConn] 收到非 JSON 消息: %s", text)
|
||||
return
|
||||
|
||||
headers = payload.get("headers") or {}
|
||||
req_id = headers.get("req_id")
|
||||
if isinstance(req_id, str):
|
||||
waiter = self._response_waiters.get(req_id)
|
||||
if waiter and not waiter.done():
|
||||
waiter.set_result(payload)
|
||||
return
|
||||
|
||||
cmd = payload.get("cmd")
|
||||
if cmd in {"aibot_msg_callback", "aibot_event_callback"}:
|
||||
await self.message_handler(payload)
|
||||
return
|
||||
|
||||
if payload.get("errcode") not in (None, 0):
|
||||
logger.warning(
|
||||
"[WecomAI][LongConn] 服务端返回错误: errcode=%s errmsg=%s",
|
||||
payload.get("errcode"),
|
||||
payload.get("errmsg"),
|
||||
)
|
||||
|
||||
async def send_command(
|
||||
self,
|
||||
cmd: str,
|
||||
req_id: str,
|
||||
body: dict[str, Any] | None,
|
||||
) -> bool:
|
||||
"""发送长连接命令。"""
|
||||
headers = {"req_id": req_id}
|
||||
payload: dict[str, Any] = {"cmd": cmd, "headers": headers}
|
||||
if body is not None:
|
||||
payload["body"] = body
|
||||
|
||||
async with self._command_lock:
|
||||
max_retries = 3
|
||||
for attempt in range(max_retries + 1):
|
||||
response = await self._send_and_wait_response(req_id, payload)
|
||||
if not response:
|
||||
if attempt < max_retries:
|
||||
await asyncio.sleep(min(0.2 * (2**attempt), 2.0))
|
||||
continue
|
||||
return False
|
||||
|
||||
errcode = response.get("errcode")
|
||||
if errcode in (0, None):
|
||||
return True
|
||||
|
||||
if errcode == 6000 and attempt < max_retries:
|
||||
backoff = min(0.2 * (2**attempt), 2.0)
|
||||
logger.warning(
|
||||
"[WecomAI][LongConn] 命令冲突(errcode=6000),将重试。cmd=%s req_id=%s attempt=%d",
|
||||
cmd,
|
||||
req_id,
|
||||
attempt + 1,
|
||||
)
|
||||
await asyncio.sleep(backoff)
|
||||
continue
|
||||
|
||||
logger.warning(
|
||||
"[WecomAI][LongConn] 命令失败: cmd=%s req_id=%s errcode=%s errmsg=%s",
|
||||
cmd,
|
||||
req_id,
|
||||
errcode,
|
||||
response.get("errmsg"),
|
||||
)
|
||||
return False
|
||||
|
||||
return False
|
||||
|
||||
async def _send_and_wait_response(
|
||||
self,
|
||||
req_id: str,
|
||||
payload: dict[str, Any],
|
||||
timeout: float = 10.0,
|
||||
) -> dict[str, Any] | None:
|
||||
loop = asyncio.get_running_loop()
|
||||
waiter: asyncio.Future[dict[str, Any]] = loop.create_future()
|
||||
self._response_waiters[req_id] = waiter
|
||||
try:
|
||||
await self._send_json(payload)
|
||||
return await asyncio.wait_for(waiter, timeout=timeout)
|
||||
except TimeoutError:
|
||||
logger.warning(
|
||||
"[WecomAI][LongConn] 等待命令响应超时: cmd=%s req_id=%s",
|
||||
payload.get("cmd"),
|
||||
req_id,
|
||||
)
|
||||
return None
|
||||
finally:
|
||||
self._response_waiters.pop(req_id, None)
|
||||
|
||||
async def _send_json(self, payload: dict[str, Any]) -> None:
|
||||
ws = self._ws
|
||||
if ws is None or ws.closed:
|
||||
raise RuntimeError("长连接尚未建立")
|
||||
async with self._send_lock:
|
||||
await ws.send_json(payload)
|
||||
|
||||
async def shutdown(self) -> None:
|
||||
self._shutdown_event.set()
|
||||
ws = self._ws
|
||||
if ws is not None and not ws.closed:
|
||||
await ws.close()
|
||||
|
||||
session = self._session
|
||||
if session is not None and not session.closed:
|
||||
await session.close()
|
||||
@@ -4,6 +4,7 @@
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any
|
||||
|
||||
@@ -82,7 +83,7 @@ class WecomAIQueueMgr:
|
||||
del self.pending_responses[session_id]
|
||||
logger.debug(f"[WecomAI] 移除待处理响应: {session_id}")
|
||||
if mark_finished:
|
||||
self.completed_streams[session_id] = asyncio.get_event_loop().time()
|
||||
self.completed_streams[session_id] = time.monotonic()
|
||||
logger.debug(f"[WecomAI] 标记流已结束: {session_id}")
|
||||
|
||||
def remove_queue(self, session_id: str):
|
||||
@@ -135,7 +136,7 @@ class WecomAIQueueMgr:
|
||||
"""
|
||||
self.pending_responses[session_id] = {
|
||||
"callback_params": callback_params,
|
||||
"timestamp": asyncio.get_event_loop().time(),
|
||||
"timestamp": time.monotonic(),
|
||||
}
|
||||
logger.debug(f"[WecomAI] 设置待处理响应: {session_id}")
|
||||
|
||||
@@ -160,7 +161,7 @@ class WecomAIQueueMgr:
|
||||
finished_at = self.completed_streams.get(session_id)
|
||||
if finished_at is None:
|
||||
return False
|
||||
if asyncio.get_event_loop().time() - finished_at > max_age_seconds:
|
||||
if time.monotonic() - finished_at > max_age_seconds:
|
||||
self.completed_streams.pop(session_id, None)
|
||||
return False
|
||||
return True
|
||||
@@ -172,7 +173,7 @@ class WecomAIQueueMgr:
|
||||
max_age_seconds: 最大存活时间(秒)
|
||||
|
||||
"""
|
||||
current_time = asyncio.get_event_loop().time()
|
||||
current_time = time.monotonic()
|
||||
expired_sessions = []
|
||||
|
||||
for session_id, response_data in self.pending_responses.items():
|
||||
|
||||
@@ -369,7 +369,7 @@ class WeixinOfficialAccountPlatformAdapter(Platform):
|
||||
if future:
|
||||
logger.debug(f"duplicate message id checked: {msg.id}")
|
||||
else:
|
||||
future = asyncio.get_event_loop().create_future()
|
||||
future = asyncio.get_running_loop().create_future()
|
||||
self.wexin_event_workers[msg_id] = future
|
||||
await self.convert_message(msg, future)
|
||||
# I love shield so much!
|
||||
@@ -461,7 +461,7 @@ class WeixinOfficialAccountPlatformAdapter(Platform):
|
||||
elif msg.type == "voice":
|
||||
assert isinstance(msg, VoiceMessage)
|
||||
|
||||
resp: Response = await asyncio.get_event_loop().run_in_executor(
|
||||
resp: Response = await asyncio.get_running_loop().run_in_executor(
|
||||
None,
|
||||
self.client.media.download,
|
||||
msg.media_id,
|
||||
|
||||
@@ -4,7 +4,11 @@ import asyncio
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
from collections.abc import AsyncGenerator, Awaitable, Callable
|
||||
import threading
|
||||
import urllib.parse
|
||||
from collections.abc import AsyncGenerator, Awaitable, Callable, Mapping
|
||||
from dataclasses import dataclass
|
||||
from types import MappingProxyType
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
@@ -17,6 +21,103 @@ from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
||||
|
||||
DEFAULT_MCP_CONFIG = {"mcpServers": {}}
|
||||
|
||||
DEFAULT_MCP_INIT_TIMEOUT_SECONDS = 180.0
|
||||
DEFAULT_ENABLE_MCP_TIMEOUT_SECONDS = 180.0
|
||||
MCP_INIT_TIMEOUT_ENV = "ASTRBOT_MCP_INIT_TIMEOUT"
|
||||
ENABLE_MCP_TIMEOUT_ENV = "ASTRBOT_MCP_ENABLE_TIMEOUT"
|
||||
MAX_MCP_TIMEOUT_SECONDS = 300.0
|
||||
|
||||
|
||||
class MCPInitError(Exception):
|
||||
"""Base exception for MCP initialization failures."""
|
||||
|
||||
|
||||
class MCPInitTimeoutError(asyncio.TimeoutError, MCPInitError):
|
||||
"""Raised when MCP client initialization exceeds the configured timeout."""
|
||||
|
||||
|
||||
class MCPAllServicesFailedError(MCPInitError):
|
||||
"""Raised when all configured MCP services fail to initialize."""
|
||||
|
||||
|
||||
class MCPShutdownTimeoutError(asyncio.TimeoutError):
|
||||
"""Raised when MCP shutdown exceeds the configured timeout."""
|
||||
|
||||
def __init__(self, names: list[str], timeout: float) -> None:
|
||||
self.names = names
|
||||
self.timeout = timeout
|
||||
message = f"MCP 服务关闭超时({timeout:g} 秒):{', '.join(names)}"
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
@dataclass
|
||||
class MCPInitSummary:
|
||||
total: int
|
||||
success: int
|
||||
failed: list[str]
|
||||
|
||||
|
||||
@dataclass
|
||||
class _MCPServerRuntime:
|
||||
name: str
|
||||
client: MCPClient
|
||||
shutdown_event: asyncio.Event
|
||||
lifecycle_task: asyncio.Task[None]
|
||||
|
||||
|
||||
class _MCPClientDictView(Mapping[str, MCPClient]):
|
||||
"""Read-only view of MCP clients derived from runtime state."""
|
||||
|
||||
def __init__(self, runtime: dict[str, _MCPServerRuntime]) -> None:
|
||||
self._runtime = runtime
|
||||
|
||||
def __getitem__(self, key: str) -> MCPClient:
|
||||
return self._runtime[key].client
|
||||
|
||||
def __iter__(self):
|
||||
return iter(self._runtime)
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self._runtime)
|
||||
|
||||
|
||||
def _resolve_timeout(
|
||||
timeout: float | int | str | None = None,
|
||||
*,
|
||||
env_name: str = MCP_INIT_TIMEOUT_ENV,
|
||||
default: float = DEFAULT_MCP_INIT_TIMEOUT_SECONDS,
|
||||
) -> float:
|
||||
"""Resolve timeout with precedence: explicit argument > env value > default."""
|
||||
source = f"环境变量 {env_name}"
|
||||
if timeout is None:
|
||||
timeout = os.getenv(env_name, str(default))
|
||||
else:
|
||||
source = "显式参数 timeout"
|
||||
|
||||
try:
|
||||
timeout_value = float(timeout)
|
||||
except (TypeError, ValueError):
|
||||
logger.warning(
|
||||
f"超时配置({source})={timeout!r} 无效,使用默认值 {default:g} 秒。"
|
||||
)
|
||||
return default
|
||||
|
||||
if timeout_value <= 0:
|
||||
logger.warning(
|
||||
f"超时配置({source})={timeout_value:g} 必须大于 0,使用默认值 {default:g} 秒。"
|
||||
)
|
||||
return default
|
||||
|
||||
if timeout_value > MAX_MCP_TIMEOUT_SECONDS:
|
||||
logger.warning(
|
||||
f"超时配置({source})={timeout_value:g} 过大,已限制为最大值 "
|
||||
f"{MAX_MCP_TIMEOUT_SECONDS:g} 秒,以避免长时间等待。"
|
||||
)
|
||||
return MAX_MCP_TIMEOUT_SECONDS
|
||||
|
||||
return timeout_value
|
||||
|
||||
|
||||
SUPPORTED_TYPES = [
|
||||
"string",
|
||||
"number",
|
||||
@@ -106,9 +207,49 @@ async def _quick_test_mcp_connection(config: dict) -> tuple[bool, str]:
|
||||
class FunctionToolManager:
|
||||
def __init__(self) -> None:
|
||||
self.func_list: list[FuncTool] = []
|
||||
self.mcp_client_dict: dict[str, MCPClient] = {}
|
||||
"""MCP 服务列表"""
|
||||
self.mcp_client_event: dict[str, asyncio.Event] = {}
|
||||
self._mcp_server_runtime: dict[str, _MCPServerRuntime] = {}
|
||||
"""MCP 服务运行时状态(唯一事实来源)"""
|
||||
self._mcp_server_runtime_view = MappingProxyType(self._mcp_server_runtime)
|
||||
self._mcp_client_dict_view = _MCPClientDictView(self._mcp_server_runtime)
|
||||
self._timeout_mismatch_warned = False
|
||||
self._timeout_warn_lock = threading.Lock()
|
||||
self._runtime_lock = asyncio.Lock()
|
||||
self._mcp_starting: set[str] = set()
|
||||
self._init_timeout_default = _resolve_timeout(
|
||||
timeout=None,
|
||||
env_name=MCP_INIT_TIMEOUT_ENV,
|
||||
default=DEFAULT_MCP_INIT_TIMEOUT_SECONDS,
|
||||
)
|
||||
self._enable_timeout_default = _resolve_timeout(
|
||||
timeout=None,
|
||||
env_name=ENABLE_MCP_TIMEOUT_ENV,
|
||||
default=DEFAULT_ENABLE_MCP_TIMEOUT_SECONDS,
|
||||
)
|
||||
self._warn_on_timeout_mismatch(
|
||||
self._init_timeout_default,
|
||||
self._enable_timeout_default,
|
||||
)
|
||||
|
||||
@property
|
||||
def mcp_client_dict(self) -> Mapping[str, MCPClient]:
|
||||
"""Read-only compatibility view for external callers that still read mcp_client_dict.
|
||||
|
||||
Note: Mutating this mapping is unsupported and will raise TypeError.
|
||||
"""
|
||||
return self._mcp_client_dict_view
|
||||
|
||||
@property
|
||||
def mcp_server_runtime_view(self) -> Mapping[str, _MCPServerRuntime]:
|
||||
"""Read-only view of MCP runtime metadata for external callers."""
|
||||
return self._mcp_server_runtime_view
|
||||
|
||||
@property
|
||||
def mcp_server_runtime(self) -> Mapping[str, _MCPServerRuntime]:
|
||||
"""Backward-compatible read-only view (deprecated). Do not mutate.
|
||||
|
||||
Note: Mutations are not supported and will raise TypeError.
|
||||
"""
|
||||
return self._mcp_server_runtime_view
|
||||
|
||||
def empty(self) -> bool:
|
||||
return len(self.func_list) == 0
|
||||
@@ -179,7 +320,34 @@ class FunctionToolManager:
|
||||
tool_set = ToolSet(self.func_list.copy())
|
||||
return tool_set
|
||||
|
||||
async def init_mcp_clients(self) -> None:
|
||||
@staticmethod
|
||||
def _log_safe_mcp_debug_config(cfg: dict) -> None:
|
||||
# 仅记录脱敏后的摘要,避免泄露 command/args/url 中的敏感信息
|
||||
if "command" in cfg:
|
||||
cmd = cfg["command"]
|
||||
executable = str(cmd[0] if isinstance(cmd, (list, tuple)) and cmd else cmd)
|
||||
args_val = cfg.get("args", [])
|
||||
args_count = (
|
||||
len(args_val)
|
||||
if isinstance(args_val, (list, tuple))
|
||||
else (0 if args_val is None else 1)
|
||||
)
|
||||
logger.debug(f" 命令可执行文件: {executable}, 参数数量: {args_count}")
|
||||
return
|
||||
|
||||
if "url" in cfg:
|
||||
parsed = urllib.parse.urlparse(str(cfg["url"]))
|
||||
host = parsed.hostname or ""
|
||||
scheme = parsed.scheme or "unknown"
|
||||
try:
|
||||
port = f":{parsed.port}" if parsed.port else ""
|
||||
except ValueError:
|
||||
port = ""
|
||||
logger.debug(f" 主机: {scheme}://{host}{port}")
|
||||
|
||||
async def init_mcp_clients(
|
||||
self, raise_on_all_failed: bool = False
|
||||
) -> MCPInitSummary:
|
||||
"""从项目根目录读取 mcp_server.json 文件,初始化 MCP 服务列表。文件格式如下:
|
||||
```
|
||||
{
|
||||
@@ -197,6 +365,10 @@ class FunctionToolManager:
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
Timeout behavior:
|
||||
- 初始化超时使用环境变量 ASTRBOT_MCP_INIT_TIMEOUT 或默认值。
|
||||
- 动态启用超时使用 ASTRBOT_MCP_ENABLE_TIMEOUT(独立于初始化超时)。
|
||||
"""
|
||||
data_dir = get_astrbot_data_path()
|
||||
|
||||
@@ -206,56 +378,217 @@ class FunctionToolManager:
|
||||
with open(mcp_json_file, "w", encoding="utf-8") as f:
|
||||
json.dump(DEFAULT_MCP_CONFIG, f, ensure_ascii=False, indent=4)
|
||||
logger.info(f"未找到 MCP 服务配置文件,已创建默认配置文件 {mcp_json_file}")
|
||||
return
|
||||
return MCPInitSummary(total=0, success=0, failed=[])
|
||||
|
||||
mcp_server_json_obj: dict[str, dict] = json.load(
|
||||
open(mcp_json_file, encoding="utf-8"),
|
||||
)["mcpServers"]
|
||||
with open(mcp_json_file, encoding="utf-8") as f:
|
||||
mcp_server_json_obj: dict[str, dict] = json.load(f)["mcpServers"]
|
||||
|
||||
for name in mcp_server_json_obj:
|
||||
cfg = mcp_server_json_obj[name]
|
||||
init_timeout = self._init_timeout_default
|
||||
timeout_display = f"{init_timeout:g}"
|
||||
|
||||
active_configs: list[tuple[str, dict, asyncio.Event]] = []
|
||||
for name, cfg in mcp_server_json_obj.items():
|
||||
if cfg.get("active", True):
|
||||
event = asyncio.Event()
|
||||
asyncio.create_task(
|
||||
self._init_mcp_client_task_wrapper(name, cfg, event),
|
||||
)
|
||||
self.mcp_client_event[name] = event
|
||||
shutdown_event = asyncio.Event()
|
||||
active_configs.append((name, cfg, shutdown_event))
|
||||
|
||||
async def _init_mcp_client_task_wrapper(
|
||||
if not active_configs:
|
||||
return MCPInitSummary(total=0, success=0, failed=[])
|
||||
|
||||
logger.info(f"等待 {len(active_configs)} 个 MCP 服务初始化...")
|
||||
|
||||
init_tasks = [
|
||||
asyncio.create_task(
|
||||
self._start_mcp_server(
|
||||
name=name,
|
||||
cfg=cfg,
|
||||
shutdown_event=shutdown_event,
|
||||
timeout=init_timeout,
|
||||
),
|
||||
name=f"mcp-init:{name}",
|
||||
)
|
||||
for (name, cfg, shutdown_event) in active_configs
|
||||
]
|
||||
results = await asyncio.gather(*init_tasks, return_exceptions=True)
|
||||
|
||||
success_count = 0
|
||||
failed_services: list[str] = []
|
||||
|
||||
for (name, cfg, _), result in zip(active_configs, results, strict=False):
|
||||
if isinstance(result, Exception):
|
||||
if isinstance(result, MCPInitTimeoutError):
|
||||
logger.error(
|
||||
f"Connected to MCP server {name} timeout ({timeout_display} seconds)"
|
||||
)
|
||||
else:
|
||||
logger.error(f"Failed to initialize MCP server {name}: {result}")
|
||||
self._log_safe_mcp_debug_config(cfg)
|
||||
failed_services.append(name)
|
||||
async with self._runtime_lock:
|
||||
self._mcp_server_runtime.pop(name, None)
|
||||
continue
|
||||
|
||||
success_count += 1
|
||||
|
||||
if failed_services:
|
||||
logger.warning(
|
||||
f"The following MCP services failed to initialize: {', '.join(failed_services)}. "
|
||||
f"Please check the mcp_server.json file and server availability."
|
||||
)
|
||||
|
||||
summary = MCPInitSummary(
|
||||
total=len(active_configs), success=success_count, failed=failed_services
|
||||
)
|
||||
logger.info(
|
||||
f"MCP services initialization completed: {summary.success}/{summary.total} successful, {len(summary.failed)} failed."
|
||||
)
|
||||
if summary.total > 0 and summary.success == 0:
|
||||
msg = "All MCP services failed to initialize, please check the mcp_server.json and server availability."
|
||||
if raise_on_all_failed:
|
||||
raise MCPAllServicesFailedError(msg)
|
||||
logger.error(msg)
|
||||
return summary
|
||||
|
||||
async def _start_mcp_server(
|
||||
self,
|
||||
name: str,
|
||||
cfg: dict,
|
||||
event: asyncio.Event,
|
||||
ready_future: asyncio.Future | None = None,
|
||||
*,
|
||||
shutdown_event: asyncio.Event | None = None,
|
||||
timeout: float,
|
||||
) -> None:
|
||||
"""初始化 MCP 客户端的包装函数,用于捕获异常"""
|
||||
"""Initialize MCP server with timeout and register task/event together.
|
||||
|
||||
This method is idempotent. If the server is already running, the existing
|
||||
runtime is kept and the new config is ignored.
|
||||
"""
|
||||
async with self._runtime_lock:
|
||||
if name in self._mcp_server_runtime or name in self._mcp_starting:
|
||||
logger.warning(
|
||||
f"Connected to MCP server {name}, ignoring this startup request (timeout={timeout:g})."
|
||||
)
|
||||
self._log_safe_mcp_debug_config(cfg)
|
||||
return
|
||||
self._mcp_starting.add(name)
|
||||
|
||||
if shutdown_event is None:
|
||||
shutdown_event = asyncio.Event()
|
||||
|
||||
mcp_client: MCPClient | None = None
|
||||
try:
|
||||
await self._init_mcp_client(name, cfg)
|
||||
tools = await self.mcp_client_dict[name].list_tools_and_save()
|
||||
if ready_future and not ready_future.done():
|
||||
# tell the caller we are ready
|
||||
ready_future.set_result(tools)
|
||||
await event.wait()
|
||||
logger.info(f"收到 MCP 客户端 {name} 终止信号")
|
||||
except Exception as e:
|
||||
logger.error(f"初始化 MCP 客户端 {name} 失败", exc_info=True)
|
||||
if ready_future and not ready_future.done():
|
||||
ready_future.set_exception(e)
|
||||
mcp_client = await asyncio.wait_for(
|
||||
self._init_mcp_client(name, cfg),
|
||||
timeout=timeout,
|
||||
)
|
||||
except asyncio.TimeoutError as exc:
|
||||
raise MCPInitTimeoutError(
|
||||
f"Connected to MCP server {name} timeout ({timeout:g} seconds)"
|
||||
) from exc
|
||||
except Exception:
|
||||
logger.error(f"Failed to initialize MCP client {name}", exc_info=True)
|
||||
raise
|
||||
finally:
|
||||
# 无论如何都能清理
|
||||
await self._terminate_mcp_client(name)
|
||||
if mcp_client is None:
|
||||
async with self._runtime_lock:
|
||||
self._mcp_starting.discard(name)
|
||||
|
||||
async def _init_mcp_client(self, name: str, config: dict) -> None:
|
||||
async def lifecycle() -> None:
|
||||
try:
|
||||
await shutdown_event.wait()
|
||||
logger.info(f"Received shutdown signal for MCP client {name}")
|
||||
except asyncio.CancelledError:
|
||||
logger.debug(f"MCP client {name} task was cancelled")
|
||||
raise
|
||||
finally:
|
||||
await self._terminate_mcp_client(name)
|
||||
|
||||
lifecycle_task = asyncio.create_task(lifecycle(), name=f"mcp-client:{name}")
|
||||
async with self._runtime_lock:
|
||||
self._mcp_server_runtime[name] = _MCPServerRuntime(
|
||||
name=name,
|
||||
client=mcp_client,
|
||||
shutdown_event=shutdown_event,
|
||||
lifecycle_task=lifecycle_task,
|
||||
)
|
||||
self._mcp_starting.discard(name)
|
||||
|
||||
async def _shutdown_runtimes(
|
||||
self,
|
||||
runtimes: list[_MCPServerRuntime],
|
||||
timeout: float,
|
||||
*,
|
||||
strict: bool = True,
|
||||
) -> list[str]:
|
||||
"""Shutdown runtimes and wait for lifecycle tasks to complete."""
|
||||
lifecycle_tasks = [
|
||||
runtime.lifecycle_task
|
||||
for runtime in runtimes
|
||||
if not runtime.lifecycle_task.done()
|
||||
]
|
||||
if not lifecycle_tasks:
|
||||
return []
|
||||
|
||||
for runtime in runtimes:
|
||||
runtime.shutdown_event.set()
|
||||
|
||||
try:
|
||||
results = await asyncio.wait_for(
|
||||
asyncio.gather(*lifecycle_tasks, return_exceptions=True),
|
||||
timeout=timeout,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
pending_names = [
|
||||
runtime.name
|
||||
for runtime in runtimes
|
||||
if not runtime.lifecycle_task.done()
|
||||
]
|
||||
for task in lifecycle_tasks:
|
||||
if not task.done():
|
||||
task.cancel()
|
||||
await asyncio.gather(*lifecycle_tasks, return_exceptions=True)
|
||||
if strict:
|
||||
raise MCPShutdownTimeoutError(pending_names, timeout)
|
||||
logger.warning(
|
||||
"MCP server shutdown timeout (%s seconds), the following servers were not fully closed: %s",
|
||||
f"{timeout:g}",
|
||||
", ".join(pending_names),
|
||||
)
|
||||
return pending_names
|
||||
else:
|
||||
for result in results:
|
||||
if isinstance(result, asyncio.CancelledError):
|
||||
logger.debug("MCP lifecycle task was cancelled during shutdown.")
|
||||
elif isinstance(result, Exception):
|
||||
logger.error(
|
||||
"MCP lifecycle task failed during shutdown.",
|
||||
exc_info=(type(result), result, result.__traceback__),
|
||||
)
|
||||
return []
|
||||
|
||||
async def _cleanup_mcp_client_safely(
|
||||
self, mcp_client: MCPClient, name: str
|
||||
) -> None:
|
||||
"""安全清理单个 MCP 客户端,避免清理异常中断主流程。"""
|
||||
try:
|
||||
await mcp_client.cleanup()
|
||||
except Exception as cleanup_exc: # noqa: BLE001 - only log here
|
||||
logger.error(
|
||||
f"Failed to cleanup MCP client resources {name}: {cleanup_exc}"
|
||||
)
|
||||
|
||||
async def _init_mcp_client(self, name: str, config: dict) -> MCPClient:
|
||||
"""初始化单个MCP客户端"""
|
||||
# 先清理之前的客户端,如果存在
|
||||
if name in self.mcp_client_dict:
|
||||
await self._terminate_mcp_client(name)
|
||||
|
||||
mcp_client = MCPClient()
|
||||
mcp_client.name = name
|
||||
self.mcp_client_dict[name] = mcp_client
|
||||
await mcp_client.connect_to_server(config, name)
|
||||
tools_res = await mcp_client.list_tools_and_save()
|
||||
try:
|
||||
await mcp_client.connect_to_server(config, name)
|
||||
tools_res = await mcp_client.list_tools_and_save()
|
||||
except asyncio.CancelledError:
|
||||
await self._cleanup_mcp_client_safely(mcp_client, name)
|
||||
raise
|
||||
except Exception:
|
||||
await self._cleanup_mcp_client_safely(mcp_client, name)
|
||||
raise
|
||||
logger.debug(f"MCP server {name} list tools response: {tools_res}")
|
||||
tool_names = [tool.name for tool in tools_res.tools]
|
||||
|
||||
@@ -275,27 +608,37 @@ class FunctionToolManager:
|
||||
)
|
||||
self.func_list.append(func_tool)
|
||||
|
||||
logger.info(f"已连接 MCP 服务 {name}, Tools: {tool_names}")
|
||||
logger.info(f"Connected to MCP server {name}, Tools: {tool_names}")
|
||||
return mcp_client
|
||||
|
||||
async def _terminate_mcp_client(self, name: str) -> None:
|
||||
"""关闭并清理MCP客户端"""
|
||||
if name in self.mcp_client_dict:
|
||||
client = self.mcp_client_dict[name]
|
||||
try:
|
||||
# 关闭MCP连接
|
||||
await client.cleanup()
|
||||
except Exception as e:
|
||||
logger.error(f"清空 MCP 客户端资源 {name}: {e}。")
|
||||
finally:
|
||||
# Remove client from dict after cleanup attempt (successful or not)
|
||||
self.mcp_client_dict.pop(name, None)
|
||||
# 移除关联的FuncTool
|
||||
self.func_list = [
|
||||
f
|
||||
for f in self.func_list
|
||||
if not (isinstance(f, MCPTool) and f.mcp_server_name == name)
|
||||
]
|
||||
logger.info(f"已关闭 MCP 服务 {name}")
|
||||
async with self._runtime_lock:
|
||||
runtime = self._mcp_server_runtime.get(name)
|
||||
if runtime:
|
||||
client = runtime.client
|
||||
# 关闭MCP连接
|
||||
await self._cleanup_mcp_client_safely(client, name)
|
||||
# 移除关联的FuncTool
|
||||
self.func_list = [
|
||||
f
|
||||
for f in self.func_list
|
||||
if not (isinstance(f, MCPTool) and f.mcp_server_name == name)
|
||||
]
|
||||
async with self._runtime_lock:
|
||||
self._mcp_server_runtime.pop(name, None)
|
||||
self._mcp_starting.discard(name)
|
||||
logger.info(f"Disconnected from MCP server {name}")
|
||||
return
|
||||
|
||||
# Runtime missing but stale tools may still exist after failed flows.
|
||||
self.func_list = [
|
||||
f
|
||||
for f in self.func_list
|
||||
if not (isinstance(f, MCPTool) and f.mcp_server_name == name)
|
||||
]
|
||||
async with self._runtime_lock:
|
||||
self._mcp_starting.discard(name)
|
||||
|
||||
@staticmethod
|
||||
async def test_mcp_server_connection(config: dict) -> list[str]:
|
||||
@@ -319,42 +662,36 @@ class FunctionToolManager:
|
||||
self,
|
||||
name: str,
|
||||
config: dict,
|
||||
event: asyncio.Event | None = None,
|
||||
ready_future: asyncio.Future | None = None,
|
||||
timeout: int = 30,
|
||||
shutdown_event: asyncio.Event | None = None,
|
||||
timeout: float | int | str | None = None,
|
||||
) -> None:
|
||||
"""Enable_mcp_server a new MCP server to the manager and initialize it.
|
||||
"""Enable a new MCP server and initialize it.
|
||||
|
||||
Args:
|
||||
name (str): The name of the MCP server.
|
||||
config (dict): Configuration for the MCP server.
|
||||
event (asyncio.Event): Event to signal when the MCP client is ready.
|
||||
ready_future (asyncio.Future): Future to signal when the MCP client is ready.
|
||||
timeout (int): Timeout for the initialization.
|
||||
name: The name of the MCP server.
|
||||
config: Configuration for the MCP server.
|
||||
shutdown_event: Event to signal when the MCP client should shut down.
|
||||
timeout: Timeout in seconds for initialization.
|
||||
Uses ASTRBOT_MCP_ENABLE_TIMEOUT by default (separate from init timeout).
|
||||
|
||||
Raises:
|
||||
TimeoutError: If the initialization does not complete within the specified timeout.
|
||||
MCPInitTimeoutError: If initialization does not complete within timeout.
|
||||
Exception: If there is an error during initialization.
|
||||
|
||||
"""
|
||||
if not event:
|
||||
event = asyncio.Event()
|
||||
if not ready_future:
|
||||
ready_future = asyncio.Future()
|
||||
if name in self.mcp_client_dict:
|
||||
return
|
||||
asyncio.create_task(
|
||||
self._init_mcp_client_task_wrapper(name, config, event, ready_future),
|
||||
if timeout is None:
|
||||
timeout_value = self._enable_timeout_default
|
||||
else:
|
||||
timeout_value = _resolve_timeout(
|
||||
timeout=timeout,
|
||||
env_name=ENABLE_MCP_TIMEOUT_ENV,
|
||||
default=self._enable_timeout_default,
|
||||
)
|
||||
await self._start_mcp_server(
|
||||
name=name,
|
||||
cfg=config,
|
||||
shutdown_event=shutdown_event,
|
||||
timeout=timeout_value,
|
||||
)
|
||||
try:
|
||||
await asyncio.wait_for(ready_future, timeout=timeout)
|
||||
finally:
|
||||
self.mcp_client_event[name] = event
|
||||
|
||||
if ready_future.done() and ready_future.exception():
|
||||
exc = ready_future.exception()
|
||||
if exc is not None:
|
||||
raise exc
|
||||
|
||||
async def disable_mcp_server(
|
||||
self,
|
||||
@@ -367,39 +704,40 @@ class FunctionToolManager:
|
||||
name (str): The name of the MCP server to disable. If None, ALL MCP servers will be disabled.
|
||||
timeout (int): Timeout.
|
||||
|
||||
Raises:
|
||||
MCPShutdownTimeoutError: If shutdown does not complete within timeout.
|
||||
Only raised when disabling a specific server (name is not None).
|
||||
|
||||
"""
|
||||
if name:
|
||||
if name not in self.mcp_client_event:
|
||||
async with self._runtime_lock:
|
||||
runtime = self._mcp_server_runtime.get(name)
|
||||
if runtime is None:
|
||||
return
|
||||
client = self.mcp_client_dict.get(name)
|
||||
self.mcp_client_event[name].set()
|
||||
if not client:
|
||||
return
|
||||
client_running_event = client.running_event
|
||||
try:
|
||||
await asyncio.wait_for(client_running_event.wait(), timeout=timeout)
|
||||
finally:
|
||||
self.mcp_client_event.pop(name, None)
|
||||
self.func_list = [
|
||||
f
|
||||
for f in self.func_list
|
||||
if not (isinstance(f, MCPTool) and f.mcp_server_name == name)
|
||||
]
|
||||
|
||||
await self._shutdown_runtimes([runtime], timeout, strict=True)
|
||||
else:
|
||||
running_events = [
|
||||
client.running_event.wait() for client in self.mcp_client_dict.values()
|
||||
]
|
||||
for key, event in self.mcp_client_event.items():
|
||||
event.set()
|
||||
# waiting for all clients to finish
|
||||
try:
|
||||
await asyncio.wait_for(asyncio.gather(*running_events), timeout=timeout)
|
||||
finally:
|
||||
self.mcp_client_event.clear()
|
||||
self.mcp_client_dict.clear()
|
||||
self.func_list = [
|
||||
f for f in self.func_list if not isinstance(f, MCPTool)
|
||||
]
|
||||
async with self._runtime_lock:
|
||||
runtimes = list(self._mcp_server_runtime.values())
|
||||
await self._shutdown_runtimes(runtimes, timeout, strict=False)
|
||||
|
||||
def _warn_on_timeout_mismatch(
|
||||
self,
|
||||
init_timeout: float,
|
||||
enable_timeout: float,
|
||||
) -> None:
|
||||
if init_timeout == enable_timeout:
|
||||
return
|
||||
with self._timeout_warn_lock:
|
||||
if self._timeout_mismatch_warned:
|
||||
return
|
||||
logger.info(
|
||||
"检测到 MCP 初始化超时与动态启用超时配置不同:"
|
||||
"初始化使用 %s 秒,动态启用使用 %s 秒。如需一致,请设置相同值。",
|
||||
f"{init_timeout:g}",
|
||||
f"{enable_timeout:g}",
|
||||
)
|
||||
self._timeout_mismatch_warned = True
|
||||
|
||||
def get_func_desc_openai_style(self, omit_empty_parameter_field=False) -> list:
|
||||
"""获得 OpenAI API 风格的**已经激活**的工具描述"""
|
||||
|
||||
@@ -79,6 +79,7 @@ class ProviderManager:
|
||||
self._provider_change_hooks: list[
|
||||
Callable[[str, ProviderType, str | None], None]
|
||||
] = []
|
||||
self._mcp_init_task: asyncio.Task | None = None
|
||||
|
||||
def set_provider_change_callback(
|
||||
self,
|
||||
@@ -330,8 +331,17 @@ class ProviderManager:
|
||||
if not self.curr_tts_provider_inst and self.tts_provider_insts:
|
||||
self.curr_tts_provider_inst = self.tts_provider_insts[0]
|
||||
|
||||
# 初始化 MCP Client 连接
|
||||
asyncio.create_task(self.llm_tools.init_mcp_clients(), name="init_mcp_clients")
|
||||
async def _init_mcp_clients_bg() -> None:
|
||||
try:
|
||||
await self.llm_tools.init_mcp_clients()
|
||||
except Exception:
|
||||
logger.error("MCP init background task failed", exc_info=True)
|
||||
|
||||
if self._mcp_init_task is None or self._mcp_init_task.done():
|
||||
self._mcp_init_task = asyncio.create_task(
|
||||
_init_mcp_clients_bg(),
|
||||
name="provider-manager:mcp-init",
|
||||
)
|
||||
|
||||
def dynamic_import_provider(self, type: str) -> None:
|
||||
"""动态导入提供商适配器模块
|
||||
@@ -798,8 +808,17 @@ class ProviderManager:
|
||||
config.save_config()
|
||||
# load instance
|
||||
await self.load_provider(new_config)
|
||||
# sync in-memory config for API queries (e.g., embedding provider list)
|
||||
self.providers_config = astrbot_config["provider"]
|
||||
|
||||
async def terminate(self) -> None:
|
||||
if self._mcp_init_task and not self._mcp_init_task.done():
|
||||
self._mcp_init_task.cancel()
|
||||
try:
|
||||
await self._mcp_init_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
for provider_inst in self.provider_insts:
|
||||
if hasattr(provider_inst, "terminate"):
|
||||
await provider_inst.terminate() # type: ignore
|
||||
|
||||
@@ -281,7 +281,24 @@ class TTSProvider(AbstractProvider):
|
||||
accumulated_text += text_part
|
||||
|
||||
async def test(self) -> None:
|
||||
await self.get_audio("hi")
|
||||
audio_path = await self.get_audio("hi")
|
||||
|
||||
# 检查生成的音频文件是否有效
|
||||
if not os.path.exists(audio_path):
|
||||
raise Exception("TTS test failed: audio file was not created")
|
||||
|
||||
file_size = os.path.getsize(audio_path)
|
||||
if file_size == 0:
|
||||
raise Exception(
|
||||
"TTS test failed: generated audio file is empty (0 bytes). "
|
||||
"Please check your TTS provider configuration, especially required parameters like group_id for MiniMax."
|
||||
)
|
||||
|
||||
# 清理测试文件
|
||||
try:
|
||||
os.remove(audio_path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
class EmbeddingProvider(AbstractProvider):
|
||||
|
||||
@@ -276,9 +276,24 @@ class ProviderAnthropic(Provider):
|
||||
llm_response.id = completion.id
|
||||
llm_response.usage = self._extract_usage(completion.usage)
|
||||
|
||||
# TODO(Soulter): 处理 end_turn 情况
|
||||
# Handle cases where completion only contains ThinkingBlock (e.g., MiniMax max_tokens)
|
||||
# When stop_reason='max_tokens', the model may return only thinking content
|
||||
# This is valid and should not raise an exception
|
||||
if not llm_response.completion_text and not llm_response.tools_call_args:
|
||||
raise Exception(f"Anthropic API 返回的 completion 无法解析:{completion}。")
|
||||
# Guard clause: raise early if no valid content at all
|
||||
if not llm_response.reasoning_content:
|
||||
raise ValueError(
|
||||
f"Anthropic API returned unparsable completion: "
|
||||
f"no text, tool_use, or thinking content found. "
|
||||
f"Completion: {completion}"
|
||||
)
|
||||
|
||||
# We have reasoning content (ThinkingBlock) - this is valid
|
||||
stop_reason = getattr(completion, "stop_reason", "unknown")
|
||||
logger.debug(
|
||||
f"Completion contains only ThinkingBlock (stop_reason={stop_reason})"
|
||||
)
|
||||
llm_response.completion_text = "" # Ensure empty string, not None
|
||||
|
||||
return llm_response
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ from ..register import register_provider_adapter
|
||||
|
||||
TEMP_DIR = Path(get_astrbot_temp_path()) / "azure_tts"
|
||||
TEMP_DIR.mkdir(parents=True, exist_ok=True)
|
||||
AZURE_TTS_SUBSCRIPTION_KEY_PATTERN = r"^(?:[a-zA-Z0-9]{32}|[a-zA-Z0-9]{84})$"
|
||||
|
||||
|
||||
class OTTSProvider:
|
||||
@@ -116,7 +117,7 @@ class AzureNativeProvider(TTSProvider):
|
||||
"azure_tts_subscription_key",
|
||||
"",
|
||||
).strip()
|
||||
if not re.fullmatch(r"^[a-zA-Z0-9]{32}$", self.subscription_key):
|
||||
if not re.fullmatch(AZURE_TTS_SUBSCRIPTION_KEY_PATTERN, self.subscription_key):
|
||||
raise ValueError("无效的Azure订阅密钥")
|
||||
self.region = provider_config.get("azure_tts_region", "eastus").strip()
|
||||
self.endpoint = (
|
||||
@@ -235,9 +236,9 @@ class AzureTTSProvider(TTSProvider):
|
||||
raise ValueError(error_msg) from e
|
||||
except KeyError as e:
|
||||
raise ValueError(f"配置错误: 缺少必要参数 {e}") from e
|
||||
if re.fullmatch(r"^[a-zA-Z0-9]{32}$", key_value):
|
||||
if re.fullmatch(AZURE_TTS_SUBSCRIPTION_KEY_PATTERN, key_value):
|
||||
return AzureNativeProvider(config, self.provider_settings)
|
||||
raise ValueError("订阅密钥格式无效,应为32位字母数字或other[...]格式")
|
||||
raise ValueError("订阅密钥格式无效,应为32位或84位字母数字或other[...]格式")
|
||||
|
||||
async def get_audio(self, text: str) -> str:
|
||||
if isinstance(self.provider, OTTSProvider):
|
||||
|
||||
@@ -87,7 +87,7 @@ class ProviderDashscopeTTSAPI(TTSProvider):
|
||||
model: str,
|
||||
text: str,
|
||||
) -> tuple[bytes | None, str]:
|
||||
loop = asyncio.get_event_loop()
|
||||
loop = asyncio.get_running_loop()
|
||||
response = await loop.run_in_executor(None, self._call_qwen_tts, model, text)
|
||||
audio_bytes = await self._extract_audio_from_response(response)
|
||||
if not audio_bytes:
|
||||
@@ -143,7 +143,7 @@ class ProviderDashscopeTTSAPI(TTSProvider):
|
||||
voice=self.voice,
|
||||
format=AudioFormat.WAV_24000HZ_MONO_16BIT,
|
||||
)
|
||||
loop = asyncio.get_event_loop()
|
||||
loop = asyncio.get_running_loop()
|
||||
audio_bytes = await loop.run_in_executor(
|
||||
None,
|
||||
synthesizer.call,
|
||||
|
||||
@@ -59,7 +59,7 @@ class GenieTTSProvider(TTSProvider):
|
||||
filename = f"genie_tts_{uuid.uuid4()}.wav"
|
||||
path = os.path.join(temp_dir, filename)
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
def _generate(save_path: str) -> None:
|
||||
assert genie is not None
|
||||
@@ -85,7 +85,7 @@ class GenieTTSProvider(TTSProvider):
|
||||
text_queue: asyncio.Queue[str | None],
|
||||
audio_queue: "asyncio.Queue[bytes | tuple[str, bytes] | None]",
|
||||
) -> None:
|
||||
loop = asyncio.get_event_loop()
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
while True:
|
||||
text = await text_queue.get()
|
||||
|
||||
@@ -13,3 +13,11 @@ class ProviderGroq(ProviderOpenAIOfficial):
|
||||
) -> None:
|
||||
super().__init__(provider_config, provider_settings)
|
||||
self.reasoning_key = "reasoning"
|
||||
|
||||
def _finally_convert_payload(self, payloads: dict) -> None:
|
||||
"""Groq rejects assistant history items that include reasoning_content."""
|
||||
super()._finally_convert_payload(payloads)
|
||||
for message in payloads.get("messages", []):
|
||||
if message.get("role") == "assistant":
|
||||
message.pop("reasoning_content", None)
|
||||
message.pop("reasoning", None)
|
||||
|
||||
@@ -154,6 +154,14 @@ class ProviderMiniMaxTTSAPI(TTSProvider):
|
||||
audio_stream = self._call_tts_stream(text)
|
||||
audio = await self._audio_play(audio_stream)
|
||||
|
||||
# 检查音频数据是否为空
|
||||
if not audio or len(audio) == 0:
|
||||
raise Exception(
|
||||
"MiniMax TTS API returned empty audio data. "
|
||||
"Please verify your configuration, especially the 'group_id' parameter. "
|
||||
"You can find your group_id in Account Management -> Basic Information on the MiniMax platform."
|
||||
)
|
||||
|
||||
# 结果保存至文件
|
||||
with open(path, "wb") as file:
|
||||
file.write(audio)
|
||||
@@ -161,4 +169,4 @@ class ProviderMiniMaxTTSAPI(TTSProvider):
|
||||
return path
|
||||
|
||||
except aiohttp.ClientError as e:
|
||||
raise e
|
||||
raise Exception(f"MiniMax TTS API request failed: {e!s}")
|
||||
|
||||
@@ -311,7 +311,7 @@ class ProviderOpenAIOfficial(Provider):
|
||||
state.handle_chunk(chunk)
|
||||
except Exception as e:
|
||||
logger.warning("Saving chunk state error: " + str(e))
|
||||
if len(chunk.choices) == 0:
|
||||
if not chunk.choices:
|
||||
continue
|
||||
delta = chunk.choices[0].delta
|
||||
# logger.debug(f"chunk delta: {delta}")
|
||||
@@ -322,7 +322,7 @@ class ProviderOpenAIOfficial(Provider):
|
||||
if reasoning:
|
||||
llm_response.reasoning_content = reasoning
|
||||
_y = True
|
||||
if delta.content:
|
||||
if delta and 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(
|
||||
@@ -345,7 +345,7 @@ class ProviderOpenAIOfficial(Provider):
|
||||
) -> str:
|
||||
"""Extract reasoning content from OpenAI ChatCompletion if available."""
|
||||
reasoning_text = ""
|
||||
if len(completion.choices) == 0:
|
||||
if not completion.choices:
|
||||
return reasoning_text
|
||||
if isinstance(completion, ChatCompletion):
|
||||
choice = completion.choices[0]
|
||||
@@ -468,7 +468,7 @@ class ProviderOpenAIOfficial(Provider):
|
||||
"""Parse OpenAI ChatCompletion into LLMResponse"""
|
||||
llm_response = LLMResponse("assistant")
|
||||
|
||||
if len(completion.choices) == 0:
|
||||
if not completion.choices:
|
||||
raise Exception("API 返回的 completion 为空。")
|
||||
choice = completion.choices[0]
|
||||
|
||||
@@ -629,7 +629,8 @@ class ProviderOpenAIOfficial(Provider):
|
||||
# 最后一次不等待
|
||||
if retry_cnt < max_retries - 1:
|
||||
await asyncio.sleep(1)
|
||||
available_api_keys.remove(chosen_key)
|
||||
if chosen_key in available_api_keys:
|
||||
available_api_keys.remove(chosen_key)
|
||||
if len(available_api_keys) > 0:
|
||||
chosen_key = random.choice(available_api_keys)
|
||||
return (
|
||||
|
||||
@@ -16,4 +16,7 @@ class ProviderOpenRouter(ProviderOpenAIOfficial):
|
||||
self.client._custom_headers["HTTP-Referer"] = ( # type: ignore
|
||||
"https://github.com/AstrBotDevs/AstrBot"
|
||||
)
|
||||
self.client._custom_headers["X-TITLE"] = "AstrBot" # type: ignore
|
||||
self.client._custom_headers["X-OpenRouter-Title"] = "AstrBot" # type: ignore
|
||||
self.client._custom_headers["X-OpenRouter-Categories"] = (
|
||||
"general-chat,personal-agent" # type: ignore
|
||||
)
|
||||
|
||||
@@ -43,7 +43,7 @@ class ProviderSenseVoiceSTTSelfHost(STTProvider):
|
||||
logger.info("下载或者加载 SenseVoice 模型中,这可能需要一些时间 ...")
|
||||
|
||||
# 将模型加载放到线程池中执行
|
||||
self.model = await asyncio.get_event_loop().run_in_executor(
|
||||
self.model = await asyncio.get_running_loop().run_in_executor(
|
||||
None,
|
||||
lambda: SenseVoiceSmall(self.model_name, quantize=True, batch_size=16),
|
||||
)
|
||||
@@ -88,7 +88,7 @@ class ProviderSenseVoiceSTTSelfHost(STTProvider):
|
||||
audio_url = output_path
|
||||
|
||||
# 使用 run_in_executor 来调用模型进行识别
|
||||
loop = asyncio.get_event_loop()
|
||||
loop = asyncio.get_running_loop()
|
||||
res = await loop.run_in_executor(
|
||||
None, # 使用默认的线程池
|
||||
lambda: cast(SenseVoiceSmall, self.model)(
|
||||
|
||||
@@ -31,7 +31,7 @@ class ProviderOpenAIWhisperSelfHost(STTProvider):
|
||||
self.model = None
|
||||
|
||||
async def initialize(self) -> None:
|
||||
loop = asyncio.get_event_loop()
|
||||
loop = asyncio.get_running_loop()
|
||||
logger.info("下载或者加载 Whisper 模型中,这可能需要一些时间 ...")
|
||||
self.model = await loop.run_in_executor(
|
||||
None,
|
||||
@@ -50,7 +50,7 @@ class ProviderOpenAIWhisperSelfHost(STTProvider):
|
||||
return False
|
||||
|
||||
async def get_text(self, audio_url: str) -> str:
|
||||
loop = asyncio.get_event_loop()
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
is_tencent = False
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import shutil
|
||||
import tempfile
|
||||
import zipfile
|
||||
@@ -26,6 +27,13 @@ _SANDBOX_SKILLS_CACHE_VERSION = 1
|
||||
_SKILL_NAME_RE = re.compile(r"^[A-Za-z0-9._-]+$")
|
||||
|
||||
|
||||
def _is_ignored_zip_entry(name: str) -> bool:
|
||||
parts = PurePosixPath(name).parts
|
||||
if not parts:
|
||||
return True
|
||||
return parts[0] == "__MACOSX"
|
||||
|
||||
|
||||
@dataclass
|
||||
class SkillInfo:
|
||||
name: str
|
||||
@@ -72,7 +80,59 @@ def _parse_frontmatter_description(text: str) -> str:
|
||||
|
||||
# Regex for sanitizing paths used in prompt examples — only allow
|
||||
# safe path characters to prevent prompt injection via crafted skill paths.
|
||||
_SAFE_PATH_RE = re.compile(r"[^A-Za-z0-9_./ -]")
|
||||
_SAFE_PATH_RE = re.compile(r"[^\w./ ,()'\-]", re.UNICODE)
|
||||
_WINDOWS_DRIVE_PATH_RE = re.compile(r"^[A-Za-z]:(?:/|\\)")
|
||||
_WINDOWS_UNC_PATH_RE = re.compile(r"^(//|\\\\)[^/\\]+[/\\][^/\\]+")
|
||||
_CONTROL_CHARS_RE = re.compile(r"[\x00-\x1F\x7F]")
|
||||
|
||||
|
||||
def _is_windows_prompt_path(path: str) -> bool:
|
||||
if os.name != "nt":
|
||||
return False
|
||||
return bool(_WINDOWS_DRIVE_PATH_RE.match(path) or _WINDOWS_UNC_PATH_RE.match(path))
|
||||
|
||||
|
||||
def _sanitize_prompt_path_for_prompt(path: str) -> str:
|
||||
if not path:
|
||||
return ""
|
||||
|
||||
if _WINDOWS_DRIVE_PATH_RE.match(path) or _WINDOWS_UNC_PATH_RE.match(path):
|
||||
path = path.replace("\\", "/")
|
||||
|
||||
drive_prefix = ""
|
||||
if _WINDOWS_DRIVE_PATH_RE.match(path):
|
||||
drive_prefix = path[:2]
|
||||
path = path[2:]
|
||||
|
||||
path = path.replace("`", "")
|
||||
path = _CONTROL_CHARS_RE.sub("", path)
|
||||
sanitized = _SAFE_PATH_RE.sub("", path)
|
||||
return f"{drive_prefix}{sanitized}"
|
||||
|
||||
|
||||
def _sanitize_prompt_description(description: str) -> str:
|
||||
description = description.replace("`", "")
|
||||
description = _CONTROL_CHARS_RE.sub(" ", description)
|
||||
description = " ".join(description.split())
|
||||
return description
|
||||
|
||||
|
||||
def _sanitize_skill_display_name(name: str) -> str:
|
||||
if _SKILL_NAME_RE.fullmatch(name):
|
||||
return name
|
||||
return "<invalid_skill_name>"
|
||||
|
||||
|
||||
def _build_skill_read_command_example(path: str) -> str:
|
||||
if path == "<skills_root>/<skill_name>/SKILL.md":
|
||||
return f"cat {path}"
|
||||
if _is_windows_prompt_path(path):
|
||||
command = "type"
|
||||
path_arg = f'"{path}"'
|
||||
else:
|
||||
command = "cat"
|
||||
path_arg = shlex.quote(path)
|
||||
return f"{command} {path_arg}"
|
||||
|
||||
|
||||
def build_skills_prompt(skills: list[SkillInfo]) -> str:
|
||||
@@ -85,16 +145,37 @@ def build_skills_prompt(skills: list[SkillInfo]) -> str:
|
||||
skills_lines: list[str] = []
|
||||
example_path = ""
|
||||
for skill in skills:
|
||||
display_name = _sanitize_skill_display_name(skill.name)
|
||||
|
||||
description = skill.description or "No description"
|
||||
if skill.source_type == "sandbox_only":
|
||||
description = _sanitize_prompt_description(description)
|
||||
if not description:
|
||||
description = "Read SKILL.md for details."
|
||||
|
||||
if skill.source_type == "sandbox_only":
|
||||
rendered_path = (
|
||||
f"{str(SANDBOX_WORKSPACE_ROOT)}/{str(SANDBOX_SKILLS_ROOT)}/"
|
||||
f"{display_name}/SKILL.md"
|
||||
)
|
||||
else:
|
||||
rendered_path = _sanitize_prompt_path_for_prompt(skill.path)
|
||||
if not rendered_path:
|
||||
rendered_path = "<skills_root>/<skill_name>/SKILL.md"
|
||||
|
||||
skills_lines.append(
|
||||
f"- **{skill.name}**: {description}\n File: `{skill.path}`"
|
||||
f"- **{display_name}**: {description}\n File: `{rendered_path}`"
|
||||
)
|
||||
if not example_path:
|
||||
example_path = skill.path
|
||||
example_path = rendered_path
|
||||
skills_block = "\n".join(skills_lines)
|
||||
# Sanitize example_path — it may originate from sandbox cache (untrusted)
|
||||
example_path = _SAFE_PATH_RE.sub("", example_path) if example_path else ""
|
||||
example_path = example_path or "<skills_root>/<skill_name>/SKILL.md"
|
||||
if example_path == "<skills_root>/<skill_name>/SKILL.md":
|
||||
example_path = "<skills_root>/<skill_name>/SKILL.md"
|
||||
else:
|
||||
example_path = _sanitize_prompt_path_for_prompt(example_path)
|
||||
example_path = example_path or "<skills_root>/<skill_name>/SKILL.md"
|
||||
example_command = _build_skill_read_command_example(example_path)
|
||||
|
||||
return (
|
||||
"## Skills\n\n"
|
||||
@@ -112,8 +193,9 @@ def build_skills_prompt(skills: list[SkillInfo]) -> str:
|
||||
"*Never silently skip a matching skill* — either use it or briefly "
|
||||
"explain why you chose not to.\n"
|
||||
"3. **Mandatory grounding** — Before executing any skill you MUST "
|
||||
"first read its `SKILL.md` by running a shell command with the "
|
||||
f"**absolute path** shown above (e.g. `cat {example_path}`). "
|
||||
"first read its `SKILL.md` by running a shell command compatible "
|
||||
"with the current runtime shell and using the **absolute path** "
|
||||
f"shown above (e.g. `{example_command}`). "
|
||||
"Never rely on memory or assumptions about a skill's content.\n"
|
||||
"4. **Progressive disclosure** — Load only what is directly "
|
||||
"referenced from `SKILL.md`:\n"
|
||||
@@ -401,7 +483,11 @@ class SkillManager:
|
||||
raise ValueError("Uploaded file is not a valid zip archive.")
|
||||
|
||||
with zipfile.ZipFile(zip_path) as zf:
|
||||
names = [name.replace("\\", "/") for name in zf.namelist()]
|
||||
names = [
|
||||
name
|
||||
for name in (entry.replace("\\", "/") for entry in zf.namelist())
|
||||
if name and not _is_ignored_zip_entry(name)
|
||||
]
|
||||
file_names = [name for name in names if name and not name.endswith("/")]
|
||||
if not file_names:
|
||||
raise ValueError("Zip archive is empty.")
|
||||
@@ -436,7 +522,11 @@ class SkillManager:
|
||||
raise ValueError("SKILL.md not found in the skill folder.")
|
||||
|
||||
with tempfile.TemporaryDirectory(dir=get_astrbot_temp_path()) as tmp_dir:
|
||||
zf.extractall(tmp_dir)
|
||||
for member in zf.infolist():
|
||||
member_name = member.filename.replace("\\", "/")
|
||||
if not member_name or _is_ignored_zip_entry(member_name):
|
||||
continue
|
||||
zf.extract(member, tmp_dir)
|
||||
src_dir = Path(tmp_dir) / skill_name
|
||||
if not src_dir.exists():
|
||||
raise ValueError("Skill folder not found after extraction.")
|
||||
|
||||
@@ -15,4 +15,4 @@ class RegexFilter(HandlerFilter):
|
||||
self.regex = re.compile(regex)
|
||||
|
||||
def filter(self, event: AstrMessageEvent, cfg: AstrBotConfig) -> bool:
|
||||
return bool(self.regex.match(event.get_message_str().strip()))
|
||||
return bool(self.regex.search(event.get_message_str().strip()))
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
"""插件的重载、启停、安装、卸载等操作。"""
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import functools
|
||||
import inspect
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import traceback
|
||||
from types import ModuleType
|
||||
|
||||
@@ -14,7 +16,12 @@ 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 import (
|
||||
DependencyConflictError,
|
||||
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
|
||||
@@ -24,9 +31,13 @@ from astrbot.core.utils.astrbot_path import (
|
||||
get_astrbot_config_path,
|
||||
get_astrbot_path,
|
||||
get_astrbot_plugin_path,
|
||||
get_astrbot_temp_path,
|
||||
)
|
||||
from astrbot.core.utils.io import remove_dir
|
||||
from astrbot.core.utils.metrics import Metric
|
||||
from astrbot.core.utils.requirements_utils import (
|
||||
plan_missing_requirements_install,
|
||||
)
|
||||
|
||||
from . import StarMetadata
|
||||
from .command_management import sync_command_configs
|
||||
@@ -48,6 +59,97 @@ class PluginVersionIncompatibleError(Exception):
|
||||
"""Raised when plugin astrbot_version is incompatible with current AstrBot."""
|
||||
|
||||
|
||||
class PluginDependencyInstallError(Exception):
|
||||
"""Raised when plugin dependency installation fails."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
plugin_label: str,
|
||||
requirements_path: str,
|
||||
error: Exception,
|
||||
) -> None:
|
||||
message = f"插件 {plugin_label} 依赖安装失败: {error!s}"
|
||||
super().__init__(message)
|
||||
self.plugin_label = plugin_label
|
||||
self.requirements_path = requirements_path
|
||||
self.error = error
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _temporary_filtered_requirements_file(
|
||||
*,
|
||||
install_lines: tuple[str, ...],
|
||||
):
|
||||
filtered_requirements_path: str | None = None
|
||||
temp_dir = get_astrbot_temp_path()
|
||||
|
||||
try:
|
||||
os.makedirs(temp_dir, exist_ok=True)
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w",
|
||||
suffix="_plugin_requirements.txt",
|
||||
delete=False,
|
||||
dir=temp_dir,
|
||||
encoding="utf-8",
|
||||
) as filtered_requirements_file:
|
||||
filtered_requirements_file.write("\n".join(install_lines) + "\n")
|
||||
filtered_requirements_path = filtered_requirements_file.name
|
||||
|
||||
yield filtered_requirements_path
|
||||
finally:
|
||||
if filtered_requirements_path and os.path.exists(filtered_requirements_path):
|
||||
try:
|
||||
os.remove(filtered_requirements_path)
|
||||
except OSError as exc:
|
||||
logger.warning(
|
||||
"删除临时插件依赖文件失败:%s(路径:%s)",
|
||||
exc,
|
||||
filtered_requirements_path,
|
||||
)
|
||||
|
||||
|
||||
async def _install_requirements_with_precheck(
|
||||
*,
|
||||
plugin_label: str,
|
||||
requirements_path: str,
|
||||
) -> None:
|
||||
install_plan = plan_missing_requirements_install(requirements_path)
|
||||
|
||||
if install_plan is None:
|
||||
logger.info(
|
||||
f"正在安装插件 {plugin_label} 的依赖库(缺失依赖预检查不可裁剪,回退到完整安装): "
|
||||
f"{requirements_path}"
|
||||
)
|
||||
await pip_installer.install(requirements_path=requirements_path)
|
||||
return
|
||||
|
||||
if not install_plan.missing_names:
|
||||
logger.info(f"插件 {plugin_label} 的依赖已满足,跳过安装。")
|
||||
return
|
||||
|
||||
if not install_plan.install_lines:
|
||||
fallback_reason = install_plan.fallback_reason or "unknown reason"
|
||||
logger.info(
|
||||
"检测到插件 %s 缺失依赖,但无法安全裁剪 requirements,回退到完整安装: %s (%s)",
|
||||
plugin_label,
|
||||
requirements_path,
|
||||
fallback_reason,
|
||||
)
|
||||
await pip_installer.install(requirements_path=requirements_path)
|
||||
return
|
||||
|
||||
logger.info(
|
||||
f"检测到插件 {plugin_label} 缺失依赖,正在按 requirements.txt 安装: "
|
||||
f"{requirements_path} -> {sorted(install_plan.missing_names)}"
|
||||
)
|
||||
|
||||
with _temporary_filtered_requirements_file(
|
||||
install_lines=install_plan.install_lines,
|
||||
) as filtered_requirements_path:
|
||||
await pip_installer.install(requirements_path=filtered_requirements_path)
|
||||
|
||||
|
||||
class PluginManager:
|
||||
def __init__(self, context: Context, config: AstrBotConfig) -> None:
|
||||
from .star_tools import StarTools
|
||||
@@ -198,15 +300,37 @@ class PluginManager:
|
||||
to_update.append(p.root_dir_name)
|
||||
for p in to_update:
|
||||
plugin_path = os.path.join(plugin_dir, p)
|
||||
if os.path.exists(os.path.join(plugin_path, "requirements.txt")):
|
||||
pth = os.path.join(plugin_path, "requirements.txt")
|
||||
logger.info(f"正在安装插件 {p} 所需的依赖库: {pth}")
|
||||
try:
|
||||
await pip_installer.install(requirements_path=pth)
|
||||
except Exception as e:
|
||||
logger.error(f"更新插件 {p} 的依赖失败。Code: {e!s}")
|
||||
await self._ensure_plugin_requirements(plugin_path, p)
|
||||
return True
|
||||
|
||||
async def _ensure_plugin_requirements(
|
||||
self,
|
||||
plugin_dir_path: str,
|
||||
plugin_label: str,
|
||||
) -> None:
|
||||
requirements_path = os.path.join(plugin_dir_path, "requirements.txt")
|
||||
if not os.path.exists(requirements_path):
|
||||
return
|
||||
|
||||
try:
|
||||
await _install_requirements_with_precheck(
|
||||
plugin_label=plugin_label,
|
||||
requirements_path=requirements_path,
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except DependencyConflictError as e:
|
||||
logger.error(f"插件 {plugin_label} 依赖冲突: {e!s}")
|
||||
raise
|
||||
except Exception as e:
|
||||
dependency_error = PluginDependencyInstallError(
|
||||
plugin_label=plugin_label,
|
||||
requirements_path=requirements_path,
|
||||
error=e,
|
||||
)
|
||||
logger.exception(str(dependency_error))
|
||||
raise dependency_error from e
|
||||
|
||||
async def _import_plugin_with_dependency_recovery(
|
||||
self,
|
||||
path: str,
|
||||
@@ -422,7 +546,7 @@ class PluginManager:
|
||||
root_dir_name: str,
|
||||
plugin_dir_path: str,
|
||||
reserved: bool,
|
||||
error: Exception | str,
|
||||
error: BaseException | str,
|
||||
error_trace: str,
|
||||
) -> dict:
|
||||
record: dict = {
|
||||
@@ -495,6 +619,9 @@ class PluginManager:
|
||||
|
||||
self._cleanup_plugin_state(dir_name)
|
||||
|
||||
plugin_path = os.path.join(self.plugin_store_path, dir_name)
|
||||
await self._ensure_plugin_requirements(plugin_path, dir_name)
|
||||
|
||||
success, error = await self.load(specified_dir_name=dir_name)
|
||||
if success:
|
||||
self.failed_plugin_dict.pop(dir_name, None)
|
||||
@@ -1078,6 +1205,10 @@ class PluginManager:
|
||||
|
||||
# reload the plugin
|
||||
dir_name = os.path.basename(plugin_path)
|
||||
await self._ensure_plugin_requirements(
|
||||
plugin_path,
|
||||
dir_name,
|
||||
)
|
||||
success, error_message = await self.load(
|
||||
specified_dir_name=dir_name,
|
||||
ignore_version_check=ignore_version_check,
|
||||
@@ -1317,6 +1448,12 @@ class PluginManager:
|
||||
raise Exception("该插件是 AstrBot 保留插件,无法更新。")
|
||||
|
||||
await self.updator.update(plugin, proxy=proxy)
|
||||
if plugin.root_dir_name:
|
||||
plugin_dir_path = os.path.join(self.plugin_store_path, plugin.root_dir_name)
|
||||
await self._ensure_plugin_requirements(
|
||||
plugin_dir_path,
|
||||
plugin_name,
|
||||
)
|
||||
await self.reload(plugin_name)
|
||||
|
||||
async def turn_off_plugin(self, plugin_name: str) -> None:
|
||||
@@ -1374,10 +1511,23 @@ class PluginManager:
|
||||
return
|
||||
|
||||
if "__del__" in star_metadata.star_cls_type.__dict__:
|
||||
asyncio.get_event_loop().run_in_executor(
|
||||
loop = asyncio.get_running_loop()
|
||||
future = loop.run_in_executor(
|
||||
None,
|
||||
star_metadata.star_cls.__del__,
|
||||
)
|
||||
|
||||
def _log_del_exception(fut: asyncio.Future) -> None:
|
||||
if fut.cancelled():
|
||||
return
|
||||
if (exc := fut.exception()) is not None:
|
||||
logger.error(
|
||||
"插件 %s 在 __del__ 中抛出了异常:%r",
|
||||
star_metadata.name,
|
||||
exc,
|
||||
)
|
||||
|
||||
future.add_done_callback(_log_del_exception)
|
||||
elif "terminate" in star_metadata.star_cls_type.__dict__:
|
||||
await star_metadata.star_cls.terminate()
|
||||
|
||||
@@ -1475,6 +1625,7 @@ class PluginManager:
|
||||
os.remove(zip_file_path)
|
||||
except BaseException as e:
|
||||
logger.warning(f"删除插件压缩包失败: {e!s}")
|
||||
await self._ensure_plugin_requirements(desti_dir, dir_name)
|
||||
# await self.reload()
|
||||
success, error_message = await self.load(
|
||||
specified_dir_name=dir_name,
|
||||
|
||||
@@ -30,7 +30,7 @@ class CreateActiveCronTool(FunctionTool[AstrAgentContext]):
|
||||
"properties": {
|
||||
"cron_expression": {
|
||||
"type": "string",
|
||||
"description": "Cron expression defining recurring schedule (e.g., '0 8 * * *').",
|
||||
"description": "Cron expression defining recurring schedule (e.g., '0 8 * * *' or '0 23 * * mon-fri'). Prefer named weekdays like 'mon-fri' or 'sat,sun' instead of numeric day-of-week ranges such as '1-5' to avoid ambiguity across cron implementations.",
|
||||
},
|
||||
"run_at": {
|
||||
"type": "string",
|
||||
|
||||
@@ -25,12 +25,22 @@ class UmopConfigRouter:
|
||||
)
|
||||
self.umop_to_conf_id = sp_data
|
||||
|
||||
@staticmethod
|
||||
def _split_umo(umo: str) -> tuple[str, str, str] | None:
|
||||
"""将 UMO 拆分为 3 个部分,同时保留 session_id 中的 ':'"""
|
||||
if not isinstance(umo, str):
|
||||
return None
|
||||
parts = umo.split(":", 2)
|
||||
if len(parts) != 3:
|
||||
return None
|
||||
return parts[0], parts[1], parts[2]
|
||||
|
||||
def _is_umo_match(self, p1: str, p2: str) -> bool:
|
||||
"""判断 p2 umo 是否逻辑包含于 p1 umo"""
|
||||
p1_ls = p1.split(":")
|
||||
p2_ls = p2.split(":")
|
||||
p1_ls = self._split_umo(p1)
|
||||
p2_ls = self._split_umo(p2)
|
||||
|
||||
if len(p1_ls) != 3 or len(p2_ls) != 3:
|
||||
if p1_ls is None or p2_ls is None:
|
||||
return False # 非法格式
|
||||
|
||||
return all(p == "" or fnmatch.fnmatchcase(t, p) for p, t in zip(p1_ls, p2_ls))
|
||||
@@ -62,7 +72,7 @@ class UmopConfigRouter:
|
||||
|
||||
"""
|
||||
for part in new_routing:
|
||||
if not isinstance(part, str) or len(part.split(":")) != 3:
|
||||
if self._split_umo(part) is None:
|
||||
raise ValueError(
|
||||
"umop keys must be strings in the format [platform_id]:[message_type]:[session_id], with optional wildcards * or empty for all",
|
||||
)
|
||||
@@ -81,7 +91,7 @@ class UmopConfigRouter:
|
||||
ValueError: 如果 umo 格式不正确
|
||||
|
||||
"""
|
||||
if not isinstance(umo, str) or len(umo.split(":")) != 3:
|
||||
if self._split_umo(umo) is None:
|
||||
raise ValueError(
|
||||
"umop must be a string in the format [platform_id]:[message_type]:[session_id], with optional wildcards * or empty for all",
|
||||
)
|
||||
@@ -99,7 +109,7 @@ class UmopConfigRouter:
|
||||
ValueError: 当 umo 格式不正确时抛出
|
||||
"""
|
||||
|
||||
if not isinstance(umo, str) or len(umo.split(":")) != 3:
|
||||
if self._split_umo(umo) is None:
|
||||
raise ValueError(
|
||||
"umop must be a string in the format [platform_id]:[message_type]:[session_id], with optional wildcards * or empty for all",
|
||||
)
|
||||
|
||||
@@ -149,7 +149,9 @@ class AstrBotUpdator(RepoZipUpdator):
|
||||
file_url = None
|
||||
|
||||
if os.environ.get("ASTRBOT_CLI") or os.environ.get("ASTRBOT_LAUNCHER"):
|
||||
raise Exception("不支持更新此方式启动的AstrBot") # 避免版本管理混乱
|
||||
raise Exception(
|
||||
"Error: You are running AstrBot via CLI, please use `pip` or `uv tool upgrade` to update AstrBot."
|
||||
) # 避免版本管理混乱
|
||||
|
||||
if latest:
|
||||
latest_version = update_data[0]["tag_name"]
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import contextlib
|
||||
import functools
|
||||
import importlib.metadata as importlib_metadata
|
||||
import logging
|
||||
import os
|
||||
from collections.abc import Iterator
|
||||
|
||||
from packaging.requirements import Requirement
|
||||
|
||||
from astrbot.core.utils.requirements_utils import (
|
||||
canonicalize_distribution_name,
|
||||
collect_installed_distribution_versions,
|
||||
get_requirement_check_paths,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("astrbot")
|
||||
|
||||
|
||||
def _resolve_core_dist_name(core_dist_name: str | None) -> str | None:
|
||||
if core_dist_name:
|
||||
try:
|
||||
importlib_metadata.distribution(core_dist_name)
|
||||
return core_dist_name
|
||||
except importlib_metadata.PackageNotFoundError:
|
||||
return None
|
||||
|
||||
try:
|
||||
importlib_metadata.distribution("AstrBot")
|
||||
return "AstrBot"
|
||||
except importlib_metadata.PackageNotFoundError:
|
||||
pass
|
||||
|
||||
if not __package__:
|
||||
return None
|
||||
|
||||
top_pkg = __package__.split(".")[0]
|
||||
for dist in importlib_metadata.distributions():
|
||||
try:
|
||||
top_level = dist.read_text("top_level.txt") or ""
|
||||
except Exception:
|
||||
continue
|
||||
if top_pkg in top_level.splitlines():
|
||||
if "Name" in dist.metadata:
|
||||
return dist.metadata["Name"]
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@functools.cache
|
||||
def _get_core_constraints(core_dist_name: str | None) -> tuple[str, ...]:
|
||||
try:
|
||||
resolved_core_dist_name = _resolve_core_dist_name(core_dist_name)
|
||||
except Exception as exc:
|
||||
logger.warning("解析核心分发名称失败: %s", exc)
|
||||
return ()
|
||||
|
||||
if not resolved_core_dist_name:
|
||||
return ()
|
||||
|
||||
try:
|
||||
dist = importlib_metadata.distribution(resolved_core_dist_name)
|
||||
except importlib_metadata.PackageNotFoundError:
|
||||
return ()
|
||||
except Exception as exc:
|
||||
logger.warning("读取核心分发元数据失败 (%s): %s", resolved_core_dist_name, exc)
|
||||
return ()
|
||||
|
||||
if not dist or not dist.requires:
|
||||
return ()
|
||||
|
||||
installed = collect_installed_distribution_versions(get_requirement_check_paths())
|
||||
if not installed:
|
||||
return ()
|
||||
|
||||
constraints: list[str] = []
|
||||
for req_str in dist.requires:
|
||||
try:
|
||||
req = Requirement(req_str)
|
||||
if req.marker and not req.marker.evaluate():
|
||||
continue
|
||||
name = canonicalize_distribution_name(req.name)
|
||||
if name in installed:
|
||||
constraints.append(f"{name}=={installed[name]}")
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
return tuple(constraints)
|
||||
|
||||
|
||||
class CoreConstraintsProvider:
|
||||
def __init__(self, core_dist_name: str | None) -> None:
|
||||
self._core_dist_name = core_dist_name
|
||||
|
||||
@contextlib.contextmanager
|
||||
def constraints_file(self) -> Iterator[str | None]:
|
||||
constraints = _get_core_constraints(self._core_dist_name)
|
||||
if not constraints:
|
||||
yield None
|
||||
return
|
||||
|
||||
path: str | None = None
|
||||
try:
|
||||
import tempfile
|
||||
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w", suffix="_constraints.txt", delete=False, encoding="utf-8"
|
||||
) as f:
|
||||
f.write("\n".join(constraints))
|
||||
path = f.name
|
||||
logger.info("已启用核心依赖版本保护 (%d 个约束)", len(constraints))
|
||||
except Exception as exc:
|
||||
logger.warning("创建临时约束文件失败: %s", exc)
|
||||
yield None
|
||||
return
|
||||
|
||||
try:
|
||||
yield path
|
||||
finally:
|
||||
if path and os.path.exists(path):
|
||||
with contextlib.suppress(Exception):
|
||||
os.remove(path)
|
||||
@@ -14,7 +14,7 @@ import certifi
|
||||
import psutil
|
||||
from PIL import Image
|
||||
|
||||
from .astrbot_path import get_astrbot_data_path, get_astrbot_temp_path
|
||||
from .astrbot_path import get_astrbot_data_path, get_astrbot_path, get_astrbot_temp_path
|
||||
|
||||
logger = logging.getLogger("astrbot")
|
||||
|
||||
@@ -219,7 +219,13 @@ def get_local_ip_addresses():
|
||||
|
||||
|
||||
async def get_dashboard_version():
|
||||
# First check user data directory (manually updated / downloaded dashboard).
|
||||
dist_dir = os.path.join(get_astrbot_data_path(), "dist")
|
||||
if not os.path.exists(dist_dir):
|
||||
# Fall back to the dist bundled inside the installed wheel.
|
||||
_bundled = Path(get_astrbot_path()) / "astrbot" / "dashboard" / "dist"
|
||||
if _bundled.exists():
|
||||
dist_dir = str(_bundled)
|
||||
if os.path.exists(dist_dir):
|
||||
version_file = os.path.join(dist_dir, "assets", "version")
|
||||
if os.path.exists(version_file):
|
||||
|
||||
@@ -7,21 +7,71 @@ import io
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import sys
|
||||
import threading
|
||||
from collections import deque
|
||||
from dataclasses import dataclass
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_site_packages_path
|
||||
from astrbot.core.utils.core_constraints import CoreConstraintsProvider
|
||||
from astrbot.core.utils.requirements_utils import (
|
||||
canonicalize_distribution_name as _canonicalize_distribution_name,
|
||||
)
|
||||
from astrbot.core.utils.requirements_utils import (
|
||||
extract_requirement_name,
|
||||
extract_requirement_names,
|
||||
parse_package_install_input,
|
||||
)
|
||||
from astrbot.core.utils.runtime_env import is_packaged_desktop_runtime
|
||||
|
||||
logger = logging.getLogger("astrbot")
|
||||
|
||||
_DISTLIB_FINDER_PATCH_ATTEMPTED = False
|
||||
_SITE_PACKAGES_IMPORT_LOCK = threading.RLock()
|
||||
_PIP_FAILURE_PATTERNS = {
|
||||
"error_prefix": re.compile(r"^\s*error:", re.IGNORECASE),
|
||||
"user_requested": re.compile(r"\bthe user requested\b", re.IGNORECASE),
|
||||
"resolution_impossible": re.compile(r"\bresolutionimpossible\b", re.IGNORECASE),
|
||||
"cannot_install": re.compile(r"\bcannot install\b", re.IGNORECASE),
|
||||
"conflict": re.compile(r"\bconflict(?:ing|s)?\b", re.IGNORECASE),
|
||||
"constraint": re.compile(r"\(constraint\)", re.IGNORECASE),
|
||||
"dependency_detail": re.compile(r"\bdepends on\b", re.IGNORECASE),
|
||||
}
|
||||
_SENSITIVE_PIP_VALUE_KEYS = frozenset(
|
||||
{"password", "passwd", "pass", "api_token", "token", "auth_token"}
|
||||
)
|
||||
_MAX_PIP_OUTPUT_LINES = 200
|
||||
|
||||
|
||||
def _canonicalize_distribution_name(name: str) -> str:
|
||||
return re.sub(r"[-_.]+", "-", name).strip("-").lower()
|
||||
class DependencyConflictError(Exception):
|
||||
"""Raised when pip encounters a dependency conflict."""
|
||||
|
||||
def __init__(
|
||||
self, message: str, errors: list[str], *, is_core_conflict: bool
|
||||
) -> None:
|
||||
super().__init__(message)
|
||||
self.errors = errors
|
||||
self.is_core_conflict = is_core_conflict
|
||||
|
||||
|
||||
class PipInstallError(Exception):
|
||||
"""Raised when pip install fails without a classified dependency conflict."""
|
||||
|
||||
def __init__(self, message: str, *, code: int) -> None:
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
|
||||
|
||||
@dataclass
|
||||
class PipConflictContext:
|
||||
relevant_lines: list[str]
|
||||
requested_lines: list[str]
|
||||
dependency_detail_lines: list[str]
|
||||
constraint_lines: list[str]
|
||||
has_strong_conflict_signal: bool
|
||||
has_contextual_conflict_signal: bool
|
||||
|
||||
|
||||
def _get_pip_main():
|
||||
@@ -41,11 +91,12 @@ def _get_pip_main():
|
||||
return pip_main
|
||||
|
||||
|
||||
def _run_pip_main_with_output(pip_main, args: list[str]) -> tuple[int, str]:
|
||||
stream = io.StringIO()
|
||||
with contextlib.redirect_stdout(stream), contextlib.redirect_stderr(stream):
|
||||
result_code = pip_main(args)
|
||||
return result_code, stream.getvalue()
|
||||
def _prepend_sys_path(path: str) -> None:
|
||||
normalized_target = os.path.realpath(path)
|
||||
sys.path[:] = [
|
||||
item for item in sys.path if os.path.realpath(item) != normalized_target
|
||||
]
|
||||
sys.path.insert(0, normalized_target)
|
||||
|
||||
|
||||
def _cleanup_added_root_handlers(original_handlers: list[logging.Handler]) -> None:
|
||||
@@ -59,76 +110,258 @@ def _cleanup_added_root_handlers(original_handlers: list[logging.Handler]) -> No
|
||||
handler.close()
|
||||
|
||||
|
||||
def _prepend_sys_path(path: str) -> None:
|
||||
normalized_target = os.path.realpath(path)
|
||||
sys.path[:] = [
|
||||
item for item in sys.path if os.path.realpath(item) != normalized_target
|
||||
]
|
||||
sys.path.insert(0, normalized_target)
|
||||
def _get_trusted_host_for_index_url(index_url: str) -> str | None:
|
||||
parsed = urlparse(index_url if "://" in index_url else f"//{index_url}")
|
||||
host = parsed.hostname
|
||||
if host == "mirrors.aliyun.com":
|
||||
return host
|
||||
return None
|
||||
|
||||
|
||||
def _module_exists_in_site_packages(module_name: str, site_packages_path: str) -> bool:
|
||||
base_path = os.path.join(site_packages_path, *module_name.split("."))
|
||||
package_init = os.path.join(base_path, "__init__.py")
|
||||
module_file = f"{base_path}.py"
|
||||
return os.path.isfile(package_init) or os.path.isfile(module_file)
|
||||
def _normalize_sensitive_pip_key(raw_key: str) -> str:
|
||||
return raw_key.lstrip("-").replace("-", "_").lower()
|
||||
|
||||
|
||||
def _is_module_loaded_from_site_packages(
|
||||
module_name: str,
|
||||
site_packages_path: str,
|
||||
) -> bool:
|
||||
module = sys.modules.get(module_name)
|
||||
if module is None:
|
||||
try:
|
||||
module = importlib.import_module(module_name)
|
||||
except Exception:
|
||||
return False
|
||||
def _is_sensitive_pip_value_key(raw_key: str) -> bool:
|
||||
return _normalize_sensitive_pip_key(raw_key) in _SENSITIVE_PIP_VALUE_KEYS
|
||||
|
||||
module_file = getattr(module, "__file__", None)
|
||||
if not module_file:
|
||||
return False
|
||||
|
||||
module_path = os.path.realpath(module_file)
|
||||
site_packages_real = os.path.realpath(site_packages_path)
|
||||
try:
|
||||
return (
|
||||
os.path.commonpath([module_path, site_packages_real]) == site_packages_real
|
||||
def _redact_url_credentials(raw_value: str) -> str:
|
||||
"""Redact URL credentials and known inline secret values for safe logging."""
|
||||
parsed = urlparse(raw_value)
|
||||
if parsed.netloc and "@" in parsed.netloc:
|
||||
hostname = parsed.hostname or ""
|
||||
port = f":{parsed.port}" if parsed.port else ""
|
||||
return parsed._replace(netloc=f"<redacted>@{hostname}{port}").geturl()
|
||||
|
||||
if raw_value.startswith("--"):
|
||||
option, separator, _ = raw_value.partition("=")
|
||||
if separator and _is_sensitive_pip_value_key(option):
|
||||
return f"{option}=****"
|
||||
return raw_value
|
||||
|
||||
key, separator, _ = raw_value.partition("=")
|
||||
if separator and _is_sensitive_pip_value_key(key):
|
||||
return f"{key}=****"
|
||||
|
||||
return raw_value
|
||||
|
||||
|
||||
def _redact_pip_args_for_logging(args: list[str]) -> list[str]:
|
||||
redacted_args: list[str] = []
|
||||
redact_next_value = False
|
||||
|
||||
for arg in args:
|
||||
if redact_next_value:
|
||||
redacted_args.append("****")
|
||||
redact_next_value = False
|
||||
continue
|
||||
|
||||
if arg.startswith("--") and "=" in arg:
|
||||
option, value = arg.split("=", 1)
|
||||
if _is_sensitive_pip_value_key(option):
|
||||
redacted_args.append(f"{option}=****")
|
||||
else:
|
||||
redacted_args.append(f"{option}={_redact_url_credentials(value)}")
|
||||
continue
|
||||
|
||||
if arg.startswith("-i") and arg != "-i":
|
||||
redacted_args.append(f"-i{_redact_url_credentials(arg[2:])}")
|
||||
continue
|
||||
|
||||
if _is_sensitive_pip_value_key(arg):
|
||||
redacted_args.append(arg)
|
||||
redact_next_value = True
|
||||
continue
|
||||
|
||||
redacted_args.append(_redact_url_credentials(arg))
|
||||
|
||||
return redacted_args
|
||||
|
||||
|
||||
def _package_specs_override_index(package_specs: list[str]) -> bool:
|
||||
for index, spec in enumerate(package_specs):
|
||||
if spec == "--no-index":
|
||||
return True
|
||||
if spec in {"-i", "--index-url"}:
|
||||
if index + 1 < len(package_specs):
|
||||
return True
|
||||
continue
|
||||
if spec.startswith("--index-url="):
|
||||
return True
|
||||
if spec.startswith("-i") and spec != "-i":
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class _StreamingLogWriter(io.TextIOBase):
|
||||
def __init__(self, log_func, *, max_lines: int | None = None) -> None:
|
||||
self._log_func = log_func
|
||||
self._lines = deque(maxlen=max_lines or _MAX_PIP_OUTPUT_LINES)
|
||||
self._buffer = ""
|
||||
|
||||
def write(self, text: str) -> int:
|
||||
if not text:
|
||||
return 0
|
||||
|
||||
self._buffer += text.replace("\r\n", "\n").replace("\r", "\n")
|
||||
while "\n" in self._buffer:
|
||||
raw_line, self._buffer = self._buffer.split("\n", 1)
|
||||
line = raw_line.rstrip("\r\n")
|
||||
self._log_func(line)
|
||||
self._lines.append(line)
|
||||
return len(text)
|
||||
|
||||
def flush(self) -> None:
|
||||
line = self._buffer.rstrip("\r\n")
|
||||
if line:
|
||||
self._log_func(line)
|
||||
self._lines.append(line)
|
||||
self._buffer = ""
|
||||
|
||||
@property
|
||||
def lines(self) -> list[str]:
|
||||
return list(self._lines)
|
||||
|
||||
|
||||
def _run_pip_main_streaming(pip_main, args: list[str]) -> tuple[int, list[str]]:
|
||||
stream = _StreamingLogWriter(logger.info, max_lines=_MAX_PIP_OUTPUT_LINES)
|
||||
with (
|
||||
contextlib.redirect_stdout(stream),
|
||||
contextlib.redirect_stderr(stream),
|
||||
):
|
||||
result_code = pip_main(args)
|
||||
stream.flush()
|
||||
return result_code, stream.lines
|
||||
|
||||
|
||||
def _matches_pip_failure_pattern(line: str, *pattern_names: str) -> bool:
|
||||
names = pattern_names or tuple(_PIP_FAILURE_PATTERNS)
|
||||
return any(_PIP_FAILURE_PATTERNS[name].search(line) for name in names)
|
||||
|
||||
|
||||
def _normalize_conflict_detail_line(line: str) -> str:
|
||||
stripped = line.strip()
|
||||
if _matches_pip_failure_pattern(stripped, "user_requested"):
|
||||
return re.sub(
|
||||
r"^\s*The user requested\s+",
|
||||
"",
|
||||
stripped,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
except ValueError:
|
||||
return False
|
||||
return stripped
|
||||
|
||||
|
||||
def _extract_requirement_name(raw_requirement: str) -> str | None:
|
||||
line = raw_requirement.split("#", 1)[0].strip()
|
||||
if not line:
|
||||
return None
|
||||
if line.startswith(("-r", "--requirement", "-c", "--constraint")):
|
||||
return None
|
||||
if line.startswith("-"):
|
||||
def _build_pip_conflict_context(output_lines: list[str]) -> PipConflictContext | None:
|
||||
matched_indices = [
|
||||
index
|
||||
for index, line in enumerate(output_lines)
|
||||
if _matches_pip_failure_pattern(line)
|
||||
]
|
||||
if matched_indices:
|
||||
relevant_index_set: set[int] = set()
|
||||
for index in matched_indices:
|
||||
start = max(0, index - 1)
|
||||
end = min(len(output_lines), index + 2)
|
||||
relevant_index_set.update(range(start, end))
|
||||
relevant_output_lines = [
|
||||
line
|
||||
for index, line in enumerate(output_lines)
|
||||
if index in relevant_index_set
|
||||
]
|
||||
else:
|
||||
relevant_output_lines = output_lines[-5:]
|
||||
|
||||
if not relevant_output_lines:
|
||||
return None
|
||||
|
||||
egg_match = re.search(r"#egg=([A-Za-z0-9_.-]+)", raw_requirement)
|
||||
if egg_match:
|
||||
return _canonicalize_distribution_name(egg_match.group(1))
|
||||
dependency_detail_lines = [
|
||||
line.strip()
|
||||
for line in relevant_output_lines
|
||||
if _matches_pip_failure_pattern(line, "dependency_detail")
|
||||
]
|
||||
requested_lines = [
|
||||
line.strip()
|
||||
for line in relevant_output_lines
|
||||
if _matches_pip_failure_pattern(line, "user_requested")
|
||||
and not _matches_pip_failure_pattern(line, "constraint")
|
||||
]
|
||||
if not requested_lines:
|
||||
requested_lines = [
|
||||
line
|
||||
for line in dependency_detail_lines
|
||||
if not _matches_pip_failure_pattern(line, "constraint")
|
||||
]
|
||||
constraint_lines = [
|
||||
line.strip()
|
||||
for line in relevant_output_lines
|
||||
if _matches_pip_failure_pattern(line, "constraint")
|
||||
]
|
||||
|
||||
candidate = re.split(r"[<>=!~;\s\[]", line, maxsplit=1)[0].strip()
|
||||
if not candidate:
|
||||
has_strong_conflict_signal = any(
|
||||
_matches_pip_failure_pattern(
|
||||
line,
|
||||
"resolution_impossible",
|
||||
"cannot_install",
|
||||
)
|
||||
for line in relevant_output_lines
|
||||
)
|
||||
|
||||
has_contextual_conflict_signal = any(
|
||||
_matches_pip_failure_pattern(line, "conflict") for line in relevant_output_lines
|
||||
) and bool(dependency_detail_lines or requested_lines or constraint_lines)
|
||||
|
||||
return PipConflictContext(
|
||||
relevant_lines=relevant_output_lines,
|
||||
requested_lines=requested_lines,
|
||||
dependency_detail_lines=dependency_detail_lines,
|
||||
constraint_lines=constraint_lines,
|
||||
has_strong_conflict_signal=has_strong_conflict_signal,
|
||||
has_contextual_conflict_signal=has_contextual_conflict_signal,
|
||||
)
|
||||
|
||||
|
||||
def _classify_pip_failure(output_lines: list[str]) -> DependencyConflictError | None:
|
||||
context = _build_pip_conflict_context(output_lines)
|
||||
if context is None:
|
||||
return None
|
||||
return _canonicalize_distribution_name(candidate)
|
||||
|
||||
if (
|
||||
not context.has_strong_conflict_signal
|
||||
and not context.has_contextual_conflict_signal
|
||||
and not (context.requested_lines and context.constraint_lines)
|
||||
):
|
||||
return None
|
||||
|
||||
def _extract_requirement_names(requirements_path: str) -> set[str]:
|
||||
names: set[str] = set()
|
||||
try:
|
||||
with open(requirements_path, encoding="utf-8") as requirements_file:
|
||||
for line in requirements_file:
|
||||
requirement_name = _extract_requirement_name(line)
|
||||
if requirement_name:
|
||||
names.add(requirement_name)
|
||||
except Exception as exc:
|
||||
logger.warning("读取依赖文件失败,跳过冲突检测: %s", exc)
|
||||
return names
|
||||
is_core_conflict = bool(context.constraint_lines)
|
||||
|
||||
detail = ""
|
||||
if context.constraint_lines and context.requested_lines:
|
||||
detail = (
|
||||
" 冲突详情: "
|
||||
f"{_normalize_conflict_detail_line(context.requested_lines[0])} vs "
|
||||
f"{_normalize_conflict_detail_line(context.constraint_lines[0])}。"
|
||||
)
|
||||
elif len(context.dependency_detail_lines) >= 2:
|
||||
detail = (
|
||||
" 冲突详情: "
|
||||
f"{_normalize_conflict_detail_line(context.dependency_detail_lines[0])} vs "
|
||||
f"{_normalize_conflict_detail_line(context.dependency_detail_lines[1])}。"
|
||||
)
|
||||
|
||||
if is_core_conflict:
|
||||
message = (
|
||||
f"检测到核心依赖版本保护冲突。{detail}插件要求的依赖版本与 AstrBot 核心不兼容,"
|
||||
"为了系统稳定,已阻止该降级行为。请联系插件作者或调整 requirements.txt。"
|
||||
)
|
||||
else:
|
||||
message = f"检测到依赖冲突。{detail}"
|
||||
|
||||
return DependencyConflictError(
|
||||
message,
|
||||
context.relevant_lines,
|
||||
is_core_conflict=is_core_conflict,
|
||||
)
|
||||
|
||||
|
||||
def _extract_top_level_modules(
|
||||
@@ -155,7 +388,11 @@ def _collect_candidate_modules(
|
||||
by_name: dict[str, list[importlib_metadata.Distribution]] = {}
|
||||
try:
|
||||
for distribution in importlib_metadata.distributions(path=[site_packages_path]):
|
||||
distribution_name = distribution.metadata.get("Name")
|
||||
distribution_name = (
|
||||
distribution.metadata["Name"]
|
||||
if "Name" in distribution.metadata
|
||||
else None
|
||||
)
|
||||
if not distribution_name:
|
||||
continue
|
||||
canonical_name = _canonicalize_distribution_name(distribution_name)
|
||||
@@ -173,7 +410,7 @@ def _collect_candidate_modules(
|
||||
|
||||
for distribution in by_name.get(requirement_name, []):
|
||||
for dependency_line in distribution.requires or []:
|
||||
dependency_name = _extract_requirement_name(dependency_line)
|
||||
dependency_name = extract_requirement_name(dependency_line)
|
||||
if not dependency_name:
|
||||
continue
|
||||
if dependency_name in expanded_requirement_names:
|
||||
@@ -230,6 +467,38 @@ def _ensure_preferred_modules(
|
||||
raise RuntimeError(conflict_message)
|
||||
|
||||
|
||||
def _module_exists_in_site_packages(module_name: str, site_packages_path: str) -> bool:
|
||||
base_path = os.path.join(site_packages_path, *module_name.split("."))
|
||||
package_init = os.path.join(base_path, "__init__.py")
|
||||
module_file = f"{base_path}.py"
|
||||
return os.path.isfile(package_init) or os.path.isfile(module_file)
|
||||
|
||||
|
||||
def _is_module_loaded_from_site_packages(
|
||||
module_name: str,
|
||||
site_packages_path: str,
|
||||
) -> bool:
|
||||
module = sys.modules.get(module_name)
|
||||
if module is None:
|
||||
try:
|
||||
module = importlib.import_module(module_name)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
module_file = getattr(module, "__file__", None)
|
||||
if not module_file:
|
||||
return False
|
||||
|
||||
module_path = os.path.realpath(module_file)
|
||||
site_packages_real = os.path.realpath(site_packages_path)
|
||||
try:
|
||||
return (
|
||||
os.path.commonpath([module_path, site_packages_real]) == site_packages_real
|
||||
)
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def _prefer_module_from_site_packages(
|
||||
module_name: str, site_packages_path: str
|
||||
) -> bool:
|
||||
@@ -531,9 +800,63 @@ def _patch_distlib_finder_for_frozen_runtime() -> None:
|
||||
|
||||
|
||||
class PipInstaller:
|
||||
def __init__(self, pip_install_arg: str, pypi_index_url: str | None = None) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
pip_install_arg: str,
|
||||
pypi_index_url: str | None = None,
|
||||
core_dist_name: str | None = "AstrBot",
|
||||
) -> None:
|
||||
self.pip_install_arg = pip_install_arg
|
||||
self.pypi_index_url = pypi_index_url
|
||||
self.core_dist_name = core_dist_name
|
||||
self._core_constraints = CoreConstraintsProvider(core_dist_name)
|
||||
|
||||
def _build_pip_args(
|
||||
self,
|
||||
package_name: str | None,
|
||||
requirements_path: str | None,
|
||||
mirror: str | None,
|
||||
) -> tuple[list[str], set[str]]:
|
||||
args: list[str] = []
|
||||
requested_requirements: set[str] = set()
|
||||
normalized_requirements_path = (
|
||||
requirements_path.strip() if requirements_path else ""
|
||||
)
|
||||
|
||||
if package_name and normalized_requirements_path:
|
||||
raise ValueError(
|
||||
"package_name and requirements_path cannot be used together"
|
||||
)
|
||||
|
||||
if package_name:
|
||||
parsed_package = parse_package_install_input(package_name)
|
||||
if parsed_package.specs:
|
||||
args = ["install", *parsed_package.specs]
|
||||
requested_requirements = set(parsed_package.requirement_names)
|
||||
elif normalized_requirements_path:
|
||||
args = ["install", "-r", normalized_requirements_path]
|
||||
requested_requirements = extract_requirement_names(
|
||||
normalized_requirements_path
|
||||
)
|
||||
|
||||
if not args:
|
||||
return [], requested_requirements
|
||||
|
||||
pip_install_args = (
|
||||
shlex.split(self.pip_install_arg) if self.pip_install_arg else []
|
||||
)
|
||||
|
||||
if not _package_specs_override_index([*args[1:], *pip_install_args]):
|
||||
index_url = mirror or self.pypi_index_url or "https://pypi.org/simple"
|
||||
trusted_host = _get_trusted_host_for_index_url(index_url)
|
||||
if trusted_host:
|
||||
args.extend(["--trusted-host", trusted_host])
|
||||
args.extend(["-i", index_url])
|
||||
|
||||
if pip_install_args:
|
||||
args.extend(pip_install_args)
|
||||
|
||||
return args, requested_requirements
|
||||
|
||||
async def install(
|
||||
self,
|
||||
@@ -541,36 +864,37 @@ class PipInstaller:
|
||||
requirements_path: str | None = None,
|
||||
mirror: str | None = None,
|
||||
) -> None:
|
||||
args = ["install"]
|
||||
requested_requirements: set[str] = set()
|
||||
if package_name:
|
||||
args.append(package_name)
|
||||
requirement_name = _extract_requirement_name(package_name)
|
||||
if requirement_name:
|
||||
requested_requirements.add(requirement_name)
|
||||
elif requirements_path:
|
||||
args.extend(["-r", requirements_path])
|
||||
requested_requirements = _extract_requirement_names(requirements_path)
|
||||
|
||||
index_url = mirror or self.pypi_index_url or "https://pypi.org/simple"
|
||||
args.extend(["--trusted-host", "mirrors.aliyun.com", "-i", index_url])
|
||||
args, requested_requirements = self._build_pip_args(
|
||||
package_name, requirements_path, mirror
|
||||
)
|
||||
if not args:
|
||||
logger.info("Pip 包管理器跳过安装:未提供有效的包名或 requirements 文件。")
|
||||
return
|
||||
|
||||
target_site_packages = None
|
||||
if is_packaged_desktop_runtime():
|
||||
target_site_packages = get_astrbot_site_packages_path()
|
||||
os.makedirs(target_site_packages, exist_ok=True)
|
||||
_prepend_sys_path(target_site_packages)
|
||||
args.extend(["--target", target_site_packages])
|
||||
args.extend(["--upgrade", "--force-reinstall"])
|
||||
args.extend(
|
||||
[
|
||||
"--target",
|
||||
target_site_packages,
|
||||
"--upgrade",
|
||||
"--upgrade-strategy",
|
||||
"only-if-needed",
|
||||
]
|
||||
)
|
||||
|
||||
if self.pip_install_arg:
|
||||
args.extend(self.pip_install_arg.split())
|
||||
with self._core_constraints.constraints_file() as constraints_file_path:
|
||||
if constraints_file_path:
|
||||
args.extend(["-c", constraints_file_path])
|
||||
|
||||
logger.info(f"Pip 包管理器: pip {' '.join(args)}")
|
||||
result_code = await self._run_pip_in_process(args)
|
||||
|
||||
if result_code != 0:
|
||||
raise Exception(f"安装失败,错误码:{result_code}")
|
||||
logger.info(
|
||||
"Pip 包管理器 argv: %s",
|
||||
["pip", *_redact_pip_args_for_logging(args)],
|
||||
)
|
||||
await self._run_pip_with_classification(args)
|
||||
|
||||
if target_site_packages:
|
||||
_prepend_sys_path(target_site_packages)
|
||||
@@ -589,7 +913,7 @@ class PipInstaller:
|
||||
if not os.path.isdir(target_site_packages):
|
||||
return
|
||||
|
||||
requested_requirements = _extract_requirement_names(requirements_path)
|
||||
requested_requirements = extract_requirement_names(requirements_path)
|
||||
if not requested_requirements:
|
||||
return
|
||||
|
||||
@@ -605,13 +929,21 @@ class PipInstaller:
|
||||
_patch_distlib_finder_for_frozen_runtime()
|
||||
|
||||
original_handlers = list(logging.getLogger().handlers)
|
||||
result_code, output = await asyncio.to_thread(
|
||||
_run_pip_main_with_output, pip_main, args
|
||||
)
|
||||
for line in output.splitlines():
|
||||
line = line.strip()
|
||||
if line:
|
||||
logger.info(line)
|
||||
try:
|
||||
result_code, output_lines = await asyncio.to_thread(
|
||||
_run_pip_main_streaming, pip_main, args
|
||||
)
|
||||
finally:
|
||||
_cleanup_added_root_handlers(original_handlers)
|
||||
|
||||
if result_code != 0:
|
||||
conflict = _classify_pip_failure(output_lines)
|
||||
if conflict:
|
||||
raise conflict
|
||||
|
||||
_cleanup_added_root_handlers(original_handlers)
|
||||
return result_code
|
||||
|
||||
async def _run_pip_with_classification(self, args: list[str]) -> None:
|
||||
result_code = await self._run_pip_in_process(args)
|
||||
if result_code != 0:
|
||||
raise PipInstallError(f"安装失败,错误码:{result_code}", code=result_code)
|
||||
|
||||
@@ -0,0 +1,486 @@
|
||||
import importlib.metadata as importlib_metadata
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import sys
|
||||
from collections.abc import Iterable, Iterator, Sequence
|
||||
from dataclasses import dataclass
|
||||
|
||||
from packaging.requirements import InvalidRequirement, Requirement
|
||||
from packaging.specifiers import SpecifierSet
|
||||
from packaging.version import InvalidVersion, Version
|
||||
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_site_packages_path
|
||||
from astrbot.core.utils.runtime_env import is_packaged_desktop_runtime
|
||||
|
||||
logger = logging.getLogger("astrbot")
|
||||
|
||||
|
||||
class RequirementsPrecheckFailed(Exception):
|
||||
"""Raised when the pre-check of requirements fails."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ParsedPackageInput:
|
||||
specs: tuple[str, ...]
|
||||
requirement_names: frozenset[str]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MissingRequirementsPlan:
|
||||
missing_names: frozenset[str]
|
||||
install_lines: tuple[str, ...]
|
||||
fallback_reason: str | None = None
|
||||
|
||||
|
||||
def canonicalize_distribution_name(name: str) -> str:
|
||||
return re.sub(r"[-_.]+", "-", name).strip("-").lower()
|
||||
|
||||
|
||||
def strip_inline_requirement_comment(raw_input: str) -> str:
|
||||
if raw_input.lstrip().startswith("#"):
|
||||
return ""
|
||||
return re.split(r"[ \t]+#", raw_input, maxsplit=1)[0].strip()
|
||||
|
||||
|
||||
def _specifier_contains_version(specifier: SpecifierSet, version: str) -> bool:
|
||||
try:
|
||||
parsed_version = Version(version)
|
||||
except InvalidVersion:
|
||||
return False
|
||||
return specifier.contains(parsed_version, prereleases=True)
|
||||
|
||||
|
||||
def _looks_like_local_path_reference(token: str) -> bool:
|
||||
candidate = token.strip()
|
||||
if not candidate:
|
||||
return False
|
||||
return candidate in {".", ".."} or candidate.startswith(
|
||||
("./", "../", "/", "~/", ".\\", "..\\", "\\")
|
||||
)
|
||||
|
||||
|
||||
def looks_like_direct_reference(token: str) -> bool:
|
||||
candidate = token.strip()
|
||||
if not candidate:
|
||||
return False
|
||||
return (
|
||||
_looks_like_local_path_reference(candidate)
|
||||
or candidate.startswith("git+")
|
||||
or "://" in candidate
|
||||
)
|
||||
|
||||
|
||||
def extract_requirement_name(raw_requirement: str) -> str | None:
|
||||
line = raw_requirement.split("#", 1)[0].strip()
|
||||
if not line:
|
||||
return None
|
||||
if line.startswith(("-r", "--requirement", "-c", "--constraint")):
|
||||
return None
|
||||
|
||||
egg_match = re.search(r"#egg=([A-Za-z0-9_.-]+)", raw_requirement)
|
||||
if egg_match:
|
||||
return canonicalize_distribution_name(egg_match.group(1))
|
||||
|
||||
if line.startswith("-"):
|
||||
return None
|
||||
|
||||
candidate = re.split(r"[<>=!~;\s\[]", line, maxsplit=1)[0].strip()
|
||||
if not candidate:
|
||||
return None
|
||||
return canonicalize_distribution_name(candidate)
|
||||
|
||||
|
||||
def _parse_editable_or_direct_name(target: str) -> str | None:
|
||||
name = extract_requirement_name(target)
|
||||
if not name:
|
||||
return None
|
||||
if "#egg=" in target or not looks_like_direct_reference(target):
|
||||
return name
|
||||
return None
|
||||
|
||||
|
||||
def _parse_requirement_name_and_spec(
|
||||
line: str,
|
||||
) -> tuple[str | None, SpecifierSet | None]:
|
||||
if line.startswith(("-c", "--constraint")):
|
||||
return None, None
|
||||
|
||||
try:
|
||||
req = Requirement(line)
|
||||
except InvalidRequirement:
|
||||
tokens = shlex.split(line)
|
||||
if not tokens:
|
||||
return None, None
|
||||
|
||||
editable_target: str | None = None
|
||||
if tokens[0] in {"-e", "--editable"} and len(tokens) > 1:
|
||||
editable_target = tokens[1]
|
||||
elif tokens[0].startswith("--editable="):
|
||||
editable_target = tokens[0].split("=", 1)[1]
|
||||
|
||||
if editable_target:
|
||||
name = _parse_editable_or_direct_name(editable_target)
|
||||
return (name, None) if name else (None, None)
|
||||
|
||||
name = _parse_editable_or_direct_name(line)
|
||||
return (name, None) if name else (None, None)
|
||||
|
||||
if req.marker and not req.marker.evaluate():
|
||||
return None, None
|
||||
|
||||
return canonicalize_distribution_name(req.name), (req.specifier or None)
|
||||
|
||||
|
||||
def _parse_requirement_line(
|
||||
line: str,
|
||||
) -> tuple[str, SpecifierSet | None] | None:
|
||||
name, specifier = _parse_requirement_name_and_spec(line)
|
||||
return (name, specifier) if name else None
|
||||
|
||||
|
||||
def _extract_requirement_names_from_package_tokens(tokens: list[str]) -> frozenset[str]:
|
||||
requirement_names: set[str] = set()
|
||||
skip_next_for: str | None = None
|
||||
|
||||
for token in tokens:
|
||||
if skip_next_for:
|
||||
if skip_next_for == "editable":
|
||||
name = _parse_editable_or_direct_name(token)
|
||||
if name:
|
||||
requirement_names.add(name)
|
||||
skip_next_for = None
|
||||
continue
|
||||
|
||||
if token in {"-e", "--editable"}:
|
||||
skip_next_for = "editable"
|
||||
continue
|
||||
|
||||
if token in {
|
||||
"-i",
|
||||
"--index-url",
|
||||
"--extra-index-url",
|
||||
"-f",
|
||||
"--find-links",
|
||||
"--trusted-host",
|
||||
"-r",
|
||||
"--requirement",
|
||||
"-c",
|
||||
"--constraint",
|
||||
}:
|
||||
skip_next_for = "option-value"
|
||||
continue
|
||||
|
||||
if token.startswith(("--editable=",)):
|
||||
editable_target = token.split("=", 1)[1]
|
||||
name = _parse_editable_or_direct_name(editable_target)
|
||||
if name:
|
||||
requirement_names.add(name)
|
||||
continue
|
||||
|
||||
if token.startswith(
|
||||
(
|
||||
"--index-url=",
|
||||
"--extra-index-url=",
|
||||
"--find-links=",
|
||||
"--trusted-host=",
|
||||
"--requirement=",
|
||||
"--constraint=",
|
||||
)
|
||||
):
|
||||
continue
|
||||
|
||||
if (
|
||||
(token.startswith("-i") and token != "-i")
|
||||
or (token.startswith("-f") and token != "-f")
|
||||
or token == "--no-index"
|
||||
):
|
||||
continue
|
||||
|
||||
if token.startswith("-"):
|
||||
continue
|
||||
|
||||
name, _ = _parse_requirement_name_and_spec(token)
|
||||
if name:
|
||||
requirement_names.add(name)
|
||||
|
||||
return frozenset(requirement_names)
|
||||
|
||||
|
||||
def parse_package_install_input(raw_input: str) -> ParsedPackageInput:
|
||||
specs: list[str] = []
|
||||
requirement_names: set[str] = set()
|
||||
normalized = raw_input.strip()
|
||||
if not normalized:
|
||||
return ParsedPackageInput(specs=(), requirement_names=frozenset())
|
||||
|
||||
for raw_line in normalized.splitlines():
|
||||
line = strip_inline_requirement_comment(raw_line)
|
||||
if not line:
|
||||
continue
|
||||
|
||||
try:
|
||||
Requirement(line)
|
||||
except InvalidRequirement:
|
||||
tokens = shlex.split(line)
|
||||
if not tokens:
|
||||
continue
|
||||
specs.extend(tokens)
|
||||
requirement_names.update(
|
||||
_extract_requirement_names_from_package_tokens(tokens)
|
||||
)
|
||||
continue
|
||||
|
||||
specs.append(line)
|
||||
name, _ = _parse_requirement_name_and_spec(line)
|
||||
if name:
|
||||
requirement_names.add(name)
|
||||
|
||||
return ParsedPackageInput(
|
||||
specs=tuple(specs),
|
||||
requirement_names=frozenset(requirement_names),
|
||||
)
|
||||
|
||||
|
||||
def _iter_requirement_lines(
|
||||
requirements_path: str,
|
||||
_visited: set[str] | None = None,
|
||||
) -> Iterator[str]:
|
||||
visited = _visited or set()
|
||||
resolved_path = os.path.realpath(requirements_path)
|
||||
if resolved_path in visited:
|
||||
logger.warning(
|
||||
"检测到循环依赖的 requirements 包含: %s,将跳过该文件", resolved_path
|
||||
)
|
||||
return
|
||||
visited.add(resolved_path)
|
||||
|
||||
with open(resolved_path, encoding="utf-8") as f:
|
||||
for raw_line in f:
|
||||
line = strip_inline_requirement_comment(raw_line)
|
||||
if not line:
|
||||
continue
|
||||
|
||||
tokens = shlex.split(line)
|
||||
if not tokens:
|
||||
continue
|
||||
|
||||
nested: str | None = None
|
||||
if tokens[0] in {"-r", "--requirement"} and len(tokens) > 1:
|
||||
nested = tokens[1]
|
||||
elif tokens[0].startswith("--requirement="):
|
||||
nested = tokens[0].split("=", 1)[1]
|
||||
|
||||
if nested:
|
||||
if not os.path.isabs(nested):
|
||||
nested = os.path.join(os.path.dirname(resolved_path), nested)
|
||||
yield from _iter_requirement_lines(nested, _visited=visited)
|
||||
continue
|
||||
|
||||
yield line
|
||||
|
||||
|
||||
def iter_requirements(
|
||||
requirements_path: str | None = None,
|
||||
lines: Iterable[str] | None = None,
|
||||
) -> Iterator[tuple[str, SpecifierSet | None]]:
|
||||
if lines is None:
|
||||
if requirements_path is None:
|
||||
raise ValueError("Either requirements_path or lines must be provided")
|
||||
lines = _iter_requirement_lines(requirements_path)
|
||||
|
||||
for line in lines:
|
||||
parsed = _parse_requirement_line(line)
|
||||
if parsed is not None:
|
||||
yield parsed
|
||||
|
||||
|
||||
def extract_requirement_names(requirements_path: str) -> set[str]:
|
||||
try:
|
||||
return {
|
||||
name for name, _ in iter_requirements(requirements_path=requirements_path)
|
||||
}
|
||||
except Exception as exc:
|
||||
logger.warning("读取依赖文件失败,跳过冲突检测: %s", exc)
|
||||
return set()
|
||||
|
||||
|
||||
def get_requirement_check_paths() -> list[str]:
|
||||
paths = list(sys.path)
|
||||
if is_packaged_desktop_runtime():
|
||||
target_site_packages = get_astrbot_site_packages_path()
|
||||
if os.path.isdir(target_site_packages):
|
||||
paths.insert(0, target_site_packages)
|
||||
return paths
|
||||
|
||||
|
||||
def _canonical_distribution_identity(distribution) -> tuple[str | None, str | None]:
|
||||
distribution_name = (
|
||||
distribution.metadata["Name"] if "Name" in distribution.metadata else None
|
||||
)
|
||||
if not distribution_name:
|
||||
return None, None
|
||||
return canonicalize_distribution_name(distribution_name), distribution.version
|
||||
|
||||
|
||||
def collect_installed_distribution_versions(paths: list[str]) -> dict[str, str] | None:
|
||||
installed: dict[str, str] = {}
|
||||
try:
|
||||
for distribution in importlib_metadata.distributions(path=paths):
|
||||
distribution_name, version = _canonical_distribution_identity(distribution)
|
||||
if not distribution_name or not version:
|
||||
continue
|
||||
installed.setdefault(distribution_name, version)
|
||||
except Exception as exc:
|
||||
logger.warning("读取已安装依赖失败,跳过缺失依赖预检查: %s", exc)
|
||||
return None
|
||||
return installed
|
||||
|
||||
|
||||
def _load_requirement_lines_for_precheck(
|
||||
requirements_path: str,
|
||||
) -> tuple[bool, list[str] | None]:
|
||||
try:
|
||||
requirement_lines = list(_iter_requirement_lines(requirements_path))
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"预检查缺失依赖失败,将回退到完整安装: %s (%s)",
|
||||
requirements_path,
|
||||
exc,
|
||||
)
|
||||
return False, None
|
||||
|
||||
fallback_line = next(
|
||||
(
|
||||
line
|
||||
for line in requirement_lines
|
||||
if (
|
||||
(
|
||||
line.startswith(("-e ", "--editable ", "--editable="))
|
||||
and "#egg=" not in line
|
||||
)
|
||||
or (
|
||||
_parse_requirement_line(line) is None
|
||||
and looks_like_direct_reference(line)
|
||||
)
|
||||
)
|
||||
),
|
||||
None,
|
||||
)
|
||||
if fallback_line is not None:
|
||||
logger.info(
|
||||
"缺失依赖预检查发现无法安全裁剪的 option/direct-reference 行,将回退到完整安装: %s (%s)",
|
||||
requirements_path,
|
||||
fallback_line,
|
||||
)
|
||||
return False, None
|
||||
|
||||
return True, requirement_lines
|
||||
|
||||
|
||||
def find_missing_requirements(requirements_path: str) -> set[str] | None:
|
||||
can_precheck, requirement_lines = _load_requirement_lines_for_precheck(
|
||||
requirements_path
|
||||
)
|
||||
if not can_precheck or requirement_lines is None:
|
||||
return None
|
||||
|
||||
return find_missing_requirements_from_lines(requirement_lines)
|
||||
|
||||
|
||||
def find_missing_requirements_from_lines(
|
||||
requirement_lines: Sequence[str],
|
||||
) -> set[str] | None:
|
||||
|
||||
required = list(iter_requirements(lines=requirement_lines))
|
||||
if not required:
|
||||
return set()
|
||||
|
||||
installed = collect_installed_distribution_versions(get_requirement_check_paths())
|
||||
if installed is None:
|
||||
return None
|
||||
|
||||
missing: set[str] = set()
|
||||
for name, specifier in required:
|
||||
installed_version = installed.get(name)
|
||||
if not installed_version:
|
||||
missing.add(name)
|
||||
continue
|
||||
if specifier and not _specifier_contains_version(specifier, installed_version):
|
||||
missing.add(name)
|
||||
|
||||
return missing
|
||||
|
||||
|
||||
def build_missing_requirements_install_lines(
|
||||
requirements_path: str,
|
||||
requirement_lines: Sequence[str],
|
||||
missing_names: set[str] | frozenset[str],
|
||||
) -> tuple[str, ...] | None:
|
||||
wanted_names = set(missing_names)
|
||||
install_lines: list[str] = []
|
||||
for line in requirement_lines:
|
||||
parsed = _parse_requirement_line(line)
|
||||
if parsed is None:
|
||||
if looks_like_direct_reference(line) or line.startswith(("-", "--")):
|
||||
logger.debug(
|
||||
"缺失依赖行筛选回退到完整安装:requirements 中包含无法安全裁剪的 option/direct-reference 行: %s (%s)",
|
||||
requirements_path,
|
||||
line,
|
||||
)
|
||||
return None
|
||||
continue
|
||||
|
||||
name, _specifier = parsed
|
||||
if name in wanted_names:
|
||||
install_lines.append(line)
|
||||
|
||||
return tuple(install_lines)
|
||||
|
||||
|
||||
def plan_missing_requirements_install(
|
||||
requirements_path: str,
|
||||
) -> MissingRequirementsPlan | None:
|
||||
can_precheck, requirement_lines = _load_requirement_lines_for_precheck(
|
||||
requirements_path
|
||||
)
|
||||
if not can_precheck or requirement_lines is None:
|
||||
return None
|
||||
|
||||
missing = find_missing_requirements_from_lines(requirement_lines)
|
||||
if missing is None:
|
||||
return None
|
||||
|
||||
install_lines = build_missing_requirements_install_lines(
|
||||
requirements_path,
|
||||
requirement_lines,
|
||||
missing,
|
||||
)
|
||||
if install_lines is None:
|
||||
return None
|
||||
if missing and not install_lines:
|
||||
logger.warning(
|
||||
"预检查缺失依赖成功,但无法映射到可安装 requirement 行,将回退到完整安装: %s -> %s",
|
||||
requirements_path,
|
||||
sorted(missing),
|
||||
)
|
||||
return MissingRequirementsPlan(
|
||||
missing_names=frozenset(missing),
|
||||
install_lines=(),
|
||||
fallback_reason="unmapped missing requirement names",
|
||||
)
|
||||
|
||||
return MissingRequirementsPlan(
|
||||
missing_names=frozenset(missing),
|
||||
install_lines=install_lines,
|
||||
)
|
||||
|
||||
|
||||
def find_missing_requirements_or_raise(requirements_path: str) -> set[str]:
|
||||
missing = find_missing_requirements(requirements_path)
|
||||
if missing is None:
|
||||
raise RequirementsPrecheckFailed(f"预检查失败: {requirements_path}")
|
||||
return missing
|
||||
@@ -7,4 +7,4 @@ def is_frozen_runtime() -> bool:
|
||||
|
||||
|
||||
def is_packaged_desktop_runtime() -> bool:
|
||||
return is_frozen_runtime() and os.environ.get("ASTRBOT_DESKTOP_CLIENT") == "1"
|
||||
return os.environ.get("ASTRBOT_DESKTOP_CLIENT") == "1"
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import asyncio
|
||||
import threading
|
||||
import weakref
|
||||
from collections import defaultdict
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
|
||||
class SessionLockManager:
|
||||
class _PerLoopSessionLockManager:
|
||||
"""Per-event-loop session lock manager; keeps original simple semantics."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._locks: dict[str, asyncio.Lock] = defaultdict(asyncio.Lock)
|
||||
self._lock_count: dict[str, int] = defaultdict(int)
|
||||
@@ -26,4 +30,26 @@ class SessionLockManager:
|
||||
self._lock_count.pop(session_id, None)
|
||||
|
||||
|
||||
class SessionLockManager:
|
||||
"""Thread-safe session lock manager with per-event-loop isolation."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._state_guard = threading.Lock()
|
||||
self._loop_managers: weakref.WeakKeyDictionary[
|
||||
asyncio.AbstractEventLoop, _PerLoopSessionLockManager
|
||||
] = weakref.WeakKeyDictionary()
|
||||
|
||||
def _get_loop_manager(self) -> _PerLoopSessionLockManager:
|
||||
"""Get the lock manager for the current event loop."""
|
||||
loop = asyncio.get_running_loop()
|
||||
with self._state_guard:
|
||||
return self._loop_managers.setdefault(loop, _PerLoopSessionLockManager())
|
||||
|
||||
@asynccontextmanager
|
||||
async def acquire_lock(self, session_id: str):
|
||||
manager = self._get_loop_manager()
|
||||
async with manager.acquire_lock(session_id):
|
||||
yield
|
||||
|
||||
|
||||
session_lock_manager = SessionLockManager()
|
||||
|
||||
@@ -82,7 +82,8 @@ class AuthRoute(Route):
|
||||
def generate_jwt(self, username):
|
||||
payload = {
|
||||
"username": username,
|
||||
"exp": datetime.datetime.utcnow() + datetime.timedelta(days=7),
|
||||
"exp": datetime.datetime.now(datetime.timezone.utc)
|
||||
+ datetime.timedelta(days=7),
|
||||
}
|
||||
jwt_token = self.config["dashboard"].get("jwt_secret", None)
|
||||
if not jwt_token:
|
||||
|
||||
@@ -977,7 +977,17 @@ class BackupRoute(Route):
|
||||
if not jwt_secret:
|
||||
return Response().error("服务器配置错误").__dict__
|
||||
|
||||
jwt.decode(token, jwt_secret, algorithms=["HS256"])
|
||||
# Verify JWT token with strict security options
|
||||
jwt.decode(
|
||||
token,
|
||||
jwt_secret,
|
||||
algorithms=["HS256"],
|
||||
options={
|
||||
"require": ["exp"], # Require expiration claim
|
||||
"verify_signature": True, # Explicitly verify signature
|
||||
"verify_exp": True, # Verify expiration
|
||||
},
|
||||
)
|
||||
except jwt.ExpiredSignatureError:
|
||||
return Response().error("Token 已过期,请刷新页面后重试").__dict__
|
||||
except jwt.InvalidTokenError:
|
||||
|
||||
@@ -36,6 +36,20 @@ async def track_conversation(convs: dict, conv_id: str):
|
||||
convs.pop(conv_id, None)
|
||||
|
||||
|
||||
async def _poll_webchat_stream_result(back_queue, username: str):
|
||||
try:
|
||||
result = await asyncio.wait_for(back_queue.get(), timeout=1)
|
||||
except asyncio.TimeoutError:
|
||||
return None, False
|
||||
except asyncio.CancelledError:
|
||||
logger.debug(f"[WebChat] 用户 {username} 断开聊天长连接。")
|
||||
return None, True
|
||||
except Exception as e:
|
||||
logger.error(f"WebChat stream error: {e}")
|
||||
return None, False
|
||||
return result, False
|
||||
|
||||
|
||||
class ChatRoute(Route):
|
||||
def __init__(
|
||||
self,
|
||||
@@ -51,6 +65,7 @@ class ChatRoute(Route):
|
||||
"/chat/get_session": ("GET", self.get_session),
|
||||
"/chat/stop": ("POST", self.stop_session),
|
||||
"/chat/delete_session": ("GET", self.delete_webchat_session),
|
||||
"/chat/batch_delete_sessions": ("POST", self.batch_delete_sessions),
|
||||
"/chat/update_session_display_name": (
|
||||
"POST",
|
||||
self.update_session_display_name,
|
||||
@@ -342,16 +357,12 @@ class ChatRoute(Route):
|
||||
|
||||
async with track_conversation(self.running_convs, webchat_conv_id):
|
||||
while True:
|
||||
try:
|
||||
result = await asyncio.wait_for(back_queue.get(), timeout=1)
|
||||
except asyncio.TimeoutError:
|
||||
continue
|
||||
except asyncio.CancelledError:
|
||||
logger.debug(f"[WebChat] 用户 {username} 断开聊天长连接。")
|
||||
result, should_break = await _poll_webchat_stream_result(
|
||||
back_queue, username
|
||||
)
|
||||
if should_break:
|
||||
client_disconnected = True
|
||||
except Exception as e:
|
||||
logger.error(f"WebChat stream error: {e}")
|
||||
|
||||
break
|
||||
if not result:
|
||||
continue
|
||||
|
||||
@@ -578,19 +589,9 @@ class ChatRoute(Route):
|
||||
|
||||
return Response().ok(data={"stopped_count": stopped_count}).__dict__
|
||||
|
||||
async def delete_webchat_session(self):
|
||||
"""Delete a Platform session and all its related data."""
|
||||
session_id = request.args.get("session_id")
|
||||
if not session_id:
|
||||
return Response().error("Missing key: session_id").__dict__
|
||||
username = g.get("username", "guest")
|
||||
|
||||
# 验证会话是否存在且属于当前用户
|
||||
session = await self.db.get_platform_session_by_id(session_id)
|
||||
if not session:
|
||||
return Response().error(f"Session {session_id} not found").__dict__
|
||||
if session.creator != username:
|
||||
return Response().error("Permission denied").__dict__
|
||||
async def _delete_session_internal(self, session, username: str) -> None:
|
||||
"""Delete a single session and all its related data."""
|
||||
session_id = session.session_id
|
||||
|
||||
# 删除该会话下的所有对话
|
||||
message_type = "GroupMessage" if session.is_group else "FriendMessage"
|
||||
@@ -632,8 +633,70 @@ class ChatRoute(Route):
|
||||
# 删除会话
|
||||
await self.db.delete_platform_session(session_id)
|
||||
|
||||
async def delete_webchat_session(self):
|
||||
"""Delete a Platform session and all its related data."""
|
||||
session_id = request.args.get("session_id")
|
||||
if not session_id:
|
||||
return Response().error("Missing key: session_id").__dict__
|
||||
username = g.get("username", "guest")
|
||||
|
||||
session = await self.db.get_platform_session_by_id(session_id)
|
||||
if not session:
|
||||
return Response().error(f"Session {session_id} not found").__dict__
|
||||
if session.creator != username:
|
||||
return Response().error("Permission denied").__dict__
|
||||
|
||||
await self._delete_session_internal(session, username)
|
||||
|
||||
return Response().ok().__dict__
|
||||
|
||||
async def batch_delete_sessions(self):
|
||||
"""Batch delete multiple Platform sessions."""
|
||||
post_data = await request.json
|
||||
if post_data is None:
|
||||
return Response().error("Missing JSON body").__dict__
|
||||
if not isinstance(post_data, dict):
|
||||
return Response().error("Invalid JSON body: expected object").__dict__
|
||||
|
||||
session_ids = post_data.get("session_ids")
|
||||
if not session_ids or not isinstance(session_ids, list):
|
||||
return Response().error("Missing or invalid key: session_ids").__dict__
|
||||
|
||||
username = g.get("username", "guest")
|
||||
sessions = await self.db.get_platform_sessions_by_ids(session_ids)
|
||||
sessions_by_id = {session.session_id: session for session in sessions}
|
||||
deleted_count = 0
|
||||
failed_items = []
|
||||
|
||||
for sid in session_ids:
|
||||
session = sessions_by_id.get(sid)
|
||||
if not session:
|
||||
failed_items.append({"session_id": sid, "reason": "not found"})
|
||||
continue
|
||||
if session.creator != username:
|
||||
failed_items.append({"session_id": sid, "reason": "permission denied"})
|
||||
continue
|
||||
|
||||
try:
|
||||
await self._delete_session_internal(session, username)
|
||||
deleted_count += 1
|
||||
sessions_by_id.pop(sid, None)
|
||||
except Exception:
|
||||
logger.warning("Failed to delete session %s", sid)
|
||||
failed_items.append({"session_id": sid, "reason": "internal_error"})
|
||||
|
||||
return (
|
||||
Response()
|
||||
.ok(
|
||||
data={
|
||||
"deleted_count": deleted_count,
|
||||
"failed_count": len(failed_items),
|
||||
"failed_items": failed_items,
|
||||
}
|
||||
)
|
||||
.__dict__
|
||||
)
|
||||
|
||||
def _extract_attachment_ids(self, history_list) -> list[str]:
|
||||
"""从消息历史中提取所有 attachment_id"""
|
||||
attachment_ids = []
|
||||
|
||||
@@ -610,6 +610,7 @@ class ConfigRoute(Route):
|
||||
|
||||
try:
|
||||
conf_id = self.acm.create_conf(name=name, config=config)
|
||||
await self.core_lifecycle.reload_pipeline_scheduler(conf_id)
|
||||
return Response().ok(message="创建成功", data={"conf_id": conf_id}).__dict__
|
||||
except ValueError as e:
|
||||
return Response().error(str(e)).__dict__
|
||||
@@ -649,6 +650,7 @@ class ConfigRoute(Route):
|
||||
try:
|
||||
success = self.acm.delete_conf(conf_id)
|
||||
if success:
|
||||
self.core_lifecycle.pipeline_scheduler_mapping.pop(conf_id, None)
|
||||
return Response().ok(message="删除成功").__dict__
|
||||
return Response().error("删除失败").__dict__
|
||||
except ValueError as e:
|
||||
|
||||
@@ -130,16 +130,6 @@ class LiveChatRoute(Route):
|
||||
|
||||
async def live_chat_ws(self) -> None:
|
||||
"""Legacy Live Chat WebSocket 处理器(默认 ct=live)"""
|
||||
await self._unified_ws_loop(force_ct="live")
|
||||
|
||||
async def unified_chat_ws(self) -> None:
|
||||
"""Unified Chat WebSocket 处理器(支持 ct=live/chat)"""
|
||||
await self._unified_ws_loop(force_ct=None)
|
||||
|
||||
async def _unified_ws_loop(self, force_ct: str | None = None) -> None:
|
||||
"""统一 WebSocket 循环"""
|
||||
# WebSocket 不能通过 header 传递 token,需要从 query 参数获取
|
||||
# 注意:WebSocket 上下文使用 websocket.args 而不是 request.args
|
||||
token = websocket.args.get("token")
|
||||
if not token:
|
||||
await websocket.close(1008, "Missing authentication token")
|
||||
@@ -156,6 +146,49 @@ class LiveChatRoute(Route):
|
||||
await websocket.close(1008, "Invalid token")
|
||||
return
|
||||
|
||||
await self.run_ws_session(username=username, force_ct="live")
|
||||
|
||||
async def unified_chat_ws(self) -> None:
|
||||
"""Unified Chat WebSocket 处理器(支持 ct=live/chat)"""
|
||||
token = websocket.args.get("token")
|
||||
if not token:
|
||||
await websocket.close(1008, "Missing authentication token")
|
||||
return
|
||||
|
||||
try:
|
||||
jwt_secret = self.config["dashboard"].get("jwt_secret")
|
||||
payload = jwt.decode(token, jwt_secret, algorithms=["HS256"])
|
||||
username = payload["username"]
|
||||
except jwt.ExpiredSignatureError:
|
||||
await websocket.close(1008, "Token expired")
|
||||
return
|
||||
except jwt.InvalidTokenError:
|
||||
await websocket.close(1008, "Invalid token")
|
||||
return
|
||||
|
||||
await self.run_ws_session(username=username, force_ct=None)
|
||||
|
||||
async def _unified_ws_loop(self, force_ct: str | None = None) -> None:
|
||||
"""统一 WebSocket 循环"""
|
||||
# Keep the legacy entry point for internal call sites.
|
||||
token = websocket.args.get("token")
|
||||
if not token:
|
||||
await websocket.close(1008, "Missing authentication token")
|
||||
return
|
||||
try:
|
||||
jwt_secret = self.config["dashboard"].get("jwt_secret")
|
||||
payload = jwt.decode(token, jwt_secret, algorithms=["HS256"])
|
||||
username = payload["username"]
|
||||
except jwt.ExpiredSignatureError:
|
||||
await websocket.close(1008, "Token expired")
|
||||
return
|
||||
except jwt.InvalidTokenError:
|
||||
await websocket.close(1008, "Invalid token")
|
||||
return
|
||||
await self.run_ws_session(username=username, force_ct=force_ct)
|
||||
|
||||
async def run_ws_session(self, username: str, force_ct: str | None = None) -> None:
|
||||
"""Run a live/unified websocket session for an authenticated username."""
|
||||
session_id = f"webchat_live!{username}!{uuid.uuid4()}"
|
||||
live_session = LiveChatSession(session_id, username)
|
||||
self.sessions[session_id] = live_session
|
||||
@@ -690,6 +723,16 @@ class LiveChatRoute(Route):
|
||||
|
||||
elif msg_type == "end_speaking":
|
||||
# 结束说话
|
||||
if session.is_processing:
|
||||
await websocket.send_json(
|
||||
{
|
||||
"t": "error",
|
||||
"data": "Session is busy",
|
||||
"code": "PROCESSING_ERROR",
|
||||
}
|
||||
)
|
||||
return
|
||||
|
||||
stamp = message.get("stamp")
|
||||
if not stamp:
|
||||
logger.warning("[Live Chat] end_speaking 缺少 stamp")
|
||||
@@ -703,45 +746,59 @@ class LiveChatRoute(Route):
|
||||
# 处理音频:STT -> LLM -> TTS
|
||||
await self._process_audio(session, audio_path, assemble_duration)
|
||||
|
||||
elif msg_type == "text_input":
|
||||
if session.is_processing:
|
||||
await websocket.send_json(
|
||||
{
|
||||
"t": "error",
|
||||
"data": "Session is busy",
|
||||
"code": "PROCESSING_ERROR",
|
||||
}
|
||||
)
|
||||
return
|
||||
|
||||
user_text = message.get("text")
|
||||
if not isinstance(user_text, str):
|
||||
user_text = message.get("message")
|
||||
|
||||
if not isinstance(user_text, str) or not user_text.strip():
|
||||
await websocket.send_json(
|
||||
{
|
||||
"t": "error",
|
||||
"data": "message must be non-empty text",
|
||||
"code": "INVALID_MESSAGE_FORMAT",
|
||||
}
|
||||
)
|
||||
return
|
||||
|
||||
await self._process_live_user_text(
|
||||
session,
|
||||
user_text=user_text.strip(),
|
||||
initial_metrics={"input_type": "text"},
|
||||
processing_start_time=time.time(),
|
||||
)
|
||||
|
||||
elif msg_type == "interrupt":
|
||||
# 用户打断
|
||||
session.should_interrupt = True
|
||||
logger.info(f"[Live Chat] 用户打断: {session.username}")
|
||||
|
||||
async def _process_audio(
|
||||
self, session: LiveChatSession, audio_path: str, assemble_duration: float
|
||||
async def _process_live_user_text(
|
||||
self,
|
||||
session: LiveChatSession,
|
||||
user_text: str,
|
||||
initial_metrics: dict[str, Any] | None = None,
|
||||
processing_start_time: float | None = None,
|
||||
) -> None:
|
||||
"""处理音频:STT -> LLM -> 流式 TTS"""
|
||||
"""处理 Live 用户文本:走 run_live_agent pipeline 并回传流式 TTS."""
|
||||
try:
|
||||
# 发送 WAV 组装耗时
|
||||
await websocket.send_json(
|
||||
{"t": "metrics", "data": {"wav_assemble_time": assemble_duration}}
|
||||
)
|
||||
wav_assembly_finish_time = time.time()
|
||||
if initial_metrics:
|
||||
await websocket.send_json({"t": "metrics", "data": initial_metrics})
|
||||
|
||||
processing_start = processing_start_time or time.time()
|
||||
session.is_processing = True
|
||||
session.should_interrupt = False
|
||||
|
||||
# 1. STT - 语音转文字
|
||||
ctx = self.plugin_manager.context
|
||||
stt_provider = ctx.provider_manager.stt_provider_insts[0]
|
||||
|
||||
if not stt_provider:
|
||||
logger.error("[Live Chat] STT Provider 未配置")
|
||||
await websocket.send_json({"t": "error", "data": "语音识别服务未配置"})
|
||||
return
|
||||
|
||||
await websocket.send_json(
|
||||
{"t": "metrics", "data": {"stt": stt_provider.meta().type}}
|
||||
)
|
||||
|
||||
user_text = await stt_provider.get_text(audio_path)
|
||||
if not user_text:
|
||||
logger.warning("[Live Chat] STT 识别结果为空")
|
||||
return
|
||||
|
||||
logger.info(f"[Live Chat] STT 结果: {user_text}")
|
||||
|
||||
await websocket.send_json(
|
||||
{
|
||||
"t": "user_msg",
|
||||
@@ -761,7 +818,6 @@ class LiveChatRoute(Route):
|
||||
"action_type": "live", # 标记为 live mode
|
||||
}
|
||||
|
||||
# 将消息放入队列
|
||||
await queue.put((session.username, cid, payload))
|
||||
|
||||
# 3. 等待响应并流式发送 TTS 音频
|
||||
@@ -776,11 +832,9 @@ class LiveChatRoute(Route):
|
||||
# 用户打断,停止处理
|
||||
logger.info("[Live Chat] 检测到用户打断")
|
||||
await websocket.send_json({"t": "stop_play"})
|
||||
# 保存消息并标记为被打断
|
||||
await self._save_interrupted_message(
|
||||
session, user_text, bot_text
|
||||
)
|
||||
# 清空队列中未处理的消息
|
||||
while not back_queue.empty():
|
||||
try:
|
||||
back_queue.get_nowait()
|
||||
@@ -805,6 +859,7 @@ class LiveChatRoute(Route):
|
||||
|
||||
result_type = result.get("type")
|
||||
result_chain_type = result.get("chain_type")
|
||||
result_streaming = bool(result.get("streaming", False))
|
||||
data = result.get("data", "")
|
||||
|
||||
if result_chain_type == "agent_stats":
|
||||
@@ -827,29 +882,41 @@ class LiveChatRoute(Route):
|
||||
if result_chain_type == "tts_stats":
|
||||
try:
|
||||
stats = json.loads(data)
|
||||
await websocket.send_json(
|
||||
{
|
||||
"t": "metrics",
|
||||
"data": stats,
|
||||
}
|
||||
)
|
||||
await websocket.send_json({"t": "metrics", "data": stats})
|
||||
except Exception as e:
|
||||
logger.error(f"[Live Chat] 解析 TTSStats 失败: {e}")
|
||||
continue
|
||||
|
||||
if result_chain_type == "live_text_delta":
|
||||
if data:
|
||||
await websocket.send_json(
|
||||
{
|
||||
"t": "bot_delta_chunk",
|
||||
"data": {"text": data},
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
if result_type == "plain":
|
||||
# 普通文本消息
|
||||
if (
|
||||
result_streaming
|
||||
and data
|
||||
and result_chain_type != "reasoning"
|
||||
):
|
||||
await websocket.send_json(
|
||||
{
|
||||
"t": "bot_delta_chunk",
|
||||
"data": {"text": data},
|
||||
}
|
||||
)
|
||||
bot_text += data
|
||||
|
||||
elif result_type == "audio_chunk":
|
||||
# 流式音频数据
|
||||
if not audio_playing:
|
||||
audio_playing = True
|
||||
logger.debug("[Live Chat] 开始播放音频流")
|
||||
|
||||
# Calculate latency from wav assembly finish to first audio chunk
|
||||
speak_to_first_frame_latency = (
|
||||
time.time() - wav_assembly_finish_time
|
||||
time.time() - processing_start
|
||||
)
|
||||
await websocket.send_json(
|
||||
{
|
||||
@@ -869,19 +936,15 @@ class LiveChatRoute(Route):
|
||||
}
|
||||
)
|
||||
|
||||
# 发送音频数据给前端
|
||||
await websocket.send_json(
|
||||
{
|
||||
"t": "response",
|
||||
"data": data, # base64 编码的音频数据
|
||||
"data": data,
|
||||
}
|
||||
)
|
||||
|
||||
elif result_type in ["complete", "end"]:
|
||||
# 处理完成
|
||||
logger.info(f"[Live Chat] Bot 回复完成: {bot_text}")
|
||||
|
||||
# 如果没有音频流,发送 bot 消息文本
|
||||
if not audio_playing:
|
||||
await websocket.send_json(
|
||||
{
|
||||
@@ -893,11 +956,8 @@ class LiveChatRoute(Route):
|
||||
}
|
||||
)
|
||||
|
||||
# 发送结束标记
|
||||
await websocket.send_json({"t": "end"})
|
||||
|
||||
# 发送总耗时
|
||||
wav_to_tts_duration = time.time() - wav_assembly_finish_time
|
||||
wav_to_tts_duration = time.time() - processing_start
|
||||
await websocket.send_json(
|
||||
{
|
||||
"t": "metrics",
|
||||
@@ -909,13 +969,65 @@ class LiveChatRoute(Route):
|
||||
webchat_queue_mgr.remove_back_queue(message_id)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Live Chat] 处理音频失败: {e}", exc_info=True)
|
||||
logger.error(f"[Live Chat] 处理文本失败: {e}", exc_info=True)
|
||||
await websocket.send_json({"t": "error", "data": f"处理失败: {str(e)}"})
|
||||
|
||||
finally:
|
||||
session.is_processing = False
|
||||
session.should_interrupt = False
|
||||
|
||||
async def _process_audio(
|
||||
self, session: LiveChatSession, audio_path: str, assemble_duration: float
|
||||
) -> None:
|
||||
"""处理音频:STT -> LLM -> 流式 TTS"""
|
||||
try:
|
||||
await websocket.send_json(
|
||||
{
|
||||
"t": "metrics",
|
||||
"data": {
|
||||
"wav_assemble_time": assemble_duration,
|
||||
"input_type": "audio",
|
||||
},
|
||||
}
|
||||
)
|
||||
wav_assembly_finish_time = time.time()
|
||||
|
||||
# 1. STT - 语音转文字
|
||||
ctx = self.plugin_manager.context
|
||||
stt_provider = ctx.provider_manager.stt_provider_insts[0]
|
||||
|
||||
if not stt_provider:
|
||||
logger.error("[Live Chat] STT Provider 未配置")
|
||||
await websocket.send_json({"t": "error", "data": "语音识别服务未配置"})
|
||||
return
|
||||
|
||||
await websocket.send_json(
|
||||
{
|
||||
"t": "metrics",
|
||||
"data": {
|
||||
"stt": stt_provider.meta().type,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
user_text = await stt_provider.get_text(audio_path)
|
||||
if not user_text:
|
||||
logger.warning("[Live Chat] STT 识别结果为空")
|
||||
return
|
||||
|
||||
logger.info(f"[Live Chat] STT 结果: {user_text}")
|
||||
|
||||
await self._process_live_user_text(
|
||||
session,
|
||||
user_text=user_text,
|
||||
initial_metrics=None,
|
||||
processing_start_time=wav_assembly_finish_time,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Live Chat] 处理音频失败: {e}", exc_info=True)
|
||||
await websocket.send_json({"t": "error", "data": f"处理失败: {str(e)}"})
|
||||
|
||||
async def _save_interrupted_message(
|
||||
self, session: LiveChatSession, user_text: str, bot_text: str
|
||||
) -> None:
|
||||
|
||||
@@ -19,6 +19,7 @@ from astrbot.core.utils.datetime_utils import to_utc_isoformat
|
||||
|
||||
from .api_key import ALL_OPEN_API_SCOPES
|
||||
from .chat import ChatRoute
|
||||
from .live_chat import LiveChatRoute
|
||||
from .route import Response, Route, RouteContext
|
||||
|
||||
|
||||
@@ -29,12 +30,14 @@ class OpenApiRoute(Route):
|
||||
db: BaseDatabase,
|
||||
core_lifecycle: AstrBotCoreLifecycle,
|
||||
chat_route: ChatRoute,
|
||||
live_chat_route: LiveChatRoute,
|
||||
) -> None:
|
||||
super().__init__(context)
|
||||
self.db = db
|
||||
self.core_lifecycle = core_lifecycle
|
||||
self.platform_manager = core_lifecycle.platform_manager
|
||||
self.chat_route = chat_route
|
||||
self.live_chat_route = live_chat_route
|
||||
|
||||
self.routes = {
|
||||
"/v1/chat": ("POST", self.chat_send),
|
||||
@@ -46,6 +49,7 @@ class OpenApiRoute(Route):
|
||||
}
|
||||
self.register_routes()
|
||||
self.app.websocket("/api/v1/chat/ws")(self.chat_ws)
|
||||
self.app.websocket("/api/v1/live/ws")(self.live_ws)
|
||||
|
||||
@staticmethod
|
||||
def _resolve_open_username(
|
||||
@@ -534,6 +538,39 @@ class OpenApiRoute(Route):
|
||||
except Exception as e:
|
||||
logger.debug("Open API WS connection closed: %s", e)
|
||||
|
||||
async def live_ws(self) -> None:
|
||||
authed, auth_err = await self._authenticate_chat_ws_api_key()
|
||||
if not authed:
|
||||
await self._send_chat_ws_error(auth_err or "Unauthorized", "UNAUTHORIZED")
|
||||
await websocket.close(1008, auth_err or "Unauthorized")
|
||||
return
|
||||
|
||||
username, username_err = self._resolve_open_username(
|
||||
websocket.args.get("username")
|
||||
)
|
||||
if username_err or not username:
|
||||
await self._send_chat_ws_error(
|
||||
username_err or "Invalid username",
|
||||
"BAD_USER",
|
||||
)
|
||||
await websocket.close(1008, username_err or "Invalid username")
|
||||
return
|
||||
|
||||
ct = websocket.args.get("ct")
|
||||
force_ct = ct.strip() if isinstance(ct, str) and ct.strip() else "live"
|
||||
if force_ct not in {"live", "chat"}:
|
||||
await self._send_chat_ws_error(
|
||||
"ct must be 'live' or 'chat'",
|
||||
"INVALID_MESSAGE",
|
||||
)
|
||||
await websocket.close(1008, "Invalid ct")
|
||||
return
|
||||
|
||||
await self.live_chat_route.run_ws_session(
|
||||
username=username,
|
||||
force_ct=force_ct,
|
||||
)
|
||||
|
||||
async def upload_file(self):
|
||||
return await self.chat_route.post_file()
|
||||
|
||||
|
||||
@@ -5,7 +5,8 @@ import os
|
||||
import ssl
|
||||
import traceback
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import aiohttp
|
||||
import certifi
|
||||
@@ -352,6 +353,34 @@ class PluginRoute(Route):
|
||||
logger.warning(f"获取插件 Logo 失败: {e}")
|
||||
return None
|
||||
|
||||
def _resolve_plugin_dir(self, plugin) -> Path | None:
|
||||
if not plugin.root_dir_name:
|
||||
return None
|
||||
|
||||
base_dir = Path(
|
||||
self.plugin_manager.reserved_plugin_path
|
||||
if plugin.reserved
|
||||
else self.plugin_manager.plugin_store_path
|
||||
)
|
||||
plugin_dir = base_dir / plugin.root_dir_name
|
||||
if not plugin_dir.is_dir():
|
||||
return None
|
||||
return plugin_dir
|
||||
|
||||
def _get_plugin_installed_at(self, plugin) -> str | None:
|
||||
plugin_dir = self._resolve_plugin_dir(plugin)
|
||||
if plugin_dir is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
return datetime.fromtimestamp(
|
||||
plugin_dir.stat().st_mtime,
|
||||
timezone.utc,
|
||||
).isoformat()
|
||||
except OSError as exc:
|
||||
logger.warning(f"获取插件安装时间失败 {plugin.name}: {exc!s}")
|
||||
return None
|
||||
|
||||
async def get_plugins(self):
|
||||
_plugin_resp = []
|
||||
plugin_name = request.args.get("name")
|
||||
@@ -377,6 +406,7 @@ class PluginRoute(Route):
|
||||
"logo": f"/api/file/{logo_url}" if logo_url else None,
|
||||
"support_platforms": plugin.support_platforms,
|
||||
"astrbot_version": plugin.astrbot_version,
|
||||
"installed_at": self._get_plugin_installed_at(plugin),
|
||||
}
|
||||
# 检查是否为全空的幽灵插件
|
||||
if not any(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user