Compare commits

...

67 Commits

Author SHA1 Message Date
Soulter
bf3fbe3e96 fix: workflow job dependency 2025-05-04 19:52:27 +08:00
Soulter
0a93d22bc8 📦 release: v3.5.6 2025-05-04 12:46:40 +08:00
Raven95676
f5b3d94d16 fix: 修正thinking_config 2025-05-02 15:36:07 +08:00
Raven95676
4d1a6994aa fix: 保证Gemini anyOf 字段唯一 2025-05-02 10:56:05 +08:00
Raven95676
05c686782c Merge remote-tracking branch 'origin/master' 2025-05-02 10:51:01 +08:00
Raven95676
85609ea742 feat: 支持Gemini思考设置 2025-05-02 10:49:45 +08:00
Soulter
20dabc0615 Merge pull request #1333 from LIghtJUNction/master
Feature: 新增CLI命令行程序
2025-05-01 20:53:58 +08:00
Soulter
356dd9bc2b cd: upload to pypi 2025-05-01 20:48:11 +08:00
Soulter
cd5d7534c4 chore: imporove help message 2025-05-01 20:35:10 +08:00
LIghtJUNction
b4f12fc933 feat: supports CLI mode
Squashed by:

STEP1 - 新增CLI命令行程序

🎨 style: improve code style and some typo fixes

remove: llms.txt
2025-05-01 20:32:05 +08:00
Soulter
cbea387ce0 Merge pull request #1445 from AstrBotDevs/fix-download-file
Improve: 优化 QQ 下自动下载文件的问题
2025-05-01 20:15:06 +08:00
Soulter
345b155374 Merge pull request #1447 from anka-afk/1446-bug-mcp
fix: mcp 服务器页面搜索功能无法使用: 在前端实现搜索
2025-05-01 14:08:54 +08:00
Soulter
29d216950e Merge pull request #1427 from AstrBotDevs/fix-gewechat
Improve: 优化 Gewechat 下文件回调逻辑
2025-05-01 12:54:03 +08:00
anka
321b04772c refactor: 🍩将本地路径和url分离, 需要本地文件时提供下载接口, 同时向前兼容 2025-05-01 01:16:30 +08:00
anka
5b924aee98 Merge remote-tracking branch 'origin/1360-featurereset' into 1446-bug-mcp 2025-04-30 23:53:52 +08:00
anka
46d44e3405 fix: 🧩在前端实现mcp服务器的搜索 2025-04-30 23:52:55 +08:00
Raven95676
4d5332fe25 fix: 处理旧版本不存在ws_reverse_token的情况 2025-04-30 22:39:54 +08:00
Raven95676
18bd4c54f4 fix: 修正判断逻辑 2025-04-30 22:31:56 +08:00
Soulter
31c7768ca0 🎈 perf: 优化 QQ 下自动下载文件的问题 2025-04-30 21:47:14 +08:00
Raven95676
6ec643e9d1 fix: add self.lock 2025-04-30 00:51:49 +08:00
Soulter
2b39f6f61c Merge pull request #1426 from Raven95676/aiocqhttp-token
feat: 添加aiocqhttp对Token设置的支持
2025-04-30 00:04:52 +08:00
Soulter
bf3ca13961 Update astrbot/core/platform/sources/gewechat/client.py
Co-authored-by: 渡鸦95676 <Raven95676@gmail.com>
2025-04-30 00:03:21 +08:00
Soulter
82026370ec feat: 插件支持基于 Star 和 updated_at 排序 2025-04-29 11:17:00 +08:00
Soulter
6d49bf5346 fix: 修正 _handle_file 方法下的变量名 2025-04-28 23:49:36 +08:00
Soulter
67431d87fb fix: gewechat file 2025-04-28 23:31:45 +08:00
Raven95676
fdf55221e6 feat: 添加aiocqhttp对Token设置的支持 2025-04-28 22:14:51 +08:00
Soulter
07f277dd3b Merge pull request #1321 from XiGuang/master
bug: 修复私聊中接收引用消息无法准确获取用户昵称的问题
2025-04-26 23:21:22 +08:00
Soulter
cf8f0603ca 🐛 fix: gewechat 去除强制忽略自身消息的逻辑
fixes: #1388
2025-04-26 22:57:41 +08:00
Soulter
5592408ab8 Merge pull request #1386 from Raven95676/feature/mcp-img
feat: 处理MCP返回ImageContent、EmbeddedResource的情况,提供简单fallback
2025-04-26 21:29:14 +08:00
Soulter
a01617b45c fix: OneBot v11 request 类事件 补全 session_id 的获取 2025-04-26 21:00:30 +08:00
Soulter
7abb4087b3 Update README.md 2025-04-26 19:50:30 +08:00
渡鸦95676
dff15cf27a Merge pull request #1383 from Raven95676/feature/tg-optional-command
feat: 允许用户自定义telegram适配器指令注册行为,优化命令注册机制
2025-04-25 09:40:44 +08:00
Soulter
aa858137e5 Merge pull request #1240 from BigFace123/master
bug: 修复gewechat在群组中无法获取被at人的wxid问题
2025-04-25 00:51:11 +08:00
Soulter
45cb143202 perf: 实现解析微信群聊下对其他人的 At 2025-04-25 00:46:40 +08:00
Soulter
7a9c6ab8c4 Merge pull request #1374 from Raven95676/fix/gemini-func
fix: Gemini保证偶数索引为用户消息,奇数索引为模型消息
2025-04-23 23:27:10 +08:00
Raven95676
e2c26c292d feat: 处理MCP返回ImageContent、EmbeddedResource的情况,提供简单fallback 2025-04-23 19:55:15 +08:00
Soulter
be7c3fd00e docs: update PR template 2025-04-23 16:31:59 +08:00
Soulter
7e5461a2cf Merge pull request #1362 from anka-afk/1360-featurereset
feat: 😽对reset在不同情况下的权限特殊处理, 使其兼容alter_cmd 🤠为new指令增加清理上下文选项, 默认为清理, 更符合直觉
2025-04-23 16:21:20 +08:00
Raven95676
6ee9010645 feat: 允许用户自定义telegram适配器指令注册行为,优化命令注册机制 2025-04-23 15:53:18 +08:00
Raven95676
a23d5be056 refactor: 减少嵌套条件和重复代码 2025-04-23 12:49:27 +08:00
Raven95676
97a6a1fdc2 feat: 保证第一条消息不为model 2025-04-23 12:20:18 +08:00
Raven95676
c8f567347b feat: 修改重排序逻辑为合并连续相同类型的消息 2025-04-23 11:52:22 +08:00
anka
74c1e7f69e fix: ⚒️ 仍然清除聊天增强记录 2025-04-23 11:24:17 +08:00
anka
15a5fc0cae fix: 🧩revert logic of new func 2025-04-23 09:56:48 +08:00
Raven95676
f07c54d47c style: 减少一层 intent
Co-authored-by: Soulter <37870767+Soulter@users.noreply.github.com>
2025-04-23 00:48:25 +08:00
Soulter
70446be108 perf: catching a more specific exception type instead
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-04-23 00:08:03 +08:00
Soulter
d6d21fca56 Merge pull request #1347 from kkjzio/master
bug: 修复aiocqhttp平台使用指令组时,如果使用文本中携带网址无法识别指令
2025-04-23 00:00:04 +08:00
Raven95676
8d7273924f fix: Gemini保证偶数索引为用户消息,奇数索引为模型消息 2025-04-22 22:12:03 +08:00
Soulter
ea64afbaa7 docs: Update FUNDING.yml 2025-04-22 19:12:40 +08:00
Soulter
45da9837ec docs: Create FUNDING.yml 2025-04-22 19:12:03 +08:00
Raven95676
8c19b7d163 chore: clean code,format 2025-04-22 17:52:25 +08:00
Raven95676
ab227a08d0 fix: 修复openai source中e的作用域问题 2025-04-22 11:50:47 +08:00
anka
40d6e77964 fix: 🫓使用enum代替字典后的一些修改 2025-04-22 11:16:24 +08:00
anka
9326e3f1b0 refactor: 使用enum代替字典
Co-authored-by: 渡鸦95676 <Raven95676@gmail.com>
2025-04-22 10:55:32 +08:00
kkjz
0e1eb3daf6 fix: 使用join方法优化相邻文本段合并 2025-04-21 20:56:18 +08:00
anka
05daac12ed refactor: 🍔降低复杂性 2025-04-21 12:35:08 +08:00
anka
c5b24b4764 feat: 🤠为new指令增加清理上下文选项, 默认为清理, 更符合直觉 2025-04-21 12:06:20 +08:00
anka
cc16548e5f feat: 😽对reset在不同情况下的权限特殊处理, 使其兼容alter_cmd 2025-04-21 11:56:12 +08:00
kkjz
0ae61d5865 fix: 修复生成text的Plain时文本为处理后的文本 2025-04-20 22:11:24 +08:00
kkjz
d3bd775a79 feat: 使用groupby来合并aiocqhttp连续的文本段 2025-04-20 18:09:04 +08:00
Soulter
d921b0f6bd 🎈 perf: 优化 gewechat 的引用消息解析 2025-04-20 16:00:59 +08:00
Soulter
0607b95df6 🎈 perf: 增强异常处理 2025-04-20 15:40:51 +08:00
kkjz
98427345cf bug: 修复aiocqhttp平台使用指令组时,如果使用文本中携带网址无法识别指令 2025-04-20 12:04:02 +08:00
XiGuang
95563c8659 bug fix: 更新引用嵌套消息解析逻辑,支持图片处理 2025-04-19 16:15:47 +08:00
XiGuang
d916fda04c feat: 增强消息处理逻辑,支持引用嵌套消息解析 2025-04-19 12:10:51 +08:00
XiGuang
afa1aa5d93 🐛 fix: 更新用户真实姓名获取逻辑,改为从用户信息中提取 2025-04-18 21:22:46 +08:00
BigFace123
7c1e8ce48c 添加gewechat被at人wxid获取,AstrBotMessage添加be_at_wxid字段 2025-04-12 10:17:42 +08:00
26 changed files with 1704 additions and 829 deletions

15
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1,15 @@
# These are supported funding model platforms
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon: # Replace with a single Patreon username
open_collective: astrbot
ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
polar: # Replace with a single Polar username
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
thanks_dev: # Replace with a single thanks.dev username
custom: ['https://afdian.com/a/astrbot_team']

View File

@@ -1,5 +1,5 @@
<!-- 如果有的话,指定这个 PR 要解决的 ISSUE -->
修复#XYZ
解决#XYZ
### Motivation
@@ -10,5 +10,10 @@
<!--简单解释你的改动-->
### Check
- [ ] 我的 Commit Message 符合良好的[规范](https://www.conventionalcommits.org/en/v1.0.0/#summary)
- [ ] 我新增/修复/优化的功能经过良好的测试
<!--如果分支被合并,您的代码将服务于数万名用户!在提交前,请核查一下几点内容-->
- [ ] 😊 我的 Commit Message 符合良好的[规范](https://www.conventionalcommits.org/en/v1.0.0/#summary)
- [ ] 👀 我的更改经过良好的测试
- [ ] 🤓 我确保没有引入新依赖库,或者引入了新依赖库的同时将其添加到了 `requirements.txt``pyproject.toml` 文件相应位置。
- [ ] 😮 我的更改没有引入恶意代码

View File

@@ -7,7 +7,7 @@ on:
name: Auto Release
jobs:
build:
build-and-publish-to-github-release:
runs-on: ubuntu-latest
permissions:
contents: write
@@ -28,8 +28,35 @@ jobs:
run: |
echo "changelog=changelogs/${{github.ref_name}}.md" >> "$GITHUB_ENV"
- name: Create Release
- name: Create GitHub Release
uses: ncipollo/release-action@v1
with:
bodyFile: ${{ env.changelog }}
artifacts: "dashboard/dist.zip"
artifacts: "dashboard/dist.zip"
build-and-publish-to-pypi:
# 构建并发布到 PyPI
runs-on: ubuntu-latest
needs: build-and-publish-to-github-release
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.10'
- name: Install uv
run: |
python -m pip install uv
- name: Build package
run: |
uv build
- name: Publish to PyPI
env:
UV_PUBLISH_TOKEN: ${{ secrets.PYPI_TOKEN }}
run: |
uv publish

1
.python-version Normal file
View File

@@ -0,0 +1 @@
3.10

View File

@@ -152,6 +152,8 @@ pre-commit install
## ✨ Demo
<details><summary>👉 点击展开多张 Demo 截图 👈</summary>
<div align='center'>
<img src="https://github.com/user-attachments/assets/4ee688d9-467d-45c8-99d6-368f9a8a92d8" width="600">
@@ -173,6 +175,9 @@ _✨ WebUI ✨_
</div>
</details>
## ❤️ Special Thanks
特别感谢所有 Contributors 和插件开发者对 AstrBot 的贡献 ❤️

238
astrbot/cli/__main__.py Normal file
View File

@@ -0,0 +1,238 @@
import asyncio
import os
import shutil
import sys
import click
from pathlib import Path
from astrbot.core.config.default import VERSION
logo_tmpl = r"""
___ _______.___________..______ .______ ______ .___________.
/ \ / | || _ \ | _ \ / __ \ | |
/ ^ \ | (----`---| |----`| |_) | | |_) | | | | | `---| |----`
/ /_\ \ \ \ | | | / | _ < | | | | | |
/ _____ \ .----) | | | | |\ \----.| |_) | | `--' | | |
/__/ \__\ |_______/ |__| | _| `._____||______/ \______/ |__|
"""
# utils
def _get_astrbot_root(path: str | None) -> Path:
"""获取astrbot根目录"""
match path:
case None:
match ASTRBOT_ROOT := os.getenv("ASTRBOT_ROOT"):
case None:
astrbot_root = Path.cwd() / "data"
case _:
astrbot_root = Path(ASTRBOT_ROOT).resolve()
case str():
astrbot_root = Path(path).resolve()
dot_astrbot = astrbot_root / ".astrbot"
if not dot_astrbot.exists():
if click.confirm(
f"运行前必须先执行初始化!请检查当前目录是否正确,回车以继续: {astrbot_root}",
default=True,
abort=True,
):
dot_astrbot.touch()
astrbot_root.mkdir(parents=True, exist_ok=True)
click.echo(f"Created {dot_astrbot}")
return astrbot_root
# 通过类型来验证先后,必须先获取 Path 对象才能对该目录进行检查
def _check_astrbot_root(astrbot_root: Path) -> None:
"""验证"""
dot_astrbot = astrbot_root / ".astrbot"
if not astrbot_root.exists():
click.echo(f"AstrBot root directory does not exist: {astrbot_root}")
click.echo("Please run 'astrbot init' to create the directory.")
sys.exit(1)
else:
click.echo(f"AstrBot root directory exists: {astrbot_root}")
if not dot_astrbot.exists():
click.echo(
"如果你确认这是 Astrbot root directory, 你需要在当前目录下创建一个 .astrbot 文件标记该目录为 AstrBot 的数据目录。"
)
if click.confirm(
f"请检查当前目录是否正确,确认正确请回车: {astrbot_root}",
default=True,
abort=True,
):
dot_astrbot.touch()
click.echo(f"Created {dot_astrbot}")
else:
click.echo(f"Welcome back! AstrBot root directory: {astrbot_root}")
async def _check_dashboard(astrbot_root: Path) -> None:
"""检查是否安装了dashboard"""
try:
from ..core.utils.io import get_dashboard_version, download_dashboard
except ImportError:
from astrbot.core.utils.io import get_dashboard_version, download_dashboard
try:
# 添加 create=True 参数以确保在初始化时不会抛出异常
dashboard_version = await get_dashboard_version()
match dashboard_version:
case None:
click.echo("未安装管理面板")
if click.confirm(
"是否安装管理面板?",
default=True,
abort=True,
):
click.echo("正在安装管理面板...")
# 确保使用 create=True 参数
await download_dashboard(
path="data/dashboard.zip", extract_path=str(astrbot_root)
)
click.echo("管理面板安装完成")
case str():
if dashboard_version == f"v{VERSION}":
click.echo("无需更新")
else:
try:
version = dashboard_version.split("v")[1]
click.echo(f"管理面板版本: {version}")
# 确保使用 create=True 参数
await download_dashboard(
path="data/dashboard.zip", extract_path=str(astrbot_root)
)
except Exception as e:
click.echo(f"下载管理面板失败: {e}")
return
except FileNotFoundError:
click.echo("初始化管理面板目录...")
# 初始化模式下,下载到指定位置
try:
await download_dashboard(
path=str(astrbot_root / "dashboard.zip"), extract_path=str(astrbot_root)
)
click.echo("管理面板初始化完成")
except Exception as e:
click.echo(f"下载管理面板失败: {e}")
return
@click.group(name="astrbot")
def cli() -> None:
"""The AstrBot CLI"""
click.echo(logo_tmpl)
click.echo("Welcome to AstrBot CLI!")
click.echo(f"AstrBot version: {VERSION}")
# region init
@cli.command()
@click.option("--path", "-p", help="AstrBot 数据目录")
@click.option("--force", "-f", is_flag=True, help="强制初始化")
def init(path: str | None, force: bool) -> None:
"""Initialize AstrBot"""
click.echo("Initializing AstrBot...")
astrbot_root = _get_astrbot_root(path)
if force:
if click.confirm(
"强制初始化会删除当前目录下的所有文件,是否继续?",
default=False,
abort=True,
):
click.echo("正在删除当前目录下的所有文件...")
shutil.rmtree(astrbot_root, ignore_errors=True)
_check_astrbot_root(astrbot_root)
click.echo(f"AstrBot root directory: {astrbot_root}")
if not astrbot_root.exists():
# 创建目录
astrbot_root.mkdir(parents=True, exist_ok=True)
click.echo(f"Created directory: {astrbot_root}")
else:
click.echo(f"Directory already exists: {astrbot_root}")
config_path: Path = astrbot_root / "config"
plugins_path: Path = astrbot_root / "plugins"
temp_path: Path = astrbot_root / "temp"
config_path.mkdir(parents=True, exist_ok=True)
plugins_path.mkdir(parents=True, exist_ok=True)
temp_path.mkdir(parents=True, exist_ok=True)
click.echo(f"Created directories: {config_path}, {plugins_path}, {temp_path}")
# 检查是否安装了dashboard
asyncio.run(_check_dashboard(astrbot_root))
# region run
@cli.command()
@click.option("--path", "-p", help="AstrBot 数据目录")
def run(path: str | None = None) -> None:
"""Run AstrBot"""
# 解析为绝对路径
try:
from ..core.log import LogBroker
from ..core import db_helper
from ..core.initial_loader import InitialLoader
except ImportError:
from astrbot.core.log import LogBroker
from astrbot.core import db_helper
from astrbot.core.initial_loader import InitialLoader
astrbot_root = _get_astrbot_root(path)
_check_astrbot_root(astrbot_root)
asyncio.run(_check_dashboard(astrbot_root))
log_broker = LogBroker()
db = db_helper
core_lifecycle = InitialLoader(db, log_broker)
try:
asyncio.run(core_lifecycle.start())
except KeyboardInterrupt:
click.echo("接收到退出信号,正在关闭 AstrBot...")
except Exception as e:
click.echo(f"运行时出现错误: {e}")
# region Basic
@cli.command(name="version")
def version() -> None:
"""Show the version of AstrBot"""
click.echo(f"AstrBot version: {VERSION}")
@cli.command()
@click.argument("command_name", required=False, type=str)
def help(command_name: str | None) -> None:
"""Show help information for commands
If COMMAND_NAME is provided, show detailed help for that command.
Otherwise, show general help information.
"""
ctx = click.get_current_context()
if command_name:
# 查找指定命令
command = cli.get_command(ctx, command_name)
if command:
# 显示特定命令的帮助信息
click.echo(command.get_help(ctx))
else:
click.echo(f"Unknown command: {command_name}")
sys.exit(1)
else:
# 显示通用帮助信息
click.echo(cli.get_help(ctx))
if __name__ == "__main__":
cli()

View File

@@ -2,7 +2,7 @@
如需修改配置,请在 `data/cmd_config.json` 中修改或者在管理面板中可视化修改。
"""
VERSION = "3.5.5"
VERSION = "3.5.6"
DB_PATH = "data/data_v3.db"
# 默认配置
@@ -140,6 +140,7 @@ CONFIG_METADATA_2 = {
"enable": False,
"ws_reverse_host": "0.0.0.0",
"ws_reverse_port": 6199,
"ws_reverse_token": "",
},
"gewechat(微信)": {
"id": "gwchat",
@@ -186,6 +187,9 @@ CONFIG_METADATA_2 = {
"start_message": "Hello, I'm AstrBot!",
"telegram_api_base_url": "https://api.telegram.org/bot",
"telegram_file_base_url": "https://api.telegram.org/file/bot",
"telegram_command_register": True,
"telegram_command_auto_refresh": True,
"telegram_command_register_interval": 300,
},
},
"items": {
@@ -194,6 +198,21 @@ CONFIG_METADATA_2 = {
"type": "string",
"hint": "如果你的网络环境为中国大陆,请在 `其他配置` 处设置代理或更改 api_base。",
},
"telegram_command_register": {
"description": "Telegram 命令注册",
"type": "bool",
"hint": "启用后AstrBot 将会自动注册 Telegram 命令。",
},
"telegram_command_auto_refresh": {
"description": "Telegram 命令自动刷新",
"type": "bool",
"hint": "启用后AstrBot 将会在运行时自动刷新 Telegram 命令。(单独设置此项无效)",
},
"telegram_command_register_interval": {
"description": "Telegram 命令自动刷新间隔",
"type": "int",
"hint": "Telegram 命令自动刷新间隔,单位为秒。",
},
"id": {
"description": "ID",
"type": "string",
@@ -240,6 +259,11 @@ CONFIG_METADATA_2 = {
"type": "int",
"hint": "aiocqhttp 适配器的反向 Websocket 端口。",
},
"ws_reverse_token": {
"description": "反向 Websocket Token",
"type": "string",
"hint": "aiocqhttp 适配器的反向 Websocket Token。未设置则不启用 Token 验证。",
},
"lark_bot_name": {
"description": "飞书机器人的名字",
"type": "string",
@@ -544,6 +568,9 @@ CONFIG_METADATA_2 = {
"sexually_explicit": "BLOCK_MEDIUM_AND_ABOVE",
"dangerous_content": "BLOCK_MEDIUM_AND_ABOVE",
},
"gm_thinking_config": {
"budget": 0,
},
},
"DeepSeek": {
"id": "deepseek_default",
@@ -777,6 +804,17 @@ CONFIG_METADATA_2 = {
},
},
},
"gm_thinking_config": {
"description": "Gemini思考设置",
"type": "object",
"items": {
"budget": {
"description": "思考预算",
"type": "int",
"hint": "模型应该生成的思考Token的数量设为0关闭思考。除gemini-2.5-flash外的模型会静默忽略此参数。",
},
},
},
"rag_options": {
"description": "RAG 选项",
"type": "object",

View File

@@ -26,10 +26,12 @@ import base64
import json
import os
import uuid
import asyncio
import typing as T
from enum import Enum
from pydantic.v1 import BaseModel
from astrbot.core.utils.io import download_image_by_url, file_to_base64
from astrbot.core import logger
from astrbot.core.utils.io import download_image_by_url, file_to_base64, download_file
class ComponentType(Enum):
@@ -407,17 +409,15 @@ class Reply(BaseMessageComponent):
id: T.Union[str, int]
"""所引用的消息 ID"""
chain: T.Optional[T.List["BaseMessageComponent"]] = []
"""引用的消息段列表"""
"""引用的消息段列表"""
sender_id: T.Optional[int] | T.Optional[str] = 0
"""引用的消息发送者 ID"""
"""引用的消息对应的发送者 ID"""
sender_nickname: T.Optional[str] = ""
"""引用的消息发送者昵称"""
"""引用的消息对应的发送者昵称"""
time: T.Optional[int] = 0
"""引用的消息发送时间"""
"""引用的消息发送时间"""
message_str: T.Optional[str] = ""
"""解析后的纯文本消息字符串"""
sender_str: T.Optional[str] = ""
"""被引用的消息纯文本"""
"""被引用的消息解析后的纯文本消息字符串"""
text: T.Optional[str] = ""
"""deprecated"""
@@ -554,15 +554,91 @@ class Unknown(BaseMessageComponent):
class File(BaseMessageComponent):
"""
目前此消息段只适配了 Napcat。
文件消息段
"""
type: ComponentType = "File"
name: T.Optional[str] = "" # 名字
file: T.Optional[str] = "" # url本地路径
_file: T.Optional[str] = "" # 本地路径
url: T.Optional[str] = "" # url
_downloaded: bool = False # 是否已经下载
def __init__(self, name: str, file: str):
super().__init__(name=name, file=file)
def __init__(self, name: str = "", file: str = "", url: str = ""):
super().__init__(name=name, _file=file, url=url)
@property
def file(self) -> str:
"""
获取文件路径如果文件不存在但有URL则同步下载文件
Returns:
str: 文件路径
"""
if self._file and os.path.exists(self._file):
return self._file
if self.url and not self._downloaded:
try:
loop = asyncio.get_event_loop()
if loop.is_running():
logger.warning(
"不可以在异步上下文中同步等待下载! 请使用 await get_file() 代替"
)
return ""
else:
# 等待下载完成
loop.run_until_complete(self._download_file())
if self._file and os.path.exists(self._file):
return self._file
except Exception as e:
logger.error(f"文件下载失败: {e}")
return ""
@file.setter
def file(self, value: str):
"""
向前兼容, 设置file属性, 传入的参数可能是文件路径或URL
Args:
value (str): 文件路径或URL
"""
if value.startswith("http://") or value.startswith("https://"):
self.url = value
else:
self._file = value
async def get_file(self) -> str:
"""
异步获取文件
To 插件开发者: 请注意在使用后清理下载的文件, 以免占用过多空间
Returns:
str: 文件路径
"""
if self._file and os.path.exists(self._file):
return self._file
if self.url:
await self._download_file()
return self._file
return ""
async def _download_file(self):
"""下载文件"""
if self._downloaded:
return
os.makedirs("data/download", exist_ok=True)
filename = self.name or f"{uuid.uuid4().hex}"
file_path = f"data/download/{filename}"
await download_file(self.url, file_path)
self._file = file_path
self._downloaded = True
class WechatEmoji(BaseMessageComponent):

View File

@@ -26,6 +26,13 @@ from astrbot.core.provider.entities import (
)
from astrbot.core.star.star_handler import star_handlers_registry, EventType
from astrbot.core.star.star import star_map
from mcp.types import (
TextContent,
ImageContent,
EmbeddedResource,
TextResourceContents,
BlobResourceContents,
)
class LLMRequestSubStage(Stage):
@@ -66,9 +73,9 @@ class LLMRequestSubStage(Stage):
if event.get_extra("provider_request"):
req = event.get_extra("provider_request")
assert isinstance(
req, ProviderRequest
), "provider_request 必须是 ProviderRequest 类型。"
assert isinstance(req, ProviderRequest), (
"provider_request 必须是 ProviderRequest 类型。"
)
if req.conversation:
all_contexts = json.loads(req.conversation.history)
@@ -149,7 +156,14 @@ class LLMRequestSubStage(Stage):
-(self.max_context_length - self.dequeue_context_length + 1) * 2 :
]
# 找到第一个role 为 user 的索引,确保上下文格式正确
index = next((i for i, item in enumerate(req.contexts) if item.get("role") == "user"), None)
index = next(
(
i
for i, item in enumerate(req.contexts)
if item.get("role") == "user"
),
None,
)
if index is not None and index > 0:
req.contexts = req.contexts[index:]
@@ -265,6 +279,12 @@ class LLMRequestSubStage(Stage):
event.set_extra("tool_call_result", None)
yield
# 暂时直接发出去
if img_b64 := event.get_extra("tool_call_img_respond"):
await event.send(MessageChain(chain=[Image.fromBase64(img_b64)]))
event.set_extra("tool_call_img_respond", None)
yield
async def _handle_llm_response(
self,
event: AstrMessageEvent,
@@ -375,21 +395,68 @@ class LLMRequestSubStage(Stage):
client = req.func_tool.mcp_client_dict[func_tool.mcp_server_name]
res = await client.session.call_tool(func_tool.name, func_tool_args)
if res:
# TODO content的类型可能包括list[TextContent | ImageContent | EmbeddedResource]这里只处理了TextContent。
tool_call_result.append(
ToolCallMessageSegment(
role="tool",
tool_call_id=func_tool_id,
content=res.content[0].text,
# TODO 仅对ImageContent | EmbeddedResource进行了简单的Fallback
if isinstance(res.content[0], TextContent):
tool_call_result.append(
ToolCallMessageSegment(
role="tool",
tool_call_id=func_tool_id,
content=res.content[0].text,
)
)
)
elif isinstance(res.content[0], ImageContent):
tool_call_result.append(
ToolCallMessageSegment(
role="tool",
tool_call_id=func_tool_id,
content="返回了图片(已直接发送给用户)",
)
)
event.set_extra(
"tool_call_img_respond",
res.content[0].data,
)
elif isinstance(res.content[0], EmbeddedResource):
resource = res.content[0].resource
if isinstance(resource, TextResourceContents):
tool_call_result.append(
ToolCallMessageSegment(
role="tool",
tool_call_id=func_tool_id,
content=resource.text,
)
)
elif (
isinstance(resource, BlobResourceContents)
and resource.mimeType
and resource.mimeType.startswith("image/")
):
tool_call_result.append(
ToolCallMessageSegment(
role="tool",
tool_call_id=func_tool_id,
content="返回了图片(已直接发送给用户)",
)
)
event.set_extra(
"tool_call_img_respond",
res.content[0].data,
)
else:
tool_call_result.append(
ToolCallMessageSegment(
role="tool",
tool_call_id=func_tool_id,
content="返回的数据类型不受支持",
)
)
else:
# 获取处理器,过滤掉平台不兼容的处理器
platform_id = event.get_platform_id()
star_md = star_map.get(func_tool.handler_module_path)
if (
star_md and
platform_id in star_md.supported_platforms
star_md
and platform_id in star_md.supported_platforms
and not star_md.supported_platforms[platform_id]
):
logger.debug(

View File

@@ -1,8 +1,8 @@
import os
import time
import asyncio
import logging
import uuid
import itertools
from typing import Awaitable, Any
from aiocqhttp import CQHttp, Event
from astrbot.api.platform import (
@@ -20,7 +20,6 @@ from .aiocqhttp_message_event import AiocqhttpMessageEvent
from astrbot.core.platform.astr_message_event import MessageSesion
from ...register import register_platform_adapter
from aiocqhttp.exceptions import ActionFailed
from astrbot.core.utils.io import download_file
@register_platform_adapter(
@@ -45,7 +44,12 @@ class AiocqhttpAdapter(Platform):
)
self.bot = CQHttp(
use_ws_reverse=True, import_name="aiocqhttp", api_timeout_sec=180
use_ws_reverse=True,
import_name="aiocqhttp",
api_timeout_sec=180,
access_token=platform_config.get(
"ws_reverse_token"
), # 以防旧版本配置不存在
)
@self.bot.on_request()
@@ -119,6 +123,12 @@ class AiocqhttpAdapter(Platform):
abm.type = MessageType.FRIEND_MESSAGE
if self.unique_session and abm.type == MessageType.GROUP_MESSAGE:
abm.session_id = str(abm.sender.user_id) + "_" + str(event.group_id)
else:
abm.session_id = (
str(event.group_id)
if abm.type == MessageType.GROUP_MESSAGE
else abm.sender.user_id
)
abm.message_str = ""
abm.message = []
abm.timestamp = int(time.time())
@@ -155,7 +165,9 @@ class AiocqhttpAdapter(Platform):
if "sub_type" in event:
if event["sub_type"] == "poke" and "target_id" in event:
abm.message.append(Poke(qq=str(event["target_id"]), type="poke")) # noqa: F405
abm.message.append(
Poke(qq=str(event["target_id"]), type="poke")
) # noqa: F405
return abm
@@ -202,82 +214,83 @@ class AiocqhttpAdapter(Platform):
return
# 按消息段类型类型适配
for m in event.message:
t = m["type"]
for t, m_group in itertools.groupby(event.message, key=lambda x: x["type"]):
a = None
if t == "text":
message_str += m["data"]["text"].strip()
a = ComponentTypes[t](**m["data"]) # noqa: F405
# 合并相邻文本段
message_str = "".join(m["data"]["text"] for m in m_group).strip()
a = ComponentTypes[t](text=message_str) # noqa: F405
abm.message.append(a)
elif t == "file":
if m["data"].get("url") and m["data"].get("url").startswith("http"):
# Lagrange
logger.info("guessing lagrange")
for m in m_group:
if m["data"].get("url") and m["data"].get("url").startswith("http"):
# Lagrange
logger.info("guessing lagrange")
file_name = m["data"].get("file_name", "file")
abm.message.append(File(name=file_name, url=m["data"]["url"]))
else:
try:
# Napcat
ret = None
if abm.type == MessageType.GROUP_MESSAGE:
ret = await self.bot.call_action(
action="get_group_file_url",
file_id=event.message[0]["data"]["file_id"],
group_id=event.group_id,
)
elif abm.type == MessageType.FRIEND_MESSAGE:
ret = await self.bot.call_action(
action="get_private_file_url",
file_id=event.message[0]["data"]["file_id"],
)
if ret and "url" in ret:
file_url = ret["url"] # https
a = File(name="", url=file_url)
abm.message.append(a)
else:
logger.error(f"获取文件失败: {ret}")
file_name = m["data"].get("file_name", "file")
path = os.path.join("data/temp", file_name)
await download_file(m["data"]["url"], path)
m["data"] = {"file": path, "name": file_name}
a = ComponentTypes[t](**m["data"]) # noqa: F405
abm.message.append(a)
else:
try:
# Napcat, LLBot
ret = await self.bot.call_action(
action="get_file",
file_id=event.message[0]["data"]["file_id"],
)
if not ret.get("file", None):
raise ValueError(f"无法解析文件响应: {ret}")
if not os.path.exists(ret["file"]):
raise FileNotFoundError(
f"文件不存在或者权限问题: {ret['file']}。如果您使用 Docker 部署了 AstrBot 或者消息协议端(Napcat等),请先映射路径。如果路径在 /root 目录下,请用 sudo 打开 AstrBot"
)
m["data"] = {"file": ret["file"], "name": ret["file_name"]}
a = ComponentTypes[t](**m["data"]) # noqa: F405
abm.message.append(a)
except ActionFailed as e:
logger.error(f"获取文件失败: {e},此消息段将被忽略。")
except BaseException as e:
logger.error(f"获取文件失败: {e},此消息段将被忽略。")
except ActionFailed as e:
logger.error(f"获取文件失败: {e},此消息段将被忽略。")
except BaseException as e:
logger.error(f"获取文件失败: {e},此消息段将被忽略。")
elif t == "reply":
if not get_reply:
a = ComponentTypes[t](**m["data"]) # noqa: F405
abm.message.append(a)
else:
try:
reply_event_data = await self.bot.call_action(
action="get_msg",
message_id=int(m["data"]["id"]),
)
abm_reply = await self._convert_handle_message_event(
Event.from_payload(reply_event_data), get_reply=False
)
reply_seg = Reply(
id=abm_reply.message_id,
chain=abm_reply.message,
sender_id=abm_reply.sender.user_id,
sender_nickname=abm_reply.sender.nickname,
time=abm_reply.timestamp,
message_str=abm_reply.message_str,
text=abm_reply.message_str, # for compatibility
qq=abm_reply.sender.user_id, # for compatibility
)
abm.message.append(reply_seg)
except BaseException as e:
logger.error(f"获取引用消息失败: {e}")
for m in m_group:
if not get_reply:
a = ComponentTypes[t](**m["data"]) # noqa: F405
abm.message.append(a)
else:
try:
reply_event_data = await self.bot.call_action(
action="get_msg",
message_id=int(m["data"]["id"]),
)
abm_reply = await self._convert_handle_message_event(
Event.from_payload(reply_event_data), get_reply=False
)
reply_seg = Reply(
id=abm_reply.message_id,
chain=abm_reply.message,
sender_id=abm_reply.sender.user_id,
sender_nickname=abm_reply.sender.nickname,
time=abm_reply.timestamp,
message_str=abm_reply.message_str,
text=abm_reply.message_str, # for compatibility
qq=abm_reply.sender.user_id, # for compatibility
)
abm.message.append(reply_seg)
except BaseException as e:
logger.error(f"获取引用消息失败: {e}")
a = ComponentTypes[t](**m["data"]) # noqa: F405
abm.message.append(a)
else:
a = ComponentTypes[t](**m["data"]) # noqa: F405
abm.message.append(a)
for m in m_group:
a = ComponentTypes[t](**m["data"]) # noqa: F405
abm.message.append(a)
abm.timestamp = int(time.time())
abm.message_str = message_str

View File

@@ -3,6 +3,7 @@ import base64
import datetime
import os
import re
import uuid
import threading
import aiohttp
@@ -63,7 +64,7 @@ class SimpleGewechatClient:
"/astrbot-gewechat/callback", view_func=self._callback, methods=["POST"]
)
self.server.add_url_rule(
"/astrbot-gewechat/file/<file_id>",
"/astrbot-gewechat/file/<file_token>",
view_func=self._handle_file,
methods=["GET"],
)
@@ -81,6 +82,11 @@ class SimpleGewechatClient:
self.shutdown_event = asyncio.Event()
self.staged_files = {}
"""存储了允许外部访问的文件列表。auth_token: file_path。通过 register_file 方法注册。"""
self.lock = asyncio.Lock()
async def get_token_id(self):
"""获取 Gewechat Token。"""
async with aiohttp.ClientSession() as session:
@@ -143,18 +149,25 @@ class SimpleGewechatClient:
content = d["Content"]["string"] # 消息内容
at_me = False
at_wxids = []
if "@chatroom" in from_user_name:
abm.type = MessageType.GROUP_MESSAGE
_t = content.split(":\n")
user_id = _t[0]
content = _t[1]
# at
msg_source = d["MsgSource"]
if "\u2005" in content:
# at
# content = content.split('\u2005')[1]
content = re.sub(r"@[^\u2005]*\u2005", "", content)
at_wxids = re.findall(
r"<atuserlist><!\[CDATA\[.*?(?:,|\b)([^,]+?)(?=,|\]\]></atuserlist>)",
msg_source,
)
abm.group_id = from_user_name
# at
msg_source = d["MsgSource"]
if (
f"<atuserlist><![CDATA[,{abm.self_id}]]>" in msg_source
or f"<atuserlist><![CDATA[{abm.self_id}]]>" in msg_source
@@ -167,13 +180,12 @@ class SimpleGewechatClient:
user_id = from_user_name
# 检查消息是否由自己发送,若是则忽略
if user_id == abm.self_id:
logger.info("忽略自己发送的消息")
return None
# 已经有可配置项专门配置是否需要响应自己的消息,因此这里注释掉。
# if user_id == abm.self_id:
# logger.info("忽略自己发送的消息")
# return None
abm.message = []
if at_me:
abm.message.insert(0, At(qq=abm.self_id))
# 解析用户真实名字
user_real_name = "unknown"
@@ -197,7 +209,19 @@ class SimpleGewechatClient:
else:
user_real_name = self.userrealnames[abm.group_id][user_id]
else:
user_real_name = d.get("PushContent", "unknown : ").split(" : ")[0]
try:
info = (await self.get_user_or_group_info(user_id))["data"][0]
user_real_name = info["nickName"]
except Exception as e:
logger.debug(f"获取用户 {user_id} 昵称失败: {e}")
user_real_name = user_id
if at_me:
abm.message.insert(0, At(qq=abm.self_id, name=self.nickname))
for wxid in at_wxids:
# 群聊里 At 其他人的列表
_username = self.userrealnames.get(abm.group_id, {}).get(wxid, wxid)
abm.message.append(At(qq=wxid, name=_username))
abm.sender = MessageMember(user_id, user_real_name)
abm.raw_message = d
@@ -248,9 +272,12 @@ class SimpleGewechatClient:
logger.info("消息类型(48):地理位置")
case 49: # 公众号/文件/小程序/引用/转账/红包/视频号/群聊邀请
data_parser = GeweDataParser(content, abm.group_id == "")
abm_data = data_parser.parse_mutil_49()
if abm_data:
abm.message.append(abm_data)
segments = data_parser.parse_mutil_49()
if segments:
abm.message.extend(segments)
for seg in segments:
if isinstance(seg, Plain):
abm.message_str += seg.text
case 51: # 帐号消息同步?
logger.info("消息类型(51):帐号消息同步?")
case 10000: # 被踢出群聊/更换群主/修改群名称
@@ -289,9 +316,33 @@ class SimpleGewechatClient:
return quart.jsonify({"r": "AstrBot ACK"})
async def _handle_file(self, file_id):
file_path = f"data/temp/{file_id}"
return await quart.send_file(file_path)
async def _register_file(self, file_path: str) -> str:
"""向 AstrBot 回调服务器 注册一个允许外部访问的文件。
Args:
file_path (str): 文件路径。
Returns:
str: 返回一个 auth_token文件路径为 file_path。通过 /astrbot-gewechat/file/auth_token 得到文件。
"""
async with self.lock:
if not os.path.exists(file_path):
raise Exception(f"文件不存在: {file_path}")
file_token = str(uuid.uuid4())
self.staged_files[file_token] = file_path
return file_token
async def _handle_file(self, file_token):
async with self.lock:
if file_token not in self.staged_files:
logger.warning(f"请求的文件 {file_token} 不存在。")
return quart.abort(404)
if not os.path.exists(self.staged_files[file_token]):
logger.warning(f"请求的文件 {self.staged_files[file_token]} 不存在。")
return quart.abort(404)
file_path = self.staged_files[file_token]
self.staged_files.pop(file_token, None)
return await quart.send_file(file_path)
async def _set_callback_url(self):
logger.info("设置回调,请等待...")
@@ -441,17 +492,18 @@ class SimpleGewechatClient:
"此次登录需要安全验证码,请在管理面板聊天页输入 /gewe_code 验证码 来验证,如 /gewe_code 123456"
)
else:
status = json_blob["data"]["status"]
nickname = json_blob["data"].get("nickName", "")
if status == 1:
logger.info(f"等待确认...{nickname}")
elif status == 2:
logger.info(f"绿泡泡平台登录成功: {nickname}")
break
elif status == 0:
logger.info("等待扫码...")
else:
logger.warning(f"未知状态: {status}")
if "status" in json_blob["data"]:
status = json_blob["data"]["status"]
nickname = json_blob["data"].get("nickName", "")
if status == 1:
logger.info(f"等待确认...{nickname}")
elif status == 2:
logger.info(f"绿泡泡平台登录成功: {nickname}")
break
elif status == 0:
logger.info("等待扫码...")
else:
logger.warning(f"未知状态: {status}")
await asyncio.sleep(5)
if appid:

View File

@@ -83,15 +83,9 @@ class GewechatPlatformEvent(AstrMessageEvent):
elif isinstance(comp, Image):
img_path = await comp.convert_to_file_path()
# 检查 record_path 是否在 data/temp 目录中
temp_directory = os.path.abspath("data/temp")
if os.path.commonpath([temp_directory, img_path]) != temp_directory:
with open(img_path, "rb") as f:
img_path = save_temp_img(f.read())
file_id = os.path.basename(img_path)
img_url = f"{client.file_server_url}/{file_id}"
# 为了安全,向 AstrBot 回调服务注册可被 gewechat 访问的文件,并获得文件 token
token = await client._register_file(img_path)
img_url = f"{client.file_server_url}/{token}"
logger.debug(f"gewe callback img url: {img_url}")
await client.post_image(to_wxid, img_url)
elif isinstance(comp, Video):
@@ -110,20 +104,29 @@ class GewechatPlatformEvent(AstrMessageEvent):
video_url = comp.file
# 根据 url 下载视频
video_filename = f"{uuid.uuid4()}.mp4"
video_path = f"data/temp/{video_filename}"
await download_file(video_url, video_path)
if video_url.startswith("http"):
video_filename = f"{uuid.uuid4()}.mp4"
video_path = f"data/temp/{video_filename}"
await download_file(video_url, video_path)
else:
video_path = video_url
video_token = await client._register_file(video_path)
video_callback_url = f"{client.file_server_url}/{video_token}"
# 获取视频第一帧
thumb_path = f"data/temp/{uuid.uuid4()}.jpg"
thumb_path = f"data/temp/gewechat_video_thumb_{uuid.uuid4()}.jpg"
video_path = video_path.replace(" ", "\\ ")
try:
ff = FFmpeg()
command = f'-i "{video_path}" -ss 0 -vframes 1 "{thumb_path}"'
command = f"-i {video_path} -ss 0 -vframes 1 {thumb_path}"
ff.options(command)
thumb_file_id = os.path.basename(thumb_path)
thumb_url = f"{client.file_server_url}/{thumb_file_id}"
thumb_token = await client._register_file(thumb_path)
thumb_url = f"{client.file_server_url}/{thumb_token}"
except Exception as e:
logger.error(f"获取视频第一帧失败: {e}")
# 获取视频时长
try:
from pyffmpeg import FFprobe
@@ -138,15 +141,12 @@ class GewechatPlatformEvent(AstrMessageEvent):
logger.error(f"获取时长失败: {e}")
video_duration = 10
file_id = os.path.basename(video_path)
video_url = f"{client.file_server_url}/{file_id}"
# 发送视频
await client.post_video(
to_wxid, video_url, thumb_url, video_duration
to_wxid, video_callback_url, thumb_url, video_duration
)
# 删除临时视频和缩略图文件
if os.path.exists(video_path):
os.remove(video_path)
# 删除临时缩略图文件
if os.path.exists(thumb_path):
os.remove(thumb_path)
elif isinstance(comp, Record):
@@ -163,8 +163,8 @@ class GewechatPlatformEvent(AstrMessageEvent):
logger.info("Silk 语音文件格式转换至: " + record_path)
if duration == 0:
duration = get_wav_duration(record_path)
file_id = os.path.basename(silk_path)
record_url = f"{client.file_server_url}/{file_id}"
token = await client._register_file(silk_path)
record_url = f"{client.file_server_url}/{token}"
logger.debug(f"gewe callback record url: {record_url}")
await client.post_voice(to_wxid, record_url, duration * 1000)
elif isinstance(comp, File):
@@ -177,10 +177,10 @@ class GewechatPlatformEvent(AstrMessageEvent):
else:
file_path = file_path
file_id = os.path.basename(file_path)
file_url = f"{client.file_server_url}/{file_id}"
token = await client._register_file(file_path)
file_url = f"{client.file_server_url}/{token}"
logger.debug(f"gewe callback file url: {file_url}")
await client.post_file(to_wxid, file_url, file_id)
await client.post_file(to_wxid, file_url, file_name)
elif isinstance(comp, Emoji):
await client.post_emoji(to_wxid, comp.md5, comp.md5_len, comp.cdnurl)
elif isinstance(comp, At):

View File

@@ -1,6 +1,11 @@
from defusedxml import ElementTree as eT
from astrbot.api import logger
from astrbot.api.message_components import WechatEmoji as Emoji, Reply, Plain
from astrbot.api.message_components import (
WechatEmoji as Emoji,
Reply,
Plain,
BaseMessageComponent,
)
class GeweDataParser:
@@ -11,7 +16,7 @@ class GeweDataParser:
def _format_to_xml(self):
return eT.fromstring(self.data)
def parse_mutil_49(self):
def parse_mutil_49(self) -> list[BaseMessageComponent] | None:
appmsg_type = self._format_to_xml().find(".//appmsg/type")
if appmsg_type is None:
return
@@ -34,13 +39,18 @@ class GeweDataParser:
except Exception as e:
logger.error(f"gewechat: parse_emoji failed, {e}")
def parse_reply(self) -> Reply | None:
def parse_reply(self) -> list[Reply, Plain] | None:
"""解析引用消息
Returns:
list[Reply, Plain]: 一个包含两个元素的列表。Reply 消息对象和引用者说的文本内容。微信平台下引用消息时只能发送文本消息。
"""
try:
replied_id = -1
replied_uid = 0
replied_nickname = ""
replied_content = ""
content = ""
replied_content = "" # 被引用者说的内容
content = "" # 引用者说的内容
root = self._format_to_xml()
refermsg = root.find(".//refermsg")
@@ -57,22 +67,44 @@ class GeweDataParser:
if displayname is not None:
replied_nickname = displayname.text
if refermsg_content is not None:
replied_content = refermsg_content.text
# 处理引用嵌套,包括嵌套公众号消息
if refermsg_content.text.startswith(
"<msg>"
) or refermsg_content.text.startswith("<?xml"):
try:
logger.debug("gewechat: Reference message is nested")
refer_root = eT.fromstring(refermsg_content.text)
img = refer_root.find("img")
if img is not None:
replied_content = "[图片]"
else:
app_msg = refer_root.find("appmsg")
refermsg_content_title = app_msg.find("title")
logger.debug(
f"gewechat: Reference message nesting: {refermsg_content_title.text}"
)
replied_content = refermsg_content_title.text
except Exception as e:
logger.error(f"gewechat: nested failed, {e}")
# 处理异常情况
replied_content = refermsg_content.text
else:
replied_content = refermsg_content.text
# 提取引用者说的内容
title = root.find(".//appmsg/title")
if title is not None:
content = title.text
r = Reply(
reply_seg = Reply(
id=replied_id,
chain=[Plain(content)],
chain=[Plain(replied_content)],
sender_id=replied_uid,
sender_nickname=replied_nickname,
sender_str=replied_content,
message_str=content,
message_str=replied_content,
)
return r
plain_seg = Plain(content)
return [reply_seg, plain_seg]
except Exception as e:
logger.error(f"gewechat: parse_reply failed, {e}")

View File

@@ -58,6 +58,14 @@ class TelegramPlatformAdapter(Platform):
self.base_url = base_url
self.enable_command_register = self.config.get(
"telegram_command_register", True
)
self.enable_command_refresh = self.config.get(
"telegram_command_auto_refresh", True
)
self.last_command_hash = None
self.application = (
ApplicationBuilder()
.token(self.config["telegram_token"])
@@ -95,17 +103,19 @@ class TelegramPlatformAdapter(Platform):
async def run(self):
await self.application.initialize()
await self.application.start()
await self.register_commands()
# TODO 使用更优雅的方式重新注册命令
self.scheduler.add_job(
self.register_commands,
"interval",
minutes=5,
id="telegram_command_register",
misfire_grace_time=60,
)
self.scheduler.start()
if self.enable_command_register:
await self.register_commands()
if self.enable_command_refresh and self.enable_command_register:
self.scheduler.add_job(
self.register_commands,
"interval",
seconds=self.config.get("telegram_command_register_interval", 300),
id="telegram_command_register",
misfire_grace_time=60,
)
self.scheduler.start()
queue = self.application.updater.start_polling()
logger.info("Telegram Platform Adapter is running.")
@@ -114,10 +124,16 @@ class TelegramPlatformAdapter(Platform):
async def register_commands(self):
"""收集所有注册的指令并注册到 Telegram"""
try:
await self.client.delete_my_commands()
commands = self.collect_commands()
if commands:
current_hash = hash(
tuple((cmd.command, cmd.description) for cmd in commands)
)
if current_hash == self.last_command_hash:
return
self.last_command_hash = current_hash
await self.client.delete_my_commands()
await self.client.set_my_commands(commands)
except Exception as e:
@@ -342,7 +358,9 @@ class TelegramPlatformAdapter(Platform):
self.scheduler.shutdown()
await self.application.stop()
await self.client.delete_my_commands()
if self.enable_command_register:
await self.client.delete_my_commands()
# 保险起见先判断是否存在updater对象
if self.application.updater is not None:

View File

@@ -3,7 +3,6 @@ import json
import textwrap
import os
import asyncio
import copy
import logging
from typing import Dict, List, Awaitable, Literal, Any
@@ -360,7 +359,7 @@ class FuncCall:
self.func_list.append(func_tool)
logger.info(f"已连接 MCP 服务 {name}, Tools: {tool_names}")
return True
return
except Exception as e:
import traceback
@@ -369,7 +368,7 @@ class FuncCall:
# 发生错误时确保客户端被清理
if name in self.mcp_client_dict:
await self._terminate_mcp_client(name)
return False
return
async def _terminate_mcp_client(self, name: str) -> None:
"""关闭并清理MCP客户端"""
@@ -441,28 +440,51 @@ class FuncCall:
"""
# Gemini API 支持的数据类型和格式
supported_types = {"string", "number", "integer", "boolean", "array", "object", "null"}
supported_types = {
"string",
"number",
"integer",
"boolean",
"array",
"object",
"null",
}
supported_formats = {
"string": {"enum", "date-time"},
"integer": {"int32", "int64"},
"number": {"float", "double"}
"number": {"float", "double"},
}
def convert_schema(schema: dict) -> dict:
"""转换 schema 为 Gemini API 格式"""
# 如果 schema 包含 anyOf则只返回 anyOf 字段
if "anyOf" in schema:
return {"anyOf": [convert_schema(s) for s in schema["anyOf"]]}
result = {}
if "type" in schema and schema["type"] in supported_types:
result["type"] = schema["type"]
if ("format" in schema and
schema["format"] in supported_formats.get(result["type"], set())):
if "format" in schema and schema["format"] in supported_formats.get(
result["type"], set()
):
result["format"] = schema["format"]
else:
# 暂时指定默认为null
result["type"] = "null"
support_fields = {"title", "description", "enum", "minimum", "maximum",
"maxItems", "minItems", "nullable", "required"}
support_fields = {
"title",
"description",
"enum",
"minimum",
"maximum",
"maxItems",
"minItems",
"nullable",
"required",
}
result.update({k: schema[k] for k in support_fields if k in schema})
if "properties" in schema:
@@ -478,8 +500,6 @@ class FuncCall:
if "items" in schema:
result["items"] = convert_schema(schema["items"])
if "anyOf" in schema:
result["anyOf"] = [convert_schema(s) for s in schema["anyOf"]]
return result
@@ -487,9 +507,10 @@ class FuncCall:
{
"name": f.name,
"description": f.description,
**({"parameters": convert_schema(f.parameters)})
**({"parameters": convert_schema(f.parameters)}),
}
for f in self.func_list if f.active
for f in self.func_list
if f.active
]
declarations = {}

View File

@@ -162,18 +162,34 @@ class ProviderGoogleGenAI(Provider):
return types.GenerateContentConfig(
system_instruction=system_instruction,
temperature=temperature,
max_output_tokens=payloads.get("max_tokens") or payloads.get("maxOutputTokens"),
max_output_tokens=payloads.get("max_tokens")
or payloads.get("maxOutputTokens"),
top_p=payloads.get("top_p") or payloads.get("topP"),
top_k=payloads.get("top_k") or payloads.get("topK"),
frequency_penalty=payloads.get("frequency_penalty") or payloads.get("frequencyPenalty"),
presence_penalty=payloads.get("presence_penalty") or payloads.get("presencePenalty"),
frequency_penalty=payloads.get("frequency_penalty")
or payloads.get("frequencyPenalty"),
presence_penalty=payloads.get("presence_penalty")
or payloads.get("presencePenalty"),
stop_sequences=payloads.get("stop") or payloads.get("stopSequences"),
response_logprobs=payloads.get("response_logprobs") or payloads.get("responseLogprobs"),
response_logprobs=payloads.get("response_logprobs")
or payloads.get("responseLogprobs"),
logprobs=payloads.get("logprobs"),
seed=payloads.get("seed"),
response_modalities=modalities,
tools=tool_list,
safety_settings=self.safety_settings if self.safety_settings else None,
thinking_config=types.ThinkingConfig(
thinking_budget=min(
int(
self.provider_config.get("gm_thinking_config", {}).get(
"budget", 0
)
),
24576,
),
)
if "gemini-2.5-flash" in self.get_model()
else None,
automatic_function_calling=types.AutomaticFunctionCallingConfig(
disable=True
),
@@ -182,11 +198,11 @@ class ProviderGoogleGenAI(Provider):
def _prepare_conversation(self, payloads: Dict) -> List[types.Content]:
"""准备 Gemini SDK 的 Content 列表"""
def create_text_part(text: str) -> types.UserContent:
def create_text_part(text: str) -> types.Part:
content_a = text if text else " "
if not text:
logger.warning("文本内容为空,已添加空格占位")
return types.UserContent(parts=[types.Part.from_text(text=content_a)])
return types.Part.from_text(text=content_a)
def process_image_url(image_url_dict: dict) -> types.Part:
url = image_url_dict["url"]
@@ -194,6 +210,16 @@ class ProviderGoogleGenAI(Provider):
image_bytes = base64.b64decode(url.split(",", 1)[1])
return types.Part.from_bytes(data=image_bytes, mime_type=mime_type)
def append_or_extend(
contents: list[types.Content],
part: list[types.Part],
content_cls: type[types.Content],
) -> None:
if contents and isinstance(contents[-1], content_cls):
contents[-1].parts.extend(part)
else:
contents.append(content_cls(parts=part))
gemini_contents: List[types.Content] = []
native_tool_enabled = any(
[
@@ -205,60 +231,53 @@ class ProviderGoogleGenAI(Provider):
role, content = message["role"], message.get("content")
if role == "user":
if isinstance(content, str):
gemini_contents.append(create_text_part(content))
elif isinstance(content, list):
if isinstance(content, list):
parts = [
types.Part.from_text(text=item["text"] or " ")
if item["type"] == "text"
else process_image_url(item["image_url"])
for item in content
]
gemini_contents.append(types.UserContent(parts=parts))
else:
parts = [create_text_part(content)]
append_or_extend(gemini_contents, parts, types.UserContent)
elif role == "assistant":
if content:
gemini_contents.append(
types.ModelContent(parts=[types.Part.from_text(text=content)])
)
elif "tool_calls" in message and not native_tool_enabled:
gemini_contents.extend(
[
types.ModelContent(
parts=[
types.Part.from_function_call(
name=tool["function"]["name"],
args=json.loads(tool["function"]["arguments"]),
)
]
)
for tool in message["tool_calls"]
]
)
parts = [types.Part.from_text(text=content)]
append_or_extend(gemini_contents, parts, types.ModelContent)
elif not native_tool_enabled and "tool_calls" in message:
parts = [
types.Part.from_function_call(
name=tool["function"]["name"],
args=json.loads(tool["function"]["arguments"]),
)
for tool in message["tool_calls"]
]
append_or_extend(gemini_contents, parts, types.ModelContent)
else:
logger.warning("assistant 角色的消息内容为空,已添加空格占位")
if native_tool_enabled:
if native_tool_enabled and "tool_calls" in message:
logger.warning(
"检测到启用Gemini原生工具且上下文中存在函数调用建议使用 /reset 重置上下文"
)
gemini_contents.append(
types.ModelContent(parts=[types.Part.from_text(text=" ")])
)
parts = [types.Part.from_text(text=" ")]
append_or_extend(gemini_contents, parts, types.ModelContent)
elif role == "tool" and not native_tool_enabled:
gemini_contents.append(
types.UserContent(
parts=[
types.Part.from_function_response(
name=message["tool_call_id"],
response={
"name": message["tool_call_id"],
"content": message["content"],
},
)
]
parts = [
types.Part.from_function_response(
name=message["tool_call_id"],
response={
"name": message["tool_call_id"],
"content": message["content"],
},
)
)
]
append_or_extend(gemini_contents, parts, types.UserContent)
if gemini_contents and isinstance(gemini_contents[0], types.ModelContent):
gemini_contents.pop()
return gemini_contents
@@ -313,9 +332,7 @@ class ProviderGoogleGenAI(Provider):
chain.append(Comp.Image.fromBytes(part.inline_data.data))
return MessageChain(chain=chain)
async def _query(
self, payloads: dict, tools: FuncCall
) -> LLMResponse:
async def _query(self, payloads: dict, tools: FuncCall) -> LLMResponse:
"""非流式请求 Gemini API"""
system_instruction = next(
(msg["content"] for msg in payloads["messages"] if msg["role"] == "system"),
@@ -327,7 +344,7 @@ class ProviderGoogleGenAI(Provider):
modalities.append("Image")
conversation = self._prepare_conversation(payloads)
temperature=payloads.get("temperature", 0.7)
temperature = payloads.get("temperature", 0.7)
result: Optional[types.GenerateContentResponse] = None
while True:

View File

@@ -362,7 +362,7 @@ class ProviderOpenAIOfficial(Provider):
available_api_keys = self.api_keys.copy()
chosen_key = random.choice(available_api_keys)
e = None
last_exception = None
retry_cnt = 0
for retry_cnt in range(max_retries):
try:
@@ -376,6 +376,7 @@ class ProviderOpenAIOfficial(Provider):
payloads["messages"] = new_contexts
context_query = new_contexts
except Exception as e:
last_exception = e
(
success,
chosen_key,
@@ -398,7 +399,9 @@ class ProviderOpenAIOfficial(Provider):
if retry_cnt == max_retries - 1:
logger.error(f"API 调用失败,重试 {max_retries} 次仍然失败。")
raise e
if last_exception is None:
raise Exception("未知错误")
raise last_exception
return llm_response
async def text_chat_stream(
@@ -428,7 +431,7 @@ class ProviderOpenAIOfficial(Provider):
available_api_keys = self.api_keys.copy()
chosen_key = random.choice(available_api_keys)
e = None
last_exception = None
retry_cnt = 0
for retry_cnt in range(max_retries):
try:
@@ -443,6 +446,7 @@ class ProviderOpenAIOfficial(Provider):
payloads["messages"] = new_contexts
context_query = new_contexts
except Exception as e:
last_exception = e
(
success,
chosen_key,
@@ -465,7 +469,9 @@ class ProviderOpenAIOfficial(Provider):
if retry_cnt == max_retries - 1:
logger.error(f"API 调用失败,重试 {max_retries} 次仍然失败。")
raise e
if last_exception is None:
raise Exception("未知错误")
raise last_exception
async def _remove_image_from_context(self, contexts: List):
"""
@@ -505,7 +511,10 @@ class ProviderOpenAIOfficial(Provider):
async def assemble_context(self, text: str, image_urls: List[str] = None) -> dict:
"""组装成符合 OpenAI 格式的 role 为 user 的消息段"""
if image_urls:
user_content = {"role": "user", "content": [{"type": "text", "text": text if text else "[图片]"}]}
user_content = {
"role": "user",
"content": [{"type": "text", "text": text if text else "[图片]"}],
}
for image_url in image_urls:
if image_url.startswith("http"):
image_path = await download_image_by_url(image_url)

View File

@@ -209,20 +209,20 @@ async def get_dashboard_version():
return None
async def download_dashboard():
async def download_dashboard(path: str = "data/dashboard.zip", extract_path: str = "data"):
"""下载管理面板文件"""
dashboard_release_url = "https://astrbot-registry.soulter.top/download/astrbot-dashboard/latest/dist.zip"
try:
await download_file(
dashboard_release_url, "data/dashboard.zip", show_progress=True
dashboard_release_url, path, show_progress=True
)
except BaseException as _:
dashboard_release_url = (
"https://github.com/Soulter/AstrBot/releases/latest/download/dist.zip"
)
await download_file(
dashboard_release_url, "data/dashboard.zip", show_progress=True
dashboard_release_url, path, show_progress=True
)
print("解压管理面板文件中...")
with zipfile.ZipFile("data/dashboard.zip", "r") as z:
z.extractall("data")
with zipfile.ZipFile(path, "r") as z:
z.extractall(extract_path)

View File

@@ -145,7 +145,9 @@ class PluginRoute(Route):
if handler.event_type == EventType.AdapterMessageEvent:
# 处理平台适配器消息事件
has_admin = False
for filter in (
for (
filter
) in (
handler.event_filters
): # 正常handler就只有 1~2 个 filter因此这里时间复杂度不会太高
if isinstance(filter, CommandFilter):

13
changelogs/v3.5.6.md Normal file
View File

@@ -0,0 +1,13 @@
# What's Changed
> 🙁 Gewechat 已经停止维护,我们将更换更稳定的个人微信接入方式。如有问题请提交 issue。
> 🧐 预告:接下来三个版本之内将会逐步上线 Live2D 桌宠、长期记忆(实验性)的功能。
1. Gewechat 相关 bug 修复(即使已经不可用 :( @BigFace123 @XiGuang @Soulter
2. 支持 CLI 命令行 @LIghtJUNction
3. 修复 QQ 下带有网址的指令可能无法识别的问题 @kkjzio
4. `reset` 指令优化 @anka-afk
5. Gemini 请求优化,支持 Gemini 思考信息设置 @Raven95676
6. 支持处理 MCP 服务器返回的图片等多模态信息 @Raven95676
7. 插件市场支持基于 Star 和 更新时间排序 @Soulter
8. 优化 QQ 下自动下载文件导致磁盘被占满的问题 @Soulter @anka-afk

View File

@@ -145,6 +145,8 @@ export const useCommonStore = defineStore({
"tags": res.data.data[key]?.tags ? res.data.data[key].tags : [],
"logo": res.data.data[key]?.logo ? res.data.data[key].logo : "",
"pinned": res.data.data[key]?.pinned ? res.data.data[key].pinned : false,
"stars": res.data.data[key]?.stars ? res.data.data[key].stars : 0,
"updated_at": res.data.data[key]?.updated_at ? res.data.data[key].updated_at : "",
})
}
this.pluginMarketData = data;

View File

@@ -99,15 +99,13 @@ import 'highlight.js/styles/github.css';
</template>
<template v-slot:item.stars="{ item }">
<a :href="item.repo">
<img v-if="item.repo"
:src="`https://img.shields.io/github/stars/${item.repo.split('/').slice(-2).join('/')}.svg`"
:alt="`Stars for ${item.name}`"
style="height: 20px;"
/>
</a>
<span>{{ item.stars }}</span>
</template>
<template v-slot:item.updated_at="{ item }">
<!-- 2025-04-28T16:39:27Z -->
<span>{{ new Date(item.updated_at).toLocaleString() }}</span>
</template>
<template v-slot:item.tags="{ item }">
<span v-if="item.tags.length === 0"></span>
<v-chip v-for="tag in item.tags" :key="tag" color="primary" size="x-small">{{ tag
@@ -283,6 +281,7 @@ export default {
{ title: '描述', key: 'desc', maxWidth: '250px' },
{ title: '作者', key: 'author', maxWidth: '70px' },
{ title: 'Star数', key: 'stars', maxWidth: '100px' },
{ title: '最近更新', key: 'updated_at', maxWidth: '100px' },
{ title: '标签', key: 'tags', maxWidth: '100px' },
{ title: '操作', key: 'actions', sortable: false }
],

View File

@@ -560,7 +560,16 @@ export default {
// 过滤后的市场服务器
filteredMarketplaceServers() {
return this.marketplaceServers;
if (!this.marketplaceSearch.trim()) {
return this.marketplaceServers;
}
const searchTerm = this.marketplaceSearch.toLowerCase();
return this.marketplaceServers.filter(server =>
server.name.toLowerCase().includes(searchTerm) ||
(server.name_h && server.name_h.toLowerCase().includes(searchTerm)) ||
(server.description && server.description.toLowerCase().includes(searchTerm))
);
},
},

View File

@@ -24,6 +24,32 @@ from .long_term_memory import LongTermMemory
from astrbot.core import logger
from astrbot.api.message_components import Plain, Image, Reply
from typing import Union
from enum import Enum
class RstScene(Enum):
GROUP_UNIQUE_ON = ("group_unique_on", "群聊+会话隔离开启")
GROUP_UNIQUE_OFF = ("group_unique_off", "群聊+会话隔离关闭")
PRIVATE = ("private", "私聊")
@property
def key(self) -> str:
return self.value[0]
@property
def name(self) -> str:
return self.value[1]
@classmethod
def from_index(cls, index: int) -> "RstScene":
mapping = {1: cls.GROUP_UNIQUE_ON, 2: cls.GROUP_UNIQUE_OFF, 3: cls.PRIVATE}
return mapping[index]
@classmethod
def get_scene(cls, is_group: bool, is_unique_session: bool) -> "RstScene":
if is_group:
return cls.GROUP_UNIQUE_ON if is_unique_session else cls.GROUP_UNIQUE_OFF
return cls.PRIVATE
@star.register(
@@ -33,6 +59,7 @@ from typing import Union
version="4.0.0",
)
class Main(star.Star):
def __init__(self, context: star.Context) -> None:
self.context = context
cfg = context.get_config()
@@ -479,14 +506,30 @@ UID: {user_id} 此 ID 可用于设置管理员。
@filter.command("reset")
async def reset(self, message: AstrMessageEvent):
"""重置 LLM 会话"""
# ==============================
# 读取当前情况和配置
# ==============================
is_unique_session = self.context.get_config()["platform_settings"][
"unique_session"
]
if message.get_group_id() and not is_unique_session and message.role != "admin":
# 群聊,没开独立会话,发送人不是管理员
is_group = bool(message.get_group_id())
scene = RstScene.get_scene(is_group, is_unique_session)
alter_cmd_cfg = sp.get("alter_cmd", {})
plugin_config = alter_cmd_cfg.get("astrbot", {})
reset_cfg = plugin_config.get("reset", {})
required_perm = reset_cfg.get(
scene.key, "admin" if is_group and not is_unique_session else "member"
)
if required_perm == "admin" and message.role != "admin":
message.set_result(
MessageEventResult().message(
f"会话处于群聊,并且未开启独立会话,并且您 (ID {message.get_sender_id()}) 不是管理员,因此没有权限重置当前对话。"
f"{scene.name}场景下reset命令需要管理员权限"
f"您 (ID {message.get_sender_id()}) 不是管理员,无法执行此操作。"
)
)
return
@@ -733,7 +776,9 @@ UID: {user_id} 此 ID 可用于设置管理员。
@filter.command("new")
async def new_conv(self, message: AstrMessageEvent):
"""创建新对话"""
"""
创建新对话
"""
provider = self.context.get_using_provider()
if provider and provider.meta().type == "dify":
assert isinstance(provider, ProviderDify)
@@ -746,6 +791,14 @@ UID: {user_id} 此 ID 可用于设置管理员。
cid = await self.context.conversation_manager.new_conversation(
message.unified_msg_origin
)
# 长期记忆
if self.ltm:
try:
await self.ltm.remove_session(event=message)
except Exception as e:
logger.error(f"清理聊天增强记录失败: {e}")
message.set_result(
MessageEventResult().message(f"切换到新对话: 新对话({cid[:4]})。")
)
@@ -882,7 +935,9 @@ UID: {user_id} 此 ID 可用于设置管理员。
assert isinstance(provider, ProviderDify)
dify_cid = provider.conversation_ids.pop(message.unified_msg_origin, None)
if dify_cid:
await provider.api_client.delete_chat_conv(message.unified_msg_origin, dify_cid)
await provider.api_client.delete_chat_conv(
message.unified_msg_origin, dify_cid
)
message.set_result(
MessageEventResult().message(
"删除当前对话成功。不再处于对话状态,使用 /switch 序号 切换到其他对话或 /new 创建。"
@@ -1233,7 +1288,9 @@ UID: {user_id} 此 ID 可用于设置管理员。
if mood_dialogs := persona["_mood_imitation_dialogs_processed"]:
req.system_prompt += "\nHere are few shots of dialogs, you need to imitate the tone of 'B' in the following dialogs to respond:\n"
req.system_prompt += mood_dialogs
if (begin_dialogs := persona["_begin_dialogs_processed"]) and not req.contexts:
if (
begin_dialogs := persona["_begin_dialogs_processed"]
) and not req.contexts:
req.contexts[:0] = begin_dialogs
if quote and quote.message_str:
@@ -1265,13 +1322,59 @@ UID: {user_id} 此 ID 可用于设置管理员。
token = self.parse_commands(event.message_str)
if token.len < 2:
yield event.plain_result(
"可设置所有其他指令是否需要管理员权限。\n格式: /alter_cmd <cmd_name> <admin/member>\n 例如: /alter_cmd provider admin 将 provider 设置为管理员指令"
"可设置所有其他指令是否需要管理员权限。\n格式: /alter_cmd <cmd_name> <admin/member>\n 例如: /alter_cmd provider admin 将 provider 设置为管理员指令\n /alter_cmd reset config 打开reset权限配置"
)
return
cmd_name = token.get(1)
cmd_type = token.get(2)
# ============================
# 对reset权限进行特殊处理
# ============================
if cmd_name == "reset" and cmd_type == "config":
alter_cmd_cfg = sp.get("alter_cmd", {})
plugin_ = alter_cmd_cfg.get("astrbot", {})
reset_cfg = plugin_.get("reset", {})
group_unique_on = reset_cfg.get("group_unique_on", "admin")
group_unique_off = reset_cfg.get("group_unique_off", "admin")
private = reset_cfg.get("private", "member")
config_menu = f"""reset命令权限细粒度配置
当前配置:
1. 群聊+会话隔离开: {group_unique_on}
2. 群聊+会话隔离关: {group_unique_off}
3. 私聊: {private}
修改指令格式:
/alter_cmd reset scene <场景编号> <admin/member>
例如: /alter_cmd reset scene 2 member"""
yield event.plain_result(config_menu)
return
if cmd_name == "reset" and cmd_type == "scene" and token.len >= 4:
scene_num = token.get(3)
perm_type = token.get(4)
if not scene_num.isdigit() or int(scene_num) < 1 or int(scene_num) > 3:
yield event.plain_result("场景编号必须是1-3之间的数字")
return
if perm_type not in ["admin", "member"]:
yield event.plain_result("权限类型错误只能是admin或member")
return
scene_num = int(scene_num)
scene = RstScene.from_index(scene_num)
scene_key = scene.key
self.update_reset_permission(scene_key, perm_type)
yield event.plain_result(
f"已将 reset 命令在{scene.name}场景下的权限设为{perm_type}"
)
return
if cmd_type not in ["admin", "member"]:
yield event.plain_result("指令类型错误,可选类型有 admin, member")
return
@@ -1326,3 +1429,18 @@ UID: {user_id} 此 ID 可用于设置管理员。
)
yield event.plain_result(f"已将 {cmd_name} 设置为 {cmd_type} 指令")
def update_reset_permission(self, scene_key: str, perm_type: str):
"""更新reset命令在特定场景下的权限设置
Args:
scene_key (str): 场景编号1-3
perm_type (str): 权限类型admin或member
"""
alter_cmd_cfg = sp.get("alter_cmd", {})
plugin_cfg = alter_cmd_cfg.get("astrbot", {})
reset_cfg = plugin_cfg.get("reset", {})
reset_cfg[scene_key] = perm_type
plugin_cfg["reset"] = reset_cfg
alter_cmd_cfg["astrbot"] = plugin_cfg
sp.put("alter_cmd", alter_cmd_cfg)

View File

@@ -1,6 +1,6 @@
[project]
name = "AstrBot"
version = "3.5.5"
version = "3.5.6"
description = "易上手的多平台 LLM 聊天机器人及开发框架"
readme = "README.md"
requires-python = ">=3.10"
@@ -40,6 +40,13 @@ dependencies = [
"wechatpy>=1.8.18",
]
[project.scripts]
astrbot = "astrbot.cli.__main__:cli"
[build-system]
requires = ["hatchling", "uv-dynamic-versioning"]
build-backend = "hatchling.build"
[tool.ruff]
exclude = [
"astrbot/core/utils/t2i/local_strategy.py",

1219
uv.lock generated

File diff suppressed because it is too large Load Diff