├── .github
└── workflows
│ └── pypi-publish.yml
├── .gitignore
├── LICENSE
├── README.md
├── nonebot
└── adapters
│ └── ntchat
│ ├── __init__.py
│ ├── adapter.py
│ ├── bot.py
│ ├── bot.pyi
│ ├── collator.py
│ ├── config.py
│ ├── event.py
│ ├── exception.py
│ ├── message.py
│ ├── permission.py
│ ├── store.py
│ ├── type.py
│ └── utils.py
├── requirements.txt
└── setup.py
/.github/workflows/pypi-publish.yml:
--------------------------------------------------------------------------------
1 | name: Publish Python 🐍 distributions 📦 to PyPI
2 |
3 | on: push
4 |
5 | jobs:
6 | build-n-publish:
7 | name: Build and publish Python 🐍 distributions 📦 to PyPI
8 | runs-on: ubuntu-20.04
9 | steps:
10 | - uses: actions/checkout@master
11 | - name: Set up Python 3.8
12 | uses: actions/setup-python@v1
13 | with:
14 | python-version: 3.8
15 | - name: Install pypa/build
16 | run: >-
17 | python -m
18 | pip install
19 | build
20 | --user
21 | - name: Build a binary wheel and a source tarball
22 | run: >-
23 | python -m
24 | build
25 | --sdist
26 | --wheel
27 | --outdir dist/
28 | .
29 | - name: Publish distribution 📦 to PyPI
30 | if: startsWith(github.ref, 'refs/tags')
31 | uses: pypa/gh-action-pypi-publish@master
32 | with:
33 | password: ${{ secrets.PYPI_API_TOKEN }}
34 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Byte-compiled / optimized / DLL files
2 | __pycache__/
3 | *.py[cod]
4 | *$py.class
5 |
6 | # C extensions
7 | *.so
8 |
9 | # Distribution / packaging
10 | .Python
11 | build/
12 | develop-eggs/
13 | dist/
14 | downloads/
15 | eggs/
16 | .eggs/
17 | lib/
18 | lib64/
19 | parts/
20 | sdist/
21 | var/
22 | wheels/
23 | pip-wheel-metadata/
24 | share/python-wheels/
25 | *.egg-info/
26 | .installed.cfg
27 | *.egg
28 | MANIFEST
29 |
30 | # PyInstaller
31 | # Usually these files are written by a python script from a template
32 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
33 | *.manifest
34 | *.spec
35 |
36 | # Installer logs
37 | pip-log.txt
38 | pip-delete-this-directory.txt
39 |
40 | # Unit test / coverage reports
41 | htmlcov/
42 | .tox/
43 | .nox/
44 | .coverage
45 | .coverage.*
46 | .cache
47 | nosetests.xml
48 | coverage.xml
49 | *.cover
50 | *.py,cover
51 | .hypothesis/
52 | .pytest_cache/
53 |
54 | # Translations
55 | *.mo
56 | *.pot
57 |
58 | # Django stuff:
59 | *.log
60 | local_settings.py
61 | db.sqlite3
62 | db.sqlite3-journal
63 |
64 | # Flask stuff:
65 | instance/
66 | .webassets-cache
67 |
68 | # Scrapy stuff:
69 | .scrapy
70 |
71 | # Sphinx documentation
72 | docs/_build/
73 |
74 | # PyBuilder
75 | target/
76 |
77 | # Jupyter Notebook
78 | .ipynb_checkpoints
79 |
80 | # IPython
81 | profile_default/
82 | ipython_config.py
83 |
84 | # pyenv
85 | .python-version
86 |
87 | # pipenv
88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies
90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not
91 | # install all needed dependencies.
92 | #Pipfile.lock
93 |
94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow
95 | __pypackages__/
96 |
97 | # Celery stuff
98 | celerybeat-schedule
99 | celerybeat.pid
100 |
101 | # SageMath parsed files
102 | *.sage.py
103 |
104 | # Environments
105 | .env
106 | .venv
107 | env/
108 | venv/
109 | ENV/
110 | env.bak/
111 | venv.bak/
112 |
113 | # Spyder project settings
114 | .spyderproject
115 | .spyproject
116 |
117 | # Rope project settings
118 | .ropeproject
119 |
120 | # mkdocs documentation
121 | /site
122 |
123 | # mypy
124 | .mypy_cache/
125 | .dmypy.json
126 | dmypy.json
127 |
128 | # Pyre type checker
129 | .pyre/
130 | .vscode/
131 | *.code-workspace
132 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2022 那个小白白白
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
Nonebot Adapter Ntchat
2 |
3 |
4 |
5 |
6 |
7 |
8 | ## 新的开始
9 | 这边已经不再维护,但可继续使用。
10 |
11 | 可以关注新坑:[ComWeChatBotClient](https://github.com/JustUndertaker/ComWeChatBotClient) ,使用onebot12协议,更方便做一些操作。
12 |
13 |
14 | ## 简介
15 |
16 | nonebot2的ntchat适配器,配合 [ntchat-client](https://github.com/JustUndertaker/ntchat-client) 可以实现与微信对接。
17 |
18 | ## 安装方式
19 |
20 | ### 使用包管理安装(推荐)
21 |
22 | ```bash
23 | pip install nonebot-adapter-ntchat
24 | ```
25 |
26 | ### 使用源码(不推荐)
27 |
28 | ```bash
29 | git clone https://github.com/JustUndertaker/adapter-ntchat.git
30 | ```
31 |
32 | 将目录复制到`site-packages`下
33 |
34 | ## 已实现连接方式
35 |
36 | - [x] 反向ws
37 | - [x] http post
38 | - [ ] ~~正向ws~~
39 |
40 | 其他的感觉用处不大就...
41 |
42 | ## 配置内容
43 |
44 | ```dotenv
45 | access_token = ""
46 | ```
47 |
48 | 可不填,如填写需要与 ntchat-lient 一致。
49 |
50 | ### 使用反向ws:
51 |
52 | 默认配置使用反向ws,无需调整
53 |
54 | ### 使用http post
55 |
56 | 需要将driver类型设置为:ForwardDriver,同时配置http api地址。
57 |
58 | 设置方法:[文档](https://v2.nonebot.dev/docs/next/tutorial/choose-driver)
59 |
60 | 示例:
61 |
62 | ``` dotenv
63 | DRIVER=~httpx
64 | ntchat_http_api_root="http://127.0.0.1:8000"
65 | ```
66 |
67 | ## 注意事项
68 |
69 | 由于微信不支持连续不同类型消息发出(比如图文消息,发出来会变成2条),需注意:
70 |
71 | - matcher的默认发送支持str,MessageSegment,Message,但是发送Message会同时发送多条消息(每个MessageSegment都会发送一条消息)。
72 |
73 | ## 已实现事件
74 |
75 | ### 消息事件
76 |
77 | 消息事件可以使用`on_message`、`on_regex`、`on_keyword`等检测命令的方式触发,但是部分消息如`图片消息`没有文本返回,所有message内容为空字符串,但是可以用`on_message`捕获该事件,如果想单独监听某类事件,可以在`matcher.handle()`内的`event`参数单独注入事件类型,比如:`event: PictureMessageEvent`来处理图片事件
78 |
79 | - **TextMessageEvent**:文本消息事件
80 | - **QuoteMessageEvent**:引用回复消息事件
81 | - **PictureMessageEvent**:图片消息事件
82 | - **VoiceMessageEvent**:语音消息事件
83 | - **CardMessageEvent**:名片消息事件
84 | - **VideoMessageEvent**:视频消息事件
85 | - **EmojiMessageEvent**:表情消息事件
86 | - **LocationMessageEvent**:位置消息事件
87 | - **FileMessageEvent**:文件消息事件
88 |
89 | ### 请求事件
90 |
91 | 请求事件可以使用`on_request`进行捕获
92 |
93 | - **FriendAddRequestEvent**:添加好友请求事件
94 |
95 | ### 通知事件
96 |
97 | 通知事件可以使用`on_notice`进行捕获
98 |
99 | - **RevokeNoticeEvent**:撤回消息事件
100 | - **FriendAddNoticeEvent**:添加好友通知事件
101 | - **InvitedRoomEvent**:被邀请入群通知事件
102 |
103 | ### APP事件
104 |
105 | APP事件,事件type是`app`,可以通过`on("app")`来监听此类事件
106 |
107 | - **LinkMessageEvent**:链接消息事件
108 | - **MiniAppMessageEvent**:小程序消息事件
109 | - **WcpayMessageEvent**:转账消息事件
110 | - **OtherAppMessageEvent**:其他应用消息,字段`wx_sub_type`未知
111 |
112 | ### 监听事件
113 |
114 | 除了通用的on_message,on_notice等一般行为,想要监听单独某个事件时,可以使用`on`来注册一个matcher,此函数第一个参数为事件`type`,比如:
115 |
116 | ```python
117 | from nonebot.plugin import on
118 | from nonebot.adapter.ntchat import WcpayMessageEvent
119 |
120 | matcher = on("app") # rule,permission等参数同样可以加入
121 |
122 | @matcher.handle()
123 | async def _(event:WcpayMessageEvent):
124 | pass
125 | ```
126 |
127 | 上述例子会监听所有的转账事件。
128 |
129 | ### 发送图片
130 |
131 | 使用MessageSegment.image发送图片(其他消息同理),图片与其他文件支持url、bytes、BytesIO、base64、Path发送,手动发送base64时需要在字符串前面加上"base64://",下面是发送图片的例子.
132 |
133 | ```python
134 | from base64 import b64encode
135 | from io import BytesIO
136 | from pathlib import Path
137 |
138 | from nonebot import on_regex
139 | from nonebot.adapter.ntchat import MessageSegment,TextMessageEvent
140 |
141 | matcher = on_regex(r"^测试$")
142 |
143 | @matcher.handle()
144 | async def _(event:TextMessageEvent):
145 | url = "https://v2.nonebot.dev/logo.png"
146 | image = MessageSegment.image(url) # 使用url构造
147 | await matcher.send(image)
148 |
149 | image_path = Path("./1.png")
150 | image = MessageSegment.image(image_path) # 使用Path构造
151 | await matcher.send(image)
152 |
153 | with open(image_path, mode="rb") as f:
154 | data = f.read()
155 | image = MessageSegment.image(data) # 使用bytes构造
156 | await matcher.send(image)
157 |
158 | bio = BytesIO(data)
159 | image = MessageSegment.image(bio) # 使用BytesIO构造
160 | await matcher.send(image)
161 |
162 | base64_data = f"base64://{b64encode(data).decode()}" # 使用base64构造
163 | image = MessageSegment.image(base64_data)
164 | await matcher.finish(image)
165 | ```
166 |
167 |
168 |
169 | ### Permission
170 |
171 | 内置2个Permission,为:
172 |
173 | - **PRIVATE**:匹配任意私聊消息类型事件
174 | - **GROUP**:匹配任意群聊消息类型事件
175 |
176 | ## 已实现api
177 |
178 | - **get_login_info**:获取登录信息
179 | - **get_self_info**:获取自己个人信息跟登录信息类似
180 | - **get_contacts**:获取联系人列表
181 | - **get_publics**:获取关注公众号列表
182 | - **get_contact_detail**:获取联系人详细信息
183 | - **search_contacts**:根据wxid、微信号、昵称和备注模糊搜索联系人
184 | - **get_rooms**:获取群列表
185 | - **get_room_detail**:获取指定群详细信息
186 | - **get_room_members**:获取群成员列表
187 | - **send_text**:发送文本消息
188 | - **send_room_at_msg**:发送群@消息,需要注意:
189 | - 假如文本为:"test,你好{$@},你好{$@}.早上好"
190 | - 文本消息的content的内容中设置占位字符串 {$@},这些字符的位置就是最终的@符号所在的位置
191 | - 假设这两个被@的微信号的群昵称分别为aa,bb
192 | - 则实际发送的内容为 "test,你好@ aa,你好@ bb.早上好"(占位符被替换了)
193 | - 占位字符串的数量必须和at_list中的微信数量相等.
194 | - **send_card**:发送名片
195 | - **send_link_card**:发送链接卡片
196 | - **send_image**:发送图片接口
197 | - **send_file**:发送文件
198 | - **send_video**:发送视频
199 | - **send_gif**:发送gif图片
200 | - **send_xml**:发送xml
201 | - **send_pat**:发送拍一拍
202 | - **accept_friend_request**:同意加好友请求
203 | - **create_room**:创建群
204 | - **add_room_member**:添加好友入群
205 | - **invite_room_member**:邀请好友入群
206 | - **del_room_member**:删除群成员
207 | - **modify_room_name**:修改群名
208 | - **modify_room_notice**:修改群公告
209 | - **add_room_friend**:添加群成员为好友
210 | - **quit_room**:退出群
211 | - **modify_friend_remark**:修改好友备注
212 |
213 |
--------------------------------------------------------------------------------
/nonebot/adapters/ntchat/__init__.py:
--------------------------------------------------------------------------------
1 | from .adapter import Adapter as Adapter
2 | from .bot import Bot as Bot
3 | from .event import *
4 | from .message import MessageSegment as MessageSegment
5 | from .permission import GROUP as GROUP
6 | from .permission import PRIVATE as PRIVATE
7 |
--------------------------------------------------------------------------------
/nonebot/adapters/ntchat/adapter.py:
--------------------------------------------------------------------------------
1 | """ntchat适配器
2 | 适配ntchat服务
3 | """
4 | import asyncio
5 | import contextlib
6 | import inspect
7 | import json
8 | from typing import Any, Dict, List, Optional, cast
9 |
10 | from nonebot.drivers.fastapi import Driver
11 | from nonebot.exception import ApiNotAvailable, NetworkError, WebSocketClosed
12 | from nonebot.internal.driver import (
13 | URL,
14 | ForwardDriver,
15 | HTTPServerSetup,
16 | Request,
17 | Response,
18 | WebSocket,
19 | WebSocketServerSetup,
20 | )
21 | from nonebot.typing import overrides
22 | from nonebot.utils import DataclassEncoder, escape_tag
23 |
24 | from nonebot.adapters import Adapter as BaseAdapter
25 |
26 | from . import event
27 | from .bot import Bot
28 | from .collator import EventModels
29 | from .config import Config
30 | from .event import Event
31 | from .store import ResultStore
32 | from .utils import handle_api_result, log
33 |
34 | event_models = EventModels[Event]()
35 | """事件模型创建器"""
36 |
37 |
38 | class Adapter(BaseAdapter):
39 |
40 | _result_store = ResultStore()
41 | """api回调存储"""
42 | ntchat_config: Config
43 | """ntchat配置"""
44 |
45 | def __init__(self, driver: Driver, **kwargs) -> None:
46 | super().__init__(driver, **kwargs)
47 | self.ntchat_config: Config = Config(**self.config.dict())
48 | self.connections: Dict[str, WebSocket] = {}
49 | self.tasks: List["asyncio.Task"] = []
50 | self._search_events()
51 | self._setup()
52 |
53 | def _search_events(self) -> None:
54 | """搜索事件模型"""
55 | for model_name in dir(event):
56 | model = getattr(event, model_name)
57 | if not inspect.isclass(model) or not issubclass(model, Event):
58 | continue
59 | event_models.add_event_model(model)
60 |
61 | def _setup(self) -> None:
62 | http_setup = HTTPServerSetup(
63 | URL("/ntchat/"), "POST", self.get_name(), self._handle_http
64 | )
65 | self.setup_http_server(http_setup)
66 | http_setup = HTTPServerSetup(
67 | URL("/ntchat/http"), "POST", self.get_name(), self._handle_http
68 | )
69 | self.setup_http_server(http_setup)
70 | http_setup = HTTPServerSetup(
71 | URL("/ntchat/http/"), "POST", self.get_name(), self._handle_http
72 | )
73 | self.setup_http_server(http_setup)
74 |
75 | ws_setup = WebSocketServerSetup(
76 | URL("/ntchat/"), self.get_name(), self._handle_ws
77 | )
78 | self.setup_websocket_server(ws_setup)
79 | ws_setup = WebSocketServerSetup(
80 | URL("/ntchat/ws"), self.get_name(), self._handle_ws
81 | )
82 | self.setup_websocket_server(ws_setup)
83 | ws_setup = WebSocketServerSetup(
84 | URL("/ntchat/ws/"), self.get_name(), self._handle_ws
85 | )
86 | self.setup_websocket_server(ws_setup)
87 |
88 | @classmethod
89 | @overrides(BaseAdapter)
90 | def get_name(cls) -> str:
91 | """适配器名称: `ntchat`"""
92 | return "ntchat"
93 |
94 | @overrides(BaseAdapter)
95 | async def _call_api(self, bot: Bot, api: str, **data: Any) -> Any:
96 | websocket = self.connections.get(bot.self_id, None)
97 | timeout: float = data.get("_timeout", self.config.api_timeout)
98 | log("DEBUG", f"Calling API {api}")
99 |
100 | if websocket:
101 | seq = self._result_store.get_seq()
102 | json_data = json.dumps(
103 | {"action": api, "params": data, "echo": str(seq)},
104 | cls=DataclassEncoder,
105 | ensure_ascii=False,
106 | )
107 | await websocket.send(json_data)
108 | return handle_api_result(
109 | await self._result_store.fetch(bot.self_id, seq, timeout)
110 | )
111 | elif isinstance(self.driver, ForwardDriver):
112 | api_root = self.ntchat_config.ntchat_http_api_root
113 | if not api_root:
114 | raise ApiNotAvailable
115 | elif not api_root.endswith("/"):
116 | api_root += "/"
117 |
118 | headers = {"Content-Type": "application/json"}
119 |
120 | request = Request(
121 | "POST",
122 | api_root + api,
123 | headers=headers,
124 | timeout=timeout,
125 | content=json.dumps(data, cls=DataclassEncoder),
126 | )
127 |
128 | try:
129 | response = await self.driver.request(request)
130 |
131 | if 200 <= response.status_code < 300:
132 | if not response.content:
133 | raise ValueError("Empty response")
134 | result = json.loads(response.content)
135 | return handle_api_result(result)
136 | raise NetworkError(
137 | f"HTTP request received unexpected "
138 | f"status code: {response.status_code}"
139 | )
140 | except NetworkError:
141 | raise
142 | except Exception as e:
143 | raise NetworkError("HTTP request failed") from e
144 | else:
145 | raise ApiNotAvailable
146 |
147 | async def _handle_http(self, request: Request) -> Response:
148 | self_id = request.headers.get("X-Self-ID")
149 |
150 | # check self_id
151 | if not self_id:
152 | log("WARNING", "Missing X-Self-ID Header")
153 | return Response(400, content="Missing X-Self-ID Header")
154 |
155 | # check access_token
156 | response = self._check_access_token(request)
157 | if response is not None:
158 | return response
159 |
160 | data = request.content
161 | if data is not None:
162 | json_data = json.loads(data)
163 | event = self.json_to_event(json_data)
164 | if event:
165 | bot = self.bots.get(self_id, None)
166 | if not bot:
167 | bot = Bot(self, self_id)
168 | self.bot_connect(bot)
169 | log("INFO", f"Bot {escape_tag(self_id)} connected")
170 | bot = cast(Bot, bot)
171 | asyncio.create_task(bot.handle_event(event))
172 | return Response(204)
173 |
174 | async def _handle_ws(self, websocket: WebSocket) -> None:
175 | self_id = websocket.request.headers.get("X-Self-ID")
176 |
177 | # check self_id
178 | if not self_id:
179 | log("WARNING", "Missing X-Self-ID Header")
180 | await websocket.close(1008, "Missing X-Self-ID Header")
181 | return
182 | elif self_id in self.bots:
183 | log("WARNING", f"There's already a bot {self_id}, ignored")
184 | await websocket.close(1008, "Duplicate X-Self-ID")
185 | return
186 |
187 | # check access_token
188 | response = self._check_access_token(websocket.request)
189 | if response is not None:
190 | content = cast(str, response.content)
191 | await websocket.close(1008, content)
192 | return
193 |
194 | await websocket.accept()
195 | bot = Bot(self, self_id)
196 | self.connections[self_id] = websocket
197 | self.bot_connect(bot)
198 |
199 | log("INFO", f"Bot {escape_tag(self_id)} connected")
200 |
201 | try:
202 | while True:
203 | data = await websocket.receive()
204 | json_data = json.loads(data)
205 | event = self.json_to_event(json_data, self_id)
206 | if event:
207 | asyncio.create_task(bot.handle_event(event))
208 | except WebSocketClosed:
209 | log("WARNING", f"WebSocket for Bot {escape_tag(self_id)} closed by peer")
210 | except Exception as e:
211 | log(
212 | "ERROR",
213 | f"Error while process data from websocketfor bot {escape_tag(self_id)}.",
214 | e,
215 | )
216 | finally:
217 | with contextlib.suppress(Exception):
218 | await websocket.close()
219 | self.connections.pop(self_id, None)
220 | self.bot_disconnect(bot)
221 |
222 | def _check_access_token(self, request: Request) -> Optional[Response]:
223 | token = request.headers.get("access_token")
224 |
225 | access_token = self.ntchat_config.access_token
226 | if access_token and access_token != token:
227 | msg = "身份认证失败" if token else "缺少身份认证码"
228 | log("WARNING", msg)
229 | return Response(403, content=msg)
230 |
231 | @classmethod
232 | def json_to_event(
233 | cls, json_data: Any, self_id: Optional[str] = None
234 | ) -> Optional[Event]:
235 | """将 json 数据转换为 Event 对象。
236 |
237 | 如果为 API 调用返回数据且提供了 Event 对应 Bot,则将数据存入 ResultStore。
238 |
239 | 参数:
240 | json_data: json 数据
241 | self_id: 当前 Event 对应的 Bot
242 |
243 | 返回:
244 | Event 对象,如果解析失败或为 API 调用返回数据,则返回 None
245 | """
246 | if not isinstance(json_data, dict):
247 | return None
248 |
249 | # api回调设置结果
250 | if "type" not in json_data:
251 | if self_id is not None:
252 | cls._result_store.add_result(self_id, json_data)
253 | return
254 |
255 | # 实例化事件
256 | event_model = event_models.get_event_model(json_data)
257 | try:
258 | json_data.update(**json_data.get("data"))
259 | event = event_model.parse_obj(json_data)
260 | return event
261 | except Exception as e:
262 | log(
263 | "ERROR",
264 | "Failed to parse event. "
265 | f"Raw: {escape_tag(str(json_data))}",
266 | e,
267 | )
268 |
--------------------------------------------------------------------------------
/nonebot/adapters/ntchat/bot.py:
--------------------------------------------------------------------------------
1 | import asyncio
2 | import re
3 | from io import BytesIO
4 | from pathlib import Path
5 | from typing import Any, Callable, Union
6 |
7 | from nonebot.message import handle_event
8 | from nonebot.typing import overrides
9 |
10 | from nonebot.adapters import Bot as BaseBot
11 |
12 | from .event import Event, TextMessageEvent
13 | from .exception import NotInteractableEventError
14 | from .message import Message, MessageSegment
15 | from .utils import log
16 |
17 |
18 | def _check_at_me(bot: "Bot", event: TextMessageEvent) -> None:
19 | """检查消息开头或结尾是否存在 @机器人,去除并赋值 `event.to_me`。
20 |
21 | 参数:
22 | bot: Bot 对象
23 | event: TextMessageEvent 对象
24 | """
25 | if not isinstance(event, TextMessageEvent):
26 | return
27 |
28 | if bot.self_id in event.at_user_list:
29 | event.to_me = True
30 |
31 |
32 | def _check_nickname(bot: "Bot", event: TextMessageEvent) -> None:
33 | """检查消息开头是否存在昵称,去除并赋值 `event.to_me`。
34 |
35 | 参数:
36 | bot: Bot 对象
37 | event: TextMessageEvent 对象
38 | """
39 | first_text = event.msg
40 | nicknames = set(filter(lambda n: n, bot.config.nickname))
41 | if nicknames:
42 | # check if the user is calling me with my nickname
43 | nickname_regex = "|".join(nicknames)
44 | m = re.search(rf"^({nickname_regex})([\s,,]*|$)", first_text, re.IGNORECASE)
45 | if m:
46 | nickname = m.group(1)
47 | log("DEBUG", f"User is calling me {nickname}")
48 | event.to_me = True
49 | loc = m.end()
50 | event.msg = first_text[loc:]
51 |
52 |
53 | async def send(
54 | bot: "Bot",
55 | event: Event,
56 | message: Union[str, MessageSegment, Message],
57 | **params: Any, # extra options passed to send_msg API
58 | ) -> Any:
59 | """默认回复消息处理函数。"""
60 | try:
61 | from_wxid: str = getattr(event, "from_wxid")
62 | room_wxid: str = getattr(event, "room_wxid")
63 | except AttributeError:
64 | raise NotInteractableEventError("该类型事件没有交互对象,无法发送消息!")
65 | wx_id = from_wxid if room_wxid == "" else room_wxid
66 |
67 | if isinstance(message, str) or isinstance(message, MessageSegment):
68 | message = Message(message)
69 |
70 | task = []
71 | for segment in message:
72 | api = f"send_{segment.type}"
73 | segment.data["to_wxid"] = wx_id
74 | task.append(bot.call_api(api, **segment.data))
75 |
76 | return await asyncio.gather(*task)
77 |
78 |
79 | class Bot(BaseBot):
80 | """
81 | ntchat协议适配。
82 | """
83 |
84 | send_handler: Callable[["Bot", Event, Union[str, MessageSegment]], Any] = send
85 |
86 | async def handle_event(self, event: Event) -> None:
87 | """处理收到的事件。"""
88 | if isinstance(event, TextMessageEvent):
89 | _check_at_me(self, event)
90 | _check_nickname(self, event)
91 |
92 | await handle_event(self, event)
93 |
94 | @overrides(BaseBot)
95 | async def send(
96 | self,
97 | event: Event,
98 | message: Union[str, MessageSegment],
99 | **kwargs: Any,
100 | ) -> Any:
101 | """根据 `event` 向触发事件的主体回复消息。
102 |
103 | 参数:
104 | event: Event 对象
105 | message: 要发送的消息
106 | kwargs: 其他参数
107 |
108 | 返回:
109 | API 调用返回数据
110 |
111 | 异常:
112 | NetworkError: 网络错误
113 | ActionFailed: API 调用失败
114 | """
115 | return await self.__class__.send_handler(self, event, message, **kwargs)
116 |
117 | async def send_image(
118 | self, to_wxid: str, file_path: Union[str, bytes, BytesIO, Path]
119 | ):
120 | """
121 | 说明:
122 | 发送图片
123 |
124 | 参数:
125 | * `to_wxid`:接收方的wx_id,可以是好友id,也可以是room_id
126 | * `file`:图片内容,支持url,本地路径,bytes,BytesIO
127 | """
128 | data = MessageSegment.image(file_path=file_path).data
129 | data["to_wxid"] = to_wxid
130 | return await self.call_api("send_image", **data)
131 |
132 | async def send_file(
133 | self, *, to_wxid: str, file_path: Union[str, bytes, BytesIO, Path]
134 | ):
135 | """
136 | 说明:
137 | 发送文件
138 |
139 | 参数:
140 | * `to_wxid`:接收人id
141 | * `file_path`:文件内容,支持url,本地路径,bytes,BytesIO
142 | """
143 | data = MessageSegment.file(file_path=file_path).data
144 | data["to_wxid"] = to_wxid
145 | return await self.call_api("send_file", **data)
146 |
147 | async def send_video(
148 | self, *, to_wxid: str, file_path: Union[str, bytes, BytesIO, Path]
149 | ):
150 | """
151 | 说明:
152 | 发送视频
153 |
154 | 参数:
155 | * `to_wxid`:接收人id
156 | * `file_path`:视频内容,支持url,本地路径,bytes,BytesIO
157 | """
158 | data = MessageSegment.video(file_path=file_path).data
159 | data["to_wxid"] = to_wxid
160 | return await self.call_api("send_video", **data)
161 |
162 | async def send_gif(self, *, to_wxid: str, file: Union[str, bytes, BytesIO, Path]):
163 | """
164 | 说明:
165 | 发送gif图片
166 |
167 | 参数:
168 | * `to_wxid`:接收人id
169 | * `file`:图片内容,支持url,本地路径,bytes,BytesIO
170 | """
171 | data = MessageSegment.gif(file=file).data
172 | data["to_wxid"] = to_wxid
173 | return await self.call_api("send_gif", **data)
174 |
--------------------------------------------------------------------------------
/nonebot/adapters/ntchat/bot.pyi:
--------------------------------------------------------------------------------
1 | from io import BytesIO
2 | from pathlib import Path
3 | from typing import Any, Dict, List, Union
4 |
5 | from nonebot.adapters import Bot as BaseBot
6 |
7 | from .event import Event, TextMessageEvent
8 | from .message import MessageSegment
9 |
10 | def _check_at_me(bot: "Bot", event: TextMessageEvent) -> None: ...
11 | def _check_nickname(bot: "Bot", event: TextMessageEvent) -> None: ...
12 | async def send(
13 | bot: "Bot",
14 | event: Event,
15 | message: Union[str, MessageSegment],
16 | **params: Any,
17 | ) -> Any: ...
18 |
19 | class Bot(BaseBot):
20 | async def call_api(self, api: str, **data) -> Any:
21 | """调用 ntchat API。
22 |
23 | 参数:
24 | api: API 名称
25 | data: API 参数
26 |
27 | 返回:
28 | API 调用返回数据
29 |
30 | 异常:
31 | nonebot.adapters.ntchat.exception.NetworkError: 网络错误
32 | nonebot.adapters.ntchat.exception.ActionFailed: API 调用失败
33 | """
34 | ...
35 | async def handle_event(self, event: Event) -> None:
36 | """处理收到的事件。"""
37 | ...
38 | async def send(
39 | self,
40 | event: Event,
41 | message: Union[str, MessageSegment],
42 | **kwargs: Any,
43 | ) -> Any:
44 | """根据 `event` 向触发事件的主体回复消息。"""
45 | ...
46 | async def sql_query(self, sql: str, db: int) -> Dict[str, Any]:
47 | """
48 | 说明:
49 | 数据库查询
50 |
51 | 参数:
52 | * `sql`:sql地址
53 | * `db`:数据库名称
54 | """
55 | ...
56 | async def get_login_info(self) -> Dict[str, Any]:
57 | """
58 | 说明:
59 | 获取登录信息
60 | """
61 | ...
62 | async def get_self_info(self) -> Dict[str, Any]:
63 | """
64 | 说明:
65 | 获取自己个人信息跟登录信息类似
66 | """
67 | ...
68 | async def get_contacts(self) -> List[Dict[str, Any]]:
69 | """
70 | 说明:
71 | 获取联系人列表
72 | """
73 | ...
74 | async def get_publics(self) -> List[Dict[str, Any]]:
75 | """
76 | 说明:
77 | 获取关注公众号列表
78 | """
79 | ...
80 | async def get_contact_detail(self, *, wxid: str) -> Dict[str, Any]:
81 | """
82 | 说明:
83 | 获取联系人详细信息
84 |
85 | 参数:
86 | * `wxid`:联系人微信id
87 | """
88 | ...
89 | async def search_contacts(
90 | self,
91 | *,
92 | wxid: Union[None, str] = None,
93 | account: Union[None, str] = None,
94 | nickname: Union[None, str] = None,
95 | remark: Union[None, str] = None,
96 | fuzzy_search: bool = True,
97 | ) -> List[Dict[str, Any]]:
98 | """
99 | 说明:
100 | 根据wxid、微信号、昵称和备注模糊搜索联系人
101 |
102 | 参数:
103 | * `wxid`:微信id
104 | * `account`:微信账号
105 | * `nickname`:昵称
106 | * `remark`:备注
107 | * `fuzzy_search`:是否模糊搜索
108 | """
109 | ...
110 | async def get_rooms(self) -> List[Dict[str, Any]]:
111 | """
112 | 说明:
113 | 获取群列表
114 | """
115 | ...
116 | async def get_room_detail(self, *, room_wxid: str) -> Dict[str, Any]:
117 | """
118 | 说明:
119 | 获取指定群详细信息
120 |
121 | 参数:
122 | * `room_wxid`:群id
123 | """
124 | ...
125 | async def get_room_members(self, *, room_wxid: str) -> List[Dict[str, Any]]:
126 | """
127 | 说明:
128 | 获取群成员列表
129 |
130 | 参数:
131 | * `room_wxid`:群id
132 | """
133 | ...
134 | async def send_text(self, *, to_wxid: str, content: str) -> None:
135 | """
136 | 说明:
137 | 发送文本消息
138 |
139 | 参数:
140 | * `to_wxid`:接收人id
141 | * `content`:文本内容
142 | """
143 | ...
144 | async def send_room_at_msg(self, *, to_wxid: str, content: str, at_list: List[str]):
145 | """
146 | 说明:
147 | 发送群@消息
148 |
149 | 参数:
150 | * `to_wxid`:接收人id
151 | * `content`:消息内容
152 | * `at_list`:at列表
153 |
154 | 注意:
155 | - 假如文本为:"test,你好{$@},你好{$@}.早上好"
156 | - 文本消息的content的内容中设置占位字符串 {$@},这些字符的位置就是最终的@符号所在的位置
157 | - 假设这两个被@的微信号的群昵称分别为aa,bb
158 | - 则实际发送的内容为 "test,你好@ aa,你好@ bb.早上好"(占位符被替换了)
159 | - 占位字符串的数量必须和at_list中的微信数量相等.
160 | """
161 | ...
162 | async def send_card(self, *, to_wxid: str, card_wxid: str):
163 | """
164 | 说明:
165 | 发送名片
166 |
167 | 参数:
168 | * `to_wxid`:接收人id
169 | * `card_wxid`:卡片人id
170 | """
171 | ...
172 | async def send_link_card(
173 | self, *, to_wxid: str, title: str, desc: str, url: str, image_url: str
174 | ):
175 | """
176 | 说明:
177 | 发送链接卡片
178 |
179 | 参数:
180 | * `to_wxid`:接收人id
181 | * `title`:新闻标题
182 | * `desc`:描述
183 | * `url`:卡片链接地址
184 | * `image_url`:图片地址
185 | """
186 | ...
187 | async def send_image(
188 | self, to_wxid: str, file_path: Union[str, bytes, BytesIO, Path]
189 | ):
190 | """
191 | 说明:
192 | 发送图片
193 |
194 | 参数:
195 | * `to_wxid`:接收方的wx_id,可以是好友id,也可以是room_id
196 | * `file_path`:图片内容,支持url,本地路径,bytes,BytesIO
197 | """
198 | ...
199 | async def send_file(
200 | self, *, to_wxid: str, file_path: Union[str, bytes, BytesIO, Path]
201 | ):
202 | """
203 | 说明:
204 | 发送文件
205 |
206 | 参数:
207 | * `to_wxid`:接收人id
208 | * `file_path`:文件内容,支持url,本地路径,bytes,BytesIO
209 | """
210 | ...
211 | async def send_video(
212 | self, *, to_wxid: str, file_path: Union[str, bytes, BytesIO, Path]
213 | ):
214 | """
215 | 说明:
216 | 发送视频
217 |
218 | 参数:
219 | * `to_wxid`:接收人id
220 | * `file_path`:视频内容,支持url,本地路径,bytes,BytesIO
221 | """
222 | ...
223 | async def send_gif(self, *, to_wxid: str, file: Union[str, bytes, BytesIO, Path]):
224 | """
225 | 说明:
226 | 发送gif图片
227 |
228 | 参数:
229 | * `to_wxid`:接收人id
230 | * `file`:图片内容,支持url,本地路径,bytes,BytesIO
231 | """
232 | ...
233 | async def send_xml(self, *, to_wxid: str, xml: str, app_type: int = 5):
234 | """
235 | 说明:
236 | 发送xml
237 |
238 | 参数:
239 | * `to_wxid`:接收人id
240 | * `xml`:xml内容
241 | * `app_type`:应用id
242 | """
243 | ...
244 | async def send_pat(self, *, room_wxid: str, patted_wxid: str):
245 | """
246 | 说明:
247 | 发送拍一拍
248 |
249 | 参数:
250 | * `room_wxid`:群id
251 | * `patted_wxid`:拍一拍目标id
252 | """
253 | ...
254 | async def accept_friend_request(
255 | self, *, encryptusername: str, ticket: str, scene: int
256 | ):
257 | """
258 | 说明:
259 | 同意加好友请求
260 |
261 | 参数:
262 | * `encryptusername`:备注名
263 | * `ticket`:ticket
264 | * `scene`scene
265 | """
266 | ...
267 | async def create_room(self, *, member_list: List[str]):
268 | """
269 | 说明:
270 | 创建群
271 |
272 | 参数:
273 | * `member_list`:邀请成员列表
274 | """
275 | ...
276 | async def add_room_member(self, *, room_wxid: str, member_list: List[str]):
277 | """
278 | 说明:
279 | 添加好友入群
280 |
281 | 参数:
282 | * `room_wxid`:群id
283 | * `member_list`:添加id列表
284 | """
285 | ...
286 | async def invite_room_member(self, *, room_wxid: str, member_list: List[str]):
287 | """
288 | 说明:
289 | 邀请好友入群
290 |
291 | 参数:
292 | * `room_wxid`:群id
293 | * `member_list`:邀请id列表
294 | """
295 | ...
296 | async def del_room_member(self, *, room_wxid: str, member_list: List[str]):
297 | """
298 | 说明:
299 | 删除群成员
300 |
301 | 参数:
302 | * `room_wxid`:群id
303 | * `member_list`:删除id列表
304 | """
305 | ...
306 | async def modify_room_name(self, *, room_wxid: str, name: str):
307 | """
308 | 说明:
309 | 修改群名
310 |
311 | 参数:
312 | * `room_wxid`:群id
313 | * `name`:修改后的群名
314 | """
315 | ...
316 | async def modify_room_notice(self, *, room_wxid: str, notice: str):
317 | """
318 | 说明:
319 | 修改群公告
320 |
321 | 参数:
322 | * `room_wxid`:群id
323 | * `notice`:修改后的公告
324 | """
325 | ...
326 | async def add_room_friend(self, *, room_wxid: str, wxid: str, verify: str):
327 | """
328 | 说明:
329 | 添加群成员为好友
330 |
331 | 参数:
332 | * `room_wxid`:群id
333 | * `wxid`:目标id
334 | * `verify`:备注名
335 | """
336 | ...
337 | async def quit_room(self, *, room_wxid: str):
338 | """
339 | 说明:
340 | 退出群
341 |
342 | 参数:
343 | * `room_wxid`:群id
344 | """
345 | ...
346 | async def modify_friend_remark(self, *, wxid: str, remark: str):
347 | """
348 | 说明:
349 | 修改好友备注
350 |
351 | 参数:
352 | * `wxid`:好友id
353 | * `remark`:修改后的备注
354 | """
355 | ...
356 | async def get_room_name(self, *, room_wxid: str) -> str:
357 | """
358 | 说明:
359 | 获取群名
360 |
361 | 参数:
362 | * `room_wxid`:群聊id
363 | """
364 | ...
365 |
--------------------------------------------------------------------------------
/nonebot/adapters/ntchat/collator.py:
--------------------------------------------------------------------------------
1 | from typing import Dict, Generic, Tuple, Type, TypeVar
2 |
3 | from .event import Event
4 |
5 | E = TypeVar("E", bound=Event)
6 |
7 |
8 | class EventModels(Generic[E]):
9 | """
10 | 事件创建器
11 | """
12 |
13 | event_dict: Dict[Tuple[int, int], Type[E]] = {}
14 | """事件模型字典"""
15 |
16 | def add_event_model(self, event: Type[E]) -> None:
17 | """添加事件模型"""
18 | event_type = event.__fields__.get("type").default
19 | sub_type = event.__fields__.get("wx_sub_type")
20 | if sub_type is None:
21 | sub_type = 0
22 | else:
23 | sub_type = sub_type.default
24 | if event_type:
25 | self.event_dict[(event_type, sub_type)] = event
26 |
27 | def get_event_model(self, data: Dict) -> Type[E]:
28 | """获取事件模型"""
29 | event_type: int = data.get("type")
30 | sub_type = data["data"].get("wx_sub_type", 0)
31 | event_model = self.event_dict.get((event_type, sub_type), None)
32 | if event_model is None and sub_type != 0:
33 | event_model = self.event_dict.get((event_type, 0))
34 | return event_model if event_model else Event
35 |
--------------------------------------------------------------------------------
/nonebot/adapters/ntchat/config.py:
--------------------------------------------------------------------------------
1 | from typing import Optional
2 |
3 | from pydantic import AnyUrl, BaseModel, Field
4 |
5 |
6 | class WSUrl(AnyUrl):
7 | """ws或wss url"""
8 |
9 | allow_schemes = {"ws", "wss"}
10 |
11 |
12 | class Config(BaseModel):
13 | """ntchat 配置类"""
14 |
15 | access_token: Optional[str] = Field(default=None)
16 | """令牌口令"""
17 | ntchat_http_api_root: Optional[str] = Field(default=None)
18 | """http api请求地址"""
19 |
20 | class Config:
21 | extra = "ignore"
22 |
--------------------------------------------------------------------------------
/nonebot/adapters/ntchat/event.py:
--------------------------------------------------------------------------------
1 | from copy import deepcopy
2 | from enum import IntEnum
3 | from pathlib import Path
4 | from typing import Any, Dict, List
5 | from urllib.parse import unquote
6 | from xml.etree import ElementTree as ET
7 |
8 | from nonebot.typing import overrides
9 | from nonebot.utils import escape_tag
10 | from pydantic import BaseModel, root_validator
11 |
12 | from nonebot.adapters import Event as BaseEvent
13 |
14 | from .message import Message
15 | from .type import EventType, SubType, WxType
16 |
17 |
18 | class Event(BaseEvent):
19 | """
20 | ntchat事件基类
21 | """
22 |
23 | data: Dict
24 | """事件原始数据"""
25 | type: int
26 | """事件类型"""
27 | to_me: bool = False
28 | """
29 | :说明: 消息是否与机器人有关
30 |
31 | :类型: ``bool``
32 | """
33 |
34 | @overrides(BaseEvent)
35 | def get_type(self) -> str:
36 | try:
37 | wx_type = EventType(self.type)
38 | return wx_type.name
39 | except Exception:
40 | return str(self.type)
41 |
42 | @overrides(BaseEvent)
43 | def get_event_name(self) -> str:
44 | try:
45 | wx_type = EventType(self.type)
46 | return wx_type.name
47 | except Exception:
48 | return str(self.type)
49 |
50 | @overrides(BaseEvent)
51 | def get_message(self) -> "Message":
52 | raise ValueError("事件没有message实例")
53 |
54 | @overrides(BaseEvent)
55 | def get_event_description(self) -> str:
56 | return escape_tag(str(self.dict()))
57 |
58 | @overrides(BaseEvent)
59 | def get_user_id(self) -> str:
60 | raise ValueError("事件没有user_id")
61 |
62 | @overrides(BaseEvent)
63 | def get_session_id(self) -> str:
64 | return str(self.type)
65 |
66 | @overrides(BaseEvent)
67 | def is_tome(self) -> bool:
68 | return self.to_me
69 |
70 |
71 | class MessageEvent(Event):
72 | """消息事件基类"""
73 |
74 | timestamp: int
75 | """时间戳"""
76 | wx_type: int
77 | """消息原始类型"""
78 | from_wxid: str
79 | """发送者的wxid"""
80 | room_wxid: str
81 | """群聊的wxid"""
82 | to_wxid: str
83 | """接收者的wxid"""
84 | msgid: str
85 | """消息id"""
86 | message: Message
87 | """消息message对象"""
88 | to_me: bool = False
89 | """
90 | :说明: 消息是否与机器人有关
91 |
92 | :类型: ``bool``
93 | """
94 |
95 | @root_validator(pre=True, allow_reuse=True)
96 | def check_message(cls, values: Dict[str, Any]) -> Dict[str, Any]:
97 | if "msg" in values:
98 | values["message"] = deepcopy(values["msg"])
99 | else:
100 | values["message"] = ""
101 | return values
102 |
103 | @overrides(Event)
104 | def get_type(self) -> str:
105 | return "message"
106 |
107 | @overrides(Event)
108 | def get_user_id(self) -> str:
109 | return self.from_wxid
110 |
111 | @overrides(Event)
112 | def get_message(self) -> "Message":
113 | return self.message
114 |
115 |
116 | class TextMessageEvent(MessageEvent):
117 | """接收文本消息事件"""
118 |
119 | type: int = EventType.MT_RECV_TEXT_MSG
120 | at_user_list: List[str]
121 | """在群里@的wxid列表"""
122 | msg: str
123 | """消息文本内容"""
124 |
125 | @overrides(MessageEvent)
126 | def get_event_description(self) -> str:
127 | if self.room_wxid:
128 | return f"Message {self.msgid} from {self.from_wxid}@[群:{self.room_wxid}]: {escape_tag(self.msg)}"
129 | else:
130 | return f"Message {self.msgid} from {self.from_wxid}: {escape_tag(self.msg)}"
131 |
132 |
133 | class QuoteMessageEvent(MessageEvent):
134 | """
135 | 引用消息事件,注意此事件原则上属于app事件,但为了方便改为了Message事件
136 | """
137 |
138 | type: int = EventType.MT_RECV_OTHER_APP_MSG
139 | wx_sub_type: int = SubType.WX_APPMSG_QUOTE
140 | """消息子类型"""
141 | raw_msg: str
142 | """微信中的原始消息,xml格式"""
143 | quote_message_id: str
144 | """被引用消息id"""
145 | quote_uer_id: str
146 | """被引用用户id"""
147 |
148 | @root_validator(pre=True, allow_reuse=True)
149 | def get_pre_message(cls, values: Dict[str, Any]) -> Dict[str, Any]:
150 | raw_xml = values["raw_msg"]
151 | xml_obj = ET.fromstring(raw_xml)
152 | values["message"] = xml_obj.find("./appmsg/title").text
153 | refermsg = xml_obj.find("./appmsg/refermsg")
154 | values["quote_message_id"] = refermsg.find("./svrid").text
155 | values["quote_uer_id"] = refermsg.find("./chatusr").text
156 | return values
157 |
158 | @overrides(MessageEvent)
159 | def get_event_description(self) -> str:
160 | return f"Message {self.msgid} from {self.from_wxid}@[群:{self.room_wxid}]: {self.message}"
161 |
162 |
163 | class PictureMessageEvent(MessageEvent):
164 | """接收图片消息"""
165 |
166 | type: int = EventType.MT_RECV_PICTURE_MSG
167 | raw_msg: str
168 | """微信中的原始消息,xml格式"""
169 | image_thumb: str
170 | """图片缩略图路径"""
171 | image: str
172 | """图片大图路径"""
173 |
174 | @overrides(MessageEvent)
175 | def get_event_description(self) -> str:
176 | msg = "[图片消息]请查看raw_msg"
177 | if self.room_wxid:
178 | return f"Message {self.msgid} from {self.from_wxid}@[群:{self.room_wxid}]: {msg}"
179 | else:
180 | return f"Message {self.msgid} from {self.from_wxid}: {msg}"
181 |
182 |
183 | class VoiceMessageEvent(MessageEvent):
184 | """接收语音消息"""
185 |
186 | type: int = EventType.MT_RECV_VOICE_MSG
187 | mp3_file: str
188 | """语音消息文件"""
189 | raw_msg: str
190 | """微信中的原始消息,xml格式"""
191 |
192 | @overrides(MessageEvent)
193 | def get_event_description(self) -> str:
194 | msg = "[语音消息]请查看raw_msg"
195 | if self.room_wxid:
196 | return f"Message {self.msgid} from {self.from_wxid}@[群:{self.room_wxid}]: {msg}"
197 | else:
198 | return f"Message {self.msgid} from {self.from_wxid}: {msg}"
199 |
200 |
201 | class CardMessageEvent(MessageEvent):
202 | """接收名片消息"""
203 |
204 | type: int = EventType.MT_RECV_CARD_MSG
205 | headimg_url: str
206 | """头像url"""
207 | nickname: str
208 | """名片用户昵称"""
209 | raw_msg: str
210 | """微信中的原始消息,xml格式"""
211 |
212 | @root_validator(pre=True, allow_reuse=True)
213 | def get_pre_message(cls, values: Dict[str, Any]) -> Dict[str, Any]:
214 | raw_xml = values["raw_msg"]
215 | xml_obj = ET.fromstring(raw_xml)
216 | values["headimg_url"] = xml_obj.attrib.get("bigheadimgurl")
217 | values["nickname"] = xml_obj.attrib.get("nickname")
218 | return values
219 |
220 | @overrides(MessageEvent)
221 | def get_event_description(self) -> str:
222 | msg = f"[名片消息] - {self.nickname}"
223 | if self.room_wxid:
224 | return f"Message {self.msgid} from {self.from_wxid}@[群:{self.room_wxid}]: {msg}"
225 | else:
226 | return f"Message {self.msgid} from {self.from_wxid}: {msg}"
227 |
228 |
229 | class VideoMessageEvent(MessageEvent):
230 | """接收视频消息"""
231 |
232 | type: int = EventType.MT_RECV_VIDEO_MSG
233 | video: str
234 | "接收视频路径"
235 | video_thumb: str
236 | "视频缩略图路径"
237 | raw_msg: str
238 | """微信中的原始消息,xml格式"""
239 |
240 | @overrides(MessageEvent)
241 | def get_event_description(self) -> str:
242 | msg = "[视频消息]请查看raw_msg"
243 | if self.room_wxid:
244 | return f"Message {self.msgid} from {self.from_wxid}@[群:{self.room_wxid}]: {msg}"
245 | else:
246 | return f"Message {self.msgid} from {self.from_wxid}: {msg}"
247 |
248 |
249 | class EmojiMessageEvent(MessageEvent):
250 | """接收表情消息"""
251 |
252 | type = EventType.MT_RECV_EMOJI_MSG
253 | emoji_url: str
254 | """表情url地址"""
255 | raw_msg: str
256 | """微信中的原始消息,xml格式"""
257 |
258 | @root_validator(pre=True, allow_reuse=True)
259 | def get_pre_message(cls, values: Dict[str, Any]) -> Dict[str, Any]:
260 | raw_xml = values["raw_msg"]
261 | xml_obj = ET.fromstring(raw_xml)
262 | emoji_url = xml_obj.find("./emoji").attrib.get("cdnurl")
263 | values["emoji_url"] = unquote(emoji_url)
264 | return values
265 |
266 | @overrides(MessageEvent)
267 | def get_event_description(self) -> str:
268 | msg = "[表情消息]请查看raw_msg"
269 | if self.room_wxid:
270 | return f"Message {self.msgid} from {self.from_wxid}@[群:{self.room_wxid}]: {msg}"
271 | else:
272 | return f"Message {self.msgid} from {self.from_wxid}: {msg}"
273 |
274 |
275 | class LocationMessageEvent(MessageEvent):
276 | """接收位置消息消息"""
277 |
278 | type: int = EventType.MT_RECV_LOCATION_MSG
279 | location_x: str
280 | """位置x坐标"""
281 | location_y: str
282 | """位置y坐标"""
283 | label: str
284 | """位置标签"""
285 | poiname: str
286 | """位置名称"""
287 | raw_msg: str
288 | """微信中的原始消息,xml格式"""
289 |
290 | @root_validator(pre=True, allow_reuse=True)
291 | def get_pre_message(cls, values: Dict[str, Any]) -> Dict[str, Any]:
292 | raw_xml = values["raw_msg"]
293 | xml_obj = ET.fromstring(raw_xml)
294 | location = xml_obj.find("./location")
295 | values["location_x"] = location.attrib.get("x")
296 | values["location_y"] = location.attrib.get("y")
297 | values["label"] = location.attrib.get("label")
298 | values["poiname"] = location.attrib.get("poiname")
299 | return values
300 |
301 | @overrides(MessageEvent)
302 | def get_event_description(self) -> str:
303 | msg = f"[位置消息] - {self.poiname}"
304 | if self.room_wxid:
305 | return f"Message {self.msgid} from {self.from_wxid}@[群:{self.room_wxid}]: {msg}"
306 | else:
307 | return f"Message {self.msgid} from {self.from_wxid}: {msg}"
308 |
309 |
310 | class FileMessageEvent(MessageEvent):
311 | """接收文件消息"""
312 |
313 | type: int = EventType.MT_RECV_FILE_MSG
314 | wx_sub_type: int = SubType.WX_APPMSG_FILE
315 | file: str
316 | """接收文件路径"""
317 | file_name: str
318 | """接收文件名称"""
319 | raw_msg: str
320 | """微信中的原始消息,xml格式"""
321 |
322 | @root_validator(pre=True, allow_reuse=True)
323 | def get_pre_message(cls, values: Dict[str, Any]) -> Dict[str, Any]:
324 | file = Path(values["file"])
325 | values["file_name"] = file.name
326 | return values
327 |
328 | @overrides(Event)
329 | def get_event_description(self) -> str:
330 | msg = f"[接收文件事件] - {self.file_name}"
331 | if self.room_wxid:
332 | return f"Message {self.msgid} from {self.from_wxid}@[群:{self.room_wxid}]: {msg}"
333 | else:
334 | return f"Message {self.msgid} from {self.from_wxid}: {msg}"
335 |
336 |
337 | class SystemMessageEvent(Event):
338 | """接收系统消息"""
339 |
340 | type: int = EventType.MT_RECV_SYSTEM_MSG
341 | timestamp: int
342 | """时间戳"""
343 | wx_type: int
344 | """消息原始类型"""
345 | from_wxid: str
346 | """发送者的wxid"""
347 | room_wxid: str
348 | """群聊的wxid"""
349 | to_wxid: str
350 | """接收者的wxid"""
351 | msgid: str
352 | """消息id"""
353 | raw_msg: str
354 | """微信中的原始消息,xml格式"""
355 |
356 | @overrides(Event)
357 | def get_type(self) -> str:
358 | return "system"
359 |
360 | @overrides(Event)
361 | def get_user_id(self) -> str:
362 | return self.from_wxid
363 |
364 | @overrides(Event)
365 | def get_event_description(self) -> str:
366 | msg = "[系统消息]请查看raw_msg"
367 | if self.room_wxid:
368 | return f"Message {self.msgid} from {self.from_wxid}@[群:{self.room_wxid}]: {msg}"
369 | else:
370 | return f"Message {self.msgid} from {self.from_wxid}: {msg}"
371 |
372 |
373 | class OtherMessageEvent(Event):
374 | """接收其他消息,根据wx_type自行判断"""
375 |
376 | type: int = EventType.MT_RECV_OTHER_MSG
377 | timestamp: int
378 | """时间戳"""
379 | wx_type: int
380 | """消息原始类型"""
381 | from_wxid: str
382 | """发送者的wxid"""
383 | room_wxid: str
384 | """群聊的wxid"""
385 | to_wxid: str
386 | """接收者的wxid"""
387 | msgid: str
388 | """消息id"""
389 | raw_msg: str
390 | """微信中的原始消息,xml格式"""
391 |
392 | @overrides(Event)
393 | def get_type(self) -> str:
394 | return "other"
395 |
396 | @overrides(Event)
397 | def get_user_id(self) -> str:
398 | return self.from_wxid
399 |
400 | @overrides(Event)
401 | def get_event_description(self) -> str:
402 | msg = "[其他未知消息]请查看对应type和raw_msg"
403 | if self.room_wxid:
404 | return f"Message {self.msgid} from {self.from_wxid}@[群:{self.room_wxid}]: {msg}"
405 | else:
406 | return f"Message {self.msgid} from {self.from_wxid}: {msg}"
407 |
408 |
409 | class RequestEvent(Event):
410 | """请求事件基类"""
411 |
412 | timestamp: int
413 | """时间戳"""
414 | wx_type: int
415 | """消息原始类型"""
416 | from_wxid: str
417 | """发送者的wxid"""
418 | room_wxid: str
419 | """群聊的wxid"""
420 | to_wxid: str
421 | """接收者的wxid"""
422 | msgid: str
423 | """消息id"""
424 |
425 | @overrides(Event)
426 | def get_type(self) -> str:
427 | return "request"
428 |
429 | @overrides(Event)
430 | def get_user_id(self) -> str:
431 | return self.from_wxid
432 |
433 |
434 | class FriendAddRequestEvent(RequestEvent):
435 | """添加好友请求"""
436 |
437 | type: int = EventType.MT_RECV_FRIEND_MSG
438 | raw_msg: str
439 | """微信中的原始消息,xml格式"""
440 |
441 | @overrides(Event)
442 | def get_event_description(self) -> str:
443 | msg = "[好友请求消息]请查看raw_msg"
444 | if self.room_wxid:
445 | return f"Message {self.msgid} from {self.from_wxid}@[群:{self.room_wxid}]: {msg}"
446 | else:
447 | return f"Message {self.msgid} from {self.from_wxid}: {msg}"
448 |
449 |
450 | class NoticeEvent(Event):
451 | """通知事件"""
452 |
453 | @overrides(Event)
454 | def get_type(self) -> str:
455 | return "notice"
456 |
457 |
458 | class RevokeNoticeEvent(NoticeEvent):
459 | """接收撤回消息"""
460 |
461 | type: int = EventType.MT_RECV_REVOKE_MSG
462 | wx_type: int
463 | """消息原始类型"""
464 | from_wxid: str
465 | """发送者的wxid"""
466 | room_wxid: str
467 | """群聊的wxid"""
468 | to_wxid: str
469 | """接收者的wxid"""
470 | raw_msg: str
471 | """微信中的原始消息,xml格式"""
472 | msg_id: str
473 | """撤回消息id"""
474 |
475 | @overrides(NoticeEvent)
476 | def get_user_id(self) -> str:
477 | return self.from_wxid
478 |
479 | @root_validator(pre=True, allow_reuse=True)
480 | def get_pre_message(cls, values: Dict[str, Any]) -> Dict[str, Any]:
481 | raw_xml = values["raw_msg"]
482 | xml_obj = ET.fromstring(raw_xml)
483 | values["msg_id"] = xml_obj.find("./revokemsg/newmsgid").text
484 | return values
485 |
486 | @overrides(NoticeEvent)
487 | def get_event_description(self) -> str:
488 | msg = f"[撤回通知]msg_id:{self.msg_id}"
489 | if self.room_wxid:
490 | return f"Message from {self.from_wxid}@[群:{self.room_wxid}]: {msg}"
491 | else:
492 | return f"Message from {self.from_wxid}: {msg}"
493 |
494 |
495 | class Sex(IntEnum):
496 | """性别枚举"""
497 |
498 | Man = 0
499 | """男"""
500 | Woman = 1
501 | """女"""
502 |
503 |
504 | class FriendAddNoticeEvent(NoticeEvent):
505 | """好友添加通知"""
506 |
507 | type: int = EventType.MT_FRIEND_ADD_NOTIFY_MSG
508 | account: str
509 | """微信号"""
510 | avatar: str
511 | """头像url"""
512 | city: str
513 | """城市"""
514 | country: str
515 | """国家"""
516 | nickname: str
517 | """微信昵称"""
518 | remark: str
519 | """备注"""
520 | sex: Sex
521 | """性别"""
522 | wxid: str
523 | """微信id"""
524 |
525 | @overrides(NoticeEvent)
526 | def get_user_id(self) -> str:
527 | return self.wxid
528 |
529 | @overrides(NoticeEvent)
530 | def get_event_description(self) -> str:
531 | return f"[好友添加通知]:{self.dict()}"
532 |
533 |
534 | class RoomMember(BaseModel):
535 | """群成员模型"""
536 |
537 | avatar: str
538 | """头像url"""
539 | invite_by: str
540 | """邀请人wxid"""
541 | nickname: str
542 | """群内昵称"""
543 | wxid: str
544 | """成员wxid"""
545 |
546 |
547 | class RoomMemberDel(BaseModel):
548 | """群成员删除模型"""
549 |
550 | nickname: str
551 | """群内昵称"""
552 | wxid: str
553 | """成员wxid"""
554 |
555 |
556 | class InvitedRoomEvent(NoticeEvent):
557 | """被邀请入群事件"""
558 |
559 | type: int = EventType.MT_ROOM_INTIVTED_NOTIFY_MSG
560 | avatar: str
561 | """群头像url"""
562 | is_manager: bool
563 | """你是否为管理员"""
564 | manager_wxid: str
565 | """管理员微信id"""
566 | member_list: List[RoomMember]
567 | """成员列表"""
568 | nickname: str
569 | """群名"""
570 | room_wxid: str
571 | """群wxid"""
572 | total_member: int
573 | """群内总人数"""
574 |
575 | @overrides(NoticeEvent)
576 | def get_event_description(self) -> str:
577 | return f"[被邀请入群通知]:{self.dict()}"
578 |
579 |
580 | class RoomMemberAddNoticeEvent(NoticeEvent):
581 | """接收群成员加入消息"""
582 |
583 | type: int = EventType.MT_ROOM_ADD_MEMBER_NOTIFY_MSG
584 | """消息原始类型"""
585 | total_member: int
586 | """群成员人数"""
587 | room_wxid: str
588 | """群聊的wxid"""
589 | member_list: List[RoomMember]
590 | """群成员变动的wxid 可用event.member_list[0].wxid获取"""
591 | nickname: str
592 | """群昵称"""
593 | data: Dict
594 | """微信中的原始消息,xml格式"""
595 |
596 | @overrides(NoticeEvent)
597 | def get_user_id(self) -> str:
598 | return self.room_wxid
599 |
600 | @overrides(NoticeEvent)
601 | def get_event_description(self) -> str:
602 | return f"[群成员加入消息通知]:{self.dict()}"
603 |
604 |
605 | class RoomMemberDelNoticeEvent(NoticeEvent):
606 | """接收群成员退出消息"""
607 |
608 | type: int = EventType.MT_ROOM_DEL_MEMBER_NOTIFY_MSG
609 | """消息原始类型"""
610 | total_member: int
611 | """群成员人数"""
612 | room_wxid: str
613 | """群聊的wxid"""
614 | member_list: List[RoomMemberDel]
615 | """群成员变动的wxid 可用event.member_list[0].wxid获取"""
616 | nickname: str
617 | """群昵称"""
618 | data: Dict
619 | """微信中的原始消息,xml格式"""
620 |
621 | @overrides(NoticeEvent)
622 | def get_user_id(self) -> str:
623 | return self.room_wxid
624 |
625 | @overrides(NoticeEvent)
626 | def get_event_description(self) -> str:
627 | return f"[群成员退出消息通知]:{self.dict()}"
628 |
629 |
630 | class AppEvent(Event):
631 | """app事件"""
632 |
633 | timestamp: int
634 | """时间戳"""
635 | wx_type: int
636 | """消息原始类型"""
637 | from_wxid: str
638 | """发送者的wxid"""
639 | room_wxid: str
640 | """群聊的wxid"""
641 | to_wxid: str
642 | """接收者的wxid"""
643 | msgid: str
644 | """消息id"""
645 |
646 | @overrides(Event)
647 | def get_type(self) -> str:
648 | return "app"
649 |
650 | @overrides(Event)
651 | def get_user_id(self) -> str:
652 | return self.from_wxid
653 |
654 | @overrides(Event)
655 | def get_event_name(self) -> str:
656 | wx_type = WxType(self.wx_type).name
657 | try:
658 | sub_type = SubType(self.wx_sub_type).name
659 | except Exception:
660 | sub_type = self.wx_sub_type
661 | return f"AppMessage.{wx_type}.{sub_type}"
662 |
663 |
664 | class LinkMessageEvent(AppEvent):
665 | """接收链接消息"""
666 |
667 | type: int = EventType.MT_RECV_LINK_MSG
668 | wx_sub_type: int = SubType.WX_APPMSG_LINK
669 | raw_msg: str
670 | """微信中的原始消息,xml格式"""
671 |
672 | @overrides(Event)
673 | def get_event_description(self) -> str:
674 | msg = "[小程序链接事件]请查看raw_msg"
675 | if self.room_wxid:
676 | return f"Message {self.msgid} from {self.from_wxid}@[群:{self.room_wxid}]: {msg}"
677 | else:
678 | return f"Message {self.msgid} from {self.from_wxid}: {msg}"
679 |
680 |
681 | class MiniAppMessageEvent(AppEvent):
682 | """接收小程序消息"""
683 |
684 | type: int = EventType.MT_RECV_MINIAPP_MSG
685 | wx_sub_type: int = SubType.WX_APPMSG_MINIAPP
686 | raw_msg: str
687 | """微信中的原始消息,xml格式"""
688 |
689 | @overrides(Event)
690 | def get_event_description(self) -> str:
691 | msg = "[小程序消息]请查看raw_msg"
692 | if self.room_wxid:
693 | return f"Message {self.msgid} from {self.from_wxid}@[群:{self.room_wxid}]: {msg}"
694 | else:
695 | return f"Message {self.msgid} from {self.from_wxid}: {msg}"
696 |
697 |
698 | class WcpayMessageEvent(AppEvent):
699 | """接收转帐消息"""
700 |
701 | type: int = EventType.MT_RECV_WCPAY_MSG
702 | wx_sub_type: int = SubType.WX_APPMSG_WCPAY
703 | feedesc: str
704 | """收钱数目,以¥开头"""
705 | pay_memo: str
706 | """转账说明"""
707 | raw_msg: str
708 | """微信中的原始消息,xml格式"""
709 |
710 | @root_validator(pre=True, allow_reuse=True)
711 | def get_pre_message(cls, values: Dict[str, Any]) -> Dict[str, Any]:
712 | raw_xml = values["raw_msg"]
713 | xml_obj = ET.fromstring(raw_xml)
714 | wcpayinfo = xml_obj.find("./appmsg/wcpayinfo")
715 | values["feedesc"] = wcpayinfo.find("./feedesc").text
716 | values["pay_memo"] = wcpayinfo.find("./pay_memo").text
717 | return values
718 |
719 | @overrides(Event)
720 | def get_event_description(self) -> str:
721 | msg = f"[转账消息] - 收到{self.feedesc},转账说明:{self.pay_memo}"
722 | if self.room_wxid:
723 | return f"Message {self.msgid} from {self.from_wxid}@[群:{self.room_wxid}]: {msg}"
724 | else:
725 | return f"Message {self.msgid} from {self.from_wxid}: {msg}"
726 |
727 |
728 | class OtherAppMessageEvent(AppEvent):
729 | """接收其他应用类型消息,自行判断"""
730 |
731 | type: int = EventType.MT_RECV_OTHER_APP_MSG
732 | raw_msg: str
733 | """微信中的原始消息,xml格式"""
734 |
735 | @overrides(Event)
736 | def get_event_description(self) -> str:
737 | msg = "[其他应用消息]请查看raw_msg和其他字段"
738 | if self.room_wxid:
739 | return f"Message {self.msgid} from {self.from_wxid}@[群:{self.room_wxid}]: {msg}"
740 | else:
741 | return f"Message {self.msgid} from {self.from_wxid}: {msg}"
742 |
--------------------------------------------------------------------------------
/nonebot/adapters/ntchat/exception.py:
--------------------------------------------------------------------------------
1 | """adapter异常
2 | """
3 |
4 | from typing import Optional
5 |
6 | from nonebot.exception import AdapterException
7 | from nonebot.exception import ApiNotAvailable as BaseApiNotAvailable
8 | from nonebot.exception import NetworkError as BaseNetworkError
9 |
10 |
11 | class NtchatAdapterException(AdapterException):
12 | def __init__(self) -> None:
13 | super().__init__("ntchat")
14 |
15 |
16 | class NotInteractableEventError(NtchatAdapterException):
17 | """非可交互事件错误"""
18 |
19 | def __init__(self, msg: Optional[str] = None) -> None:
20 | super().__init__()
21 | self.msg: Optional[str] = msg
22 | """错误原因"""
23 |
24 | def __repr__(self) -> str:
25 | return f""
26 |
27 | def __str__(self) -> str:
28 | return self.__repr__()
29 |
30 |
31 | class NetworkError(BaseNetworkError, NtchatAdapterException):
32 | """网络错误。"""
33 |
34 | def __init__(self, msg: Optional[str] = None) -> None:
35 | super().__init__()
36 | self.msg: Optional[str] = msg
37 | """错误原因"""
38 |
39 | def __repr__(self) -> str:
40 | return f""
41 |
42 | def __str__(self) -> str:
43 | return self.__repr__()
44 |
45 |
46 | class ApiNotAvailable(BaseApiNotAvailable, NtchatAdapterException):
47 | """API 连接不可用"""
48 |
--------------------------------------------------------------------------------
/nonebot/adapters/ntchat/message.py:
--------------------------------------------------------------------------------
1 | from base64 import b64encode
2 | from io import BytesIO
3 | from pathlib import Path
4 | from typing import Iterable, List, Type, Union
5 |
6 | from nonebot.typing import overrides
7 |
8 | from nonebot.adapters import Message as BaseMessage
9 | from nonebot.adapters import MessageSegment as BaseMessageSegment
10 |
11 |
12 | class MessageSegment(BaseMessageSegment["Message"]):
13 | """ntchat MessageSegment 适配。具体方法参考https://www.showdoc.com.cn/579570325733136/3417108506295223。"""
14 |
15 | @classmethod
16 | @overrides(BaseMessageSegment)
17 | def get_message_class(cls) -> Type["Message"]:
18 | return Message
19 |
20 | @overrides(BaseMessageSegment)
21 | def __str__(self) -> str:
22 | type_ = self.type
23 | data = self.data.copy()
24 | if type_ == "text":
25 | # 用于command检验
26 | return data.get("content", "")
27 | return f"[{type_}]: {data}"
28 |
29 | @overrides(BaseMessageSegment)
30 | def __add__(
31 | self, other: Union[str, "MessageSegment", Iterable["MessageSegment"]]
32 | ) -> "Message":
33 | return Message(self) + (
34 | MessageSegment.text(other) if isinstance(other, str) else other
35 | )
36 |
37 | @overrides(BaseMessageSegment)
38 | def __radd__(
39 | self, other: Union[str, "MessageSegment", Iterable["MessageSegment"]]
40 | ) -> "Message":
41 | return (
42 | MessageSegment.text(other) if isinstance(other, str) else Message(other)
43 | ) + self
44 |
45 | @overrides(BaseMessageSegment)
46 | def is_text(self) -> bool:
47 | return self.type == "text" or self.type == "room_at_msg"
48 |
49 | @staticmethod
50 | def room_at_msg(content: str, at_list: List[str]) -> "MessageSegment":
51 | """
52 | 说明:
53 | - 群里发送@消息,文本消息的content的内容中设置占位字符串 {$@},
54 | - 这些字符的位置就是最终的@符号所在的位置,假设这两个被@的微信号的群昵称分别为aa,bb:
55 | - 则实际发送的内容为 "test,你好@ aa,你好@ bb.早上好"(占位符被替换了)
56 |
57 | 参数:
58 | * `content`:文字内容
59 | * `at_list`:at列表
60 | """
61 | return MessageSegment(
62 | "room_at_msg", data={"content": content, "at_list": at_list}
63 | )
64 |
65 | @staticmethod
66 | def text(content: str) -> "MessageSegment":
67 | """文字消息"""
68 | return MessageSegment("text", data={"content": content})
69 |
70 | @staticmethod
71 | def card(card_wxid: str) -> "MessageSegment":
72 | """名片消息"""
73 | return MessageSegment("card", {"card_wxid": card_wxid})
74 |
75 | @staticmethod
76 | def link(title: str, desc: str, url: str, image_url: str) -> "MessageSegment":
77 | """
78 | 说明:
79 | 链接消息
80 |
81 | 参数:
82 | tittle:标题
83 | desc:说明文字
84 | url:链接url
85 | image_url:图片url
86 | """
87 | return MessageSegment(
88 | "link", {"title": title, "desc": desc, "url": url, "image_url": image_url}
89 | )
90 |
91 | @staticmethod
92 | def image(file_path: Union[str, bytes, BytesIO, Path]) -> "MessageSegment":
93 | """图片消息"""
94 | if isinstance(file_path, BytesIO):
95 | file_path = file_path.getvalue()
96 | if isinstance(file_path, bytes):
97 | file_path = f"base64://{b64encode(file_path).decode()}"
98 | elif isinstance(file_path, Path):
99 | file_path = file_path.resolve().as_uri()
100 | return MessageSegment("image", {"file_path": file_path})
101 |
102 | @staticmethod
103 | def file(file_path: Union[str, bytes, BytesIO, Path]) -> "MessageSegment":
104 | """文件消息"""
105 | if isinstance(file_path, BytesIO):
106 | file_path = file_path.getvalue()
107 | if isinstance(file_path, bytes):
108 | file_path = f"base64://{b64encode(file_path).decode()}"
109 | elif isinstance(file_path, Path):
110 | file_path = file_path.resolve().as_uri()
111 | return MessageSegment("file", {"file_path": file_path})
112 |
113 | @staticmethod
114 | def video(file_path: Union[str, bytes, BytesIO, Path]) -> "MessageSegment":
115 | """视频消息"""
116 | if isinstance(file_path, BytesIO):
117 | file_path = file_path.getvalue()
118 | if isinstance(file_path, bytes):
119 | file_path = f"base64://{b64encode(file_path).decode()}"
120 | elif isinstance(file_path, Path):
121 | file_path = file_path.resolve().as_uri()
122 | return MessageSegment("video", {"file_path": file_path})
123 |
124 | @staticmethod
125 | def gif(file: Union[str, bytes, BytesIO, Path]) -> "MessageSegment":
126 | """gif消息"""
127 | if isinstance(file, BytesIO):
128 | file = file.getvalue()
129 | if isinstance(file, bytes):
130 | file = f"base64://{b64encode(file).decode()}"
131 | elif isinstance(file, Path):
132 | file = file.resolve().as_uri()
133 | return MessageSegment("file", {"file": file})
134 |
135 | @staticmethod
136 | def xml(xml: str, app_type: int = 5) -> "MessageSegment":
137 | """xml消息"""
138 | return MessageSegment("xml", {"xml": xml, "app_type": app_type})
139 |
140 |
141 | class Message(BaseMessage[MessageSegment]):
142 | """ntchat 协议 Message 适配。"""
143 |
144 | @classmethod
145 | @overrides(BaseMessage)
146 | def get_segment_class(cls) -> Type[MessageSegment]:
147 | return MessageSegment
148 |
149 | @overrides(BaseMessage)
150 | def __add__(
151 | self, other: Union[str, MessageSegment, Iterable[MessageSegment]]
152 | ) -> "Message":
153 | return super(Message, self).__add__(
154 | MessageSegment.text(other) if isinstance(other, str) else other
155 | )
156 |
157 | @overrides(BaseMessage)
158 | def __radd__(
159 | self, other: Union[str, MessageSegment, Iterable[MessageSegment]]
160 | ) -> "Message":
161 | return super(Message, self).__radd__(
162 | MessageSegment.text(other) if isinstance(other, str) else other
163 | )
164 |
165 | @overrides(BaseMessage)
166 | def __iadd__(
167 | self, other: Union[str, MessageSegment, Iterable[MessageSegment]]
168 | ) -> "Message":
169 | return super().__iadd__(
170 | MessageSegment.text(other) if isinstance(other, str) else other
171 | )
172 |
173 | @staticmethod
174 | @overrides(BaseMessage)
175 | def _construct(msg: str) -> Iterable[MessageSegment]:
176 | yield MessageSegment.text(msg)
177 |
--------------------------------------------------------------------------------
/nonebot/adapters/ntchat/permission.py:
--------------------------------------------------------------------------------
1 | """
2 | ntchat权限辅助
3 | """
4 |
5 | from nonebot.permission import Permission
6 |
7 | from .event import Event
8 |
9 |
10 | async def _private(event: Event) -> bool:
11 | return event.room_wxid == ""
12 |
13 |
14 | PRIVATE: Permission = Permission(_private)
15 | """ 匹配任意私聊消息类型事件"""
16 |
17 |
18 | async def _group(event: Event) -> bool:
19 | return event.room_wxid != ""
20 |
21 |
22 | GROUP: Permission = Permission(_group)
23 | """匹配任意群聊消息类型事件"""
24 |
--------------------------------------------------------------------------------
/nonebot/adapters/ntchat/store.py:
--------------------------------------------------------------------------------
1 | """API回调存储
2 | """
3 |
4 | import asyncio
5 | import sys
6 | from typing import Any, Dict, Optional, Tuple
7 |
8 | from .exception import NetworkError
9 |
10 |
11 | class ResultStore:
12 | def __init__(self) -> None:
13 | self._seq: int = 1
14 | self._futures: Dict[Tuple[str, int], asyncio.Future] = {}
15 |
16 | def get_seq(self) -> int:
17 | s = self._seq
18 | self._seq = (self._seq + 1) % sys.maxsize
19 | return s
20 |
21 | def add_result(self, self_id: str, result: Dict[str, Any]):
22 | echo = result.get("echo")
23 | if isinstance(echo, str) and echo.isdecimal():
24 | future = self._futures.get((self_id, int(echo)))
25 | if future:
26 | future.set_result(result)
27 |
28 | async def fetch(
29 | self, self_id: str, seq: int, timeout: Optional[float]
30 | ) -> Dict[str, Any]:
31 | future = asyncio.get_event_loop().create_future()
32 | self._futures[(self_id, seq)] = future
33 | try:
34 | return await asyncio.wait_for(future, timeout)
35 | except asyncio.TimeoutError:
36 | raise NetworkError("WebSocket API call timeout") from None
37 | finally:
38 | del self._futures[(self_id, seq)]
39 |
--------------------------------------------------------------------------------
/nonebot/adapters/ntchat/type.py:
--------------------------------------------------------------------------------
1 | """
2 | 消息类型枚举,参考:https://www.showdoc.com.cn/579570325733136/3417087407035329
3 | """
4 |
5 | from enum import IntEnum
6 |
7 |
8 | class EventType(IntEnum):
9 | """消息类型枚举"""
10 |
11 | MT_DEBUG_LOG = 11024
12 | """DEBUG消息"""
13 | MT_RECV_QRCODE_MSG = 11087
14 | """获取用户登录二维码"""
15 | MT_USER_LOGIN = 11025
16 | """登录消息"""
17 | MT_USER_LOGOUT = 11026
18 | """注销消息"""
19 | MT_DATA_FRIENDS_MSG = 11030
20 | """获取好友列表消息"""
21 | MT_DATA_CHATROOMS_MSG = 11031
22 | """获取群聊列表消息"""
23 | MT_DATA_CHATROOM_MEMBERS_MSG = 11032
24 | """获取群成员消息"""
25 | MT_DATA_PUBLICS_MSG = 11033
26 | """获取公众号消息"""
27 | MT_SEND_TEXTMSG = 11036
28 | """发送文本消息"""
29 | MT_SEND_CHATROOM_ATMSG = 11037
30 | """发送群@消息"""
31 | MT_SEND_CARDMSG = 11038
32 | """发送名片消息"""
33 | MT_SEND_LINKMSG = 11039
34 | """发送链接消息"""
35 | MT_SEND_IMGMSG = 11040
36 | """发送图片消息"""
37 | MT_SEND_FILEMSG = 11041
38 | """发送文件消息"""
39 | MT_SEND_VIDEOMSG = 11042
40 | """发送视频消息"""
41 | MT_SEND_GIFMSG = 11043
42 | """发送GIF消息"""
43 | MT_RECV_TEXT_MSG = 11046
44 | """接收文本消息"""
45 | MT_RECV_PICTURE_MSG = 11047
46 | """接收图片消息"""
47 | MT_RECV_VOICE_MSG = 11048
48 | """接收语音消息"""
49 | MT_RECV_FRIEND_MSG = 11049
50 | """接收申请好友消息"""
51 | MT_RECV_CARD_MSG = 11050
52 | """接收名片消息"""
53 | MT_RECV_VIDEO_MSG = 11051
54 | """接收视频消息"""
55 | MT_RECV_EMOJI_MSG = 11052
56 | """接收表情消息"""
57 | MT_RECV_LOCATION_MSG = 11053
58 | """接收位置消息"""
59 | MT_RECV_LINK_MSG = 11054
60 | """接收链接消息"""
61 | MT_RECV_FILE_MSG = 11055
62 | """接收文件消息"""
63 | MT_RECV_MINIAPP_MSG = 11056
64 | """接收小程序消息"""
65 | MT_RECV_WCPAY_MSG = 11057
66 | """接收好友转账消息"""
67 | MT_RECV_SYSTEM_MSG = 11058
68 | """接收系统消息"""
69 | MT_RECV_REVOKE_MSG = 11059
70 | """接收撤回消息"""
71 | MT_RECV_OTHER_MSG = 11060
72 | """接收其他未知消息"""
73 | MT_RECV_OTHER_APP_MSG = 11061
74 | """接收应用类型未知消息"""
75 | MT_ROOM_ADD_MEMBER_NOTIFY_MSG = 11098
76 | """群员新增通知"""
77 | MT_ROOM_DEL_MEMBER_NOTIFY_MSG = 11099
78 | """群员删除通知"""
79 | MT_ROOM_INTIVTED_NOTIFY_MSG = 11100
80 | """被邀请入群通知"""
81 | MT_FRIEND_ADD_NOTIFY_MSG = 11102
82 | """好友添加通知"""
83 |
84 |
85 | class WxType(IntEnum):
86 | """微信原始类型枚举"""
87 |
88 | WX_MSG_TEXT = 1
89 | """文本"""
90 | WX_MSG_PICTURE = 3
91 | """图片"""
92 | WX_MSG_VOICE = 34
93 | """语音"""
94 | WX_MSG_FRIEND = 37
95 | """加好友请求"""
96 | WX_MSG_CARD = 42
97 | """名片"""
98 | WX_MSG_VIDEO = 43
99 | """视频"""
100 | WX_MSG_EMOJI = 47
101 | """表情"""
102 | WX_MSG_LOCATION = 48
103 | """位置"""
104 | WX_MSG_APP = 49
105 | """应用类型"""
106 | WX_MSG_SYSTEM = 10000
107 | """系统消息"""
108 | WX_MSG_REVOKE = 10002
109 | """撤回消息"""
110 |
111 |
112 | class SubType(IntEnum):
113 | """应用子类型枚举"""
114 |
115 | WX_APPMSG_LINK = 5
116 | """链接(包含群邀请)"""
117 | WX_APPMSG_FILE = 6
118 | """文件"""
119 | WX_APPMSG_EMOJI = 8
120 | """表情消息"""
121 | WX_APPMSG_MUTIL = 19
122 | """合并消息"""
123 | WX_APPMSG_MINIAPP = 33
124 | """小程序"""
125 | WX_APPMSG_QUOTE = 57
126 | """引用消息"""
127 | WX_APPMSG_WCPAY = 2000
128 | """转账"""
129 |
--------------------------------------------------------------------------------
/nonebot/adapters/ntchat/utils.py:
--------------------------------------------------------------------------------
1 | from typing import Any, Dict, Optional
2 |
3 | from nonebot.exception import ActionFailed
4 | from nonebot.utils import logger_wrapper
5 |
6 | log = logger_wrapper("ntchat")
7 |
8 |
9 | def handle_api_result(result: Optional[Dict[str, Any]]) -> Any:
10 | """处理 API 请求返回值。
11 |
12 | 参数:
13 | result: API 返回数据
14 |
15 | 返回:
16 | API 调用返回数据
17 |
18 | 异常:
19 | ActionFailed: API 调用失败
20 | """
21 | if isinstance(result, dict):
22 | if result.get("status") == "failed":
23 | raise ActionFailed(**result)
24 | return result.get("data")
25 |
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | anyio==3.6.1
2 | certifi==2022.9.24
3 | click==8.1.3
4 | colorama==0.4.5
5 | fastapi==0.79.1
6 | h11==0.12.0
7 | httpcore==0.15.0
8 | httptools==0.5.0
9 | httpx==0.23.0
10 | idna==3.4
11 | loguru==0.6.0
12 | multidict==6.0.2
13 | nonebot2==2.0.0rc1
14 | pydantic==1.9.2
15 | pygtrie==2.5.0
16 | python-dotenv==0.21.0
17 | PyYAML==6.0
18 | rfc3986==1.5.0
19 | sniffio==1.3.0
20 | starlette==0.19.1
21 | tomlkit==0.11.5
22 | typing_extensions==4.4.0
23 | uvicorn==0.18.3
24 | watchfiles==0.17.0
25 | websockets==10.3
26 | win32-setctime==1.1.0
27 | wincertstore==0.2
28 | yarl==1.8.1
29 |
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
1 | import setuptools
2 |
3 | with open("README.md", "r", encoding="utf-8") as fh:
4 | long_description = fh.read()
5 |
6 | setuptools.setup(
7 | name="nonebot-adapter-ntchat",
8 | version="0.3.5",
9 | author="JustUndertaker",
10 | author_email="806792561@qq.com",
11 | description="a wechat adapter for nonebot2",
12 | long_description=long_description,
13 | long_description_content_type="text/markdown",
14 | url="https://github.com/JustUndertaker/adapter-ntchat",
15 | packages=["nonebot.adapters.ntchat"],
16 | classifiers=[
17 | "Programming Language :: Python :: 3.8",
18 | "Programming Language :: Python :: 3.9",
19 | "Programming Language :: Python :: 3.10",
20 | "License :: OSI Approved :: MIT License",
21 | ],
22 | python_requires=">=3.8",
23 | install_requires=["httpx==0.23.0"],
24 | )
25 |
--------------------------------------------------------------------------------