Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 55e1431084 | |||
| 418f05f6e4 | |||
| df421e5554 | |||
| ed84074a60 | |||
| bbf61239ad | |||
| 92ee534a2c | |||
| fa4df0b5f3 | |||
| e5ac31efe7 | |||
| 2a7745c767 | |||
| 82e7502f74 | |||
| 866e546b59 | |||
| 6b642d7674 | |||
| 0711ec346f | |||
| 0dbe32e2dc | |||
| 4e855a17bc | |||
| f2fc724e0f | |||
| 460acf40c0 | |||
| cf29d9390f | |||
| ac44d1fdef | |||
| 66d0f0afd4 |
@@ -36,7 +36,7 @@ jobs:
|
||||
zip -r dist.zip dist
|
||||
|
||||
- name: Archive production artifacts
|
||||
uses: actions/upload-artifact@v6
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: dist-without-markdown
|
||||
path: |
|
||||
|
||||
@@ -71,7 +71,7 @@ jobs:
|
||||
zip -r "AstrBot-${{ steps.tag.outputs.tag }}-dashboard.zip" dist
|
||||
|
||||
- name: Upload dashboard artifact
|
||||
uses: actions/upload-artifact@v6
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: Dashboard-${{ steps.tag.outputs.tag }}
|
||||
if-no-files-found: error
|
||||
@@ -132,7 +132,7 @@ jobs:
|
||||
echo "tag=$tag" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Download dashboard artifact
|
||||
uses: actions/download-artifact@v7
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: Dashboard-${{ steps.tag.outputs.tag }}
|
||||
path: release-assets
|
||||
|
||||
@@ -36,6 +36,9 @@ dashboard/dist/
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
|
||||
# Bundled dashboard dist (generated by hatch_build.py during pip wheel build)
|
||||
astrbot/dashboard/dist/
|
||||
|
||||
# Operating System
|
||||
**/.DS_Store
|
||||
.DS_Store
|
||||
|
||||
@@ -184,6 +184,12 @@ Connect AstrBot to your favorite chat platform.
|
||||
| Minimax TTS | Text-to-Speech Services |
|
||||
| Volcano Engine TTS | Text-to-Speech Services |
|
||||
|
||||
## ❤️ Sponsors
|
||||
|
||||
<p align="center">
|
||||
<img alt="sponsors" src="https://sponsors.astrbot.app/?v=1">
|
||||
</p>
|
||||
|
||||
## ❤️ Contributing
|
||||
|
||||
Issues and Pull Requests are always welcome! Feel free to submit your changes to this project :)
|
||||
|
||||
@@ -8,7 +8,7 @@ from bs4 import BeautifulSoup
|
||||
from readability import Document
|
||||
|
||||
from astrbot.api import AstrBotConfig, llm_tool, logger, sp, star
|
||||
from astrbot.api.event import AstrMessageEvent, MessageEventResult, filter
|
||||
from astrbot.api.event import AstrMessageEvent, filter
|
||||
from astrbot.api.provider import ProviderRequest
|
||||
from astrbot.core.provider.func_tool_manager import FunctionToolManager
|
||||
|
||||
@@ -196,15 +196,6 @@ class Main(star.Star):
|
||||
)
|
||||
return results
|
||||
|
||||
@filter.command("websearch")
|
||||
async def websearch(self, event: AstrMessageEvent, oper: str | None = None) -> None:
|
||||
"""网页搜索指令(已废弃)"""
|
||||
event.set_result(
|
||||
MessageEventResult().message(
|
||||
"此指令已经被废弃,请在 WebUI 中开启或关闭网页搜索功能。",
|
||||
),
|
||||
)
|
||||
|
||||
@llm_tool(name="web_search")
|
||||
async def search_from_search_engine(
|
||||
self,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""AstrBot CLI入口"""
|
||||
"""AstrBot CLI entry point"""
|
||||
|
||||
import sys
|
||||
|
||||
@@ -29,23 +29,23 @@ def cli() -> None:
|
||||
@click.command()
|
||||
@click.argument("command_name", required=False, type=str)
|
||||
def help(command_name: str | None) -> None:
|
||||
"""显示命令的帮助信息
|
||||
"""Display help information for commands
|
||||
|
||||
如果提供了 COMMAND_NAME,则显示该命令的详细帮助信息。
|
||||
否则,显示通用帮助信息。
|
||||
If COMMAND_NAME is provided, display detailed help for that command.
|
||||
Otherwise, display general help information.
|
||||
"""
|
||||
ctx = click.get_current_context()
|
||||
if command_name:
|
||||
# 查找指定命令
|
||||
# Find the specified command
|
||||
command = cli.get_command(ctx, command_name)
|
||||
if command:
|
||||
# 显示特定命令的帮助信息
|
||||
# Display help for the specific command
|
||||
click.echo(command.get_help(ctx))
|
||||
else:
|
||||
click.echo(f"Unknown command: {command_name}")
|
||||
sys.exit(1)
|
||||
else:
|
||||
# 显示通用帮助信息
|
||||
# Display general help information
|
||||
click.echo(cli.get_help(ctx))
|
||||
|
||||
|
||||
|
||||
@@ -10,57 +10,61 @@ from ..utils import check_astrbot_root, get_astrbot_root
|
||||
|
||||
|
||||
def _validate_log_level(value: str) -> str:
|
||||
"""验证日志级别"""
|
||||
"""Validate log level"""
|
||||
value = value.upper()
|
||||
if value not in ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]:
|
||||
raise click.ClickException(
|
||||
"日志级别必须是 DEBUG/INFO/WARNING/ERROR/CRITICAL 之一",
|
||||
"Log level must be one of DEBUG/INFO/WARNING/ERROR/CRITICAL",
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def _validate_dashboard_port(value: str) -> int:
|
||||
"""验证 Dashboard 端口"""
|
||||
"""Validate Dashboard port"""
|
||||
try:
|
||||
port = int(value)
|
||||
if port < 1 or port > 65535:
|
||||
raise click.ClickException("端口必须在 1-65535 范围内")
|
||||
raise click.ClickException("Port must be in range 1-65535")
|
||||
return port
|
||||
except ValueError:
|
||||
raise click.ClickException("端口必须是数字")
|
||||
raise click.ClickException("Port must be a number")
|
||||
|
||||
|
||||
def _validate_dashboard_username(value: str) -> str:
|
||||
"""验证 Dashboard 用户名"""
|
||||
"""Validate Dashboard username"""
|
||||
if not value:
|
||||
raise click.ClickException("用户名不能为空")
|
||||
raise click.ClickException("Username cannot be empty")
|
||||
return value
|
||||
|
||||
|
||||
def _validate_dashboard_password(value: str) -> str:
|
||||
"""验证 Dashboard 密码"""
|
||||
"""Validate Dashboard password"""
|
||||
if not value:
|
||||
raise click.ClickException("密码不能为空")
|
||||
raise click.ClickException("Password cannot be empty")
|
||||
return hashlib.md5(value.encode()).hexdigest()
|
||||
|
||||
|
||||
def _validate_timezone(value: str) -> str:
|
||||
"""验证时区"""
|
||||
"""Validate timezone"""
|
||||
try:
|
||||
zoneinfo.ZoneInfo(value)
|
||||
except Exception:
|
||||
raise click.ClickException(f"无效的时区: {value},请使用有效的IANA时区名称")
|
||||
raise click.ClickException(
|
||||
f"Invalid timezone: {value}. Please use a valid IANA timezone name"
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def _validate_callback_api_base(value: str) -> str:
|
||||
"""验证回调接口基址"""
|
||||
"""Validate callback API base URL"""
|
||||
if not value.startswith("http://") and not value.startswith("https://"):
|
||||
raise click.ClickException("回调接口基址必须以 http:// 或 https:// 开头")
|
||||
raise click.ClickException(
|
||||
"Callback API base must start with http:// or https://"
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
# 可通过CLI设置的配置项,配置键到验证器函数的映射
|
||||
# Configuration items settable via CLI, mapping config keys to validator functions
|
||||
CONFIG_VALIDATORS: dict[str, Callable[[str], Any]] = {
|
||||
"timezone": _validate_timezone,
|
||||
"log_level": _validate_log_level,
|
||||
@@ -72,11 +76,11 @@ CONFIG_VALIDATORS: dict[str, Callable[[str], Any]] = {
|
||||
|
||||
|
||||
def _load_config() -> dict[str, Any]:
|
||||
"""加载或初始化配置文件"""
|
||||
"""Load or initialize config file"""
|
||||
root = get_astrbot_root()
|
||||
if not check_astrbot_root(root):
|
||||
raise click.ClickException(
|
||||
f"{root}不是有效的 AstrBot 根目录,如需初始化请使用 astrbot init",
|
||||
f"{root} is not a valid AstrBot root directory. Use 'astrbot init' to initialize",
|
||||
)
|
||||
|
||||
config_path = root / "data" / "cmd_config.json"
|
||||
@@ -91,11 +95,11 @@ def _load_config() -> dict[str, Any]:
|
||||
try:
|
||||
return json.loads(config_path.read_text(encoding="utf-8-sig"))
|
||||
except json.JSONDecodeError as e:
|
||||
raise click.ClickException(f"配置文件解析失败: {e!s}")
|
||||
raise click.ClickException(f"Failed to parse config file: {e!s}")
|
||||
|
||||
|
||||
def _save_config(config: dict[str, Any]) -> None:
|
||||
"""保存配置文件"""
|
||||
"""Save config file"""
|
||||
config_path = get_astrbot_root() / "data" / "cmd_config.json"
|
||||
|
||||
config_path.write_text(
|
||||
@@ -105,21 +109,21 @@ def _save_config(config: dict[str, Any]) -> None:
|
||||
|
||||
|
||||
def _set_nested_item(obj: dict[str, Any], path: str, value: Any) -> None:
|
||||
"""设置嵌套字典中的值"""
|
||||
"""Set a value in a nested dictionary"""
|
||||
parts = path.split(".")
|
||||
for part in parts[:-1]:
|
||||
if part not in obj:
|
||||
obj[part] = {}
|
||||
elif not isinstance(obj[part], dict):
|
||||
raise click.ClickException(
|
||||
f"配置路径冲突: {'.'.join(parts[: parts.index(part) + 1])} 不是字典",
|
||||
f"Config path conflict: {'.'.join(parts[: parts.index(part) + 1])} is not a dict",
|
||||
)
|
||||
obj = obj[part]
|
||||
obj[parts[-1]] = value
|
||||
|
||||
|
||||
def _get_nested_item(obj: dict[str, Any], path: str) -> Any:
|
||||
"""获取嵌套字典中的值"""
|
||||
"""Get a value from a nested dictionary"""
|
||||
parts = path.split(".")
|
||||
for part in parts:
|
||||
obj = obj[part]
|
||||
@@ -128,21 +132,21 @@ def _get_nested_item(obj: dict[str, Any], path: str) -> Any:
|
||||
|
||||
@click.group(name="conf")
|
||||
def conf() -> None:
|
||||
"""配置管理命令
|
||||
"""Configuration management commands
|
||||
|
||||
支持的配置项:
|
||||
Supported config keys:
|
||||
|
||||
- timezone: 时区设置 (例如: Asia/Shanghai)
|
||||
- timezone: Timezone setting (e.g. Asia/Shanghai)
|
||||
|
||||
- log_level: 日志级别 (DEBUG/INFO/WARNING/ERROR/CRITICAL)
|
||||
- log_level: Log level (DEBUG/INFO/WARNING/ERROR/CRITICAL)
|
||||
|
||||
- dashboard.port: Dashboard 端口
|
||||
- dashboard.port: Dashboard port
|
||||
|
||||
- dashboard.username: Dashboard 用户名
|
||||
- dashboard.username: Dashboard username
|
||||
|
||||
- dashboard.password: Dashboard 密码
|
||||
- dashboard.password: Dashboard password
|
||||
|
||||
- callback_api_base: 回调接口基址
|
||||
- callback_api_base: Callback API base URL
|
||||
"""
|
||||
|
||||
|
||||
@@ -150,9 +154,9 @@ def conf() -> None:
|
||||
@click.argument("key")
|
||||
@click.argument("value")
|
||||
def set_config(key: str, value: str) -> None:
|
||||
"""设置配置项的值"""
|
||||
"""Set the value of a config item"""
|
||||
if key not in CONFIG_VALIDATORS:
|
||||
raise click.ClickException(f"不支持的配置项: {key}")
|
||||
raise click.ClickException(f"Unsupported config key: {key}")
|
||||
|
||||
config = _load_config()
|
||||
|
||||
@@ -162,29 +166,29 @@ def set_config(key: str, value: str) -> None:
|
||||
_set_nested_item(config, key, validated_value)
|
||||
_save_config(config)
|
||||
|
||||
click.echo(f"配置已更新: {key}")
|
||||
click.echo(f"Config updated: {key}")
|
||||
if key == "dashboard.password":
|
||||
click.echo(" 原值: ********")
|
||||
click.echo(" 新值: ********")
|
||||
click.echo(" Old value: ********")
|
||||
click.echo(" New value: ********")
|
||||
else:
|
||||
click.echo(f" 原值: {old_value}")
|
||||
click.echo(f" 新值: {validated_value}")
|
||||
click.echo(f" Old value: {old_value}")
|
||||
click.echo(f" New value: {validated_value}")
|
||||
|
||||
except KeyError:
|
||||
raise click.ClickException(f"未知的配置项: {key}")
|
||||
raise click.ClickException(f"Unknown config key: {key}")
|
||||
except Exception as e:
|
||||
raise click.UsageError(f"设置配置失败: {e!s}")
|
||||
raise click.UsageError(f"Failed to set config: {e!s}")
|
||||
|
||||
|
||||
@conf.command(name="get")
|
||||
@click.argument("key", required=False)
|
||||
def get_config(key: str | None = None) -> None:
|
||||
"""获取配置项的值,不提供key则显示所有可配置项"""
|
||||
"""Get the value of a config item. If no key is provided, show all configurable items"""
|
||||
config = _load_config()
|
||||
|
||||
if key:
|
||||
if key not in CONFIG_VALIDATORS:
|
||||
raise click.ClickException(f"不支持的配置项: {key}")
|
||||
raise click.ClickException(f"Unsupported config key: {key}")
|
||||
|
||||
try:
|
||||
value = _get_nested_item(config, key)
|
||||
@@ -192,11 +196,11 @@ def get_config(key: str | None = None) -> None:
|
||||
value = "********"
|
||||
click.echo(f"{key}: {value}")
|
||||
except KeyError:
|
||||
raise click.ClickException(f"未知的配置项: {key}")
|
||||
raise click.ClickException(f"Unknown config key: {key}")
|
||||
except Exception as e:
|
||||
raise click.UsageError(f"获取配置失败: {e!s}")
|
||||
raise click.UsageError(f"Failed to get config: {e!s}")
|
||||
else:
|
||||
click.echo("当前配置:")
|
||||
click.echo("Current config:")
|
||||
for key in CONFIG_VALIDATORS:
|
||||
try:
|
||||
value = (
|
||||
|
||||
@@ -8,16 +8,12 @@ from ..utils import check_dashboard, get_astrbot_root
|
||||
|
||||
|
||||
async def initialize_astrbot(astrbot_root: Path) -> None:
|
||||
"""执行 AstrBot 初始化逻辑"""
|
||||
"""Execute AstrBot initialization logic"""
|
||||
dot_astrbot = astrbot_root / ".astrbot"
|
||||
|
||||
if not dot_astrbot.exists():
|
||||
click.echo(f"Current Directory: {astrbot_root}")
|
||||
click.echo(
|
||||
"如果你确认这是 Astrbot root directory, 你需要在当前目录下创建一个 .astrbot 文件标记该目录为 AstrBot 的数据目录。",
|
||||
)
|
||||
if click.confirm(
|
||||
f"请检查当前目录是否正确,确认正确请回车: {astrbot_root}",
|
||||
f"Install AstrBot to this directory? {astrbot_root}",
|
||||
default=True,
|
||||
abort=True,
|
||||
):
|
||||
@@ -40,7 +36,7 @@ async def initialize_astrbot(astrbot_root: Path) -> None:
|
||||
|
||||
@click.command()
|
||||
def init() -> None:
|
||||
"""初始化 AstrBot"""
|
||||
"""Initialize AstrBot"""
|
||||
click.echo("Initializing AstrBot...")
|
||||
astrbot_root = get_astrbot_root()
|
||||
lock_file = astrbot_root / "astrbot.lock"
|
||||
@@ -49,8 +45,11 @@ def init() -> None:
|
||||
try:
|
||||
with lock.acquire():
|
||||
asyncio.run(initialize_astrbot(astrbot_root))
|
||||
click.echo("Done! You can now run 'astrbot run' to start AstrBot")
|
||||
except Timeout:
|
||||
raise click.ClickException("无法获取锁文件,请检查是否有其他实例正在运行")
|
||||
raise click.ClickException(
|
||||
"Cannot acquire lock file. Please check if another instance is running"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
raise click.ClickException(f"初始化失败: {e!s}")
|
||||
raise click.ClickException(f"Initialization failed: {e!s}")
|
||||
|
||||
@@ -16,14 +16,14 @@ from ..utils import (
|
||||
|
||||
@click.group()
|
||||
def plug() -> None:
|
||||
"""插件管理"""
|
||||
"""Plugin management"""
|
||||
|
||||
|
||||
def _get_data_path() -> Path:
|
||||
base = get_astrbot_root()
|
||||
if not check_astrbot_root(base):
|
||||
raise click.ClickException(
|
||||
f"{base}不是有效的 AstrBot 根目录,如需初始化请使用 astrbot init",
|
||||
f"{base} is not a valid AstrBot root directory. Use 'astrbot init' to initialize",
|
||||
)
|
||||
return (base / "data").resolve()
|
||||
|
||||
@@ -32,7 +32,9 @@ def display_plugins(plugins, title=None, color=None) -> None:
|
||||
if title:
|
||||
click.echo(click.style(title, fg=color, bold=True))
|
||||
|
||||
click.echo(f"{'名称':<20} {'版本':<10} {'状态':<10} {'作者':<15} {'描述':<30}")
|
||||
click.echo(
|
||||
f"{'Name':<20} {'Version':<10} {'Status':<10} {'Author':<15} {'Description':<30}"
|
||||
)
|
||||
click.echo("-" * 85)
|
||||
|
||||
for p in plugins:
|
||||
@@ -46,30 +48,30 @@ def display_plugins(plugins, title=None, color=None) -> None:
|
||||
@plug.command()
|
||||
@click.argument("name")
|
||||
def new(name: str) -> None:
|
||||
"""创建新插件"""
|
||||
"""Create a new plugin"""
|
||||
base_path = _get_data_path()
|
||||
plug_path = base_path / "plugins" / name
|
||||
|
||||
if plug_path.exists():
|
||||
raise click.ClickException(f"插件 {name} 已存在")
|
||||
raise click.ClickException(f"Plugin {name} already exists")
|
||||
|
||||
author = click.prompt("请输入插件作者", type=str)
|
||||
desc = click.prompt("请输入插件描述", type=str)
|
||||
version = click.prompt("请输入插件版本", type=str)
|
||||
author = click.prompt("Enter plugin author", type=str)
|
||||
desc = click.prompt("Enter plugin description", type=str)
|
||||
version = click.prompt("Enter plugin version", type=str)
|
||||
if not re.match(r"^\d+\.\d+(\.\d+)?$", version.lower().lstrip("v")):
|
||||
raise click.ClickException("版本号必须为 x.y 或 x.y.z 格式")
|
||||
repo = click.prompt("请输入插件仓库:", type=str)
|
||||
raise click.ClickException("Version must be in x.y or x.y.z format")
|
||||
repo = click.prompt("Enter plugin repository URL:", type=str)
|
||||
if not repo.startswith("http"):
|
||||
raise click.ClickException("仓库地址必须以 http 开头")
|
||||
raise click.ClickException("Repository URL must start with http")
|
||||
|
||||
click.echo("下载插件模板...")
|
||||
click.echo("Downloading plugin template...")
|
||||
get_git_repo(
|
||||
"https://github.com/Soulter/helloworld",
|
||||
plug_path,
|
||||
)
|
||||
|
||||
click.echo("重写插件信息...")
|
||||
# 重写 metadata.yaml
|
||||
click.echo("Rewriting plugin metadata...")
|
||||
# Rewrite metadata.yaml
|
||||
with open(plug_path / "metadata.yaml", "w", encoding="utf-8") as f:
|
||||
f.write(
|
||||
f"name: {name}\n"
|
||||
@@ -79,11 +81,13 @@ def new(name: str) -> None:
|
||||
f"repo: {repo}\n",
|
||||
)
|
||||
|
||||
# 重写 README.md
|
||||
# Rewrite README.md
|
||||
with open(plug_path / "README.md", "w", encoding="utf-8") as f:
|
||||
f.write(f"# {name}\n\n{desc}\n\n# 支持\n\n[帮助文档](https://astrbot.app)\n")
|
||||
f.write(
|
||||
f"# {name}\n\n{desc}\n\n# Support\n\n[Documentation](https://astrbot.app)\n"
|
||||
)
|
||||
|
||||
# 重写 main.py
|
||||
# Rewrite main.py
|
||||
with open(plug_path / "main.py", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
|
||||
@@ -95,54 +99,54 @@ def new(name: str) -> None:
|
||||
with open(plug_path / "main.py", "w", encoding="utf-8") as f:
|
||||
f.write(new_content)
|
||||
|
||||
click.echo(f"插件 {name} 创建成功")
|
||||
click.echo(f"Plugin {name} created successfully")
|
||||
|
||||
|
||||
@plug.command()
|
||||
@click.option("--all", "-a", is_flag=True, help="列出未安装的插件")
|
||||
@click.option("--all", "-a", is_flag=True, help="List uninstalled plugins")
|
||||
def list(all: bool) -> None:
|
||||
"""列出插件"""
|
||||
"""List plugins"""
|
||||
base_path = _get_data_path()
|
||||
plugins = build_plug_list(base_path / "plugins")
|
||||
|
||||
# 未发布的插件
|
||||
# Unpublished plugins
|
||||
not_published_plugins = [
|
||||
p for p in plugins if p["status"] == PluginStatus.NOT_PUBLISHED
|
||||
]
|
||||
if not_published_plugins:
|
||||
display_plugins(not_published_plugins, "未发布的插件", "red")
|
||||
display_plugins(not_published_plugins, "Unpublished Plugins", "red")
|
||||
|
||||
# 需要更新的插件
|
||||
# Plugins needing update
|
||||
need_update_plugins = [
|
||||
p for p in plugins if p["status"] == PluginStatus.NEED_UPDATE
|
||||
]
|
||||
if need_update_plugins:
|
||||
display_plugins(need_update_plugins, "需要更新的插件", "yellow")
|
||||
display_plugins(need_update_plugins, "Plugins Needing Update", "yellow")
|
||||
|
||||
# 已安装的插件
|
||||
# Installed plugins
|
||||
installed_plugins = [p for p in plugins if p["status"] == PluginStatus.INSTALLED]
|
||||
if installed_plugins:
|
||||
display_plugins(installed_plugins, "已安装的插件", "green")
|
||||
display_plugins(installed_plugins, "Installed Plugins", "green")
|
||||
|
||||
# 未安装的插件
|
||||
# Uninstalled plugins
|
||||
not_installed_plugins = [
|
||||
p for p in plugins if p["status"] == PluginStatus.NOT_INSTALLED
|
||||
]
|
||||
if not_installed_plugins and all:
|
||||
display_plugins(not_installed_plugins, "未安装的插件", "blue")
|
||||
display_plugins(not_installed_plugins, "Uninstalled Plugins", "blue")
|
||||
|
||||
if (
|
||||
not any([not_published_plugins, need_update_plugins, installed_plugins])
|
||||
and not all
|
||||
):
|
||||
click.echo("未安装任何插件")
|
||||
click.echo("No plugins installed")
|
||||
|
||||
|
||||
@plug.command()
|
||||
@click.argument("name")
|
||||
@click.option("--proxy", help="代理服务器地址")
|
||||
@click.option("--proxy", help="Proxy server address")
|
||||
def install(name: str, proxy: str | None) -> None:
|
||||
"""安装插件"""
|
||||
"""Install a plugin"""
|
||||
base_path = _get_data_path()
|
||||
plug_path = base_path / "plugins"
|
||||
plugins = build_plug_list(base_path / "plugins")
|
||||
@@ -157,7 +161,7 @@ def install(name: str, proxy: str | None) -> None:
|
||||
)
|
||||
|
||||
if not plugin:
|
||||
raise click.ClickException(f"未找到可安装的插件 {name},可能是不存在或已安装")
|
||||
raise click.ClickException(f"Plugin {name} not found or already installed")
|
||||
|
||||
manage_plugin(plugin, plug_path, is_update=False, proxy=proxy)
|
||||
|
||||
@@ -165,30 +169,32 @@ def install(name: str, proxy: str | None) -> None:
|
||||
@plug.command()
|
||||
@click.argument("name")
|
||||
def remove(name: str) -> None:
|
||||
"""卸载插件"""
|
||||
"""Uninstall a plugin"""
|
||||
base_path = _get_data_path()
|
||||
plugins = build_plug_list(base_path / "plugins")
|
||||
plugin = next((p for p in plugins if p["name"] == name), None)
|
||||
|
||||
if not plugin or not plugin.get("local_path"):
|
||||
raise click.ClickException(f"插件 {name} 不存在或未安装")
|
||||
raise click.ClickException(f"Plugin {name} does not exist or is not installed")
|
||||
|
||||
plugin_path = plugin["local_path"]
|
||||
|
||||
click.confirm(f"确定要卸载插件 {name} 吗?", default=False, abort=True)
|
||||
click.confirm(
|
||||
f"Are you sure you want to uninstall plugin {name}?", default=False, abort=True
|
||||
)
|
||||
|
||||
try:
|
||||
shutil.rmtree(plugin_path)
|
||||
click.echo(f"插件 {name} 已卸载")
|
||||
click.echo(f"Plugin {name} has been uninstalled")
|
||||
except Exception as e:
|
||||
raise click.ClickException(f"卸载插件 {name} 失败: {e}")
|
||||
raise click.ClickException(f"Failed to uninstall plugin {name}: {e}")
|
||||
|
||||
|
||||
@plug.command()
|
||||
@click.argument("name", required=False)
|
||||
@click.option("--proxy", help="Github代理地址")
|
||||
@click.option("--proxy", help="GitHub proxy address")
|
||||
def update(name: str, proxy: str | None) -> None:
|
||||
"""更新插件"""
|
||||
"""Update plugins"""
|
||||
base_path = _get_data_path()
|
||||
plug_path = base_path / "plugins"
|
||||
plugins = build_plug_list(base_path / "plugins")
|
||||
@@ -204,7 +210,9 @@ def update(name: str, proxy: str | None) -> None:
|
||||
)
|
||||
|
||||
if not plugin:
|
||||
raise click.ClickException(f"插件 {name} 不需要更新或无法更新")
|
||||
raise click.ClickException(
|
||||
f"Plugin {name} does not need updating or cannot be updated"
|
||||
)
|
||||
|
||||
manage_plugin(plugin, plug_path, is_update=True, proxy=proxy)
|
||||
else:
|
||||
@@ -213,20 +221,20 @@ def update(name: str, proxy: str | None) -> None:
|
||||
]
|
||||
|
||||
if not need_update_plugins:
|
||||
click.echo("没有需要更新的插件")
|
||||
click.echo("No plugins need updating")
|
||||
return
|
||||
|
||||
click.echo(f"发现 {len(need_update_plugins)} 个插件需要更新")
|
||||
click.echo(f"Found {len(need_update_plugins)} plugin(s) needing update")
|
||||
for plugin in need_update_plugins:
|
||||
plugin_name = plugin["name"]
|
||||
click.echo(f"正在更新插件 {plugin_name}...")
|
||||
click.echo(f"Updating plugin {plugin_name}...")
|
||||
manage_plugin(plugin, plug_path, is_update=True, proxy=proxy)
|
||||
|
||||
|
||||
@plug.command()
|
||||
@click.argument("query")
|
||||
def search(query: str) -> None:
|
||||
"""搜索插件"""
|
||||
"""Search for plugins"""
|
||||
base_path = _get_data_path()
|
||||
plugins = build_plug_list(base_path / "plugins")
|
||||
|
||||
@@ -239,7 +247,7 @@ def search(query: str) -> None:
|
||||
]
|
||||
|
||||
if not matched_plugins:
|
||||
click.echo(f"未找到匹配 '{query}' 的插件")
|
||||
click.echo(f"No plugins matching '{query}' found")
|
||||
return
|
||||
|
||||
display_plugins(matched_plugins, f"搜索结果: '{query}'", "cyan")
|
||||
display_plugins(matched_plugins, f"Search results: '{query}'", "cyan")
|
||||
|
||||
@@ -11,7 +11,7 @@ from ..utils import check_astrbot_root, check_dashboard, get_astrbot_root
|
||||
|
||||
|
||||
async def run_astrbot(astrbot_root: Path) -> None:
|
||||
"""运行 AstrBot"""
|
||||
"""Run AstrBot"""
|
||||
from astrbot.core import LogBroker, LogManager, db_helper, logger
|
||||
from astrbot.core.initial_loader import InitialLoader
|
||||
|
||||
@@ -26,18 +26,18 @@ async def run_astrbot(astrbot_root: Path) -> None:
|
||||
await core_lifecycle.start()
|
||||
|
||||
|
||||
@click.option("--reload", "-r", is_flag=True, help="插件自动重载")
|
||||
@click.option("--port", "-p", help="Astrbot Dashboard端口", required=False, type=str)
|
||||
@click.option("--reload", "-r", is_flag=True, help="Auto-reload plugins")
|
||||
@click.option("--port", "-p", help="AstrBot Dashboard port", required=False, type=str)
|
||||
@click.command()
|
||||
def run(reload: bool, port: str) -> None:
|
||||
"""运行 AstrBot"""
|
||||
"""Run AstrBot"""
|
||||
try:
|
||||
os.environ["ASTRBOT_CLI"] = "1"
|
||||
astrbot_root = get_astrbot_root()
|
||||
|
||||
if not check_astrbot_root(astrbot_root):
|
||||
raise click.ClickException(
|
||||
f"{astrbot_root}不是有效的 AstrBot 根目录,如需初始化请使用 astrbot init",
|
||||
f"{astrbot_root} is not a valid AstrBot root directory. Use 'astrbot init' to initialize",
|
||||
)
|
||||
|
||||
os.environ["ASTRBOT_ROOT"] = str(astrbot_root)
|
||||
@@ -47,7 +47,7 @@ def run(reload: bool, port: str) -> None:
|
||||
os.environ["DASHBOARD_PORT"] = port
|
||||
|
||||
if reload:
|
||||
click.echo("启用插件自动重载")
|
||||
click.echo("Plugin auto-reload enabled")
|
||||
os.environ["ASTRBOT_RELOAD"] = "1"
|
||||
|
||||
lock_file = astrbot_root / "astrbot.lock"
|
||||
@@ -55,8 +55,10 @@ def run(reload: bool, port: str) -> None:
|
||||
with lock.acquire():
|
||||
asyncio.run(run_astrbot(astrbot_root))
|
||||
except KeyboardInterrupt:
|
||||
click.echo("AstrBot 已关闭...")
|
||||
click.echo("AstrBot has been shut down.")
|
||||
except Timeout:
|
||||
raise click.ClickException("无法获取锁文件,请检查是否有其他实例正在运行")
|
||||
raise click.ClickException(
|
||||
"Cannot acquire lock file. Please check if another instance is running"
|
||||
)
|
||||
except Exception as e:
|
||||
raise click.ClickException(f"运行时出现错误: {e}\n{traceback.format_exc()}")
|
||||
raise click.ClickException(f"Runtime error: {e}\n{traceback.format_exc()}")
|
||||
|
||||
+21
-13
@@ -2,9 +2,12 @@ from pathlib import Path
|
||||
|
||||
import click
|
||||
|
||||
# Static assets bundled inside the installed wheel (built by hatch_build.py).
|
||||
_BUNDLED_DIST = Path(__file__).parent.parent.parent / "dashboard" / "dist"
|
||||
|
||||
|
||||
def check_astrbot_root(path: str | Path) -> bool:
|
||||
"""检查路径是否为 AstrBot 根目录"""
|
||||
"""Check if the path is an AstrBot root directory"""
|
||||
if not isinstance(path, Path):
|
||||
path = Path(path)
|
||||
if not path.exists() or not path.is_dir():
|
||||
@@ -15,43 +18,48 @@ def check_astrbot_root(path: str | Path) -> bool:
|
||||
|
||||
|
||||
def get_astrbot_root() -> Path:
|
||||
"""获取Astrbot根目录路径"""
|
||||
"""Get the AstrBot root directory path"""
|
||||
return Path.cwd()
|
||||
|
||||
|
||||
async def check_dashboard(astrbot_root: Path) -> None:
|
||||
"""检查是否安装了dashboard"""
|
||||
"""Check if the dashboard is installed"""
|
||||
from astrbot.core.config.default import VERSION
|
||||
from astrbot.core.utils.io import download_dashboard, get_dashboard_version
|
||||
|
||||
from .version_comparator import VersionComparator
|
||||
|
||||
# If the wheel ships bundled dashboard assets, no network download is needed.
|
||||
if _BUNDLED_DIST.exists():
|
||||
click.echo("Dashboard is bundled with the package – skipping download.")
|
||||
return
|
||||
|
||||
try:
|
||||
dashboard_version = await get_dashboard_version()
|
||||
match dashboard_version:
|
||||
case None:
|
||||
click.echo("未安装管理面板")
|
||||
click.echo("Dashboard is not installed")
|
||||
if click.confirm(
|
||||
"是否安装管理面板?",
|
||||
"Install dashboard?",
|
||||
default=True,
|
||||
abort=True,
|
||||
):
|
||||
click.echo("正在安装管理面板...")
|
||||
click.echo("Installing dashboard...")
|
||||
await download_dashboard(
|
||||
path="data/dashboard.zip",
|
||||
extract_path=str(astrbot_root),
|
||||
version=f"v{VERSION}",
|
||||
latest=False,
|
||||
)
|
||||
click.echo("管理面板安装完成")
|
||||
click.echo("Dashboard installed successfully")
|
||||
|
||||
case str():
|
||||
if VersionComparator.compare_version(VERSION, dashboard_version) <= 0:
|
||||
click.echo("管理面板已是最新版本")
|
||||
click.echo("Dashboard is already up to date")
|
||||
return
|
||||
try:
|
||||
version = dashboard_version.split("v")[1]
|
||||
click.echo(f"管理面板版本: {version}")
|
||||
click.echo(f"Dashboard version: {version}")
|
||||
await download_dashboard(
|
||||
path="data/dashboard.zip",
|
||||
extract_path=str(astrbot_root),
|
||||
@@ -59,10 +67,10 @@ async def check_dashboard(astrbot_root: Path) -> None:
|
||||
latest=False,
|
||||
)
|
||||
except Exception as e:
|
||||
click.echo(f"下载管理面板失败: {e}")
|
||||
click.echo(f"Failed to download dashboard: {e}")
|
||||
return
|
||||
except FileNotFoundError:
|
||||
click.echo("初始化管理面板目录...")
|
||||
click.echo("Initializing dashboard directory...")
|
||||
try:
|
||||
await download_dashboard(
|
||||
path=str(astrbot_root / "dashboard.zip"),
|
||||
@@ -70,7 +78,7 @@ async def check_dashboard(astrbot_root: Path) -> None:
|
||||
version=f"v{VERSION}",
|
||||
latest=False,
|
||||
)
|
||||
click.echo("管理面板初始化完成")
|
||||
click.echo("Dashboard initialized successfully")
|
||||
except Exception as e:
|
||||
click.echo(f"下载管理面板失败: {e}")
|
||||
click.echo(f"Failed to download dashboard: {e}")
|
||||
return
|
||||
|
||||
+47
-43
@@ -13,22 +13,22 @@ from .version_comparator import VersionComparator
|
||||
|
||||
|
||||
class PluginStatus(str, Enum):
|
||||
INSTALLED = "已安装"
|
||||
NEED_UPDATE = "需更新"
|
||||
NOT_INSTALLED = "未安装"
|
||||
NOT_PUBLISHED = "未发布"
|
||||
INSTALLED = "installed"
|
||||
NEED_UPDATE = "needs-update"
|
||||
NOT_INSTALLED = "not-installed"
|
||||
NOT_PUBLISHED = "unpublished"
|
||||
|
||||
|
||||
def get_git_repo(url: str, target_path: Path, proxy: str | None = None) -> None:
|
||||
"""从 Git 仓库下载代码并解压到指定路径"""
|
||||
"""Download code from a Git repository and extract to the specified path"""
|
||||
temp_dir = Path(tempfile.mkdtemp())
|
||||
try:
|
||||
# 解析仓库信息
|
||||
# Parse repository info
|
||||
repo_namespace = url.split("/")[-2:]
|
||||
author = repo_namespace[0]
|
||||
repo = repo_namespace[1]
|
||||
|
||||
# 尝试获取最新的 release
|
||||
# Try to get the latest release
|
||||
release_url = f"https://api.github.com/repos/{author}/{repo}/releases"
|
||||
try:
|
||||
with httpx.Client(
|
||||
@@ -40,21 +40,21 @@ def get_git_repo(url: str, target_path: Path, proxy: str | None = None) -> None:
|
||||
releases = resp.json()
|
||||
|
||||
if releases:
|
||||
# 使用最新的 release
|
||||
# Use the latest release
|
||||
download_url = releases[0]["zipball_url"]
|
||||
else:
|
||||
# 没有 release,使用默认分支
|
||||
click.echo(f"正在从默认分支下载 {author}/{repo}")
|
||||
# No release found, use default branch
|
||||
click.echo(f"Downloading {author}/{repo} from default branch")
|
||||
download_url = f"https://github.com/{author}/{repo}/archive/refs/heads/master.zip"
|
||||
except Exception as e:
|
||||
click.echo(f"获取 release 信息失败: {e},将直接使用提供的 URL")
|
||||
click.echo(f"Failed to get release info: {e}. Using provided URL directly")
|
||||
download_url = url
|
||||
|
||||
# 应用代理
|
||||
# Apply proxy
|
||||
if proxy:
|
||||
download_url = f"{proxy}/{download_url}"
|
||||
|
||||
# 下载并解压
|
||||
# Download and extract
|
||||
with httpx.Client(
|
||||
proxy=proxy if proxy else None,
|
||||
follow_redirects=True,
|
||||
@@ -65,7 +65,7 @@ def get_git_repo(url: str, target_path: Path, proxy: str | None = None) -> None:
|
||||
and "archive/refs/heads/master.zip" in download_url
|
||||
):
|
||||
alt_url = download_url.replace("master.zip", "main.zip")
|
||||
click.echo("master 分支不存在,尝试下载 main 分支")
|
||||
click.echo("Branch 'master' not found, trying 'main' branch")
|
||||
resp = client.get(alt_url)
|
||||
resp.raise_for_status()
|
||||
else:
|
||||
@@ -84,13 +84,13 @@ def get_git_repo(url: str, target_path: Path, proxy: str | None = None) -> None:
|
||||
|
||||
|
||||
def load_yaml_metadata(plugin_dir: Path) -> dict:
|
||||
"""从 metadata.yaml 文件加载插件元数据
|
||||
"""Load plugin metadata from metadata.yaml file
|
||||
|
||||
Args:
|
||||
plugin_dir: 插件目录路径
|
||||
plugin_dir: Plugin directory path
|
||||
|
||||
Returns:
|
||||
dict: 包含元数据的字典,如果读取失败则返回空字典
|
||||
dict: Dictionary containing metadata, or empty dict if loading fails
|
||||
|
||||
"""
|
||||
yaml_path = plugin_dir / "metadata.yaml"
|
||||
@@ -98,33 +98,33 @@ def load_yaml_metadata(plugin_dir: Path) -> dict:
|
||||
try:
|
||||
return yaml.safe_load(yaml_path.read_text(encoding="utf-8")) or {}
|
||||
except Exception as e:
|
||||
click.echo(f"读取 {yaml_path} 失败: {e}", err=True)
|
||||
click.echo(f"Failed to read {yaml_path}: {e}", err=True)
|
||||
return {}
|
||||
|
||||
|
||||
def build_plug_list(plugins_dir: Path) -> list:
|
||||
"""构建插件列表,包含本地和在线插件信息
|
||||
"""Build plugin list containing local and online plugin information
|
||||
|
||||
Args:
|
||||
plugins_dir (Path): 插件目录路径
|
||||
plugins_dir (Path): Plugin directory path
|
||||
|
||||
Returns:
|
||||
list: 包含插件信息的字典列表
|
||||
list: List of dicts containing plugin information
|
||||
|
||||
"""
|
||||
# 获取本地插件信息
|
||||
# Get local plugin info
|
||||
result = []
|
||||
if plugins_dir.exists():
|
||||
for plugin_name in [d.name for d in plugins_dir.glob("*") if d.is_dir()]:
|
||||
plugin_dir = plugins_dir / plugin_name
|
||||
|
||||
# 从 metadata.yaml 加载元数据
|
||||
# Load metadata from metadata.yaml
|
||||
metadata = load_yaml_metadata(plugin_dir)
|
||||
|
||||
if "desc" not in metadata and "description" in metadata:
|
||||
metadata["desc"] = metadata["description"]
|
||||
|
||||
# 如果成功加载元数据,添加到结果列表
|
||||
# If metadata loaded successfully, add to result list
|
||||
if metadata and all(
|
||||
k in metadata for k in ["name", "desc", "version", "author", "repo"]
|
||||
):
|
||||
@@ -140,7 +140,7 @@ def build_plug_list(plugins_dir: Path) -> list:
|
||||
},
|
||||
)
|
||||
|
||||
# 获取在线插件列表
|
||||
# Get online plugin list
|
||||
online_plugins = []
|
||||
try:
|
||||
with httpx.Client() as client:
|
||||
@@ -160,13 +160,13 @@ def build_plug_list(plugins_dir: Path) -> list:
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
click.echo(f"获取在线插件列表失败: {e}", err=True)
|
||||
click.echo(f"Failed to get online plugin list: {e}", err=True)
|
||||
|
||||
# 与在线插件比对,更新状态
|
||||
# Compare with online plugins and update status
|
||||
online_plugin_names = {plugin["name"] for plugin in online_plugins}
|
||||
for local_plugin in result:
|
||||
if local_plugin["name"] in online_plugin_names:
|
||||
# 查找对应的在线插件
|
||||
# Find the corresponding online plugin
|
||||
online_plugin = next(
|
||||
p for p in online_plugins if p["name"] == local_plugin["name"]
|
||||
)
|
||||
@@ -179,10 +179,10 @@ def build_plug_list(plugins_dir: Path) -> list:
|
||||
):
|
||||
local_plugin["status"] = PluginStatus.NEED_UPDATE
|
||||
else:
|
||||
# 本地插件未在线上发布
|
||||
# Local plugin is not published online
|
||||
local_plugin["status"] = PluginStatus.NOT_PUBLISHED
|
||||
|
||||
# 添加未安装的在线插件
|
||||
# Add uninstalled online plugins
|
||||
for online_plugin in online_plugins:
|
||||
if not any(plugin["name"] == online_plugin["name"] for plugin in result):
|
||||
result.append(online_plugin)
|
||||
@@ -196,19 +196,19 @@ def manage_plugin(
|
||||
is_update: bool = False,
|
||||
proxy: str | None = None,
|
||||
) -> None:
|
||||
"""安装或更新插件
|
||||
"""Install or update a plugin
|
||||
|
||||
Args:
|
||||
plugin (dict): 插件信息字典
|
||||
plugins_dir (Path): 插件目录
|
||||
is_update (bool, optional): 是否为更新操作. 默认为 False
|
||||
proxy (str, optional): 代理服务器地址
|
||||
plugin (dict): Plugin info dict
|
||||
plugins_dir (Path): Plugins directory
|
||||
is_update (bool, optional): Whether this is an update operation. Defaults to False
|
||||
proxy (str, optional): Proxy server address
|
||||
|
||||
"""
|
||||
plugin_name = plugin["name"]
|
||||
repo_url = plugin["repo"]
|
||||
|
||||
# 如果是更新且有本地路径,直接使用本地路径
|
||||
# If updating and local path exists, use it directly
|
||||
if is_update and plugin.get("local_path"):
|
||||
target_path = Path(plugin["local_path"])
|
||||
else:
|
||||
@@ -216,11 +216,13 @@ def manage_plugin(
|
||||
|
||||
backup_path = Path(f"{target_path}_backup") if is_update else None
|
||||
|
||||
# 检查插件是否存在
|
||||
# Check if plugin exists
|
||||
if is_update and not target_path.exists():
|
||||
raise click.ClickException(f"插件 {plugin_name} 未安装,无法更新")
|
||||
raise click.ClickException(
|
||||
f"Plugin {plugin_name} is not installed and cannot be updated"
|
||||
)
|
||||
|
||||
# 备份现有插件
|
||||
# Backup existing plugin
|
||||
if is_update and backup_path is not None and backup_path.exists():
|
||||
shutil.rmtree(backup_path)
|
||||
if is_update and backup_path is not None:
|
||||
@@ -228,19 +230,21 @@ def manage_plugin(
|
||||
|
||||
try:
|
||||
click.echo(
|
||||
f"正在从 {repo_url} {'更新' if is_update else '下载'}插件 {plugin_name}...",
|
||||
f"{'Updating' if is_update else 'Downloading'} plugin {plugin_name} from {repo_url}...",
|
||||
)
|
||||
get_git_repo(repo_url, target_path, proxy)
|
||||
|
||||
# 更新成功,删除备份
|
||||
# Update succeeded, delete backup
|
||||
if is_update and backup_path is not None and backup_path.exists():
|
||||
shutil.rmtree(backup_path)
|
||||
click.echo(f"插件 {plugin_name} {'更新' if is_update else '安装'}成功")
|
||||
click.echo(
|
||||
f"Plugin {plugin_name} {'updated' if is_update else 'installed'} successfully"
|
||||
)
|
||||
except Exception as e:
|
||||
if target_path.exists():
|
||||
shutil.rmtree(target_path, ignore_errors=True)
|
||||
if is_update and backup_path is not None and backup_path.exists():
|
||||
shutil.move(backup_path, target_path)
|
||||
raise click.ClickException(
|
||||
f"{'更新' if is_update else '安装'}插件 {plugin_name} 时出错: {e}",
|
||||
f"Error {'updating' if is_update else 'installing'} plugin {plugin_name}: {e}",
|
||||
)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""拷贝自 astrbot.core.utils.version_comparator"""
|
||||
"""Copied from astrbot.core.utils.version_comparator"""
|
||||
|
||||
import re
|
||||
|
||||
@@ -6,11 +6,11 @@ import re
|
||||
class VersionComparator:
|
||||
@staticmethod
|
||||
def compare_version(v1: str, v2: str) -> int:
|
||||
"""根据 Semver 语义版本规范来比较版本号的大小。支持不仅局限于 3 个数字的版本号,并处理预发布标签。
|
||||
"""Compare version numbers according to Semver semantics. Supports version numbers with more than 3 digits and handles pre-release tags.
|
||||
|
||||
参考: https://semver.org/lang/zh-CN/
|
||||
Reference: https://semver.org/
|
||||
|
||||
返回 1 表示 v1 > v2,返回 -1 表示 v1 < v2,返回 0 表示 v1 = v2。
|
||||
Returns 1 if v1 > v2, -1 if v1 < v2, 0 if v1 == v2.
|
||||
"""
|
||||
v1 = v1.lower().replace("v", "")
|
||||
v2 = v2.lower().replace("v", "")
|
||||
@@ -24,7 +24,7 @@ class VersionComparator:
|
||||
return [], None
|
||||
major_minor_patch = match.group(1).split(".")
|
||||
prerelease = match.group(2)
|
||||
# buildmetadata = match.group(3) # 构建元数据在比较时忽略
|
||||
# buildmetadata = match.group(3) # Build metadata is ignored in comparison
|
||||
parts = [int(x) for x in major_minor_patch]
|
||||
prerelease = VersionComparator._split_prerelease(prerelease)
|
||||
return parts, prerelease
|
||||
@@ -32,7 +32,7 @@ class VersionComparator:
|
||||
v1_parts, v1_prerelease = split_version(v1)
|
||||
v2_parts, v2_prerelease = split_version(v2)
|
||||
|
||||
# 比较数字部分
|
||||
# Compare numeric parts
|
||||
length = max(len(v1_parts), len(v2_parts))
|
||||
v1_parts.extend([0] * (length - len(v1_parts)))
|
||||
v2_parts.extend([0] * (length - len(v2_parts)))
|
||||
@@ -43,11 +43,11 @@ class VersionComparator:
|
||||
if v1_parts[i] < v2_parts[i]:
|
||||
return -1
|
||||
|
||||
# 比较预发布标签
|
||||
# Compare pre-release tags
|
||||
if v1_prerelease is None and v2_prerelease is not None:
|
||||
return 1 # 没有预发布标签的版本高于有预发布标签的版本
|
||||
return 1 # Version without pre-release tag is higher than one with it
|
||||
if v1_prerelease is not None and v2_prerelease is None:
|
||||
return -1 # 有预发布标签的版本低于没有预发布标签的版本
|
||||
return -1 # Version with pre-release tag is lower than one without it
|
||||
if v1_prerelease is not None and v2_prerelease is not None:
|
||||
len_pre = max(len(v1_prerelease), len(v2_prerelease))
|
||||
for i in range(len_pre):
|
||||
@@ -72,9 +72,9 @@ class VersionComparator:
|
||||
return 1
|
||||
if p1 < p2:
|
||||
return -1
|
||||
return 0 # 预发布标签完全相同
|
||||
return 0 # Pre-release tags are identical
|
||||
|
||||
return 0 # 数字部分和预发布标签都相同
|
||||
return 0 # Both numeric parts and pre-release tags are equal
|
||||
|
||||
@staticmethod
|
||||
def _split_prerelease(prerelease):
|
||||
|
||||
@@ -14,7 +14,7 @@ from .utils.astrbot_path import get_astrbot_data_path
|
||||
# 初始化数据存储文件夹
|
||||
os.makedirs(get_astrbot_data_path(), exist_ok=True)
|
||||
|
||||
DEMO_MODE = os.getenv("DEMO_MODE", False)
|
||||
DEMO_MODE = os.getenv("DEMO_MODE", "False").strip().lower() in ("true", "1", "t")
|
||||
|
||||
astrbot_config = AstrBotConfig()
|
||||
t2i_base_url = astrbot_config.get("t2i_endpoint", "https://t2i.soulter.top/text2img")
|
||||
|
||||
@@ -291,6 +291,9 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
prov_settings: dict = ctx.get_config(umo=umo).get("provider_settings", {})
|
||||
agent_max_step = int(prov_settings.get("max_agent_step", 30))
|
||||
stream = prov_settings.get("streaming_response", False)
|
||||
llm_resp = await ctx.tool_loop_agent(
|
||||
event=event,
|
||||
chat_provider_id=prov_id,
|
||||
@@ -299,9 +302,8 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
|
||||
system_prompt=tool.agent.instructions,
|
||||
tools=toolset,
|
||||
contexts=contexts,
|
||||
max_steps=30,
|
||||
run_hooks=tool.agent.run_hooks,
|
||||
stream=ctx.get_config().get("provider_settings", {}).get("stream", False),
|
||||
max_steps=agent_max_step,
|
||||
stream=stream,
|
||||
)
|
||||
yield mcp.types.CallToolResult(
|
||||
content=[mcp.types.TextContent(type="text", text=llm_resp.completion_text)]
|
||||
|
||||
@@ -846,6 +846,8 @@ def _apply_sandbox_tools(
|
||||
) -> None:
|
||||
if req.func_tool is None:
|
||||
req.func_tool = ToolSet()
|
||||
if req.system_prompt is None:
|
||||
req.system_prompt = ""
|
||||
booter = config.sandbox_cfg.get("booter", "shipyard_neo")
|
||||
if booter == "shipyard":
|
||||
ep = config.sandbox_cfg.get("shipyard_endpoint", "")
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
@@ -41,8 +42,6 @@ def _discover_bay_credentials(endpoint: str) -> str:
|
||||
Returns:
|
||||
API key string, or empty string if not found.
|
||||
"""
|
||||
import os
|
||||
|
||||
candidates: list[Path] = []
|
||||
|
||||
# 1. BAY_DATA_DIR env var
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import platform
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
import mcp
|
||||
@@ -10,6 +11,8 @@ from astrbot.core.computer.computer_client import get_booter, get_local_booter
|
||||
from astrbot.core.computer.tools.permissions import check_admin_permission
|
||||
from astrbot.core.message.message_event_result import MessageChain
|
||||
|
||||
_OS_NAME = platform.system()
|
||||
|
||||
param_schema = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -61,7 +64,7 @@ async def handle_result(result: dict, event: AstrMessageEvent) -> ToolExecResult
|
||||
@dataclass
|
||||
class PythonTool(FunctionTool):
|
||||
name: str = "astrbot_execute_ipython"
|
||||
description: str = "Run codes in an IPython shell."
|
||||
description: str = f"Run codes in an IPython shell. Current OS: {_OS_NAME}."
|
||||
parameters: dict = field(default_factory=lambda: param_schema)
|
||||
|
||||
async def call(
|
||||
@@ -83,7 +86,10 @@ class PythonTool(FunctionTool):
|
||||
@dataclass
|
||||
class LocalPythonTool(FunctionTool):
|
||||
name: str = "astrbot_execute_python"
|
||||
description: str = "Execute codes in a Python environment."
|
||||
description: str = (
|
||||
f"Execute codes in a Python environment. Current OS: {_OS_NAME}. "
|
||||
"Use system-compatible commands."
|
||||
)
|
||||
|
||||
parameters: dict = field(default_factory=lambda: param_schema)
|
||||
|
||||
|
||||
+114
-42
@@ -395,7 +395,6 @@ CONFIG_METADATA_2 = {
|
||||
"discord_token": "",
|
||||
"discord_proxy": "",
|
||||
"discord_command_register": True,
|
||||
"discord_guild_id_for_debug": "",
|
||||
"discord_activity_name": "",
|
||||
},
|
||||
"Misskey": {
|
||||
@@ -450,6 +449,20 @@ CONFIG_METADATA_2 = {
|
||||
"satori_heartbeat_interval": 10,
|
||||
"satori_reconnect_delay": 5,
|
||||
},
|
||||
"kook": {
|
||||
"id": "kook",
|
||||
"type": "kook",
|
||||
"enable": False,
|
||||
"kook_bot_token": "",
|
||||
"kook_bot_nickname": "",
|
||||
"kook_reconnect_delay": 1,
|
||||
"kook_max_reconnect_delay": 60,
|
||||
"kook_max_retry_delay": 60,
|
||||
"kook_heartbeat_interval": 30,
|
||||
"kook_heartbeat_timeout": 6,
|
||||
"kook_max_heartbeat_failures": 3,
|
||||
"kook_max_consecutive_failures": 5,
|
||||
},
|
||||
# "WebChat": {
|
||||
# "id": "webchat",
|
||||
# "type": "webchat",
|
||||
@@ -755,7 +768,8 @@ CONFIG_METADATA_2 = {
|
||||
"hint": "可选的代理地址:http://ip:port",
|
||||
},
|
||||
"discord_command_register": {
|
||||
"description": "是否自动将插件指令注册为 Discord 斜杠指令",
|
||||
"description": "注册 Discord 指令",
|
||||
"hint": "启用后,自动将插件指令注册为 Discord 斜杠指令",
|
||||
"type": "bool",
|
||||
},
|
||||
"discord_activity_name": {
|
||||
@@ -790,6 +804,51 @@ CONFIG_METADATA_2 = {
|
||||
"type": "string",
|
||||
"hint": "统一 Webhook 模式下的唯一标识符,创建平台时自动生成。",
|
||||
},
|
||||
"kook_bot_token": {
|
||||
"description": "机器人 Token",
|
||||
"type": "string",
|
||||
"hint": "必填项。从 KOOK 开发者平台获取的机器人 Token。",
|
||||
},
|
||||
"kook_bot_nickname": {
|
||||
"description": "Bot Nickname",
|
||||
"type": "string",
|
||||
"hint": "可选项。若发送者昵称与此值一致,将忽略该消息以避免广播风暴。",
|
||||
},
|
||||
"kook_reconnect_delay": {
|
||||
"description": "重连延迟",
|
||||
"type": "int",
|
||||
"hint": "重连延迟时间(秒),使用指数退避策略。",
|
||||
},
|
||||
"kook_max_reconnect_delay": {
|
||||
"description": "最大重连延迟",
|
||||
"type": "int",
|
||||
"hint": "重连延迟的最大值(秒)。",
|
||||
},
|
||||
"kook_max_retry_delay": {
|
||||
"description": "最大重试延迟",
|
||||
"type": "int",
|
||||
"hint": "重试的最大延迟时间(秒)。",
|
||||
},
|
||||
"kook_heartbeat_interval": {
|
||||
"description": "心跳间隔",
|
||||
"type": "int",
|
||||
"hint": "心跳检测间隔时间(秒)。",
|
||||
},
|
||||
"kook_heartbeat_timeout": {
|
||||
"description": "心跳超时时间",
|
||||
"type": "int",
|
||||
"hint": "心跳检测超时时间(秒)。",
|
||||
},
|
||||
"kook_max_heartbeat_failures": {
|
||||
"description": "最大心跳失败次数",
|
||||
"type": "int",
|
||||
"hint": "允许的最大心跳失败次数,超过后断开连接。",
|
||||
},
|
||||
"kook_max_consecutive_failures": {
|
||||
"description": "最大连续失败次数",
|
||||
"type": "int",
|
||||
"hint": "允许的最大连续失败次数,超过后停止重试。",
|
||||
},
|
||||
},
|
||||
},
|
||||
"platform_settings": {
|
||||
@@ -3152,46 +3211,6 @@ CONFIG_METADATA_3 = {
|
||||
"provider_settings.agent_runner_type": "local",
|
||||
},
|
||||
},
|
||||
"provider_settings.max_quoted_fallback_images": {
|
||||
"description": "引用图片回退解析上限",
|
||||
"type": "int",
|
||||
"hint": "引用/转发消息回退解析图片时的最大注入数量,超出会截断。",
|
||||
"condition": {
|
||||
"provider_settings.agent_runner_type": "local",
|
||||
},
|
||||
},
|
||||
"provider_settings.quoted_message_parser.max_component_chain_depth": {
|
||||
"description": "引用解析组件链深度",
|
||||
"type": "int",
|
||||
"hint": "解析 Reply 组件链时允许的最大递归深度。",
|
||||
"condition": {
|
||||
"provider_settings.agent_runner_type": "local",
|
||||
},
|
||||
},
|
||||
"provider_settings.quoted_message_parser.max_forward_node_depth": {
|
||||
"description": "引用解析转发节点深度",
|
||||
"type": "int",
|
||||
"hint": "解析合并转发节点时允许的最大递归深度。",
|
||||
"condition": {
|
||||
"provider_settings.agent_runner_type": "local",
|
||||
},
|
||||
},
|
||||
"provider_settings.quoted_message_parser.max_forward_fetch": {
|
||||
"description": "引用解析转发拉取上限",
|
||||
"type": "int",
|
||||
"hint": "递归拉取 get_forward_msg 的最大次数。",
|
||||
"condition": {
|
||||
"provider_settings.agent_runner_type": "local",
|
||||
},
|
||||
},
|
||||
"provider_settings.quoted_message_parser.warn_on_action_failure": {
|
||||
"description": "引用解析 action 失败告警",
|
||||
"type": "bool",
|
||||
"hint": "开启后,get_msg/get_forward_msg 全部尝试失败时输出 warning 日志。",
|
||||
"condition": {
|
||||
"provider_settings.agent_runner_type": "local",
|
||||
},
|
||||
},
|
||||
"provider_settings.max_agent_step": {
|
||||
"description": "工具调用轮数上限",
|
||||
"type": "int",
|
||||
@@ -3235,6 +3254,46 @@ CONFIG_METADATA_3 = {
|
||||
"type": "bool",
|
||||
"hint": "/provider 命令列出模型时是否并发检测连通性。开启后会主动调用模型测试连通性,可能产生额外 token 消耗。",
|
||||
},
|
||||
"provider_settings.max_quoted_fallback_images": {
|
||||
"description": "引用图片回退解析上限",
|
||||
"type": "int",
|
||||
"hint": "引用/转发消息回退解析图片时的最大注入数量,超出会截断。",
|
||||
"condition": {
|
||||
"provider_settings.agent_runner_type": "local",
|
||||
},
|
||||
},
|
||||
"provider_settings.quoted_message_parser.max_component_chain_depth": {
|
||||
"description": "引用解析组件链深度",
|
||||
"type": "int",
|
||||
"hint": "解析 Reply 组件链时允许的最大递归深度。",
|
||||
"condition": {
|
||||
"provider_settings.agent_runner_type": "local",
|
||||
},
|
||||
},
|
||||
"provider_settings.quoted_message_parser.max_forward_node_depth": {
|
||||
"description": "引用解析转发节点深度",
|
||||
"type": "int",
|
||||
"hint": "解析合并转发节点时允许的最大递归深度。",
|
||||
"condition": {
|
||||
"provider_settings.agent_runner_type": "local",
|
||||
},
|
||||
},
|
||||
"provider_settings.quoted_message_parser.max_forward_fetch": {
|
||||
"description": "引用解析转发拉取上限",
|
||||
"type": "int",
|
||||
"hint": "递归拉取 get_forward_msg 的最大次数。",
|
||||
"condition": {
|
||||
"provider_settings.agent_runner_type": "local",
|
||||
},
|
||||
},
|
||||
"provider_settings.quoted_message_parser.warn_on_action_failure": {
|
||||
"description": "引用解析 action 失败告警",
|
||||
"type": "bool",
|
||||
"hint": "开启后,get_msg/get_forward_msg 全部尝试失败时输出 warning 日志。",
|
||||
"condition": {
|
||||
"provider_settings.agent_runner_type": "local",
|
||||
},
|
||||
},
|
||||
},
|
||||
"condition": {
|
||||
"provider_settings.enable": True,
|
||||
@@ -3446,6 +3505,19 @@ CONFIG_METADATA_3 = {
|
||||
"platform_specific.telegram.pre_ack_emoji.enable": True,
|
||||
},
|
||||
},
|
||||
"platform_specific.discord.pre_ack_emoji.enable": {
|
||||
"description": "[Discord] 启用预回应表情",
|
||||
"type": "bool",
|
||||
},
|
||||
"platform_specific.discord.pre_ack_emoji.emojis": {
|
||||
"description": "表情列表(Unicode 或自定义表情名)",
|
||||
"type": "list",
|
||||
"items": {"type": "string"},
|
||||
"hint": "填写 Unicode 表情符号,例如:👍、🤔、⏳",
|
||||
"condition": {
|
||||
"platform_specific.discord.pre_ack_emoji.enable": True,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -175,6 +175,10 @@ class LogManager:
|
||||
_trace_sink_id: int | None = None
|
||||
_NOISY_LOGGER_LEVELS: dict[str, int] = {
|
||||
"aiosqlite": logging.WARNING,
|
||||
"filelock": logging.WARNING,
|
||||
"asyncio": logging.WARNING,
|
||||
"tzlocal": logging.WARNING,
|
||||
"apscheduler": logging.WARNING,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -27,7 +27,7 @@ class PreProcessStage(Stage):
|
||||
) -> None | AsyncGenerator[None, None]:
|
||||
"""在处理事件之前的预处理"""
|
||||
# 平台特异配置:platform_specific.<platform>.pre_ack_emoji
|
||||
supported = {"telegram", "lark"}
|
||||
supported = {"telegram", "lark", "discord"}
|
||||
platform = event.get_platform_name()
|
||||
cfg = (
|
||||
self.config.get("platform_specific", {})
|
||||
|
||||
@@ -180,6 +180,10 @@ class PlatformManager:
|
||||
from .sources.line.line_adapter import (
|
||||
LinePlatformAdapter, # noqa: F401
|
||||
)
|
||||
case "kook":
|
||||
from .sources.kook.kook_adapter import (
|
||||
KookPlatformAdapter, # noqa: F401
|
||||
)
|
||||
except (ImportError, ModuleNotFoundError) as e:
|
||||
logger.error(
|
||||
f"加载平台适配器 {platform_config['type']} 失败,原因:{e}。请检查依赖库是否安装。提示:可以在 管理面板->平台日志->安装Pip库 中安装依赖库。",
|
||||
|
||||
@@ -0,0 +1,371 @@
|
||||
import asyncio
|
||||
import json
|
||||
import re
|
||||
|
||||
from astrbot import logger
|
||||
from astrbot.api.event import MessageChain
|
||||
from astrbot.api.message_components import At, AtAll, Image, Plain
|
||||
from astrbot.api.platform import (
|
||||
AstrBotMessage,
|
||||
MessageMember,
|
||||
MessageType,
|
||||
Platform,
|
||||
PlatformMetadata,
|
||||
register_platform_adapter,
|
||||
)
|
||||
from astrbot.core.platform.astr_message_event import MessageSesion
|
||||
|
||||
from .kook_client import KookClient
|
||||
from .kook_config import KookConfig
|
||||
from .kook_event import KookEvent
|
||||
|
||||
|
||||
@register_platform_adapter(
|
||||
"kook",
|
||||
"KOOK 适配器",
|
||||
)
|
||||
class KookPlatformAdapter(Platform):
|
||||
def __init__(
|
||||
self, platform_config: dict, platform_settings: dict, event_queue: asyncio.Queue
|
||||
) -> None:
|
||||
super().__init__(platform_config, event_queue)
|
||||
self.kook_config = KookConfig.from_dict(platform_config)
|
||||
logger.debug(f"[KOOK] 配置: {self.kook_config.pretty_jsons()}")
|
||||
self.settings = platform_settings
|
||||
self.client = KookClient(self.kook_config, self._on_received)
|
||||
self._reconnect_task = None
|
||||
self.running = False
|
||||
self._main_task = None
|
||||
|
||||
async def send_by_session(
|
||||
self, session: MessageSesion, message_chain: MessageChain
|
||||
):
|
||||
inner_message = AstrBotMessage()
|
||||
inner_message.session_id = session.session_id
|
||||
inner_message.type = session.message_type
|
||||
message_event = KookEvent(
|
||||
message_str=message_chain.get_plain_text(),
|
||||
message_obj=inner_message,
|
||||
platform_meta=self.meta(),
|
||||
session_id=session.session_id,
|
||||
client=self.client,
|
||||
)
|
||||
await message_event.send(message_chain)
|
||||
|
||||
def meta(self) -> PlatformMetadata:
|
||||
return PlatformMetadata(
|
||||
name="kook", description="KOOK 适配器", id=self.kook_config.id
|
||||
)
|
||||
|
||||
def _should_ignore_event_by_bot_nickname(self, payload: dict) -> bool:
|
||||
bot_nickname = self.kook_config.bot_nickname.strip()
|
||||
if not bot_nickname:
|
||||
return False
|
||||
|
||||
author = payload.get("extra", {}).get("author", {})
|
||||
if not isinstance(author, dict):
|
||||
return False
|
||||
|
||||
author_nickname = author.get("nickname") or author.get("username") or ""
|
||||
if not isinstance(author_nickname, str):
|
||||
author_nickname = str(author_nickname)
|
||||
|
||||
return author_nickname.strip().casefold() == bot_nickname.casefold()
|
||||
|
||||
async def _on_received(self, data: dict):
|
||||
logger.debug(f"KOOK 收到数据: {data}")
|
||||
if "d" in data and data["s"] == 0:
|
||||
payload = data["d"]
|
||||
event_type = payload.get("type")
|
||||
# 支持type=9(文本)和type=10(卡片)
|
||||
if event_type in (9, 10):
|
||||
if self._should_ignore_event_by_bot_nickname(payload):
|
||||
return
|
||||
try:
|
||||
abm = await self.convert_message(payload)
|
||||
await self.handle_msg(abm)
|
||||
except Exception as e:
|
||||
logger.error(f"[KOOK] 消息处理异常: {e}")
|
||||
|
||||
async def run(self):
|
||||
"""主运行循环"""
|
||||
self.running = True
|
||||
logger.info("[KOOK] 启动KOOK适配器")
|
||||
|
||||
# 启动主循环
|
||||
self._main_task = asyncio.create_task(self._main_loop())
|
||||
|
||||
try:
|
||||
await self._main_task
|
||||
except asyncio.CancelledError:
|
||||
logger.info("[KOOK] 适配器被取消")
|
||||
except Exception as e:
|
||||
logger.error(f"[KOOK] 适配器运行异常: {e}")
|
||||
finally:
|
||||
self.running = False
|
||||
await self._cleanup()
|
||||
|
||||
async def _main_loop(self):
|
||||
"""主循环,处理连接和重连"""
|
||||
consecutive_failures = 0
|
||||
max_consecutive_failures = self.kook_config.max_consecutive_failures
|
||||
max_retry_delay = self.kook_config.max_retry_delay
|
||||
|
||||
while self.running:
|
||||
try:
|
||||
logger.info("[KOOK] 尝试连接KOOK服务器...")
|
||||
|
||||
# 尝试连接
|
||||
success = await self.client.connect()
|
||||
|
||||
if success:
|
||||
logger.info("[KOOK] 连接成功,开始监听消息")
|
||||
consecutive_failures = 0 # 重置失败计数
|
||||
|
||||
# 等待连接结束(可能是正常关闭或异常)
|
||||
while self.client.running and self.running:
|
||||
try:
|
||||
# 等待 client 内部触发 _stop_event,或者超时 1 秒后重试
|
||||
# 使用 wait_for 配合 timeout 是为了防止极端情况下 self.running 变化没被察觉
|
||||
await asyncio.wait_for(
|
||||
self.client.wait_until_closed(), timeout=1.0
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
# 正常超时,继续下一轮 while 检查
|
||||
continue
|
||||
|
||||
if self.running:
|
||||
logger.warning("[KOOK] 连接断开,准备重连")
|
||||
|
||||
else:
|
||||
consecutive_failures += 1
|
||||
logger.error(
|
||||
f"[KOOK] 连接失败,连续失败次数: {consecutive_failures}"
|
||||
)
|
||||
|
||||
if consecutive_failures >= max_consecutive_failures:
|
||||
logger.error("[KOOK] 连续失败次数过多,停止重连")
|
||||
break
|
||||
|
||||
# 等待一段时间后重试
|
||||
wait_time = min(
|
||||
2**consecutive_failures, max_retry_delay
|
||||
) # 指数退避
|
||||
logger.info(f"[KOOK] 等待 {wait_time} 秒后重试...")
|
||||
await asyncio.sleep(wait_time)
|
||||
|
||||
except Exception as e:
|
||||
consecutive_failures += 1
|
||||
logger.error(f"[KOOK] 主循环异常: {e}")
|
||||
|
||||
if consecutive_failures >= max_consecutive_failures:
|
||||
logger.error("[KOOK] 连续异常次数过多,停止重连")
|
||||
break
|
||||
|
||||
await asyncio.sleep(5)
|
||||
|
||||
async def _cleanup(self):
|
||||
"""清理资源"""
|
||||
logger.info("[KOOK] 开始清理资源")
|
||||
|
||||
if self.client:
|
||||
try:
|
||||
await self.client.close()
|
||||
except Exception as e:
|
||||
logger.error(f"[KOOK] 关闭客户端异常: {e}")
|
||||
|
||||
if self._main_task and not self._main_task.done():
|
||||
self._main_task.cancel()
|
||||
try:
|
||||
await self._main_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
logger.info("[KOOK] 资源清理完成")
|
||||
|
||||
def _parse_kmarkdown_text_message(
|
||||
self, data: dict, self_id: str
|
||||
) -> tuple[list, str]:
|
||||
kmarkdown = data.get("extra", {}).get("kmarkdown", {})
|
||||
content = data.get("content") or ""
|
||||
raw_content = kmarkdown.get("raw_content") or content
|
||||
if not isinstance(content, str):
|
||||
content = str(content)
|
||||
if not isinstance(raw_content, str):
|
||||
raw_content = str(raw_content)
|
||||
|
||||
mention_name_map: dict[str, str] = {}
|
||||
mention_part = kmarkdown.get("mention_part", [])
|
||||
if isinstance(mention_part, list):
|
||||
for item in mention_part:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
mention_id = item.get("id")
|
||||
if mention_id is None:
|
||||
continue
|
||||
mention_name_map[str(mention_id)] = str(item.get("username", ""))
|
||||
|
||||
components = []
|
||||
cursor = 0
|
||||
for match in re.finditer(r"\(met\)([^()]+)\(met\)", content):
|
||||
if match.start() > cursor:
|
||||
plain_text = content[cursor : match.start()]
|
||||
if plain_text:
|
||||
components.append(Plain(text=plain_text))
|
||||
|
||||
mention_target = match.group(1).strip()
|
||||
if mention_target == "all":
|
||||
components.append(AtAll())
|
||||
elif mention_target:
|
||||
components.append(
|
||||
At(
|
||||
qq=mention_target,
|
||||
name=mention_name_map.get(mention_target, ""),
|
||||
)
|
||||
)
|
||||
cursor = match.end()
|
||||
|
||||
if cursor < len(content):
|
||||
tail_text = content[cursor:]
|
||||
if tail_text:
|
||||
components.append(Plain(text=tail_text))
|
||||
|
||||
message_str = raw_content
|
||||
if components:
|
||||
for comp in components:
|
||||
if isinstance(comp, Plain):
|
||||
if not comp.text.strip():
|
||||
continue
|
||||
break
|
||||
if isinstance(comp, At):
|
||||
if str(comp.qq) == str(self_id):
|
||||
message_str = re.sub(
|
||||
r"^@[^\s]+(\s*-\s*[^\s]+)?\s*",
|
||||
"",
|
||||
message_str,
|
||||
count=1,
|
||||
).strip()
|
||||
break
|
||||
if not components:
|
||||
if message_str:
|
||||
components = [Plain(text=message_str)]
|
||||
else:
|
||||
components = []
|
||||
|
||||
return components, message_str
|
||||
|
||||
def _parse_card_message(self, data: dict) -> tuple[list, str]:
|
||||
content = data.get("content", "[]")
|
||||
if not isinstance(content, str):
|
||||
content = str(content)
|
||||
card_list = json.loads(content)
|
||||
|
||||
text_parts: list[str] = []
|
||||
images: list[str] = []
|
||||
|
||||
for card in card_list:
|
||||
if not isinstance(card, dict):
|
||||
continue
|
||||
for module in card.get("modules", []):
|
||||
if not isinstance(module, dict):
|
||||
continue
|
||||
|
||||
module_type = module.get("type")
|
||||
if module_type == "section":
|
||||
section_text = module.get("text", {}).get("content", "")
|
||||
if section_text:
|
||||
text_parts.append(str(section_text))
|
||||
continue
|
||||
|
||||
if module_type != "container":
|
||||
continue
|
||||
|
||||
for element in module.get("elements", []):
|
||||
if not isinstance(element, dict):
|
||||
continue
|
||||
if element.get("type") != "image":
|
||||
continue
|
||||
|
||||
image_src = element.get("src")
|
||||
if not isinstance(image_src, str):
|
||||
logger.warning(
|
||||
f'[KOOK] 处理卡片中的图片时发生错误,图片url "{image_src}" 应该为str类型, 而不是 "{type(image_src)}" '
|
||||
)
|
||||
continue
|
||||
if not image_src.startswith(("http://", "https://")):
|
||||
logger.warning(f"[KOOK] 屏蔽非http图片url: {image_src}")
|
||||
continue
|
||||
images.append(image_src)
|
||||
|
||||
text = "".join(text_parts)
|
||||
message = []
|
||||
if text:
|
||||
message.append(Plain(text=text))
|
||||
for img_url in images:
|
||||
message.append(Image(file=img_url))
|
||||
return message, text
|
||||
|
||||
async def convert_message(self, data: dict) -> AstrBotMessage:
|
||||
abm = AstrBotMessage()
|
||||
abm.raw_message = data
|
||||
abm.self_id = self.client.bot_id
|
||||
|
||||
channel_type = data.get("channel_type")
|
||||
author_id = data.get("author_id", "unknown")
|
||||
# channel_type定义: https://developer.kookapp.cn/doc/event/event-introduction
|
||||
match channel_type:
|
||||
case "GROUP":
|
||||
session_id = data.get("target_id") or "unknown"
|
||||
abm.type = MessageType.GROUP_MESSAGE
|
||||
abm.group_id = session_id
|
||||
abm.session_id = session_id
|
||||
case "PERSON":
|
||||
abm.type = MessageType.FRIEND_MESSAGE
|
||||
abm.group_id = ""
|
||||
abm.session_id = data.get("author_id", "unknown")
|
||||
case "BROADCAST":
|
||||
session_id = data.get("target_id") or "unknown"
|
||||
abm.type = MessageType.OTHER_MESSAGE
|
||||
abm.group_id = session_id
|
||||
abm.session_id = session_id
|
||||
case _:
|
||||
raise ValueError(f"不支持的频道类型: {channel_type}")
|
||||
|
||||
abm.sender = MessageMember(
|
||||
user_id=author_id,
|
||||
nickname=data.get("extra", {}).get("author", {}).get("username", ""),
|
||||
)
|
||||
|
||||
abm.message_id = data.get("msg_id", "unknown")
|
||||
|
||||
# 普通文本消息
|
||||
if data.get("type") == 9:
|
||||
message, message_str = self._parse_kmarkdown_text_message(
|
||||
data, str(abm.self_id)
|
||||
)
|
||||
abm.message = message
|
||||
abm.message_str = message_str
|
||||
# 卡片消息
|
||||
elif data.get("type") == 10:
|
||||
try:
|
||||
abm.message, abm.message_str = self._parse_card_message(data)
|
||||
except Exception as exp:
|
||||
logger.error(f"[KOOK] 卡片消息解析失败: {exp}")
|
||||
abm.message_str = "[卡片消息解析失败]"
|
||||
abm.message = [Plain(text="[卡片消息解析失败]")]
|
||||
else:
|
||||
logger.warning(f'[KOOK] 不支持的kook消息类型: "{data.get("type")}"')
|
||||
abm.message_str = "[不支持的消息类型]"
|
||||
abm.message = [Plain(text="[不支持的消息类型]")]
|
||||
|
||||
return abm
|
||||
|
||||
async def handle_msg(self, message: AstrBotMessage):
|
||||
message_event = KookEvent(
|
||||
message_str=message.message_str,
|
||||
message_obj=message,
|
||||
platform_meta=self.meta(),
|
||||
session_id=message.session_id,
|
||||
client=self.client,
|
||||
)
|
||||
self.commit_event(message_event)
|
||||
@@ -0,0 +1,437 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import time
|
||||
import zlib
|
||||
from pathlib import Path
|
||||
|
||||
import aiofiles
|
||||
import aiohttp
|
||||
import websockets
|
||||
|
||||
from astrbot import logger
|
||||
from astrbot.core.platform.message_type import MessageType
|
||||
|
||||
from .kook_config import KookConfig
|
||||
from .kook_types import KookApiPaths, KookMessageType
|
||||
|
||||
|
||||
class KookClient:
|
||||
def __init__(self, config: KookConfig, event_callback):
|
||||
# 数据字段
|
||||
self.config = config
|
||||
self._bot_id = ""
|
||||
self._bot_name = ""
|
||||
|
||||
# 资源字段
|
||||
self._http_client = aiohttp.ClientSession(
|
||||
headers={
|
||||
"Authorization": f"Bot {self.config.token}",
|
||||
}
|
||||
)
|
||||
self.event_callback = event_callback # 回调函数,用于处理接收到的事件
|
||||
self.ws = None
|
||||
self.heartbeat_task = None
|
||||
self._stop_event = asyncio.Event() # 用于通知连接结束
|
||||
|
||||
# 状态/计算字段
|
||||
self.running = False
|
||||
self.session_id = None
|
||||
self.last_sn = 0 # 记录最后处理的消息序号
|
||||
self.last_heartbeat_time = 0
|
||||
self.heartbeat_failed_count = 0
|
||||
|
||||
@property
|
||||
def bot_id(self):
|
||||
return self._bot_id
|
||||
|
||||
@property
|
||||
def bot_name(self):
|
||||
return self._bot_name
|
||||
|
||||
async def get_bot_info(self) -> str:
|
||||
"""获取机器人账号ID"""
|
||||
url = KookApiPaths.USER_ME
|
||||
|
||||
try:
|
||||
async with self._http_client.get(url) as resp:
|
||||
if resp.status != 200:
|
||||
logger.error(f"[KOOK] 获取机器人账号ID失败,状态码: {resp.status}")
|
||||
return ""
|
||||
|
||||
data = await resp.json()
|
||||
if data.get("code") != 0:
|
||||
logger.error(f"[KOOK] 获取机器人账号ID失败: {data}")
|
||||
return ""
|
||||
|
||||
bot_id: str = data["data"]["id"]
|
||||
self._bot_id = bot_id
|
||||
logger.info(f"[KOOK] 获取机器人账号ID成功: {bot_id}")
|
||||
bot_name: str = data["data"]["nickname"] or data["data"]["username"]
|
||||
self._bot_name = bot_name
|
||||
logger.info(f"[KOOK] 获取机器人名称成功: {self._bot_name}")
|
||||
|
||||
return bot_id
|
||||
except Exception as e:
|
||||
logger.error(f"[KOOK] 获取机器人账号ID异常: {e}")
|
||||
return ""
|
||||
|
||||
async def get_gateway_url(self, resume=False, sn=0, session_id=None):
|
||||
"""获取网关连接地址"""
|
||||
url = KookApiPaths.GATEWAY_INDEX
|
||||
|
||||
# 构建连接参数
|
||||
params = {}
|
||||
if resume:
|
||||
params["resume"] = 1
|
||||
params["sn"] = sn
|
||||
if session_id:
|
||||
params["session_id"] = session_id
|
||||
|
||||
try:
|
||||
async with self._http_client.get(url, params=params) as resp:
|
||||
if resp.status != 200:
|
||||
logger.error(f"[KOOK] 获取gateway失败,状态码: {resp.status}")
|
||||
return None
|
||||
|
||||
data = await resp.json()
|
||||
if data.get("code") != 0:
|
||||
logger.error(f"[KOOK] 获取gateway失败: {data}")
|
||||
return None
|
||||
|
||||
gateway_url: str = data["data"]["url"]
|
||||
logger.info(f"[KOOK] 获取gateway成功: {gateway_url.split('?')[0]}")
|
||||
return gateway_url
|
||||
except Exception as e:
|
||||
logger.error(f"[KOOK] 获取gateway异常: {e}")
|
||||
return None
|
||||
|
||||
async def connect(self, resume=False):
|
||||
"""连接WebSocket"""
|
||||
if self.ws:
|
||||
try:
|
||||
await self.ws.close()
|
||||
except Exception:
|
||||
pass
|
||||
self.ws = None
|
||||
self._stop_event.clear()
|
||||
try:
|
||||
# 获取gateway地址
|
||||
gateway_url = await self.get_gateway_url(
|
||||
resume=resume, sn=self.last_sn, session_id=self.session_id
|
||||
)
|
||||
await self.get_bot_info()
|
||||
|
||||
if not gateway_url:
|
||||
return False
|
||||
|
||||
# 连接WebSocket
|
||||
self.ws = await websockets.connect(gateway_url)
|
||||
self.running = True
|
||||
logger.info("[KOOK] WebSocket 连接成功")
|
||||
|
||||
# 启动心跳任务
|
||||
if self.heartbeat_task:
|
||||
self.heartbeat_task.cancel()
|
||||
self.heartbeat_task = asyncio.create_task(self._heartbeat_loop())
|
||||
|
||||
# 开始监听消息
|
||||
await self.listen()
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[KOOK] WebSocket 连接失败: {e}")
|
||||
if self.ws:
|
||||
try:
|
||||
await self.ws.close()
|
||||
except Exception:
|
||||
pass
|
||||
self.ws = None
|
||||
return False
|
||||
|
||||
async def listen(self):
|
||||
"""监听WebSocket消息"""
|
||||
try:
|
||||
while self.running:
|
||||
try:
|
||||
msg = await asyncio.wait_for(self.ws.recv(), timeout=10) # type: ignore
|
||||
|
||||
if isinstance(msg, bytes):
|
||||
try:
|
||||
msg = zlib.decompress(msg)
|
||||
except Exception as e:
|
||||
logger.error(f"[KOOK] 解压消息失败: {e}")
|
||||
continue
|
||||
msg = msg.decode("utf-8")
|
||||
|
||||
data = json.loads(msg)
|
||||
|
||||
# 处理不同类型的信令
|
||||
await self._handle_signal(data)
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
# 超时检查,继续循环
|
||||
continue
|
||||
except websockets.exceptions.ConnectionClosed:
|
||||
logger.warning("[KOOK] WebSocket连接已关闭")
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"[KOOK] 消息处理异常: {e}")
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[KOOK] WebSocket 监听异常: {e}")
|
||||
finally:
|
||||
self.running = False
|
||||
self._stop_event.set()
|
||||
|
||||
async def _handle_signal(self, data):
|
||||
"""处理不同类型的信令"""
|
||||
signal_type = data.get("s")
|
||||
|
||||
if signal_type == 0: # 事件消息
|
||||
# 更新消息序号
|
||||
if "sn" in data:
|
||||
self.last_sn = data["sn"]
|
||||
await self.event_callback(data)
|
||||
|
||||
elif signal_type == 1: # HELLO握手
|
||||
await self._handle_hello(data)
|
||||
|
||||
elif signal_type == 3: # PONG心跳响应
|
||||
await self._handle_pong(data)
|
||||
|
||||
elif signal_type == 5: # RECONNECT重连指令
|
||||
await self._handle_reconnect(data)
|
||||
|
||||
elif signal_type == 6: # RESUME ACK
|
||||
await self._handle_resume_ack(data)
|
||||
|
||||
else:
|
||||
logger.debug(f"[KOOK] 未处理的信令类型: {signal_type}")
|
||||
|
||||
async def _handle_hello(self, data):
|
||||
"""处理HELLO握手"""
|
||||
hello_data = data.get("d", {})
|
||||
code = hello_data.get("code", 0)
|
||||
|
||||
if code == 0:
|
||||
self.session_id = hello_data.get("session_id")
|
||||
logger.info(f"[KOOK] 握手成功,session_id: {self.session_id}")
|
||||
# TODO 重置重连延迟
|
||||
# self.reconnect_delay = 1
|
||||
else:
|
||||
logger.error(f"[KOOK] 握手失败,错误码: {code}")
|
||||
if code == 40103: # token过期
|
||||
logger.error("[KOOK] Token已过期,需要重新获取")
|
||||
self.running = False
|
||||
|
||||
async def _handle_pong(self, data):
|
||||
"""处理PONG心跳响应"""
|
||||
self.last_heartbeat_time = time.time()
|
||||
self.heartbeat_failed_count = 0
|
||||
|
||||
async def _handle_reconnect(self, data):
|
||||
"""处理重连指令"""
|
||||
logger.warning("[KOOK] 收到重连指令")
|
||||
# 清空本地状态
|
||||
self.last_sn = 0
|
||||
self.session_id = None
|
||||
self.running = False
|
||||
|
||||
async def _handle_resume_ack(self, data):
|
||||
"""处理RESUME确认"""
|
||||
resume_data = data.get("d", {})
|
||||
self.session_id = resume_data.get("session_id")
|
||||
logger.info(f"[KOOK] Resume成功,session_id: {self.session_id}")
|
||||
|
||||
async def _heartbeat_loop(self):
|
||||
"""心跳循环"""
|
||||
while self.running:
|
||||
try:
|
||||
# 随机化心跳间隔 (±5秒)
|
||||
interval = max(
|
||||
1, self.config.heartbeat_interval + random.randint(-5, 5)
|
||||
)
|
||||
await asyncio.sleep(interval)
|
||||
|
||||
if not self.running:
|
||||
break
|
||||
|
||||
# 发送心跳
|
||||
await self._send_ping()
|
||||
|
||||
# 等待PONG响应
|
||||
await asyncio.sleep(self.config.heartbeat_timeout)
|
||||
|
||||
# 检查是否收到PONG响应
|
||||
if (
|
||||
time.time() - self.last_heartbeat_time
|
||||
> self.config.heartbeat_timeout
|
||||
):
|
||||
self.heartbeat_failed_count += 1
|
||||
logger.warning(
|
||||
f"[KOOK] 心跳超时,失败次数: {self.heartbeat_failed_count}"
|
||||
)
|
||||
|
||||
if (
|
||||
self.heartbeat_failed_count
|
||||
>= self.config.max_heartbeat_failures
|
||||
):
|
||||
logger.error("[KOOK] 心跳失败次数过多,准备重连")
|
||||
self.running = False
|
||||
break
|
||||
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"[KOOK] 心跳异常: {e}")
|
||||
self.heartbeat_failed_count += 1
|
||||
|
||||
async def _send_ping(self):
|
||||
"""发送心跳PING"""
|
||||
try:
|
||||
ping_data = {"s": 2, "sn": self.last_sn}
|
||||
await self.ws.send(json.dumps(ping_data)) # type: ignore
|
||||
except Exception as e:
|
||||
logger.error(f"[KOOK] 发送心跳失败: {e}")
|
||||
|
||||
async def send_text(
|
||||
self,
|
||||
target_id: str,
|
||||
content: str,
|
||||
astrbot_message_type: MessageType,
|
||||
kook_message_type: KookMessageType,
|
||||
reply_message_id: str | int = "",
|
||||
):
|
||||
"""发送文本消息
|
||||
消息发送接口文档参见: https://developer.kookapp.cn/doc/http/message#%E5%8F%91%E9%80%81%E9%A2%91%E9%81%93%E8%81%8A%E5%A4%A9%E6%B6%88%E6%81%AF
|
||||
KMarkdown格式参见: https://developer.kookapp.cn/doc/kmarkdown-desc
|
||||
"""
|
||||
url = KookApiPaths.CHANNEL_MESSAGE_CREATE
|
||||
if astrbot_message_type == MessageType.FRIEND_MESSAGE:
|
||||
url = KookApiPaths.DIRECT_MESSAGE_CREATE
|
||||
|
||||
payload = {
|
||||
"target_id": target_id,
|
||||
"content": content,
|
||||
"type": kook_message_type,
|
||||
}
|
||||
if reply_message_id:
|
||||
payload["quote"] = reply_message_id
|
||||
payload["reply_msg_id"] = reply_message_id
|
||||
|
||||
try:
|
||||
async with self._http_client.post(url, json=payload) as resp:
|
||||
if resp.status == 200:
|
||||
result = await resp.json()
|
||||
if result.get("code") != 0:
|
||||
raise RuntimeError(
|
||||
f'发送kook消息类型 "{kook_message_type.name}" 失败: {result}'
|
||||
)
|
||||
# else:
|
||||
# logger.info("[KOOK] 发送消息成功")
|
||||
else:
|
||||
raise RuntimeError(
|
||||
f'发送kook消息类型 "{kook_message_type.name}" HTTP错误: {resp.status} , 响应内容 : {await resp.text()}'
|
||||
)
|
||||
except RuntimeError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f'[KOOK] 发送kook消息类型 "{kook_message_type.name}" 异常: {e}'
|
||||
)
|
||||
|
||||
async def upload_asset(self, file_url: str | None) -> str:
|
||||
"""上传文件到kook,获得远端资源url
|
||||
接口定义参见: https://developer.kookapp.cn/doc/http/asset
|
||||
"""
|
||||
if not file_url:
|
||||
return ""
|
||||
|
||||
bytes_data: bytes | None = None
|
||||
filename = "unknown"
|
||||
if file_url.startswith(("http://", "https://")):
|
||||
filename = file_url.split("/")[-1]
|
||||
return file_url
|
||||
|
||||
if file_url.startswith("base64:///"):
|
||||
# b64decode的时候得开头留一个'/'的, 不然会报错
|
||||
b64_str = file_url.removeprefix("base64://")
|
||||
bytes_data = base64.b64decode(b64_str)
|
||||
|
||||
elif file_url.startswith("file://") or os.path.exists(file_url):
|
||||
file_url = file_url.removeprefix("file:///")
|
||||
file_url = file_url.removeprefix("file://")
|
||||
|
||||
try:
|
||||
target_path = Path(file_url).resolve()
|
||||
except Exception as exp:
|
||||
logger.error(f'[KOOK] 获取文件 "{file_url}" 绝对路径失败: "{exp}"')
|
||||
raise FileNotFoundError(
|
||||
f'获取文件 "{file_url}" 绝对路径失败: "{exp}"'
|
||||
) from exp
|
||||
|
||||
if not target_path.is_file():
|
||||
raise FileNotFoundError(f"文件不存在: {target_path.name}")
|
||||
|
||||
filename = target_path.name
|
||||
async with aiofiles.open(target_path, "rb") as f:
|
||||
bytes_data = await f.read()
|
||||
|
||||
else:
|
||||
raise ValueError(f'[KOOK] 不支持的文件资源类型: "{file_url}"')
|
||||
|
||||
data = aiohttp.FormData()
|
||||
data.add_field("file", bytes_data, filename=filename)
|
||||
|
||||
url = KookApiPaths.ASSET_CREATE
|
||||
try:
|
||||
async with self._http_client.post(url, data=data) as resp:
|
||||
if resp.status == 200:
|
||||
result: dict = await resp.json()
|
||||
logger.debug(f"[KOOK] 上传文件响应: {result}")
|
||||
if result.get("code") == 0:
|
||||
logger.info("[KOOK] 上传文件到kook服务器成功")
|
||||
remote_url = result["data"]["url"]
|
||||
logger.debug(f"[KOOK] 文件远端URL: {remote_url}")
|
||||
return remote_url
|
||||
else:
|
||||
raise RuntimeError(f"上传文件到kook服务器失败: {result}")
|
||||
else:
|
||||
raise RuntimeError(
|
||||
f"上传文件到kook服务器 HTTP错误: {resp.status} , {await resp.text()}"
|
||||
)
|
||||
except RuntimeError:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"上传文件到kook服务器异常: {e}") from e
|
||||
|
||||
async def wait_until_closed(self):
|
||||
"""提供给外部调用的等待方法"""
|
||||
await self._stop_event.wait()
|
||||
|
||||
async def close(self):
|
||||
"""关闭连接"""
|
||||
self.running = False
|
||||
self._stop_event.set()
|
||||
|
||||
if self.heartbeat_task:
|
||||
self.heartbeat_task.cancel()
|
||||
try:
|
||||
await self.heartbeat_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
if self.ws:
|
||||
try:
|
||||
await self.ws.close()
|
||||
except Exception as e:
|
||||
logger.error(f"[KOOK] 关闭WebSocket异常: {e}")
|
||||
|
||||
if self._http_client:
|
||||
await self._http_client.close()
|
||||
|
||||
logger.info("[KOOK] 连接已关闭")
|
||||
@@ -0,0 +1,133 @@
|
||||
import json
|
||||
from dataclasses import asdict, dataclass
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass
|
||||
class KookConfig:
|
||||
"""KOOK 适配器配置类"""
|
||||
|
||||
# 基础配置
|
||||
token: str
|
||||
bot_nickname: str = ""
|
||||
enable: bool = False
|
||||
id: str = "kook"
|
||||
|
||||
# 重连配置
|
||||
reconnect_delay: int = 1
|
||||
"""重连延迟基数(秒),指数退避"""
|
||||
max_reconnect_delay: int = 60
|
||||
"""最大重连延迟(秒)"""
|
||||
max_retry_delay: int = 60
|
||||
"""最大重试延迟(秒)"""
|
||||
|
||||
# 心跳配置
|
||||
heartbeat_interval: int = 30
|
||||
"""心跳间隔(秒)"""
|
||||
heartbeat_timeout: int = 6
|
||||
"""心跳超时时间(秒)"""
|
||||
max_heartbeat_failures: int = 3
|
||||
"""最大心跳失败次数"""
|
||||
|
||||
# 失败处理
|
||||
max_consecutive_failures: int = 5
|
||||
"""最大连续失败次数"""
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, config_dict: dict) -> "KookConfig":
|
||||
"""从字典创建配置对象"""
|
||||
return cls(
|
||||
# 适配器id 应该是不能改的
|
||||
# id=config_dict.get("id", "kook"),
|
||||
enable=config_dict.get("enable", False),
|
||||
token=config_dict.get("kook_bot_token", ""),
|
||||
bot_nickname=config_dict.get("kook_bot_nickname", ""),
|
||||
reconnect_delay=config_dict.get(
|
||||
"kook_reconnect_delay",
|
||||
KookConfig.reconnect_delay,
|
||||
),
|
||||
max_reconnect_delay=config_dict.get(
|
||||
"kook_max_reconnect_delay",
|
||||
KookConfig.max_reconnect_delay,
|
||||
),
|
||||
max_retry_delay=config_dict.get(
|
||||
"kook_max_retry_delay",
|
||||
KookConfig.max_retry_delay,
|
||||
),
|
||||
heartbeat_interval=config_dict.get(
|
||||
"kook_heartbeat_interval",
|
||||
KookConfig.heartbeat_interval,
|
||||
),
|
||||
heartbeat_timeout=config_dict.get(
|
||||
"kook_heartbeat_timeout",
|
||||
KookConfig.heartbeat_timeout,
|
||||
),
|
||||
max_heartbeat_failures=config_dict.get(
|
||||
"kook_max_heartbeat_failures",
|
||||
KookConfig.max_heartbeat_failures,
|
||||
),
|
||||
max_consecutive_failures=config_dict.get(
|
||||
"kook_max_consecutive_failures",
|
||||
KookConfig.max_consecutive_failures,
|
||||
),
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
def pretty_jsons(self, indent=2) -> str:
|
||||
dict_config = self.to_dict()
|
||||
dict_config["token"] = "*" * len(self.token) if self.token else "MISSING"
|
||||
return json.dumps(dict_config, indent=indent, ensure_ascii=False)
|
||||
|
||||
|
||||
# TODO 没用上的config配置,未来有空会实现这些配置描述的功能?
|
||||
# # 连接配置
|
||||
# CONNECTION_CONFIG = {
|
||||
# # 心跳配置
|
||||
# "heartbeat_interval": 30, # 心跳间隔(秒)
|
||||
# "heartbeat_timeout": 6, # 心跳超时时间(秒)
|
||||
# "max_heartbeat_failures": 3, # 最大心跳失败次数
|
||||
# # 重连配置
|
||||
# "initial_reconnect_delay": 1, # 初始重连延迟(秒)
|
||||
# "max_reconnect_delay": 60, # 最大重连延迟(秒)
|
||||
# "max_consecutive_failures": 5, # 最大连续失败次数
|
||||
# # WebSocket配置
|
||||
# "websocket_timeout": 10, # WebSocket接收超时(秒)
|
||||
# "connection_timeout": 30, # 连接超时(秒)
|
||||
# # 消息处理配置
|
||||
# "enable_compression": True, # 是否启用消息压缩
|
||||
# "max_message_size": 1024 * 1024, # 最大消息大小(字节)
|
||||
# }
|
||||
|
||||
# # 日志配置
|
||||
# LOGGING_CONFIG = {
|
||||
# "level": "INFO", # 日志级别:DEBUG, INFO, WARNING, ERROR
|
||||
# "format": "[KOOK] %(message)s",
|
||||
# "enable_heartbeat_logs": False, # 是否启用心跳日志
|
||||
# "enable_message_logs": False, # 是否启用消息日志
|
||||
# }
|
||||
|
||||
# # 错误处理配置
|
||||
# ERROR_HANDLING_CONFIG = {
|
||||
# "retry_on_network_error": True, # 网络错误时是否重试
|
||||
# "retry_on_token_expired": True, # Token过期时是否重试
|
||||
# "max_retry_attempts": 3, # 最大重试次数
|
||||
# "retry_delay_base": 2, # 重试延迟基数(秒)
|
||||
# }
|
||||
|
||||
# # 性能配置
|
||||
# PERFORMANCE_CONFIG = {
|
||||
# "enable_message_buffering": True, # 是否启用消息缓冲
|
||||
# "buffer_size": 100, # 缓冲区大小
|
||||
# "enable_connection_pooling": True, # 是否启用连接池
|
||||
# "max_concurrent_requests": 10, # 最大并发请求数
|
||||
# }
|
||||
|
||||
# # 安全配置
|
||||
# SECURITY_CONFIG = {
|
||||
# "verify_ssl": True, # 是否验证SSL证书
|
||||
# "enable_rate_limiting": True, # 是否启用速率限制
|
||||
# "rate_limit_requests": 100, # 速率限制请求数
|
||||
# "rate_limit_window": 60, # 速率限制窗口(秒)
|
||||
# }
|
||||
@@ -0,0 +1,209 @@
|
||||
import asyncio
|
||||
import json
|
||||
from collections.abc import Coroutine
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from astrbot import logger
|
||||
from astrbot.api.event import AstrMessageEvent, MessageChain
|
||||
from astrbot.api.platform import AstrBotMessage, PlatformMetadata
|
||||
from astrbot.core.message.components import (
|
||||
At,
|
||||
AtAll,
|
||||
BaseMessageComponent,
|
||||
File,
|
||||
Image,
|
||||
Json,
|
||||
Plain,
|
||||
Record,
|
||||
Reply,
|
||||
Video,
|
||||
)
|
||||
from astrbot.core.platform import MessageType
|
||||
|
||||
from .kook_client import KookClient
|
||||
from .kook_types import (
|
||||
FileModule,
|
||||
KookCardMessage,
|
||||
KookCardMessageContainer,
|
||||
KookMessageType,
|
||||
OrderMessage,
|
||||
)
|
||||
|
||||
|
||||
class KookEvent(AstrMessageEvent):
|
||||
def __init__(
|
||||
self,
|
||||
message_str: str,
|
||||
message_obj: AstrBotMessage,
|
||||
platform_meta: PlatformMetadata,
|
||||
session_id: str,
|
||||
client: KookClient,
|
||||
):
|
||||
super().__init__(message_str, message_obj, platform_meta, session_id)
|
||||
self.client = client
|
||||
self.channel_id = message_obj.group_id or message_obj.session_id
|
||||
self.astrbot_message_type: MessageType = message_obj.type
|
||||
self._file_message_counter = 0
|
||||
|
||||
def _wrap_message(
|
||||
self, index: int, message_component: BaseMessageComponent
|
||||
) -> Coroutine[Any, Any, OrderMessage]:
|
||||
async def wrap_upload(
|
||||
index: int, message_type: KookMessageType, upload_coro
|
||||
) -> OrderMessage:
|
||||
url = await upload_coro
|
||||
return OrderMessage(index=index, text=url, type=message_type)
|
||||
|
||||
async def handle_plain(
|
||||
index: int,
|
||||
text: str | None,
|
||||
reply_id: str | int = "",
|
||||
type: KookMessageType = KookMessageType.KMARKDOWN,
|
||||
):
|
||||
if not text:
|
||||
text = ""
|
||||
return OrderMessage(
|
||||
index=index,
|
||||
text=text,
|
||||
type=type,
|
||||
reply_id=reply_id,
|
||||
)
|
||||
|
||||
match message_component:
|
||||
case Image():
|
||||
self._file_message_counter += 1
|
||||
return wrap_upload(
|
||||
index,
|
||||
KookMessageType.IMAGE,
|
||||
self.client.upload_asset(message_component.file),
|
||||
)
|
||||
|
||||
case Video():
|
||||
self._file_message_counter += 1
|
||||
return wrap_upload(
|
||||
index,
|
||||
KookMessageType.VIDEO,
|
||||
self.client.upload_asset(message_component.file),
|
||||
)
|
||||
case File():
|
||||
|
||||
async def handle_file(index: int, f_item: File):
|
||||
f_data = await f_item.get_file()
|
||||
url = await self.client.upload_asset(f_data)
|
||||
return OrderMessage(
|
||||
index=index, text=url, type=KookMessageType.FILE
|
||||
)
|
||||
|
||||
self._file_message_counter += 1
|
||||
return handle_file(index, message_component)
|
||||
|
||||
case Record():
|
||||
|
||||
async def handle_audio(index: int, f_item: Record):
|
||||
file_path = await f_item.convert_to_file_path()
|
||||
url = await self.client.upload_asset(file_path)
|
||||
title = f_item.text or Path(file_path).name
|
||||
return OrderMessage(
|
||||
index=index,
|
||||
text=KookCardMessageContainer(
|
||||
[
|
||||
KookCardMessage(
|
||||
modules=[
|
||||
FileModule(
|
||||
type="audio",
|
||||
title=title,
|
||||
src=url,
|
||||
)
|
||||
]
|
||||
)
|
||||
]
|
||||
).to_json(),
|
||||
type=KookMessageType.CARD,
|
||||
)
|
||||
|
||||
return handle_audio(index, message_component)
|
||||
case Plain():
|
||||
return handle_plain(index, message_component.text)
|
||||
case At():
|
||||
return handle_plain(index, f"(met){message_component.qq}(met)")
|
||||
case AtAll():
|
||||
return handle_plain(index, "(met)all(met)")
|
||||
case Reply():
|
||||
return handle_plain(index, "", reply_id=message_component.id)
|
||||
case Json():
|
||||
json_data = message_component.data
|
||||
# kook卡片json外层得是一个列表
|
||||
if isinstance(json_data, dict):
|
||||
json_data = [json_data]
|
||||
return handle_plain(
|
||||
index,
|
||||
# 考虑到kook可能会更改消息结构,为了能让插件开发者
|
||||
# 自行根据kook文档描述填卡片json内容,故不做模型校验
|
||||
# KookCardMessage().model_validate(message_component.data).to_json(),
|
||||
text=json.dumps(json_data),
|
||||
type=KookMessageType.CARD,
|
||||
)
|
||||
case _:
|
||||
raise NotImplementedError(
|
||||
f'kook适配器尚未实现对 "{message_component.type}" 消息类型的支持'
|
||||
)
|
||||
|
||||
async def send(self, message: MessageChain):
|
||||
file_upload_tasks: list[Coroutine[Any, Any, OrderMessage]] = []
|
||||
for index, item in enumerate(message.chain):
|
||||
file_upload_tasks.append(self._wrap_message(index, item))
|
||||
|
||||
if self._file_message_counter > 0:
|
||||
logger.debug("[Kook] 正在向kook服务器上传文件")
|
||||
|
||||
tasks_result = await asyncio.gather(*file_upload_tasks, return_exceptions=True)
|
||||
order_messages: list[OrderMessage] = []
|
||||
|
||||
for index, result in enumerate(tasks_result):
|
||||
if isinstance(result, BaseException):
|
||||
logger.error(f"[Kook] {result}")
|
||||
# 构造一个虚假的 OrderMessage,让用户知道这里本来有张图但坏了
|
||||
# 这样后面的 for 循环就能把它当成普通文本发出去
|
||||
err_node = OrderMessage(
|
||||
index=index,
|
||||
text=str(result),
|
||||
type=KookMessageType.TEXT,
|
||||
)
|
||||
order_messages.append(err_node)
|
||||
else:
|
||||
order_messages.append(result)
|
||||
|
||||
order_messages.sort(key=lambda x: x.index)
|
||||
|
||||
reply_id: str | int = ""
|
||||
errors: list[Exception] = []
|
||||
for item in order_messages:
|
||||
if item.reply_id:
|
||||
reply_id = item.reply_id
|
||||
if not item.text:
|
||||
logger.debug(f'[Kook] 跳过空消息,类型为"{item.type}"')
|
||||
continue
|
||||
try:
|
||||
await self.client.send_text(
|
||||
self.channel_id,
|
||||
item.text,
|
||||
self.astrbot_message_type,
|
||||
item.type,
|
||||
reply_id,
|
||||
)
|
||||
except RuntimeError as exp:
|
||||
await self.client.send_text(
|
||||
self.channel_id,
|
||||
str(exp),
|
||||
self.astrbot_message_type,
|
||||
KookMessageType.TEXT,
|
||||
reply_id,
|
||||
)
|
||||
errors.append(exp)
|
||||
|
||||
if errors:
|
||||
err_msg = "\n".join([str(err) for err in errors])
|
||||
logger.error(f"[kook] {err_msg}")
|
||||
|
||||
await super().send(message)
|
||||
@@ -0,0 +1,241 @@
|
||||
import json
|
||||
from dataclasses import field
|
||||
from enum import IntEnum
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from pydantic.dataclasses import dataclass
|
||||
|
||||
|
||||
class KookApiPaths:
|
||||
"""Kook Api 路径"""
|
||||
|
||||
BASE_URL = "https://www.kookapp.cn"
|
||||
API_VERSION_PATH = "/api/v3"
|
||||
|
||||
# 初始化相关
|
||||
USER_ME = f"{BASE_URL}{API_VERSION_PATH}/user/me"
|
||||
GATEWAY_INDEX = f"{BASE_URL}{API_VERSION_PATH}/gateway/index"
|
||||
|
||||
# 消息相关
|
||||
ASSET_CREATE = f"{BASE_URL}{API_VERSION_PATH}/asset/create"
|
||||
## 频道消息
|
||||
CHANNEL_MESSAGE_CREATE = f"{BASE_URL}{API_VERSION_PATH}/message/create"
|
||||
## 私聊消息
|
||||
DIRECT_MESSAGE_CREATE = f"{BASE_URL}{API_VERSION_PATH}/direct-message/create"
|
||||
|
||||
|
||||
# 定义参见kook事件结构文档: https://developer.kookapp.cn/doc/event/event-introduction
|
||||
class KookMessageType(IntEnum):
|
||||
TEXT = 1
|
||||
IMAGE = 2
|
||||
VIDEO = 3
|
||||
FILE = 4
|
||||
AUDIO = 8
|
||||
KMARKDOWN = 9
|
||||
CARD = 10
|
||||
SYSTEM = 255
|
||||
|
||||
|
||||
ThemeType = Literal[
|
||||
"primary", "success", "danger", "warning", "info", "secondary", "none", "invisible"
|
||||
]
|
||||
"""主题,可选的值为:primary, success, danger, warning, info, secondary, none.默认为 primary,为 none 时不显示侧边框。"""
|
||||
SizeType = Literal["xs", "sm", "md", "lg"]
|
||||
"""大小,可选值为:xs, sm, md, lg, 一般默认为 lg"""
|
||||
|
||||
SectionMode = Literal["left", "right"]
|
||||
CountdownMode = Literal["day", "hour", "second"]
|
||||
|
||||
|
||||
class KookCardColor(str):
|
||||
"""16 进制色值"""
|
||||
|
||||
|
||||
class KookCardModelBase:
|
||||
"""卡片模块基类"""
|
||||
|
||||
type: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class PlainTextElement(KookCardModelBase):
|
||||
content: str
|
||||
type: str = "plain-text"
|
||||
emoji: bool = True
|
||||
|
||||
|
||||
@dataclass
|
||||
class KmarkdownElement(KookCardModelBase):
|
||||
content: str
|
||||
type: str = "kmarkdown"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ImageElement(KookCardModelBase):
|
||||
src: str
|
||||
type: str = "image"
|
||||
alt: str = ""
|
||||
size: SizeType = "lg"
|
||||
circle: bool = False
|
||||
fallbackUrl: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ButtonElement(KookCardModelBase):
|
||||
text: str
|
||||
type: str = "button"
|
||||
theme: ThemeType = "primary"
|
||||
value: str = ""
|
||||
"""当为 link 时,会跳转到 value 代表的链接;
|
||||
当为 return-val 时,系统会通过系统消息将消息 id,点击用户 id 和 value 发回给发送者,发送者可以根据自己的需求进行处理,消息事件参见button 点击事件。私聊和频道内均可使用按钮点击事件。"""
|
||||
click: Literal["", "link", "return-val"] = ""
|
||||
"""click 代表用户点击的事件,默认为"",代表无任何事件。"""
|
||||
|
||||
|
||||
AnyElement = PlainTextElement | KmarkdownElement | ImageElement | ButtonElement | str
|
||||
|
||||
|
||||
@dataclass
|
||||
class ParagraphStructure(KookCardModelBase):
|
||||
fields: list[PlainTextElement | KmarkdownElement]
|
||||
type: str = "paragraph"
|
||||
cols: int = 1
|
||||
"""范围是 1-3 , 移动端忽略此参数"""
|
||||
|
||||
|
||||
@dataclass
|
||||
class HeaderModule(KookCardModelBase):
|
||||
text: PlainTextElement
|
||||
type: str = "header"
|
||||
|
||||
|
||||
@dataclass
|
||||
class SectionModule(KookCardModelBase):
|
||||
text: PlainTextElement | KmarkdownElement | ParagraphStructure
|
||||
type: str = "section"
|
||||
mode: SectionMode = "left"
|
||||
accessory: ImageElement | ButtonElement | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ImageGroupModule(KookCardModelBase):
|
||||
"""1 到多张图片的组合"""
|
||||
|
||||
elements: list[ImageElement]
|
||||
type: str = "image-group"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ContainerModule(KookCardModelBase):
|
||||
"""1 到多张图片的组合,与图片组模块(ImageGroupModule)不同,图片并不会裁切为正方形。多张图片会纵向排列。"""
|
||||
|
||||
elements: list[ImageElement]
|
||||
type: str = "container"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ActionGroupModule(KookCardModelBase):
|
||||
elements: list[ButtonElement]
|
||||
type: str = "action-group"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ContextModule(KookCardModelBase):
|
||||
elements: list[PlainTextElement | KmarkdownElement | ImageElement]
|
||||
"""最多包含10个元素"""
|
||||
type: str = "context"
|
||||
|
||||
|
||||
@dataclass
|
||||
class DividerModule(KookCardModelBase):
|
||||
type: str = "divider"
|
||||
|
||||
|
||||
@dataclass
|
||||
class FileModule(KookCardModelBase):
|
||||
src: str
|
||||
title: str = ""
|
||||
type: Literal["file", "audio", "video"] = "file"
|
||||
cover: str | None = None
|
||||
"""cover 仅音频有效, 是音频的封面图"""
|
||||
|
||||
|
||||
@dataclass
|
||||
class CountdownModule(KookCardModelBase):
|
||||
"""startTime 和 endTime 为毫秒时间戳,startTime 和 endTime 不能小于服务器当前时间戳。"""
|
||||
|
||||
endTime: int
|
||||
"""毫秒时间戳"""
|
||||
type: str = "countdown"
|
||||
startTime: int | None = None
|
||||
"""毫秒时间戳, 仅当mode为second才有这个字段"""
|
||||
mode: CountdownMode = "day"
|
||||
"""mode 主要是倒计时的样式"""
|
||||
|
||||
|
||||
@dataclass
|
||||
class InviteModule(KookCardModelBase):
|
||||
code: str
|
||||
"""邀请链接或者邀请码"""
|
||||
type: str = "invite"
|
||||
|
||||
|
||||
# 所有模块的联合类型
|
||||
AnyModule = (
|
||||
HeaderModule
|
||||
| SectionModule
|
||||
| ImageGroupModule
|
||||
| ContainerModule
|
||||
| ActionGroupModule
|
||||
| ContextModule
|
||||
| DividerModule
|
||||
| FileModule
|
||||
| CountdownModule
|
||||
| InviteModule
|
||||
)
|
||||
|
||||
|
||||
class KookCardMessage(BaseModel):
|
||||
"""卡片定义文档详见 : https://developer.kookapp.cn/doc/cardmessage
|
||||
此类型不能直接to_json后发送,因为kook要求卡片容器json顶层必须是**列表**
|
||||
若要发送卡片消息,请使用KookCardMessageContainer
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(arbitrary_types_allowed=True)
|
||||
type: str = "card"
|
||||
theme: ThemeType | None = None
|
||||
size: SizeType | None = None
|
||||
color: KookCardColor | None = None
|
||||
modules: list[AnyModule] = field(default_factory=list)
|
||||
"""单个 card 模块数量不限制,但是一条消息中所有卡片的模块数量之和最多是 50"""
|
||||
|
||||
def add_module(self, module: AnyModule):
|
||||
self.modules.append(module)
|
||||
|
||||
def to_dict(self, exclude_none: bool = True):
|
||||
"""exclude_none:去掉值为 None 字段,保留结构"""
|
||||
return self.model_dump(exclude_none=exclude_none)
|
||||
|
||||
def to_json(self, indent: int | None = None, ensure_ascii: bool = True):
|
||||
return json.dumps(self.to_dict(), indent=indent, ensure_ascii=ensure_ascii)
|
||||
|
||||
|
||||
class KookCardMessageContainer(list[KookCardMessage]):
|
||||
"""卡片消息容器(列表),此类型可以直接to_json后发送出去"""
|
||||
|
||||
def append(self, object: KookCardMessage) -> None:
|
||||
return super().append(object)
|
||||
|
||||
def to_json(self, indent: int | None = None, ensure_ascii: bool = True) -> str:
|
||||
return json.dumps(
|
||||
[i.to_dict() for i in self], indent=indent, ensure_ascii=ensure_ascii
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class OrderMessage:
|
||||
index: int
|
||||
text: str
|
||||
type: KookMessageType
|
||||
reply_id: str | int = ""
|
||||
@@ -104,7 +104,7 @@ class LineMessageEvent(AstrMessageEvent):
|
||||
@staticmethod
|
||||
async def _resolve_image_url(segment: Image) -> str:
|
||||
candidate = (segment.url or segment.file or "").strip()
|
||||
if candidate.startswith("http://") or candidate.startswith("https://"):
|
||||
if candidate.startswith("https://"):
|
||||
return candidate
|
||||
try:
|
||||
return await segment.register_to_file_service()
|
||||
@@ -115,7 +115,7 @@ class LineMessageEvent(AstrMessageEvent):
|
||||
@staticmethod
|
||||
async def _resolve_record_url(segment: Record) -> str:
|
||||
candidate = (segment.url or segment.file or "").strip()
|
||||
if candidate.startswith("http://") or candidate.startswith("https://"):
|
||||
if candidate.startswith("https://"):
|
||||
return candidate
|
||||
try:
|
||||
return await segment.register_to_file_service()
|
||||
@@ -137,7 +137,7 @@ class LineMessageEvent(AstrMessageEvent):
|
||||
@staticmethod
|
||||
async def _resolve_video_url(segment: Video) -> str:
|
||||
candidate = (segment.file or "").strip()
|
||||
if candidate.startswith("http://") or candidate.startswith("https://"):
|
||||
if candidate.startswith("https://"):
|
||||
return candidate
|
||||
try:
|
||||
return await segment.register_to_file_service()
|
||||
@@ -148,9 +148,7 @@ class LineMessageEvent(AstrMessageEvent):
|
||||
@staticmethod
|
||||
async def _resolve_video_preview_url(segment: Video) -> str:
|
||||
cover_candidate = (segment.cover or "").strip()
|
||||
if cover_candidate.startswith("http://") or cover_candidate.startswith(
|
||||
"https://"
|
||||
):
|
||||
if cover_candidate.startswith("https://"):
|
||||
return cover_candidate
|
||||
|
||||
if cover_candidate:
|
||||
@@ -191,7 +189,7 @@ class LineMessageEvent(AstrMessageEvent):
|
||||
|
||||
@staticmethod
|
||||
async def _resolve_file_url(segment: File) -> str:
|
||||
if segment.url and segment.url.startswith(("http://", "https://")):
|
||||
if segment.url and segment.url.startswith("https://"):
|
||||
return segment.url
|
||||
try:
|
||||
return await segment.register_to_file_service()
|
||||
|
||||
@@ -4,7 +4,11 @@ import asyncio
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
from collections.abc import AsyncGenerator, Awaitable, Callable
|
||||
import threading
|
||||
import urllib.parse
|
||||
from collections.abc import AsyncGenerator, Awaitable, Callable, Mapping
|
||||
from dataclasses import dataclass
|
||||
from types import MappingProxyType
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
@@ -17,6 +21,103 @@ from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
||||
|
||||
DEFAULT_MCP_CONFIG = {"mcpServers": {}}
|
||||
|
||||
DEFAULT_MCP_INIT_TIMEOUT_SECONDS = 20.0
|
||||
DEFAULT_ENABLE_MCP_TIMEOUT_SECONDS = 30.0
|
||||
MCP_INIT_TIMEOUT_ENV = "ASTRBOT_MCP_INIT_TIMEOUT"
|
||||
ENABLE_MCP_TIMEOUT_ENV = "ASTRBOT_MCP_ENABLE_TIMEOUT"
|
||||
MAX_MCP_TIMEOUT_SECONDS = 300.0
|
||||
|
||||
|
||||
class MCPInitError(Exception):
|
||||
"""Base exception for MCP initialization failures."""
|
||||
|
||||
|
||||
class MCPInitTimeoutError(asyncio.TimeoutError, MCPInitError):
|
||||
"""Raised when MCP client initialization exceeds the configured timeout."""
|
||||
|
||||
|
||||
class MCPAllServicesFailedError(MCPInitError):
|
||||
"""Raised when all configured MCP services fail to initialize."""
|
||||
|
||||
|
||||
class MCPShutdownTimeoutError(asyncio.TimeoutError):
|
||||
"""Raised when MCP shutdown exceeds the configured timeout."""
|
||||
|
||||
def __init__(self, names: list[str], timeout: float) -> None:
|
||||
self.names = names
|
||||
self.timeout = timeout
|
||||
message = f"MCP 服务关闭超时({timeout:g} 秒):{', '.join(names)}"
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
@dataclass
|
||||
class MCPInitSummary:
|
||||
total: int
|
||||
success: int
|
||||
failed: list[str]
|
||||
|
||||
|
||||
@dataclass
|
||||
class _MCPServerRuntime:
|
||||
name: str
|
||||
client: MCPClient
|
||||
shutdown_event: asyncio.Event
|
||||
lifecycle_task: asyncio.Task[None]
|
||||
|
||||
|
||||
class _MCPClientDictView(Mapping[str, MCPClient]):
|
||||
"""Read-only view of MCP clients derived from runtime state."""
|
||||
|
||||
def __init__(self, runtime: dict[str, _MCPServerRuntime]) -> None:
|
||||
self._runtime = runtime
|
||||
|
||||
def __getitem__(self, key: str) -> MCPClient:
|
||||
return self._runtime[key].client
|
||||
|
||||
def __iter__(self):
|
||||
return iter(self._runtime)
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self._runtime)
|
||||
|
||||
|
||||
def _resolve_timeout(
|
||||
timeout: float | int | str | None = None,
|
||||
*,
|
||||
env_name: str = MCP_INIT_TIMEOUT_ENV,
|
||||
default: float = DEFAULT_MCP_INIT_TIMEOUT_SECONDS,
|
||||
) -> float:
|
||||
"""Resolve timeout with precedence: explicit argument > env value > default."""
|
||||
source = f"环境变量 {env_name}"
|
||||
if timeout is None:
|
||||
timeout = os.getenv(env_name, str(default))
|
||||
else:
|
||||
source = "显式参数 timeout"
|
||||
|
||||
try:
|
||||
timeout_value = float(timeout)
|
||||
except (TypeError, ValueError):
|
||||
logger.warning(
|
||||
f"超时配置({source})={timeout!r} 无效,使用默认值 {default:g} 秒。"
|
||||
)
|
||||
return default
|
||||
|
||||
if timeout_value <= 0:
|
||||
logger.warning(
|
||||
f"超时配置({source})={timeout_value:g} 必须大于 0,使用默认值 {default:g} 秒。"
|
||||
)
|
||||
return default
|
||||
|
||||
if timeout_value > MAX_MCP_TIMEOUT_SECONDS:
|
||||
logger.warning(
|
||||
f"超时配置({source})={timeout_value:g} 过大,已限制为最大值 "
|
||||
f"{MAX_MCP_TIMEOUT_SECONDS:g} 秒,以避免长时间等待。"
|
||||
)
|
||||
return MAX_MCP_TIMEOUT_SECONDS
|
||||
|
||||
return timeout_value
|
||||
|
||||
|
||||
SUPPORTED_TYPES = [
|
||||
"string",
|
||||
"number",
|
||||
@@ -106,9 +207,49 @@ async def _quick_test_mcp_connection(config: dict) -> tuple[bool, str]:
|
||||
class FunctionToolManager:
|
||||
def __init__(self) -> None:
|
||||
self.func_list: list[FuncTool] = []
|
||||
self.mcp_client_dict: dict[str, MCPClient] = {}
|
||||
"""MCP 服务列表"""
|
||||
self.mcp_client_event: dict[str, asyncio.Event] = {}
|
||||
self._mcp_server_runtime: dict[str, _MCPServerRuntime] = {}
|
||||
"""MCP 服务运行时状态(唯一事实来源)"""
|
||||
self._mcp_server_runtime_view = MappingProxyType(self._mcp_server_runtime)
|
||||
self._mcp_client_dict_view = _MCPClientDictView(self._mcp_server_runtime)
|
||||
self._timeout_mismatch_warned = False
|
||||
self._timeout_warn_lock = threading.Lock()
|
||||
self._runtime_lock = asyncio.Lock()
|
||||
self._mcp_starting: set[str] = set()
|
||||
self._init_timeout_default = _resolve_timeout(
|
||||
timeout=None,
|
||||
env_name=MCP_INIT_TIMEOUT_ENV,
|
||||
default=DEFAULT_MCP_INIT_TIMEOUT_SECONDS,
|
||||
)
|
||||
self._enable_timeout_default = _resolve_timeout(
|
||||
timeout=None,
|
||||
env_name=ENABLE_MCP_TIMEOUT_ENV,
|
||||
default=DEFAULT_ENABLE_MCP_TIMEOUT_SECONDS,
|
||||
)
|
||||
self._warn_on_timeout_mismatch(
|
||||
self._init_timeout_default,
|
||||
self._enable_timeout_default,
|
||||
)
|
||||
|
||||
@property
|
||||
def mcp_client_dict(self) -> Mapping[str, MCPClient]:
|
||||
"""Read-only compatibility view for external callers that still read mcp_client_dict.
|
||||
|
||||
Note: Mutating this mapping is unsupported and will raise TypeError.
|
||||
"""
|
||||
return self._mcp_client_dict_view
|
||||
|
||||
@property
|
||||
def mcp_server_runtime_view(self) -> Mapping[str, _MCPServerRuntime]:
|
||||
"""Read-only view of MCP runtime metadata for external callers."""
|
||||
return self._mcp_server_runtime_view
|
||||
|
||||
@property
|
||||
def mcp_server_runtime(self) -> Mapping[str, _MCPServerRuntime]:
|
||||
"""Backward-compatible read-only view (deprecated). Do not mutate.
|
||||
|
||||
Note: Mutations are not supported and will raise TypeError.
|
||||
"""
|
||||
return self._mcp_server_runtime_view
|
||||
|
||||
def empty(self) -> bool:
|
||||
return len(self.func_list) == 0
|
||||
@@ -179,7 +320,34 @@ class FunctionToolManager:
|
||||
tool_set = ToolSet(self.func_list.copy())
|
||||
return tool_set
|
||||
|
||||
async def init_mcp_clients(self) -> None:
|
||||
@staticmethod
|
||||
def _log_safe_mcp_debug_config(cfg: dict) -> None:
|
||||
# 仅记录脱敏后的摘要,避免泄露 command/args/url 中的敏感信息
|
||||
if "command" in cfg:
|
||||
cmd = cfg["command"]
|
||||
executable = str(cmd[0] if isinstance(cmd, (list, tuple)) and cmd else cmd)
|
||||
args_val = cfg.get("args", [])
|
||||
args_count = (
|
||||
len(args_val)
|
||||
if isinstance(args_val, (list, tuple))
|
||||
else (0 if args_val is None else 1)
|
||||
)
|
||||
logger.debug(f" 命令可执行文件: {executable}, 参数数量: {args_count}")
|
||||
return
|
||||
|
||||
if "url" in cfg:
|
||||
parsed = urllib.parse.urlparse(str(cfg["url"]))
|
||||
host = parsed.hostname or ""
|
||||
scheme = parsed.scheme or "unknown"
|
||||
try:
|
||||
port = f":{parsed.port}" if parsed.port else ""
|
||||
except ValueError:
|
||||
port = ""
|
||||
logger.debug(f" 主机: {scheme}://{host}{port}")
|
||||
|
||||
async def init_mcp_clients(
|
||||
self, raise_on_all_failed: bool = False
|
||||
) -> MCPInitSummary:
|
||||
"""从项目根目录读取 mcp_server.json 文件,初始化 MCP 服务列表。文件格式如下:
|
||||
```
|
||||
{
|
||||
@@ -197,6 +365,10 @@ class FunctionToolManager:
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
Timeout behavior:
|
||||
- 初始化超时使用环境变量 ASTRBOT_MCP_INIT_TIMEOUT 或默认值。
|
||||
- 动态启用超时使用 ASTRBOT_MCP_ENABLE_TIMEOUT(独立于初始化超时)。
|
||||
"""
|
||||
data_dir = get_astrbot_data_path()
|
||||
|
||||
@@ -206,56 +378,211 @@ class FunctionToolManager:
|
||||
with open(mcp_json_file, "w", encoding="utf-8") as f:
|
||||
json.dump(DEFAULT_MCP_CONFIG, f, ensure_ascii=False, indent=4)
|
||||
logger.info(f"未找到 MCP 服务配置文件,已创建默认配置文件 {mcp_json_file}")
|
||||
return
|
||||
return MCPInitSummary(total=0, success=0, failed=[])
|
||||
|
||||
mcp_server_json_obj: dict[str, dict] = json.load(
|
||||
open(mcp_json_file, encoding="utf-8"),
|
||||
)["mcpServers"]
|
||||
with open(mcp_json_file, encoding="utf-8") as f:
|
||||
mcp_server_json_obj: dict[str, dict] = json.load(f)["mcpServers"]
|
||||
|
||||
for name in mcp_server_json_obj:
|
||||
cfg = mcp_server_json_obj[name]
|
||||
init_timeout = self._init_timeout_default
|
||||
timeout_display = f"{init_timeout:g}"
|
||||
|
||||
active_configs: list[tuple[str, dict, asyncio.Event]] = []
|
||||
for name, cfg in mcp_server_json_obj.items():
|
||||
if cfg.get("active", True):
|
||||
event = asyncio.Event()
|
||||
asyncio.create_task(
|
||||
self._init_mcp_client_task_wrapper(name, cfg, event),
|
||||
)
|
||||
self.mcp_client_event[name] = event
|
||||
shutdown_event = asyncio.Event()
|
||||
active_configs.append((name, cfg, shutdown_event))
|
||||
|
||||
async def _init_mcp_client_task_wrapper(
|
||||
if not active_configs:
|
||||
return MCPInitSummary(total=0, success=0, failed=[])
|
||||
|
||||
logger.info(f"等待 {len(active_configs)} 个 MCP 服务初始化...")
|
||||
|
||||
init_tasks = [
|
||||
asyncio.create_task(
|
||||
self._start_mcp_server(
|
||||
name=name,
|
||||
cfg=cfg,
|
||||
shutdown_event=shutdown_event,
|
||||
timeout=init_timeout,
|
||||
),
|
||||
name=f"mcp-init:{name}",
|
||||
)
|
||||
for (name, cfg, shutdown_event) in active_configs
|
||||
]
|
||||
results = await asyncio.gather(*init_tasks, return_exceptions=True)
|
||||
|
||||
success_count = 0
|
||||
failed_services: list[str] = []
|
||||
|
||||
for (name, cfg, _), result in zip(active_configs, results, strict=False):
|
||||
if isinstance(result, Exception):
|
||||
if isinstance(result, MCPInitTimeoutError):
|
||||
logger.error(f"MCP 服务 {name} 初始化超时({timeout_display}秒)")
|
||||
else:
|
||||
logger.error(f"MCP 服务 {name} 初始化失败: {result}")
|
||||
self._log_safe_mcp_debug_config(cfg)
|
||||
failed_services.append(name)
|
||||
async with self._runtime_lock:
|
||||
self._mcp_server_runtime.pop(name, None)
|
||||
continue
|
||||
|
||||
success_count += 1
|
||||
|
||||
if failed_services:
|
||||
logger.warning(
|
||||
f"以下 MCP 服务初始化失败: {', '.join(failed_services)}。"
|
||||
f"请检查配置文件 mcp_server.json 和服务器可用性。"
|
||||
)
|
||||
|
||||
summary = MCPInitSummary(
|
||||
total=len(active_configs), success=success_count, failed=failed_services
|
||||
)
|
||||
logger.info(f"MCP 服务初始化完成: {summary.success}/{summary.total} 成功")
|
||||
if summary.total > 0 and summary.success == 0:
|
||||
msg = "全部 MCP 服务初始化失败,请检查 mcp_server.json 配置和服务器可用性。"
|
||||
if raise_on_all_failed:
|
||||
raise MCPAllServicesFailedError(msg)
|
||||
logger.error(msg)
|
||||
return summary
|
||||
|
||||
async def _start_mcp_server(
|
||||
self,
|
||||
name: str,
|
||||
cfg: dict,
|
||||
event: asyncio.Event,
|
||||
ready_future: asyncio.Future | None = None,
|
||||
*,
|
||||
shutdown_event: asyncio.Event | None = None,
|
||||
timeout: float,
|
||||
) -> None:
|
||||
"""初始化 MCP 客户端的包装函数,用于捕获异常"""
|
||||
"""Initialize MCP server with timeout and register task/event together.
|
||||
|
||||
This method is idempotent. If the server is already running, the existing
|
||||
runtime is kept and the new config is ignored.
|
||||
"""
|
||||
async with self._runtime_lock:
|
||||
if name in self._mcp_server_runtime or name in self._mcp_starting:
|
||||
logger.warning(
|
||||
f"MCP 服务 {name} 已在运行,忽略本次启用请求(timeout={timeout:g})。"
|
||||
)
|
||||
self._log_safe_mcp_debug_config(cfg)
|
||||
return
|
||||
self._mcp_starting.add(name)
|
||||
|
||||
if shutdown_event is None:
|
||||
shutdown_event = asyncio.Event()
|
||||
|
||||
mcp_client: MCPClient | None = None
|
||||
try:
|
||||
await self._init_mcp_client(name, cfg)
|
||||
tools = await self.mcp_client_dict[name].list_tools_and_save()
|
||||
if ready_future and not ready_future.done():
|
||||
# tell the caller we are ready
|
||||
ready_future.set_result(tools)
|
||||
await event.wait()
|
||||
logger.info(f"收到 MCP 客户端 {name} 终止信号")
|
||||
except Exception as e:
|
||||
mcp_client = await asyncio.wait_for(
|
||||
self._init_mcp_client(name, cfg),
|
||||
timeout=timeout,
|
||||
)
|
||||
except asyncio.TimeoutError as exc:
|
||||
raise MCPInitTimeoutError(
|
||||
f"MCP 服务 {name} 初始化超时({timeout:g} 秒)"
|
||||
) from exc
|
||||
except Exception:
|
||||
logger.error(f"初始化 MCP 客户端 {name} 失败", exc_info=True)
|
||||
if ready_future and not ready_future.done():
|
||||
ready_future.set_exception(e)
|
||||
raise
|
||||
finally:
|
||||
# 无论如何都能清理
|
||||
await self._terminate_mcp_client(name)
|
||||
if mcp_client is None:
|
||||
async with self._runtime_lock:
|
||||
self._mcp_starting.discard(name)
|
||||
|
||||
async def _init_mcp_client(self, name: str, config: dict) -> None:
|
||||
async def lifecycle() -> None:
|
||||
try:
|
||||
await shutdown_event.wait()
|
||||
logger.info(f"收到 MCP 客户端 {name} 终止信号")
|
||||
except asyncio.CancelledError:
|
||||
logger.debug(f"MCP 客户端 {name} 任务被取消")
|
||||
raise
|
||||
finally:
|
||||
await self._terminate_mcp_client(name)
|
||||
|
||||
lifecycle_task = asyncio.create_task(lifecycle(), name=f"mcp-client:{name}")
|
||||
async with self._runtime_lock:
|
||||
self._mcp_server_runtime[name] = _MCPServerRuntime(
|
||||
name=name,
|
||||
client=mcp_client,
|
||||
shutdown_event=shutdown_event,
|
||||
lifecycle_task=lifecycle_task,
|
||||
)
|
||||
self._mcp_starting.discard(name)
|
||||
|
||||
async def _shutdown_runtimes(
|
||||
self,
|
||||
runtimes: list[_MCPServerRuntime],
|
||||
timeout: float,
|
||||
*,
|
||||
strict: bool = True,
|
||||
) -> list[str]:
|
||||
"""Shutdown runtimes and wait for lifecycle tasks to complete."""
|
||||
lifecycle_tasks = [
|
||||
runtime.lifecycle_task
|
||||
for runtime in runtimes
|
||||
if not runtime.lifecycle_task.done()
|
||||
]
|
||||
if not lifecycle_tasks:
|
||||
return []
|
||||
|
||||
for runtime in runtimes:
|
||||
runtime.shutdown_event.set()
|
||||
|
||||
try:
|
||||
results = await asyncio.wait_for(
|
||||
asyncio.gather(*lifecycle_tasks, return_exceptions=True),
|
||||
timeout=timeout,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
pending_names = [
|
||||
runtime.name
|
||||
for runtime in runtimes
|
||||
if not runtime.lifecycle_task.done()
|
||||
]
|
||||
for task in lifecycle_tasks:
|
||||
if not task.done():
|
||||
task.cancel()
|
||||
await asyncio.gather(*lifecycle_tasks, return_exceptions=True)
|
||||
if strict:
|
||||
raise MCPShutdownTimeoutError(pending_names, timeout)
|
||||
logger.warning(
|
||||
"MCP 服务关闭超时(%s 秒),以下服务未完全关闭:%s",
|
||||
f"{timeout:g}",
|
||||
", ".join(pending_names),
|
||||
)
|
||||
return pending_names
|
||||
else:
|
||||
for result in results:
|
||||
if isinstance(result, asyncio.CancelledError):
|
||||
logger.debug("MCP lifecycle task was cancelled during shutdown.")
|
||||
elif isinstance(result, Exception):
|
||||
logger.error(
|
||||
"MCP lifecycle task failed during shutdown.",
|
||||
exc_info=(type(result), result, result.__traceback__),
|
||||
)
|
||||
return []
|
||||
|
||||
async def _cleanup_mcp_client_safely(
|
||||
self, mcp_client: MCPClient, name: str
|
||||
) -> None:
|
||||
"""安全清理单个 MCP 客户端,避免清理异常中断主流程。"""
|
||||
try:
|
||||
await mcp_client.cleanup()
|
||||
except Exception as cleanup_exc: # noqa: BLE001 - only log here
|
||||
logger.error(f"清理 MCP 客户端资源 {name} 失败: {cleanup_exc}")
|
||||
|
||||
async def _init_mcp_client(self, name: str, config: dict) -> MCPClient:
|
||||
"""初始化单个MCP客户端"""
|
||||
# 先清理之前的客户端,如果存在
|
||||
if name in self.mcp_client_dict:
|
||||
await self._terminate_mcp_client(name)
|
||||
|
||||
mcp_client = MCPClient()
|
||||
mcp_client.name = name
|
||||
self.mcp_client_dict[name] = mcp_client
|
||||
await mcp_client.connect_to_server(config, name)
|
||||
tools_res = await mcp_client.list_tools_and_save()
|
||||
try:
|
||||
await mcp_client.connect_to_server(config, name)
|
||||
tools_res = await mcp_client.list_tools_and_save()
|
||||
except asyncio.CancelledError:
|
||||
await self._cleanup_mcp_client_safely(mcp_client, name)
|
||||
raise
|
||||
except Exception:
|
||||
await self._cleanup_mcp_client_safely(mcp_client, name)
|
||||
raise
|
||||
logger.debug(f"MCP server {name} list tools response: {tools_res}")
|
||||
tool_names = [tool.name for tool in tools_res.tools]
|
||||
|
||||
@@ -276,26 +603,36 @@ class FunctionToolManager:
|
||||
self.func_list.append(func_tool)
|
||||
|
||||
logger.info(f"已连接 MCP 服务 {name}, Tools: {tool_names}")
|
||||
return mcp_client
|
||||
|
||||
async def _terminate_mcp_client(self, name: str) -> None:
|
||||
"""关闭并清理MCP客户端"""
|
||||
if name in self.mcp_client_dict:
|
||||
client = self.mcp_client_dict[name]
|
||||
try:
|
||||
# 关闭MCP连接
|
||||
await client.cleanup()
|
||||
except Exception as e:
|
||||
logger.error(f"清空 MCP 客户端资源 {name}: {e}。")
|
||||
finally:
|
||||
# Remove client from dict after cleanup attempt (successful or not)
|
||||
self.mcp_client_dict.pop(name, None)
|
||||
# 移除关联的FuncTool
|
||||
self.func_list = [
|
||||
f
|
||||
for f in self.func_list
|
||||
if not (isinstance(f, MCPTool) and f.mcp_server_name == name)
|
||||
]
|
||||
logger.info(f"已关闭 MCP 服务 {name}")
|
||||
async with self._runtime_lock:
|
||||
runtime = self._mcp_server_runtime.get(name)
|
||||
if runtime:
|
||||
client = runtime.client
|
||||
# 关闭MCP连接
|
||||
await self._cleanup_mcp_client_safely(client, name)
|
||||
# 移除关联的FuncTool
|
||||
self.func_list = [
|
||||
f
|
||||
for f in self.func_list
|
||||
if not (isinstance(f, MCPTool) and f.mcp_server_name == name)
|
||||
]
|
||||
async with self._runtime_lock:
|
||||
self._mcp_server_runtime.pop(name, None)
|
||||
self._mcp_starting.discard(name)
|
||||
logger.info(f"已关闭 MCP 服务 {name}")
|
||||
return
|
||||
|
||||
# Runtime missing but stale tools may still exist after failed flows.
|
||||
self.func_list = [
|
||||
f
|
||||
for f in self.func_list
|
||||
if not (isinstance(f, MCPTool) and f.mcp_server_name == name)
|
||||
]
|
||||
async with self._runtime_lock:
|
||||
self._mcp_starting.discard(name)
|
||||
|
||||
@staticmethod
|
||||
async def test_mcp_server_connection(config: dict) -> list[str]:
|
||||
@@ -319,42 +656,36 @@ class FunctionToolManager:
|
||||
self,
|
||||
name: str,
|
||||
config: dict,
|
||||
event: asyncio.Event | None = None,
|
||||
ready_future: asyncio.Future | None = None,
|
||||
timeout: int = 30,
|
||||
shutdown_event: asyncio.Event | None = None,
|
||||
timeout: float | int | str | None = None,
|
||||
) -> None:
|
||||
"""Enable_mcp_server a new MCP server to the manager and initialize it.
|
||||
"""Enable a new MCP server and initialize it.
|
||||
|
||||
Args:
|
||||
name (str): The name of the MCP server.
|
||||
config (dict): Configuration for the MCP server.
|
||||
event (asyncio.Event): Event to signal when the MCP client is ready.
|
||||
ready_future (asyncio.Future): Future to signal when the MCP client is ready.
|
||||
timeout (int): Timeout for the initialization.
|
||||
name: The name of the MCP server.
|
||||
config: Configuration for the MCP server.
|
||||
shutdown_event: Event to signal when the MCP client should shut down.
|
||||
timeout: Timeout in seconds for initialization.
|
||||
Uses ASTRBOT_MCP_ENABLE_TIMEOUT by default (separate from init timeout).
|
||||
|
||||
Raises:
|
||||
TimeoutError: If the initialization does not complete within the specified timeout.
|
||||
MCPInitTimeoutError: If initialization does not complete within timeout.
|
||||
Exception: If there is an error during initialization.
|
||||
|
||||
"""
|
||||
if not event:
|
||||
event = asyncio.Event()
|
||||
if not ready_future:
|
||||
ready_future = asyncio.Future()
|
||||
if name in self.mcp_client_dict:
|
||||
return
|
||||
asyncio.create_task(
|
||||
self._init_mcp_client_task_wrapper(name, config, event, ready_future),
|
||||
if timeout is None:
|
||||
timeout_value = self._enable_timeout_default
|
||||
else:
|
||||
timeout_value = _resolve_timeout(
|
||||
timeout=timeout,
|
||||
env_name=ENABLE_MCP_TIMEOUT_ENV,
|
||||
default=self._enable_timeout_default,
|
||||
)
|
||||
await self._start_mcp_server(
|
||||
name=name,
|
||||
cfg=config,
|
||||
shutdown_event=shutdown_event,
|
||||
timeout=timeout_value,
|
||||
)
|
||||
try:
|
||||
await asyncio.wait_for(ready_future, timeout=timeout)
|
||||
finally:
|
||||
self.mcp_client_event[name] = event
|
||||
|
||||
if ready_future.done() and ready_future.exception():
|
||||
exc = ready_future.exception()
|
||||
if exc is not None:
|
||||
raise exc
|
||||
|
||||
async def disable_mcp_server(
|
||||
self,
|
||||
@@ -367,39 +698,40 @@ class FunctionToolManager:
|
||||
name (str): The name of the MCP server to disable. If None, ALL MCP servers will be disabled.
|
||||
timeout (int): Timeout.
|
||||
|
||||
Raises:
|
||||
MCPShutdownTimeoutError: If shutdown does not complete within timeout.
|
||||
Only raised when disabling a specific server (name is not None).
|
||||
|
||||
"""
|
||||
if name:
|
||||
if name not in self.mcp_client_event:
|
||||
async with self._runtime_lock:
|
||||
runtime = self._mcp_server_runtime.get(name)
|
||||
if runtime is None:
|
||||
return
|
||||
client = self.mcp_client_dict.get(name)
|
||||
self.mcp_client_event[name].set()
|
||||
if not client:
|
||||
return
|
||||
client_running_event = client.running_event
|
||||
try:
|
||||
await asyncio.wait_for(client_running_event.wait(), timeout=timeout)
|
||||
finally:
|
||||
self.mcp_client_event.pop(name, None)
|
||||
self.func_list = [
|
||||
f
|
||||
for f in self.func_list
|
||||
if not (isinstance(f, MCPTool) and f.mcp_server_name == name)
|
||||
]
|
||||
|
||||
await self._shutdown_runtimes([runtime], timeout, strict=True)
|
||||
else:
|
||||
running_events = [
|
||||
client.running_event.wait() for client in self.mcp_client_dict.values()
|
||||
]
|
||||
for key, event in self.mcp_client_event.items():
|
||||
event.set()
|
||||
# waiting for all clients to finish
|
||||
try:
|
||||
await asyncio.wait_for(asyncio.gather(*running_events), timeout=timeout)
|
||||
finally:
|
||||
self.mcp_client_event.clear()
|
||||
self.mcp_client_dict.clear()
|
||||
self.func_list = [
|
||||
f for f in self.func_list if not isinstance(f, MCPTool)
|
||||
]
|
||||
async with self._runtime_lock:
|
||||
runtimes = list(self._mcp_server_runtime.values())
|
||||
await self._shutdown_runtimes(runtimes, timeout, strict=False)
|
||||
|
||||
def _warn_on_timeout_mismatch(
|
||||
self,
|
||||
init_timeout: float,
|
||||
enable_timeout: float,
|
||||
) -> None:
|
||||
if init_timeout == enable_timeout:
|
||||
return
|
||||
with self._timeout_warn_lock:
|
||||
if self._timeout_mismatch_warned:
|
||||
return
|
||||
logger.info(
|
||||
"检测到 MCP 初始化超时与动态启用超时配置不同:"
|
||||
"初始化使用 %s 秒,动态启用使用 %s 秒。如需一致,请设置相同值。",
|
||||
f"{init_timeout:g}",
|
||||
f"{enable_timeout:g}",
|
||||
)
|
||||
self._timeout_mismatch_warned = True
|
||||
|
||||
def get_func_desc_openai_style(self, omit_empty_parameter_field=False) -> list:
|
||||
"""获得 OpenAI API 风格的**已经激活**的工具描述"""
|
||||
|
||||
@@ -330,8 +330,25 @@ class ProviderManager:
|
||||
if not self.curr_tts_provider_inst and self.tts_provider_insts:
|
||||
self.curr_tts_provider_inst = self.tts_provider_insts[0]
|
||||
|
||||
# 初始化 MCP Client 连接
|
||||
asyncio.create_task(self.llm_tools.init_mcp_clients(), name="init_mcp_clients")
|
||||
# 初始化 MCP Client 连接(等待完成以确保工具可用)
|
||||
strict_mcp_init = os.getenv("ASTRBOT_MCP_INIT_STRICT", "").strip().lower() in {
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
"on",
|
||||
}
|
||||
mcp_init_summary = await self.llm_tools.init_mcp_clients(
|
||||
raise_on_all_failed=strict_mcp_init
|
||||
)
|
||||
if (
|
||||
mcp_init_summary.total > 0
|
||||
and mcp_init_summary.success == 0
|
||||
and not strict_mcp_init
|
||||
):
|
||||
logger.warning(
|
||||
"MCP 服务全部初始化失败,系统将继续启动(可设置 "
|
||||
"ASTRBOT_MCP_INIT_STRICT=1 以在此场景下中止启动)。"
|
||||
)
|
||||
|
||||
def dynamic_import_provider(self, type: str) -> None:
|
||||
"""动态导入提供商适配器模块
|
||||
|
||||
@@ -149,7 +149,9 @@ class AstrBotUpdator(RepoZipUpdator):
|
||||
file_url = None
|
||||
|
||||
if os.environ.get("ASTRBOT_CLI") or os.environ.get("ASTRBOT_LAUNCHER"):
|
||||
raise Exception("不支持更新此方式启动的AstrBot") # 避免版本管理混乱
|
||||
raise Exception(
|
||||
"Error: You are running AstrBot via CLI, please use `pip` or `uv tool upgrade` to update AstrBot."
|
||||
) # 避免版本管理混乱
|
||||
|
||||
if latest:
|
||||
latest_version = update_data[0]["tag_name"]
|
||||
|
||||
@@ -14,7 +14,7 @@ import certifi
|
||||
import psutil
|
||||
from PIL import Image
|
||||
|
||||
from .astrbot_path import get_astrbot_data_path, get_astrbot_temp_path
|
||||
from .astrbot_path import get_astrbot_data_path, get_astrbot_path, get_astrbot_temp_path
|
||||
|
||||
logger = logging.getLogger("astrbot")
|
||||
|
||||
@@ -219,7 +219,13 @@ def get_local_ip_addresses():
|
||||
|
||||
|
||||
async def get_dashboard_version():
|
||||
# First check user data directory (manually updated / downloaded dashboard).
|
||||
dist_dir = os.path.join(get_astrbot_data_path(), "dist")
|
||||
if not os.path.exists(dist_dir):
|
||||
# Fall back to the dist bundled inside the installed wheel.
|
||||
_bundled = Path(get_astrbot_path()) / "astrbot" / "dashboard" / "dist"
|
||||
if _bundled.exists():
|
||||
dist_dir = str(_bundled)
|
||||
if os.path.exists(dist_dir):
|
||||
version_file = os.path.join(dist_dir, "assets", "version")
|
||||
if os.path.exists(version_file):
|
||||
|
||||
@@ -51,11 +51,9 @@ class ToolsRoute(Route):
|
||||
server_info[key] = value
|
||||
|
||||
# 如果MCP客户端已初始化,从客户端获取工具名称
|
||||
for (
|
||||
name_key,
|
||||
mcp_client,
|
||||
) in self.tool_mgr.mcp_client_dict.items():
|
||||
for name_key, runtime in self.tool_mgr.mcp_server_runtime_view.items():
|
||||
if name_key == name:
|
||||
mcp_client = runtime.client
|
||||
server_info["tools"] = [tool.name for tool in mcp_client.tools]
|
||||
server_info["errlogs"] = mcp_client.server_errlogs
|
||||
break
|
||||
@@ -192,7 +190,7 @@ class ToolsRoute(Route):
|
||||
# 处理MCP客户端状态变化
|
||||
if active:
|
||||
if (
|
||||
old_name in self.tool_mgr.mcp_client_dict
|
||||
old_name in self.tool_mgr.mcp_server_runtime_view
|
||||
or not only_update_active
|
||||
or is_rename
|
||||
):
|
||||
@@ -233,7 +231,7 @@ class ToolsRoute(Route):
|
||||
.__dict__
|
||||
)
|
||||
# 如果要停用服务器
|
||||
elif old_name in self.tool_mgr.mcp_client_dict:
|
||||
elif old_name in self.tool_mgr.mcp_server_runtime_view:
|
||||
try:
|
||||
await self.tool_mgr.disable_mcp_server(old_name, timeout=10)
|
||||
except TimeoutError:
|
||||
@@ -272,7 +270,7 @@ class ToolsRoute(Route):
|
||||
del config["mcpServers"][name]
|
||||
|
||||
if self.tool_mgr.save_mcp_config(config):
|
||||
if name in self.tool_mgr.mcp_client_dict:
|
||||
if name in self.tool_mgr.mcp_server_runtime_view:
|
||||
try:
|
||||
await self.tool_mgr.disable_mcp_server(name, timeout=10)
|
||||
except TimeoutError:
|
||||
|
||||
@@ -33,6 +33,9 @@ from .routes.session_management import SessionManagementRoute
|
||||
from .routes.subagent import SubAgentRoute
|
||||
from .routes.t2i import T2iRoute
|
||||
|
||||
# Static assets shipped inside the wheel (built during `hatch build`).
|
||||
_BUNDLED_DIST = Path(__file__).parent / "dist"
|
||||
|
||||
|
||||
class _AddrWithPort(Protocol):
|
||||
port: int
|
||||
@@ -66,13 +69,22 @@ class AstrBotDashboard:
|
||||
self.config = core_lifecycle.astrbot_config
|
||||
self.db = db
|
||||
|
||||
# 参数指定webui目录
|
||||
# Path priority:
|
||||
# 1. Explicit webui_dir argument
|
||||
# 2. data/dist/ (user-installed / manually updated dashboard)
|
||||
# 3. astrbot/dashboard/dist/ (bundled with the wheel)
|
||||
if webui_dir and os.path.exists(webui_dir):
|
||||
self.data_path = os.path.abspath(webui_dir)
|
||||
else:
|
||||
self.data_path = os.path.abspath(
|
||||
os.path.join(get_astrbot_data_path(), "dist"),
|
||||
)
|
||||
user_dist = os.path.join(get_astrbot_data_path(), "dist")
|
||||
if os.path.exists(user_dist):
|
||||
self.data_path = os.path.abspath(user_dist)
|
||||
elif _BUNDLED_DIST.exists():
|
||||
self.data_path = str(_BUNDLED_DIST)
|
||||
logger.info("Using bundled dashboard dist: %s", self.data_path)
|
||||
else:
|
||||
# Fall back to expected user path (will fail gracefully later)
|
||||
self.data_path = os.path.abspath(user_dist)
|
||||
|
||||
self.app = Quart("dashboard", static_folder=self.data_path, static_url_path="/")
|
||||
APP = self.app # noqa
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<meta name="keywords" content="AstrBot Soulter" />
|
||||
<meta name="description" content="AstrBot Dashboard" />
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
|
||||
@@ -37,14 +37,7 @@
|
||||
|
||||
<!-- 正常聊天界面 -->
|
||||
<template v-else>
|
||||
<div class="conversation-header fade-in" v-if="isMobile">
|
||||
<!-- 手机端菜单按钮 -->
|
||||
<v-btn icon class="mobile-menu-btn" @click="toggleMobileSidebar" variant="text">
|
||||
<v-icon>mdi-menu</v-icon>
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<!-- 面包屑导航 -->
|
||||
<div v-if="currentSessionProject && messages && messages.length > 0" class="breadcrumb-container">
|
||||
<div class="breadcrumb-content">
|
||||
<span class="breadcrumb-emoji">{{ currentSessionProject.emoji || '📁' }}</span>
|
||||
@@ -241,6 +234,7 @@ const route = useRoute();
|
||||
const { t } = useI18n();
|
||||
const { tm } = useModuleI18n('features/chat');
|
||||
const theme = useTheme();
|
||||
const customizer = useCustomizerStore();
|
||||
|
||||
// UI 状态
|
||||
const isMobile = ref(false);
|
||||
@@ -342,19 +336,28 @@ function checkMobile() {
|
||||
isMobile.value = window.innerWidth <= 768;
|
||||
if (!isMobile.value) {
|
||||
mobileMenuOpen.value = false;
|
||||
customizer.SET_CHAT_SIDEBAR(false);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleMobileSidebar() {
|
||||
mobileMenuOpen.value = !mobileMenuOpen.value;
|
||||
customizer.SET_CHAT_SIDEBAR(mobileMenuOpen.value);
|
||||
}
|
||||
|
||||
function closeMobileSidebar() {
|
||||
mobileMenuOpen.value = false;
|
||||
customizer.SET_CHAT_SIDEBAR(false);
|
||||
}
|
||||
|
||||
// 同步 nav header 中的 sidebar toggle
|
||||
watch(() => customizer.chatSidebarOpen, (val) => {
|
||||
if (isMobile.value) {
|
||||
mobileMenuOpen.value = val;
|
||||
}
|
||||
});
|
||||
|
||||
function toggleTheme() {
|
||||
const customizer = useCustomizerStore();
|
||||
const newTheme = customizer.uiTheme === 'PurpleTheme' ? 'PurpleThemeDark' : 'PurpleTheme';
|
||||
customizer.SET_UI_THEME(newTheme);
|
||||
theme.global.name.value = newTheme;
|
||||
@@ -722,6 +725,7 @@ onBeforeUnmount(() => {
|
||||
height: 100%;
|
||||
max-height: 100%;
|
||||
overflow: hidden;
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
.chat-page-container {
|
||||
|
||||
@@ -32,11 +32,12 @@
|
||||
</div>
|
||||
</transition>
|
||||
<textarea ref="inputField" v-model="localPrompt" @keydown="handleKeyDown" :disabled="disabled"
|
||||
placeholder="Ask AstrBot..."
|
||||
style="width: 100%; resize: none; outline: none; border: 1px solid var(--v-theme-border); border-radius: 12px; padding: 12px 16px; min-height: 40px; font-family: inherit; font-size: 16px; background-color: var(--v-theme-surface);"></textarea>
|
||||
placeholder="Ask AstrBot..." class="chat-textarea"
|
||||
autocomplete="off" autocorrect="off" autocapitalize="sentences" spellcheck="false"
|
||||
style="width: 100%; resize: none; outline: none; border: 1px solid var(--v-theme-border); border-radius: 12px; padding: 16px 20px; min-height: 40px; max-height: 200px; overflow-y: auto; font-family: inherit; font-size: 16px; background-color: var(--v-theme-surface);"></textarea>
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; padding: 6px 14px;">
|
||||
<div
|
||||
style="display: flex; justify-content: flex-start; margin-top: 4px; align-items: center; gap: 8px;">
|
||||
style="display: flex; justify-content: flex-start; margin-top: 4px; align-items: center; gap: 8px; min-width: 0; flex: 1; overflow: hidden;">
|
||||
<!-- Settings Menu -->
|
||||
<StyledMenu offset="8" location="top start" :close-on-content-click="false">
|
||||
<template v-slot:activator="{ props: activatorProps }">
|
||||
@@ -72,9 +73,9 @@
|
||||
<!-- Provider/Model Selector Menu -->
|
||||
<ProviderModelMenu v-if="showProviderSelector" ref="providerModelMenuRef" />
|
||||
</div>
|
||||
<div style="display: flex; justify-content: flex-end; margin-top: 8px; align-items: center;">
|
||||
<div style="display: flex; justify-content: flex-end; margin-top: 8px; align-items: center; flex-shrink: 0;">
|
||||
<input type="file" ref="imageInputRef" @change="handleFileSelect" style="display: none" multiple />
|
||||
<v-progress-circular v-if="disabled" indeterminate size="16" class="mr-1" width="1.5" />
|
||||
<v-progress-circular v-if="disabled && !mobile" indeterminate size="16" class="mr-1" width="1.5" />
|
||||
<!-- <v-btn @click="$emit('openLiveMode')"
|
||||
icon
|
||||
variant="text"
|
||||
@@ -87,36 +88,21 @@
|
||||
</v-tooltip>
|
||||
</v-btn> -->
|
||||
<v-btn @click="handleRecordClick" icon variant="text" :color="isRecording ? 'error' : 'deep-purple'"
|
||||
class="record-btn" size="small">
|
||||
class="record-btn">
|
||||
<v-icon :icon="isRecording ? 'mdi-stop-circle' : 'mdi-microphone'" variant="text"
|
||||
plain></v-icon>
|
||||
<v-tooltip activator="parent" location="top">
|
||||
{{ isRecording ? tm('voice.speaking') : tm('voice.startRecording') }}
|
||||
</v-tooltip>
|
||||
</v-btn>
|
||||
<v-btn
|
||||
icon
|
||||
v-if="isRunning"
|
||||
@click="$emit('stop')"
|
||||
variant="text"
|
||||
class="send-btn"
|
||||
size="small"
|
||||
>
|
||||
<v-btn icon v-if="isRunning" @click="$emit('stop')" variant="tonal" color="deep-purple" class="send-btn">
|
||||
<v-icon icon="mdi-stop" variant="text" plain></v-icon>
|
||||
<v-tooltip activator="parent" location="top">
|
||||
{{ tm('input.stopGenerating') }}
|
||||
</v-tooltip>
|
||||
</v-btn>
|
||||
<v-btn
|
||||
v-else
|
||||
@click="$emit('send')"
|
||||
icon="mdi-send"
|
||||
variant="text"
|
||||
color="deep-purple"
|
||||
:disabled="!canSend"
|
||||
class="send-btn"
|
||||
size="small"
|
||||
/>
|
||||
<v-btn v-else @click="$emit('send')" icon="mdi-send" variant="tonal" color="deep-purple"
|
||||
:disabled="!canSend" class="send-btn" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -152,7 +138,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
|
||||
import { ref, computed, watch, nextTick, onMounted, onBeforeUnmount } from 'vue';
|
||||
import { useDisplay } from 'vuetify';
|
||||
import { useModuleI18n } from '@/i18n/composables';
|
||||
import { useCustomizerStore } from '@/stores/customizer';
|
||||
import ConfigSelector from './ConfigSelector.vue';
|
||||
@@ -251,21 +238,34 @@ function handleReplyAfterLeave() {
|
||||
isReplyClosing.value = false;
|
||||
}
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
// Enter 发送消息或触发命令
|
||||
if (e.keyCode === 13 && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
const { mobile } = useDisplay();
|
||||
|
||||
// 检查是否是 /astr_live_dev 命令
|
||||
// Auto-resize textarea
|
||||
function autoResize() {
|
||||
const el = inputField.value;
|
||||
if (!el) return;
|
||||
el.style.height = 'auto';
|
||||
el.style.height = Math.min(el.scrollHeight, 200) + 'px';
|
||||
}
|
||||
|
||||
watch(localPrompt, () => {
|
||||
nextTick(autoResize);
|
||||
});
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
// Enter 插入换行(桌面和手机端均如此,发送通过右下角发送按鈕)
|
||||
// Shift+Enter 发送(Ctrl+Enter / Cmd+Enter 也保留)
|
||||
if (e.keyCode === 13 && (e.shiftKey || e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
if (localPrompt.value.trim() === '/astr_live_dev') {
|
||||
emit('openLiveMode');
|
||||
localPrompt.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
if (canSend.value) {
|
||||
emit('send');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+B 录音
|
||||
@@ -588,11 +588,20 @@ defineExpose({
|
||||
@media (max-width: 768px) {
|
||||
.input-area {
|
||||
padding: 0 !important;
|
||||
padding-bottom: 10px !important;
|
||||
}
|
||||
|
||||
.input-container {
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
}
|
||||
|
||||
.input-area textarea,
|
||||
.chat-textarea {
|
||||
min-height: 32px !important;
|
||||
max-height: 160px !important;
|
||||
font-size: 16px !important;
|
||||
padding: 16px 16px 12px 16px !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
@deleteProject="$emit('deleteProject', $event)"
|
||||
/>
|
||||
|
||||
<div style="overflow-y: auto; flex-grow: 1;"
|
||||
<div style="overflow-y: auto; flex-grow: 1; overscroll-behavior-y: contain;"
|
||||
v-if="!sidebarCollapsed || isMobile">
|
||||
<v-card v-if="sessions.length > 0" flat style="background-color: transparent;">
|
||||
<v-list density="compact" nav class="conversation-list"
|
||||
@@ -326,6 +326,13 @@ function handleTransportModeChange(mode: string | null) {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.conversation-actions {
|
||||
opacity: 1 !important;
|
||||
visibility: visible !important;
|
||||
}
|
||||
}
|
||||
|
||||
.edit-title-btn,
|
||||
.delete-conversation-btn {
|
||||
opacity: 0.7;
|
||||
|
||||
@@ -965,6 +965,7 @@ export default {
|
||||
height: 100%;
|
||||
max-height: 100%;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior-y: contain;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -370,7 +370,8 @@
|
||||
"hint": "Optional Discord activity name. Leave empty to disable."
|
||||
},
|
||||
"discord_command_register": {
|
||||
"description": "Auto-register plugin commands as Discord slash commands"
|
||||
"description": "Register Discord slash commands",
|
||||
"hint": "When enabled, AstrBot will automatically register plugin commands as Discord slash commands"
|
||||
},
|
||||
"discord_proxy": {
|
||||
"description": "Discord Proxy URL",
|
||||
@@ -583,6 +584,51 @@
|
||||
"only_use_webhook_url_to_send": {
|
||||
"description": "Send Replies via Webhook Only",
|
||||
"hint": "When enabled, all WeCom AI Bot replies are sent through msg_push_webhook_url. The message push webhook supports more message types (such as images, files, etc.). If you do not need the typing effect, it is strongly recommended to use this option. "
|
||||
},
|
||||
"kook_bot_token": {
|
||||
"description": "Bot Token",
|
||||
"type": "string",
|
||||
"hint": "Required. The Bot Token obtained from the KOOK Developer Platform."
|
||||
},
|
||||
"kook_bot_nickname": {
|
||||
"description": "Bot Nickname",
|
||||
"type": "string",
|
||||
"hint": "Optional. If the sender nickname matches this value, the message will be ignored to prevent broadcast storms."
|
||||
},
|
||||
"kook_reconnect_delay": {
|
||||
"description": "Reconnect Delay",
|
||||
"type": "int",
|
||||
"hint": "Delay time for reconnection (seconds), using an exponential backoff strategy."
|
||||
},
|
||||
"kook_max_reconnect_delay": {
|
||||
"description": "Max Reconnect Delay",
|
||||
"type": "int",
|
||||
"hint": "The maximum value for reconnection delay (seconds)."
|
||||
},
|
||||
"kook_max_retry_delay": {
|
||||
"description": "Max Retry Delay",
|
||||
"type": "int",
|
||||
"hint": "The maximum delay time for retries (seconds)."
|
||||
},
|
||||
"kook_heartbeat_interval": {
|
||||
"description": "Heartbeat Interval",
|
||||
"type": "int",
|
||||
"hint": "The interval time for heartbeat detection (seconds)."
|
||||
},
|
||||
"kook_heartbeat_timeout": {
|
||||
"description": "Heartbeat Timeout",
|
||||
"type": "int",
|
||||
"hint": "The timeout duration for heartbeat detection (seconds)."
|
||||
},
|
||||
"kook_max_heartbeat_failures": {
|
||||
"description": "Max Heartbeat Failures",
|
||||
"type": "int",
|
||||
"hint": "Maximum allowed heartbeat failures; the connection will be dropped if exceeded."
|
||||
},
|
||||
"kook_max_consecutive_failures": {
|
||||
"description": "Max Consecutive Failures",
|
||||
"type": "int",
|
||||
"hint": "Maximum allowed consecutive failures; retries will stop if exceeded."
|
||||
}
|
||||
},
|
||||
"general": {
|
||||
@@ -737,6 +783,17 @@
|
||||
"hint": "Telegram only supports a fixed reaction set, reference: [https://gist.github.com/Soulter/3f22c8e5f9c7e152e967e8bc28c97fc9](https://gist.github.com/Soulter/3f22c8e5f9c7e152e967e8bc28c97fc9)"
|
||||
}
|
||||
}
|
||||
},
|
||||
"discord": {
|
||||
"pre_ack_emoji": {
|
||||
"enable": {
|
||||
"description": "[Discord] Enable Pre-acknowledgment Emoji"
|
||||
},
|
||||
"emojis": {
|
||||
"description": "Emoji List (Unicode or Custom Emoji Name)",
|
||||
"hint": "Enter Unicode emoji symbols, e.g., 👍, 🤔, ⏳"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -373,7 +373,8 @@
|
||||
"hint": "可选的 Discord 活动名称。留空则不设置活动。"
|
||||
},
|
||||
"discord_command_register": {
|
||||
"description": "是否自动将插件指令注册为 Discord 斜杠指令"
|
||||
"description": "注册 Discord 指令",
|
||||
"hint": "启用后,自动将插件指令注册为 Discord 斜杠指令"
|
||||
},
|
||||
"discord_proxy": {
|
||||
"description": "Discord 代理地址",
|
||||
@@ -586,6 +587,51 @@
|
||||
"only_use_webhook_url_to_send": {
|
||||
"description": "仅使用 Webhook 发送消息",
|
||||
"hint": "启用后,企业微信智能机器人的所有回复都改为通过消息推送 Webhook 发送。消息推送 Webhook 支持更多的消息类型(如图片、文件等)。如果不需要打字机效果,强烈建议使用此选项。"
|
||||
},
|
||||
"kook_bot_token": {
|
||||
"description": "机器人 Token",
|
||||
"type": "string",
|
||||
"hint": "必填项。从 KOOK 开发者平台获取的机器人 Token"
|
||||
},
|
||||
"kook_bot_nickname": {
|
||||
"description": "Bot Nickname",
|
||||
"type": "string",
|
||||
"hint": "可选项。若发送者昵称与此值一致,将忽略该消息。"
|
||||
},
|
||||
"kook_reconnect_delay": {
|
||||
"description": "重连延迟",
|
||||
"type": "int",
|
||||
"hint": "重连延迟时间(秒),使用指数退避策略"
|
||||
},
|
||||
"kook_max_reconnect_delay": {
|
||||
"description": "最大重连延迟",
|
||||
"type": "int",
|
||||
"hint": "重连延迟的最大值(秒)"
|
||||
},
|
||||
"kook_max_retry_delay": {
|
||||
"description": "最大重试延迟",
|
||||
"type": "int",
|
||||
"hint": "重试的最大延迟时间(秒)"
|
||||
},
|
||||
"kook_heartbeat_interval": {
|
||||
"description": "心跳间隔",
|
||||
"type": "int",
|
||||
"hint": "心跳检测间隔时间(秒)"
|
||||
},
|
||||
"kook_heartbeat_timeout": {
|
||||
"description": "心跳超时时间",
|
||||
"type": "int",
|
||||
"hint": "心跳检测超时时间(秒)"
|
||||
},
|
||||
"kook_max_heartbeat_failures": {
|
||||
"description": "最大心跳失败次数",
|
||||
"type": "int",
|
||||
"hint": "允许的最大心跳失败次数,超过后断开连接"
|
||||
},
|
||||
"kook_max_consecutive_failures": {
|
||||
"description": "最大连续失败次数",
|
||||
"type": "int",
|
||||
"hint": "允许的最大连续失败次数,超过后停止重试"
|
||||
}
|
||||
},
|
||||
"general": {
|
||||
@@ -740,6 +786,17 @@
|
||||
"hint": "Telegram 仅支持固定反应集合,参考:[https://gist.github.com/Soulter/3f22c8e5f9c7e152e967e8bc28c97fc9](https://gist.github.com/Soulter/3f22c8e5f9c7e152e967e8bc28c97fc9)"
|
||||
}
|
||||
}
|
||||
},
|
||||
"discord": {
|
||||
"pre_ack_emoji": {
|
||||
"enable": {
|
||||
"description": "[Discord] 启用预回应表情"
|
||||
},
|
||||
"emojis": {
|
||||
"description": "表情列表(Unicode 或自定义表情名)",
|
||||
"hint": "填写 Unicode 表情符号,例如:👍、🤔、⏳"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -468,6 +468,12 @@ onMounted(async () => {
|
||||
<v-icon>mdi-menu</v-icon>
|
||||
</v-btn>
|
||||
|
||||
<!-- 移动端 chat sidebar 展开按钮 - 仅在 chat 模式下的小屏幕显示 -->
|
||||
<v-btn v-if="customizer.viewMode === 'chat'" class="hidden-lg-and-up ms-1" icon rounded="sm" variant="flat"
|
||||
@click.stop="customizer.TOGGLE_CHAT_SIDEBAR()">
|
||||
<v-icon>mdi-menu</v-icon>
|
||||
</v-btn>
|
||||
|
||||
<div class="logo-container" :class="{ 'mobile-logo': $vuetify.display.xs, 'chat-mode-logo': customizer.viewMode === 'chat' }" @click="handleLogoClick">
|
||||
<span class="logo-text Outfit">Astr<span class="logo-text bot-text-wrapper">Bot
|
||||
<img v-if="isChristmas" src="@/assets/images/xmas-hat.png" alt="Christmas hat" class="xmas-hat" />
|
||||
@@ -488,13 +494,13 @@ onMounted(async () => {
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<!-- Bot/Chat 模式切换按钮 -->
|
||||
<!-- Bot/Chat 模式切换按钮 - 手机端隐藏,移入 ... 菜单 -->
|
||||
<v-btn-toggle
|
||||
v-model="viewMode"
|
||||
mandatory
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
class="mr-4"
|
||||
class="mr-4 hidden-xs"
|
||||
color="primary"
|
||||
>
|
||||
<v-btn value="bot" size="small">
|
||||
@@ -524,6 +530,30 @@ onMounted(async () => {
|
||||
</v-btn>
|
||||
</template>
|
||||
|
||||
<!-- Bot/Chat 模式切换 - 仅在手机端显示 -->
|
||||
<template v-if="$vuetify.display.xs">
|
||||
<div class="mobile-mode-toggle-wrapper">
|
||||
<v-btn-toggle
|
||||
v-model="viewMode"
|
||||
mandatory
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
color="primary"
|
||||
class="mobile-mode-toggle"
|
||||
>
|
||||
<v-btn value="bot" size="small">
|
||||
<v-icon start>mdi-robot</v-icon>
|
||||
Bot
|
||||
</v-btn>
|
||||
<v-btn value="chat" size="small">
|
||||
<v-icon start>mdi-chat</v-icon>
|
||||
Chat
|
||||
</v-btn>
|
||||
</v-btn-toggle>
|
||||
</div>
|
||||
<v-divider class="my-1" />
|
||||
</template>
|
||||
|
||||
<!-- 语言切换 -->
|
||||
<v-list-item
|
||||
v-for="lang in languages"
|
||||
@@ -888,6 +918,10 @@ onMounted(async () => {
|
||||
margin-left: 22px;
|
||||
}
|
||||
|
||||
.mobile-logo.chat-mode-logo {
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.logo-text {
|
||||
font-size: 24px;
|
||||
font-weight: 1000;
|
||||
@@ -926,6 +960,20 @@ onMounted(async () => {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.mobile-mode-toggle-wrapper {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 8px 12px 4px;
|
||||
}
|
||||
|
||||
.mobile-mode-toggle {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.mobile-mode-toggle .v-btn {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* 移动端对话框标题样式 */
|
||||
.mobile-card-title {
|
||||
display: flex;
|
||||
|
||||
@@ -38,7 +38,7 @@ const isItemActive = computed(() => {
|
||||
</template>
|
||||
|
||||
<!-- children -->
|
||||
<template v-for="(child, index) in item.children" :key="index">
|
||||
<template v-for="(child, index) in item.children" :key="child.title || child.to || `child-${index}`">
|
||||
<NavItem :item="child" :level="(level || 0) + 1" />
|
||||
</template>
|
||||
</v-list-group>
|
||||
|
||||
@@ -10,26 +10,60 @@ import ChangelogDialog from '@/components/shared/ChangelogDialog.vue';
|
||||
const { t, locale } = useI18n();
|
||||
|
||||
const customizer = useCustomizerStore();
|
||||
const sidebarMenu = shallowRef(sidebarItems);
|
||||
|
||||
function collectGroupValues(items, values = new Set()) {
|
||||
items.forEach((item) => {
|
||||
if (item?.children && item.title) {
|
||||
values.add(item.title);
|
||||
collectGroupValues(item.children, values);
|
||||
}
|
||||
});
|
||||
return values;
|
||||
}
|
||||
|
||||
function sanitizeOpenedItems(items, menuItems) {
|
||||
if (!Array.isArray(items)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const groupValues = collectGroupValues(menuItems);
|
||||
return items.filter((item) => typeof item === 'string' && groupValues.has(item));
|
||||
}
|
||||
|
||||
function getInitialOpenedItems(menuItems) {
|
||||
try {
|
||||
const stored = JSON.parse(localStorage.getItem('sidebar_openedItems') || '[]');
|
||||
return sanitizeOpenedItems(stored, menuItems);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
const sidebarMenu = shallowRef(applySidebarCustomization(sidebarItems));
|
||||
|
||||
// 侧边栏分组展开状态持久化
|
||||
const openedItems = ref(JSON.parse(localStorage.getItem('sidebar_openedItems') || '[]'));
|
||||
watch(openedItems, (val) => localStorage.setItem('sidebar_openedItems', JSON.stringify(val)), { deep: true });
|
||||
const openedItems = ref(getInitialOpenedItems(sidebarMenu.value));
|
||||
watch(openedItems, (val) => {
|
||||
localStorage.setItem('sidebar_openedItems', JSON.stringify(sanitizeOpenedItems(val, sidebarMenu.value)));
|
||||
}, { deep: true });
|
||||
|
||||
function refreshSidebarMenu() {
|
||||
sidebarMenu.value = applySidebarCustomization(sidebarItems);
|
||||
openedItems.value = sanitizeOpenedItems(openedItems.value, sidebarMenu.value);
|
||||
}
|
||||
|
||||
// Apply customization on mount and listen for storage changes
|
||||
const handleStorageChange = (e) => {
|
||||
if (e.key === 'astrbot_sidebar_customization') {
|
||||
sidebarMenu.value = applySidebarCustomization(sidebarItems);
|
||||
refreshSidebarMenu();
|
||||
}
|
||||
};
|
||||
|
||||
const handleCustomEvent = () => {
|
||||
sidebarMenu.value = applySidebarCustomization(sidebarItems);
|
||||
refreshSidebarMenu();
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
sidebarMenu.value = applySidebarCustomization(sidebarItems);
|
||||
|
||||
window.addEventListener('storage', handleStorageChange);
|
||||
window.addEventListener('sidebar-customization-changed', handleCustomEvent);
|
||||
});
|
||||
@@ -255,7 +289,7 @@ function openChangelogDialog() {
|
||||
>
|
||||
<div class="sidebar-container">
|
||||
<v-list class="pa-4 listitem flex-grow-1" v-model:opened="openedItems" :open-strategy="'multiple'">
|
||||
<template v-for="(item, i) in sidebarMenu" :key="i">
|
||||
<template v-for="(item, i) in sidebarMenu" :key="item.title || item.to || `sidebar-item-${i}`">
|
||||
<NavItem :item="item" class="leftPadding" />
|
||||
</template>
|
||||
</v-list>
|
||||
|
||||
@@ -15,3 +15,7 @@
|
||||
@import './components/VScrollbar';
|
||||
|
||||
@import './pages/dashboards';
|
||||
|
||||
html, body {
|
||||
overscroll-behavior-y: none;
|
||||
}
|
||||
|
||||
@@ -10,7 +10,8 @@ export const useCustomizerStore = defineStore({
|
||||
fontTheme: "Poppins",
|
||||
uiTheme: config.uiTheme,
|
||||
inputBg: config.inputBg,
|
||||
viewMode: (localStorage.getItem('viewMode') as 'bot' | 'chat') || 'bot' // 'bot' 或 'chat'
|
||||
viewMode: (localStorage.getItem('viewMode') as 'bot' | 'chat') || 'bot', // 'bot' 或 'chat'
|
||||
chatSidebarOpen: false // chat mode mobile sidebar state
|
||||
}),
|
||||
|
||||
getters: {},
|
||||
@@ -30,7 +31,13 @@ export const useCustomizerStore = defineStore({
|
||||
},
|
||||
SET_VIEW_MODE(payload: 'bot' | 'chat') {
|
||||
this.viewMode = payload;
|
||||
localStorage.setItem("viewMode", payload);
|
||||
localStorage.setItem('viewMode', payload);
|
||||
},
|
||||
TOGGLE_CHAT_SIDEBAR() {
|
||||
this.chatSidebarOpen = !this.chatSidebarOpen;
|
||||
},
|
||||
SET_CHAT_SIDEBAR(payload: boolean) {
|
||||
this.chatSidebarOpen = payload;
|
||||
},
|
||||
}
|
||||
});
|
||||
|
||||
@@ -46,22 +46,22 @@ export function getPlatformIcon(name) {
|
||||
*/
|
||||
export function getTutorialLink(platformType) {
|
||||
const tutorialMap = {
|
||||
"qq_official_webhook": "https://docs.astrbot.app/deploy/platform/qqofficial/webhook.html",
|
||||
"qq_official": "https://docs.astrbot.app/deploy/platform/qqofficial/websockets.html",
|
||||
"aiocqhttp": "https://docs.astrbot.app/deploy/platform/aiocqhttp/napcat.html",
|
||||
"wecom": "https://docs.astrbot.app/deploy/platform/wecom.html",
|
||||
"wecom_ai_bot": "https://docs.astrbot.app/deploy/platform/wecom_ai_bot.html",
|
||||
"lark": "https://docs.astrbot.app/deploy/platform/lark.html",
|
||||
"telegram": "https://docs.astrbot.app/deploy/platform/telegram.html",
|
||||
"dingtalk": "https://docs.astrbot.app/deploy/platform/dingtalk.html",
|
||||
"weixin_official_account": "https://docs.astrbot.app/deploy/platform/weixin-official-account.html",
|
||||
"discord": "https://docs.astrbot.app/deploy/platform/discord.html",
|
||||
"slack": "https://docs.astrbot.app/deploy/platform/slack.html",
|
||||
"kook": "https://docs.astrbot.app/deploy/platform/kook.html",
|
||||
"vocechat": "https://docs.astrbot.app/deploy/platform/vocechat.html",
|
||||
"satori": "https://docs.astrbot.app/deploy/platform/satori/llonebot.html",
|
||||
"misskey": "https://docs.astrbot.app/deploy/platform/misskey.html",
|
||||
"line": "https://docs.astrbot.app/deploy/platform/line.html",
|
||||
"qq_official_webhook": "https://docs.astrbot.app/platform/qqofficial/webhook.html",
|
||||
"qq_official": "https://docs.astrbot.app/platform/qqofficial/websockets.html",
|
||||
"aiocqhttp": "https://docs.astrbot.app/platform/aiocqhttp/napcat.html",
|
||||
"wecom": "https://docs.astrbot.app/platform/wecom.html",
|
||||
"wecom_ai_bot": "https://docs.astrbot.app/platform/wecom_ai_bot.html",
|
||||
"lark": "https://docs.astrbot.app/platform/lark.html",
|
||||
"telegram": "https://docs.astrbot.app/platform/telegram.html",
|
||||
"dingtalk": "https://docs.astrbot.app/platform/dingtalk.html",
|
||||
"weixin_official_account": "https://docs.astrbot.app/platform/weixin-official-account.html",
|
||||
"discord": "https://docs.astrbot.app/platform/discord.html",
|
||||
"slack": "https://docs.astrbot.app/platform/slack.html",
|
||||
"kook": "https://docs.astrbot.app/platform/kook.html",
|
||||
"vocechat": "https://docs.astrbot.app/platform/vocechat.html",
|
||||
"satori": "https://docs.astrbot.app/platform/satori/llonebot.html",
|
||||
"misskey": "https://docs.astrbot.app/platform/misskey.html",
|
||||
"line": "https://docs.astrbot.app/platform/line.html",
|
||||
}
|
||||
return tutorialMap[platformType] || "https://docs.astrbot.app";
|
||||
}
|
||||
|
||||
@@ -52,6 +52,21 @@ export function clearSidebarCustomization() {
|
||||
export function resolveSidebarItems(defaultItems, customization, options = {}) {
|
||||
const { cloneItems = false, assembleMoreGroup = false } = options;
|
||||
|
||||
const normalizeKeys = (keys = []) => {
|
||||
const list = Array.isArray(keys) ? keys : [];
|
||||
const deduped = [];
|
||||
const seen = new Set();
|
||||
|
||||
list.forEach((key) => {
|
||||
if (typeof key !== 'string') return;
|
||||
if (seen.has(key)) return;
|
||||
seen.add(key);
|
||||
deduped.push(key);
|
||||
});
|
||||
|
||||
return deduped;
|
||||
};
|
||||
|
||||
const all = new Map();
|
||||
const defaultMain = [];
|
||||
const defaultMore = [];
|
||||
@@ -70,9 +85,23 @@ export function resolveSidebarItems(defaultItems, customization, options = {}) {
|
||||
});
|
||||
|
||||
const hasCustomization = Boolean(customization);
|
||||
const mainKeys = hasCustomization ? customization.mainItems || [] : defaultMain;
|
||||
const moreKeys = hasCustomization ? customization.moreItems || [] : defaultMore;
|
||||
const used = hasCustomization ? new Set([...mainKeys, ...moreKeys]) : new Set(defaultMain.concat(defaultMore));
|
||||
let mainKeys = hasCustomization ? normalizeKeys(customization.mainItems || []) : [...defaultMain];
|
||||
let moreKeys = hasCustomization ? normalizeKeys(customization.moreItems || []) : [...defaultMore];
|
||||
|
||||
if (hasCustomization) {
|
||||
mainKeys = mainKeys.filter(title => all.has(title));
|
||||
moreKeys = moreKeys.filter(title => all.has(title));
|
||||
}
|
||||
|
||||
if (hasCustomization) {
|
||||
// 如果同一项同时出现在主区与更多区,主区优先。
|
||||
const mainSet = new Set(mainKeys);
|
||||
moreKeys = moreKeys.filter(title => !mainSet.has(title));
|
||||
}
|
||||
|
||||
const used = hasCustomization
|
||||
? new Set([...mainKeys, ...moreKeys])
|
||||
: new Set(defaultMain.concat(defaultMore));
|
||||
|
||||
const mainItems = mainKeys
|
||||
.map(title => all.get(title))
|
||||
@@ -119,7 +148,13 @@ export function resolveSidebarItems(defaultItems, customization, options = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
return { mainItems, moreItems, merged };
|
||||
return {
|
||||
mainItems,
|
||||
moreItems,
|
||||
merged,
|
||||
normalizedMainKeys: [...mainKeys],
|
||||
normalizedMoreKeys: [...moreKeys]
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -129,9 +164,29 @@ export function resolveSidebarItems(defaultItems, customization, options = {}) {
|
||||
*/
|
||||
export function applySidebarCustomization(defaultItems) {
|
||||
const customization = getSidebarCustomization();
|
||||
const { merged } = resolveSidebarItems(defaultItems, customization, {
|
||||
const {
|
||||
merged,
|
||||
normalizedMainKeys,
|
||||
normalizedMoreKeys
|
||||
} = resolveSidebarItems(defaultItems, customization, {
|
||||
cloneItems: true,
|
||||
assembleMoreGroup: true
|
||||
});
|
||||
|
||||
if (customization) {
|
||||
const rawMainKeys = Array.isArray(customization.mainItems) ? customization.mainItems : [];
|
||||
const rawMoreKeys = Array.isArray(customization.moreItems) ? customization.moreItems : [];
|
||||
const hasChanged =
|
||||
JSON.stringify(rawMainKeys) !== JSON.stringify(normalizedMainKeys) ||
|
||||
JSON.stringify(rawMoreKeys) !== JSON.stringify(normalizedMoreKeys);
|
||||
|
||||
if (hasChanged) {
|
||||
setSidebarCustomization({
|
||||
mainItems: normalizedMainKeys,
|
||||
moreItems: normalizedMoreKeys
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return merged || defaultItems;
|
||||
}
|
||||
|
||||
@@ -114,6 +114,14 @@ exclude = ["dashboard", "node_modules", "dist", "data", "tests"]
|
||||
[tool.hatch.metadata]
|
||||
allow-direct-references = true
|
||||
|
||||
# Include bundled dashboard dist even though it is not tracked by VCS.
|
||||
[tool.hatch.build.targets.wheel]
|
||||
artifacts = ["astrbot/dashboard/dist/**"]
|
||||
|
||||
# Custom build hook: builds the Vue dashboard and copies dist into the package.
|
||||
[tool.hatch.build.hooks.custom]
|
||||
path = "scripts/hatch_build.py"
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
"""
|
||||
Custom Hatchling build hook.
|
||||
|
||||
During `hatch build` (or `pip wheel`), this hook:
|
||||
1. Runs `npm run build` inside the `dashboard/` directory.
|
||||
2. Copies the resulting `dashboard/dist/` tree into
|
||||
`astrbot/dashboard/dist/` so the static assets are shipped
|
||||
inside the Python wheel.
|
||||
"""
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
|
||||
|
||||
|
||||
class CustomBuildHook(BuildHookInterface):
|
||||
PLUGIN_NAME = "custom"
|
||||
|
||||
def initialize(self, version: str, build_data: dict) -> None:
|
||||
root = Path(self.root)
|
||||
dashboard_src = root / "dashboard"
|
||||
dist_src = dashboard_src / "dist"
|
||||
dist_target = root / "astrbot" / "dashboard" / "dist"
|
||||
|
||||
if not dashboard_src.exists():
|
||||
print(
|
||||
"[hatch_build] 'dashboard/' directory not found – skipping dashboard build.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return
|
||||
|
||||
# ── Install Node dependencies if node_modules is absent ─────────────
|
||||
if not (dashboard_src / "node_modules").exists():
|
||||
print("[hatch_build] Installing dashboard Node dependencies...")
|
||||
subprocess.run(
|
||||
["npm", "install"],
|
||||
cwd=dashboard_src,
|
||||
check=True,
|
||||
)
|
||||
|
||||
# ── Build the Vue/Vite dashboard ──────────────────────────────────────
|
||||
print("[hatch_build] Building Vue dashboard (npm run build)...")
|
||||
subprocess.run(
|
||||
["npm", "run", "build"],
|
||||
cwd=dashboard_src,
|
||||
check=True,
|
||||
)
|
||||
|
||||
if not dist_src.exists():
|
||||
print(
|
||||
"[hatch_build] dashboard/dist not found after build – skipping copy.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return
|
||||
|
||||
# ── Copy into the Python package tree ────────────────────────────────
|
||||
if dist_target.exists():
|
||||
shutil.rmtree(dist_target)
|
||||
shutil.copytree(dist_src, dist_target)
|
||||
print(f"[hatch_build] Dashboard dist copied → {dist_target.relative_to(root)}")
|
||||
@@ -52,18 +52,6 @@ class TestContextTruncator:
|
||||
assert len(result) == 3
|
||||
assert result == messages
|
||||
|
||||
def test_fix_messages_tool_with_valid_context(self):
|
||||
"""Test fix_messages with tool message after user+assistant."""
|
||||
truncator = ContextTruncator()
|
||||
messages = [
|
||||
self.create_message("user", "Run tool"),
|
||||
self.create_message("assistant", "Running..."),
|
||||
self.create_message("tool", "Tool result"),
|
||||
]
|
||||
result = truncator.fix_messages(messages)
|
||||
assert len(result) == 3
|
||||
assert result == messages
|
||||
|
||||
def test_fix_messages_tool_without_context(self):
|
||||
"""Test fix_messages with tool message without enough context."""
|
||||
truncator = ContextTruncator()
|
||||
@@ -74,43 +62,6 @@ class TestContextTruncator:
|
||||
# Tool message without context should be removed
|
||||
assert len(result) == 0
|
||||
|
||||
def test_fix_messages_tool_with_only_one_message(self):
|
||||
"""Test fix_messages with tool message after only one message."""
|
||||
truncator = ContextTruncator()
|
||||
messages = [
|
||||
self.create_message("user", "Hello"),
|
||||
self.create_message("tool", "Tool result"),
|
||||
]
|
||||
result = truncator.fix_messages(messages)
|
||||
# Tool message without enough context should be removed
|
||||
assert len(result) == 0
|
||||
|
||||
def test_fix_messages_multiple_tools(self):
|
||||
"""Test fix_messages with multiple tool messages."""
|
||||
truncator = ContextTruncator()
|
||||
messages = [
|
||||
self.create_message("user", "Run tool"),
|
||||
self.create_message("assistant", "Running..."),
|
||||
self.create_message("tool", "Tool 1 result"),
|
||||
self.create_message("tool", "Tool 2 result"),
|
||||
]
|
||||
result = truncator.fix_messages(messages)
|
||||
assert len(result) == 4
|
||||
assert result == messages
|
||||
|
||||
def test_fix_messages_mixed_system_tool(self):
|
||||
"""Test fix_messages with system message and tool messages."""
|
||||
truncator = ContextTruncator()
|
||||
messages = [
|
||||
self.create_message("system", "System prompt"),
|
||||
self.create_message("user", "Run tool"),
|
||||
self.create_message("assistant", "Running..."),
|
||||
self.create_message("tool", "Tool result"),
|
||||
]
|
||||
result = truncator.fix_messages(messages)
|
||||
assert len(result) == 4
|
||||
assert result == messages
|
||||
|
||||
# ==================== truncate_by_turns Tests ====================
|
||||
|
||||
def test_truncate_by_turns_no_limit(self):
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
!data
|
||||
@@ -0,0 +1,100 @@
|
||||
{
|
||||
"type": "card",
|
||||
"theme": "info",
|
||||
"size": "lg",
|
||||
"modules": [
|
||||
{
|
||||
"text": {
|
||||
"content": "test1",
|
||||
"type": "plain-text",
|
||||
"emoji": true
|
||||
},
|
||||
"type": "header"
|
||||
},
|
||||
{
|
||||
"text": {
|
||||
"content": "test2",
|
||||
"type": "kmarkdown"
|
||||
},
|
||||
"type": "section",
|
||||
"mode": "left"
|
||||
},
|
||||
{
|
||||
"type": "divider"
|
||||
},
|
||||
{
|
||||
"text": {
|
||||
"fields": [
|
||||
{
|
||||
"content": "test3",
|
||||
"type": "kmarkdown"
|
||||
},
|
||||
{
|
||||
"content": "**test4**",
|
||||
"type": "kmarkdown"
|
||||
}
|
||||
],
|
||||
"type": "paragraph",
|
||||
"cols": 2
|
||||
},
|
||||
"type": "section",
|
||||
"mode": "left"
|
||||
},
|
||||
{
|
||||
"elements": [
|
||||
{
|
||||
"src": "https://img.kookapp.cn/attachments/2023-01/05/63b645851ff19.svg",
|
||||
"type": "image",
|
||||
"alt": "",
|
||||
"size": "lg",
|
||||
"circle": false
|
||||
}
|
||||
],
|
||||
"type": "image-group"
|
||||
},
|
||||
{
|
||||
"src": "https://img.kookapp.cn/attachments/2023-01/05/63b645851ff19.svg",
|
||||
"title": "test5",
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"endTime": 1772343427360,
|
||||
"type": "countdown",
|
||||
"startTime": 1772343378259,
|
||||
"mode": "second"
|
||||
},
|
||||
{
|
||||
"elements": [
|
||||
{
|
||||
"text": "点我测试回调",
|
||||
"type": "button",
|
||||
"theme": "primary",
|
||||
"value": "btn_clicked",
|
||||
"click": "return-val"
|
||||
},
|
||||
{
|
||||
"text": "访问官网",
|
||||
"type": "button",
|
||||
"theme": "danger",
|
||||
"value": "https://www.kookapp.cn",
|
||||
"click": "link"
|
||||
}
|
||||
],
|
||||
"type": "action-group"
|
||||
},
|
||||
{
|
||||
"elements": [
|
||||
{
|
||||
"content": "test6",
|
||||
"type": "plain-text",
|
||||
"emoji": true
|
||||
}
|
||||
],
|
||||
"type": "context"
|
||||
},
|
||||
{
|
||||
"code": "test7",
|
||||
"type": "invite"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
TEST_DATA_DIR = Path(__file__).parent / "data"
|
||||
@@ -0,0 +1,223 @@
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
from astrbot.api.platform import AstrBotMessage, MessageType, PlatformMetadata, Unknown
|
||||
from astrbot.api.event import MessageChain
|
||||
from astrbot.core.message.components import (
|
||||
File,
|
||||
Image,
|
||||
Plain,
|
||||
Video,
|
||||
At,
|
||||
AtAll,
|
||||
BaseMessageComponent,
|
||||
Json,
|
||||
Record,
|
||||
Reply,
|
||||
)
|
||||
|
||||
|
||||
from astrbot.core.platform.sources.kook.kook_event import KookEvent
|
||||
from astrbot.core.platform.sources.kook.kook_types import KookMessageType, OrderMessage
|
||||
|
||||
|
||||
async def mock_kook_client(upload_asset_return: str, send_text_return: str):
|
||||
# 1. Mock 掉整个 KookClient 类
|
||||
client = MagicMock()
|
||||
|
||||
client.upload_asset = AsyncMock(return_value=upload_asset_return)
|
||||
client.send_text = AsyncMock(return_value=send_text_return)
|
||||
return client
|
||||
|
||||
|
||||
def mock_file_message(input: str):
|
||||
message = MagicMock(spec=File)
|
||||
message.get_file = AsyncMock(return_value=input)
|
||||
return message
|
||||
|
||||
|
||||
def mock_record_message(input: str):
|
||||
message = MagicMock(spec=Record)
|
||||
message.text = input
|
||||
message.convert_to_file_path = AsyncMock(return_value=input)
|
||||
return message
|
||||
|
||||
|
||||
def mock_astrbot_message():
|
||||
message = AstrBotMessage()
|
||||
message.type = MessageType.OTHER_MESSAGE
|
||||
message.group_id = "test"
|
||||
message.session_id = "test"
|
||||
message.message_id = "test"
|
||||
return message
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"input_message,upload_asset_return, expected_output, expected_error",
|
||||
[
|
||||
(
|
||||
Image("test image"),
|
||||
"test image",
|
||||
OrderMessage(
|
||||
1,
|
||||
text="test image",
|
||||
type=KookMessageType.IMAGE,
|
||||
),
|
||||
None,
|
||||
),
|
||||
(
|
||||
Video("test video"),
|
||||
"test video",
|
||||
OrderMessage(
|
||||
1,
|
||||
text="test video",
|
||||
type=KookMessageType.VIDEO,
|
||||
),
|
||||
None,
|
||||
),
|
||||
(
|
||||
mock_file_message("test file"),
|
||||
"test file",
|
||||
OrderMessage(
|
||||
1,
|
||||
text="test file",
|
||||
type=KookMessageType.FILE,
|
||||
),
|
||||
None,
|
||||
),
|
||||
(
|
||||
mock_record_message("./tests/file.wav"),
|
||||
"./tests/file.wav",
|
||||
OrderMessage(
|
||||
1,
|
||||
text='[{"type": "card", "modules": [{"src": "./tests/file.wav", "title": "./tests/file.wav", "type": "audio"}]}]',
|
||||
type=KookMessageType.CARD,
|
||||
),
|
||||
None,
|
||||
),
|
||||
(
|
||||
Plain("test plain"),
|
||||
"test plain",
|
||||
OrderMessage(
|
||||
1,
|
||||
text="test plain",
|
||||
type=KookMessageType.KMARKDOWN,
|
||||
),
|
||||
None,
|
||||
),
|
||||
(
|
||||
At(qq="test at"),
|
||||
"test at",
|
||||
OrderMessage(
|
||||
1,
|
||||
text="(met)test at(met)",
|
||||
type=KookMessageType.KMARKDOWN,
|
||||
),
|
||||
None,
|
||||
),
|
||||
(
|
||||
AtAll(qq="all"),
|
||||
"test atAll",
|
||||
OrderMessage(
|
||||
1,
|
||||
text="(met)all(met)",
|
||||
type=KookMessageType.KMARKDOWN,
|
||||
),
|
||||
None,
|
||||
),
|
||||
(
|
||||
Reply(id="test reply"),
|
||||
"test reply",
|
||||
OrderMessage(
|
||||
1,
|
||||
text="",
|
||||
type=KookMessageType.KMARKDOWN,
|
||||
reply_id="test reply",
|
||||
),
|
||||
None,
|
||||
),
|
||||
(
|
||||
Json(data={"test": "json"}),
|
||||
"test json",
|
||||
OrderMessage(
|
||||
1,
|
||||
text='[{"test": "json"}]',
|
||||
type=KookMessageType.CARD,
|
||||
),
|
||||
None,
|
||||
),
|
||||
(
|
||||
Unknown(text="test unknown"),
|
||||
"test unknown",
|
||||
None,
|
||||
NotImplementedError,
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_kook_event_warp_message(
|
||||
input_message: BaseMessageComponent,
|
||||
upload_asset_return: str,
|
||||
expected_output: OrderMessage,
|
||||
expected_error: type[Exception] | None,
|
||||
):
|
||||
client = await mock_kook_client(
|
||||
upload_asset_return,
|
||||
"",
|
||||
)
|
||||
|
||||
event = KookEvent(
|
||||
"",
|
||||
mock_astrbot_message(),
|
||||
PlatformMetadata(
|
||||
name="test",
|
||||
id="test",
|
||||
description="test",
|
||||
),
|
||||
"",
|
||||
client,
|
||||
)
|
||||
|
||||
if expected_error:
|
||||
with pytest.raises(expected_error):
|
||||
await event._wrap_message(1, input_message)
|
||||
return
|
||||
|
||||
result = await event._wrap_message(1, input_message)
|
||||
assert result == expected_output
|
||||
|
||||
|
||||
# @pytest.mark.asyncio
|
||||
# @pytest.mark.parametrize(
|
||||
# "message_chain,send_text_expected_output,expected_error",
|
||||
# [
|
||||
# (
|
||||
# MessageChain(
|
||||
# chain=[
|
||||
# Image(file="test image"),
|
||||
# Plain(text="test plain"),
|
||||
# ],
|
||||
# ),
|
||||
# ""
|
||||
# ),
|
||||
# ],
|
||||
# )
|
||||
# async def test_kook_event_send():
|
||||
# client = await mock_kook_client(
|
||||
# "",
|
||||
# "",
|
||||
# )
|
||||
|
||||
# event = KookEvent(
|
||||
# "",
|
||||
# mock_astrbot_message(),
|
||||
# PlatformMetadata(
|
||||
# name="test",
|
||||
# id="test",
|
||||
# description="test",
|
||||
# ),
|
||||
# "",
|
||||
# client,
|
||||
# )
|
||||
|
||||
# await event.send(message=mock_astrbot_message())
|
||||
@@ -0,0 +1,107 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from astrbot.core.platform.sources.kook.kook_types import (
|
||||
ActionGroupModule,
|
||||
ButtonElement,
|
||||
ContextModule,
|
||||
CountdownModule,
|
||||
DividerModule,
|
||||
FileModule,
|
||||
HeaderModule,
|
||||
ImageElement,
|
||||
ImageGroupModule,
|
||||
InviteModule,
|
||||
KmarkdownElement,
|
||||
KookCardMessage,
|
||||
ParagraphStructure,
|
||||
PlainTextElement,
|
||||
SectionModule,
|
||||
KookCardMessageContainer,
|
||||
)
|
||||
from tests.test_kook.shared import TEST_DATA_DIR
|
||||
|
||||
|
||||
def test_kook_card_message_container_append():
|
||||
container = KookCardMessageContainer()
|
||||
container.append(KookCardMessage())
|
||||
assert len(container) == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"input, expect_container_length",
|
||||
[
|
||||
([KookCardMessage()], 1),
|
||||
([KookCardMessage()] * 2, 2),
|
||||
],
|
||||
)
|
||||
def test_kook_card_message_container_to_json(
|
||||
input: list[KookCardMessage], expect_container_length: int
|
||||
):
|
||||
container = KookCardMessageContainer(input)
|
||||
json_output = container.to_json()
|
||||
output = json.loads(json_output)
|
||||
assert isinstance(output, list)
|
||||
assert len(output) == expect_container_length
|
||||
|
||||
|
||||
def test_all_kook_card_type():
|
||||
expect_json_data = Path(TEST_DATA_DIR / "kook_card_data.json").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
json_output = KookCardMessage(
|
||||
theme="info",
|
||||
size="lg",
|
||||
modules=[
|
||||
HeaderModule(text=PlainTextElement(content="test1")),
|
||||
SectionModule(text=KmarkdownElement(content="test2")),
|
||||
DividerModule(),
|
||||
SectionModule(
|
||||
text=ParagraphStructure(
|
||||
cols=2,
|
||||
fields=[
|
||||
KmarkdownElement(content="test3"),
|
||||
KmarkdownElement(content="**test4**"),
|
||||
],
|
||||
)
|
||||
),
|
||||
ImageGroupModule(
|
||||
elements=[
|
||||
ImageElement(
|
||||
src="https://img.kookapp.cn/attachments/2023-01/05/63b645851ff19.svg"
|
||||
)
|
||||
]
|
||||
),
|
||||
FileModule(
|
||||
src="https://img.kookapp.cn/attachments/2023-01/05/63b645851ff19.svg",
|
||||
title="test5",
|
||||
type="file",
|
||||
),
|
||||
CountdownModule(
|
||||
endTime=1772343427360,
|
||||
startTime=1772343378259,
|
||||
mode="second",
|
||||
),
|
||||
ActionGroupModule(
|
||||
elements=[
|
||||
ButtonElement(
|
||||
value="btn_clicked",
|
||||
text="点我测试回调",
|
||||
click="return-val",
|
||||
theme="primary",
|
||||
),
|
||||
ButtonElement(
|
||||
value="https://www.kookapp.cn",
|
||||
text="访问官网",
|
||||
click="link",
|
||||
theme="danger",
|
||||
),
|
||||
]
|
||||
),
|
||||
ContextModule(elements=[PlainTextElement(content="test6")]),
|
||||
InviteModule(code="test7"),
|
||||
],
|
||||
).to_json(indent=4, ensure_ascii=False)
|
||||
assert json_output == expect_json_data
|
||||
@@ -516,30 +516,6 @@ class TestEnsurePersonaAndSkills:
|
||||
|
||||
assert "Persona Instructions" not in req.system_prompt
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ensure_skills(self, mock_event, mock_context):
|
||||
"""Test applying skills to request."""
|
||||
module = ama
|
||||
mock_skill = MagicMock()
|
||||
mock_skill.name = "test_skill"
|
||||
mock_skill.to_prompt.return_value = "Skill description"
|
||||
mock_context.persona_manager.personas_v3 = []
|
||||
mock_context.persona_manager.resolve_selected_persona = AsyncMock(
|
||||
return_value=(None, None, None, False)
|
||||
)
|
||||
|
||||
with patch("astrbot.core.astr_main_agent.SkillManager") as mock_skill_mgr_cls:
|
||||
mock_skill_mgr = MagicMock()
|
||||
mock_skill_mgr.list_skills.return_value = [mock_skill]
|
||||
mock_skill_mgr_cls.return_value = mock_skill_mgr
|
||||
|
||||
req = ProviderRequest()
|
||||
req.conversation = MagicMock(persona_id=None)
|
||||
|
||||
await module._ensure_persona_and_skills(req, {}, mock_context, mock_event)
|
||||
|
||||
assert "test_skill" in req.system_prompt
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ensure_tools_from_persona(self, mock_event, mock_context):
|
||||
"""Test applying tools from persona."""
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import platform
|
||||
from astrbot.core.computer.tools.python import PythonTool, LocalPythonTool
|
||||
|
||||
def test_python_tool_description_contains_os():
|
||||
"""测试 PythonTool 的描述中是否包含当前操作系统信息"""
|
||||
tool = PythonTool()
|
||||
current_os = platform.system()
|
||||
assert current_os in tool.description
|
||||
assert "IPython" in tool.description
|
||||
|
||||
def test_local_python_tool_description_contains_os():
|
||||
"""测试 LocalPythonTool 的描述中是否包含当前操作系统信息和兼容性提示"""
|
||||
tool = LocalPythonTool()
|
||||
current_os = platform.system()
|
||||
assert current_os in tool.description
|
||||
assert "Python environment" in tool.description
|
||||
assert "system-compatible" in tool.description
|
||||
Reference in New Issue
Block a user