Compare commits

...

12 Commits

Author SHA1 Message Date
邹永赫
caa0e3dc49 fix: simplify lsp reader task lifecycle 2026-03-26 03:51:37 +09:00
邹永赫
4e9d7d3b4b docs: translate ai guide details label 2026-03-26 03:42:26 +09:00
邹永赫
83ab4f143d test: shorten hanging lsp fixture sleep 2026-03-26 03:35:17 +09:00
邹永赫
1f642edb9c docs: fix platform adapter session type 2026-03-26 03:25:11 +09:00
邹永赫
979b8cc282 fix: centralize lsp reader lifecycle 2026-03-26 03:21:21 +09:00
邹永赫
466105d38a fix: refine lsp reader failure handling 2026-03-26 03:05:26 +09:00
邹永赫
a05fab371a fix: harden lsp reader task teardown 2026-03-26 02:57:01 +09:00
邹永赫
07f5cf4917 fix: refine lsp client test coverage 2026-03-26 02:48:16 +09:00
邹永赫
3b62c6284a fix: harden lsp client reconnect handling 2026-03-26 02:33:27 +09:00
邹永赫
34da564506 fix: trim trailing whitespace in docs 2026-03-26 02:22:32 +09:00
邹永赫
00f9b4f0ce fix: observe lsp reader task failures 2026-03-26 02:10:43 +09:00
邹永赫
5fa18dd836 fix: avoid lsp client cancel scope leaks 2026-03-26 01:55:39 +09:00
25 changed files with 463 additions and 260 deletions

View File

@@ -7,6 +7,7 @@ that provide language intelligence features (completions, diagnostics, etc.).
from __future__ import annotations
import asyncio
import json
from typing import Any
@@ -35,14 +36,29 @@ class AstrbotLspClient(BaseAstrbotLspClient):
self._pending_requests: dict[int, Any] = {}
self._request_id = 0
self._server_command: list[str] | None = None
# anyio TaskGroup handle for background readers
self._task_group: Any | None = None
self._reader_task: asyncio.Task[None] | None = None
@property
def connected(self) -> bool:
"""True if connected to an LSP server."""
return self._connected
async def _stop_reader_task(self) -> None:
reader_task = self._reader_task
if reader_task is None:
return
self._reader_task = None
if reader_task is asyncio.current_task():
return
if not reader_task.done():
reader_task.cancel()
try:
await reader_task
except asyncio.CancelledError:
pass
except Exception as exc:
log.debug("Ignoring failed LSP reader task during teardown", exc_info=exc)
async def connect(self) -> None:
"""
Connect to configured LSP servers.
@@ -66,6 +82,8 @@ class AstrbotLspClient(BaseAstrbotLspClient):
"""
log.debug(f"Starting LSP server: {' '.join(command)}")
await self._stop_reader_task()
self._server_process = await anyio.open_process(
command,
stdin=-1,
@@ -77,11 +95,8 @@ class AstrbotLspClient(BaseAstrbotLspClient):
self._server_command = command
self._connected = True
# Start reading responses in background using anyio TaskGroup
# Create and enter a TaskGroup so the reader runs until we close it at shutdown.
self._task_group = anyio.create_task_group()
await self._task_group.__aenter__()
self._task_group.start_soon(self._read_responses)
# Start reading responses in the background.
self._reader_task = asyncio.create_task(self._read_responses())
# Send initialize request
await self.send_request(
@@ -204,9 +219,20 @@ class AstrbotLspClient(BaseAstrbotLspClient):
except anyio.EndOfStream:
break
except anyio.get_cancelled_exc_class():
# Task was cancelled via the TaskGroup cancel/exit during shutdown
pass
except asyncio.CancelledError:
raise
except Exception as exc:
if self._connected:
self._connected = False
log.error("LSP reader task failed", exc_info=exc)
return
else:
if self._connected:
self._connected = False
log.warning("LSP reader task exited unexpectedly")
finally:
if self._reader_task is asyncio.current_task():
self._reader_task = None
async def _handle_notification(self, notification: dict[str, Any]) -> None:
"""Handle incoming LSP notifications."""
@@ -217,13 +243,7 @@ class AstrbotLspClient(BaseAstrbotLspClient):
"""Shutdown the LSP client."""
self._connected = False
if self._task_group:
try:
# Exit the TaskGroup, which cancels background tasks started within it
await self._task_group.__aexit__(None, None, None)
except anyio.get_cancelled_exc_class():
pass
self._task_group = None
await self._stop_reader_task()
if self._server_process:
try:

View File

@@ -54,7 +54,7 @@ python3 -m venv ./venv
```
> It might be `python` instead of `python3`
The above steps will create and activate a virtual environment (to avoid disrupting your local Python environment).
Next, install the dependencies with the following command, which may take some time:

View File

@@ -288,83 +288,83 @@ class Conversation:
#### `new_conversation`
- **Usage**
- **Usage**
Create a new conversation in the current session and automatically switch to it.
- **Arguments**
- `unified_msg_origin: str` In the format `platform_name:message_type:session_id`
- `platform_id: str | None` Platform identifier, defaults to parsing from `unified_msg_origin`
- `content: list[dict] | None` Initial message history
- `title: str | None` Conversation title
- **Arguments**
- `unified_msg_origin: str` In the format `platform_name:message_type:session_id`
- `platform_id: str | None` Platform identifier, defaults to parsing from `unified_msg_origin`
- `content: list[dict] | None` Initial message history
- `title: str | None` Conversation title
- `persona_id: str | None` Associated persona ID
- **Returns**
- **Returns**
`str` Newly generated UUID conversation ID
#### `switch_conversation`
- **Usage**
- **Usage**
Switch the session to a specified conversation.
- **Arguments**
- `unified_msg_origin: str`
- **Arguments**
- `unified_msg_origin: str`
- `conversation_id: str`
- **Returns**
- **Returns**
`None`
#### `delete_conversation`
- **Usage**
- **Usage**
Delete a conversation from the session; if `conversation_id` is `None`, deletes the current conversation.
- **Arguments**
- `unified_msg_origin: str`
- **Arguments**
- `unified_msg_origin: str`
- `conversation_id: str | None`
- **Returns**
- **Returns**
`None`
#### `get_curr_conversation_id`
- **Usage**
- **Usage**
Get the conversation ID currently in use by the session.
- **Arguments**
- **Arguments**
- `unified_msg_origin: str`
- **Returns**
- **Returns**
`str | None` Current conversation ID, returns `None` if it doesn't exist
#### `get_conversation`
- **Usage**
- **Usage**
Get the complete object for a specified conversation; automatically creates it if it doesn't exist and `create_if_not_exists=True`.
- **Arguments**
- `unified_msg_origin: str`
- `conversation_id: str`
- **Arguments**
- `unified_msg_origin: str`
- `conversation_id: str`
- `create_if_not_exists: bool = False`
- **Returns**
- **Returns**
`Conversation | None`
#### `get_conversations`
- **Usage**
- **Usage**
Retrieve the complete list of conversations for a user or platform.
- **Arguments**
- `unified_msg_origin: str | None` When `None`, does not filter by user
- **Arguments**
- `unified_msg_origin: str | None` When `None`, does not filter by user
- `platform_id: str | None`
- **Returns**
- **Returns**
`List[Conversation]`
#### `update_conversation`
- **Usage**
- **Usage**
Update the title, history, or persona_id of a conversation.
- **Arguments**
- `unified_msg_origin: str`
- `conversation_id: str | None` Uses the current conversation when `None`
- `history: list[dict] | None`
- `title: str | None`
- **Arguments**
- `unified_msg_origin: str`
- `conversation_id: str | None` Uses the current conversation when `None`
- `history: list[dict] | None`
- `title: str | None`
- `persona_id: str | None`
- **Returns**
- **Returns**
`None`
## Persona Manager
`PersonaManager` is responsible for unified loading, caching, and providing CRUD interfaces for all Personas, while maintaining compatibility with the legacy persona format (v3) from before AstrBot 4.x.
`PersonaManager` is responsible for unified loading, caching, and providing CRUD interfaces for all Personas, while maintaining compatibility with the legacy persona format (v3) from before AstrBot 4.x.
During initialization, it automatically reads all personas from the database and generates v3-compatible data for seamless use with legacy code.
```py
@@ -386,59 +386,59 @@ persona_mgr = self.context.persona_manager
#### `get_all_personas`
- **Usage**
- **Usage**
Retrieve all personas from the database at once.
- **Returns**
- **Returns**
`list[Persona]` Persona list, may be empty
#### `create_persona`
- **Usage**
- **Usage**
Create a new persona and immediately write it to the database; automatically refreshes the local cache upon success.
- **Arguments**
- `persona_id: str` New persona ID (unique)
- `system_prompt: str` System prompt
- `begin_dialogs: list[str]` Optional, opening dialogs (even number of entries, alternating user/assistant)
- **Arguments**
- `persona_id: str` New persona ID (unique)
- `system_prompt: str` System prompt
- `begin_dialogs: list[str]` Optional, opening dialogs (even number of entries, alternating user/assistant)
- `tools: list[str]` Optional, list of allowed tools; `None`=all tools, `[]`=disable all
- **Returns**
- **Returns**
`Persona` Newly created persona object
- **Raises**
- **Raises**
`ValueError` If `persona_id` already exists
#### `update_persona`
- **Usage**
- **Usage**
Update any fields of an existing persona and synchronize to database and cache.
- **Arguments**
- `persona_id: str` Persona ID to update
- `system_prompt: str` Optional, new system prompt
- `begin_dialogs: list[str]` Optional, new opening dialogs
- **Arguments**
- `persona_id: str` Persona ID to update
- `system_prompt: str` Optional, new system prompt
- `begin_dialogs: list[str]` Optional, new opening dialogs
- `tools: list[str]` Optional, new tool list; semantics same as `create_persona`
- **Returns**
- **Returns**
`Persona` Updated persona object
- **Raises**
- **Raises**
`ValueError` If `persona_id` doesn't exist
#### `delete_persona`
- **Usage**
- **Usage**
Delete the specified persona and clean up both database and cache.
- **Arguments**
- **Arguments**
- `persona_id: str` Persona ID to delete
- **Raises**
- **Raises**
`ValueError` If `persona_id` doesn't exist
#### `get_default_persona_v3`
- **Usage**
Get the default persona (v3 format) to use based on the current session configuration.
- **Usage**
Get the default persona (v3 format) to use based on the current session configuration.
Falls back to `DEFAULT_PERSONALITY` if configuration doesn't specify one or the specified persona doesn't exist.
- **Arguments**
- **Arguments**
- `umo: str | MessageSession | None` Session identifier, used to read user-level configuration
- **Returns**
- **Returns**
`Personality` Default persona object in v3 format
::: details Persona / Personality 类型定义
::: details Persona / Personality type definitions
```py

View File

@@ -13,7 +13,7 @@
| File | Yes | Yes | Supports external links |
| Card (JSON) | Yes | Yes | See [Kook Docs - Card Messages] |
Proactive message push: Supported
Proactive message push: Supported
Message receiving mode: WebSocket
## Create a Bot on Kook

View File

@@ -2,5 +2,5 @@
AstrBot supports integration with many mainstream instant messaging platforms, so you can use AstrBot on the IM platform your team already uses.
In WebUI, click **Bots** in the left sidebar to open the messaging platform integration page.
In WebUI, click **Bots** in the left sidebar to open the messaging platform integration page.
Then click **Create Bot** in the top-right corner, choose the platform you want to connect, and follow the platform-specific guide in the left sidebar of this documentation.

View File

@@ -54,7 +54,7 @@ python3 -m venv ./venv
```
> 也可能是 `python` 而不是 `python3`
以上步骤会创建一个虚拟环境并激活(以免打乱您设备本地的 Python 环境)。
接下来,通过以下命令安装依赖文件,这可能需要花费一些时间:

View File

@@ -73,7 +73,7 @@ sudo docker run -itd -p 6185:6185 -p 6199:6199 -v $PWD/data:/AstrBot/data -v /et
> ```
>
> (感谢 DaoCloud ❤️)
>
>
> Windows 下不需要加 sudo下同
>
Windows 同步 Host Time需要WSL2

View File

@@ -31,7 +31,7 @@ MacOS 用户下载安装好后,可能会遇到 "已损坏,无法打开" 的
### 下载安装器
打开 https://github.com/AstrBotDevs/AstrBotLauncher/releases/latest
打开 https://github.com/AstrBotDevs/AstrBotLauncher/releases/latest
下载 `Source code (zip)` 并解压到您的电脑。

View File

@@ -23,7 +23,7 @@ class FakeClient():
self.token = token
self.username = username
# ...
async def start_polling(self):
while True:
await asyncio.sleep(5)
@@ -35,10 +35,10 @@ class FakeClient():
'message_id': 'asdhoashd',
'group_id': 'group123',
})
async def send_text(self, to: str, message: str):
print('发了消息:', to, message)
async def send_image(self, to: str, image_path: str):
print('发了消息:', to, image_path)
```
@@ -51,12 +51,12 @@ import asyncio
from astrbot.api.platform import Platform, AstrBotMessage, MessageMember, PlatformMetadata, MessageType
from astrbot.api.event import MessageChain
from astrbot.api.message_components import Plain, Image, Record # 消息链中的组件,可以根据需要导入
from astrbot.core.platform.astr_message_event import MessageSesion
from astrbot.core.platform.astr_message_event import MessageSession
from astrbot.api.platform import register_platform_adapter
from astrbot import logger
from .client import FakeClient
from .fake_platform_event import FakePlatformEvent
# 注册平台适配器。第一个参数为平台名,第二个为描述。第三个为默认配置。
@register_platform_adapter("fake", "fake 适配器", default_config_tmpl={
"token": "your_token",
@@ -68,11 +68,11 @@ class FakePlatformAdapter(Platform):
super().__init__(event_queue)
self.config = platform_config # 上面的默认配置,用户填写后会传到这里
self.settings = platform_settings # platform_settings 平台设置。
async def send_by_session(self, session: MessageSesion, message_chain: MessageChain):
async def send_by_session(self, session: MessageSession, message_chain: MessageChain):
# 必须实现
await super().send_by_session(session, message_chain)
def meta(self) -> PlatformMetadata:
# 必须实现,直接像下面一样返回即可。
return PlatformMetadata(
@@ -87,8 +87,8 @@ class FakePlatformAdapter(Platform):
async def on_received(data):
logger.info(data)
abm = await self.convert_message(data=data) # 转换成 AstrBotMessage
await self.handle_msg(abm)
await self.handle_msg(abm)
# 初始化 FakeClient
self.client = FakeClient(self.config['token'], self.config['username'])
self.client.on_message_received = on_received
@@ -107,9 +107,9 @@ class FakePlatformAdapter(Platform):
abm.self_id = data['bot_id']
abm.session_id = data['userid'] # 会话 ID。重要
abm.message_id = data['message_id'] # 消息 ID。
return abm
async def handle_msg(self, message: AstrBotMessage):
# 处理消息
message_event = FakePlatformEvent(
@@ -136,12 +136,12 @@ class FakePlatformEvent(AstrMessageEvent):
def __init__(self, message_str: str, message_obj: AstrBotMessage, platform_meta: PlatformMetadata, session_id: str, client: FakeClient):
super().__init__(message_str, message_obj, platform_meta, session_id)
self.client = client
async def send(self, message: MessageChain):
for i in message.chain: # 遍历消息链
if isinstance(i, Plain): # 如果是文字类型的
await self.client.send_text(to=self.get_sender_id(), message=i.text)
elif isinstance(i, Image): # 如果是图片类型的
elif isinstance(i, Image): # 如果是图片类型的
img_url = i.file
img_path = ""
# 下面的三个条件可以直接参考一下。
@@ -153,7 +153,7 @@ class FakePlatformEvent(AstrMessageEvent):
img_path = img_url
# 请善于 Debug
await self.client.send_image(to=self.get_sender_id(), image_path=img_path)
await super().send(message) # 需要最后加上这一段,执行父类的 send 方法。
@@ -182,4 +182,4 @@ class MyPlugin(Star):
![image](https://files.astrbot.app/docs/source/images/plugin-platform-adapter/QQ_1738156166893.png)
有任何疑问欢迎加群询问~
有任何疑问欢迎加群询问~

View File

@@ -352,83 +352,83 @@ await conv_mgr.add_message_pair(
#### `new_conversation`
- __Usage__
- __Usage__
在当前会话中新建一条对话,并自动切换为该对话。
- __Arguments__
- `unified_msg_origin: str` 形如 `platform_name:message_type:session_id`
- `platform_id: str | None` 平台标识,默认从 `unified_msg_origin` 解析
- `content: list[dict] | None` 初始历史消息
- `title: str | None` 对话标题
- __Arguments__
- `unified_msg_origin: str` 形如 `platform_name:message_type:session_id`
- `platform_id: str | None` 平台标识,默认从 `unified_msg_origin` 解析
- `content: list[dict] | None` 初始历史消息
- `title: str | None` 对话标题
- `persona_id: str | None` 绑定的 persona ID
- __Returns__
- __Returns__
`str` 新生成的 UUID 对话 ID
#### `switch_conversation`
- __Usage__
- __Usage__
将会话切换到指定的对话。
- __Arguments__
- `unified_msg_origin: str`
- __Arguments__
- `unified_msg_origin: str`
- `conversation_id: str`
- __Returns__
- __Returns__
`None`
#### `delete_conversation`
- __Usage__
- __Usage__
删除会话中的某条对话;若 `conversation_id` 为 `None`,则删除当前对话。
- __Arguments__
- `unified_msg_origin: str`
- __Arguments__
- `unified_msg_origin: str`
- `conversation_id: str | None`
- __Returns__
- __Returns__
`None`
#### `get_curr_conversation_id`
- __Usage__
- __Usage__
获取当前会话正在使用的对话 ID。
- __Arguments__
- __Arguments__
- `unified_msg_origin: str`
- __Returns__
- __Returns__
`str | None` 当前对话 ID不存在时返回 `None`
#### `get_conversation`
- __Usage__
- __Usage__
获取指定对话的完整对象;若不存在且 `create_if_not_exists=True` 则自动创建。
- __Arguments__
- `unified_msg_origin: str`
- `conversation_id: str`
- __Arguments__
- `unified_msg_origin: str`
- `conversation_id: str`
- `create_if_not_exists: bool = False`
- __Returns__
- __Returns__
`Conversation | None`
#### `get_conversations`
- __Usage__
- __Usage__
拉取用户或平台下的全部对话列表。
- __Arguments__
- `unified_msg_origin: str | None` 为 `None` 时不过滤用户
- __Arguments__
- `unified_msg_origin: str | None` 为 `None` 时不过滤用户
- `platform_id: str | None`
- __Returns__
- __Returns__
`List[Conversation]`
#### `update_conversation`
- __Usage__
- __Usage__
更新对话的标题、历史记录或 persona_id。
- __Arguments__
- `unified_msg_origin: str`
- `conversation_id: str | None` 为 `None` 时使用当前对话
- `history: list[dict] | None`
- `title: str | None`
- __Arguments__
- `unified_msg_origin: str`
- `conversation_id: str | None` 为 `None` 时使用当前对话
- `history: list[dict] | None`
- `title: str | None`
- `persona_id: str | None`
- __Returns__
- __Returns__
`None`
## 人格设定管理器
`PersonaManager` 负责统一加载、缓存并提供所有人格Persona的增删改查接口同时兼容 AstrBot 4.x 之前的旧版人格格式v3
`PersonaManager` 负责统一加载、缓存并提供所有人格Persona的增删改查接口同时兼容 AstrBot 4.x 之前的旧版人格格式v3
初始化时会自动从数据库读取全部人格,并生成一份 v3 兼容数据,供旧代码无缝使用。
```py
@@ -450,56 +450,56 @@ persona_mgr = self.context.persona_manager
#### `get_all_personas`
- __Usage__
- __Usage__
一次性获取数据库中所有人格。
- __Returns__
- __Returns__
`list[Persona]` 人格列表,可能为空
#### `create_persona`
- __Usage__
- __Usage__
新建人格并立即写入数据库,成功后自动刷新本地缓存。
- __Arguments__
- `persona_id: str` 新人格 ID唯一
- `system_prompt: str` 系统提示词
- `begin_dialogs: list[str]` 可选开场对话偶数条user/assistant 交替)
- __Arguments__
- `persona_id: str` 新人格 ID唯一
- `system_prompt: str` 系统提示词
- `begin_dialogs: list[str]` 可选开场对话偶数条user/assistant 交替)
- `tools: list[str]` 可选,允许使用的工具列表;`None`=全部工具,`[]`=禁用全部
- __Returns__
- __Returns__
`Persona` 新建后的人格对象
- __Raises__
- __Raises__
`ValueError` 若 `persona_id` 已存在
#### `update_persona`
- __Usage__
- __Usage__
更新现有人格的任意字段,并同步到数据库与缓存。
- __Arguments__
- `persona_id: str` 待更新的人格 ID
- `system_prompt: str` 可选,新的系统提示词
- `begin_dialogs: list[str]` 可选,新的开场对话
- __Arguments__
- `persona_id: str` 待更新的人格 ID
- `system_prompt: str` 可选,新的系统提示词
- `begin_dialogs: list[str]` 可选,新的开场对话
- `tools: list[str]` 可选,新的工具列表;语义同 `create_persona`
- __Returns__
- __Returns__
`Persona` 更新后的人格对象
- __Raises__
- __Raises__
`ValueError` 若 `persona_id` 不存在
#### `delete_persona`
- __Usage__
- __Usage__
删除指定人格,同时清理数据库与缓存。
- __Arguments__
- __Arguments__
- `persona_id: str` 待删除的人格 ID
- __Raises__
`Valueable` 若 `persona_id` 不存在
- __Raises__
`ValueError` 若 `persona_id` 不存在
#### `get_default_persona_v3`
- __Usage__
根据当前会话配置获取应使用的默认人格v3 格式)。
- __Usage__
根据当前会话配置获取应使用的默认人格v3 格式)。
若配置未指定或指定的人格不存在,则回退到 `DEFAULT_PERSONALITY`。
- __Arguments__
- __Arguments__
- `umo: str | MessageSession | None` 会话标识,用于读取用户级配置
- __Returns__
- __Returns__
`Personality` v3 格式的默认人格对象
::: details Persona / Personality 类型定义

View File

@@ -1316,103 +1316,103 @@ class Conversation:
##### `new_conversation`
- **Usage**
- **Usage**
在当前会话中新建一条对话,并自动切换为该对话。
- **Arguments**
- `unified_msg_origin: str` 形如 `platform_name:message_type:session_id`
- `platform_id: str | None` 平台标识,默认从 `unified_msg_origin` 解析
- `content: list[dict] | None` 初始历史消息
- `title: str | None` 对话标题
- **Arguments**
- `unified_msg_origin: str` 形如 `platform_name:message_type:session_id`
- `platform_id: str | None` 平台标识,默认从 `unified_msg_origin` 解析
- `content: list[dict] | None` 初始历史消息
- `title: str | None` 对话标题
- `persona_id: str | None` 绑定的 persona ID
- **Returns**
- **Returns**
`str` 新生成的 UUID 对话 ID
##### `switch_conversation`
- **Usage**
- **Usage**
将会话切换到指定的对话。
- **Arguments**
- `unified_msg_origin: str`
- **Arguments**
- `unified_msg_origin: str`
- `conversation_id: str`
- **Returns**
- **Returns**
`None`
##### `delete_conversation`
- **Usage**
- **Usage**
删除会话中的某条对话;若 `conversation_id` 为 `None`,则删除当前对话。
- **Arguments**
- `unified_msg_origin: str`
- **Arguments**
- `unified_msg_origin: str`
- `conversation_id: str | None`
- **Returns**
- **Returns**
`None`
##### `get_curr_conversation_id`
- **Usage**
- **Usage**
获取当前会话正在使用的对话 ID。
- **Arguments**
- **Arguments**
- `unified_msg_origin: str`
- **Returns**
- **Returns**
`str | None` 当前对话 ID不存在时返回 `None`
##### `get_conversation`
- **Usage**
- **Usage**
获取指定对话的完整对象;若不存在且 `create_if_not_exists=True` 则自动创建。
- **Arguments**
- `unified_msg_origin: str`
- `conversation_id: str`
- **Arguments**
- `unified_msg_origin: str`
- `conversation_id: str`
- `create_if_not_exists: bool = False`
- **Returns**
- **Returns**
`Conversation | None`
##### `get_conversations`
- **Usage**
- **Usage**
拉取用户或平台下的全部对话列表。
- **Arguments**
- `unified_msg_origin: str | None` 为 `None` 时不过滤用户
- **Arguments**
- `unified_msg_origin: str | None` 为 `None` 时不过滤用户
- `platform_id: str | None`
- **Returns**
- **Returns**
`List[Conversation]`
##### `get_filtered_conversations`
- **Usage**
- **Usage**
分页 + 关键词搜索对话。
- **Arguments**
- `page: int = 1`
- `page_size: int = 20`
- `platform_ids: list[str] | None`
- `search_query: str = ""`
- **Arguments**
- `page: int = 1`
- `page_size: int = 20`
- `platform_ids: list[str] | None`
- `search_query: str = ""`
- `**kwargs` 透传其他过滤条件
- **Returns**
- **Returns**
`tuple[list[Conversation], int]` 对话列表与总数
##### `update_conversation`
- **Usage**
- **Usage**
更新对话的标题、历史记录或 persona_id。
- **Arguments**
- `unified_msg_origin: str`
- `conversation_id: str | None` 为 `None` 时使用当前对话
- `history: list[dict] | None`
- `title: str | None`
- **Arguments**
- `unified_msg_origin: str`
- `conversation_id: str | None` 为 `None` 时使用当前对话
- `history: list[dict] | None`
- `title: str | None`
- `persona_id: str | None`
- **Returns**
- **Returns**
`None`
##### `get_human_readable_context`
- **Usage**
- **Usage**
生成分页后的人类可读对话上下文,方便展示或调试。
- **Arguments**
- `unified_msg_origin: str`
- `conversation_id: str`
- `page: int = 1`
- **Arguments**
- `unified_msg_origin: str`
- `conversation_id: str`
- `page: int = 1`
- `page_size: int = 10`
- **Returns**
- **Returns**
`tuple[list[str], int]` 当前页文本列表与总页数
```py
@@ -1423,7 +1423,7 @@ context = json.loads(conversation.history)
#### 人格设定管理器 PersonaManager
`PersonaManager` 负责统一加载、缓存并提供所有人格Persona的增删改查接口同时兼容 AstrBot 4.x 之前的旧版人格格式v3
`PersonaManager` 负责统一加载、缓存并提供所有人格Persona的增删改查接口同时兼容 AstrBot 4.x 之前的旧版人格格式v3
初始化时会自动从数据库读取全部人格,并生成一份 v3 兼容数据,供旧代码无缝使用。
```py
@@ -1443,56 +1443,56 @@ persona_mgr = self.context.persona_manager
##### `get_all_personas`
- **Usage**
- **Usage**
一次性获取数据库中所有人格。
- **Returns**
- **Returns**
`list[Persona]` 人格列表,可能为空
##### `create_persona`
- **Usage**
- **Usage**
新建人格并立即写入数据库,成功后自动刷新本地缓存。
- **Arguments**
- `persona_id: str` 新人格 ID唯一
- `system_prompt: str` 系统提示词
- `begin_dialogs: list[str]` 可选开场对话偶数条user/assistant 交替)
- **Arguments**
- `persona_id: str` 新人格 ID唯一
- `system_prompt: str` 系统提示词
- `begin_dialogs: list[str]` 可选开场对话偶数条user/assistant 交替)
- `tools: list[str]` 可选,允许使用的工具列表;`None`=全部工具,`[]`=禁用全部
- **Returns**
- **Returns**
`Persona` 新建后的人格对象
- **Raises**
- **Raises**
`ValueError` 若 `persona_id` 已存在
##### `update_persona`
- **Usage**
- **Usage**
更新现有人格的任意字段,并同步到数据库与缓存。
- **Arguments**
- `persona_id: str` 待更新的人格 ID
- `system_prompt: str` 可选,新的系统提示词
- `begin_dialogs: list[str]` 可选,新的开场对话
- **Arguments**
- `persona_id: str` 待更新的人格 ID
- `system_prompt: str` 可选,新的系统提示词
- `begin_dialogs: list[str]` 可选,新的开场对话
- `tools: list[str]` 可选,新的工具列表;语义同 `create_persona`
- **Returns**
- **Returns**
`Persona` 更新后的人格对象
- **Raises**
- **Raises**
`ValueError` 若 `persona_id` 不存在
##### `delete_persona`
- **Usage**
- **Usage**
删除指定人格,同时清理数据库与缓存。
- **Arguments**
- **Arguments**
- `persona_id: str` 待删除的人格 ID
- **Raises**
`Valueable` 若 `persona_id` 不存在
- **Raises**
`ValueError` 若 `persona_id` 不存在
##### `get_default_persona_v3`
- **Usage**
根据当前会话配置获取应使用的默认人格v3 格式)。
- **Usage**
根据当前会话配置获取应使用的默认人格v3 格式)。
若配置未指定或指定的人格不存在,则回退到 `DEFAULT_PERSONALITY`。
- **Arguments**
- **Arguments**
- `umo: str | MessageSession | None` 会话标识,用于读取用户级配置
- **Returns**
- **Returns**
`Personality` v3 格式的默认人格对象
::: details Persona / Personality 类型定义

View File

@@ -20,7 +20,7 @@
6. **配置部署**:
* **Production Branch**: 保持默认 (`main`) 即可。
* **Entrypoint**: **这是关键步骤!** 点击下拉框,找到并选择 `deno_index.ts` 文件作为入口点。
* **Project Name**: Deno 会自动生成一个项目名称,这将是你的服务地址的一部分。你可以保留自动生成的名称 (例如 `fluffy-donkey-12`),也可以自定义名称 (例如 `my-astrbot-proxy`)。
* **Project Name**: Deno 会自动生成一个项目名称,这将是你的服务地址的一部分。你可以保留自动生成的名称 (例如 `fluffy-donkey-12`),也可以自定义名称 (例如 `my-astrbot-proxy`)。
7. **开始部署**: 确认设置无误后,点击 **Link****Deploy** 按钮。稍等片刻即可完成。
8. **获取服务地址**: 部署成功后,页面会显示你的服务地址,格式为 `https://<第6步设置的项目名>.deno.dev`。复制这个地址。
9. **配置 AstrBot**:

View File

@@ -28,7 +28,7 @@
# 启动服务
```bash
# 新版本默认0.0.0.0改成了::默认启用了双栈支持如果使用的旧版需要手动修改配置文件将host修改为[::]
# 新版本默认0.0.0.0改成了::默认启用了双栈支持如果使用的旧版需要手动修改配置文件将host修改为[::]
astrbot run
# 不出意外你可以在输出里面看到24开头一长串的ipv6链接
# http://[ipv6地址]:6185

View File

@@ -13,16 +13,16 @@
| 文件 | 是 | 是 | 支持外链 |
| 卡片JSON | 是 | 是 | 参见[Kook文档-卡片消息] |
主动消息推送:支持
主动消息推送:支持
消息接收模式WebSocket
## 在 Kook 创建机器人
1. 点击跳转 [Kook 开发者平台] ,完成以下步骤:
2. 登录账号并完成实名认证;
3. 点击「新建应用」,自定义 Bot 昵称;
4. 进入应用后台,选择「机器人」模块,开启 **WebSocket 连接模式**,注意保存生成的 **Token**后续配置Astrbot需要使用
1. 点击跳转 [Kook 开发者平台] ,完成以下步骤:
2. 登录账号并完成实名认证;
3. 点击「新建应用」,自定义 Bot 昵称;
4. 进入应用后台,选择「机器人」模块,开启 **WebSocket 连接模式**,注意保存生成的 **Token**,后续配置 AstrBot 需要使用;
5. 在左边栏「机器人」页面下点击「邀请链接」,设置角色权限(建议赋予全权限,确保功能完整)。
6. 设置好角色权限后,点击上方邀请链接的复制按钮复制链接,在浏览器中打开复制出来的邀请链接,将机器人加入到所需的服务器。

View File

@@ -36,7 +36,7 @@
1. 进入 AstrBot 的管理面板
2. 点击左边栏 `机器人`
3. 然后在右边的界面中,点击 `+ 创建机器人`
3. 然后在右边的界面中,点击 `+ 创建机器人`
4. 选择 `lark(飞书)`
弹出的配置项填写:

View File

@@ -13,7 +13,7 @@
Slack 支持两种接入方式:`Webhook``Socket`。如果您没有公网服务器并且消息业务量的规模较小,我们建议您使用 `socket` 方式。如果您有公网服务器(或者有一定的技术背景,了解如何设置 Tunnel如 Cloudflare Tunnel可以选择 `webhook` 方式。`socket` 方式部署相对简单。
1. 创建 [Slack](https://slack.com/signin) 账号和一个工作区Workspace
2. 前往 [应用后台](https://api.slack.com/apps)点击「Create New App」->「From Scratch」输入 `应用名称` 和要添加到的工作区然后点击「Create App」。
2. 前往 [应用后台](https://api.slack.com/apps)点击「Create New App」->「From Scratch」输入 `应用名称` 和要添加到的工作区然后点击「Create App」。
3. (仅 Webhook 需要)获取 `Signing Secret`,在左边栏 Basic Information 页下,找到 App Credentials 的 `Signing Secret`,点击 Show 并且复制到平台适配器配置的 signing_secret 处。
![image](https://files.astrbot.app/docs/source/images/slack/image.png)

View File

@@ -28,7 +28,7 @@
1. 进入 AstrBot 的管理面板
2. 点击左边栏 `机器人`
3. 然后在右边的界面中,点击 `+ 创建机器人`
3. 然后在右边的界面中,点击 `+ 创建机器人`
4. 选择 `telegram`
弹出的配置项填写:

View File

@@ -22,7 +22,7 @@ AstrBot 支持接入企业微信应用和微信客服。
1. 进入 AstrBot 的管理面板
2. 点击左边栏 `机器人`
3. 然后在右边的界面中,点击 `+ 创建机器人`
3. 然后在右边的界面中,点击 `+ 创建机器人`
4. 选择 `wecom`
这将弹出一个对话框。接下来,不要关闭页面,转移到下一步。
@@ -145,4 +145,4 @@ linux 用户可以使用 `apt install ffmpeg` 安装。
windows 用户可以在 [ffmpeg 官网](https://ffmpeg.org/download.html) 下载安装。
mac 用户可以使用 `brew install ffmpeg` 安装。
mac 用户可以使用 `brew install ffmpeg` 安装。

View File

@@ -73,7 +73,7 @@ Workflow 应用接收输入变量,然后执行工作流,最后输出结果
![alt text](https://files.astrbot.app/docs/source/images/dify/image-5.png)
当设置变量后AstrBot 会在下次向 Dify 请求时附上您设置的变量,以灵活适配您的 Workflow。
![alt text](https://files.astrbot.app/docs/source/images/dify/image-4.png)
当然,可以使用 `/unset` 指令来取消设置的变量。

View File

@@ -4,7 +4,7 @@ AstrBot 适配了 OpenAI、Google GenAI、Anthropic 三种原生 API 格式。
> [!NOTE]
> 如果您位于中国大陆境内,我们强烈建议您使用符合当地法律法规的由**模型厂商官方提供的**或经过备案的模型服务提供商,例如:
>
>
> - [MoonshotAI](https://moonshot.cn/)
> - [GLM](https://bigmodel.cn/)
> - [MiniMax](https://www.minimax.io/)
@@ -12,7 +12,7 @@ AstrBot 适配了 OpenAI、Google GenAI、Anthropic 三种原生 API 格式。
> - [DeepSeek](https://deepseek.com/)
>
> 上述提供商均支持 OpenAI API 格式,您可以通过其文档中有关 “OpenAI 格式接入” 的说明,找到 API Base URL 及 API Key然后将其填入 AstrBot 的提供商配置中。
>
>
> 请注意,使用未经备案的第三方模型服务提供商可能会导致服务不可用、信息泄露或其他法律风险,请谨慎选择。更多内容,请阅读我们的最终用户许可协议([EULA](https://github.com/AstrBotDevs/AstrBot/blob/master/EULA.md))。
例如,您可以选择接入如下(但不限于)模型提供商提供的模型服务:

View File

@@ -4,7 +4,7 @@
![alt text](https://files.astrbot.app/docs/source/images/context-compress/image.png)
AstrBot 会在对话上下文达到**使用的对话模型上下文窗口的最大长度的 82% 时**,自动对上下文进行压缩,以确保在不丢失关键信息的情况下,尽可能多地保留对话内容。
AstrBot 会在对话上下文达到**使用的对话模型上下文窗口的最大长度的 82% 时**,自动对上下文进行压缩,以确保在不丢失关键信息的情况下,尽可能多地保留对话内容。
## 压缩策略

View File

@@ -8,7 +8,7 @@ AstrBot 在 v4.13.0 之后引入了对 Anthropic Skills 的支持,使得用户
- 按需加载 (Progressive Disclosure):模型初始只加载技能名称和简短描述。只有当任务匹配时,才会加载详细的 SKILL.md 指令,从而节省上下文窗口并降低成本。
- 高度可复用:技能可以在不同的 Claude API 项目、Claude Code 或 Claude.ai 中通用。
- 执行能力:技能可以包含可执行代码脚本,配合 Anthropic 代码执行环境Code Execution直接生成或处理文件。
- 执行能力:技能可以包含可执行代码脚本,配合 Anthropic 代码执行环境Code Execution直接生成或处理文件。
## 上传 Skills 到 AstrBot

View File

@@ -0,0 +1,13 @@
"""LSP fixture that never answers initialization."""
from __future__ import annotations
import time
def main() -> None:
time.sleep(5)
if __name__ == "__main__":
main()

View File

@@ -6,11 +6,19 @@ Tests the LSP client against a real LSP server fixture.
from __future__ import annotations
import os
import sys
from pathlib import Path
import anyio
import pytest
from anyio.lowlevel import checkpoint
from astrbot._internal.protocols.lsp.client import AstrbotLspClient
TEST_DIR = Path(__file__).resolve().parent
SERVER_PATH = TEST_DIR / "fixtures" / "echo_lsp_server.py"
HANGING_SERVER_PATH = TEST_DIR / "fixtures" / "hanging_lsp_server.py"
@pytest.mark.anyio
async def test_lsp_client_initialization():
@@ -25,11 +33,8 @@ async def test_lsp_client_connect_to_echo_server():
"""Test LSP client can connect to echo LSP server."""
client = AstrbotLspClient()
test_dir = os.path.dirname(os.path.abspath(__file__))
server_path = os.path.join(test_dir, "fixtures", "echo_lsp_server.py")
await client.connect_to_server(
command=["python", server_path],
command=[sys.executable, str(SERVER_PATH)],
workspace_uri="file:///tmp",
)
@@ -44,11 +49,8 @@ async def test_lsp_client_send_request():
"""Test LSP client can send a request and receive response."""
client = AstrbotLspClient()
test_dir = os.path.dirname(os.path.abspath(__file__))
server_path = os.path.join(test_dir, "fixtures", "echo_lsp_server.py")
await client.connect_to_server(
command=["python", server_path],
command=[sys.executable, str(SERVER_PATH)],
workspace_uri="file:///tmp",
)
@@ -68,11 +70,8 @@ async def test_lsp_client_send_notification():
"""Test LSP client can send a notification (no response)."""
client = AstrbotLspClient()
test_dir = os.path.dirname(os.path.abspath(__file__))
server_path = os.path.join(test_dir, "fixtures", "echo_lsp_server.py")
await client.connect_to_server(
command=["python", server_path],
command=[sys.executable, str(SERVER_PATH)],
workspace_uri="file:///tmp",
)
@@ -99,4 +98,41 @@ async def test_lsp_client_send_notification_not_connected():
"""Test LSP client raises RuntimeError when sending notification while not connected."""
client = AstrbotLspClient()
with pytest.raises(RuntimeError, match="LSP client not connected"):
await client.send_notification("test", {})
await client.send_notification("test", {})
@pytest.mark.anyio
async def test_lsp_client_connect_does_not_corrupt_anyio_cancel_scope():
"""Test connect/shutdown can run inside fail_after scopes without scope corruption."""
client = AstrbotLspClient()
with anyio.fail_after(5):
await client.connect_to_server(
command=[sys.executable, str(SERVER_PATH)],
workspace_uri="file:///tmp",
)
try:
assert client.connected
finally:
with anyio.fail_after(5):
await client.shutdown()
@pytest.mark.anyio
async def test_lsp_client_connect_timeout_does_not_corrupt_anyio_cancel_scope():
"""Test timeout-driven cancellation leaves later fail_after scopes usable."""
client = AstrbotLspClient()
with pytest.raises(TimeoutError):
with anyio.fail_after(0.1):
await client.connect_to_server(
command=[sys.executable, str(HANGING_SERVER_PATH)],
workspace_uri="file:///tmp",
)
with anyio.fail_after(5):
await client.shutdown()
with anyio.fail_after(1):
await checkpoint()

View File

@@ -0,0 +1,134 @@
from __future__ import annotations
import asyncio
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from anyio.abc import ByteReceiveStream
from astrbot._internal.protocols.lsp.client import AstrbotLspClient
class FakeReader(ByteReceiveStream):
def __init__(self, receive_mock: AsyncMock) -> None:
self._receive_mock = receive_mock
async def receive(self, max_bytes: int = 65536) -> bytes:
del max_bytes
return await self._receive_mock()
async def aclose(self) -> None:
return None
@pytest.mark.asyncio
async def test_lsp_read_responses_failure_disconnects_and_logs():
"""Test reader failures are handled inside _read_responses."""
client = AstrbotLspClient()
client._connected = True
client._reader = FakeReader(AsyncMock(side_effect=RuntimeError("reader crashed")))
with patch("astrbot._internal.protocols.lsp.client.log") as mock_log:
await client._read_responses()
assert client.connected is False
mock_log.error.assert_called_once()
@pytest.mark.asyncio
async def test_lsp_read_responses_unexpected_exit_disconnects_and_warns():
"""Test non-cancelled reader exit updates connection state."""
client = AstrbotLspClient()
client._connected = True
client._reader = FakeReader(AsyncMock(return_value=b""))
with patch("astrbot._internal.protocols.lsp.client.log") as mock_log:
await client._read_responses()
assert client.connected is False
mock_log.warning.assert_called_once()
@pytest.mark.asyncio
async def test_lsp_read_responses_clears_reader_task_reference_on_exit():
"""Test _read_responses clears the stored task reference when it exits."""
client = AstrbotLspClient()
client._connected = True
client._reader = FakeReader(AsyncMock(return_value=b""))
task = asyncio.create_task(client._read_responses())
client._reader_task = task
await task
assert client._reader_task is None
@pytest.mark.asyncio
async def test_lsp_stop_reader_task_swallows_failed_reader_exceptions():
"""Test reader teardown does not re-raise prior reader failures."""
client = AstrbotLspClient()
async def fail_reader() -> None:
raise RuntimeError("reader crashed")
client._reader_task = asyncio.create_task(fail_reader())
await asyncio.sleep(0)
await client._stop_reader_task()
assert client._reader_task is None
@pytest.mark.asyncio
async def test_lsp_connect_to_server_cancels_previous_reader_task_before_restart():
"""Test reconnect tears down an existing reader task before replacing it."""
client = AstrbotLspClient()
fake_process = SimpleNamespace(stdout=MagicMock(), stdin=MagicMock())
first_reader_cancelled = asyncio.Event()
first_reader_started = asyncio.Event()
async def first_reader() -> None:
first_reader_started.set()
try:
await asyncio.Event().wait()
except asyncio.CancelledError:
first_reader_cancelled.set()
raise
with (
patch(
"astrbot._internal.protocols.lsp.client.anyio.open_process",
AsyncMock(return_value=fake_process),
),
patch.object(client, "send_request", AsyncMock(return_value={})),
patch.object(client, "send_notification", AsyncMock()),
):
client._read_responses = first_reader # type: ignore[method-assign]
await client.connect_to_server(["python", "first_lsp.py"], "file:///tmp")
await asyncio.wait_for(first_reader_started.wait(), timeout=1)
assert client.connected is True
second_reader = AsyncMock(return_value=None)
client._read_responses = second_reader # type: ignore[method-assign]
await client.connect_to_server(["python", "second_lsp.py"], "file:///tmp")
await asyncio.sleep(0)
assert first_reader_cancelled.is_set() is True
assert client.connected is True
@pytest.mark.asyncio
async def test_lsp_stop_reader_task_does_not_await_current_task():
"""Test stopping the reader from within itself does not self-await."""
client = AstrbotLspClient()
done = asyncio.Event()
async def stop_self() -> None:
client._reader_task = asyncio.current_task()
await client._stop_reader_task()
done.set()
task = asyncio.create_task(stop_self())
await asyncio.wait_for(done.wait(), timeout=1)
await task