Repository: Visecy/Karuha (language: python)

Issue:
Reconnect does not work in the new version
You are making an endless loop:
https://github.com/Visecy/Karuha/blob/master/karuha/bot.py#L1163


However, you explicitly set `self.state = BotState.stopped`:
- https://github.com/Visecy/Karuha/blob/master/karuha/bot.py#L1326
- https://github.com/Visecy/Karuha/blob/master/karuha/bot.py#L1345

Your retry only works on login (https://github.com/Visecy/Karuha/blob/master/karuha/event/bot.py#L52). But obviously the cause of `grpc.RpcError` on production can occur regularly. I don't think it's a good approach to just stop the bot.

Simple case: I locally shut down the server (for example, we have network problems) and tinode bot just terminates. What am I doing wrong?

This commit broke this logic (https://github.com/Visecy/Karuha/commit/a8fcaa7d207a3c4df443327cd53690b1f702bca9).

<img width="496" alt="image" src="https://github.com/user-attachments/assets/66925bb2-d7b0-4d00-b360-67fdaa6eb28f" />




Discussion / hints:

Fix patch:
diff --git a/.coveragerc b/.coveragerc
index e466baf..cad9a27 100644
--- a/.coveragerc
+++ b/.coveragerc
@@ -3,8 +3,6 @@
 branch = True
 omit =
     __main__.py
-    karuha/plugin_server.py
-    karuha/utils/locks.py
 
 [report]
 # Regexes for lines to exclude from consideration
diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml
index bde15ca..56eabe7 100644
--- a/.github/workflows/lint.yml
+++ b/.github/workflows/lint.yml
@@ -10,12 +10,12 @@ jobs:
   flake8-lint:
     runs-on: ubuntu-latest
     steps:
-      - uses: actions/checkout@v2
+      - uses: actions/checkout@v4
       - name: Set up Python
-        uses: actions/setup-python@v2
+        uses: actions/setup-python@v4
         with:
           python-version: 3.8
       - name: Install flake8
-        run: pip install flake8
+        run: pip install .[lint]
       - name: Run flake8
         run: make lint
diff --git a/Makefile b/Makefile
index 36b7b5f..1e5dd9a 100644
--- a/Makefile
+++ b/Makefile
@@ -13,7 +13,7 @@ build:
 	python setup.py build
 
 build_dist: test
-	python setup.py sdist bdist_wheel
+	python -m build
 
 install:
 	pip install .
diff --git a/README.md b/README.md
index 3e06716..f1a0edc 100644
--- a/README.md
+++ b/README.md
@@ -6,11 +6,11 @@
 ![PyPI - Downloads](https://img.shields.io/pypi/dw/KaruhaBot)
 ![Python Version](https://img.shields.io/badge/python-3.8%20|%203.9%20|%203.10%20|%203.11%20|%203.12-blue.svg)
 
-A simple Tinode chatbot framework.
+A simple Tinode chat bot framework
 
 **Language: English/[中文](README_cn.md)**
 
-The name of the library `Karuha` comes from the character Karuha Ramukone (カルハ・ラムコネ) in the game 星空鉄道とシロの旅.
+The name of the library `Karuha` comes from the character Karuha Ramkone (カルハ・ラムコネ) in the game Starry Sky Train and White's Journey.
 
 <div align="center">
 
@@ -22,11 +22,11 @@ The name of the library `Karuha` comes from the character Karuha Ramukone (カ
 
 ## Installation
 
-From pip:
+Install from Pypi:
 
     pip install KaruhaBot
 
-From source code:
+Install from Pypi:
 
     git clone https://github.com/Visecy/Karuha.git
     cd Karuha
@@ -34,16 +34,17 @@ From source code:
 
 ## Quick Start
 
-Before starting, you need to make sure you have the Tinode service running locally with its gRPC port set to the default value of 16060.
+Before you begin, you need to ensure that the Tinode service is running locally with the default gRPC port 16060.
 
-> If your service is not local or the gRPC port has been changed, you may need to modify or add additional server configuration items in the following code.
+> If your service is not local or the gRPC port has changed, you may need to modify or add server configuration items in the following code.
 
-Create a new file config.json and write the configuration in it:
+Create a new file `config.json` and write the following content as the configuration file:
 
 ```json
 {
     "server": {
-        "host": "localhost:16060"
+        "host": "localhost:16060",
+        "web_host": "http://localhost:6060"
     },
     "bots": [
         {
@@ -55,107 +56,146 @@ Create a new file config.json and write the configuration in it:
 }
 ```
 
-> Replace `{chatbot_login_name}` and `{chatebot_login_passwd}` with the chatbot’s login account name and password in the Tinode server.
+> Replace `{chatbot_login_name}` and `{chatebot_login_passwd}` with the login account name and password of the chatbot on the Tinode server.
 
 Use the following command to start the chatbot:
 
     python -m Karuha ./config.json
 
-Now you can view the messages others have sent to the chatbot from the command line.
+You can now view messages sent to the chatbot from others in the command line.
 
 ## User Interaction
 
-Of course, only receiving messages is not enough, we need some interaction with users. Karuha provides a powerful command system. With the command system, we can conveniently receive user-issued information and make appropriate responses.
+Receiving messages alone is certainly not enough; we need to interact with users. Karuha provides a powerful command system. With the command system, we can easily receive messages from users and respond accordingly.
 
 ### Simple Command Example
-Let's start with a very simple command. We want to implement a hi command, which replies Hello! when the chatbot receives this command.
 
-Create a new file hi.py and write the following:
+Let's start with the simplest command. We want to implement a `hi` command that replies `Hello!` when the bot receives this command.
+
+Create a new `hi.py` and write the following content in it:
 
 ```python
 from karuha import on_command, MessageSession
 
+
 @on_command
 async def hi(session: MessageSession) -> None:
     await session.send("Hello!")
 ```
 
-The above code involves some Python knowledge, which I will briefly introduce one by one. If you already understand these concepts, you can skip this part.
+The above code involves some Python knowledge, and I will briefly introduce each one. If you are already familiar with this knowledge, you can skip this part.
 
-The first line imports the `on_command` decorator and the `MessageSession` class from the karuha module. Decorators are objects that can be used to decorate functions or classes. Here, its usage is shown on line 4, decorating the function with `@on_command` before the function definition. The decorated function will be registered as a command and called when the corresponding message is received.
+In the first line of the code, we import the `on_command` decorator and the `MessageSession` class from the `karuha` module. A decorator is an object that can be used to modify functions or classes. Here, its usage is shown in the fourth line, where it decorates the function by using `@on_command` before the function definition. The decorated function will be registered as a command and will be called when the corresponding message is received.
 
-Next is the definition of the hi function. Here we use `async def` to define the command function. Unlike functions defined using `def`, functions defined with `async def` are asynchronous functions. Asynchrony is a complex topic. If you don't understand it, that's fine - we will only use some simple syntax here similar to normal functions.
+Next is the definition of the `hi` function. Here we use `async def` to define the command function. Unlike regular functions defined with `def`, functions defined with `async def` are asynchronous functions. Asynchronous programming is a relatively complex topic, and it's okay if you don't understand it; here we will only use some simple syntax similar to normal functions.
 
-You may be unfamiliar with line `(session: MessageSession) -> None`. This is a type annotation to indicate the parameter type and return value type of the function. Here we declare the type of session to be `MessageSession`, and the return type to be `None`, meaning no return value. Type annotations are optional in Python but recommended for Karuha commands to help parse message data.
+You might be a bit unfamiliar with the line `(session: MessageSession) -> None`. This is a type annotation that specifies the parameter type and return type of the function. Here we declare the type of `session` as `MessageSession`, and the return type as `None`, meaning there is no return value. In Python, type annotations are optional, but for commands in Karuha, they are used for parsing message data. While not mandatory, it is recommended to add type annotations when writing commands to help Karuha better understand your code.
 
-Then comes the function body, which is very short with only one line. `session` is a session object that encapsulates many APIs for receiving and sending messages. Here we use the `send` method to send a message. `send` is an asynchronous method, so we need to use await when calling it.
+Next is the content of the function, which is very short, only one line. `session` is a session object that encapsulates many APIs for receiving and sending messages. Here, we use the `send` method to send messages. `send` is an asynchronous method, so it needs to be preceded by `await` when called.
 
-After writing the command, we can run the chatbot to test it. Use the following command:
+After completing the command writing, we can run the robot to test it. Use the following command to run the robot:
 
 ```sh
 python -m karuha ./config.json -m hi
 ```
 
-Then in the conversation with the bot, enter the following:
+Then, in the conversation with the robot, enter the following content:
 
-     /hi
+    /hi
 
+> By default, karuha will only process text messages starting with `/` as commands. This behavior can be set before defining all commands using the `set_prefix` function.
 
-> By default, karuha will only process text messages starting with `/` as commands. This behavior can be set through the `set_prefix` function before defining all commands.
+If all goes well, you should see the robot reply with `Hello!`.
 
-If everything goes well, you should see the `Hello!` reply from the bot.
+### Example Extension
 
-### Getting User Input
+In the above example, we did not directly use the user's input. What should I do if I want to get the user's input?
 
-In the above example, we did not directly use the user's input. What if I want to get the user's input content?
+One method is to use the message log in `session`. The complete user input is contained in `session.last_message`. But this is not very elegant.
 
-One way is to use the message record in the `session`. `session.last_message` contains the complete user input. But this is not very elegant.
-
-A more convenient method is to directly modify the function signature, such as:
+A more convenient method is to directly modify the function signature, for example: 
 
 ```python
-async def hi(session: MessageSession, text: str) -> None:
+from typing import List
+
+@on_command
+async def hi(session: MessageSession, argv: List[str]) -> None:
     ...
 ```
 
-We add a text parameter of type str to represent the user's input content. Let's modify the contents of the hi function a bit to allow it to:
-
+We added an `argv` parameter, of type `List[str]`, which represents the user input. Let's slightly modify the content of the `hi` function: 
 
 ```python
 @on_command
-async def hi(session: MessageSession, text: str) -> None:
-    total = text.split(' ', 1)
-    if len(total) == 1:
+async def hi(session: MessageSession, argv: List[str]) -> None:
+    if nor argv:
         await session.send("Hello!")
         return
-    name = total[1]
+    name = argv[0]
     await session.send(f"Hello {name}!")
 ```
 
-The code above builds on the previous logic by adding the name to greet in the command's response. This involves some string processing operations that are not explained here for now.
+In the above code, we added the name to greet in the command's reply content based on the previous logic.
 
-Run the chatbot and send it:
+Let's run the bot and try sending it the following: 
 
     /hi world
 
-You should receive the chatbot's response of `Hello world!`.
+You will receive `Hello world!` returned by the bot.
 
 ## About More
-Of course, Karuha provides more APIs than just these. If you are interested in learning more, please refer to the source code of the library.
-
-### Development Goals
-Features that may be added in the future include:
 
-- [x] APIs related to user information getting and setting
-- [x] Match rule for command
-- [ ] Automatic argument parsing in argparse format for commands
+Of course, the API provided by Karuha is not limited to these; if you are interested in more content, you can refer to the library's source code.
+
+### Features and Support
+
+The functionalities that have been implemented so far are as follows: 
+
+- [X] Command system
+- [X] Rich text (Drafty) parsing
+- [X] Rule-based chat message processing
+- [X] User and topic information reading✨
+- [X] Proxy sending bot✨
+- [X] Tinode plugin server✨
+- [X] Client low-level API encapsulation
+  - [X] `hi` message
+  - [X] `acc` message
+  - [X] `login` message
+  - [X] `sub` message
+  - [X] `leave` message
+  - [X] `pub` message
+  - [X] `get` message
+  - [X] `set` message
+  - [X] `del` message
+  - [X] `note` message
+- [X] Server-side low-level API handling
+  - [X] `data` message
+  - [X] `ctrl` message
+  - [X] `meta` message
+  - [X] `pres` message
+  - [X] `info` message
+- [X] Large file upload and download ✨
+
+> Items marked with ✨ are experimental features, and their functionality may have issues that require further experimentation and feedback. The interfaces for these features may also undergo breaking changes in the future.
+
+Possible features to be added next include:
+
+- [ ] User and topic information modification
+- [ ] Audio attachment upload support
+- [ ] Video attachment upload support
+- [ ] The underlying API encapsulation based on http and websocket
+- [ ] Automatic parsing of command parameters in argparse format
+- [ ] Refactor the store module using sqlalchemy
 
 ### Module Development
-Currently, Karuha's support for module development is relatively simple. There are no dedicated APIs for defining chatbot modules, but predefined commands can still be supported.
 
-The way to define commands in external modules is similar to the normal definition. But to avoid affecting the user's related command settings, we need to create a new CommandCollection. The method to establish a command collection and define commands in it is as follows:
+Currently, karuha's support for module development is relatively simple. There is no dedicated API for defining chatbot modules, but it can support presetting some commands.
+
+The way to define commands in external modules is similar to the normal definition method. However, to avoid affecting the user's related command settings, we need to create a new command collection (CommandCollection). The method to create a command collection and define commands within it is as follows:
 
 ```python
+from typing import List
+
 from karuha import MessageSession
 from karuha.command import new_collection, add_sub_collection
 
@@ -165,38 +205,42 @@ add_sub_collection(collection)
 
 
 @collection.on_command
-async def hi(session: MessageSession, text: str) -> None:
+async def hi(session: MessageSession, argv: List[str]) -> None:
     ...
 ```
 
-> Note that to make the command collection take effect, the add_sub_collection function needs to be called to add the command collection to the sub-command collection.
+> Note: In order for the command collection to take effect, you need to call the `add_sub_collection` function to add the command collection to the sub-command collection.
+
+### Architecture Description
 
-### Architecture Overview
 The overall architecture of Karuha is as follows:
 
-| Layer | Provided Module | Function |
-| --- | --- | --- |
-| Upper layer | karuha.command | Command registration and processing |
-| Middle layer | karuha.event | Async event-driven system |
-| Lower layer | karuha.bot | Tinode API basic encapsulation |
+| Interface Level | Provided Module  | Function               |
+| -------- | -------------- | ------------------ |
+| Upper Level     | karuha.command    | Command Registration and Processing     |
+| Middle Level    | karuha.event      | Asynchronous Event-Driven System   |
+| Lower Level     | karuha.bot        | Basic Encapsulation of Tinode API |
+
+In addition, there are also some relatively independent modules:
+
+| Module                 | Function                                   |
+| -------------------- | -------------------------------------- |
+| karuha.text          | Text processing module                     |
+| karuha.config        | Configuration file parsing module         |
+| karuha.plugin_server | Plugin module used by the Tinode server, not enabled by default |
+
+### Message Processing
 
-In addition, there are some relatively independent modules:
+Karuha provides two complementary message processing systems, namely the asynchronous message event system (event) and the message dispatcher system (dispatcher).
 
-| Module | Function |
-| --- | --- |
-| karuha.text | Text processing module |
-| karuha.config | Configuration file parsing module |
-| karuha.plugin_server | Plugin module for Tinode server, not enabled by default |
+The asynchronous message event is used to receive and process messages in parallel. By registering message event handlers, relevant information about messages can be quickly collected. Different message handlers operate in parallel and do not interfere with each other.
 
-### Message Handling
-Karuha internally provides two complementary message handling systems: the asynchronous event message system (event) and the message dispatcher system (dispatcher).
+The message dispatcher is located downstream of the asynchronous message event system, used to determine the final message handler. This system is used to bridge the middle-layer event system and the upper-layer command system. If you wish to provide feedback to users based on message content and avoid interference from other message processing modules, you should use this system.
 
-The asynchronous event message is used to receive and parallelly process messages. By registering message event handlers, the related information of the message can be quickly collected. Message handlers are parallel and non-interfering with each other.
+## About the Documentation
 
-The message dispatcher is downstream of the asynchronous event message system. It is used to determine the final message handler. This system is used to handle the connection between the middle layer event system and the upper command system. If you want to provide feedback to users according to message content and avoid interference from other message processing modules, you should use this system.
+There are no plans for comprehensive documentation for this project. There will be no documentation for this project in the foreseeable future. If you wish to provide documentation for this project, I would be very grateful.
 
-## About Documentation
-This project does not have documentation plans. Documentation will not be provided for the foreseeable future. If you want to provide documentation for this project, I will greatly appreciate it.
+## About Contributions
 
-## About Contribution
-Welcome to post your questions and suggestions in issues. If you are interested in developing Tinode chatbots, welcome to contribute.
+You are welcome to raise your questions and suggestions in the issues. If you are interested in contributing to the development of the Tinode chatbot, you are welcome to participate.
diff --git a/README_cn.md b/README_cn.md
index b10ff36..5472f56 100644
--- a/README_cn.md
+++ b/README_cn.md
@@ -10,7 +10,7 @@
 
 **语言: [English](README.md)/中文**
 
-库的名称`Karuha`来自游戏星空列车与白的旅行中的角色 狩叶·朗姆柯妮（カルハ・ラムコネ）
+库的名称 `Karuha`来自游戏星空列车与白的旅行中的角色 狩叶·朗姆柯妮（カルハ・ラムコネ）
 
 <div align="center">
 
@@ -38,12 +38,13 @@
 
 > 如果你的服务不在本地或更改了gRPC端口，在接下来的代码中，你可能需要修改或额外添加服务器的配置项。
 
-创建一个新的文件`config.json`并写入以下内容作为配置文件：
+创建一个新的文件 `config.json`并写入以下内容作为配置文件：
 
 ```json
 {
     "server": {
-        "host": "localhost:16060"
+        "host": "localhost:16060",
+        "web_host": "http://localhost:6060"
     },
     "bots": [
         {
@@ -69,9 +70,9 @@
 
 ### 简单命令示例
 
-让我们从一个最简单的命令开始。我们希望实现一个`hi`命令，当机器人收到此命令时，回复`Hello!`。
+让我们从一个最简单的命令开始。我们希望实现一个 `hi`命令，当机器人收到此命令时，回复 `Hello!`。
 
-新建一个`hi.py`，并在其中写入以下内容：
+新建一个 `hi.py`，并在其中写入以下内容：
 
 ```python
 from karuha import on_command, MessageSession
@@ -84,13 +85,13 @@ async def hi(session: MessageSession) -> None:
 
 以上的代码涉及到一些Python知识，我将会逐个的简单介绍一下。如果你已经了解这些知识，可以跳过这一部分。
 
-代码的第一行，我们从`karuha`模块中导入了`on_command`装饰器和`MessageSession`类。装饰器是一种可以用来修饰函数或类的的对象。在这里，它的用法如第四行所示，在函数定义前通过`@on_command`来修饰函数。被修饰的函数会被注册为命令，将会在收到对应的消息时被调用。
+代码的第一行，我们从 `karuha`模块中导入了 `on_command`装饰器和 `MessageSession`类。装饰器是一种可以用来修饰函数或类的的对象。在这里，它的用法如第四行所示，在函数定义前通过 `@on_command`来修饰函数。被修饰的函数会被注册为命令，将会在收到对应的消息时被调用。
 
-接下来是`hi`函数的定义。这里我们使用`async def`来定义命令函数。与使用`def`定义的普通函数不同，`async def`定义的函数是异步函数。异步是一个比较复杂的话题，如果你不了解它也没有关系，这里我们只会使用一些简单的与正常函数类似的语法。
+接下来是 `hi`函数的定义。这里我们使用 `async def`来定义命令函数。与使用 `def`定义的普通函数不同，`async def`定义的函数是异步函数。异步是一个比较复杂的话题，如果你不了解它也没有关系，这里我们只会使用一些简单的与正常函数类似的语法。
 
-你可能对`(session: MessageSession) -> None`这一行有些陌生。这是一段类型注释，用于说明函数的参数类型和返回值类型。这里我们声明了`session`的类型为`MessageSession`，而返回值类型为`None`，也就是没有返回值。在Python中，类型注释是可选的，但对于Karuha中的命令来说，它们会被用于消息数据的解析。虽然不是必须的，但建议在编写命令时添加类型注释，以便于Karuha更好的理解你的代码。
+你可能对 `(session: MessageSession) -> None`这一行有些陌生。这是一段类型注释，用于说明函数的参数类型和返回值类型。这里我们声明了 `session`的类型为 `MessageSession`，而返回值类型为 `None`，也就是没有返回值。在Python中，类型注释是可选的，但对于Karuha中的命令来说，它们会被用于消息数据的解析。虽然不是必须的，但建议在编写命令时添加类型注释，以便于Karuha更好的理解你的代码。
 
-然后是函数的内容，这里非常短只有一行。`session`是一个会话对象，其中封装了很多接收与发送消息的API。在这里，我们使用`send`方法来发送消息。`send`是一个异步方法，因此在调用时需要在前面使用`await`。
+然后是函数的内容，这里非常短只有一行。`session`是一个会话对象，其中封装了很多接收与发送消息的API。在这里，我们使用 `send`方法来发送消息。`send`是一个异步方法，因此在调用时需要在前面使用 `await`。
 
 在完成编写命令后，我们可以来运行一下机器人来测试一下。使用如下的命令运行机器人：
 
@@ -102,16 +103,15 @@ python -m karuha ./config.json -m hi
 
     /hi
 
+> 在默认情况下，karuha只会将以 `/`为开头的文本消息作为命令处理，此行为可以在定义所以命令前通过 `set_prefix`函数来设置。
 
-> 在默认情况下，karuha只会将以`/`为开头的文本消息作为命令处理，此行为可以在定义所以命令前通过`set_prefix`函数来设置。
-
-如果一切顺利，你就应该能看到机器人回复的`Hello!`了。
+如果一切顺利，你就应该能看到机器人回复的 `Hello!`了。
 
 ### 示例扩展
 
 在上面的示例中，我们并没有直接用到用户的输入内容。如果我希望获取用户的输入内容应该怎么办呢？
 
-一个方法是使用`session`中的消息记录。`session.last_message`中就包含了完整的用户输入内容。但这样并不够雅观。
+一个方法是使用 `session`中的消息记录。`session.last_message`中就包含了完整的用户输入内容。但这样并不够雅观。
 
 更加方便的方法是直接修改函数签名，比如：
 
@@ -123,7 +123,7 @@ async def hi(session: MessageSession, argv: List[str]) -> None:
     ...
 ```
 
-我们增加了一个`argv`参数，类型为`List[str]`，表示用户输入的内容。让我们稍微修改一下`hi`函数的内容：
+我们增加了一个 `argv`参数，类型为 `List[str]`，表示用户输入的内容。让我们稍微修改一下 `hi`函数的内容：
 
 ```python
 @on_command
@@ -141,20 +141,51 @@ async def hi(session: MessageSession, argv: List[str]) -> None:
 
     /hi world
 
-你就会收到机器人返回的`Hello world!`了。
+你就会收到机器人返回的 `Hello world!`了。
 
 ## 关于更多
 
 当然Karuha提供的API并不止这些，如果你想对更多内容感兴趣，可以参考库的源码。
 
-### 开发目标
+### 特性与支持
+
+目前已经实现的功能如下：
+
+- [X] 命令系统
+- [X] 富文本（Drafty）解析
+- [X] 基于规则匹配的聊天消息处理
+- [X] 用户及话题信息读取✨
+- [X] 代理发送机器人✨
+- [X] Tinode插件服务器✨
+- [X] 客户端低层级API封装
+  - [X] `hi`消息
+  - [X] `acc`消息
+  - [X] `login`消息
+  - [X] `sub`消息
+  - [X] `leave`消息
+  - [X] `pub`消息
+  - [X] `get`消息
+  - [X] `set`消息
+  - [X] `del`消息
+  - [X] `note`消息
+- [X] 服务端低层级API处理
+  - [X] `data`消息
+  - [X] `ctrl`消息
+  - [X] `meta`消息
+  - [X] `pres`消息
+  - [X] `info`消息
+- [X] 大文件上传下载✨
+
+> 带有✨标记的项是实验性的功能，其功能可能会存在问题，需要进一步的实验和反馈，这些功能的接口也可能会在未来发生破坏性更改。
 
 在接下来可能会添加的功能包括：
 
-- [x] 用户信息获取与设置相关的API
-- [x] 消息匹配规则
+- [ ] 用户及话题信息修改
+- [ ] 音频附件上传支持
+- [ ] 视频附件上传支持
+- [ ] 基于http及websocket的底层API封装
 - [ ] argparse格式的命令参数自动解析
-- [ ] 代理发送机器人
+- [ ] 使用sqlalchemy重构store模块
 
 ### 模块开发
 
@@ -178,24 +209,24 @@ async def hi(session: MessageSession, argv: List[str]) -> None:
     ...
 ```
 
-> 注意，为了使命令集合生效，需要调用`add_sub_collection`函数将命令集合添加到子命令集合中。
+> 注意，为了使命令集合生效，需要调用 `add_sub_collection`函数将命令集合添加到子命令集合中。
 
 ### 架构说明
 
 Karuha的总体架构如下：
 
-| 接口层次 | 提供模块 | 功能 |
-| --- | --- | --- |
-| 上层 | karuha.command | 命令注册与处理 |
-| 中层 | karuha.event | 异步事件驱动系统 |
-| 底层 | karuha.bot | Tinode API基础封装 |
+| 接口层次 | 提供模块       | 功能               |
+| -------- | -------------- | ------------------ |
+| 上层     | karuha.command | 命令注册与处理     |
+| 中层     | karuha.event   | 异步事件驱动系统   |
+| 底层     | karuha.bot     | Tinode API基础封装 |
 
 除此之外，也有一些较为独立的模块：
 
-| 模块 | 功能 |
-| --- | --- |
-| karuha.text | 文本处理模块 |
-| karuha.config | 配置文件解析模块 |
+| 模块                 | 功能                                   |
+| -------------------- | -------------------------------------- |
+| karuha.text          | 文本处理模块                           |
+| karuha.config        | 配置文件解析模块                       |
 | karuha.plugin_server | Tinode服务器使用的插件模块，默认不启用 |
 
 ### 消息处理
diff --git a/examples/active_send.py b/examples/active_send.py
index a4f41c7..ed11dee 100644
--- a/examples/active_send.py
+++ b/examples/active_send.py
@@ -1,6 +1,5 @@
 import asyncio
 import sys
-from queue import Queue
 
 from prompt_toolkit import prompt
 from prompt_toolkit.patch_stdout import patch_stdout
@@ -14,32 +13,37 @@
 TOPIC = "grp_test"
 
 
-def read(queue: Queue) -> None:
+def read(queue: asyncio.Queue) -> None:
+    loop = karuha.runner._get_running_loop()
     with patch_stdout():
         old_stream = console_handler.stream
         console_handler.setStream(sys.stdout)
         while True:
             try:
                 text = prompt("> ")
-            except (IOError, KeyboardInterrupt):
+            except KeyboardInterrupt:
                 break
-            print(f"< {text}")
-            queue.put(text)
+            loop.call_soon_threadsafe(queue.put_nowait, text)
         console_handler.setStream(old_stream)
 
 
 @karuha.on(BotReadyEvent)
 async def handle(bot: karuha.Bot) -> None:
-    queue = Queue()
+    queue = asyncio.Queue()
     loop = asyncio.get_running_loop()
-    loop.run_in_executor(None, read, queue)
+    future = loop.run_in_executor(None, read, queue)
+    future.add_done_callback(lambda _: bot.cancel())
 
     async with BaseSession(bot, TOPIC) as ss:
         while True:
-            text = await loop.run_in_executor(None, queue.get)
+            text = await queue.get()
             await ss.send(text)
 
 
 if __name__ == "__main__":
+    if len(sys.argv) < 2:
+        print(f"Usage: python {sys.argv[0]} <topic>")
+        sys.exit(1)
+    TOPIC = sys.argv[1]
     karuha.load_config()
     karuha.run()
diff --git a/examples/exec.py b/examples/exec.py
index d6ed292..e252013 100644
--- a/examples/exec.py
+++ b/examples/exec.py
@@ -24,7 +24,7 @@
 
 @on_command("eval")
 async def eval_(session: MessageSession, name: str, user_id: str, text: str) -> None:
-    user = await session.get_user(user_id, ensure_user=True)
+    user = await session.get_user(user_id, skip_cache=True)
     if not user.staff:
         await session.finish("Permission denied")
     text = text[text.index(name) + len(name):]
diff --git a/karuha/__init__.py b/karuha/__init__.py
index 5f386fc..9fdfdcd 100644
--- a/karuha/__init__.py
+++ b/karuha/__init__.py
@@ -3,9 +3,11 @@
 """
 import os
 from pathlib import Path
+from typing import Final
 
 
-WORKDIR = Path(os.environ.get("KARUHA_HOME", ".bot"))  # dir to storage bot data
+WORKDIR: Final[Path] = Path(os.environ.get("KARUHA_HOME", ".bot"))  # dir to storage bot data
+CONFIG_PATH: Final[Path] = Path(os.environ.get("KARUHA_CONFIG", "config.json"))
 
 
 from .version import __version__
diff --git a/karuha/__main__.py b/karuha/__main__.py
index 27f1619..7fc5904 100644
--- a/karuha/__main__.py
+++ b/karuha/__main__.py
@@ -20,7 +20,7 @@
 from importlib import import_module
 from pathlib import Path
 
-from . import load_config, run
+from . import load_config, run, CONFIG_PATH
 from .version import APP_VERSION, LIB_VERSION
 
 
@@ -34,7 +34,7 @@
     f"gRPC-python/{LIB_VERSION}"
 ))
 
-default_config = os.environ.get("KARUHA_CONFIG", "config.json")
+default_config = CONFIG_PATH
 default_modules = os.environ.get("KARUHA_MODULES", "").split(os.pathsep)
 
 parser = ArgumentParser("Karuha", description=description)
diff --git a/karuha/bot.py b/karuha/bot.py
index db66027..01b3a02 100644
--- a/karuha/bot.py
+++ b/karuha/bot.py
@@ -1,6 +1,5 @@
 import asyncio
 import base64
-from io import IOBase, TextIOBase
 import os
 import platform
 import sys
@@ -9,13 +8,16 @@
 from contextlib import asynccontextmanager, contextmanager
 from datetime import datetime, timezone
 from enum import IntEnum
-from typing import (Any, AsyncGenerator, Callable, Coroutine, Dict, Generator,
-                    Iterable, List, Literal, Optional, Tuple, Union, overload)
+from io import IOBase
+from typing import (Any, AsyncGenerator, BinaryIO, Callable, Coroutine, Dict,
+                    Generator, Iterable, List, Literal, Optional, Tuple, Union,
+                    overload)
 from weakref import WeakSet, ref
 
 import grpc
 from aiofiles import open as aio_open
-from aiohttp import ClientError, ClientSession, FormData
+from aiofiles.threadpool.binary import AsyncBufferedIOBase
+from aiohttp import ClientError, ClientSession, ClientTimeout, FormData
 from google.protobuf.message import Message
 from grpc import aio as grpc_aio
 from pydantic import GetCoreSchemaHandler, TypeAdapter
@@ -28,6 +30,7 @@
 from .config import Server as ServerConfig
 from .config import get_config, init_config
 from .logger import Level, get_sub_logger
+from .utils.context import nullcontext
 from .utils.decode import decode_mapping, encode_mapping
 from .version import APP_VERSION, LIB_VERSION
 
@@ -117,7 +120,7 @@ def __init__(
     ) -> None:
         if isinstance(name, BotConfig):
             self.config = name
-        elif schema is None or secret is None:
+        elif schema is None or secret is None:  # pragma: no cover
             raise ValueError("authentication scheme not defined")
         else:
             self.config = BotConfig(name=name, schema=schema, secret=secret)
@@ -178,7 +181,7 @@ async def account(
             scheme: Optional[str] = None,
             secret: Optional[bytes] = None,
             *,
-            state: str = "ok",
+            state: Optional[str] = None,
             do_login: bool = True,
             desc: Optional[pb.SetDesc] = None,
             tags: Iterable[str] = (),
@@ -362,7 +365,14 @@ async def subscribe(
             self.logger.info(f"subscribe topic {topic}")
         return tid, decode_mapping(ctrl.params)
 
-    async def leave(self, /, topic: str, *, extra: Optional[pb.ClientExtra] = None) -> Tuple[str, Dict[str, Any]]:
+    async def leave(
+        self,
+        /,
+        topic: str,
+        *,
+        unsub: bool = False,
+        extra: Optional[pb.ClientExtra] = None,
+    ) -> Tuple[str, Dict[str, Any]]:
         """
         This is a counterpart to {sub} message. It also serves two functions:
 
@@ -384,7 +394,8 @@ async def leave(self, /, topic: str, *, extra: Optional[pb.ClientExtra] = None)
             tid,
             leave=pb.ClientLeave(
                 id=tid,
-                topic=topic
+                topic=topic,
+                unsub=unsub,
             ),
             extra=extra
         )
@@ -392,11 +403,6 @@ async def leave(self, /, topic: str, *, extra: Optional[pb.ClientExtra] = None)
         if ctrl.code < 200 or ctrl.code >= 400:
             err_text = f"fail to leave topic {topic}: {ctrl.text}"
             self.logger.error(err_text)
-            if topic == "me":  # pragma: no cover
-                if ctrl.code == 502:
-                    self.restart()
-                else:
-                    self.cancel()
             raise KaruhaBotError(err_text, bot=self, code=ctrl.code)
         else:
             self.logger.info(f"leave topic {topic}")
@@ -441,7 +447,7 @@ async def publish(
             extra=extra
         )
         assert isinstance(ctrl, pb.ServerCtrl)
-        if ctrl.code < 200 or ctrl.code >= 400:
+        if ctrl.code < 200 or ctrl.code >= 400:  # pragma: no cover
             err_text = f"fail to publish message to {topic}: {ctrl.text}"
             self.logger.error(err_text)
             raise KaruhaBotError(err_text, bot=self, code=ctrl.code)
@@ -575,7 +581,7 @@ async def get(
         :return: tid and meta
         :rtype: Tuple[str, Optional[pb.ServerMeta]]
         """
-    
+
     @overload
     async def get(
             self,
@@ -633,7 +639,7 @@ async def get(
             elif data is not None:
                 assert desc is None and sub is None
                 what = "data"
-            else:
+            else:  # pragma: no cover
                 raise ValueError("what must be specified")
         meta = await self.send_message(
             tid,
@@ -997,7 +1003,7 @@ async def note_read(self, /, topic: str, seq: int) -> None:
 
     async def upload(
             self,
-            path: Union[str, os.PathLike, IOBase]
+            path: Union[str, os.PathLike, BinaryIO]
     ) -> Tuple[str, Dict[str, Any]]:
         """
         upload a file
@@ -1017,13 +1023,14 @@ async def upload(
                     url = "/v0/file/u/"
                     data = FormData()
                     data.add_field("id", tid)
-                    if isinstance(path, IOBase):
-                        f = path
+                    if isinstance(path, (BinaryIO, IOBase)):
+                        path.seek(0)
+                        cm = nullcontext(path)
                         _path = None
                     else:
-                        f = open(path, "rb")
+                        cm = open(path, "rb")
                         _path = os.path.basename(path)
-                    with f:
+                    with cm as f:
                         data.add_field("file", f, filename=_path)
                         async with session.post(url, data=data) as resp:
                             ret = await resp.text()
@@ -1053,7 +1060,7 @@ async def upload(
     async def download(
             self,
             url: str,
-            path: Union[str, os.PathLike, IOBase]
+            path: Union[str, os.PathLike, BinaryIO]
     ) -> None:
         """
         download a file
@@ -1065,26 +1072,23 @@ async def download(
         :raises KaruhaBotError: fail to download file
         """
         tid = self._get_tid()
+        if isinstance(path, (BinaryIO, IOBase)):
+            path.seek(0)
+            cm = nullcontext(path)
+        else:
+            cm = aio_open(path, "wb")
         try:
-            async with await self._get_http_session() as session:
+            async with await self._get_http_session() as session, cm as f:
                 self.logger.debug(f"download request: {tid=} {path=} {session.headers=}")
                 size = 0
-                if isinstance(path, IOBase):
-                    async with session.get(url, params={"id": tid}) as resp:
-                        resp.raise_for_status()
-                        async for chunk in resp.content.iter_any():
-                            if isinstance(path, TextIOBase):
-                                path.write(chunk.decode())
-                            else:
-                                path.write(chunk)
-                            size += len(chunk)
-                else:
-                    async with aio_open(path, "wb") as f:
-                        async with session.get(url, params={"id": tid}) as resp:
-                            resp.raise_for_status()
-                            async for chunk in resp.content.iter_any():
-                                await f.write(chunk)
-                        size = await f.tell()
+                async with session.get(url, params={"id": tid}) as resp:
+                    resp.raise_for_status()
+                    async for chunk in resp.content.iter_any():
+                        size += len(chunk)
+                        if isinstance(f, AsyncBufferedIOBase):
+                            await f.write(chunk)
+                        else:
+                            f.write(chunk)
             self.logger.debug(f"download length: {size}")
         except OSError as e:
             raise KaruhaBotError(f"fail to write file {path}", bot=self) from e
@@ -1268,7 +1272,7 @@ async def _get_http_session(self) -> ClientSession:
             "X-Tinode-Auth": f"{schema.title()} {secret}",
             "User-Agent": f"KaruhaBot {APP_VERSION}/{LIB_VERSION}",
         }
-        return ClientSession(web_host, headers=headers)
+        return ClientSession(str(web_host), headers=headers, timeout=ClientTimeout(self.server.timeout))
 
     @contextmanager
     def _wait_reply(self, tid: Optional[str] = None) -> Generator[asyncio.Future, None, None]:
@@ -1293,6 +1297,38 @@ def _create_task(self, coro: Coroutine, /) -> asyncio.Task:
         self._tasks.add(task)
         return task
 
+    async def _prepare_account(self) -> None:
+        try:
+            await self.hello()
+        except Exception:
+            self.logger.error("failed to connect to server, restarting")
+            self.restart()
+            return
+
+        if not self.config.auto_login:
+            self.logger.info("auto login is disabled, skipping")
+            return
+
+        retry = self.server.retry if self.server is not None else 0
+        for i in range(retry):
+            try:
+                await self.login()
+            except (asyncio.TimeoutError, KaruhaBotError):
+                self.logger.warning(f"login failed, retrying {i+1} times")
+            else:
+                break
+        else:
+            try:
+                await self.login()
+            except (asyncio.TimeoutError, KaruhaBotError):
+                self.logger.error("login failed, cancel the bot")
+                self.cancel()
+
+        await self.subscribe(
+            "me",
+            get="sub desc tags cred"
+        )
+
     def _prepare_loop_task(self) -> None:
         if self.state == BotState.running:
             raise KaruhaBotError(f"rerun bot {self.name}", bot=self)
@@ -1325,10 +1361,7 @@ async def _run_context(self, server_config: ServerConfig, /) -> AsyncGenerator[g
             self.logger.error(f"disconnected from {server_config.host}, retrying...", exc_info=sys.exc_info())
             await asyncio.sleep(0.5)
         except asyncio.CancelledError:
-            if self.state == BotState.restarting:
-                # uncancel from Bot.restart()
-                self.state = BotState.running
-            elif self.state == BotState.running:
+            if self.state == BotState.running:
                 self.cancel(cancel_loop=False)
                 raise
         except:  # noqa: E722
@@ -1342,7 +1375,13 @@ async def _run_context(self, server_config: ServerConfig, /) -> AsyncGenerator[g
                 self.logger.exception("error while finalizing event callback", exc_info=True)
             except asyncio.CancelledError:
                 pass
-            self.state = BotState.stopped
+
+            if self.state == BotState.restarting:
+                # uncancel from Bot.restart()
+                self.state = BotState.running
+            elif self.state == BotState.cancelling:
+                # shutdown from Bot.cancel()
+                self.state = BotState.stopped
             self.server = old_server_config
 
             # clean up for restarting
@@ -1383,9 +1422,9 @@ def __repr__(self) -> str:
         return f"<bot {self.name} ({uid}) {state} on host {host}>"
 
 
-class AgentBot(Bot):
+class ProxyBot(Bot):
     """
-    the bot that runs on the `extra.on_behalf_of` agent
+    the bot that runs on the `extra.on_behalf_of` proxy
     """
     __slots__ = ["on_behalf_of", "login_user_id"]
 
@@ -1397,7 +1436,7 @@ def __init__(self, *args: Any, on_behalf_of: str, **kwds: Any) -> None:
     def from_bot(cls, bot: Bot, /, on_behalf_of: str, name: Optional[str] = None) -> Self:
         config = bot.config.model_copy()
         if name is None:
-            config.name = f"{config.name}_agent_{on_behalf_of}"
+            config.name = f"{config.name}_proxy_{on_behalf_of}"
         else:
             config.name = name
         return cls(config, bot.server, bot.logger.level, on_behalf_of=on_behalf_of)
diff --git a/karuha/config.py b/karuha/config.py
index affb206..39402d4 100644
--- a/karuha/config.py
+++ b/karuha/config.py
@@ -1,19 +1,19 @@
 from pathlib import Path
 from typing import Iterable, Literal, Optional, Tuple, Union
-from pydantic import AnyUrl, BaseModel, Field, HttpUrl, PrivateAttr, ValidationError, field_validator
-from typing_extensions import Annotated
+from pydantic import BaseModel, Field, HttpUrl, PrivateAttr, ValidationError, field_validator
 
+from . import CONFIG_PATH
 from .logger import logger, Level
 
 
 class Server(BaseModel):
-    host: Annotated[str, AnyUrl] = "localhost:16060"
-    web_host: Annotated[str, HttpUrl] = "http://localhost:6060"
+    host: str = "localhost:16060"
+    web_host: HttpUrl = HttpUrl("http://localhost:6060")
     api_key: str = "AQEAAAABAAD_rAp4DJh05a1HAwFT3A6K"
     ssl: bool = False
     ssl_host: Optional[str] = None
     enable_plugin: bool = False
-    listen: Annotated[str, AnyUrl] = "0.0.0.0:40051"
+    listen: str = "0.0.0.0:40051"
     timeout: float = 5
     retry: int = 5
 
@@ -66,7 +66,7 @@ def get_config() -> Config:
 
 
 def load_config(
-    path: Union[str, Path] = "config.json",
+    path: Union[str, Path] = CONFIG_PATH,
     *,
     encoding: str = "utf-8",
     auto_create: bool = True
diff --git a/karuha/data/cache.py b/karuha/data/cache.py
index 106d7df..91a0bfa 100644
--- a/karuha/data/cache.py
+++ b/karuha/data/cache.py
@@ -1,24 +1,29 @@
 from typing import (FrozenSet, Iterable, List, Optional, Tuple, TypeVar, Union,
                     overload)
+from typing_extensions import deprecated
 
 from pydantic import BaseModel
 from tinode_grpc import pb
 
 from ..bot import Bot
 from ..event import on
-from ..text import Message
-from ..logger import logger
 from ..event.bot import MetaEvent
-from ..event.sys import SystemStartEvent
 from ..event.message import MessageEvent
-from ..store import (DataModel, LruStore, MemoryStore, MessageBoundDataModel, PrimaryKey, TopicBoundDataModel,
-                     UserBoundDataModel)
-from .meta import (BaseDesc, BaseSubscription, CommonDesc, Cred,
+from ..event.sys import SystemStartEvent
+from ..logger import logger
+from ..store import (DataModel, LruStore, MemoryStore, MessageBoundDataModel,
+                     PrimaryKey, TopicBoundDataModel, UserBoundDataModel)
+from ..text import Message
+from .meta import (BaseDesc, BaseSubscription, CommonDesc,
                    GroupTopicDesc, P2PTopicDesc, Subscription, TopicInfo,
-                   UserCred, UserDesc, UserTags)
-from .sub import ensure_sub
+                   UserDesc)
+from .model import Cred
+from .sub import ensure_sub, has_sub
+
 
 T = TypeVar("T", bound=BaseModel)
+UserTags = List[str]
+UserCreds = List[Cred]
 
 
 @overload
@@ -41,7 +46,7 @@ def _update_model(raw: Optional[T], model: Optional[T]) -> Optional[T]:  # noqa:
 class UserCache(UserBoundDataModel):
     desc: Optional[BaseDesc] = None
     tags: Optional[UserTags] = None
-    cred: Optional[UserCred] = None
+    cred: Optional[UserCreds] = None
 
 
 class GroupTopicCache(TopicBoundDataModel):
@@ -73,7 +78,7 @@ def update_user_cache(
     user_id: str,
     desc: Optional[BaseDesc] = None,
     tags: Optional[UserTags] = None,
-    cred: Optional[UserCred] = None,
+    cred: Optional[UserCreds] = None,
 ) -> None:
     logger.debug(f"Updating user cache for {user_id}")
     if user_id not in user_cache:
@@ -132,16 +137,11 @@ def try_get_user_desc(bot: Bot, /, user_id: str) -> Optional[BaseDesc]:
     return cache and cache.desc
 
 
-async def get_user_desc(bot: Bot, /, user_id: str, *, ensure_meta: bool = False) -> BaseDesc:
+async def get_user_desc(bot: Bot, /, user_id: str, *, skip_cache: bool = False) -> BaseDesc:
     assert user_id.startswith("usr") or user_id == "me", "user_id must be a user"
-    if user_id == "me":
-        user = user_cache.get(bot.uid)
-    else:
-        assert not ensure_meta, "only 'me' can be used with ensure_meta=True"
-        user = user_cache.get(user_id)
-    if user is not None and user.desc is not None and (not ensure_meta or isinstance(user.desc, UserDesc)):
+    user = user_cache.get(bot.uid if user_id == "me" else user_id)
+    if user is not None and user.desc is not None and not skip_cache:
         return user.desc
-    await ensure_sub(bot, user_id)
     _, user = await bot.get(user_id, "desc")
     assert user is not None
     return UserDesc.from_meta(user.desc)
@@ -156,7 +156,7 @@ def try_get_group_desc(bot: Bot, /, topic_id: str) -> Optional[BaseDesc]:
 
 
 async def get_group_desc(
-    bot: Bot, /, topic_id: str, *, ensure_meta: bool = False
+    bot: Bot, /, topic_id: str, *, skip_cache: bool = False
 ) -> BaseDesc:
     assert topic_id.startswith("grp"), "topic_id must be a group topic"
     topic = group_cache.get(topic_id)
@@ -164,10 +164,9 @@ async def get_group_desc(
         topic is not None
         and topic.desc is not None
         and isinstance(topic.desc, BaseDesc)
-        and (not ensure_meta or isinstance(topic.desc, GroupTopicDesc))
+        and not skip_cache
     ):
         return topic.desc
-    await ensure_sub(bot, topic_id)
     _, topic = await bot.get(topic_id, "desc")
     assert topic is not None
     return GroupTopicDesc.from_meta(topic.desc)
@@ -185,30 +184,29 @@ def try_get_p2p_desc(bot: Bot, /, topic_id: str) -> Optional[TopicInfo]:
     return topic and topic.desc
 
 
-async def _get_p2p_topic(bot: Bot, /, topic_id: str) -> TopicInfo:
+async def _get_p2p_topic(bot: Bot, /, topic_id: str, *, skip_cache: bool = False) -> TopicInfo:
     topic = group_cache.get(topic_id)
     if (
         topic is not None
         and topic.desc is not None
         and isinstance(topic.desc, TopicInfo)
+        and not skip_cache
     ):
         return topic.desc
-    await ensure_sub(bot, topic_id)
     _, topic = await bot.get(topic_id, "desc")
     assert topic is not None
     return P2PTopicDesc.from_meta(topic.desc)
 
 
-async def get_p2p_desc(bot: Bot, /, user_id: str) -> TopicInfo:
+async def get_p2p_desc(bot: Bot, /, user_id: str, *, skip_cache: bool = False) -> TopicInfo:
     if user_id.startswith("p2p"):
-        desc = await _get_p2p_topic(bot, user_id)
+        desc = await _get_p2p_topic(bot, user_id, skip_cache=skip_cache)
         return desc
     assert user_id.startswith("usr"), "user_id must be a user"
     user_pair = frozenset((bot.uid, user_id))
     topic = p2p_cache.get(user_pair)
-    if topic is not None:
+    if topic is not None and not skip_cache:
         return topic.desc
-    await ensure_sub(bot, user_id)
     _, topic = await bot.get(user_id, "desc")
     assert topic is not None
     return P2PTopicDesc.from_meta(topic.desc)
@@ -219,46 +217,62 @@ def try_get_sub(bot: Bot, /, topic_id: str) -> Optional[BaseSubscription]:
     return sub and sub.sub
 
 
-async def get_sub(bot: Bot, /, topic_id: str, *, ensure_meta: bool = False) -> BaseSubscription:
-    assert topic_id != "me", "topic_id must not be 'me'"
+async def get_sub(
+    bot: Bot,
+    /,
+    topic_id: str,
+    *,
+    skip_cache: bool = False,
+    skip_sub_check: bool = False,
+) -> Optional[BaseSubscription]:
+    if topic_id == "me":
+        return
     sub = subscription_cache.get((topic_id, bot.uid))
-    if sub is not None and (not ensure_meta or isinstance(sub.sub, Subscription)):
+    if sub is not None and not skip_cache:
         return sub.sub
-    await ensure_sub(bot, topic_id)
+    if not skip_sub_check and not has_sub(bot, topic_id):
+        return
     _, sub_meta = await bot.get("me", "sub")
-    assert sub_meta is not None
+    if sub_meta is None:
+        return
     for i in sub_meta.sub:
-        if i.user_id == bot.uid:
+        if not i.user_id or i.user_id == bot.uid:
             return Subscription.from_meta(i)
-    raise ValueError(f"bot not subscribed to topic {topic_id}")
+    raise ValueError(f"cannot find sub for {topic_id}")
 
 
+def try_get_topic_sub(bot: Bot, /, topic_id: str) -> List[Tuple[str, BaseSubscription]]:
+    assert topic_id != "me", "topic_id must not be 'me'"
+    return [
+        (c.user_id, c.sub) for c in subscription_cache.values() if c.topic == topic_id
+    ]
+
+
+@deprecated("use `get_user_list` instead")
 async def get_topic_sub(
-    bot: Bot, /, topic_id: str, *, ensure_meta: bool = False, ensure_all: bool = True
+    bot: Bot, /, topic_id: str, *, ensure_all: bool = True
 ) -> List[Tuple[str, BaseSubscription]]:
     assert topic_id != "me", "topic_id must not be 'me'"
-    sub = [(c.user_id, c.sub) for c in subscription_cache.values() if c.topic == topic_id]
-    if (
-        sub
-        and not ensure_all
-        and (not ensure_meta or all(isinstance(s[1], Subscription) for s in sub))
-    ):
+    sub = [
+        (c.user_id, c.sub) for c in subscription_cache.values() if c.topic == topic_id
+    ]
+    if sub and not ensure_all:
         return sub
-    await ensure_sub(bot, topic_id)
     _, sub_meta = await bot.get(topic_id, "sub")
     assert sub_meta is not None
     return [(s.user_id, Subscription.from_meta(s)) for s in sub_meta.sub]
 
 
+def try_get_my_sub(bot: Bot, /) -> List[Tuple[str, BaseSubscription]]:
+    return [(c.topic, c.sub) for c in subscription_cache.values() if c.user_id == bot.uid]
+
+
+@deprecated("Use `get_topic_list` instead")
 async def get_my_sub(
-    bot: Bot, /, *, ensure_meta: bool = False, ensure_all: bool = True
+    bot: Bot, /, *, ensure_all: bool = True
 ) -> List[Tuple[str, BaseSubscription]]:
     sub = [(c.topic, c.sub) for c in subscription_cache.values() if c.user_id == bot.uid]
-    if (
-        sub
-        and not ensure_all
-        and (not ensure_meta or all(isinstance(s[1], Subscription) for s in sub))
-    ):
+    if sub and not ensure_all:
         return sub
     await ensure_sub(bot, "me")
     _, sub_meta = await bot.get("me", "sub")
@@ -294,10 +308,11 @@ def cache_user(user_id: str, desc: pb.TopicDesc) -> None:
 def cache_p2p_topic(user_id: str, topic_id: str, desc: pb.TopicDesc) -> None:
     topic = P2PTopicDesc.from_meta(desc)
     user = CommonDesc.from_meta(desc)
-    sub = BaseSubscription.from_meta(desc)
     update_p2p_cache(user_id, topic_id, desc=topic)
     update_user_cache(topic_id, desc=user)
-    update_sub_cache(topic_id, user_id, sub)
+    if desc.acs.ListFields():
+        sub = BaseSubscription.from_meta(desc)
+        update_sub_cache(topic_id, user_id, sub)
 
 
 def cache_group_topic(user_id: str, topic_id: str, desc: pb.TopicDesc) -> None:
@@ -322,12 +337,15 @@ def cache_me_subscription(user_id: str, sub_meta: pb.TopicSub) -> None:
         update_group_cache(topic, desc=desc)
 
 
-def cache_topic_subscription(topic: str, sub_meta: pb.TopicSub) -> None:
+def cache_topic_subscription(topic: str, sub_meta: pb.TopicSub, bot_user_id: str) -> None:
     user_id = sub_meta.user_id
-    assert user_id
     sub = Subscription.from_meta(sub_meta)
-    desc = BaseDesc.from_meta(sub_meta)
+    if not user_id:
+        # user_id is empty, use user_id from bot
+        update_sub_cache(topic, bot_user_id, sub)
+        return
     update_sub_cache(topic, user_id, sub)
+    desc = BaseDesc.from_meta(sub_meta)
     update_user_cache(user_id, desc=desc)
 
 
@@ -341,28 +359,28 @@ def cache_tags(topic: str, tags: Iterable[str]) -> None:
 @on(MetaEvent)
 def handle_meta(event: MetaEvent) -> None:
     meta = event.server_message
-    user = event.bot.uid
+    bot_user_id = event.bot.user_id
     topic = meta.topic
     if meta.HasField("desc"):
         if topic == "me":
-            cache_user(user, meta.desc)
+            cache_user(bot_user_id, meta.desc)
         elif topic.startswith("usr"):
-            cache_p2p_topic(user, topic, meta.desc)
+            cache_p2p_topic(bot_user_id, topic, meta.desc)
         else:
-            cache_group_topic(user, topic, meta.desc)
+            cache_group_topic(bot_user_id, topic, meta.desc)
     if meta.sub:
         if topic == "me":
             for sub_meta in meta.sub:
-                cache_me_subscription(user, sub_meta)
+                cache_me_subscription(bot_user_id, sub_meta)
         else:
             for sub_meta in meta.sub:
-                cache_topic_subscription(topic, sub_meta)
+                cache_topic_subscription(topic, sub_meta, bot_user_id)
     if meta.tags:
-        cache_tags(user if topic == "me" else topic, meta.tags)
+        cache_tags(bot_user_id if topic == "me" else topic, meta.tags)
     if meta.cred:
         assert topic == "me" or topic.startswith("usr")
         update_user_cache(
-            user if topic == "me" else topic,
+            bot_user_id if topic == "me" else topic,
             cred=[
                 {"method": c.method, "value": c.value, "done": c.done}
                 for c in meta.cred
diff --git a/karuha/data/meta.py b/karuha/data/meta.py
index 04f6700..5bd06b6 100644
--- a/karuha/data/meta.py
+++ b/karuha/data/meta.py
@@ -1,111 +1,12 @@
 from datetime import datetime
-from typing import Any, Dict, List, Optional, Union
+from typing import Any, Dict, Optional, Union
 from typing_extensions import Self
 
-from pydantic import BaseModel, Json, model_serializer, model_validator
+from pydantic import BaseModel, Json
 from tinode_grpc import pb
 
 from ..utils.decode import msg2dict
-
-
-class AccessPermission(BaseModel):
-    """
-    User's access to a topic is defined by two sets of permissions:
-    user's desired permissions "want", and permissions granted to user by topic's manager(s) "given".
-    Each permission is represented by a bit in a bitmap.
-    It can be either present or absent.
-    The actual access is determined as a bitwise AND of wanted and given permissions.
-    The permissions are communicated in messages as a set of ASCII characters,
-    where presence of a character means a set permission bit:
-
-    - No access: N is not a permission per se but an indicator that permissions are explicitly cleared/not set.
-        It usually indicates that the default permissions should not be applied.
-    - Join: J, permission to subscribe to a topic
-    - Read: R, permission to receive {data} packets
-    - Write: W, permission to {pub} to topic
-    - Presence: P, permission to receive presence updates {pres}
-    - Approve: A, permission to approve requests to join a topic, remove and ban members;
-        a user with such permission is topic's administrator
-    - Sharing: S, permission to invite other people to join the topic
-    - Delete: D, permission to hard-delete messages; only owners can completely delete topics
-    - Owner: O, user is the topic owner; the owner can assign any other permission to any topic member,
-        change topic description, delete topic; topic may have a single owner only;
-        some topics have no owner
-    """
-    join: bool = False
-    read: bool = False
-    write: bool = False
-    presence: bool = False
-    approve: bool = False
-    sharing: bool = False
-    delete: bool = False
-    owner: bool = False
-
-    @model_validator(mode="before")  # type: ignore
-    def validate(cls, value: Any) -> Any:
-        if not isinstance(value, str):
-            return value
-        value = value.upper()
-        result = {}
-        if value == "N":
-            return result
-        for i in value:
-            if i == "J":
-                result["join"] = True
-            elif i == "R":
-                result["read"] = True
-            elif i == "W":
-                result["write"] = True
-            elif i == "P":
-                result["presence"] = True
-            elif i == "A":
-                result["approve"] = True
-            elif i == "S":
-                result["sharing"] = True
-            elif i == "D":
-                result["delete"] = True
-            elif i == "O":
-                result["owner"] = True
-            else:
-                raise ValueError(f"unknown permission: {i}")
-        return result
-
-    @model_serializer(mode="plain")
-    def serialize(self) -> str:
-        result = ""
-        if self.join:
-            result += "J"
-        if self.read:
-            result += "R"
-        if self.write:
-            result += "W"
-        if self.presence:
-            result += "P"
-        if self.approve:
-            result += "A"
-        if self.sharing:
-            result += "S"
-        if self.delete:
-            result += "D"
-        if self.owner:
-            result += "O"
-        return result
-
-
-class Cred(BaseModel):
-    method: str
-    value: str
-    done: bool = False
-
-
-class DefaultAccess(BaseModel):
-    auth: AccessPermission
-    anon: AccessPermission
-
-
-class Access(BaseModel):
-    want: AccessPermission
-    given: AccessPermission
+from .model import Access, DefaultAccess
 
 
 class TimeInfo(BaseModel):
@@ -232,7 +133,3 @@ def from_meta(cls, meta: pb.TopicSub) -> Self:
             recv=meta.recv_id,
             clear=meta.del_id,
         )
-
-
-UserTags = List[str]
-UserCred = List[Cred]
diff --git a/karuha/data/model.py b/karuha/data/model.py
new file mode 100644
index 0000000..2a1589c
--- /dev/null
+++ b/karuha/data/model.py
@@ -0,0 +1,136 @@
+from typing import Any, Dict, Mapping, Optional
+from pydantic import BaseModel, model_validator, model_serializer
+
+
+class AccessPermission(BaseModel):
+    """
+    User's access to a topic is defined by two sets of permissions:
+    user's desired permissions "want", and permissions granted to user by topic's manager(s) "given".
+    Each permission is represented by a bit in a bitmap.
+    It can be either present or absent.
+    The actual access is determined as a bitwise AND of wanted and given permissions.
+    The permissions are communicated in messages as a set of ASCII characters,
+    where presence of a character means a set permission bit:
+
+    - No access: N is not a permission per se but an indicator that permissions are explicitly cleared/not set.
+        It usually indicates that the default permissions should not be applied.
+    - Join: J, permission to subscribe to a topic
+    - Read: R, permission to receive {data} packets
+    - Write: W, permission to {pub} to topic
+    - Presence: P, permission to receive presence updates {pres}
+    - Approve: A, permission to approve requests to join a topic, remove and ban members;
+        a user with such permission is topic's administrator
+    - Sharing: S, permission to invite other people to join the topic
+    - Delete: D, permission to hard-delete messages; only owners can completely delete topics
+    - Owner: O, user is the topic owner; the owner can assign any other permission to any topic member,
+        change topic description, delete topic; topic may have a single owner only;
+        some topics have no owner
+    """
+    join: bool = False
+    read: bool = False
+    write: bool = False
+    presence: bool = False
+    approve: bool = False
+    sharing: bool = False
+    delete: bool = False
+    owner: bool = False
+
+    @model_validator(mode="before")
+    def validate(cls, value: Any) -> Any:
+        if not isinstance(value, str):
+            return value
+        value = value.upper()
+        result = {}
+        if value == "N":
+            return result
+        for i in value:
+            if i == "J":
+                result["join"] = True
+            elif i == "R":
+                result["read"] = True
+            elif i == "W":
+                result["write"] = True
+            elif i == "P":
+                result["presence"] = True
+            elif i == "A":
+                result["approve"] = True
+            elif i == "S":
+                result["sharing"] = True
+            elif i == "D":
+                result["delete"] = True
+            elif i == "O":
+                result["owner"] = True
+            else:
+                raise ValueError(f"unknown permission: {i}")
+        return result
+
+    @model_serializer(mode="plain")
+    def serialize(self) -> str:
+        result = ""
+        if self.join:
+            result += "J"
+        if self.read:
+            result += "R"
+        if self.write:
+            result += "W"
+        if self.presence:
+            result += "P"
+        if self.approve:
+            result += "A"
+        if self.sharing:
+            result += "S"
+        if self.delete:
+            result += "D"
+        if self.owner:
+            result += "O"
+        if not result:
+            return "N"
+        return result
+
+
+class Cred(BaseModel):
+    method: str
+    value: str
+    done: bool = False
+
+
+class DefaultAccess(BaseModel):
+    auth: AccessPermission
+    anon: AccessPermission
+
+
+class Access(BaseModel):
+    want: AccessPermission
+    given: AccessPermission
+
+
+class BaseInfo(BaseModel, frozen=True):
+    public: Optional[Dict[str, Any]] = None
+    trusted: Optional[Dict[str, Any]] = None
+    private: Optional[Dict[str, Any]] = None
+
+    @property
+    def fn(self) -> Optional[str]:
+        if self.public:
+            return self.public.get("fn")
+
+    @property
+    def note(self) -> Optional[str]:
+        if self.public:
+            return self.public.get("note")
+
+    @property
+    def comment(self) -> Optional[str]:
+        if self.private:
+            return self.private.get("comment")
+
+    @property
+    def verified(self) -> bool:
+        return False if self.trusted is None else self.trusted.get("verified", False)
+
+
+class ClientCred(BaseModel):
+    method: str
+    value: str
+    response: Optional[str] = None
+    params: Optional[Mapping[str, bytes]] = None
diff --git a/karuha/data/topic.py b/karuha/data/topic.py
index 0a26b6e..8c3a283 100644
--- a/karuha/data/topic.py
+++ b/karuha/data/topic.py
@@ -1,85 +1,17 @@
-from abc import abstractproperty
 from datetime import datetime
-from typing import Any, Dict, List, Literal, Optional, overload
+from typing import Any, Dict, List, Optional
 
-from pydantic import BaseModel
 from pydantic_core import to_json
 from tinode_grpc import pb
+from typing_extensions import deprecated
 
 from ..bot import Bot
-from .cache import (get_group_desc, get_my_sub, get_p2p_desc, get_sub,
-                    get_user_desc, try_get_group_desc, try_get_p2p_desc, try_get_sub,
-                    try_get_user_desc)
-from .meta import (Access, CommonDesc, DefaultAccess, GroupTopicDesc,
-                   Subscription)
-
-
-class BaseInfo(BaseModel, frozen=True):
-    public: Optional[Dict[str, Any]] = None
-    trusted: Optional[Dict[str, Any]] = None
-    private: Optional[Dict[str, Any]] = None
-
-    @property
-    def fn(self) -> Optional[str]:
-        if self.public:
-            return self.public.get("fn")
-
-    @property
-    def note(self) -> Optional[str]:
-        if self.public:
-            return self.public.get("note")
-
-    @property
-    def comment(self) -> Optional[str]:
-        if self.private:
-            return self.private.get("comment")
-
-    @property
-    def verified(self) -> bool:
-        return False if self.trusted is None else self.trusted.get("verified", False)
-    
-    @abstractproperty
-    def topic_id(self) -> str:
-        raise NotImplementedError
-
-    async def set_info(
-        self,
-        bot: Bot,
-        /,
-        public: Optional[Dict[str, Any]] = None,
-        trusted: Optional[Dict[str, Any]] = None,
-        private: Optional[Dict[str, Any]] = None,
-        *,
-        update: bool = False,
-    ) -> None:
-        if update:
-            if public is not None and self.public is not None:
-                public = public.copy().update(self.public)
-            if trusted is not None and self.trusted is not None:
-                trusted = trusted.copy().update(self.trusted)
-            if private is not None and self.private is not None:
-                private = private.copy().update(self.private)
-        await set_info(
-            bot, self.topic_id, public=public, trusted=trusted, private=private
-        )
-
-    async def set_public(self, bot: Bot, /, public: Optional[Dict[str, Any]] = None, *, update: bool = False) -> None:
-        await self.set_info(bot, public=public, update=update)
-
-    async def set_trusted(self, bot: Bot, /, trusted: Optional[Dict[str, Any]] = None, *, update: bool = False) -> None:
-        await self.set_info(bot, trusted=trusted, update=update)
-
-    async def set_private(self, bot: Bot, /, private: Optional[Dict[str, Any]] = None, *, update: bool = False) -> None:
-        await self.set_info(bot, private=private, update=update)
-
-    async def set_fn(self, bot: Bot, /, fn: str) -> None:
-        await self.set_info(bot, public={"fn": fn}, update=True)
-
-    async def set_note(self, bot: Bot, /, note: str) -> None:
-        await self.set_info(bot, public={"note": note}, update=True)
-
-    async def set_comment(self, bot: Bot, /, comment: str) -> None:
-        await self.set_info(bot, private={"comment": comment}, update=True)
+from ..utils.decode import load_json, msg2dict
+from .cache import (get_group_desc, get_p2p_desc, get_sub, get_user_desc,
+                    try_get_group_desc, try_get_my_sub, try_get_p2p_desc,
+                    try_get_sub, try_get_user_desc)
+from .meta import Access, CommonDesc, DefaultAccess, GroupTopicDesc
+from .model import BaseInfo
 
 
 class BaseTopic(BaseInfo, frozen=True):
@@ -88,9 +20,6 @@ class BaseTopic(BaseInfo, frozen=True):
     @property
     def topic_id(self) -> str:
         return self.topic
-    
-    async def ensure_topic(self, bot: Bot, /) -> "Topic":
-        return await get_topic(bot, self.topic, ensure_topic=True)
 
 
 class Topic(BaseTopic, frozen=True):
@@ -119,6 +48,7 @@ class TopicSub(BaseTopic, frozen=True):
     acs: Optional[Access] = None
 
 
+@deprecated("Use `UserService.set_info` instead")
 async def set_info(
     bot: Bot,
     /,
@@ -147,63 +77,58 @@ def try_get_p2p_topic(bot: Bot, /, topic_id: str) -> Optional[BaseTopic]:
     desc = try_get_user_desc(bot, topic_id)
     info = try_get_p2p_desc(bot, topic_id)
     sub = try_get_sub(bot, topic_id)
-    if desc is not None:
-        if isinstance(desc, CommonDesc) and info is not None and sub is not None:
-            return Topic(
-                topic=topic_id,
-                public=desc.public,
-                trusted=desc.trusted,
-                private=sub.private,
-                created=info.created,
-                updated=info.updated,
-                touched=info.touched,
-                defacs=desc.defacs,
-                acs=sub.acs,
-                seq=info.seq,
-                read=sub.read,
-                recv=sub.recv,
-                clear=sub.clear,
-            )
-        return BaseTopic(
+    if desc is None:
+        return
+    if isinstance(desc, CommonDesc) and info is not None and sub is not None:
+        return Topic(
             topic=topic_id,
             public=desc.public,
             trusted=desc.trusted,
+            private=sub.private,
+            created=info.created,
+            updated=info.updated,
+            touched=info.touched,
+            defacs=desc.defacs,
+            acs=sub.acs,
+            seq=info.seq,
+            read=sub.read,
+            recv=sub.recv,
+            clear=sub.clear,
+        )
+    return BaseTopic(
+        topic=topic_id,
+        public=desc.public,
+        trusted=desc.trusted,
         )
 
 
-@overload
-async def get_p2p_topic(bot: Bot, /, topic_id: str, *, ensure_topic: Literal[True]) -> Topic: ...
-@overload
-async def get_p2p_topic(bot: Bot, /, topic_id: str, *, ensure_topic: bool = False) -> BaseTopic: ...
-
-
-async def get_p2p_topic(bot: Bot, /, topic_id: str, *, ensure_topic: bool = False) -> BaseTopic:
-    desc = await get_user_desc(bot, user_id=topic_id, ensure_meta=False)
-    sub = await get_sub(bot, topic_id=topic_id, ensure_meta=False)
+async def get_p2p_topic(bot: Bot, /, topic_id: str, *, skip_cache: bool = False) -> BaseTopic:
+    desc = await get_user_desc(bot, user_id=topic_id, skip_cache=skip_cache)
+    sub = await get_sub(bot, topic_id=topic_id, skip_cache=skip_cache)
     info = try_get_p2p_desc(bot, topic_id)
-    if isinstance(desc, CommonDesc) and ensure_topic and info is None:
-        info = await get_p2p_desc(bot, topic_id)
+    if isinstance(desc, CommonDesc) and skip_cache and info is None:
+        info = await get_p2p_desc(bot, topic_id, skip_cache=skip_cache)
     elif not isinstance(desc, CommonDesc) or info is None:
         return BaseTopic(
             topic=topic_id,
             public=desc.public,
             trusted=desc.trusted,
-            private=sub.private,
+            private=sub and sub.private,
         )
     return Topic(
         topic=topic_id,
         public=desc.public,
         trusted=desc.trusted,
-        private=sub.private,
+        private=sub and sub.private,
         created=info.created,
         updated=info.updated,
         touched=info.touched,
         seq=info.seq,
         defacs=desc.defacs,
-        acs=sub.acs,
-        read=sub.read,
-        recv=sub.recv,
-        clear=sub.clear
+        acs=sub and sub.acs,
+        read=sub and sub.read,
+        recv=sub and sub.recv,
+        clear=sub and sub.clear
     )
 
 
@@ -236,51 +161,41 @@ def try_get_group_topic(bot: Bot, /, topic_id: str) -> Optional[BaseTopic]:
         )
 
 
-@overload
-async def get_group_topic(bot: Bot, /, topic_id: str, *, ensure_topic: Literal[True]) -> Topic: ...
-@overload
-async def get_group_topic(bot: Bot, /, topic_id: str, *, ensure_topic: bool = False) -> BaseTopic: ...
-
-
-async def get_group_topic(bot: Bot, /, topic_id: str, *, ensure_topic: bool = False) -> BaseTopic:
-    desc = await get_group_desc(bot, topic_id, ensure_meta=ensure_topic)
-    sub = await get_sub(bot, topic_id, ensure_meta=False)
+async def get_group_topic(bot: Bot, /, topic_id: str, *, skip_cache: bool = False) -> BaseTopic:
+    desc = await get_group_desc(bot, topic_id, skip_cache=skip_cache)
+    sub = await get_sub(bot, topic_id, skip_cache=skip_cache)
     if not isinstance(desc, GroupTopicDesc):
         return BaseTopic(
             topic=topic_id,
             public=desc.public,
             trusted=desc.trusted,
-            private=sub.private
+            private=sub and sub.private
         )
     return Topic(
         topic=topic_id,
         public=desc.public,
         trusted=desc.trusted,
-        private=sub.private,
+        private=sub and sub.private,
         is_chan=desc.is_chan,
         defacs=desc.defacs,
-        acs=sub.acs,
+        acs=sub and sub.acs,
         seq=desc.seq,
         created=desc.created,
         updated=desc.updated,
         touched=desc.touched,
-        read=sub.read,
-        recv=sub.recv,
-        clear=sub.clear,
+        read=sub and sub.read,
+        recv=sub and sub.recv,
+        clear=sub and sub.clear,
     )
 
 
-@overload
-async def get_topic(bot: Bot, topic_id: str, /, *, ensure_topic: Literal[True]) -> Topic: ...
-@overload
-async def get_topic(bot: Bot, topic_id: str, /, *, ensure_topic: bool = False) -> BaseTopic: ...
-
-
-async def get_topic(bot: Bot, /, topic_id: str, *, ensure_topic: bool = False) -> BaseTopic:
+async def get_topic(bot: Bot, /, topic_id: str, *, skip_cache: bool = False) -> BaseTopic:
+    if topic_id == "me":
+        raise ValueError("cannot get topic info for 'me', use get_user() instead")
     if topic_id.startswith("grp"):
-        return await get_group_topic(bot, topic_id, ensure_topic=ensure_topic)
+        return await get_group_topic(bot, topic_id, skip_cache=skip_cache)
     else:
-        return await get_p2p_topic(bot, topic_id, ensure_topic=ensure_topic)
+        return await get_p2p_topic(bot, topic_id, skip_cache=skip_cache)
 
 
 def try_get_topic(bot: Bot, /, topic_id: str) -> Optional[BaseTopic]:
@@ -290,32 +205,31 @@ def try_get_topic(bot: Bot, /, topic_id: str) -> Optional[BaseTopic]:
         return try_get_p2p_topic(bot, topic_id)
 
 
-@overload
-async def get_topic_list(bot: Bot, /, *, ensure_topic_sub: Literal[True]) -> List[TopicSub]: ...
-@overload
-async def get_topic_list(bot: Bot, /, *, ensure_topic_sub: bool = False) -> List[BaseTopic]: ...
-
-
-async def get_topic_list(bot: Bot, /, *, ensure_topic_sub: bool = False) -> List[BaseTopic]:  # type: ignore[misc]
-    subs = await get_my_sub(bot, ensure_meta=ensure_topic_sub)
-    topic_list = []
-    for topic_id, sub in subs:
-        topic = await get_topic(bot, topic_id, ensure_topic=False)
-        if isinstance(sub, Subscription):
-            topic_sub = TopicSub(
-                topic=topic_id,
-                public=topic.public,
-                trusted=topic.trusted,
-                private=topic.private,
-                updated=sub.updated,
-                deleted=sub.deleted,
-                touched=sub.touched,
-                read=sub.read,
-                recv=sub.recv,
-                clear=sub.clear,
-                acs=sub.acs,
-            )
-        else:
-            topic_sub = topic
-        topic_list.append(topic_sub)
-    return topic_list
+def try_get_topic_list(bot: Bot, /) -> List[BaseTopic]:
+    if subs := try_get_my_sub(bot):
+        return list(filter(None, (try_get_topic(bot, topic) for topic, _ in subs)))
+    return []
+
+
+async def get_topic_list(bot: Bot, /, *, ensure_all: bool = True) -> List[BaseTopic]:  # type: ignore[misc]
+    if not ensure_all:
+        if subs := try_get_topic_list(bot):
+            return subs
+    _, sub_meta = await bot.get("me", "sub")
+    assert sub_meta is not None
+    return [
+        TopicSub(
+            topic=sub.topic,
+            public=load_json(sub.public),
+            trusted=load_json(sub.trusted),
+            private=load_json(sub.private),
+            updated=sub.updated_at,  # type: ignore
+            touched=sub.touched_at,  # type: ignore
+            deleted=sub.deleted_at,  # type: ignore
+            read=sub.read_id,
+            recv=sub.recv_id,
+            clear=sub.del_id,
+            acs=msg2dict(sub.acs)  # type: ignore
+        )
+        for sub in sub_meta.sub
+    ]
diff --git a/karuha/data/types.py b/karuha/data/types.py
new file mode 100644
index 0000000..6c0a077
--- /dev/null
+++ b/karuha/data/types.py
@@ -0,0 +1,11 @@
+from typing import Literal, TypeVar, Union, Dict, Any
+
+from tinode_grpc import pb
+from .model import ClientCred
+
+
+T = TypeVar("T")
+
+
+UserStateType = Literal["ok", "susp", "del", "undef"]
+ClientCredType = Union[Dict[str, Any], ClientCred, pb.ClientCred]
diff --git a/karuha/data/user.py b/karuha/data/user.py
index 154f2d8..d93d3fa 100644
--- a/karuha/data/user.py
+++ b/karuha/data/user.py
@@ -1,19 +1,16 @@
 from datetime import datetime
-from typing import List, Literal, Optional, overload
+from typing import List, Optional
 
 from ..bot import Bot
-from .cache import get_sub, get_topic_sub, get_user_desc, try_get_sub, try_get_user_desc
-from .meta import Access, DefaultAccess, Subscription, UserDesc
-from .topic import BaseInfo
+from ..utils.decode import load_json, msg2dict
+from .cache import get_sub, get_user_desc, try_get_sub, try_get_topic_sub, try_get_user_desc
+from .meta import Access, DefaultAccess, UserDesc
+from .model import BaseInfo
 
 
 class BaseUser(BaseInfo, frozen=True):
     user_id: str
 
-    @property
-    def topic_id(self) -> str:
-        return self.user_id
-
     @property
     def staff(self) -> bool:
         if self.trusted is None:
@@ -69,17 +66,12 @@ def try_get_user(bot: Bot, /, user_id: str = "me") -> Optional[BaseUser]:
         )
 
 
-@overload
-async def get_user(bot: Bot, /, user_id: str = "me", *, ensure_user: Literal[True]) -> User: ...
-@overload
-async def get_user(bot: Bot, /, user_id: str = "me", *, ensure_user: bool = False) -> BaseUser: ...
-
-
-async def get_user(bot: Bot, /, user_id: str = "me", *, ensure_user: bool = False) -> BaseUser:
+async def get_user(bot: Bot, /, user_id: str = "me", *, skip_cache: bool = False, skip_sub_check: bool = False) -> BaseUser:
     if user_id == bot.uid:
         user_id = "me"
-    desc = await get_user_desc(bot, user_id, ensure_meta=ensure_user and user_id == "me")
-    if user_id == "me":
+    desc = await get_user_desc(bot, user_id, skip_cache=skip_cache)
+    sub = await get_sub(bot, user_id, skip_sub_check=skip_sub_check)
+    if sub is None:
         if isinstance(desc, UserDesc):
             return User(
                 user_id=bot.uid,
@@ -98,7 +90,6 @@ async def get_user(bot: Bot, /, user_id: str = "me", *, ensure_user: bool = Fals
                 public=desc.public,
                 trusted=desc.trusted,
             )
-    sub = await get_sub(bot, user_id, ensure_meta=False)
     if isinstance(desc, UserDesc):
         return User(
             user_id=user_id,
@@ -122,38 +113,33 @@ async def get_user(bot: Bot, /, user_id: str = "me", *, ensure_user: bool = Fals
         )
 
 
-@overload
-async def get_user_list(bot: Bot, /, topic: str, *, ensure_user_sub: Literal[True]) -> List[UserSub]: ...
-@overload
-async def get_user_list(bot: Bot, /, topic: str, *, ensure_user_sub: bool = False) -> List[BaseUser]: ...
-
-
-async def get_user_list(bot: Bot, /, topic: str, *, ensure_user_sub: bool = False) -> List[BaseUser]:  # type: ignore[misc]
-    assert topic != "me", "cannot get user list of myself, use `get_topic_list()` instead"
-    subs = await get_topic_sub(bot, topic, ensure_meta=ensure_user_sub)
-    user_list = []
-    for user_id, sub in subs:
-        user_desc = await get_user_desc(bot, user_id, ensure_meta=False)
-        if isinstance(sub, Subscription):
-            user_sub = UserSub(
-                user_id=user_id,
-                public=user_desc.public,
-                trusted=user_desc.trusted,
-                private=sub.private,
-                updated=sub.updated,
-                deleted=sub.deleted,
-                touched=sub.touched,
-                read=sub.read,
-                recv=sub.recv,
-                clear=sub.clear,
-                acs=sub.acs
-            )
-        else:
-            user_sub = BaseUser(
-                user_id=user_id,
-                public=user_desc.public,
-                trusted=user_desc.trusted,
-                private=sub.private,
-            )
-        user_list.append(user_sub)
-    return user_list
+def try_get_user_list(bot: Bot, /, topic: str) -> List[BaseUser]:
+    if subs := try_get_topic_sub(bot, topic):
+        return list(filter(None, (try_get_user(bot, user_id) for user_id, _ in subs)))
+    return []
+
+
+async def get_user_list(bot: Bot, /, topic: str, *, ensure_all: bool = True) -> List[BaseUser]:
+    if topic == "me":
+        raise ValueError("cannot get user list for 'me', use get_topic_list() instead")
+    if not ensure_all:
+        if subs := try_get_user_list(bot, topic):
+            return subs
+    _, sub_meta = await bot.get(topic, "sub")
+    assert sub_meta is not None
+    return [
+        UserSub(
+            user_id=sub.user_id,
+            public=load_json(sub.public),
+            trusted=load_json(sub.trusted),
+            private=load_json(sub.private),
+            updated=sub.updated_at,  # type: ignore
+            touched=sub.touched_at,  # type: ignore
+            deleted=sub.deleted_at,  # type: ignore
+            read=sub.read_id,
+            recv=sub.recv_id,
+            clear=sub.del_id,
+            acs=msg2dict(sub.acs),  # type: ignore
+        )
+        for sub in sub_meta.sub
+    ]
diff --git a/karuha/event/base.py b/karuha/event/base.py
index a1c5171..ab9015f 100644
--- a/karuha/event/base.py
+++ b/karuha/event/base.py
@@ -29,7 +29,7 @@ class Event(HandlerInvoker):
     __slots__ = []
 
     __handlers__: ClassVar[List[Callable[..., Any]]] = []
-    __event_lock__: ClassVar[Lock] = Lock()
+    __event_lock__: ClassVar[Lock]
     
     @classmethod
     def add_handler(cls, handler: Callable[..., Any]) -> None:
diff --git a/karuha/event/bot.py b/karuha/event/bot.py
index 259e0b0..70f6914 100644
--- a/karuha/event/bot.py
+++ b/karuha/event/bot.py
@@ -1,4 +1,3 @@
-import asyncio
 from functools import partial
 from typing import Any, Awaitable, Callable, Coroutine, Mapping, Optional, TypeVar
 from typing_extensions import Self
@@ -7,7 +6,6 @@
 from tinode_grpc import pb
 
 from .. import bot
-from ..exception import KaruhaBotError
 from ..session import BaseSession
 from ..utils.proxy_propery import ProxyProperty
 from ..utils.invoker import Dependency, depend_property
@@ -37,32 +35,8 @@ class BotInitEvent(BotEvent):
     __slots__ = []
 
     async def __default_handler__(self) -> None:
-        bot = self.bot
-        try:
-            await bot.hello()
-        except Exception:
-            bot.logger.error("failed to connect to server, restarting")
-            bot.restart()
-            return
-        
-        if not bot.config.auto_login:
-            bot.logger.info("auto login is disabled, skipping")
-            return
-        
-        retry = bot.server.retry if bot.server is not None else 0
-        for i in range(retry):
-            try:
-                await bot.login()
-            except (asyncio.TimeoutError, KaruhaBotError):
-                bot.logger.warning(f"login failed, retrying {i+1} times")
-            else:
-                break
-        else:
-            try:
-                await bot.login()
-            except (asyncio.TimeoutError, KaruhaBotError):
-                bot.logger.error("login failed, cancel the bot")
-                bot.cancel()
+        prepare_task = self.bot._create_task(self.bot._prepare_account())
+        prepare_task.add_done_callback(lambda _: BotReadyEvent.new(self.bot))
 
 
 class BotReadyEvent(BotEvent):
@@ -374,6 +348,11 @@ class PresEvent(ServerEvent, on_field="pres"):
     async def __default_handler__(self) -> None:
         msg = self.server_message
         if msg.topic != "me":
+            if msg.what == pb.ServerPres.ACS:
+                topic = msg.topic
+                _, meta = await self.bot.get(topic, "desc")
+                if meta:
+                    await self.bot.set(topic, sub=pb.SetSub(mode=meta.desc.acs.given))
             return
         if msg.what == pb.ServerPres.ON:
             await self.bot.subscribe(msg.src, get="desc sub")
@@ -505,11 +484,6 @@ async def __default_handler__(self) -> None:
             if code < 200 or code >= 400 and code != 409:
                 # login failed
                 return
-        await self.bot.subscribe(
-            "me",
-            get="sub desc tags cred"
-        )
-        BotReadyEvent.new(self.bot)
 
 
 class PublishEvent(ClientEvent, on_field="pub"):
diff --git a/karuha/exception.py b/karuha/exception.py
index 8bdec59..6d27379 100644
--- a/karuha/exception.py
+++ b/karuha/exception.py
@@ -1,7 +1,10 @@
 import asyncio
-from typing import Any, Optional
+from typing import TYPE_CHECKING, Optional
 
-from . import bot
+if TYPE_CHECKING:
+    from .bot import Bot
+    from .command.collection import CommandCollection
+    from .command.command import AbstractCommand
 
 
 class KaruhaException(Exception):
@@ -18,7 +21,7 @@ class KaruhaBotError(KaruhaException):
     """unspecified chatbot run-time error"""
     __slots__ = ["bot", "code"]
 
-    def __init__(self, *args: object, bot: Optional["bot.Bot"] = None, code: Optional[int] = None) -> None:
+    def __init__(self, *args: object, bot: Optional["Bot"] = None, code: Optional[int] = None) -> None:
         super().__init__(*args)
         self.bot = bot
         self.code = code
@@ -37,8 +40,8 @@ def __init__(
             self,
             *args: object,
             name: str,
-            collection: Any = None,
-            command: Any = None
+            collection: Optional["CommandCollection"] = None,
+            command: Optional["AbstractCommand"] = None
     ) -> None:
         super().__init__(*args)
         self.name = name
diff --git a/karuha/exception.pyi b/karuha/exception.pyi
deleted file mode 100644
index 52fbde1..0000000
--- a/karuha/exception.pyi
+++ /dev/null
@@ -1,48 +0,0 @@
-import asyncio
-from typing import Optional
-
-from .bot import Bot
-from .command.collection import CommandCollection as CommandCollection
-from .command.command import AbstractCommand as AbstractCommand
-
-
-class KaruhaException(Exception):
-    """base exception for all errors in Karuha module"""
-
-class KaruhaRuntimeError(KaruhaException):
-    """unspecified run-time error"""
-
-class KaruhaBotError(KaruhaException):
-    """unspecified chatbot run-time error"""
-
-    bot: Optional[Bot]
-    code: Optional[int]
-    def __init__(
-        self, *args: object, bot: Bot | None = None, code: int | None = None
-    ) -> None: ...
-
-class KaruhaTimeoutError(KaruhaRuntimeError, asyncio.TimeoutError):
-    """run-time error: timeout"""
-
-class KaruhaCommandError(KaruhaException):
-    """command error"""
-
-    name: str
-    collection: Optional[CommandCollection]
-    _command: Optional[AbstractCommand]
-
-    def __init__(
-        self,
-        *args: object,
-        name: str,
-        collection: CommandCollection | None = None,
-        command: AbstractCommand | None = None
-    ) -> None: ...
-    @property
-    def command(self) -> AbstractCommand | None: ...
-
-class KaruhaCommandCanceledError(asyncio.CancelledError):
-    """command cancelled"""
-
-class KaruhaHandlerInvokerError(KaruhaException):
-    """param parser error"""
diff --git a/karuha/py.typed b/karuha/py.typed
new file mode 100644
index 0000000..e69de29
diff --git a/karuha/runner.py b/karuha/runner.py
index 264d56b..c443aeb 100644
--- a/karuha/runner.py
+++ b/karuha/runner.py
@@ -1,17 +1,21 @@
 import asyncio
 import contextlib
 import signal
-from typing import Dict, List, Optional
+import threading
+from typing import AsyncGenerator, Dict, List, Optional
 
 from .config import get_config, reset_config
 from .bot import Bot, BotState
 from .event.sys import SystemStartEvent, SystemStopEvent
+from .event.bot import BotReadyEvent
 from .logger import logger
 from .utils.gathering import DynamicGatheringFuture
+from .utils.event_catcher import EventCatcher
 
 
 _bot_cache: Dict[str, Bot] = {}
 _gathering_future: Optional[DynamicGatheringFuture] = None
+_runner_lock = threading.Lock()
 
 
 def try_get_bot(name: str = "chatbot") -> Optional[Bot]:
@@ -54,6 +58,13 @@ def add_bot(bot: Bot) -> None:
         raise ValueError(f"bot {bot.name} has existed")
 
 
+def try_remove_bot(bot: Bot) -> bool:
+    if bot.name not in _bot_cache:
+        return False
+    remove_bot(bot)
+    return True
+
+
 def remove_bot(bot: Bot) -> None:
     if bot.name not in _bot_cache:
         raise ValueError(f"bot {bot.name} not found")
@@ -70,6 +81,29 @@ def cancel_all_bots() -> bool:
     return False if _gathering_future is None else _gathering_future.cancel()
 
 
+@contextlib.asynccontextmanager
+async def run_bot(bot: Bot, *, ensure_state: bool = True) -> AsyncGenerator[Bot, None]:
+    """run bot temporarily
+
+    :param bot: bot to run
+    :type bot: Bot
+    :param ensure_state: ensure bot is ready before yield, defaults to True
+    :type ensure_state: bool, optional
+    :yield: the bot
+    :rtype: Generator[Bot, None, None]
+    """
+    add_bot(bot)
+    if ensure_state:
+        with EventCatcher(BotReadyEvent) as catcher:
+            ev = await catcher.catch_event()
+            while ev.bot is not bot:
+                ev = await catcher.catch_event()
+    try:
+        yield bot
+    finally:
+        remove_bot(bot)
+
+
 def _get_running_loop() -> asyncio.AbstractEventLoop:
     if _gathering_future is None:  # pragma: no cover
         raise RuntimeError("no running loop")
@@ -83,6 +117,7 @@ def _handle_sigterm() -> None:  # pragma: no cover
 
 async def async_run() -> None:
     global _gathering_future
+
     config = get_config()
     loop = asyncio.get_running_loop()
     with contextlib.suppress(NotImplementedError):
@@ -94,21 +129,22 @@ async def async_run() -> None:
         bot = Bot.from_config(i, config)
         _bot_cache[i.name] = bot
 
+    if not _runner_lock.acquire(blocking=False):
+        raise RuntimeError("another runner is running")
+    
     tasks: List[asyncio.Future] = []
-    for bot in _bot_cache.values():
-        logger.debug(f"run bot {bot.config}")
-        tasks.append(loop.create_task(bot.async_run(config.server)))
-
-    if config.server.enable_plugin:  # pragma: no cover
-        server = init_server(config.server.listen)
-        loop.call_soon(server.start)
-    else:
-        server = None
+    server = None
+    try:
+        for bot in _bot_cache.values():
+            logger.debug(f"run bot {bot.config}")
+            tasks.append(loop.create_task(bot.async_run(config.server)))
 
-    if config.log_level == "DEBUG":
-        loop.set_debug(True)
+        if config.server.enable_plugin:  # pragma: no cover
+            server = init_server(config.server.listen)
+            loop.call_soon(server.start)
 
-    try:
+        if config.log_level == "DEBUG":
+            loop.set_debug(True)
         await SystemStartEvent.new_and_wait(config, _bot_cache.values())
         if not tasks:  # pragma: no cover
             logger.warning("no bot found, exit")
@@ -120,8 +156,11 @@ async def async_run() -> None:
         if server is not None:  # pragma: no cover
             logger.info("stop plugin server")
             server.stop(None)
-        await SystemStopEvent(config).trigger(return_exceptions=True)
-        _gathering_future = None
+        try:
+            await SystemStopEvent(config).trigger(return_exceptions=True)
+        finally:
+            _gathering_future = None
+            _runner_lock.release()
 
 
 def run() -> None:  # pragma: no cover
diff --git a/karuha/service/__init__.py b/karuha/service/__init__.py
new file mode 100644
index 0000000..aefe43b
--- /dev/null
+++ b/karuha/service/__init__.py
@@ -0,0 +1,8 @@
+from .base import BaseService
+from .user import UserService
+
+
+__all__ = [
+    "BaseService",
+    "UserService",
+]
diff --git a/karuha/service/base.py b/karuha/service/base.py
new file mode 100644
index 0000000..5dbcdcf
--- /dev/null
+++ b/karuha/service/base.py
@@ -0,0 +1,20 @@
+from inspect import Parameter
+from typing import Any, Union
+
+from ..bot import Bot
+from ..event.bot import BotEvent
+from ..text import Message
+from ..utils.invoker import HandlerInvokerDependency, KaruhaHandlerInvokerError
+
+
+class BaseService(HandlerInvokerDependency):
+    __slots__ = ["bot"]
+
+    def __init__(self, bot: Bot, /) -> None:
+        self.bot = bot
+    
+    @classmethod
+    def resolve_dependency(cls, invoker: Union[Message, BotEvent], param: Parameter, **kwds: Any) -> Any:
+        if not hasattr(invoker, "bot"):
+            raise KaruhaHandlerInvokerError(f"cannot resolve dependency for {param.name!r}")
+        return cls(invoker.bot)
diff --git a/karuha/service/user.py b/karuha/service/user.py
new file mode 100644
index 0000000..db81e29
--- /dev/null
+++ b/karuha/service/user.py
@@ -0,0 +1,192 @@
+from contextlib import asynccontextmanager
+from typing import Any, AsyncGenerator, Iterable, Mapping, Optional, Tuple, Union
+
+from pydantic_core import to_json
+from tinode_grpc import pb
+from google.protobuf.json_format import ParseDict
+from pydantic import BaseModel
+
+from .base import BaseService
+from ..runner import add_bot, run_bot
+from ..data.user import BaseUser, get_user
+from ..data.types import ClientCredType, UserStateType
+from ..bot import Bot, ProxyBot
+
+
+def _parse_cred(cred: ClientCredType) -> pb.ClientCred:
+    if isinstance(cred, pb.ClientCred):
+        return cred
+    elif isinstance(cred, BaseModel):
+        return ParseDict(cred.model_dump(), pb.ClientCred())
+    return ParseDict(cred, pb.ClientCred())
+
+
+class UserService(BaseService):
+    __slots__ = []
+
+    async def new_bot(
+        self,
+        uname: str,
+        password: str,
+        *,
+        name: Optional[str] = None,
+        tags: Iterable[str] = (),
+        cred: Iterable[ClientCredType],
+        state: UserStateType = "ok",
+        use_proxy: bool = False,
+        start_run: bool = True,
+    ) -> Bot:
+        secret = f"{uname}:{password}"
+        _, params = await self.bot.account(
+            "new",
+            "basic",
+            secret.encode(),
+            do_login=False,
+            state=state,
+            tags=tags,
+            cred=map(_parse_cred, cred),
+        )
+        user_id = params["user"]
+        if name is None:
+            name = f"{uname}_{user_id}"
+        if not use_proxy:
+            bot = Bot(name, schema="basic", secret=secret, server=self.bot.server)
+        else:
+            bot = ProxyBot.from_bot(self.bot, user_id, name)
+        if start_run:
+            add_bot(bot)
+        return bot
+
+    async def new_user(
+        self,
+        uname: str,
+        password: str,
+        *,
+        tags: Iterable[str] = (),
+        cred: Iterable[ClientCredType] = (),
+        state: Optional[UserStateType] = None
+    ) -> Tuple[str, str]:
+        secret = f"{uname}:{password}"
+        _, params = await self.bot.account(
+            "new",
+            "basic",
+            secret.encode(),
+            do_login=False,
+            state=state,
+            tags=tags,
+            cred=map(_parse_cred, cred),
+        )
+        user_id = params["user"]
+        token = params["token"]
+        return user_id, token
+
+    async def get_user(
+        self,
+        user_id: str,
+        *,
+        skip_cache: bool = False,
+        skip_sub_check: bool = False,
+        use_proxy: bool = False,
+    ) -> BaseUser:
+        if not use_proxy:
+            return await get_user(
+                self.bot, user_id, skip_cache=skip_cache, skip_sub_check=skip_sub_check
+            )
+        async with self._ensure_run_as_me(user_id) as bot:
+            return await get_user(bot, skip_cache=skip_cache, skip_sub_check=True)
+
+    async def get_user_fn(self, user: Union[str, BaseUser]) -> Optional[str]:
+        if isinstance(user, str):
+            user = await self.get_user(user)
+        return user.fn
+
+    async def get_user_comment(self, user: Union[str, BaseUser]) -> Optional[str]:
+        if isinstance(user, str):
+            user = await self.get_user(user)
+        return user.comment
+
+    async def get_user_note(self, user: Union[str, BaseUser]) -> Optional[str]:
+        if isinstance(user, str):
+            user = await self.get_user(user)
+        return user.note
+
+    async def get_user_staff(self, user: Union[str, BaseUser]) -> bool:
+        if isinstance(user, str):
+            user = await self.get_user(user)
+        return user.staff
+
+    async def get_user_verified(self, user: Union[str, BaseUser]) -> bool:
+        if isinstance(user, str):
+            user = await self.get_user(user)
+        return user.verified
+
+    async def set_user_meta(
+        self,
+        user: Union[str, BaseUser],
+        *,
+        public: Optional[Mapping[str, Any]] = None,
+        trusted: Optional[Mapping[str, Any]] = None,
+        private: Optional[Mapping[str, Any]] = None,
+        attachments: Optional[Iterable[str]] = None,
+        as_root: bool = False,
+        use_proxy: bool = False,
+    ) -> None:
+        if isinstance(user, str):
+            user_id = user
+            user = await self.get_user(user)
+        else:
+            user_id = user.user_id
+        set_desc = pb.SetDesc(
+            public=to_json(public) if public else None,
+            trusted=to_json(trusted) if trusted else None,
+            private=to_json(private) if private else None,
+        )
+        async with self._ensure_run_as_me(user_id, use_proxy=use_proxy) as bot:
+            await bot.set(
+                "me",
+                desc=set_desc,
+                extra=pb.ClientExtra(attachments=attachments, auth_level=pb.ROOT if as_root else None),
+            )
+    
+    async def set_user_public(self, user: Union[str, BaseUser], **kwds: Any) -> None:
+        user = await self._ensure_user(user)
+        public = user.public or {}
+        public.update(**kwds)
+        await self.set_user_meta(user, public=public, use_proxy=True)
+    
+    async def set_user_trusted(self, user: Union[str, BaseUser], **kwds: Any) -> None:
+        user = await self._ensure_user(user)
+        trusted = user.trusted or {}
+        trusted.update(**kwds)
+        await self.set_user_meta(user, trusted=trusted, as_root=True, use_proxy=True)
+    
+    async def set_user_private(self, user: Union[str, BaseUser], **kwds: Any) -> None:
+        user = await self._ensure_user(user)
+        private = user.private or {}
+        private.update(**kwds)
+        await self.set_user_meta(user, private=private, use_proxy=False)
+
+    async def set_user_fn(self, user: Union[str, BaseUser], fn: str) -> None:
+        await self.set_user_public(user, fn=fn)
+    
+    async def set_user_note(self, user: Union[str, BaseUser], note: str) -> None:
+        await self.set_user_public(user, note=note)
+
+    async def set_user_comment(self, user: Union[str, BaseUser], comment: str) -> None:
+        await self.set_user_private(user, comment=comment)
+
+    async def _ensure_user(self, user: Union[str, BaseUser]) -> BaseUser:
+        if isinstance(user, str):
+            return await self.get_user(user)
+        else:
+            return user
+
+    @asynccontextmanager
+    async def _ensure_run_as_me(self, user_id: str, *, use_proxy: bool = True) -> AsyncGenerator[Bot, None]:
+        if user_id == self.bot.user_id:
+            user_id = "me"
+        if user_id == "me" or not use_proxy:
+            yield self.bot
+        else:
+            async with run_bot(ProxyBot.from_bot(self.bot, user_id)) as proxy:
+                yield proxy
diff --git a/karuha/session.py b/karuha/session.py
index bfc6793..b1f6b6b 100644
--- a/karuha/session.py
+++ b/karuha/session.py
@@ -4,12 +4,14 @@
 import re
 import weakref
 from functools import partialmethod
-from typing import (Any, Dict, Iterable, List, NoReturn, Optional, Union,
-                    overload)
+from io import IOBase
+from typing import (Any, BinaryIO, Dict, Iterable, List, NoReturn, Optional,
+                    Union, overload)
 
+from aiofiles import open as aio_open
 from aiofiles.ospath import getsize
 from tinode_grpc import pb
-from typing_extensions import Self
+from typing_extensions import Self, deprecated
 
 import karuha
 
@@ -152,6 +154,19 @@ async def send_attachment(
 
     send_file = partialmethod(send_attachment, attachment_cls_name="File")
     send_image = partialmethod(send_attachment, attachment_cls_name="Image")
+    send_audio = partialmethod(send_attachment, attachment_cls_name="Audio")
+
+    async def download_attachment(self, attachment: "textchain._Attachment", path: Union[str, os.PathLike, BinaryIO]) -> None:
+        if attachment.raw_val is not None:
+            if isinstance(path, (BinaryIO, IOBase)):
+                path.write(attachment.raw_val)
+            else:
+                async with aio_open(path, "wb") as f:
+                    await f.write(attachment.raw_val)
+        elif attachment.ref is not None:
+            await self.bot.download(attachment.ref, path)
+        else:
+            raise ValueError("attachment has no data")
 
     async def wait_reply(
             self,
@@ -300,7 +315,8 @@ async def leave(self, topic: Optional[str] = None, *, force: bool = False, **kwd
         if karuha.data.has_sub(self.bot, topic) or force:
             await self.bot.leave(topic, **kwds)
 
-    async def get_user(self, user_id: str, *, ensure_user: bool = False) -> "karuha.data.BaseUser":
+    @deprecated("use `UserService` instead")
+    async def get_user(self, user_id: str, *, skip_cache: bool = False) -> "karuha.data.BaseUser":
         """Get the user data from the specified user ID.
         
         :param user_id: the user ID to get the data from
@@ -311,9 +327,9 @@ async def get_user(self, user_id: str, *, ensure_user: bool = False) -> "karuha.
         :rtype: "karuha.data.BaseUser"
         """
         self._ensure_status()
-        return await karuha.data.get_user(self.bot, user_id, ensure_user=ensure_user)
+        return await karuha.data.get_user(self.bot, user_id, skip_cache=skip_cache)
 
-    async def get_topic(self, topic: Optional[str] = None, *, ensure_topic: bool = False) -> "karuha.data.BaseTopic":
+    async def get_topic(self, topic: Optional[str] = None, *, skip_cache: bool = False) -> "karuha.data.BaseTopic":
         """Get the topic data from the specified topic ID.
         
         :param topic: the topic ID to get the data from, defaults to None
@@ -324,7 +340,7 @@ async def get_topic(self, topic: Optional[str] = None, *, ensure_topic: bool = F
         :rtype: "karuha.data.BaseTopic"
         """
         self._ensure_status()
-        return await karuha.data.get_topic(self.bot, topic or self.topic, ensure_topic=ensure_topic)
+        return await karuha.data.get_topic(self.bot, topic or self.topic, skip_cache=skip_cache)
 
     @overload
     async def get_data(
diff --git a/karuha/text/message.py b/karuha/text/message.py
index f272d27..4f50b18 100644
--- a/karuha/text/message.py
+++ b/karuha/text/message.py
@@ -146,6 +146,8 @@ class _HeadDependency(HandlerInvokerDependency):
 
     @classmethod
     def resolve_dependency(cls, invoker: Message, param: Parameter, **kwds: Any) -> Any:
+        if not isinstance(invoker, Message):
+            raise KaruhaHandlerInvokerError(f"cannot resolve head dependency for {param.name!r}")
         return invoker.head.get(param.name)
 
 
diff --git a/karuha/text/textchain.py b/karuha/text/textchain.py
index 24e7ce1..a9902f3 100644
--- a/karuha/text/textchain.py
+++ b/karuha/text/textchain.py
@@ -1,13 +1,15 @@
 import mimetypes
-import os
 import operator as op
+import os
 from abc import abstractmethod
 from base64 import b64decode, b64encode
-from typing import (Any, ClassVar, Dict, Final, Generator, Iterable, List, Literal,
-                    MutableMapping, Optional, Sequence, SupportsIndex, Type,
-                    Union, overload)
+from io import BytesIO
+from typing import (Any, BinaryIO, ClassVar, Dict, Final, Generator, Iterable,
+                    List, Literal, MutableMapping, Optional, Sequence,
+                    SupportsIndex, Type, Union, overload)
 
 from aiofiles import open as aio_open
+from puremagic import from_stream
 from pydantic import AnyHttpUrl, BaseModel, model_validator
 from typing_extensions import Self
 
@@ -510,6 +512,15 @@ async def from_file(
                 **kwds
             )
     
+    @classmethod
+    def analyze_bytes(cls, data: bytes, *, name: Optional[str] = None) -> Dict[str, Any]:
+        return cls.analyze_file(BytesIO(data), name=name)
+
+    @classmethod
+    def analyze_file(cls, fp: BinaryIO, *, name: Optional[str] = None) -> Dict[str, Any]:
+        mime = from_stream(fp, True, name)
+        return {"mime": mime}
+    
     async def save(self, path: Union[str, os.PathLike, None] = None) -> None:
         path = path or self.name
         if path is None:
@@ -570,6 +581,10 @@ class Image(_Attachment):
     width: int
     height: int
 
+    @classmethod
+    def analyze_file(cls, fp: BinaryIO) -> Dict[str, Any]:
+        return super().analyze_file(fp)
+
     @classmethod
     async def from_file(
             cls,
diff --git a/karuha/utils/context.py b/karuha/utils/context.py
index 6f4d8b3..2faa46f 100644
--- a/karuha/utils/context.py
+++ b/karuha/utils/context.py
@@ -1,14 +1,51 @@
+from contextlib import AbstractAsyncContextManager, AbstractContextManager
 from types import TracebackType
-from typing import Type
+from typing import Any, Generic, Type, TypeVar
+
 from typing_extensions import Self
 
+T = TypeVar("T")
+
 
-class _ContextHelper(object):
+class _ContextHelper(AbstractContextManager):
     __slots__ = []
 
     def __enter__(self) -> Self:
-        self.activate()  # type: ignore
+        self.activate()
         return self
     
     def __exit__(self, exc_type: Type[BaseException], exc_ins: BaseException, traceback: TracebackType) -> None:
-        self.deactivate()  # type: ignore
+        self.deactivate()
+    
+    def activate(self) -> None:
+        raise NotImplementedError
+    
+    def deactivate(self) -> None:
+        raise NotImplementedError
+
+
+class nullcontext(AbstractContextManager, AbstractAsyncContextManager, Generic[T]):
+    """Context manager that does no additional processing.
+
+    Used as a stand-in for a normal context manager, when a particular
+    block of code is only sometimes used with a normal context manager:
+
+    cm = optional_cm if condition else nullcontext()
+    with cm:
+        # Perform operation, using optional_cm if condition is True
+    """
+
+    def __init__(self, enter_result: T = None) -> None:
+        self.enter_result = enter_result
+
+    def __enter__(self) -> T:
+        return self.enter_result
+
+    def __exit__(self, *excinfo: Any) -> None:
+        pass
+
+    async def __aenter__(self) -> T:
+        return self.enter_result
+
+    async def __aexit__(self, *excinfo: Any) -> None:
+        pass
diff --git a/karuha/utils/decode.py b/karuha/utils/decode.py
index fd2ecbf..f2a99b7 100644
--- a/karuha/utils/decode.py
+++ b/karuha/utils/decode.py
@@ -1,7 +1,7 @@
 from typing import Any, Dict, Mapping, Union
 
-from google.protobuf.internal import containers
 from google.protobuf.message import Message
+from google.protobuf.json_format import MessageToDict
 from pydantic_core import from_json, to_json
 
 
@@ -20,14 +20,4 @@ def decode_mapping(data: Mapping[str, bytes]) -> Dict[str, Any]:
 
 
 def msg2dict(msg: Message) -> Dict[str, Any]:
-    return _msg2obj(msg)
-
-
-def _msg2obj(msg: Any) -> Any:
-    if isinstance(msg, Message):
-        return {k.name: _msg2obj(v) for k, v in msg.ListFields()}
-    elif isinstance(msg, containers.ScalarMap):
-        return dict(msg)
-    elif isinstance(msg, containers.BaseContainer):
-        return list(msg)
-    return msg
+    return MessageToDict(msg)
diff --git a/karuha/utils/dispatcher.py b/karuha/utils/dispatcher.py
index e477347..50b7311 100644
--- a/karuha/utils/dispatcher.py
+++ b/karuha/utils/dispatcher.py
@@ -1,5 +1,5 @@
 import asyncio
-from abc import ABC, abstractmethod
+from abc import abstractmethod
 from typing import Any, Callable, ClassVar, Generic, Optional, Set, TypeVar
 from typing_extensions import Self
 
@@ -9,7 +9,7 @@
 T = TypeVar("T")
 
 
-class AbstractDispatcher(ABC, _ContextHelper, Generic[T]):
+class AbstractDispatcher(_ContextHelper, Generic[T]):
     __slots__ = ["once"]
 
     dispatchers: ClassVar[Set[Self]] = set()
diff --git a/karuha/utils/invoker.py b/karuha/utils/invoker.py
index b1b9f86..d5dc302 100644
--- a/karuha/utils/invoker.py
+++ b/karuha/utils/invoker.py
@@ -50,7 +50,7 @@ def resolve_missing_dependencies(
 
         # resolve dependency class & instance
         for param in missing:
-            param_type = get_origin(param.annotation)
+            param_type = get_origin(param.annotation) or param.annotation
             if param_type is Annotated:
                 for i in get_args(param.annotation):
                     if isinstance(i, HandlerInvokerDependency) or isclass(i) and issubclass(i, HandlerInvokerDependency):
diff --git a/karuha/version.py b/karuha/version.py
index c656e58..e5b22b3 100644
--- a/karuha/version.py
+++ b/karuha/version.py
@@ -1,4 +1,4 @@
 from importlib.metadata import distribution
 
-APP_VERSION = __version__ = "0.2.2"
+APP_VERSION = __version__ = "0.2.3"
 LIB_VERSION = distribution("tinode_grpc").version
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..e0e2b32
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,54 @@
+[build-system]
+requires = ["setuptools>=61.0", "wheel"]
+build-backend = "setuptools.build_meta"
+
+[project]
+name = "KaruhaBot"
+description = "A simple Tinode chatbot framework."
+readme = "README.md"
+license = {text = "Apache 2.0"}
+authors = [
+    {name = "Visecy", email = "Visecy@visecy.org"},
+]
+maintainers = [
+    {name = "Ovizro", email = "Ovizro@visecy.org"},
+]
+urls = {repository = "https://github.com/Visecy/Karuha"}
+classifiers = [
+    "Development Status :: 5 - Production/Stable",
+    "License :: OSI Approved :: Apache Software License",
+    "Programming Language :: Python :: 3",
+    "Programming Language :: Python :: 3 :: Only",
+    "Programming Language :: Python :: 3.8",
+    "Programming Language :: Python :: 3.9",
+    "Programming Language :: Python :: 3.10",
+    "Programming Language :: Python :: 3.11",
+    "Programming Language :: Python :: 3.12",
+    "Topic :: Software Development :: Libraries :: Python Modules",
+    "Topic :: Communications :: Chat",
+]
+
+dependencies = [
+    "typing_extensions>=4.9",
+    "grpcio>=1.40.0",
+    "tinode-grpc>=0.20.0b3",
+    "pydantic>=2.9",
+    "aiohttp>=3.7",
+    "aiofiles>=23.1",
+    "puremagic>=1.20"
+]
+requires-python = ">=3.8"
+dynamic = ["version"]
+
+[project.optional-dependencies]
+lint = ["flake8>=6.1"]
+image = ["pillow>=10.0"]
+data = ["greenback"]
+all = ["pillow>=10.0", "greenback"]
+
+[tool.setuptools.packages.find]
+where = ["."]
+include = ["karuha*"]
+
+[tool.setuptools.dynamic]
+version = {attr = "karuha.version.__version__"}
diff --git a/setup.py b/setup.py
index 6b5f0ba..73bc453 100644
--- a/setup.py
+++ b/setup.py
@@ -54,11 +54,13 @@
         "typing_extensions>=4.9",
         "grpcio>=1.40.0",
         "tinode-grpc>=0.20.0b3",
-        "pydantic>2.0",
+        "pydantic>=2.9",
         "aiohttp>=3.7",
-        "aiofiles>=23.1"
+        "aiofiles>=23.1",
+        "puremagic>=1.20"
     ],
     extras_require={
+        "lint": ["flake8>=6.1"],
         "image": ["pillow>=10.0"],
         "data": ["greenback"],
         "all": ["pillow>=10.0", "greenback"],


Test patch:
diff --git a/.github/workflows/build_test.yml b/.github/workflows/build_test.yml
index 953a8b6..d6e78d9 100644
--- a/.github/workflows/build_test.yml
+++ b/.github/workflows/build_test.yml
@@ -26,7 +26,7 @@ jobs:
     - name: Upgrade pip
       run: |
         python -m pip install --upgrade pip
-        pip install setuptools wheel
+        pip install setuptools wheel build
     - name: Build dist and test with unittest
       run: |
         make build install_all test build_dist
diff --git a/tests/client/test_bot.py b/tests/client/test_bot.py
index 20eb516..0c039df 100644
--- a/tests/client/test_bot.py
+++ b/tests/client/test_bot.py
@@ -1,11 +1,10 @@
-from functools import partial
-from io import StringIO
+from io import BytesIO
 
 from tinode_grpc import pb
 
-from karuha import BaseSession, MessageSession, add_bot, remove_bot, on_rule
-from karuha.bot import AgentBot
-from karuha.event.bot import BotReadyEvent
+from karuha import BaseSession, MessageSession, on_rule
+from karuha.bot import ProxyBot
+from karuha.runner import run_bot
 from ..utils import AsyncBotClientTestCase
 
 
@@ -15,13 +14,13 @@ async def test_hi(self) -> None:
         await self.bot.hello(lang="CN")
 
     async def test_upload(self) -> None:
-        f = StringIO("Hello world!")
+        f = BytesIO(b"Hello world!")
         _, params = await self.bot.upload(f)
         self.assertTrue("url" in params)
 
-        f = StringIO()
+        f = BytesIO()
         await self.bot.download(params["url"], f)
-        self.assertEqual(f.getvalue(), "Hello world!")
+        self.assertEqual(f.getvalue(), b"Hello world!")
 
     async def test_account(self) -> None:
         _, params = await self.bot.account(
@@ -32,38 +31,44 @@ async def test_account(self) -> None:
             do_login=False,
         )
         uid = params["user"]
+        await self.bot.delete("topic", topic=uid, hard=True)
         await self.bot.delete("user", user_id=uid, hard=True)
     
-    async def test_on_behalf_of(self) -> None:
+    async def test_anonymous_account(self) -> None:
         _, params = await self.bot.account(
-            "newkr_test",
+            "newkr_anon_test",
+            "anonymous",
+            do_login=False,
+        )
+        uid = params["user"]
+        await self.bot.delete("topic", topic=uid, hard=True)
+        await self.bot.delete("user", user_id=uid, hard=True)
+    
+    async def test_proxy_bot(self) -> None:
+        _, params = await self.bot.account(
+            "new",
             "basic",
             b"test:test123456",
+            state="ok",
             cred=[pb.ClientCred(method="email", value="test@example.com")],
             do_login=False,
         )
         uid = params["user"]
         try:
-            agent_bot = AgentBot.from_bot(self.bot, on_behalf_of=uid)
-            add_bot(agent_bot)
-            self.addCleanup(partial(remove_bot, agent_bot))
-            
-            with self.catchEvent(BotReadyEvent) as catcher:
-                ev = await catcher.catch_event()
-                self.assertIs(ev.bot, agent_bot)
-            self.assertEqual(agent_bot.user_id, uid)
-            self.assertEqual(agent_bot.login_user_id, self.bot.user_id)
+            async with run_bot(ProxyBot.from_bot(self.bot, on_behalf_of=uid)) as agent_bot:
+                assert isinstance(agent_bot, ProxyBot)
+                self.assertEqual(agent_bot.user_id, uid)
+                self.assertEqual(agent_bot.login_user_id, self.bot.user_id)
 
-            @on_rule(user_id=uid, bot=self.bot)
-            async def _handler(session: MessageSession, text: str) -> None:
-                self.assertEqual(text, "test")
-                await session.send("test_reply")
-            
-            self.addCleanup(_handler.deactivate)
-            
-            async with BaseSession(agent_bot, self.bot.user_id) as session:
-                await session.send("test")
-                reply = await session.wait_reply()
-                self.assertEqual(reply.plain_text, "test_reply")
+                @on_rule(user_id=uid, bot=self.bot, once=True)
+                async def _handler(session: MessageSession, text: str) -> None:
+                    self.assertEqual(text, "test")
+                    await session.send("test_reply")
+                
+                async with BaseSession(agent_bot, self.bot.user_id) as session:
+                    await session.send("test")
+                    reply = await session.wait_reply()
+                    self.assertEqual(reply.plain_text, "test_reply")
         finally:
+            await self.bot.delete("topic", topic=uid, hard=True)
             await self.bot.delete("user", user_id=uid, hard=True)
diff --git a/tests/test_bot.py b/tests/test_bot.py
index 6960b21..9109e77 100644
--- a/tests/test_bot.py
+++ b/tests/test_bot.py
@@ -102,6 +102,7 @@ async def test_client_message(self) -> None:
             self.assertEqual(e.client_message, login_msg_inner)
             self.assertIsNotNone(e.response_message)
 
+        sub_task = asyncio.create_task(bot.subscribe("me"))
         sub_msg = await bot.consum_message()
         sub_msg_inner = sub_msg.sub
         self.assertIsInstance(sub_msg_inner, pb.ClientSub)
diff --git a/tests/test_data.py b/tests/test_data.py
index b10362d..c4f21e6 100644
--- a/tests/test_data.py
+++ b/tests/test_data.py
@@ -3,7 +3,8 @@
 from tinode_grpc import pb
 
 from karuha.data.cache import UserCache, clear_cache, get_user_cred, get_user_tags, update_user_cache, user_cache
-from karuha.data.meta import AccessPermission, BaseDesc, Cred, UserDesc
+from karuha.data.meta import BaseDesc, UserDesc
+from karuha.data.model import AccessPermission, Cred
 from karuha.data.sub import has_sub
 from karuha.data.topic import BaseTopic, Topic, TopicSub, get_topic, get_topic_list
 from karuha.data.user import BaseUser, User, get_user
@@ -52,6 +53,10 @@ def test_access(self) -> None:
             ).model_dump(),
             'JRW'
         )
+        self.assertEqual(
+            AccessPermission().model_dump(),
+            "N"
+        )
 
     def test_cache(self) -> None:
         base_desc = BaseDesc(
@@ -83,7 +88,7 @@ def test_cache(self) -> None:
         self.assertEqual(cache.desc.created, desc.created)
 
     async def test_me_meta(self) -> None:
-        task = asyncio.create_task(get_user(self.bot, ensure_user=True))
+        task = asyncio.create_task(get_user(self.bot, skip_cache=True))
         get_msg = await self.bot.consum_message()
         assert get_msg.get
         get_msg = get_msg.get
@@ -117,9 +122,9 @@ async def test_me_meta(self) -> None:
 
     async def test_me_sub_meta(self) -> None:
         clear_cache()
-        task = asyncio.create_task(get_topic_list(self.bot, ensure_topic_sub=True))
+        task = asyncio.create_task(get_topic_list(self.bot, ensure_all=True))
         get_msg = await self.bot.consum_message()
-        assert get_msg.get
+        assert get_msg.HasField("get"), get_msg
         get_msg = get_msg.get
         meta = pb.ServerMeta(
             id=get_msg.id,
@@ -158,7 +163,7 @@ async def test_me_sub_meta(self) -> None:
         subs = await self.wait_for(task)
         self.assertEqual(len(subs), 2)
         for sub in subs:
-            self.assertIsInstance(sub, TopicSub)
+            assert isinstance(sub, TopicSub)
             if sub.topic == "grp_test_1":
                 self.assertEqual(sub.public, {"fn": "Test Group"})
                 self.assertIsNotNone(sub.touched)
@@ -235,14 +240,12 @@ async def test_tag_and_cred(self) -> None:
     
     async def test_p2p_meta(self) -> None:
         clear_cache()
-        task = asyncio.create_task(get_topic(self.bot, "usr_test_1", ensure_topic=True))
-        sub_msg = await self.bot.consum_message()
-        assert sub_msg.HasField("sub") and sub_msg.sub.topic == "usr_test_1"
-        self.bot.confirm_message()
-
+        task = asyncio.create_task(get_topic(self.bot, "usr_test_1", skip_cache=True))
+    
         get_msg = await self.bot.consum_message()
         assert get_msg.HasField("get")
         get_msg = get_msg.get
+        self.assertEqual(get_msg.query.what, "desc")
         meta = pb.ServerMeta(
             id=get_msg.id,
             topic="usr_test_1",
@@ -250,35 +253,29 @@ async def test_p2p_meta(self) -> None:
                 created_at=1684421151062,
                 updated_at=1684421151062,
                 touched_at=1709023886332,
-                acs=pb.AccessMode(want="JRWPA", given="JRWPAS"),
-                seq_id=285,
-                read_id=285,
-                recv_id=285,
-                del_id=22,
+                acs=pb.AccessMode(),  # want="JRWPA", given="JRWPAS"
+                # seq_id=285,
+                # read_id=285,
+                # recv_id=285,
+                # del_id=22,
                 public=to_json({"fn": "Test User"}),
-                last_seen_time=1709705329000,
-                last_seen_user_agent="Tindroid/0.22.12 (Android 11; zh_CN); tindroid/0.22.12"
+                # last_seen_time=1709705329000,
+                # last_seen_user_agent="Tindroid/0.22.12 (Android 11; zh_CN); tindroid/0.22.12"
             )
         )
+        self.bot.receive_message(pb.ServerMsg(meta=meta))
+
         self.bot.receive_message(pb.ServerMsg(meta=meta))
         topic = await self.wait_for(task)
+        assert isinstance(topic, Topic)
         self.assertEqual(topic.topic, "usr_test_1")
         self.assertEqual(topic.fn, "Test User")
         self.assertIsNone(topic.note)
-        self.assertEqual(topic.seq, 285)
         self.assertIsNone(topic.defacs)
-        assert topic.acs
-        self.assertEqual(
-            topic.acs.want,
-            AccessPermission(join=True, read=True, write=True, presence=True, approve=True)
-        )
     
     async def test_topic_meta(self) -> None:
         clear_cache()
-        task = asyncio.create_task(get_topic(self.bot, "grp_test_1", ensure_topic=True))
-        sub_msg = await self.bot.consum_message()
-        assert sub_msg.HasField("sub") and sub_msg.sub.topic == "grp_test_1"
-        self.bot.confirm_message()
+        task = asyncio.create_task(get_topic(self.bot, "grp_test_1"))
 
         get_msg = await self.bot.consum_message()
         assert get_msg.HasField("get")
@@ -303,7 +300,7 @@ async def test_topic_meta(self) -> None:
         self.bot.receive_message(pb.ServerMsg(meta=meta))
         
         topic = await self.wait_for(task)
-        self.assertIsInstance(topic, Topic)
+        assert isinstance(topic, Topic)
         self.assertEqual(topic.topic, "grp_test_1")
         self.assertEqual(topic.fn, "Test Group")
         self.assertEqual(topic.note, "Test Group Note")
diff --git a/tests/test_service.py b/tests/test_service.py
new file mode 100644
index 0000000..227be97
--- /dev/null
+++ b/tests/test_service.py
@@ -0,0 +1,11 @@
+from karuha.service import BaseService
+from .utils import AsyncBotTestCase, new_test_message
+
+
+class ServiceTestCase(AsyncBotTestCase):
+    def test_base(self) -> None:
+        msg = new_test_message()
+        
+        def f(base_svc: BaseService) -> BaseService:
+            return base_svc
+        self.assertEqual(msg.call_handler(f).bot, msg.bot)