From 20a62fcf69f16db1944d7c5989e86ffdbd4adba3 Mon Sep 17 00:00:00 2001 From: fdc310 <82008029+fdc310@users.noreply.github.com> Date: Wed, 14 May 2025 21:18:08 +0800 Subject: [PATCH] feat: add wechatpad for personal wechat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 更新了wechatpad接口,以及适配器 * 更新了wechatpad接口,以及适配器 * 修复一些细节问题,比如at回复,以及启动登录和启动ws长连接的线程同步 * importutil中修复了在wi上启动替换斜杠问题,login中加上了一个login,暂时没啥用。wechatpad中做出了一些细节修改 * 更新了wechatpad接口,以及适配器 * 怎加了处理图片链接转换为image_base64发送 * feat(wechatpad): 调整日志+bugfix * feat(wechatpad): fix typo * 修正了发送语音api参数错误,添加了发送链接处理为base64数据(好像只有一部分链接可以) * 修复了部分手抽的typo错误 * chore: remove manager.py --------- Co-authored-by: shinelin Co-authored-by: Junyan Qin (Chin) --- libs/wechatpad_api/LICENSE | 201 +++++ libs/wechatpad_api/README.md | 38 + libs/wechatpad_api/__init__.py | 1 + libs/wechatpad_api/api/__init__.py | 0 libs/wechatpad_api/api/chatroom.py | 14 + libs/wechatpad_api/api/downloadpai.py | 39 + libs/wechatpad_api/api/friend.py | 11 + libs/wechatpad_api/api/login.py | 102 +++ libs/wechatpad_api/api/message.py | 123 ++++ libs/wechatpad_api/api/user.py | 37 + libs/wechatpad_api/client.py | 97 +++ libs/wechatpad_api/util/__init__.py | 0 libs/wechatpad_api/util/http_util.py | 92 +++ libs/wechatpad_api/util/terminal_printer.py | 31 + pkg/platform/sources/wechatpad.py | 773 ++++++++++++++++++++ pkg/platform/sources/wechatpad.yaml | 51 ++ pkg/utils/importutil.py | 2 +- 17 files changed, 1611 insertions(+), 1 deletion(-) create mode 100644 libs/wechatpad_api/LICENSE create mode 100644 libs/wechatpad_api/README.md create mode 100644 libs/wechatpad_api/__init__.py create mode 100644 libs/wechatpad_api/api/__init__.py create mode 100644 libs/wechatpad_api/api/chatroom.py create mode 100644 libs/wechatpad_api/api/downloadpai.py create mode 100644 libs/wechatpad_api/api/friend.py create mode 100644 libs/wechatpad_api/api/login.py create mode 100644 libs/wechatpad_api/api/message.py create mode 100644 libs/wechatpad_api/api/user.py create mode 100644 libs/wechatpad_api/client.py create mode 100644 libs/wechatpad_api/util/__init__.py create mode 100644 libs/wechatpad_api/util/http_util.py create mode 100644 libs/wechatpad_api/util/terminal_printer.py create mode 100644 pkg/platform/sources/wechatpad.py create mode 100644 pkg/platform/sources/wechatpad.yaml diff --git a/libs/wechatpad_api/LICENSE b/libs/wechatpad_api/LICENSE new file mode 100644 index 00000000..261eeb9e --- /dev/null +++ b/libs/wechatpad_api/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/libs/wechatpad_api/README.md b/libs/wechatpad_api/README.md new file mode 100644 index 00000000..5720c2c9 --- /dev/null +++ b/libs/wechatpad_api/README.md @@ -0,0 +1,38 @@ +# wechatpad-python + + +## 此项目时准备对接wechatpadpro 的pythonsdk + +## 未完工接口 + +* 关于好友的接口 +* 关于群管理的接口 +* 关于下载的接口 +* 关于用户的部分接口 +* 关于消息的部分接口 +* 关于支付的 +* 关于朋友圈的 +* 关于标签的 +* 关于收藏的 + +* 暂时只写了一部分接口 + + +## 已完工接口 + +1. 获取普通token +2. 登录二维码(只是返回数据,暂时还未打印二维码) +3. 获取登录状态 +4. 唤醒登录 +5. 退出登录 +6. 获取用户信息 +7. 获取用户二维码 +8. 上传用户头像 +9. 获取设备信息 +10. 发送文本消息 +11. 发送图片消息 +12. 发送语音消息 +13. 发送app消息 +14. 发送emoji消息 +15. 发送名片消息 +16. 撤回消息 diff --git a/libs/wechatpad_api/__init__.py b/libs/wechatpad_api/__init__.py new file mode 100644 index 00000000..23c23fb2 --- /dev/null +++ b/libs/wechatpad_api/__init__.py @@ -0,0 +1 @@ +from .client import WeChatPadClient \ No newline at end of file diff --git a/libs/wechatpad_api/api/__init__.py b/libs/wechatpad_api/api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/libs/wechatpad_api/api/chatroom.py b/libs/wechatpad_api/api/chatroom.py new file mode 100644 index 00000000..a7af207c --- /dev/null +++ b/libs/wechatpad_api/api/chatroom.py @@ -0,0 +1,14 @@ +from libs.wechatpad_api.util.http_util import async_request, post_json + + +class ChatRoomApi: + def __init__(self, base_url, token): + self.base_url = base_url + self.token = token + + def get_chatroom_member_detail(self, chatroom_name): + params = { + "ChatRoomName": chatroom_name + } + url = self.base_url + '/group/GetChatroomMemberDetail' + return post_json(url, token=self.token, data=params) diff --git a/libs/wechatpad_api/api/downloadpai.py b/libs/wechatpad_api/api/downloadpai.py new file mode 100644 index 00000000..a82a5674 --- /dev/null +++ b/libs/wechatpad_api/api/downloadpai.py @@ -0,0 +1,39 @@ +from libs.wechatpad_api.util.http_util import async_request, post_json +import httpx +import base64 + +class DownloadApi: + def __init__(self, base_url, token): + self.base_url = base_url + self.token = token + + def send_download(self, aeskey, file_type, file_url): + json_data = { + "AesKey": aeskey, + "FileType": file_type, + "FileURL": file_url + } + url = self.base_url + "/message/SendCdnDownload" + return post_json(url, token=self.token, data=json_data) + + def get_msg_voice(self,buf_id, length, new_msgid): + json_data = { + "Bufid": buf_id, + "Length": length, + "NewMsgId": new_msgid, + "ToUserName": "" + } + url = self.base_url + "/message/GetMsgVoice" + return post_json(url, token=self.token, data=json_data) + + + async def download_url_to_base64(self, download_url): + async with httpx.AsyncClient() as client: + response = await client.get(download_url) + + if response.status_code == 200: + file_bytes = response.content + base64_str = base64.b64encode(file_bytes).decode('utf-8') # 返回字符串格式 + return base64_str + else: + raise Exception('获取文件失败') \ No newline at end of file diff --git a/libs/wechatpad_api/api/friend.py b/libs/wechatpad_api/api/friend.py new file mode 100644 index 00000000..00701a5d --- /dev/null +++ b/libs/wechatpad_api/api/friend.py @@ -0,0 +1,11 @@ +from libs.wechatpad_api.util.http_util import post_json,async_request +from typing import List, Dict, Any, Optional + + +class FriendApi: + """联系人API类,处理所有与联系人相关的操作""" + + def __init__(self, base_url: str, token: str): + self.base_url = base_url + self.token = token + diff --git a/libs/wechatpad_api/api/login.py b/libs/wechatpad_api/api/login.py new file mode 100644 index 00000000..142a3c85 --- /dev/null +++ b/libs/wechatpad_api/api/login.py @@ -0,0 +1,102 @@ +from libs.wechatpad_api.util.http_util import async_request,post_json,get_json + + +class LoginApi: + def __init__(self, base_url: str, token: str = None, admin_key: str = None): + ''' + + Args: + base_url: 原始路径 + token: token + admin_key: 管理员key + ''' + self.base_url = base_url + self.token = token + # self.admin_key = admin_key + + def get_token(self, admin_key, day: int=365): + # 获取普通token + url = f"{self.base_url}/admin/GenAuthKey1" + json_data = { + "Count": 1, + "Days": day + } + return post_json(base_url=url, token=admin_key, data=json_data) + + def get_login_qr(self, Proxy: str = ""): + ''' + + Args: + Proxy:异地使用时代理 + + Returns:json数据 + + ''' + """ + + { + "Code": 200, + "Data": { + "Key": "3141312", + "QrCodeUrl": "https://1231x/g6bMlv2dX8zwNbqE6-Zs", + "Txt": "建议返回data=之后内容自定义生成二维码", + "baseResp": { + "ret": 0, + "errMsg": {} + } + }, + "Text": "" +} + + """ + #获取登录二维码 + url = f"{self.base_url}/login/GetLoginQrCodeNew" + check = False + if Proxy != "": + check = True + json_data = { + "Check": check, + "Proxy": Proxy + } + return post_json(base_url=url, token=self.token, data=json_data) + + + def get_login_status(self): + # 获取登录状态 + url = f'{self.base_url}/login/GetLoginStatus' + return get_json(base_url=url, token=self.token) + + + + def logout(self): + # 退出登录 + url = f'{self.base_url}/login/LogOut' + return post_json(base_url=url, token=self.token) + + + + + def wake_up_login(self, Proxy: str = ""): + # 唤醒登录 + url = f'{self.base_url}/login/WakeUpLogin' + check = False + if Proxy != "": + check = True + json_data = { + "Check": check, + "Proxy": "" + } + + return post_json(base_url=url, token=self.token, data=json_data) + + + + def login(self,admin_key): + login_status = self.get_login_status() + if login_status["Code"] == 300 and login_status["Text"] == "你已退出微信": + print("token已经失效,重新获取") + token_data = self.get_token(admin_key) + self.token = token_data["Data"][0] + + + diff --git a/libs/wechatpad_api/api/message.py b/libs/wechatpad_api/api/message.py new file mode 100644 index 00000000..2089ce96 --- /dev/null +++ b/libs/wechatpad_api/api/message.py @@ -0,0 +1,123 @@ + +from libs.wechatpad_api.util.http_util import async_request, post_json + + +class MessageApi: + def __init__(self, base_url, token): + self.base_url = base_url + self.token = token + + def post_text(self, to_wxid, content, ats: list= []): + ''' + + Args: + app_id: 微信id + to_wxid: 发送方的微信id + content: 内容 + ats: at + + Returns: + + ''' + url = self.base_url + "/message/SendTextMessage" + """发送文字消息""" + json_data = { + "MsgItem": [ + { + "AtWxIDList": ats, + "ImageContent": "", + "MsgType": 0, + "TextContent": content, + "ToUserName": to_wxid + } + ] + } + return post_json(base_url=url, token=self.token, data=json_data) + + + + + def post_image(self, to_wxid, img_url, ats: list= []): + """发送图片消息""" + # 这里好像可以尝试发送多个暂时未测试 + json_data = { + "MsgItem": [ + { + "AtWxIDList": ats, + "ImageContent": img_url, + "MsgType": 0, + "TextContent": '', + "ToUserName": to_wxid + } + ] + } + url = self.base_url + "/message/SendImageMessage" + return post_json(base_url=url, token=self.token, data=json_data) + + def post_voice(self, to_wxid, voice_data, voice_forma, voice_duration): + """发送语音消息""" + json_data = { + "ToUserName": to_wxid, + "VoiceData": voice_data, + "VoiceFormat": voice_forma, + "VoiceSecond": voice_duration + } + url = self.base_url + "/message/SendVoice" + return post_json(base_url=url, token=self.token, data=json_data) + + + + + + def post_name_card(self, alias, to_wxid, nick_name, name_card_wxid, flag): + """发送名片消息""" + param = { + "CardAlias": alias, + "CardFlag": flag, + "CardNickName": nick_name, + "CardWxId": name_card_wxid, + "ToUserName": to_wxid + } + url = f"{self.base_url}/message/ShareCardMessage" + return post_json(base_url=url, token=self.token, data=param) + + def post_emoji(self, to_wxid, emoji_md5, emoji_size:int=0): + """发送emoji消息""" + json_data = { + "EmojiList": [ + { + "EmojiMd5": emoji_md5, + "EmojiSize": emoji_size, + "ToUserName": to_wxid + } + ] + } + url = f"{self.base_url}/message/SendEmojiMessage" + return post_json(base_url=url, token=self.token, data=json_data) + + def post_app_msg(self, to_wxid,xml_data, contenttype:int=0): + """发送appmsg消息""" + json_data = { + "AppList": [ + { + "ContentType": contenttype, + "ContentXML": xml_data, + "ToUserName": to_wxid + } + ] + } + url = f"{self.base_url}/message/SendAppMessage" + return post_json(base_url=url, token=self.token, data=json_data) + + + + def revoke_msg(self, to_wxid, msg_id, new_msg_id, create_time): + """撤回消息""" + param = { + "ClientMsgId": msg_id, + "CreateTime": create_time, + "NewMsgId": new_msg_id, + "ToUserName": to_wxid + } + url = f"{self.base_url}/message/RevokeMsg" + return post_json(base_url=url, token=self.token, data=param) \ No newline at end of file diff --git a/libs/wechatpad_api/api/user.py b/libs/wechatpad_api/api/user.py new file mode 100644 index 00000000..2dc73bd2 --- /dev/null +++ b/libs/wechatpad_api/api/user.py @@ -0,0 +1,37 @@ +from libs.wechatpad_api.util.http_util import post_json, async_request, get_json + + +class UserApi: + def __init__(self, base_url, token): + self.base_url = base_url + self.token = token + + def get_profile(self): + """获取个人资料""" + url = f'{self.base_url}/user/GetProfile' + + return get_json(base_url=url, token=self.token) + + def get_qr_code(self, recover:bool=True, style:int=8): + """获取自己的二维码""" + param = { + "Recover": recover, + "Style": style + } + url = f'{self.base_url}/user/GetMyQRCode' + return post_json(base_url=url, token=self.token, data=param) + + def get_safety_info(self): + """获取设备记录""" + url = f'{self.base_url}/equipment/GetSafetyInfo' + return post_json(base_url=url, token=self.token) + + + + async def update_head_img(self, head_img_base64): + """修改头像""" + param = { + "Base64": head_img_base64 + } + url = f'{self.base_url}/user/UploadHeadImage' + return await async_request(base_url=url, token_key=self.token, json=param) \ No newline at end of file diff --git a/libs/wechatpad_api/client.py b/libs/wechatpad_api/client.py new file mode 100644 index 00000000..b3eed16b --- /dev/null +++ b/libs/wechatpad_api/client.py @@ -0,0 +1,97 @@ + +from libs.wechatpad_api.api.login import LoginApi +from libs.wechatpad_api.api.friend import FriendApi +from libs.wechatpad_api.api.message import MessageApi +from libs.wechatpad_api.api.user import UserApi +from libs.wechatpad_api.api.downloadpai import DownloadApi +from libs.wechatpad_api.api.chatroom import ChatRoomApi + + + + + +class WeChatPadClient: + def __init__(self,base_url, token): + self._login_api = LoginApi(base_url, token) + self._friend_api = FriendApi(base_url, token) + self._message_api = MessageApi(base_url, token) + self._user_api = UserApi(base_url, token) + self._download_api = DownloadApi(base_url, token) + self._chatroom_api = ChatRoomApi(base_url, token) + + def get_token(self,admin_key, day: int): + '''获取token''' + return self._login_api.get_token(admin_key, day) + + def get_login_qr(self, Proxy:str=""): + """登录二维码""" + return self._login_api.get_login_qr(Proxy=Proxy) + + def awaken_login(self, Proxy:str=""): + '''唤醒登录''' + return self._login_api.wake_up_login(Proxy=Proxy) + + def log_out(self): + """退出登录""" + return self._login_api.logout() + + def get_login_status(self): + """获取登录状态""" + return self._login_api.get_login_status() + + def send_text_message(self, to_wxid, message, ats: list=[]): + """发送文本消息""" + return self._message_api.post_text(to_wxid, message, ats) + + def send_image_message(self, to_wxid, img_url, ats: list=[]): + """发送图片消息""" + return self._message_api.post_image(to_wxid, img_url, ats) + + def send_voice_message(self, to_wxid, voice_data, voice_forma, voice_duration): + """发送音频消息""" + return self._message_api.post_voice(to_wxid, voice_data, voice_forma, voice_duration) + + def send_app_message(self, to_wxid, app_message, type): + """发送app消息""" + return self._message_api.post_app_msg(to_wxid, app_message, type) + + def send_emoji_message(self, to_wxid, emoji_md5, emoji_size): + """发送emoji消息""" + return self._message_api.post_emoji(to_wxid,emoji_md5,emoji_size) + + def revoke_msg(self, to_wxid, msg_id, new_msg_id, create_time): + """撤回消息""" + return self._message_api.revoke_msg(to_wxid, msg_id, new_msg_id, create_time) + + def get_profile(self): + """获取用户信息""" + return self._user_api.get_profile() + + def get_qr_code(self, recover:bool=True, style:int=8): + """获取用户二维码""" + return self._user_api.get_qr_code(recover=recover, style=style) + + def get_safety_info(self): + """获取设备信息""" + return self._user_api.get_safety_info() + + def update_head_img(self, head_img_base64): + """上传用户头像""" + return self._user_api.update_head_img(head_img_base64) + + def cdn_download(self, aeskey, file_type, file_url): + """cdn下载""" + return self._download_api.send_download( aeskey, file_type, file_url) + + def get_msg_voice(self,buf_id, length, msgid): + """下载语音""" + return self._download_api.get_msg_voice(buf_id, length, msgid) + + async def download_base64(self,url): + return await self._download_api.download_url_to_base64(download_url=url) + + def get_chatroom_member_detail(self, chatroom_name): + """查看群成员详情""" + return self._chatroom_api.get_chatroom_member_detail(chatroom_name) + + diff --git a/libs/wechatpad_api/util/__init__.py b/libs/wechatpad_api/util/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/libs/wechatpad_api/util/http_util.py b/libs/wechatpad_api/util/http_util.py new file mode 100644 index 00000000..754003e9 --- /dev/null +++ b/libs/wechatpad_api/util/http_util.py @@ -0,0 +1,92 @@ +import requests + +def post_json(base_url, token, data=None): + headers = { + 'Content-Type': 'application/json' + } + + + url = base_url + f'?key={token}' + + try: + response = requests.post(url, json=data, headers=headers, timeout=60) + response.raise_for_status() + result = response.json() + + if result: + return result + else: + raise RuntimeError(response.text) + except Exception as e: + print(f"http请求失败, url={url}, exception={e}") + raise RuntimeError(str(e)) + +def get_json(base_url, token): + headers = { + 'Content-Type': 'application/json' + } + + + url = base_url + f'?key={token}' + + try: + response = requests.get(url, headers=headers, timeout=60) + response.raise_for_status() + result = response.json() + + if result: + return result + else: + raise RuntimeError(response.text) + except Exception as e: + print(f"http请求失败, url={url}, exception={e}") + raise RuntimeError(str(e)) + +import aiohttp +import asyncio + + +async def async_request( + base_url: str, + token_key: str, + method: str = 'POST', + params: dict = None, + # headers: dict = None, + data: dict = None, + json: dict = None +): + """ + 通用异步请求函数 + + :param base_url: 请求URL + :param token_key: 请求token + :param method: HTTP方法 (GET, POST, PUT, DELETE等) + :param params: URL查询参数 + # :param headers: 请求头 + :param data: 表单数据 + :param json: JSON数据 + :return: 响应文本 + """ + headers = { + 'Content-Type': 'application/json' + } + url = f"{base_url}?key={token_key}" + async with aiohttp.ClientSession() as session: + async with session.request( + method=method, + url=url, + params=params, + headers=headers, + data=data, + json=json + ) as response: + response.raise_for_status() # 如果状态码不是200,抛出异常 + result = await response.json() + # print(result) + return result + # if result.get('Code') == 200: + # + # return await result + # else: + # raise RuntimeError("请求失败",response.text) + diff --git a/libs/wechatpad_api/util/terminal_printer.py b/libs/wechatpad_api/util/terminal_printer.py new file mode 100644 index 00000000..48af021e --- /dev/null +++ b/libs/wechatpad_api/util/terminal_printer.py @@ -0,0 +1,31 @@ +import qrcode + +def print_green(text): + print(f"\033[32m{text}\033[0m") + +def print_yellow(text): + print(f"\033[33m{text}\033[0m") + +def print_red(text): + print(f"\033[31m{text}\033[0m") + +def make_and_print_qr(url): + """生成并打印二维码 + + Args: + url: 需要生成二维码的URL字符串 + + Returns: + None + + 功能: + 1. 在终端打印二维码的ASCII图形 + 2. 同时提供在线二维码生成链接作为备选 + """ + print_green("请扫描下方二维码登录") + qr = qrcode.QRCode() + qr.add_data(url) + qr.make() + qr.print_ascii(invert=True) + print_green(f"也可以访问下方链接获取二维码:\nhttps://api.qrserver.com/v1/create-qr-code/?data={url}") + diff --git a/pkg/platform/sources/wechatpad.py b/pkg/platform/sources/wechatpad.py new file mode 100644 index 00000000..8b7e5a0e --- /dev/null +++ b/pkg/platform/sources/wechatpad.py @@ -0,0 +1,773 @@ +import requests +import websockets +import websocket +import json +import time +import httpx + +from libs.wechatpad_api.client import WeChatPadClient + +import typing +import asyncio +import traceback +import time +import re +import base64 +import uuid +import json +import os +import copy +import datetime +import threading + +import quart +import aiohttp + +from .. import adapter +from ...pipeline.longtext.strategies import forward +from ...core import app +from ..types import message as platform_message +from ..types import events as platform_events +from ..types import entities as platform_entities +from ...utils import image +import xml.etree.ElementTree as ET +from typing import Optional, List, Tuple +from functools import partial +import logging + +class WeChatPadMessageConverter(adapter.MessageConverter): + + def __init__(self, config: dict): + self.config = config + self.bot = WeChatPadClient(self.config["wechatpad_url"],self.config["token"]) + self.logger = logging.getLogger("WeChatPadMessageConverter") + + @staticmethod + async def yiri2target( + message_chain: platform_message.MessageChain + ) -> list[dict]: + content_list = [] + current_file_path = os.path.abspath(__file__) + + + + for component in message_chain: + if isinstance(component, platform_message.At): + content_list.append({"type": "at", "target": component.target}) + elif isinstance(component, platform_message.Plain): + content_list.append({"type": "text", "content": component.text}) + elif isinstance(component, platform_message.Image): + if component.url: + async with httpx.AsyncClient() as client: + response = await client.get(component.url) + + if response.status_code == 200: + file_bytes = response.content + base64_str = base64.b64encode(file_bytes).decode('utf-8') # 返回字符串格式 + else: + raise Exception('获取文件失败') + # pass + content_list.append({"type": "image", "image": base64_str}) + elif component.base64: + content_list.append({"type": "image", "image": component.base64}) + + elif isinstance(component, platform_message.WeChatEmoji): + content_list.append( + {'type': 'WeChatEmoji', 'emoji_md5': component.emoji_md5, 'emoji_size': component.emoji_size}) + elif isinstance(component, platform_message.Voice): + content_list.append({"type": "voice", "data": component.url, "duration": component.length, "forma": 0}) + elif isinstance(component, platform_message.WeChatAppMsg): + content_list.append({'type': 'WeChatAppMsg', 'app_msg': component.app_msg}) + elif isinstance(component, platform_message.Forward): + for node in component.node_list: + if node.message_chain: + content_list.extend(await WeChatPadMessageConverter.yiri2target(node.message_chain)) + + return content_list + + + async def target2yiri( + self, + message: dict, + bot_account_id: str + ) -> platform_message.MessageChain: + """外部消息转平台消息""" + # 数据预处理 + message_list = [] + ats_bot = False # 是否被@ + content = message["content"]["str"] + content_no_preifx = content # 群消息则去掉前缀 + is_group_message = self._is_group_message(message) + if is_group_message: + ats_bot = self._ats_bot(message, bot_account_id) + if "@所有人" in content: + message_list.append(platform_message.AtAll()) + elif ats_bot: + message_list.append(platform_message.At(target=bot_account_id)) + content_no_preifx, _ = self._extract_content_and_sender(content) + + msg_type = message["msg_type"] + + # 映射消息类型到处理器方法 + handler_map = { + 1: self._handler_text, + 3: self._handler_image, + 34: self._handler_voice, + 49: self._handler_compound, # 复合类型 + } + + # 分派处理 + handler = handler_map.get(msg_type, self._handler_default) + handler_result = await handler( + message=message, # 原始的message + content_no_preifx=content_no_preifx, # 处理后的content + ) + + if handler_result and len(handler_result) > 0: + message_list.extend(handler_result) + + return platform_message.MessageChain(message_list) + + async def _handler_text( + self, + message: Optional[dict], + content_no_preifx: str + ) -> platform_message.MessageChain: + """处理文本消息 (msg_type=1)""" + if message and self._is_group_message(message): + pattern = r'@\S{1,20}' + content_no_preifx = re.sub(pattern, '', content_no_preifx) + + return platform_message.MessageChain([platform_message.Plain(content_no_preifx)]) + + async def _handler_image( + self, + message: Optional[dict], + content_no_preifx: str + ) -> platform_message.MessageChain: + """处理图像消息 (msg_type=3)""" + try: + image_xml = content_no_preifx + if not image_xml: + return platform_message.MessageChain([platform_message.Unknown("[图片内容为空]")]) + root = ET.fromstring(image_xml) + + # 提取img标签的属性 + img_tag = root.find('img') + if img_tag is not None: + aeskey = img_tag.get('aeskey') + cdnthumburl = img_tag.get('cdnthumburl') + # cdnmidimgurl = img_tag.get('cdnmidimgurl') + + + image_data = self.bot.cdn_download(aeskey=aeskey, file_type=1, file_url=cdnthumburl) + if image_data["Data"]['FileData'] == '': + image_data = self.bot.cdn_download(aeskey=aeskey, file_type=2, file_url=cdnthumburl) + base64_str = image_data["Data"]['FileData'] + # self.logger.info(f"data:image/png;base64,{base64_str}") + + + elements = [ + platform_message.Image(base64=f"data:image/png;base64,{base64_str}"), + # platform_message.WeChatForwardImage(xml_data=image_xml) # 微信消息转发 + ] + return platform_message.MessageChain(elements) + except Exception as e: + self.logger.error(f"处理图片失败: {str(e)}") + return platform_message.MessageChain([platform_message.Unknown("[图片处理失败]")]) + + async def _handler_voice( + self, + message: Optional[dict], + content_no_preifx: str + ) -> platform_message.MessageChain: + """处理语音消息 (msg_type=34)""" + message_List = [] + try: + # 从消息中提取语音数据(需根据实际数据结构调整字段名) + # audio_base64 = message["img_buf"]["buffer"] + voice_xml = content_no_preifx + new_msg_id = message['new_msg_id'] + root = ET.fromstring(voice_xml) + + # 提取voicemsg标签的属性 + voicemsg = root.find('voicemsg') + if voicemsg is not None: + bufid = voicemsg.get('bufid') + length = voicemsg.get('voicelength') + voice_data = self.bot.get_msg_voice(buf_id=str(bufid), length=int(length), msgid=str(new_msg_id)) + audio_base64 = voice_data["Data"]['Base64'] + + # 验证语音数据有效性 + if not audio_base64: + message_List.append(platform_message.Unknown(text="[语音内容为空]")) + return platform_message.MessageChain(message_List) + + # 转换为平台支持的语音格式(如 Silk 格式) + voice_element = platform_message.Voice( + base64=f"data:audio/silk;base64,{audio_base64}" + ) + message_List.append(voice_element) + + except KeyError as e: + self.logger.error(f"语音数据字段缺失: {str(e)}") + message_List.append(platform_message.Unknown(text="[语音数据解析失败]")) + except Exception as e: + self.logger.error(f"处理语音消息异常: {str(e)}") + message_List.append(platform_message.Unknown(text="[语音处理失败]")) + + return platform_message.MessageChain(message_List) + + async def _handler_compound( + self, + message: Optional[dict], + content_no_preifx: str + ) -> platform_message.MessageChain: + """处理复合消息 (msg_type=49),根据子类型分派""" + try: + xml_data = ET.fromstring(content_no_preifx) + appmsg_data = xml_data.find('.//appmsg') + if appmsg_data: + data_type = appmsg_data.findtext('.//type', "") + # 二次分派处理器 + sub_handler_map = { + '57': self._handler_compound_quote, + '5': self._handler_compound_link, + '6': self._handler_compound_file, + '33': self._handler_compound_mini_program, + '36': self._handler_compound_mini_program, + '2000': partial(self._handler_compound_unsupported, text="[转账消息]"), + '2001': partial(self._handler_compound_unsupported, text="[红包消息]"), + '51': partial(self._handler_compound_unsupported, text="[视频号消息]"), + } + + handler = sub_handler_map.get(data_type, self._handler_compound_unsupported) + return await handler( + message=message, # 原始msg + xml_data=xml_data, # xml数据 + ) + else: + return platform_message.MessageChain([platform_message.Unknown(text=content_no_preifx)]) + except Exception as e: + self.logger.error(f"解析复合消息失败: {str(e)}") + return platform_message.MessageChain([platform_message.Unknown(text=content_no_preifx)]) + + async def _handler_compound_quote( + self, + message: Optional[dict], + xml_data: ET.Element + ) -> platform_message.MessageChain: + """处理引用消息 (data_type=57)""" + message_list = [] +# self.logger.info("_handler_compound_quote", ET.tostring(xml_data, encoding='unicode')) + appmsg_data = xml_data.find('.//appmsg') + quote_data = "" # 引用原文 + quote_id = None # 引用消息的原发送者 + tousername = None # 接收方: 所属微信的wxid + user_data = "" # 用户消息 + sender_id = xml_data.findtext('.//fromusername') # 发送方:单聊用户/群member + + # 引用消息转发 + if appmsg_data: + user_data = appmsg_data.findtext('.//title') or "" + quote_data = appmsg_data.find('.//refermsg').findtext('.//content') + quote_id = appmsg_data.find('.//refermsg').findtext('.//chatusr') + message_list.append( + platform_message.WeChatAppMsg( + app_msg=ET.tostring(appmsg_data, encoding='unicode')) + ) + if message: + tousername = message['to_user_name']["str"] + + if quote_data: + quote_data_message_list = platform_message.MessageChain() + # 文本消息 + try: + if "" not in quote_data: + quote_data_message_list.append(platform_message.Plain(quote_data)) + else: + # 引用消息展开 + quote_data_xml = ET.fromstring(quote_data) + if quote_data_xml.find("img"): + quote_data_message_list.extend(await self._handler_image(None, quote_data)) + elif quote_data_xml.find("voicemsg"): + quote_data_message_list.extend(await self._handler_voice(None, quote_data)) + elif quote_data_xml.find("videomsg"): + quote_data_message_list.extend(await self._handler_default(None, quote_data)) # 先不处理 + else: + # appmsg + quote_data_message_list.extend(await self._handler_compound(None, quote_data)) + except Exception as e: + self.logger.error(f"处理引用消息异常 expcetion:{e}") + quote_data_message_list.append(platform_message.Plain(quote_data)) + message_list.append( + platform_message.Quote( + sender_id=sender_id, + origin=quote_data_message_list, + ) + ) + if len(user_data) > 0: + pattern = r'@\S{1,20}' + user_data = re.sub(pattern, '', user_data) + message_list.append(platform_message.Plain(user_data)) + + return platform_message.MessageChain(message_list) + + async def _handler_compound_file( + self, + message: dict, + xml_data: ET.Element + ) -> platform_message.MessageChain: + """处理文件消息 (data_type=6)""" + xml_data_str = ET.tostring(xml_data, encoding='unicode') + return platform_message.MessageChain([ + platform_message.WeChatForwardFile(xml_data=xml_data_str) + ]) + + async def _handler_compound_link( + self, + message: dict, + xml_data: ET.Element + ) -> platform_message.MessageChain: + """处理链接消息(如公众号文章、外部网页)""" + message_list = [] + try: + # 解析 XML 中的链接参数 + appmsg = xml_data.find('.//appmsg') + if appmsg is None: + return platform_message.MessageChain() + message_list.append( + platform_message.WeChatLink( + link_title=appmsg.findtext('title', ''), + link_desc=appmsg.findtext('des', ''), + link_url=appmsg.findtext('url', ''), + link_thumb_url=appmsg.findtext("thumburl", '') # 这个字段拿不到 + ) + ) + # 还没有发链接的接口, 暂时还需要自己构造appmsg, 先用WeChatAppMsg。 + message_list.append( + platform_message.WeChatAppMsg( + app_msg=ET.tostring(appmsg, encoding='unicode') + ) + ) + except Exception as e: + self.logger.error(f"解析链接消息失败: {str(e)}") + return platform_message.MessageChain(message_list) + + async def _handler_compound_mini_program( + self, + message: dict, + xml_data: ET.Element + ) -> platform_message.MessageChain: + """处理小程序消息(如小程序卡片、服务通知)""" + xml_data_str = ET.tostring(xml_data, encoding='unicode') + return platform_message.MessageChain([ + platform_message.WeChatForwardMiniPrograms(xml_data=xml_data_str) + ]) + + async def _handler_default( + self, + message: Optional[dict], + content_no_preifx: str + ) -> platform_message.MessageChain: + """处理未知消息类型""" + if message: + msg_type = message["msg_type"] + else: + msg_type = "" + return platform_message.MessageChain([ + platform_message.Unknown(text=f"[未知消息类型 msg_type:{msg_type}]") + ]) + + def _handler_compound_unsupported( + self, + message: dict, + xml_data: str, + text: Optional[str] = None + ) -> platform_message.MessageChain: + """处理未支持复合消息类型(msg_type=49)子类型""" + if not text: + text = f"[xml_data={xml_data}]" + content_list = [] + content_list.append( + platform_message.Unknown(text=f"[处理未支持复合消息类型[msg_type=49]|{text}")) + + return platform_message.MessageChain(content_list) + + # 返回是否被艾特 + def _ats_bot(self, message: dict, bot_account_id: str) -> bool: + ats_bot = False + try: + to_user_name = message['to_user_name']['str'] # 接收方: 所属微信的wxid + raw_content = message["content"]["str"] # 原始消息内容 + content_no_prefix, _ = self._extract_content_and_sender(raw_content) + # 直接艾特机器人(这个有bug,当被引用的消息里面有@bot,会套娃 + # ats_bot = ats_bot or (f"@{bot_account_id}" in content_no_prefix) + # 文本类@bot + push_content = message.get('push_content', '') + ats_bot = ats_bot or ('在群聊中@了你' in push_content) + # 引用别人时@bot + msg_source = message.get('msg_source', '') or '' + if len(msg_source) > 0: + msg_source_data = ET.fromstring(msg_source) + at_user_list = msg_source_data.findtext("atuserlist") or "" + ats_bot = ats_bot or (to_user_name in at_user_list) + # 引用bot + if message.get('msg_type', 0) == 49: + xml_data = ET.fromstring(content_no_prefix) + appmsg_data = xml_data.find('.//appmsg') + tousername = message['to_user_name']['str'] + if appmsg_data: # 接收方: 所属微信的wxid + quote_id = appmsg_data.find('.//refermsg').findtext('.//chatusr') # 引用消息的原发送者 + ats_bot = ats_bot or (quote_id == tousername) + except Exception as e: + self.logger.error(f"_ats_bot got except: {e}") + finally: + return ats_bot + + # 提取一下content前面的sender_id, 和去掉前缀的内容 + def _extract_content_and_sender(self, raw_content: str) -> Tuple[str, Optional[str]]: + try: + # 检查消息开头,如果有 wxid_sbitaz0mt65n22:\n 则删掉 + # add: 有些用户的wxid不是上述格式。换成user_name: + regex = re.compile(r"^[a-zA-Z0-9_\-]{5,20}:") + line_split = raw_content.split("\n") + if len(line_split) > 0 and regex.match(line_split[0]): + raw_content = "\n".join(line_split[1:]) + sender_id = line_split[0].strip(":") + return raw_content, sender_id + except Exception as e: + self.logger.error(f"_extract_content_and_sender got except: {e}") + finally: + return raw_content, None + + # 是否是群消息 + def _is_group_message(self, message: dict) -> bool: + from_user_name = message['from_user_name']['str'] + return from_user_name.endswith("@chatroom") + + +class WeChatPadEventConverter(adapter.EventConverter): + + def __init__(self, config: dict): + self.config = config + self.message_converter = WeChatPadMessageConverter(config) + self.logger = logging.getLogger("WeChatPadEventConverter") + + @staticmethod + async def yiri2target( + event: platform_events.MessageEvent + ) -> dict: + pass + + async def target2yiri( + self, + event: dict, + bot_account_id: str + ) -> platform_events.MessageEvent: + + # 排除公众号以及微信团队消息 + if event['from_user_name']['str'].startswith('gh_') \ + or event['from_user_name']['str']=='weixin'\ + or event['from_user_name']['str'] == "newsapp"\ + or event['from_user_name']['str'] == self.config["wxid"]: + return None + message_chain = await self.message_converter.target2yiri(copy.deepcopy(event), bot_account_id) + + if not message_chain: + return None + + if '@chatroom' in event['from_user_name']['str']: + # 找出开头的 wxid_ 字符串,以:结尾 + sender_wxid = event['content']['str'].split(":")[0] + + return platform_events.GroupMessage( + sender=platform_entities.GroupMember( + id=sender_wxid, + member_name=event['from_user_name']['str'], + permission=platform_entities.Permission.Member, + group=platform_entities.Group( + id=event['from_user_name']['str'], + name=event['from_user_name']['str'], + permission=platform_entities.Permission.Member, + ), + special_title="", + join_timestamp=0, + last_speak_timestamp=0, + mute_time_remaining=0, + ), + message_chain=message_chain, + time=event["create_time"], + source_platform_object=event, + ) + else: + return platform_events.FriendMessage( + sender=platform_entities.Friend( + id=event['from_user_name']['str'], + nickname=event['from_user_name']['str'], + remark='', + ), + message_chain=message_chain, + time=event["create_time"], + source_platform_object=event, + ) + + +class WeChatPadAdapter(adapter.MessagePlatformAdapter): + name: str = "WeChatPad" # 定义适配器名称 + + bot: WeChatPadClient + quart_app: quart.Quart + + bot_account_id: str + + config: dict + + ap: app.Application + + message_converter: WeChatPadMessageConverter + event_converter: WeChatPadEventConverter + + listeners: typing.Dict[ + typing.Type[platform_events.Event], + typing.Callable[[platform_events.Event, adapter.MessagePlatformAdapter], None], + ] = {} + + def __init__(self, config: dict, ap: app.Application): + self.config = config + self.ap = ap + self.quart_app = quart.Quart(__name__) + + self.message_converter = WeChatPadMessageConverter(config) + self.event_converter = WeChatPadEventConverter(config) + + async def ws_message(self, data): + """处理接收到的消息""" + # self.ap.logger.debug(f"Gewechat callback event: {data}") + # print(data) + + + try: + event = await self.event_converter.target2yiri(data.copy(), self.bot_account_id) + except Exception as e: + traceback.print_exc() + + if event.__class__ in self.listeners: + await self.listeners[event.__class__](event, self) + + return 'ok' + + + async def _handle_message( + self, + message: platform_message.MessageChain, + target_id: str + ): + """统一消息处理核心逻辑""" + content_list = await self.message_converter.yiri2target(message) + # print(content_list) + at_targets = [item["target"] for item in content_list if item["type"] == "at"] + # print(at_targets) + # 处理@逻辑 + at_targets = at_targets or [] + member_info = [] + if at_targets: + member_info = self.bot.get_chatroom_member_detail( + target_id, + )["Data"]["member_data"]["chatroom_member_list"] + + # 处理消息组件 + for msg in content_list: + # 文本消息处理@ + if msg['type'] == 'text' and at_targets: + at_nick_name_list = [] + for member in member_info: + if member["user_name"] in at_targets: + at_nick_name_list.append(f'@{member["nick_name"]}') + msg['content'] = f'{" ".join(at_nick_name_list)} {msg["content"]}' + + # 统一消息派发 + handler_map = { + 'text': lambda msg: self.bot.send_text_message( + to_wxid=target_id, + message=msg['content'], + ats=at_targets + ), + 'image': lambda msg: self.bot.send_image_message( + to_wxid=target_id, + img_url=msg["image"], + ats = at_targets + ), + 'WeChatEmoji': lambda msg: self.bot.send_emoji_message( + to_wxid=target_id, + emoji_md5=msg['emoji_md5'], + emoji_size=msg['emoji_size'] + ), + + 'voice': lambda msg: self.bot.send_voice_message( + to_wxid=target_id, + voice_data=msg['data'], + voice_duration=msg["duration"], + voice_forma=msg["forma"], + ), + 'WeChatAppMsg': lambda msg: self.bot.send_app_message( + to_wxid=target_id, + app_message=msg['app_msg'], + type=0, + ), + 'at': lambda msg: None + } + + if handler := handler_map.get(msg['type']): + handler(msg) + # self.ap.logger.warning(f"未处理的消息类型: {ret}") + else: + self.ap.logger.warning(f"未处理的消息类型: {msg['type']}") + continue + + async def send_message( + self, + target_type: str, + target_id: str, + message: platform_message.MessageChain + ): + """主动发送消息""" + return await self._handle_message(message, target_id) + + async def reply_message( + self, + message_source: platform_events.MessageEvent, + message: platform_message.MessageChain, + quote_origin: bool = False + ): + """回复消息""" + if message_source.source_platform_object: + target_id = message_source.source_platform_object['from_user_name']['str'] + return await self._handle_message(message, target_id) + + async def is_muted(self, group_id: int) -> bool: + pass + + def register_listener( + self, + event_type: typing.Type[platform_events.Event], + callback: typing.Callable[[platform_events.Event, adapter.MessagePlatformAdapter], None] + ): + self.listeners[event_type] = callback + + def unregister_listener( + self, + event_type: typing.Type[platform_events.Event], + callback: typing.Callable[[platform_events.Event, adapter.MessagePlatformAdapter], None] + ): + pass + + async def run_async(self): + + if not self.config["admin_key"] and not self.config["token"]: + raise RuntimeError("无wechatpad管理密匙,请填入配置文件后重启") + else: + if self.config["token"]: + self.bot = WeChatPadClient( + self.config['wechatpad_url'], + self.config["token"] + ) + data = self.bot.get_login_status() + self.ap.logger.info(data) + if data["Code"] == 300 and data["Text"] == "你已退出微信": + response = requests.post( + f"{self.config['wechatpad_url']}/admin/GenAuthKey1?key={self.config['admin_key']}", + json={"Count": 1, "Days": 365} + ) + if response.status_code != 200: + raise Exception(f"获取token失败: {response.text}") + self.config["token"] = response.json()["Data"][0] + + elif not self.config["token"]: + response = requests.post( + f"{self.config['wechatpad_url']}/admin/GenAuthKey1?key={self.config['admin_key']}", + json={"Count": 1, "Days": 365} + ) + if response.status_code != 200: + raise Exception(f"获取token失败: {response.text}") + self.config["token"] = response.json()["Data"][0] + + self.bot = WeChatPadClient( + self.config['wechatpad_url'], + self.config["token"] + ) + self.ap.logger.info(self.config["token"]) + thread_1 = threading.Event() + + + def wechat_login_process(): + # 不登录,这些先注释掉,避免登陆态尝试拉qrcode。 + # login_data =self.bot.get_login_qr() + + # url = login_data['Data']["QrCodeUrl"] + # self.ap.logger.info(login_data) + + + profile =self.bot.get_profile() + self.ap.logger.info(profile) + + self.bot_account_id = profile["Data"]["userInfo"]["nickName"]["str"] + self.config["wxid"] = profile["Data"]["userInfo"]["userName"]["str"] + thread_1.set() + + + # asyncio.create_task(wechat_login_process) + threading.Thread(target=wechat_login_process).start() + + def connect_websocket_sync() -> None: + + thread_1.wait() + uri = f"{self.config['wechatpad_ws']}/GetSyncMsg?key={self.config['token']}" + self.ap.logger.info(f"Connecting to WebSocket: {uri}") + def on_message(ws, message): + try: + data = json.loads(message) + self.ap.logger.debug(f"Received message: {data}") + # 这里需要确保ws_message是同步的,或者使用asyncio.run调用异步方法 + asyncio.run(self.ws_message(data)) + except json.JSONDecodeError: + self.ap.logger.error(f"Non-JSON message: {message[:100]}...") + + def on_error(ws, error): + self.ap.logger.error(f"WebSocket error: {str(error)[:200]}") + + def on_close(ws, close_status_code, close_msg): + self.ap.logger.info("WebSocket closed, reconnecting...") + time.sleep(5) + connect_websocket_sync() # 自动重连 + + def on_open(ws): + self.ap.logger.info("WebSocket connected successfully!") + + ws = websocket.WebSocketApp( + uri, + on_message=on_message, + on_error=on_error, + on_close=on_close, + on_open=on_open + ) + ws.run_forever( + ping_interval=60, + ping_timeout=20 + ) + + # 直接调用同步版本(会阻塞) + # connect_websocket_sync() + + # 这行代码会在WebSocket连接断开后才会执行 + # self.ap.logger.info("WebSocket client thread started") + thread = threading.Thread( + target=connect_websocket_sync, + name="WebSocketClientThread", + daemon=True + ) + thread.start() + self.ap.logger.info("WebSocket client thread started") + + async def kill(self) -> bool: + pass diff --git a/pkg/platform/sources/wechatpad.yaml b/pkg/platform/sources/wechatpad.yaml new file mode 100644 index 00000000..df2970a2 --- /dev/null +++ b/pkg/platform/sources/wechatpad.yaml @@ -0,0 +1,51 @@ +apiVersion: v1 +kind: MessagePlatformAdapter +metadata: + name: wechatpad + label: + en_US: WeChatPad + zh_CN: WeChatPad(个人微信ipad) + description: + en_US: WeChatPad Adapter + zh_CN: WeChatPad 适配器 +spec: + config: + - name: wechatpad_url + label: + en_US: WeChatPad ERL + zh_CN: WeChatPad URL + type: string + required: true + default: "" + - name: wechatpad_ws + label: + en_US: WeChatPad_Ws + zh_CN: WeChatPad_Ws + type: string + required: true + default: "" + - name: admin_key + label: + en_US: Admin_Key + zh_CN: 管理员密匙 + type: string + required: true + default: "" + - name: token + label: + en_US: Token + zh_CN: 令牌 + type: string + required: true + default: "" + - name: wxid + label: + en_US: wxid + zh_CN: wxid + type: string + required: true + default: "" +execution: + python: + path: ./wechatpad.py + attr: WeChatPadAdapter diff --git a/pkg/utils/importutil.py b/pkg/utils/importutil.py index 1933d611..8acc5c45 100644 --- a/pkg/utils/importutil.py +++ b/pkg/utils/importutil.py @@ -32,7 +32,7 @@ def import_dir(path: str): rel_path = full_path.replace(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), '') rel_path = rel_path[1:] rel_path = rel_path.replace('/', '.')[:-3] - rel_path = rel_path.replace('\\', '.') + rel_path = rel_path.replace("\\",".") importlib.import_module(rel_path)