mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-02 18:50:15 +08:00
Compare commits
12 Commits
dev
...
fix/lsp-cl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
caa0e3dc49 | ||
|
|
4e9d7d3b4b | ||
|
|
83ab4f143d | ||
|
|
1f642edb9c | ||
|
|
979b8cc282 | ||
|
|
466105d38a | ||
|
|
a05fab371a | ||
|
|
07f5cf4917 | ||
|
|
3b62c6284a | ||
|
|
34da564506 | ||
|
|
00f9b4f0ce | ||
|
|
5fa18dd836 |
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -54,7 +54,7 @@ python3 -m venv ./venv
|
||||
```
|
||||
|
||||
> 也可能是 `python` 而不是 `python3`
|
||||
|
||||
|
||||
以上步骤会创建一个虚拟环境并激活(以免打乱您设备本地的 Python 环境)。
|
||||
|
||||
接下来,通过以下命令安装依赖文件,这可能需要花费一些时间:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -31,7 +31,7 @@ MacOS 用户下载安装好后,可能会遇到 "已损坏,无法打开" 的
|
||||
|
||||
### 下载安装器
|
||||
|
||||
打开 https://github.com/AstrBotDevs/AstrBotLauncher/releases/latest
|
||||
打开 https://github.com/AstrBotDevs/AstrBotLauncher/releases/latest
|
||||
|
||||
下载 `Source code (zip)` 并解压到您的电脑。
|
||||
|
||||
|
||||
@@ -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):
|
||||

|
||||
|
||||
|
||||
有任何疑问欢迎加群询问~
|
||||
有任何疑问欢迎加群询问~
|
||||
|
||||
@@ -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 类型定义
|
||||
|
||||
@@ -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 类型定义
|
||||
|
||||
@@ -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**:
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
|
||||
# 启动服务
|
||||
```bash
|
||||
# 新版本默认0.0.0.0改成了::,默认启用了双栈支持,如果使用的旧版,需要手动修改配置文件,将host修改为[::]
|
||||
# 新版本默认0.0.0.0改成了::,默认启用了双栈支持,如果使用的旧版,需要手动修改配置文件,将host修改为[::]
|
||||
astrbot run
|
||||
# 不出意外,你可以在输出里面看到24开头,一长串的ipv6链接
|
||||
# http://[ipv6地址]:6185
|
||||
|
||||
@@ -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. 设置好角色权限后,点击上方邀请链接的复制按钮复制链接,在浏览器中打开复制出来的邀请链接,将机器人加入到所需的服务器。
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
|
||||
1. 进入 AstrBot 的管理面板
|
||||
2. 点击左边栏 `机器人`
|
||||
3. 然后在右边的界面中,点击 `+ 创建机器人`
|
||||
3. 然后在右边的界面中,点击 `+ 创建机器人`
|
||||
4. 选择 `lark(飞书)`
|
||||
|
||||
弹出的配置项填写:
|
||||
|
||||
@@ -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 处。
|
||||
|
||||

|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
|
||||
1. 进入 AstrBot 的管理面板
|
||||
2. 点击左边栏 `机器人`
|
||||
3. 然后在右边的界面中,点击 `+ 创建机器人`
|
||||
3. 然后在右边的界面中,点击 `+ 创建机器人`
|
||||
4. 选择 `telegram`
|
||||
|
||||
弹出的配置项填写:
|
||||
|
||||
@@ -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` 安装。
|
||||
|
||||
@@ -73,7 +73,7 @@ Workflow 应用接收输入变量,然后执行工作流,最后输出结果
|
||||

|
||||
|
||||
当设置变量后,AstrBot 会在下次向 Dify 请求时附上您设置的变量,以灵活适配您的 Workflow。
|
||||
|
||||
|
||||

|
||||
|
||||
当然,可以使用 `/unset` 指令来取消设置的变量。
|
||||
|
||||
@@ -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))。
|
||||
|
||||
例如,您可以选择接入如下(但不限于)模型提供商提供的模型服务:
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||

|
||||
|
||||
AstrBot 会在对话上下文达到**使用的对话模型上下文窗口的最大长度的 82% 时**,自动对上下文进行压缩,以确保在不丢失关键信息的情况下,尽可能多地保留对话内容。
|
||||
AstrBot 会在对话上下文达到**使用的对话模型上下文窗口的最大长度的 82% 时**,自动对上下文进行压缩,以确保在不丢失关键信息的情况下,尽可能多地保留对话内容。
|
||||
|
||||
## 压缩策略
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
13
tests/integration/fixtures/hanging_lsp_server.py
Normal file
13
tests/integration/fixtures/hanging_lsp_server.py
Normal 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()
|
||||
@@ -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()
|
||||
|
||||
134
tests/unit/test_internal/test_lsp_client.py
Normal file
134
tests/unit/test_internal/test_lsp_client.py
Normal 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
|
||||
Reference in New Issue
Block a user