Update python client

This commit is contained in:
Changhua 2023-02-25 21:58:00 +08:00
parent 1905649bb1
commit 27a4a44c9f
5 changed files with 222 additions and 152 deletions

View File

@ -1,2 +1,2 @@
include wcferry/*.dll include wcferry/*.dll
exclude demo.py include wcferry/*.exe

View File

@ -1,6 +1,5 @@
# WeChatFerry Python 客户端 # WeChatFerry Python 客户端
⚠️ **只支持 Windows** ⚠️ ⚠️ **只支持 Windows** ⚠️
⚠️ **只支持 32 位 Python** ⚠️
## 快速开始 ## 快速开始
```sh ```sh
@ -12,48 +11,44 @@ pip install wcferry
#! /usr/bin/env python3 #! /usr/bin/env python3
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
import signal import logging
from time import sleep
from wcferry import Wcf from wcferry import Wcf
def main(): def main():
wcf = Wcf() LOG = logging.getLogger("Demo")
LOG.info("Start demo...")
wcf = Wcf(debug=True) # 默认连接本地服务
# wcf = Wcf("tcp://127.0.0.1:10086") # 连接远端服务
def handler(sig, frame): LOG.info(f"Is Login: {True if wcf.is_login() else False}")
wcf.cleanup() # 退出前清理环境 LOG.info(f"SelfWxid: {wcf.get_self_wxid()}")
exit(0)
signal.signal(signal.SIGINT, handler) wcf.enable_recv_msg(LOG.info)
sleep(1) # Slow down # wcf.disable_recv_msg() # Call anytime when you don't want to receive messages
print(f"Is Login: {True if wcf.is_login() else False}")
print(f"SelfWxid: {wcf.get_self_wxid()}")
sleep(1)
wcf.enable_recv_msg(print)
# wcf.disable_recv_msg() # 当需要停止接收消息的时候,随时调用
ret = wcf.send_text("Hello world.", "filehelper") ret = wcf.send_text("Hello world.", "filehelper")
print(f"send_text: {ret}") LOG.info(f"send_text: {ret}")
ret = wcf.send_image("TEQuant.jpeg", "filehelper") ret = wcf.send_image("TEQuant.jpeg", "filehelper")
print(f"send_image: {ret}") LOG.info(f"send_image: {ret}")
print(f"Message types:\n{wcf.get_msg_types()}") LOG.info(f"Message types:\n{wcf.get_msg_types()}")
print(f"Contacts:\n{wcf.get_contacts()}") LOG.info(f"Contacts:\n{wcf.get_contacts()}")
print(f"DBs:\n{wcf.get_dbs()}") LOG.info(f"DBs:\n{wcf.get_dbs()}")
print(f"Tables:\n{wcf.get_tables('db')}") LOG.info(f"Tables:\n{wcf.get_tables('db')}")
print(f"Results:\n{wcf.query_sql('MicroMsg.db', 'SELECT * FROM Contact LIMIT 1;')}") LOG.info(f"Results:\n{wcf.query_sql('MicroMsg.db', 'SELECT * FROM Contact LIMIT 1;')}")
# wcf.accept_new_friend("v3", "v4") # 需要真正的 V3、V4 信息 # wcf.accept_new_friend("v3", "v4") # 需要真正的 V3、V4 信息
# 阻塞程序,让程序一直运行 # Keep running to receive messages
wcf.keep_running() wcf.keep_running()
if __name__ == "__main__": if __name__ == "__main__":
logging.basicConfig(level='DEBUG', format="%(asctime)s %(message)s")
main() main()
``` ```
@ -68,13 +63,13 @@ source .env/Scripts/activate
# 升级 pip # 升级 pip
pip install --upgrade pip pip install --upgrade pip
# 安装依赖包 # 安装依赖包
pip install grpcio grpcio-tools pip install grpcio-tools pynng
``` ```
### 重新生成 gRPC 文件 ### 重新生成 PB 文件
```sh ```sh
cd wcf cd python
python -m grpc_tools.protoc --python_out=. --grpc_python_out=. -I=../ wcf.proto python -m grpc_tools.protoc --python_out=. --proto_path=..\rpc\proto\ wcf.proto
``` ```
### 参考项目 [README](../README.MD) ### 参考项目 [README](../README.MD)

View File

@ -2,42 +2,34 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
import logging import logging
import signal
from time import sleep
from wcferry import Wcf from wcferry import Wcf
def main(): def main():
logging.info("Start demo...") LOG = logging.getLogger("Demo")
wcf = Wcf() # 默认连接本地服务 LOG.info("Start demo...")
# wcf = Wcf("IP:10086") # 连接远端服务 wcf = Wcf(debug=True) # 默认连接本地服务
# wcf = Wcf("tcp://127.0.0.1:10086") # 连接远端服务
def handler(sig, frame): LOG.info(f"Is Login: {True if wcf.is_login() else False}")
wcf.cleanup() LOG.info(f"SelfWxid: {wcf.get_self_wxid()}")
exit(0)
signal.signal(signal.SIGINT, handler) wcf.enable_recv_msg(LOG.info)
sleep(1) # Slow down
print(f"Is Login: {True if wcf.is_login() else False}")
print(f"SelfWxid: {wcf.get_self_wxid()}")
sleep(1)
wcf.enable_recv_msg(print)
# wcf.disable_recv_msg() # Call anytime when you don't want to receive messages # wcf.disable_recv_msg() # Call anytime when you don't want to receive messages
ret = wcf.send_text("Hello world.", "filehelper") ret = wcf.send_text("Hello world.", "filehelper")
print(f"send_text: {ret}") LOG.info(f"send_text: {ret}")
ret = wcf.send_image("TEQuant.jpeg", "filehelper") ret = wcf.send_image("TEQuant.jpeg", "filehelper")
print(f"send_image: {ret}") LOG.info(f"send_image: {ret}")
print(f"Message types:\n{wcf.get_msg_types()}") LOG.info(f"Message types:\n{wcf.get_msg_types()}")
print(f"Contacts:\n{wcf.get_contacts()}") LOG.info(f"Contacts:\n{wcf.get_contacts()}")
print(f"DBs:\n{wcf.get_dbs()}") LOG.info(f"DBs:\n{wcf.get_dbs()}")
print(f"Tables:\n{wcf.get_tables('db')}") LOG.info(f"Tables:\n{wcf.get_tables('db')}")
print(f"Results:\n{wcf.query_sql('MicroMsg.db', 'SELECT * FROM Contact LIMIT 1;')}") LOG.info(f"Results:\n{wcf.query_sql('MicroMsg.db', 'SELECT * FROM Contact LIMIT 1;')}")
# wcf.accept_new_friend("v3", "v4") # 需要真正的 V3、V4 信息 # wcf.accept_new_friend("v3", "v4") # 需要真正的 V3、V4 信息
@ -46,5 +38,5 @@ def main():
if __name__ == "__main__": if __name__ == "__main__":
logging.basicConfig(level='DEBUG') logging.basicConfig(level='DEBUG', format="%(asctime)s %(message)s")
main() main()

View File

@ -26,8 +26,8 @@ setup(
include_package_data=True, include_package_data=True,
install_requires=[ install_requires=[
"setuptools", "setuptools",
"grpcio",
"grpcio-tools", "grpcio-tools",
"pynng"
], ],
classifiers=[ classifiers=[
"Environment :: Win32 (MS Windows)", "Environment :: Win32 (MS Windows)",

View File

@ -2,23 +2,23 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
import atexit import atexit
import ctypes import base64
import logging import logging
import os import os
import re import re
import sys import sys
from threading import Thread from threading import Thread
from time import sleep from time import sleep
from typing import List, Callable, Optional from typing import Callable, List, Optional
import grpc import pynng
from google.protobuf import json_format
WCF_ROOT = os.path.abspath(os.path.dirname(__file__)) WCF_ROOT = os.path.abspath(os.path.dirname(__file__))
sys.path.insert(0, WCF_ROOT) sys.path.insert(0, WCF_ROOT)
import wcf_pb2 # noqa import wcf_pb2 # noqa
import wcf_pb2_grpc # noqa
__version__ = "3.7.0.30.12" __version__ = "3.7.0.30.13"
class Wcf(): class Wcf():
@ -58,21 +58,34 @@ class Wcf():
"""是否文本消息""" """是否文本消息"""
return self.type == 1 return self.type == 1
def __init__(self, host_port: str = None) -> None: def __init__(self, host_port: str = None, debug: bool = False) -> None:
self._local_host = False self._local_host = False
self._is_running = False self._is_running = False
self._enable_recv_msg = False self._is_receiving_msg = False
self.LOG = logging.getLogger("WCF") self.LOG = logging.getLogger("WCF")
if host_port is None: if host_port is None:
self._local_host = True self._local_host = True
host_port = "127.0.0.1:10086" host_port = "tcp://127.0.0.1:10086"
self._sdk = ctypes.cdll.LoadLibrary(f"{WCF_ROOT}/sdk.dll") cmd = f"{WCF_ROOT}/wcf.exe start {'debug' if debug else ''}"
if self._sdk.WxInitSDK() != 0: if os.system(cmd) != 0:
self.LOG.error("初始化失败!") self.LOG.error("初始化失败!")
return
# 连接 RPC
self.cmd_socket = pynng.Pair1() # Client --> Server发送消息
self.cmd_socket.send_timeout = 2000 # 发送 2 秒超时
self.cmd_socket.recv_timeout = 2000 # 接收 2 秒超时
self.cmd_socket.dial(host_port, block=False)
self.msg_socket = pynng.Pair1() # Server --> Client接收消息
self.msg_socket.send_timeout = 2000 # 发送 2 秒超时
self.msg_socket.recv_timeout = 2000 # 接收 2 秒超时
self.msg_url = host_port.replace("10086", "10087")
atexit.register(self.cleanup) # 退出的时候停止消息接收,防止内存泄露
while not self.is_login(): # 等待微信登录成功
sleep(1)
self._channel = grpc.insecure_channel(host_port)
self._stub = wcf_pb2_grpc.WcfStub(self._channel)
atexit.register(self.disable_recv_msg) # 退出的时候停止消息接收,防止内存泄露
self._is_running = True self._is_running = True
self.contacts = [] self.contacts = []
self._SQL_TYPES = {1: int, 2: float, 3: lambda x: x.decode("utf-8"), 4: bytes, 5: lambda x: None} self._SQL_TYPES = {1: int, 2: float, 3: lambda x: x.decode("utf-8"), 4: bytes, 5: lambda x: None}
@ -82,17 +95,18 @@ class Wcf():
self.cleanup() self.cleanup()
def cleanup(self) -> None: def cleanup(self) -> None:
"""停止 gRPC关闭连接,回收资源""" """关闭连接,回收资源"""
if not self._is_running: if not self._is_running:
return return
self.disable_recv_msg() self.disable_recv_msg()
self._channel.close() self.cmd_socket.close()
if self._local_host: if self._local_host:
self._sdk.WxDestroySDK() cmd = f"{WCF_ROOT}/wcf.exe stop"
handle = self._sdk._handle if os.system(cmd) != 0:
del self._sdk self.LOG.error("退出失败!")
ctypes.windll.kernel32.FreeLibrary(handle) return
self._is_running = False self._is_running = False
def keep_running(self): def keep_running(self):
@ -103,109 +117,184 @@ class Wcf():
except Exception as e: except Exception as e:
self.cleanup() self.cleanup()
def is_login(self) -> int: def _send_request(self, req: wcf_pb2.Request) -> wcf_pb2.Response:
data = req.SerializeToString()
self.cmd_socket.send(data)
rsp = wcf_pb2.Response()
rsp.ParseFromString(self.cmd_socket.recv_msg().bytes)
return rsp
def is_login(self) -> bool:
"""是否已经登录""" """是否已经登录"""
rsp = self._stub.RpcIsLogin(wcf_pb2.Empty()) req = wcf_pb2.Request()
return rsp.status req.func = wcf_pb2.FUNC_IS_LOGIN # FUNC_IS_LOGIN
rsp = self._send_request(req)
return rsp.status == 1
def get_self_wxid(self) -> str: def get_self_wxid(self) -> str:
"""获取登录账户的 wxid""" """获取登录账户的 wxid"""
rsp = self._stub.RpcGetSelfWxid(wcf_pb2.Empty()) req = wcf_pb2.Request()
req.func = wcf_pb2.FUNC_GET_SELF_WXID # FUNC_GET_SELF_WXID
rsp = self._send_request(req)
return rsp.str return rsp.str
def _rpc_get_message(self, func): def get_msg_types(self) -> dict:
rsps = self._stub.RpcEnableRecvMsg(wcf_pb2.Empty()) """获取所有消息类型"""
try: req = wcf_pb2.Request()
for rsp in rsps: req.func = wcf_pb2.FUNC_GET_MSG_TYPES # FUNC_GET_MSG_TYPES
func(self.WxMsg(rsp)) rsp = self._send_request(req)
except Exception as e: types = json_format.MessageToDict(rsp.types)["types"]
self.LOG.error(f"RpcEnableRecvMsg: {e}") types = {int(k): v for k, v in types.items()}
finally:
self.disable_recv_msg() return dict(sorted(dict(types).items()))
def get_contacts(self) -> List[dict]:
"""获取完整通讯录"""
req = wcf_pb2.Request()
req.func = wcf_pb2.FUNC_GET_CONTACTS # FUNC_GET_CONTACTS
rsp = self._send_request(req)
contacts = json_format.MessageToDict(rsp.contacts)["contacts"]
for cnt in contacts:
gender = cnt.get("gender", "")
if gender == 1:
gender = ""
elif gender == 2:
gender = ""
contact = {
"wxid": cnt.get("wxid", ""),
"code": cnt.get("code", ""),
"name": cnt.get("name", ""),
"country": cnt.get("country", ""),
"province": cnt.get("province", ""),
"city": cnt.get("city", ""),
"gender": gender}
self.contacts.append(contact)
return self.contacts
def get_dbs(self) -> List[str]:
"""获取所有数据库"""
req = wcf_pb2.Request()
req.func = wcf_pb2.FUNC_GET_DB_NAMES # FUNC_GET_DB_NAMES
rsp = self._send_request(req)
dbs = json_format.MessageToDict(rsp.dbs)["names"]
return dbs
def get_tables(self, db: str) -> List[dict]:
"""获取 db 中所有表"""
req = wcf_pb2.Request()
req.func = wcf_pb2.FUNC_GET_DB_TABLES # FUNC_GET_DB_TABLES
req.str = db
rsp = self._send_request(req)
tables = json_format.MessageToDict(rsp.tables).get("tables", [])
return tables
def send_text(self, msg: str, receiver: str, aters: Optional[str] = "") -> int:
"""发送文本消息"""
req = wcf_pb2.Request()
req.func = wcf_pb2.FUNC_SEND_TXT # FUNC_SEND_TXT
req.txt.msg = msg
req.txt.receiver = receiver
if aters:
req.txt.aters = aters
rsp = self._send_request(req)
return rsp.status
def send_image(self, path: str, receiver: str) -> int:
"""发送图片"""
req = wcf_pb2.Request()
req.func = wcf_pb2.FUNC_SEND_IMG # FUNC_SEND_IMG
req.file.path = path
req.file.receiver = receiver
rsp = self._send_request(req)
return rsp.status
def send_file(self, path: str, receiver: str) -> int:
"""发送文件"""
req = wcf_pb2.Request()
req.func = wcf_pb2.FUNC_SEND_FILE # FUNC_SEND_FILE
req.file.path = path
req.file.receiver = receiver
rsp = self._send_request(req)
return rsp.status
def enable_recv_msg(self, callback: Callable[[WxMsg], None] = None) -> bool: def enable_recv_msg(self, callback: Callable[[WxMsg], None] = None) -> bool:
"""设置接收消息回调""" """设置接收消息回调"""
if self._enable_recv_msg: # TODO: 加队列,消息推送有超时,需要先缓存下来
def listening_msg():
rsp = wcf_pb2.Response()
self.msg_socket.dial(self.msg_url, block=True)
while self._is_receiving_msg:
try:
rsp.ParseFromString(self.msg_socket.recv_msg().bytes)
except Exception as e:
pass
else:
callback(self.WxMsg(rsp.wxmsg))
# 退出前关闭通信通道
self.msg_socket.close()
if self._is_receiving_msg:
return True return True
if callback is None: if callback is None:
return False return False
self._enable_recv_msg = True req = wcf_pb2.Request()
req.func = wcf_pb2.FUNC_ENABLE_RECV_TXT # FUNC_ENABLE_RECV_TXT
rsp = self._send_request(req)
if rsp.status != 0:
return False
self._is_receiving_msg = True
# 阻塞,把控制权交给用户 # 阻塞,把控制权交给用户
# self._rpc_get_message(callback) # self._rpc_get_message(callback)
# 不阻塞,启动一个新的线程来接收消息 # 不阻塞,启动一个新的线程来接收消息
Thread(target=self._rpc_get_message, name="GetMessage", args=(callback,), daemon=True).start() Thread(target=listening_msg, name="GetMessage", daemon=True).start()
return True return True
def disable_recv_msg(self) -> int: def disable_recv_msg(self) -> int:
"""停止接收消息""" """停止接收消息"""
if not self._enable_recv_msg: if not self._is_receiving_msg:
return -1 return 0
rsp = self._stub.RpcDisableRecvMsg(wcf_pb2.Empty()) req = wcf_pb2.Request()
if rsp.status == 0: req.func = wcf_pb2.FUNC_DISABLE_RECV_TXT # FUNC_DISABLE_RECV_TXT
self._enable_recv_msg = False rsp = self._send_request(req)
self._is_receiving_msg = False
return rsp.status return rsp.status
def send_text(self, msg: str, receiver: str, aters: Optional[str] = "") -> int:
"""发送文本消息"""
rsp = self._stub.RpcSendTextMsg(wcf_pb2.TextMsg(msg=msg, receiver=receiver, aters=aters))
return rsp.status
def send_image(self, path: str, receiver: str) -> int:
"""发送图片"""
rsp = self._stub.RpcSendImageMsg(wcf_pb2.ImageMsg(path=path, receiver=receiver))
return rsp.status
def get_msg_types(self) -> dict:
"""获取所有消息类型"""
rsp = self._stub.RpcGetMsgTypes(wcf_pb2.Empty())
return dict(sorted(dict(rsp.types).items()))
def get_contacts(self) -> List[dict]:
"""获取完整通讯录"""
rsp = self._stub.RpcGetContacts(wcf_pb2.Empty())
for cnt in rsp.contacts:
gender = ""
if cnt.gender == 1:
gender = ""
elif cnt.gender == 2:
gender = ""
self.contacts.append({"wxid": cnt.wxid, "code": cnt.code, "name": cnt.name,
"country": cnt.country, "province": cnt.province, "city": cnt.city, "gender": gender})
return self.contacts
def get_dbs(self) -> List[str]:
"""获取所有数据库"""
rsp = self._stub.RpcGetDbNames(wcf_pb2.Empty())
return rsp.names
def get_tables(self, db: str) -> List[dict]:
"""获取 db 中所有表"""
tables = []
rsp = self._stub.RpcGetDbTables(wcf_pb2.String(str=db))
for tbl in rsp.tables:
tables.append({"name": tbl.name, "sql": tbl.sql})
return tables
def query_sql(self, db: str, sql: str) -> List[dict]: def query_sql(self, db: str, sql: str) -> List[dict]:
"""执行 SQL""" """执行 SQL"""
result = [] result = []
rsp = self._stub.RpcExecDbQuery(wcf_pb2.DbQuery(db=db, sql=sql)) req = wcf_pb2.Request()
for r in rsp.rows: req.func = wcf_pb2.FUNC_EXEC_DB_QUERY # FUNC_EXEC_DB_QUERY
req.query.db = db
req.query.sql = sql
rsp = self._send_request(req)
rows = json_format.MessageToDict(rsp.rows)["rows"]
for r in rows:
row = {} row = {}
for f in r.fields: for f in r["fields"]:
row[f.column] = self._SQL_TYPES[f.type](f.content) c = base64.b64decode(f.get("content", ""))
row[f["column"]] = self._SQL_TYPES[f["type"]](c)
result.append(row) result.append(row)
return result return result
def accept_new_friend(self, v3: str, v4: str) -> int: def accept_new_friend(self, v3: str, v4: str) -> int:
"""通过好友验证""" """发送文件"""
rsp = self._stub.RpcAcceptNewFriend(wcf_pb2.Verification(v3=v3, v4=v4)) req = wcf_pb2.Request()
req.func = wcf_pb2.FUNC_ACCEPT_FRIEND # FUNC_ACCEPT_FRIEND
req.v.v3 = v3
req.v.v4 = v4
rsp = self._send_request(req)
return rsp.status return rsp.status
def get_friends(self) -> List[dict]: def get_friends(self) -> List[dict]:
@ -218,18 +307,12 @@ class Wcf():
"newsapp": "新闻", "newsapp": "新闻",
} }
friends = [] friends = []
rsp = self._stub.RpcGetContacts(wcf_pb2.Empty()) for cnt in self.get_contacts():
for cnt in rsp.contacts:
if (cnt.wxid.endswith("@chatroom") # 群聊 if (cnt.wxid.endswith("@chatroom") # 群聊
or cnt.wxid.startswith("gh_") # 公众号 or cnt.wxid.startswith("gh_") # 公众号
or cnt.wxid in not_friends.keys() # 其他杂号 or cnt.wxid in not_friends.keys() # 其他杂号
): ):
continue continue
gender = "" friends.append(cnt)
if cnt.gender == 1:
gender = ""
elif cnt.gender == 2:
gender = ""
friends.append({"wxid": cnt.wxid, "code": cnt.code, "name": cnt.name,
"country": cnt.country, "province": cnt.province, "city": cnt.city, "gender": gender})
return friends return friends