Compare commits

..

1 Commits
dev ... v4.22.3

Author SHA1 Message Date
Soulter
7ab750c088 chore: bump version to 4.22.3 2026-04-05 01:50:12 +08:00
1414 changed files with 54464 additions and 214665 deletions

View File

@@ -16,11 +16,8 @@ venv*/
ENV/
.conda/
dashboard/
!astrbot/dashboard/
!astrbot/dashboard/dist/
!astrbot/dashboard/dist/**
data/
tests/
.ruff_cache/
.astrbot
astrbot.lock
astrbot.lock

View File

@@ -1,184 +0,0 @@
# ==========================================
# AstrBot Instance Configuration: ${INSTANCE_NAME}
# AstrBot 实例配置文件:${INSTANCE_NAME}
# ==========================================
# 将此文件复制为 .env 并根据需要修改。
# Copy this file to .env and modify as needed.
# 注意:在此处设置的变量将覆盖默认配置。
# Note: Variables set here override application defaults.
# ------------------------------------------
# 实例标识 / Instance Identity
# ------------------------------------------
# 实例名称(用于日志和服务名)
# Instance name (used in logs/service names)
INSTANCE_NAME="${INSTANCE_NAME}"
# ------------------------------------------
# 核心配置 / Core Configuration
# ------------------------------------------
# AstrBot 根目录路径
# AstrBot root directory path
# 默认 Default: 当前工作目录,桌面客户端为 ~/.astrbot服务器为 /var/lib/astrbot/<instance>/
# 示例 Example: /var/lib/astrbot/mybot
ASTRBOT_ROOT="${ASTRBOT_ROOT}"
# 日志等级
# Log level
# 可选值 Values: DEBUG, INFO, WARNING, ERROR, CRITICAL
# 默认 Default: INFO
# ASTRBOT_LOG_LEVEL=INFO
# 启用插件热重载(开发时有用)
# Enable plugin hot reload (useful for development)
# 可选值 Values: 0 (禁用 disabled), 1 (启用 enabled)
# 默认 Default: 0
# ASTRBOT_RELOAD=0
# 禁用匿名使用统计
# Disable anonymous usage statistics
# 可选值 Values: 0 (启用统计 enabled), 1 (禁用统计 disabled)
# 默认 Default: 0
ASTRBOT_DISABLE_METRICS=0
# 覆盖 Python 可执行文件路径(用于本地代码执行功能)
# Override Python executable path (for local code execution)
# 示例 Example: /usr/bin/python3, /home/user/.pyenv/shims/python
# PYTHON=/usr/bin/python3
# 启用演示模式(可能限制部分功能)
# Enable demo mode (may restrict certain features)
# 可选值 Values: True, False
# 默认 Default: False
# DEMO_MODE=False
# 启用测试模式(影响日志和部分行为)
# Enable testing mode (affects logging and behavior)
# 可选值 Values: True, False
# 默认 Default: False
# TESTING=False
# 标记:是否通过桌面客户端执行(主要用于内部)
# Flag: running via desktop client (internal use)
# 可选值 Values: 0, 1
# ASTRBOT_DESKTOP_CLIENT=0
# 标记:是否通过 systemd 服务执行
# Flag: running via systemd service
# 可选值 Values: 0, 1
ASTRBOT_SYSTEMD=1
# ------------------------------------------
# 管理面板配置 / Dashboard Configuration
# ------------------------------------------
# 启用或禁用 WebUI 管理面板
# Enable or disable WebUI dashboard
# 可选值 Values: True, False
# 默认 Default: True
ASTRBOT_DASHBOARD_ENABLE=True
# 允许跨域请求的来源域名(多个用逗号分隔,允许所有则用 *
# Allowed CORS origins for WebUI dashboard (comma-separated, or * for all)
# 示例 Example: https://dash.astrbot.men
# 默认 Default: *
# ASTRBOT_CORS_ALLOW_ORIGIN="*"
# ------------------------------------------
# 国际化配置 / Internationalization Configuration
# ------------------------------------------
# CLI 界面语言
# CLI interface language
# 可选值 Values: zh (中文), en (英文)
# 默认 Default: zh (跟随系统 locale / follows system locale)
# ASTRBOT_CLI_LANG=zh
# ------------------------------------------
# 网络配置 / Network Configuration
# ------------------------------------------
# API 绑定主机
# API bind host
# 示例 Example: 0.0.0.0 (所有接口 all interfaces), 127.0.0.1 (仅本地 localhost only)
ASTRBOT_HOST="${ASTRBOT_HOST}"
# API 绑定端口
# API bind port
# 示例 Example: 3000, 6185, 8080
ASTRBOT_PORT="${ASTRBOT_PORT}"
# 是否为 API 启用 SSL/TLS
# Enable SSL/TLS for API
# 可选值 Values: true, false
# 默认 Default: false
ASTRBOT_SSL_ENABLE=false
# SSL 证书路径PEM 格式)
# SSL certificate path (PEM format)
# 示例 Example: /etc/astrbot/certs/myinstance/fullchain.pem
ASTRBOT_SSL_CERT=""
# SSL 私钥路径PEM 格式)
# SSL private key path (PEM format)
# 示例 Example: /etc/astrbot/certs/myinstance/privkey.pem
ASTRBOT_SSL_KEY=""
# SSL CA 证书链路径(可选,用于客户端验证)
# SSL CA certificates bundle (optional, for client verification)
# 示例 Example: /etc/ssl/certs/ca-certificates.crt
ASTRBOT_SSL_CA_CERTS=""
# ------------------------------------------
# 代理配置 / Proxy Configuration
# ------------------------------------------
# HTTP 代理地址
# HTTP proxy URL
# 示例 Example: http://127.0.0.1:7890, socks5://127.0.0.1:1080
# http_proxy=
# HTTPS 代理地址
# HTTPS proxy URL
# 示例 Example: http://127.0.0.1:7890, socks5://127.0.0.1:1080
# https_proxy=
# 不走代理的主机列表(逗号分隔)
# Hosts to bypass proxy (comma-separated)
# 示例 Example: localhost,127.0.0.1,192.168.0.0/16,.local
# no_proxy=localhost,127.0.0.1
# ------------------------------------------
# 第三方集成 / Third-party Integrations
# ------------------------------------------
# 阿里云 DashScope API 密钥(用于 Rerank 服务)
# Alibaba DashScope API Key (for Rerank service)
# 获取地址 Get from: https://dashscope.console.aliyun.com/
# 示例 Example: sk-xxxxxxxxxxxx
# DASHSCOPE_API_KEY=
# Coze 集成
# Coze integration
# 获取地址 Get from: https://www.coze.com/
# COZE_API_KEY=
# COZE_BOT_ID=
# 计算机控制相关的数据目录(用于截图/文件存储)
# Computer control data directory (for screenshots/file storage)
# 示例 Example: /var/lib/astrbot/bay_data
# BAY_DATA_DIR=
# ------------------------------------------
# 平台特定配置 / Platform-specific Configuration
# ------------------------------------------
# QQ 官方机器人测试模式开关
# QQ official bot test mode
# 可选值 Values: on, off
# 默认 Default: off
# TEST_MODE=off
# End of template / 模板结束

2
.envrc
View File

@@ -1,2 +0,0 @@
git pull
git status

View File

@@ -1,4 +1,4 @@
name: Build and Deploy AstrBot Docs
name: release
on:
push:
@@ -11,22 +11,16 @@ jobs:
runs-on: ubuntu-latest # 运行环境
steps:
- name: checkout
uses: actions/checkout@v7
- name: Setup pnpm
uses: pnpm/action-setup@v6.0.9
with:
version: 10.28.2
- name: Setup Node.js
uses: actions/checkout@v6
- name: nodejs installation
uses: actions/setup-node@v6
with:
node-version: "24.13.0"
cache: "pnpm"
cache-dependency-path: docs/pnpm-lock.yaml
- name: Install dependencies
run: pnpm install --frozen-lockfile
working-directory: './docs'
- name: Build docs
run: pnpm run docs:build
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

View File

@@ -12,7 +12,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v7
uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6

View File

@@ -56,7 +56,7 @@ jobs:
# your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages
steps:
- name: Checkout repository
uses: actions/checkout@v7
uses: actions/checkout@v6
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL

View File

@@ -17,7 +17,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v7
uses: actions/checkout@v6
with:
fetch-depth: 0
@@ -41,6 +41,6 @@ jobs:
- name: Upload results to Codecov
if: github.repository == 'AstrBotDevs/AstrBot'
uses: codecov/codecov-action@v7
uses: codecov/codecov-action@v6
with:
token: ${{ secrets.CODECOV_TOKEN }}

View File

@@ -12,25 +12,19 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v7
- name: Setup pnpm
uses: pnpm/action-setup@v6.0.9
with:
version: 10.28.2
uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: '24.13.0'
cache: "pnpm"
cache-dependency-path: dashboard/pnpm-lock.yaml
- name: Install and Build
working-directory: dashboard
- name: npm install, build
run: |
pnpm install --frozen-lockfile
pnpm lint:check
cd dashboard
npm install pnpm -g
pnpm install
pnpm i --save-dev @types/markdown-it
pnpm run build
- name: Inject Commit SHA

View File

@@ -20,7 +20,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v7
uses: actions/checkout@v6
with:
fetch-depth: 1
fetch-tag: true
@@ -46,21 +46,14 @@ jobs:
- name: Build Dashboard
run: |
dashboard_version=$(python3 - <<'PY'
import tomllib
with open("pyproject.toml", "rb") as f:
print("v" + tomllib.load(f)["project"]["version"])
PY
)
cd dashboard
npm install
npm run build
mkdir -p dist/assets
echo "$dashboard_version" > dist/assets/version
echo $(git rev-parse HEAD) > dist/assets/version
cd ..
mkdir -p astrbot/dashboard
rm -rf astrbot/dashboard/dist
cp -r dashboard/dist astrbot/dashboard/dist
mkdir -p data
cp -r dashboard/dist data/
- name: Determine test image tags
id: test-meta
@@ -71,20 +64,20 @@ jobs:
echo "build_date=$build_date" >> $GITHUB_OUTPUT
- name: Set QEMU
uses: docker/setup-qemu-action@v4.1.0
uses: docker/setup-qemu-action@v4.0.0
- name: Set Docker Buildx
uses: docker/setup-buildx-action@v4.1.0
uses: docker/setup-buildx-action@v4.0.0
- name: Log in to DockerHub
uses: docker/login-action@v4.2.0
uses: docker/login-action@v4.0.0
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
- name: Login to GitHub Container Registry
if: env.HAS_GHCR_TOKEN == 'true'
uses: docker/login-action@v4.2.0
uses: docker/login-action@v4.0.0
with:
registry: ghcr.io
username: ${{ env.GHCR_OWNER }}
@@ -105,7 +98,7 @@ jobs:
echo "EOF" >> $GITHUB_OUTPUT
- name: Build and Push Nightly Image
uses: docker/build-push-action@v7.2.0
uses: docker/build-push-action@v7.0.0
with:
context: .
platforms: linux/amd64,linux/arm64
@@ -125,7 +118,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v7
uses: actions/checkout@v6
with:
fetch-depth: 1
fetch-tag: true
@@ -164,34 +157,33 @@ jobs:
npm install
npm run build
mkdir -p dist/assets
echo "${{ steps.release-meta.outputs.version }}" > dist/assets/version
echo $(git rev-parse HEAD) > dist/assets/version
cd ..
mkdir -p astrbot/dashboard
rm -rf astrbot/dashboard/dist
cp -r dashboard/dist astrbot/dashboard/dist
mkdir -p data
cp -r dashboard/dist data/
- name: Set QEMU
uses: docker/setup-qemu-action@v4.1.0
uses: docker/setup-qemu-action@v4.0.0
- name: Set Docker Buildx
uses: docker/setup-buildx-action@v4.1.0
uses: docker/setup-buildx-action@v4.0.0
- name: Log in to DockerHub
uses: docker/login-action@v4.2.0
uses: docker/login-action@v4.0.0
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
- name: Login to GitHub Container Registry
if: env.HAS_GHCR_TOKEN == 'true'
uses: docker/login-action@v4.2.0
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@v7.2.0
uses: docker/build-push-action@v7.0.0
with:
context: .
platforms: linux/amd64,linux/arm64

54
.github/workflows/pr-title-check.yml vendored Normal file
View File

@@ -0,0 +1,54 @@
name: PR Title Check
on:
pull_request_target:
types: [opened, edited, reopened, synchronize]
jobs:
title-format:
if: github.repository == 'AstrBotDevs/AstrBot'
runs-on: ubuntu-latest
permissions:
pull-requests: write
issues: write
steps:
- name: Validate PR title
uses: actions/github-script@v8
with:
script: |
const title = (context.payload.pull_request.title || "").trim();
// allow only:
// feat: xxx
// feat(scope): xxx
const pattern = /^(feat)(\([a-z0-9-]+\))?:\s.+$/i;
const isValid = pattern.test(title);
const isSameRepo =
context.payload.pull_request.head.repo.full_name === context.payload.repository.full_name;
if (!isValid) {
if (isSameRepo) {
try {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.payload.pull_request.number,
body: [
"⚠️ PR title format check failed.",
"Required formats:",
"- `feat: xxx`",
"- `feat(scope): xxx`",
"Please update your PR title and push again."
].join("\n")
});
} catch (e) {
core.warning(`Failed to post PR title comment: ${e.message}`);
}
} else {
core.warning("Fork PR: comment permission is restricted; skip posting review comment.");
}
}
if (!isValid) {
core.setFailed("Invalid PR title. Expected format: feat: xxx or feat(scope): xxx.");
}

View File

@@ -1,4 +1,4 @@
name: Release AstrBot
name: Release
on:
push:
@@ -28,7 +28,7 @@ jobs:
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
steps:
- name: Checkout repository
uses: actions/checkout@v7
uses: actions/checkout@v6
with:
fetch-depth: 0
ref: ${{ inputs.ref || github.ref }}
@@ -51,35 +51,26 @@ jobs:
echo "tag=$tag" >> "$GITHUB_OUTPUT"
- name: Setup pnpm
uses: pnpm/action-setup@v6.0.9
uses: pnpm/action-setup@v5.0.0
with:
version: 10.28.2
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: "24.13.0"
node-version: '24.13.0'
cache: "pnpm"
cache-dependency-path: dashboard/pnpm-lock.yaml
- name: Build dashboard dist
shell: bash
working-directory: dashboard
run: |
pnpm install --frozen-lockfile
pnpm run build
echo "${{ steps.tag.outputs.tag }}" > dist/assets/version
pnpm --dir dashboard install --frozen-lockfile
pnpm --dir dashboard run build
echo "${{ steps.tag.outputs.tag }}" > dashboard/dist/assets/version
cd dashboard
zip -r "AstrBot-${{ steps.tag.outputs.tag }}-dashboard.zip" dist
- name: Build core package
shell: bash
run: |
git archive \
--format=zip \
--prefix="AstrBot-${{ steps.tag.outputs.tag }}/" \
--output="AstrBot-${{ steps.tag.outputs.tag }}-core.zip" \
HEAD
- name: Upload dashboard artifact
uses: actions/upload-artifact@v7
with:
@@ -87,12 +78,11 @@ jobs:
if-no-files-found: error
path: dashboard/AstrBot-${{ steps.tag.outputs.tag }}-dashboard.zip
- name: Upload release packages to Cloudflare R2
- name: Upload dashboard package to Cloudflare R2
if: ${{ env.R2_ACCOUNT_ID != '' && env.R2_ACCESS_KEY_ID != '' && env.R2_SECRET_ACCESS_KEY != '' }}
env:
R2_BUCKET_NAME: "astrbot"
DASHBOARD_LATEST_OBJECT_NAME: "astrbot-webui-latest.zip"
CORE_LATEST_OBJECT_NAME: "astrbot-core-latest.zip"
R2_OBJECT_NAME: "astrbot-webui-latest.zip"
VERSION_TAG: ${{ steps.tag.outputs.tag }}
shell: bash
run: |
@@ -108,18 +98,11 @@ jobs:
endpoint = https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com
EOF
cp "dashboard/AstrBot-${VERSION_TAG}-dashboard.zip" "dashboard/${DASHBOARD_LATEST_OBJECT_NAME}"
rclone copy "dashboard/${DASHBOARD_LATEST_OBJECT_NAME}" "r2:${R2_BUCKET_NAME}" --progress
cp "dashboard/AstrBot-${VERSION_TAG}-dashboard.zip" "dashboard/${R2_OBJECT_NAME}"
rclone copy "dashboard/${R2_OBJECT_NAME}" "r2:${R2_BUCKET_NAME}" --progress
cp "dashboard/AstrBot-${VERSION_TAG}-dashboard.zip" "dashboard/astrbot-webui-${VERSION_TAG}.zip"
rclone copy "dashboard/astrbot-webui-${VERSION_TAG}.zip" "r2:${R2_BUCKET_NAME}" --progress
cp "AstrBot-${VERSION_TAG}-core.zip" "${CORE_LATEST_OBJECT_NAME}"
rclone copy "${CORE_LATEST_OBJECT_NAME}" "r2:${R2_BUCKET_NAME}" --progress
cp "AstrBot-${VERSION_TAG}-core.zip" "astrbot-core-${VERSION_TAG}.zip"
rclone copy "astrbot-core-${VERSION_TAG}.zip" "r2:${R2_BUCKET_NAME}" --progress
rclone copyto "AstrBot-${VERSION_TAG}-core.zip" "r2:${R2_BUCKET_NAME}/astrbot-core/${VERSION_TAG}/source.zip" --progress
rclone copyto "AstrBot-${VERSION_TAG}-core.zip" "r2:${R2_BUCKET_NAME}/download/astrbot-core/${VERSION_TAG}/source.zip" --progress
publish-release:
name: Publish GitHub Release
if: github.repository == 'AstrBotDevs/AstrBot'
@@ -128,7 +111,7 @@ jobs:
- build-dashboard
steps:
- name: Checkout repository
uses: actions/checkout@v7
uses: actions/checkout@v6
with:
fetch-depth: 0
ref: ${{ inputs.ref || github.ref }}
@@ -156,6 +139,7 @@ jobs:
name: Dashboard-${{ steps.tag.outputs.tag }}
path: release-assets
- name: Resolve release notes
id: notes
shell: bash
@@ -207,7 +191,7 @@ jobs:
- publish-release
steps:
- name: Checkout repository
uses: actions/checkout@v7
uses: actions/checkout@v6
with:
fetch-depth: 0
ref: ${{ inputs.ref || github.ref }}

View File

@@ -5,9 +5,9 @@ on:
branches:
- master
paths-ignore:
- "README*.md"
- "changelogs/**"
- "dashboard/**"
- 'README*.md'
- 'changelogs/**'
- 'dashboard/**'
pull_request:
workflow_dispatch:
@@ -16,18 +16,18 @@ jobs:
name: Run smoke tests
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout
uses: actions/checkout@v7
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.12"
python-version: '3.12'
- name: Install UV package manager
run: |
pip install uv
@@ -40,9 +40,6 @@ jobs:
- name: Run smoke tests
run: |
uv run main.py &
# uv tool install -e . --force
# astrbot init -y
# astrbot run --backend-only &
APP_PID=$!
echo "Waiting for application to start..."

View File

@@ -1,4 +1,4 @@
name: Sync AstrBot Docs to GitHub Wiki
name: sync wiki
on:
workflow_dispatch:
@@ -31,7 +31,7 @@ jobs:
exit 1
- name: Check out docs repository
uses: actions/checkout@v7
uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6

View File

@@ -19,7 +19,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v7
uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6

33
.gitignore vendored
View File

@@ -1,5 +1,6 @@
# Python related
__pycache__
.mypy_cache
.venv*
.conda/
uv.lock
@@ -50,46 +51,16 @@ astrbot.lock
chroma
venv/*
pytest.ini
AGENTS.md
IFLOW.md
CLAUDE.md
# genie_tts data
CharacterModels/
GenieData/
.agent/
.codex/
.claude/
.opencode/
.kilocode/
.serena
.worktrees/
.astrbot_sdk_testing/
.env
dashboard/warker.js
dashboard/bun.lock
.pua/
# Rust build artifacts
rust/target/
# Build outputs
dist/
*.whl
# 拓展模块
*.so
*.dll
# MDI font subset (generated by dashboard/scripts/subset-mdi-font.mjs)
dashboard/src/assets/mdi-subset/*.woff
dashboard/src/assets/mdi-subset/*.woff2
.planning
*cache
node_modules
*pinokio*
dashboard/pnpm-lock.yaml
.obsidian
dashboard/.codex
.codex
.zed/settings.json

View File

@@ -6,20 +6,20 @@ ci:
autoupdate_schedule: weekly
autoupdate_commit_msg: ":balloon: pre-commit autoupdate"
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version.
rev: v0.15.7
hooks:
# Run the linter.
- id: ruff-check
types_or: [python, pyi]
args: [--fix]
# Run the formatter.
- id: ruff-format
types_or: [python, pyi]
- repo: https://github.com/asottile/pyupgrade
rev: v3.21.2
hooks:
- id: pyupgrade
args: [--py312-plus]
- repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version.
rev: v0.14.1
hooks:
# Run the linter.
- id: ruff-check
types_or: [ python, pyi ]
args: [ --fix ]
# Run the formatter.
- id: ruff-format
types_or: [ python, pyi ]
- repo: https://github.com/asottile/pyupgrade
rev: v3.21.0
hooks:
- id: pyupgrade
args: [--py310-plus]

View File

@@ -1 +1 @@
3.12
3.12

249
AGENTS.md
View File

@@ -3,10 +3,8 @@
### Core
```
uv tool install -e . --force
astrbot init
astrbot run # start the bot
astrbot run --backend-only # start the backend only
uv sync
uv run main.py
```
Exposed an API server on `http://localhost:6185` by default.
@@ -15,243 +13,22 @@ Exposed an API server on `http://localhost:6185` by default.
```
cd dashboard
bun install # First time only.
bun dev
pnpm install # First time only. Use npm install -g pnpm if pnpm is not installed.
pnpm dev
```
Runs on `http://localhost:3000` by default.
## Pre-commit setup
AstrBot uses [pre-commit](https://pre-commit.com/) hooks to automatically format and lint Python code before each commit. The hooks run `ruff check`, `ruff format`, and `pyupgrade` (see [`.pre-commit-config.yaml`](.pre-commit-config.yaml) for details).
To set it up:
```bash
pip install pre-commit
pre-commit install
```
After installation, the hooks will run automatically on `git commit`. You can also run them manually at any time:
```bash
ruff format .
ruff check .
```
> **Note:** If you use VSCode, install the `Ruff` extension for real-time formatting and linting in the editor.
## Dev environment tips
- **Main entry**: `astrbot/__main__.py` or via CLI `astrbot run`
- **CLI commands**: `astrbot/cli/commands/`
- **Core modules**: `astrbot/core/`
- **Platform adapters**: `astrbot/core/platform/sources/`
- **Star plugins**: `astrbot/builtin_stars/`
- **Dashboard**: `dashboard/` (Vue.js frontend)
1. When modifying the WebUI, be sure to maintain componentization and clean code. Avoid duplicate code.
2. Do not add any report files such as xxx_SUMMARY.md.
3. After finishing, use `ruff format .` and `ruff check .` to format and check the code.
4. When committing, ensure to use conventional commits messages, such as `feat: add new agent for data analysis` or `fix: resolve bug in provider manager`.
5. Use English for all new comments.
6. For path handling, use `pathlib.Path` instead of string paths, and use `astrbot.core.utils.path_utils` to get the AstrBot data and temp directory.
## File Organization
## PR instructions
```
astrbot/
├── __main__.py # Main entry point
├── __init__.py # Package init, exports
├── cli/ # CLI commands
│ └── commands/ # Individual command modules
├── core/ # Core functionality
│ ├── agent/ # Agent execution
│ ├── platform/ # Platform adapters
│ ├── pipeline/ # Message processing
│ ├── star/ # Plugin system
│ └── config/ # Configuration
├── builtin_stars/ # Built-in plugins
├── dashboard/ # Vue.js frontend
└── utils/ # Utilities
```
## Architecture
### Core Components
- `astrbot/core/` - Core bot functionality
- `astrbot/core/platform/` - Platform adapter system
- `astrbot/core/agent/` - Agent execution logic
- `astrbot/core/star/` - Plugin/Star handler system
- `astrbot/core/pipeline/` - Message processing pipeline
- `astrbot/cli/` - Command-line interface
### Important Utilities
```python
from astrbot.core.utils.astrbot_path import (
get_astrbot_root, # AstrBot root directory
get_astrbot_data_path, # Data directory
get_astrbot_config_path, # Config directory
get_astrbot_plugin_path, # Plugin directory
get_astrbot_temp_path, # Temp directory
get_astrbot_skills_path, # Skills directory
)
```
### Platform Adapters
Platform adapters are in `astrbot/core/platform/sources/`:
- Each adapter extends base platform classes
- Use `@register_platform_adapter` decorator
- Events flow through `commit_event()` to message queue
### Star (Plugin) System
Stars are plugins in `astrbot/builtin_stars/`:
- Extend `Star` base class
- Use decorators for command handlers: `@star.on_command`, `@star.on_message`, etc.
- Access via `context` object
## Code Style
1. **Type hints required** - Use Python 3.12+ syntax:
- `list[str]` not `List[str]`
- `int | None` not `Optional[int]`
- Avoid `Any` when possible. Use proper `TypedDict`, `dataclass`, or `Protocol` instead.
- When encountering dict access issues (e.g., `msg.get("key")` where type inference is wrong), define a `TypedDict` with `total=False` to explicitly declare allowed keys.
Good example:
```python
class MessageComponent(TypedDict, total=False):
type: str
text: str
path: str
```
Bad example (avoid):
```python
msg: Any = something
msg = cast(dict, msg)
```
2. **Path handling** - Always use `pathlib.Path`:
```python
from pathlib import Path
# Use astrbot.core.utils.path_utils for data/temp directories
from astrbot.core.utils.path_utils import get_astrbot_data_path
```
3. **Formatting** - Run before committing:
```bash
ruff format .
ruff check .
```
4. **Comments** - Use English for all comments and docstrings
5. **Imports** - Use absolute imports via `astrbot.` prefix
### Environment Variables
When adding new environment variables:
1. Use `ASTRBOT_` prefix: `ASTRBOT_ENABLE_FEATURE`
2. Add to `.env.example` with description
3. Update `astrbot/cli/commands/cmd_run.py`:
- Add to module docstring under "Environment Variables Used in Project"
- Add to `keys_to_print` list for debug output
## Testing
1. Tests go in `tests/` directory
2. Use `pytest` with `pytest-asyncio`
3. Run: `uv sync --group dev && uv run pytest --cov=astrbot tests/`
4. Test files: `test_*.py` or `*_test.py`
### Code Quality Scoring Test
The project enforces a **code quality score** via `tests/test_code_quality_typing.py`. All agents must treat this as a hard constraint when modifying code.
**Run the test:**
```bash
uv run pytest tests/test_code_quality_typing.py -v
```
**Scoring rules (target: 100/100, threshold for PASS: 80/100):**
| Pattern | Cost |
|---------|------|
| `cast(Any, ...)` | -1 pt each |
| `# type: ignore` | -0.5 pt each |
| **BAD** `# type: ignore[...]` (unresolved-import, class-alias, no-name-module, attr-defined, etc.) | **-3 pt each** |
| `bare except:` (no exception type) | -0.5 pt each |
| Duplicate code block (5+ identical lines, ≥2 occurrences) | -2 pt each |
**Why bad type: ignore is heavily penalized:**
- `# type: ignore[unresolved-import]` — hides missing module/stub issues
- `# type: ignore[class-alias]` — hides improper type alias patterns
- `# type: ignore[attr-defined]` — hides missing attribute errors
- These are **workarounds, not fixes** — they paper over real type errors
**Scoring formula:**
```
score = max(0, 100 - cast_any - type_ignore*0.5 - bad_type_ignore*3 - bare_except*0.5 - dup_blocks*2)
```
**Agent rules when modifying code:**
1. **Do not add** `# type: ignore[unresolved-import]` or `# type: ignore[class-alias]` — fix the underlying issue instead
2. **Do not use** `cast(Any, ...)` to suppress type errors — use proper type annotations
3. **Do not add** bare `except:` clauses — use `except SomeSpecificException:`
4. **Do not copy-paste** 5+ line blocks — extract to a shared helper function
5. Before committing, run the scoring test and ensure score ≥ 80
## Git Conventions
### Commit Messages
Use conventional commits:
```
feat: add new feature
fix: resolve bug
docs: update documentation
refactor: restructure code
test: add tests
chore: maintenance tasks
```
### PR Guidelines
1. Title: conventional commit format
2. Description: English
3. Target branch: `dev`
4. Keep changes focused and atomic
## Project-Specific Guidelines
1. **No report files** - Do not add `xxx_SUMMARY.md` or similar
2. **Componentization** - Maintain clean code, avoid duplication in WebUI
3. **Backward compatibility** - When deprecating, add warnings
4. **CLI help** - Run `astrbot help --all` to see all commands
5. When modifying frontend/dashboard code, use the project's custom request module `@/utils/request` for HTTP calls
6. For fetch or SSE URLs, use `resolveApiUrl('/api/your-path')` so the configured `VITE_API_BASE` and dev proxy rules are respected
7. Do not import the plain `axios` package directly in dashboard source files
## Common Tasks
### Adding a new platform adapter
1. Create adapter in `astrbot/core/platform/sources/`
2. Extend `Platform` base class
3. Use `@register_platform_adapter` decorator
4. Implement required methods: `run()`, `convert_message()`, `meta()`
### Adding a new command
1. Add to appropriate module in `cli/commands/`
2. Register with `@click.command()`
3. Update `astrbot/cli/__main__.py` to add command
### Adding a new Star handler
1. Create in `astrbot/builtin_stars/` or as plugin
2. Extend `Star` class
3. Use decorators: `@star.on_command()`, `@star.on_schedule()`, etc.
## Release versions
1. Replace current version name to specific version name.
2. Write changelog in `changelogs/`, you can refer to the full commit messages between the latest tag to the latest commit.
3. Make and push a commit into master branch with message format like: `chore: bump version to 4.25.0`
4. Create a tag and push the tag. For example: `git tag v4.25.0 && git push origin v4.25.0`
1. Title format: use conventional commit messages
2. Use English to write PR title and descriptions.

View File

@@ -16,7 +16,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
gnupg \
git \
ripgrep \
&& curl -fsSL https://deb.nodesource.com/setup_lts.x | bash - \
&& apt-get install -y --no-install-recommends nodejs \
&& apt-get clean \

View File

@@ -11,6 +11,4 @@ As of now, AstrBot has **no commercial services of any kind**, and the official
If anyone asks you to pay while using AstrBot, **you are likely being scammed**. Please request a refund immediately and report it to us by email.
📊 Please read the [End User License Agreement](https://github.com/AstrBotDevs/AstrBot/blob/master/EULA.md) carefully before using this project. By installing, you agree to all its contents.
📮 Official email: [community@astrbot.app](mailto:community@astrbot.app)

View File

@@ -11,6 +11,4 @@ AstrBot 是受 AGPLv3 开源协议保护的**免费开源软件项目**,您可
如果您在使用 AstrBot 的过程中被要求付费,**表明您已经遭遇诈骗行为**。请立即向相关方申请退款,并及时通过邮件向我们反馈。
📊 在使用本项目之前,请仔细阅读 [最终用户许可协议](https://github.com/AstrBotDevs/AstrBot/blob/master/EULA.md)。安装即表示您已阅读并同意其中的全部内容。
📮 官方邮箱:[community@astrbot.app](mailto:community@astrbot.app)

View File

@@ -1,16 +0,0 @@
## Добро пожаловать в AstrBot
🌟 Спасибо, что используете AstrBot!
AstrBot — это Agentic AI-ассистент для личных и групповых чатов с поддержкой множества IM-платформ и широким набором встроенных функций. Надеемся, что он сделает ваше общение эффективным и приятным. ❤️
Важное уведомление:
AstrBot — это **бесплатный проект с открытым исходным кодом**, защищённый лицензией AGPLv3. Полный исходный код и связанные ресурсы доступны на [**официальном сайте**](https://astrbot.app) и [**GitHub**](https://github.com/astrbotdevs/astrbot).
На данный момент AstrBot **не предоставляет никаких коммерческих услуг**, и официальная команда **никогда не будет взимать плату с пользователей** под каким-либо названием.
Если кто-то просит вас заплатить при использовании AstrBot, **вас, скорее всего, пытаются обмануть**. Немедленно запросите возврат средств и сообщите нам по электронной почте.
📊 Пожалуйста, внимательно прочитайте [Лицензионное соглашение](https://github.com/AstrBotDevs/AstrBot/blob/master/EULA.md) перед использованием. Устанавливая программу, вы соглашаетесь со всеми его условиями.
📮 Официальная почта: [community@astrbot.app](mailto:community@astrbot.app)

View File

@@ -1,5 +1,4 @@
![astrbot-github-banner-v2-light-0405_副本](https://github.com/user-attachments/assets/36fb04e4-cc75-4454-bd8b-049d11aa86f9)
![AstrBot-Logo-Simplified](https://github.com/user-attachments/assets/ffd99b6b-3272-4682-beaa-6fe74250f7d9)
<div align="center">
@@ -12,7 +11,7 @@
<br>
<div>
<a href="https://trendshift.io/repositories/21369" target="_blank"><img src="https://trendshift.io/api/badge/repositories/21369" alt="AstrBotDevs%2FAstrBot | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
<a href="https://trendshift.io/repositories/12875" target="_blank"><img src="https://trendshift.io/api/badge/repositories/12875" alt="Soulter%2FAstrBot | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
<a href="https://hellogithub.com/repository/AstrBotDevs/AstrBot" target="_blank"><img src="https://api.hellogithub.com/v1/widgets/recommend.svg?rid=d127d50cd5e54c5382328acc3bb25483&claim_uid=ZO9by7qCXgSd6Lp&t=2" alt="FeaturedHelloGitHub" style="width: 250px; height: 54px;" width="250" height="54" /></a>
</div>
@@ -77,21 +76,20 @@ AstrBot is an open-source all-in-one Agent chatbot platform that integrates with
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 --python 3.12
uv tool install astrbot
astrbot init # Only execute this command for the first time to initialize the environment
astrbot run
```
> Requires [uv](https://docs.astral.sh/uv/) to be installed.
> AstrBot requires Python 3.12 or later. The `--python 3.12` option ensures that `uv` creates the tool environment with Python 3.12.
> [!NOTE]
> For macOS users: due to macOS security checks, the first run of the `astrbot` command may take longer (about 10-20s).
> 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 --python 3.12
uv tool upgrade astrbot
```
> [!WARNING]
@@ -101,7 +99,7 @@ uv tool upgrade astrbot --python 3.12
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://docs.astrbot.app/deploy/astrbot/docker.html#%E4%BD%BF%E7%94%A8-docker-%E9%83%A8%E7%BD%B2-astrbot).
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
@@ -139,7 +137,7 @@ yay -S astrbot-git
**More deployment methods**
If you need panel-based management or deeper customization, see [BT-Panel Deployment](https://docs.astrbot.app/deploy/astrbot/btpanel.html) for BT Panel app-store setup, [1Panel Deployment](https://docs.astrbot.app/deploy/astrbot/1panel.html) for 1Panel app-market deployment, [CasaOS Deployment](https://docs.astrbot.app/deploy/astrbot/casaos.html) for NAS/home-server visual deployment, and [Manual Deployment](https://docs.astrbot.app/deploy/astrbot/cli.html) for fully custom source-based installation with `uv`.
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
@@ -158,12 +156,10 @@ Connect AstrBot to your favorite chat platform.
| Discord | Official |
| LINE | Official |
| Satori | Official |
| KOOK | Official |
| Misskey | Official |
| Mattermost | Official |
| WhatsApp (Coming Soon) | Official |
| [Matrix](https://github.com/stevessr/astrbot_plugin_matrix_adapter) | Community |
| [Rocket.Chat](https://github.com/NET-Homeless/astrbot_plugin_rocket_chat_adapter) | Community |
| [KOOK](https://github.com/wuyan1003/astrbot_plugin_kook_adapter) | Community |
| [VoceChat](https://github.com/HikariFroya/astrbot_plugin_vocechat) | Community |
## Supported Model Services
@@ -234,6 +230,10 @@ pre-commit install
### QQ Groups
- Group 12: 916228568 (New)
- Group 9: 1076659624 (Full)
- Group 10: 1078079676 (Full)
- Group 11: 704659519 (Full)
- Group 1: 322154837 (Full)
- Group 3: 630166526 (Full)
- Group 4: 1077826412 (Full)
@@ -241,12 +241,6 @@ pre-commit install
- Group 6: 753075035 (Full)
- Group 7: 743746109 (Full)
- Group 8: 1030353265 (Full)
- Group 9: 1076659624 (Full)
- Group 10: 1078079676 (Full)
- Group 11: 704659519 (Full)
- Group 12: 916228568 (Full)
- Group 13: 1092185289
- Group 14: 1103419483
- Developer Group(Chit-chat): 975206796
- Developer Group(Formal): 1039761811
@@ -260,7 +254,7 @@ pre-commit install
Special thanks to all Contributors and plugin developers for their contributions to AstrBot ❤️
<a href="https://github.com/AstrBotDevs/AstrBot/graphs/contributors">
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot&max=300&columns=15" />
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot&max=200&columns=14" />
</a>
Additionally, the birth of this project would not have been possible without the help of the following open-source projects:

View File

@@ -11,7 +11,7 @@
<br>
<div>
<a href="https://trendshift.io/repositories/21369" target="_blank"><img src="https://trendshift.io/api/badge/repositories/21369" alt="AstrBotDevs%2FAstrBot | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
<a href="https://trendshift.io/repositories/12875" target="_blank"><img src="https://trendshift.io/api/badge/repositories/12875" alt="Soulter%2FAstrBot | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
<a href="https://hellogithub.com/repository/AstrBotDevs/AstrBot" target="_blank"><img src="https://api.hellogithub.com/v1/widgets/recommend.svg?rid=d127d50cd5e54c5382328acc3bb25483&claim_uid=ZO9by7qCXgSd6Lp&t=2" alt="FeaturedHelloGitHub" style="width: 250px; height: 54px;" width="250" height="54" /></a>
</div>
@@ -76,13 +76,12 @@ AstrBot est une plateforme de chatbot Agent tout-en-un open source qui s'intègr
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 --python 3.12
uv tool install astrbot
astrbot init # Exécutez cette commande uniquement la première fois pour initialiser l'environnement
astrbot run
```
> [uv](https://docs.astral.sh/uv/) doit être installé.
> AstrBot nécessite Python 3.12 ou une version plus récente. L'option `--python 3.12` garantit que `uv` crée l'environnement tool avec Python 3.12.
> [!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).
@@ -90,7 +89,7 @@ astrbot run
Mettre à jour `astrbot` :
```bash
uv tool upgrade astrbot --python 3.12
uv tool upgrade astrbot
```
> [!WARNING]
@@ -100,7 +99,7 @@ uv tool upgrade astrbot --python 3.12
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://docs.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
@@ -138,7 +137,7 @@ yay -S astrbot-git
**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://docs.astrbot.app/deploy/astrbot/btpanel.html) pour une installation via BT Panel, [Déploiement 1Panel](https://docs.astrbot.app/deploy/astrbot/1panel.html) pour le marketplace 1Panel, [Déploiement CasaOS](https://docs.astrbot.app/deploy/astrbot/casaos.html) pour un déploiement visuel sur NAS/serveur domestique, et [Déploiement manuel](https://docs.astrbot.app/deploy/astrbot/cli.html) pour une installation complète depuis les sources avec `uv`.
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
@@ -157,12 +156,10 @@ Connectez AstrBot à vos plateformes de chat préférées.
| Discord | Officielle |
| LINE | Officielle |
| Satori | Officielle |
| KOOK | Officielle |
| Misskey | Officielle |
| Mattermost | Officielle |
| WhatsApp (Bientôt disponible) | Officielle |
| [Matrix](https://github.com/stevessr/astrbot_plugin_matrix_adapter) | Communauté |
| [Rocket.Chat](https://github.com/NET-Homeless/astrbot_plugin_rocket_chat_adapter) | Communauté |
| [KOOK](https://github.com/wuyan1003/astrbot_plugin_kook_adapter) | Communauté |
| [VoceChat](https://github.com/HikariFroya/astrbot_plugin_vocechat) | Communauté |
## Services de modèles pris en charge
@@ -248,7 +245,7 @@ pre-commit install
Un grand merci à tous les contributeurs et développeurs de plugins pour leurs contributions à AstrBot ❤️
<a href="https://github.com/AstrBotDevs/AstrBot/graphs/contributors">
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot&max=300&columns=15" />
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot&max=200&columns=14" />
</a>
De plus, la naissance de ce projet n'aurait pas été possible sans l'aide des projets open source suivants :

View File

@@ -11,7 +11,7 @@
<br>
<div>
<a href="https://trendshift.io/repositories/21369" target="_blank"><img src="https://trendshift.io/api/badge/repositories/21369" alt="AstrBotDevs%2FAstrBot | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
<a href="https://trendshift.io/repositories/12875" target="_blank"><img src="https://trendshift.io/api/badge/repositories/12875" alt="Soulter%2FAstrBot | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
<a href="https://hellogithub.com/repository/AstrBotDevs/AstrBot" target="_blank"><img src="https://api.hellogithub.com/v1/widgets/recommend.svg?rid=d127d50cd5e54c5382328acc3bb25483&claim_uid=ZO9by7qCXgSd6Lp&t=2" alt="FeaturedHelloGitHub" style="width: 250px; height: 54px;" width="250" height="54" /></a>
</div>
@@ -76,13 +76,12 @@ AstrBot は、主要なインスタントメッセージングアプリと統合
AstrBot を素早く試したいユーザーで、コマンドラインに慣れており `uv` 環境を自分でインストールできる場合は、`uv` のワンクリックデプロイをおすすめします ⚡️:
```bash
uv tool install astrbot --python 3.12
uv tool install astrbot
astrbot init # 初回のみ実行して環境を初期化します
astrbot run
```
> [uv](https://docs.astral.sh/uv/) のインストールが必要です。
> AstrBot には Python 3.12 以降が必要です。`--python 3.12` を指定すると、`uv` は Python 3.12 で tool 環境を作成します。
> [!NOTE]
> macOS ユーザーの場合macOS のセキュリティチェックにより、`astrbot` コマンドの初回実行に時間がかかる場合があります(約 10〜20 秒)。
@@ -90,7 +89,7 @@ astrbot run
`astrbot` の更新:
```bash
uv tool upgrade astrbot --python 3.12
uv tool upgrade astrbot
```
> [!WARNING]
@@ -100,7 +99,7 @@ uv tool upgrade astrbot --python 3.12
コンテナ運用に慣れており、より安定した本番向けのデプロイ方法を求めるユーザーには、Docker / Docker Compose での AstrBot デプロイをおすすめします。
公式ドキュメント [Docker を使用した AstrBot のデプロイ](https://docs.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) をご参照ください。
### 雨云でのデプロイ
@@ -138,7 +137,7 @@ yay -S astrbot-git
**その他のデプロイ方法**
パネル操作での導入やより高度なカスタマイズが必要な場合は、[宝塔パネルデプロイ](https://docs.astrbot.app/deploy/astrbot/btpanel.html)BT Panel 経由の導入)、[1Panel デプロイ](https://docs.astrbot.app/deploy/astrbot/1panel.html)1Panel アプリマーケット経由)、[CasaOS デプロイ](https://docs.astrbot.app/deploy/astrbot/casaos.html)NAS / ホームサーバー向け可視化導入)、[手動デプロイ](https://docs.astrbot.app/deploy/astrbot/cli.html)`uv` とソースベースのフルカスタム導入)を参照してください。
パネル操作での導入やより高度なカスタマイズが必要な場合は、[宝塔パネルデプロイ](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` とソースベースのフルカスタム導入)を参照してください。
## サポートされているメッセージプラットフォーム
@@ -157,12 +156,10 @@ AstrBot をよく使うチャットプラットフォームに接続できます
| Discord | 公式 |
| LINE | 公式 |
| Satori | 公式 |
| KOOK | 公式 |
| Misskey | 公式 |
| Mattermost | 公式 |
| WhatsApp (近日対応予定) | 公式 |
| [Matrix](https://github.com/stevessr/astrbot_plugin_matrix_adapter) | コミュニティ |
| [Rocket.Chat](https://github.com/NET-Homeless/astrbot_plugin_rocket_chat_adapter) | コミュニティ |
| [KOOK](https://github.com/wuyan1003/astrbot_plugin_kook_adapter) | コミュニティ |
| [VoceChat](https://github.com/HikariFroya/astrbot_plugin_vocechat) | コミュニティ |
@@ -249,7 +246,7 @@ pre-commit install
AstrBot への貢献をしていただいたすべてのコントリビューターとプラグイン開発者に特別な感謝を ❤️
<a href="https://github.com/AstrBotDevs/AstrBot/graphs/contributors">
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot&max=300&columns=15" />
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot&max=200&columns=14" />
</a>
また、このプロジェクトの誕生は以下のオープンソースプロジェクトの助けなしには実現できませんでした:

View File

@@ -11,7 +11,7 @@
<br>
<div>
<a href="https://trendshift.io/repositories/21369" target="_blank"><img src="https://trendshift.io/api/badge/repositories/21369" alt="AstrBotDevs%2FAstrBot | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
<a href="https://trendshift.io/repositories/12875" target="_blank"><img src="https://trendshift.io/api/badge/repositories/12875" alt="Soulter%2FAstrBot | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
<a href="https://hellogithub.com/repository/AstrBotDevs/AstrBot" target="_blank"><img src="https://api.hellogithub.com/v1/widgets/recommend.svg?rid=d127d50cd5e54c5382328acc3bb25483&claim_uid=ZO9by7qCXgSd6Lp&t=2" alt="FeaturedHelloGitHub" style="width: 250px; height: 54px;" width="250" height="54" /></a>
</div>
@@ -76,13 +76,12 @@ AstrBot — это универсальная платформа Agent-чатб
Для пользователей, которые хотят быстро попробовать AstrBot, знакомы с командной строкой и могут самостоятельно установить окружение `uv`, мы рекомендуем использовать развёртывание в один клик через `uv` ⚡️:
```bash
uv tool install astrbot --python 3.12
uv tool install astrbot
astrbot init # Выполните эту команду только при первом запуске для инициализации окружения
astrbot run
```
> Требуется установленный [uv](https://docs.astral.sh/uv/).
> Для AstrBot требуется Python 3.12 или новее. Параметр `--python 3.12` гарантирует, что `uv` создаст tool-окружение с Python 3.12.
> [!NOTE]
> Для пользователей macOS: из-за проверок безопасности macOS первый запуск команды `astrbot` может занять больше времени (около 10-20 секунд).
@@ -90,7 +89,7 @@ astrbot run
Обновить `astrbot`:
```bash
uv tool upgrade astrbot --python 3.12
uv tool upgrade astrbot
```
> [!WARNING]
@@ -100,7 +99,7 @@ uv tool upgrade astrbot --python 3.12
Для пользователей, знакомых с контейнерами и которым нужен более стабильный и подходящий для production способ, мы рекомендуем разворачивать AstrBot через Docker / Docker Compose.
См. официальную документацию [Развёртывание AstrBot с Docker](https://docs.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
@@ -138,7 +137,7 @@ yay -S astrbot-git
**Другие способы развёртывания**
Если вам нужна панельная установка или более глубокая кастомизация, смотрите [Развёртывание BT-Panel](https://docs.astrbot.app/deploy/astrbot/btpanel.html) (установка через BT Panel), [Развёртывание 1Panel](https://docs.astrbot.app/deploy/astrbot/1panel.html) (развёртывание через маркетплейс 1Panel), [Развёртывание CasaOS](https://docs.astrbot.app/deploy/astrbot/casaos.html) (визуальный вариант для NAS и домашних серверов) и [Ручное развёртывание](https://docs.astrbot.app/deploy/astrbot/cli.html) (полностью настраиваемая установка из исходников через `uv`).
Если вам нужна панельная установка или более глубокая кастомизация, смотрите [Развёртывание 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`).
## Поддерживаемые платформы обмена сообщениями
@@ -157,12 +156,10 @@ yay -S astrbot-git
| Discord | Официальная |
| LINE | Официальная |
| Satori | Официальная |
| KOOK | Официальная |
| Misskey | Официальная |
| Mattermost | Официальная |
| WhatsApp (Скоро) | Официальная |
| [Matrix](https://github.com/stevessr/astrbot_plugin_matrix_adapter) | Сообщество |
| [Rocket.Chat](https://github.com/NET-Homeless/astrbot_plugin_rocket_chat_adapter) | Сообщество |
| [KOOK](https://github.com/wuyan1003/astrbot_plugin_kook_adapter) | Сообщество |
| [VoceChat](https://github.com/HikariFroya/astrbot_plugin_vocechat) | Сообщество |
## Поддерживаемые сервисы моделей
@@ -248,7 +245,7 @@ pre-commit install
Особая благодарность всем контрибьюторам и разработчикам плагинов за их вклад в AstrBot ❤️
<a href="https://github.com/AstrBotDevs/AstrBot/graphs/contributors">
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot&max=300&columns=15" />
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot&max=200&columns=14" />
</a>
Кроме того, рождение этого проекта было бы невозможно без помощи следующих проектов с открытым исходным кодом:

View File

@@ -11,7 +11,7 @@
<br>
<div>
<a href="https://trendshift.io/repositories/21369" target="_blank"><img src="https://trendshift.io/api/badge/repositories/21369" alt="AstrBotDevs%2FAstrBot | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
<a href="https://trendshift.io/repositories/12875" target="_blank"><img src="https://trendshift.io/api/badge/repositories/12875" alt="Soulter%2FAstrBot | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
<a href="https://hellogithub.com/repository/AstrBotDevs/AstrBot" target="_blank"><img src="https://api.hellogithub.com/v1/widgets/recommend.svg?rid=d127d50cd5e54c5382328acc3bb25483&claim_uid=ZO9by7qCXgSd6Lp&t=2" alt="FeaturedHelloGitHub" style="width: 250px; height: 54px;" width="250" height="54" /></a>
</div>
@@ -32,7 +32,7 @@
<a href="https://astrbot.app/">文件</a>
<a href="https://blog.astrbot.app/">Blog</a>
<a href="https://astrbot.featurebase.app/roadmap">路線圖</a>
<a href="https://github.com/AstrBotDevs/AstrBot/issues">問題回報</a>
<a href="https://github.com/AstrBotDevs/AstrBot/issues">問題回報</a>
<a href="mailto:community@astrbot.app">Email</a>
</div>
@@ -76,13 +76,12 @@ AstrBot 是一個開源的一站式 Agent 聊天機器人平台,可接入主
對於想快速體驗 AstrBot、且熟悉命令列並能自行安裝 `uv` 環境的使用者,我們推薦使用 `uv` 一鍵部署方式 ⚡️。
```bash
uv tool install astrbot --python 3.12
uv tool install astrbot
astrbot init # 僅首次執行此命令以初始化環境
astrbot run
```
> 需要安裝 [uv](https://docs.astral.sh/uv/)。
> AstrBot 需要 Python 3.12 或更高版本。`--python 3.12` 會確保 `uv` 使用 Python 3.12 建立 tool 環境。
> [!NOTE]
> 對於 macOS 使用者:由於 macOS 安全性檢查,首次執行 `astrbot` 指令可能需要較長時間(約 10-20 秒)。
@@ -90,7 +89,7 @@ astrbot run
更新 `astrbot`
```bash
uv tool upgrade astrbot --python 3.12
uv tool upgrade astrbot
```
> [!WARNING]
@@ -100,7 +99,7 @@ uv tool upgrade astrbot --python 3.12
對於熟悉容器、希望獲得更穩定且更適合正式環境部署方式的使用者,我們推薦使用 Docker / Docker Compose 部署 AstrBot。
請參考官方文件 [使用 Docker 部署 AstrBot](https://docs.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)。
### 在雨雲上部署
@@ -138,7 +137,7 @@ yay -S astrbot-git
**更多部署方式**
若你需要面板化或更高自訂程度的部署,可參考 [寶塔面板](https://docs.astrbot.app/deploy/astrbot/btpanel.html)BT Panel 應用商店安裝)、[1Panel](https://docs.astrbot.app/deploy/astrbot/1panel.html)1Panel 應用商店安裝)、[CasaOS](https://docs.astrbot.app/deploy/astrbot/casaos.html)NAS / 家用伺服器可視化部署)與 [手動部署](https://docs.astrbot.app/deploy/astrbot/cli.html)(基於原始碼與 `uv` 的完整自訂安裝)。
若你需要面板化或更高自訂程度的部署,可參考 [寶塔面板](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` 的完整自訂安裝)。
## 支援的訊息平台
@@ -157,12 +156,10 @@ yay -S astrbot-git
| Discord | 官方維護 |
| LINE | 官方維護 |
| Satori | 官方維護 |
| KOOK | 官方維護 |
| Misskey | 官方維護 |
| Mattermost | 官方維護 |
| WhatsApp即將支援 | 官方維護 |
| Whatsapp即將支援 | 官方維護 |
| [Matrix](https://github.com/stevessr/astrbot_plugin_matrix_adapter) | 社群維護 |
| [Rocket.Chat](https://github.com/NET-Homeless/astrbot_plugin_rocket_chat_adapter) | 社群維護 |
| [KOOK](https://github.com/wuyan1003/astrbot_plugin_kook_adapter) | 社群維護 |
| [VoceChat](https://github.com/HikariFroya/astrbot_plugin_vocechat) | 社群維護 |
## 支援的模型服務
@@ -248,7 +245,7 @@ pre-commit install
特別感謝所有 Contributors 和外掛開發者對 AstrBot 的貢獻 ❤️
<a href="https://github.com/AstrBotDevs/AstrBot/graphs/contributors">
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot&max=300&columns=15" />
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot&max=200&columns=14" />
</a>
此外,本專案的誕生離不開以下開源專案的幫助:

View File

@@ -9,7 +9,7 @@
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_ru.md">Русский</a>
<div>
<a href="https://trendshift.io/repositories/21369" target="_blank"><img src="https://trendshift.io/api/badge/repositories/21369" alt="AstrBotDevs%2FAstrBot | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
<a href="https://trendshift.io/repositories/12875" target="_blank"><img src="https://trendshift.io/api/badge/repositories/12875" alt="Soulter%2FAstrBot | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
<a href="https://hellogithub.com/repository/AstrBotDevs/AstrBot" target="_blank"><img src="https://api.hellogithub.com/v1/widgets/recommend.svg?rid=d127d50cd5e54c5382328acc3bb25483&claim_uid=ZO9by7qCXgSd6Lp&t=2" alt="FeaturedHelloGitHub" style="width: 250px; height: 54px;" width="250" height="54" /></a>
</div>
@@ -31,12 +31,12 @@
<a href="https://astrbot.app/">文档</a>
<a href="https://blog.astrbot.app/">博客</a>
<a href="https://astrbot.featurebase.app/roadmap">路线图</a>
<a href="https://github.com/AstrBotDevs/AstrBot/issues">问题提交</a>
<a href="https://github.com/AstrBotDevs/AstrBot/issues">问题提交</a>
<a href="mailto:community@astrbot.app">Email</a>
</div>
AstrBot 是一个开源的一站式 Agentic 个人和群聊助手,可在 QQ、Telegram、企业微信、飞书、钉钉、Slack 等数十款主流即时通讯软件上部署,此外还内置类似 OpenWebUI 的轻量化 ChatUI为个人、开发者和团队打造可靠、可扩展的对话式智能基础设施。无论是个人 AI 伙伴、智能客服、自动化助手还是企业知识库AstrBot 都能在你的即时通讯软件平台的工作流中快速构建 AI 应用。
AstrBot 是一个开源的一站式 Agentic 个人和群聊助手,可在 QQ、Telegram、企业微信、飞书、钉钉、Slack等数十款主流即时通讯软件上部署,此外还内置类似 OpenWebUI 的轻量化 ChatUI为个人、开发者和团队打造可靠、可扩展的对话式智能基础设施。无论是个人 AI 伙伴、智能客服、自动化助手还是企业知识库AstrBot 都能在你的即时通讯软件平台的工作流中快速构建 AI 应用。
![landingpage](https://github.com/user-attachments/assets/45fc5699-cddf-4e21-af35-13040706f6c0)
@@ -76,16 +76,12 @@ AstrBot 是一个开源的一站式 Agentic 个人和群聊助手,可在 QQ、
对于想快速体验 AstrBot、且熟悉命令行并能够自行安装 `uv` 环境的用户,我们推荐使用 `uv` 一键部署方式 ⚡️。
```bash
uv tool install astrbot --python 3.12
uv tool install astrbot
astrbot init # 仅首次执行此命令以初始化环境
astrbot run # astrbot run --backend-only 仅启动后端服务
# 安装开发版本(更多修复,新功能,但不够稳定,适合开发者)
uv tool install git+https://github.com/AstrBotDevs/AstrBot@dev
astrbot run
```
> 需要安装 [uv](https://docs.astral.sh/uv/)。
> AstrBot 需要 Python 3.12 或更高版本。`--python 3.12` 会确保 `uv` 使用 Python 3.12 创建 tool 环境。
> [!NOTE]
> 对于 macOS 用户:由于 macOS 安全检查,首次运行 `astrbot` 命令可能需要较长时间(约 10-20 秒)。
@@ -93,7 +89,7 @@ uv tool install git+https://github.com/AstrBotDevs/AstrBot@dev
更新 `astrbot`
```bash
uv tool upgrade astrbot --python 3.12
uv tool upgrade astrbot
```
> [!WARNING]
@@ -103,7 +99,7 @@ uv tool upgrade astrbot --python 3.12
对于熟悉容器、希望获得更稳定且更适合生产环境部署方式的用户,我们推荐使用 Docker / Docker Compose 部署 AstrBot。
请参考官方文档 [使用 Docker 部署 AstrBot](https://docs.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)。
### 在 雨云 上部署
@@ -141,7 +137,7 @@ yay -S astrbot-git
**更多部署方式**
若你需要面板化或更高自定义部署,可参考 [宝塔面板](https://docs.astrbot.app/deploy/astrbot/btpanel.html)BT Panel 应用商店安装)、[1Panel](https://docs.astrbot.app/deploy/astrbot/1panel.html)1Panel 应用商店安装)、[CasaOS](https://docs.astrbot.app/deploy/astrbot/casaos.html)NAS / 家庭服务器可视化部署)和 [手动部署](https://docs.astrbot.app/deploy/astrbot/cli.html)(基于源码与 `uv` 的完整自定义安装)。
若你需要面板化或更高自定义部署,可参考 [宝塔面板](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` 的完整自定义安装)。
## 支持的消息平台
@@ -160,12 +156,10 @@ yay -S astrbot-git
| **Discord** | 官方维护 |
| **LINE** | 官方维护 |
| **Satori** | 官方维护 |
| **KOOK** | 官方维护 |
| **Misskey** | 官方维护 |
| **Mattermost** | 官方维护 |
| **WhatsApp将支持** | 官方维护 |
| **Whatsapp (将支持)** | 官方维护 |
| [**Matrix**](https://github.com/stevessr/astrbot_plugin_matrix_adapter) | 社区维护 |
| [**Rocket.Chat**](https://github.com/NET-Homeless/astrbot_plugin_rocket_chat_adapter) | 社区维护 |
| [**KOOK**](https://github.com/wuyan1003/astrbot_plugin_kook_adapter) | 社区维护 |
| [**VoceChat**](https://github.com/HikariFroya/astrbot_plugin_vocechat) | 社区维护 |
## 支持的模型提供商
@@ -207,25 +201,13 @@ yay -S astrbot-git
| Xiaomi MiMo TTS | 文本转语音 |
| 火山引擎 TTS | 文本转语音 |
## ❤️ Sponsors
<p align="center">
<img alt="sponsors" src="https://sponsors.astrbot.app/?v=1">
</p>
## ❤️ 贡献
欢迎任何 Issues/Pull Requests只需要将你的更改提交到此项目 :)
欢迎任何 Issues/Pull Requests只需要将你的更改提交到此项目 )
### 如何贡献
你可以通过查看问题或帮助审核 PR拉取请求来贡献。任何问题或 PR 都欢迎参与,以促进社区贡献。当然,这些只是建议,你可以以任何方式进行贡献。对于新功能的添加,请先通过 Issue 讨论。
建议将功能性PR合并至dev分支将在测试修改后合并到主分支并发布新版本。
为了减少冲突,建议如下:
1. 工作分支最好基于 `dev` 分支创建,避免直接在 `main` 分支上工作。
2. 提交 PR 时,选择 `dev` 分支作为目标分支。
3. 定期同步 `dev` 分支到本地多使用git pull。
### 开发环境
@@ -233,26 +215,18 @@ AstrBot 使用 `ruff` 进行代码格式化和检查。
```bash
git clone https://github.com/AstrBotDevs/AstrBot
git switch dev # 切换到开发分支
pip install pre-commit # 或者uv tool install pre-commit
pip install pre-commit
pre-commit install
```
推荐使用uv本地安装进行测试
```bash
uv tool install -e . --force
astrbot init
astrbot run
```
调试前端
```bash
astrbot run --backend-only
cd dashboard
bun install # 或者pnpm 等
bun dev
```
## 🌍 社区
### QQ 群组
- 12 群916228568 (新)
- 9 群1076659624 (人满)
- 10 群1078079676 (人满)
- 11 群704659519 (人满)
- 1 群322154837 (人满)
- 3 群630166526 (人满)
- 4 群1077826412 (人满)
@@ -260,14 +234,6 @@ bun dev
- 6 群753075035 (人满)
- 7 群743746109 (人满)
- 8 群1030353265 (人满)
- 9 群1076659624 (人满)
- 10 群1078079676 (人满)
- 11 群704659519 (人满)
- 12 群916228568 (人满)
- 13 群1092185289
- 14 群1103419483
- 开发者群偏闲聊吹水975206796
- 开发者群正式1039761811
@@ -280,7 +246,7 @@ bun dev
特别感谢所有 Contributors 和插件开发者对 AstrBot 的贡献 ❤️
<a href="https://github.com/AstrBotDevs/AstrBot/graphs/contributors">
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot&max=300&columns=15" />
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot&max=200&columns=14" />
</a>
此外,本项目的诞生离不开以下开源项目的帮助:

View File

@@ -1,30 +1,3 @@
from __future__ import annotations
from .core.log import LogManager
from importlib.metadata import PackageNotFoundError
from importlib.metadata import version as _pkg_version
from typing import TYPE_CHECKING, Any
try:
__version__ = _pkg_version("astrbot")
except PackageNotFoundError:
__version__ = "4.26.1"
if TYPE_CHECKING:
from .core import logger as logger
__all__ = ["logger"]
def __getattr__(name: str) -> Any:
if name == "cli":
from astrbot.cli.__main__ import cli
return cli()
if name == "logger":
from .core import logger
return logger
raise AttributeError(name)
logger = LogManager.GetLogger(log_name="astrbot")

View File

@@ -1,147 +0,0 @@
import argparse
import asyncio
import mimetypes
import os
import sys
from pathlib import Path
import anyio
from astrbot.core import LogBroker, LogManager, db_helper, logger
from astrbot.core.config.default import VERSION
from astrbot.core.initial_loader import InitialLoader
from astrbot.core.utils.astrbot_path import (
get_astrbot_config_path,
get_astrbot_data_path,
get_astrbot_knowledge_base_path,
get_astrbot_plugin_path,
get_astrbot_root,
get_astrbot_site_packages_path,
get_astrbot_skills_path,
get_astrbot_temp_path,
)
from astrbot.core.utils.io import (
download_dashboard,
get_dashboard_version,
)
# 将父目录添加到 sys.path
sys.path.append(Path(__file__).parent.as_posix())
logo_tmpl = r"""
___ _______.___________..______ .______ ______ .___________.
/ \ / | || _ \ | _ \ / __ \ | |
/ ^ \ | (----`---| |----`| |_) | | |_) | | | | | `---| |----`
/ /_\ \ \ \ | | | / | _ < | | | | | |
/ _____ \ .----) | | | | |\ \----.| |_) | | `--' | | |
/__/ \__\ |_______/ |__| | _| `._____||______/ \______/ |__|
"""
def check_env() -> None:
# Python version check: require 3.12 or 3.13
if not (sys.version_info.major == 3 and sys.version_info.minor in (12, 13)):
sys.exit(1)
astrbot_root = get_astrbot_root()
if astrbot_root not in sys.path:
sys.path.insert(0, astrbot_root)
site_packages_path = get_astrbot_site_packages_path()
if site_packages_path not in sys.path:
sys.path.insert(0, site_packages_path)
os.makedirs(get_astrbot_config_path(), exist_ok=True)
os.makedirs(get_astrbot_plugin_path(), exist_ok=True)
os.makedirs(get_astrbot_temp_path(), exist_ok=True)
os.makedirs(get_astrbot_knowledge_base_path(), exist_ok=True)
os.makedirs(get_astrbot_skills_path(), exist_ok=True)
os.makedirs(site_packages_path, exist_ok=True)
# 针对问题 #181 的临时解决方案
mimetypes.add_type("text/javascript", ".js")
mimetypes.add_type("text/javascript", ".mjs")
mimetypes.add_type("application/json", ".json")
async def check_dashboard_files(webui_dir: str | None = None):
"""下载管理面板文件"""
# 指定webui目录
if webui_dir:
if await anyio.Path(webui_dir).exists():
logger.info(f"使用指定的 WebUI 目录: {webui_dir}")
return webui_dir
logger.warning(f"指定的 WebUI 目录 {webui_dir} 不存在,将使用默认逻辑。")
data_dist_path = os.path.join(get_astrbot_data_path(), "dist")
if await anyio.Path(data_dist_path).exists():
v = await get_dashboard_version()
if v is not None:
# 存在文件
if v == f"v{VERSION}":
logger.info("WebUI 版本已是最新。")
else:
logger.warning(
f"检测到 WebUI 版本 ({v}) 与当前 AstrBot 版本 (v{VERSION}) 不符。",
)
return data_dist_path
logger.info(
"开始下载管理面板文件...高峰期(晚上)可能导致较慢的速度。如多次下载失败,请前往 https://github.com/AstrBotDevs/AstrBot/releases/latest 下载 dist.zip,并将其中的 dist 文件夹解压至 data 目录下。",
)
try:
await download_dashboard(version=f"v{VERSION}", latest=False)
except Exception as e:
logger.warning(
f"下载指定版本(v{VERSION})的管理面板文件失败: {e},尝试下载最新版本。",
)
try:
await download_dashboard(latest=True)
except Exception as e:
logger.critical(f"下载管理面板文件失败: {e}")
return None
logger.info("管理面板下载完成。")
return data_dist_path
async def main_async(webui_dir_arg: str | None, log_broker: LogBroker) -> None:
"""主异步入口"""
# 检查仪表板文件
webui_dir = await check_dashboard_files(webui_dir_arg)
if webui_dir is None:
logger.warning(
"管理面板文件检查失败,WebUI 功能将不可用。"
"请检查网络连接或手动指定 --webui-dir 参数。",
)
db = db_helper
# 打印 logo
logger.info(logo_tmpl)
core_lifecycle = InitialLoader(db, log_broker)
core_lifecycle.webui_dir = webui_dir
await core_lifecycle.start()
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="AstrBot")
parser.add_argument(
"--webui-dir",
type=str,
help="指定 WebUI 静态文件目录路径",
default=None,
)
args = parser.parse_args()
check_env()
# 启动日志代理
log_broker = LogBroker()
LogManager.set_queue_handler(logger, log_broker)
# 只使用一次 asyncio.run()
asyncio.run(main_async(args.webui_dir, log_broker))

View File

@@ -1,4 +1,3 @@
# ruff: noqa: F401, F403, F811, I001
from astrbot.core.config.astrbot_config import AstrBotConfig
from astrbot import logger
from astrbot.core import html_renderer
@@ -30,7 +29,7 @@ from astrbot.core.star.filter.platform_adapter_type import (
PlatformAdapterType,
)
from astrbot.core.star.register import (
register_star as register, # 注册插件(Star)
register_star as register, # 注册插件Star
)
from astrbot.core.star import Context, Star
from astrbot.core.star.config import *
@@ -52,4 +51,4 @@ from astrbot.core.platform import (
from astrbot.core.platform.register import register_platform_adapter
from .message_components import *
from .message_components import *

View File

@@ -14,8 +14,6 @@ from astrbot.core.star.register import register_command_group as command_group
from astrbot.core.star.register import register_custom_filter as custom_filter
from astrbot.core.star.register import register_event_message_type as event_message_type
from astrbot.core.star.register import register_llm_tool as llm_tool
from astrbot.core.star.register import register_on_agent_begin as on_agent_begin
from astrbot.core.star.register import register_on_agent_done as on_agent_done
from astrbot.core.star.register import register_on_astrbot_loaded as on_astrbot_loaded
from astrbot.core.star.register import (
register_on_decorating_result as on_decorating_result,
@@ -53,20 +51,18 @@ __all__ = [
"custom_filter",
"event_message_type",
"llm_tool",
"on_agent_begin",
"on_agent_done",
"on_astrbot_loaded",
"on_decorating_result",
"on_llm_request",
"on_llm_response",
"on_llm_tool_respond",
"on_platform_loaded",
"on_plugin_error",
"on_plugin_loaded",
"on_plugin_unloaded",
"on_using_llm_tool",
"on_platform_loaded",
"on_waiting_llm_request",
"permission_type",
"platform_adapter_type",
"regex",
"on_using_llm_tool",
"on_llm_tool_respond",
]

View File

@@ -1,7 +1,7 @@
from astrbot.core.star import Context, Star, StarTools
from astrbot.core.star.config import *
from astrbot.core.star.register import (
register_star as register, # 注册插件(Star)
register_star as register, # 注册插件Star
)
__all__ = ["Context", "Star", "StarTools", "register"]

View File

@@ -1,453 +0,0 @@
from __future__ import annotations
import contextvars
from collections.abc import Callable, KeysView
from contextlib import contextmanager
from pathlib import Path
from typing import Any, Generic, TypeVar, overload
from fastapi.encoders import jsonable_encoder
from fastapi.responses import FileResponse, JSONResponse
from starlette.datastructures import Headers
from starlette.datastructures import UploadFile as StarletteUploadFile
from starlette.responses import StreamingResponse
ValueT = TypeVar("ValueT")
DefaultT = TypeVar("DefaultT")
ConvertedT = TypeVar("ConvertedT")
class PluginMultiDict(Generic[ValueT]):
"""Dictionary-like request values that preserves duplicate keys."""
def __init__(self, pairs: list[tuple[str, ValueT]]) -> None:
self._pairs = pairs
@overload
def get(self, key: str) -> ValueT | None: ...
@overload
def get(self, key: str, default: DefaultT) -> ValueT | DefaultT: ...
@overload
def get(
self,
key: str,
default: DefaultT,
type: Callable[[ValueT], ConvertedT],
) -> ConvertedT | DefaultT: ...
def get(self, key: str, default: Any = None, type: Callable | None = None):
"""Return the last value for a key.
Args:
key: Value key to read.
default: Value returned when the key is missing or conversion fails.
type: Optional callable used to convert the value.
Returns:
The matching value, converted value, or default.
"""
for item_key, item_value in reversed(self._pairs):
if item_key != key:
continue
if type is None:
return item_value
try:
return type(item_value)
except (TypeError, ValueError):
return default
return default
def getlist(self, key: str) -> list[ValueT]:
"""Return all values for a key.
Args:
key: Value key to read.
Returns:
Values in request order.
"""
return [item_value for item_key, item_value in self._pairs if item_key == key]
def keys(self) -> KeysView[str]:
return dict.fromkeys(item_key for item_key, _ in self._pairs).keys()
def values(self) -> list[ValueT]:
return [self[key] for key in self.keys()]
def items(self) -> list[tuple[str, ValueT]]:
return [(key, self[key]) for key in self.keys()]
def __contains__(self, key: str) -> bool:
return any(item_key == key for item_key, _ in self._pairs)
def __getitem__(self, key: str) -> ValueT:
value = self.get(key)
if value is None and key not in self:
raise KeyError(key)
return value
def __bool__(self) -> bool:
return bool(self._pairs)
class PluginUploadFile:
"""Uploaded file wrapper exposed to plugin Web API handlers."""
def __init__(self, upload_file: StarletteUploadFile) -> None:
self._upload_file: StarletteUploadFile = upload_file
self.filename: str | None = upload_file.filename
self.content_type: str | None = upload_file.content_type
self.headers: Headers = upload_file.headers
self.content_length: int | None = self._resolve_content_length()
def _resolve_content_length(self) -> int | None:
try:
raw = self.headers.get("content-length")
return int(raw) if raw else None
except (TypeError, ValueError):
return None
async def save(self, destination: str | Path) -> None:
"""Save the uploaded file to disk.
Args:
destination: Destination file path.
"""
path = Path(destination)
try:
await self._upload_file.seek(0)
except Exception:
pass
with path.open("wb") as output:
while True:
chunk = await self._upload_file.read(1024 * 1024)
if not chunk:
break
output.write(chunk)
async def read(self, size: int = -1) -> bytes:
"""Read bytes from the uploaded file.
Args:
size: Maximum number of bytes to read. Use -1 to read all bytes.
Returns:
File bytes.
"""
return await self._upload_file.read(size)
async def write(self, data: bytes) -> None:
"""Write bytes to the uploaded file object.
Args:
data: Bytes to write.
"""
await self._upload_file.write(data)
async def seek(self, offset: int) -> None:
"""Move the uploaded file cursor.
Args:
offset: Absolute byte offset.
"""
await self._upload_file.seek(offset)
async def close(self) -> None:
"""Close the uploaded file."""
await self._upload_file.close()
def __getattr__(self, key: str) -> Any:
return getattr(self._upload_file, key)
class PluginRequest:
"""Request object exposed to plugin Web API handlers."""
def __init__(
self,
request_: Any,
*,
path_params: dict[str, Any] | None = None,
plugin_name: str | None = None,
username: str | None = None,
) -> None:
self._request: Any = request_
self.method: str = request_.method
self.path: str = request_.url.path
self.headers: Headers = request_.headers
self.cookies: dict[str, str] = request_.cookies
self.content_type: str | None = request_.headers.get("content-type")
self.client_host: str | None = request_.client.host if request_.client else None
self.path_params: dict[str, Any] = path_params or {}
self.plugin_name: str | None = plugin_name
self.username: str | None = username
self.query: PluginMultiDict[str] = PluginMultiDict[str](
list(request_.query_params.multi_items())
)
self._form_cache: PluginMultiDict[str] | None = None
self._files_cache: PluginMultiDict[PluginUploadFile] | None = None
async def body(self) -> bytes:
"""Read the raw request body.
Returns:
Raw request body bytes.
"""
return await self._request.body()
async def json(self, default: DefaultT | None = None) -> Any | DefaultT | None:
"""Read the JSON request body.
Args:
default: Value returned when the request body cannot be parsed as JSON.
Returns:
Parsed JSON data or default.
"""
try:
return await self._request.json()
except Exception:
return default
async def _load_form_parts(self) -> None:
if self._form_cache is not None and self._files_cache is not None:
return
form = await self._request.form()
form_pairs: list[tuple[str, str]] = []
file_pairs: list[tuple[str, PluginUploadFile]] = []
for key, value in form.multi_items():
if isinstance(value, StarletteUploadFile):
file_pairs.append((key, PluginUploadFile(value)))
else:
form_pairs.append((key, value))
self._form_cache = PluginMultiDict(form_pairs)
self._files_cache = PluginMultiDict(file_pairs)
async def form(self) -> PluginMultiDict[str]:
"""Read form fields from a multipart or form-urlencoded request.
Returns:
Form values without uploaded files.
"""
await self._load_form_parts()
assert self._form_cache is not None
return self._form_cache
async def files(self) -> PluginMultiDict[PluginUploadFile]:
"""Read uploaded files from a multipart request.
Returns:
Uploaded file values.
"""
await self._load_form_parts()
assert self._files_cache is not None
return self._files_cache
_request_var: contextvars.ContextVar[PluginRequest] = contextvars.ContextVar(
"astrbot_plugin_web_request"
)
class PluginRequestProxy:
"""Typed proxy for the request bound to the current plugin Web handler."""
def _get_current(self) -> PluginRequest:
try:
return _request_var.get()
except LookupError as exc:
raise RuntimeError(
"astrbot.api.web.request is only available inside a plugin Web API "
"handler."
) from exc
@property
def method(self) -> str:
return self._get_current().method
@property
def path(self) -> str:
return self._get_current().path
@property
def headers(self) -> Headers:
return self._get_current().headers
@property
def cookies(self) -> dict[str, str]:
return self._get_current().cookies
@property
def content_type(self) -> str | None:
return self._get_current().content_type
@property
def client_host(self) -> str | None:
return self._get_current().client_host
@property
def path_params(self) -> dict[str, Any]:
return self._get_current().path_params
@property
def plugin_name(self) -> str | None:
return self._get_current().plugin_name
@property
def username(self) -> str | None:
return self._get_current().username
@property
def query(self) -> PluginMultiDict[str]:
return self._get_current().query
async def body(self) -> bytes:
return await self._get_current().body()
async def json(self, default: DefaultT | None = None) -> Any | DefaultT | None:
return await self._get_current().json(default=default)
async def form(self) -> PluginMultiDict[str]:
return await self._get_current().form()
async def files(self) -> PluginMultiDict[PluginUploadFile]:
return await self._get_current().files()
def __getattr__(self, key: str) -> Any:
return getattr(self._get_current(), key)
request: PluginRequestProxy = PluginRequestProxy()
@contextmanager
def bind_request_context(request_: PluginRequest):
"""Bind a plugin Web request for the current async context.
Args:
request_: Request object exposed through the module-level request proxy.
Yields:
The bound request object.
"""
token = _request_var.set(request_)
try:
yield request_
finally:
_request_var.reset(token)
def json_response(
data: Any = None,
*,
status_code: int = 200,
headers: dict[str, str] | None = None,
) -> JSONResponse:
"""Build a JSON response for plugin Web API handlers.
Args:
data: JSON-serializable response body.
status_code: HTTP status code.
headers: Optional response headers.
Returns:
A Starlette/FastAPI JSON response.
"""
return JSONResponse(
jsonable_encoder({} if data is None else data),
status_code=status_code,
headers=headers,
)
def error_response(
message: str,
*,
status_code: int = 400,
data: Any = None,
headers: dict[str, str] | None = None,
) -> JSONResponse:
"""Build a standard error response for plugin bridge calls.
Args:
message: Public error message.
status_code: HTTP status code.
data: Optional error details that are safe to expose.
headers: Optional response headers.
Returns:
A JSON response with the AstrBot error envelope.
"""
return json_response(
{"status": "error", "message": message, "data": data},
status_code=status_code,
headers=headers,
)
def file_response(
path: str | Path,
*,
filename: str | None = None,
content_type: str | None = None,
headers: dict[str, str] | None = None,
) -> FileResponse:
"""Build a file download response for plugin Web API handlers.
Args:
path: File path to send.
filename: Optional download filename.
content_type: Optional response media type.
headers: Optional response headers.
Returns:
A Starlette/FastAPI file response.
"""
return FileResponse(
path,
filename=filename,
media_type=content_type,
headers=headers,
)
def stream_response(
content: Any,
*,
content_type: str = "text/event-stream",
status_code: int = 200,
headers: dict[str, str] | None = None,
) -> StreamingResponse:
"""Build a streaming response for plugin Web API handlers.
Args:
content: Sync or async iterable that yields response chunks.
content_type: Response media type.
status_code: HTTP status code.
headers: Optional response headers.
Returns:
A Starlette/FastAPI streaming response.
"""
return StreamingResponse(
content,
media_type=content_type,
status_code=status_code,
headers=headers,
)
__all__ = [
"PluginMultiDict",
"PluginRequest",
"PluginRequestProxy",
"PluginUploadFile",
"bind_request_context",
"error_response",
"file_response",
"json_response",
"request",
"stream_response",
]

View File

@@ -1,6 +0,0 @@
{
"metadata": {
"display_name": "AstrBot",
"desc": "AstrBot's internal plugin, providing some basic capabilities."
}
}

View File

@@ -1,6 +0,0 @@
{
"metadata": {
"display_name": "AstrBot",
"desc": "AstrBot 的内部插件,提供一些基础能力。"
}
}

View File

@@ -1,302 +0,0 @@
import asyncio
import datetime
import random
import uuid
from collections import defaultdict, deque
from astrbot import logger
from astrbot.api import star
from astrbot.api.event import AstrMessageEvent
from astrbot.api.message_components import (
At,
AtAll,
Face,
File,
Forward,
Image,
Plain,
Record,
Reply,
Video,
)
from astrbot.api.platform import MessageType
from astrbot.api.provider import Provider, ProviderRequest
from astrbot.core.agent.message import TextPart
from astrbot.core.astrbot_config_mgr import AstrBotConfigManager
"""
Group chat context awareness.
"""
GROUP_HISTORY_HEADER = (
"<system_reminder>"
"You are in a group chat. "
"Belows are group chat context after your last reply:\n"
"--- BEGIN CONTEXT---\n"
)
GROUP_HISTORY_FOOTER = "\n--- END CONTEXT ---\n</system_reminder>"
DEFAULT_GROUP_MESSAGE_MAX_CNT = 300
class GroupChatContext:
def __init__(self, acm: AstrBotConfigManager, context: star.Context) -> None:
self.acm = acm
self.context = context
self._locks: dict[str, asyncio.Lock] = {}
self.raw_records: dict[str, deque[str]] = defaultdict(deque)
self._record_ids: dict[str, deque[str]] = defaultdict(deque)
def _get_lock(self, umo: str) -> asyncio.Lock:
lock = self._locks.get(umo)
if lock is None:
lock = asyncio.Lock()
self._locks[umo] = lock
return lock
def cfg(self, event: AstrMessageEvent):
cfg = self.context.get_config(umo=event.unified_msg_origin)
group_context_cfg = cfg["provider_ltm_settings"]
image_caption_prompt = cfg["provider_settings"]["image_caption_prompt"]
image_caption_provider_id = group_context_cfg.get("image_caption_provider_id")
image_caption = group_context_cfg["image_caption"] and bool(
image_caption_provider_id
)
active_reply = group_context_cfg["active_reply"]
enable_active_reply = active_reply.get("enable", False)
ar_method = active_reply["method"]
ar_possibility = active_reply["possibility_reply"]
ar_prompt = active_reply.get("prompt", "")
ar_whitelist = active_reply.get("whitelist", [])
return {
"group_message_max_cnt": _positive_int(
group_context_cfg.get(
"group_message_max_cnt",
DEFAULT_GROUP_MESSAGE_MAX_CNT,
),
DEFAULT_GROUP_MESSAGE_MAX_CNT,
),
"image_caption": image_caption,
"image_caption_prompt": image_caption_prompt,
"image_caption_provider_id": image_caption_provider_id,
"enable_active_reply": enable_active_reply,
"ar_method": ar_method,
"ar_possibility": ar_possibility,
"ar_prompt": ar_prompt,
"ar_whitelist": ar_whitelist,
}
async def get_image_caption(
self,
image_url: str,
image_caption_provider_id: str,
image_caption_prompt: str,
) -> str:
if not image_caption_provider_id:
provider = self.context.get_using_provider()
else:
provider = self.context.get_provider_by_id(image_caption_provider_id)
if not provider:
raise Exception(f"没有找到 ID 为 {image_caption_provider_id} 的提供商")
if not isinstance(provider, Provider):
raise Exception(f"提供商类型错误({type(provider)}),无法获取图片描述")
response = await provider.text_chat(
prompt=image_caption_prompt,
session_id=uuid.uuid4().hex,
image_urls=[image_url],
persist=False,
)
return response.completion_text
async def need_active_reply(self, event: AstrMessageEvent) -> bool:
cfg = self.cfg(event)
if not cfg["enable_active_reply"]:
return False
if event.get_message_type() != MessageType.GROUP_MESSAGE:
return False
if event.is_at_or_wake_command:
return False
if cfg["ar_whitelist"] and (
event.unified_msg_origin not in cfg["ar_whitelist"]
and (
event.get_group_id() and event.get_group_id() not in cfg["ar_whitelist"]
)
):
return False
match cfg["ar_method"]:
case "possibility_reply":
return random.random() < cfg["ar_possibility"]
return False
async def remove_session(self, event: AstrMessageEvent) -> int:
umo = event.unified_msg_origin
lock = self._get_lock(umo)
async with lock:
cnt = len(self.raw_records.get(umo, deque()))
self.raw_records.pop(umo, None)
self._record_ids.pop(umo, None)
self._locks.pop(umo, None)
return cnt
async def handle_message(self, event: AstrMessageEvent) -> None:
if event.get_message_type() != MessageType.GROUP_MESSAGE:
return
umo = event.unified_msg_origin
cfg = self.cfg(event)
final_message = await self._format_message(event, cfg)
async with self._get_lock(umo):
records = self.raw_records[umo]
record_ids = self._record_ids[umo]
record_id = uuid.uuid4().hex
records.append(final_message)
record_ids.append(record_id)
_trim_left(records, cfg["group_message_max_cnt"], record_ids)
event.set_extra("_group_context_record_id", record_id)
event.set_extra("_group_context_raw_idx", len(records) - 1)
logger.debug(f"group_chat_context | {umo} | {final_message}")
async def on_req_llm(self, event: AstrMessageEvent, req: ProviderRequest) -> None:
umo = event.unified_msg_origin
record_id = event.get_extra("_group_context_record_id", None)
prompt_idx = event.get_extra("_group_context_raw_idx", -1)
if not isinstance(record_id, str) and (
not isinstance(prompt_idx, int) or prompt_idx < 0
):
return
async with self._get_lock(umo):
records = self.raw_records.get(umo)
if not records:
return
raw_list = list(records)
id_list = list(self._record_ids.get(umo, deque()))
if isinstance(record_id, str) and record_id in id_list:
prompt_idx = id_list.index(record_id)
if prompt_idx >= len(raw_list):
return
records_to_inject = raw_list[:prompt_idx]
remaining = raw_list[prompt_idx + 1 :]
remaining_ids = id_list[prompt_idx + 1 :] if id_list else []
records.clear()
records.extend(remaining)
if id_list:
record_ids = self._record_ids[umo]
record_ids.clear()
record_ids.extend(remaining_ids)
if records_to_inject:
req.extra_user_content_parts.append(
TextPart(text=_format_group_history_block(records_to_inject))
)
async def _format_message(self, event: AstrMessageEvent, cfg: dict) -> str:
datetime_str = datetime.datetime.now().strftime("%H:%M:%S")
parts = [f"[{event.message_obj.sender.nickname}/{datetime_str}]: "]
for comp in event.get_messages():
if isinstance(comp, Plain):
parts.append(f" {comp.text}")
elif isinstance(comp, Image):
if cfg["image_caption"]:
try:
url = comp.url if comp.url else comp.file
if not url:
raise Exception("图片 URL 为空")
caption = await self.get_image_caption(
url,
cfg["image_caption_provider_id"],
cfg["image_caption_prompt"],
)
parts.append(f" [Image: {caption}]")
except Exception as e:
logger.error(f"获取图片描述失败: {e}")
else:
parts.append(" [Image]")
elif isinstance(comp, At):
is_at_self = str(comp.qq) in (
event.get_self_id(),
"all",
)
if is_at_self:
parts.insert(1, "⚠️[DIRECTED AT YOU] ")
parts.append(f" [At: {comp.name}]")
elif isinstance(comp, Reply):
if comp.message_str:
parts.append(
f" [Quote({comp.sender_nickname}: {_truncate_reply_text(comp.message_str)})]"
)
elif comp.chain:
chain_desc = _describe_chain(comp.chain)
parts.append(f" [Quote({comp.sender_nickname}: {chain_desc})]")
else:
parts.append(" [Quote]")
return "".join(parts)
_MAX_REPLY_TEXT_LENGTH = 200
def _describe_chain(chain: list) -> str:
"""Summarize message chain content for quoted reply display."""
desc = []
for c in chain:
if isinstance(c, Plain) and getattr(c, "text", None):
desc.append(c.text)
elif isinstance(c, Image):
desc.append("[Image]")
elif isinstance(c, At):
name = getattr(c, "name", "") or getattr(c, "qq", "")
desc.append(f"[At: {name}]")
elif isinstance(c, Record):
desc.append("[Voice]")
elif isinstance(c, Video):
desc.append("[Video]")
elif isinstance(c, File):
desc.append(f"[File: {getattr(c, 'name', '') or ''}]")
elif isinstance(c, Forward):
desc.append("[Forward]")
elif isinstance(c, AtAll):
desc.append("[At: All]")
elif isinstance(c, Face):
desc.append(f"[Sticker: {getattr(c, 'id', '')}]")
elif isinstance(c, Reply):
desc.append("[Quote]")
else:
desc.append(f"[{c.__class__.__name__}]")
return "".join(desc) or "[Unknown]"
def _truncate_reply_text(text: str) -> str:
"""Truncate overly long quoted reply text."""
if len(text) <= _MAX_REPLY_TEXT_LENGTH:
return text
return text[:_MAX_REPLY_TEXT_LENGTH] + "..."
def _positive_int(value, fallback: int) -> int:
try:
parsed = int(value)
except (TypeError, ValueError):
return fallback
return parsed if parsed > 0 else fallback
def _trim_left(
records: deque[str],
max_records: int,
record_ids: deque[str] | None = None,
) -> None:
while len(records) > max_records:
records.popleft()
if record_ids:
record_ids.popleft()
def _format_group_history_block(records: list[str]) -> str:
return GROUP_HISTORY_HEADER + "\n".join(records) + GROUP_HISTORY_FOOTER

View File

@@ -0,0 +1,188 @@
import datetime
import random
import uuid
from collections import defaultdict
from astrbot import logger
from astrbot.api import star
from astrbot.api.event import AstrMessageEvent
from astrbot.api.message_components import At, Image, Plain
from astrbot.api.platform import MessageType
from astrbot.api.provider import LLMResponse, Provider, ProviderRequest
from astrbot.core.astrbot_config_mgr import AstrBotConfigManager
"""
聊天记忆增强
"""
class LongTermMemory:
def __init__(self, acm: AstrBotConfigManager, context: star.Context) -> None:
self.acm = acm
self.context = context
self.session_chats = defaultdict(list)
"""记录群成员的群聊记录"""
def cfg(self, event: AstrMessageEvent):
cfg = self.context.get_config(umo=event.unified_msg_origin)
try:
max_cnt = int(cfg["provider_ltm_settings"]["group_message_max_cnt"])
except BaseException as e:
logger.error(e)
max_cnt = 300
image_caption_prompt = cfg["provider_settings"]["image_caption_prompt"]
image_caption_provider_id = cfg["provider_ltm_settings"].get(
"image_caption_provider_id"
)
image_caption = cfg["provider_ltm_settings"]["image_caption"] and bool(
image_caption_provider_id
)
active_reply = cfg["provider_ltm_settings"]["active_reply"]
enable_active_reply = active_reply.get("enable", False)
ar_method = active_reply["method"]
ar_possibility = active_reply["possibility_reply"]
ar_prompt = active_reply.get("prompt", "")
ar_whitelist = active_reply.get("whitelist", [])
ret = {
"max_cnt": max_cnt,
"image_caption": image_caption,
"image_caption_prompt": image_caption_prompt,
"image_caption_provider_id": image_caption_provider_id,
"enable_active_reply": enable_active_reply,
"ar_method": ar_method,
"ar_possibility": ar_possibility,
"ar_prompt": ar_prompt,
"ar_whitelist": ar_whitelist,
}
return ret
async def remove_session(self, event: AstrMessageEvent) -> int:
cnt = 0
if event.unified_msg_origin in self.session_chats:
cnt = len(self.session_chats[event.unified_msg_origin])
del self.session_chats[event.unified_msg_origin]
return cnt
async def get_image_caption(
self,
image_url: str,
image_caption_provider_id: str,
image_caption_prompt: str,
) -> str:
if not image_caption_provider_id:
provider = self.context.get_using_provider()
else:
provider = self.context.get_provider_by_id(image_caption_provider_id)
if not provider:
raise Exception(f"没有找到 ID 为 {image_caption_provider_id} 的提供商")
if not isinstance(provider, Provider):
raise Exception(f"提供商类型错误({type(provider)}),无法获取图片描述")
response = await provider.text_chat(
prompt=image_caption_prompt,
session_id=uuid.uuid4().hex,
image_urls=[image_url],
persist=False,
)
return response.completion_text
async def need_active_reply(self, event: AstrMessageEvent) -> bool:
cfg = self.cfg(event)
if not cfg["enable_active_reply"]:
return False
if event.get_message_type() != MessageType.GROUP_MESSAGE:
return False
if event.is_at_or_wake_command:
# if the message is a command, let it pass
return False
if cfg["ar_whitelist"] and (
event.unified_msg_origin not in cfg["ar_whitelist"]
and (
event.get_group_id() and event.get_group_id() not in cfg["ar_whitelist"]
)
):
return False
match cfg["ar_method"]:
case "possibility_reply":
trig = random.random() < cfg["ar_possibility"]
return trig
return False
async def handle_message(self, event: AstrMessageEvent) -> None:
"""仅支持群聊"""
if event.get_message_type() == MessageType.GROUP_MESSAGE:
datetime_str = datetime.datetime.now().strftime("%H:%M:%S")
parts = [f"[{event.message_obj.sender.nickname}/{datetime_str}]: "]
cfg = self.cfg(event)
for comp in event.get_messages():
if isinstance(comp, Plain):
parts.append(f" {comp.text}")
elif isinstance(comp, Image):
if cfg["image_caption"]:
try:
url = comp.url if comp.url else comp.file
if not url:
raise Exception("图片 URL 为空")
caption = await self.get_image_caption(
url,
cfg["image_caption_provider_id"],
cfg["image_caption_prompt"],
)
parts.append(f" [Image: {caption}]")
except Exception as e:
logger.error(f"获取图片描述失败: {e}")
else:
parts.append(" [Image]")
elif isinstance(comp, At):
parts.append(f" [At: {comp.name}]")
final_message = "".join(parts)
logger.debug(f"ltm | {event.unified_msg_origin} | {final_message}")
self.session_chats[event.unified_msg_origin].append(final_message)
if len(self.session_chats[event.unified_msg_origin]) > cfg["max_cnt"]:
self.session_chats[event.unified_msg_origin].pop(0)
async def on_req_llm(self, event: AstrMessageEvent, req: ProviderRequest) -> None:
"""当触发 LLM 请求前,调用此方法修改 req"""
if event.unified_msg_origin not in self.session_chats:
return
chats_str = "\n---\n".join(self.session_chats[event.unified_msg_origin])
cfg = self.cfg(event)
if cfg["enable_active_reply"]:
prompt = req.prompt
req.prompt = (
f"You are now in a chatroom. The chat history is as follows:\n{chats_str}"
f"\nNow, a new message is coming: `{prompt}`. "
"Please react to it. Only output your response and do not output any other information. "
"You MUST use the SAME language as the chatroom is using."
)
req.contexts = [] # 清空上下文当使用了主动回复所有聊天记录都在一个prompt中。
else:
req.system_prompt += (
"You are now in a chatroom. The chat history is as follows: \n"
)
req.system_prompt += chats_str
async def after_req_llm(
self, event: AstrMessageEvent, llm_resp: LLMResponse
) -> None:
if event.unified_msg_origin not in self.session_chats:
return
if llm_resp.completion_text:
final_message = f"[You/{datetime.datetime.now().strftime('%H:%M:%S')}]: {llm_resp.completion_text}"
logger.debug(
f"Recorded AI response: {event.unified_msg_origin} | {final_message}"
)
self.session_chats[event.unified_msg_origin].append(final_message)
cfg = self.cfg(event)
if len(self.session_chats[event.unified_msg_origin]) > cfg["max_cnt"]:
self.session_chats[event.unified_msg_origin].pop(0)

View File

@@ -1,196 +1,66 @@
import copy
import traceback
from collections.abc import Iterable
from sys import maxsize
import astrbot.api.message_components as Comp
from astrbot.api import star
from astrbot.api.event import AstrMessageEvent, filter
from astrbot.api.message_components import Image, Plain
from astrbot.api.provider import ProviderRequest
from astrbot.api.provider import LLMResponse, ProviderRequest
from astrbot.core import logger
from astrbot.core.utils.session_waiter import (
FILTERS,
USER_SESSIONS,
SessionController,
SessionWaiter,
session_waiter,
)
from .group_chat_context import GroupChatContext
def _iter_message_components(event: AstrMessageEvent):
messages = getattr(getattr(event, "message_obj", None), "message", None)
if not isinstance(messages, Iterable) or isinstance(messages, (str, bytes)):
return ()
return tuple(messages)
from .long_term_memory import LongTermMemory
class Main(star.Star):
def __init__(self, context: star.Context) -> None:
self.context = context
self.group_chat_context = None
self.ltm = None
try:
self.group_chat_context = GroupChatContext(
self.context.astrbot_config_mgr,
self.context,
)
self.ltm = LongTermMemory(self.context.astrbot_config_mgr, self.context)
except BaseException as e:
logger.error(f"group chat context init failed: {e}")
logger.error(f"聊天增强 err: {e}")
@filter.event_message_type(filter.EventMessageType.ALL, priority=maxsize)
async def handle_session_control_agent(self, event: AstrMessageEvent) -> None:
"""会话控制代理"""
for session_filter in FILTERS:
session_id = session_filter.filter(event)
if session_id in USER_SESSIONS:
await SessionWaiter.trigger(session_id, event)
event.stop_event()
@filter.event_message_type(filter.EventMessageType.ALL, priority=maxsize - 1)
async def handle_empty_mention(self, event: AstrMessageEvent):
"""处理只有一个 @ 或仅有唤醒前缀的消息,并等待用户下一条内容。"""
try:
messages = event.get_messages()
cfg = self.context.get_config(umo=event.unified_msg_origin)
p_settings = cfg["platform_settings"]
wake_prefix = cfg.get("wake_prefix", [])
if len(messages) != 1:
return
is_empty_mention = (
isinstance(messages[0], Comp.At)
and str(messages[0].qq) == str(event.get_self_id())
and p_settings.get("empty_mention_waiting", True)
)
is_wake_prefix_only = (
isinstance(messages[0], Comp.Plain)
and messages[0].text.strip() in wake_prefix
)
if not (is_empty_mention or is_wake_prefix_only):
return
if p_settings.get("empty_mention_waiting_need_reply", True):
try:
curr_cid = await self.context.conversation_manager.get_curr_conversation_id(
event.unified_msg_origin,
)
conversation = None
if curr_cid:
conversation = (
await self.context.conversation_manager.get_conversation(
event.unified_msg_origin,
curr_cid,
)
)
else:
curr_cid = (
await self.context.conversation_manager.new_conversation(
event.unified_msg_origin,
platform_id=event.get_platform_id(),
)
)
yield event.request_llm(
prompt=(
"注意,你正在社交媒体上中与用户进行聊天,用户只是通过@来唤醒你,但并未在这条消息中输入内容,他可能会在接下来一条发送他想发送的内容。"
"你友好地询问用户想要聊些什么或者需要什么帮助,回复要符合人设,不要太过机械化。"
"请注意,你仅需要输出要回复用户的内容,不要输出其他任何东西"
),
session_id=curr_cid,
contexts=[],
system_prompt="",
conversation=conversation,
)
except Exception as e:
logger.error(f"LLM response failed: {e!s}")
yield event.plain_result("想要问什么呢?😄")
@session_waiter(60)
async def empty_mention_waiter(
controller: SessionController,
event: AstrMessageEvent,
) -> None:
if not event.message_str or not event.message_str.strip():
return
event.message_obj.message.insert(
0,
Comp.At(qq=event.get_self_id(), name=event.get_self_id()),
)
new_event = copy.copy(event)
self.context.get_event_queue().put_nowait(new_event)
event.stop_event()
controller.stop()
try:
await empty_mention_waiter(event)
except TimeoutError:
pass
except Exception as e:
yield event.plain_result("发生错误,请联系管理员: " + str(e))
finally:
event.stop_event()
except Exception as e:
logger.error("handle_empty_mention error: " + str(e))
def group_context_enabled(self, event: AstrMessageEvent):
group_context_settings = self.context.get_config(umo=event.unified_msg_origin)[
def ltm_enabled(self, event: AstrMessageEvent):
ltmse = self.context.get_config(umo=event.unified_msg_origin)[
"provider_ltm_settings"
]
return (
group_context_settings["group_icl_enable"]
or group_context_settings["active_reply"]["enable"]
)
return ltmse["group_icl_enable"] or ltmse["active_reply"]["enable"]
@filter.platform_adapter_type(filter.PlatformAdapterType.ALL)
async def on_message(self, event: AstrMessageEvent):
"""群聊上下文感知"""
message_components = _iter_message_components(event)
"""群聊记忆增强"""
has_image_or_plain = False
for comp in message_components:
for comp in event.message_obj.message:
if isinstance(comp, Plain) or isinstance(comp, Image):
has_image_or_plain = True
break
group_context_enabled = False
if self.group_chat_context:
try:
group_context_enabled = self.group_context_enabled(event)
except BaseException as e:
logger.error(f"group chat context: {e}")
if self.ltm_enabled(event) and self.ltm and has_image_or_plain:
need_active = await self.ltm.need_active_reply(event)
if group_context_enabled and self.group_chat_context and has_image_or_plain:
need_active = await self.group_chat_context.need_active_reply(event)
group_icl_enable = self.context.get_config(umo=event.unified_msg_origin)[
"provider_ltm_settings"
]["group_icl_enable"]
group_icl_enable = self.context.get_config()["provider_ltm_settings"][
"group_icl_enable"
]
if group_icl_enable:
# Skip recording if a command handler matched (e.g. /reset,
# /help, /new). Slash commands are bot instructions, not group
# chat context that should be injected into future LLM requests.
if not event.get_extra("handlers_parsed_params", {}):
try:
await self.group_chat_context.handle_message(event)
except BaseException as e:
logger.error(e)
"""记录对话"""
try:
await self.ltm.handle_message(event)
except BaseException as e:
logger.error(e)
if need_active:
"""主动回复"""
provider = self.context.get_using_provider(event.unified_msg_origin)
if not provider:
logger.error("未找到任何 LLM 提供商。请先配置。无法主动回复")
return
try:
conv = None
session_curr_cid = await self.context.conversation_manager.get_curr_conversation_id(
event.unified_msg_origin,
)
if not session_curr_cid:
logger.error(
"当前未处于对话状态,无法主动回复,请确保 平台设置->会话隔离(unique_session) 未开启,并使用 /new 创建一个会话。",
"当前未处于对话状态,无法主动回复,请确保 平台设置->会话隔离(unique_session) 未开启,并使用 /switch 序号 切换或者 /new 创建一个会话。",
)
return
@@ -199,23 +69,15 @@ class Main(star.Star):
session_curr_cid,
)
prompt = event.message_str
if not conv:
logger.error("未找到对话,无法主动回复")
return
prompt = event.message_str
image_urls = []
for comp in message_components:
if isinstance(comp, Image):
try:
image_urls.append(await comp.convert_to_file_path())
except Exception:
logger.exception("主动回复处理图片失败")
yield event.request_llm(
prompt=prompt,
session_id=event.session_id,
image_urls=image_urls,
conversation=conv,
)
except BaseException as e:
@@ -227,19 +89,30 @@ class Main(star.Star):
self, event: AstrMessageEvent, req: ProviderRequest
) -> None:
"""在请求 LLM 前注入人格信息、Identifier、时间、回复内容等 System Prompt"""
if self.group_chat_context and self.group_context_enabled(event):
if self.ltm and self.ltm_enabled(event):
try:
await self.group_chat_context.on_req_llm(event, req)
await self.ltm.on_req_llm(event, req)
except BaseException as e:
logger.error(f"group chat context: {e}")
logger.error(f"ltm: {e}")
@filter.on_llm_response()
async def record_llm_resp_to_ltm(
self, event: AstrMessageEvent, resp: LLMResponse
) -> None:
"""在 LLM 响应后记录对话"""
if self.ltm and self.ltm_enabled(event):
try:
await self.ltm.after_req_llm(event, resp)
except Exception as e:
logger.error(f"ltm: {e}")
@filter.after_message_sent()
async def after_message_sent(self, event: AstrMessageEvent) -> None:
"""消息发送后处理"""
if self.group_chat_context and self.group_context_enabled(event):
if self.ltm and self.ltm_enabled(event):
try:
clean_session = event.get_extra("_clean_group_context_session", False)
clean_session = event.get_extra("_clean_ltm_session", False)
if clean_session:
await self.group_chat_context.remove_session(event)
await self.ltm.remove_session(event)
except Exception as e:
logger.error(f"group chat context: {e}")
logger.error(f"ltm: {e}")

View File

@@ -1,4 +1,4 @@
name: astrbot
desc: AstrBot's internal plugin, providing some basic capabilities.
author: AstrBot Team
version: 4.1.0
desc: AstrBot 自带插件,包含人格注入、思考内容注入、群聊上下文感知等功能的实现,禁用后将无法使用这些功能。
author: Soulter
version: 4.1.0

View File

@@ -1,6 +0,0 @@
{
"metadata": {
"display_name": "Built-in Commands",
"desc": "AstrBot's internal plugin, providing built-in commands such as /reset, /help, and /sid."
}
}

View File

@@ -1,6 +0,0 @@
{
"metadata": {
"display_name": "内置指令",
"desc": "AstrBot 自带插件,提供 /reset、/help、/sid 等内置指令。"
}
}

View File

@@ -5,7 +5,7 @@ from .alter_cmd import AlterCmdCommands
from .conversation import ConversationCommands
from .help import HelpCommand
from .llm import LLMCommands
from .name import NameCommand
from .persona import PersonaCommands
from .plugin import PluginCommands
from .provider import ProviderCommands
from .setunset import SetUnsetCommands
@@ -19,7 +19,7 @@ __all__ = [
"ConversationCommands",
"HelpCommand",
"LLMCommands",
"NameCommand",
"PersonaCommands",
"PluginCommands",
"ProviderCommands",
"SIDCommand",

View File

@@ -9,56 +9,56 @@ class AdminCommands:
self.context = context
async def op(self, event: AstrMessageEvent, admin_id: str = "") -> None:
"""授权管理员op <admin_id>"""
"""授权管理员op <admin_id>"""
if not admin_id:
event.set_result(
MessageEventResult().message(
"使用方法: /op <id> 授权管理员;/deop <id> 取消管理员可通过 /sid 获取 ID",
"使用方法: /op <id> 授权管理员/deop <id> 取消管理员可通过 /sid 获取 ID",
),
)
return
self.context.get_config()["admins_id"].append(str(admin_id))
self.context.get_config().save_config()
event.set_result(MessageEventResult().message("授权成功"))
event.set_result(MessageEventResult().message("授权成功"))
async def deop(self, event: AstrMessageEvent, admin_id: str = "") -> None:
"""取消授权管理员deop <admin_id>"""
"""取消授权管理员deop <admin_id>"""
if not admin_id:
event.set_result(
MessageEventResult().message(
"使用方法: /deop <id> 取消管理员可通过 /sid 获取 ID",
"使用方法: /deop <id> 取消管理员可通过 /sid 获取 ID",
),
)
return
try:
self.context.get_config()["admins_id"].remove(str(admin_id))
self.context.get_config().save_config()
event.set_result(MessageEventResult().message("取消授权成功"))
event.set_result(MessageEventResult().message("取消授权成功"))
except ValueError:
event.set_result(
MessageEventResult().message("此用户 ID 不在管理员名单内"),
MessageEventResult().message("此用户 ID 不在管理员名单内"),
)
async def wl(self, event: AstrMessageEvent, sid: str = "") -> None:
"""添加白名单wl <sid>"""
"""添加白名单wl <sid>"""
if not sid:
event.set_result(
MessageEventResult().message(
"使用方法: /wl <id> 添加白名单;/dwl <id> 删除白名单可通过 /sid 获取 ID",
"使用方法: /wl <id> 添加白名单/dwl <id> 删除白名单可通过 /sid 获取 ID",
),
)
return
cfg = self.context.get_config(umo=event.unified_msg_origin)
cfg["platform_settings"]["id_whitelist"].append(str(sid))
cfg.save_config()
event.set_result(MessageEventResult().message("添加白名单成功"))
event.set_result(MessageEventResult().message("添加白名单成功"))
async def dwl(self, event: AstrMessageEvent, sid: str = "") -> None:
"""删除白名单dwl <sid>"""
"""删除白名单dwl <sid>"""
if not sid:
event.set_result(
MessageEventResult().message(
"使用方法: /dwl <id> 删除白名单可通过 /sid 获取 ID",
"使用方法: /dwl <id> 删除白名单可通过 /sid 获取 ID",
),
)
return
@@ -66,12 +66,12 @@ class AdminCommands:
cfg = self.context.get_config(umo=event.unified_msg_origin)
cfg["platform_settings"]["id_whitelist"].remove(str(sid))
cfg.save_config()
event.set_result(MessageEventResult().message("删除白名单成功"))
event.set_result(MessageEventResult().message("删除白名单成功"))
except ValueError:
event.set_result(MessageEventResult().message("此 SID 不在白名单内"))
event.set_result(MessageEventResult().message("此 SID 不在白名单内"))
async def update_dashboard(self, event: AstrMessageEvent) -> None:
"""更新管理面板"""
await event.send(MessageChain().message("正在尝试更新管理面板..."))
await download_dashboard(version=f"v{VERSION}", latest=False)
await event.send(MessageChain().message("管理面板更新完成"))
await event.send(MessageChain().message("管理面板更新完成"))

View File

@@ -18,9 +18,7 @@ class AlterCmdCommands(CommandParserMixin):
"""更新reset命令在特定场景下的权限设置"""
from astrbot.api import sp
alter_cmd_cfg: dict[str, dict[str, dict[str, str]]] = (
await sp.global_get("alter_cmd", {}) or {}
)
alter_cmd_cfg = await sp.global_get("alter_cmd", {})
plugin_cfg = alter_cmd_cfg.get("astrbot", {})
reset_cfg = plugin_cfg.get("reset", {})
reset_cfg[scene_key] = perm_type
@@ -33,7 +31,7 @@ class AlterCmdCommands(CommandParserMixin):
if token.len < 3:
await event.send(
MessageChain().message(
"该指令用于设置指令或指令组的权限\n"
"该指令用于设置指令或指令组的权限\n"
"格式: /alter_cmd <cmd_name> <admin/member>\n"
"例1: /alter_cmd c1 admin 将 c1 设为管理员指令\n"
"例2: /alter_cmd g1 c1 admin 将 g1 指令组的 c1 子指令设为管理员指令\n"
@@ -49,9 +47,7 @@ class AlterCmdCommands(CommandParserMixin):
if cmd_name == "reset" and cmd_type == "config":
from astrbot.api import sp
alter_cmd_cfg: dict[str, dict[str, dict[str, str]]] = (
await sp.global_get("alter_cmd", {}) or {}
)
alter_cmd_cfg = await sp.global_get("alter_cmd", {})
plugin_ = alter_cmd_cfg.get("astrbot", {})
reset_cfg = plugin_.get("reset", {})
@@ -60,11 +56,11 @@ class AlterCmdCommands(CommandParserMixin):
private = reset_cfg.get("private", "member")
config_menu = f"""reset命令权限细粒度配置
当前配置:
当前配置
1. 群聊+会话隔离开: {group_unique_on}
2. 群聊+会话隔离关: {group_unique_off}
3. 私聊: {private}
修改指令格式:
修改指令格式
/alter_cmd reset scene <场景编号> <admin/member>
例如: /alter_cmd reset scene 2 member"""
await event.send(MessageChain().message(config_menu))
@@ -86,12 +82,12 @@ class AlterCmdCommands(CommandParserMixin):
if perm_type not in ["admin", "member"]:
await event.send(
MessageChain().message("权限类型错误,只能是 admin 或 member"),
MessageChain().message("权限类型错误只能是 admin 或 member"),
)
return
scene_index = int(scene_num)
scene = RstScene.from_index(scene_index)
scene_num = int(scene_num)
scene = RstScene.from_index(scene_num)
scene_key = scene.key
await self.update_reset_permission(scene_key, perm_type)
@@ -105,18 +101,13 @@ class AlterCmdCommands(CommandParserMixin):
if cmd_type not in ["admin", "member"]:
await event.send(
MessageChain().message("指令类型错误,可选类型有 admin, member"),
MessageChain().message("指令类型错误可选类型有 admin, member"),
)
return
# 查找指令
cmd_name = " ".join(token.tokens[1:-1])
permission_type = token.get(-1)
if permission_type not in ["admin", "member"]:
await event.send(
MessageChain().message("指令类型错误,可选类型有 admin, member"),
)
return
cmd_type = token.get(-1)
found_command = None
cmd_group = False
for handler in star_handlers_registry:
@@ -140,25 +131,20 @@ class AlterCmdCommands(CommandParserMixin):
from astrbot.api import sp
stored_alter_cmd_cfg: dict[str, dict[str, dict[str, str]]] = (
await sp.global_get("alter_cmd", {}) or {}
)
if found_plugin.name is None:
await event.send(MessageChain().message("未找到指令对应的插件名称"))
return
plugin_ = stored_alter_cmd_cfg.get(found_plugin.name, {})
alter_cmd_cfg = await sp.global_get("alter_cmd", {})
plugin_ = alter_cmd_cfg.get(found_plugin.name, {})
cfg = plugin_.get(found_command.handler_name, {})
cfg["permission"] = permission_type
cfg["permission"] = cmd_type
plugin_[found_command.handler_name] = cfg
stored_alter_cmd_cfg[found_plugin.name] = plugin_
alter_cmd_cfg[found_plugin.name] = plugin_
await sp.global_put("alter_cmd", stored_alter_cmd_cfg)
await sp.global_put("alter_cmd", alter_cmd_cfg)
# 注入权限过滤器
found_permission_filter = False
for filter_ in found_command.event_filters:
if isinstance(filter_, PermissionTypeFilter):
if permission_type == "admin":
if cmd_type == "admin":
from astrbot.api.event import filter
filter_.permission_type = filter.PermissionType.ADMIN
@@ -175,13 +161,13 @@ class AlterCmdCommands(CommandParserMixin):
0,
PermissionTypeFilter(
filter.PermissionType.ADMIN
if permission_type == "admin"
if cmd_type == "admin"
else filter.PermissionType.MEMBER,
),
)
cmd_group_str = "指令组" if cmd_group else "指令"
await event.send(
MessageChain().message(
f"已将{cmd_name}{cmd_group_str} 的权限级别调整为 {permission_type}",
f"已将{cmd_name}{cmd_group_str} 的权限级别调整为 {cmd_type}",
),
)

View File

@@ -1,16 +1,13 @@
from sqlalchemy import case, func, select
from sqlmodel import col
import datetime
from astrbot.api import sp, star
from astrbot.api.event import AstrMessageEvent, MessageEventResult
from astrbot.core import logger
from astrbot.core.agent.runners.deerflow.constants import (
DEERFLOW_AGENT_RUNNER_PROVIDER_ID_KEY,
DEERFLOW_PROVIDER_TYPE,
DEERFLOW_THREAD_ID_KEY,
)
from astrbot.core.agent.runners.deerflow.deerflow_api_client import DeerFlowAPIClient
from astrbot.core.db.po import ProviderStat
from astrbot.core.platform.astr_message_event import MessageSession
from astrbot.core.platform.message_type import MessageType
from astrbot.core.utils.active_event_registry import active_event_registry
from .utils.rst_scene import RstScene
@@ -24,85 +21,6 @@ THIRD_PARTY_AGENT_RUNNER_KEY = {
THIRD_PARTY_AGENT_RUNNER_STR = ", ".join(THIRD_PARTY_AGENT_RUNNER_KEY.keys())
async def _cleanup_deerflow_thread_if_present(
context: star.Context,
umo: str,
) -> None:
try:
thread_id = await sp.get_async(
scope="umo",
scope_id=umo,
key=DEERFLOW_THREAD_ID_KEY,
default="",
)
if not thread_id:
return
cfg = context.get_config(umo=umo)
provider_id = cfg["provider_settings"].get(
DEERFLOW_AGENT_RUNNER_PROVIDER_ID_KEY,
"",
)
if not provider_id:
return
merged_provider_config = context.provider_manager.get_provider_config_by_id(
provider_id,
merged=True,
)
if not merged_provider_config:
logger.warning(
"Failed to resolve DeerFlow provider config for remote thread cleanup: provider_id=%s",
provider_id,
)
return
client = DeerFlowAPIClient(
api_base=merged_provider_config.get(
"deerflow_api_base",
"http://127.0.0.1:2026",
),
api_key=merged_provider_config.get("deerflow_api_key", ""),
auth_header=merged_provider_config.get("deerflow_auth_header", ""),
proxy=merged_provider_config.get("proxy", ""),
)
try:
await client.delete_thread(thread_id)
finally:
try:
await client.close()
except Exception as e:
logger.warning(
"Failed to close DeerFlow API client after thread cleanup: %s",
e,
)
except Exception as e:
logger.warning(
"Failed to clean up DeerFlow thread for session %s: %s",
umo,
e,
)
async def _clear_third_party_agent_runner_state(
context: star.Context,
umo: str,
agent_runner_type: str,
) -> None:
session_key = THIRD_PARTY_AGENT_RUNNER_KEY.get(agent_runner_type)
if not session_key:
return
if agent_runner_type == DEERFLOW_PROVIDER_TYPE:
await _cleanup_deerflow_thread_if_present(context, umo)
await sp.remove_async(
scope="umo",
scope_id=umo,
key=session_key,
)
class ConversationCommands:
def __init__(self, context: star.Context) -> None:
self.context = context
@@ -142,8 +60,8 @@ class ConversationCommands:
if required_perm == "admin" and message.role != "admin":
message.set_result(
MessageEventResult().message(
f"Reset command requires admin permission in {scene.name} scenario, "
f"you (ID {message.get_sender_id()}) are not admin, cannot perform this action.",
f"{scene.name}场景下reset命令需要管理员权限"
f" (ID {message.get_sender_id()}) 不是管理员,无法执行此操作。",
),
)
return
@@ -151,21 +69,17 @@ class ConversationCommands:
agent_runner_type = cfg["provider_settings"]["agent_runner_type"]
if agent_runner_type in THIRD_PARTY_AGENT_RUNNER_KEY:
active_event_registry.stop_all(umo, exclude=message)
await _clear_third_party_agent_runner_state(
self.context,
umo,
agent_runner_type,
)
message.set_result(
MessageEventResult().message("✅ Conversation reset successfully.")
await sp.remove_async(
scope="umo",
scope_id=umo,
key=THIRD_PARTY_AGENT_RUNNER_KEY[agent_runner_type],
)
message.set_result(MessageEventResult().message("重置对话成功。"))
return
if not self.context.get_using_provider(umo):
message.set_result(
MessageEventResult().message(
"😕 Cannot find any LLM provider. Configure one first."
),
MessageEventResult().message("未找到任何 LLM 提供商。请先配置。"),
)
return
@@ -174,7 +88,7 @@ class ConversationCommands:
if not cid:
message.set_result(
MessageEventResult().message(
"😕 You are not in a conversation. Use /new to create one.",
"当前未处于对话状态,请 /switch 切换或者 /new 创建。",
),
)
return
@@ -187,9 +101,9 @@ class ConversationCommands:
[],
)
ret = "✅ Conversation reset successfully."
ret = "清除聊天历史成功!"
message.set_extra("_clean_group_context_session", True)
message.set_extra("_clean_ltm_session", True)
message.set_result(MessageEventResult().message(ret))
@@ -210,29 +124,160 @@ class ConversationCommands:
if stopped_count > 0:
message.set_result(
MessageEventResult().message(
f"✅ Requested to stop {stopped_count} running tasks."
f"已请求停止 {stopped_count} 个运行中的任务。"
)
)
return
message.set_result(
MessageEventResult().message("✅ No running tasks in the current session.")
message.set_result(MessageEventResult().message("当前会话没有运行中的任务。"))
async def his(self, message: AstrMessageEvent, page: int = 1) -> None:
"""查看对话记录"""
if not self.context.get_using_provider(message.unified_msg_origin):
message.set_result(
MessageEventResult().message("未找到任何 LLM 提供商。请先配置。"),
)
return
size_per_page = 6
conv_mgr = self.context.conversation_manager
umo = message.unified_msg_origin
session_curr_cid = await conv_mgr.get_curr_conversation_id(umo)
if not session_curr_cid:
session_curr_cid = await conv_mgr.new_conversation(
umo,
message.get_platform_id(),
)
contexts, total_pages = await conv_mgr.get_human_readable_context(
umo,
session_curr_cid,
page,
size_per_page,
)
parts = []
for context in contexts:
if len(context) > 150:
context = context[:150] + "..."
parts.append(f"{context}\n")
history = "".join(parts)
ret = (
f"当前对话历史记录:"
f"{history or '无历史记录'}\n\n"
f"{page} 页 | 共 {total_pages}\n"
f"*输入 /history 2 跳转到第 2 页"
)
message.set_result(MessageEventResult().message(ret).use_t2i(False))
async def convs(self, message: AstrMessageEvent, page: int = 1) -> None:
"""查看对话列表"""
cfg = self.context.get_config(umo=message.unified_msg_origin)
agent_runner_type = cfg["provider_settings"]["agent_runner_type"]
if agent_runner_type in THIRD_PARTY_AGENT_RUNNER_KEY:
message.set_result(
MessageEventResult().message(
f"{THIRD_PARTY_AGENT_RUNNER_STR} 对话列表功能暂不支持。",
),
)
return
size_per_page = 6
"""获取所有对话列表"""
conversations_all = await self.context.conversation_manager.get_conversations(
message.unified_msg_origin,
)
"""计算总页数"""
total_pages = (len(conversations_all) + size_per_page - 1) // size_per_page
"""确保页码有效"""
page = max(1, min(page, total_pages))
"""分页处理"""
start_idx = (page - 1) * size_per_page
end_idx = start_idx + size_per_page
conversations_paged = conversations_all[start_idx:end_idx]
parts = ["对话列表:\n---\n"]
"""全局序号从当前页的第一个开始"""
global_index = start_idx + 1
"""生成所有对话的标题字典"""
_titles = {}
for conv in conversations_all:
title = conv.title if conv.title else "新对话"
_titles[conv.cid] = title
"""遍历分页后的对话生成列表显示"""
provider_settings = cfg.get("provider_settings", {})
platform_name = message.get_platform_name()
for conv in conversations_paged:
(
persona_id,
_,
force_applied_persona_id,
_,
) = await self.context.persona_manager.resolve_selected_persona(
umo=message.unified_msg_origin,
conversation_persona_id=conv.persona_id,
platform_name=platform_name,
provider_settings=provider_settings,
)
if persona_id == "[%None]":
persona_name = ""
elif persona_id:
persona_name = persona_id
else:
persona_name = ""
if force_applied_persona_id:
persona_name = f"{persona_name} (自定义规则)"
title = _titles.get(conv.cid, "新对话")
parts.append(
f"{global_index}. {title}({conv.cid[:4]})\n 人格情景: {persona_name}\n 上次更新: {datetime.datetime.fromtimestamp(conv.updated_at).strftime('%m-%d %H:%M')}\n"
)
global_index += 1
parts.append("---\n")
ret = "".join(parts)
curr_cid = await self.context.conversation_manager.get_curr_conversation_id(
message.unified_msg_origin,
)
if curr_cid:
"""从所有对话的标题字典中获取标题"""
title = _titles.get(curr_cid, "新对话")
ret += f"\n当前对话: {title}({curr_cid[:4]})"
else:
ret += "\n当前对话: 无"
cfg = self.context.get_config(umo=message.unified_msg_origin)
unique_session = cfg["platform_settings"]["unique_session"]
if unique_session:
ret += "\n会话隔离粒度: 个人"
else:
ret += "\n会话隔离粒度: 群聊"
ret += f"\n{page} 页 | 共 {total_pages}"
ret += "\n*输入 /ls 2 跳转到第 2 页"
message.set_result(MessageEventResult().message(ret).use_t2i(False))
return
async def new_conv(self, message: AstrMessageEvent) -> None:
"""创建新对话"""
cfg = self.context.get_config(umo=message.unified_msg_origin)
agent_runner_type = cfg["provider_settings"]["agent_runner_type"]
if agent_runner_type in THIRD_PARTY_AGENT_RUNNER_KEY:
active_event_registry.stop_all(message.unified_msg_origin, exclude=message)
await _clear_third_party_agent_runner_state(
self.context,
message.unified_msg_origin,
agent_runner_type,
)
message.set_result(
MessageEventResult().message("✅ New conversation created.")
await sp.remove_async(
scope="umo",
scope_id=message.unified_msg_origin,
key=THIRD_PARTY_AGENT_RUNNER_KEY[agent_runner_type],
)
message.set_result(MessageEventResult().message("已创建新对话。"))
return
active_event_registry.stop_all(message.unified_msg_origin, exclude=message)
@@ -243,69 +288,133 @@ class ConversationCommands:
persona_id=cpersona,
)
message.set_extra("_clean_group_context_session", True)
message.set_extra("_clean_ltm_session", True)
message.set_result(
MessageEventResult().message(
f"✅ Switched to new conversation: {cid[:4]}"
),
MessageEventResult().message(f"切换到新对话: 新对话({cid[:4]})。"),
)
async def stats(self, message: AstrMessageEvent) -> None:
"""Show token usage statistics for the current conversation."""
async def groupnew_conv(self, message: AstrMessageEvent, sid: str = "") -> None:
"""创建新群聊对话"""
if sid:
session = str(
MessageSession(
platform_name=message.platform_meta.id,
message_type=MessageType("GroupMessage"),
session_id=sid,
),
)
cpersona = await self._get_current_persona_id(session)
cid = await self.context.conversation_manager.new_conversation(
session,
message.get_platform_id(),
persona_id=cpersona,
)
message.set_result(
MessageEventResult().message(
f"群聊 {session} 已切换到新对话: 新对话({cid[:4]})。",
),
)
else:
message.set_result(
MessageEventResult().message("请输入群聊 ID。/groupnew 群聊ID。"),
)
async def switch_conv(
self,
message: AstrMessageEvent,
index: int | None = None,
) -> None:
"""通过 /ls 前面的序号切换对话"""
if not isinstance(index, int):
message.set_result(
MessageEventResult().message("类型错误,请输入数字对话序号。"),
)
return
if index is None:
message.set_result(
MessageEventResult().message(
"请输入对话序号。/switch 对话序号。/ls 查看对话 /new 新建对话",
),
)
return
conversations = await self.context.conversation_manager.get_conversations(
message.unified_msg_origin,
)
if index > len(conversations) or index < 1:
message.set_result(
MessageEventResult().message("对话序号错误,请使用 /ls 查看"),
)
else:
conversation = conversations[index - 1]
title = conversation.title if conversation.title else "新对话"
await self.context.conversation_manager.switch_conversation(
message.unified_msg_origin,
conversation.cid,
)
message.set_result(
MessageEventResult().message(
f"切换到对话: {title}({conversation.cid[:4]})。",
),
)
async def rename_conv(self, message: AstrMessageEvent, new_name: str = "") -> None:
"""重命名对话"""
if not new_name:
message.set_result(MessageEventResult().message("请输入新的对话名称。"))
return
await self.context.conversation_manager.update_conversation_title(
message.unified_msg_origin,
new_name,
)
message.set_result(MessageEventResult().message("重命名对话成功。"))
async def del_conv(self, message: AstrMessageEvent) -> None:
"""删除当前对话"""
umo = message.unified_msg_origin
cid = await self.context.conversation_manager.get_curr_conversation_id(umo)
if not cid:
cfg = self.context.get_config(umo=umo)
is_unique_session = cfg["platform_settings"]["unique_session"]
if message.get_group_id() and not is_unique_session and message.role != "admin":
# 群聊,没开独立会话,发送人不是管理员
message.set_result(
MessageEventResult().message(
"❌ You are not in a conversation. Use /new to create one."
f"会话处于群聊,并且未开启独立会话,并且您 (ID {message.get_sender_id()}) 不是管理员,因此没有权限删除当前对话。",
),
)
return
db = self.context.get_db()
async with db.get_db() as session:
result = await session.execute(
select(
func.count(case((col(ProviderStat.id).is_not(None), 1))).label(
"record_count",
),
func.coalesce(func.sum(ProviderStat.token_input_other), 0).label(
"total_input_other",
),
func.coalesce(func.sum(ProviderStat.token_input_cached), 0).label(
"total_input_cached",
),
func.coalesce(func.sum(ProviderStat.token_output), 0).label(
"total_output",
),
).where(
col(ProviderStat.agent_type) == "internal",
col(ProviderStat.conversation_id) == cid,
)
)
stats = result.one()
if stats.record_count == 0:
message.set_result(
MessageEventResult().message(
"📊 No stats available for this conversation yet."
),
agent_runner_type = cfg["provider_settings"]["agent_runner_type"]
if agent_runner_type in THIRD_PARTY_AGENT_RUNNER_KEY:
active_event_registry.stop_all(umo, exclude=message)
await sp.remove_async(
scope="umo",
scope_id=umo,
key=THIRD_PARTY_AGENT_RUNNER_KEY[agent_runner_type],
)
message.set_result(MessageEventResult().message("重置对话成功。"))
return
total_input_other = stats.total_input_other
total_input_cached = stats.total_input_cached
total_output = stats.total_output
total_tokens = total_input_other + total_input_cached + total_output
ret = (
f"📊 Conversation Token usage (ID: {cid[:8]}...)\n"
f"Total: {total_tokens:,}\n"
f"Input (cached): {total_input_cached:,}\n"
f"Input (other): {total_input_other:,}\n"
f"Output: {total_output:,}\n"
session_curr_cid = (
await self.context.conversation_manager.get_curr_conversation_id(umo)
)
if not session_curr_cid:
message.set_result(
MessageEventResult().message(
"当前未处于对话状态,请 /switch 序号 切换或 /new 创建。",
),
)
return
active_event_registry.stop_all(umo, exclude=message)
await self.context.conversation_manager.delete_conversation(
umo,
session_curr_cid,
)
ret = "删除当前对话成功。不再处于对话状态,使用 /switch 序号 切换到其他对话或 /new 创建。"
message.set_extra("_clean_ltm_session", True)
message.set_result(MessageEventResult().message(ret))

View File

@@ -23,13 +23,16 @@ class HelpCommand:
return ""
async def _build_reserved_command_lines(self) -> list[str]:
"""使用实时指令配置生成内置指令清单,确保重命名/禁用后与实际生效状态保持一致。"""
"""
使用实时指令配置生成内置指令清单,确保重命名/禁用后与实际生效状态保持一致。
"""
try:
commands = await command_management.list_commands()
except BaseException:
return []
lines: list[str] = []
hidden_commands = {"set", "unset", "websearch"}
def walk(items: list[dict], indent: int = 0) -> None:
for item in items:
@@ -46,12 +49,9 @@ class HelpCommand:
or item.get("original_command")
or item.get("handler_name")
)
if not effective or effective in [
"set",
"unset",
"help",
"dashboard_update",
]:
if not effective:
continue
if effective in hidden_commands:
continue
description = item.get("description") or ""
@@ -73,13 +73,12 @@ class HelpCommand:
dashboard_version = await get_dashboard_version()
command_lines = await self._build_reserved_command_lines()
commands_section = (
"\n".join(command_lines)
if command_lines
else "No enabled built-in commands."
"\n".join(command_lines) if command_lines else "暂无启用的内置指令"
)
msg_parts = [
f"AstrBot v{VERSION}(WebUI: {dashboard_version})",
"内置指令:",
commands_section,
]
if notice:

View File

@@ -17,4 +17,4 @@ class LLMCommands:
cfg["provider_settings"]["enable"] = True
status = "开启"
cfg.save_config()
await event.send(MessageChain().message(f"{status} LLM 聊天功能"))
await event.send(MessageChain().message(f"{status} LLM 聊天功能"))

View File

@@ -1,48 +0,0 @@
from astrbot.api import star
from astrbot.api.event import AstrMessageEvent, MessageEventResult
from astrbot.core.umo_alias import get_event_auto_name, normalize_umo_name
class NameCommand:
def __init__(self, context: star.Context) -> None:
self.context = context
async def name(self, event: AstrMessageEvent, alias: str) -> None:
umo = event.unified_msg_origin
auto_name = get_event_auto_name(event)
alias = normalize_umo_name(alias)
if not alias:
saved_alias = await self.context.get_db().get_umo_alias(umo)
user_alias = normalize_umo_name(
saved_alias.user_alias if saved_alias else ""
)
event.set_result(
MessageEventResult()
.message(
"\n".join(
[
"Usage: /name <name>",
f"UMO: {umo}",
f"Auto name: {auto_name or '(empty)'}",
f"Alias: {user_alias or '(empty)'}",
]
)
)
.use_t2i(False)
)
return
sender_id = str(event.get_sender_id() or "")
await self.context.get_db().upsert_umo_alias(
umo=umo,
creator_sender_id=sender_id,
auto_name=auto_name,
user_alias=alias,
)
event.set_result(
MessageEventResult()
.message(f"UMO name set to: {alias}\nUMO: {umo}")
.use_t2i(False)
)

View File

@@ -18,10 +18,10 @@ class PersonaCommands:
all_personas: list["Persona"],
depth: int = 0,
) -> list[str]:
"""递归构建树状输出,使用短线条表示层级"""
"""递归构建树状输出使用短线条表示层级"""
lines: list[str] = []
# 使用短线条作为缩进前缀,每层只用 "" 加一个空格
prefix = " " * depth
# 使用短线条作为缩进前缀每层只用 "" 加一个空格
prefix = " " * depth
for folder in folder_tree:
# 输出文件夹
@@ -31,7 +31,7 @@ class PersonaCommands:
folder_personas = [
p for p in all_personas if p.folder_id == folder["folder_id"]
]
child_prefix = " " * (depth + 1)
child_prefix = " " * (depth + 1)
# 输出该文件夹下的人格
for persona in folder_personas:
@@ -45,13 +45,13 @@ class PersonaCommands:
children,
all_personas,
depth + 1,
),
)
)
return lines
async def persona(self, message: AstrMessageEvent) -> None:
parts = message.message_str.split(" ")
l = message.message_str.split(" ") # noqa: E741
umo = message.unified_msg_origin
curr_persona_name = ""
@@ -71,7 +71,7 @@ class PersonaCommands:
if conv is None:
message.set_result(
MessageEventResult().message(
"当前对话不存在,请先使用 /new 新建一个对话",
"当前对话不存在请先使用 /new 新建一个对话",
),
)
return
@@ -100,10 +100,10 @@ class PersonaCommands:
if force_applied_persona_id:
curr_persona_name = f"{curr_persona_name} (自定义规则)"
curr_cid_title = conv.title or "新对话"
curr_cid_title = conv.title if conv.title else "新对话"
curr_cid_title += f"({cid[:4]})"
if len(parts) == 1:
if len(l) == 1:
message.set_result(
MessageEventResult()
.message(
@@ -122,21 +122,21 @@ class PersonaCommands:
)
.use_t2i(False),
)
elif parts[1] == "list":
elif l[1] == "list":
# 获取文件夹树和所有人格
folder_tree = await self.context.persona_manager.get_folder_tree()
all_personas = self.context.persona_manager.personas
lines = ["📂 人格列表:\n"]
lines = ["📂 人格列表\n"]
# 构建树状输出
tree_lines = self._build_tree_output(folder_tree, all_personas)
lines.extend(tree_lines)
# 输出根目录下的人格(没有文件夹的)
# 输出根目录下的人格没有文件夹的
root_personas = [p for p in all_personas if p.folder_id is None]
if root_personas:
if tree_lines: # 如果有文件夹内容,加个空行
if tree_lines: # 如果有文件夹内容加个空行
lines.append("")
for persona in root_personas:
lines.append(f"👤 {persona.persona_id}")
@@ -149,44 +149,44 @@ class PersonaCommands:
msg = "\n".join(lines)
message.set_result(MessageEventResult().message(msg).use_t2i(False))
elif parts[1] == "view":
if len(parts) == 2:
elif l[1] == "view":
if len(l) == 2:
message.set_result(MessageEventResult().message("请输入人格情景名"))
return
ps = parts[2].strip()
if persona_info := next(
ps = l[2].strip()
if persona := next(
builtins.filter(
lambda persona: persona["name"] == ps,
self.context.provider_manager.personas,
),
None,
):
msg = f"人格{ps}的详细信息:\n"
msg += f"{persona_info['prompt']}\n"
msg = f"人格{ps}的详细信息\n"
msg += f"{persona['prompt']}\n"
else:
msg = f"人格{ps}不存在"
message.set_result(MessageEventResult().message(msg))
elif parts[1] == "unset":
elif l[1] == "unset":
if not cid:
message.set_result(
MessageEventResult().message("当前没有对话,无法取消人格"),
MessageEventResult().message("当前没有对话无法取消人格"),
)
return
await self.context.conversation_manager.update_conversation_persona_id(
message.unified_msg_origin,
"[%None]",
)
message.set_result(MessageEventResult().message("取消人格成功"))
message.set_result(MessageEventResult().message("取消人格成功"))
else:
ps = "".join(parts[1:]).strip()
ps = "".join(l[1:]).strip()
if not cid:
message.set_result(
MessageEventResult().message(
"当前没有对话,请先开始对话或使用 /new 创建一个对话",
"当前没有对话请先开始对话或使用 /new 创建一个对话",
),
)
return
if persona_info := next(
if persona := next(
builtins.filter(
lambda persona: persona["name"] == ps,
self.context.provider_manager.personas,
@@ -199,16 +199,18 @@ class PersonaCommands:
)
force_warn_msg = ""
if force_applied_persona_id:
force_warn_msg = "提醒:由于自定义规则,您现在切换的人格将不会生效。"
force_warn_msg = (
"提醒:由于自定义规则,您现在切换的人格将不会生效。"
)
message.set_result(
MessageEventResult().message(
f"设置成功如果您正在切换到不同的人格,请注意使用 /reset 来清空上下文,防止原人格对话影响现人格{force_warn_msg}",
f"设置成功如果您正在切换到不同的人格请注意使用 /reset 来清空上下文防止原人格对话影响现人格{force_warn_msg}",
),
)
else:
message.set_result(
MessageEventResult().message(
"不存在该人格情景使用 /persona list 查看所有",
"不存在该人格情景使用 /persona list 查看所有",
),
)

View File

@@ -4,6 +4,7 @@ from astrbot.core import DEMO_MODE, logger
from astrbot.core.star.filter.command import CommandFilter
from astrbot.core.star.filter.command_group import CommandGroupFilter
from astrbot.core.star.star_handler import StarHandlerMetadata, star_handlers_registry
from astrbot.core.star.star_manager import PluginManager
class PluginCommands:
@@ -11,8 +12,8 @@ class PluginCommands:
self.context = context
async def plugin_ls(self, event: AstrMessageEvent) -> None:
"""获取已经安装的插件列表"""
parts = ["已加载的插件:\n"]
"""获取已经安装的插件列表"""
parts = ["已加载的插件\n"]
for plugin in self.context.get_all_stars():
line = f"- `{plugin.name}` By {plugin.author}: {plugin.desc}"
if not plugin.activated:
@@ -20,11 +21,11 @@ class PluginCommands:
parts.append(line + "\n")
if len(parts) == 1:
plugin_list_info = "没有加载任何插件"
plugin_list_info = "没有加载任何插件"
else:
plugin_list_info = "".join(parts)
plugin_list_info += "\n使用 /plugin help <插件名> 查看插件帮助和加载的指令\n使用 /plugin on/off <插件名> 启用或者禁用插件"
plugin_list_info += "\n使用 /plugin help <插件名> 查看插件帮助和加载的指令\n使用 /plugin on/off <插件名> 启用或者禁用插件"
event.set_result(
MessageEventResult().message(f"{plugin_list_info}").use_t2i(False),
)
@@ -32,51 +33,45 @@ class PluginCommands:
async def plugin_off(self, event: AstrMessageEvent, plugin_name: str = "") -> None:
"""禁用插件"""
if DEMO_MODE:
event.set_result(MessageEventResult().message("演示模式下无法禁用插件"))
event.set_result(MessageEventResult().message("演示模式下无法禁用插件"))
return
if not plugin_name:
event.set_result(
MessageEventResult().message("/plugin off <插件名> 禁用插件"),
MessageEventResult().message("/plugin off <插件名> 禁用插件"),
)
return
if self.context._star_manager is None:
event.set_result(MessageEventResult().message("插件管理器未初始化。"))
return
await self.context._star_manager.turn_off_plugin(plugin_name)
event.set_result(MessageEventResult().message(f"插件 {plugin_name} 已禁用。"))
await self.context._star_manager.turn_off_plugin(plugin_name) # type: ignore
event.set_result(MessageEventResult().message(f"插件 {plugin_name} 已禁用。"))
async def plugin_on(self, event: AstrMessageEvent, plugin_name: str = "") -> None:
"""启用插件"""
if DEMO_MODE:
event.set_result(MessageEventResult().message("演示模式下无法启用插件"))
event.set_result(MessageEventResult().message("演示模式下无法启用插件"))
return
if not plugin_name:
event.set_result(
MessageEventResult().message("/plugin on <插件名> 启用插件"),
MessageEventResult().message("/plugin on <插件名> 启用插件"),
)
return
if self.context._star_manager is None:
event.set_result(MessageEventResult().message("插件管理器未初始化。"))
return
await self.context._star_manager.turn_on_plugin(plugin_name)
event.set_result(MessageEventResult().message(f"插件 {plugin_name} 已启用。"))
await self.context._star_manager.turn_on_plugin(plugin_name) # type: ignore
event.set_result(MessageEventResult().message(f"插件 {plugin_name} 已启用。"))
async def plugin_get(self, event: AstrMessageEvent, plugin_repo: str = "") -> None:
"""安装插件"""
if DEMO_MODE:
event.set_result(MessageEventResult().message("演示模式下无法安装插件"))
event.set_result(MessageEventResult().message("演示模式下无法安装插件"))
return
if not plugin_repo:
event.set_result(
MessageEventResult().message("/plugin get <插件仓库地址> 安装插件"),
)
return
logger.info(f"准备从 {plugin_repo} 安装插件")
logger.info(f"准备从 {plugin_repo} 安装插件")
if self.context._star_manager:
star_mgr = self.context._star_manager
star_mgr: PluginManager = self.context._star_manager
try:
await star_mgr.install_plugin(plugin_repo)
event.set_result(MessageEventResult().message("安装插件成功"))
await star_mgr.install_plugin(plugin_repo) # type: ignore
event.set_result(MessageEventResult().message("安装插件成功"))
except Exception as e:
logger.error(f"安装插件失败: {e}")
event.set_result(MessageEventResult().message(f"安装插件失败: {e}"))
@@ -86,12 +81,12 @@ class PluginCommands:
"""获取插件帮助"""
if not plugin_name:
event.set_result(
MessageEventResult().message("/plugin help <插件名> 查看插件信息"),
MessageEventResult().message("/plugin help <插件名> 查看插件信息"),
)
return
plugin = self.context.get_registered_star(plugin_name)
if plugin is None:
event.set_result(MessageEventResult().message("未找到此插件"))
event.set_result(MessageEventResult().message("未找到此插件"))
return
help_msg = ""
help_msg += f"\n\n✨ 作者: {plugin.author}\n✨ 版本: {plugin.version}"
@@ -111,15 +106,15 @@ class PluginCommands:
command_names.append(filter_.group_name)
if len(command_handlers) > 0:
parts = ["\n\n🔧 指令列表:\n"]
parts = ["\n\n🔧 指令列表\n"]
for i in range(len(command_handlers)):
line = f"- {command_names[i]}"
if command_handlers[i].desc:
line += f": {command_handlers[i].desc}"
parts.append(line + "\n")
parts.append("\nTip: 指令的触发需要添加唤醒前缀,默认为 /")
parts.append("\nTip: 指令的触发需要添加唤醒前缀默认为 /")
help_msg += "".join(parts)
ret = f"🧩 插件 {plugin_name} 帮助信息:\n" + help_msg
ret += "更多帮助信息请查看插件仓库 README"
ret = f"🧩 插件 {plugin_name} 帮助信息\n" + help_msg
ret += "更多帮助信息请查看插件仓库 README"
event.set_result(MessageEventResult().message(ret).use_t2i(False))

View File

@@ -1,6 +1,10 @@
from __future__ import annotations
import asyncio
import time
from collections.abc import Sequence
from dataclasses import dataclass
from typing import TYPE_CHECKING
from astrbot import logger
from astrbot.api import star
@@ -8,10 +12,251 @@ from astrbot.api.event import AstrMessageEvent, MessageEventResult
from astrbot.core.provider.entities import ProviderType
from astrbot.core.utils.error_redaction import safe_error
if TYPE_CHECKING:
from astrbot.core.provider.provider import Provider
MODEL_LIST_CACHE_TTL_SECONDS_DEFAULT = 30.0
MODEL_LOOKUP_MAX_CONCURRENCY_DEFAULT = 4
MODEL_LOOKUP_MAX_CONCURRENCY_UPPER_BOUND = 16
MODEL_LIST_CACHE_TTL_KEY = "model_list_cache_ttl_seconds"
MODEL_LOOKUP_MAX_CONCURRENCY_KEY = "model_lookup_max_concurrency"
MODEL_CACHE_MAX_ENTRIES = 512
@dataclass(frozen=True)
class _ModelLookupConfig:
umo: str | None
cache_ttl_seconds: float
max_concurrency: int
class _ModelCache:
def __init__(self) -> None:
self._store: dict[tuple[str, str | None], tuple[float, list[str]]] = {}
def get(self, provider_id: str, umo: str | None, ttl: float) -> list[str] | None:
if ttl <= 0:
return None
entry = self._store.get((provider_id, umo))
if not entry:
return None
timestamp, models = entry
if time.monotonic() - timestamp > ttl:
self._store.pop((provider_id, umo), None)
return None
return models
def set(
self, provider_id: str, umo: str | None, models: list[str], ttl: float
) -> None:
if ttl <= 0:
return
self._store[(provider_id, umo)] = (time.monotonic(), list(models))
self._evict_if_needed()
def _evict_if_needed(self) -> None:
if len(self._store) <= MODEL_CACHE_MAX_ENTRIES:
return
# Drop oldest entries first when cache grows too large.
overflow = len(self._store) - MODEL_CACHE_MAX_ENTRIES
for key, _ in sorted(
self._store.items(),
key=lambda item: item[1][0],
)[:overflow]:
self._store.pop(key, None)
def invalidate(
self, provider_id: str | None = None, *, umo: str | None = None
) -> None:
if provider_id is None:
self._store.clear()
return
if umo is not None:
self._store.pop((provider_id, umo), None)
return
stale_keys = [
cache_key for cache_key in self._store if cache_key[0] == provider_id
]
for cache_key in stale_keys:
self._store.pop(cache_key, None)
class ProviderCommands:
def __init__(self, context: star.Context) -> None:
self.context = context
self._model_cache = _ModelCache()
self._register_provider_change_hook()
def _register_provider_change_hook(self) -> None:
set_change_callback = getattr(
self.context.provider_manager,
"set_provider_change_callback",
None,
)
if callable(set_change_callback):
set_change_callback(self._on_provider_manager_changed)
return
register_change_hook = getattr(
self.context.provider_manager,
"register_provider_change_hook",
None,
)
if callable(register_change_hook):
register_change_hook(self._on_provider_manager_changed)
def invalidate_provider_models_cache(
self, provider_id: str | None = None, *, umo: str | None = None
) -> None:
"""Public hook for cache invalidation on external provider config changes."""
self._model_cache.invalidate(provider_id, umo=umo)
def _on_provider_manager_changed(
self,
provider_id: str,
provider_type: ProviderType,
umo: str | None,
) -> None:
if provider_type == ProviderType.CHAT_COMPLETION:
self.invalidate_provider_models_cache(provider_id, umo=umo)
def _get_provider_settings(self, umo: str | None) -> dict:
if not umo:
return {}
try:
return self.context.get_config(umo).get("provider_settings", {}) or {}
except Exception as e:
logger.debug(
"读取 provider_settings 失败,使用默认值: %s",
safe_error("", e),
)
return {}
def _get_model_cache_ttl(self, umo: str | None) -> float:
settings = self._get_provider_settings(umo)
raw = settings.get(
MODEL_LIST_CACHE_TTL_KEY,
MODEL_LIST_CACHE_TTL_SECONDS_DEFAULT,
)
try:
return max(float(raw), 0.0)
except Exception as e:
logger.debug(
"读取 %s 失败,回退默认值 %r: %s",
MODEL_LIST_CACHE_TTL_KEY,
MODEL_LIST_CACHE_TTL_SECONDS_DEFAULT,
safe_error("", e),
)
return MODEL_LIST_CACHE_TTL_SECONDS_DEFAULT
def _get_model_lookup_concurrency(self, umo: str | None) -> int:
settings = self._get_provider_settings(umo)
raw = settings.get(
MODEL_LOOKUP_MAX_CONCURRENCY_KEY,
MODEL_LOOKUP_MAX_CONCURRENCY_DEFAULT,
)
try:
value = int(raw)
except Exception as e:
logger.debug(
"读取 %s 失败,回退默认值 %r: %s",
MODEL_LOOKUP_MAX_CONCURRENCY_KEY,
MODEL_LOOKUP_MAX_CONCURRENCY_DEFAULT,
safe_error("", e),
)
value = MODEL_LOOKUP_MAX_CONCURRENCY_DEFAULT
return min(max(value, 1), MODEL_LOOKUP_MAX_CONCURRENCY_UPPER_BOUND)
def _get_model_lookup_config(self, umo: str | None) -> _ModelLookupConfig:
return _ModelLookupConfig(
umo=umo,
cache_ttl_seconds=self._get_model_cache_ttl(umo),
max_concurrency=self._get_model_lookup_concurrency(umo),
)
def _resolve_model_name(
self,
model_name: str,
models: Sequence[str],
) -> str | None:
"""Resolve model name with precedence:
exact > case-insensitive > provider-qualified suffix.
"""
requested = model_name.strip()
if not requested:
return None
requested_norm = requested.casefold()
# exact / case-insensitive match
for candidate in models:
if candidate == requested or candidate.casefold() == requested_norm:
return candidate
# provider-qualified suffix match:
# e.g. candidate `openai/gpt-4o` should match requested `gpt-4o`.
for candidate in models:
cand_norm = candidate.casefold()
if cand_norm.endswith(f"/{requested_norm}") or cand_norm.endswith(
f":{requested_norm}"
):
return candidate
return None
def _apply_model(
self, prov: Provider, model_name: str, *, umo: str | None = None
) -> str:
prov.set_model(model_name)
self.invalidate_provider_models_cache(prov.meta().id, umo=umo)
return f"切换模型成功。当前提供商: [{prov.meta().id}] 当前模型: [{prov.get_model()}]"
async def _get_provider_models(
self,
provider: Provider,
*,
config: _ModelLookupConfig,
use_cache: bool = True,
) -> list[str]:
provider_id = provider.meta().id
ttl_seconds = config.cache_ttl_seconds
umo = config.umo
if use_cache:
cached = self._model_cache.get(provider_id, umo, ttl_seconds)
if cached is not None:
return cached
models = list(await provider.get_models())
if use_cache:
self._model_cache.set(provider_id, umo, models, ttl_seconds)
return models
async def _get_models_or_reply_error(
self,
message: AstrMessageEvent,
prov: Provider,
config: _ModelLookupConfig,
*,
error_prefix: str,
disable_t2i: bool = False,
warning_log: str | None = None,
) -> list[str] | None:
try:
return await self._get_provider_models(prov, config=config)
except asyncio.CancelledError:
raise
except Exception as e:
if warning_log is not None:
logger.warning(
warning_log,
prov.meta().id,
safe_error("", e),
)
result = MessageEventResult().message(safe_error(error_prefix, e))
if disable_t2i:
result = result.use_t2i(False)
message.set_result(result)
return None
def _log_reachability_failure(
self,
@@ -20,6 +265,7 @@ class ProviderCommands:
err_code: str,
err_reason: str,
) -> None:
"""记录不可达原因到日志。"""
meta = provider.meta()
logger.warning(
"Provider reachability check failed: id=%s type=%s code=%s reason=%s",
@@ -30,6 +276,7 @@ class ProviderCommands:
)
async def _test_provider_capability(self, provider):
"""测试单个 provider 的可用性"""
meta = provider.meta()
provider_capability_type = meta.provider_type
@@ -40,76 +287,93 @@ class ProviderCommands:
err_code = "TEST_FAILED"
err_reason = safe_error("", e)
self._log_reachability_failure(
provider,
provider_capability_type,
err_code,
err_reason,
provider, provider_capability_type, err_code, err_reason
)
return False, err_code, err_reason
async def _build_provider_display_data(
async def _find_provider_for_model(
self,
providers,
provider_type: str,
reachability_check_enabled: bool,
) -> list[dict]:
if not providers:
return []
model_name: str,
*,
exclude_provider_id: str | None = None,
config: _ModelLookupConfig,
use_cache: bool = True,
) -> tuple[Provider | None, str | None]:
all_providers = []
for provider in self.context.get_all_providers():
provider_meta = provider.meta()
if provider_meta.provider_type != ProviderType.CHAT_COMPLETION:
continue
if (
exclude_provider_id is not None
and provider_meta.id == exclude_provider_id
):
continue
all_providers.append(provider)
if not all_providers:
return None, None
if reachability_check_enabled:
check_results = await asyncio.gather(
*[self._test_provider_capability(provider) for provider in providers],
return_exceptions=True,
semaphore = asyncio.Semaphore(config.max_concurrency)
async def fetch_models(
provider: Provider,
) -> tuple[Provider, list[str] | None, str | None]:
async with semaphore:
try:
models = await self._get_provider_models(
provider,
config=config,
use_cache=use_cache,
)
return provider, models, None
except asyncio.CancelledError:
raise
except Exception as e:
err = safe_error("", e)
logger.debug(
"跨提供商查找模型 %s 获取 %s 模型列表失败: %s",
model_name,
provider.meta().id,
err,
)
return provider, None, err
results = await asyncio.gather(
*(fetch_models(provider) for provider in all_providers)
)
failed_provider_errors: list[tuple[str, str]] = []
for provider, models, err in results:
if err is not None:
failed_provider_errors.append((provider.meta().id, err))
continue
if models is None:
continue
matched_model_name = self._resolve_model_name(model_name, models)
if matched_model_name is not None:
return provider, matched_model_name
if failed_provider_errors and len(failed_provider_errors) == len(all_providers):
failed_ids = ",".join(
provider_id for provider_id, _ in failed_provider_errors
)
else:
check_results = [None for _ in providers]
display_data = []
for provider, reachable in zip(providers, check_results, strict=False):
meta = provider.meta()
id_ = meta.id
error_code = None
if isinstance(reachable, asyncio.CancelledError):
raise reachable
if isinstance(reachable, Exception):
self._log_reachability_failure(
provider,
None,
reachable.__class__.__name__,
safe_error("", reachable),
)
reachable_flag = False
error_code = reachable.__class__.__name__
elif isinstance(reachable, tuple):
reachable_flag, error_code, _ = reachable
else:
reachable_flag = reachable
if provider_type == "llm":
info = f"{id_} ({meta.model})"
else:
info = f"{id_}"
if reachable_flag is True:
mark = ""
elif reachable_flag is False:
if error_code:
mark = f" ❌(errcode: {error_code})"
else:
mark = ""
else:
mark = ""
display_data.append(
{
"info": info,
"mark": mark,
"provider": provider,
},
logger.error(
"跨提供商查找模型 %s 时,所有 %d 个提供商的 get_models() 均失败: %s。请检查配置或网络",
model_name,
len(all_providers),
failed_ids,
)
return display_data
elif failed_provider_errors:
logger.debug(
"跨提供商查找模型 %s 时有 %d 个提供商获取模型失败: %s",
model_name,
len(failed_provider_errors),
",".join(
f"{provider_id}({error})"
for provider_id, error in failed_provider_errors
),
)
return None, None
async def provider(
self,
@@ -123,82 +387,137 @@ class ProviderCommands:
reachability_check_enabled = cfg.get("reachability_check", True)
if idx is None:
parts = ["## LLM Providers\n"]
parts = ["## 载入的 LLM 提供商\n"]
# 获取所有类型的提供商
llms = list(self.context.get_all_providers())
ttss = self.context.get_all_tts_providers()
stts = self.context.get_all_stt_providers()
if reachability_check_enabled and (llms or ttss or stts):
await event.send(
MessageEventResult().message("👀 Testing provider reachability..."),
# 构造待检测列表: [(provider, type_label), ...]
all_providers = []
all_providers.extend([(p, "llm") for p in llms])
all_providers.extend([(p, "tts") for p in ttss])
all_providers.extend([(p, "stt") for p in stts])
# 并发测试连通性
if reachability_check_enabled:
if all_providers:
await event.send(
MessageEventResult().message(
"正在进行提供商可达性测试,请稍候..."
)
)
check_results = await asyncio.gather(
*[self._test_provider_capability(p) for p, _ in all_providers],
return_exceptions=True,
)
else:
# 用 None 表示未检测
check_results = [None for _ in all_providers]
# 整合结果
display_data = []
for (p, p_type), reachable in zip(all_providers, check_results):
meta = p.meta()
id_ = meta.id
error_code = None
if isinstance(reachable, asyncio.CancelledError):
raise reachable
if isinstance(reachable, Exception):
# 异常情况下兜底处理,避免单个 provider 导致列表失败
self._log_reachability_failure(
p,
None,
reachable.__class__.__name__,
safe_error("", reachable),
)
reachable_flag = False
error_code = reachable.__class__.__name__
elif isinstance(reachable, tuple):
reachable_flag, error_code, _ = reachable
else:
reachable_flag = reachable
# 根据类型构建显示名称
if p_type == "llm":
info = f"{id_} ({meta.model})"
else:
info = f"{id_}"
# 确定状态标记
if reachable_flag is True:
mark = ""
elif reachable_flag is False:
if error_code:
mark = f" ❌(错误码: {error_code})"
else:
mark = ""
else:
mark = "" # 不支持检测时不显示标记
display_data.append(
{
"type": p_type,
"info": info,
"mark": mark,
"provider": p,
}
)
llm_data, tts_data, stt_data = await asyncio.gather(
self._build_provider_display_data(
llms,
"llm",
reachability_check_enabled,
),
self._build_provider_display_data(
ttss,
"tts",
reachability_check_enabled,
),
self._build_provider_display_data(
stts,
"stt",
reachability_check_enabled,
),
)
provider_using = self.context.get_using_provider(umo=umo)
# 分组输出
# 1. LLM
llm_data = [d for d in display_data if d["type"] == "llm"]
for i, d in enumerate(llm_data):
line = f"{i + 1}. {d['info']}{d['mark']}"
provider_using = self.context.get_using_provider(umo=umo)
if (
provider_using
and provider_using.meta().id == d["provider"].meta().id
):
line += " 👈"
line += " (当前使用)"
parts.append(line + "\n")
# 2. TTS
tts_data = [d for d in display_data if d["type"] == "tts"]
if tts_data:
parts.append("\n## TTS Providers\n")
tts_using = self.context.get_using_tts_provider(umo=umo)
parts.append("\n## 载入的 TTS 提供商\n")
for i, d in enumerate(tts_data):
line = f"{i + 1}. {d['info']}{d['mark']}"
tts_using = self.context.get_using_tts_provider(umo=umo)
if tts_using and tts_using.meta().id == d["provider"].meta().id:
line += " 👈"
line += " (当前使用)"
parts.append(line + "\n")
# 3. STT
stt_data = [d for d in display_data if d["type"] == "stt"]
if stt_data:
parts.append("\n## STT Providers\n")
stt_using = self.context.get_using_stt_provider(umo=umo)
parts.append("\n## 载入的 STT 提供商\n")
for i, d in enumerate(stt_data):
line = f"{i + 1}. {d['info']}{d['mark']}"
stt_using = self.context.get_using_stt_provider(umo=umo)
if stt_using and stt_using.meta().id == d["provider"].meta().id:
line += " 👈"
line += " (当前使用)"
parts.append(line + "\n")
parts.append("\nUse /provider <idx> to switch LLM providers.")
parts.append("\n使用 /provider <序号> 切换 LLM 提供商。")
ret = "".join(parts)
if ttss:
ret += "\nUse /provider tts <idx> to switch TTS providers."
ret += "\n使用 /provider tts <序号> 切换 TTS 提供商。"
if stts:
ret += "\nUse /provider stt <idx> to switch STT providers."
ret += "\n使用 /provider stt <序号> 切换 STT 提供商。"
if not reachability_check_enabled:
ret += "\n已跳过提供商可达性检测,如需检测请在配置文件中开启。"
event.set_result(MessageEventResult().message(ret))
elif idx == "tts":
if idx2 is None:
event.set_result(
MessageEventResult().message("Please enter the index."),
)
event.set_result(MessageEventResult().message("请输入序号。"))
return
if idx2 > len(self.context.get_all_tts_providers()) or idx2 < 1:
event.set_result(
MessageEventResult().message("❌ Invalid provider index."),
)
event.set_result(MessageEventResult().message("无效的提供商序号。"))
return
provider = self.context.get_all_tts_providers()[idx2 - 1]
id_ = provider.meta().id
@@ -207,19 +526,13 @@ class ProviderCommands:
provider_type=ProviderType.TEXT_TO_SPEECH,
umo=umo,
)
event.set_result(
MessageEventResult().message(f"✅ Successfully switched to {id_}."),
)
event.set_result(MessageEventResult().message(f"成功切换到 {id_}"))
elif idx == "stt":
if idx2 is None:
event.set_result(
MessageEventResult().message("Please enter the index."),
)
event.set_result(MessageEventResult().message("请输入序号。"))
return
if idx2 > len(self.context.get_all_stt_providers()) or idx2 < 1:
event.set_result(
MessageEventResult().message("❌ Invalid provider index."),
)
event.set_result(MessageEventResult().message("无效的提供商序号。"))
return
provider = self.context.get_all_stt_providers()[idx2 - 1]
id_ = provider.meta().id
@@ -228,14 +541,10 @@ class ProviderCommands:
provider_type=ProviderType.SPEECH_TO_TEXT,
umo=umo,
)
event.set_result(
MessageEventResult().message(f"✅ Successfully switched to {id_}."),
)
event.set_result(MessageEventResult().message(f"成功切换到 {id_}"))
elif isinstance(idx, int):
if idx > len(self.context.get_all_providers()) or idx < 1:
event.set_result(
MessageEventResult().message("❌ Invalid provider index."),
)
event.set_result(MessageEventResult().message("无效的提供商序号。"))
return
provider = self.context.get_all_providers()[idx - 1]
id_ = provider.meta().id
@@ -244,87 +553,184 @@ class ProviderCommands:
provider_type=ProviderType.CHAT_COMPLETION,
umo=umo,
)
event.set_result(
MessageEventResult().message(f"✅ Successfully switched to {id_}."),
)
event.set_result(MessageEventResult().message(f"成功切换到 {id_}"))
else:
event.set_result(MessageEventResult().message("❌ Invalid parameter."))
event.set_result(MessageEventResult().message("无效的参数。"))
async def model_ls(
self,
event: AstrMessageEvent,
idx_or_name: int | str | None = None,
async def _switch_model_by_name(
self, message: AstrMessageEvent, model_name: str, prov: Provider
) -> None:
"""查看或者切换当前 Provider 的模型。"""
umo = event.unified_msg_origin
provider = self.context.get_using_provider(umo=umo)
if provider is None:
event.set_result(
MessageEventResult().message("未找到任何 LLM 提供商。请先配置。"),
)
model_name = model_name.strip()
if not model_name:
message.set_result(MessageEventResult().message("模型名不能为空。"))
return
try:
models = await provider.get_models()
except Exception as e:
event.set_result(
umo = message.unified_msg_origin
config = self._get_model_lookup_config(umo)
curr_provider_id = prov.meta().id
models = await self._get_models_or_reply_error(
message,
prov,
config,
error_prefix="获取当前提供商模型列表失败: ",
warning_log="获取当前提供商 %s 模型列表失败,停止跨提供商查找: %s",
)
if models is None:
return
matched_model_name = self._resolve_model_name(model_name, models)
if matched_model_name is not None:
message.set_result(
MessageEventResult().message(
f"获取模型列表失败: {safe_error('', e)}",
self._apply_model(prov, matched_model_name, umo=umo)
),
)
return
current_model = provider.get_model()
if idx_or_name is None:
if not models:
event.set_result(
MessageEventResult().message(
f"当前模型: {current_model}\n此提供商未返回可切换模型列表。",
),
)
return
parts = [f"当前模型: {current_model}\n\n可用模型:\n"]
for index, model_name in enumerate(models, start=1):
suffix = " 👈" if model_name == current_model else ""
parts.append(f"{index}. {model_name}{suffix}\n")
parts.append("\n使用 /model <序号> 或 /model <模型名> 切换模型。")
event.set_result(MessageEventResult().message("".join(parts)))
return
selected_model: str | None = None
if isinstance(idx_or_name, int):
if 1 <= idx_or_name <= len(models):
selected_model = models[idx_or_name - 1]
else:
text = idx_or_name.strip()
if text.isdigit():
model_index = int(text)
if 1 <= model_index <= len(models):
selected_model = models[model_index - 1]
elif text:
selected_model = text
if not selected_model:
event.set_result(MessageEventResult().message("❌ Invalid model index."))
return
provider.set_model(selected_model)
provider.provider_config["model"] = selected_model
cfg = self.context.get_config(umo)
providers_config = cfg.get("provider", [])
if isinstance(providers_config, list):
for provider_config in providers_config:
if not isinstance(provider_config, dict):
continue
if provider_config.get("id") == provider.meta().id:
provider_config["model"] = selected_model
break
cfg.save_config()
event.set_result(
MessageEventResult().message(
f"✅ Successfully switched model to {selected_model}.",
),
target_prov, matched_target_model_name = await self._find_provider_for_model(
model_name,
exclude_provider_id=curr_provider_id,
config=config,
)
if target_prov is None or matched_target_model_name is None:
message.set_result(
MessageEventResult().message(
f"模型 [{model_name}] 未在任何已配置的提供商中找到,或所有提供商模型列表获取失败,请检查配置或网络后重试。",
),
)
return
target_id = target_prov.meta().id
try:
await self.context.provider_manager.set_provider(
provider_id=target_id,
provider_type=ProviderType.CHAT_COMPLETION,
umo=umo,
)
self._apply_model(target_prov, matched_target_model_name, umo=umo)
message.set_result(
MessageEventResult().message(
f"检测到模型 [{matched_target_model_name}] 属于提供商 [{target_id}],已自动切换提供商并设置模型。",
),
)
except asyncio.CancelledError:
raise
except Exception as e:
message.set_result(
MessageEventResult().message(
safe_error("跨提供商切换并设置模型失败: ", e)
),
)
async def model_ls(
self,
message: AstrMessageEvent,
idx_or_name: int | str | None = None,
) -> None:
"""查看或者切换模型"""
prov = self.context.get_using_provider(message.unified_msg_origin)
if not prov:
message.set_result(
MessageEventResult().message("未找到任何 LLM 提供商。请先配置。"),
)
return
config = self._get_model_lookup_config(message.unified_msg_origin)
if idx_or_name is None:
models = await self._get_models_or_reply_error(
message,
prov,
config,
error_prefix="获取模型列表失败: ",
disable_t2i=True,
)
if models is None:
return
parts = ["下面列出了此模型提供商可用模型:"]
for i, model in enumerate(models, 1):
parts.append(f"\n{i}. {model}")
curr_model = prov.get_model() or ""
parts.append(f"\n当前模型: [{curr_model}]")
parts.append(
"\nTips: 使用 /model <模型名/编号> 切换模型。输入模型名时可自动跨提供商查找并切换;跨提供商也可使用 /provider 切换。"
)
ret = "".join(parts)
message.set_result(MessageEventResult().message(ret).use_t2i(False))
elif isinstance(idx_or_name, int):
models = await self._get_models_or_reply_error(
message,
prov,
config,
error_prefix="获取模型列表失败: ",
)
if models is None:
return
if idx_or_name > len(models) or idx_or_name < 1:
message.set_result(MessageEventResult().message("模型序号错误。"))
else:
try:
new_model = models[idx_or_name - 1]
message.set_result(
MessageEventResult().message(
self._apply_model(
prov,
new_model,
umo=message.unified_msg_origin,
)
),
)
except Exception as e:
message.set_result(
MessageEventResult().message(
safe_error("切换模型未知错误: ", e)
),
)
return
else:
await self._switch_model_by_name(message, idx_or_name, prov)
async def key(self, message: AstrMessageEvent, index: int | None = None) -> None:
prov = self.context.get_using_provider(message.unified_msg_origin)
if not prov:
message.set_result(
MessageEventResult().message("未找到任何 LLM 提供商。请先配置。"),
)
return
if index is None:
keys_data = prov.get_keys()
curr_key = prov.get_current_key()
parts = ["Key:"]
for i, k in enumerate(keys_data, 1):
parts.append(f"\n{i}. {k[:8]}")
parts.append(f"\n当前 Key: {curr_key[:8]}")
parts.append("\n当前模型: " + prov.get_model())
parts.append("\n使用 /key <idx> 切换 Key。")
ret = "".join(parts)
message.set_result(MessageEventResult().message(ret).use_t2i(False))
else:
keys_data = prov.get_keys()
if index > len(keys_data) or index < 1:
message.set_result(MessageEventResult().message("Key 序号错误。"))
else:
try:
new_key = keys_data[index - 1]
prov.set_key(new_key)
self.invalidate_provider_models_cache(
prov.meta().id,
umo=message.unified_msg_origin,
)
message.set_result(MessageEventResult().message("切换 Key 成功。"))
except Exception as e:
message.set_result(
MessageEventResult().message(
safe_error("切换 Key 未知错误: ", e)
),
)
return

View File

@@ -2,16 +2,6 @@ from astrbot.api import sp, star
from astrbot.api.event import AstrMessageEvent, MessageEventResult
def _normalize_session_variables(value: object) -> dict[str, str]:
if not isinstance(value, dict):
return {}
return {
key: value
for key, value in value.items()
if isinstance(key, str) and isinstance(value, str)
}
class SetUnsetCommands:
def __init__(self, context: star.Context) -> None:
self.context = context
@@ -19,32 +9,28 @@ class SetUnsetCommands:
async def set_variable(self, event: AstrMessageEvent, key: str, value: str) -> None:
"""设置会话变量"""
uid = event.unified_msg_origin
session_var = _normalize_session_variables(
await sp.session_get(uid, "session_variables", {}),
)
session_var = await sp.session_get(uid, "session_variables", {})
session_var[key] = value
await sp.session_put(uid, "session_variables", session_var)
event.set_result(
MessageEventResult().message(
f"会话 {uid} 变量 {key} 存储成功使用 /unset 移除",
f"会话 {uid} 变量 {key} 存储成功使用 /unset 移除",
),
)
async def unset_variable(self, event: AstrMessageEvent, key: str) -> None:
"""移除会话变量"""
uid = event.unified_msg_origin
session_var = _normalize_session_variables(
await sp.session_get(uid, "session_variables", {}),
)
session_var = await sp.session_get(uid, "session_variables", {})
if key not in session_var:
event.set_result(
MessageEventResult().message("没有那个变量名格式 /unset 变量名"),
MessageEventResult().message("没有那个变量名格式 /unset 变量名"),
)
else:
del session_var[key]
await sp.session_put(uid, "session_variables", session_var)
event.set_result(
MessageEventResult().message(f"会话 {uid} 变量 {key} 移除成功"),
MessageEventResult().message(f"会话 {uid} 变量 {key} 移除成功"),
)

View File

@@ -18,19 +18,19 @@ class SIDCommand:
umo_msg_type = event.session.message_type.value
umo_session_id = event.session.session_id
ret = (
f"UMO: {sid} 此值可用于设置白名单\n"
f"UID: {user_id} 此值可用于设置管理员\n"
f"UMO: {sid} 此值可用于设置白名单\n"
f"UID: {user_id} 此值可用于设置管理员\n"
f"消息会话来源信息:\n"
f" 机器人 ID: {umo_platform}\n"
f" 消息类型: {umo_msg_type}\n"
f" 会话 ID: {umo_session_id}\n"
f"消息来源可用于配置机器人的配置文件路由"
f" 机器人 ID: {umo_platform}\n"
f" 消息类型: {umo_msg_type}\n"
f" 会话 ID: {umo_session_id}\n"
f"消息来源可用于配置机器人的配置文件路由"
)
if (
self.context.get_config()["platform_settings"]["unique_session"]
and event.get_group_id()
):
ret += f"\n\n当前处于独立会话模式, 此群 ID: {event.get_group_id()}, 也可将此 ID 加入白名单来放行整个群聊"
ret += f"\n\n当前处于独立会话模式, 此群 ID: {event.get_group_id()}, 也可将此 ID 加入白名单来放行整个群聊"
event.set_result(MessageEventResult().message(ret).use_t2i(False))

View File

@@ -16,8 +16,8 @@ class T2ICommand:
if config["t2i"]:
config["t2i"] = False
config.save_config()
event.set_result(MessageEventResult().message("已关闭文本转图片模式"))
event.set_result(MessageEventResult().message("已关闭文本转图片模式"))
return
config["t2i"] = True
config.save_config()
event.set_result(MessageEventResult().message("已开启文本转图片模式"))
event.set_result(MessageEventResult().message("已开启文本转图片模式"))

View File

@@ -12,7 +12,7 @@ class TTSCommand:
self.context = context
async def tts(self, event: AstrMessageEvent) -> None:
"""开关文本转语音(会话级别)"""
"""开关文本转语音会话级别"""
umo = event.unified_msg_origin
ses_tts = await SessionServiceManager.is_tts_enabled_for_session(umo)
cfg = self.context.get_config(umo=umo)
@@ -27,10 +27,10 @@ class TTSCommand:
if new_status and not tts_enable:
event.set_result(
MessageEventResult().message(
f"{status_text}当前会话的文本转语音但 TTS 功能在配置中未启用,请前往 WebUI 开启",
f"{status_text}当前会话的文本转语音但 TTS 功能在配置中未启用请前往 WebUI 开启",
),
)
else:
event.set_result(
MessageEventResult().message(f"{status_text}当前会话的文本转语音"),
MessageEventResult().message(f"{status_text}当前会话的文本转语音"),
)

View File

@@ -1,6 +1,5 @@
from astrbot.api import star
from astrbot.api.event import AstrMessageEvent, filter
from astrbot.core.star.filter.command import GreedyStr
from .commands import (
AdminCommands,
@@ -8,7 +7,7 @@ from .commands import (
ConversationCommands,
HelpCommand,
LLMCommands,
NameCommand,
PersonaCommands,
PluginCommands,
ProviderCommands,
SetUnsetCommands,
@@ -27,13 +26,13 @@ class Main(star.Star):
self.plugin_c = PluginCommands(self.context)
self.admin_c = AdminCommands(self.context)
self.conversation_c = ConversationCommands(self.context)
self.name_c = NameCommand(self.context)
self.provider_c = ProviderCommands(self.context)
self.persona_c = PersonaCommands(self.context)
self.alter_cmd_c = AlterCmdCommands(self.context)
self.setunset_c = SetUnsetCommands(self.context)
self.t2i_c = T2ICommand(self.context)
self.tts_c = TTSCommand(self.context)
self.sid_c = SIDCommand(self.context)
self.alter_cmd_c = AlterCmdCommands(self.context)
@filter.command("help")
async def help(self, event: AstrMessageEvent) -> None:
@@ -52,7 +51,7 @@ class Main(star.Star):
@plugin.command("ls")
async def plugin_ls(self, event: AstrMessageEvent) -> None:
"""获取已经安装的插件列表"""
"""获取已经安装的插件列表"""
await self.plugin_c.plugin_ls(event)
@filter.permission_type(filter.PermissionType.ADMIN)
@@ -85,7 +84,7 @@ class Main(star.Star):
@filter.command("tts")
async def tts(self, event: AstrMessageEvent) -> None:
"""开关文本转语音(会话级别)"""
"""开关文本转语音会话级别"""
await self.tts_c.tts(event)
@filter.command("sid")
@@ -93,41 +92,30 @@ class Main(star.Star):
"""获取会话 ID 和 管理员 ID"""
await self.sid_c.sid(event)
@filter.permission_type(filter.PermissionType.ADMIN)
@filter.command("name")
async def name(self, event: AstrMessageEvent, alias: GreedyStr) -> None:
"""Set display name for current UMO"""
await self.name_c.name(event, alias)
@filter.permission_type(filter.PermissionType.ADMIN)
@filter.command("op")
async def op(self, event: AstrMessageEvent, admin_id: str = "") -> None:
"""授权管理员op <admin_id>"""
"""授权管理员op <admin_id>"""
await self.admin_c.op(event, admin_id)
@filter.permission_type(filter.PermissionType.ADMIN)
@filter.command("deop")
async def deop(self, event: AstrMessageEvent, admin_id: str) -> None:
"""取消授权管理员deop <admin_id>"""
"""取消授权管理员deop <admin_id>"""
await self.admin_c.deop(event, admin_id)
@filter.permission_type(filter.PermissionType.ADMIN)
@filter.command("wl")
async def wl(self, event: AstrMessageEvent, sid: str = "") -> None:
"""添加白名单wl <sid>"""
"""添加白名单wl <sid>"""
await self.admin_c.wl(event, sid)
@filter.permission_type(filter.PermissionType.ADMIN)
@filter.command("dwl")
async def dwl(self, event: AstrMessageEvent, sid: str) -> None:
"""删除白名单dwl <sid>"""
"""删除白名单dwl <sid>"""
await self.admin_c.dwl(event, sid)
@filter.command("stats")
async def stats(self, message: AstrMessageEvent) -> None:
"""Show token usage statistics for the current conversation"""
await self.conversation_c.stats(message)
@filter.permission_type(filter.PermissionType.ADMIN)
@filter.command("provider")
async def provider(
@@ -174,6 +162,41 @@ class Main(star.Star):
"""创建新对话"""
await self.conversation_c.new_conv(message)
@filter.permission_type(filter.PermissionType.ADMIN)
@filter.command("groupnew")
async def groupnew_conv(self, message: AstrMessageEvent, sid: str) -> None:
"""创建新群聊对话"""
await self.conversation_c.groupnew_conv(message, sid)
@filter.command("switch")
async def switch_conv(
self, message: AstrMessageEvent, index: int | None = None
) -> None:
"""通过 /ls 前面的序号切换对话"""
await self.conversation_c.switch_conv(message, index)
@filter.command("rename")
async def rename_conv(self, message: AstrMessageEvent, new_name: str) -> None:
"""重命名对话"""
await self.conversation_c.rename_conv(message, new_name)
@filter.command("del")
async def del_conv(self, message: AstrMessageEvent) -> None:
"""删除当前对话"""
await self.conversation_c.del_conv(message)
@filter.permission_type(filter.PermissionType.ADMIN)
@filter.command("key")
async def key(self, message: AstrMessageEvent, index: int | None = None) -> None:
"""查看或者切换 Key"""
await self.provider_c.key(message, index)
@filter.permission_type(filter.PermissionType.ADMIN)
@filter.command("persona")
async def persona(self, message: AstrMessageEvent) -> None:
"""查看或者切换 Persona"""
await self.persona_c.persona(message)
@filter.permission_type(filter.PermissionType.ADMIN)
@filter.command("dashboard_update")
async def update_dashboard(self, event: AstrMessageEvent) -> None:

View File

@@ -1,4 +1,4 @@
name: builtin_commands
desc: AstrBot's internal plugin, providing all built-in commands such as /reset.
desc: AstrBot 自带指令,提供常用的对话管理、工具使用、插件管理等功能。
author: Soulter
version: 0.0.1

View File

@@ -72,9 +72,9 @@ class Main(Star):
# 使用 LLM 生成回复
yield event.request_llm(
prompt=(
"注意,你正在社交媒体上中与用户进行聊天,用户只是通过@来唤醒你,但并未在这条消息中输入内容,他可能会在接下来一条发送他想发送的内容"
"你友好地询问用户想要聊些什么或者需要什么帮助,回复要符合人设,不要太过机械化"
"请注意,你仅需要输出要回复用户的内容,不要输出其他任何东西"
"注意你正在社交媒体上中与用户进行聊天用户只是通过@来唤醒你但并未在这条消息中输入内容他可能会在接下来一条发送他想发送的内容"
"你友好地询问用户想要聊些什么或者需要什么帮助回复要符合人设不要太过机械化"
"请注意你仅需要输出要回复用户的内容不要输出其他任何东西"
),
session_id=curr_cid,
contexts=[],
@@ -83,8 +83,8 @@ class Main(Star):
)
except Exception as e:
logger.error(f"LLM response failed: {e!s}")
# LLM 回复失败,使用原始预设回复
yield event.plain_result("想要问什么呢?😄")
# LLM 回复失败使用原始预设回复
yield event.plain_result("想要问什么呢😄")
@session_waiter(60)
async def empty_mention_waiter(
@@ -108,7 +108,7 @@ class Main(Star):
except TimeoutError as _:
pass
except Exception as e:
yield event.plain_result("发生错误,请联系管理员: " + str(e))
yield event.plain_result("发生错误请联系管理员: " + str(e))
finally:
event.stop_event()
except Exception as e:

View File

@@ -0,0 +1,5 @@
name: session_controller
desc: 为插件支持会话控制
author: Cvandia & Soulter
version: v1.0.1
repo: https://astrbot.app

View File

@@ -1,9 +1,8 @@
import random
import urllib.parse
from collections.abc import Callable
from dataclasses import dataclass
from aiohttp import ClientSession, ClientTimeout
from aiohttp import ClientSession
from bs4 import BeautifulSoup, Tag
HEADERS = {
@@ -43,7 +42,7 @@ class SearchEngine:
"""搜索引擎爬虫基类"""
def __init__(self) -> None:
self.TIMEOUT = ClientTimeout(total=10)
self.TIMEOUT = 10
self.page = 1
self.headers = HEADERS
@@ -82,7 +81,7 @@ class SearchEngine:
return ret
def tidy_text(self, text: str) -> str:
"""清理文本,去除空格换行符等"""
"""清理文本去除空格换行符等"""
return text.strip().replace("\n", " ").replace("\r", " ").replace(" ", " ")
def _get_url(self, tag: Tag) -> str:
@@ -96,12 +95,6 @@ class SearchEngine:
soup = BeautifulSoup(resp, "html.parser")
links = soup.select(self._set_selector("links"))
results = []
try:
text_selector = self._set_selector("text")
except (KeyError, NotImplementedError):
# Keep backward compatibility with engines that only expose
# title/url/link selectors and do not provide snippets.
text_selector = ""
for link in links:
# Safely get the title text (select_one may return None)
title_elem = link.select_one(self._set_selector("title"))
@@ -111,36 +104,9 @@ class SearchEngine:
url_tag = link.select_one(self._set_selector("url"))
snippet = ""
if text_selector:
text_elem = link.select_one(text_selector)
if text_elem is not None:
snippet = self.tidy_text(text_elem.get_text())
if title and url_tag:
url = self._get_url(url_tag)
if not url:
continue
if url.startswith("//"):
url = f"https:{url}"
results.append(SearchResult(title=title, url=url, snippet=snippet))
return results[:num_results] if len(results) > num_results else results
except Exception as e:
raise e
async def _search_with_result_filter(
self,
query: str,
num_results: int,
predicate: Callable[[SearchResult], bool],
) -> list[SearchResult]:
if num_results <= 0:
return []
rough_results = await SearchEngine.search(self, query, max(num_results * 2, 10))
final_results: list[SearchResult] = []
for result in rough_results:
if not predicate(result):
continue
final_results.append(result)
if len(final_results) >= num_results:
break
return final_results

View File

@@ -2,12 +2,9 @@ from . import USER_AGENT_BING, SearchEngine
class Bing(SearchEngine):
NAME = "bing"
def __init__(self) -> None:
super().__init__()
# Prefer international Bing first, keep cn endpoint as compatibility fallback.
self.base_urls = ["https://www.bing.com", "https://cn.bing.com"]
self.base_urls = ["https://cn.bing.com", "https://www.bing.com"]
self.headers.update({"User-Agent": USER_AGENT_BING})
def _set_selector(self, selector: str):

View File

@@ -1,64 +0,0 @@
from urllib.parse import unquote, urlencode, urlparse
from bs4 import Tag
from . import SearchEngine, SearchResult
class Comet(SearchEngine):
"""Best-effort search via public Perplexity/Comet page.
Note:
- This endpoint is often protected by anti-bot challenges.
- We intentionally treat failures as non-fatal and rely on fallback engines.
"""
NAME = "comet"
def __init__(self) -> None:
super().__init__()
self.base_url = "https://www.perplexity.ai"
def _set_selector(self, selector: str):
selectors = {
"url": "a[href^='http'], a[href^='//']",
"title": "main h1, main h2, main h3, h3, h2",
"text": "main article, main div[role='article'], main section, main p, p",
"links": "main article, main div[role='article'], main li, main div.result, article, div[role='article'], li, div.result",
"next": "",
}
return selectors[selector]
async def _get_next_page(self, query: str) -> str:
url = f"{self.base_url}/search?{urlencode({'q': unquote(query)})}"
return await self._get_html(url, None)
def _get_url(self, tag: Tag) -> str:
href = str(tag.get("href") or "")
if href.startswith("//"):
return f"https:{href}"
return href
@staticmethod
def _is_valid_result_url(url: str) -> bool:
lowered = (url or "").strip().lower()
if not lowered:
return False
if lowered.startswith(("#", "javascript:", "mailto:")):
return False
if not lowered.startswith(("http://", "https://")):
return False
netloc = urlparse(lowered).netloc
if not netloc:
return False
if netloc.endswith("perplexity.ai"):
return False
return True
async def search(self, query: str, num_results: int) -> list[SearchResult]:
return await self._search_with_result_filter(
query=query,
num_results=num_results,
predicate=lambda result: self._is_valid_result_url(result.url),
)

View File

@@ -1,43 +0,0 @@
import urllib.parse
from bs4 import Tag
from . import SearchEngine, SearchResult
class DuckDuckGo(SearchEngine):
NAME = "duckduckgo"
def __init__(self) -> None:
super().__init__()
self.base_url = "https://html.duckduckgo.com/html"
def _set_selector(self, selector: str):
selectors = {
"url": "a.result__a, h2 a",
"title": "a.result__a, h2",
"text": "a.result__snippet, div.result__snippet",
"links": "div.result, div.web-result",
"next": "a.result--more__btn",
}
return selectors[selector]
async def _get_next_page(self, query: str) -> str:
params = {"q": urllib.parse.unquote(query), "kl": "us-en"}
url = f"{self.base_url}/?{urllib.parse.urlencode(params)}"
return await self._get_html(url, None)
def _get_url(self, tag: Tag) -> str:
href = str(tag.get("href") or "")
if "duckduckgo.com/l/?" in href:
parsed = urllib.parse.urlparse(href)
target = urllib.parse.parse_qs(parsed.query).get("uddg", [""])[0]
return urllib.parse.unquote(target)
return href
async def search(self, query: str, num_results: int) -> list[SearchResult]:
return await self._search_with_result_filter(
query=query,
num_results=num_results,
predicate=lambda result: result.url.startswith("http"),
)

View File

@@ -1,51 +0,0 @@
import urllib.parse
from bs4 import Tag
from . import SearchEngine, SearchResult
class Google(SearchEngine):
NAME = "google"
def __init__(self) -> None:
super().__init__()
self.base_url = "https://www.google.com"
def _set_selector(self, selector: str):
selectors = {
"url": "a[href]",
"title": "h3",
"text": "div.VwiC3b, span.aCOpRe",
"links": "div#search div.g, div#search div.MjjYud",
"next": "a#pnnext",
}
return selectors[selector]
async def _get_next_page(self, query: str) -> str:
params = {
"q": urllib.parse.unquote(query),
"hl": "en",
"gl": "us",
"pws": "0",
"num": "10",
}
url = f"{self.base_url}/search?{urllib.parse.urlencode(params)}"
return await self._get_html(url, None)
def _get_url(self, tag: Tag) -> str:
href = str(tag.get("href") or "")
if href.startswith("/url?"):
parsed = urllib.parse.urlparse(href)
q = urllib.parse.parse_qs(parsed.query).get("q", [""])[0]
return urllib.parse.unquote(q)
return href
async def search(self, query: str, num_results: int) -> list[SearchResult]:
return await self._search_with_result_filter(
query=query,
num_results=num_results,
predicate=lambda result: (
result.url.startswith("http") and "google.com/search?" not in result.url
),
)

View File

@@ -1,5 +1,6 @@
import random
import re
from typing import cast
from bs4 import BeautifulSoup, Tag
@@ -7,8 +8,6 @@ from . import USER_AGENTS, SearchEngine, SearchResult
class Sogo(SearchEngine):
NAME = "sogo"
def __init__(self) -> None:
super().__init__()
self.base_url = "https://www.sogou.com"
@@ -29,7 +28,7 @@ class Sogo(SearchEngine):
return await self._get_html(url, None)
def _get_url(self, tag: Tag) -> str:
return str(tag.get("href") or "")
return cast(str, tag.get("href"))
async def search(self, query: str, num_results: int) -> list[SearchResult]:
results = await super().search(query, num_results)
@@ -47,7 +46,7 @@ class Sogo(SearchEngine):
script_text = (
script.string if script.string is not None else script.get_text()
)
match = re.search('window.location.replace\\("(.+?)"\\)', script_text)
match = re.search(r'window.location.replace\("(.+?)"\)', script_text)
if match:
url = match.group(1)
return url

View File

@@ -2,7 +2,6 @@ import asyncio
import json
import random
import uuid
from typing import ClassVar
import aiohttp
from bs4 import BeautifulSoup
@@ -15,21 +14,11 @@ from astrbot.core.provider.func_tool_manager import FunctionToolManager
from .engines import HEADERS, USER_AGENTS, SearchResult
from .engines.bing import Bing
from .engines.comet import Comet
from .engines.duckduckgo import DuckDuckGo
from .engines.google import Google
from .engines.sogo import Sogo
from .provider_routing import (
DEFAULT_WEB_SEARCH_PROVIDER,
build_default_engine_order,
normalize_websearch_provider,
normalize_websearch_provider_for_tools,
validate_default_engine_registry,
)
class Main(star.Star):
TOOLS: ClassVar[list[str]] = [
TOOLS = [
"web_search",
"fetch_url",
"web_search_tavily",
@@ -45,14 +34,14 @@ class Main(star.Star):
self.bocha_key_index = 0
self.bocha_key_lock = asyncio.Lock()
# 将 str 类型的 key 迁移至 list[str],并保存
# 将 str 类型的 key 迁移至 list[str]并保存
cfg = self.context.get_config()
provider_settings = cfg.get("provider_settings")
if provider_settings:
tavily_key = provider_settings.get("websearch_tavily_key")
if isinstance(tavily_key, str):
logger.info(
"检测到旧版 websearch_tavily_key (字符串格式),自动迁移为列表格式并保存",
"检测到旧版 websearch_tavily_key (字符串格式)自动迁移为列表格式并保存",
)
if tavily_key:
provider_settings["websearch_tavily_key"] = [tavily_key]
@@ -68,26 +57,12 @@ class Main(star.Star):
provider_settings["websearch_bocha_key"] = []
cfg.save_config()
self.google_search = Google()
self.bing_search = Bing()
self.ddg_search = DuckDuckGo()
self.comet_search = Comet()
self.sogo_search = Sogo()
self.default_search_engines = {
engine.NAME: engine
for engine in (
self.google_search,
self.bing_search,
self.ddg_search,
self.comet_search,
self.sogo_search,
)
}
validate_default_engine_registry(self.default_search_engines)
self.baidu_initialized = False
async def _tidy_text(self, text: str) -> str:
"""清理文本,去除空格换行符等"""
"""清理文本去除空格换行符等"""
return text.strip().replace("\n", " ").replace("\r", " ").replace(" ", " ")
async def _get_from_url(self, url: str) -> str:
@@ -130,35 +105,29 @@ class Main(star.Star):
self,
query,
num_results: int = 5,
preferred_provider: str = DEFAULT_WEB_SEARCH_PROVIDER,
) -> list[SearchResult]:
for engine_name in build_default_engine_order(preferred_provider):
engine = self.default_search_engines.get(engine_name)
if not engine:
continue
results = []
try:
results = await self.bing_search.search(query, num_results)
except Exception as e:
logger.error(f"bing search error: {e}, try the next one...")
if len(results) == 0:
logger.debug("search bing failed")
try:
results = await engine.search(query, num_results)
results = await self.sogo_search.search(query, num_results)
except Exception as e:
logger.error(
f"{engine_name} search error: {e}, try the next one...",
)
continue
logger.error(f"sogo search error: {e}")
if len(results) == 0:
logger.debug("search sogo failed")
return []
if results:
logger.info(
f"web_searcher - provider `{engine_name}` success: {len(results)} results",
)
return results
logger.debug(f"search {engine_name} returned no results")
return []
return results
async def _get_tavily_key(self, cfg: AstrBotConfig) -> str:
"""并发安全的从列表中获取并轮换Tavily API密钥"""
"""并发安全的从列表中获取并轮换Tavily API密钥"""
tavily_keys = cfg.get("provider_settings", {}).get("websearch_tavily_key", [])
if not tavily_keys:
raise ValueError("错误:Tavily API密钥未在AstrBot中配置")
raise ValueError("错误Tavily API密钥未在AstrBot中配置")
async with self.tavily_key_lock:
key = tavily_keys[self.tavily_key_index]
@@ -234,27 +203,18 @@ class Main(star.Star):
query: str,
max_results: int = 5,
) -> str:
"""搜索网络以回答用户的问题当用户需要搜索网络以获取即时性的信息时调用此工具
"""搜索网络以回答用户的问题当用户需要搜索网络以获取即时性的信息时调用此工具
Args:
query(string): 和用户的问题最相关的搜索关键词,用于在 Google 上搜索
max_results(number): 返回的最大搜索结果数量,默认为 5
query(string): 和用户的问题最相关的搜索关键词用于在 Google 上搜索
max_results(number): 返回的最大搜索结果数量默认为 5
"""
logger.info(f"web_searcher - search_from_search_engine: {query}")
cfg = self.context.get_config(umo=event.unified_msg_origin)
websearch_link = cfg["provider_settings"].get("web_search_link", False)
preferred_provider = normalize_websearch_provider(
cfg.get("provider_settings", {}).get(
"websearch_provider",
DEFAULT_WEB_SEARCH_PROVIDER,
),
)
results = await self._web_search_default(
query,
max_results,
preferred_provider=preferred_provider,
)
results = await self._web_search_default(query, max_results)
if not results:
return "Error: web searcher does not return any results."
@@ -271,7 +231,7 @@ class Main(star.Star):
ret += processed_result
if websearch_link:
ret += "\n\n针对问题,请根据上面的结果分点总结,并且在结尾处附上对应内容的参考链接(如有)。"
ret += "\n\n针对问题请根据上面的结果分点总结并且在结尾处附上对应内容的参考链接如有)。"
return ret
@@ -379,7 +339,7 @@ class Main(star.Star):
"snippet": f"{result.snippet}",
# TODO: do not need ref for non-webchat platform adapter
"index": index,
},
}
)
if result.favicon:
sp.temporary_cache["_ws_favicon"][result.url] = result.favicon
@@ -424,10 +384,10 @@ class Main(star.Star):
return ret
async def _get_bocha_key(self, cfg: AstrBotConfig) -> str:
"""并发安全的从列表中获取并轮换BoCha API密钥"""
"""并发安全的从列表中获取并轮换BoCha API密钥"""
bocha_keys = cfg.get("provider_settings", {}).get("websearch_bocha_key", [])
if not bocha_keys:
raise ValueError("错误:BoCha API密钥未在AstrBot中配置")
raise ValueError("错误BoCha API密钥未在AstrBot中配置")
async with self.bocha_key_lock:
key = bocha_keys[self.bocha_key_index]
@@ -481,7 +441,8 @@ class Main(star.Star):
exclude: str = "",
count: int = 10,
) -> str:
"""A web search tool based on Bocha Search API, used to retrieve web pages
"""
A web search tool based on Bocha Search API, used to retrieve web pages
related to the user's query.
Args:
@@ -510,16 +471,14 @@ class Main(star.Star):
include (string): Optional. Specifies the domains to include in
the search. Multiple domains can be separated by "|" or ",".
A maximum of 100 domains is allowed.
Examples:
Examples:
- "qq.com"
- "qq.com|m.163.com"
exclude (string): Optional. Specifies the domains to exclude from
the search. Multiple domains can be separated by "|" or ",".
A maximum of 100 domains is allowed.
Examples:
Examples:
- "qq.com"
- "qq.com|m.163.com"
@@ -528,7 +487,6 @@ class Main(star.Star):
- Default: 10
The actual number of returned results may be less than the
specified count.
"""
logger.info(f"web_searcher - search_from_bocha: {query}")
cfg = self.context.get_config(umo=event.unified_msg_origin)
@@ -542,18 +500,18 @@ class Main(star.Star):
"count": count,
}
# freshness:时间范围
# freshness时间范围
if freshness:
payload["freshness"] = freshness
# 是否返回摘要
payload["summary"] = summary
# include:限制搜索域
# include限制搜索域
if include:
payload["include"] = include
# exclude:排除搜索域
# exclude排除搜索域
if exclude:
payload["exclude"] = exclude
@@ -571,7 +529,7 @@ class Main(star.Star):
"url": f"{result.url}",
"snippet": f"{result.snippet}",
"index": index,
},
}
)
if result.favicon:
sp.temporary_cache["_ws_favicon"][result.url] = result.favicon
@@ -589,13 +547,7 @@ class Main(star.Star):
cfg = self.context.get_config(umo=event.unified_msg_origin)
prov_settings = cfg.get("provider_settings", {})
websearch_enable = prov_settings.get("web_search", False)
raw_provider = prov_settings.get(
"websearch_provider",
DEFAULT_WEB_SEARCH_PROVIDER,
)
branch_provider, is_known_provider = normalize_websearch_provider_for_tools(
raw_provider,
)
provider = prov_settings.get("websearch_provider", "default")
tool_set = req.func_tool
if isinstance(tool_set, FunctionToolManager):
@@ -612,12 +564,7 @@ class Main(star.Star):
return
func_tool_mgr = self.context.get_llm_tool_manager()
if branch_provider == "default":
if not is_known_provider:
logger.warning(
"Unsupported websearch_provider `%s`, fallback to default search tool branch.",
raw_provider,
)
if provider == "default":
web_search_t = func_tool_mgr.get_func("web_search")
fetch_url_t = func_tool_mgr.get_func("fetch_url")
if web_search_t and web_search_t.active:
@@ -628,7 +575,7 @@ class Main(star.Star):
tool_set.remove_tool("tavily_extract_web_page")
tool_set.remove_tool("AIsearch")
tool_set.remove_tool("web_search_bocha")
elif branch_provider == "tavily":
elif provider == "tavily":
web_search_tavily = func_tool_mgr.get_func("web_search_tavily")
tavily_extract_web_page = func_tool_mgr.get_func("tavily_extract_web_page")
if web_search_tavily and web_search_tavily.active:
@@ -639,7 +586,7 @@ class Main(star.Star):
tool_set.remove_tool("fetch_url")
tool_set.remove_tool("AIsearch")
tool_set.remove_tool("web_search_bocha")
elif branch_provider == "baidu_ai_search":
elif provider == "baidu_ai_search":
try:
await self.ensure_baidu_ai_search_mcp(event.unified_msg_origin)
aisearch_tool = func_tool_mgr.get_func("AIsearch")
@@ -652,7 +599,7 @@ class Main(star.Star):
tool_set.remove_tool("web_search_bocha")
except Exception as e:
logger.error(f"Cannot Initialize Baidu AI Search MCP Server: {e}")
elif branch_provider == "bocha":
elif provider == "bocha":
web_search_bocha = func_tool_mgr.get_func("web_search_bocha")
if web_search_bocha and web_search_bocha.active:
tool_set.add_tool(web_search_bocha)

View File

@@ -0,0 +1,4 @@
name: astrbot-web-searcher
desc: 让 LLM 具有网页检索能力
author: Soulter
version: 1.14.514

View File

@@ -1,24 +0,0 @@
from __future__ import annotations
DEFAULT_WEB_SEARCH_PROVIDER = "default"
# Canonical provider ids shown in config UI options.
WEB_SEARCH_PROVIDER_OPTIONS: tuple[str, ...] = (
DEFAULT_WEB_SEARCH_PROVIDER,
"duckduckgo",
"google",
"bing",
"comet",
"sogo",
"tavily",
"baidu_ai_search",
"bocha",
)
# Provider ids that select non-default tool branches directly.
WEB_SEARCH_TOOL_BRANCH_PROVIDERS: tuple[str, ...] = (
DEFAULT_WEB_SEARCH_PROVIDER,
"tavily",
"baidu_ai_search",
"bocha",
)

View File

@@ -1,132 +0,0 @@
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass
from .engines.bing import Bing
from .engines.comet import Comet
from .engines.duckduckgo import DuckDuckGo
from .engines.google import Google
from .engines.sogo import Sogo
from .provider_constants import (
DEFAULT_WEB_SEARCH_PROVIDER,
WEB_SEARCH_PROVIDER_OPTIONS,
WEB_SEARCH_TOOL_BRANCH_PROVIDERS,
)
ENGINE_REGISTRY: tuple[tuple[str, type[object], bool], ...] = (
(Bing.NAME, Bing, True),
(Sogo.NAME, Sogo, True),
# Compatibility first: DDG should stay as fallback and cannot become primary.
(DuckDuckGo.NAME, DuckDuckGo, False),
(Google.NAME, Google, True),
(Comet.NAME, Comet, True),
)
DEFAULT_ENGINE_ORDER: tuple[str, ...] = tuple(name for name, _, _ in ENGINE_REGISTRY)
_ENGINE_PROVIDER_SET = {name for name, _, _ in ENGINE_REGISTRY}
_ENGINE_CAN_BE_PRIMARY = {
name: can_be_primary for name, _, can_be_primary in ENGINE_REGISTRY
}
_TOOL_BRANCH_PROVIDER_SET = set(WEB_SEARCH_TOOL_BRANCH_PROVIDERS)
_CANONICAL_PROVIDER_SET = _ENGINE_PROVIDER_SET | _TOOL_BRANCH_PROVIDER_SET
if not _CANONICAL_PROVIDER_SET.issubset(set(WEB_SEARCH_PROVIDER_OPTIONS)):
raise RuntimeError(
"web search provider options and routing providers are out of sync: "
f"canonical={sorted(_CANONICAL_PROVIDER_SET)} options={list(WEB_SEARCH_PROVIDER_OPTIONS)}",
)
_WEB_SEARCH_PROVIDER_ALIASES: dict[str, str] = {
"": DEFAULT_WEB_SEARCH_PROVIDER,
"default": DEFAULT_WEB_SEARCH_PROVIDER,
"native": DEFAULT_WEB_SEARCH_PROVIDER,
}
_WEB_SEARCH_PROVIDER_ALIASES.update({name: name for name in _CANONICAL_PROVIDER_SET})
_WEB_SEARCH_PROVIDER_ALIASES.update(
{
"duckduck_go": DuckDuckGo.NAME,
"duckduck-go": DuckDuckGo.NAME,
"ddg": DuckDuckGo.NAME,
"baidu_ai": "baidu_ai_search",
"baidu": "baidu_ai_search",
"bochaai": "bocha",
# ZeroClaw compatibility: AstrBot has no Brave provider yet, so downgrade to default.
"brave": DEFAULT_WEB_SEARCH_PROVIDER,
},
)
@dataclass(frozen=True)
class NormalizedProvider:
canonical: str
tool_branch: str
is_known: bool
def _normalize_raw_provider(provider: object) -> str:
return str(provider or "").strip().lower().replace(" ", "")
def normalize_websearch(provider: object) -> NormalizedProvider:
raw = _normalize_raw_provider(provider)
alias = _WEB_SEARCH_PROVIDER_ALIASES.get(raw, raw)
canonical = alias or DEFAULT_WEB_SEARCH_PROVIDER
is_engine = canonical in _ENGINE_PROVIDER_SET
is_tool_branch = canonical in _TOOL_BRANCH_PROVIDER_SET
is_known = is_engine or is_tool_branch
tool_branch = canonical if is_tool_branch else DEFAULT_WEB_SEARCH_PROVIDER
return NormalizedProvider(
canonical=canonical,
tool_branch=tool_branch,
is_known=is_known,
)
def normalize_websearch_provider(provider: object) -> str:
return normalize_websearch(provider).canonical
def normalize_websearch_provider_for_tools(provider: object) -> tuple[str, bool]:
normalized = normalize_websearch(provider)
return normalized.tool_branch, normalized.is_known
def resolve_tool_branch_provider(provider: object) -> str:
return normalize_websearch(provider).tool_branch
def build_default_engine_order(provider: object) -> tuple[str, ...]:
normalized = normalize_websearch(provider)
engine_name = normalized.canonical
if engine_name not in _ENGINE_PROVIDER_SET:
return DEFAULT_ENGINE_ORDER
if not _ENGINE_CAN_BE_PRIMARY.get(engine_name, False):
return DEFAULT_ENGINE_ORDER
return (
engine_name,
*tuple(name for name in DEFAULT_ENGINE_ORDER if name != engine_name),
)
def is_known_websearch_provider(provider: object) -> bool:
return normalize_websearch(provider).is_known
def validate_default_engine_registry(engines_by_name: Mapping[str, object]) -> None:
expected_names = {name for name, _, _ in ENGINE_REGISTRY}
missing = [name for name in DEFAULT_ENGINE_ORDER if name not in engines_by_name]
extra = [name for name in engines_by_name if name not in expected_names]
if not missing and not extra:
return
raise ValueError(
"default search engine registry mismatch. "
f"missing={missing}, extra={extra}, expected_order={list(DEFAULT_ENGINE_ORDER)}",
)

View File

@@ -1,3 +1 @@
from astrbot import __version__
__all__ = ["__version__"]
__version__ = "4.22.3"

View File

@@ -1,128 +1,48 @@
"""AstrBot CLI entry point"""
import os
import platform
import sys
from pathlib import Path
import click
from click.shell_completion import get_completion_class
from . import __version__
from .commands import bk, config, init, password, plugin, run, service, uninstall
from .i18n import t
from .commands import conf, init, plug, run
logo_tmpl = r"""
___ _______.___________..______ .______ ______ .___________.
/ \ / | || _ \ | _ \ / __ \ | |
/ ^ \ | (----`---| |----`| |_) | | |_) | | | | | `---| |----`
/ /_\ \ \ \ | | | / | _ < | | | | | |
/ _____ \ .----) | | | | |\ \----.| |_) | | `--' | | |
/__/ \__\ |_______/ |__| | _| `._____||______/ \______/ |__|
"""
def print_version_detail() -> None:
"""Print detailed version info (same for --version and version command)"""
from astrbot.core.utils.astrbot_path import astrbot_paths
click.echo(f"AstrBot: {__version__}")
click.echo(f"Python: {sys.version.split()[0]}")
click.echo(f"System: {platform.system()} {platform.release()}")
click.echo(f"Machine: {platform.machine()}")
git_root = Path(astrbot_paths.root) / ".git"
if git_root.exists():
import subprocess
try:
git_hash = subprocess.check_output(
["git", "rev-parse", "--short", "HEAD"],
cwd=str(astrbot_paths.root),
text=True,
).strip()
git_branch = subprocess.check_output(
["git", "rev-parse", "--abbrev-ref", "HEAD"],
cwd=str(astrbot_paths.root),
text=True,
).strip()
click.echo(f"Git Branch: {git_branch}")
click.echo(f"Git Commit: {git_hash}")
except Exception:
pass
click.echo(f"AstrBot Root: {astrbot_paths.root}")
click.echo(f"Platform: {platform.platform()}")
def version_callback(ctx: click.Context, param: click.Parameter, value: bool) -> bool:
"""Callback for --version to show detailed version and exit."""
if not value:
return value
print_version_detail()
ctx.exit()
return value
class AstrBotCLIGroup(click.Group):
COMMAND_ALIASES = {
"conf": "config",
"plug": "plugin",
}
def get_command(self, ctx: click.Context, cmd_name: str) -> click.Command | None:
command = super().get_command(ctx, cmd_name)
if command is not None:
return command
alias_target = self.COMMAND_ALIASES.get(cmd_name)
if alias_target is None:
return None
return super().get_command(ctx, alias_target)
@click.group(cls=AstrBotCLIGroup)
@click.group()
@click.version_option(__version__, prog_name="AstrBot")
def cli() -> None:
"""Astrbot
Agentic IM Chatbot infrastructure that integrates lots of IM platforms, LLMs, plugins and AI feature, and can be your openclaw alternative. ✨
"""
"""The AstrBot CLI"""
click.echo(logo_tmpl)
click.echo("Welcome to AstrBot CLI!")
click.echo(f"AstrBot CLI version: {__version__}")
@click.command()
@click.argument("command_name", required=False, type=str)
@click.option(
"--all",
"-a",
is_flag=True,
help="Show help for all commands recursively.",
)
def help(command_name: str | None, all: bool) -> None:
def help(command_name: str | None) -> None:
"""Display help information for commands
If COMMAND_NAME is provided, display detailed help for that command.
Otherwise, display general help information.
"""
ctx = click.get_current_context()
if all:
def print_recursive_help(command, parent_ctx):
name = command.name
if parent_ctx is None:
name = "astrbot"
cmd_ctx = click.Context(command, info_name=name, parent=parent_ctx)
click.echo(command.get_help(cmd_ctx))
click.echo("\n" + "-" * 50 + "\n")
if isinstance(command, click.Group):
for subcommand in command.commands.values():
print_recursive_help(subcommand, cmd_ctx)
print_recursive_help(cli, None)
return
if command_name:
# Find the specified command
command = cli.get_command(ctx, command_name)
if command:
# Display help for the specific command
parent = ctx.parent or ctx
cmd_ctx = click.Context(command, info_name=command.name, parent=parent)
click.echo(command.get_help(cmd_ctx))
click.echo(command.get_help(ctx))
else:
click.echo(t("cli_unknown_command", command=command_name))
click.echo(f"Unknown command: {command_name}")
sys.exit(1)
else:
# Display general help information
@@ -132,56 +52,8 @@ def help(command_name: str | None, all: bool) -> None:
cli.add_command(init)
cli.add_command(run)
cli.add_command(help)
cli.add_command(plugin)
cli.add_command(config)
cli.add_command(uninstall)
cli.add_command(bk)
cli.add_command(password)
cli.add_command(service)
@click.command()
@click.argument("shell", required=False, type=click.Choice(["bash", "zsh", "fish"]))
def completion(shell: str | None) -> None:
"""Generate shell completion script"""
if shell is None:
shell_path = os.environ.get("SHELL", "")
if "zsh" in shell_path:
shell = "zsh"
elif "bash" in shell_path:
shell = "bash"
elif "fish" in shell_path:
shell = "fish"
else:
click.echo(
"Could not detect shell. Please specify one of: bash, zsh, fish",
err=True,
)
sys.exit(1)
comp_cls = get_completion_class(shell)
if comp_cls is None:
click.echo(f"No completion support for shell: {shell}", err=True)
sys.exit(1)
comp = comp_cls(
cli,
ctx_args={},
prog_name="astrbot",
complete_var="_ASTRBOT_COMPLETE",
)
click.echo(comp.source())
cli.add_command(completion)
@click.command(name="version")
def version_cmd() -> None:
"""Display detailed version information"""
print_version_detail()
cli.add_command(version_cmd)
cli.add_command(plug)
cli.add_command(conf)
if __name__ == "__main__":
cli()

View File

@@ -1,28 +0,0 @@
"""ASCII logo and interactive mode utilities for CLI"""
import sys
logo_tmpl = r"""
___ _______.___________..______ .______ ______ .___________.
/ \ / | || _ \ | _ \ / __ \ | |
/ ^ \ | (----`---| |----`| |_) | | |_) | | | | | `---| |----`
/ /_\ \ \ \ | | | / | _ < | | | | | |
/ _____ \ .----) | | | | |\ \----.| |_) | | `--' | | |
/__/ \__\ |_______/ |__| | _| `._____||______/ \______/ |__|
"""
def is_interactive() -> bool:
"""Check if stdout is connected to a TTY (interactive terminal)"""
try:
return sys.stdout.isatty()
except Exception:
return False
def print_logo() -> None:
"""Print ASCII logo if in interactive mode"""
import click
if is_interactive():
click.echo(logo_tmpl)

View File

@@ -1,24 +1,6 @@
from .cmd_bk import bk
from .cmd_conf import conf as config
from .cmd_conf import conf
from .cmd_init import init
from .cmd_password import password
from .cmd_plug import plug as plugin
from .cmd_plug import plug
from .cmd_run import run
from .cmd_service import service
from .cmd_uninstall import uninstall
conf = config
plug = plugin
__all__ = [
"bk",
"conf",
"config",
"init",
"password",
"plug",
"plugin",
"run",
"service",
"uninstall",
]
__all__ = ["conf", "init", "plug", "run"]

View File

@@ -1,392 +0,0 @@
import asyncio
import hashlib
import shutil
import subprocess
from pathlib import Path
import anyio
import click
from astrbot.core import db_helper
from astrbot.core.backup import AstrBotExporter, AstrBotImporter
async def _get_kb_manager():
"""Initialize and return a KnowledgeBaseManager with full dependency chain."""
from astrbot.core import astrbot_config, sp
from astrbot.core.astrbot_config_mgr import AstrBotConfigManager
from astrbot.core.knowledge_base.kb_mgr import KnowledgeBaseManager
from astrbot.core.persona_mgr import PersonaManager
from astrbot.core.provider.manager import ProviderManager
from astrbot.core.umop_config_router import UmopConfigRouter
ucr = UmopConfigRouter(sp=sp)
await ucr.initialize()
acm = AstrBotConfigManager(
default_config=astrbot_config,
ucr=ucr,
sp=sp,
)
persona_mgr = PersonaManager(db_helper, acm)
await persona_mgr.initialize()
provider_manager = ProviderManager(
acm,
db_helper,
persona_mgr,
)
kb_manager = KnowledgeBaseManager(provider_manager)
await kb_manager.initialize()
return kb_manager
@click.group(name="bk")
def bk():
"""Backup management (Export/Import)"""
@bk.command(name="export")
@click.option("--output", "-o", help="Output directory", default=None)
@click.option(
"--gpg-sign",
"-S",
is_flag=True,
help="Sign backup with GPG default private key",
)
@click.option(
"--gpg-encrypt",
"-E",
help="Encrypt for GPG recipient (Asymmetric)",
metavar="RECIPIENT",
)
@click.option(
"--gpg-symmetric",
"-C",
is_flag=True,
help="Encrypt with symmetric cipher (GPG)",
)
@click.option(
"--digest",
"-d",
type=click.Choice(["md5", "sha1", "sha256", "sha512"]),
help="Generate digital digest",
)
def export_data(
output: str | None,
gpg_sign: bool,
gpg_encrypt: str | None,
gpg_symmetric: bool,
digest: str | None,
):
"""Export all AstrBot data to a backup archive.
If any GPG option (-S, -E, -C) is used, the output file will be processed by GPG
and saved with a .gpg extension.
Examples:
\b
1. Standard Export:
astrbot bk export
-> Generates a plain .zip file.
\b
2. Signed Backup (Integrity Check):
astrbot bk export -S
-> Generates a .zip.gpg file containing the backup and your signature.
-> NOT ENCRYPTED, but packaged in OpenPGP format.
-> Use 'astrbot bk import' or 'gpg --verify' to check integrity.
\b
3. Password Protected (Symmetric Encryption):
astrbot bk export -C
-> Generates an encrypted .zip.gpg file.
-> Prompts for a passphrase.
-> Only accessible with the passphrase.
\b
4. Encrypted for Recipient (Asymmetric Encryption):
astrbot bk export -E "alice@example.com"
-> Generates an encrypted .zip.gpg file for Alice.
-> Only Alice's private key can decrypt it.
\b
5. Signed and Encrypted with Digest:
astrbot bk export -S -E "bob@example.com" -d sha256
-> Signs, encrypts for Bob, and generates a SHA256 checksum file.
"""
# Handle case where -E consumes the next flag (e.g. -E -S)
if gpg_encrypt and gpg_encrypt.startswith("-"):
consumed_flag = gpg_encrypt
click.echo(
click.style(
f"Warning: Flag '{consumed_flag}' was interpreted as the recipient for -E.",
fg="yellow",
),
)
# Recover flags
if consumed_flag == "-S":
gpg_sign = True
click.echo("Recovered flag -S (Sign).")
elif consumed_flag == "-C":
gpg_symmetric = True
click.echo("Recovered flag -C (Symmetric).")
# Prompt for the actual recipient
gpg_encrypt = click.prompt("Please enter the GPG recipient (email or key ID)")
async def _run():
if gpg_sign or gpg_encrypt or gpg_symmetric:
if not shutil.which("gpg"):
raise click.ClickException(
"GPG tool not found. Please install GnuPG to use encryption/signing features.",
)
exporter = AstrBotExporter(db_helper)
async def on_progress(stage, current, total, message):
click.echo(f"[{stage}] {message}")
try:
path_str = await exporter.export_all(output, progress_callback=on_progress)
final_path = Path(path_str)
click.echo(
click.style(f"\nRaw backup exported to: {final_path}", fg="green"),
)
# GPG Operations
if gpg_sign or gpg_encrypt or gpg_symmetric:
# Construct GPG command
# output file usually ends with .gpg
gpg_output = final_path.with_name(final_path.name + ".gpg")
cmd = ["gpg", "--output", str(gpg_output), "--yes"]
if gpg_symmetric:
if gpg_encrypt:
click.echo(
click.style(
"Warning: Symmetric encryption selected, ignoring asymmetric recipient.",
fg="yellow",
),
)
cmd.append("--symmetric")
# No --batch to allow interactive passphrase entry on TTY
else:
# Asymmetric or just Sign
# Note: If encrypting, -s adds signature to the encrypted packet.
if gpg_encrypt:
cmd.extend(["--encrypt", "--recipient", gpg_encrypt])
if gpg_sign:
cmd.append("--sign")
cmd.append(str(final_path))
click.echo(f"Running GPG: {' '.join(cmd)}")
# Replace subprocess.run with asyncio.create_subprocess_exec to avoid blocking the event loop
process = await asyncio.create_subprocess_exec(*cmd)
await process.wait()
if process.returncode != 0:
raise subprocess.CalledProcessError(process.returncode or 1, cmd)
# Clean up original file
await anyio.Path(final_path).unlink()
final_path = gpg_output
click.echo(
click.style(f"Processed backup created: {final_path}", fg="green"),
)
# Digest Generation
if digest:
click.echo(f"Calculating {digest} digest...")
hash_func = getattr(hashlib, digest)()
# Read file in chunks
async with await anyio.open_file(final_path, "rb") as f:
while chunk := await f.read(8192):
hash_func.update(chunk)
digest_val = hash_func.hexdigest()
digest_file = final_path.with_name(final_path.name + f".{digest}")
await anyio.Path(digest_file).write_text(
f"{digest_val} *{final_path.name}\n",
encoding="utf-8",
)
click.echo(click.style(f"Digest generated: {digest_file}", fg="green"))
except subprocess.CalledProcessError as e:
click.echo(click.style(f"\nGPG process failed: {e}", fg="red"), err=True)
except Exception as e:
click.echo(click.style(f"\nExport failed: {e}", fg="red"), err=True)
asyncio.run(_run())
@bk.command(name="import")
@click.argument("backup_file")
@click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompts")
def import_data_command(backup_file: str, yes: bool):
"""Import AstrBot data from a backup archive.
Automatically handles .zip files and .gpg files (signed or encrypted).
If the file is encrypted, you will be prompted for the passphrase.
If a digest file (.sha256, .md5, etc.) exists, it will be verified automatically.
"""
backup_path = Path(backup_file)
if not backup_path.exists():
raise click.ClickException(f"Backup file not found: {backup_file}")
# 1. Verify Digest if exists
def _verify_digest(file_path: Path) -> bool:
supported_digests = ["sha256", "sha512", "md5", "sha1"]
digest_verified = True # Default true if no digest file found
for algo in supported_digests:
digest_file = file_path.with_name(f"{file_path.name}.{algo}")
if digest_file.exists():
click.echo(f"Found digest file: {digest_file.name}")
try:
# Parse digest file
content = digest_file.read_text(encoding="utf-8").strip()
# Format: "digest *filename" or "digest filename"
# We expect the hash to be the first part
if " " in content:
expected_digest = content.split()[0].lower()
else:
expected_digest = content.lower()
click.echo(f"Verifying {algo} digest...")
hash_func = getattr(hashlib, algo)()
with open(file_path, "rb") as f:
while chunk := f.read(8192):
hash_func.update(chunk)
calculated_digest = hash_func.hexdigest().lower()
if calculated_digest == expected_digest:
click.echo(
click.style("Digest verification PASSED.", fg="green"),
)
else:
click.echo(
click.style(
"Digest verification FAILED!",
fg="red",
bold=True,
),
)
click.echo(f" Expected: {expected_digest}")
click.echo(f" Actual: {calculated_digest}")
digest_verified = False
except Exception as e:
click.echo(click.style(f"Error checking digest: {e}", fg="red"))
digest_verified = False
return digest_verified
if not _verify_digest(backup_path):
if not yes:
if not click.confirm(
"Digest verification failed. Abort import?",
default=True,
abort=True,
):
pass
else:
click.echo(
click.style(
"Warning: Digest verification failed. Continuing due to --yes.",
fg="yellow",
),
)
if not yes:
click.confirm(
"This will OVERWRITE all current data (DB, Config, Plugins). Continue?",
abort=True,
default=False,
)
async def _run():
zip_path = backup_path
is_temp_file = False
# Handle GPG encrypted files
if backup_path.suffix == ".gpg":
if not shutil.which("gpg"):
raise click.ClickException(
"GPG tool not found. Cannot decrypt .gpg file.",
)
# Remove .gpg extension for output
decrypted_path = backup_path.with_suffix("")
# If it doesn't look like a zip after stripping .gpg, maybe append .zip?
# But the exporter creates .zip.gpg, so stripping .gpg gives .zip.
click.echo(f"Processing GPG file {backup_path}...")
try:
cmd = [
"gpg",
"--output",
str(decrypted_path),
"--decrypt", # This handles both decryption and signature verification/extraction
str(backup_path),
]
# Allow interactive passphrase
process = await asyncio.create_subprocess_exec(*cmd)
await process.wait()
if process.returncode != 0:
raise subprocess.CalledProcessError(process.returncode or 1, cmd)
zip_path = decrypted_path
is_temp_file = True
except subprocess.CalledProcessError:
click.echo(
click.style(
"GPG processing failed. Verify signature or decryption key.",
fg="red",
),
err=True,
)
return
kb_mgr = await _get_kb_manager()
importer = AstrBotImporter(db_helper, kb_mgr)
async def on_progress(stage, current, total, message):
click.echo(f"[{stage}] {message}")
try:
result = await importer.import_all(
str(zip_path),
progress_callback=on_progress,
)
if result.errors:
click.echo(
click.style("\nImport failed with errors:", fg="red"),
err=True,
)
for err in result.errors:
click.echo(f" - {err}", err=True)
else:
click.echo(click.style("\nImport completed successfully!", fg="green"))
if result.warnings:
click.echo(click.style("\nWarnings:", fg="yellow"))
for warn in result.warnings:
click.echo(f" - {warn}")
finally:
if is_temp_file and await anyio.Path(zip_path).exists():
await anyio.Path(zip_path).unlink()
click.echo(f"Cleaned up temporary file: {zip_path}")
asyncio.run(_run())

View File

@@ -1,95 +1,70 @@
"""Configuration CLI for AstrBot.
This module provides:
- secure hashing utilities for the dashboard password (argon2)
- validators for commonly configurable items
- click CLI group with `set`, `get`, and `password` subcommands
"""
from __future__ import annotations
import hashlib
import json
import zoneinfo
from collections.abc import Callable
from typing import Any
import click
from filelock import FileLock, Timeout
from astrbot.cli.i18n import t
from astrbot.core.config.default import DEFAULT_CONFIG
from astrbot.core.utils.astrbot_path import astrbot_paths
from astrbot.core.utils.auth_password import (
_is_argon2_hash,
_is_pbkdf2_hash,
hash_dashboard_password,
hash_legacy_dashboard_password,
is_legacy_dashboard_password,
validate_dashboard_password,
)
# --- Password hashing & validation utilities ---
def is_dashboard_password_hash(value: str) -> bool:
"""Heuristic: return True if `value` looks like a supported dashboard password hash."""
if not isinstance(value, str) or not value:
return False
return _is_argon2_hash(value) or _is_pbkdf2_hash(value)
# --- Validators for CLI configuration items ---
from ..utils import check_astrbot_root, get_astrbot_root
def _validate_log_level(value: str) -> str:
value_up = value.upper()
allowed = {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"}
if value_up not in allowed:
raise click.ClickException(t("config_log_level_invalid"))
return value_up
def _validate_dashboard_port(value: str) -> int:
try:
port = int(value)
except ValueError:
raise click.ClickException(t("config_port_must_be_number")) from None
if port < 1 or port > 65535:
raise click.ClickException(t("config_port_range_invalid"))
return port
def _validate_dashboard_username(value: str) -> str:
if value is None or value.strip() == "":
raise click.ClickException(t("config_username_empty"))
return value.strip()
def _validate_dashboard_password(value: str) -> str:
if value is None or value == "":
raise click.ClickException(t("config_password_empty"))
try:
validate_dashboard_password(value)
except ValueError as e:
raise click.ClickException(str(e)) from e
# Return the plaintext value; callers hash it before storage.
"""Validate log level"""
value = value.upper()
if value not in ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]:
raise click.ClickException(
"Log level must be one of DEBUG/INFO/WARNING/ERROR/CRITICAL",
)
return value
def _validate_dashboard_port(value: str) -> int:
"""Validate Dashboard port"""
try:
port = int(value)
if port < 1 or port > 65535:
raise click.ClickException("Port must be in range 1-65535")
return port
except ValueError:
raise click.ClickException("Port must be a number")
def _validate_dashboard_username(value: str) -> str:
"""Validate Dashboard username"""
if not value:
raise click.ClickException("Username cannot be empty")
return value
def _validate_dashboard_password(value: str) -> str:
"""Validate Dashboard password"""
if not value:
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 as e:
raise click.ClickException(t("config_timezone_invalid", value=value)) from e
except Exception:
raise click.ClickException(
f"Invalid timezone: {value}. Please use a valid IANA timezone name"
)
return value
def _validate_callback_api_base(value: str) -> str:
if not (value.startswith("http://") or value.startswith("https://")):
raise click.ClickException(t("config_callback_invalid"))
"""Validate callback API base URL"""
if not value.startswith("http://") and not value.startswith("https://"):
raise click.ClickException(
"Callback API base must start with http:// or https://"
)
return value
# 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,
@@ -100,22 +75,18 @@ CONFIG_VALIDATORS: dict[str, Callable[[str], Any]] = {
}
# --- Config file helpers ---
def _load_config() -> dict[str, Any]:
"""Load or initialize the CLI config file (data/cmd_config.json).
Ensures the astrbot root is valid before proceeding.
"""
root = astrbot_paths.root
if not astrbot_paths.is_root:
"""Load or initialize config file"""
root = get_astrbot_root()
if not check_astrbot_root(root):
raise click.ClickException(
f"{root} is not a valid AstrBot root directory. Use 'astrbot init' to initialize",
)
config_path = astrbot_paths.data / "cmd_config.json"
config_path = root / "data" / "cmd_config.json"
if not config_path.exists():
# Write DEFAULT_CONFIG to disk if file missing
from astrbot.core.config.default import DEFAULT_CONFIG
config_path.write_text(
json.dumps(DEFAULT_CONFIG, ensure_ascii=False, indent=2),
encoding="utf-8-sig",
@@ -124,123 +95,58 @@ 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"Failed to parse config file: {e!s}") from e
raise click.ClickException(f"Failed to parse config file: {e!s}")
def _save_config(config: dict[str, Any]) -> None:
config_path = astrbot_paths.data / "cmd_config.json"
"""Save config file"""
config_path = get_astrbot_root() / "data" / "cmd_config.json"
config_path.write_text(
json.dumps(config, ensure_ascii=False, indent=2),
encoding="utf-8-sig",
)
def ensure_config_file() -> dict[str, Any]:
return _load_config()
def _set_nested_item(obj: dict[str, Any], path: str, value: Any) -> None:
"""Set a value in a nested dictionary"""
parts = path.split(".")
cur = obj
for part in parts[:-1]:
if part not in cur:
cur[part] = {}
elif not isinstance(cur[part], dict):
if part not in obj:
obj[part] = {}
elif not isinstance(obj[part], dict):
raise click.ClickException(
f"Config path conflict: {'.'.join(parts[: parts.index(part) + 1])} is not a dict",
)
cur = cur[part]
cur[parts[-1]] = value
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(".")
cur = obj
for part in parts:
cur = cur[part]
return cur
obj = obj[part]
return obj
# --- CLI commands ---
def prompt_dashboard_password(prompt: str = "Dashboard password") -> str:
# 显示密码规则提示
click.echo()
click.echo("密码规则:")
click.echo(" - 至少 12 个字符")
click.echo(" - 必须包含至少一个大写字母")
click.echo(" - 必须包含至少一个小写字母")
click.echo(" - 必须包含至少一个数字")
click.echo()
password = click.prompt(prompt, hide_input=True, confirmation_prompt=True, type=str)
click.echo(f"密码长度: {len(password)} 字符")
return _validate_dashboard_password(password)
def set_dashboard_credentials(
config: dict[str, Any],
*,
username: str | None = None,
password_hash: str | None = None,
) -> None:
if username is not None:
_set_nested_item(
config,
"dashboard.username",
_validate_dashboard_username(username),
)
if password_hash is not None:
if isinstance(password_hash, str) and is_dashboard_password_hash(password_hash):
_set_nested_item(config, "dashboard.password", password_hash)
else:
if is_legacy_dashboard_password(password_hash):
raise click.ClickException(
"Storing legacy dashboard password hashes is no longer supported. "
"Please provide the plaintext password (it will be hashed securely), "
"or provide an Argon2-encoded hash string.",
)
validated = _validate_dashboard_password(password_hash)
_set_nested_item(
config,
"dashboard.pbkdf2_password",
hash_dashboard_password(validated),
)
_set_nested_item(
config,
"dashboard.password",
hash_legacy_dashboard_password(validated),
)
def _set_dashboard_password(config: dict[str, Any], raw_password: str) -> None:
"""Set dashboard password hashes and clear password migration flags."""
_set_nested_item(
config,
"dashboard.pbkdf2_password",
hash_dashboard_password(raw_password),
)
_set_nested_item(
config,
"dashboard.password",
hash_legacy_dashboard_password(raw_password),
)
_set_nested_item(config, "dashboard.password_storage_upgraded", True)
_set_nested_item(config, "dashboard.password_change_required", False)
@click.group(name="config")
@click.group(name="conf")
def conf() -> None:
"""Configuration management commands.
"""Configuration management commands
Supported config keys:
- timezone
- log_level
- dashboard.port
- dashboard.username
- dashboard.password
- callback_api_base
- timezone: Timezone setting (e.g. Asia/Shanghai)
- log_level: Log level (DEBUG/INFO/WARNING/ERROR/CRITICAL)
- dashboard.port: Dashboard port
- dashboard.username: Dashboard username
- dashboard.password: Dashboard password
- callback_api_base: Callback API base URL
"""
@@ -248,122 +154,60 @@ 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"Unsupported config key: {key}")
config = _load_config()
try:
# Attempt to get old value (may raise KeyError)
try:
old_value = _get_nested_item(config, key)
except Exception:
old_value = "<not set>"
try:
old_value = _get_nested_item(config, key)
validated_value = CONFIG_VALIDATORS[key](value)
if key == "dashboard.password":
_set_dashboard_password(config, validated_value)
else:
_set_nested_item(config, key, validated_value)
_set_nested_item(config, key, validated_value)
_save_config(config)
click.echo(f"Config updated: {key}")
click.echo(f" Old value: {old_value}")
click.echo(f" New value: {validated_value}")
except KeyError as e:
raise click.ClickException(f"Unknown config key: {key}") from e
except click.ClickException:
raise
if key == "dashboard.password":
click.echo(" Old value: ********")
click.echo(" New value: ********")
else:
click.echo(f" Old value: {old_value}")
click.echo(f" New value: {validated_value}")
except KeyError:
raise click.ClickException(f"Unknown config key: {key}")
except Exception as e:
raise click.UsageError(f"Failed to set config: {e!s}") from e
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:
"""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"Unsupported config key: {key}")
try:
value = _get_nested_item(config, key)
if key == "dashboard.password":
value = "********"
click.echo(f"{key}: {value}")
except KeyError as e:
raise click.ClickException(f"Unknown config key: {key}") from e
except KeyError:
raise click.ClickException(f"Unknown config key: {key}")
except Exception as e:
raise click.UsageError(f"Failed to get config: {e!s}") from e
raise click.UsageError(f"Failed to get config: {e!s}")
else:
click.echo("Current config:")
for k in CONFIG_VALIDATORS:
for key in CONFIG_VALIDATORS:
try:
v = (
value = (
"********"
if k == "dashboard.password"
else _get_nested_item(config, k)
if key == "dashboard.password"
else _get_nested_item(config, key)
)
click.echo(f" {k}: {v}")
click.echo(f" {key}: {value}")
except (KeyError, TypeError):
# Missing or non-dict paths are simply skipped in listing
pass
def _check_astrbot_not_running() -> None:
"""Refuse to proceed if astrbot is currently running (lock file held)."""
lock_file = astrbot_paths.root / "astrbot.lock"
if not lock_file.exists():
return
lock = FileLock(lock_file, timeout=1)
try:
lock.acquire()
except Timeout:
raise click.ClickException(
"AstrBot is currently running. "
"Please stop it first before changing the password via CLI.",
) from None
else:
lock.release()
@conf.command(name="admin")
@click.option("-u", "--username", type=str, help="Update admain username as well")
@click.option(
"-p",
"--password",
type=str,
help="Set admain password directly without interactive prompt",
)
def set_dashboard_password(username: str | None, password: str | None) -> None:
"""Interactively set dashboard password (with confirmation) or set directly with -p.
Acceptable inputs:
- Plaintext password (recommended): it will be hashed securely before storage.
- Argon2 encoded hash (advanced): stored as-is.
"""
_check_astrbot_not_running()
config = _load_config()
if password is not None:
if isinstance(password, str) and is_dashboard_password_hash(password):
password_hash = password
else:
if is_legacy_dashboard_password(password):
raise click.ClickException(
"Providing legacy dashboard password hashes is no longer supported. "
"Please supply the plaintext password (it will be hashed securely), "
"or provide an Argon2-encoded hash string.",
)
password_hash = _validate_dashboard_password(password)
else:
password_hash = prompt_dashboard_password()
set_dashboard_credentials(
config,
username=username.strip() if username is not None else None,
password_hash=password_hash,
)
_save_config(config)
if username is not None:
click.echo(f"Dashboard username updated: {username.strip()}")
click.echo("Dashboard password updated.")

View File

@@ -1,237 +1,55 @@
import asyncio
import json
import os
import re
from pathlib import Path
import click
from filelock import FileLock, Timeout
from astrbot.cli.utils import DashboardManager
from astrbot.core.config.default import DEFAULT_CONFIG
from astrbot.core.utils.astrbot_path import astrbot_paths
from .cmd_conf import ensure_config_file, set_dashboard_credentials
DASHBOARD_INITIAL_PASSWORD_ENV = "ASTRBOT_DASHBOARD_INITIAL_PASSWORD"
from ..utils import check_dashboard, get_astrbot_root
def _initialize_config_from_env(astrbot_root: Path) -> None:
if DASHBOARD_INITIAL_PASSWORD_ENV not in os.environ:
return
from astrbot.core.config.astrbot_config import AstrBotConfig
AstrBotConfig(config_path=str(astrbot_root / "data" / "cmd_config.json"))
click.echo("Initialized data/cmd_config.json with dashboard initial password.")
async def initialize_astrbot(
astrbot_root: Path,
*,
yes: bool,
backend_only: bool,
admin_username: str | None,
admin_password: str | None,
) -> None:
async def initialize_astrbot(astrbot_root: Path) -> None:
"""Execute AstrBot initialization logic"""
from astrbot.cli.banner import print_logo
click.echo("=" * 60)
click.echo("AstrBot 初始化向导")
click.echo("=" * 60)
print_logo()
click.echo()
dot_astrbot = astrbot_root / ".astrbot"
if not dot_astrbot.exists():
if yes or click.confirm(
f"确定要将 AstrBot 安装到以下目录吗?\n {astrbot_root}",
if click.confirm(
f"Install AstrBot to this directory? {astrbot_root}",
default=True,
abort=True,
):
dot_astrbot.touch()
click.echo(f"[OK] 已创建: {dot_astrbot}")
click.echo(f"Created {dot_astrbot}")
paths = {
"data": astrbot_root / "data",
"config": astrbot_root / "data" / "config",
"plugins": astrbot_root / "data" / "plugins",
"temp": astrbot_root / "data" / "temp",
"skills": astrbot_root / "data" / "skills",
}
for name, path in paths.items():
path.mkdir(parents=True, exist_ok=True)
status = "Created" if not path.exists() else "Exists"
click.echo(f" [{status}] {name.title()}: {path}")
click.echo(f"{'Created' if not path.exists() else 'Directory exists'}: {path}")
_initialize_config_from_env(astrbot_root)
config_path = astrbot_root / "data" / "cmd_config.json"
if not config_path.exists():
config_path.write_text(
json.dumps(DEFAULT_CONFIG, ensure_ascii=False, indent=2),
encoding="utf-8-sig",
)
click.echo(f"[OK] 配置文件已创建: {config_path}")
ASTRBOT_ROOT = astrbot_root
env_file = ASTRBOT_ROOT / ".env"
if not env_file.exists():
tmpl_candidates = [
Path("/opt/astrbot/config.template"),
getattr(astrbot_paths, "project_root", Path.cwd()) / "config.template",
Path.cwd() / "config.template",
]
tmpl = None
for t in tmpl_candidates:
try:
if t.exists():
tmpl = t
break
except Exception:
continue
if tmpl is not None:
try:
txt = tmpl.read_text(encoding="utf-8")
instance_name = astrbot_root.name or "astrbot"
txt = re.sub("\\$\\{INSTANCE_NAME(:-[^}]*)?\\}", instance_name, txt)
port_val = (
os.environ.get("ASTRBOT_PORT") or os.environ.get("PORT") or "8000"
)
txt = re.sub("\\$\\{PORT(:-[^}]*)?\\}", str(port_val), txt)
txt = re.sub("\\$\\{ASTRBOT_ROOT(:-[^}]*)?\\}", str(ASTRBOT_ROOT), txt)
header = f"# Generated from config.template by astrbot init for instance: {instance_name}\n# This file will be auto-loaded by 'astrbot run'\n\n"
env_file.write_text(header + txt, encoding="utf-8")
env_file.chmod(420)
click.echo(f"[OK] 环境变量文件已创建: {env_file}")
except Exception as e:
click.echo(f"[警告] 无法从模板生成 .env 文件: {e!s}")
else:
click.echo("[提示] 未找到 config.template 文件,跳过 .env 生成")
if admin_password is not None:
raise click.ClickException(
"--admin-password is no longer supported during init. Run 'astrbot conf admin' after initialization.",
)
effective_admin_username = (
admin_username.strip()
if admin_username
else str(DEFAULT_CONFIG["dashboard"]["username"])
)
if admin_username:
config = ensure_config_file()
set_dashboard_credentials(
config,
username=effective_admin_username,
password_hash=None,
)
config_path.write_text(
json.dumps(config, ensure_ascii=False, indent=2),
encoding="utf-8-sig",
)
click.echo(f"[OK] Dashboard admin 用户名已设置为: {effective_admin_username}")
click.echo()
click.echo("!" * 60)
click.echo("重要提示:")
click.echo(" 1. Dashboard 密码尚未设置!首次登录前必须先设置密码")
click.echo(" 2. 设置命令: astrbot conf admin")
click.echo(" 3. 登录地址: http://localhost:6185 或 http://服务器IP:6185")
click.echo("!" * 60)
click.echo()
if not backend_only and (
yes
or click.confirm(
"是否需要集成式 WebUI个人电脑推荐服务器推荐使用后端模式",
default=True,
)
):
await DashboardManager().ensure_installed(astrbot_root)
else:
click.echo()
click.echo("[提示] 你选择了后端模式,可以使用以下方式管理 AstrBot")
click.echo(" - 使用在线 Dashboard: 在浏览器中访问远程服务器的 WebUI")
click.echo(" - 使用 CLI 命令: astrbot conf / astrbot plug 等")
click.echo()
click.echo("!" * 60)
click.echo("安全提示:")
click.echo(" HTTPS 前端只能安全连接 localhost 的 HTTP 后端")
click.echo(" 不支持远程 + HTTP 后端(不安全)")
click.echo(" 如需远程访问,请使用 HTTPS 后端或通过反向代理")
click.echo("!" * 60)
click.echo()
await check_dashboard(astrbot_root / "data")
@click.command()
@click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompts")
@click.option("--backend-only", "-b", is_flag=True, help="Only initialize the backend")
@click.option("--backup", "-f", help="Initialize from backup file", type=str)
@click.option(
"-u",
"--admin-username",
type=str,
help="Set dashboard admin username during initialization",
)
@click.option(
"-p",
"--admin-password",
type=str,
help="Deprecated. Run `astrbot conf admin` after initialization.",
)
@click.option(
"--root",
help="ASTRBOT root directory to initialize (overrides ASTRBOT_ROOT env)",
type=str,
)
def init(
yes: bool,
backend_only: bool,
backup: str | None,
admin_username: str | None,
admin_password: str | None,
root: str | None = None,
) -> None:
def init() -> None:
"""Initialize AstrBot"""
click.echo("Initializing AstrBot...")
if os.environ.get("ASTRBOT_SYSTEMD") == "1":
yes = True
from astrbot.core.utils.astrbot_path import astrbot_paths
astrbot_root = Path(root) if root else astrbot_paths.root
astrbot_root = get_astrbot_root()
lock_file = astrbot_root / "astrbot.lock"
lock = FileLock(lock_file, timeout=5)
try:
with lock.acquire():
asyncio.run(
initialize_astrbot(
astrbot_root,
yes=yes,
backend_only=backend_only,
admin_username=admin_username,
admin_password=admin_password,
),
)
if backup:
from .cmd_bk import import_data_command
click.echo(f"Restoring from backup: {backup}")
click.get_current_context().invoke(
import_data_command,
backup_file=backup,
yes=True,
)
click.echo()
click.echo("=" * 60)
click.echo("初始化完成!")
click.echo("=" * 60)
click.echo()
click.echo("启动 AstrBot")
click.echo(" 完整模式(含 Dashboard: astrbot run")
click.echo(" 仅后端模式: astrbot run --backend-only")
click.echo()
click.echo("首次使用前请先设置管理员密码:")
click.echo(" astrbot conf admin")
click.echo()
except Timeout as err:
asyncio.run(initialize_astrbot(astrbot_root))
click.echo("Done! You can now run 'astrbot run' to start AstrBot")
except Timeout:
raise click.ClickException(
"Cannot acquire lock file. Please check if another instance is running",
) from err
"Cannot acquire lock file. Please check if another instance is running"
)
except Exception as e:
raise click.ClickException(f"Initialization failed: {e!s}") from e
raise click.ClickException(f"Initialization failed: {e!s}")

View File

@@ -1,38 +0,0 @@
import click
from .cmd_conf import (
_load_config,
_save_config,
_set_dashboard_password,
_set_nested_item,
_validate_dashboard_password,
_validate_dashboard_username,
)
@click.command(name="password")
@click.option(
"--username",
help="Optional dashboard username to set together with the new password.",
)
def password(username: str | None) -> None:
"""Change the AstrBot dashboard password."""
config = _load_config()
new_password = click.prompt(
"New dashboard password",
hide_input=True,
confirmation_prompt=True,
)
validated_password = _validate_dashboard_password(new_password)
if username is not None:
validated_username = _validate_dashboard_username(username.strip())
_set_nested_item(config, "dashboard.username", validated_username)
_set_dashboard_password(config, validated_password)
_save_config(config)
click.echo("Dashboard password updated.")
if username is not None:
click.echo(f"Dashboard username updated: {validated_username}")

View File

@@ -1,28 +1,39 @@
import re
import shutil
from pathlib import Path
import click
from astrbot.cli.i18n import t
from astrbot.cli.utils import (
from ..utils import (
PluginStatus,
build_plug_list,
check_astrbot_root,
get_astrbot_root,
get_git_repo,
manage_plugin,
)
@click.group(name="plugin")
@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} is not a valid AstrBot root directory. Use 'astrbot init' to initialize",
)
return (base / "data").resolve()
def display_plugins(plugins, title=None, color=None) -> None:
if title:
click.echo(click.style(title, fg=color, bold=True))
click.echo(
f"{'Name':<20} {'Version':<10} {'Status':<10} {'Author':<15} {'Description':<30}",
f"{'Name':<20} {'Version':<10} {'Status':<10} {'Author':<15} {'Description':<30}"
)
click.echo("-" * 85)
@@ -38,13 +49,11 @@ def display_plugins(plugins, title=None, color=None) -> None:
@click.argument("name")
def new(name: str) -> None:
"""Create a new plugin"""
from astrbot.core.utils.astrbot_path import astrbot_paths
base_path = astrbot_paths.data
base_path = _get_data_path()
plug_path = base_path / "plugins" / name
if plug_path.exists():
raise click.ClickException(t("plugin_already_exists", name=name))
raise click.ClickException(f"Plugin {name} already exists")
author = click.prompt("Enter plugin author", type=str)
desc = click.prompt("Enter plugin description", type=str)
@@ -75,7 +84,7 @@ def new(name: str) -> None:
# Rewrite README.md
with open(plug_path / "README.md", "w", encoding="utf-8") as f:
f.write(
f"# {name}\n\n{desc}\n\n# Support\n\n[Documentation](https://docs.astrbot.app)\n",
f"# {name}\n\n{desc}\n\n# Support\n\n[Documentation](https://astrbot.app)\n"
)
# Rewrite main.py
@@ -97,9 +106,7 @@ def new(name: str) -> None:
@click.option("--all", "-a", is_flag=True, help="List uninstalled plugins")
def list(all: bool) -> None:
"""List plugins"""
from astrbot.core.utils.astrbot_path import astrbot_paths
base_path = astrbot_paths.data
base_path = _get_data_path()
plugins = build_plug_list(base_path / "plugins")
# Unpublished plugins
@@ -140,9 +147,7 @@ def list(all: bool) -> None:
@click.option("--proxy", help="Proxy server address")
def install(name: str, proxy: str | None) -> None:
"""Install a plugin"""
from astrbot.core.utils.astrbot_path import astrbot_paths
base_path = astrbot_paths.data
base_path = _get_data_path()
plug_path = base_path / "plugins"
plugins = build_plug_list(base_path / "plugins")
@@ -156,7 +161,7 @@ def install(name: str, proxy: str | None) -> None:
)
if not plugin:
raise click.ClickException(t("plugin_not_found_or_installed", name=name))
raise click.ClickException(f"Plugin {name} not found or already installed")
manage_plugin(plugin, plug_path, is_update=False, proxy=proxy)
@@ -165,26 +170,24 @@ def install(name: str, proxy: str | None) -> None:
@click.argument("name")
def remove(name: str) -> None:
"""Uninstall a plugin"""
from astrbot.core.utils.astrbot_path import astrbot_paths
base_path = astrbot_paths.data
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(t("plugin_not_found_or_installed", name=name))
raise click.ClickException(f"Plugin {name} does not exist or is not installed")
plugin_path = plugin["local_path"]
click.confirm(t("plugin_uninstall_confirm", name=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(t("plugin_uninstall_success", name=name))
click.echo(f"Plugin {name} has been uninstalled")
except Exception as e:
raise click.ClickException(
t("plugin_uninstall_failed_ex", name=name, error=str(e)),
) from e
raise click.ClickException(f"Failed to uninstall plugin {name}: {e}")
@plug.command()
@@ -192,9 +195,7 @@ def remove(name: str) -> None:
@click.option("--proxy", help="GitHub proxy address")
def update(name: str, proxy: str | None) -> None:
"""Update plugins"""
from astrbot.core.utils.astrbot_path import astrbot_paths
base_path = astrbot_paths.data
base_path = _get_data_path()
plug_path = base_path / "plugins"
plugins = build_plug_list(base_path / "plugins")
@@ -210,7 +211,7 @@ def update(name: str, proxy: str | None) -> None:
if not plugin:
raise click.ClickException(
f"Plugin {name} does not need updating or cannot be updated",
f"Plugin {name} does not need updating or cannot be updated"
)
manage_plugin(plugin, plug_path, is_update=True, proxy=proxy)
@@ -220,13 +221,13 @@ def update(name: str, proxy: str | None) -> None:
]
if not need_update_plugins:
click.echo(t("plugin_no_update_needed"))
click.echo("No plugins need updating")
return
click.echo(t("plugin_found_update", count=str(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(t("plugin_updating", name=plugin_name))
click.echo(f"Updating plugin {plugin_name}...")
manage_plugin(plugin, plug_path, is_update=True, proxy=proxy)
@@ -234,9 +235,7 @@ def update(name: str, proxy: str | None) -> None:
@click.argument("query")
def search(query: str) -> None:
"""Search for plugins"""
from astrbot.core.utils.astrbot_path import astrbot_paths
base_path = astrbot_paths.data
base_path = _get_data_path()
plugins = build_plug_list(base_path / "plugins")
matched_plugins = [
@@ -248,7 +247,7 @@ def search(query: str) -> None:
]
if not matched_plugins:
click.echo(t("plugin_search_no_result", query=query))
click.echo(f"No plugins matching '{query}' found")
return
display_plugins(matched_plugins, t("plugin_search_results", query=query), "cyan")
display_plugins(matched_plugins, f"Search results: '{query}'", "cyan")

View File

@@ -1,92 +1,13 @@
"""AstrBot Run
Environment Variables Used in Project:
Core:
- `ASTRBOT_ROOT`: AstrBot root directory path.
- `ASTRBOT_LOG_LEVEL`: Log level (e.g. INFO, DEBUG).
- `ASTRBOT_CLI`: Flag indicating execution via CLI.
- `ASTRBOT_DESKTOP_CLIENT`: Flag indicating execution via desktop client.
- `ASTRBOT_SYSTEMD`: Flag indicating execution via systemd service.
- `ASTRBOT_RELOAD`: Enable plugin auto-reload (set to "1").
- `ASTRBOT_DISABLE_METRICS`: Disable metrics upload (set to "1").
- `TESTING`: Enable testing mode.
- `DEMO_MODE`: Enable demo mode.
- `PYTHON`: Python executable path override (for local code execution).
Dashboard / Backend:
- `ASTRBOT_DASHBOARD_ENABLE`: Enable/Disable Dashboard.
- `ASTRBOT_HOST`: Dashboard bind host.
- `ASTRBOT_PORT`: Dashboard bind port.
SSL (AstrBot-standard names):
- `ASTRBOT_SSL_ENABLE`: Enable SSL for API.
- `ASTRBOT_SSL_CERT`: SSL Certificate path for backend.
- `ASTRBOT_SSL_KEY`: SSL Key path for backend.
- `ASTRBOT_SSL_CA_CERTS`: SSL CA Certs path for backend.
Network:
- `http_proxy` / `https_proxy`: Proxy URL.
- `no_proxy`: No proxy list.
Internationalization:
- `ASTRBOT_CLI_LANG`: CLI interface language (zh/en).
Integrations:
- `DASHSCOPE_API_KEY`: Alibaba DashScope API Key (for Rerank).
- `COZE_API_KEY` / `COZE_BOT_ID`: Coze integration.
- `BAY_DATA_DIR`: Computer Use data directory.
Platform Specific:
- `TEST_MODE`: Test mode for QQOfficial.
"""
from __future__ import annotations
import asyncio
import os
import re
import sys
import traceback
from pathlib import Path
import click
from dotenv import load_dotenv
from filelock import FileLock, Timeout
from astrbot.cli.utils import DashboardManager
from astrbot.runtime_bootstrap import initialize_runtime_bootstrap
# Python version check: require 3.12 or 3.13
if not (sys.version_info.major == 3 and sys.version_info.minor in (12, 13)):
sys.exit(1)
# Regular expression to find bash-like parameter expansions:
# ${VAR:-default} or ${VAR}
_PARAM_EXPAND_RE = re.compile(r"\$\{([^}:]+?)(:-([^}]*))?\}")
def _expand_parameter(
match: re.Match,
env: dict[str, str],
local: dict[str, str],
) -> str:
"""Helper to expand a single ${VAR:-default} or ${VAR} occurrence.
Precedence:
1. local dict (parsed from the same file, earlier entries)
2. environment variables
3. default provided in the expansion (if any)
4. empty string
"""
var = match.group(1)
default = match.group(3) if match.group(3) is not None else ""
# Prefer 'local' parsed values first
if var in local and local[var] != "":
return local[var]
val = env.get(var, "")
if val != "":
return val
return default
from ..utils import check_astrbot_root, check_dashboard, get_astrbot_root
async def run_astrbot(astrbot_root: Path) -> None:
@@ -94,11 +15,7 @@ async def run_astrbot(astrbot_root: Path) -> None:
from astrbot.core import LogBroker, LogManager, db_helper, logger
from astrbot.core.initial_loader import InitialLoader
if (
os.environ.get("ASTRBOT_DASHBOARD_ENABLE", os.environ.get("DASHBOARD_ENABLE"))
== "True"
):
await DashboardManager().ensure_installed(astrbot_root)
await check_dashboard(astrbot_root / "data")
log_broker = LogBroker()
LogManager.set_queue_handler(logger, log_broker)
@@ -110,316 +27,38 @@ async def run_astrbot(astrbot_root: Path) -> None:
@click.option("--reload", "-r", is_flag=True, help="Auto-reload plugins")
@click.option("--host", "-H", help="AstrBot Dashboard Host", required=False, type=str)
@click.option("--port", "-p", help="AstrBot Dashboard port", required=False, type=str)
@click.option("--root", help="AstrBot root directory", required=False, type=str)
@click.option(
"--service-config",
"-c",
help="Service configuration file path (supports ${VAR:-default} style expansion)",
required=False,
type=str,
)
@click.option(
"--backend-only",
"-b",
is_flag=True,
default=False,
help="Disable WebUI, run backend only",
)
@click.option(
"--log-level",
"-l",
help="Log level",
required=False,
type=str,
default="INFO",
)
@click.option(
"--ssl-cert",
help="SSL certificate file path for backend (preferred env name: ASTRBOT_SSL_CERT)",
required=False,
type=str,
)
@click.option(
"--ssl-key",
help="SSL private key file path for backend (preferred env name: ASTRBOT_SSL_KEY)",
required=False,
type=str,
)
@click.option(
"--ssl-ca",
help="SSL CA certificates file path for backend (preferred env name: ASTRBOT_SSL_CA_CERTS)",
required=False,
type=str,
)
@click.option("--debug", is_flag=True, help="Enable debug mode")
@click.command()
def run(
reload: bool,
host: str,
port: str,
root: str,
service_config: str,
backend_only: bool,
log_level: str,
ssl_cert: str,
ssl_key: str,
ssl_ca: str,
debug: bool,
) -> None:
def run(reload: bool, port: str) -> None:
"""Run AstrBot"""
initialize_runtime_bootstrap()
try:
if debug:
log_level = "DEBUG"
# --- Step 1: Resolve service-config path (if provided). We'll treat it as a .env file later. ---
svc_path: Path | None = None
if service_config:
candidate = Path(service_config)
if not candidate.exists():
# Try to expand user and resolve
candidate = Path(os.path.expanduser(service_config))
if candidate.exists():
svc_path = candidate
# NOTE:
# Loading of common .env files (CWD/.env, packaged project .env, ASTRBOT_ROOT/.env)
# has been moved to astrbot.core.utils.astrbot_path during import-time to avoid
# early-initialization ordering issues. Those files are loaded there using
# `override=False` so they do not clobber environment variables provided by the
# systemd unit or the caller.
#
# Here we only load an explicit service-config file (if given). Service-config
# should be able to override the common .env files, but CLI-provided values must
# still win; the CLI will set/overwrite corresponding environment variables
# below after this load.
if svc_path and svc_path.exists():
# Load service-config as an env file and allow it to override previously-loaded
# .env values (those were loaded by astrbot_path). CLI variables are applied
# after this point and will take precedence.
load_dotenv(dotenv_path=str(svc_path), override=True)
# Mark CLI execution
os.environ["ASTRBOT_CLI"] = "1"
astrbot_root = get_astrbot_root()
from astrbot.core.utils.astrbot_path import astrbot_paths
# Resolve astrbot_root with the following precedence:
# 1. CLI --root parameter (local variable `root`)
# 2. ASTRBOT_ROOT environment variable (possibly from .env or parsed service config)
# 3. packaged default astrbot_paths.root
if root:
os.environ["ASTRBOT_ROOT"] = root
astrbot_root = Path(root)
elif os.environ.get("ASTRBOT_ROOT"):
astrbot_root = Path(os.environ["ASTRBOT_ROOT"])
else:
astrbot_root = astrbot_paths.root
if not astrbot_paths.is_root:
if not check_astrbot_root(astrbot_root):
raise click.ClickException(
f"{astrbot_root} is not a valid AstrBot root directory. Use 'astrbot init' to initialize",
)
# Ensure ASTRBOT_ROOT env var is set to the resolved root (without overriding a CLI-provided root value above)
os.environ["ASTRBOT_ROOT"] = str(astrbot_root)
sys.path.insert(0, str(astrbot_root))
# Host/Port precedence: CLI args > parsed service config/env/.env > defaults.
if port is not None:
os.environ["ASTRBOT_PORT"] = port
if host is not None:
os.environ["ASTRBOT_HOST"] = host
# CLI-provided SSL paths should set backend-standard env names.
if ssl_cert is not None:
os.environ["ASTRBOT_SSL_CERT"] = ssl_cert
if ssl_key is not None:
os.environ["ASTRBOT_SSL_KEY"] = ssl_key
if ssl_ca is not None:
os.environ["ASTRBOT_SSL_CA_CERTS"] = ssl_ca
# Dashboard enable is derived from CLI flag (--backend-only). CLI decision should win.
os.environ["ASTRBOT_DASHBOARD_ENABLE"] = str(not backend_only)
os.environ["ASTRBOT_LOG_LEVEL"] = log_level
if port:
os.environ["DASHBOARD_PORT"] = port
if reload:
click.echo("Plugin auto-reload enabled")
os.environ["ASTRBOT_RELOAD"] = "1"
if debug:
keys_to_print = [
"ASTRBOT_ROOT",
"ASTRBOT_LOG_LEVEL",
"ASTRBOT_CLI",
"ASTRBOT_DESKTOP_CLIENT",
"ASTRBOT_SYSTEMD",
"ASTRBOT_RELOAD",
"ASTRBOT_DISABLE_METRICS",
"TESTING",
"DEMO_MODE",
"PYTHON",
"ASTRBOT_DASHBOARD_ENABLE",
"DASHBOARD_ENABLE",
"ASTRBOT_HOST",
"DASHBOARD_HOST",
"ASTRBOT_PORT",
"DASHBOARD_PORT",
# Dashboard SSL (legacy)
"ASTRBOT_SSL_ENABLE",
"DASHBOARD_SSL_ENABLE",
"ASTRBOT_SSL_CERT",
"DASHBOARD_SSL_CERT",
"ASTRBOT_SSL_KEY",
"DASHBOARD_SSL_KEY",
"ASTRBOT_SSL_CA_CERTS",
"DASHBOARD_SSL_CA_CERTS",
# Backend-standard SSL (preferred)
"ASTRBOT_SSL_ENABLE",
"ASTRBOT_SSL_CERT",
"ASTRBOT_SSL_KEY",
"ASTRBOT_SSL_CA_CERTS",
"http_proxy",
"https_proxy",
"no_proxy",
"DASHSCOPE_API_KEY",
"COZE_API_KEY",
"COZE_BOT_ID",
"BAY_DATA_DIR",
"TEST_MODE",
]
click.secho("\n[Debug Mode] Environment Variables:", fg="yellow", bold=True)
for key in keys_to_print:
if key in os.environ:
val = os.environ[key]
if "KEY" in key or "PASSWORD" in key or "SECRET" in key:
if len(val) > 8:
val = val[:4] + "****" + val[-4:]
else:
val = "****"
click.echo(f" {click.style(key, fg='cyan')}: {val}")
if svc_path:
click.echo(
f" {click.style('SERVICE_CONFIG', fg='cyan')}: {svc_path!s}",
)
click.echo("")
lock_file = astrbot_root / "astrbot.lock"
lock = FileLock(lock_file, timeout=5)
with lock.acquire():
async def run_with_logging() -> None:
from astrbot.core import LogBroker, LogManager, db_helper, logger
from astrbot.core.initial_loader import InitialLoader
if (
os.environ.get(
"ASTRBOT_DASHBOARD_ENABLE",
os.environ.get("DASHBOARD_ENABLE"),
)
== "True"
):
await DashboardManager().ensure_installed(astrbot_root)
log_broker = LogBroker()
LogManager.set_queue_handler(logger, log_broker)
# Register a stdout subscriber for real-time log streaming
log_queue = log_broker.register()
db = db_helper
initial_loader = InitialLoader(db, log_broker)
# Start a task to stream logs to stdout
async def stream_logs() -> None:
"""Stream logs from LogBroker to stdout."""
while True:
try:
log_entry = await asyncio.wait_for(
log_queue.get(),
timeout=0.5,
)
# Format: [LEVEL] message
level = log_entry.get("level_name", "INFO")
message = log_entry.get("message", "")
if message:
level_color = {
"DEBUG": "cyan",
"INFO": "green",
"WARNING": "yellow",
"ERROR": "red",
"CRITICAL": "red",
}.get(level, "white")
click.secho(
f"[{level}]",
fg=level_color,
bold=False,
nl=False,
)
click.echo(f" {message}")
except TimeoutError:
continue
except asyncio.CancelledError:
break
# Start streaming task
stream_task = asyncio.create_task(stream_logs())
try:
await initial_loader.start()
finally:
stream_task.cancel()
try:
await stream_task
except asyncio.CancelledError:
pass
click.echo()
click.echo("=" * 60)
click.echo("AstrBot 启动中...")
click.echo("=" * 60)
from astrbot.cli.banner import print_logo
print_logo()
click.echo()
if backend_only:
click.echo("[模式] 仅后端模式(无本地 Dashboard")
click.echo()
click.echo("[提示] 可以通过以下方式访问 WebUI")
click.echo(" - 使用远程服务器的在线 Dashboard")
click.echo(" - 地址: http://服务器IP:6185")
click.echo()
else:
dashboard_url = f"http://{host or 'localhost'}:{port or '6185'}"
click.echo("[模式] 完整模式(含本地 Dashboard")
click.echo()
click.echo(f"[Dashboard] 请访问: {dashboard_url}")
click.echo()
click.echo("!" * 60)
click.echo("安全提示:")
click.echo(" HTTPS 前端只能安全连接 localhost 的 HTTP 后端")
click.echo(" 不支持远程 + HTTP 后端(不安全)")
click.echo("!" * 60)
click.echo()
click.echo("正在启动服务...(日志输出中)")
click.echo()
asyncio.run(run_with_logging())
asyncio.run(run_astrbot(astrbot_root))
except KeyboardInterrupt:
click.echo("AstrBot has been shut down.")
except Timeout:
raise click.ClickException(
"Cannot acquire lock file. Please check if another instance is running",
) from None
"Cannot acquire lock file. Please check if another instance is running"
)
except Exception as e:
# Keep original traceback visible for diagnostics
raise click.ClickException(
f"Runtime error: {e}\n{traceback.format_exc()}",
) from e
raise click.ClickException(f"Runtime error: {e}\n{traceback.format_exc()}")

File diff suppressed because it is too large Load Diff

View File

@@ -1,69 +0,0 @@
import os
import shutil
from pathlib import Path
import click
from astrbot.core.utils.astrbot_path import astrbot_paths
@click.command()
@click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompts")
@click.option(
"--keep-data",
is_flag=True,
help="Keep data directory (config, plugins, etc.)",
)
def uninstall(yes: bool, keep_data: bool) -> None:
"""Remove AstrBot files from the current root directory."""
if os.environ.get("ASTRBOT_SYSTEMD") == "1":
yes = True
dot_astrbot = astrbot_paths.root / ".astrbot"
lock_file = astrbot_paths.root / "astrbot.lock"
data_dir = astrbot_paths.data
removable_paths: list[Path] = [dot_astrbot, lock_file]
if not keep_data:
removable_paths.insert(0, data_dir)
# Check if this looks like an AstrBot root before blowing things up
if not dot_astrbot.exists() and not data_dir.exists():
click.echo("No AstrBot initialization found in current directory.")
return
if keep_data:
click.echo("Keeping data directory as requested.")
if yes or click.confirm(
f"Are you sure you want to remove AstrBot data at {astrbot_paths.root}? \n"
f"This will delete:\n"
f" - {data_dir} (Config, Plugins, Database)\n"
f" - {dot_astrbot}\n"
f" - {lock_file}",
default=False,
abort=True,
):
removed_any = False
for path in removable_paths:
if not path.exists():
continue
removed_any = True
if path.is_dir():
click.echo(f"Removing directory: {path}")
shutil.rmtree(path)
else:
click.echo(f"Removing file: {path}")
path.unlink()
if removed_any:
click.echo("AstrBot files removed successfully.")
else:
click.echo("No removable AstrBot files were found.")
# TODO: Consider adding an explicit `--service` cleanup mode instead of
# touching systemd or other service managers during normal uninstall.
# TODO: Consider adding package-manager-specific uninstall helpers once
# the CLI can reliably detect the installation source.
click.echo("uv: uv tool uninstall astrbot")
click.echo("paru/yay: paru -R astrbot")

View File

@@ -1,278 +0,0 @@
"""Internationalization support for AstrBot CLI.
This module provides i18n support with Chinese and English languages.
Language is auto-detected from environment or can be set manually.
"""
from __future__ import annotations
import os
from enum import Enum
from functools import lru_cache
class Language(Enum):
"""Supported languages."""
ZH = "zh"
EN = "en"
# Translation dictionaries
_TRANSLATIONS: dict[Language, dict[str, str]] = {
Language.ZH: {
# CLI welcome and general
"cli_welcome": "欢迎使用 AstrBot CLI!",
"cli_version": "AstrBot CLI 版本: {version}",
"cli_unknown_command": "未知命令: {command}",
"cli_help_available": "使用 astrbot help --all 查看所有命令",
# Dashboard commands
"dashboard_bundled": "Dashboard 已打包在安装包中 - 跳过下载",
"dashboard_not_installed": "Dashboard 未安装",
"dashboard_install_confirm": "是否安装 Dashboard?",
"dashboard_installing": "正在安装 Dashboard...",
"dashboard_install_success": "Dashboard 安装成功",
"dashboard_install_failed": "Dashboard 安装失败: {error}",
"dashboard_not_needed": "Dashboard 不需要安装",
"dashboard_declined": "Dashboard 安装已取消",
"dashboard_already_up_to_date": "Dashboard 已是最新版本",
"dashboard_version": "Dashboard 版本: {version}",
"dashboard_download_failed": "Dashboard 下载失败: {error}",
"dashboard_init_dir": "正在初始化 Dashboard 目录...",
"dashboard_init_success": "Dashboard 初始化成功",
# Plugin commands
"plugin_installing": "正在安装插件: {name}",
"plugin_install_success": "插件安装成功: {name}",
"plugin_install_failed": "插件安装失败: {name}",
"plugin_uninstall_confirm": "确定要卸载插件 {name} 吗?",
"plugin_uninstall_success": "插件卸载成功: {name}",
"plugin_uninstall_failed": "插件卸载失败: {name}",
"plugin_list_empty": "未安装任何插件",
"plugin_already_installed": "插件已安装: {name}",
"plugin_not_found": "插件未找到: {name}",
"plugin_already_exists": "插件已存在: {name}",
"plugin_not_found_or_installed": "插件未找到或已安装: {name}",
"plugin_uninstall_failed_ex": "插件卸载失败 {name}: {error}",
"plugin_no_update_needed": "没有需要更新的插件",
"plugin_found_update": "发现 {count} 个插件需要更新",
"plugin_updating": "正在更新插件 {name}...",
"plugin_search_no_result": "未找到匹配 '{query}' 的插件",
"plugin_search_results": "搜索结果: '{query}'",
# Config commands
"config_show": "显示配置",
"config_set_success": "配置项已更新: {key} = {value}",
"config_set_failed": "配置项更新失败: {key}",
"config_set_failed_ex": "设置配置失败: {error}",
"config_get_success": "{key} = {value}",
"config_get_not_found": "配置项未找到: {key}",
"config_reset_confirm": "确定要重置所有配置吗?",
"config_reset_success": "配置已重置",
# Config validators
"config_log_level_invalid": "日志级别必须是 DEBUG/INFO/WARNING/ERROR/CRITICAL 之一",
"config_port_must_be_number": "端口必须是数字",
"config_port_range_invalid": "端口必须在 1-65535 范围内",
"config_username_empty": "用户名不能为空",
"config_password_empty": "密码不能为空",
"config_timezone_invalid": "无效的时区: {value}。请使用有效的 IANA 时区名称",
"config_callback_invalid": "回调 API 基础路径必须以 http:// 或 https:// 开头",
"config_key_unsupported": "不支持的配置项: {key}",
"config_key_unknown": "未知的配置项: {key}",
"config_updated": "配置已更新: {key}",
# Init command
"init_creating": "正在创建配置目录...",
"init_created": "配置目录已创建: {path}",
"init_copying": "正在复制配置文件...",
"init_copied": "配置文件已复制",
"init_success": "AstrBot 初始化完成!",
"init_failed": "初始化失败: {error}",
# Run command
"run_starting": "正在启动 AstrBot...",
"run_started": "AstrBot 已启动!",
"run_backend_only": "以无界面模式启动",
"run_failed": "启动失败: {error}",
"run_stopped": "AstrBot 已停止",
# Common
"yes": "",
"no": "",
"cancel": "取消",
"confirm": "确认",
"error": "错误",
"success": "成功",
"warning": "警告",
"info": "信息",
"loading": "加载中...",
"done": "完成",
"failed": "失败",
"retry": "重试",
"exit": "退出",
"continue": "继续",
},
Language.EN: {
# CLI welcome and general
"cli_welcome": "Welcome to AstrBot CLI!",
"cli_version": "AstrBot CLI version: {version}",
"cli_unknown_command": "Unknown command: {command}",
"cli_help_available": "Use astrbot help --all to see all commands",
# Dashboard commands
"dashboard_bundled": "Dashboard is bundled with the package - skipping download",
"dashboard_not_installed": "Dashboard is not installed",
"dashboard_install_confirm": "Install Dashboard?",
"dashboard_installing": "Installing Dashboard...",
"dashboard_install_success": "Dashboard installed successfully",
"dashboard_install_failed": "Failed to install dashboard: {error}",
"dashboard_not_needed": "Dashboard not needed",
"dashboard_declined": "Dashboard installation declined.",
"dashboard_already_up_to_date": "Dashboard is already up to date",
"dashboard_version": "Dashboard version: {version}",
"dashboard_download_failed": "Failed to download dashboard: {error}",
"dashboard_init_dir": "Initializing dashboard directory...",
"dashboard_init_success": "Dashboard initialized successfully",
# Plugin commands
"plugin_installing": "Installing plugin: {name}",
"plugin_install_success": "Plugin installed successfully: {name}",
"plugin_install_failed": "Failed to install plugin: {name}",
"plugin_uninstall_confirm": "Uninstall plugin {name}?",
"plugin_uninstall_success": "Plugin uninstalled successfully: {name}",
"plugin_uninstall_failed": "Failed to uninstall plugin: {name}",
"plugin_list_empty": "No plugins installed",
"plugin_already_installed": "Plugin already installed: {name}",
"plugin_not_found": "Plugin not found: {name}",
"plugin_already_exists": "Plugin {name} already exists",
"plugin_not_found_or_installed": "Plugin {name} not found or already installed",
"plugin_uninstall_failed_ex": "Failed to uninstall plugin {name}: {error}",
"plugin_no_update_needed": "No plugins need updating",
"plugin_found_update": "Found {count} plugin(s) needing update",
"plugin_updating": "Updating plugin {name}...",
"plugin_search_no_result": "No plugins matching '{query}' found",
"plugin_search_results": "Search results: '{query}'",
# Config commands
"config_show": "Show configuration",
"config_set_success": "Configuration updated: {key} = {value}",
"config_set_failed": "Failed to update configuration: {key}",
"config_set_failed_ex": "Failed to set config: {error}",
"config_get_success": "{key} = {value}",
"config_get_not_found": "Configuration key not found: {key}",
"config_reset_confirm": "Reset all configuration?",
"config_reset_success": "Configuration reset",
# Config validators
"config_log_level_invalid": "Log level must be one of DEBUG/INFO/WARNING/ERROR/CRITICAL",
"config_port_must_be_number": "Port must be a number",
"config_port_range_invalid": "Port must be in range 1-65535",
"config_username_empty": "Username cannot be empty",
"config_password_empty": "Password cannot be empty",
"config_timezone_invalid": "Invalid timezone: {value}. Please use a valid IANA timezone name",
"config_callback_invalid": "Callback API base must start with http:// or https://",
"config_key_unsupported": "Unsupported config key: {key}",
"config_key_unknown": "Unknown config key: {key}",
"config_updated": "Config updated: {key}",
# Init command
"init_creating": "Creating config directory...",
"init_created": "Config directory created: {path}",
"init_copying": "Copying config files...",
"init_copied": "Config files copied",
"init_success": "AstrBot initialized successfully!",
"init_failed": "Initialization failed: {error}",
# Run command
"run_starting": "Starting AstrBot...",
"run_started": "AstrBot started!",
"run_backend_only": "Starting in backend-only mode",
"run_failed": "Failed to start: {error}",
"run_stopped": "AstrBot stopped",
# Common
"yes": "Yes",
"no": "No",
"cancel": "Cancel",
"confirm": "Confirm",
"error": "Error",
"success": "Success",
"warning": "Warning",
"info": "Info",
"loading": "Loading...",
"done": "Done",
"failed": "Failed",
"retry": "Retry",
"exit": "Exit",
"continue": "Continue",
},
}
@lru_cache(maxsize=1)
def get_current_language() -> Language:
"""Get the current language based on environment or default.
Detection order:
1. ASTRBOT_CLI_LANG environment variable (zh/en)
2. LANG environment variable (if contains zh/cn)
3. LC_ALL environment variable (if contains zh/cn)
4. Default to Chinese (most users are Chinese)
"""
# Check explicit override first
explicit = os.environ.get("ASTRBOT_CLI_LANG", "").lower()
if explicit in ("zh", "en"):
return Language.ZH if explicit == "zh" else Language.EN
# Check LANG/LC_ALL for Chinese
for env_var in ("LANG", "LC_ALL"):
lang = os.environ.get(env_var, "").lower()
if "zh" in lang or "cn" in lang:
return Language.ZH
# Default to Chinese for broader appeal
return Language.ZH
def set_language(lang: Language) -> None:
"""Set the current language (clears all translation caches)."""
get_current_language.cache_clear()
_t_cached.cache_clear()
# Set environment variable for persistence
os.environ["ASTRBOT_CLI_LANG"] = lang.value
@lru_cache(maxsize=128)
def _t_cached(key: str, lang: Language) -> str:
"""Cached translation lookup."""
return _TRANSLATIONS.get(lang, {}).get(key, key)
def t(translation_key: str, **kwargs: str) -> str:
"""Get translation for the given key in the current language.
Args:
translation_key: Translation key (e.g., "cli_welcome", "plugin_installing")
**kwargs: Format arguments for the translation string
Returns:
Translated string, or the key itself if not found
"""
result = _t_cached(translation_key, get_current_language())
if kwargs:
result = result.format(**kwargs)
return result
def tr(key: str, **kwargs: str) -> str:
"""Get translation (alias for t())."""
return t(key, **kwargs)
class CLITranslations:
"""Translation accessor class for CLI contexts.
Usage:
translations = CLITranslations()
print(translations.cli_welcome)
print(translations.plugin_installing(name="my_plugin"))
"""
def __getattr__(self, key: str) -> str:
return t(key)
def __call__(self, key: str, **kwargs: str) -> str:
return t(key, **kwargs)
# Convenience instance
translations = CLITranslations()

View File

@@ -1,12 +1,18 @@
from .dashboard import DashboardManager
from .basic import (
check_astrbot_root,
check_dashboard,
get_astrbot_root,
)
from .plugin import PluginStatus, build_plug_list, get_git_repo, manage_plugin
from .version_comparator import VersionComparator
__all__ = [
"DashboardManager",
"PluginStatus",
"VersionComparator",
"build_plug_list",
"check_astrbot_root",
"check_dashboard",
"get_astrbot_root",
"get_git_repo",
"manage_plugin",
]

View File

@@ -42,6 +42,7 @@ async def check_dashboard(astrbot_root: Path) -> None:
if click.confirm(
"Install dashboard?",
default=True,
abort=True,
):
click.echo("Installing dashboard...")
await download_dashboard(

View File

@@ -1,79 +0,0 @@
import sys
from importlib import resources
from pathlib import Path
import click
from astrbot.cli.i18n import t
from .version_comparator import VersionComparator
class DashboardManager:
_bundled_dist = resources.files("astrbot") / "dashboard" / "dist"
async def ensure_installed(self, astrbot_root: Path) -> None:
"""Ensure the dashboard assets are installed and up to date."""
from astrbot.core.config.default import VERSION
from astrbot.core.utils.io import download_dashboard, get_dashboard_version
if self._bundled_dist.is_dir():
click.echo(t("dashboard_bundled"))
return
try:
dashboard_version = await get_dashboard_version()
match dashboard_version:
case None:
click.echo(t("dashboard_not_installed"))
# Skip interactive prompt in non-interactive environments
if not sys.stdin.isatty():
click.echo(t("dashboard_not_needed"))
return
if click.confirm(t("dashboard_install_confirm"), default=True):
click.echo(t("dashboard_installing"))
try:
await download_dashboard(
path="data/dashboard.zip",
extract_path=str(astrbot_root / "data"),
version=f"v{VERSION}",
latest=False,
)
click.echo(t("dashboard_install_success"))
except Exception as e:
click.echo(t("dashboard_install_failed", error=str(e)))
else:
click.echo(t("dashboard_declined"))
case str():
if (
VersionComparator.compare_version(VERSION, dashboard_version)
<= 0
):
click.echo(t("dashboard_already_up_to_date"))
return
try:
version = dashboard_version.split("v")[1]
click.echo(t("dashboard_version", version=version))
await download_dashboard(
path="data/dashboard.zip",
extract_path=str(astrbot_root / "data"),
version=f"v{VERSION}",
latest=False,
)
except Exception as e:
click.echo(t("dashboard_download_failed", error=str(e)))
return
except FileNotFoundError:
click.echo(t("dashboard_init_dir"))
try:
await download_dashboard(
path=str(astrbot_root / "data" / "dashboard.zip"),
extract_path=str(astrbot_root / "data"),
version=f"v{VERSION}",
latest=False,
)
click.echo(t("dashboard_init_success"))
except Exception as e:
click.echo(t("dashboard_download_failed", error=str(e)))
return

View File

@@ -3,7 +3,6 @@ import tempfile
from enum import Enum
from io import BytesIO
from pathlib import Path
from typing import Any
from zipfile import ZipFile
import click
@@ -33,7 +32,7 @@ def get_git_repo(url: str, target_path: Path, proxy: str | None = None) -> None:
release_url = f"https://api.github.com/repos/{author}/{repo}/releases"
try:
with httpx.Client(
proxy=proxy or None,
proxy=proxy if proxy else None,
follow_redirects=True,
) as client:
resp = client.get(release_url)
@@ -57,7 +56,7 @@ def get_git_repo(url: str, target_path: Path, proxy: str | None = None) -> None:
# Download and extract
with httpx.Client(
proxy=proxy or None,
proxy=proxy if proxy else None,
follow_redirects=True,
) as client:
resp = client.get(download_url)
@@ -84,7 +83,7 @@ def get_git_repo(url: str, target_path: Path, proxy: str | None = None) -> None:
shutil.rmtree(temp_dir, ignore_errors=True)
def load_yaml_metadata(plugin_dir: Path) -> dict[str, Any]:
def load_yaml_metadata(plugin_dir: Path) -> dict:
"""Load plugin metadata from metadata.yaml file
Args:
@@ -97,10 +96,7 @@ def load_yaml_metadata(plugin_dir: Path) -> dict[str, Any]:
yaml_path = plugin_dir / "metadata.yaml"
if yaml_path.exists():
try:
data = yaml.safe_load(yaml_path.read_text(encoding="utf-8"))
if isinstance(data, dict):
return dict[str, Any](data)
return {}
return yaml.safe_load(yaml_path.read_text(encoding="utf-8")) or {}
except Exception as e:
click.echo(f"Failed to read {yaml_path}: {e}", err=True)
return {}
@@ -176,8 +172,8 @@ def build_plug_list(plugins_dir: Path) -> list:
)
if (
VersionComparator.compare_version(
local_plugin["version"] or "",
online_plugin["version"] or "",
local_plugin["version"],
online_plugin["version"],
)
< 0
):
@@ -189,10 +185,7 @@ def build_plug_list(plugins_dir: Path) -> list:
# Add uninstalled online plugins
for online_plugin in online_plugins:
if not any(plugin["name"] == online_plugin["name"] for plugin in result):
clean: dict[str, str] = {
k: v for k, v in online_plugin.items() if v is not None
}
result.append(clean)
result.append(online_plugin)
return result
@@ -226,7 +219,7 @@ def manage_plugin(
# Check if plugin exists
if is_update and not target_path.exists():
raise click.ClickException(
f"Plugin {plugin_name} is not installed and cannot be updated",
f"Plugin {plugin_name} is not installed and cannot be updated"
)
# Backup existing plugin
@@ -245,7 +238,7 @@ def manage_plugin(
if is_update and backup_path is not None and backup_path.exists():
shutil.rmtree(backup_path)
click.echo(
f"Plugin {plugin_name} {'updated' if is_update else 'installed'} successfully",
f"Plugin {plugin_name} {'updated' if is_update else 'installed'} successfully"
)
except Exception as e:
if target_path.exists():
@@ -254,4 +247,4 @@ def manage_plugin(
shutil.move(backup_path, target_path)
raise click.ClickException(
f"Error {'updating' if is_update else 'installing'} plugin {plugin_name}: {e}",
) from e
)

View File

@@ -62,9 +62,12 @@ class VersionComparator:
return -1
if isinstance(p1, str) and isinstance(p2, int):
return 1
if (isinstance(p1, int) and isinstance(p2, int)) or (
isinstance(p1, str) and isinstance(p2, str)
):
if isinstance(p1, int) and isinstance(p2, int):
if p1 > p2:
return 1
if p1 < p2:
return -1
elif isinstance(p1, str) and isinstance(p2, str):
if p1 > p2:
return 1
if p1 < p2:

View File

@@ -22,29 +22,11 @@ from astrbot.core.utils.requirements_utils import (
from astrbot.core.utils.shared_preferences import SharedPreferences
from astrbot.core.utils.t2i.renderer import HtmlRenderer
from .log import LogBroker, LogManager
from .utils.astrbot_path import (
get_astrbot_config_path,
get_astrbot_data_path,
get_astrbot_knowledge_base_path,
get_astrbot_plugin_path,
get_astrbot_site_packages_path,
get_astrbot_skills_path,
get_astrbot_temp_path,
)
from .log import LogBroker, LogManager # noqa
from .utils.astrbot_path import get_astrbot_data_path
# Initialize required data directories eagerly so later agent/tool flows do not
# fail on missing paths when the runtime root resolves to a fresh location.
for required_dir in (
get_astrbot_data_path(),
get_astrbot_config_path(),
get_astrbot_plugin_path(),
get_astrbot_temp_path(),
get_astrbot_knowledge_base_path(),
get_astrbot_skills_path(),
get_astrbot_site_packages_path(),
):
os.makedirs(required_dir, exist_ok=True)
# 初始化数据存储文件夹
os.makedirs(get_astrbot_data_path(), exist_ok=True)
DEMO_MODE = os.getenv("DEMO_MODE", "False").strip().lower() in ("true", "1", "t")
@@ -52,11 +34,7 @@ astrbot_config = AstrBotConfig()
t2i_base_url = astrbot_config.get("t2i_endpoint", "https://t2i.soulter.top/text2img")
html_renderer = HtmlRenderer(t2i_base_url)
logger = LogManager.GetLogger(log_name="astrbot")
LogManager.configure_logger(
logger,
astrbot_config,
override_level=os.getenv("ASTRBOT_LOG_LEVEL"),
)
LogManager.configure_logger(logger, astrbot_config)
LogManager.configure_trace_logger(astrbot_config)
db_helper = SQLiteDatabase(DB_PATH)
# 简单的偏好设置存储, 这里后续应该存储到数据库中, 一些部分可以存储到配置中
@@ -67,17 +45,3 @@ pip_installer = PipInstaller(
astrbot_config.get("pip_install_arg", ""),
astrbot_config.get("pypi_index_url", None),
)
__all__ = [
"DEMO_MODE",
"AstrBotConfig",
"LogBroker",
"LogManager",
"astrbot_config",
"db_helper",
"file_token_service",
"html_renderer",
"logger",
"pip_installer",
"sp",
"t2i_base_url",
]

View File

@@ -1,11 +1,6 @@
from typing import TYPE_CHECKING, Protocol, runtime_checkable
from ...provider.modalities import (
log_context_sanitize_stats,
sanitize_contexts_by_modalities,
)
from ..message import Message
from .token_counter import EstimateTokenCounter, TokenCounter
if TYPE_CHECKING:
from astrbot import logger
@@ -101,58 +96,83 @@ class TruncateByTurnsCompressor:
return truncated_messages
def _extract_system_messages(messages: list[Message]) -> list[Message]:
"""Return the leading system messages from a message list."""
result = []
for msg in messages:
if msg.role == "system":
result.append(msg)
else:
def split_history(
messages: list[Message], keep_recent: int
) -> tuple[list[Message], list[Message], list[Message]]:
"""Split the message list into system messages, messages to summarize, and recent messages.
Ensures that the split point is between complete user-assistant pairs to maintain conversation flow.
Args:
messages: The original message list.
keep_recent: The number of latest messages to keep.
Returns:
tuple: (system_messages, messages_to_summarize, recent_messages)
"""
# keep the system messages
first_non_system = 0
for i, msg in enumerate(messages):
if msg.role != "system":
first_non_system = i
break
return result
system_messages = messages[:first_non_system]
non_system_messages = messages[first_non_system:]
if len(non_system_messages) <= keep_recent:
return system_messages, [], non_system_messages
# Find the split point, ensuring recent_messages starts with a user message
# This maintains complete conversation turns
split_index = len(non_system_messages) - keep_recent
# Search backward from split_index to find the first user message
# This ensures recent_messages starts with a user message (complete turn)
while split_index > 0 and non_system_messages[split_index].role != "user":
# TODO: +=1 or -=1 ? calculate by tokens
split_index -= 1
# If we couldn't find a user message, keep all messages as recent
if split_index == 0:
return system_messages, [], non_system_messages
messages_to_summarize = non_system_messages[:split_index]
recent_messages = non_system_messages[split_index:]
return system_messages, messages_to_summarize, recent_messages
class LLMSummaryCompressor:
"""LLM-based summary compressor.
Uses LLM to summarize old conversation history while keeping a recent token
budget as exact context.
Uses LLM to summarize the old conversation history, keeping the latest messages.
"""
TASK_CONTINUATION_INSTRUCTION = (
"If a task appears to be in progress, end the summary with the latest "
"known result and the concrete next step to continue the task."
)
def __init__(
self,
provider: "Provider",
keep_recent_ratio: float = 0.15,
keep_recent: int = 4,
instruction_text: str | None = None,
compression_threshold: float = 0.82,
token_counter: TokenCounter | None = None,
) -> None:
"""Initialize the LLM summary compressor.
Args:
provider: The LLM provider instance.
keep_recent_ratio: Ratio of current context tokens to keep as recent
exact context. Clamped to 0-0.3.
keep_recent: The number of latest messages to keep (default: 4).
instruction_text: Custom instruction for summary generation.
compression_threshold: The compression trigger threshold (default: 0.82).
"""
self.provider = provider
self.keep_recent_ratio = min(max(float(keep_recent_ratio), 0.0), 0.3)
self.keep_recent = keep_recent
self.compression_threshold = compression_threshold
self.token_counter = token_counter or EstimateTokenCounter()
self.instruction_text = instruction_text or (
"Based on our full conversation history, produce a concise summary of key takeaways and/or project progress.\n"
"The primary goal of this summary is to enable seamless continuation of the work that follows.\n"
"1. Systematically cover all core topics discussed and the final conclusion/outcome for each; clearly highlight the latest primary focus.\n"
"2. If any tools were used, summarize tool usage (total call count) and extract the most valuable insights from tool outputs.\n"
"3. If any materials (files, documents, code, references) were read during the conversation that may be helpful for subsequent work, list each one with its scope and path.\n"
"4. If there was an initial user goal, state it first and describe the current progress/status.\n"
"5. Write the summary in the user's language.\n"
"3. If there was an initial user goal, state it first and describe the current progress/status.\n"
"4. Write the summary in the user's language.\n"
)
def should_compress(
@@ -173,120 +193,39 @@ class LLMSummaryCompressor:
usage_rate = current_tokens / max_tokens
return usage_rate > self.compression_threshold
def _split_recent_rounds_by_token_ratio(
self,
rounds: list[list[Message]],
total_tokens: int,
) -> tuple[list[list[Message]], list[list[Message]]]:
"""Split rounds into summarised history and exact recent context.
The token budget is computed from the current context token count and
`keep_recent_ratio`, then floored by `int(...)`. Mapping that budget to
rounds is round-granular: a positive ratio always preserves the latest
whole round, even if that round itself exceeds the budget. Earlier
rounds are added only while the accumulated recent rounds stay within
the budget. No round is split.
"""
if not rounds or self.keep_recent_ratio <= 0 or total_tokens <= 0:
return rounds, []
budget = max(1, int(total_tokens * self.keep_recent_ratio))
used = 0
recent_start = len(rounds)
for idx in range(len(rounds) - 1, -1, -1):
round_tokens = self.token_counter.count_tokens(rounds[idx])
if used > 0 and used + round_tokens > budget:
break
used += round_tokens
recent_start = idx
return rounds[:recent_start], rounds[recent_start:]
async def __call__(self, messages: list[Message]) -> list[Message]:
"""Use LLM to generate a summary of the conversation history.
Uses round-based splitting to preserve user-assistant turn boundaries.
On LLM failure, returns the original messages unchanged (caller should
fall back to truncation).
Process:
1. Divide messages: keep the system message and the latest N messages.
2. Send the old messages + the instruction message to the LLM.
3. Reconstruct the message list: [system message, summary message, latest messages].
"""
from .round_utils import split_into_rounds
if len(messages) <= self.keep_recent + 1:
return messages
rounds = split_into_rounds(messages)
message_rounds = [
[seg for seg in rnd if isinstance(seg, Message)] for rnd in rounds
]
total_tokens = self.token_counter.count_tokens(messages)
old_rounds, recent_rounds = self._split_recent_rounds_by_token_ratio(
message_rounds,
total_tokens,
system_messages, messages_to_summarize, recent_messages = split_history(
messages, self.keep_recent
)
# The latest user message is the active request. Keep its whole round
# exact even when the ratio is 0 or the ratio budget would otherwise
# summarize every round.
if messages and messages[-1].role == "user" and old_rounds:
latest_old_round = old_rounds[-1]
if latest_old_round and latest_old_round[-1] is messages[-1]:
old_rounds = old_rounds[:-1]
recent_rounds = [latest_old_round, *recent_rounds]
if not messages_to_summarize:
return messages
if not old_rounds:
if recent_rounds and messages and messages[-1].role == "user":
return messages
old_rounds = message_rounds
recent_rounds = []
# build payload
instruction_message = Message(role="user", content=self.instruction_text)
llm_payload = messages_to_summarize + [instruction_message]
summary_contexts = [msg for rnd in old_rounds for msg in rnd]
if not any(msg.role != "system" for msg in summary_contexts):
if recent_rounds and messages and messages[-1].role == "user":
return messages
old_rounds = message_rounds
recent_rounds = []
summary_contexts = [msg for rnd in old_rounds for msg in rnd]
if not any(msg.role != "system" for msg in summary_contexts):
return messages
if summary_contexts[-1].role != "assistant":
summary_contexts.append(
Message(
role="assistant",
content="Acknowledged.",
)
)
summary_contexts.append(
Message(
role="user",
content=(
"Generate a summary of our previous conversation history.\n"
f"<extra_instruction>\n{self.instruction_text}\n\n"
f"{self.TASK_CONTINUATION_INSTRUCTION}</extra_instruction>\n"
"Respond ONLY with the summary content, without any additional text or formatting."
),
)
)
sanitized_summary_contexts, sanitize_stats = sanitize_contexts_by_modalities(
summary_contexts,
self.provider.provider_config.get("modalities", None),
)
log_context_sanitize_stats(sanitize_stats)
# Generate summary
# generate summary
try:
response = await self.provider.text_chat(
contexts=sanitized_summary_contexts,
)
summary_content = (response.completion_text or "").strip()
response = await self.provider.text_chat(contexts=llm_payload)
summary_content = response.completion_text
except Exception as e:
logger.error(f"Failed to generate summary: {e}")
return messages
if not summary_content:
logger.warning("LLM context compression returned an empty summary.")
return messages
# Build result: system messages + summary pair + recent rounds
result = _extract_system_messages(messages)
# build result
result = []
result.extend(system_messages)
result.append(
Message(
@@ -301,10 +240,6 @@ class LLMSummaryCompressor:
)
)
# Flatten recent rounds back to message list
for rnd in recent_rounds:
for seg in rnd:
if isinstance(seg, Message):
result.append(seg)
result.extend(recent_messages)
return result

View File

@@ -25,8 +25,8 @@ class ContextConfig:
"""
llm_compress_instruction: str | None = None
"""Instruction prompt for LLM-based compression."""
llm_compress_keep_recent_ratio: float = 0.15
"""Percent of current context tokens to keep as exact recent context during LLM-based compression."""
llm_compress_keep_recent: int = 0
"""Number of recent messages to keep during LLM-based compression."""
llm_compress_provider: "Provider | None" = None
"""LLM provider used for compression tasks. If None, truncation strategy is used."""
custom_token_counter: TokenCounter | None = None

View File

@@ -1,6 +1,6 @@
from astrbot import logger
from astrbot.core.agent.message import Message
from ..message import Message
from .compressor import LLMSummaryCompressor, TruncateByTurnsCompressor
from .config import ContextConfig
from .token_counter import EstimateTokenCounter
@@ -22,7 +22,6 @@ class ContextManager:
Args:
config: The context configuration.
"""
self.config = config
@@ -34,19 +33,16 @@ class ContextManager:
elif config.llm_compress_provider:
self.compressor = LLMSummaryCompressor(
provider=config.llm_compress_provider,
keep_recent_ratio=config.llm_compress_keep_recent_ratio,
keep_recent=config.llm_compress_keep_recent,
instruction_text=config.llm_compress_instruction,
token_counter=self.token_counter,
)
else:
self.compressor = TruncateByTurnsCompressor(
truncate_turns=config.truncate_turns,
truncate_turns=config.truncate_turns
)
async def process(
self,
messages: list[Message],
trusted_token_usage: int = 0,
self, messages: list[Message], trusted_token_usage: int = 0
) -> list[Message]:
"""Process the messages.
@@ -55,7 +51,6 @@ class ContextManager:
Returns:
The processed message list.
"""
try:
result = messages
@@ -71,14 +66,11 @@ class ContextManager:
# 2. 基于 token 的压缩
if self.config.max_context_tokens > 0:
total_tokens = self.token_counter.count_tokens(
result,
trusted_token_usage,
result, trusted_token_usage
)
if self.compressor.should_compress(
result,
total_tokens,
self.config.max_context_tokens,
result, total_tokens, self.config.max_context_tokens
):
result = await self._run_compression(result, total_tokens)
@@ -88,11 +80,10 @@ class ContextManager:
return messages
async def _run_compression(
self,
messages: list[Message],
prev_tokens: int,
self, messages: list[Message], prev_tokens: int
) -> list[Message]:
"""Compress/truncate the messages.
"""
Compress/truncate the messages.
Args:
messages: The original message list.
@@ -100,7 +91,6 @@ class ContextManager:
Returns:
The compressed/truncated message list.
"""
logger.debug("Compress triggered, starting compression...")
@@ -119,12 +109,10 @@ class ContextManager:
# last check
if self.compressor.should_compress(
messages,
tokens_after_summary,
self.config.max_context_tokens,
messages, tokens_after_summary, self.config.max_context_tokens
):
logger.info(
"Context still exceeds max tokens after compression, applying halving truncation...",
"Context still exceeds max tokens after compression, applying halving truncation..."
)
# still need compress, truncate by half
messages = self.truncator.truncate_by_halving(messages)

View File

@@ -1,72 +0,0 @@
"""Round-based utilities shared by LTM compaction and LLMSummaryCompressor."""
import json
from collections.abc import Sequence
from typing import Any
from ..message import ContentPart, Message, ToolCall
RoundSegment = dict[str, Any] | Message
def _segment_role(seg: RoundSegment) -> str:
if isinstance(seg, Message):
return seg.role
return str(seg.get("role", "?"))
def split_into_rounds(
contexts: Sequence[RoundSegment],
) -> list[list[RoundSegment]]:
"""Split a flat contexts list into logical rounds.
A round begins at a ``user`` segment and includes all subsequent
``assistant`` / ``tool`` segments until the next ``user`` segment.
"""
rounds: list[list[RoundSegment]] = []
current: list[RoundSegment] = []
for seg in contexts:
if _segment_role(seg) == "user" and current:
rounds.append(current)
current = []
current.append(seg)
if current:
rounds.append(current)
return rounds
def _content_to_text(content: Any) -> str:
if isinstance(content, list):
normalized = [
part.model_dump_for_context() if isinstance(part, ContentPart) else part
for part in content
]
return json.dumps(normalized, ensure_ascii=False)
if isinstance(content, ContentPart):
return json.dumps(content.model_dump_for_context(), ensure_ascii=False)
return str(content or "")
def _segment_content(seg: RoundSegment) -> Any:
if isinstance(seg, Message):
if seg.content is not None:
return seg.content
if seg.tool_calls:
return [
tc.model_dump() if isinstance(tc, ToolCall) else tc
for tc in seg.tool_calls
]
return ""
return seg.get("content") or seg.get("tool_calls") or ""
def rounds_to_text(rounds: list[list[RoundSegment]]) -> str:
"""Render rounds into a plain-text string for LLM summarisation."""
lines: list[str] = []
for i, rnd in enumerate(rounds, 1):
lines.append(f"--- Round {i} ---")
for seg in rnd:
role = _segment_role(seg)
content = _content_to_text(_segment_content(seg))
lines.append(f"[{role}] {content}")
return "\n".join(lines)

View File

@@ -1,25 +1,18 @@
import json
from typing import Protocol, runtime_checkable
from astrbot.core.agent.message import (
AudioURLPart,
ImageURLPart,
Message,
TextPart,
ThinkPart,
)
from ..message import AudioURLPart, ImageURLPart, Message, TextPart, ThinkPart
@runtime_checkable
class TokenCounter(Protocol):
"""Protocol for token counters.
"""
Protocol for token counters.
Provides an interface for counting tokens in message lists.
"""
def count_tokens(
self,
messages: list[Message],
trusted_token_usage: int = 0,
self, messages: list[Message], trusted_token_usage: int = 0
) -> int:
"""Count the total tokens in the message list.
@@ -31,14 +24,13 @@ class TokenCounter(Protocol):
Returns:
The total token count.
"""
...
# 图片/音频 token 开销估算值,参考 OpenAI vision pricing:
# low-res ~85 tokens, high-res ~170 per 512px tile, 通常几百到上千
# 这里取一个保守中位数,宁可偏高触发压缩也不要偏低导致 API 报错
# 图片/音频 token 开销估算值参考 OpenAI vision pricing:
# low-res ~85 tokens, high-res ~170 per 512px tile, 通常几百到上千
# 这里取一个保守中位数宁可偏高触发压缩也不要偏低导致 API 报错
IMAGE_TOKEN_ESTIMATE = 765
AUDIO_TOKEN_ESTIMATE = 500
@@ -52,9 +44,7 @@ class EstimateTokenCounter:
"""
def count_tokens(
self,
messages: list[Message],
trusted_token_usage: int = 0,
self, messages: list[Message], trusted_token_usage: int = 0
) -> int:
if trusted_token_usage > 0:
return trusted_token_usage

View File

@@ -1,4 +1,4 @@
from astrbot.core.agent.message import Message
from ..message import Message
class ContextTruncator:
@@ -20,7 +20,6 @@ class ContextTruncator:
Returns:
tuple: (system_messages, non_system_messages)
"""
first_non_system = 0
for i, msg in enumerate(messages):
@@ -35,44 +34,19 @@ class ContextTruncator:
truncated: list[Message],
original_messages: list[Message],
) -> list[Message]:
"""Ensure the result always contains a `user` message immediately after
system messages, as required by some LLM APIs.
Optimization strategy:
- If `truncated` already begins with a `user` message, return it as-is.
- If a `user` message exists later in `truncated`, move that message to
be the first non-system message while preserving the relative order of
the remaining truncated messages (without mutating the original list).
- Otherwise, fall back to the first `user` message from
`original_messages`.
This reduces unnecessary duplication and ensures the required ordering.
"""Ensure the result always contains the first user message right after
system messages. This is required by many LLM APIs (e.g. Zhipu) that
mandate a ``user`` message immediately following the ``system`` message.
"""
if truncated and truncated[0].role == "user":
return system_messages + truncated
# If a user message exists inside the truncated list, promote it to the front.
index_in_truncated = next(
(i for i, m in enumerate(truncated) if m.role == "user"),
None,
)
if index_in_truncated is not None:
# Build a new truncated list that places the found user message first,
# preserving the order of the other messages and avoiding in-place mutation.
user_msg = truncated[index_in_truncated]
new_truncated = [
user_msg,
*truncated[:index_in_truncated],
*truncated[index_in_truncated + 1 :],
]
return system_messages + new_truncated
# Fallback: find the first user message in the original messages.
# Locate the first user message from the *original* list.
first_user = next((m for m in original_messages if m.role == "user"), None)
if first_user is None:
# No user messages at all; return system messages + whatever was truncated.
return system_messages + truncated
return [*system_messages, first_user, *truncated]
return system_messages + [first_user] + truncated
def fix_messages(self, messages: list[Message]) -> list[Message]:
"""Fix the message list to ensure the validity of tool call and tool response pairing.
@@ -129,7 +103,8 @@ class ContextTruncator:
keep_most_recent_turns: int,
drop_turns: int = 1,
) -> list[Message]:
"""Turn-based truncation strategy, which drops the oldest turns while keeping the most recent N turns.
"""
Turn-based truncation strategy, which drops the oldest turns while keeping the most recent N turns.
A turn consists of a user message and an assistant message.
This method ensures that the truncated context list conforms to OpenAI's context format.
@@ -140,7 +115,6 @@ class ContextTruncator:
Returns:
The truncated list of messages.
"""
if keep_most_recent_turns == -1:
return messages
@@ -165,9 +139,7 @@ class ContextTruncator:
truncated_contexts = truncated_contexts[index:]
result = self._ensure_user_message(
system_messages,
truncated_contexts,
messages,
system_messages, truncated_contexts, messages
)
return self.fix_messages(result)
@@ -196,9 +168,7 @@ class ContextTruncator:
truncated_non_system = truncated_non_system[index:]
result = self._ensure_user_message(
system_messages,
truncated_non_system,
messages,
system_messages, truncated_non_system, messages
)
return self.fix_messages(result)
@@ -227,8 +197,6 @@ class ContextTruncator:
truncated_non_system = truncated_non_system[index:]
result = self._ensure_user_message(
system_messages,
truncated_non_system,
messages,
system_messages, truncated_non_system, messages
)
return self.fix_messages(result)

View File

@@ -1,19 +1,11 @@
"""MCP client
This file exists solely for backward compatibility and will be removed in a future version.
"""
import asyncio
import copy
import logging
import os
import re
import sys
from contextlib import AsyncExitStack
from datetime import timedelta
from pathlib import Path, PureWindowsPath
from typing import Any, Generic, TextIO
from typing import Generic
import httpx
from tenacity import (
before_sleep_log,
retry,
@@ -22,128 +14,28 @@ from tenacity import (
wait_exponential,
)
from astrbot import logger
from astrbot.core.agent.run_context import ContextWrapper
from astrbot.core.utils.log_pipe import LogPipe
from .run_context import TContext
from .tool import FunctionTool
logger = logging.getLogger("astrbot")
_DEFAULT_STDIO_COMMAND_ALLOWLIST = frozenset(
{
"python",
"python3",
"py",
"node",
"npx",
"npm",
"pnpm",
"yarn",
"bun",
"bunx",
"deno",
"uv",
"uvx",
}
)
_DENIED_STDIO_COMMANDS = frozenset(
{
"bash",
"sh",
"zsh",
"fish",
"cmd",
"cmd.exe",
"powershell",
"powershell.exe",
"pwsh",
"pwsh.exe",
"osascript",
"open",
"curl",
"wget",
"nc",
"netcat",
"telnet",
"ssh",
"scp",
"rm",
"mv",
"cp",
"dd",
"mkfs",
"sudo",
"su",
"chmod",
"chown",
"kill",
"killall",
"shutdown",
"reboot",
"poweroff",
"halt",
}
)
_SHELL_META_RE = re.compile(r"[\r\n\x00;&|<>`$]")
_PYTHON_INLINE_CODE_FLAGS = frozenset({"-c"})
_JS_INLINE_CODE_FLAGS = frozenset({"-e", "--eval", "-p", "--print"})
_DENIED_DOCKER_ARGS = frozenset(
{
"--privileged",
"--pid=host",
"--network=host",
"--net=host",
"--ipc=host",
}
)
_STDIO_ALLOWLIST_ENV = "ASTRBOT_MCP_STDIO_ALLOWED_COMMANDS"
try:
import anyio
import mcp
from mcp.client.sse import sse_client
except (ModuleNotFoundError, ImportError):
logger.warning(
"Warning: Missing 'mcp' dependency, MCP services will be unavailable.",
"Warning: Missing 'mcp' dependency, MCP services will be unavailable."
)
streamable_http_client_legacy = None
streamable_http_client = None
try:
from mcp.client.streamable_http import (
streamablehttp_client as streamable_http_client_legacy,
)
from mcp.client.streamable_http import streamablehttp_client
except (ModuleNotFoundError, ImportError):
try:
from mcp.client.streamable_http import (
streamable_http_client as streamable_http_client,
)
except (ModuleNotFoundError, ImportError):
logger.warning(
"Warning: Missing 'mcp' dependency or MCP library version too old, Streamable HTTP connection unavailable.",
)
class TenacityLogger:
"""Wraps a logging.Logger to satisfy tenacity's LoggerProtocol."""
__slots__ = ("_logger",)
_logger: logging.Logger
def __init__(self, logger: logging.Logger) -> None:
self._logger = logger
def log(
self,
level: int,
msg: str,
/,
*args: Any,
**kwargs: Any,
) -> None:
self._logger.log(level, msg, *args, **kwargs)
logger.warning(
"Warning: Missing 'mcp' dependency or MCP library version too old, Streamable HTTP connection unavailable.",
)
def _prepare_config(config: dict) -> dict:
@@ -155,117 +47,6 @@ def _prepare_config(config: dict) -> dict:
return config
def _normalize_stdio_command_name(command: str) -> str:
command = command.strip()
if "\\" in command:
command_name = PureWindowsPath(command).name
else:
command_name = Path(command).name
command_name = command_name.lower()
for suffix in (".exe", ".cmd", ".bat"):
if command_name.endswith(suffix):
return command_name[: -len(suffix)]
return command_name
def _get_stdio_command_allowlist() -> set[str]:
allowed = set(_DEFAULT_STDIO_COMMAND_ALLOWLIST)
configured = os.environ.get(_STDIO_ALLOWLIST_ENV, "")
if configured.strip():
allowed = {
_normalize_stdio_command_name(item)
for item in configured.split(",")
if item.strip()
}
return allowed
def _validate_stdio_args(command_name: str, args: object) -> None:
if args is None:
return
if not isinstance(args, list) or not all(isinstance(arg, str) for arg in args):
raise ValueError("MCP stdio args must be a list of strings.")
for arg in args:
if "\x00" in arg or "\r" in arg or "\n" in arg:
raise ValueError("MCP stdio args cannot contain control characters.")
if command_name.startswith("python") or command_name == "py":
if any(
arg == "-c"
or (arg.startswith("-") and not arg.startswith("--") and "c" in arg)
for arg in args
):
raise ValueError(
"MCP stdio Python servers must be launched from a module or file; inline code flags such as -c are not allowed."
)
elif command_name in {"node", "deno", "bun"} or command_name.startswith("node"):
if any(
arg in _JS_INLINE_CODE_FLAGS
or arg == "eval"
or (
arg.startswith("-")
and not arg.startswith("--")
and any(c in arg for c in "ep")
)
for arg in args
):
raise ValueError(
"MCP stdio JavaScript servers must be launched from a package or file; inline eval flags are not allowed."
)
elif command_name == "docker":
denied = []
for i, arg in enumerate(args):
if arg in _DENIED_DOCKER_ARGS:
denied.append(arg)
elif (
arg in {"--network", "--net", "--pid", "--ipc"}
and i + 1 < len(args)
and args[i + 1] == "host"
):
denied.append(f"{arg} {args[i + 1]}")
if denied:
raise ValueError(
f"MCP stdio Docker args are unsafe and not allowed: {', '.join(denied)}."
)
def validate_mcp_stdio_config(config: dict) -> None:
"""Validate MCP stdio configuration in a backward-compatible way."""
cfg = _prepare_config(config.copy())
if "url" in cfg:
return
command = cfg.get("command")
if not isinstance(command, str) or not command.strip():
raise ValueError("MCP stdio server requires a non-empty command.")
if _SHELL_META_RE.search(command):
raise ValueError("MCP stdio command contains unsafe shell metacharacters.")
command_name = _normalize_stdio_command_name(command)
if command_name in _DENIED_STDIO_COMMANDS:
raise ValueError(f"MCP stdio command `{command_name}` is not allowed.")
allowed = _get_stdio_command_allowlist()
if command_name not in allowed:
allowed_display = ", ".join(sorted(allowed))
raise ValueError(
f"MCP stdio command `{command_name}` is not allowed. "
f"Allowed commands: {allowed_display}. "
f"Set {_STDIO_ALLOWLIST_ENV} to override this list if you trust another launcher."
)
_validate_stdio_args(command_name, cfg.get("args"))
env = cfg.get("env")
if env is not None and not isinstance(env, dict):
raise ValueError("MCP stdio env must be an object.")
if isinstance(env, dict) and not all(
isinstance(key, str) and isinstance(value, str) for key, value in env.items()
):
raise ValueError("MCP stdio env keys and values must be strings.")
def _prepare_stdio_env(config: dict) -> dict:
"""Preserve Windows executable resolution for stdio subprocesses."""
if sys.platform != "win32":
@@ -278,10 +59,10 @@ def _prepare_stdio_env(config: dict) -> dict:
def _merge_environment_variables(env: dict) -> dict:
"""Merge environment variables in case-insensitive systems."""
"""合并环境变量处理Windows不区分大小写的情况"""
merged = env.copy()
# Use lower-case keys for case-insensitive matching on Windows.
# 将用户环境变量转换为统一的大小写形式便于比较
user_keys_lower = {k.lower(): k for k in merged.keys()}
for sys_key, sys_value in os.environ.items():
@@ -349,65 +130,12 @@ async def _quick_test_mcp_connection(config: dict) -> tuple[bool, str]:
return True, ""
return False, f"HTTP {response.status}: {response.reason}"
except TimeoutError:
except asyncio.TimeoutError:
return False, f"Connection timeout: {timeout} seconds"
except Exception as e:
return False, f"{e!s}"
def _normalize_mcp_input_schema(schema: dict[str, Any]) -> dict[str, Any]:
"""Normalize common non-standard MCP JSON Schema variants.
Some MCP servers incorrectly mark required properties with a boolean
`required: true` on the property schema itself. Draft 2020-12 requires the
parent object to declare `required` as an array of property names instead.
We lift those booleans to the parent object so the schema remains usable
without disabling validation entirely.
"""
def _normalize(node: Any) -> Any:
if isinstance(node, list):
return [_normalize(item) for item in node]
if not isinstance(node, dict):
return node
normalized = {key: _normalize(value) for key, value in node.items()}
properties = normalized.get("properties")
if isinstance(properties, dict):
original_properties = node.get("properties")
if not isinstance(original_properties, dict):
original_properties = {}
required = normalized.get("required")
required_list = required[:] if isinstance(required, list) else []
for prop_name, prop_schema in properties.items():
if not isinstance(prop_schema, dict):
continue
original_prop_schema = (original_properties or {}).get(prop_name, {})
prop_required = (
original_prop_schema.get("required")
if isinstance(original_prop_schema, dict)
else None
)
if isinstance(prop_required, bool):
if prop_schema.get("required") is prop_required:
prop_schema.pop("required", None)
if prop_required:
required_list.append(prop_name)
if required_list:
normalized["required"] = list(dict.fromkeys(required_list))
elif isinstance(required, list):
normalized.pop("required", None)
return normalized
return _normalize(copy.deepcopy(schema))
class MCPClient:
def __init__(self) -> None:
# Initialize session and client objects
@@ -420,7 +148,6 @@ class MCPClient:
self.tools: list[mcp.Tool] = []
self.server_errlogs: list[str] = []
self.running_event = asyncio.Event()
self.process_pid: int | None = None
# Store connection config for reconnection
self._mcp_server_config: dict | None = None
@@ -428,24 +155,6 @@ class MCPClient:
self._reconnect_lock = asyncio.Lock() # Lock for thread-safe reconnection
self._reconnecting: bool = False # For logging and debugging
@staticmethod
def _extract_stdio_process_pid(streams_context: object) -> int | None:
"""Best-effort extraction for stdio subprocess PID used by lease cleanup.
TODO(refactor): replace this async-generator frame introspection with a
stable MCP library hook once the upstream transport exposes process PID.
"""
generator = getattr(streams_context, "gen", None)
frame = getattr(generator, "ag_frame", None)
if frame is None:
return None
process = frame.f_locals.get("process")
pid = getattr(process, "pid", None)
try:
return int(pid) if pid is not None else None
except (TypeError, ValueError):
return None
async def connect_to_server(self, mcp_server_config: dict, name: str) -> None:
"""Connect to MCP server
@@ -461,17 +170,17 @@ class MCPClient:
# Store config for reconnection
self._mcp_server_config = mcp_server_config
self._server_name = name
self.process_pid = None
cfg = _prepare_config(mcp_server_config.copy())
async def logging_callback(
params: mcp.types.LoggingMessageNotificationParams,
def logging_callback(
msg: str | mcp.types.LoggingMessageNotificationParams,
) -> None:
# Handle MCP service error logs
if params.level in ("warning", "error", "critical", "alert", "emergency"):
log_msg = f"[{params.level.upper()}] {params.data!s}"
self.server_errlogs.append(log_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)
@@ -493,69 +202,45 @@ class MCPClient:
timeout=cfg.get("timeout", 5),
sse_read_timeout=cfg.get("sse_read_timeout", 60 * 5),
)
read_stream, write_stream = await self.exit_stack.enter_async_context(
streams = await self.exit_stack.enter_async_context(
self._streams_context,
)
# Create a new client session
read_timeout = timedelta(seconds=cfg.get("session_read_timeout", 60))
session = await self.exit_stack.enter_async_context(
self.session = await self.exit_stack.enter_async_context(
mcp.ClientSession(
read_stream=read_stream,
write_stream=write_stream,
*streams,
read_timeout_seconds=read_timeout,
logging_callback=logging_callback,
logging_callback=logging_callback, # type: ignore
),
)
self.session = session
else:
timeout_seconds = cfg.get("timeout", 30)
sse_read_timeout_seconds = cfg.get("sse_read_timeout", 60 * 5)
if streamable_http_client_legacy:
timeout = timedelta(seconds=timeout_seconds)
sse_read_timeout = timedelta(seconds=sse_read_timeout_seconds)
self._streams_context = streamable_http_client_legacy(
url=cfg["url"],
headers=cfg.get("headers", {}),
timeout=timeout,
sse_read_timeout=sse_read_timeout,
terminate_on_close=cfg.get("terminate_on_close", True),
)
elif streamable_http_client:
http_client = await self.exit_stack.enter_async_context(
httpx.AsyncClient(
headers=cfg.get("headers", {}),
timeout=httpx.Timeout(
timeout_seconds,
read=sse_read_timeout_seconds,
),
follow_redirects=True,
),
)
self._streams_context = streamable_http_client(
url=cfg["url"],
http_client=http_client,
terminate_on_close=cfg.get("terminate_on_close", True),
)
else:
raise RuntimeError(
"Streamable HTTP transport is not available in the installed MCP library version."
)
timeout = timedelta(seconds=cfg.get("timeout", 30))
sse_read_timeout = timedelta(
seconds=cfg.get("sse_read_timeout", 60 * 5),
)
self._streams_context = streamablehttp_client(
url=cfg["url"],
headers=cfg.get("headers", {}),
timeout=timeout,
sse_read_timeout=sse_read_timeout,
terminate_on_close=cfg.get("terminate_on_close", True),
)
read_s, write_s, _ = await self.exit_stack.enter_async_context(
self._streams_context,
)
# Create a new client session
read_timeout = timedelta(seconds=cfg.get("session_read_timeout", 60))
session = await self.exit_stack.enter_async_context(
self.session = await self.exit_stack.enter_async_context(
mcp.ClientSession(
read_stream=read_s,
write_stream=write_s,
read_timeout_seconds=read_timeout,
logging_callback=logging_callback,
logging_callback=logging_callback, # type: ignore
),
)
self.session = session
else:
cfg = _prepare_stdio_env(cfg)
@@ -573,35 +258,25 @@ class MCPClient:
"alert",
"emergency",
):
log_msg = f"[{msg.level.upper()}] {msg.data!s}"
log_msg = f"[{msg.level.upper()}] {str(msg.data)}"
self.server_errlogs.append(log_msg)
log_pipe = self.exit_stack.enter_context(
LogPipe(
level=logging.INFO,
logger=logger,
identifier=f"MCPServer-{name}",
callback=callback,
),
)
errlog_stream: TextIO = self.exit_stack.enter_context(
os.fdopen(os.dup(log_pipe.fileno()), "w"),
)
stdio_transport = await self.exit_stack.enter_async_context(
mcp.stdio_client(
server_params,
errlog=errlog_stream,
errlog=LogPipe(
level=logging.INFO,
logger=logger,
identifier=f"MCPServer-{name}",
callback=callback,
), # type: ignore
),
)
self.process_pid = self._extract_stdio_process_pid(stdio_transport)
# Create a new client session
session = await self.exit_stack.enter_async_context(
self.session = await self.exit_stack.enter_async_context(
mcp.ClientSession(*stdio_transport),
)
self.session = session
assert self.session is not None
await self.session.initialize()
async def list_tools_and_save(self) -> mcp.ListToolsResult:
@@ -619,13 +294,12 @@ class MCPClient:
Raises:
Exception: raised when reconnection fails
"""
async with self._reconnect_lock:
# Check if already reconnecting (useful for logging)
if self._reconnecting:
logger.debug(
f"MCP Client {self._server_name} is already reconnecting, skipping",
f"MCP Client {self._server_name} is already reconnecting, skipping"
)
return
@@ -635,7 +309,7 @@ class MCPClient:
self._reconnecting = True
try:
logger.info(
f"Attempting to reconnect to MCP server {self._server_name}...",
f"Attempting to reconnect to MCP server {self._server_name}..."
)
# Save old exit_stack for later cleanup (don't close it now to avoid cancel scope issues)
@@ -653,11 +327,11 @@ class MCPClient:
await self.list_tools_and_save()
logger.info(
f"Successfully reconnected to MCP server {self._server_name}",
f"Successfully reconnected to MCP server {self._server_name}"
)
except Exception as e:
logger.error(
f"Failed to reconnect to MCP server {self._server_name}: {e}",
f"Failed to reconnect to MCP server {self._server_name}: {e}"
)
raise
finally:
@@ -682,14 +356,13 @@ class MCPClient:
Raises:
ValueError: MCP session is not available
anyio.ClosedResourceError: raised after reconnection failure
"""
@retry(
retry=retry_if_exception_type(anyio.ClosedResourceError),
stop=stop_after_attempt(2),
wait=wait_exponential(multiplier=1, min=1, max=3),
before_sleep=before_sleep_log(TenacityLogger(logger), logging.WARNING),
before_sleep=before_sleep_log(logger, logging.WARNING),
reraise=True,
)
async def _call_with_retry():
@@ -704,7 +377,7 @@ class MCPClient:
)
except anyio.ClosedResourceError:
logger.warning(
f"MCP tool {tool_name} call failed (ClosedResourceError), attempting to reconnect...",
f"MCP tool {tool_name} call failed (ClosedResourceError), attempting to reconnect..."
)
# Attempt to reconnect
await self._reconnect()
@@ -728,33 +401,25 @@ class MCPClient:
# Set running_event first to unblock any waiting tasks
self.running_event.set()
self.process_pid = None
class MCPTool(FunctionTool, Generic[TContext]):
"""A function tool that calls an MCP service."""
def __init__(
self,
mcp_tool: mcp.Tool,
mcp_client: MCPClient,
mcp_server_name: str,
**kwargs,
self, mcp_tool: mcp.Tool, mcp_client: MCPClient, mcp_server_name: str, **kwargs
) -> None:
super().__init__(
name=mcp_tool.name,
description=mcp_tool.description or "",
parameters=_normalize_mcp_input_schema(mcp_tool.inputSchema),
parameters=mcp_tool.inputSchema,
)
self.mcp_tool = mcp_tool
self.mcp_client = mcp_client
self.mcp_server_name = mcp_server_name
self.source = "mcp"
async def call(
self,
context: ContextWrapper[TContext],
**kwargs,
self, context: ContextWrapper[TContext], **kwargs
) -> mcp.types.CallToolResult:
return await self.mcp_client.call_tool_with_reconnect(
tool_name=self.mcp_tool.name,

View File

@@ -1,20 +1,17 @@
# Inspired by MoonshotAI/kosong, credits to MoonshotAI/kosong authors for the original implementation.
# License: Apache License 2.0
from typing import Any, ClassVar, Literal, Self, TypeVar, cast
from typing import Any, ClassVar, Literal, cast
from pydantic import (
BaseModel,
GetCoreSchemaHandler,
PrivateAttr,
ValidationError,
model_serializer,
model_validator,
)
from pydantic_core import core_schema
ContentPartT = TypeVar("ContentPartT", bound="ContentPart")
class ContentPart(BaseModel):
"""A part of the content in a message."""
@@ -22,7 +19,6 @@ class ContentPart(BaseModel):
__content_part_registry: ClassVar[dict[str, type["ContentPart"]]] = {}
type: Literal["text", "think", "image_url", "audio_url"]
_no_save: bool = PrivateAttr(default=False)
def __init_subclass__(cls, **kwargs: Any) -> None:
super().__init_subclass__(**kwargs)
@@ -37,9 +33,7 @@ class ContentPart(BaseModel):
@classmethod
def __get_pydantic_core_schema__(
cls,
source_type: Any,
handler: GetCoreSchemaHandler,
cls, source_type: Any, handler: GetCoreSchemaHandler
) -> core_schema.CoreSchema:
# If we're dealing with the base ContentPart class, use custom validation
if cls.__name__ == "ContentPart":
@@ -51,14 +45,11 @@ class ContentPart(BaseModel):
# if it's a dict with a type field, dispatch to the appropriate subclass
if isinstance(value, dict) and "type" in value:
type_value: Any | None = cast("dict[str, Any]", value).get("type")
type_value: Any | None = cast(dict[str, Any], value).get("type")
if not isinstance(type_value, str):
raise ValueError(f"Cannot validate {value} as ContentPart")
target_class = cls.__content_part_registry[type_value]
part = target_class.model_validate(value)
if cast("dict[str, Any]", value).get("_no_save"):
part._no_save = True
return part
return target_class.model_validate(value)
raise ValueError(f"Cannot validate {value} as ContentPart")
@@ -67,20 +58,10 @@ class ContentPart(BaseModel):
# for subclasses, use the default schema
return handler(source_type)
def mark_as_temp(self) -> Self:
"""Mark this content part as provider-facing only, not persisted."""
self._no_save = True
return self
def model_dump_for_context(self) -> dict[str, Any]:
data = self.model_dump()
if self._no_save:
data["_no_save"] = True
return data
class TextPart(ContentPart):
"""TextPart(text="Hello, world!").model_dump()
"""
>>> TextPart(text="Hello, world!").model_dump()
{'type': 'text', 'text': 'Hello, world!'}
"""
@@ -89,7 +70,8 @@ class TextPart(ContentPart):
class ThinkPart(ContentPart):
"""ThinkPart(think="I think I need to think about this.").model_dump()
"""
>>> ThinkPart(think="I think I need to think about this.").model_dump()
{'type': 'think', 'think': 'I think I need to think about this.', 'encrypted': None}
"""
@@ -110,7 +92,8 @@ class ThinkPart(ContentPart):
class ImageURLPart(ContentPart):
"""ImageURLPart(image_url="http://example.com/image.jpg").model_dump()
"""
>>> ImageURLPart(image_url="http://example.com/image.jpg").model_dump()
{'type': 'image_url', 'image_url': 'http://example.com/image.jpg'}
"""
@@ -125,7 +108,8 @@ class ImageURLPart(ContentPart):
class AudioURLPart(ContentPart):
"""AudioURLPart(audio_url=AudioURLPart.AudioURL(url="https://example.com/audio.mp3")).model_dump()
"""
>>> AudioURLPart(audio_url=AudioURLPart.AudioURL(url="https://example.com/audio.mp3")).model_dump()
{'type': 'audio_url', 'audio_url': {'url': 'https://example.com/audio.mp3', 'id': None}}
"""
@@ -140,9 +124,10 @@ class AudioURLPart(ContentPart):
class ToolCall(BaseModel):
"""A tool call requested by the assistant.
"""
A tool call requested by the assistant.
ToolCall(
>>> ToolCall(
... id="123",
... function=ToolCall.FunctionBody(
... name="function",
@@ -180,15 +165,6 @@ class ToolCallPart(BaseModel):
"""A part of the arguments of the tool call."""
class CheckpointData(BaseModel):
"""Internal checkpoint data for linking LLM turns to platform history."""
id: str
CHECKPOINT_ROLE = "_checkpoint"
class Message(BaseModel):
"""A message in a conversation."""
@@ -197,10 +173,9 @@ class Message(BaseModel):
"user",
"assistant",
"tool",
"_checkpoint",
]
content: str | list[ContentPart] | CheckpointData | None = None
content: str | list[ContentPart] | None = None
"""The content of the message."""
tool_calls: list[ToolCall] | list[dict] | None = None
@@ -210,18 +185,9 @@ class Message(BaseModel):
"""The ID of the tool call."""
_no_save: bool = PrivateAttr(default=False)
_checkpoint_after: CheckpointData | None = PrivateAttr(default=None)
@model_validator(mode="after")
def check_content_required(self):
if self.role == CHECKPOINT_ROLE:
if not isinstance(self.content, CheckpointData):
raise ValueError("checkpoint message content must be CheckpointData")
return self
if isinstance(self.content, CheckpointData):
raise ValueError("CheckpointData is only allowed for role='_checkpoint'")
# assistant + tool_calls is not None: allow content to be None
if self.role == "assistant" and self.tool_calls is not None:
return self
@@ -229,7 +195,7 @@ class Message(BaseModel):
# other all cases: content is required
if self.content is None:
raise ValueError(
"content is required unless role='assistant' and tool_calls is not None",
"content is required unless role='assistant' and tool_calls is not None"
)
return self
@@ -265,96 +231,3 @@ class SystemMessageSegment(Message):
"""A message segment from the system."""
role: Literal["system"] = "system"
class CheckpointMessageSegment(Message):
"""Internal checkpoint segment for persisted conversation history."""
role: Literal["_checkpoint"] = "_checkpoint"
content: CheckpointData | None = None
def is_checkpoint_message(message: Message | dict) -> bool:
"""Return whether a message is an internal checkpoint."""
if isinstance(message, Message):
return message.role == CHECKPOINT_ROLE
return isinstance(message, dict) and message.get("role") == CHECKPOINT_ROLE
def get_checkpoint_id(message: Message | dict) -> str | None:
"""Return the checkpoint id from an internal checkpoint message."""
if not is_checkpoint_message(message):
return None
content = (
message.content if isinstance(message, Message) else message.get("content")
)
if isinstance(content, CheckpointData):
return content.id
if isinstance(content, dict):
checkpoint_id = content.get("id")
return (
checkpoint_id if isinstance(checkpoint_id, str) and checkpoint_id else None
)
return None
def strip_checkpoint_messages(history: list[dict]) -> list[dict]:
"""Remove internal checkpoint messages from provider-facing history."""
return [message for message in history if not is_checkpoint_message(message)]
def _get_checkpoint_data(message: Message | dict) -> CheckpointData | None:
if not is_checkpoint_message(message):
return None
content = (
message.content if isinstance(message, Message) else message.get("content")
)
if isinstance(content, CheckpointData):
return content
if isinstance(content, dict):
try:
return CheckpointData.model_validate(content)
except ValidationError:
return None
return None
def bind_checkpoint_messages(history: list[dict]) -> list[Message]:
"""Load persisted history and bind checkpoint segments to prior messages."""
messages: list[Message] = []
for item in history:
if is_checkpoint_message(item):
checkpoint = _get_checkpoint_data(item)
if checkpoint is not None and messages:
messages[-1]._checkpoint_after = checkpoint
continue
message = Message.model_validate(item)
if item.get("_no_save"):
message._no_save = True
messages.append(message)
return messages
def dump_messages_with_checkpoints(messages: list[Message]) -> list[dict]:
"""Dump runtime messages and reinsert bound checkpoint segments."""
dumped: list[dict] = []
for message in messages:
message_data = message.model_dump()
if isinstance(message.content, list):
message_data["content"] = [
part.model_dump()
for part in message.content
if not getattr(part, "_no_save", False)
]
dumped.append(message_data)
if message._checkpoint_after is not None:
dumped.append(
CheckpointMessageSegment(
content=message._checkpoint_after,
).model_dump(),
)
return dumped

View File

@@ -1,4 +1,4 @@
from typing import Any, Generic, cast
from typing import Any, Generic
from pydantic import Field
from pydantic.dataclasses import dataclass
@@ -13,7 +13,7 @@ TContext = TypeVar("TContext", default=Any)
class ContextWrapper(Generic[TContext]):
"""A context for running an agent, which can be used to pass additional data or state."""
context: TContext = cast("TContext", None)
context: TContext
messages: list[Message] = Field(default_factory=list)
"""This field stores the llm message context for the agent run, agent runners will maintain this field automatically."""
tool_call_timeout: int = 120 # Default tool call timeout in seconds

View File

@@ -1,16 +1,13 @@
import abc
import asyncio
from collections.abc import AsyncGenerator
import typing as T
from enum import Enum, auto
from typing import Any, Generic
from astrbot import logger
from astrbot.core.agent.hooks import BaseAgentRunHooks
from astrbot.core.agent.response import AgentResponse
from astrbot.core.agent.run_context import ContextWrapper, TContext
from astrbot.core.agent.tool_executor import BaseFunctionToolExecutor
from astrbot.core.provider.entities import LLMResponse, ProviderRequest
from astrbot.core.provider.provider import Provider
from astrbot.core.provider.entities import LLMResponse
from ..hooks import BaseAgentRunHooks
from ..response import AgentResponse
from ..run_context import ContextWrapper, TContext
class AgentState(Enum):
@@ -22,33 +19,13 @@ class AgentState(Enum):
ERROR = auto() # Error state
class BaseAgentRunner(Generic[TContext]):
def __init__(
self,
):
self.tasks: set[asyncio.Task[object]] = set()
self._state = AgentState.IDLE
class BaseAgentRunner(T.Generic[TContext]):
@abc.abstractmethod
async def reset(
self,
provider: Provider,
request: ProviderRequest,
run_context: ContextWrapper[TContext],
tool_executor: BaseFunctionToolExecutor[TContext],
agent_hooks: BaseAgentRunHooks[TContext],
streaming: bool = False,
enforce_max_turns: int = -1,
llm_compress_instruction: str | None = None,
llm_compress_keep_recent: int = 0,
llm_compress_provider: Provider | None = None,
truncate_turns: int = 1,
custom_token_counter: Any = None,
custom_compressor: Any = None,
tool_schema_mode: str | None = "full",
fallback_providers: list[Provider] | None = None,
provider_config: dict | None = None,
**kwargs: Any,
**kwargs: T.Any,
) -> None:
"""Reset the agent to its initial state.
This method should be called before starting a new run.
@@ -56,12 +33,14 @@ class BaseAgentRunner(Generic[TContext]):
...
@abc.abstractmethod
def step(self) -> AsyncGenerator[AgentResponse, None]:
async def step(self) -> T.AsyncGenerator[AgentResponse, None]:
"""Process a single step of the agent."""
...
@abc.abstractmethod
def step_until_done(self, max_step: int) -> AsyncGenerator[AgentResponse, None]:
async def step_until_done(
self, max_step: int
) -> T.AsyncGenerator[AgentResponse, None]:
"""Process steps until the agent is done."""
...

View File

@@ -1,25 +1,28 @@
import base64
import json
from typing import Any, override
import sys
import typing as T
import astrbot.core.message.components as Comp
from astrbot import logger
from astrbot.core import sp
from astrbot.core.agent.hooks import BaseAgentRunHooks
from astrbot.core.agent.message import is_checkpoint_message
from astrbot.core.agent.response import AgentResponse, AgentResponseData
from astrbot.core.agent.run_context import ContextWrapper, TContext
from astrbot.core.agent.runners.base import AgentState, BaseAgentRunner
from astrbot.core.agent.tool_executor import BaseFunctionToolExecutor
from astrbot.core.message.message_event_result import MessageChain
from astrbot.core.provider.entities import (
LLMResponse,
ProviderRequest,
)
from astrbot.core.provider.provider import Provider
from ...hooks import BaseAgentRunHooks
from ...response import AgentResponseData
from ...run_context import ContextWrapper, TContext
from ..base import AgentResponse, AgentState, BaseAgentRunner
from .coze_api_client import CozeAPIClient
if sys.version_info >= (3, 12):
from typing import override
else:
from typing_extensions import override
class CozeAgentRunner(BaseAgentRunner[TContext]):
"""Coze Agent Runner"""
@@ -27,45 +30,32 @@ class CozeAgentRunner(BaseAgentRunner[TContext]):
@override
async def reset(
self,
provider: Provider,
request: ProviderRequest,
run_context: ContextWrapper[TContext],
tool_executor: BaseFunctionToolExecutor[TContext],
agent_hooks: BaseAgentRunHooks[TContext],
streaming: bool = False,
enforce_max_turns: int = -1,
llm_compress_instruction: str | None = None,
llm_compress_keep_recent: int = 0,
llm_compress_provider: Provider | None = None,
truncate_turns: int = 1,
custom_token_counter: Any = None,
custom_compressor: Any = None,
tool_schema_mode: str | None = "full",
fallback_providers: list[Provider] | None = None,
provider_config: dict | None = None,
**kwargs: Any,
provider_config: dict,
**kwargs: T.Any,
) -> None:
self.req = request
self.streaming = streaming
self.streaming = kwargs.get("streaming", False)
self.final_llm_resp = None
self._state = AgentState.IDLE
self.agent_hooks = agent_hooks
self.run_context = run_context
provider_config = provider_config or {}
self.api_key = provider_config.get("coze_api_key", "")
if not self.api_key:
raise Exception("Coze API Key 不能为空")
raise Exception("Coze API Key 不能为空")
self.bot_id = provider_config.get("bot_id", "")
if not self.bot_id:
raise Exception("Coze Bot ID 不能为空")
raise Exception("Coze Bot ID 不能为空")
self.api_base: str = provider_config.get("coze_api_base", "https://api.coze.cn")
if not isinstance(self.api_base, str) or not self.api_base.startswith(
("http://", "https://"),
):
raise Exception(
"Coze API Base URL 格式不正确,必须以 http:// 或 https:// 开头",
"Coze API Base URL 格式不正确必须以 http:// 或 https:// 开头",
)
self.timeout = provider_config.get("timeout", 120)
@@ -81,7 +71,9 @@ class CozeAgentRunner(BaseAgentRunner[TContext]):
@override
async def step(self):
"""执行 Coze Agent 的一个步骤"""
"""
执行 Coze Agent 的一个步骤
"""
if not self.req:
raise ValueError("Request is not set. Please call reset() first.")
@@ -91,7 +83,7 @@ class CozeAgentRunner(BaseAgentRunner[TContext]):
except Exception as e:
logger.error(f"Error in on_agent_begin hook: {e}", exc_info=True)
# 开始处理,转换到运行状态
# 开始处理转换到运行状态
self._transition_state(AgentState.RUNNING)
try:
@@ -99,23 +91,24 @@ class CozeAgentRunner(BaseAgentRunner[TContext]):
async for response in self._execute_coze_request():
yield response
except Exception as e:
logger.error(f"Coze 请求失败:{e!s}")
logger.error(f"Coze 请求失败{str(e)}")
self._transition_state(AgentState.ERROR)
self.final_llm_resp = LLMResponse(
role="err",
completion_text=f"Coze 请求失败:{e!s}",
role="err", completion_text=f"Coze 请求失败:{str(e)}"
)
yield AgentResponse(
type="err",
data=AgentResponseData(
chain=MessageChain().message(f"Coze 请求失败:{e!s}"),
chain=MessageChain().message(f"Coze 请求失败{str(e)}")
),
)
finally:
await self.api_client.close()
@override
async def step_until_done(self, max_step: int):
async def step_until_done(
self, max_step: int = 30
) -> T.AsyncGenerator[AgentResponse, None]:
while not self.done():
async for resp in self.step():
yield resp
@@ -155,13 +148,11 @@ class CozeAgentRunner(BaseAgentRunner[TContext]):
# 处理历史上下文
if not self.auto_save_history and contexts:
for ctx in contexts:
if is_checkpoint_message(ctx):
continue
if isinstance(ctx, dict) and "role" in ctx and "content" in ctx:
# 处理上下文中的图片
content = ctx["content"]
if isinstance(content, list):
# 多模态内容,需要处理图片
# 多模态内容需要处理图片
processed_content = []
for item in content:
if isinstance(item, dict):
@@ -175,8 +166,7 @@ class CozeAgentRunner(BaseAgentRunner[TContext]):
if url:
file_id = (
await self._download_and_upload_image(
url,
session_id,
url, session_id
)
)
processed_content.append(
@@ -184,7 +174,7 @@ class CozeAgentRunner(BaseAgentRunner[TContext]):
"type": "file",
"file_id": file_id,
"file_url": url,
},
}
)
except Exception as e:
logger.warning(f"处理上下文图片失败: {e}")
@@ -196,7 +186,7 @@ class CozeAgentRunner(BaseAgentRunner[TContext]):
"role": ctx["role"],
"content": processed_content,
"content_type": "object_string",
},
}
)
else:
# 纯文本内容
@@ -205,7 +195,7 @@ class CozeAgentRunner(BaseAgentRunner[TContext]):
"role": ctx["role"],
"content": content,
"content_type": "text",
},
}
)
# 构建当前消息
@@ -225,7 +215,7 @@ class CozeAgentRunner(BaseAgentRunner[TContext]):
{
"type": "image",
"file_id": file_id,
},
}
)
except Exception as e:
logger.warning(f"处理图片失败 {url}: {e}")
@@ -238,7 +228,7 @@ class CozeAgentRunner(BaseAgentRunner[TContext]):
"role": "user",
"content": content,
"content_type": "object_string",
},
}
)
elif prompt:
# 纯文本
@@ -287,12 +277,12 @@ class CozeAgentRunner(BaseAgentRunner[TContext]):
accumulated_content += content
message_started = True
# 如果是流式响应,发送增量数据
# 如果是流式响应发送增量数据
if self.streaming:
yield AgentResponse(
type="streaming_delta",
data=AgentResponseData(
chain=MessageChain().message(content),
chain=MessageChain().message(content)
),
)
@@ -338,7 +328,7 @@ class CozeAgentRunner(BaseAgentRunner[TContext]):
image_url: str,
session_id: str | None = None,
) -> str:
"""下载图片并上传到 Coze,返回 file_id"""
"""下载图片并上传到 Coze返回 file_id"""
import hashlib
# 计算哈希实现缓存
@@ -359,13 +349,13 @@ class CozeAgentRunner(BaseAgentRunner[TContext]):
if session_id:
self.file_id_cache[session_id][cache_key] = file_id
logger.debug(f"[Coze] 图片上传成功并缓存,file_id: {file_id}")
logger.debug(f"[Coze] 图片上传成功并缓存file_id: {file_id}")
return file_id
except Exception as e:
logger.error(f"处理图片失败 {image_url}: {e!s}")
raise Exception(f"处理图片失败: {e!s}") from e
raise Exception(f"处理图片失败: {e!s}")
@override
def done(self) -> bool:

Some files were not shown because too many files have changed in this diff Show More