├── .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 | release 5 | License 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 | --------------------------------------------------------------------------------