commit 44bf457a5f988e6a68ab478d03b6623f49e1d52e Author: 张明明 Date: Sat May 4 22:19:15 2024 +0800 version 0.0.1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d698f3f --- /dev/null +++ b/.gitignore @@ -0,0 +1,93 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +env/ +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +*.egg-info/ +.installed.cfg +*.egg + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*,cover +.hypothesis/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# IPython Notebook +.ipynb_checkpoints + +# pyenv +.python-version + +# celery beat schedule file +celerybeat-schedule + +# dotenv +.env + +# virtualenv +venv/ +ENV/ + +# Spyder project settings +.spyderproject + +# Rope project settings +.ropeproject +*.npy +*.pkl + +.idea \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5285f42 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2017 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..99e0830 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,2 @@ +include README.md LICENSE +include wxhook/tools/* diff --git a/README.md b/README.md new file mode 100644 index 0000000..15e6aeb --- /dev/null +++ b/README.md @@ -0,0 +1,195 @@ +# WxHook + +## 简介 + +WxHook是一个基于dll注入实现的python微信机器人框架,支持多种接口、高扩展性、多线程事件高并发,让你轻松应对海量消息,为你的需求实现提供便捷灵活的支持。 + +支持的接口 +1. hook同步消息 +2. 取消hook同步消息 +3. hook日志 +4. 取消hook日志 +5. 检查登录状态 +6. 获取用户信息 +7. 发送文本消息 +8. 发送图片消息 +9. 发送文件消息 +10. 发送表情消息 +11. 发送小程序消息 +12. 发送群@消息 +13. 发送拍一拍消息 +14. 获取联系人列表 +15. 获取联系人详情 +16. 创建群聊 +17. 退出群聊 +18. 获取群详情 +19. 获取群成员列表 +20. 添加群成员 +21. 删除群成员 +22. 邀请群成员 +23. 修改群成员昵称 +24. 设置群置顶消息 +25. 移除群置顶消息 +26. 转发消息 +27. 获取朋友圈首页 +28. 获取朋友圈下一页 +29. 收藏消息 +30. 收藏图片 +31. 下载附件 +32. 转发公众号消息 +33. 转发公众号消息通过消息ID +34. 解码图片 +35. 获取语音通过消息ID +36. 图片文本识别 +37. 获取数据库句柄 +38. 执行SQL命令 +39. 测试 + +## 微信版本下载 +- [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) + +## 安装 + +```bash +pip install wxhook +``` + +## 使用示例 + +消息事件处理例子 +```python +# import os +# os.environ["WXHOOK_LOG_LEVEL"] = "INFO" # 修改日志输出级别 +import time + +from wxhook import Bot +from wxhook import events +from wxhook.model import Event + + +def on_login(bot: Bot): + bot.send_text("filehelper", "登录成功之后会触发这个函数") + + +def on_start(bot: Bot): + print("微信客户端打开之后会触发这个函数") + + +def on_stop(bot: Bot): + bot.send_text("filehelper", "关闭微信客户端之前会触发这个函数") + time.sleep(1) # 防止客户端关闭太快导致消息发送失败 + + +bot = Bot( + on_login=on_login, + on_start=on_start, + on_stop=on_stop +) + + +# 消息回调地址 +# bot.set_webhook_url("http://127.0.0.1:8000") + +@bot.handle(events.TEXT_MESSAGE, once=True) +def on_message(bot: Bot, event: Event): + bot.send_text("filehelper", "这条消息只会发送一次哦") + + +@bot.handle(events.TEXT_MESSAGE) +def on_message(bot: Bot, event: Event): + if event.fromUser != bot.info.wxid: + bot.send_text(event.fromUser, event.content) + + +@bot.handle([events.IMAGE_MESSAGE, events.EMOJI_MESSAGE, events.VIDEO_MESSAGE]) +def on_message(bot: Bot, event: Event): + if event.fromUser != bot.info.wxid: + if event.type == events.IMAGE_MESSAGE: + bot.send_text(event.fromUser, "图片消息") + elif event.type == events.EMOJI_MESSAGE: + bot.send_text(event.fromUser, "表情消息") + elif event.type == events.VIDEO_MESSAGE: + bot.send_text(event.fromUser, "视频消息") + + +bot.run() +``` + +接口使用例子 +```python +import os +import json + +from wxhook import Bot +from wxhook import events +from wxhook.model import Event + +# faked_version="3.9.10.19"解除微信低版本登录限制 +bot = Bot() + +msgid_list = [] + + +@bot.handle(events.TEXT_MESSAGE) +def on_text_message(bot: Bot, event: Event): + self_id = bot.info.wxid + content = event.content + sender = event.fromUser + msg_id = event.msgId + if sender != self_id: + if content.find("发送文本") != -1: + bot.send_text(sender, "这是一条文本消息") + elif content.find("发送图片") != -1: + bot.send_image(sender, os.path.abspath("test.png")) + elif content.find("发送表情") != -1: + bot.send_emotion(sender, os.path.abspath("test.png")) + elif content.find("发送文件") != -1: + print(print(bot.send_file(sender, os.path.abspath("test.xlsx")))) + elif content.find("发送音频") != -1: + bot.send_file(sender, os.path.abspath("test.mp3")) + elif content.find("发送视频") != -1: + print(bot.send_file(sender, os.path.abspath("test.mp4"))) + elif content.find("创建群聊") != -1: + bot.create_room(["wxid1", "wxid2"]) + elif content.find("获取群成员列表") != -1: + print(bot.get_room_members("")) + elif content.find("删除群成员") != -1: + bot.delete_room_member("", [""]) + elif content.find("添加群成员") != -1: + bot.add_room_member("", [""]) + elif content.find("邀请群成员") != -1: + bot.invite_room_member("", [""]) + elif content.find("修改在群聊中的昵称") != -1: + print(bot.modify_member_nickname(sender, "", "测试机器人")) + elif content.find("退出群聊") != -1: + bot.quit_room("") + elif content.find("at全体成员") != -1: + bot.send_room_at(sender, ["notify@all", ""], "这是一条at全体成员的消息") + elif content.find("发送群at") != -1: + bot.send_room_at(sender, ["", ""], "这是一条at群成员的消息") + elif content.find("发送群聊拍一拍") != -1: + bot.send_pat(sender, "wxid_vqj81fdula0x22") + elif content.find("发送私聊拍一拍") != -1: + bot.send_pat(sender, sender) + elif content.find("置顶消息") != -1: + bot.top_msg(msg_id) + msgid_list.append(msg_id) + elif content.find("取消置顶的消息") != -1: + bot.remove_top_msg(sender, msgid_list.pop()) + elif content.find("获取联系人列表") != -1: + bot.send_text(sender, json.dumps(bot.get_contacts())) + elif content.find("获取联系人详情") != -1: + bot.send_text(sender, json.dumps(bot.get_contact(""))) + elif content.find("获取群详情") != -1: + print(bot.get_room("")) + elif content.find("收藏消息") != -1: + bot.collect_msg(msg_id) + elif content.find("收藏图片") != -1: + bot.collect_image(sender, os.path.abspath("test.png")) + elif content.find("ocr") != -1: + print(bot.ocr(os.path.abspath("test.png"))) + +bot.run() +``` + +QQ交流群:625920215 diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..7de1967 --- /dev/null +++ b/setup.py @@ -0,0 +1,135 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +# Note: To use the 'upload' functionality of this file, you must: +# $ pipenv install twine --dev + +import io +import os +import sys +from shutil import rmtree + +from setuptools import find_packages, setup, Command + +# Package meta-data. +NAME = 'wxhook' +DESCRIPTION = 'wechat robot framework.' +URL = 'https://github.com/miloira/wxhook' +EMAIL = '690126048@qq.com' +AUTHOR = 'Msky' +REQUIRES_PYTHON = '>=3.8.0' +VERSION = '0.0.1' + +# What packages are required for this module to be executed? +REQUIRED = [ + 'loguru', + 'psutil', + 'pyee', + 'requests', + 'xmltodict' +] + +# What packages are optional? +EXTRAS = { + # 'fancy feature': ['django'], +} + +# The rest you shouldn't have to touch too much :) +# ------------------------------------------------ +# Except, perhaps the License and Trove Classifiers! +# If you do change the License, remember to change the Trove Classifier for that! + +here = os.path.abspath(os.path.dirname(__file__)) + +# Import the README and use it as the long-description. +# Note: this will only work if 'README.md' is present in your MANIFEST.in file! +try: + with io.open(os.path.join(here, 'README.md'), encoding='utf-8') as f: + long_description = '\n' + f.read() +except FileNotFoundError: + long_description = DESCRIPTION + +# Load the package's __version__.py module as a dictionary. +about = {} +if not VERSION: + project_slug = NAME.lower().replace("-", "_").replace(" ", "_") + with open(os.path.join(here, project_slug, '__version__.py')) as f: + exec(f.read(), about) +else: + about['__version__'] = VERSION + + +class UploadCommand(Command): + """Support setup.py upload.""" + + description = 'Build and publish the package.' + user_options = [] + + @staticmethod + def status(s): + """Prints things in bold.""" + print('\033[1m{0}\033[0m'.format(s)) + + def initialize_options(self): + pass + + def finalize_options(self): + pass + + def run(self): + try: + self.status('Removing previous builds…') + rmtree(os.path.join(here, 'dist')) + except OSError: + pass + + self.status('Building Source and Wheel (universal) distribution…') + os.system('{0} setup.py sdist bdist_wheel --universal'.format(sys.executable)) + + self.status('Uploading the package to PyPI via Twine…') + os.system('twine upload dist/*') + + self.status('Pushing git tags…') + os.system('git tag v{0}'.format(about['__version__'])) + os.system('git push --tags') + + sys.exit() + + +# Where the magic happens: +setup( + name=NAME, + version=about['__version__'], + description=DESCRIPTION, + long_description=long_description, + long_description_content_type='text/markdown', + author=AUTHOR, + author_email=EMAIL, + python_requires=REQUIRES_PYTHON, + url=URL, + packages=find_packages(exclude=["tests", "*.tests", "*.tests.*", "tests.*"]), + # If your package is a single module, use this instead of 'packages': + # py_modules=['mypackage'], + + # entry_points={ + # 'console_scripts': ['mycli=mymodule:cli'], + # }, + install_requires=REQUIRED, + extras_require=EXTRAS, + include_package_data=True, + license='MIT', + classifiers=[ + # Trove classifiers + # Full list: https://pypi.python.org/pypi?%3Aaction=list_classifiers + 'License :: OSI Approved :: MIT License', + 'Programming Language :: Python', + 'Programming Language :: Python :: 3', + 'Programming Language :: Python :: 3.8', + 'Programming Language :: Python :: Implementation :: CPython', + 'Programming Language :: Python :: Implementation :: PyPy' + ], + # $ setup.py publish support. + cmdclass={ + 'upload': UploadCommand, + }, +) \ No newline at end of file diff --git a/wxhook/__init__.py b/wxhook/__init__.py new file mode 100644 index 0000000..284c3dd --- /dev/null +++ b/wxhook/__init__.py @@ -0,0 +1,3 @@ +from .core import Bot + +version = "0.0.1" diff --git a/wxhook/core.py b/wxhook/core.py new file mode 100644 index 0000000..f1e393c --- /dev/null +++ b/wxhook/core.py @@ -0,0 +1,461 @@ +import os +import json +import typing + +import psutil +import pyee +import traceback +import socketserver + +import requests + +from .events import ALL_MESSAGE, SYSTEM_MESSAGE +from .logger import logger +from .model import RawData, Event, Account, Contact, ContactDetail, Room, RoomMembers, Table, DB, Response +from .utils import WeChatManager, start_wechat_with_inject, fake_wechat_version, parse_event + + +class RequestHandler(socketserver.BaseRequestHandler): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + def handle(self): + try: + data = b"" + while True: + chunk = self.request.recv(1024) + data += chunk + if len(chunk) == 0 or chunk[-1] == 0xA: + break + + bot = getattr(self.server, "bot") + bot.on_event(data) + self.request.sendall("200 OK".encode()) + except Exception: + logger.error(traceback.format_exc()) + finally: + self.request.close() + + +class Bot: + + def __init__( + self, + on_login: typing.Callable = None, + on_before_message: typing.Callable = None, + on_after_message: typing.Callable = None, + on_start: typing.Callable = None, + on_stop: typing.Callable = None, + faked_version: typing.Union[str, None] = None + ): + self.version = "3.9.5.81" + self.server_host = "127.0.0.1" + self.remote_host = "127.0.0.1" + self.on_start = on_start + self.on_login = on_login + self.on_before_message = on_before_message + self.on_after_message = on_after_message + self.on_stop = on_stop + self.faked_version = faked_version + self.event_emitter = pyee.EventEmitter() + self.wechat_manager = WeChatManager() + self.remote_port, self.server_port = self.wechat_manager.get_port() + self.BASE_URL = f"http://{self.remote_host}:{self.remote_port}" + self.webhook_url = None + self.info = None + self.DATA_SAVE_PATH = None + self.FILE_SAVE_PATH = None + self.IMAGE_SAVE_PATH = None + self.VIDEO_SAVE_PATH = None + + code, output = start_wechat_with_inject(self.remote_port) + if code == 1: + raise Exception(output) + + self.process = psutil.Process(int(output)) + + if self.faked_version is not None: + if fake_wechat_version(self.process.pid, self.version, faked_version) == 0: + logger.info(f"wechat version faked: {self.version} -> {faked_version}") + else: + logger.info(f"wechat version fake failed.") + + self.wechat_manager.add(self.process.pid, self.remote_port, self.server_port) + + self.call_hook_func(self.on_start, self) + self.handle(SYSTEM_MESSAGE, once=True)(self.init_bot) + self.hook_sync_msg(self.server_host, self.server_port) + + @staticmethod + def call_hook_func(func: typing.Callable, *args, **kwargs) -> typing.Any: + if callable(func): + return func(*args, **kwargs) + + def init_bot(self, bot: "Bot", event: Event) -> None: + if event.content["sysmsg"]["@type"] == "SafeModuleCfg": + bot.info = bot.get_self_info() + self.DATA_SAVE_PATH = bot.info.dataSavePath + self.FILE_SAVE_PATH = os.path.join(self.DATA_SAVE_PATH, "wxhelper/file") + self.IMAGE_SAVE_PATH = os.path.join(self.DATA_SAVE_PATH, "wxhelper/image") + self.VIDEO_SAVE_PATH = os.path.join(self.DATA_SAVE_PATH, "wxhelper/video") + logger.info(bot.info) + self.call_hook_func(self.on_login, bot) + + def set_webhook_url(self, webhook_url: str) -> None: + self.webhook_url = webhook_url + + def webhook(self, event: dict) -> None: + if self.webhook_url is not None: + try: + requests.post(self.webhook_url, json=event) + except Exception: + pass + + def call_api(self, api: str, *args, **kwargs) -> dict: + return requests.request("POST", self.BASE_URL + api, *args, **kwargs).json() + + def hook_sync_msg( + self, + ip: str, + port: int, + enable_http: int = 0, + url: str = "http://127.0.0.1:8000", + timeout: int = 30 + ) -> Response: + """hook同步消息""" + data = { + "port": port, + "ip": ip, + "enableHttp": enable_http, + "url": url, + "timeout": timeout + } + return Response(**self.call_api("/api/hookSyncMsg", json=data)) + + def unhook_sync_msg(self) -> Response: + """取消hook同步消息""" + return Response(**self.call_api("/api/unhookSyncMsg")) + + def hook_log(self) -> Response: + """hook日志""" + return Response(**self.call_api("/api/hookLog")) + + def unhook_log(self) -> Response: + """取消hook日志""" + return Response(**self.call_api("/api/unhookLog")) + + def check_login(self) -> Response: + """检查登录状态""" + return Response(**self.call_api("/api/checkLogin")) + + def get_self_info(self) -> Account: + """获取用户信息""" + return Account(**self.call_api("/api/userInfo")["data"]) + + def send_text(self, wxid: str, msg: str) -> Response: + """发送文本消息""" + data = { + "wxid": wxid, + "msg": msg + } + return Response(**self.call_api("/api/sendTextMsg", json=data)) + + def send_image(self, wxid: str, image_path: str) -> Response: + """发送图片消息""" + data = { + "wxid": wxid, + "imagePath": image_path + } + return Response(**self.call_api("/api/sendImagesMsg", json=data)) + + def send_emotion(self, wxid: str, file_path: str) -> Response: + """发送表情消息""" + data = { + "wxid": wxid, + "filePath": file_path + } + return Response(**self.call_api("/api/sendCustomEmotion", json=data)) + + def send_file(self, wxid: str, file_path: str) -> Response: + """发送文件消息""" + data = { + "wxid": wxid, + "filePath": file_path + } + return Response(**self.call_api("/api/sendFileMsg", json=data)) + + def send_applet( + self, + wxid: str, + waid_contact: str, + waid: str, + applet_wxid: str, + json_param: str, + head_img_url: str, + main_img: str, + index_page: str + ) -> Response: + """发送小程序消息""" + data = { + "wxid": wxid, + "waidConcat": waid_contact, + "waid": waid, + "appletWxid": applet_wxid, + "jsonParam": json_param, + "headImgUrl": head_img_url, + "mainImg": main_img, + "indexPage": index_page + } + return Response(**self.call_api("/api/sendApplet", json=data)) + + def send_room_at(self, room_id: str, wxids: list[str], msg: str) -> Response: + """发送群@消息""" + data = { + "chatRoomId": room_id, + "wxids": ",".join(wxids), + "msg": msg + } + return Response(**self.call_api("/api/sendAtText", json=data)) + + def send_pat(self, room_id: str, wxid: str) -> Response: + """发送拍一拍消息""" + data = { + "receiver": room_id, + "wxid": wxid + } + return Response(**self.call_api("/api/sendPatMsg", json=data)) + + def get_contacts(self) -> list[Contact]: + """获取联系人列表""" + return [Contact(**item) for item in self.call_api("/api/getContactList")["data"]] + + def get_contact(self, wxid: str) -> ContactDetail: + """获取联系人详情""" + data = { + "wxid": wxid + } + return ContactDetail(self.call_api("/api/getContactProfile", json=data)["data"]) + + def create_room(self, member_ids: list[str]) -> Response: + """创建群聊""" + data = { + "memberIds": ",".join(member_ids) + } + return Response(**self.call_api("/api/createChatRoom", json=data)) + + def quit_room(self, room_id: str) -> Response: + """退出群聊""" + data = { + "chatRoomId": room_id + } + return Response(**self.call_api("/api/quitChatRoom", json=data)) + + def get_room(self, room_id: str) -> Room: + """获取群详情""" + data = { + "chatRoomId": room_id + } + return Room(**self.call_api("/api/getChatRoomDetailInfo", json=data)["data"]) + + def get_room_members(self, room_id: str) -> RoomMembers: + """获取群成员列表""" + data = { + "chatRoomId": room_id + } + return RoomMembers(**self.call_api("/api/getMemberFromChatRoom", json=data)["data"]) + + def add_room_member(self, room_id: str, member_ids: list[str]) -> Response: + """添加群成员""" + data = { + "chatRoomId": room_id, + "memberIds": ",".join(member_ids) + } + return Response(**self.call_api("/api/addMemberToChatRoom", json=data)) + + def delete_room_member(self, room_id: str, member_ids: list[str]) -> Response: + """删除群成员""" + data = { + "chatRoomId": room_id, + "memberIds": ",".join(member_ids) + } + return Response(**self.call_api("/api/delMemberFromChatRoom", json=data)) + + def invite_room_member(self, room_id: str, member_ids: list[str]) -> Response: + """邀请群成员""" + data = { + "chatRoomId": room_id, + "memberIds": ",".join(member_ids) + } + return Response(**self.call_api("/api/InviteMemberToChatRoom", json=data)) + + def modify_member_nickname(self, room_id: str, wxid: str, nickname: str) -> Response: + """修改群成员昵称""" + data = { + "chatRoomId": room_id, + "wxid": wxid, + "nickName": nickname + } + return Response(**self.call_api("/api/modifyNickname", json=data)) + + def top_msg(self, msg_id: int) -> Response: + """设置群置顶消息""" + data = { + "msgId": msg_id + } + return Response(**self.call_api("/api/topMsg", json=data)) + + def remove_top_msg(self, room_id: str, msg_id: int) -> Response: + """移除群置顶消息""" + data = { + "chatRoomId": room_id, + "msgId": msg_id + } + return Response(**self.call_api("/api/removeTopMsg", json=data)) + + def forward_msg(self, msg_id: int, wxid: str) -> Response: + """转发消息""" + data = { + "msgId": msg_id, + "wxid": wxid + } + return Response(**self.call_api("/api/forwardMsg", json=data)) + + def get_sns_first_page(self) -> Response: + """获取朋友圈首页""" + return Response(**self.call_api("/api/getSNSFirstPage")) + + def get_sns_next_page(self, sns_id: int) -> Response: + """获取朋友圈下一页""" + data = { + "snsId": sns_id + } + return Response(**self.call_api("/api/getSNSNextPage", json=data)) + + def collect_msg(self, msg_id: int) -> Response: + """收藏消息""" + data = { + "msgId": msg_id + } + return Response(**self.call_api("/api/addFavFromMsg", json=data)) + + def collect_image(self, wxid: str, image_path: str) -> Response: + """收藏图片""" + data = { + "wxid": wxid, + "imagePath": image_path + } + return Response(**self.call_api("/api/addFavFromImage", json=data)) + + def download_attachment(self, msg_id: int) -> Response: + """下载附件""" + data = { + "msgId": msg_id + } + return Response(**self.call_api("/api/downloadAttach", json=data)) + + def forward_public_msg( + self, + wxid: str, + app_name: str, + username: str, + title: str, + url: str, + thumb_url: str, + digest: str + ) -> Response: + """转发公众号消息""" + data = { + "wxid": wxid, + "appName": app_name, + "userName": username, + "title": title, + "url": url, + "thumbUrl": thumb_url, + "digest": digest, + } + return Response(**self.call_api("/api/forwardPublicMsg", json=data)) + + def forward_public_msg_by_msg_id(self, wxid: str, msg_id: int) -> Response: + """转发公众号消息通过消息ID""" + data = { + "wxid": wxid, + "msg_id": msg_id + } + return Response(**self.call_api("/api/forwardPublicMsgByMsgId", json=data)) + + def decode_image(self, file_path: str, store_dir: str) -> Response: + """解码图片""" + data = { + "filePath": file_path, + "storeDir": store_dir + } + return Response(**self.call_api("/api/decodeImage", json=data)) + + def get_voice_by_msg_id(self, msg_id: int, store_dir: str) -> Response: + """获取语音通过消息ID""" + data = { + "msgId": msg_id, + "storeDir": store_dir + } + return Response(**self.call_api("/api/getVoiceByMsgId", json=data)) + + def ocr(self, image_path: str) -> Response: + """图片文本识别""" + data = { + "imagePath": image_path + } + return Response(**self.call_api("/api/ocr", json=data)) + + def get_db_info(self) -> list[DB]: + """获取数据库句柄""" + return [DB(databaseName=item["databaseName"], handle=item["handle"], + tables=[Table(**subitem) for subitem in item["tables"]]) for item in self.call_api("/api/getDBInfo")] + + def exec_sql(self, db_handle: int, sql: str) -> Response: + """执行SQL命令""" + data = { + "dbHandle": db_handle, + "sql": sql + } + return Response(**self.call_api("/api/execSql", json=data)) + + def test(self) -> Response: + """测试""" + return Response(**self.call_api("/api/test")) + + def on_event(self, raw_data: bytes): + try: + data = json.loads(raw_data) + event = Event(**parse_event(data), rawData=RawData(raw_data)) + logger.debug(event) + self.call_hook_func(self.on_before_message, self, event) + self.event_emitter.emit(str(ALL_MESSAGE), self, event) + self.event_emitter.emit(str(event.type), self, event) + self.call_hook_func(self.on_after_message, self, event) + self.webhook(data) + except Exception: + logger.error(traceback.format_exc()) + + def handle(self, events: typing.Union[list[str], str, None] = None, once: bool = False): + def wrapper(func): + listen = self.event_emitter.on if not once else self.event_emitter.once + if not events: + listen(str(ALL_MESSAGE), func) + else: + for event in events if isinstance(events, list) else [events]: + listen(str(event), func) + + return wrapper + + def exit(self): + self.call_hook_func(self.on_stop, self) + self.process.terminate() + + def run(self): + try: + server = socketserver.ThreadingTCPServer((self.server_host, self.server_port), RequestHandler) + server.bot = self + logger.info(f"{self.server_host}:{self.server_port}") + server.serve_forever() + except (KeyboardInterrupt, SystemExit): + self.exit() diff --git a/wxhook/events.py b/wxhook/events.py new file mode 100644 index 0000000..da40128 --- /dev/null +++ b/wxhook/events.py @@ -0,0 +1,28 @@ +# 通知消息事件 +NOTICE_MESSAGE = 10000 +# 系统消息事件 +SYSTEM_MESSAGE = 10002 +# 全部消息事件 +ALL_MESSAGE = 99999 +# 文本消息事件 +TEXT_MESSAGE = 1 +# 图片消息事件 +IMAGE_MESSAGE = 3 +# 语音消息事件 +VOICE_MESSAGE = 34 +# 好有验证请求消息事件 +FRIEND_VERIFY_MESSAGE = 37 +# 卡片消息事件 +CARD_MESSAGE = 42 +# 视频消息事件 +VIDEO_MESSAGE = 43 +# 表情消息事件 +EMOJI_MESSAGE = 47 +# 位置消息事件 +LOCATION_MESSAGE = 48 +# xml消息事件 +XML_MESSAGE = 49 +# 视频/语音通话消息事件 +VOIP_MESSAGE = 50 +# 手机端同步消息事件 +PHONE_MESSAGE = 51 \ No newline at end of file diff --git a/wxhook/logger.py b/wxhook/logger.py new file mode 100644 index 0000000..db51493 --- /dev/null +++ b/wxhook/logger.py @@ -0,0 +1,11 @@ +import os +import sys + +from loguru import logger + +logger.remove() +logger.add( + sink=sys.stdout, + format="{time:YYYY-MM-DD HH:mm:ss} | {level} | {message}", + level=os.environ.get("WXHOOK_LOG_LEVEL", "DEBUG") +) diff --git a/wxhook/model.py b/wxhook/model.py new file mode 100644 index 0000000..6aa1551 --- /dev/null +++ b/wxhook/model.py @@ -0,0 +1,117 @@ +import typing +from dataclasses import dataclass +from typing import List + + +@dataclass +class Account: + """用户""" + account: str # 账号名 + city: str # 所在城市 + country: str # 所在国家代码 + currentDataPath: str # 当前数据路径,通常指向用户的WeChat文件夹 + dataSavePath: str # 数据保存路径,通常指向用户的WeChat文件夹 + dbKey: str # 数据库密钥,用于加密本地数据库 + headImage: str # 头像图片URL + mobile: str # 手机号码 + name: str # 昵称 + province: str # 所在省份 + signature: str # 用户个性签名 + wxid: str # 微信ID + + +@dataclass +class Contact: + """联系人""" + customAccount: str # 用户自定义的账号 + encryptName: str # 加密名称,如果有的话 + nickname: str # 用户的昵称 + pinyin: str # 用户昵称的拼音首字母 + pinyinAll: str # 用户昵称的完整拼音 + reserved1: int # 预留字段1,具体用途未知 + reserved2: int # 预留字段2,具体用途未知 + type: int # 联系人类型 + verifyFlag: int # 验证标志,用于表示用户的验证状态 + wxid: str # 用户的微信ID + + +@dataclass +class ContactDetail: + """联系人详情""" + account: str # 用户账号,如果未设置则为空字符串 + headImage: str # 用户的头像图片URL,如果未设置则为空字符串 + nickname: str # 用户昵称 + v3: str # 用户的V3信息,通常用于加密或验证,可能包含特定的加密字符串 + wxid: str # 用户的微信ID + + +@dataclass +class Room: + """群聊""" + admin: str # 管理员的用户ID,如果没有管理员则为空字符串 + chatRoomId: str # 聊天室ID,如果没有指定聊天室则为空字符串 + notice: str # 聊天室公告内容,如果没有设置公告则为空字符串 + xml: str # 聊天室相关的XML信息,通常包含聊天室的详细配置信息,如果没有则为空字符串 + + +@dataclass +class RoomMembers: + """群成员""" + admin: str # 聊天室管理员的微信ID + adminNickname: str # 聊天室管理员的昵称 + chatRoomId: str # 聊天室的ID + memberNickname: str # 正在提及的成员昵称,可能包含特殊字符作为昵称的一部分 + members: str # 聊天室成员的微信ID列表,各ID之间使用特定字符分隔 + + +@dataclass +class RawData: + """原始数据""" + data: bytes + + def __repr__(self): + return "" + + __str__ = __repr__ + + +@dataclass +class Event: + """消息事件""" + content: typing.Any # 消息内容,可能包含用户ID和冒号之后的文本内容 + createTime: int # 消息创建时间的UNIX时间戳 + displayFullContent: str # 完整的消息内容,如果有的话 + fromUser: str # 发送消息的用户或群组ID + msgId: int # 消息的唯一标识符 + msgSequence: int # 消息序列号 + pid: int # 消息的PID + signature: str # 消息签名,包含一系列的配置信息 + toUser: str # 消息接收者的用户ID + type: int # 消息类型 + rawData: RawData # 原始数据 + base64Img: typing.Union[str, None] = None # 图片base64 + + +@dataclass +class Table: + """表结构""" + name: str # 任务名称 + rootpage: str # 根页面 + sql: str # SQL 创建表的语句 + tableName: str # 表名称 + + +@dataclass +class DB: + """数据库""" + databaseName: str # 数据库名称 + handle: int # 句柄 + tables: List[Table] # 表列表 + + +@dataclass +class Response: + """响应""" + code: int # 状态码,例如 200 + data: dict # 用户数据,当前为空对象 + msg: str # 响应消息,例如 "success" diff --git a/wxhook/tools/faker.exe b/wxhook/tools/faker.exe new file mode 100644 index 0000000..0fdb146 Binary files /dev/null and b/wxhook/tools/faker.exe differ diff --git a/wxhook/tools/start-wechat.exe b/wxhook/tools/start-wechat.exe new file mode 100644 index 0000000..20b9762 Binary files /dev/null and b/wxhook/tools/start-wechat.exe differ diff --git a/wxhook/tools/wxhook.dll b/wxhook/tools/wxhook.dll new file mode 100644 index 0000000..05a35b7 Binary files /dev/null and b/wxhook/tools/wxhook.dll differ diff --git a/wxhook/utils.py b/wxhook/utils.py new file mode 100644 index 0000000..600407e --- /dev/null +++ b/wxhook/utils.py @@ -0,0 +1,113 @@ +import os +import json +import pathlib +import subprocess + +import psutil +import xmltodict + +BASE_DIR = pathlib.Path(__file__).resolve().parent +TOOLS = BASE_DIR / "tools" +DLL = TOOLS / "wxhook.dll" +START_WECHAT = TOOLS / "start-wechat.exe" +FAKER = TOOLS / "faker.exe" + + +def start_wechat_with_inject(port: int): + result = subprocess.run(f"{START_WECHAT} {DLL} {port}", capture_output=True, text=True) + code, output = result.stdout.split(",") + return int(code), output + + +def fake_wechat_version(pid: int, old_version: str, new_version: str): + result = subprocess.run(f"{FAKER} {pid} {old_version} {new_version}", capture_output=True, text=True) + return int(result.stdout) + + +def get_processes(process_name): + processes = [] + for process in psutil.process_iter(): + if process.name().lower() == process_name.lower(): + processes.append(process) + return processes + + +def parse_xml(xml): + return xmltodict.parse(xml) + + +def parse_event(event, fields=None): + for field in fields or ["content", "signature"]: + try: + if field in event: + event[field] = parse_xml(event[field]) + except Exception: + pass + return event + + +class WeChatManager: + + def __init__(self): + # remote port: 19001 ~ 37999 + # socket port: 18999 ~ 1 + # http port: 38999 ~ 57997 + self.filename = BASE_DIR / "tools" / "wxhook.json" + if not os.path.exists(self.filename): + self.init_file() + else: + self.clean() + + def init_file(self): + with open(self.filename, "w", encoding="utf-8") as file: + json.dump({ + "increase_remote_port": 19000, + "wechat": [] + }, file) + + def read(self): + with open(self.filename, "r", encoding="utf-8") as file: + data = json.load(file) + return data + + def write(self, data): + with open(self.filename, "w", encoding="utf-8") as file: + json.dump(data, file) + + def refresh(self, pid_list): + data = self.read() + cleaned_data = [] + remote_port_list = [19000] + for item in data["wechat"]: + if item["pid"] in pid_list: + remote_port_list.append(item["remote_port"]) + cleaned_data.append(item) + + data["increase_remote_port"] = max(remote_port_list) + data["wechat"] = cleaned_data + self.write(data) + + def clean(self): + pid_list = [process.pid for process in get_processes("WeChat.exe")] + self.refresh(pid_list) + + def get_remote_port(self): + data = self.read() + return data["increase_remote_port"] + 1 + + def get_listen_port(self, remote_port): + return 19000 - (remote_port - 19000) + + def get_port(self): + remote_port = self.get_remote_port() + return remote_port, self.get_listen_port(remote_port) + + def add(self, pid, remote_port, server_port): + data = self.read() + data["increase_remote_port"] = remote_port + data["wechat"].append({ + "pid": pid, + "remote_port": remote_port, + "server_port": server_port + }) + self.write(data)