├── .gitignore ├── .pre-commit-config.yaml ├── CHANGELOG.md ├── LICENSE ├── MANIFEST.in ├── README.md ├── docs ├── imgs │ ├── chatroom_chat.png │ ├── forward_revoked_msg.png │ └── private_chat.png └── rpc │ └── api.json ├── logs └── .gitkeep ├── requirements.txt ├── setup.cfg ├── setup.py └── whochat ├── ComWeChatRobot ├── CWeChatRobot.exe ├── DWeChatRobot.dll └── __init__.py ├── __init__.py ├── __main__.py ├── _comtypes.py ├── abc.py ├── bot.py ├── cli.py ├── logger.py ├── messages ├── __init__.py ├── constants.py ├── tcp.py └── websocket.py ├── rpc ├── __init__.py ├── clients │ ├── __init__.py │ └── websocket.py ├── docs.py ├── handlers.py └── servers │ ├── __init__.py │ ├── http.py │ └── websocket.py ├── settings.py ├── signals.py └── utils.py /.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 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | *.egg-info/ 23 | .installed.cfg 24 | *.egg 25 | 26 | # PyInstaller 27 | # Usually these files are written by a python script from a template 28 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 29 | *.manifest 30 | *.spec 31 | 32 | # Installer logs 33 | pip-log.txt 34 | pip-delete-this-directory.txt 35 | 36 | # Unit test / coverage reports 37 | htmlcov/ 38 | .tox/ 39 | .coverage 40 | .coverage.* 41 | .cache 42 | nosetests.xml 43 | coverage.xml 44 | *,cover 45 | .hypothesis/ 46 | 47 | # Translations 48 | *.mo 49 | *.pot 50 | 51 | # Django stuff: 52 | *.log 53 | local_settings.py 54 | 55 | # Flask stuff: 56 | instance/ 57 | .webassets-cache 58 | 59 | # Scrapy stuff: 60 | .scrapy 61 | 62 | # Sphinx documentation 63 | docs/_build/ 64 | 65 | # PyBuilder 66 | target/ 67 | 68 | # IPython Notebook 69 | .ipynb_checkpoints 70 | 71 | # pyenv 72 | .python-version 73 | 74 | # celery beat schedule file 75 | celerybeat-schedule 76 | 77 | # dotenv 78 | .env 79 | 80 | # virtualenv 81 | venv/ 82 | ENV/ 83 | 84 | # Spyder project settings 85 | .spyderproject 86 | 87 | # Rope project settings 88 | .ropeproject 89 | 90 | # Instance 91 | media/ 92 | logs/* 93 | !logs/.gitkeep 94 | whochat/ComWeChatRobot/log 95 | 96 | # Editors & IDEs 97 | *.sublime-project 98 | *.sublime-workspace 99 | .idea/ 100 | *.swp 101 | .vscode 102 | 103 | # db 104 | *.sqlite3 105 | *.db 106 | 107 | # macOS 108 | .DS_Store 109 | 110 | # custom 111 | _test*.py 112 | _test*.json 113 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v4.3.0 4 | hooks: 5 | - id: trailing-whitespace 6 | - id: end-of-file-fixer 7 | 8 | - repo: https://github.com/PyCQA/isort 9 | rev: 5.10.1 10 | hooks: 11 | - id: isort 12 | 13 | - repo: https://github.com/PyCQA/flake8 14 | rev: 5.0.4 15 | hooks: 16 | - id: flake8 17 | 18 | - repo: https://github.com/psf/black 19 | rev: 22.6.0 20 | hooks: 21 | - id: black 22 | 23 | 24 | exclude: | 25 | (?x)^( 26 | docs 27 | ) 28 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | ``` 3 | ___ __ ___ ___ ________ ________ ___ ___ ________ _________ 4 | |\ \ |\ \|\ \|\ \|\ __ \|\ ____\|\ \|\ \|\ __ \|\___ ___\ 5 | \ \ \ \ \ \ \ \\\ \ \ \|\ \ \ \___|\ \ \\\ \ \ \|\ \|___ \ \_| 6 | \ \ \ __\ \ \ \ __ \ \ \\\ \ \ \ \ \ __ \ \ __ \ \ \ \ 7 | \ \ \|\__\_\ \ \ \ \ \ \ \\\ \ \ \____\ \ \ \ \ \ \ \ \ \ \ \ 8 | \ \____________\ \__\ \__\ \_______\ \_______\ \__\ \__\ \__\ \__\ \ \__\ 9 | \|____________|\|__|\|__|\|_______|\|_______|\|__|\|__|\|__|\|__| \|__| 10 | ``` 11 | 12 | [Tags](https://github.com/amchii/whochat/tags) 13 | ## v1.3.5 14 | * 解析最新微信版本`extra_info` 15 | * Log raw message at debug level 16 | 17 | ## v1.3.4 18 | * 自动设置微信版本号避免更新 19 | * 增加环境变量`WHOCHAT_WECHAT_VERSION`自定义微信版本号 20 | * 尝试使`BotWebsocketRPCClient.rpc_call`更正确地运行 21 | 22 | ## v1.3.3 23 | * 增加获取微信最新版本号的方法 24 | * 修复Mac用户发送@消息无法正确解析的问题 25 | 26 | ## v1.3.2 27 | * 修改日志级别,增加日志文件记录 28 | 29 | ## v1.3.0 30 | * 增加RPC Websocket客户端 31 | * 消息转发命令行增加`--welcome`参数决定是否在客户端连接是发送"hello" 32 | * `hook_`方法返回路径 33 | * 增加`prevent_revoke`阻止文件消息被撤回时被删除 34 | 35 | ## v1.2.1 36 | * 更新适配 [Robot DLL](https://github.com/amchii/ComWeChatRobot/commit/f6d75778d22b590a4775e49b72cb9c19037d2671) 37 | * 添加`_comtypes.py`方便在非Windows平台开发 38 | 39 | ## v1.1.0 40 | 41 | * 更新 [Robot DLL](https://github.com/ljc545w/ComWeChatRobot/commit/ff76f80ce2f3d979bf968d07f530701d834dc988) 42 | * 接收消息增加`extrainfo`字段,当消息为群消息时可获取群成员数量和被@的人的微信ID 43 | * 命令行增加`log-level`选项控制日志级别 44 | * 调用bot方法时自动注入dll 45 | * 添加 [docs/rpc/api.json](docs/rpc/api.json) 46 | 47 | ## v1.0.1 48 | 49 | * 添加Python版本依赖说明 50 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2022, Amchii 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | 2. Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | 3. Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md 2 | include LICENSE 3 | global-exclude *.pyc 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # WhoChat 2 | ``` 3 | ___ __ ___ ___ ________ ________ ___ ___ ________ _________ 4 | |\ \ |\ \|\ \|\ \|\ __ \|\ ____\|\ \|\ \|\ __ \|\___ ___\ 5 | \ \ \ \ \ \ \ \\\ \ \ \|\ \ \ \___|\ \ \\\ \ \ \|\ \|___ \ \_| 6 | \ \ \ __\ \ \ \ __ \ \ \\\ \ \ \ \ \ __ \ \ __ \ \ \ \ 7 | \ \ \|\__\_\ \ \ \ \ \ \ \\\ \ \ \____\ \ \ \ \ \ \ \ \ \ \ \ 8 | \ \____________\ \__\ \__\ \_______\ \_______\ \__\ \__\ \__\ \__\ \ \__\ 9 | \|____________|\|__|\|__|\|_______|\|_______|\|__|\|__|\|__|\|__| \|__| 10 | ``` 11 | 12 | **一个依赖于 [ComWeChatRobot](https://github.com/ljc545w/ComWeChatRobot)提供的Com接口的微信机器人,在此之上提供了:** 13 | 14 | 1. 发布至PyPI,可以一键安装 15 | 2. 命令行支持,可以方便通过命令操作(见下面使用说明) 16 | 3. WebSocket消息推送 17 | 4. [JSON-RPC2.0](https://wiki.geekdream.com/Specification/json-rpc_2.0.html)方法调用,支持WebSocket和HTTP 18 | 5. 简单的定时任务支持 19 | 6. 其他 20 | 21 | **当前支持微信版本为`3.7.0.30`, Python版本3.8及以上** 22 | 23 | ## 示例: 24 | 25 | ### 一个接入ChatGPT的机器人示例,支持私聊和群聊: 26 | 27 | 1. 私聊 28 | 29 | ![Private Chat](https://github.com/amchii/whochat/raw/main/docs/imgs/private_chat.png) 30 | 31 | 2. 群 32 | 33 | ![Chatroom Chat](https://github.com/amchii/whochat/raw/main/docs/imgs/chatroom_chat.png) 34 | 35 | 3. 防撤回(转发撤回消息至文件传输助手) 36 | 37 | ![forward revoked msg](https://github.com/amchii/whochat/raw/main/docs/imgs/forward_revoked_msg.png) 38 | 39 | 4. 其他 40 | 41 | 复读机,定时任务... 42 | 43 | 示例源码: 44 | 45 | https://github.com/amchii/wechat-bot 46 | 47 | ## 安装: 48 | 49 | `pip install whochat` 50 | 51 | 若需要HTTP RPC支持,则是 52 | 53 | `pip install whochat[httprpc]` 54 | 55 | 安装完成之后尝试使用`whochat`命令,理应看到以下输出: 56 | 57 | ``` 58 | D:\ 59 | > whochat --help 60 | Usage: whochat [OPTIONS] COMMAND [ARGS]... 61 | 62 | 微信机器人 63 | 64 | 使用<子命令> --help查看使用说明 65 | 66 | Options: 67 | --help Show this message and exit. 68 | 69 | Commands: 70 | list-wechat 列出当前运行的微信进程 71 | regserver 注册COM 72 | serve-message-ws 运行接收微信消息的Websocket服务 73 | serve-rpc-http 运行微信机器人RPC服务(JSON-RPC2.0), 使用HTTP接口 74 | serve-rpc-ws 运行微信机器人RPC服务(JSON-RPC2.0), 使用Websocket 75 | show-rpc-docs 列出RPC接口 76 | version 显示程序和支持微信的版本信息 77 | ``` 78 | 79 | ## 使用 : 80 | 81 | 1. 列出当前运行的微信进程: 82 | 83 | ``` 84 | > whochat list-wechat 85 | PID: 102852 86 | 启动时间: 2022-08-27T22:22:02.290700 87 | 运行状态: running 88 | 用户名: wxid_hjkafa123a 89 | --- 90 | ``` 91 | 92 | 2. 注册COM服务: 93 | 94 | ``` 95 | > whochat regserver # 注册 96 | > whochat regserver --unreg # 取消注册 97 | ``` 98 | 99 | 注册一次就可以使用服务了。 100 | 101 | 3. 开启微信消息转发WebSocket服务 102 | 103 | ``` 104 | > whochat serve-message-ws --help 105 | Usage: whochat serve-message-ws [OPTIONS] [WX_PIDS]... 106 | 107 | 运行接收微信消息的Websocket服务 108 | 109 | WX_PIDS: 微信进程PID 110 | 111 | Options: 112 | -h, --host TEXT Server host. [default: localhost] 113 | -p, --port INTEGER Server port [default: 9001] 114 | --help Show this message and exit. 115 | ``` 116 | 117 | 该子命令接受一或多个微信PID作为位置参数,可以指定地址 118 | 119 | ``` 120 | > whochat serve-message-ws 102852 121 | 注册SIGINT信号处理程序: WechatWebsocketServer.shutdown 122 | 开始运行微信消息接收服务 123 | 开始向客户端广播接收到的微信消息 124 | 开始运行微信Websocket服务,地址为: 125 | {'wxId': 'wxid_hjkafa123a', 'wxNumber': 'wxid_hjkafa123a', 'wxNickName': 'Cider', 'Sex': '男', 'wxSignature': 'null', 'wxBigAvatar': 'http://wx.qlogo.cn/mmhead/ver_1/R50J6cxxTRzE28sY32DVJibeRUZPiaPotzPVjuReXZsONBdNZXQChSfrK0rDWh8RKS5ibt7VJdK0p22YJrOGjRA051lY9mwkt6ONruLmYTyBAA/0', 'wxSmallAvatar': 'http://wx.qlogo.cn/mmhead/ver_1/R50J6cxxTRzE28sY32DVJibeRUZPiaPotzPVjuReXZsONBdNZXQChSfrK0rDWh8RKS5ibt7VJdK0p22YJrOGjRA051lY9mwkt6ONruLmYTyBAA/132', 'wxNation': 'CN', 'wxProvince': 'Anhui', 'wxCity': 'Hefei', 'PhoneNumber': 'null'} 126 | 开启Robot消息推送 127 | ``` 128 | 129 | 默认地址为`localhost:9001`,连接测试: 130 | ![WebSocket测试](https://user-images.githubusercontent.com/26922464/187036096-3a780aaa-e79e-4c82-abb2-9f7c402601a1.gif) 131 | 132 | 当前接收消息格式示例: 133 | 134 | ```json 135 | { 136 | "extrainfo": { 137 | "is_at_msg": true, 138 | "at_user_list": [ 139 | "wx_user_id1", 140 | "wx_user_id2" 141 | ], 142 | "member_count": 23 143 | }, 144 | "filepath": "", 145 | "isSendMsg": 0, 146 | "message": "@wx_user1\u2005@wx_user2\u2005Hello", 147 | "msgid": 7495392442139043211, 148 | "pid": 17900, 149 | "sender": "20813132945@chatroom", 150 | "time": "2022-09-03 22: 10: 33", 151 | "type": 1, 152 | "wxid": "wx_user_id10" 153 | } 154 | ``` 155 | 156 | 4. 开启WebSocket RPC服务进行方法调用: 157 | 158 | ``` 159 | > whochat serve-rpc-ws 160 | PID: 28824 161 | 注册SIGINT信号处理程序: run..shutdown 162 | 运行微信机器人RPC websocket服务, 地址为 163 | ``` 164 | 165 | 默认地址为`localhost:9002`,测试发送消息给文件传输助手,~~记得先调用`start_robot_service`注入dll~~ 166 | ,现在调用方法时会自动注入dll 167 | ![发送消息](https://user-images.githubusercontent.com/26922464/187036614-f1b8589b-ce2b-4c57-bbb0-c167755201a5.png) 168 | RPC所有方法和参数可通过`whochat show-rpc-docs`命令查看或者`whochat show-rpc-docs --json > docs.json` 169 | 生成JSON文档([rpc-api.json](docs/rpc/api.json)): 170 | 171 | ``` 172 | > whochat show-rpc-docs --help 173 | Usage: whochat show-rpc-docs [OPTIONS] 174 | 175 | 列出RPC接口 176 | 177 | whochat show-rpc-docs 178 | or 179 | whochat show-rpc-docs --json > docs.json 180 | 181 | Options: 182 | --json JSON文档 183 | --help Show this message and exit. 184 | ``` 185 | 186 | 5. 定时任务: 187 | 188 | 在每天上午6点整喊基友起床,同样使用RPC调用`schedule_a_job`(获取接口文档见*4*), 189 | 190 | ```json 191 | { 192 | "jsonrpc": "2.0", 193 | "method": "schedule_a_job", 194 | "params": { 195 | "name": "GETUP", 196 | "unit": "days", 197 | "every": 1, 198 | "at": "08:00:00", 199 | "do": { 200 | "func": "send_text", 201 | "args": [ 202 | 102852, 203 | "jiyou", 204 | "GET UP!" 205 | ] 206 | }, 207 | "description": "", 208 | "tags": [ 209 | "jiyou" 210 | ] 211 | }, 212 | "id": 4 213 | } 214 | ``` 215 | 216 | ## CHANGE LOG: 217 | 218 | [CHANGELOG](https://github.com/amchii/whochat/blob/main/CHANGELOG.md) 219 | 220 | [Tags](https://github.com/amchii/whochat/tags) 221 | 222 | ### v1.3.5 223 | 224 | * 解析最新微信版本`extra_info` 225 | * Log raw message at debug level 226 | 227 | ### v1.3.4 228 | 229 | * 自动设置微信版本号避免更新 230 | * 增加环境变量`WHOCHAT_WECHAT_VERSION`自定义微信版本号 231 | * 尝试使`BotWebsocketRPCClient.rpc_call`更正确地运行 232 | 233 | ### v1.3.3 234 | 235 | * 增加获取微信最新版本号的方法 236 | * 修复Mac用户发送@消息无法正确解析的问题 237 | 238 | ### v1.3.2 239 | 240 | * 修改日志级别,增加日志文件记录 241 | 242 | ### v1.3.0 243 | 244 | * 增加RPC WebSocket客户端 245 | * 消息转发命令行增加`--welcome`参数决定是否在客户端连接是发送"hello" 246 | * `hook_`方法返回路径 247 | * 增加`prevent_revoke`阻止文件消息被撤回时被删除 248 | 249 | ### v1.2.1 250 | 251 | * 更新适配 [Robot DLL](https://github.com/amchii/ComWeChatRobot/commit/f6d75778d22b590a4775e49b72cb9c19037d2671) 252 | * 添加`_comtypes.py`方便在非Windows平台开发 253 | 254 | ### v.1.1.0 255 | 256 | * 更新 [Robot DLL](https://github.com/ljc545w/ComWeChatRobot/commit/ff76f80ce2f3d979bf968d07f530701d834dc988) 257 | * 接收消息增加`extrainfo`字段,当消息为群消息时可获取群成员数量和被@的人的微信ID 258 | * 命令行增加`log-level`选项控制日志级别 259 | * 调用bot方法时自动注入dll 260 | * 添加 [docs/rpc/api.json](https://github.com/amchii/whochat/blob/main/docs/rpc/api.json) 261 | 262 | ### v1.0.1 263 | 264 | * 添加Python版本依赖说明 265 | 266 | ## 欢迎学习交流,点个star⭐️ 267 | -------------------------------------------------------------------------------- /docs/imgs/chatroom_chat.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amchii/whochat/d2d0b77055014860c1cd5a43062fb2e7525852c1/docs/imgs/chatroom_chat.png -------------------------------------------------------------------------------- /docs/imgs/forward_revoked_msg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amchii/whochat/d2d0b77055014860c1cd5a43062fb2e7525852c1/docs/imgs/forward_revoked_msg.png -------------------------------------------------------------------------------- /docs/imgs/private_chat.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amchii/whochat/d2d0b77055014860c1cd5a43062fb2e7525852c1/docs/imgs/private_chat.png -------------------------------------------------------------------------------- /docs/rpc/api.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "add_brand_contact", 4 | "description": null, 5 | "params": [ 6 | { 7 | "name": "wx_pid", 8 | "default": null, 9 | "required": true 10 | }, 11 | { 12 | "name": "public_id", 13 | "default": null, 14 | "required": true 15 | } 16 | ] 17 | }, 18 | { 19 | "name": "add_friend_by_wxid", 20 | "description": null, 21 | "params": [ 22 | { 23 | "name": "wx_pid", 24 | "default": null, 25 | "required": true 26 | }, 27 | { 28 | "name": "wxid", 29 | "default": null, 30 | "required": true 31 | }, 32 | { 33 | "name": "message", 34 | "default": null, 35 | "required": true 36 | } 37 | ] 38 | }, 39 | { 40 | "name": "cancel_jobs", 41 | "description": "\n 取消任务\n :param tag: 标签名\n ", 42 | "params": [ 43 | { 44 | "name": "tag", 45 | "default": null, 46 | "required": false 47 | } 48 | ] 49 | }, 50 | { 51 | "name": "delete_user", 52 | "description": null, 53 | "params": [ 54 | { 55 | "name": "wx_pid", 56 | "default": null, 57 | "required": true 58 | }, 59 | { 60 | "name": "wxid", 61 | "default": null, 62 | "required": true 63 | } 64 | ] 65 | }, 66 | { 67 | "name": "forward_message", 68 | "description": "\n 转发消息\n\n Args:\n wxid (str): 消息接收人\n msgid (int): 消息id\n ", 69 | "params": [ 70 | { 71 | "name": "wx_pid", 72 | "default": null, 73 | "required": true 74 | }, 75 | { 76 | "name": "wxid", 77 | "default": null, 78 | "required": true 79 | }, 80 | { 81 | "name": "msgid", 82 | "default": null, 83 | "required": true 84 | } 85 | ] 86 | }, 87 | { 88 | "name": "get_a8_key", 89 | "description": "\n 获取A8Key\n\n Args:\n url (str): 公众号文章链接\n ", 90 | "params": [ 91 | { 92 | "name": "wx_pid", 93 | "default": null, 94 | "required": true 95 | }, 96 | { 97 | "name": "url", 98 | "default": null, 99 | "required": true 100 | } 101 | ] 102 | }, 103 | { 104 | "name": "get_chat_room_member_ids", 105 | "description": null, 106 | "params": [ 107 | { 108 | "name": "wx_pid", 109 | "default": null, 110 | "required": true 111 | }, 112 | { 113 | "name": "chatroom_id", 114 | "default": null, 115 | "required": true 116 | } 117 | ] 118 | }, 119 | { 120 | "name": "get_chat_room_member_nickname", 121 | "description": null, 122 | "params": [ 123 | { 124 | "name": "wx_pid", 125 | "default": null, 126 | "required": true 127 | }, 128 | { 129 | "name": "chatroom_id", 130 | "default": null, 131 | "required": true 132 | }, 133 | { 134 | "name": "wxid", 135 | "default": null, 136 | "required": true 137 | } 138 | ] 139 | }, 140 | { 141 | "name": "get_chat_room_members", 142 | "description": "\n 获取群成员id及昵称信息\n\n [\n {\n \"wx_id\": \"\",\n \"nickname\": \"\"\n }\n ]\n ", 143 | "params": [ 144 | { 145 | "name": "wx_pid", 146 | "default": null, 147 | "required": true 148 | }, 149 | { 150 | "name": "chatroom_id", 151 | "default": null, 152 | "required": true 153 | } 154 | ] 155 | }, 156 | { 157 | "name": "get_db_handles", 158 | "description": null, 159 | "params": [ 160 | { 161 | "name": "wx_pid", 162 | "default": null, 163 | "required": true 164 | } 165 | ] 166 | }, 167 | { 168 | "name": "get_friend_list", 169 | "description": null, 170 | "params": [ 171 | { 172 | "name": "wx_pid", 173 | "default": null, 174 | "required": true 175 | } 176 | ] 177 | }, 178 | { 179 | "name": "get_history_public_msg", 180 | "description": "\n 获取公众号历史消息\n\n Args:\n offset (str, optional): 起始偏移,为空的话则从新到久获取十条,该值可从返回数据中取得\n ", 181 | "params": [ 182 | { 183 | "name": "wx_pid", 184 | "default": null, 185 | "required": true 186 | }, 187 | { 188 | "name": "public_id", 189 | "default": null, 190 | "required": true 191 | }, 192 | { 193 | "name": "offset", 194 | "default": "", 195 | "required": false 196 | } 197 | ] 198 | }, 199 | { 200 | "name": "get_msg_cdn", 201 | "description": "\n 下载图片、视频、文件等\n\n Returns:\n str\n 成功返回文件路径,失败返回空字符串.\n ", 202 | "params": [ 203 | { 204 | "name": "wx_pid", 205 | "default": null, 206 | "required": true 207 | }, 208 | { 209 | "name": "msgid", 210 | "default": null, 211 | "required": true 212 | } 213 | ] 214 | }, 215 | { 216 | "name": "get_qrcode_image", 217 | "description": "\n 获取二维码,同时切换到扫码登陆\n\n Returns:\n bytes\n 二维码bytes数据.\n You can convert it to image object,like this:\n >>> from io import BytesIO\n >>> from PIL import Image\n >>> buf = wx.GetQrcodeImage()\n >>> image = Image.open(BytesIO(buf)).convert(\"L\")\n >>> image.save('./qrcode.png')\n ", 218 | "params": [ 219 | { 220 | "name": "wx_pid", 221 | "default": null, 222 | "required": true 223 | } 224 | ] 225 | }, 226 | { 227 | "name": "get_robot_pid", 228 | "description": null, 229 | "params": [] 230 | }, 231 | { 232 | "name": "get_self_info", 233 | "description": null, 234 | "params": [ 235 | { 236 | "name": "wx_pid", 237 | "default": null, 238 | "required": true 239 | }, 240 | { 241 | "name": "refresh", 242 | "default": false, 243 | "required": false 244 | } 245 | ] 246 | }, 247 | { 248 | "name": "get_transfer", 249 | "description": "\n 收款\n\n Args:\n wxid : str\n 转账人wxid.\n transcationid : str\n 从转账消息xml中获取.\n transferid : str\n 从转账消息xml中获取.\n\n Returns:\n int\n 成功返回0,失败返回非0值.\n ", 250 | "params": [ 251 | { 252 | "name": "wx_pid", 253 | "default": null, 254 | "required": true 255 | }, 256 | { 257 | "name": "wxid", 258 | "default": null, 259 | "required": true 260 | }, 261 | { 262 | "name": "transactionid", 263 | "default": null, 264 | "required": true 265 | }, 266 | { 267 | "name": "transferid", 268 | "default": null, 269 | "required": true 270 | } 271 | ] 272 | }, 273 | { 274 | "name": "get_we_chat_ver", 275 | "description": null, 276 | "params": [] 277 | }, 278 | { 279 | "name": "get_wx_user_info", 280 | "description": null, 281 | "params": [ 282 | { 283 | "name": "wx_pid", 284 | "default": null, 285 | "required": true 286 | }, 287 | { 288 | "name": "wxid", 289 | "default": null, 290 | "required": true 291 | } 292 | ] 293 | }, 294 | { 295 | "name": "hook_image_msg", 296 | "description": null, 297 | "params": [ 298 | { 299 | "name": "wx_pid", 300 | "default": null, 301 | "required": true 302 | }, 303 | { 304 | "name": "savepath", 305 | "default": null, 306 | "required": true 307 | } 308 | ] 309 | }, 310 | { 311 | "name": "hook_voice_msg", 312 | "description": null, 313 | "params": [ 314 | { 315 | "name": "wx_pid", 316 | "default": null, 317 | "required": true 318 | }, 319 | { 320 | "name": "savepath", 321 | "default": null, 322 | "required": true 323 | } 324 | ] 325 | }, 326 | { 327 | "name": "is_wx_login", 328 | "description": null, 329 | "params": [ 330 | { 331 | "name": "wx_pid", 332 | "default": null, 333 | "required": true 334 | } 335 | ] 336 | }, 337 | { 338 | "name": "kill_robot", 339 | "description": null, 340 | "params": [] 341 | }, 342 | { 343 | "name": "list_jobs", 344 | "description": "列出所有任务", 345 | "params": [] 346 | }, 347 | { 348 | "name": "list_wechat", 349 | "description": null, 350 | "params": [] 351 | }, 352 | { 353 | "name": "logout", 354 | "description": "\n 登出\n\n Returns:\n int: 0表示成功\n ", 355 | "params": [ 356 | { 357 | "name": "wx_pid", 358 | "default": null, 359 | "required": true 360 | } 361 | ] 362 | }, 363 | { 364 | "name": "open_browser", 365 | "description": null, 366 | "params": [ 367 | { 368 | "name": "wx_pid", 369 | "default": null, 370 | "required": true 371 | }, 372 | { 373 | "name": "url", 374 | "default": null, 375 | "required": true 376 | } 377 | ] 378 | }, 379 | { 380 | "name": "schedule_a_job", 381 | "description": "\n {\n \"name\": \"Greet\",\n \"unit\": \"days\",\n \"every\": 1,\n \"at\": \"08:00:00\",\n \"do\": {\n \"func\": \"send_text\",\n \"args\": [12314, \"wxid_foo\", \"Morning!\"]\n },\n \"description\": \"\",\n \"tags\": [\"tian\"]\n }\n 参见 https://schedule.readthedocs.io/en/stable/examples.html\n :param name: 任务名\n :param unit: 单位,seconds, minutes, hours, days, weeks, monday, tuesday, wednesday, thursday, friday, saturday, sunday\n :param every: 每\n :param at: For daily jobs -> HH:MM:SS or HH:MM\n For hourly jobs -> MM:SS or :MM\n For minute jobs -> :SS\n :param do: 执行的方法,func: 方法名, args: 参数列表\n :param description: 描述\n :param tags: 标签,总会添加任务名作为标签\n ", 382 | "params": [ 383 | { 384 | "name": "name", 385 | "default": null, 386 | "required": true 387 | }, 388 | { 389 | "name": "unit", 390 | "default": null, 391 | "required": true 392 | }, 393 | { 394 | "name": "every", 395 | "default": null, 396 | "required": true 397 | }, 398 | { 399 | "name": "at", 400 | "default": null, 401 | "required": true 402 | }, 403 | { 404 | "name": "do", 405 | "default": null, 406 | "required": true 407 | }, 408 | { 409 | "name": "description", 410 | "default": null, 411 | "required": false 412 | }, 413 | { 414 | "name": "tags", 415 | "default": null, 416 | "required": false 417 | } 418 | ] 419 | }, 420 | { 421 | "name": "search_contact_by_net", 422 | "description": null, 423 | "params": [ 424 | { 425 | "name": "wx_pid", 426 | "default": null, 427 | "required": true 428 | }, 429 | { 430 | "name": "keyword", 431 | "default": null, 432 | "required": true 433 | } 434 | ] 435 | }, 436 | { 437 | "name": "send_app_msg", 438 | "description": null, 439 | "params": [ 440 | { 441 | "name": "wx_pid", 442 | "default": null, 443 | "required": true 444 | }, 445 | { 446 | "name": "wxid", 447 | "default": null, 448 | "required": true 449 | }, 450 | { 451 | "name": "app_id", 452 | "default": null, 453 | "required": true 454 | } 455 | ] 456 | }, 457 | { 458 | "name": "send_article", 459 | "description": null, 460 | "params": [ 461 | { 462 | "name": "wx_pid", 463 | "default": null, 464 | "required": true 465 | }, 466 | { 467 | "name": "wxid", 468 | "default": null, 469 | "required": true 470 | }, 471 | { 472 | "name": "title", 473 | "default": null, 474 | "required": true 475 | }, 476 | { 477 | "name": "abstract", 478 | "default": null, 479 | "required": true 480 | }, 481 | { 482 | "name": "url", 483 | "default": null, 484 | "required": true 485 | }, 486 | { 487 | "name": "imgpath", 488 | "default": null, 489 | "required": true 490 | } 491 | ] 492 | }, 493 | { 494 | "name": "send_at_text", 495 | "description": null, 496 | "params": [ 497 | { 498 | "name": "wx_pid", 499 | "default": null, 500 | "required": true 501 | }, 502 | { 503 | "name": "chatroom_id", 504 | "default": null, 505 | "required": true 506 | }, 507 | { 508 | "name": "at_wxids", 509 | "default": null, 510 | "required": true 511 | }, 512 | { 513 | "name": "text", 514 | "default": null, 515 | "required": true 516 | }, 517 | { 518 | "name": "auto_nickname", 519 | "default": true, 520 | "required": false 521 | } 522 | ] 523 | }, 524 | { 525 | "name": "send_card", 526 | "description": null, 527 | "params": [ 528 | { 529 | "name": "wx_pid", 530 | "default": null, 531 | "required": true 532 | }, 533 | { 534 | "name": "wxid", 535 | "default": null, 536 | "required": true 537 | }, 538 | { 539 | "name": "shared_wxid", 540 | "default": null, 541 | "required": true 542 | }, 543 | { 544 | "name": "nickname", 545 | "default": null, 546 | "required": true 547 | } 548 | ] 549 | }, 550 | { 551 | "name": "send_emotion", 552 | "description": null, 553 | "params": [ 554 | { 555 | "name": "wx_pid", 556 | "default": null, 557 | "required": true 558 | }, 559 | { 560 | "name": "wxid", 561 | "default": null, 562 | "required": true 563 | }, 564 | { 565 | "name": "img_path", 566 | "default": null, 567 | "required": true 568 | } 569 | ] 570 | }, 571 | { 572 | "name": "send_file", 573 | "description": null, 574 | "params": [ 575 | { 576 | "name": "wx_pid", 577 | "default": null, 578 | "required": true 579 | }, 580 | { 581 | "name": "wx_id", 582 | "default": null, 583 | "required": true 584 | }, 585 | { 586 | "name": "filepath", 587 | "default": null, 588 | "required": true 589 | } 590 | ] 591 | }, 592 | { 593 | "name": "send_image", 594 | "description": null, 595 | "params": [ 596 | { 597 | "name": "wx_pid", 598 | "default": null, 599 | "required": true 600 | }, 601 | { 602 | "name": "wx_id", 603 | "default": null, 604 | "required": true 605 | }, 606 | { 607 | "name": "img_path", 608 | "default": null, 609 | "required": true 610 | } 611 | ] 612 | }, 613 | { 614 | "name": "send_text", 615 | "description": null, 616 | "params": [ 617 | { 618 | "name": "wx_pid", 619 | "default": null, 620 | "required": true 621 | }, 622 | { 623 | "name": "wx_id", 624 | "default": null, 625 | "required": true 626 | }, 627 | { 628 | "name": "text", 629 | "default": null, 630 | "required": true 631 | } 632 | ] 633 | }, 634 | { 635 | "name": "send_xml_msg", 636 | "description": "\n 发送原始xml消息\n\n Returns:\n int: 0表示成功\n ", 637 | "params": [ 638 | { 639 | "name": "wx_pid", 640 | "default": null, 641 | "required": true 642 | }, 643 | { 644 | "name": "wxid", 645 | "default": null, 646 | "required": true 647 | }, 648 | { 649 | "name": "xml", 650 | "default": null, 651 | "required": true 652 | }, 653 | { 654 | "name": "img_path", 655 | "default": null, 656 | "required": true 657 | } 658 | ] 659 | }, 660 | { 661 | "name": "start_receive_message", 662 | "description": "\n 开始接收消息\n :param port: 端口, port为0则使用COM Event推送\n ", 663 | "params": [ 664 | { 665 | "name": "wx_pid", 666 | "default": null, 667 | "required": true 668 | }, 669 | { 670 | "name": "port", 671 | "default": null, 672 | "required": true 673 | } 674 | ] 675 | }, 676 | { 677 | "name": "start_robot_service", 678 | "description": null, 679 | "params": [ 680 | { 681 | "name": "wx_pid", 682 | "default": null, 683 | "required": true 684 | } 685 | ] 686 | }, 687 | { 688 | "name": "start_wechat", 689 | "description": null, 690 | "params": [] 691 | }, 692 | { 693 | "name": "stop_receive_message", 694 | "description": null, 695 | "params": [ 696 | { 697 | "name": "wx_pid", 698 | "default": null, 699 | "required": true 700 | } 701 | ] 702 | }, 703 | { 704 | "name": "stop_robot_service", 705 | "description": null, 706 | "params": [ 707 | { 708 | "name": "wx_pid", 709 | "default": null, 710 | "required": true 711 | } 712 | ] 713 | }, 714 | { 715 | "name": "unhook_image_msg", 716 | "description": null, 717 | "params": [ 718 | { 719 | "name": "wx_pid", 720 | "default": null, 721 | "required": true 722 | } 723 | ] 724 | }, 725 | { 726 | "name": "unhook_voice_msg", 727 | "description": null, 728 | "params": [ 729 | { 730 | "name": "wx_pid", 731 | "default": null, 732 | "required": true 733 | } 734 | ] 735 | } 736 | ] 737 | -------------------------------------------------------------------------------- /logs/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amchii/whochat/d2d0b77055014860c1cd5a43062fb2e7525852c1/logs/.gitkeep -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with Python 3.9 3 | # by the following command: 4 | # 5 | # pip-compile 6 | # 7 | 8 | attrs==23.1.0 9 | # via jsonschema 10 | click==8.1.3 11 | # via whochat (setup.py) 12 | comtypes==1.1.14 13 | # via whochat (setup.py) 14 | jsonrpcclient==4.0.3 15 | # via whochat (setup.py) 16 | jsonrpcserver==5.0.9 17 | # via whochat (setup.py) 18 | jsonschema==4.17.3 19 | # via jsonrpcserver 20 | oslash==0.6.3 21 | # via jsonrpcserver 22 | psutil==5.9.5 23 | # via whochat (setup.py) 24 | pydantic==1.10.7 25 | # via whochat (setup.py) 26 | pyrsistent==0.19.3 27 | # via jsonschema 28 | schedule==1.2.0 29 | # via whochat (setup.py) 30 | typing-extensions==4.5.0 31 | # via 32 | # oslash 33 | # pydantic 34 | websockets==11.0.2 35 | # via whochat (setup.py) 36 | 37 | whochat~=1.3.0 38 | setuptools~=65.6.3 39 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = whochat 3 | url = https://github.com/amchii/whochat 4 | version = attr: whochat.__version__ 5 | project_urls = 6 | Source = https://github.com/amchii/whochat 7 | license = BSD 3-Clause License 8 | author = Amchii 9 | author_email = finethankuandyou@gmail.com 10 | description = 一个命令就可启用的微信机器人 11 | long_description = file: README.md 12 | long_description_content_type = text/markdown 13 | classifiers = 14 | Intended Audience :: Developers 15 | Operating System :: Microsoft :: Windows 16 | License :: OSI Approved :: BSD License 17 | Programming Language :: Python :: 3 :: Only 18 | Programming Language :: Python :: 3.8 19 | 20 | [options] 21 | python_requires = >=3.8 22 | include_package_data = True 23 | packages = find: 24 | install_requires = 25 | comtypes 26 | psutil 27 | click 28 | websockets 29 | jsonrpcserver 30 | jsonrpcclient 31 | schedule 32 | pydantic[dotenv] 33 | 34 | [options.package_data] 35 | whochat.ComWeChatRobot = 36 | CWeChatRobot.exe 37 | DWeChatRobot.dll 38 | 39 | [options.extras_require] 40 | httprpc = 41 | fastapi[uvicorn] 42 | 43 | [options.entry_points] 44 | console_scripts = 45 | whochat = whochat.__main__:main 46 | 47 | [flake8] 48 | ignore = E203, E266, E402, E501, W503, W504, B950, F405, F403, C901 49 | max-complexity = 50 50 | select = B,C,E,F,W 51 | 52 | [isort] 53 | profile = black 54 | skip = migrations 55 | combine_as_imports = True 56 | include_trailing_comma = True 57 | multi_line_output = 3 58 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup() 4 | -------------------------------------------------------------------------------- /whochat/ComWeChatRobot/CWeChatRobot.exe: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amchii/whochat/d2d0b77055014860c1cd5a43062fb2e7525852c1/whochat/ComWeChatRobot/CWeChatRobot.exe -------------------------------------------------------------------------------- /whochat/ComWeChatRobot/DWeChatRobot.dll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amchii/whochat/d2d0b77055014860c1cd5a43062fb2e7525852c1/whochat/ComWeChatRobot/DWeChatRobot.dll -------------------------------------------------------------------------------- /whochat/ComWeChatRobot/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | https://github.com/ljc545w/ComWeChatRobot/releases 3 | https://github.com/amchii/ComWeChatRobot/releases 4 | """ 5 | import os.path 6 | 7 | from whochat.utils import as_admin 8 | 9 | CWeChatRobotEXE_PATH = os.path.abspath( 10 | os.path.join(os.path.dirname(__file__), "CWeChatRobot.exe") 11 | ) 12 | 13 | __wechat_version__ = "3.7.0.30" 14 | __robot_commit_hash__ = "f6d7577" 15 | 16 | 17 | def register(): 18 | as_admin(CWeChatRobotEXE_PATH, "/regserver") 19 | 20 | 21 | def unregister(): 22 | as_admin(CWeChatRobotEXE_PATH, "/unregserver") 23 | -------------------------------------------------------------------------------- /whochat/__init__.py: -------------------------------------------------------------------------------- 1 | # flake8: noqa 2 | from .logger import logger 3 | 4 | __version__ = "1.3.5" 5 | -------------------------------------------------------------------------------- /whochat/__main__.py: -------------------------------------------------------------------------------- 1 | __all__ = ("main",) 2 | 3 | import sys 4 | 5 | 6 | def main(): 7 | from whochat.cli import whochat 8 | 9 | sys.exit(whochat()) 10 | -------------------------------------------------------------------------------- /whochat/_comtypes.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | from whochat.utils import windows_only 4 | 5 | if sys.platform == "win32": 6 | from comtypes import * # noqa 7 | from comtypes import client # noqa 8 | else: 9 | 10 | COINIT_MULTITHREADED = 0x0 11 | COINIT_APARTMENTTHREADED = 0x2 12 | COINIT_DISABLE_OLE1DDE = 0x4 13 | COINIT_SPEED_OVER_MEMORY = 0x8 14 | 15 | class Unusable: 16 | def __call__(self, *args, **kwargs): 17 | windows_only() 18 | 19 | class UnusableModule: 20 | def __getattr__(self, item): 21 | return Unusable() 22 | 23 | CoInitialize = Unusable() 24 | CoUninitialize = Unusable() 25 | CoInitializeEx = Unusable() 26 | client = UnusableModule() 27 | -------------------------------------------------------------------------------- /whochat/abc.py: -------------------------------------------------------------------------------- 1 | from typing import Any, List, Sequence, Tuple, Union 2 | 3 | DictIterable = Sequence[Tuple[str, Any]] 4 | JsonStr = str 5 | 6 | 7 | class CWechatRobotABC: 8 | """Com Wechat实现的接口""" 9 | 10 | def CStartRobotService(self, wx_pid: int) -> int: 11 | ... 12 | 13 | def CStopRobotService(self, wx_pid: int) -> int: 14 | ... 15 | 16 | def CSendImage(self, wx_pid: int, wxid: str, imgpath: str) -> int: 17 | ... 18 | 19 | def CSendText(self, wx_pid: int, wxid: str, text: str) -> int: 20 | ... 21 | 22 | def CSendFile(self, wx_pid: int, wxid: str, filepath: str) -> int: 23 | ... 24 | 25 | def CSendArticle( 26 | self, wx_pid: int, wxid: str, title: str, abstract: str, url: str, imgpath: str 27 | ) -> int: 28 | ... 29 | 30 | def CSendCard(self, wx_pid: int, wxid: str, shared_wxid: str, nickname: str) -> int: 31 | ... 32 | 33 | def CSendAtText( 34 | self, 35 | wx_pid: int, 36 | chatroomid: str, 37 | at_wxids: Union[str, List[str]], 38 | text: str, 39 | auto_nickname: bool = True, 40 | ) -> int: 41 | ... 42 | 43 | def CGetFriendList( 44 | self, 45 | wx_pid: int, 46 | ) -> DictIterable: 47 | ... 48 | 49 | def CGetFriendListString(self, wx_pid: int): 50 | ... 51 | 52 | def CGetWxUserInfo(self, wx_pid: int, wxid: str) -> str: # json 53 | ... 54 | 55 | def CGetSelfInfo(self, wx_pid: int) -> str: # json 56 | ... 57 | 58 | def CCheckFriendStatus(self, wx_pid: int, wxid: str): 59 | """当前测试不是无痕检测""" 60 | ... 61 | 62 | def CGetComWorkPath(self, wx_pid: int) -> str: 63 | ... 64 | 65 | def CStartReceiveMessage(self, wx_pid: int, port: int) -> int: 66 | """向指定TCP端口发送接收到的消息, 消息结构参考`ReceiveMsgStruct`""" 67 | ... 68 | 69 | def CStopReceiveMessage(self, wx_pid: int) -> int: 70 | ... 71 | 72 | def CGetChatRoomMembers(self, wx_pid: int, chatroomid: str) -> DictIterable: 73 | ... 74 | 75 | def CGetDbHandles(self, wx_pid: int) -> DictIterable: 76 | ... 77 | 78 | def CExecuteSQL(self, wx_pid: int, handle: int, sql: str) -> list: 79 | ... 80 | 81 | def CBackupSQLiteDB(self, wx_pid: int, handle: int, savepath: str) -> int: 82 | ... 83 | 84 | def CVerifyFriendApply(self, wx_pid: int, v3: str, v4: str) -> int: 85 | ... 86 | 87 | def CAddFriendByWxid(self, wx_pid: int, wxid: str, message: str) -> int: 88 | ... 89 | 90 | def CAddFriendByV3(self, wx_pid: int, v3: str, message: str) -> int: 91 | ... 92 | 93 | def CGetWeChatVer(self) -> str: 94 | ... 95 | 96 | def CStartWeChat(self) -> int: 97 | ... 98 | 99 | def CSearchContactByNet(self, wx_pid: int, keyword: str) -> DictIterable: 100 | ... 101 | 102 | def CAddBrandContact(self, wx_pid: int, public_id: str) -> int: 103 | """添加公众号""" 104 | ... 105 | 106 | def CHookVoiceMsg(self, wx_pid: int, savepath: str) -> int: 107 | ... 108 | 109 | def CUnHookVoiceMsg(self, wx_pid: int) -> int: 110 | ... 111 | 112 | def CHookImageMsg(self, wx_pid: int, savepath: str) -> int: 113 | ... 114 | 115 | def CUnHookImageMsg(self, wx_pid: int) -> int: 116 | ... 117 | 118 | def CChangeWeChatVer(self, wx_pid: int, version: str) -> int: 119 | ... 120 | 121 | def CSendAppMsg(self, wx_pid: int, wxid: str, app_id: str) -> int: 122 | """发送小程序""" 123 | ... 124 | 125 | def CDeleteUser(self, wx_pid: int, wxid: str) -> int: 126 | ... 127 | 128 | def CIsWxLogin(self, wx_pid: int) -> int: 129 | ... 130 | 131 | def CEditRemark(self, wx_pid: int, wxid: str, remark: str): 132 | ... 133 | 134 | def CSetChatRoomName(self, wx_pid: int, chatroomid: str, name: str): 135 | ... 136 | 137 | def CSetChatRoomAnnouncement(self, wx_pid: int, chatroomid: str, announcement: str): 138 | ... 139 | 140 | def CSetChatRoomSelfNickname(self, wx_pid: int, chatroomid: str, nickname: str): 141 | ... 142 | 143 | def CGetChatRoomMemberNickname(self, wx_pid: int, chatroomid: str, wxid: str): 144 | ... 145 | 146 | def CDelChatRoomMember(self, wx_pid: int, chatroomid: str, wxids: List[str]): 147 | ... 148 | 149 | def CAddChatRoomMember(self, wx_pid: int, chatroomid: str, wxids: List[str]): 150 | ... 151 | 152 | def COpenBrowser(self, wx_pid: int, url: str): 153 | """ 154 | 打开内置浏览器 155 | """ 156 | 157 | def CGetHistoryPublicMsg( 158 | self, wx_pid: int, public_id: str, offset: str = "" 159 | ) -> Tuple[JsonStr]: 160 | """ 161 | 获取公众号历史消息 162 | 163 | Args: 164 | offset (str, optional): 起始偏移,为空的话则从新到久获取十条,该值可从返回数据中取得. 165 | """ 166 | 167 | def CForwardMessage(self, wx_pid: int, wxid: str, msgid: int): 168 | """ 169 | 转发消息 170 | 171 | Args: 172 | wxid (str): 消息接收人 173 | msgid (int): 消息id 174 | """ 175 | 176 | def CGetQrcodeImage(self, wx_pid: int) -> bytes: 177 | """ 178 | 获取二维码,同时切换到扫码登陆 179 | 180 | Returns: 181 | bytes 182 | 二维码bytes数据. 183 | You can convert it to image object,like this: 184 | >>> from io import BytesIO 185 | >>> from PIL import Image 186 | >>> buf = wx.GetQrcodeImage() 187 | >>> image = Image.open(BytesIO(buf)).convert("L") 188 | >>> image.save('./qrcode.png') 189 | 190 | """ 191 | 192 | def CGetA8Key(self, wx_pid: int, url: str) -> Tuple[JsonStr]: 193 | """ 194 | 获取A8Key 195 | 196 | Args: 197 | url (str): 公众号文章链接 198 | """ 199 | 200 | def CSendXmlMsg(self, wx_pid: int, wxid: str, xml: str, img_path: str) -> int: 201 | """ 202 | 发送原始xml消息 203 | 204 | Returns: 205 | int: 0表示成功 206 | """ 207 | 208 | def CLogout(self, wx_pid: int) -> int: 209 | """ 210 | 登出 211 | 212 | Returns: 213 | int: 0表示成功 214 | """ 215 | 216 | def CGetTransfer( 217 | self, wx_pid: int, wxid: str, transactionid: str, transferid: int 218 | ) -> int: 219 | """ 220 | 收款 221 | 222 | Args: 223 | wxid : str 224 | 转账人wxid. 225 | transcationid : str 226 | 从转账消息xml中获取. 227 | transferid : str 228 | 从转账消息xml中获取. 229 | 230 | Returns: 231 | int 232 | 成功返回0,失败返回非0值. 233 | 234 | """ 235 | 236 | def CSendEmotion(self, wx_pid: int, wxid: str, img_path: str) -> int: 237 | ... 238 | 239 | def CGetMsgCDN(self, wx_pid: int, msgid: int) -> str: 240 | """ 241 | 下载图片、视频、文件等 242 | 243 | Returns: 244 | str 245 | 成功返回文件路径,失败返回空字符串. 246 | """ 247 | 248 | 249 | class RobotEventSinkABC: 250 | def OnGetMessageEvent(self, msg): 251 | ... 252 | 253 | 254 | class RobotEventABC: 255 | def CRegisterWxPidWithCookie(self, wx_pid: int, cookie: int): 256 | ... 257 | -------------------------------------------------------------------------------- /whochat/bot.py: -------------------------------------------------------------------------------- 1 | import functools 2 | import json 3 | import os 4 | import pathlib 5 | import re 6 | import shutil 7 | import tempfile 8 | import threading 9 | import time 10 | from datetime import datetime 11 | from typing import Dict, List, Union, overload 12 | from urllib import request 13 | 14 | import psutil 15 | 16 | from ._comtypes import client as com_client 17 | from .abc import CWechatRobotABC, RobotEventABC, RobotEventSinkABC 18 | from .logger import logger 19 | from .utils import guess_wechat_base_directory, guess_wechat_user_by_paths 20 | 21 | _robot_local = threading.local() 22 | 23 | 24 | # 保证每个线程的COM对象独立 25 | def get_robot_object(com_id) -> CWechatRobotABC: 26 | if not hasattr(_robot_local, "robot_object"): 27 | setattr(_robot_local, "robot_object", com_client.CreateObject(com_id)) 28 | return getattr(_robot_local, "robot_object") 29 | 30 | 31 | def get_robot_event(com_id) -> RobotEventABC: 32 | if not hasattr(_robot_local, "robot_event"): 33 | setattr(_robot_local, "robot_event", com_client.CreateObject(com_id)) 34 | return getattr(_robot_local, "robot_event") 35 | 36 | 37 | def auto_start(func): 38 | @functools.wraps(func) 39 | def wrapper(self: "WechatBot", *args, **kwargs): 40 | self.start_robot_service() 41 | try: 42 | return func(self, *args, **kwargs) 43 | except Exception as e: 44 | logger.exception(e) 45 | 46 | return wrapper 47 | 48 | 49 | class WechatBot: 50 | def __init__( 51 | self, 52 | wx_pid, 53 | robot_object_id: str, 54 | robot_event_id: str = None, 55 | wechat_version=None, 56 | ): 57 | self.wx_pid = int(wx_pid) 58 | self._robot_object_id = robot_object_id 59 | self._robot_event_id = robot_event_id 60 | self.started = False 61 | self.user_info = {} 62 | self.event_connection = None 63 | 64 | self._base_directory = None 65 | self.image_hook_path = None 66 | self.voice_hook_path = None 67 | self.wechat_version = wechat_version 68 | 69 | @property 70 | def robot(self): 71 | return get_robot_object(self._robot_object_id) 72 | 73 | @property 74 | def robot_event(self): 75 | return get_robot_event(self._robot_event_id) 76 | 77 | def __enter__(self): 78 | self.start_robot_service() 79 | return self 80 | 81 | def __exit__(self, exc_type, exc_val, exc_tb): 82 | self.stop_robot_service() 83 | 84 | def start_robot_service(self): 85 | if self.started: 86 | return True 87 | result = self.robot.CStartRobotService( 88 | self.wx_pid, 89 | ) 90 | if result == 0: 91 | self.started = True 92 | if self.wechat_version: 93 | self.change_wechat_ver(self.wechat_version) 94 | logger.info(f"更改微信版本号为: {self.wechat_version}") 95 | return not result 96 | 97 | def stop_robot_service(self): 98 | if not self.started: 99 | return True 100 | result = self.robot.CStopRobotService( 101 | self.wx_pid, 102 | ) 103 | if result == 0: 104 | self.started = False 105 | return not result 106 | 107 | @property 108 | def base_directory(self): 109 | if not self._base_directory: 110 | return self.get_base_directory() 111 | return self._base_directory 112 | 113 | def get_base_directory(self): 114 | p = psutil.Process(pid=self.wx_pid) 115 | base_directory = guess_wechat_base_directory([f.path for f in p.open_files()]) 116 | self._base_directory = base_directory 117 | return base_directory 118 | 119 | @overload 120 | def send_image(self, wx_id, img_path) -> int: 121 | ... 122 | 123 | @auto_start 124 | def send_image(self, wx_id, img_path): 125 | path = pathlib.Path(img_path) 126 | if not path.is_absolute(): 127 | base_dir = self.image_hook_path or self.base_directory 128 | path = pathlib.Path(base_dir).joinpath(path) 129 | 130 | # BUG: (20220821)图片文件名如果有多个"."的话不能成功发送, 所以复制到临时文件进行发送 131 | if "." in path.stem: 132 | with tempfile.TemporaryDirectory() as td: 133 | temp_path = os.path.join(td, os.urandom(5).hex() + path.suffix) 134 | shutil.copy(path, temp_path) 135 | return self.robot.CSendImage(self.wx_pid, wx_id, temp_path) 136 | 137 | return self.robot.CSendImage(self.wx_pid, wx_id, str(path)) 138 | 139 | @overload 140 | def send_text(self, wx_id, text) -> int: 141 | ... 142 | 143 | @auto_start 144 | def send_text(self, wx_id, text): 145 | return self.robot.CSendText(self.wx_pid, wx_id, text) 146 | 147 | @overload 148 | def send_file(self, wx_id, filepath) -> int: 149 | ... 150 | 151 | @auto_start 152 | def send_file(self, wx_id, filepath): 153 | path = pathlib.Path(filepath) 154 | if not path.is_absolute(): 155 | path = pathlib.Path(self.base_directory).joinpath(path) 156 | 157 | return self.robot.CSendFile(self.wx_pid, wx_id, str(path)) 158 | 159 | @overload 160 | def send_article( 161 | self, wxid: str, title: str, abstract: str, url: str, imgpath: str 162 | ): 163 | ... 164 | 165 | @auto_start 166 | def send_article( 167 | self, wxid: str, title: str, abstract: str, url: str, imgpath: str 168 | ): 169 | return self.robot.CSendArticle(self.wx_pid, wxid, title, abstract, url, imgpath) 170 | 171 | @overload 172 | def send_card(self, wxid: str, shared_wxid: str, nickname: str) -> int: 173 | ... 174 | 175 | @auto_start 176 | def send_card(self, wxid: str, shared_wxid: str, nickname: str): 177 | return self.robot.CSendCard(self.wx_pid, wxid, shared_wxid, nickname) 178 | 179 | @overload 180 | def send_at_text( 181 | self, 182 | chatroom_id: str, 183 | at_wxids: Union[str, List[str]], 184 | text: str, 185 | auto_nickname: bool = True, 186 | ) -> int: 187 | ... 188 | 189 | @auto_start 190 | def send_at_text( 191 | self, 192 | chatroom_id: str, 193 | at_wxids: Union[str, List[str]], 194 | text: str, 195 | auto_nickname: bool = True, 196 | ): 197 | return self.robot.CSendAtText( 198 | self.wx_pid, chatroom_id, at_wxids, text, auto_nickname 199 | ) 200 | 201 | @overload 202 | def get_friend_list(self) -> List[Dict]: 203 | ... 204 | 205 | @auto_start 206 | def get_friend_list(self): 207 | return [ 208 | dict(item) 209 | for item in ( 210 | self.robot.CGetFriendList( 211 | self.wx_pid, 212 | ) 213 | or [] 214 | ) 215 | ] 216 | 217 | @overload 218 | def get_wx_user_info(self, wxid: str): 219 | ... 220 | 221 | @auto_start 222 | def get_wx_user_info(self, wxid: str): 223 | return json.loads(self.robot.CGetWxUserInfo(self.wx_pid, wxid)) 224 | 225 | @property 226 | def wxid(self): 227 | return self.get_self_info()["wxId"] 228 | 229 | @property 230 | def self_path(self): 231 | return os.path.join(self.base_directory, str(self.wx_pid)) 232 | 233 | @overload 234 | def get_self_info(self, refresh=False) -> Dict: 235 | ... 236 | 237 | @auto_start 238 | def get_self_info(self, refresh=False): 239 | if refresh or not self.user_info: 240 | self.user_info = json.loads( 241 | self.robot.CGetSelfInfo( 242 | self.wx_pid, 243 | ) 244 | ) 245 | return self.user_info 246 | 247 | @overload 248 | def check_friend_status(self, wxid: str) -> int: 249 | ... 250 | 251 | @auto_start 252 | def check_friend_status(self, wxid: str): 253 | return self.robot.CCheckFriendStatus(self.wx_pid, wxid) 254 | 255 | @overload 256 | def get_com_work_path(self) -> int: 257 | ... 258 | 259 | @auto_start 260 | def get_com_work_path(self): 261 | return self.robot.CGetComWorkPath( 262 | self.wx_pid, 263 | ) 264 | 265 | @overload 266 | def start_receive_message(self, port: int) -> int: 267 | ... 268 | 269 | @auto_start 270 | def start_receive_message(self, port: int): 271 | """ 272 | 开始接收消息 273 | :param port: 端口, port为0则使用COM Event推送 274 | """ 275 | return self.robot.CStartReceiveMessage(self.wx_pid, port) 276 | 277 | @overload 278 | def stop_receive_message(self) -> int: 279 | ... 280 | 281 | @auto_start 282 | def stop_receive_message(self): 283 | return self.robot.CStopReceiveMessage( 284 | self.wx_pid, 285 | ) 286 | 287 | @overload 288 | def get_chat_room_member_ids(self, chatroom_id: str) -> List: 289 | ... 290 | 291 | @auto_start 292 | def get_chat_room_member_ids(self, chatroom_id: str): 293 | wx_ids_str: str = self.robot.CGetChatRoomMembers(self.wx_pid, chatroom_id)[1][1] 294 | wx_ids = wx_ids_str.split("^G") 295 | return wx_ids 296 | 297 | @overload 298 | def get_chat_room_member_nickname(self, chatroom_id: str, wxid: str) -> str: 299 | ... 300 | 301 | @auto_start 302 | def get_chat_room_member_nickname(self, chatroom_id: str, wxid: str): 303 | return self.robot.CGetChatRoomMemberNickname(self.wx_pid, chatroom_id, wxid) 304 | 305 | def get_chat_room_members(self, chatroom_id: str) -> List[dict]: 306 | """ 307 | 获取群成员id及昵称信息 308 | 309 | [ 310 | { 311 | "wx_id": "", 312 | "nickname": "" 313 | } 314 | ] 315 | """ 316 | results = [] 317 | for wx_id in self.get_chat_room_member_ids(chatroom_id): 318 | results.append( 319 | { 320 | "wx_id": wx_id, 321 | "nickname": self.get_chat_room_member_nickname(chatroom_id, wx_id), 322 | } 323 | ) 324 | return results 325 | 326 | @overload 327 | def add_friend_by_wxid(self, wxid: str, message: str) -> int: 328 | ... 329 | 330 | @auto_start 331 | def add_friend_by_wxid(self, wxid: str, message: str): 332 | return self.robot.CAddFriendByWxid(self.wx_pid, wxid, message) 333 | 334 | @overload 335 | def search_contact_by_net(self, keyword: str) -> List[Dict]: 336 | ... 337 | 338 | @auto_start 339 | def search_contact_by_net(self, keyword: str): 340 | return [ 341 | dict(item) for item in self.robot.CSearchContactByNet(self.wx_pid, keyword) 342 | ] 343 | 344 | @overload 345 | def add_brand_contact(self, public_id: str) -> int: 346 | ... 347 | 348 | @auto_start 349 | def add_brand_contact(self, public_id: str): 350 | return self.robot.CAddBrandContact(self.wx_pid, public_id) 351 | 352 | @overload 353 | def change_wechat_ver(self, version: str) -> int: 354 | ... 355 | 356 | @auto_start 357 | def change_wechat_ver(self, version: str): 358 | return self.robot.CChangeWeChatVer(self.wx_pid, version) 359 | 360 | @overload 361 | def send_app_msg(self, wxid: str, app_id: str) -> int: 362 | ... 363 | 364 | @auto_start 365 | def send_app_msg(self, wxid: str, app_id: str): 366 | return self.robot.CSendAppMsg(self.wx_pid, wxid, app_id) 367 | 368 | @overload 369 | def delete_user(self, wxid: str) -> int: 370 | ... 371 | 372 | @auto_start 373 | def delete_user(self, wxid: str): 374 | return self.robot.CDeleteUser(self.wx_pid, wxid) 375 | 376 | @overload 377 | def is_wx_login(self) -> int: 378 | ... 379 | 380 | @auto_start 381 | def is_wx_login(self) -> int: 382 | return self.robot.CIsWxLogin( 383 | self.wx_pid, 384 | ) 385 | 386 | @overload 387 | def get_db_handles(self) -> List[Dict]: 388 | ... 389 | 390 | @auto_start 391 | def get_db_handles(self): 392 | return [dict(item) for item in self.robot.CGetDbHandles(self.wx_pid)] 393 | 394 | @overload 395 | def register_event(self, event_sink: RobotEventSinkABC) -> int: 396 | ... 397 | 398 | @auto_start 399 | def register_event(self, event_sink: RobotEventSinkABC): 400 | if self.event_connection: 401 | self.event_connection.__del__() 402 | self.event_connection = com_client.GetEvents(self.robot_event, event_sink) 403 | self.robot_event.CRegisterWxPidWithCookie( 404 | self.wx_pid, self.event_connection.cookie 405 | ) 406 | 407 | @overload 408 | def get_wechat_ver(self) -> str: 409 | ... 410 | 411 | @auto_start 412 | def get_wechat_ver(self) -> str: 413 | return self.robot.CGetWeChatVer() 414 | 415 | @overload 416 | def hook_voice_msg(self, savepath: str) -> Union[int, str]: 417 | ... 418 | 419 | @auto_start 420 | def hook_voice_msg(self, savepath: str) -> Union[int, str]: 421 | p = pathlib.Path(savepath) 422 | if not p.is_absolute(): 423 | p = pathlib.Path.home().joinpath(p) 424 | result = self.robot.CHookVoiceMsg(self.wx_pid, str(p)) 425 | if result == 0: 426 | self.voice_hook_path = str(p) 427 | return self.voice_hook_path 428 | return result 429 | 430 | @overload 431 | def unhook_voice_msg(self) -> int: 432 | ... 433 | 434 | @auto_start 435 | def unhook_voice_msg(self): 436 | return self.robot.CUnHookVoiceMsg(self.wx_pid) 437 | 438 | @overload 439 | def hook_image_msg(self, savepath: str) -> Union[int, str]: 440 | ... 441 | 442 | @auto_start 443 | def hook_image_msg(self, savepath: str) -> Union[int, str]: 444 | p = pathlib.Path(savepath) 445 | if not p.is_absolute(): 446 | p = pathlib.Path.home().joinpath(p) 447 | result = self.robot.CHookImageMsg(self.wx_pid, str(p)) 448 | if result == 0: 449 | self.image_hook_path = str(p) 450 | return self.image_hook_path 451 | return result 452 | 453 | @overload 454 | def unhook_image_msg(self) -> int: 455 | ... 456 | 457 | @auto_start 458 | def unhook_image_msg(self): 459 | return self.robot.CUnHookImageMsg(self.wx_pid) 460 | 461 | @overload 462 | def open_browser(self, url: str) -> int: 463 | ... 464 | 465 | @auto_start 466 | def open_browser(self, url: str): 467 | return self.robot.COpenBrowser(self.wx_pid, url) 468 | 469 | @overload 470 | def get_history_public_msg(self, public_id: str, offset: str = "") -> List: 471 | ... 472 | 473 | @auto_start 474 | def get_history_public_msg(self, public_id: str, offset: str = ""): 475 | """ 476 | 获取公众号历史消息 477 | 478 | Args: 479 | offset (str, optional): 起始偏移,为空的话则从新到久获取十条,该值可从返回数据中取得 480 | """ 481 | r = self.robot.CGetHistoryPublicMsg(self.wx_pid, public_id, offset) 482 | try: 483 | msgs = json.loads(r[0]) 484 | except (IndexError, json.JSONDecodeError) as e: 485 | logger.exception(e) 486 | return [] 487 | return msgs 488 | 489 | @overload 490 | def forward_message(self, wxid: str, msgid: int) -> int: 491 | ... 492 | 493 | @auto_start 494 | def forward_message(self, wxid: str, msgid: int): 495 | """ 496 | 转发消息 497 | 498 | Args: 499 | wxid (str): 消息接收人 500 | msgid (int): 消息id 501 | """ 502 | return self.robot.CForwardMessage(self.wx_pid, wxid, msgid) 503 | 504 | @overload 505 | def get_qrcode_image( 506 | self, 507 | ) -> bytes: 508 | ... 509 | 510 | @auto_start 511 | def get_qrcode_image( 512 | self, 513 | ) -> bytes: 514 | """ 515 | 获取二维码,同时切换到扫码登陆 516 | 517 | Returns: 518 | bytes 519 | 二维码bytes数据. 520 | You can convert it to image object,like this: 521 | >>> from io import BytesIO 522 | >>> from PIL import Image 523 | >>> buf = wx.GetQrcodeImage() 524 | >>> image = Image.open(BytesIO(buf)).convert("L") 525 | >>> image.save('./qrcode.png') 526 | """ 527 | return self.robot.CGetQrcodeImage(self.wx_pid) 528 | 529 | @overload 530 | def get_a8_key(self, url: str) -> str: 531 | ... 532 | 533 | @auto_start 534 | def get_a8_key(self, url: str): 535 | """ 536 | 获取A8Key 537 | 538 | Args: 539 | url (str): 公众号文章链接 540 | """ 541 | r = self.robot.CGetA8Key(self.wx_pid, url) 542 | try: 543 | result = json.loads(r[0]) 544 | except (IndexError, json.JSONDecodeError) as e: 545 | logger.exception(e) 546 | return "" 547 | return result 548 | 549 | @overload 550 | def send_xml_msg(self, wxid: str, xml: str, img_path: str) -> int: 551 | ... 552 | 553 | @auto_start 554 | def send_xml_msg(self, wxid: str, xml: str, img_path: str) -> int: 555 | """ 556 | 发送原始xml消息 557 | 558 | Returns: 559 | int: 0表示成功 560 | """ 561 | return self.robot.CSendXmlMsg(self.wx_pid, wxid, xml, img_path) 562 | 563 | @overload 564 | def logout(self) -> int: 565 | ... 566 | 567 | @auto_start 568 | def logout(self) -> int: 569 | """ 570 | 登出 571 | 572 | Returns: 573 | int: 0表示成功 574 | """ 575 | return self.robot.CLogout(self.wx_pid) 576 | 577 | @overload 578 | def get_transfer(self, wxid: str, transactionid: str, transferid: int) -> int: 579 | ... 580 | 581 | @auto_start 582 | def get_transfer(self, wxid: str, transactionid: str, transferid: int) -> int: 583 | """ 584 | 收款 585 | 586 | Args: 587 | wxid : str 588 | 转账人wxid. 589 | transcationid : str 590 | 从转账消息xml中获取. 591 | transferid : str 592 | 从转账消息xml中获取. 593 | 594 | Returns: 595 | int 596 | 成功返回0,失败返回非0值. 597 | """ 598 | return self.robot.CGetTransfer(self.wx_pid, wxid, transactionid, transferid) 599 | 600 | @overload 601 | def send_emotion(self, wxid: str, img_path: str) -> int: 602 | ... 603 | 604 | @auto_start 605 | def send_emotion(self, wxid: str, img_path: str) -> int: 606 | return self.robot.CSendEmotion(self.wx_pid, wxid, img_path) 607 | 608 | @overload 609 | def get_msg_cdn(self, msgid: int) -> str: 610 | ... 611 | 612 | @auto_start 613 | def get_msg_cdn(self, msgid: int) -> str: 614 | """ 615 | 下载图片、视频、文件等 616 | 617 | Returns: 618 | str 619 | 成功返回文件路径,失败返回空字符串. 620 | """ 621 | return self.robot.CGetMsgCDN(self.wx_pid, msgid) 622 | 623 | def prevent_revoke(self, rel_path: str, hold_time: int = 120): 624 | """ 625 | 防止文件被删除 626 | 通过打开文件来阻止微信将撤回的文件删除,仅Windows可用 627 | 628 | :param rel_path: 相对微信数据目录的路径,如微信目录为"C:\\Users\\foo\\Documents\\WeChat Files", 629 | `rel_path`为"foo.txt", 则实际文件路径为"C:\\Users\\foo\\Documents\\WeChat Files\\foo.txt" 630 | :param hold_time: 持续时间,秒,默认为微信撤回时间 631 | """ 632 | full_path = os.path.join(self.base_directory, rel_path) 633 | start = time.perf_counter() 634 | if not os.path.isfile(full_path): 635 | tmp_file_path = full_path + ".wxtmp" 636 | if not os.path.exists(tmp_file_path): 637 | logger.error( 638 | f"'{full_path}' is not a file and it's tmp file {tmp_file_path} not exists" 639 | ) 640 | return 1 641 | 642 | while not os.path.isfile(full_path): 643 | time.sleep(0.1) 644 | if time.perf_counter() > start + hold_time: 645 | logger.warning("File has been revoked") 646 | return 1 647 | 648 | def hold(): 649 | logger.info(f"Try to open file {full_path} to prevent the deletion.") 650 | with open(full_path, "rb"): 651 | while not time.perf_counter() > start + hold_time: 652 | time.sleep(1) 653 | logger.info(f"Opened file {full_path} closed.") 654 | 655 | t = threading.Thread( 656 | target=hold, 657 | ) 658 | t.start() 659 | return 0 660 | 661 | 662 | class WechatBotFactoryMetaclass(type): 663 | _robot: CWechatRobotABC 664 | _robot_object_id: str 665 | _robot_event_id: str 666 | 667 | @property 668 | def robot(cls): 669 | return get_robot_object(cls._robot_object_id) 670 | 671 | @property 672 | def robot_event(cls): 673 | return get_robot_event(cls._robot_event_id) 674 | 675 | @property 676 | def robot_pid(cls) -> int: 677 | return cls.robot.CStopRobotService(0) 678 | 679 | 680 | class WechatBotFactory(metaclass=WechatBotFactoryMetaclass): 681 | """ 682 | 单例 683 | """ 684 | 685 | _instances: Dict[int, "WechatBot"] = {} 686 | _robot_object_id = "WeChatRobot.CWeChatRobot" 687 | _robot_event_id = "WeChatRobot.RobotEvent" 688 | 689 | @classmethod 690 | def get(cls, wx_pid) -> "WechatBot": 691 | if wx_pid not in cls._instances: 692 | wechat_version = os.environ.get( 693 | "WHOCHAT_WECHAT_VERSION" 694 | ) or cls.get_latest_wechat_version(fill=".99") 695 | bot = WechatBot( 696 | wx_pid, 697 | cls._robot_object_id, 698 | cls._robot_event_id, 699 | wechat_version=wechat_version, 700 | ) 701 | cls._instances[wx_pid] = bot 702 | return cls._instances[wx_pid] 703 | 704 | @classmethod 705 | def exit(cls): 706 | logger.info("卸载注入的dll...") 707 | for instance in cls._instances.values(): 708 | instance.stop_robot_service() 709 | 710 | @classmethod 711 | def get_we_chat_ver(cls) -> str: 712 | return cls.robot.CGetWeChatVer() 713 | 714 | @classmethod 715 | def start_wechat(cls) -> int: 716 | return cls.robot.CStartWeChat() 717 | 718 | @classmethod 719 | def list_wechat(cls) -> List[dict]: 720 | results = [] 721 | for process in psutil.process_iter(): 722 | if process.name().lower() == "wechat.exe": 723 | files = [f.path for f in process.open_files()] 724 | results.append( 725 | { 726 | "pid": process.pid, 727 | "started": datetime.fromtimestamp( 728 | process.create_time() 729 | ).isoformat(), 730 | "status": process.status(), 731 | "wechat_user": guess_wechat_user_by_paths(files), 732 | "base_directory": guess_wechat_base_directory(files), 733 | } 734 | ) 735 | return results 736 | 737 | @classmethod 738 | def get_robot_pid(cls): 739 | return cls.robot_pid 740 | 741 | @classmethod 742 | def kill_robot(cls): 743 | bot_pid = cls.robot_pid 744 | logger.info(f"尝试结束CWeChatRobot进程: pid {bot_pid}") 745 | try: 746 | psutil.Process(bot_pid).kill() 747 | logger.info("OK") 748 | except psutil.Error as e: 749 | logger.warning(e, exc_info=True) 750 | 751 | @classmethod 752 | def register_events(cls, wx_pids, event_sink: RobotEventSinkABC): 753 | connection = com_client.GetEvents(cls.robot_event, event_sink) 754 | for wx_pid in wx_pids: 755 | cls.robot_event.CRegisterWxPidWithCookie(wx_pid, connection.cookie) 756 | 757 | @classmethod 758 | def get_current_dir(cls): 759 | return os.getcwd() 760 | 761 | @classmethod 762 | def get_latest_wechat_version(cls, fill: str = None): 763 | logger.info("获取微信最新版本号...") 764 | with request.urlopen( 765 | request.Request( 766 | "https://pc.weixin.qq.com/?lang=zh_CN", 767 | headers={ 768 | "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.0.0 Safari/537.36" 769 | }, 770 | method="GET", 771 | ) 772 | ) as fp: 773 | html = fp.read().decode() 774 | m = re.search(r"download-version\">(.*?)", html) 775 | if not m: 776 | logger.warning("获取微信最新版本号异常") 777 | return 778 | version = m.group(1) 779 | if fill: 780 | version = version + fill 781 | logger.info(f"微信最新版本号:{version}") 782 | return version 783 | -------------------------------------------------------------------------------- /whochat/cli.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | from logging.handlers import RotatingFileHandler 4 | 5 | import click 6 | 7 | from whochat.rpc.handlers import register_rpc_methods 8 | from whochat.utils import windows_only 9 | 10 | 11 | @click.group( 12 | name="whochat", 13 | ) 14 | @click.option( 15 | "--log-level", 16 | "-l", 17 | "log_level", 18 | default="info", 19 | show_default=True, 20 | help="日志等级, `debug`, `info`, `warn`, `error`", 21 | ) 22 | @click.option("--log-file", "log_file", help="日志文件, 可以是相对路径") 23 | def whochat(log_level: str, log_file): 24 | """ 25 | 微信机器人 26 | 27 | 使用<子命令> --help查看使用说明 28 | """ 29 | logger = logging.getLogger("whochat") 30 | logger.setLevel(log_level.upper()) 31 | if log_file: 32 | abs_log_file = os.path.abspath(log_file) 33 | dirname = os.path.dirname(abs_log_file) 34 | if dirname and not os.path.exists(dirname): 35 | os.makedirs(dirname) 36 | assert os.path.isdir(dirname), f"{dirname}不存在或不是一个目录" 37 | file_handler = RotatingFileHandler( 38 | abs_log_file, 39 | maxBytes=1024 * 1024, # 1MB 40 | backupCount=10, 41 | encoding="utf-8", 42 | ) 43 | verbose_formatter = logging.Formatter( 44 | "[%(levelname)s] [%(name)s] %(asctime)s %(filename)s %(process)d %(message)s" 45 | ) 46 | file_handler.setFormatter(verbose_formatter) 47 | logger.addHandler(file_handler) 48 | 49 | 50 | @whochat.command() 51 | def version(): 52 | """显示程序和支持微信的版本信息""" 53 | from whochat import __version__ 54 | from whochat.ComWeChatRobot import __robot_commit_hash__, __wechat_version__ 55 | 56 | s = "" 57 | s += f"WhoChat version: {__version__}\n" 58 | s += f"Supported WeChat version: {__wechat_version__}\n" 59 | s += f"Build ComWechatRobot's dll from: {__robot_commit_hash__}" 60 | click.echo(s) 61 | 62 | 63 | @whochat.command() 64 | @click.option("--unreg", "-R", is_flag=True, default=False, help="取消注册") 65 | def regserver(unreg): 66 | """注册COM""" 67 | windows_only() 68 | from whochat.ComWeChatRobot import register, unregister 69 | 70 | if unreg: 71 | unregister() 72 | else: 73 | register() 74 | 75 | 76 | @whochat.command() 77 | def list_wechat(): 78 | """列出当前运行的微信进程""" 79 | windows_only() 80 | 81 | from whochat.bot import WechatBotFactory 82 | 83 | s = "" 84 | for r in WechatBotFactory.list_wechat(): 85 | s += f"PID: {r['pid']}" + "\n" 86 | s += f"启动时间: {r['started']}" + "\n" 87 | s += f"运行状态: {r['status']}" + "\n" 88 | s += f"用户名: {r['wechat_user']}" + "\n" 89 | s += "---\n" 90 | click.echo(s) 91 | 92 | 93 | @whochat.command() 94 | @click.option( 95 | "--host", "-h", default="localhost", show_default=True, help="Server host." 96 | ) 97 | @click.option("--port", "-p", default=9001, show_default=True, help="Server port") 98 | @click.option( 99 | "--welcome", 100 | default=True, 101 | type=bool, 102 | show_default=True, 103 | help="Respond 'hello' on client connect", 104 | ) 105 | @click.argument("wx_pids", nargs=-1, type=int) 106 | def serve_message_ws(host, port, welcome, wx_pids): 107 | """ 108 | 运行接收微信消息的Websocket服务 109 | 110 | WX_PIDS: 微信进程PID 111 | """ 112 | windows_only() 113 | 114 | import asyncio 115 | 116 | from whochat.messages.websocket import WechatMessageWebsocketServer 117 | 118 | if not wx_pids: 119 | raise click.BadArgumentUsage("请指定至少一个微信进程PID") 120 | 121 | async def main(): 122 | server = WechatMessageWebsocketServer( 123 | wx_pids=wx_pids, ws_host=host, ws_port=port, welcome=welcome 124 | ) 125 | await server.serve() 126 | 127 | asyncio.run(main()) 128 | 129 | 130 | @whochat.command() 131 | @click.option("--json", "json_", is_flag=True, default=False, help="JSON文档") 132 | def show_rpc_docs(json_): 133 | """ 134 | 列出RPC接口 135 | 136 | \b 137 | whochat show-rpc-docs 138 | or 139 | whochat show-rpc-docs --json > api.json 140 | """ 141 | import json 142 | 143 | from whochat.rpc.docs import make_docs, pretty_docs 144 | 145 | if json_: 146 | click.echo(json.dumps(make_docs(), ensure_ascii=False, indent=4)) 147 | else: 148 | click.echo(pretty_docs()) 149 | 150 | 151 | @whochat.command() 152 | @click.option( 153 | "--host", "-h", default="localhost", show_default=True, help="Server host." 154 | ) 155 | @click.option("--port", "-p", default=9002, show_default=True, help="Server port") 156 | def serve_rpc_ws(host, port): 157 | """ 158 | 运行微信机器人RPC服务(JSON-RPC2.0), 使用Websocket 159 | """ 160 | windows_only() 161 | 162 | import asyncio 163 | 164 | from whochat.rpc.servers.websocket import run 165 | 166 | click.echo(f"PID: {os.getpid()}") 167 | register_rpc_methods() 168 | 169 | asyncio.run(run(host, port)) 170 | 171 | 172 | @whochat.command() 173 | @click.option( 174 | "--host", "-h", default="localhost", show_default=True, help="Server host." 175 | ) 176 | @click.option("--port", "-p", default=9002, show_default=True, help="Server port") 177 | def serve_rpc_http(host, port): 178 | """ 179 | 运行微信机器人RPC服务(JSON-RPC2.0), 使用HTTP接口 180 | 181 | 需要安装fastapi和uvicorn: `pip install fastapi uvicorn` 182 | """ 183 | windows_only() 184 | 185 | import uvicorn 186 | 187 | from whochat.rpc.servers.http import app 188 | 189 | register_rpc_methods() 190 | uvicorn.run(app, host=host, port=port) 191 | -------------------------------------------------------------------------------- /whochat/logger.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from logging.handlers import RotatingFileHandler 3 | 4 | from whochat.settings import settings 5 | 6 | verbose_formatter = logging.Formatter( 7 | "[%(levelname)s] [%(name)s] %(asctime)s %(filename)s %(process)d %(message)s" 8 | ) 9 | 10 | logger = logging.getLogger("whochat") 11 | logger.setLevel(settings.DEFAULT_LOG_LEVEL) 12 | console_handler = logging.StreamHandler() 13 | console_handler.setFormatter(verbose_formatter) 14 | logger.addHandler(console_handler) 15 | 16 | if settings.DEBUG: 17 | logger.setLevel(logging.DEBUG) 18 | file_handler = RotatingFileHandler( 19 | settings.DEV_LOG_DIR.joinpath("whochat.log"), 20 | maxBytes=1024 * 1024, # 1MB 21 | backupCount=10, 22 | encoding="utf-8", 23 | ) 24 | file_handler.setFormatter(verbose_formatter) 25 | logger.addHandler(file_handler) 26 | -------------------------------------------------------------------------------- /whochat/messages/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amchii/whochat/d2d0b77055014860c1cd5a43062fb2e7525852c1/whochat/messages/__init__.py -------------------------------------------------------------------------------- /whochat/messages/constants.py: -------------------------------------------------------------------------------- 1 | import enum 2 | 3 | 4 | class WechatMsgType(enum.IntEnum): 5 | 文字 = 1 6 | 图片 = 3 7 | 语音 = 34 8 | 好友确认 = 37 9 | 名片 = 42 10 | 视频 = 43 11 | 表情 = 47 12 | 位置 = 48 13 | 共享实时位置_文件_转账_链接 = 49 14 | 语音通话消息 = 50 15 | 微信初始化 = 51 16 | 语音通话通知 = 52 17 | 语音通话邀请 = 53 18 | 小视频 = 62 19 | SYS_NOTICE = 9999 20 | 系统消息_红包 = 10000 21 | 撤回_群语音邀请 = 10002 22 | -------------------------------------------------------------------------------- /whochat/messages/tcp.py: -------------------------------------------------------------------------------- 1 | """不推荐:使用Com Event""" 2 | import json 3 | import socket 4 | import socketserver 5 | import threading 6 | import time 7 | import typing 8 | from collections import deque 9 | from ctypes import Structure, c_ulonglong, c_wchar, sizeof, wintypes 10 | 11 | import comtypes 12 | 13 | from whochat.bot import WechatBot, WechatBotFactory 14 | from whochat.logger import logger 15 | from whochat.signals import Signal 16 | 17 | 18 | class ReceiveMsgStruct(Structure): 19 | _fields_ = [ 20 | ("pid", wintypes.DWORD), 21 | ("type", wintypes.DWORD), 22 | ("is_send_msg", wintypes.DWORD), 23 | ("msgid", c_ulonglong), 24 | ("sender", c_wchar * 80), 25 | ("wxid", c_wchar * 80), 26 | ("message", c_wchar * 0x1000B), 27 | ("filepath", c_wchar * 260), 28 | ("time", c_wchar * 30), 29 | ] 30 | 31 | @classmethod 32 | def from_bytes(cls, data: bytes) -> "ReceiveMsgStruct": 33 | return cls.from_buffer_copy(data) 34 | 35 | def to_dict(self): 36 | return {attname: getattr(self, attname) for attname, _ in self._fields_} 37 | 38 | 39 | class ReceiveMsgHandler(socketserver.BaseRequestHandler): 40 | timeout = 3 41 | msg_struct_cls = ReceiveMsgStruct 42 | server: "WechatReceiveMsgTCPServer" 43 | request: socket.socket 44 | 45 | def setup(self) -> None: 46 | self.request.settimeout(self.timeout) 47 | 48 | def handle(self) -> None: 49 | comtypes.CoInitialize() 50 | try: 51 | data = self.request.recv(1024) 52 | struct_size = sizeof(ReceiveMsgStruct) 53 | while len(data) < struct_size: 54 | data += self.request.recv(1024) 55 | 56 | msg = self.msg_struct_cls.from_bytes(data) 57 | self._handle(msg, self.server.bot) 58 | self.request.sendall(b"200 OK") 59 | except OSError as e: 60 | logger.exception(e) 61 | return 62 | finally: 63 | comtypes.CoUninitialize() 64 | 65 | def _handle(self, msg: ReceiveMsgStruct, bot: typing.Optional[WechatBot] = None): 66 | pass 67 | 68 | 69 | class WechatReceiveMsgTCPServer(socketserver.ThreadingTCPServer): 70 | """ 71 | Com接口不支持指定IP 72 | """ 73 | 74 | bot_factory = WechatBotFactory 75 | 76 | def __init__( 77 | self, 78 | wx_pid: int, 79 | port: int, 80 | RequestHandlerClass: typing.Callable[..., ReceiveMsgHandler], 81 | **kwargs, 82 | ): 83 | self.wx_pid = wx_pid 84 | self.port = port 85 | self.bot = self.bot_factory.get(wx_pid) 86 | super().__init__(("127.0.0.1", port), RequestHandlerClass, **kwargs) 87 | 88 | def serve_forever(self, poll_interval=0.5) -> None: 89 | logger.info(f"开始运行微信消息接收服务,地址为:{self.server_address}") 90 | with self.bot: 91 | self.bot.start_receive_message(self.port) 92 | super().serve_forever(poll_interval) 93 | 94 | 95 | class StoreReceiveMsgHandler(ReceiveMsgHandler): 96 | def __init__( 97 | self, request, client_address, server, queue: deque[dict], initial_data=b"" 98 | ): 99 | self.dqueue = queue 100 | self.initial_data = initial_data 101 | super(StoreReceiveMsgHandler, self).__init__(request, client_address, server) 102 | 103 | def handle(self) -> None: 104 | data = self.initial_data 105 | comtypes.CoInitialize() 106 | try: 107 | while True: 108 | data += self.request.recv(1024) 109 | struct_size = sizeof(ReceiveMsgStruct) 110 | while len(data) < struct_size: 111 | buf = self.request.recv(1024) 112 | if not buf: 113 | return 114 | data += buf 115 | msg = self.msg_struct_cls.from_bytes(data) 116 | self._handle(msg, self.server.bot) 117 | data = b"" 118 | self.request.sendall(b"200 OK") 119 | except OSError as e: 120 | logger.exception(e) 121 | finally: 122 | comtypes.CoUninitialize() 123 | 124 | def _handle(self, msg: ReceiveMsgStruct, bot: typing.Optional[WechatBot] = None): 125 | msg_dict = msg.to_dict() 126 | logger.info(msg_dict) 127 | self.dqueue.append(msg_dict) 128 | 129 | 130 | class WechatReceiveMsgRedirectTCPServer(WechatReceiveMsgTCPServer): 131 | RequestHandlerClass: typing.Callable[..., StoreReceiveMsgHandler] 132 | dqueue: deque[dict] 133 | 134 | def __init__( 135 | self, 136 | wx_pid: int, 137 | port: int, 138 | redirect_key: bytes, 139 | RequestHandlerClass: typing.Callable[ 140 | ..., StoreReceiveMsgHandler 141 | ] = StoreReceiveMsgHandler, 142 | **kwargs, 143 | ): 144 | self.port = port 145 | self.dqueue = deque(maxlen=100) 146 | # addr tuple -> socket client which need to redirect message 147 | self.redirects: set[socket.socket] = set() 148 | self.redirect_key = redirect_key 149 | self.stop_redirect = False 150 | assert len(self.redirect_key) <= 16 151 | super().__init__(wx_pid, port, RequestHandlerClass, **kwargs) 152 | logger.info(f"转发Key为: {self.redirect_key}") 153 | Signal.register_sigint(self.shutdown) 154 | 155 | def finish_request(self, request, client_address) -> None: 156 | if self.redirect_key: 157 | flag = request.recv(len(self.redirect_key)) 158 | if flag == self.redirect_key: 159 | self.redirects.add(request) 160 | logger.info(f"已添加一个接受消息转发的客户端:{client_address}") 161 | else: 162 | self.RequestHandlerClass( 163 | request, client_address, self, self.dqueue, initial_data=flag 164 | ) 165 | else: 166 | self.RequestHandlerClass(request, client_address, self, self.dqueue) 167 | 168 | def shutdown_request(self, request) -> None: 169 | if request in self.redirects: 170 | return 171 | super().shutdown_request(request) 172 | 173 | def shutdown(self) -> None: 174 | super().shutdown() 175 | self.stop_redirect = True 176 | self.bot_factory.kill_robot() 177 | 178 | def serve_forever_in_thread(self, poll_interval=0.5, daemon=True): 179 | def serve_in_thread(): 180 | comtypes.CoInitialize() 181 | self.serve_forever(poll_interval) 182 | comtypes.CoUninitialize() 183 | 184 | t = threading.Thread(target=serve_in_thread) 185 | t.setDaemon(daemon) 186 | t.start() 187 | return t 188 | 189 | def keep_redirect(self): 190 | logger.info("开启转发服务") 191 | while not self.stop_redirect: 192 | if len(self.dqueue) != 0: 193 | msg = self.dqueue.popleft() 194 | data = json.dumps(msg).encode("utf-8") + b"\n" 195 | bad_requests = set() 196 | for redirect in self.redirects: 197 | try: 198 | redirect.sendall(data) 199 | except Exception: 200 | bad_requests.add(redirect) 201 | for bad_request in bad_requests: 202 | self.redirects.remove(bad_request) 203 | self.shutdown_request(bad_request) 204 | logger.info("已移除一个接受消息转发的客户端") 205 | time.sleep(0.1) 206 | logger.info("转发服务已关闭") 207 | 208 | def serve(self): 209 | self.serve_forever_in_thread() 210 | self.keep_redirect() 211 | -------------------------------------------------------------------------------- /whochat/messages/websocket.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import json 3 | import logging 4 | import re 5 | import warnings 6 | from collections import deque 7 | from functools import partial 8 | from typing import Awaitable, Callable, List 9 | 10 | import websockets 11 | import websockets.client 12 | import websockets.server 13 | from websockets.typing import Data 14 | 15 | from whochat import _comtypes as comtypes 16 | from whochat.abc import RobotEventSinkABC 17 | from whochat.bot import WechatBotFactory 18 | from whochat.signals import Signal 19 | from whochat.utils import EventWaiter 20 | 21 | logger = logging.getLogger("whochat") 22 | 23 | 24 | class MessageEventStoreSink(RobotEventSinkABC): 25 | def __init__(self, deque_: deque): 26 | self.deque_ = deque_ 27 | 28 | @staticmethod 29 | def _parse_extrainfo(extrainfo): 30 | """ 31 | Windows 3.9.2.26: 32 | 33 | 34 | 0 35 | 12 36 | v1_6XIdCSDF 37 | 38 | <![CDATA[]]> 39 | 40 | 41 | 42 | Mac 3.7.0: 43 | 44 | wxid_enuyja8axoz92 45 | 46 | 1 47 | 48 | 0 49 | 12 50 | v1_JVxT4Vi9 51 | 52 | <![CDATA[]]> 53 | 54 | 55 | 56 | Android 8.0.35: 57 | 58 | 59 | 0 60 | 12 61 | v1_VQrQyKdq 62 | 63 | <![CDATA[]]> 64 | 65 | 66 | """ 67 | extra = {"is_at_msg": False} 68 | android_windows_at_pattern = r"" 69 | m = re.search(android_windows_at_pattern, extrainfo) 70 | if not m: 71 | mac_at_pattern = r"(.*?)" 72 | m = re.search(mac_at_pattern, extrainfo) 73 | if m: 74 | extra["is_at_msg"] = True 75 | extra["at_user_list"] = [ 76 | wxid.strip() for wxid in m.group(1).split(",") if wxid.strip() 77 | ] 78 | m = re.search(r"(\d+)", extrainfo) 79 | if m: 80 | extra["member_count"] = int(m.group(1)) 81 | return extra 82 | 83 | def OnGetMessageEvent(self, msg): 84 | logger.debug(f"Raw message: {msg}") 85 | if isinstance(msg, (list, tuple)): 86 | msg = msg[0] 87 | try: 88 | data = json.loads(msg) 89 | if "@chatroom" not in data["sender"]: 90 | data["extrainfo"] = None 91 | else: 92 | data["extrainfo"] = self._parse_extrainfo(data["extrainfo"]) 93 | except (json.JSONDecodeError, TypeError) as e: 94 | logger.warning("接收消息错误: ") 95 | logger.exception(e) 96 | return 97 | logger.debug(f"收到消息: {data}") 98 | self.deque_.append(data) 99 | 100 | 101 | class WechatMessageWebsocketServer: 102 | def __init__( 103 | self, 104 | wx_pids: List[int], 105 | ws_host: str = None, 106 | ws_port: int = 9001, 107 | queue: asyncio.Queue = None, 108 | welcome: bool = True, 109 | **kwargs, 110 | ): 111 | self.wx_pids = wx_pids 112 | self.ws_host = ws_host 113 | self.ws_port = ws_port 114 | self.extra_kwargs = kwargs 115 | self.queue = queue or asyncio.Queue(maxsize=10) 116 | self.welcome = welcome 117 | 118 | self.ws_server = None 119 | self.clients = set() 120 | self._deque = deque(maxlen=self.queue.maxsize) 121 | self._event_waiter = EventWaiter(2) 122 | 123 | self._stop_broadcast = False 124 | self._stop_receive_msg = False 125 | self._stop_websocket = asyncio.Event() 126 | Signal.register_sigint(self.shutdown) 127 | 128 | async def handler(self, websocket): 129 | if websocket not in self.clients: 130 | logger.info(f"Accept connection from {websocket.remote_address}") 131 | self.clients.add(websocket) 132 | if self.welcome: 133 | await websocket.send("hello") 134 | await websocket.wait_closed() 135 | self.clients.remove(websocket) 136 | logger.info(f"Connection from {websocket.remote_address} was closed") 137 | 138 | async def serve_websocket(self): 139 | async with websockets.server.serve( 140 | self.handler, self.ws_host, self.ws_port, **self.extra_kwargs 141 | ) as ws_server: 142 | logger.info(f"开始运行微信Websocket服务,地址为:<{self.ws_host}:{self.ws_port}>") 143 | self.ws_server = ws_server 144 | async with ws_server: 145 | await self._stop_websocket.wait() 146 | logger.info("Websocket服务已停止") 147 | 148 | def _start_receive_msg(self, wx_pids): 149 | comtypes.CoInitialize() 150 | try: 151 | sink = MessageEventStoreSink(self._deque) 152 | for wx_pid in wx_pids: 153 | bot = WechatBotFactory.get(wx_pid) 154 | bot.start_robot_service() 155 | logger.info(bot.get_self_info()) 156 | logger.info("开启Robot消息推送") 157 | bot.start_receive_message(0) 158 | bot.register_event(sink) 159 | self._event_waiter.create_handle() 160 | self._event_waiter.wait_forever() 161 | finally: 162 | comtypes.CoUninitialize() 163 | 164 | async def deque_to_queue(self): 165 | while not self._stop_receive_msg: 166 | try: 167 | e = self._deque.popleft() 168 | await self.queue.put(e) 169 | except IndexError: 170 | await asyncio.sleep(0.1) 171 | 172 | async def start_receive_msg(self): 173 | logger.info("开始运行微信消息接收服务") 174 | loop = asyncio.get_event_loop() 175 | loop.create_task(self.deque_to_queue()) 176 | await loop.run_in_executor(None, partial(self._start_receive_msg, self.wx_pids)) 177 | logger.info("微信消息接收服务已停止") 178 | 179 | def stop_websocket(self): 180 | self._stop_websocket.set() 181 | 182 | def stop_receive_msg(self): 183 | self._event_waiter.stop() 184 | self._stop_receive_msg = True 185 | for wx_pid in self.wx_pids: 186 | bot = WechatBotFactory.get(wx_pid) 187 | bot.stop_receive_message() 188 | bot.stop_robot_service() 189 | 190 | def stop_broadcast(self): 191 | self._stop_broadcast = True 192 | try: 193 | self.queue.put_nowait("bye") 194 | except asyncio.QueueFull: 195 | pass 196 | 197 | async def broadcast_received_msg(self): 198 | logger.info("开始向客户端广播接收到的微信消息") 199 | while not self._stop_broadcast: 200 | data = await self.queue.get() 201 | if not self._stop_broadcast: 202 | self.broadcast(json.dumps(data)) 203 | logger.info("广播已停止") 204 | 205 | def broadcast(self, data): 206 | logger.debug(f"广播消息:{data}") 207 | websockets.broadcast(self.clients, data) 208 | 209 | def shutdown(self): 210 | logger.info("停止服务中...") 211 | self.stop_websocket() 212 | self.stop_broadcast() 213 | self.stop_receive_msg() 214 | 215 | async def serve(self): 216 | try: 217 | websocket_task = asyncio.create_task(self.serve_websocket()) 218 | receive_msg_task = asyncio.create_task(self.start_receive_msg()) 219 | broadcast_task = asyncio.create_task(self.broadcast_received_msg()) 220 | 221 | await websocket_task 222 | await receive_msg_task 223 | await broadcast_task 224 | except Exception as e: 225 | logger.exception(e) 226 | raise 227 | 228 | 229 | class WechatWebsocketServer(WechatMessageWebsocketServer): 230 | def __init__(self, *args, **kwargs): 231 | warnings.warn(" 'WechatWebsocketServer' 已更名为 'WechatMessageWebsocketServer'") 232 | super().__init__(*args, **kwargs) 233 | 234 | 235 | class WechatMessageWebsocketClient: 236 | def __init__( 237 | self, 238 | ws_uri: str, 239 | ): 240 | self.ws_uri = ws_uri 241 | 242 | async def start_consumer(self, on_message: Callable[[Data], Awaitable]): 243 | logger.info("Starting message consumer...") 244 | async for websocket in websockets.client.connect(self.ws_uri): 245 | websocket: "websockets.client.WebSocketClientProtocol" 246 | logger.info(f"Websocket client bind on {websocket.local_address}") 247 | try: 248 | async for message in websocket: 249 | await on_message(message) 250 | except websockets.ConnectionClosed: 251 | continue 252 | -------------------------------------------------------------------------------- /whochat/rpc/__init__.py: -------------------------------------------------------------------------------- 1 | """Use JSON-RPC2.0""" 2 | -------------------------------------------------------------------------------- /whochat/rpc/clients/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amchii/whochat/d2d0b77055014860c1cd5a43062fb2e7525852c1/whochat/rpc/clients/__init__.py -------------------------------------------------------------------------------- /whochat/rpc/clients/websocket.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import json 3 | import logging 4 | from collections import defaultdict 5 | from functools import partial 6 | from typing import Any, Dict 7 | 8 | import websockets 9 | import websockets.client 10 | from jsonrpcclient import request as req 11 | 12 | from whochat.rpc.handlers import make_rpc_methods 13 | 14 | logger = logging.getLogger("whochat") 15 | 16 | unset = object() 17 | 18 | 19 | class Timeout(Exception): 20 | pass 21 | 22 | 23 | class BotWebsocketRPCClient: 24 | def __init__(self, ws_uri): 25 | self.ws_uri = ws_uri 26 | self.send_queue: asyncio.Queue[Dict[str, Any]] = asyncio.Queue(maxsize=1) 27 | self._rpc_methods = make_rpc_methods() 28 | self._results = defaultdict(lambda: unset) 29 | self._current_request_id = None 30 | 31 | async def start_result_cleaner(self): 32 | logger.info("Starting result cleaner") 33 | while removable := len(self._results) - 100 > 0: 34 | logger.info( 35 | f"Current size of results: {len(self._results)}, will remove {removable} items" 36 | ) 37 | for k in list(self._results.keys())[:removable]: 38 | self._results.pop(k, None) 39 | await asyncio.sleep(1) 40 | 41 | async def start_sender( 42 | self, websocket: "websockets.client.WebSocketClientProtocol" 43 | ): 44 | while not websocket.closed: 45 | request_dict = await self.send_queue.get() 46 | logger.debug(f"SEND: {request_dict}") 47 | try: 48 | await websocket.send(json.dumps(request_dict)) 49 | except websockets.ConnectionClosedError: 50 | await self.send_queue.put(request_dict) 51 | raise 52 | 53 | async def start_receiver( 54 | self, websocket: "websockets.client.WebSocketClientProtocol" 55 | ): 56 | async for message in websocket: 57 | logger.debug(f"RECV: {message}") 58 | try: 59 | response_dict = json.loads(message) 60 | if "result" in response_dict: 61 | self._results[response_dict["id"]] = response_dict["result"] 62 | elif "error" in response_dict: 63 | self._results[response_dict["id"]] = response_dict["error"] 64 | logger.error(response_dict["error"]) 65 | except json.JSONDecodeError: 66 | continue 67 | 68 | async def start_consumer(self): 69 | logger.info("Starting rpc client consumer") 70 | async for websocket in websockets.client.connect(self.ws_uri): 71 | websocket: "websockets.client.WebSocketClientProtocol" 72 | logger.info(f"Websocket client bind on {websocket.local_address}") 73 | gathered = asyncio.gather( 74 | self.start_receiver(websocket), 75 | self.start_sender(websocket), 76 | ) 77 | try: 78 | await gathered 79 | except websockets.ConnectionClosedOK: 80 | logger.warning( 81 | f"Websocket{websocket.local_address} closed", 82 | ) 83 | except websockets.ConnectionClosedError: 84 | logger.warning( 85 | f"Websocket{websocket.local_address} closed unexpectedly", 86 | ) 87 | finally: 88 | gathered.cancel() 89 | 90 | def consume_in_background(self): 91 | asyncio.create_task(self.start_consumer()) 92 | asyncio.create_task(self.start_result_cleaner()) 93 | 94 | async def _send_and_recv(self, request): 95 | await self.send_queue.put(request) 96 | request_id = request["id"] 97 | while True: 98 | if self._results[request_id] is unset: 99 | await asyncio.sleep(0.1) 100 | else: 101 | return self._results[request_id] 102 | 103 | async def rpc_call(self, name: str, params, timeout): 104 | request = req(name, params) 105 | request_id = request["id"] 106 | self._current_request_id = request_id 107 | if timeout < 0: 108 | asyncio.create_task(self.send_queue.put(request)) 109 | return request_id 110 | if timeout == 0: 111 | return await self._send_and_recv(request) 112 | else: 113 | try: 114 | return await asyncio.wait_for(self._send_and_recv(request), timeout) 115 | except asyncio.TimeoutError: 116 | raise Timeout(f"Timeout: rpc call timeout: {name}, params {params}") 117 | 118 | def __getattr__(self, item): 119 | def remote_func(*params, timeout=5): 120 | """ 121 | :param params: 仅支持位置参数 122 | :param timeout: 超时时间。0: 阻塞等待返回结果, <0: 直接返回不等结果, >0: 等待超时时间 123 | """ 124 | return self.rpc_call(item, params, timeout) 125 | 126 | return remote_func 127 | 128 | 129 | class OneBotWebsocketRPCClient: 130 | """For convenience""" 131 | 132 | def __init__(self, wx_pid, rpc_client: "BotWebsocketRPCClient"): 133 | self.wx_pid = wx_pid 134 | self.rpc_client = rpc_client 135 | 136 | def __getattr__(self, item): 137 | return partial(getattr(self.rpc_client, item), self.wx_pid) 138 | -------------------------------------------------------------------------------- /whochat/rpc/docs.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | from collections import OrderedDict 3 | 4 | from whochat.rpc.handlers import make_rpc_methods 5 | 6 | _docs = [] 7 | 8 | 9 | def make_docs(): 10 | if _docs: 11 | return _docs 12 | rpc_methods = make_rpc_methods() 13 | 14 | for name, rpc_method in OrderedDict( 15 | {key: rpc_methods[key] for key in sorted(rpc_methods.keys())} 16 | ).items(): 17 | s = inspect.signature(rpc_method) 18 | description = rpc_method.__doc__ 19 | params = [] 20 | if rpc_method.__qualname__.split(".", maxsplit=1)[0] == "WechatBot": 21 | params.append( 22 | { 23 | "name": "wx_pid", 24 | "default": None, 25 | "required": True, 26 | } 27 | ) 28 | for param in s.parameters.values(): 29 | if param.name == "self": 30 | continue 31 | params.append( 32 | { 33 | "name": param.name, 34 | "default": None if param.default is param.empty else param.default, 35 | "required": param.default is param.empty, 36 | } 37 | ) 38 | _docs.append({"name": name, "description": description, "params": params}) 39 | 40 | return _docs 41 | 42 | 43 | def pretty_docs(): 44 | docs = make_docs() 45 | s = "" 46 | for item in docs: 47 | s += f"Name: `{item['name']}`\n" 48 | s += f"Description: \n\t{item['description'].lstrip() if item['description'] else '无'}\n" 49 | s += "Params: \n\t" 50 | for param in item["params"]: 51 | s += f"Name: `{param['name']}`\n\t" 52 | s += f"Required: `{str(param['required']).lower()}`\n\t" 53 | s += ( 54 | f"Default: `{param['default']}`\n" 55 | if param["default"] is not None 56 | else "\n\t" 57 | ) 58 | 59 | s += "\n-----------------------------------------------------\n" 60 | return s 61 | -------------------------------------------------------------------------------- /whochat/rpc/handlers.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import dataclasses 3 | import functools 4 | import logging 5 | import time 6 | from concurrent.futures import ThreadPoolExecutor 7 | from typing import Callable, Dict, List 8 | 9 | import schedule 10 | from jsonrpcserver import InvalidParams, Success 11 | 12 | from whochat import _comtypes as comtypes 13 | from whochat.bot import WechatBot, WechatBotFactory 14 | from whochat.signals import Signal 15 | 16 | logger = logging.getLogger("whochat") 17 | 18 | bot_executor = ThreadPoolExecutor( 19 | max_workers=4, 20 | initializer=comtypes.CoInitializeEx, 21 | initargs=(comtypes.COINIT_APARTMENTTHREADED,), 22 | ) 23 | 24 | 25 | class BotRpcHelper: 26 | bot_methods = { 27 | method.__name__: method 28 | for method in [ 29 | WechatBot.add_brand_contact, 30 | WechatBot.add_friend_by_wxid, 31 | WechatBot.change_wechat_ver, 32 | WechatBot.delete_user, 33 | WechatBot.forward_message, 34 | WechatBot.get_a8_key, 35 | WechatBot.get_base_directory, 36 | WechatBot.get_chat_room_member_ids, 37 | WechatBot.get_chat_room_member_nickname, 38 | WechatBot.get_chat_room_members, 39 | WechatBot.get_db_handles, 40 | WechatBot.get_friend_list, 41 | WechatBot.get_history_public_msg, 42 | WechatBot.get_msg_cdn, 43 | WechatBot.get_qrcode_image, 44 | WechatBot.get_self_info, 45 | WechatBot.get_transfer, 46 | WechatBot.get_wechat_ver, 47 | WechatBot.get_wx_user_info, 48 | WechatBot.hook_image_msg, 49 | WechatBot.hook_voice_msg, 50 | WechatBot.is_wx_login, 51 | WechatBot.logout, 52 | WechatBot.open_browser, 53 | WechatBot.prevent_revoke, 54 | WechatBot.search_contact_by_net, 55 | WechatBot.send_app_msg, 56 | WechatBot.send_article, 57 | WechatBot.send_at_text, 58 | WechatBot.send_card, 59 | WechatBot.send_emotion, 60 | WechatBot.send_file, 61 | WechatBot.send_image, 62 | WechatBot.send_text, 63 | WechatBot.send_xml_msg, 64 | WechatBot.start_receive_message, 65 | WechatBot.start_robot_service, 66 | WechatBot.stop_receive_message, 67 | WechatBot.stop_robot_service, 68 | WechatBot.unhook_image_msg, 69 | WechatBot.unhook_voice_msg, 70 | WechatBotFactory.get_current_dir, 71 | WechatBotFactory.get_latest_wechat_version, 72 | WechatBotFactory.get_robot_pid, 73 | WechatBotFactory.get_we_chat_ver, 74 | WechatBotFactory.kill_robot, 75 | WechatBotFactory.list_wechat, 76 | WechatBotFactory.start_wechat, 77 | ] 78 | } 79 | rpc_methods = {} 80 | async_rpc_methods = {} 81 | 82 | @classmethod 83 | def make_rpc_methods(cls): 84 | if cls.rpc_methods: 85 | return cls.rpc_methods 86 | 87 | from whochat.bot import WechatBotFactory 88 | 89 | def factory(func): 90 | @functools.wraps(func) 91 | def bot_self_func(wx_pid, *args, **kwargs): 92 | bot = WechatBotFactory.get(wx_pid) 93 | _func = functools.partial(func, bot, *args, **kwargs) 94 | return Success(_func()) 95 | 96 | @functools.wraps(func) 97 | def normal_func(*args, **kwargs): 98 | _func = functools.partial(func, *args, **kwargs) 99 | return Success(_func()) 100 | 101 | if func.__qualname__.split(".", maxsplit=1)[0] == "WechatBot": 102 | return bot_self_func 103 | return normal_func 104 | 105 | for name, function in cls.bot_methods.items(): 106 | cls.rpc_methods[name] = factory(function) 107 | return cls.rpc_methods 108 | 109 | @classmethod 110 | def make_async_rpc_methods(cls): 111 | if cls.async_rpc_methods: 112 | return cls.async_rpc_methods 113 | 114 | from whochat.bot import WechatBotFactory 115 | 116 | def factory(func): 117 | @functools.wraps(func) 118 | async def bot_self_func(wx_pid, *args, **kwargs): 119 | try: 120 | bot = WechatBotFactory.get(wx_pid) 121 | loop = asyncio.get_running_loop() 122 | result = await loop.run_in_executor( 123 | bot_executor, 124 | functools.partial(func, bot, *args, **kwargs), 125 | ) 126 | return Success(result) 127 | except Exception as e: 128 | logger.exception(e) 129 | raise 130 | 131 | @functools.wraps(func) 132 | async def normal_func(*args, **kwargs): 133 | try: 134 | _func = functools.partial(func, *args, **kwargs) 135 | loop = asyncio.get_running_loop() 136 | result = await loop.run_in_executor(bot_executor, _func) 137 | return Success(result) 138 | except Exception as e: 139 | logger.exception(e) 140 | raise 141 | 142 | if func.__qualname__.split(".", maxsplit=1)[0] == "WechatBot": 143 | return bot_self_func 144 | return normal_func 145 | 146 | for name, function in cls.bot_methods.items(): 147 | cls.async_rpc_methods[name] = factory(function) 148 | return cls.async_rpc_methods 149 | 150 | 151 | @dataclasses.dataclass 152 | class BotJob: 153 | """ 154 | 155 | { 156 | "name": "", 157 | "unit": "days", 158 | "every": 1, 159 | "at": "12:00:00", 160 | "do": { 161 | "func": "", 162 | "args": [] 163 | }, 164 | "description": "", 165 | "tags": "" 166 | } 167 | """ 168 | 169 | name: str 170 | unit: str 171 | every: int 172 | at: str 173 | do: dict 174 | description: str = None 175 | tags: List[str] = dataclasses.field(default_factory=list) 176 | _job_func: Callable = dataclasses.field(init=False, default=None) 177 | 178 | def __post_init__(self): 179 | func_name, func_args = self.do["func"], self.do["args"] 180 | self._job_func = functools.partial( 181 | BotRpcHelper.make_rpc_methods()[func_name], *func_args 182 | ) 183 | 184 | @property 185 | def job_func(self): 186 | return self._job_func 187 | 188 | def as_dict(self): 189 | return { 190 | "name": self.name, 191 | "unit": self.unit, 192 | "every": self.every, 193 | "at": self.at, 194 | "do": self.do, 195 | "description": self.description, 196 | "tags": self.tags, 197 | } 198 | 199 | 200 | scheduler_executor = ThreadPoolExecutor( 201 | max_workers=2, 202 | initializer=comtypes.CoInitializeEx, 203 | initargs=(comtypes.COINIT_APARTMENTTHREADED,), 204 | ) 205 | 206 | 207 | class BotScheduler: 208 | def __init__(self, loop=None): 209 | self.jobs: Dict[str, "BotJob"] = {} 210 | self._loop = loop 211 | self.executor = scheduler_executor 212 | self.scheduler = schedule.default_scheduler 213 | self.scheduled = False 214 | self.__shutdown = False 215 | 216 | @property 217 | def loop(self): 218 | if self._loop is None: 219 | self._loop = asyncio.get_running_loop() 220 | return self._loop 221 | 222 | def run(self): 223 | logger.info("开始运行任务消费线程") 224 | while not self.__shutdown: 225 | self.scheduler.run_pending() 226 | time.sleep(0.3) 227 | logger.info("任务消费线程已停止") 228 | 229 | def schedule(self): 230 | if self.scheduled: 231 | return 232 | self.scheduled = True 233 | Signal.register_sigint(self.shutdown) 234 | self.executor.submit(self.run) 235 | 236 | def shutdown(self): 237 | logger.info("正在取消所有任务...") 238 | self.__shutdown = True 239 | self._cancel_jobs() 240 | self.executor.shutdown(wait=False) 241 | 242 | async def schedule_a_job( 243 | self, 244 | name: str, 245 | unit: str, 246 | every: int, 247 | at: str, 248 | do: dict, 249 | description=None, 250 | tags=None, 251 | ): 252 | """ 253 | { 254 | "name": "Greet", 255 | "unit": "days", 256 | "every": 1, 257 | "at": "08:00:00", 258 | "do": { 259 | "func": "send_text", 260 | "args": [12314, "wxid_foo", "Morning!"] 261 | }, 262 | "description": "", 263 | "tags": ["tian"] 264 | } 265 | 参见 https://schedule.readthedocs.io/en/stable/examples.html 266 | :param name: 任务名 267 | :param unit: 单位,seconds, minutes, hours, days, weeks, monday, tuesday, wednesday, thursday, friday, saturday, sunday 268 | :param every: 每 269 | :param at: For daily jobs -> HH:MM:SS or HH:MM 270 | For hourly jobs -> MM:SS or :MM 271 | For minute jobs -> :SS 272 | :param do: 执行的方法,func: 方法名, args: 参数列表 273 | :param description: 描述 274 | :param tags: 标签,总会添加任务名作为标签 275 | """ 276 | self.schedule() 277 | try: 278 | job = BotJob(name, unit, every, at, do, description, tags or []) 279 | assert job.name not in self.jobs 280 | self.jobs[job.name] = job 281 | schedule_job = self.scheduler.every(job.every) 282 | schedule_job.unit = job.unit 283 | schedule_job.at(job.at).do(job.job_func).tag(*job.tags, job.name) 284 | except AssertionError: 285 | return InvalidParams(f"任务<{name}>已存在") 286 | except Exception as e: 287 | return InvalidParams(f"参数错误: {str(e)}") 288 | return Success() 289 | 290 | def _cancel_jobs(self, tag=None): 291 | if tag is None: 292 | self.jobs.clear() 293 | self.jobs.pop(tag, None) 294 | self.scheduler.clear(tag=tag) 295 | return Success() 296 | 297 | async def cancel_jobs(self, tag=None): 298 | """ 299 | 取消任务 300 | :param tag: 标签名 301 | """ 302 | await self.loop.run_in_executor( 303 | self.executor, functools.partial(self._cancel_jobs, tag=tag) 304 | ) 305 | return Success() 306 | 307 | async def list_jobs(self): 308 | """列出所有任务""" 309 | return Success({name: job.as_dict() for name, job in self.jobs.items()}) 310 | 311 | def get_rpc_methods(self) -> Dict[str, Callable]: 312 | return { 313 | method.__name__: method 314 | for method in [self.schedule_a_job, self.cancel_jobs, self.list_jobs] 315 | } 316 | 317 | 318 | default_bot_scheduler = BotScheduler() 319 | 320 | 321 | def make_rpc_methods(): 322 | rpc_methods = BotRpcHelper.make_async_rpc_methods() 323 | rpc_methods.update(default_bot_scheduler.get_rpc_methods()) 324 | return rpc_methods 325 | 326 | 327 | def register_rpc_methods(): 328 | from jsonrpcserver.methods import global_methods 329 | 330 | global_methods.update(make_rpc_methods()) 331 | -------------------------------------------------------------------------------- /whochat/rpc/servers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amchii/whochat/d2d0b77055014860c1cd5a43062fb2e7525852c1/whochat/rpc/servers/__init__.py -------------------------------------------------------------------------------- /whochat/rpc/servers/http.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from fastapi import FastAPI, Request, Response 4 | from fastapi.responses import RedirectResponse 5 | from jsonrpcserver import async_dispatch 6 | 7 | from whochat.rpc.docs import make_docs 8 | 9 | app = FastAPI(title="微信机器人RPC接口文档", description="HTTP和Websocket均使用JSON-RPC2.0进行函数调用") 10 | 11 | 12 | @app.get( 13 | "/", 14 | name="点击查看已有的RPC接口", 15 | description=f"```json{json.dumps(make_docs(), indent=4, ensure_ascii=False)}```", 16 | ) 17 | async def index(): 18 | return RedirectResponse("/docs") 19 | 20 | 21 | @app.get("/rpc_api_docs") 22 | async def rpc_docs(): 23 | return make_docs() 24 | 25 | 26 | @app.post( 27 | "/", 28 | name="RPC调用接口", 29 | description="""```$ curl -X POST http://localhost:5000 -d '{"jsonrpc": "2.0", "method": "start_robot_service", "params": [23472], "id": 1}'```""", 30 | ) 31 | async def rpc(request: Request): 32 | data = await request.body() 33 | result = await async_dispatch(data) 34 | return Response(result) 35 | -------------------------------------------------------------------------------- /whochat/rpc/servers/websocket.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | 4 | import websockets.server 5 | from jsonrpcserver import async_dispatch 6 | 7 | from whochat.signals import Signal 8 | 9 | logger = logging.getLogger("whochat") 10 | 11 | 12 | async def dispatch_in_task(websocket, request): 13 | res = await async_dispatch(request) 14 | await websocket.send(res) 15 | 16 | 17 | async def handler(websocket: "websockets.server.WebSocketServerProtocol"): 18 | logger.info(f"Accept connection from {websocket.remote_address}") 19 | while not websocket.closed: 20 | request = await websocket.recv() 21 | asyncio.create_task(dispatch_in_task(websocket, request)) 22 | 23 | logger.info(f"Connection from {websocket.remote_address} was closed") 24 | 25 | 26 | async def run(host, port): 27 | stop_event = asyncio.Event() 28 | 29 | def shutdown(): 30 | logger.info("正在停止微信机器人RPC websocket服务...") 31 | stop_event.set() 32 | from whochat.bot import WechatBotFactory 33 | 34 | WechatBotFactory.exit() 35 | 36 | Signal.register_sigint(shutdown) 37 | async with websockets.server.serve(handler, host, port): 38 | logger.info(f"运行微信机器人RPC websocket服务, 地址为<{host}:{port}>") 39 | await stop_event.wait() 40 | -------------------------------------------------------------------------------- /whochat/settings.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from pydantic import BaseSettings 4 | 5 | 6 | class Settings(BaseSettings): 7 | DEBUG: bool = False 8 | ROOT_DIR = Path(__file__).parent.parent.absolute() 9 | DEV_LOG_DIR = ROOT_DIR.joinpath("logs") 10 | DEFAULT_LOG_LEVEL = "INFO" 11 | 12 | class Config: 13 | env_file = ".env" 14 | 15 | 16 | settings = Settings() 17 | -------------------------------------------------------------------------------- /whochat/signals.py: -------------------------------------------------------------------------------- 1 | import signal 2 | from collections import defaultdict 3 | 4 | from .logger import logger 5 | 6 | 7 | class Signal: 8 | _signal_handlers = defaultdict(set) 9 | signalled = False 10 | 11 | @classmethod 12 | def register(cls, signum, func): 13 | if not cls.signalled: 14 | cls.signal() 15 | assert callable(func) 16 | cls._signal_handlers[signum].add(func) 17 | 18 | @classmethod 19 | def register_sigint(cls, func): 20 | logger.info(f"注册SIGINT信号处理程序: {func.__qualname__}") 21 | cls.register(signal.SIGINT, func) 22 | 23 | @classmethod 24 | def unregister(cls, signum, func): 25 | cls._signal_handlers[signum].remove(func) 26 | 27 | @classmethod 28 | def handler(cls, signum, frame): 29 | logger.info(f"接收到信号: {signal.strsignal(signum)}") 30 | for _handler in cls._signal_handlers[signum]: 31 | try: 32 | _handler() 33 | except Exception as e: 34 | logger.error(f"信号处理程序出现错误: {_handler}") 35 | logger.exception(e) 36 | 37 | @classmethod 38 | def signal(cls): 39 | signal.signal(signal.SIGINT, cls.handler) 40 | cls.signalled = True 41 | -------------------------------------------------------------------------------- /whochat/utils.py: -------------------------------------------------------------------------------- 1 | import ctypes 2 | import random 3 | import re 4 | import socket 5 | import string 6 | import sys 7 | from typing import List, Optional 8 | 9 | 10 | def windows_only(): 11 | if sys.platform != "win32": 12 | raise RuntimeError("仅支持在win32环境下使用") 13 | 14 | 15 | def get_free_port(): 16 | s = socket.socket() 17 | s.bind(("", 0)) 18 | ip, port = s.getsockname() 19 | s.close() 20 | return port 21 | 22 | 23 | def get_random_string(size): 24 | sample = string.ascii_letters + string.digits 25 | if size <= len(sample): 26 | return "".join(random.sample(sample, k=size)) 27 | return get_random_string(len(sample)) + get_random_string(size - len(sample)) 28 | 29 | 30 | # Windows Only 31 | def guess_wechat_user_by_path(path: str) -> Optional[str]: 32 | """ 33 | 34 | :param path: e.g. "C:\\Users\\foo\\Documents\\WeChat Files\\\\Msg\\Sns.db" 35 | """ 36 | 37 | m = re.match(r".*?WeChat Files\\([\w\-]+)\\", path, flags=re.I) 38 | if not m: 39 | return "" 40 | return m.group(1) 41 | 42 | 43 | def guess_wechat_user_by_paths(paths: List[str]) -> Optional[str]: 44 | for path in paths: 45 | user = guess_wechat_user_by_path(path) 46 | if user: 47 | return user 48 | return "" 49 | 50 | 51 | def guess_wechat_base_directory_by_path(path: str) -> Optional[str]: 52 | """ 53 | :param path: e.g. "C:\\Users\\foo\\Documents\\WeChat Files\\\\Msg\\Sns.db" 54 | """ 55 | m = re.match(r"(.*?\\WeChat Files).*", path, flags=re.I) 56 | if not m: 57 | return "" 58 | return m.group(1) 59 | 60 | 61 | def guess_wechat_base_directory(paths: List[str]) -> Optional[str]: 62 | for path in paths: 63 | directory = guess_wechat_base_directory_by_path(path) 64 | if directory: 65 | return directory 66 | return "" 67 | 68 | 69 | _handles_type = ctypes.c_void_p * 1 70 | RPC_S_CALLPENDING = -2147417835 71 | 72 | 73 | class EventWaiter: 74 | def __init__(self, timeout: int): 75 | self.timeout = timeout 76 | self.handle = None 77 | self._handles = None 78 | 79 | def reset_event(self): 80 | return ctypes.windll.kernel32.ResetEvent(self.handle) 81 | 82 | def set_event(self): 83 | return ctypes.windll.kernel32.SetEvent(self.handle) 84 | 85 | def create_handle(self): 86 | self.handle = ctypes.windll.kernel32.CreateEventA(None, True, False, None) 87 | self._handles = _handles_type(self.handle) 88 | 89 | def close_handle(self): 90 | return ctypes.windll.kernel32.CloseHandle(self.handle) 91 | 92 | def stop(self): 93 | return self.set_event() != 0 94 | 95 | def wait_once(self) -> bool: 96 | try: 97 | res = ctypes.oledll.ole32.CoWaitForMultipleHandles( 98 | 0, 99 | int(self.timeout * 1000), 100 | len(self._handles), 101 | self._handles, 102 | ctypes.byref(ctypes.c_ulong()), 103 | ) 104 | return res == 0 105 | except WindowsError as details: 106 | if details.winerror == RPC_S_CALLPENDING: # timeout expired 107 | return False 108 | raise 109 | 110 | def wait_forever(self): 111 | self.reset_event() 112 | try: 113 | while not self.wait_once(): 114 | pass 115 | finally: 116 | self.close_handle() 117 | 118 | 119 | def as_admin(exe: str, params: str = None): 120 | ctypes.windll.shell32.ShellExecuteW(None, "runas", exe, params) 121 | --------------------------------------------------------------------------------