├── .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 |
--------------------------------------------------------------------------------