Compare commits

..

19 Commits

Author SHA1 Message Date
Soulter 55e1431084 docs: update sponsors 2026-03-03 19:07:38 +08:00
Soulter 418f05f6e4 fix: tests 2026-03-03 16:06:49 +08:00
Soulter df421e5554 fix: test 2026-03-03 16:04:08 +08:00
shuiping233 ed84074a60 unittest: 添加之前遗漏的kook_card_data.json (#5703) 2026-03-03 16:01:26 +08:00
Soulter bbf61239ad fix(kook): remove debug logging for received messages and heartbeat responses 2026-03-03 15:54:45 +08:00
miaoxutao123 92ee534a2c feat: add OS information to tool descriptions and implement unit tests (#5677)
* feat: add OS information to tool descriptions and implement unit tests

* refactor: use module-level constant for OS name as suggested in PR review
2026-03-03 15:16:38 +08:00
L1ngg fa4df0b5f3 fix(core): correctly parse DEMO_MODE as boolean from env var. (#5676)
* fix(core): correctly parse DEMO_MODE as boolean from env var.

* Update astrbot/core/__init__.py

fix(core): 添加.strip()以确保代码健壮性

Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com>
2026-03-03 15:15:20 +08:00
dependabot[bot] e5ac31efe7 chore(deps): bump the github-actions group with 2 updates (#5694)
Bumps the github-actions group with 2 updates: [actions/upload-artifact](https://github.com/actions/upload-artifact) and [actions/download-artifact](https://github.com/actions/download-artifact).


Updates `actions/upload-artifact` from 6 to 7
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v6...v7)

Updates `actions/download-artifact` from 7 to 8
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/v7...v8)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: actions/download-artifact
  dependency-version: '8'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-03 15:14:28 +08:00
時壹 2a7745c767 fix: only allow HTTPS URLs to pass through directly in LINE adapter (#5697) 2026-03-03 15:14:08 +08:00
Gargantua 82e7502f74 fix(dashboard): stabilize sidebar customization state (#5405) (#5670)
- use stable sidebar list keys to avoid vnode reuse drift

- sanitize persisted opened groups against current sidebar menu

- guard non-array customization keys from localStorage

Co-authored-by: Gargantua <22532097@zju.edu.cn>
2026-03-03 15:12:15 +08:00
shuiping233 866e546b59 feat: integrates KOOK platform adapter (#5658)
* feat: 将kook适配器插件并入astrbot官方适配器目录中

* refactor: 重命名函数名为 _warp_message

* refactor: 使用Protocol替换Union类型

* bugfix: 修复base64前缀处理问题

* refactor: 抛出的错误不再包含"[kook]"

* refactor: 添加读取本地文件时的路径安全检查

* refactor: 卡片消息解析失败时会打印错误信息

* refactor: 添加处理接收卡片消息内的图片url时的安全校验

* refactor: 安全处理ws需要重连的情况

* Revert "refactor: 使用Protocol替换Union类型"

This reverts commit 58e0dceeb20c3d7dddb16f623fd3bbdcfa632173.

* feat: 添加获取机器人名称的实现

* refactor: 让send_by_session发送主动消息时正确传入当前消息链的文本消息内容

* refactor: 统一处理适配器配置相关内容,处理仪表盘出传入配置,并添加仪表盘的kook适配器配置页面的i18n文本

* unittest: 添加kook适配器的单元测试,虽然没覆盖多少单测

* unittest: TEST_DATA_DIR用更安全的路径

* refactor: KookConfig使用了更好的默认值处理方式

* refactor: 移除kook_adapter 的config字段重复定义

* refactor: 隐藏获取kook gateway时url里的token,防止把token打印出来

* refactor: KookConfig.pretty_jsons使用*来屏蔽token内容

* bugfix: 修复主动发送消息时,调用了父方法`send_by_session`可能导致指标被重复上传的bug

* refactor: 优化upload_asset的路径处理报错

* bugfix: 修复kook ws心跳间隔可能会出现负数时间的bug

* refactor: KookClient移到KookPlatformAdapter.__init__里初始化

* bugfix: 修复处理base64 url 多替换了/而报错的bug

* refactor: kook适配器上传文件失败时,会抛出错误

* chore: 移除一条注释

* refactor: 移除没用的return

* refactor: 即使消息链中有消息发送失败了,也尽可能将其他消息发送出去,并把报错信息也发送出去

* refactor: 增强上传任务失败时的错误处理,使其发生错误时尽力而为发送其余消息

* refactor: 发送到消息频道的报错消息加了个⚠️,小巧思这块?

* refactor: 咱们在写适配器啊,要什么小巧思呢,小巧思给上游插件开发弄不好么)

* refactor: enhance Kook adapter with kmarkdown parsing and improve file URL handling

* refactor: extract card message parsing logic into a separate method

* feat: add kook_bot_nickname configuration to ignore messages from specific nicknames

* refactor: remove commented-out code and clean up file upload error handling

* fix: remove redundant prefix handling for file URLs in asset upload

---------

Co-authored-by: Soulter <905617992@qq.com>
2026-03-03 15:08:16 +08:00
Soulter 6b642d7674 refactor: bundled webui static files into wheel and replace astrbot cli log with English (#5665)
* refactor: bundled webui static files into wheel and replace astrbot cli log with English

- Translated and standardized log messages in cmd_conf.py for better clarity.
- Updated initialization logic in cmd_init.py to provide clearer user prompts and error handling.
- Improved plugin management commands in cmd_plug.py with consistent language and error messages.
- Enhanced run command in cmd_run.py with clearer status messages and error handling.
- Updated utility functions in basic.py and plugin.py to improve readability and maintainability.
- Added version comparison logic in version_comparator.py with clearer comments.
- Enhanced logging configuration in log.py to suppress noisy loggers.
- Updated the updater logic in updator.py to provide clearer error messages for users.
- Improved IO utility functions in io.py to handle dashboard versioning more effectively.
- Enhanced dashboard server logic in server.py to prioritize bundled assets and improve user feedback.
- Updated pyproject.toml to include bundled dashboard assets and custom build hooks.
- Added a custom build script (hatch_build.py) to automate dashboard builds during package creation.

* refactor: improve exception messages and formatting in CLI command validation

* perf: change npm install to npm ci for consistent dependency installation

* fix
2026-03-03 12:58:59 +08:00
SJ 0711ec346f Fix/fix: resolve MCP tools race condition causing 'completion 无法解析' error (#5534)
* fix: resolve MCP tools race condition causing 'completion 无法解析' error

- Wait for MCP client initialization to complete before accepting requests
- Add Future-based synchronization in init_mcp_clients()
- Prevent tool_calls from being rejected due to empty func_list
- Improve error logging for MCP initialization failures

Fixes race condition where AI attempts to call MCP tools before they are
registered, resulting in 'API 返回的 completion 无法解析' exceptions.

The issue occurred because:
1. MCP clients were initialized asynchronously without waiting
2. System accepted user requests immediately after startup
3. AI received empty tool list and attempted to call non-existent tools
4. Tool matching failed, causing parsing errors

This fix ensures all MCP tools are loaded before the system processes
any requests that might use them.

* perf: add timeout and better error handling for MCP initialization

- Add 20-second total timeout to prevent slow MCP servers from blocking startup
- Show detailed configuration info when MCP initialization fails
- List all failed services in a summary warning
- Gracefully handle timeout by using already-completed services

This ensures that even if some MCP servers are slow or unreachable,
the system will start within a reasonable time and provide clear
feedback about which services failed and why.

* refactor: simplify MCP init orchestration and improve log security

- Replace Future-based sync with asyncio.wait + name→task mapping
- Explicitly cancel timed-out tasks after 20s timeout
- Downgrade sensitive config details (command/args/URL) to debug level
- Move urllib.parse import to top-level

* fix: prevent initialized MCP clients from being cleaned up on timeout

- Do not cancel pending tasks on timeout; let them continue running
  in the background waiting for the termination signal (event.set()),
  so successfully initialized services remain available
- Track initialization state with a flag to distinguish init failures
  from post-init cancellations in _init_mcp_client_task_wrapper

* fix: restore task cancellation on timeout per review feedback

Pending tasks in asyncio.wait are tasks that have NOT completed
initialization within 20s, so cancelling them is safe and correct.

* fix: separate init signal from client lifetime in MCP task wrapper

The previous design awaited task completion, but tasks only finish
on shutdown (after event.wait()), causing asyncio.wait to always
hit the 20s timeout and cancel all clients.

Fix: introduce a dedicated ready_event that is set immediately after
_init_mcp_client completes. init_mcp_clients now waits only for
ready_event (with 20s timeout), while the long-lived client task
continues running in the background until shutdown_event is set.

This ensures startup returns promptly once clients are ready.

* security: redact sensitive MCP config from debug logs

Only log executable name and argument count instead of full
command/args to avoid leaking tokens or credentials even at
debug level.

* refactor: use McpClientInfo dataclass and MCP_INIT_TIMEOUT constant

- Extract MCP_INIT_TIMEOUT = 20.0 as a named module-level constant
- Replace tuple-based client_info with _McpClientInfo dataclass to
  eliminate index-based access and improve readability
- Remove _wait_ready helper; use asyncio.create_task(event.wait()) directly
- Await cancelled tasks after timeout to prevent lingering background
  tasks and unobserved exceptions

* fix: handle CancelledError and clean up wait_tasks on timeout

- Catch asyncio.CancelledError separately in _init_mcp_client_task_wrapper
  so ready_event.set() is always called (Python 3.8+ CancelledError
  inherits BaseException, not Exception)
- Cancel and await lingering wait_tasks after timeout to prevent
  them from hanging indefinitely when ready_event is never set

* fix: align enable_mcp_server with new wrapper API and fix security/config issues

- Fix enable_mcp_server to pass shutdown_event + ready_event instead of
  ready_future, matching _init_mcp_client_task_wrapper's current signature
- Cancel and await init_task on timeout; clean up mcp_client_event on failure
- Read MCP_INIT_TIMEOUT from env var ASTRBOT_MCP_INIT_TIMEOUT (default 20s)
  so operators can tune it without code changes
- Strip userinfo from URL in debug log (use hostname+port only, not netloc)
  to avoid leaking credentials embedded in URLs

* refactor: register mcp_client_event only after successful init in enable_mcp_server

Move self.mcp_client_event[name] assignment to after initialization
succeeds, so callers never observe a stale event for a failed client.

* fix: harden MCP init state handling and timeout parsing

* fix: improve MCP timeout and post-init error observability

* refactor: simplify MCP init lifecycle orchestration

* refactor: simplify MCP init flow and cap timeout values

* fix: refine mcp timeout handling and lifecycle task tracking

* fix: harden mcp shutdown and timeout source logging

* refactor: simplify mcp runtime registry and timeout flow

* fix: keep mcp init summary return contract

* refactor: streamline mcp lifecycle and init errors

* refactor: unify mcp lifecycle wait handling

* refactor: simplify mcp runtime ownership and timeout resolution

* fix: harden mcp shutdown waiting and startup signaling

* refactor: streamline mcp lifecycle and shutdown errors

* refactor: harden mcp runtime access and shutdown

* fix: ensure mcp client cleanup and clarify views

* refactor: cache mcp client view and guard startup

* refactor: simplify mcp init cleanup and runtime lock

* refactor: reduce mcp runtime duplication

* refactor: reuse mcp cleanup and client view

---------

Co-authored-by: idiotsj <idiotsj@users.noreply.github.com>
Co-authored-by: 邹永赫 <1259085392@qq.com>
2026-03-03 01:09:45 +09:00
Copilot 0dbe32e2dc feat: add Discord pre-ack emoji support (#5609)
* Initial plan

* feat: add Discord pre-ack emoji support

Co-authored-by: Soulter <37870767+Soulter@users.noreply.github.com>

* feat: add Discord pre-acknowledgment emoji configuration in English and Chinese locales

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Soulter <37870767+Soulter@users.noreply.github.com>
Co-authored-by: Soulter <905617992@qq.com>
2026-03-02 14:38:12 +08:00
Soulter 4e855a17bc fix: update Discord command registration descriptions and hints in config metadata 2026-03-02 14:31:36 +08:00
Soulter f2fc724e0f fix: update tutorial links to use the correct path format 2026-03-02 14:22:56 +08:00
Copilot 460acf40c0 fix: apply max_agent_step config to subagents (#5608)
* Initial plan

* fix: apply max_agent_step config to subagents

Co-authored-by: Soulter <37870767+Soulter@users.noreply.github.com>

* fix: streamline max_agent_step and streaming_response retrieval in FunctionToolExecutor

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Soulter <37870767+Soulter@users.noreply.github.com>
Co-authored-by: Soulter <905617992@qq.com>
2026-03-02 14:16:14 +08:00
Soulter cf29d9390f chore: reorganize provider settings for quoted message parsing 2026-03-02 12:35:35 +08:00
Soulter ac44d1fdef feat: enhance chat interface and mobile responsiveness (#5635) 2026-03-02 12:26:55 +08:00
49 changed files with 3008 additions and 476 deletions
+1 -1
View File
@@ -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: |
+2 -2
View File
@@ -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
+3
View File
@@ -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
+6
View File
@@ -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 :)
+7 -7
View File
@@ -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))
+47 -43
View File
@@ -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 -9
View File
@@ -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}")
+54 -46
View File
@@ -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 -9
View File
@@ -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
View File
@@ -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
View File
@@ -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}",
)
+11 -11
View File
@@ -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):
+1 -1
View File
@@ -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")
+5 -3
View File
@@ -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)]
+2
View File
@@ -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 -2
View File
@@ -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
+8 -2
View File
@@ -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
View File
@@ -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,
},
},
},
},
},
+4
View File
@@ -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", {})
+4
View File
@@ -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()
+446 -114
View File
@@ -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 风格的**已经激活**的工具描述"""
+19 -2
View File
@@ -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:
"""动态导入提供商适配器模块
+3 -1
View File
@@ -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"]
+7 -1
View File
@@ -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):
+5 -7
View 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:
+16 -4
View File
@@ -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
@@ -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 表情符号,例如:👍、🤔、⏳"
}
}
}
}
}
@@ -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>
+16 -16
View File
@@ -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";
}
+60 -5
View File
@@ -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;
}
+8
View File
@@ -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"
+63
View File
@@ -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)}")
-49
View File
@@ -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):
+1
View File
@@ -0,0 +1 @@
!data
+100
View File
@@ -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"
}
]
}
+4
View File
@@ -0,0 +1,4 @@
from pathlib import Path
TEST_DATA_DIR = Path(__file__).parent / "data"
+223
View File
@@ -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())
+107
View File
@@ -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
-24
View File
@@ -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."""
+17
View File
@@ -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