├── .gitignore ├── LICENSE ├── MANIFEST.in ├── README.md ├── setup.py └── wxhook ├── __init__.py ├── core.py ├── events.py ├── logger.py ├── model.py ├── tools ├── faker.exe ├── start-wechat.exe └── wxhook.dll └── 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 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *,cover 46 | .hypothesis/ 47 | 48 | # Translations 49 | *.mo 50 | *.pot 51 | 52 | # Django stuff: 53 | *.log 54 | local_settings.py 55 | 56 | # Flask stuff: 57 | instance/ 58 | .webassets-cache 59 | 60 | # Scrapy stuff: 61 | .scrapy 62 | 63 | # Sphinx documentation 64 | docs/_build/ 65 | 66 | # PyBuilder 67 | target/ 68 | 69 | # IPython Notebook 70 | .ipynb_checkpoints 71 | 72 | # pyenv 73 | .python-version 74 | 75 | # celery beat schedule file 76 | celerybeat-schedule 77 | 78 | # dotenv 79 | .env 80 | 81 | # virtualenv 82 | venv/ 83 | ENV/ 84 | 85 | # Spyder project settings 86 | .spyderproject 87 | 88 | # Rope project settings 89 | .ropeproject 90 | *.npy 91 | *.pkl 92 | 93 | .idea -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md LICENSE 2 | include wxhook/tools/* 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # WxHook 2 | 3 | ## 简介 4 | 5 | WxHook是一个基于dll注入实现的python微信机器人框架,支持多种接口、高扩展性、多线程消息处理,让你轻松应对海量消息,为你的需求实现提供便捷灵活的支持。 6 | 7 | 支持的接口 8 | 1. hook同步消息 9 | 2. 取消hook同步消息 10 | 3. hook日志 11 | 4. 取消hook日志 12 | 5. 检查登录状态 13 | 6. 获取用户信息 14 | 7. 发送文本消息 15 | 8. 发送图片消息 16 | 9. 发送文件消息 17 | 10. 发送表情消息 18 | 11. 发送小程序消息 19 | 12. 发送群@消息 20 | 13. 发送拍一拍消息 21 | 14. 获取联系人列表 22 | 15. 获取联系人详情 23 | 16. 创建群聊 24 | 17. 退出群聊 25 | 18. 获取群详情 26 | 19. 获取群成员列表 27 | 20. 添加群成员 28 | 21. 删除群成员 29 | 22. 邀请群成员 30 | 23. 修改群成员昵称 31 | 24. 设置群置顶消息 32 | 25. 移除群置顶消息 33 | 26. 转发消息 34 | 27. 获取朋友圈首页 35 | 28. 获取朋友圈下一页 36 | 29. 收藏消息 37 | 30. 收藏图片 38 | 31. 下载附件 39 | 32. 转发公众号消息 40 | 33. 转发公众号消息通过消息ID 41 | 34. 解码图片 42 | 35. 获取语音通过消息ID 43 | 36. 图片文本识别 44 | 37. 获取数据库句柄 45 | 38. 执行SQL命令 46 | 39. 测试 47 | 48 | ## 微信版本下载 49 | - [WeChatSetup3.9.5.81.exe](https://github.com/tom-snow/wechat-windows-versions/releases/download/v3.9.5.81/WeChatSetup-3.9.5.81.exe) 50 | 51 | ## 安装 52 | 53 | ```bash 54 | pip install wxhook 55 | ``` 56 | 57 | ## 使用示例 58 | 59 | ```python 60 | # import os 61 | # os.environ["WXHOOK_LOG_LEVEL"] = "INFO" # 修改日志输出级别 62 | # os.environ["WXHOOK_LOG_FORMAT"] = "{time:YYYY-MM-DD HH:mm:ss} | {message}" # 修改日志输出格式 63 | from wxhook import Bot 64 | from wxhook import events 65 | from wxhook.model import Event 66 | 67 | 68 | def on_login(bot: Bot, event: Event): 69 | print("登录成功之后会触发这个函数") 70 | 71 | 72 | def on_start(bot: Bot): 73 | print("微信客户端打开之后会触发这个函数") 74 | 75 | 76 | def on_stop(bot: Bot): 77 | print("关闭微信客户端之前会触发这个函数") 78 | 79 | 80 | def on_before_message(bot: Bot, event: Event): 81 | print("消息事件处理之前") 82 | 83 | 84 | def on_after_message(bot: Bot, event: Event): 85 | print("消息事件处理之后") 86 | 87 | 88 | bot = Bot( 89 | # faked_version="3.9.10.19", # 解除微信低版本限制 90 | on_login=on_login, 91 | on_start=on_start, 92 | on_stop=on_stop, 93 | on_before_message=on_before_message, 94 | on_after_message=on_after_message 95 | ) 96 | 97 | 98 | # 消息回调地址 99 | # bot.set_webhook_url("http://127.0.0.1:8000") 100 | 101 | @bot.handle(events.TEXT_MESSAGE) 102 | def on_message(bot: Bot, event: Event): 103 | bot.send_text("filehelper", "hello world!") 104 | 105 | 106 | bot.run() 107 | ``` 108 | 109 | QQ交流群 110 | 111 | 一群:625920216(已满) 112 | 113 | 二群:705791428 114 | 115 | 开源版本: 116 | 117 | 微信3.9.5.81版本开发框架项目地址:https://github.com/miloira/wxhook 118 | 119 | 微信3.9.2.23版本开发框架项目地址:https://github.com/miloira/wxhelper 120 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | # Note: To use the 'upload' functionality of this file, you must: 5 | # $ pipenv install twine --dev 6 | 7 | import io 8 | import os 9 | import sys 10 | from shutil import rmtree 11 | 12 | from setuptools import find_packages, setup, Command 13 | 14 | # Package meta-data. 15 | NAME = 'wxhook' 16 | DESCRIPTION = 'wechat robot framework.' 17 | URL = 'https://github.com/miloira/wxhook' 18 | EMAIL = '690126048@qq.com' 19 | AUTHOR = 'Msky' 20 | REQUIRES_PYTHON = '>=3.8.0' 21 | VERSION = '0.0.11' 22 | 23 | # What packages are required for this module to be executed? 24 | REQUIRED = [ 25 | 'loguru', 26 | 'psutil', 27 | 'pyee', 28 | 'requests', 29 | 'xmltodict' 30 | ] 31 | 32 | # What packages are optional? 33 | EXTRAS = { 34 | # 'fancy feature': ['django'], 35 | } 36 | 37 | # The rest you shouldn't have to touch too much :) 38 | # ------------------------------------------------ 39 | # Except, perhaps the License and Trove Classifiers! 40 | # If you do change the License, remember to change the Trove Classifier for that! 41 | 42 | here = os.path.abspath(os.path.dirname(__file__)) 43 | 44 | # Import the README and use it as the long-description. 45 | # Note: this will only work if 'README.md' is present in your MANIFEST.in file! 46 | try: 47 | with io.open(os.path.join(here, 'README.md'), encoding='utf-8') as f: 48 | long_description = '\n' + f.read() 49 | except FileNotFoundError: 50 | long_description = DESCRIPTION 51 | 52 | # Load the package's __version__.py module as a dictionary. 53 | about = {} 54 | if not VERSION: 55 | project_slug = NAME.lower().replace("-", "_").replace(" ", "_") 56 | with open(os.path.join(here, project_slug, '__version__.py')) as f: 57 | exec(f.read(), about) 58 | else: 59 | about['__version__'] = VERSION 60 | 61 | 62 | class UploadCommand(Command): 63 | """Support setup.py upload.""" 64 | 65 | description = 'Build and publish the package.' 66 | user_options = [] 67 | 68 | @staticmethod 69 | def status(s): 70 | """Prints things in bold.""" 71 | print('\033[1m{0}\033[0m'.format(s)) 72 | 73 | def initialize_options(self): 74 | pass 75 | 76 | def finalize_options(self): 77 | pass 78 | 79 | def run(self): 80 | try: 81 | self.status('Removing previous builds…') 82 | rmtree(os.path.join(here, 'dist')) 83 | except OSError: 84 | pass 85 | 86 | self.status('Building Source and Wheel (universal) distribution…') 87 | os.system('{0} setup.py sdist bdist_wheel --universal'.format(sys.executable)) 88 | 89 | self.status('Uploading the package to PyPI via Twine…') 90 | os.system('twine upload dist/*') 91 | 92 | self.status('Pushing git tags…') 93 | os.system('git tag v{0}'.format(about['__version__'])) 94 | os.system('git push --tags') 95 | 96 | sys.exit() 97 | 98 | 99 | # Where the magic happens: 100 | setup( 101 | name=NAME, 102 | version=about['__version__'], 103 | description=DESCRIPTION, 104 | long_description=long_description, 105 | long_description_content_type='text/markdown', 106 | author=AUTHOR, 107 | author_email=EMAIL, 108 | python_requires=REQUIRES_PYTHON, 109 | url=URL, 110 | packages=find_packages(exclude=["tests", "*.tests", "*.tests.*", "tests.*"]), 111 | # If your package is a single module, use this instead of 'packages': 112 | # py_modules=['mypackage'], 113 | 114 | # entry_points={ 115 | # 'console_scripts': ['mycli=mymodule:cli'], 116 | # }, 117 | install_requires=REQUIRED, 118 | extras_require=EXTRAS, 119 | include_package_data=True, 120 | license='MIT', 121 | classifiers=[ 122 | # Trove classifiers 123 | # Full list: https://pypi.python.org/pypi?%3Aaction=list_classifiers 124 | 'License :: OSI Approved :: MIT License', 125 | 'Programming Language :: Python', 126 | 'Programming Language :: Python :: 3', 127 | 'Programming Language :: Python :: 3.8', 128 | 'Programming Language :: Python :: Implementation :: CPython', 129 | 'Programming Language :: Python :: Implementation :: PyPy' 130 | ], 131 | # $ setup.py publish support. 132 | cmdclass={ 133 | 'upload': UploadCommand, 134 | }, 135 | ) -------------------------------------------------------------------------------- /wxhook/__init__.py: -------------------------------------------------------------------------------- 1 | from .core import Bot 2 | 3 | version = "0.0.11" 4 | -------------------------------------------------------------------------------- /wxhook/core.py: -------------------------------------------------------------------------------- 1 | import os 2 | import json 3 | import typing 4 | import traceback 5 | import socketserver 6 | from functools import lru_cache 7 | 8 | import psutil 9 | import pyee 10 | import requests 11 | 12 | from .logger import logger 13 | from .events import ALL_MESSAGE 14 | from .model import Event, Account, Contact, ContactDetail, Room, RoomMembers, Table, DB, Response 15 | from .utils import WeChatManager, start_wechat_with_inject, fake_wechat_version, get_pid, parse_event 16 | 17 | 18 | class RequestHandler(socketserver.BaseRequestHandler): 19 | def __init__(self, *args, **kwargs): 20 | super().__init__(*args, **kwargs) 21 | 22 | def handle(self): 23 | try: 24 | data = b"" 25 | while True: 26 | chunk = self.request.recv(1024) 27 | data += chunk 28 | if len(chunk) == 0 or chunk[-1] == 0xA: 29 | break 30 | 31 | bot = getattr(self.server, "bot") 32 | bot.on_event(data) 33 | self.request.sendall("200 OK".encode()) 34 | except Exception: 35 | logger.error(traceback.format_exc()) 36 | finally: 37 | self.request.close() 38 | 39 | 40 | class Bot: 41 | 42 | def __init__( 43 | self, 44 | on_login: typing.Optional[typing.Callable[["Bot", Event], typing.Any]] = None, 45 | on_before_message: typing.Optional[typing.Callable[["Bot", Event], typing.Any]] = None, 46 | on_after_message: typing.Optional[typing.Callable[["Bot", Event], typing.Any]] = None, 47 | on_start: typing.Optional[typing.Callable[["Bot"], typing.Any]] = None, 48 | on_stop: typing.Optional[typing.Callable[["Bot"], typing.Any]] = None, 49 | faked_version: typing.Optional[str] = None 50 | ): 51 | self.version = "3.9.5.81" 52 | self.server_host = "127.0.0.1" 53 | self.remote_host = "127.0.0.1" 54 | self.on_start = on_start 55 | self.on_login = on_login 56 | self.on_before_message = on_before_message 57 | self.on_after_message = on_after_message 58 | self.on_stop = on_stop 59 | self.faked_version = faked_version 60 | self.event_emitter = pyee.EventEmitter() 61 | self.wechat_manager = WeChatManager() 62 | self.remote_port, self.server_port = self.wechat_manager.get_port() 63 | self.BASE_URL = f"http://{self.remote_host}:{self.remote_port}" 64 | self.webhook_url = None 65 | self.DATA_SAVE_PATH = None 66 | self.WXHELPER_PATH = None 67 | self.FILE_SAVE_PATH = None 68 | self.IMAGE_SAVE_PATH = None 69 | self.VIDEO_SAVE_PATH = None 70 | 71 | try: 72 | code, output = start_wechat_with_inject(self.remote_port) 73 | except Exception: 74 | code, output = get_pid(self.remote_port) 75 | 76 | if code == 1: 77 | raise Exception(output) 78 | 79 | self.process = psutil.Process(int(output)) 80 | 81 | if self.faked_version is not None: 82 | if fake_wechat_version(self.process.pid, self.version, faked_version) == 0: 83 | logger.success(f"wechat version faked: {self.version} -> {faked_version}") 84 | else: 85 | logger.error(f"wechat version fake failed.") 86 | 87 | logger.info(f"API Server at 0.0.0.0:{self.remote_port}") 88 | self.wechat_manager.add(self.process.pid, self.remote_port, self.server_port) 89 | self.call_hook_func(self.on_start, self) 90 | self.handle(ALL_MESSAGE, once=True)(self.init_bot) 91 | self.hook_sync_msg(self.server_host, self.server_port) 92 | 93 | @staticmethod 94 | def call_hook_func(func: typing.Callable, *args, **kwargs) -> typing.Any: 95 | if callable(func): 96 | return func(*args, **kwargs) 97 | 98 | def init_bot(self, bot: "Bot", event: Event) -> None: 99 | self.DATA_SAVE_PATH = bot.info.dataSavePath 100 | self.WXHELPER_PATH = os.path.join(self.DATA_SAVE_PATH, "wxhelper") 101 | self.FILE_SAVE_PATH = os.path.join(self.WXHELPER_PATH, "file") 102 | self.IMAGE_SAVE_PATH = os.path.join(self.WXHELPER_PATH, "image") 103 | self.VIDEO_SAVE_PATH = os.path.join(self.WXHELPER_PATH, "video") 104 | self.call_hook_func(self.on_login, bot, event) 105 | 106 | def set_webhook_url(self, webhook_url: str) -> None: 107 | self.webhook_url = webhook_url 108 | 109 | def webhook(self, event: dict) -> None: 110 | if self.webhook_url is not None: 111 | try: 112 | requests.post(self.webhook_url, json=event) 113 | except Exception: 114 | pass 115 | 116 | def call_api(self, api: str, *args, **kwargs) -> dict: 117 | return requests.request("POST", self.BASE_URL + api, *args, **kwargs).json() 118 | 119 | def hook_sync_msg( 120 | self, 121 | ip: str, 122 | port: int, 123 | enable_http: int = 0, 124 | url: str = "http://127.0.0.1:8000", 125 | timeout: int = 30 126 | ) -> Response: 127 | """hook同步消息""" 128 | data = { 129 | "port": port, 130 | "ip": ip, 131 | "enableHttp": enable_http, 132 | "url": url, 133 | "timeout": timeout 134 | } 135 | return Response(**self.call_api("/api/hookSyncMsg", json=data)) 136 | 137 | def unhook_sync_msg(self) -> Response: 138 | """取消hook同步消息""" 139 | return Response(**self.call_api("/api/unhookSyncMsg")) 140 | 141 | def hook_log(self) -> Response: 142 | """hook日志""" 143 | return Response(**self.call_api("/api/hookLog")) 144 | 145 | def unhook_log(self) -> Response: 146 | """取消hook日志""" 147 | return Response(**self.call_api("/api/unhookLog")) 148 | 149 | def check_login(self) -> Response: 150 | """检查登录状态""" 151 | return Response(**self.call_api("/api/checkLogin")) 152 | 153 | @lru_cache 154 | def get_self_info(self) -> Account: 155 | """获取用户信息""" 156 | return Account(**self.call_api("/api/userInfo")["data"]) 157 | 158 | def send_text(self, wxid: str, msg: str) -> Response: 159 | """发送文本消息""" 160 | data = { 161 | "wxid": wxid, 162 | "msg": msg 163 | } 164 | return Response(**self.call_api("/api/sendTextMsg", json=data)) 165 | 166 | def send_image(self, wxid: str, image_path: str) -> Response: 167 | """发送图片消息""" 168 | data = { 169 | "wxid": wxid, 170 | "imagePath": image_path 171 | } 172 | return Response(**self.call_api("/api/sendImagesMsg", json=data)) 173 | 174 | def send_emotion(self, wxid: str, file_path: str) -> Response: 175 | """发送表情消息""" 176 | data = { 177 | "wxid": wxid, 178 | "filePath": file_path 179 | } 180 | return Response(**self.call_api("/api/sendCustomEmotion", json=data)) 181 | 182 | def send_file(self, wxid: str, file_path: str) -> Response: 183 | """发送文件消息""" 184 | data = { 185 | "wxid": wxid, 186 | "filePath": file_path 187 | } 188 | return Response(**self.call_api("/api/sendFileMsg", json=data)) 189 | 190 | def send_applet( 191 | self, 192 | wxid: str, 193 | waid_contact: str, 194 | waid: str, 195 | applet_wxid: str, 196 | json_param: str, 197 | head_img_url: str, 198 | main_img: str, 199 | index_page: str 200 | ) -> Response: 201 | """发送小程序消息""" 202 | data = { 203 | "wxid": wxid, 204 | "waidConcat": waid_contact, 205 | "waid": waid, 206 | "appletWxid": applet_wxid, 207 | "jsonParam": json_param, 208 | "headImgUrl": head_img_url, 209 | "mainImg": main_img, 210 | "indexPage": index_page 211 | } 212 | return Response(**self.call_api("/api/sendApplet", json=data)) 213 | 214 | def send_room_at(self, room_id: str, wxids: typing.List[str], msg: str) -> Response: 215 | """发送群@消息""" 216 | data = { 217 | "chatRoomId": room_id, 218 | "wxids": ",".join(wxids), 219 | "msg": msg 220 | } 221 | return Response(**self.call_api("/api/sendAtText", json=data)) 222 | 223 | def send_pat(self, room_id: str, wxid: str) -> Response: 224 | """发送拍一拍消息""" 225 | data = { 226 | "receiver": room_id, 227 | "wxid": wxid 228 | } 229 | return Response(**self.call_api("/api/sendPatMsg", json=data)) 230 | 231 | def get_contacts(self) -> typing.List[Contact]: 232 | """获取联系人列表""" 233 | return [Contact(**item) for item in self.call_api("/api/getContactList")["data"]] 234 | 235 | def get_contact(self, wxid: str) -> ContactDetail: 236 | """获取联系人详情""" 237 | data = { 238 | "wxid": wxid 239 | } 240 | return ContactDetail(**self.call_api("/api/getContactProfile", json=data)["data"]) 241 | 242 | def create_room(self, member_ids: typing.List[str]) -> Response: 243 | """创建群聊""" 244 | data = { 245 | "memberIds": ",".join(member_ids) 246 | } 247 | return Response(**self.call_api("/api/createChatRoom", json=data)) 248 | 249 | def quit_room(self, room_id: str) -> Response: 250 | """退出群聊""" 251 | data = { 252 | "chatRoomId": room_id 253 | } 254 | return Response(**self.call_api("/api/quitChatRoom", json=data)) 255 | 256 | def get_room(self, room_id: str) -> Room: 257 | """获取群详情""" 258 | data = { 259 | "chatRoomId": room_id 260 | } 261 | return Room(**self.call_api("/api/getChatRoomDetailInfo", json=data)["data"]) 262 | 263 | def get_room_members(self, room_id: str) -> RoomMembers: 264 | """获取群成员列表""" 265 | data = { 266 | "chatRoomId": room_id 267 | } 268 | return RoomMembers(**self.call_api("/api/getMemberFromChatRoom", json=data)["data"]) 269 | 270 | def add_room_member(self, room_id: str, member_ids: typing.List[str]) -> Response: 271 | """添加群成员""" 272 | data = { 273 | "chatRoomId": room_id, 274 | "memberIds": ",".join(member_ids) 275 | } 276 | return Response(**self.call_api("/api/addMemberToChatRoom", json=data)) 277 | 278 | def delete_room_member(self, room_id: str, member_ids: typing.List[str]) -> Response: 279 | """删除群成员""" 280 | data = { 281 | "chatRoomId": room_id, 282 | "memberIds": ",".join(member_ids) 283 | } 284 | return Response(**self.call_api("/api/delMemberFromChatRoom", json=data)) 285 | 286 | def invite_room_member(self, room_id: str, member_ids: typing.List[str]) -> Response: 287 | """邀请群成员""" 288 | data = { 289 | "chatRoomId": room_id, 290 | "memberIds": ",".join(member_ids) 291 | } 292 | return Response(**self.call_api("/api/InviteMemberToChatRoom", json=data)) 293 | 294 | def modify_member_nickname(self, room_id: str, wxid: str, nickname: str) -> Response: 295 | """修改群成员昵称""" 296 | data = { 297 | "chatRoomId": room_id, 298 | "wxid": wxid, 299 | "nickName": nickname 300 | } 301 | return Response(**self.call_api("/api/modifyNickname", json=data)) 302 | 303 | def top_msg(self, msg_id: int) -> Response: 304 | """设置群置顶消息""" 305 | data = { 306 | "msgId": msg_id 307 | } 308 | return Response(**self.call_api("/api/topMsg", json=data)) 309 | 310 | def remove_top_msg(self, room_id: str, msg_id: int) -> Response: 311 | """移除群置顶消息""" 312 | data = { 313 | "chatRoomId": room_id, 314 | "msgId": msg_id 315 | } 316 | return Response(**self.call_api("/api/removeTopMsg", json=data)) 317 | 318 | def forward_msg(self, msg_id: int, wxid: str) -> Response: 319 | """转发消息""" 320 | data = { 321 | "msgId": msg_id, 322 | "wxid": wxid 323 | } 324 | return Response(**self.call_api("/api/forwardMsg", json=data)) 325 | 326 | def get_sns_first_page(self) -> Response: 327 | """获取朋友圈首页""" 328 | return Response(**self.call_api("/api/getSNSFirstPage")) 329 | 330 | def get_sns_next_page(self, sns_id: int) -> Response: 331 | """获取朋友圈下一页""" 332 | data = { 333 | "snsId": sns_id 334 | } 335 | return Response(**self.call_api("/api/getSNSNextPage", json=data)) 336 | 337 | def collect_msg(self, msg_id: int) -> Response: 338 | """收藏消息""" 339 | data = { 340 | "msgId": msg_id 341 | } 342 | return Response(**self.call_api("/api/addFavFromMsg", json=data)) 343 | 344 | def collect_image(self, wxid: str, image_path: str) -> Response: 345 | """收藏图片""" 346 | data = { 347 | "wxid": wxid, 348 | "imagePath": image_path 349 | } 350 | return Response(**self.call_api("/api/addFavFromImage", json=data)) 351 | 352 | def download_attachment(self, msg_id: int) -> Response: 353 | """下载附件""" 354 | data = { 355 | "msgId": msg_id 356 | } 357 | return Response(**self.call_api("/api/downloadAttach", json=data)) 358 | 359 | def forward_public_msg( 360 | self, 361 | wxid: str, 362 | app_name: str, 363 | username: str, 364 | title: str, 365 | url: str, 366 | thumb_url: str, 367 | digest: str 368 | ) -> Response: 369 | """转发公众号消息""" 370 | data = { 371 | "wxid": wxid, 372 | "appName": app_name, 373 | "userName": username, 374 | "title": title, 375 | "url": url, 376 | "thumbUrl": thumb_url, 377 | "digest": digest, 378 | } 379 | return Response(**self.call_api("/api/forwardPublicMsg", json=data)) 380 | 381 | def forward_public_msg_by_msg_id(self, wxid: str, msg_id: int) -> Response: 382 | """转发公众号消息通过消息ID""" 383 | data = { 384 | "wxid": wxid, 385 | "msg_id": msg_id 386 | } 387 | return Response(**self.call_api("/api/forwardPublicMsgByMsgId", json=data)) 388 | 389 | def decode_image(self, file_path: str, store_dir: str) -> Response: 390 | """解码图片""" 391 | data = { 392 | "filePath": file_path, 393 | "storeDir": store_dir 394 | } 395 | return Response(**self.call_api("/api/decodeImage", json=data)) 396 | 397 | def get_voice_by_msg_id(self, msg_id: int, store_dir: str) -> Response: 398 | """获取语音通过消息ID""" 399 | data = { 400 | "msgId": msg_id, 401 | "storeDir": store_dir 402 | } 403 | return Response(**self.call_api("/api/getVoiceByMsgId", json=data)) 404 | 405 | def ocr(self, image_path: str) -> Response: 406 | """图片文本识别""" 407 | data = { 408 | "imagePath": image_path 409 | } 410 | return Response(**self.call_api("/api/ocr", json=data)) 411 | 412 | def get_db_info(self) -> typing.List[DB]: 413 | """获取数据库句柄""" 414 | return [ 415 | DB(databaseName=item["databaseName"], handle=item["handle"], tables=[ 416 | Table(**sub_item) 417 | for sub_item in item["tables"] 418 | ]) 419 | for item in self.call_api("/api/getDBInfo") 420 | ] 421 | 422 | def exec_sql(self, db_handle: int, sql: str) -> Response: 423 | """执行SQL命令""" 424 | data = { 425 | "dbHandle": db_handle, 426 | "sql": sql 427 | } 428 | return Response(**self.call_api("/api/execSql", json=data)) 429 | 430 | def test(self) -> Response: 431 | """测试""" 432 | return Response(**self.call_api("/api/test")) 433 | 434 | @property 435 | def info(self) -> Account: 436 | return self.get_self_info() 437 | 438 | def on_event(self, raw_data: bytes) -> None: 439 | try: 440 | data = json.loads(raw_data) 441 | event = Event(**parse_event(data)) 442 | logger.debug(event) 443 | self.call_hook_func(self.on_before_message, self, event) 444 | self.event_emitter.emit(str(ALL_MESSAGE), self, event) 445 | self.event_emitter.emit(str(event.type), self, event) 446 | self.call_hook_func(self.on_after_message, self, event) 447 | self.webhook(data) 448 | except Exception: 449 | logger.error(traceback.format_exc()) 450 | logger.error(raw_data) 451 | 452 | def handle(self, events: typing.Union[typing.List[str], str, None] = None, once: bool = False) -> typing.Callable[[typing.Callable], None]: 453 | def wrapper(func): 454 | listen = self.event_emitter.on if not once else self.event_emitter.once 455 | if not events: 456 | listen(str(ALL_MESSAGE), func) 457 | else: 458 | for event in events if isinstance(events, list) else [events]: 459 | listen(str(event), func) 460 | 461 | return wrapper 462 | 463 | def exit(self) -> None: 464 | self.call_hook_func(self.on_stop, self) 465 | self.process.terminate() 466 | 467 | def run(self) -> None: 468 | try: 469 | server = socketserver.ThreadingTCPServer((self.server_host, self.server_port), RequestHandler) 470 | server.bot = self 471 | logger.info(f"Listening Server at {self.server_host}:{self.server_port}") 472 | server.serve_forever() 473 | except (KeyboardInterrupt, SystemExit): 474 | self.exit() 475 | -------------------------------------------------------------------------------- /wxhook/events.py: -------------------------------------------------------------------------------- 1 | # 通知消息事件 2 | NOTICE_MESSAGE = 10000 3 | # 系统消息事件 4 | SYSTEM_MESSAGE = 10002 5 | # 全部消息事件 6 | ALL_MESSAGE = 99999 7 | # 文本消息事件 8 | TEXT_MESSAGE = 1 9 | # 图片消息事件 10 | IMAGE_MESSAGE = 3 11 | # 语音消息事件 12 | VOICE_MESSAGE = 34 13 | # 好有验证请求消息事件 14 | FRIEND_VERIFY_MESSAGE = 37 15 | # 卡片消息事件 16 | CARD_MESSAGE = 42 17 | # 视频消息事件 18 | VIDEO_MESSAGE = 43 19 | # 表情消息事件 20 | EMOJI_MESSAGE = 47 21 | # 位置消息事件 22 | LOCATION_MESSAGE = 48 23 | # xml消息事件 24 | XML_MESSAGE = 49 25 | # 视频/语音通话消息事件 26 | VOIP_MESSAGE = 50 27 | # 手机端同步消息事件 28 | PHONE_MESSAGE = 51 -------------------------------------------------------------------------------- /wxhook/logger.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | from loguru import logger 5 | 6 | logger.remove() 7 | logger.add( 8 | sink=sys.stdout, 9 | format=os.environ.get("WXHOOK_LOG_FORMAT", "{time:YYYY-MM-DD HH:mm:ss} | {level} | {message}"), 10 | level=os.environ.get("WXHOOK_LOG_LEVEL", "DEBUG") 11 | ) 12 | -------------------------------------------------------------------------------- /wxhook/model.py: -------------------------------------------------------------------------------- 1 | import typing 2 | from dataclasses import dataclass 3 | from typing import List 4 | 5 | 6 | @dataclass 7 | class Account: 8 | """用户""" 9 | account: str # 账号名 10 | city: str # 所在城市 11 | country: str # 所在国家代码 12 | currentDataPath: str # 当前数据路径,通常指向用户的WeChat文件夹 13 | dataSavePath: str # 数据保存路径,通常指向用户的WeChat文件夹 14 | dbKey: str # 数据库密钥,用于加密本地数据库 15 | headImage: str # 头像图片URL 16 | mobile: str # 手机号码 17 | name: str # 昵称 18 | province: str # 所在省份 19 | signature: str # 用户个性签名 20 | wxid: str # 微信ID 21 | 22 | 23 | @dataclass 24 | class Contact: 25 | """联系人""" 26 | customAccount: str # 用户自定义的账号 27 | encryptName: str # 加密名称,如果有的话 28 | nickname: str # 用户的昵称 29 | pinyin: str # 用户昵称的拼音首字母 30 | pinyinAll: str # 用户昵称的完整拼音 31 | reserved1: int # 预留字段1,具体用途未知 32 | reserved2: int # 预留字段2,具体用途未知 33 | type: int # 联系人类型 34 | verifyFlag: int # 验证标志,用于表示用户的验证状态 35 | wxid: str # 用户的微信ID 36 | 37 | 38 | @dataclass 39 | class ContactDetail: 40 | """联系人详情""" 41 | account: str # 用户账号,如果未设置则为空字符串 42 | headImage: str # 用户的头像图片URL,如果未设置则为空字符串 43 | nickname: str # 用户昵称 44 | v3: str # 用户的V3信息,通常用于加密或验证,可能包含特定的加密字符串 45 | wxid: str # 用户的微信ID 46 | 47 | 48 | @dataclass 49 | class Room: 50 | """群聊""" 51 | admin: str # 管理员的用户ID,如果没有管理员则为空字符串 52 | chatRoomId: str # 聊天室ID,如果没有指定聊天室则为空字符串 53 | notice: str # 聊天室公告内容,如果没有设置公告则为空字符串 54 | xml: str # 聊天室相关的XML信息,通常包含聊天室的详细配置信息,如果没有则为空字符串 55 | 56 | 57 | @dataclass 58 | class RoomMembers: 59 | """群成员""" 60 | admin: str # 聊天室管理员的微信ID 61 | adminNickname: str # 聊天室管理员的昵称 62 | chatRoomId: str # 聊天室的ID 63 | memberNickname: str # 正在提及的成员昵称,可能包含特殊字符作为昵称的一部分 64 | members: str # 聊天室成员的微信ID列表,各ID之间使用特定字符分隔 65 | 66 | 67 | @dataclass 68 | class Event: 69 | """消息事件""" 70 | content: typing.Optional[typing.Any] = None # 消息内容,可能包含用户ID和冒号之后的文本内容 71 | base64Img: typing.Optional[str] = None # 图片base64 72 | data: typing.Optional[list] = None # 朋友圈数据 73 | createTime: typing.Optional[int] = None # 消息创建时间的UNIX时间戳 74 | displayFullContent: typing.Optional[str] = None # 完整的消息内容,如果有的话 75 | fromUser: typing.Optional[str] = None # 发送消息的用户或群组ID 76 | msgId: typing.Optional[int] = None # 消息的唯一标识符 77 | msgSequence: typing.Optional[int] = None # 消息序列号 78 | pid: typing.Optional[int] = None # 消息的PID 79 | signature: typing.Optional[str] = None # 消息签名,包含一系列的配置信息 80 | toUser: typing.Optional[str] = None # 消息接收者的用户ID 81 | type: typing.Optional[int] = None # 消息类型 82 | 83 | 84 | @dataclass 85 | class Table: 86 | """表结构""" 87 | name: str # 任务名称 88 | rootpage: str # 根页面 89 | sql: str # SQL 创建表的语句 90 | tableName: str # 表名称 91 | 92 | 93 | @dataclass 94 | class DB: 95 | """数据库""" 96 | databaseName: str # 数据库名称 97 | handle: int # 句柄 98 | tables: List[Table] # 表列表 99 | 100 | 101 | @dataclass 102 | class Response: 103 | """响应""" 104 | code: int # 状态码,例如 200 105 | data: dict # 用户数据,当前为空对象 106 | msg: str # 响应消息,例如 "success" 107 | -------------------------------------------------------------------------------- /wxhook/tools/faker.exe: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/miloira/wxhook/1d5abf205997895f230869b0d5d0f68a3f3a06f8/wxhook/tools/faker.exe -------------------------------------------------------------------------------- /wxhook/tools/start-wechat.exe: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/miloira/wxhook/1d5abf205997895f230869b0d5d0f68a3f3a06f8/wxhook/tools/start-wechat.exe -------------------------------------------------------------------------------- /wxhook/tools/wxhook.dll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/miloira/wxhook/1d5abf205997895f230869b0d5d0f68a3f3a06f8/wxhook/tools/wxhook.dll -------------------------------------------------------------------------------- /wxhook/utils.py: -------------------------------------------------------------------------------- 1 | import os 2 | import json 3 | import typing 4 | import pathlib 5 | import subprocess 6 | 7 | import psutil 8 | import xmltodict 9 | 10 | BASE_DIR = pathlib.Path(__file__).resolve().parent 11 | TOOLS = BASE_DIR / "tools" 12 | DLL = TOOLS / "wxhook.dll" 13 | START_WECHAT = TOOLS / "start-wechat.exe" 14 | FAKER = TOOLS / "faker.exe" 15 | 16 | 17 | def start_wechat_with_inject(port: int) -> typing.Tuple[int, str]: 18 | result = subprocess.run(f"{START_WECHAT} {DLL} {port}", capture_output=True, text=True) 19 | code, output = result.stdout.split(",") 20 | return int(code), output 21 | 22 | 23 | def fake_wechat_version(pid: int, old_version: str, new_version: str) -> int: 24 | result = subprocess.run(f"{FAKER} {pid} {old_version} {new_version}", capture_output=True, text=True) 25 | return int(result.stdout) 26 | 27 | 28 | def get_processes(process_name: str) -> typing.List[psutil.Process]: 29 | processes = [] 30 | for process in psutil.process_iter(): 31 | if process.name().lower() == process_name.lower(): 32 | processes.append(process) 33 | return processes 34 | 35 | 36 | def get_pid(port: int) -> typing.Tuple[int, int]: 37 | output = subprocess.run(f"netstat -ano | findStr \"{port}\"", capture_output=True, text=True, shell=True).stdout 38 | return 0, int(output.split("\n")[0].split("LISTENING")[-1]) 39 | 40 | 41 | def parse_xml(xml: str) -> dict: 42 | return xmltodict.parse(xml) 43 | 44 | 45 | def parse_event(event: dict, fields=None) -> dict: 46 | for field in fields or ["content", "signature"]: 47 | try: 48 | if field in event: 49 | event[field] = parse_xml(event[field]) 50 | except Exception: 51 | pass 52 | return event 53 | 54 | 55 | class WeChatManager: 56 | 57 | def __init__(self): 58 | # remote port: 19001 ~ 37999 59 | # socket port: 18999 ~ 1 60 | # http port: 38999 ~ 57997 61 | self.filename = BASE_DIR / "tools" / "wxhook.json" 62 | if not os.path.exists(self.filename): 63 | self.init_file() 64 | else: 65 | self.clean() 66 | 67 | def init_file(self) -> None: 68 | with open(self.filename, "w", encoding="utf-8") as file: 69 | json.dump({ 70 | "increase_remote_port": 19000, 71 | "wechat": [] 72 | }, file) 73 | 74 | def read(self) -> dict: 75 | with open(self.filename, "r", encoding="utf-8") as file: 76 | data = json.load(file) 77 | return data 78 | 79 | def write(self, data: dict) -> None: 80 | with open(self.filename, "w", encoding="utf-8") as file: 81 | json.dump(data, file) 82 | 83 | def refresh(self, pid_list: typing.List[int]) -> None: 84 | data = self.read() 85 | cleaned_data = [] 86 | remote_port_list = [19000] 87 | for item in data["wechat"]: 88 | if item["pid"] in pid_list: 89 | remote_port_list.append(item["remote_port"]) 90 | cleaned_data.append(item) 91 | 92 | data["increase_remote_port"] = max(remote_port_list) 93 | data["wechat"] = cleaned_data 94 | self.write(data) 95 | 96 | def clean(self) -> None: 97 | pid_list = [process.pid for process in get_processes("WeChat.exe")] 98 | self.refresh(pid_list) 99 | 100 | def get_remote_port(self) -> int: 101 | data = self.read() 102 | return data["increase_remote_port"] + 1 103 | 104 | def get_listen_port(self, remote_port: int) -> int: 105 | return 19000 - (remote_port - 19000) 106 | 107 | def get_port(self) -> typing.Tuple[int, int]: 108 | remote_port = self.get_remote_port() 109 | return remote_port, self.get_listen_port(remote_port) 110 | 111 | def add(self, pid: int, remote_port: int, server_port: int) -> None: 112 | data = self.read() 113 | data["increase_remote_port"] = remote_port 114 | data["wechat"].append({ 115 | "pid": pid, 116 | "remote_port": remote_port, 117 | "server_port": server_port 118 | }) 119 | self.write(data) 120 | --------------------------------------------------------------------------------